Cache drops a row that leaves one of several overlapping queries #43

Closed
opened 2026-08-19 17:36:58 +00:00 by yuris · 0 comments
Owner

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:

  • entry 0 — the full pair: deletes=1, inserts=1 (the row, changed, still matching this query)
  • entry 1 — a bare delete of the same key: deletes=1, inserts=0 (it left this query)

Fed through the cache:

events final store
main delete, insert, delete empty
PR #34 branch update, delete empty

The 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_update applies each updates[] 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 a Map<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

  • Reproduce with a captured fixture — two distinct queries where a change makes the row leave one. There is a probe shape in PR #34's second review comment
  • Decide: refcount rows by matching query set (correct, and matches the official SDKs), or document the limitation and tell consumers to re-read rather than trust the cache under overlapping subscriptions
  • If refcounting: query_id is already tracked per subscription and updates[] entries arrive per query, so the association exists on the wire. The work is threading it into apply_database_update, which currently takes a bare db_update
  • Either way, the README's overlapping-subscription section should cover the transaction path, not only unsubscribe

Priority

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 = 1 and WHERE 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.

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: - entry 0 — the full pair: `deletes=1`, `inserts=1` (the row, changed, still matching this query) - entry 1 — a **bare delete of the same key**: `deletes=1`, `inserts=0` (it left this query) Fed through the cache: | | events | final store | |---|---|---| | `main` | `delete`, `insert`, `delete` | **empty** | | PR #34 branch | `update`, `delete` | **empty** | The 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_update` applies each `updates[]` 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 a `Map<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 - [ ] Reproduce with a captured fixture — two distinct queries where a change makes the row leave one. There is a probe shape in PR #34's second review comment - [ ] Decide: refcount rows by matching query set (correct, and matches the official SDKs), or document the limitation and tell consumers to re-read rather than trust the cache under overlapping subscriptions - [ ] If refcounting: `query_id` is already tracked per subscription and `updates[]` entries arrive per query, so the association exists on the wire. The work is threading it into `apply_database_update`, which currently takes a bare `db_update` - [ ] Either way, the README's overlapping-subscription section should cover the transaction path, not only unsubscribe ## Priority 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 = 1` and `WHERE 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.
yuris closed this issue 2026-08-20 07:15:04 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/spacetimedb-defold#43
No description provided.