From 2dfd93687f09b402679cd411eec8d5900dbec564 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Mon, 26 Oct 2026 21:37:06 -0700 Subject: [PATCH] perf(database): defer primary fallback for user lookups (#4359) --- .../clients/database/DatabaseClient.test.ts | 19 +++++++++++++++++++ .../clients/database/DatabaseClient.ts | 12 +++++++----- .../clients/database/MySQLDatabaseClient.ts | 3 ++- src/backend/stores/user/UserStore.ts | 12 ++++++++---- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/backend/clients/database/DatabaseClient.test.ts b/src/backend/clients/database/DatabaseClient.test.ts index 9776d8a42..a90e8f8e4 100644 --- a/src/backend/clients/database/DatabaseClient.test.ts +++ b/src/backend/clients/database/DatabaseClient.test.ts @@ -108,6 +108,25 @@ describe('AbstractDatabaseClient — unimplemented surface', () => { }); describe('AbstractDatabaseClient — replica-aware reads', () => { + it('avoids a primary read when the replica has rows in sequential mode', async () => { + const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]); + expect( + await client.tryHardRead('SELECT 1', [], { race: false }), + ).toEqual([{ id: 1 }]); + expect(client.preads).toEqual([]); + }); + + it.each([[], new Error('replica unavailable')])( + 'falls back once in sequential mode', + async (replica) => { + const client = new FixtureDatabaseClient(replica, [{ id: 2 }]); + expect( + await client.tryHardRead('SELECT 1', [], { race: false }), + ).toEqual([{ id: 2 }]); + expect(client.preads).toEqual(['SELECT 1']); + }, + ); + it('returns replica rows without waiting on the primary', async () => { const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]); await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([ diff --git a/src/backend/clients/database/DatabaseClient.ts b/src/backend/clients/database/DatabaseClient.ts index d74178258..2c93ce5ef 100644 --- a/src/backend/clients/database/DatabaseClient.ts +++ b/src/backend/clients/database/DatabaseClient.ts @@ -138,25 +138,27 @@ export class AbstractDatabaseClient extends PuterClient { /** * Like `read()`, but an empty or failed replica read falls back to the - * primary, so replication lag can't hide a row that was just written. The - * primary read starts alongside the replica one rather than after it. - * Without a replica this is a single `read()`. + * primary, so replication lag can't hide a row that was just written. By + * default the primary read starts alongside the replica one; `race: false` + * reads the primary only after the replica returns empty or fails. Without + * a replica this is a single `read()`. */ @Span('db.tryHardRead', (query: string) => ({ 'db.statement': query })) async tryHardRead( query: string, params: unknown[] = [], + { race = true }: { race?: boolean } = {}, ): Promise[]> { if (!this.hasReadReplica()) return this.read(query, params); - const primary = settle(this.pread(query, params)); + const primary = race ? settle(this.pread(query, params)) : null; try { const rows = await this.replicaRead(query, params); if (rows.length > 0) return rows; } catch { // replica failed — fall through to primary } - const result = await primary; + const result = await (primary ?? settle(this.pread(query, params))); if ('error' in result) throw result.error; return result.value; } diff --git a/src/backend/clients/database/MySQLDatabaseClient.ts b/src/backend/clients/database/MySQLDatabaseClient.ts index ff95fe3ca..3f9074e57 100644 --- a/src/backend/clients/database/MySQLDatabaseClient.ts +++ b/src/backend/clients/database/MySQLDatabaseClient.ts @@ -217,7 +217,8 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient { return this.configuration === Configuration.REPLICA; } - // No failover: `tryHardRead` already has a primary read in flight. + // No failover: `tryHardRead` falls back to the primary on an empty result + // or error (raced alongside by default, after the replica with `race: false`). protected override async replicaRead( query: string, params: unknown[], diff --git a/src/backend/stores/user/UserStore.ts b/src/backend/stores/user/UserStore.ts index 10af3f990..9c13d00ba 100644 --- a/src/backend/stores/user/UserStore.ts +++ b/src/backend/stores/user/UserStore.ts @@ -316,7 +316,9 @@ export class UserStore extends PuterStore { const rows = ( primary ? await this.clients.db.pread(sql, chunk) - : await this.clients.db.tryHardRead(sql, chunk) + : await this.clients.db.tryHardRead(sql, chunk, { + race: false, + }) ) as Array>; for (const row of rows) users.push(this.#normalizeRow(row)); } @@ -430,8 +432,8 @@ export class UserStore extends PuterStore { // Replication-aware read: on `force` (or a tombstoned key), go // straight to the primary (`pread`) to bypass replica lag for hot - // reads (e.g., immediately after a signup). Otherwise `tryHardRead` - // parallels primary + replica and prefers whichever returns rows. + // reads (e.g., immediately after a signup). Otherwise the primary is + // only read when the replica returns no match or fails. // `id`, `uuid` and `username` are UNIQUE, so at most one row matches and // the optimizer drops the ordering. `email` is not — multiple rows may // legitimately hold the same address while unconfirmed, so without an @@ -447,7 +449,9 @@ export class UserStore extends PuterStore { async (primary) => { const rows = primary ? await this.clients.db.pread(sql, [value]) - : await this.clients.db.tryHardRead(sql, [value]); + : await this.clients.db.tryHardRead(sql, [value], { + race: false, + }); const row = rows[0]; if (!row) return null; if (