Skip to content

Commit 10c595e

Browse files
Round 19: Provider failover working, fract_as_ordered skeleton, sandbox tests
Provider failover fix: - Client tabs now hold Web Locks directly (auto-release on close) - Non-provider tabs poll for lock availability (1s interval) - Auto re-open database from OPFS on failover - All 18 browser tests now pass including failover test crsql_fract_as_ordered: - Add skeleton for fractional ordering setup function - Validates table name and order column arguments - TODO comments outline full implementation C oracle harness: - Add sandbox.test.c (passes) - Now 5 test suites: rowid, vtab, rows_impacted, crsql, sandbox
1 parent 0211a77 commit 10c595e

File tree

7 files changed

+345
-57
lines changed

7 files changed

+345
-57
lines changed

zig/browser-test/fixtures/coordinator.js

Lines changed: 13 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

zig/browser-test/fixtures/crsql-multitab.js

Lines changed: 88 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

zig/browser-test/src/client/db-client.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
createErrorResponse,
1717
RPC_TIMEOUT_MS,
1818
SHARED_WORKER_PATH,
19+
PROVIDER_LOCK,
1920
} from '../shared';
2021

2122
export interface DbClientOptions {
@@ -47,6 +48,13 @@ export class DbClient {
4748
{ clientId: string; requestId: RequestId }
4849
>();
4950

51+
// Track whether database has been opened (for failover recovery)
52+
private databaseOpened = false;
53+
54+
// Provider polling interval (for detecting provider loss)
55+
private providerPollInterval: ReturnType<typeof setInterval> | null = null;
56+
private static readonly PROVIDER_POLL_INTERVAL_MS = 1000;
57+
5058
readonly dbName: string;
5159

5260
constructor(options: DbClientOptions) {
@@ -67,8 +75,19 @@ export class DbClient {
6775

6876
this.port.onmessage = (event) => this.handleMessage(event.data);
6977
this.port.start();
78+
79+
// Register unload handler to notify SharedWorker when tab closes
80+
this.handleBeforeUnload = () => {
81+
console.log('[DbClient] Tab closing, sending disconnect message');
82+
this.port?.postMessage({ type: 'disconnect' });
83+
};
84+
globalThis.addEventListener('beforeunload', this.handleBeforeUnload);
85+
// Also use pagehide which is more reliable on mobile
86+
globalThis.addEventListener('pagehide', this.handleBeforeUnload);
7087
}
7188

89+
private handleBeforeUnload: () => void = () => {};
90+
7291
private handleMessage(msg: unknown) {
7392
const message = msg as {
7493
type: string;
@@ -93,10 +112,19 @@ export class DbClient {
93112
console.log('[DbClient] Provider elected. Am I provider?', this.isProvider);
94113
if (this.isProvider) {
95114
this.initializeProviderWorker();
115+
this.stopProviderPolling();
116+
} else {
117+
// Start polling in case provider dies without sending disconnect
118+
this.startProviderPolling();
96119
}
97120
this.resolveReady();
98121
break;
99122

123+
case 'try-become-provider':
124+
console.log('[DbClient] Asked to try becoming provider');
125+
this.tryAcquireProviderLock();
126+
break;
127+
100128
case 'forward-request':
101129
// Only provider should receive this
102130
if (this.isProvider && message.clientId && message.request) {
@@ -121,6 +149,68 @@ export class DbClient {
121149
}
122150
}
123151

152+
/**
153+
* Start polling for provider lock availability.
154+
* This handles the case where the provider dies without sending a disconnect message.
155+
*/
156+
private startProviderPolling() {
157+
if (this.providerPollInterval) return; // Already polling
158+
159+
console.log('[DbClient] Starting provider polling');
160+
this.providerPollInterval = setInterval(() => {
161+
if (!this.isProvider) {
162+
this.tryAcquireProviderLock();
163+
}
164+
}, DbClient.PROVIDER_POLL_INTERVAL_MS);
165+
}
166+
167+
/**
168+
* Stop polling for provider lock availability.
169+
*/
170+
private stopProviderPolling() {
171+
if (this.providerPollInterval) {
172+
console.log('[DbClient] Stopping provider polling');
173+
clearInterval(this.providerPollInterval);
174+
this.providerPollInterval = null;
175+
}
176+
}
177+
178+
/**
179+
* Attempt to acquire the provider Web Lock.
180+
* If successful, notify the coordinator that we are now the provider.
181+
* The lock is held for the lifetime of the tab - when the tab closes,
182+
* the browser automatically releases the lock.
183+
*/
184+
private async tryAcquireProviderLock() {
185+
const lockName = PROVIDER_LOCK(this.dbName);
186+
console.log('[DbClient] Trying to acquire provider lock:', lockName);
187+
188+
try {
189+
await navigator.locks.request(
190+
lockName,
191+
{ mode: 'exclusive', ifAvailable: true },
192+
async (lock) => {
193+
if (lock) {
194+
console.log('[DbClient] Acquired provider lock!');
195+
// Notify SharedWorker that we are now the provider
196+
this.port?.postMessage({ type: 'became-provider' });
197+
198+
// Hold the lock indefinitely by never resolving
199+
// The lock is automatically released when the tab closes
200+
await new Promise<void>(() => {
201+
// Never resolves - holds lock until tab closes
202+
});
203+
} else {
204+
console.log('[DbClient] Provider lock not available');
205+
// Another tab already has the lock - we'll receive provider-elected message
206+
}
207+
}
208+
);
209+
} catch (e) {
210+
console.error('[DbClient] Failed to acquire provider lock:', e);
211+
}
212+
}
213+
124214
/**
125215
* Initialize the dedicated worker for database operations (provider only)
126216
*/
@@ -136,6 +226,21 @@ export class DbClient {
136226
this.providerWorker.onerror = (error) => {
137227
console.error('[DbClient] Provider worker error:', error);
138228
};
229+
230+
// If database was previously opened, re-open it for failover recovery
231+
// This ensures the new provider can serve queries immediately
232+
if (this.databaseOpened) {
233+
console.log('[DbClient] Re-opening database after failover:', this.dbName);
234+
// Need to wait for worker to be ready before sending open request
235+
// Use a small delay to ensure worker message handler is set up
236+
setTimeout(() => {
237+
this.sendRequestToLocalWorker(
238+
createRequest('open', crypto.randomUUID(), { dbName: this.dbName })
239+
).catch((e) => {
240+
console.error('[DbClient] Failed to re-open database after failover:', e);
241+
});
242+
}, 0);
243+
}
139244
}
140245

141246
/**
@@ -285,12 +390,14 @@ export class DbClient {
285390
await this.sendRequest(
286391
createRequest('open', crypto.randomUUID(), { dbName: this.dbName })
287392
);
393+
this.databaseOpened = true;
288394
}
289395

290396
async close(): Promise<void> {
291397
await this.sendRequest(
292398
createRequest('close', crypto.randomUUID(), { dbName: this.dbName })
293399
);
400+
this.databaseOpened = false;
294401
}
295402

296403
async exec(sql: string, bind?: unknown[]): Promise<{ changes: number }> {
@@ -313,6 +420,13 @@ export class DbClient {
313420
}
314421

315422
disconnect() {
423+
// Stop provider polling
424+
this.stopProviderPolling();
425+
// Remove unload listeners
426+
globalThis.removeEventListener('beforeunload', this.handleBeforeUnload);
427+
globalThis.removeEventListener('pagehide', this.handleBeforeUnload);
428+
// Notify SharedWorker
429+
this.port?.postMessage({ type: 'disconnect' });
316430
this.port?.close();
317431
this.worker = null;
318432
this.port = null;

0 commit comments

Comments
 (0)