Conversation
generateLogId() combined Date.now() with a module-level counter, so two instances of the writer - worker threads, separate route bundles - both starting at 0 produced byte-identical ids inside the same millisecond. call_logs.id is UNIQUE, the insert threw SqliteError and the surrounding catch only logged, so the second call log was dropped silently and analytics undercounted. Keep the millisecond prefix and make the suffix per-id with crypto.randomUUID(); the counter is gone because it only protected the case that was already safe.
PR diegosouzapw#14341, filed eleven minutes before mine for the same issue, adds tests/unit/call-log-id-collision-14338.test.ts for the trace-id widening half of diegosouzapw#14338. That is a different fix, not a duplicate, but the identical file path would conflict whichever of the two merges second, so this one is renamed. No behaviour change.
|
The hardening is correct and the cross-module-instance reasoning is sound, so thank you for it — I'm recommending #14474 to land, which already contains your |
|
Your trace is right, and I confirmed it against the base rather than from the comment. On
I read #14474's diff: it removes the I will not add commits here — all three PRs edit the same two locations, so the useful thing I can do is stay out of the way. Your call on routing, and I will act on it either way: land #14474 and I will close this one as subsumed myself, or take the minimal route (#14341) and I will re-push this branch as the second half. |
|
Closing this as subsumed — the routing question from your 2026-09-22 review comment resolved itself in favour of #14474, and the minimal-route alternative #14341 was closed without merging, so the "this branch becomes the required second half" branch never opened. Measured against the current base, not from memory: The So I am not rebasing. Doing so would re-introduce a second, weaker implementation of a guard that already landed, in the same two locations the reviewer said only one PR may occupy. The only part of this branch that upstream does not carry is No code from this branch is being dropped silently — everything it fixed is already in |
Summary
generateLogId()insrc/lib/usage/callLogs.tsbuilt call-log ids as`${Date.now()}-${logIdCounter}`wherelogIdCounteris a module-level counter. Two independent instances of that module - worker threads, separate route bundles, anything that evaluates the file twice - both start at0, so a call logged by each in the same millisecond produces byte-identical ids.call_logs.idisUNIQUE, so SQLite rejected the second insert and the surroundingcatchonly logs a line: the row was lost silently. Closes #14338.Motivation
Reported from a production container: 8
[callLogs] Failed to save call log: SqliteError: UNIQUE constraint failed: call_logs.idlines in one hour, in bursts coinciding with high-concurrency traffic (chat fan-out, sub-agents). Consequences are invisible at the request layer - the completion succeeds - and land in analytics:call_logsundercounts, so#12832's provider-stats reads and the$/daycost rollups are computed over a subset that "is not recoverable", as the reporter put it.The counter never helped in the first place: within one instance a timestamp plus a counter is fine, and that is exactly the case that was already safe. It only appears unique, and the failure mode is the multi-instance case that the counter cannot cover.
What changed
One expression, no schema, no migration, no read-path change:
crypto.randomUUID()), with theDate.now()prefix kept so existing ids keep the same rough shape and text ordering, and so any human reading a log line still sees the millisecond.let logIdCounter = 0;is removed with it.buildArtifactRelativePath(
src/lib/usage/callLogArtifacts.ts:112-118) writesYYYY-MM-DD/<iso-with-dashes>_<id>.json. The newid is 13 digits, one hyphen and a 36-character UUID, i.e. digits, hyphens and hex only - no
:,/or other character that is unsafe in a path on Windows or POSIX, ~50 characters so no length limit is
approached, and strictly less collision-prone than before, which matters because the filename is the
artifact's identity.
getCallLogById, the artifact/detail loaders and log export treat them as keys only, so no read path parses the id. Checked two ways:grep -rn "id.split\|parseInt(.*id\|Number(.*logId" src/lib/usage/ src/lib/db/ src/app/api/v1/returns no call-log id parsing, and the full call-log and log-export suites pass unchanged.How to test
New regression test reproduces the production shape rather than poking the generator: it imports the writer twice (two independent module instances, as two workers are), points both at one temp
DATA_DIRSQLite file, freezes the clock inside one millisecond, and writes through the publicsaveCallLog().One of the two logs was already gone by the time the test looked - the exact silent drop from the container log.
ESLint note so CI noise is not attributed here: bare
npx eslintonsrc/lib/usage/callLogs.tsreports exactly one problem,'DeleteResult' is defined but never used, which is pre-existing and already frozen inconfig/quality/eslint-suppressions.json:1722asno-unused-vars: { count: 1 }for that file. The count does not grow with this change (the only symbol removed waslogIdCounter, which was used only by the replaced line), andnpm run lintapplies the suppression layer.Base:
release/v3.8.51at34113170f(highest activerelease/v*, the repo default).Relationship to #14341 (found while re-auditing my own claims)
#14341, opened eleven minutes before this PR, also cites #14338 and adds a test at the path this PR
originally used. It is a different fix, not a duplicate of it: it widens the trace id handling in
open-sse/handlers/chatCore/traceId.ts, while this PR changesgenerateLogId()insrc/lib/usage/callLogs.ts, which #14341 does not touch. Because two open PRs adding the same file pathwould conflict for whichever merges second, this PR's test was renamed to
call-log-id-generator-collision-14338.test.ts(commits8060b21e0,4010b1c73; no behaviour change).Merging both should be clean; if the maintainers would rather take one, the id-collision half is the one
that loses log rows today.
Migrations, feature flags, generated artifacts
A changelog fragment was added to this branch after I re-read CONTRIBUTING.md line 409: user-facing changes ship a file under the changelog directory rather than editing CHANGELOG.md, so this PR now carries one. It is the only addition beyond the fix and its test.
None. No schema change (option 2,
INTEGER PRIMARY KEY, was not taken: it needs a migration plus read-path type coercion for every existing id consumer, and a UUID suffix removes the collision with none of that). Option 3 (INSERT OR IGNORE) was rejected for the reason the issue gives: it makes the loss silent instead of fixing it.CI-only validation still pending
npm run typecheck:core,npm run lint,npm run test:unit,npm run test:vitest, the coverage ratchet and the ecosystem jobs run on the PR. Locally: the 97-test call-log family and the 62-test log-export family above, plus focused eslint/prettier. The full unit suite was not run locally.base-red note
34113170fis inherited, not from this branch. The focused suites above are green on that tip.