serverconn: fold ingress dispatch and checkpoint into one transaction - #730
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces formal P specifications and tests for the connection-actor ingress cursor, alongside a Go implementation of transactional batch dispatch (runFoldedDispatch) when using a transaction-aware delivery store. The Go tests have been updated to align with this transactional contract. The review feedback highlights a potential issue where mutating the outer newState variable inside the transaction closure could lead to incorrect state on retries, and suggests using a local copy. Additionally, it is recommended to log failures when flushing dirty checkpoints during idle periods and to add defensive nil checks in splitIngressEnvelopes to prevent potential nil-pointer dereferences.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| newState := state | ||
| err := txStore.ExecTx(ctx, false, func(txCtx context.Context, | ||
| store actor.DeliveryStore) error { | ||
|
|
||
| if len(durables) > 0 { | ||
| _, dispatchErr := a.dispatchBatch( | ||
| txCtx, durables, nextCursor, | ||
| ) | ||
| if dispatchErr != nil { | ||
| return dispatchErr | ||
| } | ||
| } | ||
|
|
||
| newState.AdvanceDispatch(nextCursor) | ||
| newState.PullCursor = nextCursor | ||
|
|
||
| return a.saveCheckpointTo(txCtx, store, newState) | ||
| }) | ||
| if err != nil { | ||
| return state, err | ||
| } | ||
|
|
||
| return newState, nil |
There was a problem hiding this comment.
In runFoldedDispatch, the outer variable newState is captured and mutated directly inside the ExecTx transaction closure. If the database transaction fails and retries (e.g., due to serialization conflicts or concurrent write retries), newState will be mutated repeatedly across retries, starting from an already-modified state rather than the original state.
To ensure correctness under transaction retries, you should initialize a local copy of the state inside the closure, perform all mutations on that local copy, and only assign it to the outer return variable at the end of the closure.
var finalState AckState
err := txStore.ExecTx(ctx, false, func(txCtx context.Context,
store actor.DeliveryStore) error {
txState := state
if len(durables) > 0 {
_, dispatchErr := a.dispatchBatch(
txCtx, durables, nextCursor,
)
if dispatchErr != nil {
return dispatchErr
}
}
txState.AdvanceDispatch(nextCursor)
txState.PullCursor = nextCursor
if err := a.saveCheckpointTo(txCtx, store, txState); err != nil {
return err
}
finalState = txState
return nil
})
if err != nil {
return state, err
}
return finalState, nil| if ackDirty { | ||
| if err := a.saveCheckpoint( | ||
| ctx, state, | ||
| ); err == nil { | ||
|
|
||
| ackDirty = false | ||
| } | ||
| } |
There was a problem hiding this comment.
If saveCheckpoint fails during the idle flush, the error is silently ignored without any logging. While retrying on the next idle poll is fine, logging a warning on failure is highly recommended to ensure visibility into checkpoint store issues during idle periods.
| if ackDirty { | |
| if err := a.saveCheckpoint( | |
| ctx, state, | |
| ); err == nil { | |
| ackDirty = false | |
| } | |
| } | |
| if ackDirty { | |
| if err := a.saveCheckpoint( | |
| ctx, state, | |
| ); err != nil { | |
| a.log.WarnS( | |
| ctx, | |
| "Failed to flush dirty ack checkpoint", | |
| err, | |
| ) | |
| } else { | |
| ackDirty = false | |
| } | |
| } |
| for _, env := range envelopes { | ||
| isResponse := env.Rpc != nil && | ||
| env.Rpc.Kind == mailboxpb.RpcMeta_KIND_RESPONSE |
There was a problem hiding this comment.
For defensive programming, it is recommended to add a nil check for env in splitIngressEnvelopes before accessing env.Rpc to prevent potential nil-pointer dereference panics if the slice contains any nil elements.
for _, env := range envelopes {
if env == nil {
continue
}
isResponse := env.Rpc != nil &&
env.Rpc.Kind == mailboxpb.RpcMeta_KIND_RESPONSEThere was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d4359f3e0f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if len(responses) > 0 { | ||
| if _, err := a.dispatchBatch(ctx, responses, 0); err != nil { |
There was a problem hiding this comment.
Keep durable response fallbacks inside the fold
When a KIND_RESPONSE has no active waiter, dispatchBatch falls back to the durable dispatcher (deliverResponse returns buffered, then dispatcher(ctx, env) is called in dispatchBatch around lines 351-387). Because this new path sends every response through dispatchBatch(ctx, responses, 0) before ExecTx, durable response routes such as the OOR indexer response routes are enqueued in their own commit instead of the folded transaction. If a later durable envelope in the same pulled batch fails or the process crashes before the folded checkpoint commits, the cursor stays behind and this response is replayed, causing duplicate/out-of-order durable actor messages; only responses actually delivered to an in-memory waiter should bypass the transaction.
Useful? React with 👍 / 👎.
d4359f3 to
874a3a4
Compare
2f8f9fe to
4f4b261
Compare
b41ce83 to
2ea0931
Compare
4f4b261 to
cfab0af
Compare
2ea0931 to
0555118
Compare
8458d39 to
6576963
Compare
|
@claude review this |
|
Claude finished @Roasbeef's task in 3m 15s —— View job Review: serverconn ingress dispatch + checkpoint foldI read the full diff against Status of the existing bot reviews (all against the older commit
|
6576963 to
e8ac181
Compare
The empirical commit ledger from the stress harness puts the ingress plumbing among the largest write-amplification buckets: every envelope in a pulled batch paid its own EnqueueMessage commit through the dispatcher Tell, and the AckState checkpoint paid another, so a batch of k envelopes cost k+1 fsyncs on the serialized SQLite writer. In this commit, we restructure the ingress loop into a two-phase dispatch when the delivery store supports transactions. In-memory response envelopes deliver first, OUTSIDE the transaction: response delivery cannot roll back, and unary callers sit blocked on these with RPC deadlines, so gating them on the writer lock turns write contention into payment-wide timeout collapse (the first cut of this change did exactly that under bench load). The durable request and event dispatches then fold into ONE ExecTx together with the AckState checkpoint: each dispatcher Tell joins the ambient transaction via the context exactly like the outbox delivery fold, so a batch of k durable envelopes costs one commit instead of k+1, and the cursor can never run ahead of the enqueues. Any dispatch failure rolls back both and the batch is re-pulled intact. The ack-path checkpoint no longer pays its own commit either; the advanced watermark rides along with the next dispatch checkpoint, with an idle-poll flush so a quiet connection still converges. Losing the in-memory advance to a crash only costs one redundant idempotent AckUpTo on restart. The partial-dispatch redelivery test is updated for the new contract: a mid-batch failure rolls back the whole batch (the prefix enqueue is erased with it), the retry re-dispatches both envelopes, and counts stay frozen after the commit, which still guards the original cursor off-by-one regression.
The transactional ingress fold changes how the connection actors advance their AckState PullCursor, so we extend the durable actor P model with the property the fold must preserve: a persisted cursor must never cover an envelope whose local enqueue did not durably commit, because the loop would resume past it and lose the message forever. In-memory response envelopes are documented out of scope: they deliver at most once, before and outside the transaction. The green test drives the folded design through nondeterministic batch sizes, injected commit rollbacks, and crash-restarts, and the checker finds no violation. Two counterexample cases document the ways an implementation can break the contract, and the checker flags both: keeping the advanced in-memory cursor after a rolled-back commit, and persisting the cursor checkpoint in a separate commit ordered before the enqueues. The second case also shows the pre-fold design was only safe by commit ordering; the fold upgrades that ordering convention into an atomicity guarantee.
e8ac181 to
1bc79c5
Compare
|
@claude review this |
|
Claude finished @Roasbeef's task in 4m 19s —— View job Review: serverconn ingress dispatch + checkpoint foldI read the diff against Prior bot reviews — all resolved at this HEADThe earlier reviews were against the older commit
One new issue worth a look (P3): waiter-delivered response re-dispatched durably on whole-batch rollbackThe fold rolls the whole batch back on any dispatch error (intentional, and strictly safer than the legacy prefix-commit). But combined with the pre-transaction waiter delivery, this opens a duplicate-delivery shape the legacy path didn't have. Consider a batch
So a response already delivered to the unary caller is also dispatched to the durable EventRouter on retry. The window is not microseconds — it's the whole backoff interval, so the waiter being gone is the likely case, not the rare one. Why it's bounded (hence P3, not higher):
Still, it's a genuinely new dup shape vs. the legacy prefix-commit, and the legacy path explicitly avoided it (it committed Nits (no action required)
Tests / P model
Overall: looks good to me. The only thing I'd genuinely weigh before merge is the P3 dup shape above — and even that is bounded by
|
|
@claude Dug into the P3 (waiter-delivered response re-dispatched durably on a whole-batch rollback). It's structurally plausible but not reachable in the current wiring — the two conditions it needs are disjoint:
The OOR indexer query responses named as the example are actually immune: they flow through
It is a genuine latent edge if a future response type is ever wired into both mechanisms (which the So I'm not adding the cross-iteration "delivered-this-batch" tracking to guard a case that can't currently occur — that's fragile state for no live benefit. Behavior stays as-is. Good shape to flag though; the reasoning holds, just not for the example given. |
In this PR, we fold the serverconn ingress path's durable dispatches and
cursor checkpoint into a single write transaction. Before this, a pulled
batch of k envelopes paid k+1 commits: each dispatcher Tell was its own
EnqueueMessagecommit, then the cursor checkpoint paid one more. Thecommit ledger (#729) measured the connection actors' checkpoints at ~21
writes per payment, the single biggest bucket nobody's estimate had
caught.
The fold reuses the ambient-transaction pattern from the outbox fold
(#727): the dispatch Tells join the transaction through the context, so
the cursor can never run ahead of the enqueues. That is an upgrade, not
just a saving. The pre-fold design was only safe by commit ordering; the
fold makes cursor-covers-enqueues atomic. The ack-path checkpoint becomes
lazy and rides the next dispatch commit, with an idle flush for quiet
connections; a crash costs at most one redundant, idempotent AckUpTo.
One contract worth calling out for review: the fold is two-phase. In-memory
KIND_RESPONSE envelopes deliver before the transaction opens, and only
durable dispatches plus the checkpoint fold inside it. Responses are what
unary callers block on with RPC deadlines, so gating them behind the
writer lock turns write contention into payment-wide timeout collapse (we
measured exactly that with a first cut that folded everything).
The second commit adds a P model spec for the no-loss contract: a
persisted cursor must never cover an envelope whose enqueue did not
commit. The spec passes against the folded design under nondeterministic
batch sizes, rollbacks, and crashes, and ships with two counterexamples
(eager cursor after rollback, checkpoint-before-enqueue) that the checker
correctly flags, so the monitor demonstrably has teeth.
On the bench: counted writes per payment fell 77.9 to 59.8 (the fold's own
commit runs on the uninstrumented TxAware path, so the true cut is
~18-20%), p95 fell 12-20%, with 4/4 clean runs at 100% success. The
operator-side clientconn deliberately does not get this fold; its
dispatchers run synchronous handler work, and folding them held the
operator writer lock across whole handlers (darepo#552 tracks the purity
split needed first). It gets the lazy ack only, in the darepo-side PR.
This PR builds on #729 and closes out the OOR optimization train.