Skip to content

Commit b9d2e67

Browse files
authored
Merge pull request #6036 from simstudioai/collab-doc-multireplica
feat(collab-doc): multi-replica shared Yjs backend + server-side markdown persistence
2 parents 5558be8 + f62e99f commit b9d2e67

18 files changed

Lines changed: 1444 additions & 56 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,26 @@ export async function fetchFileDocMerge(
7272
}
7373
return new Uint8Array(Buffer.from(body.update, 'base64'))
7474
}
75+
76+
/**
77+
* Ask the app to project a live collaborative document back to durable markdown and write it to the
78+
* file (Yjs → markdown, through the exact editor engine). This is the server-authoritative durable
79+
* path — called debounced while the doc is edited and when the last collaborator leaves — that
80+
* replaces the editor's client autosave, so a server/copilot edit can't be clobbered by a stale
81+
* keystroke. THROWS on a transport failure so the caller can log/retry on the next debounce.
82+
*/
83+
export async function fetchFileDocPersist(
84+
workspaceId: string,
85+
fileId: string,
86+
userId: string,
87+
docState: Uint8Array
88+
): Promise<void> {
89+
const response = await postToApp(
90+
'/api/internal/file-doc/persist',
91+
{ workspaceId, fileId, userId, docState: Buffer.from(docState).toString('base64') },
92+
FILE_DOC_TIMEOUTS.persistRequestMs
93+
)
94+
if (!response.ok) {
95+
throw new Error(`Persist failed for file ${fileId}: ${response.status}`)
96+
}
97+
}
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import * as Y from 'yjs'
6+
7+
/**
8+
* One shared in-memory Redis backing per test, so several {@link FileDocStore} instances (modelling
9+
* several ECS tasks) all talk to the "same Redis". A minimal fake of just the stream/lock ops the
10+
* store uses.
11+
*/
12+
interface Backing {
13+
streams: Map<string, { id: string; message: Record<string, string> }[]>
14+
kv: Map<string, string>
15+
seq: number
16+
/** Number of upcoming xAdd calls to fail with a transient error (to exercise publish retry). */
17+
failXAdd: number
18+
}
19+
20+
const state = vi.hoisted(() => ({ backing: null as Backing | null }))
21+
22+
const seqOf = (id: string) => Number(id.split('-')[0])
23+
24+
function makeClient(): any {
25+
const b = () => {
26+
if (!state.backing) throw new Error('backing not initialized')
27+
return state.backing
28+
}
29+
const client: any = {
30+
connect: async () => {},
31+
quit: async () => {},
32+
on: () => client,
33+
duplicate: () => makeClient(),
34+
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+
}
39+
const id = `${++b().seq}-0`
40+
const arr = b().streams.get(key) ?? []
41+
arr.push({ id, message: { ...fields } })
42+
b().streams.set(key, arr)
43+
return id
44+
},
45+
xRange: async (key: string) => (b().streams.get(key) ?? []).map((e) => ({ ...e })),
46+
xLen: async (key: string) => (b().streams.get(key) ?? []).length,
47+
xTrim: async (key: string, _strategy: string, minid: string) => {
48+
const arr = b().streams.get(key) ?? []
49+
b().streams.set(
50+
key,
51+
arr.filter((e) => seqOf(e.id) >= seqOf(minid))
52+
)
53+
},
54+
xRead: async (streams: { key: string; id: string }[]) => {
55+
const res: { name: string; messages: { id: string; message: Record<string, string> }[] }[] =
56+
[]
57+
for (const { key, id } of streams) {
58+
const after = (b().streams.get(key) ?? []).filter((e) => seqOf(e.id) > seqOf(id))
59+
if (after.length) res.push({ name: key, messages: after.map((e) => ({ ...e })) })
60+
}
61+
if (res.length) return res
62+
await new Promise((r) => setTimeout(r, 5))
63+
return null
64+
},
65+
set: async (key: string, val: string, opts?: { NX?: boolean }) => {
66+
if (opts?.NX && b().kv.has(key)) return null
67+
b().kv.set(key, val)
68+
return 'OK'
69+
},
70+
del: async (key: string) => {
71+
b().kv.delete(key)
72+
return 1
73+
},
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+
},
84+
expire: async () => 1,
85+
}
86+
return client
87+
}
88+
89+
vi.mock('redis', () => ({ createClient: () => makeClient() }))
90+
91+
import { FileDocStore } from '@/handlers/file-doc-store'
92+
93+
const REDIS_URL = 'redis://fake'
94+
const NAME = 'workspace-file-doc:file-1'
95+
96+
function docWithText(text: string): Y.Doc {
97+
const doc = new Y.Doc()
98+
doc.getText('body').insert(0, text)
99+
return doc
100+
}
101+
102+
/** The delta a doc emits when `text` is inserted — what the relay would `publish`. */
103+
function updateFor(text: string): Uint8Array {
104+
const doc = docWithText(text)
105+
const update = Y.encodeStateAsUpdate(doc)
106+
doc.destroy()
107+
return update
108+
}
109+
110+
let stores: FileDocStore[] = []
111+
async function newStore(): Promise<FileDocStore> {
112+
const store = new FileDocStore(REDIS_URL)
113+
await store.init()
114+
stores.push(store)
115+
return store
116+
}
117+
118+
describe('FileDocStore', () => {
119+
beforeEach(() => {
120+
state.backing = { streams: new Map(), kv: new Map(), seq: 0, failXAdd: 0 }
121+
stores = []
122+
})
123+
124+
afterEach(async () => {
125+
await Promise.all(stores.map((s) => s.shutdown()))
126+
})
127+
128+
it('elects exactly one seeder across tasks (no split-brain seed)', async () => {
129+
const a = await newStore()
130+
const b = await newStore()
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)
134+
})
135+
136+
it('does not re-seed once the stream already has content (stale lock)', async () => {
137+
const a = await newStore()
138+
const token = await a.shouldSeed(NAME)
139+
expect(token).toBeTruthy()
140+
// A seeds and releases its lock.
141+
a.publish(NAME, updateFor('hello'))
142+
await vi.waitFor(async () => expect(await a.getStreamState(NAME)).not.toBeNull())
143+
await a.releaseSeedLock(NAME, token as string)
144+
// A different task must NOT seed again — the lock is free but the stream is non-empty.
145+
const b = await newStore()
146+
expect(await b.shouldSeed(NAME)).toBeNull()
147+
})
148+
149+
it('getStreamState reconstructs the shared document from the stream', async () => {
150+
const a = await newStore()
151+
a.publish(NAME, updateFor('shared content'))
152+
let state: Uint8Array | null = null
153+
await vi.waitFor(async () => {
154+
state = await a.getStreamState(NAME)
155+
expect(state).not.toBeNull()
156+
})
157+
const doc = new Y.Doc()
158+
Y.applyUpdate(doc, state!)
159+
expect(doc.getText('body').toString()).toBe('shared content')
160+
doc.destroy()
161+
})
162+
163+
it('attachRoom catches a fresh task up to the current shared state', async () => {
164+
const a = await newStore()
165+
a.publish(NAME, updateFor('already here'))
166+
await vi.waitFor(async () => expect(await a.getStreamState(NAME)).not.toBeNull())
167+
168+
// A second task opens the same file: its doc must load the existing content, not start empty.
169+
const b = await newStore()
170+
const doc = new Y.Doc()
171+
await b.attachRoom(NAME, doc)
172+
expect(doc.getText('body').toString()).toBe('already here')
173+
doc.destroy()
174+
})
175+
176+
it('converges a peer task via the tailer after attach', async () => {
177+
const a = await newStore()
178+
const b = await newStore()
179+
const bDoc = new Y.Doc()
180+
await b.attachRoom(NAME, bDoc)
181+
182+
// A publishes an edit; B's multiplexed reader must apply it to B's attached doc.
183+
a.publish(NAME, updateFor('from task A'))
184+
await vi.waitFor(() => expect(bDoc.getText('body').toString()).toBe('from task A'), {
185+
timeout: 2000,
186+
})
187+
bDoc.destroy()
188+
})
189+
190+
it('compaction never trims peer entries the compacting task has not yet integrated', async () => {
191+
const streamKey = `filedoc:stream:${NAME}`
192+
const noop = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())).toString('base64')
193+
194+
// Two peer edits published by ANOTHER task that this task's tailer has not read yet.
195+
const peerDoc = new Y.Doc()
196+
const peerUpdates: Uint8Array[] = []
197+
peerDoc.on('update', (u: Uint8Array) => peerUpdates.push(u))
198+
peerDoc.getText('body').insert(0, 'PEER1')
199+
peerDoc.getText('body').insert(5, 'PEER2')
200+
201+
// Backing: 400 already-integrated (no-op) entries this task's doc reflects, then the 2 un-integrated
202+
// peer entries. Enough entries to cross COMPACT_THRESHOLD.
203+
const entries = Array.from({ length: 400 }, (_, i) => ({
204+
id: `${i + 1}-0`,
205+
message: { u: noop },
206+
}))
207+
entries.push({ id: '401-0', message: { u: Buffer.from(peerUpdates[0]).toString('base64') } })
208+
entries.push({ id: '402-0', message: { u: Buffer.from(peerUpdates[1]).toString('base64') } })
209+
state.backing!.streams.set(streamKey, entries)
210+
state.backing!.seq = 402
211+
212+
const a = await newStore()
213+
// This task has integrated only up to entry 400 (all no-ops) — its local doc is empty and lags the
214+
// two peer entries. Inject that lagging room directly.
215+
;(a as any).rooms.set(NAME, { doc: new Y.Doc(), lastId: '400-0', publishes: 0 })
216+
await (a as any).maybeCompact(NAME)
217+
218+
// A fresh catch-up must still reconstruct the peer content — compaction must not have trimmed 401/402.
219+
const doc = new Y.Doc()
220+
Y.applyUpdate(doc, (await a.getStreamState(NAME))!)
221+
expect(doc.getText('body').toString()).toBe('PEER1PEER2')
222+
doc.destroy()
223+
224+
// The appended snapshot entry must carry the snapshot marker, so a fresh catch-up task treats it as
225+
// edited content (not a bare seed) and persists on last-disconnect.
226+
const stream = state.backing!.streams.get(streamKey)!
227+
expect(stream[stream.length - 1].message.s).toBe('1')
228+
})
229+
230+
it('retries a transient append failure so the edit is not lost from the shared log', async () => {
231+
const a = await newStore()
232+
state.backing!.failXAdd = 2 // first two xAdd attempts throw; the third must succeed
233+
a.publish(NAME, updateFor('resilient'))
234+
await vi.waitFor(
235+
async () => {
236+
const doc = new Y.Doc()
237+
Y.applyUpdate(doc, (await a.getStreamState(NAME))!)
238+
expect(doc.getText('body').toString()).toBe('resilient')
239+
doc.destroy()
240+
},
241+
{ timeout: 2000 }
242+
)
243+
})
244+
245+
it('streamHasContent fences a seed apply against an already-seeded stream', async () => {
246+
const a = await newStore()
247+
expect(await a.streamHasContent(NAME)).toBe(false)
248+
a.publish(NAME, updateFor('seeded'))
249+
await vi.waitFor(async () => expect(await a.streamHasContent(NAME)).toBe(true))
250+
})
251+
252+
it('serializes merges across tasks via the merge lock', async () => {
253+
const a = await newStore()
254+
const b = await newStore()
255+
const aTok = await a.acquireMergeSlot(NAME, 5_000)
256+
expect(aTok).toBeTruthy()
257+
// A holds it → B is refused until A releases.
258+
expect(await b.acquireMergeSlot(NAME, 5_000)).toBeNull()
259+
// A stale-holder release with the WRONG token must NOT free A's lock (compare-and-delete).
260+
await b.releaseMergeSlot(NAME, 'wrong-token')
261+
expect(await b.acquireMergeSlot(NAME, 5_000)).toBeNull()
262+
// A releases with its real token → B can now acquire.
263+
await a.releaseMergeSlot(NAME, aTok as string)
264+
const bTok = await b.acquireMergeSlot(NAME, 5_000)
265+
expect(bTok).toBeTruthy()
266+
await b.releaseMergeSlot(NAME, bTok as string)
267+
})
268+
269+
it('is disabled without a REDIS_URL and behaves single-replica', async () => {
270+
const store = new FileDocStore(undefined)
271+
expect(store.enabled).toBe(false)
272+
// Seeds locally (returns a sentinel token), never touches a stream.
273+
expect(await store.shouldSeed(NAME)).toBeTruthy()
274+
expect(await store.getStreamState(NAME)).toBeNull()
275+
const doc = new Y.Doc()
276+
await store.attachRoom(NAME, doc) // no-op, no throw
277+
expect(doc.getText('body').toString()).toBe('')
278+
doc.destroy()
279+
})
280+
})

0 commit comments

Comments
 (0)