Cost UI deep dive: cancel-safe poll, tight hydration, burst aggregate - #49
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
pollResultcollisionTwo submits in quick succession both started a
pollResultloop. 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.pollResultnow 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:
state.activePollJobIdtoken held,cost-actual-livepill not visible (long-running job not in flight),state.lastInvokeAtis older than 10 minutes (covers worst-casetimeout × (max_retries+1)),(3)
computeEstimateLocalcould NaN through to the DOMNumber(undefined)isNaN. If the most recent job's hash had a malformed field, the formula would render$NaNin Estimated max. Added aNumber.isFiniteguard on every input and a final guard on the total. Returnsnullcleanly when inputs are bad rather than poisoning the strip.(4) First-paint estimate was missing for one tick after refresh
refreshAllfiredrefreshUsageTotal()andhydrateCostStripFromJobs()in parallel. On the very first tick after a page reload,state.pricingRateswas still undefined when hydration ran, so hydration's estimate branch was a no-op. NowrefreshAllawaitsrefreshUsageTotalbefore 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():cost.cost_usdfrom/jobs,renderActualCosthandles it without a special case,live: truewhile any job in the burst is still running, so the live pill stays on through the whole burst,burst: 17/50 doneand finallyburst 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.activePollJobIdtoken; a single Invoke immediately preempts an in-flight burst poll. The token is a stringburst:<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).$0.000010 → $0.000019 → $0.000028 (final)./jobs?limit=5: every row carries thecost.livefield./usage/cost: full rate card returned, by-status breakdown correct.pollBurstAggregate,activePollJobId,lastInvokeAt,computeEstimateLocal,hydrateCostStripFromJobs).app.js?v=20.