Skip to content

fix(logs): upsert call_logs instead of failing closed on a reused attempt id - #13574

Closed
hartmark wants to merge 1 commit into
cheaper-inference:release/v3.8.51from
hartmark:fix/call-logs-attempt-id-upsert
Closed

hartmark wants to merge 1 commit into
cheaper-inference:release/v3.8.51from
hartmark:fix/call-logs-attempt-id-upsert

Conversation

@hartmark

Copy link
Copy Markdown
Contributor

Problem

[callLogs] Failed to save call log: SqliteError: UNIQUE constraint failed: call_logs.id fires constantly in production logs -- dozens of times per hour under ordinary combo fallback traffic.

Root cause

trackPendingRequest() (usageHistory.ts) deliberately reuses one pending id across every combo fallback target of a single client request:

Reuse the same pending id across every target attempt of one client request ... so a dashboard tab's live poll survives a combo fallback to a different target instead of silently going stale.

persistAttemptLogs() is called once per attempt with that same id, and saveCallLog() still did a plain INSERT. Every attempt after the first fails closed with the UNIQUE constraint violation and is silently dropped (caught and logged, nothing else) -- the row permanently keeps the first attempt's data (often a failure), and when the dropped attempt was the eventual success, its response_id and artifact are lost with it.

That last part has a real downstream effect: resolvePreviousResponseState looks up a stored continuation state by response_id. If the successful attempt's row never got written, a later turn's previous_response_id continuation lookup finds nothing and fails closed with previous_response_not_found -- a clean, different-looking error, but ultimately caused by this same silent write failure.

Fix

Converted the INSERT to INSERT ... ON CONFLICT(id) DO UPDATE SET <every non-id column> = excluded.<column>. The latest attempt reflects the request's current/final state, so it replaces the row instead of being discarded by it -- matching the intent already documented at the trackPendingRequest() call site.

No schema change: call_logs.id is already TEXT PRIMARY KEY.

Known follow-up

Each attempt still writes its own on-disk artifact file (writeCallArtifactAsync); an overwritten row's earlier artifact is now orphaned on disk rather than referenced. Not fixed here -- existing artifact-rotation/cleanup (separately covered by #12992) already reclaims disk space over time, and cleaning up the specific orphan immediately would need its own careful review of callLogArtifactWriter.ts.

Testing

  • New regression test in save-call-log-persistence.test.ts: a failed first attempt followed by a successful second attempt on the same id ends up as exactly one row carrying the successful attempt's status/model/response_id. Confirmed it fails on the prior plain-INSERT code (asserts 200, gets the stale 429).
  • Full call-log*/call-logs-*/responses-continuation-store suite (183 tests) passes.
  • tsc -p tsconfig.typecheck-core.json clean.

🤖 Generated with Claude Code

…empt id

usageHistory.ts's trackPendingRequest() deliberately reuses one pending id
across every combo fallback target of a single client request (so a
dashboard tab's live poll survives a fallback), and persistAttemptLogs()
calls saveCallLog() once per attempt with that same id -- but saveCallLog()
still did a plain INSERT. Every attempt after the first died with
"UNIQUE constraint failed: call_logs.id" and was silently dropped: the row
permanently kept the FIRST (often a failed) attempt's data, and when the
dropped attempt was the eventual success, its response_id/artifact were
lost too -- breaking resolvePreviousResponseState's continuation lookup for
that response id (surfaces to the client as previous_response_not_found on
its very next turn).

Converts the INSERT to an upsert (ON CONFLICT(id) DO UPDATE) so the latest
attempt's data -- reflecting this request's current/final state -- replaces
the row instead of being discarded by it.

Regression test proves a first-attempt failure followed by a second-attempt
success on the same id ends up with exactly one row carrying the SUCCESSFUL
attempt's status/model/response_id, and fails on the prior plain-INSERT
code.
@diegosouzapw

Copy link
Copy Markdown
Collaborator

Thanks for the thorough root-cause writeup and the regression test — this is solid work. In
the time since you opened this, #13546 landed (2026-09-14) and fixed the same production
symptom at its source: persistAttemptLogs() now keys call_logs.id on the per-attempt
traceId instead of the shared pendingRequestId, so the UNIQUE-constraint collision this PR
works around no longer happens on the current tip. I verified your 12 tests still pass against
saveCallLog() directly, but the production call site that used to trigger the collision no
longer reuses an id at all. Closing as subsumed by #13546 rather than merging the upsert — happy
to revisit if you see a different call path still reusing an id.

Triage note: this is the review recommendation — the close itself happens only after the maintainer's per-PR sign-off (and, where a superseding PR is named, after it has landed). Nothing is being closed by this comment.

@hartmark hartmark closed this Sep 15, 2026
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.

2 participants