Skip to content

Commit 36c35e3

Browse files
committed
fix(files): reconcile staging stream compaction safeguards
2 parents 8d6939c + a69f416 commit 36c35e3

26 files changed

Lines changed: 1851 additions & 259 deletions

File tree

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

Lines changed: 207 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ interface Backing {
3939
maxReadStreams: number
4040
maxReadCount: number
4141
onSnapshot?: () => Promise<void>
42+
failSnapshotTrim?: boolean
4243
onLength?: () => Promise<void>
4344
}
4445

@@ -213,7 +214,8 @@ function makeClient(): any {
213214
}
214215
if (script.includes('ARGV[5], ARGV[4]')) {
215216
const [, generationKey] = opts.keys
216-
const [field, value, marker, expectedGeneration, generationField, upTo] = opts.arguments
217+
const [field, value, marker, expectedGeneration, generationField, upTo, compactionField] =
218+
opts.arguments
217219
const generation = b().kv.get(generationKey)
218220
if ((generation ?? '') !== expectedGeneration) return false
219221
const id = nextId()
@@ -224,10 +226,12 @@ function makeClient(): any {
224226
[field]: value,
225227
[marker]: '1',
226228
[generationField]: expectedGeneration,
229+
...(compactionField ? { [compactionField]: '1' } : {}),
227230
},
228231
})
229232
b().streams.set(key, arr)
230233
if (script.includes("redis.call('xtrim'")) {
234+
if (b().failSnapshotTrim) throw new Error('snapshot trim failed')
231235
b().streams.set(
232236
key,
233237
arr.filter((entry) => compareStreamIds(entry.id, upTo) >= 0n)
@@ -280,22 +284,29 @@ import { FileDocStore, REDIS_AGENT_ORIGIN, REDIS_ORIGIN } from '@/handlers/file-
280284
const REDIS_URL = 'redis://fake'
281285
const NAME = 'workspace-file-doc:file-1'
282286

287+
interface StoreRoomTestAccess {
288+
doc: Y.Doc
289+
lastId: string
290+
publishes: number
291+
uncompactedDeltaBytes: number
292+
lastDeltaBytes: number
293+
compactRetryAfter: number
294+
compacting: boolean
295+
seededObserved: boolean
296+
realEdited: boolean
297+
}
298+
283299
interface StoreTestAccess {
284300
localInvalidations: Map<string, { version: number; expiresAt: number }>
285-
rooms: Map<
286-
string,
287-
{
288-
doc: Y.Doc
289-
lastId: string
290-
publishes: number
291-
uncompactedDeltaBytes: number
292-
compacting: boolean
293-
seededObserved: boolean
294-
realEdited: boolean
295-
}
296-
>
301+
rooms: Map<string, StoreRoomTestAccess>
297302
maybeCompact(name: string): Promise<void>
298303
appendUpdate(name: string, update: Uint8Array): Promise<void>
304+
applyEntry(
305+
name: string,
306+
room: StoreRoomTestAccess,
307+
id: string,
308+
message: Record<string, string>
309+
): void
299310
}
300311

301312
function storeInternals(store: FileDocStore): StoreTestAccess {
@@ -958,6 +969,8 @@ describe('FileDocStore', () => {
958969
lastId: '400-0',
959970
publishes: 0,
960971
uncompactedDeltaBytes: 0,
972+
lastDeltaBytes: 0,
973+
compactRetryAfter: 0,
961974
compacting: false,
962975
seededObserved: true,
963976
realEdited: true,
@@ -1020,6 +1033,174 @@ describe('FileDocStore', () => {
10201033
doc.destroy()
10211034
})
10221035

1036+
it('compacts a burst of large edits below the entry threshold without losing content', async () => {
1037+
seedLegacyStream()
1038+
const store = await newStore()
1039+
const doc = new Y.Doc()
1040+
await store.attachRoom(NAME, doc)
1041+
const source = new Y.Doc()
1042+
for (let index = 0; index < 4; index++) {
1043+
const before = Y.encodeStateVector(source)
1044+
source.getText('body').insert(0, 'x'.repeat(3 * 1024 * 1024))
1045+
await store.publishAndWait(NAME, Y.encodeStateAsUpdate(source, before))
1046+
}
1047+
1048+
await vi.waitFor(() => {
1049+
const stream = state.backing!.streams.get(`filedoc:stream:${NAME}`)!
1050+
expect(stream.length).toBeLessThan(400)
1051+
expect(stream.some((entry) => entry.message.c === '1')).toBe(true)
1052+
})
1053+
const rebuilt = new Y.Doc()
1054+
Y.applyUpdate(rebuilt, (await store.getStreamState(NAME))!)
1055+
expect(rebuilt.getText('body').length).toBe(12 * 1024 * 1024)
1056+
store.detachRoom(NAME)
1057+
rebuilt.destroy()
1058+
source.destroy()
1059+
doc.destroy()
1060+
})
1061+
1062+
it('does not append a full snapshot per small edit after growing beyond the byte threshold', async () => {
1063+
seedLegacyStream()
1064+
const store = await newStore()
1065+
const doc = new Y.Doc()
1066+
await store.attachRoom(NAME, doc)
1067+
const source = new Y.Doc()
1068+
for (let index = 0; index < 33; index++) {
1069+
const before = Y.encodeStateVector(source)
1070+
source.getText('body').insert(0, index < 3 ? 'x'.repeat(3 * 1024 * 1024) : 'tiny')
1071+
await store.publishAndWait(NAME, Y.encodeStateAsUpdate(source, before))
1072+
await store.catchUp(NAME)
1073+
}
1074+
const streamKey = `filedoc:stream:${NAME}`
1075+
await vi.waitFor(() => {
1076+
expect(storeInternals(store).rooms.get(NAME)!.compacting).toBe(false)
1077+
expect(state.backing!.streams.get(streamKey)!.some((entry) => entry.message.c === '1')).toBe(
1078+
true
1079+
)
1080+
})
1081+
expect(
1082+
state.backing!.streams.get(streamKey)!.filter((entry) => entry.message.c === '1').length
1083+
).toBeLessThanOrEqual(2)
1084+
const rebuilt = new Y.Doc()
1085+
Y.applyUpdate(rebuilt, (await store.getStreamState(NAME))!)
1086+
expect(rebuilt.getText('body').length).toBe(9 * 1024 * 1024 + 30 * 4)
1087+
expect(rebuilt.getText('body').toString().startsWith('tiny')).toBe(true)
1088+
store.detachRoom(NAME)
1089+
source.destroy()
1090+
rebuilt.destroy()
1091+
doc.destroy()
1092+
})
1093+
1094+
it.each([false, true])(
1095+
'adopts and compacts an oversized legacy stream (agent: %s)',
1096+
async (agent) => {
1097+
const source = new Y.Doc()
1098+
const updates: Uint8Array[] = []
1099+
source.on('update', (update: Uint8Array) => updates.push(update))
1100+
source.getText('body').insert(0, 'x'.repeat(7 * 1024 * 1024))
1101+
source.getText('body').insert(0, 'tail')
1102+
const streamKey = `filedoc:stream:${NAME}`
1103+
state.backing!.streams.set(
1104+
streamKey,
1105+
updates.map((update, index) => ({
1106+
id: `${index + 1}-0`,
1107+
message: { u: Buffer.from(update).toString('base64'), ...(agent ? { a: '1' } : {}) },
1108+
}))
1109+
)
1110+
state.backing!.seq = updates.length
1111+
const store = await newStore()
1112+
const doc = new Y.Doc()
1113+
await store.attachRoom(NAME, doc)
1114+
1115+
await vi.waitFor(() =>
1116+
expect(
1117+
state.backing!.streams.get(streamKey)!.some((entry) => entry.message.c === '1')
1118+
).toBe(true)
1119+
)
1120+
const rebuilt = new Y.Doc()
1121+
Y.applyUpdate(rebuilt, (await store.getStreamState(NAME))!)
1122+
expect(rebuilt.getText('body').length).toBe(7 * 1024 * 1024 + 4)
1123+
store.detachRoom(NAME)
1124+
rebuilt.destroy()
1125+
source.destroy()
1126+
doc.destroy()
1127+
}
1128+
)
1129+
1130+
it('keeps failed compaction accounting armed without repeated snapshot appends', async () => {
1131+
seedLegacyStream()
1132+
const store = await newStore()
1133+
const doc = new Y.Doc()
1134+
await store.attachRoom(NAME, doc)
1135+
const room = storeInternals(store).rooms.get(NAME)!
1136+
room.uncompactedDeltaBytes = 9 * 1024 * 1024
1137+
state.backing!.failSnapshotTrim = true
1138+
1139+
await storeInternals(store).maybeCompact(NAME)
1140+
1141+
expect(room.uncompactedDeltaBytes).toBe(9 * 1024 * 1024)
1142+
expect(room.compactRetryAfter).toBeGreaterThan(Date.now())
1143+
const entriesAfterFailure = state.backing!.streams.get(`filedoc:stream:${NAME}`)!.length
1144+
await storeInternals(store).maybeCompact(NAME)
1145+
await storeInternals(store).maybeCompact(NAME)
1146+
expect(state.backing!.streams.get(`filedoc:stream:${NAME}`)).toHaveLength(entriesAfterFailure)
1147+
1148+
state.backing!.failSnapshotTrim = false
1149+
room.compactRetryAfter = 0
1150+
await storeInternals(store).maybeCompact(NAME)
1151+
expect(room.uncompactedDeltaBytes).toBeLessThan(9 * 1024 * 1024)
1152+
store.detachRoom(NAME)
1153+
doc.destroy()
1154+
})
1155+
1156+
it('retains the last delta without repeatedly folding an unreclaimable boundary', async () => {
1157+
seedLegacyStream()
1158+
const store = await newStore()
1159+
const doc = new Y.Doc()
1160+
await store.attachRoom(NAME, doc)
1161+
const room = storeInternals(store).rooms.get(NAME)!
1162+
room.uncompactedDeltaBytes = 9 * 1024 * 1024
1163+
room.lastDeltaBytes = room.uncompactedDeltaBytes
1164+
await storeInternals(store).maybeCompact(NAME)
1165+
expect(state.backing!.streams.get(`filedoc:stream:${NAME}`)).toHaveLength(1)
1166+
expect(room.uncompactedDeltaBytes).toBe(9 * 1024 * 1024)
1167+
store.detachRoom(NAME)
1168+
doc.destroy()
1169+
})
1170+
1171+
it('excludes older agent compaction output from delta bytes', async () => {
1172+
seedLegacyStream()
1173+
const store = await newStore()
1174+
const doc = new Y.Doc()
1175+
await store.attachRoom(NAME, doc)
1176+
const room = storeInternals(store).rooms.get(NAME)!
1177+
const priorBytes = room.uncompactedDeltaBytes
1178+
const encoded = Buffer.from(updateFor('agent snapshot')).toString('base64')
1179+
storeInternals(store).applyEntry(NAME, room, '7-0', { u: encoded, a: '1', c: '1' })
1180+
expect(room.uncompactedDeltaBytes).toBe(priorBytes)
1181+
expect(room.lastDeltaBytes).toBe(0)
1182+
expect(doc.getText('body').toString()).toBe('agent snapshot')
1183+
store.detachRoom(NAME)
1184+
doc.destroy()
1185+
})
1186+
1187+
it('counts peer deltas once using ordered replay without retaining an entry ledger', async () => {
1188+
seedLegacyStream()
1189+
const store = await newStore()
1190+
const doc = new Y.Doc()
1191+
await store.attachRoom(NAME, doc)
1192+
const room = storeInternals(store).rooms.get(NAME)!
1193+
const priorBytes = room.uncompactedDeltaBytes
1194+
const encoded = Buffer.from(updateFor('peer')).toString('base64')
1195+
storeInternals(store).applyEntry(NAME, room, '4-0', { u: encoded })
1196+
storeInternals(store).applyEntry(NAME, room, '4-0', { u: encoded })
1197+
storeInternals(store).applyEntry(NAME, room, '3-0', { u: encoded })
1198+
expect(room.uncompactedDeltaBytes).toBe(priorBytes + encoded.length)
1199+
expect(room.lastDeltaBytes).toBe(encoded.length)
1200+
store.detachRoom(NAME)
1201+
doc.destroy()
1202+
})
1203+
10231204
it('stamps a compaction snapshot of an agent-ONLY stream as an agent frame (never persisted)', async () => {
10241205
const streamKey = `filedoc:stream:${NAME}`
10251206
const noop = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())).toString('base64')
@@ -1038,6 +1219,8 @@ describe('FileDocStore', () => {
10381219
lastId: '400-0',
10391220
publishes: 0,
10401221
uncompactedDeltaBytes: 0,
1222+
lastDeltaBytes: 0,
1223+
compactRetryAfter: 0,
10411224
compacting: false,
10421225
seededObserved: true,
10431226
realEdited: false,
@@ -1186,6 +1369,8 @@ describe('FileDocStore', () => {
11861369
lastId: '1-0',
11871370
publishes: 0,
11881371
uncompactedDeltaBytes: 12 * 1024 * 1024,
1372+
lastDeltaBytes: 0,
1373+
compactRetryAfter: 0,
11891374
compacting: false,
11901375
seededObserved: true,
11911376
realEdited: true,
@@ -1239,20 +1424,23 @@ describe('FileDocStore', () => {
12391424
loaded.destroy()
12401425
})
12411426

1242-
it('preserves exactly the delta bytes observed after a compaction barrier', async () => {
1427+
it('preserves the inclusive barrier and delta bytes observed during compaction', async () => {
12431428
seedLegacyStream()
12441429
const store = await newStore()
12451430
const doc = new Y.Doc()
12461431
await store.attachRoom(NAME, doc)
12471432
const room = storeInternals(store).rooms.get(NAME)!
12481433
room.uncompactedDeltaBytes = 12 * 1024 * 1024
1434+
const retainedBarrierBytes = room.lastDeltaBytes
12491435
const lateUpdate = updateFor('concurrent edit')
12501436
state.backing!.onSnapshot = async () => {
12511437
await store.publishAndWait(NAME, lateUpdate)
12521438
await store.catchUp(NAME)
12531439
}
12541440
await storeInternals(store).maybeCompact(NAME)
1255-
expect(room.uncompactedDeltaBytes).toBe(Buffer.from(lateUpdate).toString('base64').length)
1441+
expect(room.uncompactedDeltaBytes).toBe(
1442+
retainedBarrierBytes + Buffer.from(lateUpdate).toString('base64').length
1443+
)
12561444
expect(doc.getText('body').toString()).toBe('concurrent edit')
12571445
store.detachRoom(NAME)
12581446
doc.destroy()
@@ -1546,6 +1734,8 @@ describe('FileDocStore', () => {
15461734
lastId: '401-0',
15471735
publishes: 0,
15481736
uncompactedDeltaBytes: 0,
1737+
lastDeltaBytes: 0,
1738+
compactRetryAfter: 0,
15491739
compacting: false,
15501740
seededObserved: true,
15511741
realEdited: true,
@@ -1555,6 +1745,8 @@ describe('FileDocStore', () => {
15551745
lastId: '400-0',
15561746
publishes: 0,
15571747
uncompactedDeltaBytes: 0,
1748+
lastDeltaBytes: 0,
1749+
compactRetryAfter: 0,
15581750
compacting: false,
15591751
seededObserved: true,
15601752
realEdited: true,

0 commit comments

Comments
 (0)