Fix premature KV watcher initial completion - #995
Conversation
b5395d6 to
d78ae5e
Compare
|
This fixes the reported early-completion races, but the frozen delivery boundary can become permanently unreachable. With include_history=True, a consumer can snapshot 3 pending revisions, then a concurrent kv.purge() rollup collapses them to one marker. That marker arrives with num_pending == 0, but _received == 1 never reaches _pending == 3, so None is never emitted and history() hangs. I verified NATS Server 2.14 changes pending from 3 to 1 in this case, and a targeted regression times out. Please reconcile legitimate boundary shrinkage or add a safe bounded fallback, plus a regression test. |
|
Thanks for the feedback! Good catch |
The initial None marker was emitted as soon as a message reported num_pending == 0. Under concurrent writes num_pending is non-monotonic, so a stale zero could end the initial replay while earlier entries were still undelivered (nats-io#842). The setup path also compared server num_pending against the local delivered counter, missing messages already sent but still in flight. Freeze a minimum delivery boundary at consumer creation (delivered.consumer_seq + num_pending), count every delivered callback, and emit the single initial None only once the count reaches the boundary AND the current message reports zero pending. Since a concurrent purge rollup can collapse pending revisions and make the frozen boundary unreachable, a zero-pending message arriving below the boundary triggers a consumer-info reconciliation that lowers (never raises) the boundary, so completion is emitted instead of hanging keys()/history() forever.
d78ae5e to
04c80ad
Compare
|
Your updated code looks great! It fixes the additional edge case we identified in the initial version. Details on the edge-case:This was only present in the initial version of this PR. Not the current version! The boundary is a fixed count: at watcher creation consumer_info() reports N existing keys, and the initial None marker is emitted once N messages have been delivered. Bucket at watcher creation: A@seq1, B@seq2 → num_pending = 2, boundary frozen at 2 Delivery is ascending stream sequence: keys() returns {B, C} and drops A, a key that existed the entire time. The count is correct (2 → 3 keys), but the boundary counts deliveries, not identities: the newly inserted key C (lower seq) fills a slot, while the updated pre-existing key A |
|
Quick note on the red checks - I think they're not from this PR. The nats-key-value job can't find pytest, and because that matrix has no fail-fast: false, it takes the others down with it. Same failure on #1001. The jobs that actually cover this change are green. Happy to send a small separate PR for it if you want. |
|
On the checks, yeah uv had a change in semantics, and wasn't pinned. |
Summary
Nonemarker only after reaching that boundary and while the current message reportsnum_pending == 0Problem
KeyValue.keys()andhistory()stop at the firstNoneproduced bywatch(). Two independent mechanisms can emit that marker before the initial replay actually completed:Nonewhenever an individual message reportsmetadata.num_pending == 0. Under concurrent KV writes that value can reach zero and then increase again. In a three-node NATS 2.14.3 trace, revision 1996 reported pending 0 and causedNone; revisions 1997 and 1998 then arrived behind it with pending 10 and 9. Both keys were acknowledged and live before watcher creation, sokeys()returned a partial result.num_pendingwith the local subscription delivery counter, which does not include messages the consumer has already sent but that are still in flight. This is the case described in kv.watch/history race can emit initial None too early and cause spurious NoKeysError #842.Why not a count-only boundary
A pure delivery-count boundary (
delivered.consumer_seq + num_pendingat consumer creation, emitNoneafter that many callbacks) fixes both races above but introduces an identity-displacement error withhistory=1: if keyAis still pending and a writer updatesAwhile inserting a new keyC, the pendingArevision is erased andCtakes its slot in the frozen count. The count is then satisfied before the newArevision arrives, andAis dropped — the same user-visible bug.Fix
The consumer-info snapshot boundary is a reliable minimum: at least that many deliveries must arrive before initial data can be complete. The per-message
num_pending == 0signal is a necessary drain condition but can fire early. The patch therefore requires both:num_pending == 0.The count guard filters stale early zeros; the pending guard drains displaced updates. Liveness is no worse than the released code, which also required a
num_pending == 0message to terminate. The watcher API and continued live updates afterNoneare unchanged.Related: #842
Verification
test_watch_initial_marker_ignores_transient_zero_pendingfails against released nats-py 2.15.0 (marker emitted at the stale zero) and passes with this patchtest_watch_initial_marker_waits_for_displaced_updatefails against a count-only boundary and passes with this patch