Skip to content

Commit 161b031

Browse files
committed
fix(collab-doc): harden distributed locks and durability from review
Address Greptile + Cursor review of the multi-replica backend: - Merge lock: retry LONGER than the lock TTL (guaranteed acquisition, never merges against a shared base while a peer holds the lock) and AWAIT the stream write before releasing, so the next task never diffs a stale base. - Distributed locks (seed/merge/compact) now use ownership tokens + a compare-and-delete release (Lua), so a lock that expired and was re-acquired by another task is never stolen; acquisition fails CLOSED on Redis error. - Seed: publish the seed to the stream AWAITED under the lock before releasing, so a later seeder's empty-stream fence always sees it (closes the fence's publish-after-release gap); TTL kept at the readiness deadline. - Persist: persist the AUTHORITATIVE stream state even when this task's local doc was never seeded, and capture the local fallback synchronously so a last-disconnect flush never encodes an already-destroyed doc. - Publish: retry a transient xAdd failure so a Redis blip can't silently drop an edit from the shared log.
1 parent c8ecfc0 commit 161b031

3 files changed

Lines changed: 242 additions & 104 deletions

File tree

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

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ interface Backing {
1313
streams: Map<string, { id: string; message: Record<string, string> }[]>
1414
kv: Map<string, string>
1515
seq: number
16+
/** Number of upcoming xAdd calls to fail with a transient error (to exercise publish retry). */
17+
failXAdd: number
1618
}
1719

1820
const state = vi.hoisted(() => ({ backing: null as Backing | null }))
@@ -30,6 +32,10 @@ function makeClient(): any {
3032
on: () => client,
3133
duplicate: () => makeClient(),
3234
xAdd: async (key: string, _star: string, fields: Record<string, string>) => {
35+
if (b().failXAdd > 0) {
36+
b().failXAdd--
37+
throw new Error('transient xAdd failure')
38+
}
3339
const id = `${++b().seq}-0`
3440
const arr = b().streams.get(key) ?? []
3541
arr.push({ id, message: { ...fields } })
@@ -65,6 +71,16 @@ function makeClient(): any {
6571
b().kv.delete(key)
6672
return 1
6773
},
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[] }) => {
76+
const [key] = opts.keys
77+
const [token] = opts.arguments
78+
if (b().kv.get(key) === token) {
79+
b().kv.delete(key)
80+
return 1
81+
}
82+
return 0
83+
},
6884
expire: async () => 1,
6985
}
7086
return client
@@ -101,7 +117,7 @@ async function newStore(): Promise<FileDocStore> {
101117

102118
describe('FileDocStore', () => {
103119
beforeEach(() => {
104-
state.backing = { streams: new Map(), kv: new Map(), seq: 0 }
120+
state.backing = { streams: new Map(), kv: new Map(), seq: 0, failXAdd: 0 }
105121
stores = []
106122
})
107123

@@ -112,20 +128,22 @@ describe('FileDocStore', () => {
112128
it('elects exactly one seeder across tasks (no split-brain seed)', async () => {
113129
const a = await newStore()
114130
const b = await newStore()
115-
const [aWon, bWon] = await Promise.all([a.shouldSeed(NAME), b.shouldSeed(NAME)])
116-
expect([aWon, bWon].filter(Boolean)).toHaveLength(1)
131+
// shouldSeed returns a lock token (truthy) for the winner, null for the loser.
132+
const [aTok, bTok] = await Promise.all([a.shouldSeed(NAME), b.shouldSeed(NAME)])
133+
expect([aTok, bTok].filter(Boolean)).toHaveLength(1)
117134
})
118135

119136
it('does not re-seed once the stream already has content (stale lock)', async () => {
120137
const a = await newStore()
121-
expect(await a.shouldSeed(NAME)).toBe(true)
138+
const token = await a.shouldSeed(NAME)
139+
expect(token).toBeTruthy()
122140
// A seeds and releases its lock.
123141
a.publish(NAME, updateFor('hello'))
124142
await vi.waitFor(async () => expect(await a.getStreamState(NAME)).not.toBeNull())
125-
await a.releaseSeedLock(NAME)
143+
await a.releaseSeedLock(NAME, token as string)
126144
// A different task must NOT seed again — the lock is free but the stream is non-empty.
127145
const b = await newStore()
128-
expect(await b.shouldSeed(NAME)).toBe(false)
146+
expect(await b.shouldSeed(NAME)).toBeNull()
129147
})
130148

131149
it('getStreamState reconstructs the shared document from the stream', async () => {
@@ -204,22 +222,50 @@ describe('FileDocStore', () => {
204222
doc.destroy()
205223
})
206224

225+
it('retries a transient append failure so the edit is not lost from the shared log', async () => {
226+
const a = await newStore()
227+
state.backing!.failXAdd = 2 // first two xAdd attempts throw; the third must succeed
228+
a.publish(NAME, updateFor('resilient'))
229+
await vi.waitFor(
230+
async () => {
231+
const doc = new Y.Doc()
232+
Y.applyUpdate(doc, (await a.getStreamState(NAME))!)
233+
expect(doc.getText('body').toString()).toBe('resilient')
234+
doc.destroy()
235+
},
236+
{ timeout: 2000 }
237+
)
238+
})
239+
240+
it('streamHasContent fences a seed apply against an already-seeded stream', async () => {
241+
const a = await newStore()
242+
expect(await a.streamHasContent(NAME)).toBe(false)
243+
a.publish(NAME, updateFor('seeded'))
244+
await vi.waitFor(async () => expect(await a.streamHasContent(NAME)).toBe(true))
245+
})
246+
207247
it('serializes merges across tasks via the merge lock', async () => {
208248
const a = await newStore()
209249
const b = await newStore()
210-
expect(await a.acquireMergeSlot(NAME, 5_000)).toBe(true)
250+
const aTok = await a.acquireMergeSlot(NAME, 5_000)
251+
expect(aTok).toBeTruthy()
211252
// A holds it → B is refused until A releases.
212-
expect(await b.acquireMergeSlot(NAME, 5_000)).toBe(false)
213-
await a.releaseMergeSlot(NAME)
214-
expect(await b.acquireMergeSlot(NAME, 5_000)).toBe(true)
215-
await b.releaseMergeSlot(NAME)
253+
expect(await b.acquireMergeSlot(NAME, 5_000)).toBeNull()
254+
// A stale-holder release with the WRONG token must NOT free A's lock (compare-and-delete).
255+
await b.releaseMergeSlot(NAME, 'wrong-token')
256+
expect(await b.acquireMergeSlot(NAME, 5_000)).toBeNull()
257+
// A releases with its real token → B can now acquire.
258+
await a.releaseMergeSlot(NAME, aTok as string)
259+
const bTok = await b.acquireMergeSlot(NAME, 5_000)
260+
expect(bTok).toBeTruthy()
261+
await b.releaseMergeSlot(NAME, bTok as string)
216262
})
217263

218264
it('is disabled without a REDIS_URL and behaves single-replica', async () => {
219265
const store = new FileDocStore(undefined)
220266
expect(store.enabled).toBe(false)
221-
// Seeds locally (returns true), never touches a stream.
222-
expect(await store.shouldSeed(NAME)).toBe(true)
267+
// Seeds locally (returns a sentinel token), never touches a stream.
268+
expect(await store.shouldSeed(NAME)).toBeTruthy()
223269
expect(await store.getStreamState(NAME)).toBeNull()
224270
const doc = new Y.Doc()
225271
await store.attachRoom(NAME, doc) // no-op, no throw

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

Lines changed: 130 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,20 @@ import { createLogger } from '@sim/logger'
3434
import { FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc'
3535
import { getErrorMessage } from '@sim/utils/errors'
3636
import { sleep } from '@sim/utils/helpers'
37+
import { generateId } from '@sim/utils/id'
38+
import { backoffWithJitter } from '@sim/utils/retry'
3739
import { createClient, type RedisClientType } from 'redis'
3840
import * as Y from 'yjs'
3941

4042
const 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. */
5766
const 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
6983
const COMPACT_THRESHOLD = 400
7084
/** Check whether compaction is due only every Nth local publish, to avoid an XLEN per keystroke. */
7185
const 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). */
79101
const 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

Comments
 (0)