Cache drops a row that leaves one of several overlapping queries #43
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#43
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Found while reviewing PR #34, and pre-existing — reproduced identically against
main, so #34 neither causes nor worsens it. Filing because it is silent, and because #34's work is what made the shape visible.The failure
With two live subscriptions matching a row by distinct queries, when a change makes the row stop matching one of them, the wire sends:
deletes=1,inserts=1(the row, changed, still matching this query)deletes=1,inserts=0(it left this query)Fed through the cache:
maindelete,insert,deleteupdate,deleteThe row still exists on the server and still matches the other live query, but the client's cache no longer has it. Nothing errors.
Why it happens
cache.apply_database_updateapplies eachupdates[]entry in order, and entry 1's delete lands after entry 0's insert. The cache has no notion of which queries a row satisfies, so it cannot know that a delete arriving for query B does not mean the row is gone from query A.This is the documented overlapping-subscription hazard — the README already notes that clients would otherwise "need to track which rows exist in which queries" — but it was only ever described for the unsubscribe path. Here it shows up on the ordinary transaction path, where no one has unsubscribed from anything.
Note the contrast with unsubscribe, where the server does compute the real delta: drop one of two overlapping subscriptions and it deletes nothing, because the other still matches. That is captured in
session.jsonl. On the transaction path there is no such protection — the per-query deltas are shipped independently and the client is expected to reconcile them.What a fix would need
Per-query row membership: the cache would have to know that row R is held by query sets {A, B} and only truly delete it when the last one drops it. That is refcounting, which is exactly what the official Rust and TypeScript SDKs do (
RowEntry.ref_count, and aMap<pk, [row, refcount]>respectively) — and it is the one significant piece of client-cache machinery this SDK does not have.Earlier analysis concluded refcounting was unnecessary here because the server computes the real delta on unsubscribe. That conclusion was right about unsubscribe and wrong in general: this issue is the counterexample.
Work
query_idis already tracked per subscription andupdates[]entries arrive per query, so the association exists on the wire. The work is threading it intoapply_database_update, which currently takes a baredb_updatePriority
Judgement call for 1.0. It needs two subscriptions with distinct queries overlapping on a row, which is not exotic in a real game (
WHERE room = 1andWHERE owner = me). The failure is silent and the row is recoverable only by resubscribing.Cheapest honest option for 1.0 is to document it precisely and defer refcounting, since refcounting changes cache internals rather than public API and can therefore land after the freeze without a major bump. That asymmetry — internals, not surface — is what makes deferral defensible here, unlike #35 or #42.