Skip to content

Commit 0529873

Browse files
waleedlatif1claude
andcommitted
fix(realtime): adopt byte accounting for a stream taken over
A room attaching to an existing stream started from an empty ledger, so a multi-megabyte stream under the entry threshold stayed unfolded while that room's own heartbeat kept refreshing its TTL — a restart or handoff could hold one open indefinitely. `catchUp` already reads every entry to rebuild the doc, so adopting their bytes costs no extra work. Plain deltas only: a compaction snapshot is the result of a fold rather than something a fold can reclaim, so counting one would arm the trigger against itself. The trigger is re-checked once, after catch-up, since nothing else re-checks until the next local publish and a read-only participant never makes one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3475eb7 commit 0529873

2 files changed

Lines changed: 56 additions & 0 deletions

File tree

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,45 @@ describe('FileDocStore', () => {
552552
doc.destroy()
553553
})
554554

555+
it('adopts accounting for a stream it takes over, and folds it if already over the ceiling', async () => {
556+
const streamKey = `filedoc:stream:${NAME}`
557+
// A stream left behind by a previous task: two entries, so far under the entry threshold, and
558+
// far over the byte ceiling. A fresh room starting from an empty ledger would never fold it,
559+
// while its own heartbeat kept refreshing the TTL.
560+
const seedDoc = new Y.Doc()
561+
const updates: Uint8Array[] = []
562+
seedDoc.on('update', (u: Uint8Array) => updates.push(u))
563+
seedDoc.getText('body').insert(0, 'x'.repeat(9 * 1024 * 1024))
564+
seedDoc.getText('body').insert(0, 'tail')
565+
state.backing!.streams.set(
566+
streamKey,
567+
updates.map((update, index) => ({
568+
id: `${index + 1}-0`,
569+
message: { u: Buffer.from(update).toString('base64') },
570+
}))
571+
)
572+
state.backing!.seq = updates.length
573+
574+
const a = await newStore()
575+
const doc = new Y.Doc()
576+
await a.attachRoom(NAME, doc)
577+
578+
// Either marker counts as a fold: this room only ever replayed entries, so it never observed
579+
// a real edit and its snapshot is stamped as an agent frame (the no-persist guarantee).
580+
await vi.waitFor(() => {
581+
const stream = state.backing!.streams.get(streamKey)!
582+
expect(stream.some((entry) => entry.message.s === '1' || entry.message.a === '1')).toBe(true)
583+
})
584+
585+
// Lossless: the adopted content survives the fold it triggered.
586+
const rebuilt = new Y.Doc()
587+
Y.applyUpdate(rebuilt, (await a.getStreamState(NAME))!)
588+
expect(rebuilt.getText('body').length).toBe(9 * 1024 * 1024 + 4)
589+
rebuilt.destroy()
590+
doc.destroy()
591+
seedDoc.destroy()
592+
})
593+
555594
it('stamps a compaction snapshot of an agent-ONLY stream as an agent frame (never persisted)', async () => {
556595
const streamKey = `filedoc:stream:${NAME}`
557596
const noop = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())).toString('base64')

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,9 +391,26 @@ export class FileDocStore {
391391
// the SEED after `seededObserved` latched would count it as a post-seed edit and let a
392392
// compaction snapshot claim content no user ever typed. Skip what this room already holds.
393393
if (!isAfterStreamId(entry.id, room.lastId)) continue
394+
// Adopt the accounting for what is already in the stream. A task taking one over would
395+
// otherwise start from an empty ledger, so a multi-megabyte stream under the entry
396+
// threshold would stay unfolded while this room's heartbeat keeps refreshing its TTL.
397+
// These entries are already being read to rebuild the doc, so this costs no extra work —
398+
// unlike seeding from a scan we would not otherwise do.
399+
//
400+
// Plain deltas only: a compaction snapshot is the RESULT of a fold, not something a fold
401+
// can reclaim, so counting one would arm the trigger against itself.
402+
if (!entry.message[SNAPSHOT_FIELD] && !entry.message[AGENT_FIELD]) {
403+
room.pendingDeltas.push({
404+
id: entry.id,
405+
bytes: entry.message[UPDATE_FIELD]?.length ?? 0,
406+
})
407+
}
394408
this.applyEntry(room, entry.id, entry.message)
395409
}
396410
await this.write.expire(streamKey(name), STREAM_TTL_SEC)
411+
// The adopted entries may already be past the ceiling, and nothing else re-checks until the
412+
// next local publish — which a read-only participant never makes.
413+
if (foldableDeltaBytes(room) >= COMPACT_BYTES_THRESHOLD) void this.maybeCompact(name, true)
397414
} catch (error) {
398415
logger.warn(`FileDocStore catch-up failed for ${name}`, { error: getErrorMessage(error) })
399416
}

0 commit comments

Comments
 (0)