@@ -1078,6 +1078,357 @@ describe("MollifierBuffer envs set lifecycle", () => {
10781078 ) ;
10791079} ) ;
10801080
1081+ describe ( "MollifierBuffer.mutateSnapshot" , ( ) => {
1082+ redisTest (
1083+ "returns not_found when no entry exists for the runId" ,
1084+ { timeout : 20_000 } ,
1085+ async ( { redisContainer } ) => {
1086+ const buffer = new MollifierBuffer ( {
1087+ redisOptions : {
1088+ host : redisContainer . getHost ( ) ,
1089+ port : redisContainer . getPort ( ) ,
1090+ password : redisContainer . getPassword ( ) ,
1091+ } ,
1092+ entryTtlSeconds : 600 ,
1093+ logger : new Logger ( "test" , "log" ) ,
1094+ } ) ;
1095+ try {
1096+ const result = await buffer . mutateSnapshot ( "nope" , {
1097+ type : "append_tags" ,
1098+ tags : [ "x" ] ,
1099+ } ) ;
1100+ expect ( result ) . toBe ( "not_found" ) ;
1101+ } finally {
1102+ await buffer . close ( ) ;
1103+ }
1104+ } ,
1105+ ) ;
1106+
1107+ redisTest (
1108+ "append_tags on QUEUED entry appends and dedupes" ,
1109+ { timeout : 20_000 } ,
1110+ async ( { redisContainer } ) => {
1111+ const buffer = new MollifierBuffer ( {
1112+ redisOptions : {
1113+ host : redisContainer . getHost ( ) ,
1114+ port : redisContainer . getPort ( ) ,
1115+ password : redisContainer . getPassword ( ) ,
1116+ } ,
1117+ entryTtlSeconds : 600 ,
1118+ logger : new Logger ( "test" , "log" ) ,
1119+ } ) ;
1120+ try {
1121+ await buffer . accept ( {
1122+ runId : "r1" ,
1123+ envId : "env_m" ,
1124+ orgId : "org_1" ,
1125+ payload : serialiseSnapshot ( { tags : [ "existing" ] } ) ,
1126+ } ) ;
1127+ const first = await buffer . mutateSnapshot ( "r1" , {
1128+ type : "append_tags" ,
1129+ tags : [ "existing" , "new" ] ,
1130+ } ) ;
1131+ expect ( first ) . toBe ( "applied_to_snapshot" ) ;
1132+
1133+ const entry = await buffer . getEntry ( "r1" ) ;
1134+ const payload = JSON . parse ( entry ! . payload ) as { tags : string [ ] } ;
1135+ expect ( payload . tags ) . toEqual ( [ "existing" , "new" ] ) ;
1136+
1137+ // Second mutation appends without duplicating
1138+ const second = await buffer . mutateSnapshot ( "r1" , {
1139+ type : "append_tags" ,
1140+ tags : [ "new" , "third" ] ,
1141+ } ) ;
1142+ expect ( second ) . toBe ( "applied_to_snapshot" ) ;
1143+ const e2 = await buffer . getEntry ( "r1" ) ;
1144+ const p2 = JSON . parse ( e2 ! . payload ) as { tags : string [ ] } ;
1145+ expect ( p2 . tags ) . toEqual ( [ "existing" , "new" , "third" ] ) ;
1146+ } finally {
1147+ await buffer . close ( ) ;
1148+ }
1149+ } ,
1150+ ) ;
1151+
1152+ redisTest (
1153+ "append_tags creates payload.tags when absent" ,
1154+ { timeout : 20_000 } ,
1155+ async ( { redisContainer } ) => {
1156+ const buffer = new MollifierBuffer ( {
1157+ redisOptions : {
1158+ host : redisContainer . getHost ( ) ,
1159+ port : redisContainer . getPort ( ) ,
1160+ password : redisContainer . getPassword ( ) ,
1161+ } ,
1162+ entryTtlSeconds : 600 ,
1163+ logger : new Logger ( "test" , "log" ) ,
1164+ } ) ;
1165+ try {
1166+ await buffer . accept ( {
1167+ runId : "r2" ,
1168+ envId : "env_m" ,
1169+ orgId : "org_1" ,
1170+ payload : serialiseSnapshot ( { taskId : "t" } ) ,
1171+ } ) ;
1172+ const result = await buffer . mutateSnapshot ( "r2" , {
1173+ type : "append_tags" ,
1174+ tags : [ "a" , "b" ] ,
1175+ } ) ;
1176+ expect ( result ) . toBe ( "applied_to_snapshot" ) ;
1177+ const entry = await buffer . getEntry ( "r2" ) ;
1178+ const payload = JSON . parse ( entry ! . payload ) as { tags : string [ ] } ;
1179+ expect ( payload . tags ) . toEqual ( [ "a" , "b" ] ) ;
1180+ } finally {
1181+ await buffer . close ( ) ;
1182+ }
1183+ } ,
1184+ ) ;
1185+
1186+ redisTest (
1187+ "set_metadata replaces metadata + metadataType (last-write-wins)" ,
1188+ { timeout : 20_000 } ,
1189+ async ( { redisContainer } ) => {
1190+ const buffer = new MollifierBuffer ( {
1191+ redisOptions : {
1192+ host : redisContainer . getHost ( ) ,
1193+ port : redisContainer . getPort ( ) ,
1194+ password : redisContainer . getPassword ( ) ,
1195+ } ,
1196+ entryTtlSeconds : 600 ,
1197+ logger : new Logger ( "test" , "log" ) ,
1198+ } ) ;
1199+ try {
1200+ await buffer . accept ( {
1201+ runId : "r3" ,
1202+ envId : "env_m" ,
1203+ orgId : "org_1" ,
1204+ payload : serialiseSnapshot ( { metadata : '{"v":1}' , metadataType : "application/json" } ) ,
1205+ } ) ;
1206+ const result = await buffer . mutateSnapshot ( "r3" , {
1207+ type : "set_metadata" ,
1208+ metadata : '{"v":2}' ,
1209+ metadataType : "application/json" ,
1210+ } ) ;
1211+ expect ( result ) . toBe ( "applied_to_snapshot" ) ;
1212+ const entry = await buffer . getEntry ( "r3" ) ;
1213+ const payload = JSON . parse ( entry ! . payload ) as {
1214+ metadata : string ;
1215+ metadataType : string ;
1216+ } ;
1217+ expect ( payload . metadata ) . toBe ( '{"v":2}' ) ;
1218+ expect ( payload . metadataType ) . toBe ( "application/json" ) ;
1219+ } finally {
1220+ await buffer . close ( ) ;
1221+ }
1222+ } ,
1223+ ) ;
1224+
1225+ redisTest (
1226+ "set_delay sets payload.delayUntil" ,
1227+ { timeout : 20_000 } ,
1228+ async ( { redisContainer } ) => {
1229+ const buffer = new MollifierBuffer ( {
1230+ redisOptions : {
1231+ host : redisContainer . getHost ( ) ,
1232+ port : redisContainer . getPort ( ) ,
1233+ password : redisContainer . getPassword ( ) ,
1234+ } ,
1235+ entryTtlSeconds : 600 ,
1236+ logger : new Logger ( "test" , "log" ) ,
1237+ } ) ;
1238+ try {
1239+ await buffer . accept ( {
1240+ runId : "r4" ,
1241+ envId : "env_m" ,
1242+ orgId : "org_1" ,
1243+ payload : serialiseSnapshot ( { taskId : "t" } ) ,
1244+ } ) ;
1245+ const result = await buffer . mutateSnapshot ( "r4" , {
1246+ type : "set_delay" ,
1247+ delayUntil : "2026-06-01T00:00:00.000Z" ,
1248+ } ) ;
1249+ expect ( result ) . toBe ( "applied_to_snapshot" ) ;
1250+ const entry = await buffer . getEntry ( "r4" ) ;
1251+ const payload = JSON . parse ( entry ! . payload ) as { delayUntil : string } ;
1252+ expect ( payload . delayUntil ) . toBe ( "2026-06-01T00:00:00.000Z" ) ;
1253+ } finally {
1254+ await buffer . close ( ) ;
1255+ }
1256+ } ,
1257+ ) ;
1258+
1259+ redisTest (
1260+ "mark_cancelled stamps cancelledAt + cancelReason" ,
1261+ { timeout : 20_000 } ,
1262+ async ( { redisContainer } ) => {
1263+ const buffer = new MollifierBuffer ( {
1264+ redisOptions : {
1265+ host : redisContainer . getHost ( ) ,
1266+ port : redisContainer . getPort ( ) ,
1267+ password : redisContainer . getPassword ( ) ,
1268+ } ,
1269+ entryTtlSeconds : 600 ,
1270+ logger : new Logger ( "test" , "log" ) ,
1271+ } ) ;
1272+ try {
1273+ await buffer . accept ( {
1274+ runId : "r5" ,
1275+ envId : "env_m" ,
1276+ orgId : "org_1" ,
1277+ payload : serialiseSnapshot ( { taskId : "t" } ) ,
1278+ } ) ;
1279+ const result = await buffer . mutateSnapshot ( "r5" , {
1280+ type : "mark_cancelled" ,
1281+ cancelledAt : "2026-05-19T12:00:00.000Z" ,
1282+ cancelReason : "user-initiated" ,
1283+ } ) ;
1284+ expect ( result ) . toBe ( "applied_to_snapshot" ) ;
1285+ const entry = await buffer . getEntry ( "r5" ) ;
1286+ const payload = JSON . parse ( entry ! . payload ) as {
1287+ cancelledAt : string ;
1288+ cancelReason : string ;
1289+ } ;
1290+ expect ( payload . cancelledAt ) . toBe ( "2026-05-19T12:00:00.000Z" ) ;
1291+ expect ( payload . cancelReason ) . toBe ( "user-initiated" ) ;
1292+ } finally {
1293+ await buffer . close ( ) ;
1294+ }
1295+ } ,
1296+ ) ;
1297+
1298+ redisTest (
1299+ "returns busy when entry is DRAINING" ,
1300+ { timeout : 20_000 } ,
1301+ async ( { redisContainer } ) => {
1302+ const buffer = new MollifierBuffer ( {
1303+ redisOptions : {
1304+ host : redisContainer . getHost ( ) ,
1305+ port : redisContainer . getPort ( ) ,
1306+ password : redisContainer . getPassword ( ) ,
1307+ } ,
1308+ entryTtlSeconds : 600 ,
1309+ logger : new Logger ( "test" , "log" ) ,
1310+ } ) ;
1311+ try {
1312+ await buffer . accept ( {
1313+ runId : "rd" ,
1314+ envId : "env_m" ,
1315+ orgId : "org_1" ,
1316+ payload : serialiseSnapshot ( { tags : [ ] } ) ,
1317+ } ) ;
1318+ await buffer . pop ( "env_m" ) ;
1319+ const result = await buffer . mutateSnapshot ( "rd" , {
1320+ type : "append_tags" ,
1321+ tags : [ "x" ] ,
1322+ } ) ;
1323+ expect ( result ) . toBe ( "busy" ) ;
1324+ } finally {
1325+ await buffer . close ( ) ;
1326+ }
1327+ } ,
1328+ ) ;
1329+
1330+ redisTest (
1331+ "returns busy when entry is FAILED" ,
1332+ { timeout : 20_000 } ,
1333+ async ( { redisContainer } ) => {
1334+ const buffer = new MollifierBuffer ( {
1335+ redisOptions : {
1336+ host : redisContainer . getHost ( ) ,
1337+ port : redisContainer . getPort ( ) ,
1338+ password : redisContainer . getPassword ( ) ,
1339+ } ,
1340+ entryTtlSeconds : 600 ,
1341+ logger : new Logger ( "test" , "log" ) ,
1342+ } ) ;
1343+ try {
1344+ await buffer . accept ( {
1345+ runId : "rf" ,
1346+ envId : "env_m" ,
1347+ orgId : "org_1" ,
1348+ payload : serialiseSnapshot ( { tags : [ ] } ) ,
1349+ } ) ;
1350+ await buffer . pop ( "env_m" ) ;
1351+ await buffer . fail ( "rf" , { code : "X" , message : "boom" } ) ;
1352+ const result = await buffer . mutateSnapshot ( "rf" , {
1353+ type : "append_tags" ,
1354+ tags : [ "x" ] ,
1355+ } ) ;
1356+ expect ( result ) . toBe ( "busy" ) ;
1357+ } finally {
1358+ await buffer . close ( ) ;
1359+ }
1360+ } ,
1361+ ) ;
1362+
1363+ redisTest (
1364+ "returns busy when entry is materialised (post-ack grace window)" ,
1365+ { timeout : 20_000 } ,
1366+ async ( { redisContainer } ) => {
1367+ const buffer = new MollifierBuffer ( {
1368+ redisOptions : {
1369+ host : redisContainer . getHost ( ) ,
1370+ port : redisContainer . getPort ( ) ,
1371+ password : redisContainer . getPassword ( ) ,
1372+ } ,
1373+ entryTtlSeconds : 600 ,
1374+ logger : new Logger ( "test" , "log" ) ,
1375+ } ) ;
1376+ try {
1377+ await buffer . accept ( {
1378+ runId : "rm" ,
1379+ envId : "env_m" ,
1380+ orgId : "org_1" ,
1381+ payload : serialiseSnapshot ( { tags : [ ] } ) ,
1382+ } ) ;
1383+ await buffer . pop ( "env_m" ) ;
1384+ await buffer . ack ( "rm" ) ;
1385+ const result = await buffer . mutateSnapshot ( "rm" , {
1386+ type : "append_tags" ,
1387+ tags : [ "x" ] ,
1388+ } ) ;
1389+ expect ( result ) . toBe ( "busy" ) ;
1390+ } finally {
1391+ await buffer . close ( ) ;
1392+ }
1393+ } ,
1394+ ) ;
1395+
1396+ redisTest (
1397+ "Lua atomicity serialises concurrent mutations per-runId" ,
1398+ { timeout : 20_000 } ,
1399+ async ( { redisContainer } ) => {
1400+ const buffer = new MollifierBuffer ( {
1401+ redisOptions : {
1402+ host : redisContainer . getHost ( ) ,
1403+ port : redisContainer . getPort ( ) ,
1404+ password : redisContainer . getPassword ( ) ,
1405+ } ,
1406+ entryTtlSeconds : 600 ,
1407+ logger : new Logger ( "test" , "log" ) ,
1408+ } ) ;
1409+ try {
1410+ await buffer . accept ( {
1411+ runId : "rcc" ,
1412+ envId : "env_m" ,
1413+ orgId : "org_1" ,
1414+ payload : serialiseSnapshot ( { tags : [ ] } ) ,
1415+ } ) ;
1416+
1417+ const tagsToAdd = Array . from ( { length : 50 } , ( _ , i ) => `t${ i } ` ) ;
1418+ await Promise . all (
1419+ tagsToAdd . map ( ( t ) => buffer . mutateSnapshot ( "rcc" , { type : "append_tags" , tags : [ t ] } ) ) ,
1420+ ) ;
1421+
1422+ const entry = await buffer . getEntry ( "rcc" ) ;
1423+ const payload = JSON . parse ( entry ! . payload ) as { tags : string [ ] } ;
1424+ expect ( payload . tags . sort ( ) ) . toEqual ( tagsToAdd . sort ( ) ) ;
1425+ } finally {
1426+ await buffer . close ( ) ;
1427+ }
1428+ } ,
1429+ ) ;
1430+ } ) ;
1431+
10811432describe ( "MollifierBuffer ZSET storage" , ( ) => {
10821433 redisTest (
10831434 "queue key is a ZSET scored by entry's createdAtMicros" ,
0 commit comments