feat(connection): spread the reconnect backoff per client, deterministically (#19) #62
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!62
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/19-reconnect-jitter"
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 #19. Phase 2 of the v1.1.0 milestone (#59). Additive except for
backoff_delay, which gains a trailing optional fourth parameter, so noexisting callsite or test breaks.
The decision: deterministic, not random
The issue proposes injecting
math.random. This deliberately does not, for tworeasons that are now in the doc comment:
math.randomseed— it clobbers the consuminggame's own RNG sequence, breaking any deterministic simulation the game relies
on.
reproduces the exact stampede being fixed. The issue names this as "the part
that actually bites".
So the spread is derived from the identity instead. Every client already holds a
unique 64-hex-char identity by the time it can be in a reconnect herd, so a
per-client slot needs no RNG, no global state, and no seeding — and exact-value
tests remain possible.
spread_forreturnsnilbefore an identity exists: the first connect is acold start, not a herd. A
nilspread is an exact no-op inbackoff_delay.The spread applies after the cap, so a capped delay is spread too —
otherwise every client in a long outage converges on exactly 30s, which is the
herd again at the worst possible moment.
Verified, not assumed
Lua 5.1 exactness. Largest intermediate is
4294967296 * 31 + 255=133143986431(~1.33e11) against2^53= 9.0072e15. A 10k-iteration loopdriving the fold from the worst case
h = 4294967295kept every intermediateintegral (
h == math.floor(h)throughout).The spread re-spreads per attempt — checked before asserting it. For one
identity, attempts 1..10 give 0.7405, 0.537, 0.6855, 0.834, 0.6305, 0.779,
0.9275, 0.724, 0.8725, 0.521. A herd redistributes each round rather than
marching in lockstep at a fixed offset.
No nil-attempt guard was added, because none is reachable:
attemptis alocal initialised to 0, incremented immediately before the call, and reset on
connect, so it is always a number ≥ 1 at the callsite. A guard would be dead
code.
One callsite, confirmed by grep:
stdb/init.lua:238. Everything elsematching
backoff_delayis the definition, the tests, or prose.The seven existing assertions are untouched
The
test_connection.luadiff is@@ -244,6 +244,28 @@with zero deletionlines — purely additive. That is the whole reason
spreadis a trailingoptional parameter.
Checks
Conformance is the meaningful one here, since it actually reconnects: it logged
reconnecting attempt 1 in 0.744swhere before this change it was a flat1s—the spread is live on the wire, not just in unit tests.
End-to-end check, two identities at the same attempt:
Both distinct and in
[1.0, 2.0)for attempt 3 with base 1. Also asserteddirectly:
backoff_delay(99, 1, 30, 0.5) == 15(a capped delay is spread),backoff_delay(6, 1, 30) == 30(nil is a no-op),spread_for(nil, 1) == nil.README
Three touch points, one of them not in the original plan:
of the slot, keyed on identity, with none on first connect;
Seconds; doubling, no jitter—no jitterwasnow false and is corrected;
stdb.connectionAPI table gains aspread_forrow beside the comparablepure helper
backoff_delay, andbackoff_delay's own row gains the fourthparameter. That section's prose said "exactly the six functions below" and "the
worked example of all six"; both numerals became wrong on adding a row and are
corrected to seven. Not in the assignment, but required for the claim to stay
true.
The
ponytail: no jitterdeferral comment abovebackoff_delayis deleted —this work discharges it.
Process note
The first round of edits landed in the main checkout on
maininstead of theworktree, because the edit tool resolves a section path against cwd. Caught by a
sibling agent before any commit, recovered with
git stash pushin the maincheckout and
git stash popin the worktree, no conflict and nothing lost. Everynumber reported above was re-captured from inside the worktree afterwards, since
the pre-recovery runs had been exercising an unmodified tree and proved nothing.
git status --porcelainis empty in the main checkout.Review measured the distribution instead of trusting the two-sample example in the PR body, and both of my stronger claims turned out false. "Every client lands on a different slot" is wrong above a few dozen clients. The value is 0.5 + (h % 1000)/2000, so there are exactly 1000 possible outputs - confirmed by sampling 2,000,000 identities and seeing exactly 1000 distinct values. At 1000 clients, 365 collide (36.5%), which is precisely the birthday bound for 1000 slots, and at 100,000 clients the busiest slot holds 128. The behaviour is fine - a shared slot is two clients 15ms apart at the 30s cap, not simultaneous - but the sentence overreached, and it overreached about exactly the whole-player-base case the prose invokes. Now states the slot count and what sharing one costs. "Re-spreads each attempt" implied an independent re-draw. Every client's value does change each attempt (0 of 400 unchanged between attempts 1 and 2), but the fold is affine in the attempt seed, so the cohort is TRANSLATED by one constant rather than reshuffled: h(id,2)-h(id,1) mod 2^32 measured as the single value 2807132593 across all 400 real identities, and 57% of pairs sharing a slot at attempt 1 still share one at attempt 2 against 0.1% for an independent draw. The population stays uniform at every attempt, so the feature works, but a reader expecting re-randomisation would be wrong about the failure mode. Reworded to the accurate, weaker claim. Also makes the spread range exact. The attainable set is {0.5 + k/2000 : k in 0..999}, so the maximum is 99.95% and 100% is never produced; "50-100%" now reads 50-99.95% in both places, matching the [0.5, 1.0) already in the API table. Not changed, deliberately: spread_for(id, nil) raises a raw arithmetic error. main's backoff_delay(nil, 1, 30) raises identically, the SDK's own caller can never pass nil, and matching the file's existing rigor beats adding a guard for an unreachable case. On the record rather than silently accepted.Review actioned. Verdict: READY TO MERGE — matching yours. Two README claims
fixed, one filed as follow-up, one recorded and deliberately not changed.
Thank you for measuring the distribution instead of accepting the two-sample
example in my PR body. Both of my stronger claims were false, and the PR body
repeated one of them.
D1 (every client lands on a different slot) — fixed
1000 possible outputs, 365 of 1000 clients colliding, busiest slot at 100k
holding 128. My sentence overreached about exactly the whole-player-base case
the surrounding prose invokes. The row now states the slot count and what
sharing one actually costs (15ms apart at the cap, not simultaneous), so a
reader gets the real shape.
D2 (re-spreads each attempt) — fixed, and the measurement is the interesting part
h(id,2) - h(id,1) mod 2^32being the single constant 2807132593 across all 400real identities is a much sharper statement than "it changes each attempt", and
57% of colliding pairs staying collided against 0.1% for an independent draw is
the number that makes it matter. The fold is affine in the attempt seed, so the
cohort translates rather than reshuffles. Reworded to the accurate weaker claim.
My PR body's "rather than marching in lockstep at a fixed offset" was wrong in
the same way — a fixed offset is precisely what it is.
D4 (50–100% reads inclusive) — fixed
The attainable set is
{0.5 + k/2000 : k in 0..999}, so 100% is never produced.Both prose sites now read 50–99.95%, matching the
[0.5, 1.0)already in the APItable.
D5 (mass cold start has no spread) — filed as a follow-up
This is the most valuable thing in the review and I want it on the record
properly rather than buried in a merged PR, so it is now its own issue. You are
right that "server is down, the whole player base relaunches" is a real
synchronised herd that #19 does not cover, and right that the data needed is
already loaded three lines above the callsite —
connectreadspersisted.tokenbut never
persisted.identity, andconnection.newstartsidentity = nil.I did not fold it into this PR because it is a genuine judgement call, not an
oversight: the on-disk identity is not server-confirmed at that point, so feeding
it to
spread_formeans spreading on an unverified value. That deserves decidingon its own terms rather than as a rider.
D3 (raw error on a nil attempt) — recorded, not changed
Agreed with your own reasoning:
main'sbackoff_delay(nil, 1, 30)raisesidentically, the SDK's caller can never pass nil, and matching the file's
existing rigor beats a guard for an unreachable case. Noted in the commit so the
choice is on the record rather than silently accepted. Same for the
1/0and0/0observation.Independently re-verified after the edits
lua dev/test/runner.lua6 passed 0 failed; the diff since your review isREADME-only (+3/-3).
Your end-to-end closure —
reconnecting attempt 1 in 0.9555sand thenspread_forreproducing 0.9555 exactly from the run's own logged identity — is abetter proof than anything in my original PR body.