Skip to content

JobScheduler and Connecting know to expect a job started from firefly - #384

Merged
Craiting merged 4 commits into
masterfrom
ct/CT-2495-connecting-stall
Sep 15, 2026
Merged

Craiting merged 4 commits into
masterfrom
ct/CT-2495-connecting-stall

Conversation

@Craiting

@Craiting Craiting commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes an issue (CT-2495) where Connect Widget could become stuck indefinitely on the Connecting... screen following an OAuth redirect.

  • Refresh Member on Schedule Init: Loads fresh member data from the API before initializing the job schedule rather than relying on stale post-OAuth Redux state (PENDING, null job GUID).
  • Foreign Job & 409 Recovery: Handles conflicts when Firefly initiates a background job (e.g., aggregation) on redirect:
    • Gracefully catches 409 Conflict rejections when the widget tries to start its scheduled job.
    • Polls until the member is completely idle (is_being_aggregated: false) before attempting to start the scheduled job (crucial for early data release flows).
    • Automatically re-attempts the active job once the foreign job finishes, backed by a MAX_FOREIGN_JOB_RETRIES (5) safeguard to prevent infinite loops.
  • Job Scheduler Deduplication & Preservation: Updated JobSchedule.onJobFinished so that when a foreign job completes, the scheduled ACTIVE job remains active instead of erroneously promoting a pending job or duplicating
    active jobs.
  • Safe Fallbacks for Missing Job Data: Extracted loadMostRecentJob helper to safely handle missing or 404/500 job lookups without terminating the RxJS stream.
  • PostMessage Deduplication: Added a ref guard to ensure the deprecated connect/initialDataReady postMessage event is fired at most once per connection session.

Playwright test suite passing:
image

I ran all the cypress tests locally too and they all passed except the Spanish one and I think that was caused by a different issue.

@Craiting
Craiting force-pushed the ct/CT-2495-connecting-stall branch from ffc0db1 to 3da66d9 Compare September 14, 2026 20:53
ash-wright123
ash-wright123 previously approved these changes Sep 14, 2026

@ash-wright123 ash-wright123 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@codingLogan codingLogan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm reviewing this, albeit slowly. The parts I'm needing to validate are

  • Web Sockets behavior with the new changes
  • OAuth behavior for existing members that aren't PENDING

Comment thread src/utilities/JobSchedule.js
Comment thread src/views/connecting/__tests__/ConnectingOAuthJobs-test.tsx
@codingLogan

Copy link
Copy Markdown
Collaborator

I had my AI take a swing at protecting the websocket flows and the non-PENDING member flows. It has some feedback, pretty small changes requested.

1. WebSocket Flows

  • Core WebSockets are unaffected: MemberUpdateTransport.ts, WebSocketContext.tsx, and usePollMember.tsx are untouched. The merged polling + socket transport
    operates as before.

  • Benefits:

    • initialDataReadySentRef (lines 132–134) now prevents duplicate connect/initialDataReady postMessages triggered by fast WebSocket emissions
      (members/priority_data_ready followed by members/updated).
    • The secondary wait for foreign jobs (lines 312–320) uses pollMember, so when Firefly finishes background aggregation, the WebSocket members/updated
      event with is_being_aggregated: false will resolve the wait immediately, without having to wait for the next HTTP polling tick.
  • ⚠️ Potential Null Pointer Bug (Connecting.js:305):
    In WebSocket flows, MemberUpdateTransport generates a synthetic job payload without job_type. If loadMostRecentJob returns null (e.g. 404/network error or
    missing job guid), job falls back to polledResponse.job (where job_type is undefined) or null.
    While line 337 protects this:

      const isForeignJob = job ? job.job_type !== activeJob.type : true

    Line 305 is unguarded:

      const isForeignJob = job.job_type !== activeJob.type

    If job resolves to null, line 305 will throw TypeError: Cannot read properties of null (reading 'job_type').

    Suggested fix on line 305:

      - const isForeignJob = job.job_type !== activeJob.type
      + const isForeignJob = job ? job.job_type !== activeJob.type : true

@codingLogan

Copy link
Copy Markdown
Collaborator

@Craiting Here's what Fable 5.1 had to say. This is more of an informational dump, not a list of everything we need to change. We can discuss this and see what if anything we'd like to implement. Side note #2 in the list has already been handled by your last commit.

My prompt:

The changes in this branch touch some of our more complex and hard to understand areas of our code.  Please summarize the net changes, and flag any risks
 with the current changes that have been made.

Fable 5.1 PR Review

I've reviewed the full diff plus the surrounding code (Connecting.js, JobSchedule.js, the Connect reducer, usePollMember, pollers.js, and
MemberUpdateTransport.ts). All 124 tests across the touched files pass.

Net changes (branch ct/CT-2495-connecting-stall, 2 commits)

Problem being fixed: After an OAuth redirect, Connecting mounts holding a stale pre-OAuth member (PENDING, is_being_aggregated: false, most_recent_job_guid:
null) while Firefly has already created and possibly finished its own job. Several paths could then stall forever (e.g., GET /jobs/null killing the stream,
or a "foreign" job being treated as the scheduled one).

src/utilities/JobSchedule.js — onJobFinished

  • Before: marked the matching job DONE and promoted the next PENDING job unconditionally (could produce two ACTIVE jobs, leaving one orphaned forever).
  • After: marks matching job DONE; only promotes a PENDING job if no ACTIVE one remains. Tolerates finishedJob == null.

src/redux/reducers/Connect.js — initializeJobSchedule

  • Now also upserts the member and sets currentMemberGuid. Replaces the old UPDATE_MEMBER_SUCCESS dispatch, which had the side-effect of pushing a duplicate
    CONNECTING location onto the stack.

src/views/connecting/Connecting.js

  1. Schedule init effect rewritten as one RxJS pipeline: loadMemberByGuid (refresh stale member) → conditionally updateMember for use cases → loadJob →
    initializeJobSchedule. updateMember and loadMemberByGuid failures are now swallowed (fall back to the member we have).
  2. loadMostRecentJob(member) helper guards against a null most_recent_job_guid (fixes the GET /jobs/null stall) and is used in both effects.
  3. Job-run effect:
    • After polling completes, looks the job up on the polled member (not the stale one), falling back to polledResponse.job.
    • New "foreign job" handling: if the finished job's type ≠ activeJob.type and the member is still CONNECTED+aggregating, keep polling until
      is_being_aggregated === false before completing.
    • After jobComplete, if the job was foreign and the member is CONNECTED, bumps a new activeJobAttempt state, which is now in the effect deps → re-runs
      the effect to start the still-ACTIVE scheduled job.
  4. connect/initialDataReady is now sent at most once per mount (initialDataReadySentRef) — needed because the second polling pass would otherwise re-fire
    it.

Tests

New ConnectingOAuthJobs-test.tsx (5 integration scenarios with a fake backend), plus unit tests for the schedule and reducer changes.

────────────────────────────────────────────────────────────────────────────────

Risks

🔴 1. Unbounded retry loop via activeJobAttempt (no cap)

Connecting.js:339-343. Whenever jobComplete runs with a foreign/null job and the member is CONNECTED, the effect re-runs and calls runJob again. There's no
ceiling. Two concrete loops:

  • 409 loop: re-run → runJob 409s → of(currentMember) → poll → member is CONNECTED & idle so pollingIsDone immediately → loadMostRecentJob returns the same
    foreign job → jobComplete is a no-op on the schedule → attempt++ → repeat. Every ~3s (poll interval) you'd hit runJob again, indefinitely, if the backend
    keeps returning 409 without actually running our job type.
  • Job-load-failure loop: if loadJob fails (retry+catch → null) and polledResponse.job also lacks a usable job_type, isForeignJob is true → schedule not
    advanced → attempt++ → runJob starts a brand-new real job → repeat. Before this branch, a loadJob failure stalled the UI; now it can spawn jobs in a loop
    during a /jobs outage. That's arguably worse than a stall.

Recommendation: cap attempts (e.g., 2–3), and on exhaustion either surface setConnectingError or treat the schedule as done.

🔴 2. job.job_type null dereference at Connecting.js:305

  const isForeignJob = job.job_type !== activeJob.type

job is job ?? polledResponse.job ?? null — it can be null (polled member with no most_recent_job_guid, or websocket transport before a members/updated
arrives). This throws inside the stream → stream errors → stall with no setConnectingError. The later subscribe block correctly guards with job ? … : true;
this one doesn't. Should be job?.job_type.

🟠 3. Websocket transport yields jobs without job_type

MemberUpdateTransport.ts:57-63 — in websocket mode polledResponse.job is { guid, async_account_data_ready } only. If loadMostRecentJob fails and we fall
back to it, job_type is undefined → always "foreign" → feeds risk #1. The last test ("still finishes when the completed job cannot be loaded") only passes
because the polling transport's fallback happens to include job_type. Worth a websocket-mode test.

🟠 4. onUpsertMember now fires on every Connecting mount

Connecting.js:240: member !== currentMember is a reference check, and loadMemberByGuid always returns a new object, so this consumer callback fires on every
mount — OAuth or not, use cases or not. Previously it only fired in the use-case-update path. If any host app does something heavy or stateful in
onUpsertMember, this is a behavior change.

🟠 5. updateMember failures are now silent

Previously a failed updateMember left the promise rejected and the schedule uninitialized (a stall, but a visible one). Now it's swallowed and the job runs
with use cases possibly not applied to the member. Consider at least logging/analytics, or deciding explicitly that it's acceptable.

🟡 6. Extra network round-trip on every Connecting mount

The unconditional loadMemberByGuid refresh adds latency to the non-OAuth happy path too. Probably fine, but it's a global change for an OAuth-specific
problem. Could be gated on currentMember.is_oauth or on most_recent_job_guid == null.

🟡 7. Second polling pass ignores non-aggregation terminal states

Connecting.js:311-317 filters only on is_being_aggregated === false. If the member flips to CHALLENGED/DENIED while still aggregating, we keep polling until
aggregation stops, then jobComplete routes correctly — so it terminates, but the MFA/error step is delayed compared to the primary poll (which stops on
CHALLENGED immediately). Note the 60s timedOut guard here only changes messaging; it doesn't break out.

🟡 8. Location push removed from the use-case path

UPDATE_MEMBER_SUCCESS used to pushLocation(STEPS.CONNECTING). The new reducer test explicitly asserts location is unchanged. This looks like a bug fix
(avoiding a duplicate stack entry that could affect back-navigation), but confirm nothing relied on that push — e.g., any stepChange postMessage consumers.

✅ Things that look right

  • The onJobFinished change is a genuine correctness fix and well-tested (same-type Firefly job still satisfies the active slot, which is desirable).
  • The most_recent_job_guid: null guard directly addresses the root stall.
  • initialDataReadySentRef correctly prevents the duplicate deprecated event.
  • activeJobAttempt is actually necessary (not redundant) because onJobFinished preserves object identity for untouched jobs, so activeJob wouldn't change
    reference on a foreign completion.

Top priorities before merge: fix the job.job_type deref (#2), and add an attempt cap to the retry (#1).

* docs: plan for replacing the Connecting job patches with a bounded reconcile loop

* refactor: run the Connecting job schedule as a bounded reconcile loop

Replace the isForeignJob / nested-poll / activeJobAttempt trio in Connecting
with runJobSchedule$, a pure observable that treats the backend as the source
of truth: observe whatever is running, reconcile it against the schedule, start
the next job, repeat. 409s and Firefly's redirect job go through the same path.

The loop is capped at jobs.length + 3 iterations and errors with
JobScheduleExhaustedError, which Connecting throws to the host error boundary.
Previously a perpetual 409 (or an unloadable job) re-ran runJob every poll
interval forever.

Integration tests added for: a foreign job followed by every scheduled job,
early data release still applying when nothing else is scheduled, and bounded
termination under a backend that always rejects the job.

* docs: CT-2495 reconcile loop summary, risks and next steps

* chore: drop planning docs from the repo

* refactor: trim comments that restate the code in the job schedule loop
@Craiting
Craiting merged commit 6867d95 into master Sep 15, 2026
7 checks passed
@Craiting
Craiting deleted the ct/CT-2495-connecting-stall branch September 15, 2026 19:31
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.39.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants