Skip to content

Commit 6fd3189

Browse files
committed
Merge remote-tracking branch 'origin/staging' into integrate/v2-w5
2 parents fa3724c + e8d278b commit 6fd3189

38 files changed

Lines changed: 2804 additions & 615 deletions

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

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@
1818
* client receives each update exactly once, from its own task's local broadcast — no adapter
1919
* amplification, and every task's doc stays converged. (Awareness/presence stay on the adapter: they
2020
* are ephemeral and need no convergence or replay.)
21-
* - {@link attachRoom} does a synchronous catch-up read from the head of the stream when a task first
22-
* opens a file, so a late-joining task (the normal case under autoscaling) loads the current shared
23-
* state before its first client syncs. Catch-up + tail are seamless: the tailer resumes from the
24-
* exact id catch-up stopped at.
21+
* - {@link attachRoom} reads the stream from the head when a task first opens a file, and the relay
22+
* AWAITS it before attaching a client, so a late-joining task (the normal case under autoscaling)
23+
* holds the current shared state before its first client syncs — a client must never watch the
24+
* catch-up land entry by entry, which is the document's edit history replaying on screen. Catch-up +
25+
* tail are seamless: the tailer resumes from the exact id catch-up stopped at, and {@link catchUp}
26+
* can re-run at any time for a caller that must converge without waiting on the tailer.
2527
* - The one-time seed is written via the atomic {@link seedIfEmpty} (append-iff-empty in one Redis
2628
* step), so exactly one task ever writes the seed cluster-wide (the fix for split-brain) — even if two
2729
* tasks race. {@link shouldSeed} is a Redis lock + empty-stream check layered on top ONLY as an
@@ -185,6 +187,18 @@ function applyEntryToDoc(
185187
}
186188
}
187189

190+
/**
191+
* Whether stream id `id` sorts after `than`. A Redis stream id is `<ms>-<seq>`, so a lexicographic
192+
* compare is wrong the moment the millisecond part changes digit length (`'9999-0' > '10000-0'`);
193+
* compare the two parts numerically instead. The initial `'0'` (nothing applied) has no `-seq` part,
194+
* which reads as sequence 0 — before every real entry.
195+
*/
196+
function isAfterStreamId(id: string, than: string): boolean {
197+
const [ms, seq = '0'] = id.split('-')
198+
const [thanMs, thanSeq = '0'] = than.split('-')
199+
return Number(ms) === Number(thanMs) ? Number(seq) > Number(thanSeq) : Number(ms) > Number(thanMs)
200+
}
201+
188202
/** Whether a doc carries the seed flag (mirrors the relay's `isDocSeeded`), so the store can tell the
189203
* one-time seed transition from a real post-seed edit without re-implementing the check divergently. */
190204
function isDocSeeded(doc: Y.Doc): boolean {
@@ -260,10 +274,9 @@ export class FileDocStore {
260274
}
261275

262276
/**
263-
* Register a locally-opened room and load the shared state into its doc: read the whole stream from
264-
* the head, apply every entry (origin {@link REDIS_ORIGIN}), and remember the last id so the tailer
265-
* resumes exactly after it. A brand-new file has an empty stream and loads nothing (it is seeded
266-
* shortly after, via {@link shouldSeed}). No-op when disabled.
277+
* Register a locally-opened room and load the shared state into its doc ({@link catchUp}). A
278+
* brand-new file has an empty stream and loads nothing (it is seeded shortly after, via
279+
* {@link shouldSeed}). No-op when disabled.
267280
*/
268281
async attachRoom(name: string, doc: Y.Doc): Promise<void> {
269282
if (!this.enabled || !this.write) return
@@ -277,12 +290,31 @@ export class FileDocStore {
277290
realEdited: false,
278291
}
279292
this.rooms.set(name, room)
293+
await this.catchUp(name)
294+
}
295+
296+
/**
297+
* PULL the shared state into a registered room: read the stream and apply every entry the doc has
298+
* not integrated yet (origin {@link REDIS_ORIGIN}), advancing `lastId` so the tailer resumes exactly
299+
* after it. This is the ONLY way a room loads shared state, so a caller that must not depend on the
300+
* tailer's asynchronous push — the join, which may not serve a client a half-assembled document —
301+
* can converge on demand. Idempotent and safe to call repeatedly; no-op when disabled or the room is
302+
* not registered (a fast open→close detached it). Never throws.
303+
*/
304+
async catchUp(name: string): Promise<void> {
305+
if (!this.enabled || !this.write) return
306+
const room = this.rooms.get(name)
307+
if (!room) return
280308
try {
281309
const entries = await this.write.xRange(streamKey(name), '-', '+')
282310
for (const entry of entries) {
283-
// The room can be detached + its doc destroyed while catch-up is in flight (a fast open→close);
284-
// stop touching it the moment that happens.
311+
// The room can be detached + its doc destroyed while the read is in flight (a fast
312+
// open→close); stop touching it the moment that happens.
285313
if (this.rooms.get(name) !== room) return
314+
// Applying a Yjs update twice is a no-op, but `applyEntry`'s bookkeeping is not: re-applying
315+
// the SEED after `seededObserved` latched would count it as a post-seed edit and let a
316+
// compaction snapshot claim content no user ever typed. Skip what this room already holds.
317+
if (!isAfterStreamId(entry.id, room.lastId)) continue
286318
this.applyEntry(room, entry.id, entry.message)
287319
}
288320
await this.write.expire(streamKey(name), STREAM_TTL_SEC)
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* The join's readiness contract, with the shared store ENABLED (`file-doc.test.ts` runs it disabled).
5+
*
6+
* A room loads its document from the file's Redis stream one entry at a time, into the same `Y.Doc`
7+
* that fans every update out to the room. So a client attached while that is happening is not sent the
8+
* document — it is sent the document's history, and it watches the history replay on screen (reload
9+
* right after moving a block and the block moves again in front of you). These tests pin the fix: the
10+
* join waits for the room to hold the whole document, so the client's first sync is authoritative.
11+
*/
12+
import {
13+
FILE_DOC_EVENTS,
14+
FILE_DOC_MESSAGE_TYPE,
15+
FILE_DOC_SEED,
16+
} from '@sim/realtime-protocol/file-doc'
17+
import * as decoding from 'lib0/decoding'
18+
import * as encoding from 'lib0/encoding'
19+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
20+
import * as syncProtocol from 'y-protocols/sync'
21+
import * as Y from 'yjs'
22+
import type { IRoomManager } from '@/rooms'
23+
24+
const { mockAuthorizeRoom, mockFetchFileDocSeed } = vi.hoisted(() => ({
25+
mockAuthorizeRoom: vi.fn(),
26+
mockFetchFileDocSeed: vi.fn(),
27+
}))
28+
29+
vi.mock('@sim/platform-authz/rooms', () => ({ authorizeRoom: mockAuthorizeRoom }))
30+
31+
vi.mock('@/handlers/file-doc-app', () => ({
32+
fetchFileDocSeed: mockFetchFileDocSeed,
33+
fetchFileDocMerge: vi.fn(),
34+
fetchFileDocPersist: vi.fn().mockResolvedValue({ status: 'persisted', version: 1 }),
35+
}))
36+
37+
/** One in-memory Redis backing per test — only the stream/lock ops the store actually uses. */
38+
const backing = vi.hoisted(() => ({
39+
streams: new Map<string, { id: string; message: Record<string, string> }[]>(),
40+
kv: new Map<string, string>(),
41+
seq: 0,
42+
/** Ticks of event-loop delay each xRange takes, modelling a remote (cross-region) Redis. */
43+
readDelayTicks: 0,
44+
}))
45+
46+
const seqOf = (id: string) => Number(id.split('-')[0])
47+
48+
vi.mock('redis', () => {
49+
const makeClient = (): Record<string, unknown> => {
50+
const client: Record<string, unknown> = {
51+
connect: async () => {},
52+
quit: async () => {},
53+
on: () => client,
54+
duplicate: () => makeClient(),
55+
xAdd: async (key: string, _star: string, fields: Record<string, string>) => {
56+
const id = `${++backing.seq}-0`
57+
const arr = backing.streams.get(key) ?? []
58+
arr.push({ id, message: { ...fields } })
59+
backing.streams.set(key, arr)
60+
return id
61+
},
62+
xRange: async (key: string) => {
63+
for (let i = 0; i < backing.readDelayTicks; i++) await Promise.resolve()
64+
return (backing.streams.get(key) ?? []).map((e) => ({ ...e }))
65+
},
66+
xLen: async (key: string) => (backing.streams.get(key) ?? []).length,
67+
xRead: async (streams: { key: string; id: string }[]) => {
68+
const res: { name: string; messages: { id: string; message: Record<string, string> }[] }[] =
69+
[]
70+
for (const { key, id } of streams) {
71+
const after = (backing.streams.get(key) ?? []).filter((e) => seqOf(e.id) > seqOf(id))
72+
if (after.length) res.push({ name: key, messages: after.map((e) => ({ ...e })) })
73+
}
74+
if (res.length) return res
75+
await new Promise((r) => setTimeout(r, 5))
76+
return null
77+
},
78+
set: async (key: string, val: string, opts?: { NX?: boolean }) => {
79+
if (opts?.NX && backing.kv.has(key)) return null
80+
backing.kv.set(key, val)
81+
return 'OK'
82+
},
83+
eval: async (script: string, opts: { keys: string[]; arguments: string[] }) => {
84+
const [key] = opts.keys
85+
if (script.includes('xlen')) {
86+
const [field, value] = opts.arguments
87+
const arr = backing.streams.get(key) ?? []
88+
if (arr.length > 0) return 0
89+
arr.push({ id: `${++backing.seq}-0`, message: { [field]: value } })
90+
backing.streams.set(key, arr)
91+
return 1
92+
}
93+
const [token] = opts.arguments
94+
if (backing.kv.get(key) === token) {
95+
backing.kv.delete(key)
96+
return 1
97+
}
98+
return 0
99+
},
100+
expire: async () => 1,
101+
get: async (key: string) => backing.kv.get(key) ?? null,
102+
exists: async (key: string) => (backing.kv.has(key) ? 1 : 0),
103+
}
104+
return client
105+
}
106+
return { createClient: () => makeClient() }
107+
})
108+
109+
import { cleanupFileDocForSocket, setupWorkspaceFileDocHandlers } from '@/handlers/file-doc'
110+
import { getFileDocStore, initFileDocStore } from '@/handlers/file-doc-store'
111+
112+
const FILE_ID = 'file-1'
113+
const ROOM_NAME = `workspace-file-doc:${FILE_ID}`
114+
const STREAM_KEY = `filedoc:stream:${ROOM_NAME}`
115+
const FIELD = 'default'
116+
117+
type Handler = (payload?: unknown) => Promise<void> | void
118+
119+
interface FakeSocket {
120+
id: string
121+
emit: (event: string, payload: unknown) => void
122+
rooms: Set<string>
123+
}
124+
125+
/**
126+
* An `io` that actually DELIVERS: a room emit reaches every socket that joined that room, so a frame
127+
* the relay fans out mid-assembly lands on the joiner's `emit` exactly as it would in the browser.
128+
* Recording the emits without routing them would hide the very thing these tests are about.
129+
*/
130+
function createIo(sockets: FakeSocket[]) {
131+
const emitTo = (target: string, except: string | null, event: string, payload: unknown) => {
132+
for (const socket of sockets) {
133+
if (socket.id === except || !socket.rooms.has(target)) continue
134+
socket.emit(event, payload)
135+
}
136+
}
137+
const to = vi.fn((target: string) => ({
138+
except: (exclude: string) => ({
139+
emit: (event: string, payload: unknown) => emitTo(target, exclude, event, payload),
140+
}),
141+
emit: (event: string, payload: unknown) => emitTo(target, null, event, payload),
142+
}))
143+
return {
144+
to,
145+
in: vi.fn(() => ({ socketsLeave: () => {} })),
146+
local: { to },
147+
} as unknown as IRoomManager['io']
148+
}
149+
150+
function setup(id: string, sockets: FakeSocket[]) {
151+
const handlers: Record<string, Handler> = {}
152+
const rooms = new Set<string>()
153+
const socket = {
154+
id,
155+
userId: 'user-1',
156+
userName: 'Test User',
157+
userImage: 'avatar.png',
158+
disconnected: false,
159+
rooms,
160+
on: vi.fn((event: string, handler: Handler) => {
161+
handlers[event] = handler
162+
}),
163+
emit: vi.fn(),
164+
join: vi.fn((name: string) => rooms.add(name)),
165+
leave: vi.fn((name: string) => rooms.delete(name)),
166+
}
167+
sockets.push(socket as unknown as FakeSocket)
168+
setupWorkspaceFileDocHandlers(
169+
socket as unknown as Parameters<typeof setupWorkspaceFileDocHandlers>[0],
170+
{ isReady: () => true, io: createIo(sockets) } as unknown as IRoomManager
171+
)
172+
return { socket, handlers }
173+
}
174+
175+
/** Append a Yjs update to the file's stream, exactly as `publish`/`seedIfEmpty` would. */
176+
function appendToStream(update: Uint8Array): void {
177+
const arr = backing.streams.get(STREAM_KEY) ?? []
178+
arr.push({ id: `${++backing.seq}-0`, message: { u: Buffer.from(update).toString('base64') } })
179+
backing.streams.set(STREAM_KEY, arr)
180+
}
181+
182+
/**
183+
* A warm room's history: the seed, then a later edit — the "I moved a block, then reloaded" case.
184+
* Returns the markdown-equivalent text of each state.
185+
*/
186+
function seedWarmStreamHistory(): { intermediate: string; final: string } {
187+
const doc = new Y.Doc()
188+
doc.getText(FIELD).insert(0, 'AAA')
189+
doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true)
190+
appendToStream(Y.encodeStateAsUpdate(doc))
191+
const afterSeed = Y.encodeStateVector(doc)
192+
doc.getText(FIELD).insert(0, 'BBB')
193+
appendToStream(Y.encodeStateAsUpdate(doc, afterSeed))
194+
doc.destroy()
195+
return { intermediate: 'AAA', final: 'BBBAAA' }
196+
}
197+
198+
/** Every document state this socket was ever shown, in order. */
199+
function statesDeliveredTo(socket: { emit: ReturnType<typeof vi.fn> }): string[] {
200+
const clientDoc = new Y.Doc()
201+
const states: string[] = []
202+
for (const [event, payload] of socket.emit.mock.calls) {
203+
if (event !== FILE_DOC_EVENTS.MESSAGE || !(payload instanceof Uint8Array)) continue
204+
const decoder = decoding.createDecoder(payload)
205+
if (decoding.readVarUint(decoder) !== FILE_DOC_MESSAGE_TYPE.SYNC) continue
206+
syncProtocol.readSyncMessage(decoder, encoding.createEncoder(), clientDoc, null)
207+
const text = clientDoc.getText(FIELD).toString()
208+
if (text !== (states.at(-1) ?? '')) states.push(text)
209+
}
210+
clientDoc.destroy()
211+
return states
212+
}
213+
214+
/** Let anything the join left running (a catch-up, a seed) settle, so a frame it fans out afterwards
215+
* is counted — that late delivery IS the replay these tests exist to rule out. */
216+
async function flushPendingWork(): Promise<void> {
217+
for (let i = 0; i < 20; i++) await Promise.resolve()
218+
}
219+
220+
/** Ask the server for its state the way a client does after the join ack. */
221+
function requestSyncStep2(handlers: Record<string, Handler>): void {
222+
const encoder = encoding.createEncoder()
223+
encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC)
224+
syncProtocol.writeSyncStep1(encoder, new Y.Doc())
225+
handlers[FILE_DOC_EVENTS.MESSAGE](encoding.toUint8Array(encoder))
226+
}
227+
228+
describe('file-doc join readiness (shared store enabled)', () => {
229+
/** Every socket the test created, so a room emit can be routed to its members. */
230+
const sockets: FakeSocket[] = []
231+
232+
// One store for the whole file: `initFileDocStore` is idempotent once enabled, so a per-test
233+
// shutdown would leave every later test running against a store with closed clients.
234+
beforeAll(async () => {
235+
await initFileDocStore('redis://fake')
236+
})
237+
238+
afterAll(async () => {
239+
await getFileDocStore().shutdown()
240+
})
241+
242+
beforeEach(() => {
243+
vi.clearAllMocks()
244+
backing.streams.clear()
245+
backing.kv.clear()
246+
backing.seq = 0
247+
backing.readDelayTicks = 0
248+
mockAuthorizeRoom.mockResolvedValue({
249+
allowed: true,
250+
status: 200,
251+
workspaceId: 'ws-1',
252+
workspacePermission: 'write',
253+
})
254+
mockFetchFileDocSeed.mockResolvedValue(null)
255+
})
256+
257+
afterEach(() => {
258+
cleanupFileDocForSocket('socket-1', createIo(sockets), true)
259+
sockets.length = 0
260+
})
261+
262+
it('hands a joiner the final document, never the room history it was rebuilt from', async () => {
263+
const { intermediate, final } = seedWarmStreamHistory()
264+
// The catch-up read is not instantaneous — the case that made this visible is a cross-region Redis.
265+
backing.readDelayTicks = 6
266+
const { socket, handlers } = setup('socket-1', sockets)
267+
268+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: FILE_ID, clientId: 1 })
269+
requestSyncStep2(handlers)
270+
await flushPendingWork()
271+
272+
// One state, and it is the final one: the client never saw the pre-move document.
273+
expect(statesDeliveredTo(socket)).toEqual([final])
274+
expect(statesDeliveredTo(socket)).not.toContain(intermediate)
275+
})
276+
277+
it('does not fetch a seed for a room the stream can already reconstruct', async () => {
278+
seedWarmStreamHistory()
279+
const { handlers } = setup('socket-1', sockets)
280+
281+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: FILE_ID, clientId: 1 })
282+
283+
expect(mockFetchFileDocSeed).not.toHaveBeenCalled()
284+
})
285+
286+
it('pulls a seed another writer put in the stream instead of waiting for the tailer to push it', async () => {
287+
// The seed lock is held by a writer whose room has since been dropped (a fast open→close on a
288+
// freshly created file), and its seed lands in the stream. Waiting to be told about it is what
289+
// left a new file un-editable until the client's readiness deadline lapsed; the join reads it.
290+
backing.kv.set(`filedoc:seedlock:${ROOM_NAME}`, 'held-by-a-writer-that-is-gone')
291+
const doc = new Y.Doc()
292+
doc.getText(FIELD).insert(0, 'seeded by the writer that held the lock')
293+
doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true)
294+
appendToStream(Y.encodeStateAsUpdate(doc))
295+
doc.destroy()
296+
297+
const { socket, handlers } = setup('socket-1', sockets)
298+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: FILE_ID, clientId: 1 })
299+
requestSyncStep2(handlers)
300+
await flushPendingWork()
301+
302+
expect(mockFetchFileDocSeed).not.toHaveBeenCalled()
303+
expect(statesDeliveredTo(socket)).toEqual(['seeded by the writer that held the lock'])
304+
})
305+
})

0 commit comments

Comments
 (0)