Skip to content

Commit 93c924a

Browse files
committed
fix(sqlite): preserve coordinator failure boundaries
1 parent 9bb7eb8 commit 93c924a

3 files changed

Lines changed: 320 additions & 49 deletions

File tree

packages/browser-db-sqlite-persistence/tests/browser-coordinator.test.ts

Lines changed: 230 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ import type { BrowserCollectionCoordinatorOptions } from '../src/browser-coordin
2323
* without losing metadata. A mutating RPC may replay only through the same
2424
* known leader and term, and one envelope id cannot identify two mutation
2525
* request types. Remote subset request data must be clone-safe, and each
26-
* accepted physical acquisition creates one exact acquisition lease.
26+
* accepted physical acquisition creates one exact acquisition lease. A
27+
* passive heartbeat can update a route, but only a local participant may join
28+
* that collection's leadership. Durable stream positions advance after the
29+
* adapter accepts the write, and failed release transport retains its retry
30+
* route.
2731
*
2832
* The adapter call logs, transport controls, owner callbacks, and internal-map
2933
* snapshots are focused reference ledgers. Histories vary local and follower
@@ -704,6 +708,52 @@ describe(`BrowserCollectionCoordinator`, () => {
704708
coord2.dispose()
705709
})
706710

711+
it(`records an unrelated heartbeat without joining that collection's leadership`, async () => {
712+
const adapter = createStubAdapter()
713+
const getStreamPosition = vi.spyOn(adapter, `getStreamPosition`)
714+
const coordinator = createCoordinator(adapter)
715+
coordinator.subscribe(`todos`, () => {})
716+
await flush(50)
717+
718+
injectBroadcastMessage(`tsdb:coord:test-db`, {
719+
v: 1,
720+
dbName: `test-db`,
721+
collectionId: `notes`,
722+
senderId: `notes-owner`,
723+
ts: Date.now(),
724+
payload: {
725+
type: `leader:heartbeat`,
726+
term: 4,
727+
leaderId: `notes-owner`,
728+
latestSeq: 3,
729+
latestRowVersion: 8,
730+
},
731+
})
732+
await flush(0)
733+
734+
const state = (
735+
coordinator as unknown as {
736+
collections: Map<
737+
string,
738+
{ leaderId: string | null; latestTerm: number }
739+
>
740+
}
741+
).collections.get(`notes`)
742+
expect({
743+
leaderId: state?.leaderId,
744+
latestTerm: state?.latestTerm,
745+
joinedLeadership: coordinator.isLeader(`notes`),
746+
streamPositionCollections: getStreamPosition.mock.calls.map(
747+
([collectionId]) => collectionId,
748+
),
749+
}).toEqual({
750+
leaderId: `notes-owner`,
751+
latestTerm: 4,
752+
joinedLeadership: false,
753+
streamPositionCollections: [`todos`],
754+
})
755+
})
756+
707757
it(`returns unique node ids`, () => {
708758
const coord1 = createCoordinator()
709759
const coord2 = createCoordinator()
@@ -1253,6 +1303,61 @@ describe(`BrowserCollectionCoordinator`, () => {
12531303
follower.dispose()
12541304
}
12551305
})
1306+
1307+
it(`reuses the durable stream position after local mutation persistence fails`, async () => {
1308+
const adapter = createStubAdapter()
1309+
const persistenceError = new Error(`local disk full`)
1310+
const attemptedPositions: Array<{ seq: number; rowVersion: number }> = []
1311+
adapter.applyCommittedTx = vi.fn((_collectionId, tx) => {
1312+
attemptedPositions.push({ seq: tx.seq, rowVersion: tx.rowVersion })
1313+
return attemptedPositions.length === 1
1314+
? Promise.reject(persistenceError)
1315+
: Promise.resolve()
1316+
})
1317+
const coordinator = createCoordinator(adapter)
1318+
coordinator.subscribe(`todos`, () => {})
1319+
await flush(50)
1320+
1321+
try {
1322+
await expect(
1323+
coordinator.requestApplyLocalMutations(`todos`, [
1324+
{
1325+
mutationId: `failed-local-mutation`,
1326+
type: `insert`,
1327+
key: `failed-local-mutation`,
1328+
value: { id: `failed-local-mutation` },
1329+
},
1330+
]),
1331+
).rejects.toMatchObject({ cause: persistenceError })
1332+
1333+
const response = await coordinator.requestApplyLocalMutations(`todos`, [
1334+
{
1335+
mutationId: `successful-local-mutation`,
1336+
type: `insert`,
1337+
key: `successful-local-mutation`,
1338+
value: { id: `successful-local-mutation` },
1339+
},
1340+
])
1341+
1342+
expect({ attemptedPositions, response }).toEqual({
1343+
attemptedPositions: [
1344+
{ seq: 1, rowVersion: 1 },
1345+
{ seq: 1, rowVersion: 1 },
1346+
],
1347+
response: {
1348+
type: `rpc:applyLocalMutations:res`,
1349+
rpcId: expect.any(String),
1350+
ok: true,
1351+
term: 1,
1352+
seq: 1,
1353+
latestRowVersion: 1,
1354+
acceptedMutationIds: [`successful-local-mutation`],
1355+
},
1356+
})
1357+
} finally {
1358+
coordinator.dispose()
1359+
}
1360+
})
12561361
})
12571362

12581363
describe(`RPC - applyCommittedTx`, () => {
@@ -1444,6 +1549,66 @@ describe(`BrowserCollectionCoordinator`, () => {
14441549
}
14451550
})
14461551

1552+
it(`reuses the durable stream position after a committed transaction fails`, async () => {
1553+
const adapter = createStubAdapter()
1554+
const persistenceError = new Error(`disk full`)
1555+
const attemptedPositions: Array<{
1556+
term: number
1557+
seq: number
1558+
rowVersion: number
1559+
}> = []
1560+
adapter.applyCommittedTx = vi.fn((_collectionId, tx) => {
1561+
attemptedPositions.push({
1562+
term: tx.term,
1563+
seq: tx.seq,
1564+
rowVersion: tx.rowVersion,
1565+
})
1566+
return attemptedPositions.length === 1
1567+
? Promise.reject(persistenceError)
1568+
: Promise.resolve()
1569+
})
1570+
const coordinator = createCoordinator(adapter)
1571+
coordinator.subscribe(`todos`, () => {})
1572+
await flush(50)
1573+
1574+
try {
1575+
await expect(
1576+
coordinator.requestApplyCommittedTx(`todos`, {
1577+
txId: `failed-source-tx`,
1578+
term: 0,
1579+
seq: 0,
1580+
rowVersion: 0,
1581+
mutations: [],
1582+
}),
1583+
).rejects.toMatchObject({ cause: persistenceError })
1584+
1585+
const response = await coordinator.requestApplyCommittedTx(`todos`, {
1586+
txId: `successful-source-tx`,
1587+
term: 0,
1588+
seq: 0,
1589+
rowVersion: 0,
1590+
mutations: [],
1591+
})
1592+
1593+
expect({ attemptedPositions, response }).toEqual({
1594+
attemptedPositions: [
1595+
{ term: 1, seq: 1, rowVersion: 1 },
1596+
{ term: 1, seq: 1, rowVersion: 1 },
1597+
],
1598+
response: {
1599+
type: `rpc:applyCommittedTx:res`,
1600+
rpcId: expect.any(String),
1601+
ok: true,
1602+
term: 1,
1603+
seq: 1,
1604+
latestRowVersion: 1,
1605+
},
1606+
})
1607+
} finally {
1608+
coordinator.dispose()
1609+
}
1610+
})
1611+
14471612
it(`replays the successful response when only that response is lost`, async () => {
14481613
const leaderAdapter = createStubAdapter()
14491614
const followerAdapter = createStubAdapter()
@@ -2402,6 +2567,70 @@ describe(`BrowserCollectionCoordinator`, () => {
24022567
}
24032568
})
24042569

2570+
it(`retains a follower acquisition when its release transport fails`, async () => {
2571+
const leader = createCoordinator()
2572+
const follower = createCoordinator()
2573+
leader.subscribe(`todos`, () => {})
2574+
follower.subscribe(`todos`, () => {})
2575+
await flush(50)
2576+
const owner = Object.assign(
2577+
vi.fn(() => Promise.resolve()),
2578+
{
2579+
unloadSubset: vi.fn(() => Promise.resolve()),
2580+
onError: vi.fn(),
2581+
},
2582+
)
2583+
const unregisterOwner = leader.registerRemoteSubsetOwner(`todos`, owner)
2584+
const options: LoadSubsetOptions = { limit: 1 }
2585+
const followerInternals = follower as unknown as {
2586+
sendRPC: (collectionId: string, request: unknown) => Promise<unknown>
2587+
outboundRemoteSubsetAcquisitions: Map<string, unknown>
2588+
}
2589+
2590+
try {
2591+
await follower.requestEnsureRemoteSubset(`todos`, options)
2592+
const sendRPC = followerInternals.sendRPC.bind(follower)
2593+
let releaseAttempts = 0
2594+
followerInternals.sendRPC = async (collectionId, request) => {
2595+
if (
2596+
(request as { type?: string }).type ===
2597+
`rpc:releaseRemoteSubset:req`
2598+
) {
2599+
releaseAttempts++
2600+
if (releaseAttempts === 1) {
2601+
return {
2602+
type: `rpc:releaseRemoteSubset:res`,
2603+
rpcId: (request as { rpcId: string }).rpcId,
2604+
ok: false,
2605+
error: `transient release transport failure`,
2606+
}
2607+
}
2608+
}
2609+
return sendRPC(collectionId, request)
2610+
}
2611+
2612+
await expect(
2613+
follower.requestReleaseRemoteSubset(`todos`, options),
2614+
).rejects.toThrow(`transient release transport failure`)
2615+
expect({
2616+
releaseAttempts,
2617+
retained: followerInternals.outboundRemoteSubsetAcquisitions.size,
2618+
unloads: owner.unloadSubset.mock.calls.length,
2619+
}).toEqual({ releaseAttempts: 1, retained: 1, unloads: 0 })
2620+
2621+
await follower.requestReleaseRemoteSubset(`todos`, options)
2622+
expect({
2623+
releaseAttempts,
2624+
retained: followerInternals.outboundRemoteSubsetAcquisitions.size,
2625+
unloads: owner.unloadSubset.mock.calls.length,
2626+
}).toEqual({ releaseAttempts: 2, retained: 0, unloads: 1 })
2627+
} finally {
2628+
unregisterOwner()
2629+
leader.dispose()
2630+
follower.dispose()
2631+
}
2632+
})
2633+
24052634
it(`keeps a Browser release tombstone when a transferred load rejects concurrently`, async () => {
24062635
const coordinator = createCoordinator()
24072636
coordinator.subscribe(`todos`, () => {})

0 commit comments

Comments
 (0)