fix(db): tryHardRead's replica leg doesn't fail over to the primary

MySQL's read() retries on the primary after a load-shed or transient
replica error, so routing tryHardRead through it read the primary twice
on that path. The replica leg now reads the replica alone.
This commit is contained in:
Daniel Salazar committed 2026-10-10 21:20:11 -04:00
1 parent a917a6243e
commit 250405df93
3 files changed
+37 -1

No files matched your search

+12 -1
View File
@@ -119,6 +119,17 @@ export class AbstractDatabaseClient extends PuterClient {
return false;
}
/**
* The replica leg of `tryHardRead`. Override when `read()` falls back to
* the primary by itself, or a replica failure reads the primary twice.
*/
protected replicaRead(
query: string,
params: unknown[],
): Promise<Record<string, unknown>[]> {
return this.read(query, params);
}
/**
* 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
@@ -134,7 +145,7 @@ export class AbstractDatabaseClient extends PuterClient {
const primary = settle(this.pread(query, params));
try {
const rows = await this.read(query, params);
const rows = await this.replicaRead(query, params);
if (rows.length > 0) return rows;
} catch {
// replica failed — fall through to primary
@@ -619,6 +619,21 @@ describe('MySQLDatabaseClient — tryHardRead', () => {
{ from: 'primary' },
]);
});
it('reads the primary once when the replica fails in a way read() fails over on', async () => {
const replica = {
execute: vi.fn().mockRejectedValue(codedError('dbBatchFailed')),
};
const primary = {
execute: vi.fn().mockResolvedValue([[{ from: 'primary' }]]),
};
const client = makeClient({ replica, primary });
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
{ from: 'primary' },
]);
expect(primary.execute).toHaveBeenCalledTimes(1);
});
});
describe('MySQLDatabaseClient — migrations', () => {
@@ -205,6 +205,16 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
return this.configuration === Configuration.REPLICA;
}
// No failover: `tryHardRead` already has a primary read in flight.
protected override async replicaRead(
query: string,
params: unknown[],
): Promise<Record<string, unknown>[]> {
const result = await this.dbReplica.execute(query, params);
if (!result) return [];
return (result[0] as Record<string, unknown>[]) ?? [];
}
// ------------------------------------------------------------------
// Pool management
// ------------------------------------------------------------------