fix(message): retry message writes on MySQL deadlock (1213/1205) - #19
Conversation
message.Create and CreatePair allocate a sequence number inside their
transaction as `SELECT COALESCE(MAX(seq),0)+1 WHERE session_id=?` followed by
an INSERT. When two writes to the SAME session run concurrently — e.g. a
foreground tool result and a background task's synthetic tool result landing
together — both take shared range locks on the session_id index and then each
needs the insert-intention lock, which InnoDB resolves as a deadlock
(Error 1213). With no retry, that transient, self-healing conflict propagates
up and kills the whole agent flow:
failed to process events: failed to create cancelled tool message:
Error 1213 (40001): Deadlock found when trying to get lock
(Observed on a real developer-flow run doing concurrent foreground + background
tool writes.)
Add db.WithTxRetry: a bounded, ctx-aware retry with full-jitter exponential
backoff that re-runs the transaction on retryable conflicts — MySQL deadlock
(1213) and lock-wait timeout (1205) — matching MySQL's own "try restarting
transaction" guidance. IsRetryableTxError classifies via *mysql.MySQLError and
returns false for every non-MySQL error (including all SQLite errors), so the
SQLite path is unaffected. Wrap the transactional bodies of Create and
CreatePair in it; each attempt opens its own tx and rolls back, so re-running
is safe.
A durable follow-up (not in this change) is to remove the SELECT-MAX-then-
INSERT race entirely — serialize seq allocation per session or make it atomic.
Retry is the minimal, low-risk fix for the fatal error.
Tests: internal/db/retry_test.go covers the error classifier and the retry
loop (success, retry-then-succeed, non-retryable, exhaustion, ctx-cancelled).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review summary (verified by live reproduction) — merging as-isVerdict: bug confirmed, fix is correct and safe — including multiple opencode processes sharing one MySQL DB. Reproduced against
Root-cause correction (for the record)The deadlock is not range locks from Multi-process safety (checked)
Mechanical checks on the branch: Follow-up TODO (in priority order)
Reproduction test used for this review (drop into
|
Problem
message.CreateandCreatePairallocate the per-session sequence number inside their transaction asSELECT COALESCE(MAX(seq),0)+1 WHERE session_id=?→INSERT. When two writes to the same session run concurrently — e.g. a foreground tool result and a background task's synthetic tool result landing together — both take shared range locks on thesession_idindex, then each needs the insert-intention lock. InnoDB breaks the cycle as a deadlock (Error 1213). There is no retry anywhere in the DB layer, so this transient, self-healing conflict propagates all the way up and kills the whole agent flow atinternal/llm/agent/agent.go:Seen on a real
developer-react-on-jirarun (MICRO-1014) where the agent had a backgrounded shell task whose synthetic tool-result write raced the foreground turn's tool-result write on one session. SQLite deployments don't hit this (single writer), but the MySQL session provider (SESSION_PROVIDER_TYPE=mysql, used in the pod) does.Fix
Add
db.WithTxRetry— a bounded,ctx-aware retry with full-jitter exponential backoff (5 attempts, 2ms→100ms) that re-runs the transaction on retryable conflicts, matching MySQL's own "try restarting transaction" guidance:IsRetryableTxErrorclassifies via*mysql.MySQLError→ 1213 (deadlock) and 1205 (lock-wait timeout). Iterrors.As-unwraps, and returns false for every non-MySQL error, including all SQLite errors, so the SQLite path is untouched.CreateandCreatePairare wrapped in it. Each attempt opens its owntxanddefer tx.Rollback()s, so re-running is safe; publish/fromDBItemhappen once, after the tx succeeds.sethvargo/go-retryis only an indirect dep and isn't used anywhere in-tree, so this follows the existing bespoke-backoff style (cf.internal/llm/provider/anthropic.go).Follow-up (not in this PR)
The durable fix is to remove the SELECT-MAX-then-INSERT race entirely — serialize seq allocation per session (in-process mutex keyed by sessionID) or make it atomic. Retry is the minimal, low-risk fix for the fatal error; happy to do the redesign as a separate change if you'd prefer.
Tests
internal/db/retry_test.go(white-box, no DB needed): error classifier table + retry loop (first-try success, retry-then-succeed, non-retryable passthrough, exhaustion returns last error, already-cancelled ctx makes zero calls).go build ./...,go vet, andgo test ./internal/db/... ./internal/message/...all pass locally on go 1.25.8.🤖 Generated with Claude Code