cache: count how many queries hold a row, so an overlapping one survives #51
No reviewers
Labels
No labels
area:defold
area:docs
area:infra
area:protocol
area:testing
kind:bug
kind:chore
phase:breadth
phase:core
phase:hardening
phase:slice
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
yuris/spacetimedb-defold!51
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/43-overlap"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #43 — and corrects two of the issue's own premises.
With two live subscriptions matching a row by distinct queries, a change that makes the row stop matching one of them evicted it from the cache entirely, even though it still exists on the server and still matches the other query. Nothing errored. Pre-existing, reproduced against
mainbefore this push.The cache now counts how many queries hold each primary key (
state.refs), removes a row only at the last hold, and suppresses the delete event exactly when the row survives.Premise correction 1 — the association does not exist on an
updatesentry#43 said to thread
query_idintoapply_database_update. That is impossible on the protocol this SDK speaks: upstreamcrates/client-api-messages/src/websocket/v1.rsat v2.8.2 isQueryUpdate { deletes, inserts }, withTableUpdate.updatesa bare list — no per-entry query identifier. Positional association is unusable too, because the legacySubscribeset counts apart while two identicalSubscribeMultitexts collapse to one entry.Message level identity does exist —
SubscribeApplied,SubscribeMultiApplied,Unsubscribe*AppliedandSubscriptionErrorall carry aquery_id, so a whole DatabaseUpdate on the subscribe/unsubscribe path can be attributed to one set. What is missing is per-ENTRY attribution inside aTransactionUpdate, which is exactly the #43 failure path.The v2 BSATN wire does carry it (
TransactionUpdate { query_sets: [{ query_set_id, tables }] }), which is how the official SDKs could attribute a delta — and they still only count, discarding the id (RustRowEntry.ref_count, TypeScriptMap<pk,[row,n]>).So a plain multiset hold count needs no identity, and
apply_database_update's frozen signature is untouched whilecache.luastays pure Lua.Premise correction 2 — the bare delete is entry 0, not entry 1
Captured live, the ordering in the issue is backwards. And the old cache's failure was one-sided, not two symmetric ones:
count=1, row =v1, the correct current value — but a spuriousdeleteevent fired beside theupdate, for a rowcache.getstill answers forcount=0, row gone —removenils the key regardless of value and drops thev1the pair just wroteSo in bare-then-pair order
mainis accidentally right about the data and wrong only about the event; the data loss needs pair-then-bare. Live 2.8.2 emits bare-first in every shape reproducible, which is why the loss shows up one frame later instead — at the unsubscribe of one of N (see below). Nothing on the wire promises the order (SmallVec, server-internal), so both orders are asserted and the fix is arithmetic rather than positional.Server behaviour, verified on an isolated server
mainthis is where the row is lost outright, with no dependence on entry order.session.jsonlframes 14–17. Same rule, because the count is of queries, not subscriptions.Subscribecalled twice re-sends every matching row as a bare insert with no deletes, and narrowing the query sends no delete for rows that left.The captured fixture (#43's first work item)
session.jsonlgains an overlap block (frames 43–50, a fourth socket) viacapture-fixtures.mjs's existingBLOCKSappend. It is the shape the entire design rests on and it existed nowhere in the repo — only in hand-built frames and transient captures, andsmoke.scriptPHASE3 guards the unsubscribe release path rather than this framing. So nothing committed would have noticed if 2.8.2's per-query delta framing changed.Two distinct-text queries (
sender = 'probe43',text = 'alpha') over one row, then an edit that takes it out of one of them. Frame 48, from the live server:Frame 47 is the other half nothing committed had: a distinct-text set re-sending a row the client already holds, which is the arrival a hold count counts. Frames 14–17 cannot show it — identical text is one query.
Appended, not recaptured: frames 1–42 are byte-identical (
cmpclean), so nothing pinned above needed re-verifying, andchat_schema.luare-derives clean underconformance.sh's drift gate.test_fixtures.luaasserts frame 48 on the raw bytes, then replays those two entries both ways and requires convergence — same store, same value, same singleupdateevent — and follows each order through frames 49 and 50 so the surviving hold is proved by what the socket did next rather than by readingrefs.Not vacuous: swapping
main'scache.luaunder this test fails atcaptured order, bare delete first: exactly one event: expected 1, got 2, and with the orders reversed atreversed order, pair first: the row survives: expected 1, got 0.The four interactions
updatesentry; paired-update events fire independently of the count. The single-query case is asserted byte-identical in events, counts and store.remove, so a decremented-but-kept row keeps its index entries and a truly removed row loses them. Covered by a hand-written case plus a seeded randomised property test asserting both directions — the same technique that caught a blocker in #44.The legacy
Subscribepath — a REGRESSION, now reported rather than silentCorrecting the earlier claim in this description: this is not wholly a pre-existing limitation. It is two consequences, and only one of them predates the count.
Pre-existing: narrowing a legacy
Subscribesends no delete for the row that left, so a stale row survives. Reproduced identically onmainand here; no refcount needed to make it appear.New here: calling
subscribetwice with the same text re-sends every matching row as a bare insert with no deletes, so the count reaches 2, and the row's real deletion then only decrements it. Replayed through both caches on live captured frames:mainclear_allreally deletes the row{},deleteevent fired — correct{4097:1}, no eventon_deletenever firesmainwas correct on that exact sequence. A silent wrong answer out of a method frozen at 1.0 is the same class of failure #43 itself is about, soconnection.subscribenow reports it: a second legacysubscribeon one connection emits asubscription_replacedevent before the frame goes out. Not a raise — the call is legal, if unwise — and it follows #42'sundescribedprecedent rather than adding a mechanism.resubscribe_allreplaying one live set is silent (it clearssubscriptionsfirst), and a retry after aSubscriptionErroris silent too, because a rejected set leaves the map.The count still cannot recover the inflated hold; the fix remains "call
subscribeonce per connection, usesubscribe_queryfor anything you may change". What changed is that the consumer is told.Same shape documented for pk-less tables. Both in the README.
Two documentation gaps closed
stdb/tables.lua's "things a game has to know that no signature states" list gains a fourth item:on_insertfires per arrival,on_deleteonly at the last release. Measured through the per-table API — one row delivered by two distinct queries then dropped by each in turn giveson_insert2,on_delete1 (onmain: 2 and 2, with the row wrongly evicted at the first drop). Son=n+1/n=n-1counting drifts by +1 per extra overlapping query, and unlike the reconnect drift already listed there, this one never settles.M.subscribe's own docstring now names the repeat-call hazard and points at the README, instead of documenting it only in the README andcache.lua's comment block.Cost, measured against #30
Lua 5.1 via
.#ci, best-of-7, both builds on one machine:mainInitialSubscription(pk)updatesentriesInitialSubscription(pk-less)Memory: one integer per primary key, measured by dropping the holds table in-process and re-measuring — 26.2 B/key at 20k rows, 21.0 B/key at 100k (per-key cost falls because Lua sizes the hash part to a power of two). About 6% on top of what the rows already occupy. pk-less relations pay nothing.
Against #30: a 20k-row initial subscription already costs ~114 ms on
main— roughly 7 dropped frames at 60 fps — so this adds ~9 ms to a figure already far over budget without changing the shape of that problem. One big initial frame is the cost; the steady-state transaction path pays ~2%.Verification
6 passed, 0 failed; stylua clean. Native conformance PASS both runs including PHASE5, withchat_schema.luare-derived and matched (no fixture drift). HTML5 PASS both loads including PHASE5.Three committed tests needed reshaping, all because they replayed shapes the wire does not produce — and all keep every original assertion:
test_cache.lua— a synthetic bare re-insert followed by one delete now asserts the new contract explicitly (two holds need two deletes).test_fixtures.lua— theplayerblock replayed frame 36 before frame 35, manufacturing a two-hold state the socket never had. Now in wire order, with a comment on why order matters where it did not before.smoke.script's PHASE3 now asserts the row survives the query-set drop, because the legacySubscribestill matches it — the server proves the survival is right by continuing to ship that row's deltas afterwards. The drain and delete assertions moved onto phase 5'sclear_all, which gained acache.countcheck the old script did not have.Review — automated reviewer, posted for the record
Verdict: APPROVE with 3 should-fix. No blockers. The design is correct and forced by the wire; both premise corrections hold. Closes #43 in code, but one of the issue's four work items is unmet.
scratch file
/tmp/r51-findings.md (353 lines, full evidence)
artifacts
/tmp/probe/{probe,sim,diff,bench,mem,replay,edge,tbl}.lua + captured live frames {overlap,order2,order3,legacy,legacy_worse,phase3}.jsonl. /tmp/review-51 left clean; server :5400 stopped; my probe file removed from the tree.
premise 1 no per entry query id
status — CONFIRMED — not a blocker
source — upstream websocket/v1.rs @ v2.8.2, fetched raw: L570-583
TableUpdate { table_id, table_name, num_rows, updates: SmallVec<[F::QueryUpdate; 1]> }(bare list); L631-645QueryUpdate { deletes, inserts }(two fields, no id). Upstream corroborates itself at L340-342 onUnsubscribeApplied.rows: 'To remove this in the future, we would need to send query_ids with rows in transaction updates, and we would need clients to track which rows exist in which queries.'wire — All 25 QueryUpdate objects in the committed
session.jsonlhave key set exactly ["deletes","inserts"]; all 25 TableUpdates exactly ["num_rows","table_id","table_name","updates"]. No identifier in a real frame either.v2 — v2.rs L304-314
TransactionUpdate { query_sets: [{ query_set_id, tables }] }— the asymmetry the author describes is real.conclusion — Threading
query_idin, as #43 asked, is impossible on this protocol. Keepingapply_database_update's signature is right. No identifier exists that the author missed.premise 2 bare delete is entry 0
status — CONFIRMED LIVE against 2.8.2
evidence — Own probe, q100=
WHERE sender='probe43', q101=WHERE text='alpha', then edit text->beta. Captured TransactionUpdate:e0: -[[3,"probe43","alpha"]] +[](BARE DELETE) /e1: -[[3,"probe43","alpha"]] +[[3,"probe43","beta"]](PAIR). #43's write-up has it backwards.order stability — Bare-delete-first in every shape I could produce (dropping-query subscribed 2nd, subscribed 1st, and 3 overlapping queries). Nothing on the wire promises it (
SmallVec, server-internal), so asserting both orders is correct.two distinct failures claim — Partly overstated — see nit 1. Reproduced on origin/main: order A (bare,pair) -> events
delete,update, count=1, row = v1, the CORRECT value; order B (pair,bare) -> eventsupdate,delete, count=0, row LOST. So main's data loss inside a transaction frame is one-sided, not two different failures. The PR removes the order dependence: both orders converge on refs=1, row=v1, events=[update].the live data loss is real and i reproduced it independently
On real captured bytes (
order3.jsonl, 3 overlapping distinct queries, replayed through both caches): PR f9 unsub q300 -> holds 2->1, store {7:1}; f11 unsub q303 -> delete(7), store {}. origin/main f9 -> store {} while q303 still matches, and f11 emits a SECOND delete(7) for a row already reported gone. My randomised server simulator reproduces the same on main on every seed tried (seed 2 step 21: 'store has 0 rows, expected 4'). The deleted README bullet ('Drop one of two overlapping subscriptions and it deletes nothing... Clients need no query-to-row bookkeeping of their own') is flatly wrong for distinct-text sets — its removal is a correction, not a convenience.hold count attack all seven cases pass
row twice in one batch then one delete — Survives (count 1), no delete event,
counts.deletesstill 1 (row ops). Second delete removes and emits. Fixture frame 23 is exactly this shape (pk 5 twice, two bare inserts) and frame 24 then edits it as two pairs -> refs stays 2, eventsupdate,update, identical to main.leak across reconnect — No leak.
drop_allanddrop_tableboth clearrefs; reconnect path is init.lua:262resubscribe_all-> connection.lua:542cache.drop_all.close()(init.lua:281) touches no cache, and a post-close reconnect is a freshM.connect-> freshcache.new. Grep-verified:drop_all/drop_tableare the only writers ofstate.refsoutsideretain/release.spurious or duplicated delete — Cannot go negative.
refs[key]is only ever written asn-1undern>1, or nil'd. 4 spurious deletes in a row leave the slot absent and each emits a delete event exactly as before; the next insert starts at 1 and one delete removes it.paired update on a doubly held row — One
updateevent witholdcarried, new value stored, hold count stays 2, counts 1/1 — and it still takes two deletes to leave.pk less table — No
refs[name]entry is created at all. Two arrivals = two array entries; one delete removes one copy; events byte-identical to main (same probe run on the main worktree).same key two tables —
refsis keyed per table (state.refs[name]); one table drains while the other keeps its hold.42 undescribed skip — Skipped before
table_def/retainis reached (cache.lua:431-442): counts 0/0, no events, and norefs["ghost"]table created. No holds for rows never stored.extra edges i added — BOOLEAN pk (
false) counts correctly — thedef.pk and row[def.pk]idiom is safe becausereleasecompares against nil. An insert whose pk decodes to nil still RAISES and leaves norefstable, becauseretaincallsstore(which guards) BEFORE touchingrefs— that ordering is load-bearing. A delete whose pk is nil takes theremovepath, emits, and leaves the real row and its hold untouched.interactions verified independently
21 byte identical — VERIFIED by differential, not trusted.
/tmp/probe/diff.luadumps every frame's counts and full event list (including eachupdate'sold), then the final store, pk-less counts and index contents. 5 seeds x 400 steps, single live query plus pk-less and undescribed traffic: output byte-identical between main and the PR (cmpclean, 367-383 lines each).28 indexes own randomised check — Wrote a faithful SERVER SIMULATOR (
/tmp/probe/sim.lua): true row set + live query predicates, derives each query's own delta (started matching -> insert, stopped -> delete, matched-but-changed -> pair), emits one shuffledupdatesentry per query, and drives subscribe/unsubscribe. Asserts store, values, find(handle), find(id), no dangling index entry, and the EXACT expected hold count. PR: 12 seeds x 800 steps = 9,600 frames, 9,612 verifications, ALL INVARIANTS HELD. main: fails every seed by step 11-60. #44's blocker class (decremented-but-kept row losing its index entry) does not reproduce. Note: my first generator was wrong — it handed two live rows the same uniquehandle, an impossible statereindexdocuments; fixed with in-frame handle reservation, then clean.35 and 42 — Untouched. The cache.lua diff is confined to the ROW HOLDS comment, the
refsfield,retain/release, the two call sites in the delete/insert loops, anddrop_all/drop_table.decode_rows/decode_typedare not in the diff; the typed path is covered by test_fixtures.lua (passing) and by conformance running against the generatedchat_schema.lua.the three changed tests
test cache upsert block — LEGITIMATE. Both original assertions are kept verbatim (count stays 2, sender becomes victor). The old test's setup had delivered key 2 twice as bare inserts, which under the new contract is two holds, so the following pk-change assertion ('old pk removed') needed one hold released first. A bare insert of an already-cached key really is the second-query shape (live-confirmed). The new intermediate step is required by the contract, not bent around it.
test fixtures player reorder — LEGITIMATE. Wire order in the capture is 35 (unsubscribe) then 36 (resubscribe readback); the old test replayed 36 then 35, which under the new contract puts TWO holds on one key and asserts a state the socket never produced. All four original assertions survive; the drain is now asserted from frame 35 in wire order and the block gains an index-refill assertion.
smoke script phase3 — CORRECT, and I scrutinised it hardest. Reproduced PHASE3's exact shape live: legacy
Subscribematches the row (holds 1); a SubscribeMulti of the SAME TEXT does NOT collapse with it and re-sends the row as a bare insert (holds 2); UnsubscribeMulti sends a delete (holds 1) -> the row survives; andclear_alllater sends ONE entry deleting it (holds 0) -> the cache drains. The server itself proves the survival is right: after the drop it still ships that row's deltas to this connection. On main the drop evicted it, soclear_all's delete would land on a key the cache no longer had. THE OLD ASSERTION WAS ASSERTING THE BUG. Cross-swapped both ways to prove neither assertion is vacuous: PR smoke.script + main's cache.lua -> conformance FAILS 'the drop evicted a row the legacy subscription still matches'; main's smoke.script + PR's cache.lua -> FAILS 'unsubscribed but 1 row(s) remain cached'. The drain assertion genuinely MOVED (old PHASE4tbl.deletes == 0check re-homed onto PHASE5'ssubscribedbranch) and coverage INCREASED (PHASE5 gains a newcache.count(...) ~= 0assertion inside the clear_all callback that the old script did not have).cost against 30
measured — Lua 5.1 via
.#ci, best-of-7, both builds same machine. 20k-row InitialSubscription (pk): main 113.8 ms -> PR 123.2 ms (+9.4 ms, +8.3%; 5.7 -> 6.2 us/row). 2000 paired-update frames on a warm 20k cache: 22.3 -> 22.8 ms (+1.9%, 11.4 us/frame). One frame carrying 2000updatesentries: 9.4 -> 9.6 ms (+2.1%). pk-less 20k: no holds taken, within noise.memory — Holds table measured by dropping it in-process and re-measuring: 20k pk rows 6864.7 -> 7377.0 KiB = 512.2 KiB = 26.2 B/key; 100k rows 33126.6 -> 35174.9 KiB = 2048.2 KiB = 21.0 B/key (per-key cost falls because Lua sizes the hash part to a power of two). One integer per PRIMARY KEY, ~6% on top of what the rows already occupy. pk-less relations pay nothing.
judgement — Report, do not block, per the brief. A 20k-row initial subscription already costs ~114 ms on main — about 7 dropped frames at 60 fps — so #51 adds ~9 ms to a figure already far over budget and does not change the shape of #30. The steady-state transaction path pays ~2%.
documented limitations accuracy
legacy subscribe — All three claims confirmed live: repeat
Subscribewith the same text re-sends every matching row as a bare insert with NO deletes; narrowing sends no delete for the row that left; and a legacySubscribedoes NOT collapse with aSubscribeMultiof identical text (so 'the legacy set counts as one more query' is right). Also confirmed on the committed fixture that two SubscribeMulti sets of identical text DO share one query (frames 14-17: subscribe q100 -> 2 rows, q101 -> nothing, unsub q100 -> nothing, unsub q101 -> 2 deletes). Placement: README.md:344 and cache.lua:71-80 — accurate, but not onM.subscribeitself (see should-fix 2).pk less — Accurate and executed. Two arrivals = two array entries; one delete removes one copy; a unique index files only one row per value so
cache.findreturns nil for the survivor whilecache.iterstill reaches it — exactly as README.md:337 states. Byte-identical to main. Placement is fine.sanity
suite — 6 passed, 0 failed
stylua — clean, exit 0 on
stdb dev/testnative conformance — PASS both runs, PHASE1-PHASE5, including the new 'real delete drained the cache and fired 1 on_delete callback(s)' line. Schema re-derived and matched: no fixture drift.
html5 conformance — PASS both loads, PHASE5 included.
fixture drift — none; session.jsonl and chat_schema.lua untouched
Findings
PR body ('Documented limitations') vs stdb/cache.lua:71-80 and README.md:344Subscribepath WORSE, not merely 'pre-existing, now written down'. Captured live (legacy_worse.jsonl) and replayed through both caches:Subscribetwice with the same text delivers the row twice (holds 2), thenclear_allgenuinely deletes it and ONE delete arrives. main: store {},deleteevent fired — CORRECT. PR: store {4097:1}, NO event — a PHANTOM ROW the cache serves forever, with no second delete ever coming andon_deletenever firing. main was correct on this exact sequence. The stale-row-on-narrowing half IS pre-existing (legacy.jsonlf7: row 9 stale on both builds), but the deletion half is a new silent wrong answer from a frozen public method.connection.luaknows when it issues a second legacySubscribewhile one is live (c.subscriptionsis keyed by request_id), so it can emit anunhandled/warning event instead of silently inflating every hold. Not a blocker only because the trigger is a pattern the README now explicitly steers away from andresubscribe_alldrops the cache first.stdb/tables.lua:44-51 and stdb/connection.lua:353-358on_insertfires per ARRIVAL whileon_deletefires only at the LAST release, so a consumer doingn=n+1/n=n-1drifts by +1 per extra query, permanently. tables.lua's 'Three things a game has to know that no signature states' list already documents the reconnect drift in exactly these terms and omits this one. Separately,M.subscribe's own docstring says nothing about the repeat-call hazard — it is documented only in README's unsubscribe section and cache.lua's comment block.M.subscribe's docstring pointing at the README paragraph.dev/test/fixtures/session.jsonl (untouched by this PR)dev/server/capture-fixtures.mjs's existing BLOCKS mechanism, which exists for this. Real cost acknowledged: appending invalidates every later block and forces a recapture.PR body, premise-correction-2 tablebare, pairleft the row 'present but stale — kept the row and lied about it'. Reproduced against origin/main: it leavesv1, the CORRECT current value. The lie was the spuriousdeleteEVENT beside a row that remains, not a stale row. So main's failure is one-sided (loses the row only inpair, bareorder), not 'two different failures'.bare, pair (captured) count=1 row=beta events=[delete,update]) records this correctly, so no code or test change is needed.PR body heading 'Premise correction 1 — the association does *not* exist on the wire'query_idDOES exist onSubscribeApplied(v1.rs:323),SubscribeMultiApplied(386),UnsubscribeApplied(338),UnsubscribeMultiApplied(401) andSubscriptionError(358), so on the subscribe/unsubscribe paths a whole DatabaseUpdate can be attributed to one query set. What is missing is per-ENTRY attribution inside aTransactionUpdate— which is exactly the #43 failure path, and exactly what cache.lua's comment says ('no query identifier on anupdatesentry').fa23a4ab4070cf4d9400