fix(relay): make the control lease jitter symmetric

Pullfrog: shortening-only jitter raised the mean rebind rate ~15%. Grant
55 min +/- 5 min instead; same cohort spread, unchanged steady-state load.
Auth expiry (5 min token) and the 75 s silence watchdog are enforced
separately, so a grant up to 60 min risks nothing.
This commit is contained in:
Jinwoo-H
2026-09-03 23:58:17 -04:00
parent e5ccd4a1a4
commit aa3a7ef147
2 changed files with 24 additions and 15 deletions
@@ -296,24 +296,28 @@ describe('control lease jitter', () => {
vi.useRealTimers()
})
it('grants a lease inside [55min - jitter, 55min] so cohorts drift apart', async () => {
it('grants a lease uniformly around 55min so cohorts drift apart at the same mean rate', async () => {
const now = 1_700_000_000_000
const helloAck = (socket: FakeSocket) =>
JSON.parse(
String(socket.send.mock.calls.find((call) => String(call[0]).includes('host-hello-ack'))![0])
) as { leaseExpiresAt: number }
const shortest = harness({ now: () => now, random: () => 0.999999 })
const shortest = harness({ now: () => now, random: () => 0 })
const shortestAck = helloAck(await activeHost(shortest))
const longest = harness({ now: () => now, random: () => 0 })
const centered = harness({ now: () => now, random: () => 0.5 })
const centeredAck = helloAck(await activeHost(centered))
const longest = harness({ now: () => now, random: () => 0.999999 })
const longestAck = helloAck(await activeHost(longest))
expect(longestAck.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS)
expect(shortestAck.leaseExpiresAt).toBeGreaterThan(now + CONTROL_LEASE_MS - CONTROL_LEASE_JITTER_MS)
expect(shortestAck.leaseExpiresAt).toBeLessThan(longestAck.leaseExpiresAt)
// Nine of ten hosts that connected together now differ by minutes, not zero.
expect(shortestAck.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS - CONTROL_LEASE_JITTER_MS)
expect(centeredAck.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS)
expect(longestAck.leaseExpiresAt).toBeLessThan(now + CONTROL_LEASE_MS + CONTROL_LEASE_JITTER_MS)
// The mean grant stays at 55 min, so steady-state rebind load is unchanged;
// hosts that connected together now differ by minutes, not zero.
expect(longestAck.leaseExpiresAt - shortestAck.leaseExpiresAt).toBeGreaterThan(9 * 60 * 1000)
shortest.registry.drain(0)
centered.registry.drain(0)
longest.registry.drain(0)
vi.advanceTimersByTime(0)
})
@@ -325,14 +329,14 @@ describe('control lease jitter', () => {
const first = await activeHost(h)
const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })!
const firstLease = session.leaseExpiresAt
roll = 0.5
roll = 0.75
const rebind = new FakeSocket()
await (
h.registry as unknown as {
activate: (...args: unknown[]) => Promise<void>
}
).activate(rebind as unknown as WebSocket, identity, session, 1, true, 1, '1.4.197')
expect(session.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS - CONTROL_LEASE_JITTER_MS / 2)
expect(session.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS + CONTROL_LEASE_JITTER_MS / 2)
expect(session.leaseExpiresAt).not.toBe(firstLease)
expect(first.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.PEER_DROPPED, 'control rebound')
h.registry.drain(0)
+11 -6
View File
@@ -127,12 +127,15 @@ function send(socket: WebSocket, type: string, message: object): void {
// stalled predecessor only accumulates doomed sockets.
const ACTIVATION_QUEUE_WAIT_MS = 30_000
// Why: hosts rebind 1-2 min before this expires, so every host that (re)connected
// in the same minute (a cell recreate dumps hundreds at once) rebinds as one
// cohort every cycle, forever, and each cohort lands on the cell-inventory lock
// as a single wave. Jittering the grant walks the cohort apart across cycles.
// Why: hosts rebind a few minutes before this expires, so every host that
// (re)connected in the same minute (a cell recreate dumps hundreds at once)
// rebinds as one cohort every cycle, forever, and each cohort lands on the
// cell-inventory lock as a single wave. A symmetric jitter walks the cohort
// apart across cycles without raising the mean rebind rate. The lease only
// drives the desktop's rotation timer; auth expiry (5 min token) and the 75 s
// silence watchdog are enforced separately, so a longer grant risks nothing.
export const CONTROL_LEASE_MS = 55 * 60 * 1000
export const CONTROL_LEASE_JITTER_MS = 10 * 60 * 1000
export const CONTROL_LEASE_JITTER_MS = 5 * 60 * 1000
export class HostSessionRegistry {
private readonly sessions = new Map<string, HostSession>()
@@ -150,8 +153,10 @@ export class HostSessionRegistry {
private readonly random: () => number = Math.random
) {}
// Uniform over [CONTROL_LEASE_MS - jitter, CONTROL_LEASE_MS + jitter).
private controlLeaseExpiresAt(): number {
return this.now() + CONTROL_LEASE_MS - Math.floor(this.random() * CONTROL_LEASE_JITTER_MS)
const offset = Math.floor((this.random() * 2 - 1) * CONTROL_LEASE_JITTER_MS)
return this.now() + CONTROL_LEASE_MS + offset
}
async acceptClient(