From cb82bc80dacab75c06e936b7a7a359d49dbdb5a5 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:33:15 -0400 Subject: [PATCH] fix(relay): never call a reserve cell's hosts stranded when it goes existing-only (#30609) A reserve cell keeps no activity rows and its ledger stamps last_activity_at, so the stranded rule read its connected hosts as unserved and re-placed them. --- .../assignment-reserve-mode-census.test.ts | 21 +++++++++++++++++++ cloud/apps/relay/src/assignment-store.ts | 5 +++++ 2 files changed, 26 insertions(+) diff --git a/cloud/apps/relay/src/assignment-reserve-mode-census.test.ts b/cloud/apps/relay/src/assignment-reserve-mode-census.test.ts index a10b4994013..077552c8f03 100644 --- a/cloud/apps/relay/src/assignment-reserve-mode-census.test.ts +++ b/cloud/apps/relay/src/assignment-reserve-mode-census.test.ts @@ -228,6 +228,27 @@ describe('reserve-mode census', () => { await restore() }) + it('keeps a connected host pinned when its reserve cell goes existing-only after a ledger write', async () => { + let now = 1_000 + const store = await setup(() => now) + const identity = { userId: 'user-a', relayHostId: 'host000000000001' } + const first = await store.assign(identity) + await setReserve(store, [first.cellId]) + // The reserve ledger stamps last_activity_at and the cell keeps no activity rows. + now += 5 * 60_000 + await database!.query('DELETE FROM relay_assignment_activity_leases WHERE user_id = ?', [identity.userId]) + await database!.query('UPDATE relay_assignments SET last_activity_at = ? WHERE user_id = ?', [now, identity.userId]) + await store.setCellAdmissionState(first.cellId, 'existing-only') + now += 2 * 60_000 + expect(await store.assign(identity)).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + const { unknown, restore } = await unknownSet(() => now) + expect((await unknown.assign(identity)).assignmentEpoch).toBe(first.assignmentEpoch) + await restore() + }) + // Only the first reserve-set read is held; it is released (or failed) by the test. function holdFirstRead(options: { fail?: boolean } = {}) { const query = database!.query.bind(database!) diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 66d90177bda..53dfcb5598f 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -1290,6 +1290,11 @@ export class RelayAssignmentStore { if (state !== 'existing-only') { return false } + // A reserve cell keeps no activity rows and its ledger stamps last_activity_at, so zero + // leases proves nothing; a dead one is left to the dead-reserve path and its fence. + if (await this.mayBeReserveCell(text(existing, 'cell_id'), transaction)) { + return false + } const liveLeases = ( await transaction.query( `SELECT COUNT(*) AS live FROM relay_assignment_activity_leases