[BUG, LOW] A failed inline catch-up read leaves a committed Start with funds reserved #2037
What fails
PrepareInferenceFn composes the next diff, then reads the host's catch-up, still holding Session.mu:
diff, composedIdx, err := s.composeDiffLocked(persistCtx, txsForDiff)
// ...
catchUp, inline, err := s.inlineCatchUpLocked(hostIdx, nonce)
if err != nil {
return nil, fmt.Errorf("read catch-up for host %d nonce %d: %w", hostIdx, nonce, err)
}
composeDiffLocked has already done all of the following before that read:
- Previewed
MsgStartInference. The record isPendingandBalanceis reduced byReservedCost. - Persisted the diff with
AppendDiff. - Installed that preview with
CommitValidatedand advanceds.nonce.
The error return does not roll any of that back. The caller receives nil, error and has no PreparedInference to send or to time out.
Commit 19811e042 only covers the ghost-burn symptom of this same return. When chosen is nil and the nonce moved, the picker loops and keeps the queue. It still has no PreparedInference, so runGhostProbe does not run, and that ghost MsgStart is left Pending the same way. A real request is worse: the picker already dequeued it and replies the error to that caller.
prepareInflight then returns. startInflight never runs, so there is no SendOnly, no refusal timer, and no refusal payload.
When the read runs
inlineCatchUpLocked reads the diffs this host has not applied yet, and only when that gap is still one chunk (catchUpChunkSize is 200):
from := s.hostSyncNonce[hostIdx] + 1
if from+catchUpChunkSize <= target {
return nil, false, nil // far behind; SendOnly reads later, after an inflight exists
}
diffs, err = s.diffRangeLocked(from, min(s.nonce, from+catchUpChunkSize-1))
from is the host cursor plus one. target is the nonce just composed. The result is []types.Diff over [cursor+1, nonce]: nonce, transactions, user signature, and post-state root.
The store is touched only for the prefix below the oldest diff still in s.diffs. ReadDiffPages walks that prefix in pages of at most 64 nonces and 8 MiB of txs_proto. Each page is two queries on one snapshot:
DiffSizes—nonceand the length oftxs_proto.GetDiffs— the blob, user signature, roots, warm-key JSON, then the signature rows.
SQLite uses a read-only transaction. Postgres uses repeatable read. The in-memory tail, including the diff just committed, is appended after that and cannot fail. A gap of 200 or more returns ok == false with no error; SendOnly loads those chunks later, and a failure there still has an inflight.
All three of the following have to be true, which is why this is rare:
- A store is configured.
- The host cursor was rewound, and the gap to this nonce is still under 200. With a store,
RewindHostCatchUpsends the cursor back to 0. - Those nonces are already gone from memory. Once every host cursor had moved past them,
dropDiffPrefixLockeddrops them even when the journal is shorter than the 256-diff cap. The rewind then points into history only the store still has.
Errors the read can return
PrepareInferenceFn wraps whatever ReadDiffPages returns as read catch-up for host %d nonce %d. The page callback in storedDiffs always returns nil, so it adds no error of its own.
- A database error, wrapped as
get diff sizes:orget diffs:. Pool or epoch lookup, a transaction that will not start, a query error, or a scan error. Postgres also uses its own operation deadline, so a slow read comes back as a context deadline. - A row that will not decode:
unmarshal txsontxs_proto, orunmarshal warm keysonwarm_keys_json. - A
*storage.DiffGapErrorwhen the rows are readable but the range is not contiguous:missing nonce N, next stored nonce is M, ormissing trailing nonces N..M.
Why nothing releases the reservation
A successful prepare builds an inflight. If the host never answers, scheduleDeadlineHandleTimeout remembers the refusal payload and later HandleTimeout votes TIMEOUT_REASON_REFUSED, which adds ReservedCost back to the balance. That chain needs the PreparedInference.
The execution sweep cannot fill in. A Pending inference is eligible only when a refusal payload was stored for it:
rememberRefusalPayload runs inside that same timeout scheduler. This nonce never gets there. The diff is durable, so a later catch-up can still teach the host the MsgStart, but the gateway has no inflight and no refusal payload for it. The record stays Pending and the reserved cost stays locked.
sequenceDiagram
participant R as Redundancy
participant P as sessionPicker
participant S as PrepareInferenceFn
participant SM as StateMachine
participant DB as Store
R->>P: enqueue the user request
P->>S: PrepareInferenceFn
Note over P,S: chooser dequeues the request and returns its params
S->>SM: composeDiffLocked previews MsgStartInference
SM-->>S: Pending, balance reduced by ReservedCost
S->>DB: AppendDiff
DB-->>S: write succeeds
S->>SM: CommitValidated
Note over SM: nonce advanced, funds stay reserved
S->>DB: inlineCatchUpLocked reads the rewound range
DB-->>S: read fails
S-->>P: nil, error. PreparedInference discarded
P-->>R: pickerResult with the error
R-->>R: prepareInflight returns
Note over R: no SendOnly, no refusal timer, no refusal payload
Note over SM: sweep skips Pending without a refusal payload
Direction
Treat a failed inline read the way a long gap is already treated: return the composed diff with farBehind set, and let catchUpForSend retry the store read outside Session.mu. That path already returns the error with no diffs, and it runs after the inflight exists, so a lasting read failure still schedules the refusal timeout. A ghost burn then still reaches runGhostProbe.
Returning the error before composeDiffLocked is the other safe order, and it is already what a persist failure does. It does not apply once CommitValidated has succeeded: the nonce is spent, and dropping the handle is what strands the reservation.
🔄 Auto-synced from Issue #2037 every hour.