feat(relay): the director that booked a join writes its row about a second later (off) (#27048)

Rebased onto main as one commit after its parent squash-merged.
This commit is contained in:
Jinwoo Hong
2026-10-10 19:11:59 -04:00
committed by GitHub
parent 04f8f2b5d8
commit fda01facc2
8 changed files with 664 additions and 9 deletions
@@ -14,6 +14,7 @@ const NEVER_REJECTS = new Map([
['cell-heartbeat-client.ts#send', 'one try/catch around the whole send'],
['cell-reserve-client.ts#send', 'one try/catch around the request; resolves every item'],
['cell-reserve-client.ts#demote', 'one try/catch around the request'],
['reserve-ledger-writer.ts#flush', 'one try/catch around the write; retries with backoff'],
['control-renewal-batch.ts#flush', 'rejects the waiters, never itself'],
['regional-rehome-worker.ts#run', 'one try/catch around the whole poll'],
['relay-control-flag-channel.ts#poll', 'one catch on the whole read']
+73
View File
@@ -51,6 +51,11 @@ import { CellReserveClient } from './cell-reserve-client.js'
import { googleMetadataIdentityToken, reusedIdentityToken } from './google-metadata-identity-token.js'
import { ReserveAssignment } from './reserve-assignment.js'
import { ReservePlacer } from './reserve-placement.js'
import {
reconcileReserveLedger,
RESERVE_LEDGER_RECONCILE_MS,
ReserveLedgerWriter
} from './reserve-ledger-writer.js'
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
// A malformed percent-escape in the request target must be a client error, never a URIError
@@ -185,6 +190,11 @@ export function createRelayServer(
// Step 5 on directors: needs the map and the rehome credential the cells verify.
const directorId = randomUUID()
const rehomeAudience = config.rehomeAudience
// Only a director that books writes rows, and only for the joins it booked.
const reserveLedger =
config.role === 'director' && shadowSeatPoller && config.reservePlacement === 'on'
? new ReserveLedgerWriter(observedDatabase, { now: options.now })
: undefined
const reservePlacement =
config.role === 'director' &&
shadowSeatPoller &&
@@ -204,6 +214,7 @@ export function createRelayServer(
)
}),
readRow: async (identity) => await assignments.hostWhereabouts(identity),
databaseBusy: () => readRelayDatabasePoolPressure(database).databasePoolWaiting > 0,
now: options.now,
random: options.random
})
@@ -228,6 +239,68 @@ export function createRelayServer(
}, 60_000)
: null
offWithReserveTimer?.unref()
if (reserveLedger && shadowSeatPoller) {
const directory = shadowSeatPoller.directory
directory.onBookedJoin = (join) => {
if (join.reservedBy !== directorId) return
reserveLedger.enqueue({
userId: join.userId,
relayHostId: join.relayHostId,
cellId: join.cellId,
epoch: join.epoch
})
}
reserveLedger.start()
const reconcile = (): void => {
const now = (options.now ?? Date.now)()
void reconcileReserveLedger({
writer: reserveLedger,
seats: () =>
directory
.cellIds()
.filter((cellId) => directory.admitModeOf(cellId) === 'reserve')
.flatMap((cellId) =>
directory.seatsOnCell(cellId).map(({ userId, relayHostId, seat }) => ({
userId,
relayHostId,
cellId,
epoch: seat.epoch,
joinedAt: seat.joinedAt
}))
),
demote: (seat, row) => {
reservePlacement?.demoteSeat(seat)
// The re-assign mints above the row, so the mirror can take it.
reservePlacement?.raiseEpochFloor(seat, row.epoch)
console.warn(
JSON.stringify({
event: 'orca_relay_reserve_ledger_seat_behind_row',
cellId: seat.cellId,
epoch: seat.epoch,
rowEpoch: row.epoch
})
)
},
now
})
.catch(() => console.warn('[orca-relay] reserve ledger reconcile deferred'))
.finally(schedule)
}
// Jittered, so five directors do not reconcile together.
const schedule = (): void => {
const random = options.random ?? Math.random
setTimeout(reconcile, RESERVE_LEDGER_RECONCILE_MS + random() * 60_000).unref?.()
}
// A restart loses the queue: the first run goes as soon as the map is complete.
const clock = options.now ?? Date.now
const bootedAt = clock()
const firstRun = setInterval(() => {
if (!directory.isComplete() && clock() - bootedAt < RESERVE_LEDGER_RECONCILE_MS) return
clearInterval(firstRun)
reconcile()
}, 1_000)
firstRun.unref?.()
}
const configuredConnectionLimits =
config.connectionHardCap === undefined
? null
@@ -1,6 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import type { CellReserveClient } from './cell-reserve-client.js'
import { ReserveAssignment, ReservePaceError, RESERVE_STARTUP_GATE_MS } from './reserve-assignment.js'
import {
ReserveAssignment,
ReservePaceError,
RESERVE_ROW_FLOOR_TTL_MS,
RESERVE_WRONG_CELL_ROW_READ_MS,
RESERVE_STARTUP_GATE_MS
} from './reserve-assignment.js'
import { ReservePlacer, type ReserveAttempt } from './reserve-placement.js'
import { ShadowSeatDirectory, type SeatFeedCell, type SeatFeedResponse } from './shadow-seat-directory.js'
@@ -43,6 +49,7 @@ function setup(
feeds?: SeatFeedResponse[]
reserve?: (cellId: string, sticky: boolean) => ReserveAttempt
row?: (identity: { userId: string; relayHostId: string }) => Promise<{ cellId: string; assignmentEpoch: number } | null>
databaseBusy?: () => boolean
mode?: 'off' | 'dry-run' | 'on'
startedAt?: number
} = {}
@@ -78,6 +85,7 @@ function setup(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above.
client: client as unknown as CellReserveClient,
readRow: options.row ?? (async () => null),
...(options.databaseBusy ? { databaseBusy: options.databaseBusy } : {}),
now: () => now.value,
random: () => 0.25,
log: () => undefined
@@ -88,7 +96,7 @@ function setup(
for (const response of options.feeds ?? cells.map((cell) => feed(cell.cellId))) {
directory.apply(response.cellId, { ...response, full: response.full ?? [] }, now.value - 1, now.value - 1)
}
return { assignment, directory, reserved, demoted, now }
return { assignment, directory, reserved, demoted, now, placer }
}
function seated(epoch: number, joinedAt = 0) {
@@ -162,6 +170,21 @@ describe('reserve assignment on a director', () => {
expect(plan).toMatchObject({ kind: 'answer', lane: 'sticky', assignment: { cellId: 'c1', assignmentEpoch: 7 } })
})
it('after the reconcile demotes a seat behind its row, mints above the row and never answers that seat', async () => {
const { assignment, directory, reserved, now } = setup({ feeds: [feed('c1', { full: [seated(7)] }), feed('c2')] })
// The row names another cell at epoch 8; the mirror would refuse a re-assign at 8.
assignment.raiseEpochFloor(HOST, 8)
const plan = await assignment.plan(HOST, { reconnect: true, region: US })
expect(plan).toMatchObject({ kind: 'answer', lane: 'placement', assignment: { assignmentEpoch: 9 } })
expect(reserved.every((call) => !call.sticky && call.epoch === 9)).toBe(true)
// The floor lapses, so a host it no longer protects answers from memory again.
now.value += RESERVE_ROW_FLOOR_TTL_MS + 1
for (const response of [feed('c1', { seq: 2, full: [seated(7)] }), feed('c2', { seq: 2 })]) {
directory.apply(response.cellId, response, now.value, now.value)
}
expect(await assignment.plan(HOST, { reconnect: true, region: US })).toMatchObject({ lane: 'sticky' })
})
it('never sends a drained or demoted host back, and books it elsewhere above every known epoch', async () => {
const { assignment, directory, reserved, now } = setup()
directory.apply(
@@ -231,6 +254,62 @@ describe('reserve assignment on a director', () => {
})
})
it('after a WRONG_CELL leave, any director reads the row and mints above it', async () => {
const leftWrongCell = (
row: () => Promise<{ cellId: string; assignmentEpoch: number } | null>,
databaseBusy?: () => boolean
) => {
const harness = setup({ row, ...(databaseBusy ? { databaseBusy } : {}) })
// Another director demoted the seat: this one only sees the leave in c1's feed.
harness.directory.apply(
'c1',
feed('c1', {
full: undefined,
seq: 3,
changes: [
{ seq: 2, kind: 'join', ...HOST, epoch: 7, generation: 1, at: 1 },
{ seq: 3, kind: 'leave', ...HOST, epoch: 7, generation: 1, closeCode: 4409, at: 2 }
]
}),
harness.now.value,
harness.now.value
)
return harness
}
const row = vi.fn(async () => ({ cellId: 'c2', assignmentEpoch: 8 }))
const read = leftWrongCell(row)
expect(await read.assignment.plan(HOST, { reconnect: true, region: US })).toMatchObject({
kind: 'answer',
lane: 'placement',
assignment: { assignmentEpoch: 9 }
})
expect(row).toHaveBeenCalledTimes(1)
// The database down: placed as before the read existed, above what the map knows.
const down = leftWrongCell(async () => Promise.reject(new Error('timeout')))
expect(await down.assignment.plan(HOST, { reconnect: true, region: US })).toMatchObject({
kind: 'answer',
assignment: { assignmentEpoch: 8 }
})
// Pool waiters: the read is skipped outright, so a stall cannot stack timed-out reads.
const busyRow = vi.fn(async () => ({ cellId: 'c2', assignmentEpoch: 8 }))
const busy = leftWrongCell(busyRow, () => true)
expect(await busy.assignment.plan(HOST, { reconnect: true, region: US })).toMatchObject({
kind: 'answer',
assignment: { assignmentEpoch: 8 }
})
expect(busyRow).not.toHaveBeenCalled()
// A stalled database: the read gives up after its bound and places as before.
vi.useFakeTimers()
try {
const stalled = leftWrongCell(() => new Promise(() => undefined))
const plan = stalled.assignment.plan(HOST, { reconnect: true, region: US })
await vi.advanceTimersByTimeAsync(RESERVE_WRONG_CELL_ROW_READ_MS)
expect(await plan).toMatchObject({ kind: 'answer', assignment: { assignmentEpoch: 8 } })
} finally {
vi.useRealTimers()
}
})
it('paces with Retry-After when every reserve cell is out of budget and no database cell exists', async () => {
const { assignment } = setup({ reserve: () => ({ outcome: 'intake' }) })
await expect(assignment.plan(HOST, { reconnect: false, region: US })).rejects.toBeInstanceOf(
+87 -7
View File
@@ -49,9 +49,32 @@ export type ReservePlan =
placeFresh?: () => Promise<RelayAssignment | null>
}
// A host whose seat the ledger reconcile demoted mints above its row for this long, so the
// re-assign does not collide with the row again (the mirror refuses an equal epoch).
export const RESERVE_ROW_FLOOR_TTL_MS = 2 * 60 * 60_000
const RESERVE_ROW_FLOOR_MAX = 100_000
// The WRONG_CELL row read's bound: well under a database stall (5-7 s).
export const RESERVE_WRONG_CELL_ROW_READ_MS = 1_000
// Rejects after `ms`; the read itself still settles later, and its failure is swallowed.
async function withinMs<T>(read: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('row_read_timeout')), ms)
})
read.catch(() => undefined)
try {
return await Promise.race([read, timeout])
} finally {
clearTimeout(timer)
}
}
export class ReserveAssignment {
private summary: PlacementSummary = emptySummary()
private summaryAt: number
private readonly rowFloors = new Map<string, { epoch: number; expiresAt: number }>()
constructor(
private readonly input: {
@@ -63,6 +86,9 @@ export class ReserveAssignment {
client: CellReserveClient
// One primary-key read of the host's row: only for a host the map has never seen.
readRow: (identity: Identity) => Promise<{ cellId: string; assignmentEpoch: number } | null>
// True while the pool has waiters: the optional WRONG_CELL read is skipped, so timed-out
// reads cannot stack up behind a stall.
databaseBusy?: () => boolean
now?: () => number
random?: () => number
log?: (line: string) => void
@@ -130,19 +156,34 @@ export class ReserveAssignment {
.filter((entry) => now - entry.at <= RESERVE_STICKY_RECENT_MS)
.at(-1)
const booking = directory.bookingOf(identity.userId, identity.relayHostId)
const rowFloor = this.rowFloor(identity, now)
const known = [
...seats.map((seat) => seat.epoch),
...(left ? [left.epoch] : []),
...(booking ? [booking.epoch] : [])
...(booking ? [booking.epoch] : []),
...(rowFloor === undefined ? [] : [rowFloor])
]
let row: { cellId: string; assignmentEpoch: number } | null = null
if (known.length === 0) {
// A seat closed with WRONG_CELL was demoted (or failed to re-register) because its row is
// ahead, which every director sees in that cell's feed: read the row, so the re-assign mints
// above it on any director, not just the one that demoted it.
const leftWrongCell =
left?.closeCode === RELAY_CLOSE_CODE.WRONG_CELL && !(this.input.databaseBusy?.() ?? false)
if (known.length === 0 || leftWrongCell) {
try {
row = await this.input.readRow(identity)
// Only the never-seen host needs the row; for the WRONG_CELL one it is an optimisation,
// bounded so a database stall cannot hold the reconnect (the reconcile repairs a miss).
row =
known.length === 0
? await this.input.readRow(identity)
: await withinMs(this.input.readRow(identity), RESERVE_WRONG_CELL_ROW_READ_MS)
} catch {
// A host the map never saw, with the database down: its epoch cannot be minted safely.
this.summary.retries.database += 1
return { kind: 'retry', retryAfterSeconds: 2, reason: 'database' }
// One the map knows places as it did before this read existed.
if (known.length === 0) {
this.summary.retries.database += 1
return { kind: 'retry', retryAfterSeconds: 2, reason: 'database' }
}
}
if (row) known.push(row.assignmentEpoch)
}
@@ -158,7 +199,7 @@ export class ReserveAssignment {
}
if (request.reconnect) {
const sticky = await this.stickyFromMemory(identity, seats, left, now)
const sticky = await this.stickyFromMemory(identity, seats, left, now, rowFloor ?? 0)
if (sticky) {
this.summary.sticky += 1
return { kind: 'answer', assignment: sticky, lane: 'sticky' }
@@ -183,11 +224,14 @@ export class ReserveAssignment {
identity: Identity,
seats: ShadowSeat[],
left: { cellId: string; epoch: number; incarnation?: string; closeCode?: number } | undefined,
now: number
now: number,
rowFloor: number
): Promise<RelayAssignment | null> {
// Highest epoch first, then the newest join: only the newest grant may be answered from
// memory, so a stale seat (or a leaver at a lower epoch) is never sent back.
const newest = Math.max(0, ...seats.map((seat) => seat.epoch), left?.epoch ?? 0)
// A seat at or behind a row the reconcile demoted it for is never answered again.
if (newest <= rowFloor) return null
const candidates: Array<{
cellId: string
epoch: number
@@ -310,6 +354,42 @@ export class ReserveAssignment {
return null
}
// The ledger reconcile demoted this host's seat behind (or level with) a row at `epoch`.
raiseEpochFloor(identity: Identity, epoch: number): void {
const key = `${identity.userId}\u0000${identity.relayHostId}`
const current = this.rowFloors.get(key)
this.rowFloors.delete(key)
this.rowFloors.set(key, {
epoch: Math.max(epoch, current?.epoch ?? 0),
expiresAt: this.now() + RESERVE_ROW_FLOOR_TTL_MS
})
if (this.rowFloors.size > RESERVE_ROW_FLOOR_MAX) {
this.rowFloors.delete(this.rowFloors.keys().next().value!)
}
}
private rowFloor(identity: Identity, now: number): number | undefined {
const key = `${identity.userId}\u0000${identity.relayHostId}`
const floor = this.rowFloors.get(key)
if (!floor) return undefined
if (floor.expiresAt > now) return floor.epoch
this.rowFloors.delete(key)
return undefined
}
// Fire-and-forget; the cell checks the seat's epoch and join itself.
demoteSeat(seat: { userId: string; relayHostId: string; cellId: string; epoch: number; joinedAt: number }): void {
const cell = this.input.cells().find((entry) => entry.cellId === seat.cellId)
if (!cell) return
void this.input.client.demote(cell, {
v: 1,
userId: seat.userId,
relayHostId: seat.relayHostId,
epoch: seat.epoch,
joinedAt: seat.joinedAt
})
}
// Supersede at placement: the old seats this director's map holds on reserve cells are told
// to go. Each cell checks the seat's epoch and join itself, so a stale map only misses.
private supersede(identity: Identity, seats: readonly ShadowSeat[], cellId: string, epoch: number): void {
@@ -0,0 +1,61 @@
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { openRelayDatabase, type RelayDatabase } from './database.js'
import { reconcileReserveLedger, ReserveLedgerWriter } from './reserve-ledger-writer.js'
// The ledger's statements on real PostgreSQL: the guarded upsert, its counter reset on a
// move, and the batched row read.
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
const describePostgres = databaseUrl ? describe : describe.skip
const USER = `ledger-pg-${randomUUID().slice(0, 8)}`
describePostgres('reserve ledger writer on PostgreSQL', () => {
let database: RelayDatabase
beforeAll(async () => {
database = await openRelayDatabase({ databaseUrl, dataDir: '/tmp' })
})
afterAll(async () => {
await database.query(`DELETE FROM relay_assignments WHERE user_id = ?`, [USER])
await database.close()
})
const row = async (relayHostId: string) =>
(
await database.query(
`SELECT cell_id, assignment_epoch, reserved_controls FROM relay_assignments
WHERE user_id = ? AND relay_host_id = ?`,
[USER, relayHostId]
)
)[0]
it('writes only a higher epoch, drops a moved row’s counters, and reconciles from the map', async () => {
const writer = new ReserveLedgerWriter(database, { log: () => undefined })
const host = 'ledgerpghost0001'
writer.enqueue({ userId: USER, relayHostId: host, cellId: 'c1', epoch: 5 })
await writer.flush()
expect(await row(host)).toMatchObject({ cell_id: 'c1', assignment_epoch: '5' })
await database.query(`UPDATE relay_assignments SET reserved_controls = 2 WHERE user_id = ?`, [USER])
writer.enqueue({ userId: USER, relayHostId: host, cellId: 'c2', epoch: 5 })
await writer.flush()
expect(await row(host)).toMatchObject({ cell_id: 'c1', assignment_epoch: '5', reserved_controls: '2' })
writer.enqueue({ userId: USER, relayHostId: host, cellId: 'c2', epoch: 6 })
await writer.flush()
expect(await row(host)).toMatchObject({ cell_id: 'c2', assignment_epoch: '6', reserved_controls: '0' })
const other = 'ledgerpghost0002'
const result = await reconcileReserveLedger({
writer,
seats: () => [
{ userId: USER, relayHostId: host, cellId: 'c1', epoch: 4, joinedAt: 0 },
{ userId: USER, relayHostId: other, cellId: 'c1', epoch: 2, joinedAt: 0 }
],
now: 1_000_000
})
await writer.flush()
expect(result).toEqual({ upserted: 1, demoted: 0 })
expect(await row(host)).toMatchObject({ cell_id: 'c2', assignment_epoch: '6' })
expect(await row(other)).toMatchObject({ cell_id: 'c1', assignment_epoch: '2' })
})
})
@@ -0,0 +1,127 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js'
import { reconcileReserveLedger, ReserveLedgerWriter } from './reserve-ledger-writer.js'
const HOST = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }
describe('reserve ledger writer', () => {
let database: RelayDatabase | undefined
afterEach(async () => {
await database?.close()
database = undefined
})
async function setup(now = () => 1_000) {
database = await openInMemoryRelayDatabase()
return { database, writer: new ReserveLedgerWriter(database, { now, log: () => undefined }) }
}
const row = async (db: RelayDatabase, relayHostId = HOST.relayHostId) =>
(
await db.query(
`SELECT cell_id, assignment_epoch, reserved_controls FROM relay_assignments
WHERE user_id = ? AND relay_host_id = ?`,
[HOST.userId, relayHostId]
)
)[0]
it('writes a booked join, and only a higher epoch replaces it', async () => {
const { database: db, writer } = await setup()
writer.enqueue({ ...HOST, cellId: 'c1', epoch: 5 })
await writer.flush()
expect(await row(db)).toMatchObject({ cell_id: 'c1', assignment_epoch: 5 })
writer.enqueue({ ...HOST, cellId: 'c2', epoch: 4 })
writer.enqueue({ ...HOST, cellId: 'c3', epoch: 5 })
await writer.flush()
expect(await row(db)).toMatchObject({ cell_id: 'c1', assignment_epoch: 5 })
writer.enqueue({ ...HOST, cellId: 'c2', epoch: 6 })
await writer.flush()
expect(await row(db)).toMatchObject({ cell_id: 'c2', assignment_epoch: 6 })
})
it('drops a moved row’s counters but keeps a same-cell row’s', async () => {
const { database: db, writer } = await setup()
await db.query(
`INSERT INTO relay_assignments
(user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, last_activity_at,
reserved_controls, reserved_splices, reserved_invites, pending_installs,
pending_confirmations, migration_leases)
VALUES (?, ?, 'c1', 3, 0, 0, 2, 0, 0, 0, 0, 0), (?, ?, 'c1', 3, 0, 0, 2, 0, 0, 0, 0, 0)`,
[HOST.userId, HOST.relayHostId, HOST.userId, 'bbbbbbbbbbbbbbbb']
)
writer.enqueue({ ...HOST, cellId: 'c9', epoch: 4 })
writer.enqueue({ userId: HOST.userId, relayHostId: 'bbbbbbbbbbbbbbbb', cellId: 'c1', epoch: 4 })
await writer.flush()
expect(await row(db)).toMatchObject({ cell_id: 'c9', reserved_controls: 0 })
expect(await row(db, 'bbbbbbbbbbbbbbbb')).toMatchObject({ cell_id: 'c1', reserved_controls: 2 })
})
it('keeps its queue through a database stall and drops the oldest past the bound', async () => {
let now = 1_000
const { database: db, writer } = await setup(() => now)
const query = vi.spyOn(db, 'query').mockRejectedValueOnce(new Error('timeout'))
writer.enqueue({ ...HOST, cellId: 'c1', epoch: 5 })
await writer.flush()
expect(writer.pending()).toBe(1)
// Backoff: no retry before it is due.
await writer.flush()
expect(query).toHaveBeenCalledTimes(1)
now += 1_000
await writer.flush()
expect(writer.pending()).toBe(0)
expect(await row(db)).toMatchObject({ cell_id: 'c1', assignment_epoch: 5 })
const bounded = new ReserveLedgerWriter(db, { queueMax: 2, log: () => undefined })
for (const epoch of [1, 2, 3]) bounded.enqueue({ ...HOST, cellId: 'c1', epoch })
expect(bounded.pending()).toBe(2)
})
it('reconciles the map’s seats and never lowers an epoch', async () => {
const { database: db, writer } = await setup()
// Another path wrote a higher epoch on c7: the mirror leaves it, whatever the map says.
writer.enqueue({ ...HOST, cellId: 'c7', epoch: 9 })
await writer.flush()
writer.enqueue({ ...HOST, cellId: 'c1', epoch: 9 })
await writer.flush()
const other = { userId: 'user-2', relayHostId: 'cccccccccccccccc' }
const demoted: unknown[] = []
const result = await reconcileReserveLedger({
writer,
seats: () => [
{ ...HOST, cellId: 'c1', epoch: 8, joinedAt: 0 },
{ ...other, cellId: 'c1', epoch: 2, joinedAt: 0 },
// Too young: it may still be superseded.
{ userId: 'user-3', relayHostId: 'dddddddddddddddd', cellId: 'c1', epoch: 2, joinedAt: 990_000 }
],
demote: (seat) => demoted.push(seat),
now: 1_000_000
})
await writer.flush()
// The row is ahead of the c1 seat: the row stays, and that seat is told to go.
expect(result).toEqual({ upserted: 1, demoted: 1 })
expect(demoted).toEqual([{ ...HOST, cellId: 'c1', epoch: 8, joinedAt: 0 }])
expect(await row(db)).toMatchObject({ cell_id: 'c7', assignment_epoch: 9 })
const written = await db.query(`SELECT cell_id FROM relay_assignments WHERE user_id = 'user-2'`)
expect(written).toEqual([{ cell_id: 'c1' }])
})
it('demotes a seat level with its row on another cell, which the mirror would never replace', async () => {
const { database: db, writer } = await setup()
writer.enqueue({ ...HOST, cellId: 'c7', epoch: 9 })
await writer.flush()
const demoted: unknown[] = []
const result = await reconcileReserveLedger({
writer,
seats: () => [
{ ...HOST, cellId: 'c1', epoch: 9, joinedAt: 0 },
// Level with its own row: nothing to do.
{ ...HOST, relayHostId: 'eeeeeeeeeeeeeeee', cellId: 'c7', epoch: 9, joinedAt: 0 }
],
demote: (seat, found) => demoted.push({ seat: seat.cellId, row: found }),
now: 1_000_000
})
expect(result).toEqual({ upserted: 1, demoted: 1 })
expect(demoted).toEqual([{ seat: 'c1', row: { cellId: 'c7', epoch: 9 } }])
})
})
@@ -0,0 +1,225 @@
import type { RelayDatabase } from './database.js'
// Step 5's after-the-fact record: a reserve-mode cell admits a booked host without reading
// Postgres, and the director that booked it writes the host's relay_assignments row about a
// second later. Phones still resolve through that row until step 6.
//
// A guarded mirror, which nothing reads for a desktop's correctness: only a higher epoch
// replaces a row, and nothing ever lowers one. No relay_cells row and no activity lease is
// touched. A row whose cell changes drops its counters: the leases that back them keep
// their own cell, and unbacked units stay counted on the old cell until its next reconcile,
// which only makes that cell look fuller.
export const RESERVE_LEDGER_FLUSH_MS = 250
export const RESERVE_LEDGER_QUEUE_MAX = 200_000
const RESERVE_LEDGER_BATCH = 500
const RETRY_BASE_MS = 250
const RETRY_MAX_MS = 5_000
const SUMMARY_INTERVAL_MS = 60_000
export type LedgerEntry = {
userId: string
relayHostId: string
cellId: string
epoch: number
}
export class ReserveLedgerWriter {
private queue: LedgerEntry[] = []
private flushing = false
private failures = 0
private retryAt = 0
private timer: ReturnType<typeof setInterval> | null = null
private dropped = 0
private written = 0
private summaryAt: number
constructor(
private readonly database: RelayDatabase,
private readonly input: {
now?: () => number
queueMax?: number
log?: (line: string) => void
} = {}
) {
this.summaryAt = this.now()
}
private now(): number {
return (this.input.now ?? Date.now)()
}
start(): void {
this.timer ??= setInterval(() => void this.flush(), RESERVE_LEDGER_FLUSH_MS)
this.timer.unref?.()
}
stop(): void {
if (this.timer) clearInterval(this.timer)
this.timer = null
}
// During a database stall the queue grows; past its bound the oldest entries go first,
// since the 10-minute reconcile rewrites every seat anyway.
enqueue(entry: LedgerEntry): void {
this.queue.push(entry)
const max = this.input.queueMax ?? RESERVE_LEDGER_QUEUE_MAX
if (this.queue.length > max) {
const excess = this.queue.length - max
this.queue.splice(0, excess)
this.dropped += excess
}
}
pending(): number {
return this.queue.length
}
// Single-flight; resolves once the queued rows are written or the attempt failed.
async flush(): Promise<void> {
if (this.flushing || this.queue.length === 0 || this.now() < this.retryAt) {
this.logSummary()
return
}
this.flushing = true
const batch = this.queue.slice(0, RESERVE_LEDGER_BATCH)
try {
await this.write(batch)
this.queue.splice(0, batch.length)
this.written += batch.length
this.failures = 0
this.retryAt = 0
} catch {
this.failures += 1
this.retryAt = this.now() + Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** (this.failures - 1))
} finally {
this.flushing = false
this.logSummary()
}
}
private async write(batch: LedgerEntry[]): Promise<void> {
// The newest entry per host wins inside one batch.
const latest = new Map<string, LedgerEntry>()
for (const entry of batch) {
const key = `${entry.userId}\u0000${entry.relayHostId}`
const known = latest.get(key)
if (!known || entry.epoch > known.epoch) latest.set(key, entry)
}
await this.upsert([...latest.values()])
}
private async upsert(entries: LedgerEntry[]): Promise<void> {
const now = this.now()
const rows = entries.map(() => '(?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0)').join(', ')
const zeroOnMove = (column: string) =>
`${column} = CASE WHEN relay_assignments.cell_id = excluded.cell_id
THEN relay_assignments.${column} ELSE 0 END`
await this.database.query(
`INSERT INTO relay_assignments
(user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, last_activity_at,
reserved_controls, reserved_splices, reserved_invites, pending_installs,
pending_confirmations, migration_leases)
VALUES ${rows}
ON CONFLICT (user_id, relay_host_id) DO UPDATE SET
${[
'reserved_controls',
'reserved_splices',
'reserved_invites',
'pending_installs',
'pending_confirmations',
'migration_leases'
]
.map(zeroOnMove)
.join(',\n ')},
cell_id = excluded.cell_id,
assignment_epoch = excluded.assignment_epoch,
last_activity_at = excluded.last_activity_at
WHERE relay_assignments.assignment_epoch < excluded.assignment_epoch`,
entries.flatMap((entry) => [
entry.userId,
entry.relayHostId,
entry.cellId,
entry.epoch,
now,
now
])
)
}
// Rows for these hosts, in one read per batch.
async readRows(
hosts: ReadonlyArray<{ userId: string; relayHostId: string }>
): Promise<Map<string, { cellId: string; epoch: number }>> {
const rows = new Map<string, { cellId: string; epoch: number }>()
for (let start = 0; start < hosts.length; start += RESERVE_LEDGER_BATCH) {
const chunk = hosts.slice(start, start + RESERVE_LEDGER_BATCH)
const found = await this.database.query(
`SELECT user_id, relay_host_id, cell_id, assignment_epoch FROM relay_assignments
WHERE ${chunk.map(() => '(user_id = ? AND relay_host_id = ?)').join(' OR ')}`,
chunk.flatMap((host) => [host.userId, host.relayHostId])
)
for (const row of found) {
rows.set(`${String(row.user_id)}\u0000${String(row.relay_host_id)}`, {
cellId: String(row.cell_id),
epoch: Number(row.assignment_epoch)
})
}
}
return rows
}
private logSummary(): void {
const now = this.now()
if (now - this.summaryAt < SUMMARY_INTERVAL_MS) return
this.summaryAt = now
;(this.input.log ?? ((line: string) => console.log(line)))(
JSON.stringify({
event: 'orca_relay_reserve_ledger_summary',
written: this.written,
pending: this.queue.length,
dropped: this.dropped,
failures: this.failures
})
)
this.written = 0
this.dropped = 0
}
}
export const RESERVE_LEDGER_RECONCILE_MS = 10 * 60_000
// A seat this young may still be superseded; the next reconcile takes it.
const RECONCILE_SETTLE_MS = 30_000
type ReconcileSeat = { userId: string; relayHostId: string; cellId: string; epoch: number; joinedAt: number }
// Every director upserts the map's seats on reserve-mode cells: guarded and idempotent, so
// concurrent runs are harmless. A row ahead of the seat (today's path answered a newer epoch,
// and the desktop was then answered the old seat from memory) is never rewritten: that seat is
// demoted (epoch-checked, closes with 4409), so the desktop re-assigns to what the row names.
// The demoting director then mints above the row's epoch for that host (see raiseEpochFloor).
export async function reconcileReserveLedger(input: {
writer: ReserveLedgerWriter
seats: () => ReconcileSeat[]
demote?: (seat: ReconcileSeat, row: { cellId: string; epoch: number }) => void
now: number
}): Promise<{ upserted: number; demoted: number }> {
const seats = input.seats().filter((seat) => input.now - seat.joinedAt >= RECONCILE_SETTLE_MS)
const rows = await input.writer.readRows(seats)
let upserted = 0
let demoted = 0
for (const seat of seats) {
const row = rows.get(`${seat.userId}\u0000${seat.relayHostId}`)
// Level with the row on another cell is the same divergence: the mirror never replaces it.
const behindRow = row && (row.epoch > seat.epoch || (row.epoch === seat.epoch && row.cellId !== seat.cellId))
if (row && behindRow && input.demote) {
input.demote(seat, row)
demoted += 1
continue
}
if (row && row.epoch >= seat.epoch) continue
input.writer.enqueue(seat)
upserted += 1
}
return { upserted, demoted }
}
@@ -284,6 +284,15 @@ export class ShadowSeatDirectory {
return [...this.cells.keys()]
}
// One cell's seats as the map holds them.
seatsOnCell(cellId: string): Array<{ userId: string; relayHostId: string; seat: ShadowSeat }> {
const cursor = this.cells.get(cellId)
if (!cursor) return []
return [...cursor.seats].map(([key, seat]) => {
const separator = key.indexOf('\u0000')
return { userId: key.slice(0, separator), relayHostId: key.slice(separator + 1), seat }
})
}
// An old cell (404) has no feed; its last known seats stay, marked by status.
markNoFeed(cellId: string, now: number, runtimeIncarnation?: string): void {