@@ -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-
280284const REDIS_URL = 'redis://fake'
281285const 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+
283299interface 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
301312function 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