@@ -186,6 +186,22 @@ const READER_ERROR_LOG_EVERY = 20
186186
187187const streamKey = ( name : string ) => `${ STREAM_PREFIX } ${ name } `
188188
189+ /**
190+ * Unfolded delta bytes a compaction could actually reclaim right now.
191+ *
192+ * Only entries at or before `room.lastId` count: a fold trims to that boundary, so bytes past it
193+ * would survive the trim and re-arm the trigger immediately, forcing a full snapshot append per
194+ * publish that reclaims nothing. They stay in `pendingDeltas` and start counting once the tailer
195+ * has integrated them.
196+ */
197+ function foldableDeltaBytes ( room : StoreRoom ) : number {
198+ let bytes = 0
199+ for ( const delta of room . pendingDeltas ) {
200+ if ( ! isAfterStreamId ( delta . id , room . lastId ) ) bytes += delta . bytes
201+ }
202+ return bytes
203+ }
204+
189205/**
190206 * Decode one stream entry's base64 Yjs update and apply it to `doc`. A malformed entry is logged and
191207 * SKIPPED — never thrown — so one bad frame can neither wedge the tailer nor abort a headless
@@ -236,14 +252,20 @@ interface StoreRoom {
236252 /** Local publish count, to pace compaction checks. */
237253 publishes : number
238254 /**
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.
255+ * Deltas this task has appended and not yet folded, as `{id, bytes}` pairs in append order.
256+ *
257+ * Keyed by stream id rather than summed, because a fold trims to `room.lastId` and RETAINS
258+ * anything published past it — those bytes are still in Redis, so deducting them would
259+ * disarm the trigger while the stream keeps growing. Entries are dropped only once an
260+ * `XTRIM` has provably removed them.
261+ *
262+ * Counts deltas only — never the snapshot a compaction writes, which is a function of
263+ * document size rather than of edit volume and would make a large document breach the
264+ * threshold permanently. Locally tracked, so it under-counts a peer task's appends: it is a
265+ * trigger, not an accounting, and {@link COMPACT_THRESHOLD} still covers many small edits
266+ * arriving from elsewhere.
245267 */
246- appendedBytes : number
268+ pendingDeltas : Array < { id : string ; bytes : number } >
247269 /** Set once the doc has been observed seeded, so the seed transition itself is never mistaken for an
248270 * edit (mirrors the relay's `seededObserved`). */
249271 seededObserved : boolean
@@ -325,7 +347,7 @@ export class FileDocStore {
325347 doc,
326348 lastId : '0' ,
327349 publishes : 0 ,
328- appendedBytes : 0 ,
350+ pendingDeltas : [ ] ,
329351 seededObserved : false ,
330352 realEdited : false ,
331353 }
@@ -390,9 +412,10 @@ export class FileDocStore {
390412 const encoded = Buffer . from ( update ) . toString ( 'base64' )
391413 const fields : Record < string , string > = { [ UPDATE_FIELD ] : encoded }
392414 if ( agent ) fields [ AGENT_FIELD ] = '1'
415+ let appendedId : string | null = null
393416 for ( let attempt = 0 ; attempt <= PUBLISH_MAX_RETRIES ; attempt ++ ) {
394417 try {
395- await this . write . xAdd ( streamKey ( name ) , '*' , fields )
418+ appendedId = await this . write . xAdd ( streamKey ( name ) , '*' , fields )
396419 break
397420 } catch ( error ) {
398421 if ( attempt === PUBLISH_MAX_RETRIES ) {
@@ -407,11 +430,11 @@ export class FileDocStore {
407430 await this . write . expire ( streamKey ( name ) , STREAM_TTL_SEC ) . catch ( ( ) => { } )
408431 const room = this . rooms . get ( name )
409432 if ( ! room ) return
410- room . appendedBytes += encoded . length
433+ if ( appendedId ) room . pendingDeltas . push ( { id : appendedId , bytes : encoded . length } )
411434 // Bytes are checked every publish: one entry can cross the ceiling on its own, so pacing this
412435 // check the way the entry count is paced would let a stream sit far over the ceiling for up to
413- // COMPACT_CHECK_EVERY more appends. The check itself is a local comparison .
414- const overBytes = room . appendedBytes >= COMPACT_BYTES_THRESHOLD
436+ // COMPACT_CHECK_EVERY more appends. The check itself is a local sum over unfolded entries .
437+ const overBytes = foldableDeltaBytes ( room ) >= COMPACT_BYTES_THRESHOLD
415438 if ( overBytes || ++ room . publishes % COMPACT_CHECK_EVERY === 0 ) {
416439 void this . maybeCompact ( name , overBytes )
417440 }
@@ -726,6 +749,11 @@ export class FileDocStore {
726749 // but wasteful re-delivery). The new room caught itself up via xRange already.
727750 if ( ! room || room !== snapshot . get ( name ) ) continue
728751 for ( const entry of stream . messages ) this . applyEntry ( room , entry . id , entry . message )
752+ // Foldability is decided by `lastId`, which only the tailer advances — so a burst of
753+ // large edits followed by silence would otherwise sit unfolded until the next publish
754+ // happened to re-evaluate the trigger. Re-check it where the boundary actually moved.
755+ if ( foldableDeltaBytes ( room ) >= COMPACT_BYTES_THRESHOLD )
756+ void this . maybeCompact ( name , true )
729757 }
730758 } catch ( error ) {
731759 if ( ! this . running ) break
@@ -787,10 +815,7 @@ export class FileDocStore {
787815 // appended snapshot id instead would silently drop those un-integrated peer entries.
788816 const upTo = room . lastId
789817 const snapshot = Buffer . from ( Y . encodeStateAsUpdate ( room . doc ) ) . toString ( 'base64' )
790- // Bytes this fold is accountable for. Deducted only once the trim succeeds, so a failed
791- // compaction leaves the trigger armed instead of silently disarming it — and deducting
792- // rather than zeroing preserves whatever a concurrent publish added while it ran.
793- const foldedBytes = room . appendedBytes
818+ // Captured with `upTo` so the two agree: exactly the entries this fold will trim.
794819 // Stamp the snapshot by what it folds: a real edit → SNAPSHOT_FIELD (a fresh catch-up treats it
795820 // as edited content, not a bare seed). An agent-ONLY stream (no real edit yet) → AGENT_FIELD, so a
796821 // peer catching up applies it as REDIS_AGENT_ORIGIN and never marks the doc edited — preserving
@@ -806,7 +831,11 @@ export class FileDocStore {
806831 // Never the snapshot's own size: a document whose snapshot already exceeds the ceiling
807832 // would re-breach it the instant compaction finished and force a full snapshot append on
808833 // every subsequent keystroke — the write amplification this threshold exists to prevent.
809- room . appendedBytes = Math . max ( 0 , room . appendedBytes - foldedBytes )
834+ // Drop only what the trim provably removed. An entry published past `upTo` is retained by
835+ // MINID and its bytes are still in Redis, so it stays counted; dropping it would disarm the
836+ // trigger while the stream kept growing. Done after the trim, so a failed fold changes
837+ // nothing and leaves the trigger armed.
838+ room . pendingDeltas = room . pendingDeltas . filter ( ( delta ) => isAfterStreamId ( delta . id , upTo ) )
810839 } finally {
811840 await this . releaseLock ( key , token )
812841 }
0 commit comments