Time-slice the frame that applies a first subscription #67
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#67
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?
Implementation follow-up to #30, whose measurement (#66) established that this is needed. Filed separately so the design is reviewed against the data rather than bundled with it.
What the measurement says
Applying a first subscription is linear in rows, ~2.7us/row native, all in one frame:
6,400 rows is a chat backlog, a leaderboard, or an inventory across a player base. This is a cliff real games hit.
Note what will NOT fix it: json.decode is only 40-49% of the frame natively and 20-25% in wasm, so replacing it with a native BSATN decoder has an Amdahl ceiling of 1.8x / 1.3x - it moves the native 100ms threshold from ~32k to ~58k rows and does not remove the hitch. Recorded on #9.
The shape #30 specifies
Acceptance
Closed by #76.
frame_budget(seconds, opt-in, nil by default) spreads the row decoding of an apply across engine frames. Measured onmain@52dd72d, 25000 rows, 8ms budget, medians over six samples:The worst row-decoding frame goes from ~55ms to 8.5ms, over 4 frames, with the same wall-clock total — redistribution, not overhead.
What this does not do, stated plainly
Two parts of an apply are O(rows) and cannot yield, and after this they are the worst frames:
decode_server's singlejson.decodeof the whole frame text, before any row loop exists to yield from — 12.8–23.3ms at 25000 rows.So the honest headline is ~55ms → ~20ms at 25000 rows, not "down to the budget". The acceptance sketched in this issue ("no frame over budget + idle + 5ms") is not achievable without a streaming parser; PHASE6 asserts the ceiling on the row-decoding frames and reports
first frame=andstore frame=beside them rather than widening slack until the numbers fit.On wasm the mechanism works but the tier dominates: a ~17ms rAF floor means an 8ms budget is below the frame quantum, and socket delivery sits in the first frame. At 10000 rows: decode frames 17ms (the floor), first frame ~75ms.
Two bugs worth recording, both found by measuring rather than reading
The invariant this design rests on was false, and the tests passed anyway.
apply_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. A two-table subscription of 600 rows each sat atalpha=600, beta=0, events=0for two whole engine frames —iterandfindanswering with a frame half applied — and a socket drop there stranded 600 rows in the store for the whole outage. Every test fixture had been single-table, which is exactly the case where the invariant does hold.apply_database_updateis now two passes (decode everything, then store everything), which also strengthens the pre-existing malformed-row guarantee from per-updates-entry to per-frame. Verified equivalent to the old one-pass version by differential oracle: 1800 randomised frames over a four-table schema, comparing counts and the full store snapshot, zero divergences.The hook closure was cached across pumps.
decode_rowshoistedlocal hook = M.slice_hook, so after the first yield it kept calling the first pump's closure with a deadline that had expired frames ago — yielding at every 256-row slice point. 25000 rows took 97 frames at 1.5ms each instead of 4 at 8.5ms;97 ≈ 25000/256is the signature.M.slice_hookis now re-read per slice point.Also fixed:
close()from inside a per-table callback raisedcoroutine.status(nil)out of the timer callback; PHASE6 could report PASS having sliced nothing when the budget exceeded the whole decode; andhtml5-conformance.mjsnever forwardedSTDB_BULK_BUDGET, so wasm runs measured the unsliced path while claiming otherwise.Verification on
main@52dd72d8 passed, 0 failed;stylua --checkclean;shellcheckexit 0. Native conformance passes unsliced (all six phases) and sliced at 10000/25000; HTML5 passes both ways.dev/test/test_slicing.luais mutation-tested — FIFO→LIFO, dropping the per-invocation deadline, dropping the hook clearing, hoisting the hook, and reverting the two-pass split each fail a distinct named assertion.