feat: spread a large apply across engine frames behind frame_budget #76
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!76
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/67-frame-budget"
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 #67.
connect({ frame_budget = 0.008 })spends at most that long per engine frame decoding rows and resumes on the next, so a large first subscription costs several ordinary frames instead of one hitch.Opt-in, nil by default. Slicing is visible in timing — callbacks that fired inside the receive frame now land one or more frames later — and the 1.0 surface is frozen, so a timing-visible change cannot be the default. Unset, the code path is what it was: the
EVENT_MESSAGEbranch still callsconn:handle_frameinline andcache.slice_hookstays nil.Shape
stdb/cache.luagains one module-level hook called every 256 decoded rows, insidedecode_rows' two loops and nowhere else. That is the only place in an apply where nothing has been written yet — the cache decodes a whole batch before touching the store so a malformed row cannot half-apply — so it is the only place a yield cannot leave a half-populated table. Store mutation and event emission stay atomic, which is what makes "callbacks only ever see a complete batch" fall out for free rather than needing bookkeeping.stdb/init.luagains one FIFO queue of raw frame strings and at most one live coroutine, pumped bytimer.delay(0, true, ...)(once per engine frame). One coroutine at a time is what keeps ordering intact: aTransactionUpdatearriving mid-InitialSubscriptionis appended behind it and applied strictly after. The deadline is per pump invocation and shared across every frame drained in it, so N queued frames cannot spend N budgets in one engine frame. A dropped socket clears queue and coroutine — the cache is cleared and every subscription replayed on reconnect, so finishing a dead socket's half-decoded batch is work against state about to be discarded, and it is safe to abandon precisely because nothing was written.close()cancels the pump and clears the hook.Two bugs the measurement caught
Neither was visible from reading the code, and the first made the feature almost useless:
1. The hook closure was being cached across pumps.
decode_rowshoistedlocal hook = M.slice_hookat entry. Each pump installs a new closure carrying that frame's deadline, so a loop holding the first one kept asking a deadline that had expired frames ago — yielding after every single 256-row batch. Measured doing exactly that: 25000 rows took 97 frames at 1.5ms each instead of 4 at 8.5ms. Fixed by re-readingM.slice_hookat each slice point; the cost is one table lookup per 256 rows.2. PHASE6 hung when the decode fitted in one frame. The completion gate required
slice_max, which is nil when the first frame absorbs the whole decode (that frame is billed separately). Gated onstore_msinstead, which always lands, so the "nothing was sliced" verdict reports it rather than the phase waiting 900s for a slice that was never coming.What it does and does not bound
Measured at a 8ms budget, native, medians over six samples:
The row-decoding frames sit at the budget: 8.5ms against an unsliced 55.5ms at 25000 rows, and the whole apply costs about the same wall clock (53.2ms vs 55.5ms), so this is redistribution rather than overhead.
But two parts of an apply cannot be sliced, and after this they are what is left. Both are O(rows) and both run whole:
decode_server's singlejson.decodeof the entire frame text, which happens before any row loop exists to yield from — 12.8–23.3ms at 25000 rows, i.e. now the worst frame in the apply.So the honest headline is the worst frame drops from ~55ms to ~20ms at 25000 rows, not that it drops to the budget. The issue's sketched acceptance ("no frame over budget + idle + 5ms") is not achievable and I did not make the harness pretend otherwise: PHASE6 asserts the ceiling on the row-decoding frames and reports
first frame=andstore frame=beside them, with the reasoning in a comment at the assertion. Widening the slack until the numbers fit would have hidden exactly the thing worth knowing.On wasm the mechanism works and the arithmetic is worse for reasons that are the tier's: a browser frame has a ~17ms rAF floor, so an 8ms budget is already below the frame quantum, and socket delivery is inside the first frame. Measured at 10000 rows: decode frames land at 16–17ms (the floor), first frame ~75ms. Documented as such rather than quoted as a win.
Tests
New
dev/test/test_slicing.lua, driven throughconnectwith thewebsocket/timerglobals stubbed andopts.clockhand-advanced so "out of budget" is a decision the test makes rather than a race. Covers: budget nil applies synchronously inside the socket callback; a large subscription spans several pumps and emits nothing until it completes; a mid-sliceTransactionUpdateapplies strictly after (asserted on the cache count at the moment its callback fires, and on it being the last event); disconnect mid-slice abandons the batch and leaves the store empty; an error surfaces out of the pump without wedging the queue behind it;close()cancels the pump and clears the hook; andframe_budgetof 0, negative, non-numeric, or with no clock available all raise atconnect.Mutation-tested, since "8 passed" means nothing on its own — each of these fails a distinct assertion:
an apply larger than one budget spans more than one pumpa budgeted apply does nothing inside the socket callbackand no later pump resurrects itVerification
8 passed, 0 failed;stylua --checkclean;shellcheckexit 0. Native conformance passes unsliced (all six phases) and sliced at 10000 and 25000 rows; HTML5 conformance passes both ways.html5-conformance.mjsneededSTDB_BULK_BUDGETadded to the query-string forwarding list — without it the budget silently never reached the browser and the wasm run measured the unsliced path while claiming otherwise.README gains a
Spreading a large apply across framessubsection with the table above and both unsliceable pieces named, plusframe_budgetin theconnectoptions table andcache.slice_hookin thestdb.cachereference.All seven findings actioned. The first one was correct and it was the important one — the invariant the whole design rests on was false, and the tests passed because every fixture happened to be single-table.
The tear was real;
apply_database_updateis now two passesapply_database_updateinterleaved decode and store perupdatesentry, so a yield in the second table's decode suspended with the first table's rows already written and unannounced. Reproduced before fixing, exactly as reported.Restructured as suggested: pass one walks
tables/updatesdoing onlydecode_rowsandpair_by_key(both pure), accumulating{ name, def, deleted, inserted, paired }; pass two walks that list doing therelease/retainloops and event accumulation, with no path back intodecode_rows. The store now moves from "this frame absent" to "this frame present" in one uninterruptible step, and the malformed-row guarantee strengthens from per-updates-entry to per-frame at the cost of one intermediate list.Two tests added, and both fail against the interleaved version (verified by
git checkout HEAD -- stdb/cache.lua, which reproduces your two-frame observation exactly):a two-table frame is never observable with one table populated and the other empty— polls both counts every pump across a 600+600 row two-table subscription.an abandoned two-table frame strands no rows— drops the socket mid-decode of the same frame and asserts both tables are empty, which is the permanent-tear half.The
connectfixture gained a second table (other) so the single-table blind spot cannot come back.Four surviving mutations now caught
Each fails a distinct assertion; all four ran against the current branch:
table.remove(queue, 1)→table.remove(queue)the frame that arrived first is applied first: expected insert:1, got insert:101breakone pump applies one small frame, not the whole queue: expected 2, got 10M.cache.slice_hook = nilat the end ofpumpand clears the hook on the way out: expected nil, got functionlocal hoisted = M.slice_hookindecode_rowsand take a bounded number of pumps, not one per slice pointOn the FIFO one you were right about why the old test could not see it: it delivers its second frame when the queue holds exactly one element, so the two
table.removeforms are the same call. What it actually pinned was "an in-progress coroutine is not preempted". Both tests are kept — they pin different things.The deadline test uses frames below
SLICE_ROWS, per your note that the gap is widest there because the hook never fires at all and the end-of-loop check is the only thing that can end the drain.The pump-bound test replaces nothing; it sits beside
pumps > 1, which as you say gets more true as the hoisted-hook bug gets worse.The harness could report PASS having sliced nothing
if self.slice_frames and self.slice_frames < 2→if (self.slice_frames or 0) < 2. nil is exactly what a budget larger than the whole decode produces, so the nil check skipped the verdict in the one case it existed for. Confirmed fixed:Previously that run printed
PASS PHASE6.README and comments
Both bullets are now true as written rather than narrowed, since the two-pass restructure landed — the first says explicitly that a frame spanning several tables is all-or-nothing too, and points at
apply_database_updatefor why. The abandonment bullet now says it holds because of the first bullet rather than independently."byte-identical to before" is gone, in the README and in
cache.lua's module comment. Both now say what holds: no hook is called and no behaviour changes when the budget is unset, at a per-row modulo cost that does not show above run-to-run variance — which matches your measurement (branch medians at or belowmain's, within a 14–18% spread).Verification after the changes
8 passed, 0 failed;stylua --checkclean. Native conformance passes unsliced (all six phases, 25000 rows atframe med=54.1/55.0ms) and sliced at 10000 and 25000:HTML5 conformance passes both ways; sliced at 10000 rows gives worst decode frame 16–17ms (the rAF floor, as documented) over 5 frames with the cache complete.
Thank you for attacking the invariant rather than the code — that finding was worth the whole review, and it would have shipped.