Skip to content

Commit ebf154c

Browse files
committed
Harden legacy circuit sweeps
Legacy circuit sweeps are best-effort cleanup, so they should not create extra load or overwrite marker state when they cannot reclaim leaked data. Skip the sweep for custom failure policies without a state TTL, finish the sweep marker with CAS, and bound marker acquisition retries. #917 (comment) Assisted-by: Codex:gpt-5.5
1 parent e77306d commit ebf154c

2 files changed

Lines changed: 113 additions & 3 deletions

File tree

packages/fedify/src/federation/circuit-breaker.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
121159
class 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+
721784
test("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+
9041010
test("CircuitBreaker retries expired legacy sweep markers", async () => {
9051011
const kv = new CountingSweepKvStore();
9061012
await kv.set([

packages/fedify/src/federation/circuit-breaker.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ const CIRCUIT_BREAKER_STATE_VERSION = 1;
150150
const LEGACY_SWEEP_LOCK_TTL = Temporal.Duration.from({ minutes: 5 });
151151
const LEGACY_SWEEP_RETRY_WINDOW = Temporal.Duration.from({ hours: 24 * 7 });
152152
const LEGACY_SWEEP_WAIT_INTERVAL = 10;
153+
const LEGACY_SWEEP_CAS_ATTEMPTS = 3;
153154

154155
type LegacySweepMarker =
155156
| {
@@ -464,6 +465,7 @@ export class CircuitBreaker {
464465

465466
#sweepLegacyStates(): void {
466467
if (this.#kv.cas == null) return;
468+
if (this.#options.stateTtl == null) return;
467469
if (this.#legacySweep != null) return;
468470
if (this.#isLegacySweepDone()) return;
469471
this.#legacySweep = this.#sweepLegacyStatesImpl()
@@ -493,8 +495,9 @@ export class CircuitBreaker {
493495
throw error;
494496
}
495497
const finishedMarker = this.#finishLegacySweepMarker(marker);
496-
await this.#kv.set(markerKey, finishedMarker);
497-
this.#rememberLegacySweepMarker(finishedMarker);
498+
if (await this.#kv.cas!(markerKey, marker, finishedMarker)) {
499+
this.#rememberLegacySweepMarker(finishedMarker);
500+
}
498501
}
499502

500503
#finishLegacySweepMarker(marker: LegacySweepMarker): LegacySweepMarker {
@@ -540,7 +543,7 @@ export class CircuitBreaker {
540543
async #acquireLegacySweep(
541544
markerKey: KvKey,
542545
): Promise<LegacySweepMarker | "done"> {
543-
while (true) {
546+
for (let attempt = 0; attempt < LEGACY_SWEEP_CAS_ATTEMPTS; attempt++) {
544547
const marker = await this.#kv.get(markerKey);
545548
const now = this.#now();
546549
if (isLegacySweepActive(marker, now)) {
@@ -565,6 +568,7 @@ export class CircuitBreaker {
565568
}
566569
await delay(LEGACY_SWEEP_WAIT_INTERVAL);
567570
}
571+
return "done";
568572
}
569573

570574
#isLegacySweepDone(): boolean {

0 commit comments

Comments
 (0)