Skip to content

Commit 3a06d38

Browse files
committed
feat(files): flush the live doc before a retype reads its bytes back
Changing a collaborative markdown file's type unmounts its editor and mounts one that reads the file's durable bytes. The relay owns durability for that document and persists on a 5s debounce, so the read raced the write and returned the content from before the last edits. The client cannot close this itself: its save path is disabled for a collaborative doc by design, and isDirty is pinned false. Adds a FLUSH/FLUSH_COMPLETE round trip so the client can ask the relay to project the document now and wait for the answer. flushPersist grows a mode and returns an outcome: only a debounced flush may be coalesced away by the cross-task dedup window, because a deduped no-op acked as success would ship exactly the staleness this closes. The client wait is bounded well under the persist budget and a lapsed wait proceeds with the rename rather than blocking the user.
1 parent 2a1452d commit 3a06d38

9 files changed

Lines changed: 509 additions & 34 deletions

File tree

apps/realtime/src/handlers/file-doc.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,120 @@ describe('setupWorkspaceFileDocHandlers', () => {
368368
expect(mockFetchFileDocPersist).toHaveBeenCalled()
369369
})
370370

371+
describe('FLUSH', () => {
372+
/** Joins, seeds, and lands one real user edit so the room is `edited` and worth persisting. */
373+
async function joinAndEdit(handlers: Record<string, Handler>) {
374+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
375+
await flushMicrotasks()
376+
const edit = new Y.Doc()
377+
edit.getText(FILE_DOC_FIELD).insert(0, 'user typed this')
378+
handlers[FILE_DOC_EVENTS.MESSAGE](
379+
frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) =>
380+
syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit))
381+
)
382+
)
383+
await flushMicrotasks()
384+
}
385+
386+
function flushAcks(socket: { emit: ReturnType<typeof vi.fn> }) {
387+
return socket.emit.mock.calls
388+
.filter((call: unknown[]) => call[0] === FILE_DOC_EVENTS.FLUSH_COMPLETE)
389+
.map((call: unknown[]) => call[1])
390+
}
391+
392+
it('persists immediately and acks with the resulting version', async () => {
393+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
394+
mockFetchFileDocPersist.mockResolvedValue({ status: 'persisted', version: 77 })
395+
const { io } = createIo()
396+
const { handlers, socket } = setup('socket-1', io)
397+
await joinAndEdit(handlers)
398+
399+
await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })
400+
401+
expect(mockFetchFileDocPersist).toHaveBeenCalled()
402+
expect(flushAcks(socket)).toEqual([{ fileId: 'file-1', status: 'persisted', version: 77 }])
403+
})
404+
405+
it('acks unchanged — never persisted — for a doc nobody edited', async () => {
406+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
407+
const { io } = createIo()
408+
const { handlers, socket } = setup('socket-1', io)
409+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
410+
await flushMicrotasks()
411+
412+
await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })
413+
414+
// Projecting an unedited seed back over the file is the clobber the `edited` gate exists to
415+
// prevent, so a flush must not force one.
416+
expect(mockFetchFileDocPersist).not.toHaveBeenCalled()
417+
expect(flushAcks(socket)).toEqual([{ fileId: 'file-1', status: 'unchanged' }])
418+
})
419+
420+
it('acks skipped — not persisted — when the durable file advanced out-of-band', async () => {
421+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
422+
mockFetchFileDocPersist.mockResolvedValue({ status: 'conflict' })
423+
const { io } = createIo()
424+
const { handlers, socket } = setup('socket-1', io)
425+
await joinAndEdit(handlers)
426+
427+
await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })
428+
429+
// The caller must be able to tell this from a real write: the durable bytes are NOT current.
430+
expect(flushAcks(socket)).toEqual([{ fileId: 'file-1', status: 'skipped' }])
431+
})
432+
433+
it('acks skipped when the persist request fails', async () => {
434+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
435+
mockFetchFileDocPersist.mockRejectedValue(new Error('app unreachable'))
436+
const { io } = createIo()
437+
const { handlers, socket } = setup('socket-1', io)
438+
await joinAndEdit(handlers)
439+
440+
await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })
441+
442+
expect(flushAcks(socket)).toEqual([{ fileId: 'file-1', status: 'skipped' }])
443+
})
444+
445+
it('cancels the pending debounce so no redundant second write follows', async () => {
446+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
447+
mockFetchFileDocPersist.mockResolvedValue({ status: 'persisted', version: 5 })
448+
vi.useFakeTimers()
449+
try {
450+
const { io } = createIo()
451+
const { handlers } = setup('socket-1', io)
452+
await joinAndEdit(handlers)
453+
// The edit armed the debounce; the flush must disarm it.
454+
await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' })
455+
expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1)
456+
457+
await vi.advanceTimersByTimeAsync(30_000)
458+
expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1)
459+
} finally {
460+
vi.useRealTimers()
461+
}
462+
})
463+
464+
it('refuses a flush for a file this socket never joined', async () => {
465+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
466+
const { io } = createIo()
467+
const { handlers, socket } = setup('socket-1', io)
468+
await joinAndEdit(handlers)
469+
470+
await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'someone-elses-file' })
471+
472+
// Membership IS the authorization here — a socket must not force a write to another document.
473+
expect(mockFetchFileDocPersist).not.toHaveBeenCalled()
474+
expect(flushAcks(socket)).toEqual([{ fileId: 'someone-elses-file', status: 'unchanged' }])
475+
})
476+
477+
it('ignores a payload with no fileId', async () => {
478+
const { io } = createIo()
479+
const { handlers, socket } = setup('socket-1', io)
480+
await handlers[FILE_DOC_EVENTS.FLUSH]({})
481+
expect(flushAcks(socket)).toEqual([])
482+
})
483+
})
484+
371485
it('drops document frames and evicts once the editor loses write access mid-session', async () => {
372486
// The join-time check is not a standing right: a collaborator downgraded to `read`
373487
// (or removed) must stop landing durable edits on the socket they already hold.

apps/realtime/src/handlers/file-doc.ts

Lines changed: 104 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import {
3131
FILE_DOC_SEED,
3232
FILE_DOC_TIMEOUTS,
3333
type FileDocPresenceUser,
34+
type FlushFileDocPayload,
35+
type FlushFileDocResult,
3436
type JoinFileDocPayload,
3537
type LeaveFileDocPayload,
3638
toFileDocBytes,
@@ -260,27 +262,52 @@ function schedulePersist(name: string, room: FileDocRoom): void {
260262
room.persistTimer = setTimeout(() => {
261263
room.persistTimer = null
262264
room.persistDeadline = null
263-
void flushPersist(name, room, false)
265+
void flushPersist(name, room, 'debounced')
264266
}, delay)
265267
}
266268

267269
/**
268-
* Project the live doc to markdown and write it durably via the app. `final` (last collaborator
269-
* leaving) always writes; a debounced mid-edit flush first claims a best-effort cross-task dedup WINDOW
270-
* (a TTL key that just expires, so at most ~one persist per window cluster-wide) so concurrent tasks
271-
* editing the same file don't each write a redundant blob version. Best-effort: never throws (a failure
270+
* Why a flush is running. Only `debounced` is subject to the cross-task dedup window: the other two
271+
* have a waiter that would mistake a deduped no-op for a completed write.
272+
*
273+
* - `debounced` — the mid-edit timer fired. Coalescable, nobody is waiting.
274+
* - `final` — last collaborator leaving, or shutdown. Last chance before teardown.
275+
* - `requested` — a client asked for it and is waiting on the outcome ({@link FILE_DOC_EVENTS.FLUSH}).
276+
*/
277+
type FlushMode = 'debounced' | 'final' | 'requested'
278+
279+
/** What a {@link flushPersist} call actually did. Mirrors {@link FlushFileDocResult}'s status. */
280+
type FlushPersistOutcome =
281+
| { status: 'persisted'; version: number }
282+
| { status: 'unchanged' }
283+
| { status: 'skipped' }
284+
285+
/**
286+
* Project the live doc to markdown and write it durably via the app. A `debounced` mid-edit flush
287+
* first claims a best-effort cross-task dedup WINDOW (a TTL key that just expires, so at most ~one
288+
* persist per window cluster-wide) so concurrent tasks editing the same file don't each write a
289+
* redundant blob version; `final` and `requested` always write. Best-effort: never throws (a failure
272290
* is retried on the next debounce; the stream holds the state meanwhile).
273291
*
292+
* Returns what actually happened so a `requested` flush can be acked truthfully — several paths here
293+
* complete having written nothing, and a caller that treats "returned" as "persisted" would ship
294+
* exactly the staleness the flush exists to prevent.
295+
*
274296
* Persists the AUTHORITATIVE shared state (the stream), not this task's local doc: a copilot merge — or
275297
* a peer's edit — published by another task may not be integrated into `room.doc` yet (and the stream
276298
* holds content even when THIS task's doc was never locally seeded), so a last-disconnect flush can't
277299
* clobber the durable file with a lagging projection. The local doc is captured SYNCHRONOUSLY as a
278-
* fallback before any await, so a `void flushPersist(name, room, true)` fired immediately before the
300+
* fallback before any await, so a `void flushPersist(name, room, 'final')` fired immediately before the
279301
* caller destroys `room.doc` never encodes a destroyed doc, and the disabled path stays authoritative.
280302
*/
281-
async function flushPersist(name: string, room: FileDocRoom, final: boolean): Promise<void> {
303+
async function flushPersist(
304+
name: string,
305+
room: FileDocRoom,
306+
mode: FlushMode
307+
): Promise<FlushPersistOutcome> {
282308
// Never project a doc no user actually edited back over the file (see {@link FileDocRoom.edited}).
283-
if (!room.edited || !room.workspaceId || !room.lastEditorUserId) return
309+
// Nothing to write is not a failure — the durable content is already current.
310+
if (!room.edited || !room.workspaceId || !room.lastEditorUserId) return { status: 'unchanged' }
284311
const store = getFileDocStore()
285312
const workspaceId = room.workspaceId
286313
const userId = room.lastEditorUserId
@@ -327,18 +354,24 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr
327354
}
328355

329356
try {
330-
if (!final && !(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs)))
331-
return
357+
// Only a debounced flush may be coalesced away. A `requested` flush has a client waiting on the
358+
// outcome, so losing the claim must not report back as a completed write.
359+
if (
360+
mode === 'debounced' &&
361+
!(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs))
362+
)
363+
return { status: 'skipped' }
332364

333365
// The If-Match token: the durable content version the live doc is synced to.
334366
let ifMatch = await currentVersion()
335-
// FINAL flush = last chance before teardown: if the version read momentarily fails (Redis blip) for a
336-
// peer-seeded/tail-only task that never cached it, retry briefly rather than defer and strand the
337-
// edits in the TTL'd stream (the version is cluster-wide + heartbeat-refreshed). Bounded — a genuinely
338-
// unset version never appears, and the flush must not stall teardown.
367+
// A flush with no second chance (last-leave teardown) or with a waiter (`requested`): if the version
368+
// read momentarily fails (Redis blip) for a peer-seeded/tail-only task that never cached it, retry
369+
// briefly rather than defer and strand the edits in the TTL'd stream (the version is cluster-wide +
370+
// heartbeat-refreshed). Bounded — a genuinely unset version never appears, the flush must not stall
371+
// teardown, and 2x100ms stays far inside the client's flush budget.
339372
for (
340373
let i = 0;
341-
ifMatch === undefined && final && store.enabled && i < FINAL_VERSION_RETRIES;
374+
ifMatch === undefined && mode !== 'debounced' && store.enabled && i < FINAL_VERSION_RETRIES;
342375
i++
343376
) {
344377
await sleep(FINAL_VERSION_RETRY_MS)
@@ -349,19 +382,21 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr
349382
// still at the version the live doc synced from, so a projection can never silently clobber an
350383
// out-of-band edit. A single attempt — on conflict we STOP rather than retry (see below).
351384
const docState = await captureState()
352-
if (!docState) return // nothing seeded/authoritative to persist yet
385+
// Nothing seeded/authoritative to persist yet.
386+
if (!docState) return { status: 'skipped' }
353387
const result = await fetchFileDocPersist(workspaceId, room.fileId, userId, docState, ifMatch)
354-
if (result.status === 'missing') return // the file was deleted; nothing to write
388+
// The file was deleted; nothing to write.
389+
if (result.status === 'missing') return { status: 'skipped' }
355390
if (result.status === 'deferred') {
356391
// No version token available (momentarily — a Redis blip on a peer-seeded task). Leave the edits in
357392
// the stream; a later persist writes them once the version is re-established.
358393
logger.warn(`Persist deferred for file ${room.fileId} (no synced version available yet)`)
359-
return
394+
return { status: 'skipped' }
360395
}
361396
if (result.status === 'persisted') {
362397
room.syncedVersion = Math.max(room.syncedVersion ?? 0, result.version)
363398
void store.setSyncedVersion(name, result.version)
364-
return
399+
return { status: 'persisted', version: result.version }
365400
}
366401
// status === 'conflict': the durable file advanced out-of-band since our If-Match token. We do NOT
367402
// re-persist against the current stream: an external write commits durable BEFORE its chokepoint merge
@@ -375,8 +410,10 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr
375410
logger.warn(
376411
`Persist conflict for file ${room.fileId}; durable content advanced out-of-band, left authoritative`
377412
)
413+
return { status: 'skipped' }
378414
} catch (error) {
379415
logger.warn(`Persist failed for file ${room.fileId}`, { error: getErrorMessage(error) })
416+
return { status: 'skipped' }
380417
}
381418
}
382419

@@ -446,7 +483,7 @@ function destroyRoomIfIdle(name: string) {
446483
}
447484
// Final durable flush BEFORE teardown — `flushPersist` encodes the doc synchronously (before the
448485
// destroy below) and awaits the write in the background. Best-effort; never throws.
449-
void flushPersist(name, room, true)
486+
void flushPersist(name, room, 'final')
450487
getFileDocStore().detachRoom(name)
451488
room.awareness.destroy()
452489
room.doc.destroy()
@@ -461,9 +498,9 @@ function destroyRoomIfIdle(name: string) {
461498
* process is exiting); only their durable state is secured.
462499
*/
463500
export async function flushAllFileDocRooms(): Promise<void> {
464-
const flushes: Promise<void>[] = []
501+
const flushes: Promise<unknown>[] = []
465502
for (const [name, room] of fileDocRooms) {
466-
if (room.edited) flushes.push(flushPersist(name, room, true))
503+
if (room.edited) flushes.push(flushPersist(name, room, 'final'))
467504
}
468505
await Promise.all(flushes)
469506
}
@@ -1231,6 +1268,51 @@ export function setupWorkspaceFileDocHandlers(
12311268

12321269
socket.on(FILE_DOC_EVENTS.MESSAGE, (data: unknown) => handleMessage(socket, io, data))
12331270

1271+
/**
1272+
* Persist the live document now, ahead of the debounce, and report what happened.
1273+
*
1274+
* The membership check is the authorization: `socketToRoomName` is only populated by a join that
1275+
* already passed the room's permission middleware, and the payload's file must match the room this
1276+
* socket actually holds — so a socket cannot force a write to a document it never joined.
1277+
*
1278+
* The pending debounce is cancelled first. Leaving it armed would fire a second, redundant blob
1279+
* version moments after this one for content that is already durable.
1280+
*/
1281+
socket.on(FILE_DOC_EVENTS.FLUSH, async (payload?: FlushFileDocPayload) => {
1282+
const fileId = payload?.fileId
1283+
if (!fileId) return
1284+
const ack = (status: FlushFileDocResult['status'], version?: number) => {
1285+
socket.emit(FILE_DOC_EVENTS.FLUSH_COMPLETE, {
1286+
fileId,
1287+
status,
1288+
...(version !== undefined ? { version } : {}),
1289+
} satisfies FlushFileDocResult)
1290+
}
1291+
1292+
try {
1293+
const name = socketToRoomName.get(socket.id)
1294+
// Not in a room, or in a different file's room: nothing of this client's is unpersisted here.
1295+
// Acked as `unchanged` rather than left silent so the caller's wait always resolves.
1296+
if (!name || roomName(fileDocRoom(fileId)) !== name) return ack('unchanged')
1297+
const room = fileDocRooms.get(name)
1298+
if (!room) return ack('unchanged')
1299+
1300+
if (room.persistTimer) {
1301+
clearTimeout(room.persistTimer)
1302+
room.persistTimer = null
1303+
}
1304+
room.persistDeadline = null
1305+
1306+
const outcome = await flushPersist(name, room, 'requested')
1307+
ack(outcome.status, outcome.status === 'persisted' ? outcome.version : undefined)
1308+
} catch (error) {
1309+
logger.error('Error flushing file-doc room:', error)
1310+
// `flushPersist` never throws, so reaching here means the room lookup did. The write did not
1311+
// happen, and the caller must not read the ack as durable.
1312+
ack('skipped')
1313+
}
1314+
})
1315+
12341316
socket.on(FILE_DOC_EVENTS.LEAVE, (payload?: LeaveFileDocPayload) => {
12351317
try {
12361318
// Cancel an in-flight join whose file the client is now leaving (or an unscoped leave): a

0 commit comments

Comments
 (0)