|
| 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