subscriptions, reducers: return handles instead of bare ids #38
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!38
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/29-handles"
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 #29.
subscribe_queryreturned a barequery_idandcall_reducera barerequest_id, with one(err, ...)callback each. Both now return a handle; both old forms keep working.Why each matters: a subscription that errored was silently dropped and the holder had no way to discover it except by having caught the callback at the time. And
OutOfEnergyis the account's problem while a failed reducer is the module's —handlers.TransactionUpdatealready separated all three outcomes internally and then flattened them into one error string.Backwards compatibility
git diffon the test file removes exactly 5 lines, every one of them reading an id off the return value (rid.request_id,qid.query_id, …). No assertion label, expected value, or callback shape changed. The legacySubscribe/InitialSubscriptionpath, the flattened error strings, and every procedure assertion are byte-identical and still pass.dev/example/main/smoke.scriptwas not edited — it stores whateversubscribe_queryreturns and hands it back tounsubscribe, which now accepts either a handle or a raw id.Design decisions worth an opinion
on_applied, so callbacks are retained and fire on attach — andon_appliedis a list, firing again on every reconnect replay, not a one-shot.sent, sois_active()is false across the gap, becauseresubscribe_alldrops the cache.sentis set beforec.sendso a transport that raises mid-replay cannot leave a handle claiming to be active.UnsubscribeMultiAppliedlands. It is already out ofquery_setsand therefore unreplayable, and a secondunsubscribein that window must not send a second frame.M.unsubscribe, not only on the handle method, so both entry points behave identically. An unknown raw id still goes out on the wire rather than faulting.TransactionUpdate'sp.callback(...)is now nil-guarded — a callback-lesscall_reduceris normal with handles and would otherwise fault.Verification
5 passed, 0 failed; stylua clean. +240 assertions in a new Handles section.Falsifiability by mutation testing — 12 behaviours mutated in place, suite re-run, then restored:
expected applied, got senton_appliedattached after the fact still fires: expected true, got falsedropping an already-ended set is a no-op: expected nil, got 3and the handle for it ends too: expected true, got falseconnection.lua:279 attempt to index local 'sub' (a nil value)OutOfEnergynot routed separatelyexpected out_of_energy, got errorSubscriptionErrornot propagatedexpected no such table: \ghost`, got nil`replay puts the same handle back in flight: expected sent, got appliedexpected ended, got appliedWorth noting honestly: the first pass left two mutants surviving — the raw-id wire fallback and the unsubscribe reply re-applying the handle. Two tests were added specifically to kill them.
Deliberately not done
No timeouts. A pending call still lives until the connection drops. A timeout needs an owner in the update loop and a defined interaction with reconnect replay — a behaviour change, not a handle feature. The peer Godot SDK declares one and left it unimplemented, which is the warning. Recorded in the
set_senddocstring, the README, the code and the commit message.No coroutine/await layer — separate opinion, separate issue.
Two async operations handed back a number and took one `(err, ...)` callback, and both lost information the wire already carried. A `subscribe_query` caller held a `query_id` with no way to ask whether it was still live. That matters because of reconnect: query sets are replayed under their ORIGINAL id, so a held id stays valid across a drop - except for a set the server REJECTED, which is dropped and never replayed. The only notice of that was the callback at the time; a holder who missed it had no way to find out. `sub.state` now walks pending -> sent -> applied -> ended | error, so the drop is discoverable afterwards, and the replay path re-sends the SAME handle object rather than a fresh one, which is what keeps a live handle live and re-fires its on_applied. A `call_reducer` caller got one error string for three distinct wire outcomes. TransactionUpdate already separated Committed, Failed(message) and OutOfEnergy internally and then flattened the last two. An exhausted account is not a broken module - one is the player's billing problem, the other a bug to report - and a game may well surface them differently. on_ok / on_error / on_out_of_energy keep them apart. Registering a callback for an outcome that already arrived fires it immediately. The alternative is the classic promise footgun: nothing orders registration against the socket, so an applied frame can and does land before the caller reaches its next line, and a "too late" callback would silently never run. Additive, because 1.0 freezes both forms: - the `(err, ...)` callback argument is untouched, flattened error string included. The only test lines that changed read the id off the return value (`rid.request_id`, `qid.query_id`); every legacy assertion is byte-identical and still passes. - `unsubscribe` takes a handle OR a raw numeric query_id, so dev/example/main/smoke.script needed no edit: it stores whatever subscribe_query returned and passes it straight back. - dropping an already-ended set is a no-op returning nil rather than an error. A caller cannot be expected to race its own unsubscribe against a SubscriptionError. Deliberately out of scope, both from the issue: no timeouts, and no coroutine/await layer. A pending call still lives until the connection drops. A timeout needs a real owner in the update loop and a defined interaction with reconnect replay, which is a behaviour change rather than a handle feature - the peer Godot SDK declares `reducer_call_timeout` and leaves it unimplemented, which is a fair warning. `await` is a separate opinion and a separate issue.Review — automated reviewer, posted for the record
All verification complete. Here is the review.
Review: PR #38 — handles for subscriptions and reducer calls
Verdict: the design is right and the compatibility claims all hold, but there is one blocker — a reachable sequence that leaves a handle permanently claiming
is_active() == truefor a subscription the server has dropped. That is precisely the "worse than not having the handle" failure mode. It is a two-line fix and I have verified it.Everything below was checked by running code. Line refs are to
feat/29-handles@4f8fe6c.What I verified as correct
Conformance passes, and phase 3 genuinely travels the handle path.
nix develop .#conformancepassed twice on the unmodified branch. Because "it passed" is not proof the handle branch ran, I instrumentedsmoke.scriptphase 3 with type and state assertions and re-ran the full harness in a real Defold engine against the live server:The run asserts
type(...) == "table", the presence ofis_active/unsubscribe,is_active()true after the server's realSubscribeMultiApplied, andis_ended()true afterunsubscribe. Any of those failing would have failed the run. The author's claim thatsmoke.scriptneeded no edit is correct, and phase 3 exercisedM.unsubscribe'stype(query_id) == "table"branch, not the raw-id fallback.Legacy compatibility is real, exactly as claimed. The diff removes exactly 5 lines, all id reads. Checking out
origin/main'stest_connection.luaagainst the branch'sconnection.luafails only attest_connection.lua:95— one of those 5. Applying just the 5 id reads and nothing else:5 passed, 0 failed. No assertion label, expected value, or callback shape changed. The flattened error string is unchanged for all three outcomes —connection.lua:517is untouched context in the diff, andCommitted → (nil, msg),Failed → (message, msg),OutOfEnergy → ("out of energy", msg)all still hold.All three reconnect claims hold (
connection.lua:216,363-376): an applied handle goes back tosent,on_appliedis a list that fires again per replay, and replay reuses the same handle object. Markingsentbeforec.sendis the right call and the raising-transport test earns its keep.No double-fire, and late-attach arguments are identical to the eager path. I probed the classic bug directly. Re-entrant registration from inside a firing callback fires exactly once (
firesnapshots#listin Lua 5.1's numericfor, and the late-attach branch covers the new entry). Argument parity, eager vs. late, foron_ok/on_error/on_out_of_energy/on_erroron a subscription: identical arity and identical values in all four cases.register'soutcome == "error"special case is correct.error → reconnectcorrectly does not replay (resubscribe_allreturns 0, state stayserror) — though nothing in the suite asserts it.ended → erroris benign: both areis_ended(),is_active()stays false. So thesubscription_failpath needs no guard; onlysubscription_applycan un-end a handle.Blocker 1 — an unsubscribe issued before the subscribe reply permanently corrupts the handle
stdb/connection.lua:212parks a back-referencep.subinc.pending.M.unsubscribe(274-296) clearsc.query_sets[id]and setssub.state = "ended"— but leaves that in-flight pending entry alone. When the subscribe reply lands,subscription_applied(465-467) unconditionally callssubscription_apply(p.sub, msg), which setsstate = "applied"(135-139).The real 2.8.2 server answers both frames. Captured live, subscribe and unsubscribe sent in one tick:
Replaying those real captured frames through the unmodified SDK, driven by the most ordinary code a game can write:
The handle reports
applied/is_active() == true/is_ended() == falseforever for a set that does not exist. It is unrecoverable: the set is out ofquery_sets, so no future frame or reconnect can correct it.on_appliedalso fires after teardown, so a consumer that repopulates UI from the applied message repopulates it after being told to stop.There is a second independent route:
unsubscribeduring the reconnect gap, while the replayed set issent. Same corrupt terminal state.Pre-handles this sequence was merely odd (the callback fired for rows that were then deleted). The permanent lying state is new, and
is_active()is exactly the question the handle exists to answer.Fix — one guard at the single shared mutation point, rather than chasing
c.pendingfromM.unsubscribe:Verified: both routes now end at
state=ended is_active=false is_ended=true, and the author's suite is still5 passed, 0 failedwith the test file untouched. Worth adding as a test — it is the one reachable transition the new suite misses, andended → appliedis the transition that matters most.Should-fix 2 —
pendingleak and a swallowed callback whenSubscriptionErroranswers anUnsubscribeMulticonnection.lua:542clearsc.pending[request_id]only whenp.kind == "subscribe". A live server really does answer an unsubscribe withSubscriptionError:It also does this for a double unsubscribe of a real set (
request_id 4, query_id 1→Subscription not found), so this is not an exotic path. Result:c.pending[request_id]is never cleared — a leak for the life of the connection — and the unsubscribe callback never fires despite the docstring promising it.This guard predates the PR, but the PR adds
orphan_conn:unsubscribe(77)as a blessed test case, and 1.0 freezes it. Measured:pending after the error reply: 1,unsubscribe callback fired: false,kind still parked: unsubscribe.Fix: drop the
kinddiscriminator —if p then. Verified: pending goes to 0, the callback fires with the server's error, suite still green.Should-fix 3 — a
Callhandle is stranded atpendingforever when the socket diesM.set_send(385-389) wipesc.pending, orphaning every in-flightCall. Measured after a reconnect with two calls in flight:The docstring frames this as "exactly the fate its callback already had". That is true but it understates the regression: pre-handles the loss was invisible, whereas now the handle advertises
outcome == "pending"— a status field that will never change and cannot be distinguished from still-in-flight. Worse,on_ok/on_errorattached afterwards silently never run, which is the precise failure the late-attach rule (connection.lua:62-65) exists to prevent.This needs no timeout machinery. The socket's death is a definite terminal event that
set_sendalready knows about and currently discards:Verified:
outcomebecomeserror, registrations fire, and the legacy(err, update)callback is deliberately not invoked — it never fired here before, and that shape is frozen. Suite still5 passed, 0 failed;stylua --checkclean. This is more valuable than a timeout and should land before the freeze.Should-fix 4 — a callback passed to a no-op
unsubscribeis silently droppedM.unsubscribe:276-278returnsnilearly on an ended set without touchingcallback. Measured on a rejected set:returned nil, callback never fires. The no-op-instead-of-error decision is right, but silently discarding the callback contradicts the PR's own stated principle. Either fire it immediately with the terminal reason (consistent with everyon_*) or document that a no-op never calls back.Should-fix 5 — no way to detach
on_applied, and repeat-firing makes that matterRepeat-firing is the right contract to freeze:
resubscribe_allempties the cache, so a consumer holding derived state must be told again; one-shot would silently break every consumer after its first reconnect. Keep it.But there is no
off, noonce, andon_appliedreturnsself— so there is not even a token to hold, and returningselfforecloses returning a canceller later. The natural "(re)bind whenever the set is live" pattern inside an event handler degrades quietly:Unbounded list growth and N invocations per logical event, with no escape hatch and nothing in the API to hint at one. Add
sub:off_applied(fn)(or haveon_appliedreturn a detacher) before freezing. At minimum, document that registration is permanent and belongs outside event handlers.Should-fix 6 — the two vocabularies diverge more than the domain justifies
on_appliedvson_okis defensible: a subscription is long-lived and repeating, a call is one-shot, so different verbs carry real information. Two things are not defensible and both get frozen:sub.statevscall.outcome— one concept, two names, for no domain reason. Converge onstate. They already share thependinganderrorvalues.Callhas no predicates.Subscriptiongetsis_active()/is_ended(); aCallholder must string-comparecall.outcome == "pending"— exactly what predicates exist to avoid.call:is_pending()is one line and makes the pair coherent.Nits
sub.messageretains the entire applied payload (connection.lua:137), and the connection holds the handle viaquery_sets, so it is retained whether or not the consumer keeps the handle. Measured: ~2.0 MB retained for a 4000-row set, of which ~1.9 MB issub.message. Inherent to the late-attach contract and not a growing leak (re-stored, not accumulated, per replay), but Defold ships to mobile and this deserves a line in the README.sub.messageis overloaded:subscription_fail(144) overwrites it with theSubscriptionErrorframe, so the field means two different things and a reader cannot tell which. Verified. A separatesub.applied_messagewould be clearer.:437— "Each returns arequest_idunless noted" is now false for two of the seven methods. And yes: document the handles as classes in the API tables, not only in prose.sub.query_id,sub.state,call.request_id,call.outcomeare frozen public fields and belong in a table alongside the methods.TransactionUpdateLightnever clearsc.pending(529-532), so a light update for our own call leaks the entry and pinsoutcome = "pending"forever. Pre-existing, andsmoke.scriptalready asserts the server does not do this to the caller — but handles turn an invisible leak into a visible stuck state. Cheap to close alongside fix 3.resubscribe_allcalled twice without a socket swap stacks pending subscribe entries and fireson_appliedonce per stacked entry (measured: 3 fires for one logical reconnect).init.luaalways callsset_sendfirst, so this is only reachable by calling the publicresubscribe_alldirectly. Low risk, worth knowing.c.sendraises insidesubscribe_query, the exception propagates before the handle is returned, yet the set is already registered inquery_setsand will be replayed. The caller can never unsubscribe what it cannot reference. Pre-existing in shape, marginally worse now.On the deliberate omission of timeouts
Correct call, and correctly argued: a timeout needs an owner in the update loop and a defined interaction with replay. Shipping half a timeout is worse. But the current state is not "no timeout" — it is "a pending call silently becomes unobservable garbage", which fix 3 resolves without any timeout at all. Land fix 3 and the omission becomes genuinely safe to freeze, because every terminal outcome the SDK can know about is then reported. I would not reserve a
timeoutargument in the handle API; the natural home is a connection-level option, and adding an argument later is backwards-compatible anyway.Does it close #29?
Yes on scope, with one acceptance criterion not met. All four required unit-test areas are present and genuine: state transitions including the error path, no-op unsubscribe on an ended set, each of the three reducer outcomes reaching its own callback, and the legacy form unchanged. Both "existing callback forms keep working byte for byte" and "no timeout, no await" constraints are honoured, and I verified the first independently. The shape matches the issue's sketch exactly.
Not met: the issue's own framing is that the handle makes subscription liveness observable. Blocker 1 means
is_active()can be observably wrong, permanently, for an ordinary two-line call sequence. That is the acceptance criterion in spirit, and it is the one thing that must not ship into a frozen API.Recommendation: land fixes 1–3 (about 15 lines total, all verified — author's suite untouched and green,
styluaclean, conformance passing at engine level with the patch applied), settle thestate/outcomenaming andCallpredicates while they are still free to change, then merge. Fixes 4–6 and the README items are cheap and worth doing in the same pass since 1.0 freezes them.I did not edit, push, comment on, or merge anything; all work was done in a throwaway clone at
/tmp/review-38.4f8fe6cbb4928fa1d59f