Frame budget: parsing a large subscription must not hitch the game #30
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#30
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?
Everything the SDK does with an incoming frame happens on Defold's main thread inside one
update(). A largeInitialSubscriptionis decoded start to finish in a single frame:json.decodeper row, then a cache write per row, with no yield point anywhere.Ten thousand rows on a fresh connect is one very long frame — a visible hitch at precisely the worst moment, when the player has just joined and the world is populating.
What is already threaded, and what is not
An earlier draft of this issue said
extension-websocketdelivers frames on the main thread. That is only half right, and the other half narrows the problem usefully. Fromwebsocket/src/websocket.cpp(4.2.3, the pinned version;masterreads the same):CreateConnectionWslayspawns one OS thread per connection viadmThread::New(ConnectionWorker, 0x80000, conn, "WSConnect").WSL_Poll(conn->m_Ctx)loop on a 10ms sleep. Socket reads, unmasking and frame reassembly all happen off the main thread.PushMessageappends each completed message toconn->m_MessagesunderDM_MUTEX_SCOPED_LOCK(conn->m_Mutex).OnUpdate— main thread — takes the same mutex and drains the queue, callingHandleCallback→dmScript::PCallonce per message.So the producer/consumer split already exists, and everything up to "here is one complete text frame" is already parallel. What is serialized on the main thread begins exactly at our Lua callback:
json.decode, row decode, cache write.HTML5 is a separate path —
emscripten_websocket_*with browser callbacks, no wslay, no worker thread.Consequence this issue previously missed:
OnUpdatedrains the entire queue in one pass. A burst arrives as N back-to-back Lua callbacks inside a single frame. A budget scoped to one callback therefore does not bound the frame; the budget must be a deadline shared across every callback within a frame.Why threading is not the fix
dmThreadis available to native extensions (defold/defold#5079, closing defold/defold#4835), and as above we already benefit from it. It cannot take any more of this work:lua_Stateand not reentrant.dmScript::SetupCallback/PCallare main-thread only. Anything that builds a Lua table runs on the main thread by definition — including the per-rowlua_createtable/lua_setfieldtraffic, which is plausibly the larger half of the cost at 10k rows. Unmeasured; see below.extension-websocketto intercept frames before they become Lua strings. That is a different project, not a change to this SDK.SharedArrayBuffer, which requires COOP/COEP headers from whoever serves the game. Defold's HTML5 build does not arrange this, and the web target is load-bearing here (patches/extension-websocket-html5-subprotocol.patch).For reference, the Godot SDK does move BSATN deserialization to a worker thread — packet queue in, parsed results out, applied on the main thread in
_process— and its own docs put the cost at "1-2 frames delay". Note that even it auto-disables on non-threaded builds and keeps a single-threaded path.Approach
Time-slicing, not parallelism. Spread the application of one large update across frames:
socket.gettimedeadline — after which the remainder defers to the next frame. The deadline is per frame, shared across every callbackOnUpdatedelivers in that frame, not per callback.InitialSubscriptionfollowed by aTransactionUpdatetouching the same rows, applied out of order, corrupts the cache. Later frames must queue behind an in-progress batch rather than interleave with it.Measure before choosing a number. The honest first step is finding where the cliff actually is:
json.decodeand cache/table construction — it decides whether a C++ decoder could ever have helped, and it is cheap to collect while we are already measuringAcceptance
A measurement first, posted here, before any implementation. If a 10k-row subscription applies in 3ms, this closes as unnecessary and says so — a perfectly good outcome, and far cheaper than a speculative scheduler.
If it is slow: a test proving a large batch spans multiple frames, that the resulting cache is identical to the single-frame result, and that a transaction arriving mid-batch is applied after it rather than interleaved.
Priority
Unknown until measured, which is the point. Worth resolving before 1.0 because the fix, if needed, changes when callbacks fire — exactly the kind of observable timing an API freeze should not have to break later.
Research: the native-extension alternatives, before anyone measures
No measurement yet — this is the "would a C/C++ rewrite have been the answer anyway?" question, resolved on paper so it does not get re-litigated after the numbers land. Three findings change the shape of this issue; two of them correct claims in the body above.
1.
json.decodeis already native C — the parse is not a Lua costDefold's
json.decodebinds tolua_cjson_decode; openresty/lua-cjson is vendored into the engine atengine/script/src/luacjson/and has been the decoder since 1.4.0 (jsmn before that — see Defold's 2022-11-18 blog post "Improving our json performance"). On HTML5 that is C compiled to wasm.So the parse cannot be sped up by moving it to a native extension: it is already there. Two consequences:
json, not the test one.dev/test/runner.lua:11-16injects vendored pure-Lua rxi/json so tests can run outside the engine. The~5.0 us/row vs ~5.6 us/rownote atstdb/protocol.lua:785does not record which codec it timed. If it was rxi, the in-engine number is likely far better and this issue may already be closed by measurement alone. Verify that first.quote_wide_ints/quote_big_ints(character-by-character scans over raw row text) anddecode_value's per-column coercion walk. Those, not the parse, are the native-extension candidates if one is ever justified.2. Defold HTML5 does have threads now — and it still does not help
Correcting the "Why the peer solution does not port" section above, which is right about Lua but was written against stale platform facts.
build_tools/waf_dynamo.py(L694-760):with_pthread = False; if build_util.get_target_platform() == 'wasm_pthread-web': with_pthread = True … else: DEFINES += ['DM_NO_THREAD_SUPPORT']. So:js-web,wasm-web(default)DM_NO_THREAD_SUPPORTwasm_pthread-web-pthreadcompile + link,-sPTHREAD_POOL_SIZE=1Added by defold/defold#10527 + #10575, shipped in 1.10.2 (2025-05-28). We build with 1.13.0, so it is available. Before this, threads on web genuinely did not exist (Mathias Westerdahl, Feb 2024: "we currently use Emscripten 2.x which doesn't have pthread support") — that is the statement the section above was echoing.
It changes nothing here, for three independent reasons:
lua_State, main-thread only. A worker cannot build a Lua table. The row materialization this issue is about does not move.wasm_pthread-webneeds cross-origin isolation (Cross-Origin-Opener-Policy: same-origin+Cross-Origin-Embedder-Policy: require-corp), and an iframe cannot isolate itself unless every ancestor does. That costs Poki, CrazyGames and GitHub Pages (no custom headers at all — open request since 2022, staff "no ETA"). itch.io supports it behind an opt-in checkbox, at the price of a domain change that discards players' existing localStorage saves.postMessageworks on every host with zero headers — but it could only take the parse, which is already native C per finding 1.Also worth recording, since it is the obvious next idea: calling the browser's
JSON.parseis a dead end. It is reachable (EM_JS, synchronous, strings viastringToNewUTF8), but wasm linear memory holds raw bytes only, so a parsed JS object can enter wasm just two ways — re-serialize withJSON.stringifyand parse a second time, or walk it per-field throughemscripten::valat embind's measured ~200ns per call. Both are worse than the in-wasm C parse we already get.3. Lazy row proxies do not work here
The deeper alternative — keep rows in C memory, hand Lua a userdata whose
__indexfetches a column on demand, so we never pay for columns the game does not read. Ruled out:cache.luareads key columns on every row regardless.store()writest[row[pk]] = row;reindex()readsrow[col]for every unique index, per insert.findandpair_by_keycompare those values. Laziness could only ever apply to non-key columns of never-touched rows.pairs(row)is a hard error on both our runtimes. Lua 5.1 has no__pairs(added in 5.2); LuaJIT honors it only under-DLUAJIT_ENABLE_LUA52COMPAT, which Defold does not set. Same forrawget(row, …),type(row) == "table",json.encode(row), andpprint(Defold'spprintonly recursesLUA_TTABLE). All legal today.on_insertcallback points into C memory we free on delete, on replace, and on every reconnect (cache.drop_all, which fires no delete callbacks). Making it safe means either a refcounted per-row snapshot — re-spending the allocation laziness saved — or a(table_id, key, generation)handle revalidated on every column read.-DLUAJIT_DISABLE_JIT; HTML5 is vanilla Lua 5.1.4 (no LuaJIT package for web inpackages/). SO 61890009 — benchmarked by Tarantool's Mons Anderson, explained by LuaJIT's Igor Munkin — with JIT on, proxy ≈ table; with JIT off, "tables are 20 times faster", because every read is a metamethod lookup plus a call.sdks/csharp/src/Table.cs(DecodeValueper row into POCOs,MultiDictionary<object, Row> Entries), TSbindings-typescript/src/sdk/table_cache.ts(Map<key, [RowType, version]>). Same for LuaSQL, luadbi, lua-resty-redis. Lazy userdata is proven only in adjacent niches — Tarantool'sbox.tuple, where the tuple is the engine's storage format, and net.box's opt-in per-responsereturn_raw. The designers who own the BSATN codec and could trivially keep bytes chose to materialize, twice.Unrelated correction while confirming the above: Defold does not disable the LuaJIT FFI (
LUAJIT_DISABLE_FFIappears nowhere;package.preload.ffi()works, sincescript_module.cppreplacespackage.loadersso plainrequire("ffi")misses). Irrelevant to this issue — HTML5 has no FFI at all — but worth not re-deriving.Revised plan
Unchanged in shape, sharper in detail:
jsonIf it is slow, the ranked responses are: (1) time-slice with a per-frame deadline (cheap, pure Lua, works everywhere); (2) port
quote_wide_ints+decode_valueto an optional C extension, Lua path retained as fallback (helps every platform, no threads, no header requirements); (3) threads — helps least, costs most.Filed separately, not blocking this
BSATN is an access question, not a speed one: the v2 message schema (
InitialConnection,QuerySetUpdate,ReducerResult) is reachable only overv2.bsatn.spacetimedb, and nothing in the tracker needs it today. Two facts found while researching, recorded here so they are cheap to find later:Optionis Some = tag 0, None = tag 1 — inverted from idiomatic Rust ordering. Confirmed incrates/sats/src/ser/impls.rsandde/impls.rs. Note the C++ module SDK's ownbsatn/reader.hread_optionalgets this backwards relative to both the Rust decoder and its ownwriter.h, so it is not usable as a reference implementation (it also aborts on error viastd::abort(), is statically typed rather than typespace-directed, and is BSL 1.1).v2.bsatncompresses by default — brotli above 1 KiB, gzip optional, behind a one-byte tag.?compression=noneon the WebSocket URL avoids the dependency entirely at the cost of bandwidth.If BSATN is ever built, it is a decoder, not a rewrite:
extension-websockethands binary frames to Lua as 8-bit-clean strings (HandleCallback→lua_pushlstring(L, conn->m_Buffer + msg_offset, msg_length)), so the seam is one purebytes -> Lua tablecall andconnection.lua/cache.lua/tables.luaare untouched. Try it in Lua first and measure before reaching for C++.