feat(notes): push, long polling and fallback detection for note sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m16s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m16s
Phase 4 of the collaboration plan:
- The sync engine listens for DOC_SEQ in the note's realtime room and syncs
right away. Without WebSocket/SSE it long-polls /docs/{id}/changes (25 s);
after three failures it falls back to polling every 3 s and retries long
polling when the browser comes back online.
- With push or long polling the engine only syncs every 7 s while holding
leases (renewal), otherwise every 30 s as a safety net. Focusing a block
announces its lease immediately (otherwise it could take up to 30 s).
- Realtime connection: SSE counts as connected only after its first event;
a stream that stays silent for 5 s (buffering proxy) switches SSE off for
the tab. App-level PING/PONG detects WebSockets that are accepted but
forward nothing and switches to SSE after 35 s.
- The editor shows the active channel for shared notes.
- Tests: engine channel tests (push, heartbeat, lease on focus, long
polling, interval fallback, switching), realtime fallback tests with fake
WebSocket/EventSource, Go tests for PONG and DOC_SEQ delivery, and
e2e/channels.e2e.mjs measuring latency behind six simulated proxy setups
(0.2-0.3 s on all live channels, ~3 s with plain polling).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa397c18cd
commit
8b7a53daab
18 files changed
+783
-37
No files matched your search
@@ -4,6 +4,7 @@ import type { DocumentBlock } from '../../types';
|
||||
import { DocSyncEngine, formBatch } from '../engine';
|
||||
import { applyOp, cloneDoc, Doc, emptyDoc, Op, Result } from '../model';
|
||||
import { IndexedDBStorage, MemoryStorage } from '../persist';
|
||||
import { PushChannel, PushKind } from '../push';
|
||||
import { LogEntry, SnapshotResponse, SyncRequest, SyncResponse, Transport, TransportError } from '../transport';
|
||||
|
||||
/** In-memory stand-in for the Go server: same operation rules, log and idempotency. */
|
||||
@@ -13,6 +14,12 @@ class FakeServer {
|
||||
results = new Map<string, Result>();
|
||||
roles = new Map<string, string>(); // clientId -> role
|
||||
applyCount = new Map<string, number>();
|
||||
private waiters: (() => void)[] = [];
|
||||
|
||||
/** Resolves on the next change (used by the long-poll stand-in). */
|
||||
nextChange(): Promise<void> {
|
||||
return new Promise((r) => this.waiters.push(r));
|
||||
}
|
||||
|
||||
constructor(initial: { id: string; content: string }[] = [{ id: 'b1', content: 'Start' }]) {
|
||||
let key = 'a0';
|
||||
@@ -30,6 +37,11 @@ class FakeServer {
|
||||
for (const e of effects) this.log.push({ seq: this.log.length + 1, userId: clientId, clientId, opId: op.opId, effect: e });
|
||||
if (effects.length > 0) result.seq = this.log.length;
|
||||
this.results.set(op.opId, result);
|
||||
if (effects.length > 0) {
|
||||
const w = this.waiters;
|
||||
this.waiters = [];
|
||||
w.forEach((fn) => fn());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -66,6 +78,8 @@ class FakeServer {
|
||||
/** Network between one client and the fake server, with switchable failures. */
|
||||
class FakeNet implements Transport {
|
||||
mode: 'ok' | 'down' | 'lose-response' = 'ok';
|
||||
longPollDown = false;
|
||||
changesCalls = 0;
|
||||
duringRequest: (() => void) | null = null; // runs while the request is "on the wire"
|
||||
|
||||
constructor(private server: FakeServer, private role = 'owner') {}
|
||||
@@ -84,6 +98,40 @@ class FakeNet implements Transport {
|
||||
return res;
|
||||
}
|
||||
async leave() {}
|
||||
async changes(_docId: string, since: number, waitSec: number, signal?: AbortSignal) {
|
||||
this.changesCalls++;
|
||||
if (this.mode === 'down' || this.longPollDown) throw new TransportError('blocked', 0);
|
||||
if (this.server.log.length === since) {
|
||||
await Promise.race([
|
||||
this.server.nextChange(),
|
||||
new Promise((r) => setTimeout(r, waitSec * 1000)),
|
||||
new Promise((_, reject) => signal?.addEventListener('abort', () => reject(new Error('aborted')))),
|
||||
]);
|
||||
}
|
||||
return { headSeq: this.server.log.length, changed: this.server.log.length !== since };
|
||||
}
|
||||
}
|
||||
|
||||
/** Push channel controlled by the test. */
|
||||
class FakePush implements PushChannel {
|
||||
kind: PushKind = 'none';
|
||||
private poke: (() => void) | null = null;
|
||||
private status: ((k: PushKind) => void) | null = null;
|
||||
subscribe(_docId: string, onPoke: () => void, onStatus: (k: PushKind) => void) {
|
||||
this.poke = onPoke;
|
||||
this.status = onStatus;
|
||||
onStatus(this.kind);
|
||||
return () => {
|
||||
this.poke = null;
|
||||
};
|
||||
}
|
||||
setKind(k: PushKind) {
|
||||
this.kind = k;
|
||||
this.status?.(k);
|
||||
}
|
||||
notify() {
|
||||
this.poke?.();
|
||||
}
|
||||
}
|
||||
|
||||
function flatOf(engine: DocSyncEngine): DocumentBlock[] {
|
||||
@@ -254,27 +302,23 @@ describe('DocSyncEngine', () => {
|
||||
expect(serverContents(server)).toEqual(['Start']);
|
||||
});
|
||||
|
||||
it('polls every 3 s right after reopening a shared note from local storage', async () => {
|
||||
it('changes of others arrive within seconds right after reopening a shared note from local storage', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
let calls = 0;
|
||||
const sync = net.sync.bind(net);
|
||||
net.sync = async (...args: Parameters<typeof sync>) => {
|
||||
calls++;
|
||||
return sync(...args);
|
||||
};
|
||||
net.longPollDown = true; // worst case: only plain polling works
|
||||
// Opened before: the note is reopened from local storage (like after a reload)
|
||||
const storage = new MemoryStorage();
|
||||
const first = new DocSyncEngine('doc', { transport: net, storage, manual: true });
|
||||
await first.start();
|
||||
first.stop();
|
||||
const engine = new DocSyncEngine('doc', { transport: net, storage });
|
||||
const engine = new DocSyncEngine('doc', { transport: net, storage, push: new FakePush() });
|
||||
await engine.start();
|
||||
const afterStart = calls;
|
||||
await vi.advanceTimersByTimeAsync(5_000); // long polling gives up after three attempts
|
||||
server.apply({ opId: 'remote', kind: 'set_content', blockId: 'b1', baseVersion: 1, content: 'Von anderen' }, 'other');
|
||||
await vi.advanceTimersByTimeAsync(3_500);
|
||||
expect(calls).toBeGreaterThan(afterStart); // not only after 30 s
|
||||
expect(contents(engine)).toEqual(['Von anderen']); // not only after 30 s
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -303,3 +347,141 @@ describe('DocSyncEngine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('how changes of others arrive', () => {
|
||||
async function live(push: FakePush, net: FakeNet) {
|
||||
const engine = new DocSyncEngine('doc', { transport: net, storage: new MemoryStorage(), push });
|
||||
await engine.start();
|
||||
await vi.advanceTimersByTimeAsync(100); // let the start-up round trips settle
|
||||
return engine;
|
||||
}
|
||||
const otherEdit = (server: FakeServer, text: string) =>
|
||||
server.apply({ opId: `other-${text}`, kind: 'set_content', blockId: 'b1', baseVersion: server.doc.blocks.get('b1')!.version, content: text }, 'other');
|
||||
|
||||
it('push: syncs on a notification and otherwise only checks every 30 s', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
const push = new FakePush();
|
||||
push.kind = 'ws';
|
||||
const engine = await live(push, net);
|
||||
expect(engine.getState().channel).toBe('ws');
|
||||
otherEdit(server, 'Von anderen');
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(contents(engine)).toEqual(['Start']); // no notification, no poll yet
|
||||
push.notify();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(contents(engine)).toEqual(['Von anderen']);
|
||||
expect(net.changesCalls).toBe(0);
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('push: renews leases every 7 s while a block is held', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
const push = new FakePush();
|
||||
push.kind = 'sse';
|
||||
const engine = await live(push, net);
|
||||
let syncs = 0;
|
||||
const orig = net.sync.bind(net);
|
||||
net.sync = async (...a: Parameters<typeof orig>) => {
|
||||
syncs++;
|
||||
return orig(...a);
|
||||
};
|
||||
engine.focusBlock('b1');
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(syncs).toBeGreaterThanOrEqual(2);
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('without push: long polling delivers changes right away', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
const engine = await live(new FakePush(), net);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(engine.getState().channel).toBe('longpoll');
|
||||
otherEdit(server, 'Per Long-Polling');
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(contents(engine)).toEqual(['Per Long-Polling']);
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to polling every 3 s when long polling is blocked too', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
net.longPollDown = true;
|
||||
const engine = await live(new FakePush(), net);
|
||||
await vi.advanceTimersByTimeAsync(5_000); // three failed attempts with back-off
|
||||
expect(engine.getState().channel).toBe('interval');
|
||||
otherEdit(server, 'Per Abfrage');
|
||||
await vi.advanceTimersByTimeAsync(3_500);
|
||||
expect(contents(engine)).toEqual(['Per Abfrage']);
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('push: requests the lease right after focusing a block', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
const push = new FakePush();
|
||||
push.kind = 'ws';
|
||||
const engine = await live(push, net);
|
||||
const holds: string[][] = [];
|
||||
const orig = net.sync.bind(net);
|
||||
net.sync = async (id: string, body: SyncRequest, o?: { keepalive?: boolean }) => {
|
||||
holds.push(body.leases?.hold || []);
|
||||
return orig(id, body, o);
|
||||
};
|
||||
engine.focusBlock('b1');
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(holds.some((h) => h.includes('b1'))).toBe(true);
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('switches between push and long polling when the connection changes', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
const push = new FakePush();
|
||||
push.kind = 'ws';
|
||||
const engine = await live(push, net);
|
||||
push.setKind('none');
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(engine.getState().channel).toBe('longpoll');
|
||||
push.setKind('ws');
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(engine.getState().channel).toBe('ws');
|
||||
otherEdit(server, 'Wieder per Push');
|
||||
push.notify();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(contents(engine)).toEqual(['Wieder per Push']);
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ class Server {
|
||||
};
|
||||
},
|
||||
leave: async () => {},
|
||||
changes: async () => ({ headSeq: this.log.length, changed: false }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user