Skip to content

Commit 7e3ec75

Browse files
committed
fix(files): close editor recovery and rendering gaps
1 parent fbf5cd8 commit 7e3ec75

9 files changed

Lines changed: 274 additions & 58 deletions

File tree

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

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ function makeClient(): any {
138138
},
139139
eval: async (script: string, opts: { keys: string[]; arguments: string[] }) => {
140140
const [key] = opts.keys
141+
if (script.startsWith('for _, key in ipairs(KEYS)')) return 1
141142
if (script.includes("redis.call('exists', KEYS[1])") && !b().streams.has(key)) {
142143
return script.includes('zscore') ? -1 : false
143144
}
@@ -149,13 +150,16 @@ function makeClient(): any {
149150
return opts.arguments[0]
150151
}
151152
if (script.includes("redis.call('del', KEYS[1], KEYS[4], KEYS[5])")) {
152-
const [, generationKey, versionKey, dedupeKey, agentKey] = opts.keys
153+
const [, generationKey, versionKey, dedupeKey, agentKey, invalidationKey] = opts.keys
153154
const [version, , marker] = opts.arguments
154155
const current = b().kv.get(versionKey)
156+
const invalidated = b().kv.get(invalidationKey)
157+
if (invalidated && Number(invalidated) >= Number(version)) return 0
155158
if (current && Number(current) > Number(version)) return 0
156159
if (current === version && b().kv.get(generationKey) === marker) return 0
157160
b().kv.set(generationKey, marker)
158161
b().kv.set(versionKey, version)
162+
b().kv.set(invalidationKey, version)
159163
b().streams.delete(key)
160164
b().dedupe.delete(dedupeKey)
161165
b().kv.delete(agentKey)
@@ -258,7 +262,7 @@ const REDIS_URL = 'redis://fake'
258262
const NAME = 'workspace-file-doc:file-1'
259263

260264
interface StoreTestAccess {
261-
localInvalidations: Map<string, number>
265+
localInvalidations: Map<string, { version: number; expiresAt: number }>
262266
rooms: Map<
263267
string,
264268
{
@@ -509,6 +513,41 @@ describe('FileDocStore', () => {
509513
await expect(store.getStreamState(NAME)).resolves.toBeNull()
510514
})
511515

516+
it('does not repeat an invalidation after the same durable version is reseeded', async () => {
517+
const store = await newStore()
518+
await store.seedIfEmpty(NAME, updateFor('old'), 10)
519+
await expect(store.invalidateDocument(NAME, 20)).resolves.toBe(true)
520+
await expect(store.seedIfEmpty(NAME, updateFor('replacement'), 20)).resolves.toBe(true)
521+
const generation = await store.getDocumentGeneration(NAME)
522+
const doc = new Y.Doc()
523+
Y.applyUpdate(doc, (await store.getStreamState(NAME))!)
524+
const before = Y.encodeStateVector(doc)
525+
doc.getText('body').insert(11, ' accepted')
526+
await store.publishClientUpdateAndWait(
527+
NAME,
528+
'accepted-edit',
529+
Y.encodeStateAsUpdate(doc, before),
530+
generation
531+
)
532+
533+
await expect(store.invalidateDocument(NAME, 20)).resolves.toBe(false)
534+
expect(await store.getDocumentGeneration(NAME)).toBe(generation)
535+
const recovered = new Y.Doc()
536+
Y.applyUpdate(recovered, (await store.getStreamState(NAME))!)
537+
expect(recovered.getText('body').toString()).toBe('replacement accepted')
538+
expect(state.backing!.dedupe.get(`filedoc:updates:${NAME}`)).toHaveLength(1)
539+
doc.destroy()
540+
recovered.destroy()
541+
})
542+
543+
it('applies the first invalidation even when its durable version was already seeded', async () => {
544+
const store = await newStore()
545+
await store.seedIfEmpty(NAME, updateFor('same content, changed eligibility'), 20)
546+
await expect(store.invalidateDocument(NAME, 20)).resolves.toBe(true)
547+
await expect(store.invalidateDocument(NAME, 20)).resolves.toBe(false)
548+
await expect(store.getStreamState(NAME)).resolves.toBeNull()
549+
})
550+
512551
it('does not resurrect a tracked stream with a dependency-only update after Redis loses it', async () => {
513552
const store = await newStore()
514553
await store.seedIfEmpty(NAME, updateFor('base'), 10)
@@ -576,6 +615,51 @@ describe('FileDocStore', () => {
576615
await expect(store.getStreamState(NAME)).rejects.toThrow('replaced')
577616
})
578617

618+
it.each([true, false])(
619+
'validates a modern snapshot following a legacy seed (same identity: %s)',
620+
async (sameIdentity) => {
621+
const store = await newStore()
622+
const seed = new Y.Doc()
623+
seed.getMap('config').set('initialContentLoaded', true)
624+
seed.getMap('config').set('docId', 'legacy-document')
625+
seed.getText('body').insert(0, 'legacy')
626+
seedLegacyStream(Y.encodeStateAsUpdate(seed))
627+
const backing = state.backing!
628+
backing.kv.set(
629+
`filedoc:generation:${NAME}`,
630+
sameIdentity ? 'legacy-document' : 'different-document'
631+
)
632+
backing.streams.get(`filedoc:stream:${NAME}`)!.push({
633+
id: `${++backing.seq}-0`,
634+
message: {
635+
u: Buffer.from(Y.encodeStateAsUpdate(seed)).toString('base64'),
636+
s: '1',
637+
g: sameIdentity ? 'legacy-document' : 'different-document',
638+
},
639+
})
640+
const attached = new Y.Doc()
641+
if (sameIdentity) {
642+
await store.attachRoom(NAME, attached)
643+
const before = Y.encodeStateVector(seed)
644+
seed.getText('body').insert(6, ' peer')
645+
await store.publishClientUpdateAndWait(
646+
NAME,
647+
'peer-edit',
648+
Y.encodeStateAsUpdate(seed, before),
649+
'legacy-document'
650+
)
651+
await store.catchUp(NAME)
652+
expect(attached.getText('body').toString()).toBe('legacy peer')
653+
store.detachRoom(NAME)
654+
} else {
655+
await expect(store.attachRoom(NAME, attached)).rejects.toThrow('replaced')
656+
expect(storeInternals(store).rooms.has(NAME)).toBe(false)
657+
}
658+
seed.destroy()
659+
attached.destroy()
660+
}
661+
)
662+
579663
it('replays stream history in bounded pages', async () => {
580664
const streamKey = `filedoc:stream:${NAME}`
581665
const noop = Buffer.from(updateFor('')).toString('base64')
@@ -1083,26 +1167,43 @@ describe('FileDocStore', () => {
10831167
doc.destroy()
10841168
})
10851169

1086-
it('bounds single-replica invalidation markers to the lifetime of active rooms', async () => {
1170+
it('expires idle single-replica invalidation watermarks', async () => {
1171+
vi.useFakeTimers()
10871172
const store = new FileDocStore(undefined)
1088-
for (let index = 0; index < 100; index++) {
1089-
await store.invalidateDocument(`closed-${index}`, 10)
1173+
try {
1174+
for (let index = 0; index < 100; index++) {
1175+
await store.invalidateDocument(`closed-${index}`, 10)
1176+
}
1177+
expect(storeInternals(store).localInvalidations.size).toBe(100)
1178+
await vi.advanceTimersByTimeAsync(660_000)
1179+
expect(storeInternals(store).localInvalidations.size).toBe(0)
1180+
} finally {
1181+
await store.shutdown()
1182+
vi.useRealTimers()
10901183
}
1091-
expect(storeInternals(store).localInvalidations.size).toBe(0)
1184+
})
1185+
1186+
it('deduplicates single-replica invalidations across same-version seeds and room reopen', async () => {
1187+
const store = new FileDocStore(undefined)
10921188
const staleDoc = new Y.Doc()
10931189
await store.attachRoom(NAME, staleDoc)
10941190
await store.invalidateDocument(NAME, 20)
10951191
await expect(store.seedIfEmpty(NAME, updateFor('stale fetched seed'), 10)).resolves.toBe(false)
10961192
await expect(store.isDocumentGenerationCurrent(NAME)).resolves.toBe(false)
10971193
expect(storeInternals(store).localInvalidations.size).toBe(1)
1194+
await expect(store.seedIfEmpty(NAME, updateFor('same-version seed'), 20)).resolves.toBe(true)
1195+
await expect(store.invalidateDocument(NAME, 20)).resolves.toBe(false)
1196+
await expect(store.isDocumentGenerationCurrent(NAME)).resolves.toBe(true)
10981197

10991198
store.detachRoom(NAME)
1100-
expect(storeInternals(store).localInvalidations.size).toBe(0)
1199+
expect(storeInternals(store).localInvalidations.size).toBe(1)
11011200
const freshDoc = new Y.Doc()
11021201
await store.attachRoom(NAME, freshDoc)
1103-
await expect(store.seedIfEmpty(NAME, updateFor('fresh authoritative seed'), 30)).resolves.toBe(
1202+
await expect(store.seedIfEmpty(NAME, updateFor('fresh authoritative seed'), 20)).resolves.toBe(
11041203
true
11051204
)
1205+
await expect(store.invalidateDocument(NAME, 20)).resolves.toBe(false)
1206+
await expect(store.isDocumentGenerationCurrent(NAME)).resolves.toBe(true)
11061207
await store.invalidateDocument(NAME, 40)
11071208
await store.shutdown()
11081209
expect(storeInternals(store).localInvalidations.size).toBe(0)

0 commit comments

Comments
 (0)