@@ -31,8 +31,6 @@ import {
3131 FILE_DOC_SEED ,
3232 FILE_DOC_TIMEOUTS ,
3333 type FileDocPresenceUser ,
34- type FlushFileDocPayload ,
35- type FlushFileDocResult ,
3634 type JoinFileDocPayload ,
3735 type LeaveFileDocPayload ,
3836 toFileDocBytes ,
@@ -83,16 +81,6 @@ const PERSIST_MAX_WAIT_MS = 20_000
8381const FINAL_VERSION_RETRIES = 2
8482const FINAL_VERSION_RETRY_MS = 100
8583
86- type PersistMode = 'debounced' | 'final' | 'requested'
87- type PersistOutcome =
88- | 'unchanged'
89- | 'persisted'
90- | 'missing'
91- | 'deferred'
92- | 'conflict'
93- | 'deduplicated'
94- | 'failed'
95-
9684/** Cross-task merge lock. The TTL must exceed the whole critical section it guards — stream fold +
9785 * `fetchFileDocMerge` (bounded at `mergeRequestMs`) + the awaited publish — so the lock never expires
9886 * mid-merge and lets a second task race the same base; hence `mergeRequestMs` plus generous headroom.
@@ -272,31 +260,27 @@ function schedulePersist(name: string, room: FileDocRoom): void {
272260 room . persistTimer = setTimeout ( ( ) => {
273261 room . persistTimer = null
274262 room . persistDeadline = null
275- void flushPersist ( name , room , 'debounced' )
263+ void flushPersist ( name , room , false )
276264 } , delay )
277265}
278266
279267/**
280- * Project the live doc to markdown and write it durably via the app. A final or explicitly requested
281- * flush always writes; a debounced mid-edit flush first claims a best-effort cross-task dedup WINDOW
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
282270 * (a TTL key that just expires, so at most ~one persist per window cluster-wide) so concurrent tasks
283- * editing the same file don't each write a redundant blob version. Returns an outcome so an export can
284- * wait for durable success; background callers still treat failures as best-effort .
271+ * editing the same file don't each write a redundant blob version. Best-effort: never throws (a failure
272+ * is retried on the next debounce; the stream holds the state meanwhile) .
285273 *
286274 * Persists the AUTHORITATIVE shared state (the stream), not this task's local doc: a copilot merge — or
287275 * a peer's edit — published by another task may not be integrated into `room.doc` yet (and the stream
288276 * holds content even when THIS task's doc was never locally seeded), so a last-disconnect flush can't
289277 * clobber the durable file with a lagging projection. The local doc is captured SYNCHRONOUSLY as a
290- * fallback before any await, so a `void flushPersist(name, room, 'final' )` fired immediately before the
278+ * fallback before any await, so a `void flushPersist(name, room, true )` fired immediately before the
291279 * caller destroys `room.doc` never encodes a destroyed doc, and the disabled path stays authoritative.
292280 */
293- async function flushPersist (
294- name : string ,
295- room : FileDocRoom ,
296- mode : PersistMode
297- ) : Promise < PersistOutcome > {
281+ async function flushPersist ( name : string , room : FileDocRoom , final : boolean ) : Promise < void > {
298282 // Never project a doc no user actually edited back over the file (see {@link FileDocRoom.edited}).
299- if ( ! room . edited || ! room . workspaceId || ! room . lastEditorUserId ) return 'unchanged'
283+ if ( ! room . edited || ! room . workspaceId || ! room . lastEditorUserId ) return
300284 const store = getFileDocStore ( )
301285 const workspaceId = room . workspaceId
302286 const userId = room . lastEditorUserId
@@ -306,10 +290,7 @@ async function flushPersist(
306290
307291 // Capture the AUTHORITATIVE doc state: the shared stream when enabled (a copilot merge or a peer's
308292 // edit published by another task may not be integrated into THIS task's `room.doc` yet), else the
309- // local snapshot. A requested export flush merges both CRDT snapshots: the socket's immediately
310- // preceding edit can still be in the stream publisher's fire-and-forget queue, while a peer edit can
311- // already be in the stream but not this task's doc. The CRDT union covers both without another save
312- // path or waiting on the normal debounce.
293+ // local snapshot. Re-read each attempt so a post-reconcile retry projects the converged state.
313294 const captureState = async ( ) : Promise < Uint8Array | null > => {
314295 if ( ! store . enabled ) {
315296 // Single-pod: re-read the live doc so a post-reconcile retry projects the CONVERGED state, not the
@@ -321,23 +302,12 @@ async function flushPersist(
321302 : localState
322303 }
323304 try {
324- const sharedState = await store . getStreamState ( name )
325- if ( mode !== 'requested' || ! sharedState || ! localState ) return sharedState ?? localState
326-
327- const merged = new Y . Doc ( )
328- try {
329- Y . applyUpdate ( merged , sharedState )
330- Y . applyUpdate ( merged , localState )
331- return Y . encodeStateAsUpdate ( merged )
332- } finally {
333- merged . destroy ( )
334- }
305+ return ( await store . getStreamState ( name ) ) ?? localState
335306 } catch ( streamError ) {
336307 // A transient Redis read must NOT drop the write when we already hold a valid local snapshot —
337- // else the last-disconnect flush loses the session's edits as the room is torn down. An explicit
338- // export flush can safely fail and retry, so do not risk omitting a peer edit when the shared state
339- // is temporarily unavailable.
340- if ( mode === 'requested' ) throw streamError
308+ // else the last-disconnect flush loses the session's edits as the room is torn down. But once a
309+ // reconcile has run, `localState` is NULLED (it predates the merged-in out-of-band edit), so a
310+ // failed read then correctly THROWS and aborts rather than clobbering with the stale snapshot.
341311 if ( ! localState ) throw streamError
342312 logger . warn ( `Stream state unavailable for file ${ room . fileId } ; persisting local snapshot` , {
343313 error : getErrorMessage ( streamError ) ,
@@ -357,11 +327,8 @@ async function flushPersist(
357327 }
358328
359329 try {
360- if (
361- mode === 'debounced' &&
362- ! ( await store . tryClaimPersistWindow ( name , FILE_DOC_TIMEOUTS . persistRequestMs ) )
363- )
364- return 'deduplicated'
330+ if ( ! final && ! ( await store . tryClaimPersistWindow ( name , FILE_DOC_TIMEOUTS . persistRequestMs ) ) )
331+ return
365332
366333 // The If-Match token: the durable content version the live doc is synced to.
367334 let ifMatch = await currentVersion ( )
@@ -371,7 +338,7 @@ async function flushPersist(
371338 // unset version never appears, and the flush must not stall teardown.
372339 for (
373340 let i = 0 ;
374- ifMatch === undefined && mode !== 'debounced' && store . enabled && i < FINAL_VERSION_RETRIES ;
341+ ifMatch === undefined && final && store . enabled && i < FINAL_VERSION_RETRIES ;
375342 i ++
376343 ) {
377344 await sleep ( FINAL_VERSION_RETRY_MS )
@@ -382,24 +349,19 @@ async function flushPersist(
382349 // still at the version the live doc synced from, so a projection can never silently clobber an
383350 // out-of-band edit. A single attempt — on conflict we STOP rather than retry (see below).
384351 const docState = await captureState ( )
385- if ( ! docState ) return 'unchanged' // nothing seeded/authoritative to persist yet
386- // Make an acknowledged multi-replica flush a real snapshot handshake: the normal keystroke publish
387- // is fire-and-forget, so append the converged snapshot and await Redis before updating the durable
388- // blob. If Redis is unavailable, fail the export instead of acknowledging state that a later relay
389- // persist could overwrite from an incomplete stream.
390- if ( mode === 'requested' && store . enabled ) await store . publishAndWait ( name , docState )
352+ if ( ! docState ) return // nothing seeded/authoritative to persist yet
391353 const result = await fetchFileDocPersist ( workspaceId , room . fileId , userId , docState , ifMatch )
392- if ( result . status === 'missing' ) return 'missing' // the file was deleted; nothing to write
354+ if ( result . status === 'missing' ) return // the file was deleted; nothing to write
393355 if ( result . status === 'deferred' ) {
394356 // No version token available (momentarily — a Redis blip on a peer-seeded task). Leave the edits in
395357 // the stream; a later persist writes them once the version is re-established.
396358 logger . warn ( `Persist deferred for file ${ room . fileId } (no synced version available yet)` )
397- return 'deferred'
359+ return
398360 }
399361 if ( result . status === 'persisted' ) {
400362 room . syncedVersion = Math . max ( room . syncedVersion ?? 0 , result . version )
401363 void store . setSyncedVersion ( name , result . version )
402- return 'persisted'
364+ return
403365 }
404366 // status === 'conflict': the durable file advanced out-of-band since our If-Match token. We do NOT
405367 // re-persist against the current stream: an external write commits durable BEFORE its chokepoint merge
@@ -413,10 +375,8 @@ async function flushPersist(
413375 logger . warn (
414376 `Persist conflict for file ${ room . fileId } ; durable content advanced out-of-band, left authoritative`
415377 )
416- return 'conflict'
417378 } catch ( error ) {
418379 logger . warn ( `Persist failed for file ${ room . fileId } ` , { error : getErrorMessage ( error ) } )
419- return 'failed'
420380 }
421381}
422382
@@ -486,7 +446,7 @@ function destroyRoomIfIdle(name: string) {
486446 }
487447 // Final durable flush BEFORE teardown — `flushPersist` encodes the doc synchronously (before the
488448 // destroy below) and awaits the write in the background. Best-effort; never throws.
489- void flushPersist ( name , room , 'final' )
449+ void flushPersist ( name , room , true )
490450 getFileDocStore ( ) . detachRoom ( name )
491451 room . awareness . destroy ( )
492452 room . doc . destroy ( )
@@ -501,9 +461,9 @@ function destroyRoomIfIdle(name: string) {
501461 * process is exiting); only their durable state is secured.
502462 */
503463export async function flushAllFileDocRooms ( ) : Promise < void > {
504- const flushes : Promise < PersistOutcome > [ ] = [ ]
464+ const flushes : Promise < void > [ ] = [ ]
505465 for ( const [ name , room ] of fileDocRooms ) {
506- if ( room . edited ) flushes . push ( flushPersist ( name , room , 'final' ) )
466+ if ( room . edited ) flushes . push ( flushPersist ( name , room , true ) )
507467 }
508468 await Promise . all ( flushes )
509469}
@@ -1269,44 +1229,6 @@ export function setupWorkspaceFileDocHandlers(
12691229 }
12701230 } )
12711231
1272- socket . on (
1273- FILE_DOC_EVENTS . FLUSH ,
1274- async ( payload : FlushFileDocPayload , acknowledge ?: ( result : FlushFileDocResult ) => void ) => {
1275- if ( typeof acknowledge !== 'function' ) return
1276- if ( ! payload || typeof payload . fileId !== 'string' || payload . fileId . length === 0 ) {
1277- acknowledge ( { ok : false , error : 'Invalid file document flush request' } )
1278- return
1279- }
1280-
1281- const name = socketToRoomName . get ( socket . id )
1282- const requestedName = roomName ( fileDocRoom ( payload . fileId ) )
1283- // A file with no live editor on this socket has no pending client edits to flush; its durable
1284- // blob is already the export source. This also keeps cold-load and read-only exports immediate.
1285- if ( name !== requestedName ) {
1286- acknowledge ( { ok : true } )
1287- return
1288- }
1289-
1290- const room = fileDocRooms . get ( name )
1291- if ( ! room || ! isFileDocWriteAllowed ( socket , io , name ) ) {
1292- acknowledge ( { ok : false , error : 'Unable to prepare the current document for export' } )
1293- return
1294- }
1295-
1296- // Socket.IO preserves event order on one connection, so all Yjs update frames emitted before
1297- // this request have already been applied. Replace the pending debounce with this awaited write.
1298- if ( room . persistTimer ) clearTimeout ( room . persistTimer )
1299- room . persistTimer = null
1300- room . persistDeadline = null
1301- const outcome = await flushPersist ( name , room , 'requested' )
1302- if ( outcome === 'persisted' || outcome === 'unchanged' || outcome === 'missing' ) {
1303- acknowledge ( { ok : true } )
1304- return
1305- }
1306- acknowledge ( { ok : false , error : 'Unable to save the latest document changes for export' } )
1307- }
1308- )
1309-
13101232 socket . on ( FILE_DOC_EVENTS . MESSAGE , ( data : unknown ) => handleMessage ( socket , io , data ) )
13111233
13121234 socket . on ( FILE_DOC_EVENTS . LEAVE , ( payload ?: LeaveFileDocPayload ) => {
0 commit comments