@@ -34,11 +34,20 @@ import { createLogger } from '@sim/logger'
3434import { FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc'
3535import { getErrorMessage } from '@sim/utils/errors'
3636import { sleep } from '@sim/utils/helpers'
37+ import { generateId } from '@sim/utils/id'
38+ import { backoffWithJitter } from '@sim/utils/retry'
3739import { createClient , type RedisClientType } from 'redis'
3840import * as Y from 'yjs'
3941
4042const logger = createLogger ( 'FileDocStore' )
4143
44+ /**
45+ * Compare-and-delete: release a lock ONLY if this task still holds it (its token still the value), so a
46+ * lock that expired and was re-acquired by another task is never stolen by the original holder's release.
47+ */
48+ const RELEASE_LOCK_SCRIPT =
49+ "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"
50+
4251/**
4352 * The transaction origin the store stamps on updates it applies from the stream. The relay's
4453 * `doc.on('update')` handler uses it to distinguish an update that ARRIVED from a peer (fan out to
@@ -56,6 +65,11 @@ const MERGE_LOCK_PREFIX = 'filedoc:mergelock:'
5665/** The single field each stream entry carries — a base64 Yjs update. */
5766const UPDATE_FIELD = 'u'
5867
68+ /** Sentinel token a DISABLED store returns from a lock acquire, so single-replica callers proceed
69+ * without special-casing; {@link FileDocStore.releaseLock} treats it as a no-op. Not a real UUID, so it
70+ * can never collide with a {@link generateId} token. */
71+ const DISABLED_LOCK_TOKEN = '__disabled__'
72+
5973/** How long a blocking multiplexed read waits before re-snapshotting the live room set. Also bounds
6074 * how long a room attached mid-block waits for its first cross-task update (updates are not lost — the
6175 * next read resumes from its last id — only briefly delayed). */
@@ -69,11 +83,19 @@ const READ_COUNT = 200
6983const COMPACT_THRESHOLD = 400
7084/** Check whether compaction is due only every Nth local publish, to avoid an XLEN per keystroke. */
7185const COMPACT_CHECK_EVERY = 64
72- /** The seed lock is held across the app seed fetch (hard-bounded at `seedRequestMs`) plus the apply +
73- * publish. The generous cushion over `seedRequestMs` means only a multi-second event-loop stall — not
74- * ordinary latency — could expire it mid-seed, so the single-seeder invariant does not hinge on a tight
75- * 2s margin coupled to an unrelated timeout. */
76- const SEED_LOCK_TTL_MS = FILE_DOC_TIMEOUTS . seedRequestMs + 12_000
86+ /** Compaction critical section (snapshot + xAdd + xTrim) is fast; a generous TTL covers a slow Redis
87+ * round-trip without risking expiry mid-compact. Released via compare-and-delete regardless. */
88+ const COMPACT_LOCK_TTL_MS = 10_000
89+ /** Retry a failed stream append this many times before giving up, so a transient Redis blip doesn't
90+ * silently drop an edit from the shared log (which no peer would then ever see). */
91+ const PUBLISH_MAX_RETRIES = 3
92+ /** The seed lock spans the app seed fetch (hard-bounded at `seedRequestMs = 8s`) + the apply + the
93+ * AWAITED seed publish. The margin comfortably exceeds the fetch bound so the lock does not expire
94+ * mid-seed, while staying at the client readiness deadline (12s) so a dead seeder's lock frees when
95+ * clients would recover anyway. Double-seed is prevented regardless of the margin: the seeder publishes
96+ * the seed to the stream BEFORE releasing the lock, so any later seeder's {@link streamHasContent} fence
97+ * sees it. */
98+ const SEED_LOCK_TTL_MS = FILE_DOC_TIMEOUTS . seedRequestMs + 4_000
7799/** How long a stream survives with no heartbeat — long enough that an occupied-but-idle doc never
78100 * loses its shared state (the heartbeat refreshes it while any task holds the room). */
79101const STREAM_TTL_SEC = 600
@@ -198,51 +220,108 @@ export class FileDocStore {
198220 }
199221
200222 /**
201- * Append a locally-applied update to the shared stream so every task converges. Called from the
202- * relay's `doc.on('update')` for local edits only (never for {@link REDIS_ORIGIN} updates — those
203- * already came from the stream). No-op when disabled.
223+ * Append a locally-applied update to the shared stream so every task converges, AWAITING the write
224+ * and retrying a transient failure ({@link PUBLISH_MAX_RETRIES}) so a Redis blip can't silently drop
225+ * an edit from the shared log. Only the `xAdd` is retried; the TTL refresh + compaction check are
226+ * post-write best-effort and never re-trigger the append. Throws if the append ultimately fails.
227+ */
228+ private async appendUpdate ( name : string , update : Uint8Array ) : Promise < void > {
229+ if ( ! this . write ) return
230+ const encoded = Buffer . from ( update ) . toString ( 'base64' )
231+ for ( let attempt = 0 ; ; attempt ++ ) {
232+ try {
233+ await this . write . xAdd ( streamKey ( name ) , '*' , { [ UPDATE_FIELD ] : encoded } )
234+ break
235+ } catch ( error ) {
236+ if ( attempt >= PUBLISH_MAX_RETRIES ) {
237+ logger . error ( `FileDocStore append failed for ${ name } ` , { error : getErrorMessage ( error ) } )
238+ throw error
239+ }
240+ // Snappy backoff — a stream append is a fast op; a transient blip clears in tens of ms.
241+ // `backoffWithJitter` is 1-indexed, so pass the 1-based attempt number.
242+ await sleep ( backoffWithJitter ( attempt + 1 , null , { baseMs : 50 , maxMs : 500 } ) )
243+ }
244+ }
245+ await this . write . expire ( streamKey ( name ) , STREAM_TTL_SEC ) . catch ( ( ) => { } )
246+ const room = this . rooms . get ( name )
247+ if ( room && ++ room . publishes % COMPACT_CHECK_EVERY === 0 ) void this . maybeCompact ( name )
248+ }
249+
250+ /**
251+ * Fire-and-forget append for the hot keystroke path (`doc.on('update')`): converges peers without
252+ * blocking the relay. Retries internally; never throws. No-op when disabled.
204253 */
205254 publish ( name : string , update : Uint8Array ) : void {
206255 if ( ! this . enabled || ! this . write ) return
207- const room = this . rooms . get ( name )
208- void this . write
209- . xAdd ( streamKey ( name ) , '*' , { [ UPDATE_FIELD ] : Buffer . from ( update ) . toString ( 'base64' ) } )
210- . then ( ( ) => this . write ?. expire ( streamKey ( name ) , STREAM_TTL_SEC ) )
211- . then ( ( ) => {
212- if ( room && ++ room . publishes % COMPACT_CHECK_EVERY === 0 ) return this . maybeCompact ( name )
213- } )
214- . catch ( ( error ) =>
215- logger . warn ( `FileDocStore publish failed for ${ name } ` , { error : getErrorMessage ( error ) } )
216- )
256+ void this . appendUpdate ( name , update ) . catch ( ( ) => { } ) // already logged inside appendUpdate
217257 }
218258
219259 /**
220- * Decide whether THIS task should build and write the file's one-time seed. Returns true only when
221- * the shared stream is genuinely empty AND this task wins the seed lock — so exactly one task across
222- * the cluster ever seeds a file, even if several open it at once (the fix for split-brain seeding).
223- * When disabled, always true (single-replica: seed locally).
260+ * Awaitable append for callers that must know the update is durably in the stream before proceeding
261+ * — the copilot merge, so the cross-task merge lock is not released before the diff is committed
262+ * (else the next task would diff a stale base). Throws on ultimate failure. No-op when disabled.
224263 */
225- async shouldSeed ( name : string ) : Promise < boolean > {
226- if ( ! this . enabled || ! this . write ) return true
264+ async publishAndWait ( name : string , update : Uint8Array ) : Promise < void > {
265+ if ( ! this . enabled || ! this . write ) return
266+ await this . appendUpdate ( name , update )
267+ }
268+
269+ /**
270+ * Whether the file's stream already holds content — fences a seed apply against a peer that seeded
271+ * while this task held a (possibly stale) lock, so we never write a second seed (split-brain). False
272+ * when disabled or on error (the caller then proceeds under its lock, as before).
273+ */
274+ async streamHasContent ( name : string ) : Promise < boolean > {
275+ if ( ! this . enabled || ! this . write ) return false
227276 try {
228- const won = await this . write . set ( `${ SEED_LOCK_PREFIX } ${ name } ` , '1' , {
229- NX : true ,
230- PX : SEED_LOCK_TTL_MS ,
231- } )
232- if ( won !== 'OK' ) return false
233- // The lock could be free yet the stream already seeded (a prior holder seeded then its lock
234- // expired). Re-check under the lock so we never write a SECOND seed on top of an existing one.
235- if ( ( await this . write . xLen ( streamKey ( name ) ) ) > 0 ) {
236- await this . releaseSeedLock ( name )
237- return false
238- }
239- return true
240- } catch ( error ) {
241- logger . warn ( `FileDocStore shouldSeed failed for ${ name } ` , { error : getErrorMessage ( error ) } )
277+ return ( await this . write . xLen ( streamKey ( name ) ) ) > 0
278+ } catch {
242279 return false
243280 }
244281 }
245282
283+ /**
284+ * Acquire a distributed lock with a unique ownership TOKEN (`SET key <token> NX PX`). Returns the
285+ * token to release with, or `null` if not won. Fails CLOSED (null) on a Redis error — a lock we can't
286+ * prove we hold must not be treated as held. The special sentinel {@link DISABLED_LOCK_TOKEN} lets a
287+ * disabled store return a truthy token so callers proceed single-replica without special-casing.
288+ */
289+ private async acquireLock ( key : string , ttlMs : number ) : Promise < string | null > {
290+ if ( ! this . enabled || ! this . write ) return DISABLED_LOCK_TOKEN
291+ const token = generateId ( )
292+ try {
293+ return ( await this . write . set ( key , token , { NX : true , PX : ttlMs } ) ) === 'OK' ? token : null
294+ } catch ( error ) {
295+ logger . warn ( `FileDocStore lock ${ key } failed` , { error : getErrorMessage ( error ) } )
296+ return null
297+ }
298+ }
299+
300+ /** Release a lock via compare-and-delete, so it is only dropped if we still hold our token. */
301+ private async releaseLock ( key : string , token : string ) : Promise < void > {
302+ if ( ! this . write || token === DISABLED_LOCK_TOKEN ) return
303+ await this . write . eval ( RELEASE_LOCK_SCRIPT , { keys : [ key ] , arguments : [ token ] } ) . catch ( ( ) => { } )
304+ }
305+
306+ /**
307+ * Decide whether THIS task should build and write the file's one-time seed. Returns a lock TOKEN only
308+ * when the shared stream is genuinely empty AND this task wins the seed lock — so exactly one task
309+ * across the cluster ever seeds a file, even if several open it at once (the fix for split-brain
310+ * seeding). `null` otherwise. Release the token with {@link releaseSeedLock}. Disabled → always a
311+ * token (single-replica: seed locally).
312+ */
313+ async shouldSeed ( name : string ) : Promise < string | null > {
314+ const token = await this . acquireLock ( `${ SEED_LOCK_PREFIX } ${ name } ` , SEED_LOCK_TTL_MS )
315+ if ( ! token || token === DISABLED_LOCK_TOKEN ) return token
316+ // The lock could be free yet the stream already seeded (a prior holder seeded then its lock
317+ // expired). Re-check under the lock so we never write a SECOND seed on top of an existing one.
318+ if ( await this . streamHasContent ( name ) ) {
319+ await this . releaseSeedLock ( name , token )
320+ return null
321+ }
322+ return token
323+ }
324+
246325 /**
247326 * Build the file's current shared state from the stream, headless (no registered room), for a merge
248327 * that must reach the live doc regardless of which task holds it. Returns the encoded Yjs state, or
@@ -262,10 +341,9 @@ export class FileDocStore {
262341 }
263342 }
264343
265- /** Release the seed lock (best-effort) once the seed has been published or a seed attempt failed. */
266- async releaseSeedLock ( name : string ) : Promise < void > {
267- if ( ! this . enabled || ! this . write ) return
268- await this . write . del ( `${ SEED_LOCK_PREFIX } ${ name } ` ) . catch ( ( ) => { } )
344+ /** Release the seed lock (compare-and-delete) once the seed has been published or a seed attempt failed. */
345+ async releaseSeedLock ( name : string , token : string ) : Promise < void > {
346+ await this . releaseLock ( `${ SEED_LOCK_PREFIX } ${ name } ` , token )
269347 }
270348
271349 /**
@@ -292,22 +370,15 @@ export class FileDocStore {
292370 * merges per task; this extends that across tasks so two copilot edits to the same file landing on
293371 * different tasks don't each diff the SAME shared base and publish conflicting full-document rewrites.
294372 * The loser waits and retries so it diffs against the winner's RESULT (correct sequential merge).
295- * Returns true (proceed) when disabled or once the lock is won. Release with {@link releaseMergeSlot}.
373+ * Returns a lock TOKEN (proceed) when disabled or once won; `null` otherwise (fails CLOSED on error, so
374+ * a merge never races when exclusivity can't be proven). Release with {@link releaseMergeSlot}.
296375 */
297- async acquireMergeSlot ( name : string , ttlMs : number ) : Promise < boolean > {
298- if ( ! this . enabled || ! this . write ) return true
299- try {
300- return (
301- ( await this . write . set ( `${ MERGE_LOCK_PREFIX } ${ name } ` , '1' , { NX : true , PX : ttlMs } ) ) === 'OK'
302- )
303- } catch {
304- return true
305- }
376+ async acquireMergeSlot ( name : string , ttlMs : number ) : Promise < string | null > {
377+ return this . acquireLock ( `${ MERGE_LOCK_PREFIX } ${ name } ` , ttlMs )
306378 }
307379
308- async releaseMergeSlot ( name : string ) : Promise < void > {
309- if ( ! this . enabled || ! this . write ) return
310- await this . write . del ( `${ MERGE_LOCK_PREFIX } ${ name } ` ) . catch ( ( ) => { } )
380+ async releaseMergeSlot ( name : string , token : string ) : Promise < void > {
381+ await this . releaseLock ( `${ MERGE_LOCK_PREFIX } ${ name } ` , token )
311382 }
312383
313384 private applyEntry ( room : StoreRoom , id : string , message : Record < string , string > ) : void {
@@ -358,11 +429,9 @@ export class FileDocStore {
358429 if ( ! room ) return
359430 try {
360431 if ( ( await this . write . xLen ( streamKey ( name ) ) ) < COMPACT_THRESHOLD ) return
361- const won = await this . write . set ( `${ COMPACT_LOCK_PREFIX } ${ name } ` , '1' , {
362- NX : true ,
363- PX : 10_000 ,
364- } )
365- if ( won !== 'OK' ) return
432+ const key = `${ COMPACT_LOCK_PREFIX } ${ name } `
433+ const token = await this . acquireLock ( key , COMPACT_LOCK_TTL_MS )
434+ if ( ! token ) return
366435 try {
367436 // Capture the snapshot AND the id it covers in one synchronous step (no await between): the
368437 // snapshot is `room.doc`, which holds exactly what this task's tailer has integrated — every
@@ -377,7 +446,7 @@ export class FileDocStore {
377446 // `upTo` itself (redundant with the snapshot, harmless); it drops only the folded older deltas.
378447 await this . write . xTrim ( streamKey ( name ) , 'MINID' , upTo )
379448 } finally {
380- await this . write . del ( ` ${ COMPACT_LOCK_PREFIX } ${ name } ` ) . catch ( ( ) => { } )
449+ await this . releaseLock ( key , token )
381450 }
382451 } catch ( error ) {
383452 logger . warn ( `FileDocStore compaction failed for ${ name } ` , { error : getErrorMessage ( error ) } )
0 commit comments