Skip to content

Commit 329eab9

Browse files
waleedlatif1claude
andcommitted
fix(realtime): count only deltas toward the compaction byte threshold
Re-seeding the counter with the snapshot's own size left any document larger than the ceiling permanently over it, forcing a full snapshot append on every subsequent keystroke — the write amplification the threshold exists to prevent. The counter measures edit churn since the last fold, so a stream settles at one snapshot plus that much churn. Also self-corrects the Tables byte counter whenever its buffer trims to a single entry, so an independently evicted events key cannot leave the accumulator over-reporting and pin the buffer at one entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8aead66 commit 329eab9

3 files changed

Lines changed: 56 additions & 8 deletions

File tree

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,40 @@ describe('FileDocStore', () => {
436436
doc.destroy()
437437
})
438438

439+
it('does not re-compact on every publish once the document itself exceeds the byte ceiling', async () => {
440+
const streamKey = `filedoc:stream:${NAME}`
441+
const a = await newStore()
442+
const doc = new Y.Doc()
443+
await a.attachRoom(NAME, doc)
444+
445+
const updates: Uint8Array[] = []
446+
doc.on('update', (u: Uint8Array) => updates.push(u))
447+
// Grow the document past the byte ceiling so its own snapshot exceeds it, then keep editing.
448+
// Counting the snapshot as appended bytes would leave the threshold permanently breached and
449+
// force a full snapshot append per keystroke — the amplification the threshold exists to stop.
450+
doc.getText('body').insert(0, 'x'.repeat(12 * 1024 * 1024))
451+
for (let i = 0; i < 30; i++) doc.getText('body').insert(0, 'tiny')
452+
for (const update of updates) {
453+
await a.publishAndWait(NAME, update)
454+
}
455+
await vi.waitFor(() => {
456+
const stream = state.backing!.streams.get(streamKey)!
457+
expect(stream.some((entry) => entry.message.s === '1')).toBe(true)
458+
})
459+
460+
const snapshots = state
461+
.backing!.streams.get(streamKey)!
462+
.filter((entry) => entry.message.s === '1').length
463+
expect(snapshots).toBeLessThanOrEqual(2)
464+
465+
const rebuilt = new Y.Doc()
466+
Y.applyUpdate(rebuilt, (await a.getStreamState(NAME))!)
467+
expect(rebuilt.getText('body').toString().startsWith('tiny')).toBe(true)
468+
expect(rebuilt.getText('body').length).toBe(12 * 1024 * 1024 + 30 * 4)
469+
rebuilt.destroy()
470+
doc.destroy()
471+
})
472+
439473
it('stamps a compaction snapshot of an agent-ONLY stream as an agent frame (never persisted)', async () => {
440474
const streamKey = `filedoc:stream:${NAME}`
441475
const noop = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())).toString('base64')

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

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ const COMPACT_THRESHOLD = 400
152152
* Compaction is the only safe way to shrink one of these streams: a task attaching later
153153
* replays every entry to rebuild the doc, so dropping the oldest entries — what a native
154154
* `MAXLEN` retention bound would do — loses edits outright. A snapshot folds them first.
155+
*
156+
* Measured over deltas appended since the last fold, never over the resulting snapshot, so a
157+
* stream settles at roughly one document snapshot plus this much churn.
155158
*/
156159
const COMPACT_BYTES_THRESHOLD = 8 * 1024 * 1024
157160
/** Check whether compaction is due only every Nth local publish, to avoid an XLEN per keystroke. */
@@ -233,10 +236,12 @@ interface StoreRoom {
233236
/** Local publish count, to pace compaction checks. */
234237
publishes: number
235238
/**
236-
* Bytes this task has appended since the last compaction it observed, so the byte threshold
237-
* costs no extra round-trip. Locally tracked, so it under-counts a peer task's appends — it
238-
* is a trigger, not an accounting, and {@link COMPACT_THRESHOLD} still covers the case where
239-
* many small edits arrive from elsewhere.
239+
* Delta bytes this task has appended since the last compaction it performed, so the byte
240+
* threshold costs no extra round-trip. Counts deltas only — never the snapshot a compaction
241+
* writes, which is a function of document size rather than of edit volume and would make a
242+
* large document breach the threshold permanently. Locally tracked, so it under-counts a peer
243+
* task's appends: it is a trigger, not an accounting, and {@link COMPACT_THRESHOLD} still
244+
* covers many small edits arriving from elsewhere.
240245
*/
241246
appendedBytes: number
242247
/** Set once the doc has been observed seeded, so the seed transition itself is never mistaken for an
@@ -782,10 +787,12 @@ export class FileDocStore {
782787
// appended snapshot id instead would silently drop those un-integrated peer entries.
783788
const upTo = room.lastId
784789
const snapshot = Buffer.from(Y.encodeStateAsUpdate(room.doc)).toString('base64')
785-
// The folded deltas are about to be trimmed; what remains of this task's contribution is the
786-
// snapshot. Reset before the appends so a concurrent publish's bytes are counted against the
787-
// new baseline rather than the one being retired.
788-
room.appendedBytes = snapshot.length
790+
// Counts deltas appended SINCE this fold, so it must not carry the snapshot's own size: a
791+
// document whose snapshot already exceeds the ceiling would otherwise re-breach it the instant
792+
// compaction finished and force a full snapshot append on every subsequent keystroke — the
793+
// write amplification this threshold exists to prevent. Reset before the appends so a
794+
// concurrent publish is counted against the new baseline rather than the one being retired.
795+
room.appendedBytes = 0
789796
// Stamp the snapshot by what it folds: a real edit → SNAPSHOT_FIELD (a fresh catch-up treats it
790797
// as edited content, not a bare seed). An agent-ONLY stream (no real edit yet) → AGENT_FIELD, so a
791798
// peer catching up applies it as REDIS_AGENT_ORIGIN and never marks the doc edited — preserving

apps/sim/lib/realtime/event-log.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ while max_bytes > 0 and total > max_bytes and redis.call('ZCARD', KEYS[1]) > 1 d
7171
redis.call('ZREMRANGEBYRANK', KEYS[1], 0, 0)
7272
end
7373
if total < 0 then total = 0 end
74+
-- Self-correct: the counter is an accumulator, so an independently evicted events key would leave
75+
-- it over-reporting forever and pin the buffer at a single entry. Whenever the buffer is down to one
76+
-- entry its exact size is known, so drift cannot outlive a trim.
77+
if redis.call('ZCARD', KEYS[1]) == 1 then
78+
local only = redis.call('ZRANGE', KEYS[1], 0, 0)
79+
if only[1] then total = string.len(only[1]) end
80+
end
7481
7582
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
7683
if oldest[2] then

0 commit comments

Comments
 (0)