2222 * opens a file, so a late-joining task (the normal case under autoscaling) loads the current shared
2323 * state before its first client syncs. Catch-up + tail are seamless: the tailer resumes from the
2424 * exact id catch-up stopped at.
25- * - {@link shouldSeed} coordinates the one-time seed with a Redis lock AND an empty-stream check, so
26- * exactly one task ever writes the seed cluster-wide (the fix for split-brain).
25+ * - The one-time seed is written via the atomic {@link seedIfEmpty} (append-iff-empty in one Redis
26+ * step), so exactly one task ever writes the seed cluster-wide (the fix for split-brain) — even if two
27+ * tasks race. {@link shouldSeed} is a Redis lock + empty-stream check layered on top ONLY as an
28+ * efficiency gate (so tasks don't all run the seed fetch); correctness does not depend on it.
2729 *
2830 * When `REDIS_URL` is unset (single-pod dev) the store is DISABLED and every method degrades to the
2931 * relay's original single-replica behavior: seed locally, no stream, no tailer.
@@ -48,6 +50,17 @@ const logger = createLogger('FileDocStore')
4850const RELEASE_LOCK_SCRIPT =
4951 "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"
5052
53+ /**
54+ * Atomic seed: append the seed entry ONLY if the stream is still empty, in one Redis-side step. This is
55+ * the real split-brain guard — two tasks racing (even both past an expired seed lock) can never both
56+ * write a seed (each would mint a distinct Yjs client id → duplicated content), because the emptiness
57+ * check and the append happen atomically with no check-then-append window. The seed lock is only an
58+ * efficiency optimization (avoid two seed fetches); correctness does not depend on it staying held.
59+ * Returns 1 if THIS call wrote the seed, 0 if the stream already had content.
60+ */
61+ const SEED_IF_EMPTY_SCRIPT =
62+ "if redis.call('xlen', KEYS[1]) == 0 then redis.call('xadd', KEYS[1], '*', ARGV[1], ARGV[2]); return 1 else return 0 end"
63+
5164/**
5265 * The transaction origin the store stamps on updates it applies from the stream. The relay's
5366 * `doc.on('update')` handler uses it to distinguish an update that ARRIVED from a peer (fan out to
@@ -101,12 +114,12 @@ const COMPACT_LOCK_TTL_MS = 10_000
101114/** Retry a failed stream append this many times before giving up, so a transient Redis blip doesn't
102115 * silently drop an edit from the shared log (which no peer would then ever see). */
103116const PUBLISH_MAX_RETRIES = 3
104- /** The seed lock spans the app seed fetch (hard-bounded at `seedRequestMs = 8s`) + the apply + the
105- * AWAITED seed publish. The margin comfortably exceeds the fetch bound so the lock does not expire
106- * mid-seed, while staying at the client readiness deadline (12s) so a dead seeder's lock frees when
107- * clients would recover anyway. Double-seed is prevented regardless of the margin: the seeder publishes
108- * the seed to the stream BEFORE releasing the lock, so any later seeder's {@link streamHasContent} fence
109- * sees it . */
117+ /** The seed lock spans the app seed fetch (hard-bounded at `seedRequestMs = 8s`) + the atomic seed
118+ * append. It is only an EFFICIENCY optimization — it stops two tasks both running the seed fetch — and is
119+ * sized to comfortably exceed the fetch bound while staying near the client readiness deadline (12s) so a
120+ * dead seeder's lock frees when clients would recover anyway. Double-seed is prevented even if the lock
121+ * expires mid- seed, because the seed is written via the atomic {@link SEED_IF_EMPTY_SCRIPT}
122+ * (append-iff-empty), NOT the lock — correctness never depends on the lock staying held . */
110123const SEED_LOCK_TTL_MS = FILE_DOC_TIMEOUTS . seedRequestMs + 4_000
111124/** How long a stream survives with no heartbeat — long enough that an occupied-but-idle doc never
112125 * loses its shared state (the heartbeat refreshes it while any task holds the room). */
@@ -279,11 +292,43 @@ export class FileDocStore {
279292 }
280293
281294 /**
282- * Whether the file's stream already holds content — fences a seed apply against a peer that seeded
283- * while this task held a (possibly stale) lock, so we never write a second seed (split-brain). Both
284- * callers treat `true` as "do not seed", so this fails CLOSED: a Redis `xLen` error returns `true`
285- * (cannot confirm the stream is empty → do not risk a double-seed). `false` only when genuinely empty,
286- * or when disabled (single-replica, where seeding locally is always correct).
295+ * Atomically seed the stream iff it is still empty (see {@link SEED_IF_EMPTY_SCRIPT}). This is what
296+ * actually prevents split-brain double-seeding: the emptiness check and the append happen in one
297+ * Redis-side step, so — unlike a separate {@link streamHasContent} fence + {@link publishAndWait} —
298+ * there is no check-then-append window, and two tasks racing (even both past an expired seed lock) can
299+ * never both write a seed. Returns true if THIS call wrote the seed (apply it locally), false if the
300+ * stream was already seeded (the tailer will deliver the peer's seed — do NOT apply a second one).
301+ * Retries a transient Redis error like {@link appendUpdate}; throws if it ultimately fails. Disabled →
302+ * true (single-replica: seed locally, no stream).
303+ */
304+ async seedIfEmpty ( name : string , update : Uint8Array ) : Promise < boolean > {
305+ if ( ! this . enabled || ! this . write ) return true
306+ const encoded = Buffer . from ( update ) . toString ( 'base64' )
307+ for ( let attempt = 0 ; attempt <= PUBLISH_MAX_RETRIES ; attempt ++ ) {
308+ try {
309+ const wrote = await this . write . eval ( SEED_IF_EMPTY_SCRIPT , {
310+ keys : [ streamKey ( name ) ] ,
311+ arguments : [ UPDATE_FIELD , encoded ] ,
312+ } )
313+ await this . write . expire ( streamKey ( name ) , STREAM_TTL_SEC ) . catch ( ( ) => { } )
314+ return wrote === 1
315+ } catch ( error ) {
316+ if ( attempt === PUBLISH_MAX_RETRIES ) {
317+ logger . error ( `FileDocStore seed failed for ${ name } ` , { error : getErrorMessage ( error ) } )
318+ throw error
319+ }
320+ await sleep ( backoffWithJitter ( attempt + 1 , null , { baseMs : 50 , maxMs : 500 } ) )
321+ }
322+ }
323+ return false
324+ }
325+
326+ /**
327+ * Whether the file's stream already holds content — an EFFICIENCY recheck in {@link shouldSeed} that
328+ * skips the seed fetch when a prior holder already seeded (the split-brain guard itself is the atomic
329+ * {@link SEED_IF_EMPTY_SCRIPT}, not this check). Treats `true` as "already seeded", so it fails CLOSED:
330+ * a Redis `xLen` error returns `true` (cannot confirm empty → skip the redundant fetch; the atomic seed
331+ * would no-op anyway). `false` only when genuinely empty, or when disabled (single-replica).
287332 */
288333 async streamHasContent ( name : string ) : Promise < boolean > {
289334 if ( ! this . enabled || ! this . write ) return false
@@ -321,17 +366,20 @@ export class FileDocStore {
321366 }
322367
323368 /**
324- * Decide whether THIS task should build and write the file's one-time seed. Returns a lock TOKEN only
325- * when the shared stream is genuinely empty AND this task wins the seed lock — so exactly one task
326- * across the cluster ever seeds a file, even if several open it at once (the fix for split-brain
327- * seeding). `null` otherwise. Release the token with {@link releaseSeedLock}. Disabled → always a
328- * token (single-replica: seed locally).
369+ * Decide whether THIS task should run the (expensive) seed fetch + write for a file. Returns a lock
370+ * TOKEN when this task wins the seed lock and the stream still looks empty; `null` otherwise. This is an
371+ * EFFICIENCY gate — it stops every task that opens the file at once from each fetching the seed. It does
372+ * NOT by itself guarantee a single seed: exactly-once is enforced by the atomic {@link seedIfEmpty} the
373+ * token-holder then calls (the split-brain guard), so a lock that expires mid-seed cannot cause a
374+ * double-seed. Release the token with {@link releaseSeedLock}. Disabled → always a token (single-replica:
375+ * seed locally).
329376 */
330377 async shouldSeed ( name : string ) : Promise < string | null > {
331378 const token = await this . acquireLock ( `${ SEED_LOCK_PREFIX } ${ name } ` , SEED_LOCK_TTL_MS )
332379 if ( ! token || token === DISABLED_LOCK_TOKEN ) return token
333380 // The lock could be free yet the stream already seeded (a prior holder seeded then its lock
334- // expired). Re-check under the lock so we never write a SECOND seed on top of an existing one.
381+ // expired). Re-check so we skip the redundant seed fetch — the atomic seedIfEmpty would no-op anyway,
382+ // but this avoids the wasted app round-trip.
335383 if ( await this . streamHasContent ( name ) ) {
336384 await this . releaseSeedLock ( name , token )
337385 return null
0 commit comments