Skip to content

Cost UI deep dive: cancel-safe poll, tight hydration, burst aggregate - #49

Merged
aakri0 merged 1 commit into
mainfrom
fix/cost-deep-dive
May 2, 2026
Merged

Cost UI deep dive: cancel-safe poll, tight hydration, burst aggregate#49
aakri0 merged 1 commit into
mainfrom
fix/cost-deep-dive

Conversation

@aakri0

@aakri0 aakri0 commented May 2, 2026

Copy link
Copy Markdown
Owner

Audit pass

Walked every cost code path (API → worker → JS) looking for bugs that could plausibly produce "there is still some error". Found five distinct issues, all on the JS side, that interact in confusing ways. Fixing all of them in one PR.

(1) Concurrent pollResult collision

Two submits in quick succession both started a pollResult loop. Both wrote to the same DOM elements every 500ms. The older poll's stale "live" value would race in and overwrite the newer one's display.

pollResult now claims a global token (state.activePollJobId) and bails on every iteration if a newer poll has taken over. Releases the token on terminal status or on timeout.

(2) Hydration was overwriting in-flight live state

The previous trigger was "either cell empty". During a single submit, Estimated max is set immediately but Actual stays at until pollResult sees the first running poll. In that window — milliseconds wide, but reliably hit — the 2-second hydration tick would race in and replace Actual with an older terminal job's cost.

New hydration trigger requires all five:

  • no state.activePollJobId token held,
  • cost-actual-live pill not visible (long-running job not in flight),
  • state.lastInvokeAt is older than 10 minutes (covers worst-case timeout × (max_retries+1)),
  • no running/pending job in the caller's recent list,
  • both cells empty (not "either").

(3) computeEstimateLocal could NaN through to the DOM

Number(undefined) is NaN. If the most recent job's hash had a malformed field, the formula would render $NaN in Estimated max. Added a Number.isFinite guard on every input and a final guard on the total. Returns null cleanly when inputs are bad rather than poisoning the strip.

(4) First-paint estimate was missing for one tick after refresh

refreshAll fired refreshUsageTotal() and hydrateCostStripFromJobs() in parallel. On the very first tick after a page reload, state.pricingRates was still undefined when hydration ran, so hydration's estimate branch was a no-op. Now refreshAll awaits refreshUsageTotal before hydrating — the cached rate card is ready by the time we need it.

(5) Burst (Invoke ×50) was misleading

The strip showed one sample job's cost, not the burst's. Replaced with pollBurstAggregate():

  • tracks all 50 IDs,
  • sums each row's cost.cost_usd from /jobs,
  • renders a synthetic Cost-shaped object so renderActualCost handles it without a special case,
  • tags the synthetic object live: true while any job in the burst is still running, so the live pill stays on through the whole burst,
  • updates the result-card status to burst: 17/50 done and finally burst complete: 50 jobs.

Estimated max is also scaled × 50 in onBurst, so the user sees the worst-case total they're committing to (instead of one job's worth).

Cancel-safe: the burst poll uses the same state.activePollJobId token; a single Invoke immediately preempts an in-flight burst poll. The token is a string burst:<first-id> so collisions with single-job tokens are impossible.

Verified

  • pytest -q: 174 passed (existing API tests cover the pricing math; UI tests are E2E).
  • Live single 2s submit: cost ticks $0.000010 → $0.000019 → $0.000028 (final).
  • /jobs?limit=5: every row carries the cost.live field.
  • /usage/cost: full rate card returned, by-status breakdown correct.
  • Served JS contains 23 references to the new helpers (pollBurstAggregate, activePollJobId, lastInvokeAt, computeEstimateLocal, hydrateCostStripFromJobs).

app.js?v=20.

Audit pass over the entire cost feature. Found and fixed five bugs that
together explain the 'still some error' reports.

(1) Concurrent pollResult collision.
    Two submits in quick succession both polled and both wrote to the
    strip; the older poll's stale 'live' value clobbered the newer one.
    pollResult now claims a global token (state.activePollJobId) and
    bails on every iteration if a newer poll has taken over.

(2) Hydration was overwriting in-flight live state.
    The trigger was 'either cell empty', so during a single submit
    (Estimated max set, Actual still '—' for ~ms) hydration would race
    in and replace Actual with an older job's value before pollResult
    caught up. Hydration now requires:
      * no active poll token,
      * live pill not visible,
      * lastInvokeAt > 10 minutes (covers worst-case timeout × retries),
      * no running/pending job in the caller's recent list,
      * BOTH cells empty.

(3) computeEstimateLocal could NaN through to the DOM.
    Number(undefined) is NaN, which would render as '\$NaN' in the
    Estimated max cell. Added Number.isFinite guards on every input and
    a final guard on the total. Returns null cleanly when inputs are
    bad rather than poisoning the strip.

(4) First-paint estimate was missing for one tick after refresh.
    refreshAll fired refreshUsageTotal() and hydrate in parallel, so on
    the very first tick state.pricingRates was still undefined and
    hydrate's estimate branch was a no-op. Now refreshAll awaits
    refreshUsageTotal before hydrating, so the cached rate card is
    ready by the time we need it.

(5) Burst (Invoke ×50) was misleading.
    The strip showed one sample job's cost, not the burst's. Replaced
    with pollBurstAggregate(): tracks all 50 IDs, sums each row's
    cost.cost_usd, renders a synthetic Cost-shaped object so
    renderActualCost handles it without a special case. live=true while
    any job is still running, so the live pill stays on through the
    whole burst. Estimated max is also scaled × 50 so the user sees the
    worst-case total they're committing to.

Bumped app.js?v=20.
@aakri0
aakri0 merged commit 00b1f33 into main May 2, 2026
3 checks passed
@aakri0
aakri0 deleted the fix/cost-deep-dive branch May 2, 2026 12:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant