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
@@ -8,9 +8,13 @@ import { applyEffect, applyOp, cloneDoc, Doc, docFromSnapshot, emptyDoc, Op, Res
|
||||
import { defaultStorage, SyncStorage } from './persist';
|
||||
import { httpTransport, LeaseInfo, Presence, Transport, TransportError } from './transport';
|
||||
import { buildStep, failedChecks, touchedBlocks, UndoStep } from './undo';
|
||||
import { PushChannel, PushKind, realtimePush } from './push';
|
||||
|
||||
export type EngineStatus = 'loading' | 'saved' | 'saving' | 'offline' | 'error';
|
||||
|
||||
/** How changes of others reach this client: push (WebSocket/SSE), long polling or plain polling. */
|
||||
export type SyncChannel = 'ws' | 'sse' | 'longpoll' | 'interval';
|
||||
|
||||
export interface EngineState {
|
||||
ready: boolean;
|
||||
status: EngineStatus;
|
||||
@@ -26,6 +30,7 @@ export interface EngineState {
|
||||
conflicts: Result[]; // conflicts of own operations since start
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
channel: SyncChannel;
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
@@ -37,15 +42,21 @@ export interface EngineOptions {
|
||||
transport?: Transport;
|
||||
storage?: SyncStorage;
|
||||
now?: () => number;
|
||||
/** Disables timers and window listeners (tests drive the engine with syncNow()). */
|
||||
/** Disables timers, window listeners, push and long polling (tests drive the engine with syncNow()). */
|
||||
manual?: boolean;
|
||||
/** Push notifications; defaults to the app's realtime connection (not used in manual mode). */
|
||||
push?: PushChannel;
|
||||
}
|
||||
|
||||
const LEASE_GRACE_MS = 20_000; // keep a left block for this long
|
||||
const IDLE_RELEASE_MS = 60_000; // release even the focused block after this long without input
|
||||
const SYNC_DEBOUNCE_MS = 300;
|
||||
const POLL_COLLAB_MS = 3_000;
|
||||
const POLL_SOLO_MS = 30_000;
|
||||
const POLL_COLLAB_MS = 3_000; // shared note without push or long polling
|
||||
const POLL_SOLO_MS = 30_000; // private note, opened once
|
||||
const HEARTBEAT_MS = 7_000; // renews block leases while changes arrive by push/long polling
|
||||
const SAFETY_POLL_MS = 30_000; // in case a push notification got lost
|
||||
const LONG_POLL_WAIT_S = 25; // below typical proxy timeouts (nginx 60 s, Cloudflare 100 s)
|
||||
const LONG_POLL_MAX_FAILURES = 3;
|
||||
const BACKOFF_MAX_MS = 30_000;
|
||||
const UNDO_LIMIT = 200;
|
||||
const UNDO_GROUP_MS = 1_000; // keystrokes in the same block are one undo step until a pause this long
|
||||
@@ -131,6 +142,14 @@ export class DocSyncEngine {
|
||||
private undoStack: UndoStep[] = [];
|
||||
private redoStack: UndoStep[] = [];
|
||||
|
||||
private push: PushChannel | null;
|
||||
private pushKind: PushKind = 'none';
|
||||
private unsubscribePush: (() => void) | null = null;
|
||||
private pokeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private longPollRunning = false;
|
||||
private longPollBroken = false;
|
||||
private longPollAbort: AbortController | null = null;
|
||||
|
||||
private syncing = false;
|
||||
private syncAgain = false;
|
||||
private stopped = false;
|
||||
@@ -144,6 +163,7 @@ export class DocSyncEngine {
|
||||
this.storage = opts.storage || defaultStorage();
|
||||
this.now = opts.now || (() => Date.now());
|
||||
this.manual = !!opts.manual;
|
||||
this.push = opts.push || (this.manual ? null : realtimePush);
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
@@ -165,6 +185,13 @@ export class DocSyncEngine {
|
||||
// Storage unavailable: continue with the server state only
|
||||
}
|
||||
if (!this.manual) this.attachWindowListeners();
|
||||
if (this.push) {
|
||||
this.unsubscribePush = this.push.subscribe(
|
||||
this.docId,
|
||||
() => this.onPoke(),
|
||||
(kind) => this.onPushStatus(kind)
|
||||
);
|
||||
}
|
||||
if (!this.ready) {
|
||||
try {
|
||||
await this.loadSnapshot();
|
||||
@@ -185,6 +212,10 @@ export class DocSyncEngine {
|
||||
this.flushOnExit();
|
||||
this.stopped = true;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
if (this.pokeTimer) clearTimeout(this.pokeTimer);
|
||||
this.longPollAbort?.abort();
|
||||
this.unsubscribePush?.();
|
||||
this.unsubscribePush = null;
|
||||
this.cleanup.forEach((fn) => fn());
|
||||
this.cleanup = [];
|
||||
this.listeners.clear();
|
||||
@@ -212,6 +243,7 @@ export class DocSyncEngine {
|
||||
conflicts: this.conflicts,
|
||||
canUndo: this.undoStack.length > 0,
|
||||
canRedo: this.redoStack.length > 0,
|
||||
channel: this.channel(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -314,9 +346,15 @@ export class DocSyncEngine {
|
||||
/** Tells the engine which block the cursor is in (null = none); used for block leases. */
|
||||
focusBlock(blockId: string | null): void {
|
||||
const t = this.now();
|
||||
const changed = blockId !== this.focused;
|
||||
if (this.focused) this.activity.set(this.focused, t);
|
||||
this.focused = blockId;
|
||||
if (blockId) this.activity.set(blockId, t);
|
||||
// Announce the lease right away: with push the next regular sync may be up to 30 s away
|
||||
if (changed && blockId && this.canEdit && this.collaborative()) {
|
||||
if (this.syncing) this.syncAgain = true;
|
||||
else this.schedule(SYNC_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs one sync round trip now (or right after the running one). */
|
||||
@@ -331,19 +369,26 @@ export class DocSyncEngine {
|
||||
this.timer = null;
|
||||
}
|
||||
this.syncing = true;
|
||||
let delay: number;
|
||||
let failed = false;
|
||||
try {
|
||||
await this.roundTrip();
|
||||
this.backoff = 0;
|
||||
// Decided only now: the response tells whether the note is shared (3 s) or private (30 s)
|
||||
delay = this.syncAgain || this.pending.length > 0 ? 0 : this.pollDelay();
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
delay = this.status === 'error' ? this.pollDelay() : this.nextBackoff();
|
||||
failed = true;
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
this.syncAgain = false;
|
||||
this.recompute();
|
||||
if (!failed) this.ensureLongPoll();
|
||||
// Decided only now: the response tells whether the note is shared and how changes arrive
|
||||
const delay = failed
|
||||
? this.status === 'error'
|
||||
? this.pollDelay()
|
||||
: this.nextBackoff()
|
||||
: this.syncAgain || this.pending.length > 0
|
||||
? 0
|
||||
: this.pollDelay();
|
||||
this.syncAgain = false;
|
||||
this.emit();
|
||||
this.schedule(delay);
|
||||
}
|
||||
@@ -548,8 +593,76 @@ export class DocSyncEngine {
|
||||
this.errorMessage = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
private channel(): SyncChannel {
|
||||
if (this.pushKind !== 'none') return this.pushKind;
|
||||
if (this.longPollRunning && !this.longPollBroken) return 'longpoll';
|
||||
return 'interval';
|
||||
}
|
||||
|
||||
private pollDelay(): number {
|
||||
return this.collaborative() ? POLL_COLLAB_MS : POLL_SOLO_MS;
|
||||
if (!this.collaborative()) return POLL_SOLO_MS;
|
||||
if (this.channel() === 'interval') return POLL_COLLAB_MS;
|
||||
return this.canEdit && this.holds().length > 0 ? HEARTBEAT_MS : SAFETY_POLL_MS;
|
||||
}
|
||||
|
||||
private onPoke(): void {
|
||||
if (this.pokeTimer || this.stopped) return;
|
||||
// Several notifications in a row (e.g. typing of others) lead to one sync
|
||||
this.pokeTimer = setTimeout(() => {
|
||||
this.pokeTimer = null;
|
||||
void this.syncNow();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
private onPushStatus(kind: PushKind): void {
|
||||
const wasLive = this.pushKind !== 'none';
|
||||
this.pushKind = kind;
|
||||
if (kind !== 'none') {
|
||||
this.longPollAbort?.abort();
|
||||
if (!wasLive) void this.syncNow(); // catch up on what was missed while disconnected
|
||||
} else {
|
||||
this.ensureLongPoll();
|
||||
if (!this.syncing) this.schedule(this.pollDelay());
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** Starts long polling when there is no push channel (e.g. a proxy blocks WebSocket and SSE). */
|
||||
private ensureLongPoll(): void {
|
||||
if (this.manual || this.stopped || !this.ready || this.longPollRunning || this.longPollBroken) return;
|
||||
if (this.pushKind !== 'none' || !this.collaborative() || this.status === 'error') return;
|
||||
void this.longPollLoop();
|
||||
}
|
||||
|
||||
private async longPollLoop(): Promise<void> {
|
||||
this.longPollRunning = true;
|
||||
this.emit();
|
||||
let failures = 0;
|
||||
try {
|
||||
while (!this.stopped && this.pushKind === 'none' && this.collaborative() && this.status !== 'error') {
|
||||
const ctrl = new AbortController();
|
||||
this.longPollAbort = ctrl;
|
||||
try {
|
||||
const res = await this.transport.changes(this.docId, this.lastSeq, LONG_POLL_WAIT_S, ctrl.signal);
|
||||
failures = 0;
|
||||
if (res.changed || res.headSeq !== this.lastSeq) await this.syncNow();
|
||||
} catch {
|
||||
if (ctrl.signal.aborted || this.stopped) break;
|
||||
failures++;
|
||||
if (failures >= LONG_POLL_MAX_FAILURES) {
|
||||
// Long polling does not get through either: plain polling every 3 s remains
|
||||
this.longPollBroken = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000 * failures));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.longPollRunning = false;
|
||||
this.longPollAbort = null;
|
||||
this.emit();
|
||||
if (!this.syncing && !this.stopped) this.schedule(this.pollDelay());
|
||||
}
|
||||
}
|
||||
|
||||
private nextBackoff(): number {
|
||||
@@ -580,7 +693,10 @@ export class DocSyncEngine {
|
||||
}
|
||||
|
||||
private attachWindowListeners(): void {
|
||||
const onOnline = () => void this.syncNow();
|
||||
const onOnline = () => {
|
||||
this.longPollBroken = false; // the network changed: long polling may work again
|
||||
void this.syncNow();
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') this.flushOnExit();
|
||||
else void this.syncNow();
|
||||
|
||||
Reference in new issue
Block a user