@@ -118,6 +118,44 @@ class CountingSweepKvStore extends MemoryKvStore {
118118 }
119119}
120120
121+ class ConflictingSweepMarkerKvStore extends CountingSweepKvStore {
122+ attempts = 0 ;
123+
124+ override cas (
125+ key : KvKey ,
126+ expectedValue : unknown ,
127+ newValue : unknown ,
128+ options ?: KvStoreSetOptions ,
129+ ) : Promise < boolean > {
130+ if (
131+ key . length === 4 &&
132+ key [ 0 ] === "_fedify" &&
133+ key [ 1 ] === "circuit" &&
134+ key [ 2 ] === "__fedify_meta" &&
135+ key [ 3 ] === "circuit_breaker_state_ttl_sweep_v1"
136+ ) {
137+ this . attempts ++ ;
138+ if ( this . attempts > 3 ) {
139+ throw new Error ( "legacy sweep did not stop retrying CAS misses" ) ;
140+ }
141+ return Promise . resolve ( false ) ;
142+ }
143+ return super . cas ( key , expectedValue , newValue , options ) ;
144+ }
145+ }
146+
147+ class MarkerChangedDuringListKvStore extends CountingSweepKvStore {
148+ override async * list ( prefix ?: KvKey ) {
149+ yield * super . list ( prefix ) ;
150+ await super . set ( [
151+ "_fedify" ,
152+ "circuit" ,
153+ "__fedify_meta" ,
154+ "circuit_breaker_state_ttl_sweep_v1" ,
155+ ] , { state : "final" } ) ;
156+ }
157+ }
158+
121159class UpdatingDuringListKvStore extends MemoryKvStore {
122160 override async * list ( prefix ?: KvKey ) {
123161 for await ( const entry of super . list ( prefix ) ) {
@@ -718,6 +756,31 @@ test("CircuitBreaker accepts an explicit TTL with custom failure policies", asyn
718756 } ) ;
719757} ) ;
720758
759+ test ( "CircuitBreaker skips legacy sweep when custom policies have no TTL" , async ( ) => {
760+ const kv = new CountingSweepKvStore ( ) ;
761+ await kv . set ( [ "_fedify" , "circuit" , "stale.example" ] , {
762+ state : "closed" ,
763+ failures : [ "2026-05-25T00:00:00Z" ] ,
764+ } ) ;
765+ const circuit = new CircuitBreaker ( {
766+ kv,
767+ prefix : [ "_fedify" , "circuit" ] ,
768+ now : ( ) => Temporal . Instant . from ( "2026-05-25T00:00:00Z" ) ,
769+ options : {
770+ failure : ( timestamps ) => timestamps . length >= 2 ,
771+ } ,
772+ } ) ;
773+
774+ await circuit . recordFailure ( "remote.example" ) ;
775+ await circuit . pendingSweep ;
776+
777+ assertEquals ( kv . listCalls , 0 ) ;
778+ assertEquals ( await kv . get ( [ "_fedify" , "circuit" , "stale.example" ] ) , {
779+ state : "closed" ,
780+ failures : [ "2026-05-25T00:00:00Z" ] ,
781+ } ) ;
782+ } ) ;
783+
721784test ( "CircuitBreaker migrates legacy states without TTL once" , async ( ) => {
722785 const kv = new CountingSweepKvStore ( ) ;
723786 await kv . set ( [ "_fedify" , "circuit" , "stale-a.example" ] , {
@@ -901,6 +964,49 @@ test("CircuitBreaker skips legacy sweep already running elsewhere", async () =>
901964 } ) ;
902965} ) ;
903966
967+ test ( "CircuitBreaker does not overwrite a changed legacy sweep marker" , async ( ) => {
968+ const kv = new MarkerChangedDuringListKvStore ( ) ;
969+ await kv . set ( [ "_fedify" , "circuit" , "stale.example" ] , {
970+ state : "closed" ,
971+ failures : [ "2026-05-25T00:00:00Z" ] ,
972+ } ) ;
973+ const circuit = new CircuitBreaker ( {
974+ kv,
975+ prefix : [ "_fedify" , "circuit" ] ,
976+ now : ( ) => Temporal . Instant . from ( "2026-05-25T00:00:00Z" ) ,
977+ options : { failureThreshold : 2 } ,
978+ } ) ;
979+
980+ await circuit . recordFailure ( "remote.example" ) ;
981+ await circuit . pendingSweep ;
982+
983+ assertEquals (
984+ await kv . get ( [
985+ "_fedify" ,
986+ "circuit" ,
987+ "__fedify_meta" ,
988+ "circuit_breaker_state_ttl_sweep_v1" ,
989+ ] ) ,
990+ { state : "final" } ,
991+ ) ;
992+ } ) ;
993+
994+ test ( "CircuitBreaker bounds legacy sweep CAS retries" , async ( ) => {
995+ const kv = new ConflictingSweepMarkerKvStore ( ) ;
996+ const circuit = new CircuitBreaker ( {
997+ kv,
998+ prefix : [ "_fedify" , "circuit" ] ,
999+ now : ( ) => Temporal . Instant . from ( "2026-05-25T00:00:00Z" ) ,
1000+ options : { failureThreshold : 2 } ,
1001+ } ) ;
1002+
1003+ await circuit . getState ( "remote.example" ) ;
1004+ await circuit . pendingSweep ;
1005+
1006+ assertEquals ( kv . attempts , 3 ) ;
1007+ assertEquals ( kv . listCalls , 0 ) ;
1008+ } ) ;
1009+
9041010test ( "CircuitBreaker retries expired legacy sweep markers" , async ( ) => {
9051011 const kv = new CountingSweepKvStore ( ) ;
9061012 await kv . set ( [
0 commit comments