cache: unique-column lookups maintained from the schema #44
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!44
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/28-unique-index"
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 #28.
cache.getlooks up by primary key only; any other unique column needed a fullcache.iterscan. Now that the generator emitsunique, the cache maintains a lookup per unique column:The motivating shape is the common one in a game: a
playertable keyed by auto-incrementidwith a uniqueidentitycolumn. "Which row is me?" runs on every connect and every reconnect, and it was O(n) on the main thread.Design decisions
state.indexes[table][column][value] = row, built once from the schema atcache.new, rebuilt bydrop_all.state.tables[name]already is that index; mirroring would double memory and leave two structures to keep in step.findon the pk column delegates toget, so callers — and future generated accessors — route every single-column unique lookup through one function without special-casing the pk.getstays the direct spelling. Both are frozen at 1.0, so the relationship is spelled out infind's docstring.store/remove, which are only ever reached fromapply_database_update. No separate pass, and #21's pairing stays purely an event concern — the index needs no second diff.findon a non-unique column raises, rather than returning nil or scanning. Returning nil would be indistinguishable from a genuine miss and would hide a lookup that can never succeed; a scan would be a silent O(n) on the main thread. A unique column with no match returns nil, so hit / miss / unusable are three distinguishable outcomes.schema.newnow rejects auniquename that is not a column, for the same reason it already rejects such a pk: it would build an index no row can populate, makingfindmiss forever instead of saying why.The correctness case that matters
A unique column's value changing must erase the old key before writing the new one, or the index keeps a stale entry pointing at a row that no longer holds that value.
That falls out of the existing delete-then-insert ordering — the delete half carries the old row, so the old key is erased before the insert writes the new one. The case ordering does not cover is a row change arriving as a bare insert with no delete half, which the pk store has always treated as an upsert;
storetherefore explicitly erases the keys of the row it replaces. Separately tested.reindex'soldside is always the row the cache is holding, never a row straight off the wire, so an entry can only ever be erased by the row that owns it.Tables with no
uniquemetadata get no index entry and pay one nil check. Hand-written schemas without auniquefield work unchanged.Verification
5 passed, 0 failed; stylua clean. Falsifiability by mutation — each piece broken in place, suite re-run, then reverted:reindexnever erasesnothing stale left under the old key: expected nil, got tablestoreupsert does not erase the replaced rowbare upsert erased the replaced row's key: expected nil, got tableremovepk path drops itsreindexnothing stale left under the old key: expected nil, got tableremovepk-less path drops itsreindexpk-less value change left no stale key: expected nil, got tabledrop_tabledoes not clear the indexdrop_table emptied the index with them: expected nil, got tableReviewer attention
stdb/schema.luashows +34/-34 for what is described as adding onehas_columnhelper. Please confirm that is a genuine change and not incidental reformatting.Identity-typed pk currently decodes to a table, so a uniqueidentitycolumn has the same problem — which is the exact example this feature exists to serve. Noted in a comment; not fixed here, since #35 changes a frozen row-field type.Review — automated reviewer, posted for the record
Review — PR #44,
feat/28-unique-indexChecked out at
caaa9da, base7fd668a. Suite on the branch:5 passed, 0 failed. All findings below are reproduced by probes I ran against the branch; the proposed fixes are applied to a scratch copy (/tmp/fix-44) and re-verified.The design is right.
state.indexes[table][column][value] = row, not mirroring the pk, maintenance insidestore/remove, no second diff of #21's pairing — all of that is the correct shape, and the author's own prose atstdb/cache.lua:69-71states the correct invariant. The code does not implement it. One line fixes it.BLOCKER —
reindexerases index entries it does not own, losing rows on server-legal inputstdb/cache.lua:72-87, specifically the unconditional erase at:76-78:The docstring one line above claims the opposite:
The premise is true; the conclusion does not follow.
oldis indeed a cached row, butby_value[old[col]]may already have been reassigned to a different row earlier in the same batch. The erase then deletes a live entry, and nothing ever rewrites it. The row stays instate.tablesand becomes permanently unreachable throughfind— until its indexed column changes again ordrop_allruns.Why this is reachable without a server-side constraint violation
apply_database_updateloopsupdatesentries atstdb/cache.lua:209. Deletes-first ordering (:223-228) is per entry, not per batch — so across two entries an insert can precede the delete of the row that held its value. Each entry is one subscribed query's diff, so two queries over the same table produce two entries.That is not hypothetical. The repo's own captured live 2.8.2 session already contains it —
dev/test/fixtures/session.jsonl, line 24:Minimal repro — every update a complete delete+insert pair, no unique value ever duplicated among live rows
Observed:
Trace: entry 1 erases
["a"], writes["b"]=row1. Entry 2 deletes row 2, whose cachedhandleis still"b", soreindexerases["b"]— row 1's freshly written entry — then writes["a"]=row2.Three more shapes, same root cause
find("b") == nil, row 1 stored and unreachableRandomised property check — pk + two unique columns, 300 steps × 40 seeds, asserting after every step that every index entry points at a present row holding that value and every stored row is reachable through each of its unique columns:
seed 1 step 39: find(handle=h15) != get(6)Fix — one line, makes the existing docstring true
Verified on
/tmp/fix-44: both property runs pass (0/40 seeds fail), every hand-written case above passes, existing suite still5 passed, 0 failed. Cost is within run-to-run noise and memory is unchanged (numbers in §6).The two cases that still violate the invariant afterwards both require the server to have already violated its own unique constraint (an insert colliding with a live value; duplicate unique values in a pk-less table). In both, one row shadowing the other is the only sensible outcome, and with the guard no legal row is ever lost — which is the part that matters.
Test coverage gap that hid this
dev/test/test_cache.lua:24-26:One
updatesentry, hardcoded. Every cache test in the repo goes through this helper, so no test can express more than one entry per table — the exact shape the fixture contains and the bug lives in. Please add a two-entry test alongside the swap case; the helper needs a variant that takes a list of entries.SHOULD-FIX — the level-2 trace does not point at the caller, contradicting
find's own docstringstdb/cache.lua:308promises "A stack trace at the callsite beats both." It does not deliver one.err(:20-22) callserror(msg, 2)from insideerr, so level 2 resolves to the frame that callederr—finditself:The caller is never named.
schema.luain this same PR gets it right by callingerror(..., 2)inline:This is pre-existing behaviour of
err, and a sharederrcannot have one correct level —store's use is reached fromapply_database_update, where no level points at user code. So raise inline infind:Verified:
game/player.lua:2: stdb.cache: table "a" has no unique column "level". Suite unaffected.Worth fixing here rather than later: this error is the whole justification for raising instead of returning nil, and 1.0 freezes the contract. An error that misattributes itself to the SDK sends the reader into
cache.luainstead of to their typo.SHOULD-FIX — the README promises the one use case #35 makes non-functional
The PR body and
stdb/cache.lua:311-315are honest about #35.README.md:656-658is not, and the README is what users read:Measured against the fixture's real wire encoding (
{"__identity__":"0x…"};protocol.decode_rowatstdb/protocol.lua:163-188never callsM.unwrap, so the field stays a fresh table per row):find(s, "player", "identity", ME)— the identity stringIdentityTokenhands youfind(s, "player", "identity", { __identity__ = ME })— re-wrapped as the wire spells itfind(s, "player", "identity", row.identity)— the table off that same rowdrop_all+ resubscribeSo the advertised lookup delivers zero — not "impaired". The index/store invariant is fine; the key type is the problem, exactly as the docstring says. One sentence in the README, next to the promise, closes the gap. A string, integer or
u32unique column (handle,slug,email, an account number) works fully today, which is real value — just not the value the README leads with.SHOULD-FIX — add the multi-entry test (see the coverage gap above)
Separated out because it stands on its own even after the blocker is fixed: nothing in the suite exercises
stdb/cache.lua:209's loop with more than one iteration, for the index or for #21's events.NIT — two
uniquevalidation holes, in a function whose job is diagnosticsstdb/schema.lua:49:unique = "handle"(a bare string) →stdb/schema.lua:49: bad argument #1 to 'ipairs' (table expected, got string). Not anstdb.schema:diagnostic, and it names the SDK's line.columnshas the same hole pre-existing, so a shared type check would close both.unique = { handle = true }(set-shaped — a plausible hand-written spelling, and the shapeindexes[].uniqueuses as a boolean) passes validation, builds no index, and thenfind(s, "t", "handle", v)raiseshas no unique column "handle"— a message that flatly contradicts the schema the user wrote. Rejecting a non-emptyuniquewith no[1]would cost two lines and turn a confusing runtime raise into a load-time one.Both are cheap and this is a freeze.
NIT —
state.indexesis now a hard requirement offind,store,removeanddrop_tablestdb/cache.lua:96,:123,:263,:321indexstate.indexesunguarded, so a state not built bycache.newfails withattempt to index field 'indexes' (a nil value).connection.lua:19usescache.new, so nothing internal breaks and hand-constructing state is not a documented pattern. Recording it only because it is a silent shape requirement added to a frozen module.§3 —
finddelegating togetfor the pk columnstdb/cache.lua:316-320. Both checks pass.Pk-less table: the guard is
def.pk ~= nil and column == def.pk, so on a pk-less table no delegation can fire and the unique column resolves through the index. Verified:find(s, "slug", "slug", "x")returns the row;find(s, "slug", "note", …)raises. Correct.Worth knowing (pre-existing, not this PR):
geton a pk-less table indexes the array, socache.get(s, "slug", 1)returns row 1 by list position.findis therefore the only correct keyed lookup for pk-less tables — which strengthens the case forfindexisting.Agreement on a pk column:
find(s, "a", "id", v)is identical toget(s, "a", v)across 12 key shapes —1,2,404,0,-1,"1","",true,false,1.0,1.5,nil. No disagreement. Lua 5.1's all-doubles numbers mean1.0and1are the same key, so no float/int split to worry about.find(pk)also works when the schema carries nouniquefield at all.Judgement: keep the delegation. It earns its keep concretely — the generator emits
uniqueincluding the pk, so generated accessors can loop one list and emitfind_by_<col>for every entry with no pk special case. That is the payoff, and it is why the pk is inuniqueat all. Against it: two frozen functions where one is a superset invites "which do I use?", and "getstays the direct spelling" is a style rule rather than a semantic one. That is a mild cost, andfind's docstring already spells the relationship out. One suggestion: put the reason in the README table too — without "so a generated accessor loop needs no pk special case", the line reads as a redundant API.§4 — raising for a non-unique column
stdb.cache: table "a" has no unique column "level". Names the table and the column. Good.nil, no raise. Verified.has no unique column "id").nilcolumn → raises. Both reasonable.Judgement: raising is right, and acceptable to freeze.
For a game, a typo'd column name returning nil is the worse outcome: "this lookup can never succeed" becomes indistinguishable from "this player isn't loaded yet", and it manifests as a player that silently never spawns, with no diagnostic anywhere. Raising is the ordinary programmer-error/data-absence split, and it is the same call
schema.newalready makes for a bad pk.On regeneration: yes, a column losing its unique constraint server-side turns previously-working
findcalls into runtime raises, on a path that runs on every connect. That is a real hazard — but it argues for raising, not against. Silently returning nil there would convert "this constraint no longer exists" into "this player does not exist", which in a game is a much harder failure to diagnose and a much worse one to ship. A crash on the connect path is found in the first playtest; a player that intermittently fails to load is found by users.Worth a follow-up, not a change here: if the generator emits named
find_by_<col>accessors fromunique, losing the constraint deletes the accessor and the break surfaces asattempt to call nilat the callsite — earlier and more legible than a raise from inside the cache.schema.newitself cannot pre-check this, since it cannot know which columns callers willfindon.§5 — the
schema.luadiffThe +34/-34 in the PR body is a misread.
git diff --numstatgives +24/-10; the34in--statis the churn total (24+10), not two symmetric numbers.It is a genuine change, no incidental reformatting. The 10 removed lines are exactly the inlined pk search (
local found = false…if not found then), nowhas_column. The 24 added are the helper (:16-24), theuniquedoc block (:16-21of the header comment), the one-line pk check, and theuniqueloop with its comment. The only line that moved without changing meaning is the pkerror(...), re-indented because collapsingif def.pk ~= nil thenremoved a nesting level — a consequence of the change, not a reformat.Breaking change: yes, and it is the right one.
uniquewas previously never read, so a hand-written schema withunique = { "typo" }loaded fine and now raisesstdb.schema: unique column "typo" is not a column of "t". Verified. Acceptable: such a schema was already wrong in intent, the failure is loud, it fires at init before any gameplay, the message names the offending column, and it correctly attributes to the caller's file. Same precedent as the existing pk check. The two validation holes in the nit above are the only loose ends.§6 — cost
Best-of-5, three independent runs, Lua 5.1, 20 000 rows, 4 columns. Same script both sides (
uniqueis simply ignored bymain, so the schema input is identical).InitialSubscription, 20k rows, no unique columnsInitialSubscription, 20k rows, two unique columnsMemory held by the cache after the same 20k-row load:
References, not copies — verified by identity:
state.indexes.uniqrow.handle["h7"] == cache.get(state, "uniqrow", 7)istrue. The +2561 KB is 40 000 hash entries at ≈64 B, which is a Lua 5.1Nodeplus power-of-two sizing slack (32768 slots for 20000 keys), not row duplication.Framing for #30, no fix demanded: the "no unique columns" claim in the PR body holds — one nil check at
stdb/cache.lua:96and:123, unmeasurable. For an indexed table, a 100-row transaction costs about 0.1 ms extra, ~0.6% of a 60 fps frame. Negligible. The one number worth writing down somewhere is the memory: +52% on the cached footprint of a table with two non-pk unique columns. The PR body discusses "double the memory" only in the context of the pk mirroring it correctly avoided; it does not mention that each non-pk unique column costs a full hash entry per row. Not a defect, just an undocumented cost for anyone caching a large table.The proposed guard adds nothing measurable:
fixedvsbranchacross two runs stays within ±3% on every case, memory identical.§7 — compound uniqueness
Clean, and correctly documented. With a table carrying
unique = { "a" }(pk only) plusindexes = { { name = "b_c", columns = { "b", "c" }, unique = true } }:state.indexes.pairisnil— no index built, no wrong single-column index.build_indexes(stdb/cache.lua:36-49) reads onlydef.uniqueand never touchesdef.indexes; verified no entry leaks in.find(s, "pair", "b", "x")raisestable "pair" has no unique column "b"— accurate, and the right answer.stdb/schema.lua:17-21("Compound uniqueness is not here: one client-side lookup needs one column") andREADME.md:528("Compound uniqueness lives only onindexes[].unique").No claim of support anywhere, no silent wrong index. Nothing to change. (
schema.newdoes not validateindexes[].columnsnames at all, but nothing reads them yet — out of scope.)§8 — does it close #28?
No. Four of five acceptance bullets are met; the third is met only for the shape the test helper can express.
test_cache.lua"hit: unique column finds its row"updatesentry; fails for two rows swapping, for bare-insert upserts of two rows, and for any delete/insert split acrossupdatesentries. This is the bullet the issue calls "the case a naive version gets wrong"drop_table/drop_allstore/remove, so there is no second diff at allOn how much value lands: the mechanism is correct and the string/integer unique-column case is fully working and genuinely useful. The motivating example both the issue and the README lead with —
player.identity— delivers nothing today (§ the #35 should-fix), and no amount of index correctness changes that, because the defect is the row-field type.On honesty: the PR body is honest, prominently, under "Reviewer attention", and
stdb/cache.lua:311-315carries the caveat where a maintainer will read it. That is the right instinct. The gap is that the README carries the promise without the caveat, and the README is the user-facing document. One sentence.To merge
reindexidentity guard, plus a test with twoupdatesentries and a two-row swap. This is the blocker; everything else is small.find, so the error delivers the callsite its own docstring promises.README.mdcache.findblurb noting theidentitycase is blocked on #35.unique; correct the PR body's+34/-34to+24/-10(it is a genuine change).The author asked two questions.
schema.luais a real change,+24/-10, no reformatting. And #35 does not merely limit the motivating win — it eliminates it, so the README should say so where users will see it.caaa9dab7a319f835834