@@ -45,7 +45,7 @@ import * as syncProtocol from 'y-protocols/sync'
4545import * as Y from 'yjs'
4646import { resolveAvatarUrl } from '@/handlers/avatar'
4747import { fetchFileDocMerge , fetchFileDocPersist , fetchFileDocSeed } from '@/handlers/file-doc-app'
48- import { getFileDocStore , REDIS_ORIGIN } from '@/handlers/file-doc-store'
48+ import { getFileDocStore , REDIS_ORIGIN , REDIS_SNAPSHOT_ORIGIN } from '@/handlers/file-doc-store'
4949import { resolveRoomJoinAuth } from '@/handlers/room-join-auth'
5050import type { AuthenticatedSocket } from '@/middleware/auth'
5151import type { IRoomManager } from '@/rooms'
@@ -61,6 +61,10 @@ const SEED_ORIGIN = Symbol('file-doc-seed')
6161
6262/** Debounce window for the server-side project-to-markdown persist while a doc is actively edited. */
6363const PERSIST_DEBOUNCE_MS = 5_000
64+ /** Max-wait cap on the persist debounce: a CONTINUOUS edit burst keeps resetting the 5s debounce and
65+ * would otherwise never persist until an idle pause, so force a flush at least this often — bounding how
66+ * many edits are unpersisted (in the stream only) if the task dies mid-burst. */
67+ const PERSIST_MAX_WAIT_MS = 20_000
6468
6569/** Cross-task merge lock. The TTL must exceed the whole critical section it guards — stream fold +
6670 * `fetchFileDocMerge` (bounded at `mergeRequestMs`) + the awaited publish — so the lock never expires
@@ -118,6 +122,9 @@ interface FileDocRoom {
118122 seededObserved : boolean
119123 /** The pending debounced persist timer, if any. */
120124 persistTimer : ReturnType < typeof setTimeout > | null
125+ /** Absolute time (ms) by which a debounced persist must fire even under continuous editing (the
126+ * max-wait cap); null when no persist is pending. */
127+ persistDeadline : number | null
121128}
122129
123130/** Live documents keyed by Socket.IO room name. Module-global: one Y.Doc per file. */
@@ -184,24 +191,29 @@ function broadcastLocal(
184191
185192/**
186193 * Schedule a debounced server-side persist of the live doc back to durable markdown. Coalesces rapid
187- * edits; a no-op until the room knows its workspace (set at join). The final flush on last-disconnect
188- * is separate ({@link flushPersist} with `final`).
194+ * edits; a no-op until the room knows its workspace (set at join). A {@link PERSIST_MAX_WAIT_MS}
195+ * max-wait caps the debounce so a continuous burst still persists periodically. The final flush on
196+ * last-disconnect is separate ({@link flushPersist} with `final`).
189197 */
190198function schedulePersist ( name : string , room : FileDocRoom ) : void {
191199 if ( ! room . workspaceId || ! room . lastEditorUserId ) return
200+ const now = Date . now ( )
201+ if ( room . persistDeadline === null ) room . persistDeadline = now + PERSIST_MAX_WAIT_MS
192202 if ( room . persistTimer ) clearTimeout ( room . persistTimer )
203+ const delay = Math . max ( 0 , Math . min ( PERSIST_DEBOUNCE_MS , room . persistDeadline - now ) )
193204 room . persistTimer = setTimeout ( ( ) => {
194205 room . persistTimer = null
206+ room . persistDeadline = null
195207 void flushPersist ( name , room , false )
196- } , PERSIST_DEBOUNCE_MS )
208+ } , delay )
197209}
198210
199211/**
200212 * Project the live doc to markdown and write it durably via the app. `final` (last collaborator
201- * leaving) always writes; a debounced mid-edit flush first claims a cross-task slot (held for the whole
202- * write, so a concurrent task can't issue an overlapping blob write) so tasks don't each write a
203- * redundant version. Best-effort: never throws (a failure is retried on the next debounce; the stream
204- * holds the state meanwhile).
213+ * leaving) always writes; a debounced mid-edit flush first claims a best-effort cross-task dedup WINDOW
214+ * (a TTL key that just expires, so at most ~one persist per window cluster-wide) so concurrent tasks
215+ * editing the same file don't each write a redundant blob version. Best-effort: never throws (a failure
216+ * is retried on the next debounce; the stream holds the state meanwhile).
205217 *
206218 * Persists the AUTHORITATIVE shared state (the stream), not this task's local doc: a copilot merge — or
207219 * a peer's edit — published by another task may not be integrated into `room.doc` yet (and the stream
@@ -218,7 +230,7 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr
218230 // this yields. Only meaningful once seeded; used only when the authoritative stream state is absent.
219231 const localState = isDocSeeded ( room . doc ) ? Y . encodeStateAsUpdate ( room . doc ) : null
220232 try {
221- if ( ! final && ! ( await store . acquirePersistSlot ( name , FILE_DOC_TIMEOUTS . persistRequestMs ) ) )
233+ if ( ! final && ! ( await store . tryClaimPersistWindow ( name , FILE_DOC_TIMEOUTS . persistRequestMs ) ) )
222234 return
223235 let docState = localState
224236 if ( store . enabled ) {
@@ -294,6 +306,7 @@ function awarenessUpdateClientIds(update: Uint8Array): number[] {
294306function destroyRoomIfIdle ( name : string ) {
295307 const room = fileDocRooms . get ( name )
296308 if ( ! room || room . owners . size > 0 ) return
309+ room . persistDeadline = null
297310 if ( room . persistTimer ) {
298311 clearTimeout ( room . persistTimer )
299312 room . persistTimer = null
@@ -307,6 +320,21 @@ function destroyRoomIfIdle(name: string) {
307320 fileDocRooms . delete ( name )
308321}
309322
323+ /**
324+ * Flush every open, edited room's converged doc to durable markdown, AWAITING the writes. Called on
325+ * graceful shutdown (rolling deploy / scale-in) so edits since the last debounce aren't left only in the
326+ * ephemeral stream — the per-socket disconnect flush is fire-and-forget and would race `process.exit`.
327+ * Best-effort and bounded by each persist's own timeout; never throws. Rooms are NOT torn down here (the
328+ * process is exiting); only their durable state is secured.
329+ */
330+ export async function flushAllFileDocRooms ( ) : Promise < void > {
331+ const flushes : Promise < void > [ ] = [ ]
332+ for ( const [ name , room ] of fileDocRooms ) {
333+ if ( room . edited ) flushes . push ( flushPersist ( name , room , true ) )
334+ }
335+ await Promise . all ( flushes )
336+ }
337+
310338/**
311339 * Seed a room's document server-side, once, on the first join: ask the app to build the seed (the
312340 * file's current markdown → Yjs, through the exact editor engine) and apply it, which relays the
@@ -498,6 +526,7 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
498526 edited : false ,
499527 seededObserved : false ,
500528 persistTimer : null ,
529+ persistDeadline : null ,
501530 }
502531 // Register synchronously BEFORE the async catch-up so a concurrent join sees this room, not a second.
503532 fileDocRooms . set ( name , room )
@@ -509,18 +538,27 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
509538 // Fan out to THIS task's clients only (excluding the origin socket if local). Cross-task delivery
510539 // rides the shared stream — every task's tailer applies + runs its own local fan-out.
511540 broadcastLocal ( io , name , encoding . toUint8Array ( encoder ) , originSocketId ( origin ) )
512- // Share every locally-originated update to the stream so peers converge. Skip REDIS_ORIGIN (already
513- // in the stream) and SEED_ORIGIN — the seed is published EXPLICITLY and AWAITED under the seed lock
514- // (so it lands before the lock releases), which a fire-and-forget publish here couldn't guarantee.
515- if ( origin !== REDIS_ORIGIN && origin !== SEED_ORIGIN ) getFileDocStore ( ) . publish ( name , update )
541+ // Share every locally-originated update to the stream so peers converge. Skip updates that already
542+ // came FROM the stream (REDIS_ORIGIN / REDIS_SNAPSHOT_ORIGIN) and SEED_ORIGIN — the seed is published
543+ // EXPLICITLY and AWAITED under the seed lock (so it lands before the lock releases), which a
544+ // fire-and-forget publish here couldn't guarantee.
545+ if ( origin !== REDIS_ORIGIN && origin !== REDIS_SNAPSHOT_ORIGIN && origin !== SEED_ORIGIN )
546+ getFileDocStore ( ) . publish ( name , update )
516547 // Edit tracking for persistence. Mark the doc dirty on any update applied AFTER it was seeded — a
517548 // local user edit (socket origin) OR a peer's edit relayed via the tailer (REDIS_ORIGIN) — so
518- // whichever task is last to leave persists real edits, even one that only tailed them. The seed
519- // transition itself is never counted (nor a purely-local copilot merge, already durable via
520- // copilot's direct file write), so a seeded-but-unedited doc is never projected back over the file.
549+ // whichever task is last to leave persists real edits, even one that only tailed them. A compaction
550+ // snapshot on catch-up (REDIS_SNAPSHOT_ORIGIN) also counts: it folds real edits into one frame, so a
551+ // fresh task catching up purely from it must not treat the doc as unedited. The seed transition
552+ // itself is never counted (nor a purely-local copilot merge, already durable via copilot's direct
553+ // file write), so a seeded-but-unedited doc is never projected back over the file.
521554 const seededBefore = room . seededObserved
522555 if ( isDocSeeded ( room . doc ) ) room . seededObserved = true
523- if ( originSocketId ( origin ) || ( seededBefore && origin === REDIS_ORIGIN ) ) room . edited = true
556+ if (
557+ originSocketId ( origin ) ||
558+ origin === REDIS_SNAPSHOT_ORIGIN ||
559+ ( seededBefore && origin === REDIS_ORIGIN )
560+ )
561+ room . edited = true
524562 // Debounce a persist for LOCAL user edits only (peers debounce their own).
525563 if ( originSocketId ( origin ) ) schedulePersist ( name , room )
526564 } )
0 commit comments