Skip to content

Commit 079cf13

Browse files
authored
Merge pull request #6048 from simstudioai/collab-seed-atomic
fix(realtime): make collab-doc seeding atomic to close split-brain window
2 parents 7de77ea + f83af6e commit 079cf13

3 files changed

Lines changed: 205 additions & 39 deletions

File tree

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

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,21 @@ function makeClient(): any {
7171
b().kv.delete(key)
7272
return 1
7373
},
74-
// Compare-and-delete Lua (RELEASE_LOCK_SCRIPT): del only if the stored value matches the token.
75-
eval: async (_script: string, opts: { keys: string[]; arguments: string[] }) => {
74+
eval: async (script: string, opts: { keys: string[]; arguments: string[] }) => {
7675
const [key] = opts.keys
76+
// Atomic seed-if-empty (SEED_IF_EMPTY_SCRIPT): append the entry iff the stream is empty, in one
77+
// synchronous step — mirroring Redis's atomic Lua execution, so two concurrent evals can never both
78+
// append (the second sees a non-empty stream).
79+
if (script.includes('xlen')) {
80+
const [field, value] = opts.arguments
81+
const arr = b().streams.get(key) ?? []
82+
if (arr.length > 0) return 0
83+
const id = `${++b().seq}-0`
84+
arr.push({ id, message: { [field]: value } })
85+
b().streams.set(key, arr)
86+
return 1
87+
}
88+
// Compare-and-delete Lua (RELEASE_LOCK_SCRIPT): del only if the stored value matches the token.
7789
const [token] = opts.arguments
7890
if (b().kv.get(key) === token) {
7991
b().kv.delete(key)
@@ -277,4 +289,106 @@ describe('FileDocStore', () => {
277289
expect(doc.getText('body').toString()).toBe('')
278290
doc.destroy()
279291
})
292+
293+
it('seedIfEmpty writes the seed once and reports it, then refuses a non-empty stream', async () => {
294+
const a = await newStore()
295+
expect(await a.seedIfEmpty(NAME, updateFor('first'))).toBe(true)
296+
// A second seed attempt (any task) must be refused — the stream already holds content.
297+
const b = await newStore()
298+
expect(await b.seedIfEmpty(NAME, updateFor('second'))).toBe(false)
299+
const doc = new Y.Doc()
300+
Y.applyUpdate(doc, (await a.getStreamState(NAME))!)
301+
expect(doc.getText('body').toString()).toBe('first')
302+
doc.destroy()
303+
})
304+
305+
it('atomic seed prevents split-brain even when the seed lock expired mid-seed', async () => {
306+
// The exact split-brain precondition from the concurrency audit: the seed lock is only an efficiency
307+
// optimization, so if it lapses (TTL) while the stream is still empty, TWO tasks can both hold a
308+
// token and both try to seed with DIFFERENT docs (distinct Yjs client ids). The atomic seedIfEmpty
309+
// must still let only one land — otherwise the union duplicates content.
310+
const a = await newStore()
311+
const b = await newStore()
312+
const tokenA = await a.shouldSeed(NAME)
313+
expect(tokenA).toBeTruthy()
314+
// Simulate A's lock expiring mid-seed so B also wins the freed lock over a still-empty stream.
315+
state.backing!.kv.delete(`filedoc:seedlock:${NAME}`)
316+
const tokenB = await b.shouldSeed(NAME)
317+
expect(tokenB).toBeTruthy()
318+
// Both tasks now race to seed with distinct client ids.
319+
const [seededA, seededB] = await Promise.all([
320+
a.seedIfEmpty(NAME, updateFor('SEED-A')),
321+
b.seedIfEmpty(NAME, updateFor('SEED-B')),
322+
])
323+
expect([seededA, seededB].filter(Boolean)).toHaveLength(1)
324+
// Exactly one seed is in the stream — the reconstructed text is a single seed, never a duplicated
325+
// union of both (e.g. 'SEED-ASEED-B').
326+
const doc = new Y.Doc()
327+
Y.applyUpdate(doc, (await a.getStreamState(NAME))!)
328+
expect(['SEED-A', 'SEED-B']).toContain(doc.getText('body').toString())
329+
doc.destroy()
330+
})
331+
332+
it('a peer edit published during attachRoom catch-up is not lost', async () => {
333+
// Author two INCREMENTAL edits from one doc so they converge to 'basepeer' (not an independent union).
334+
const author = new Y.Doc()
335+
const updates: Uint8Array[] = []
336+
author.on('update', (u: Uint8Array) => updates.push(u))
337+
author.getText('body').insert(0, 'base')
338+
author.getText('body').insert(4, 'peer')
339+
340+
const a = await newStore()
341+
a.publish(NAME, updates[0]) // 'base'
342+
await vi.waitFor(async () => expect(await a.getStreamState(NAME)).not.toBeNull())
343+
344+
// Task B attaches; while its synchronous catch-up runs, task A publishes the second edit. The tailer
345+
// resumes from the id catch-up stopped at, so the edit converges rather than falling into a gap.
346+
const b = await newStore()
347+
const bDoc = new Y.Doc()
348+
const attach = b.attachRoom(NAME, bDoc)
349+
a.publish(NAME, updates[1]) // 'peer' appended
350+
await attach
351+
await vi.waitFor(() => expect(bDoc.getText('body').toString()).toBe('basepeer'), {
352+
timeout: 2000,
353+
})
354+
bDoc.destroy()
355+
author.destroy()
356+
})
357+
358+
it('concurrent compaction on two tasks preserves the full document', async () => {
359+
const streamKey = `filedoc:stream:${NAME}`
360+
const noop = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())).toString('base64')
361+
362+
// Two peer edits neither compacting task has integrated (id > each task's lastId).
363+
const peerDoc = new Y.Doc()
364+
const peerUpdates: Uint8Array[] = []
365+
peerDoc.on('update', (u: Uint8Array) => peerUpdates.push(u))
366+
peerDoc.getText('body').insert(0, 'PEER1')
367+
peerDoc.getText('body').insert(5, 'PEER2')
368+
369+
const entries = Array.from({ length: 400 }, (_, i) => ({
370+
id: `${i + 1}-0`,
371+
message: { u: noop },
372+
}))
373+
entries.push({ id: '401-0', message: { u: Buffer.from(peerUpdates[0]).toString('base64') } })
374+
entries.push({ id: '402-0', message: { u: Buffer.from(peerUpdates[1]).toString('base64') } })
375+
state.backing!.streams.set(streamKey, entries)
376+
state.backing!.seq = 402
377+
378+
// Two tasks whose local docs lag at DIFFERENT points (400 and 401): both cross the threshold and
379+
// compact concurrently. Each must only trim what its own snapshot subsumes, so the union of both
380+
// snapshots plus the un-integrated peer entries still reconstructs the whole doc.
381+
const a = await newStore()
382+
const b = await newStore()
383+
const docA = new Y.Doc()
384+
Y.applyUpdate(docA, peerUpdates[0]) // A integrated up to 401
385+
;(a as any).rooms.set(NAME, { doc: docA, lastId: '401-0', publishes: 0 })
386+
;(b as any).rooms.set(NAME, { doc: new Y.Doc(), lastId: '400-0', publishes: 0 })
387+
await Promise.all([(a as any).maybeCompact(NAME), (b as any).maybeCompact(NAME)])
388+
389+
const doc = new Y.Doc()
390+
Y.applyUpdate(doc, (await a.getStreamState(NAME))!)
391+
expect(doc.getText('body').toString()).toBe('PEER1PEER2')
392+
doc.destroy()
393+
})
280394
})

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

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
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')
4850
const 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). */
103116
const 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. */
110123
const 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

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

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -373,24 +373,25 @@ async function ensureServerSeed(
373373
try {
374374
const update = await fetchFileDocSeed(workspaceId, room.fileId)
375375
if (fileDocRooms.get(name) !== room || isDocSeeded(room.doc)) return
376-
// Fence against a peer that seeded while we held a stale lock (a rare long stall): if the stream
377-
// already has content it is seeded, so never write a SECOND seed on top — that would split-brain.
378-
if (await store.streamHasContent(name)) {
379-
// Clear the guard so a later join can retry. Safe in both cases: a genuine peer-seed arrives via
380-
// the tailer (and shouldSeed/this fence then no-op), and a fail-closed Redis `xLen` error must not
381-
// strand the room unseeded forever.
382-
room.serverSeedStarted = false
383-
return
384-
}
385-
// Build the seed (file content + seed flag, or just the flag for an empty/missing file), then
386-
// PUBLISH it to the shared stream AWAITED *before* seeding the local doc. The doc is marked seeded
387-
// only once the seed is durably shared — so if the publish fails we leave the doc unseeded and the
388-
// stream empty for a clean retry, rather than serving an unpublished local seed that a peer would
389-
// re-seed on top of (split-brain). SEED_ORIGIN keeps `doc.on('update')` from re-publishing it.
376+
// Build the seed (file content + seed flag, or just the flag for an empty/missing file) and write it
377+
// to the shared stream ATOMICALLY, iff the stream is still empty. This — NOT the seed lock — is the
378+
// split-brain guard: two tasks racing (even both past an expired lock) can never both seed, because
379+
// the emptiness check and the append are one Redis-side step. Publish-before-apply: the doc is marked
380+
// seeded (via the local apply) only once the seed is durably in the stream, so a failed write leaves
381+
// the doc unseeded and the stream empty for a clean retry. SEED_ORIGIN keeps `doc.on('update')` from
382+
// re-publishing it.
390383
const seedUpdate = update ?? emptySeedUpdate()
391-
await store.publishAndWait(name, seedUpdate)
384+
const didSeed = await store.seedIfEmpty(name, seedUpdate)
392385
if (fileDocRooms.get(name) !== room || isDocSeeded(room.doc)) return
393-
Y.applyUpdate(room.doc, seedUpdate, SEED_ORIGIN)
386+
if (didSeed) {
387+
Y.applyUpdate(room.doc, seedUpdate, SEED_ORIGIN)
388+
} else {
389+
// A peer seeded first: its seed arrives via the tailer, so we must NOT apply our own — a second,
390+
// different-client-id seed IS the split-brain. Clear the guard so a later join can retry if that
391+
// peer seed somehow never lands (e.g. a fail-closed `xLen` error made `shouldSeed` skip a genuinely
392+
// empty stream); a real peer-seed makes the retry a no-op.
393+
room.serverSeedStarted = false
394+
}
394395
} catch (error) {
395396
logger.warn(`Server seed failed for file ${room.fileId} (workspace ${workspaceId})`, error)
396397
room.serverSeedStarted = false
@@ -549,8 +550,11 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
549550
// whichever task is last to leave persists real edits, even one that only tailed them. A compaction
550551
// snapshot on catch-up (REDIS_SNAPSHOT_ORIGIN) also counts: it folds real edits into one frame, so a
551552
// 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.
553+
// itself is never counted, so a seeded-but-unedited doc is never projected back over the file. NOTE:
554+
// in the multi-replica path a copilot merge is NOT excluded — it round-trips through the stream as
555+
// REDIS_ORIGIN, indistinguishable from a peer edit, so it marks `edited`. That only ever causes an
556+
// extra idempotent persist of content copilot already wrote directly (safe over-persist, never a lost
557+
// edit); the single-replica path applies the merge locally with no origin and does not count it.
554558
const seededBefore = room.seededObserved
555559
if (isDocSeeded(room.doc)) room.seededObserved = true
556560
if (

0 commit comments

Comments
 (0)