Skip to content

fix(backup,ai): order backup jobs by started_at; count cached tokens as input - #3180

Open
ToddHebebrand wants to merge 3 commits into
mainfrom
fix/backup-lastjob-ordering-and-token-accounting
Open

fix(backup,ai): order backup jobs by started_at; count cached tokens as input#3180
ToddHebebrand wants to merge 3 commits into
mainfrom
fix/backup-lastjob-ordering-and-token-accounting

Conversation

@ToddHebebrand

Copy link
Copy Markdown
Collaborator

Two release-QA defects. Both are "the number we show is derived from the wrong field" — neither needs a migration.

Defect A — backup dashboard picked the wrong "last job"

created_at is a row-insert timestamp. The profile fan-out writes an entire occurrence's jobs inside one transaction, so they share it to the microsecond and ORDER BY created_at DESC is not even a total order. QA hit exactly that tie (two jobs at 06:32:11.829738), the planner returned the older run, and the device Backup tab hid its VSS Status panel — that panel renders only when the chosen lastJob carries vss_metadata.

New apps/api/src/services/backupJobOrdering.ts states the rule once, in two shapes:

Helper Order For
latestBackupRunOrderBy started_at DESC NULLS LAST, created_at DESC, id DESC "which run is most recent"
backupJobHistoryOrderBy created_at DESC, started_at DESC NULLS LAST, id DESC chronological feeds

On NULLS LAST (the one judgement call, verified against the consumers before committing): a pending job has not run — no snapshot, no vss_metadata, no error_log — so it must not displace a real run and blank the tab every time a backup is queued. It only demotes: a device whose every job is queued still reports one, and lastSuccess/lastFailure filter to terminal statuses that always have a started_at, so they are unaffected. The UI does not lose queued-job feedback either — "Run backup now" has its own banner and the job appears immediately in the jobs table.

Why the feeds keep created_at primary: /backup/jobs filters on created_at windows (?from/?to/?date), and a just-queued job has to stay at the top of the operator's list rather than sink to the bottom. There the tiebreaks only stop the fan-out reshuffling the list between requests.

Sweep

Site Was Now
dashboard.ts /status/:deviceId — the reported defect created_at DESC latest-run
dashboard.ts attention-items row_number() window created_at DESC latest-run
dashboard.ts latest-jobs feed (limit 5) created_at DESC history
jobs.ts /backup/jobs list created_at DESC history
aiToolsBackup.ts get_backup_status desc(startedAt) latest-run
aiToolsBackup.ts list_jobs desc(startedAt) history

The last two are the same defect mirrored: desc() in Postgres means NULLS FIRST, so a single queued job floated above every real run and — under limit(1) — made get_backup_status answer "latest backup: pending" for a device that had just failed one.

Left alone deliberately: readinessCalculator and verificationService order by completed_at and pre-filter to restorable statuses, so started_at is not the key they want.

Defect B — AI session input tokens: real bug, not intent

Investigated before touching it. It is a real bug. Nothing in the code, columns or docs claims the counters track only uncached input:

  • Every comment about cache tokens frames them as a cost concern ("billed at different rates"), never as an accounting exclusion.
  • The columns are total_input_tokens, not uncached_input_tokens, and the UI renders them as "{{input}} in / {{output}} out".
  • streamingSessionManager already accumulates all four SDK fields correctly and forwards them; aiCostTracker then destructured the cache fields, used them only for pricing, and added just input_tokens to the column.
  • Existing coverage asserts cost for a fully-cached turn (aiCostTracker.test.ts — 1M cache-read → 30c) but never checked what landed in the token column. That turn incremented total_input_tokens by 0.

That exactly explains the QA observation: 8 turns, 17 input tokens, 1029 output, $0.57. Prompt caching routes almost the whole prompt through cache_read_input_tokens on every turn after the first.

Fix

sumInputTokens() (uncached + cache-read + cache-creation — three disjoint slices of one prompt, so summing cannot double-count) now feeds:

  • ai_sessions.total_input_tokens
  • ai_cost_usage.input_tokens (daily/monthly org aggregates)
  • the per-user client_ai_usage hook and the client-facing done event

Cost is untouched and was correct throughout. The three components stay split in the pricing path — the sum is deliberately never fed back into calculateCostCents, and a test pins that (a 1M cache-read turn still prices at 30c, not 300c). Budget enforcement keys on total_cost_cents, so it was never affected in either direction.

Not in scope: no backfill. Historic rows carry the uncached slice only and read implausibly low; the split components were never persisted anywhere, so there is nothing to recover them from. Recorded in a comment on the schema column. Adding cache_read/cache_creation columns to keep the breakdown would need a migration plus export-policy registration and is a bigger call than a release fix — happy to file it.

Verification

  • tsc --noEmit -p apps/api/tsconfig.jsonexit 0
  • eslint on all 11 changed files → clean
  • Real drizzle render check (throwaway): "backup_jobs"."started_at" desc nulls last, "backup_jobs"."created_at" desc, "backup_jobs"."id" desc, and the window renders as row_number() over (partition by "backup_jobs"."device_id" order by ...)
Suite Result
src/routes/backup (20 files) 239 passed
src/services/backupJobOrdering.test.ts 9 passed
src/services/aiCostTracker.test.ts + streamingSessionManager.usage.test.ts 41 passed
src/routes/clientAi + clientAiUsage (19 files) 188 passed
Combined backup + AI sweep (110 files) 1467 passed

All four new /status/:deviceId route cases were verified red against the pre-fix implementation, not just green after.

Not merged.

🤖 Generated with Claude Code

…as input

Two release-QA defects, both in "the number we show is derived from the
wrong field".

**A — backup dashboard picked the wrong "last job".**

`created_at` is a row-INSERT timestamp. The profile fan-out writes an
entire occurrence's jobs inside one transaction, so they share it to the
microsecond and `ORDER BY created_at DESC` is not even a total order. QA
hit that tie (two jobs at 06:32:11.829738), the planner returned the
OLDER run, and the device Backup tab hid its VSS Status panel — that
panel renders only when the chosen `lastJob` carries `vss_metadata`.

New `services/backupJobOrdering.ts` states the rule once, in two shapes:

- `latestBackupRunOrderBy` — `started_at DESC NULLS LAST, created_at
  DESC, id DESC`, for "which run is most recent". NULLS LAST because a
  `pending` job has not run: it has no snapshot, no VSS metadata and no
  error log, so it must not displace a real run. It only demotes, so a
  device whose every job is queued still reports one.
- `backupJobHistoryOrderBy` — `created_at DESC` primary, then the same
  tiebreaks, for chronological feeds. Those filter on `created_at`
  windows and a just-queued job has to stay at the top of the operator's
  list; the tiebreaks only stop the fan-out reshuffling it per request.

Applied to `/backup/status/:deviceId` (the reported defect), the
attention-items `row_number()` window, the dashboard's latest-jobs feed
and the `/backup/jobs` history list.

The sweep also turned up the same defect mirrored in two AI tools:
`aiToolsBackup` used `desc(startedAt)`, and DESC in Postgres is NULLS
**FIRST**, so a single queued job floated above every real run and — under
`limit(1)` — made `get_backup_status` answer "latest backup: pending" for
a device that had just failed one.

**B — AI session input tokens excluded cached tokens.** Real bug, not
intent: no comment, column name or doc said otherwise, and the UI renders
the column as "{{input}} in". QA saw 8 turns report 17 input tokens
against 1029 output and $0.57 of spend, because prompt caching routes
almost the whole prompt through `cache_read_input_tokens` on every turn
after the first.

`sumInputTokens()` now feeds `ai_sessions.total_input_tokens`,
`ai_cost_usage.input_tokens`, the per-user `client_ai_usage` hook and the
client `done` event. The three components stay split in the COST path,
which was already cache-aware and correct — the sum is deliberately never
fed back into `calculateCostCents`, and a test pins that (a 1M cache-read
turn still prices at 30c, not 300c). Budget enforcement keys on
`total_cost_cents`, so it was never affected either way.

Historic rows are not backfilled: the split components were never
persisted, so there is nothing to recover them from.

Tests: 9 ordering-rule + 4 route cases for A (all four verified red
against the old code), 7 token-accounting cases for B.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

Latest commit: f2706e9
Status: ✅  Deploy successful!
Preview URL: https://5023f5d1.breeze-9te.pages.dev
Branch Preview URL: https://fix-backup-lastjob-ordering.breeze-9te.pages.dev

View logs

Todd Hebebrand and others added 2 commits August 6, 2026 09:38
…cker mocks

CI red on streamingSessionManager.clientLoop.test.ts:
`recordExtraUsage` — "Number of calls: 0".

Root cause is a test-double gap, not a product defect. That file mocks
`./aiCostTracker` with an explicit factory listing only
`recordUsageFromSdkResult`. Vitest does not return `undefined` for an
export a factory omits — it THROWS on the access. So the new
`sumInputTokens(usageData.usage)` call threw inside the `result` handler,
the throw escaped to the processor's outer catch (visible in stderr as
"[StreamingSessionManager] Query error: No 'sumInputTokens' export is
defined on the './aiCostTracker' mock"), and everything after it was
skipped — the per-user hook AND the `done` publish. Hence 0 calls rather
than a wrong value.

The assertion was NOT invalidated by the corrected accounting: that
fixture carries no cache fields, so the correct answer is still
inputTokens: 100. Assertions left exactly as they were.

Fixed in all three streamingSessionManager suites that mock the module —
droppedToolResult and deviceBoundAuth were green only because they never
reach the result path, i.e. latent landmines on the same line.

Also hardened `sumInputTokens` to accept a nullish usage object. It sits
ahead of both the per-user hook and the `done` publish that returns the
session to 'idle', so a throw there strands the turn and hangs the
client — that line must not have a failure mode. This is the one real
robustness point the failure exposed: the previous code did inline
property reads, and hoisting a call there widened its blast radius.

Ran the full requested surface this time (the miss that let this
through): streamingSessionManager + aiCostTracker + routes/clientAi —
24 files, 248 tests, all passing.
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