Identity-keyed tables are broken in the cache (pk decodes to a table) #35
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#35
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 #34 (update-event synthesis). A table whose primary key is an
Identity— the canonical SpacetimeDB game pattern,playerkeyed by the connecting identity — does not work correctly in the cache today, and the whole test corpus dodges it.The bug
protocol.decode_row(stdb/protocol.lua:142-167) never callsprotocol.unwrapon field values. A special-type column therefore decodes to the raw wrapper table, so{"__identity__":"0x…"}becomes a Lua table and the primary key becomes a table reference.Measured against both
mainand the #34 branch — delete plus insert of one identity on apk = "owner"table:Two rows in the store for one logical row. Two distinct table references are never equal, so:
cache.get(state, name, key)cannot find the row by identitystore/removetreat every arrival as a new key, so the row set grows without bounddev/test/fixtures/chat_schema.luaisu64/string/string, so nothing in the suite touches it.Same class, same issue
u64pk above 2^53 collides in Lua 5.1's doubles. Two distinct keys become one, silently, and the cache merges two rows."1"in one array and1in the other never pairs — Lua's"1" ~= 1— so the cache holds both.Why this is not just "fix decode_row"
Unwrapping row fields changes the type of row field values, which is exactly the surface #24 freezes at 1.0. A consumer reading
row.ownertoday gets a table; after the fix they get a string. That is a breaking change, and it must land before 1.0 rather than after.It also overlaps two issues already open:
So this is the third leg: decide whether
decode_rowapplies those conversions automatically, or whether rows stay raw and consumers convert. The cache forces the question, because a pk must be a comparable scalar for the store to work at all — regardless of what happens to non-key columns.Work
decode_row(needs #25's types), versus raw rows plus explicit helpers (#23) plus a pk-only unwrap in the cacheIdentity-keyed table work: one logical row, one cache entry,cache.getfinds it by identity, and #34's pairing produces anupdatedev/server/index.jsand capture a fixture, so this is proven against the wire rather than arguedu64 > 2^53limit — Lua 5.1 has no integers, so this cannot be fully solved, only bounded and documentedupdateevent rowAcceptance
A fixture-backed test where a
playertable keyed byIdentityreceives an insert, then a pk-preserving change, and the cache holds exactly one row throughout while the event stream reports oneupdate.Priority
Blocker for 1.0. Not because it is common in the current test module — it is absent there — but because it is common in real modules, it is silent, and the fix changes a frozen type. Fixing it after 1.0 costs a major version.
Sequencing established: #26 makes this cheap, and it now gates #28's headline feature
Verified across three working trees during the #24 push.
Both #23 and #26 deliberately declined to fix this, independently and correctly
decode_rowalone.protocol.lua:380-381: "unwrap stays a generic wrapper-stripper rather than doubling as the Identity accessor."decode_row.protocol.lua:618-624: "DELIBERATELY NOT WIRED INTO decode_row … exactly the cross-cutting, frozen-surface call #35 exists to make."Two authors reached the same conclusion without coordinating, which is a good sign the boundary is real: per-column coercion on the shared path changes the type of values consumers already receive, and that is a major-version break after 1.0.
The consequence nobody had written down
The cache decodes through
decode_row(cache.lua:155-164), neverdecode_typed. So no cached row ever receives typed decoding. Measured on the #46 branch:row.identityis still a table, two decodes of identical bytes are unequal, and neither matches the raw identity string.This gates #28's motivating feature entirely
cache.find(state, "player", "identity", me)— the "which row is me?" lookup #28 exists to serve — returns nil for every spelling a consumer would try: the identity stringIdentityTokenhands you, a re-wrapped{ __identity__ = … }, and even arow.identitytable kept across adrop_all+ resubscribe (the row is re-decoded into a fresh table). Onlyrow.identityoff that same row works, which is circular.So the honest order is:
#26 → #35 → #28's motivating case works.
Merging #28 does not deliver that lookup at any point until this issue lands, independent of #28's own correctness. String and integer unique columns (
handle,slug,email, an account number) work fully today, which is real value — just not the value the feature was pitched on.The fix is now cheap, and the mechanism is specific
Once #26 lands,
decode_typedalready performs exactly the conversion needed. The remaining work is plumbing: give the cache access to the module'stypespaceand route row decoding through the typed path, rather than writing any new conversion logic. That is a much smaller change than this issue originally assumed, and it is why the decision should be made after #26 merges rather than before.Cost flag, for #30
Routing cache decode through
decode_typedcoerces every column of every row on the main thread. For scale, #28's index maintenance alone measured ~0.33 µs/row and +8% on a 20k-rowInitialSubscription. Per-column coercion will be materially more than that, and it lands on the same path — so whoever schedules this should measure it against #30's budget question rather than assuming it is free.Still a 1.0 blocker
Nothing here reduces the priority. It raises it: three merged or in-flight PRs have now each routed around this, and each one added a comment explaining why. That is the signature of a decision that needs making rather than deferring again.