From 67493d4d53faa11b34746f6863a1635e5daa6e13 Mon Sep 17 00:00:00 2001 From: heeeione <68272931+heeoneie@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:02:53 +0900 Subject: [PATCH 1/9] Bound the lifetime of public-key and signature-spec caches `KvKeyCache` stored successfully resolved keys without a TTL, and `KvSpecDeterminer.rememberSpec()` stored remembered specs without one, so a persistent `KvStore` grew with every remote key and origin the server had ever encountered. Both values are soft state, so this adds a TTL at the two write sites: 30 days for cached keys via `KvKeyCacheOptions.keyTtl`, and 90 days for remembered specs via a new optional fourth `KvSpecDeterminerOptions` argument. `KvSpecDeterminer`'s existing three positional arguments are unchanged. No sweep or migration code is included. Entries written by earlier versions carry no expiry and are left alone; the key-value store guide now documents how to clear them, naming both default prefixes with concrete Redis and PostgreSQL examples. Assisted-by: Claude Code:claude-opus-5 --- CHANGES.md | 18 ++++ changes.d/fedify/1017-kv-cache-ttl.md | 21 +++++ docs/manual/kv.md | 86 +++++++++++++++++++ .../fedify/src/federation/keycache.test.ts | 33 +++++++ packages/fedify/src/federation/keycache.ts | 18 +++- .../fedify/src/federation/middleware.test.ts | 39 +++++++++ packages/fedify/src/federation/middleware.ts | 22 ++++- 7 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 changes.d/fedify/1017-kv-cache-ttl.md diff --git a/CHANGES.md b/CHANGES.md index 8d4e68889..83b7152f1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -108,11 +108,28 @@ To be released. `esnext.temporal` lib reference. [[#823], [#925]] + - `KvKeyCache` and `KvSpecDeterminer` now write their cache entries with a + TTL, so a `KvStore` that never sees an explicit clear no longer + accumulates entries for actors and origins that have stopped + federating. [[#1017]] + + - `KvKeyCache` gained a `KvKeyCacheOptions.keyTtl` option for cached + keys, `30` days by default. + - `KvSpecDeterminer`'s constructor gained an optional 4th + `KvSpecDeterminerOptions` argument with a `specTtl` option for + remembered specs, `90` days by default. Its existing 3-argument + constructor shape is unchanged. + - Entries written by earlier Fedify versions have no TTL and are left + as is; see the new *Clearing legacy cache entries* section of the + [key–value store guide] if you want to expire them proactively + instead of waiting for them to be overwritten. + [FEP-ef61]: https://w3id.org/fep/ef61 [FEP-8b32]: https://w3id.org/fep/8b32 [FEP-fe34]: https://w3id.org/fep/fe34 [ActivityPub Media Upload extension]: https://www.w3.org/wiki/SocialCG/ActivityPub/MediaUpload [Standard Schema]: https://standardschema.dev/ +[key–value store guide]: https://fedify.dev/manual/kv [#206]: https://github.com/fedify-dev/fedify/issues/206 [#754]: https://github.com/fedify-dev/fedify/issues/754 [#797]: https://github.com/fedify-dev/fedify/issues/797 @@ -133,6 +150,7 @@ To be released. [#930]: https://github.com/fedify-dev/fedify/issues/930 [#934]: https://github.com/fedify-dev/fedify/pull/934 [#968]: https://github.com/fedify-dev/fedify/pull/968 +[#1017]: https://github.com/fedify-dev/fedify/issues/1017 ### @fedify/astro diff --git a/changes.d/fedify/1017-kv-cache-ttl.md b/changes.d/fedify/1017-kv-cache-ttl.md new file mode 100644 index 000000000..e2faad331 --- /dev/null +++ b/changes.d/fedify/1017-kv-cache-ttl.md @@ -0,0 +1,21 @@ +--- +links: + '#1017': https://github.com/fedify-dev/fedify/issues/1017 +--- + - `KvKeyCache` and `KvSpecDeterminer` now write their cache entries with a + TTL, so a `KvStore` that never sees an explicit clear no longer + accumulates entries for actors and origins that have stopped + federating. [[#1017]] + + - `KvKeyCache` gained a `KvKeyCacheOptions.keyTtl` option for cached + keys, `30` days by default. + - `KvSpecDeterminer`'s constructor gained an optional 4th + `KvSpecDeterminerOptions` argument with a `specTtl` option for + remembered specs, `90` days by default. Its existing 3-argument + constructor shape is unchanged. + - Entries written by earlier Fedify versions have no TTL and are left + as is; see the new *Clearing legacy cache entries* section of the + [key–value store guide] if you want to expire them proactively + instead of waiting for them to be overwritten. + +[key–value store guide]: https://fedify.dev/manual/kv diff --git a/docs/manual/kv.md b/docs/manual/kv.md index 542428fb0..4ccf71e44 100644 --- a/docs/manual/kv.md +++ b/docs/manual/kv.md @@ -512,6 +512,92 @@ export default { [Cloudflare Workers KV]: https://developers.cloudflare.com/kv/ +Clearing legacy cache entries +----------------------------- + +*This section is relevant since Fedify 2.4.0.* + +Fedify keeps two caches in your `KvStore`: cached actor public keys (used by +`KvKeyCache`) and remembered per-origin HTTP Message Signatures specs (used +by `KvSpecDeterminer`). As of Fedify 2.4.0, Fedify writes both with a +TTL it configures internally—30 days for cached keys and 90 days for +remembered specs. + +Entries written by Fedify 2.3 or earlier have no TTL. They are *not* +migrated or expired automatically: they simply stay in your `KvStore` until +something overwrites them, which is the same behavior Fedify has always had. +Leaving them alone is a perfectly valid choice—Fedify keeps serving and +refreshing them as before, and they get a TTL the next time they're written. + +If you'd rather not wait for that, you can clear the old entries yourself. +Both caches live under fixed key prefixes: + + - `["_fedify", "publicKey"]` — cached actor public keys + - `["_fedify", "httpMessageSignaturesSpec"]` — remembered HTTP + Message Signatures specs + +Deleting everything under these prefixes is always safe. Both caches are +soft state: Fedify relearns them on demand (by refetching the actor's key, or +by renegotiating the signature spec on the next delivery), at the cost of a +few extra fetches right after you clear them. + +### Clearing entries in `RedisKvStore` + +[`RedisKvStore`] stores every key under a shared prefix (`"fedify::"` by +default, configurable via `RedisKvStoreOptions.keyPrefix`), followed by the +`KvKey` parts joined with `"::"`. So with the default prefix, scan for and +delete the two Fedify caches like this: + +~~~~ bash +redis-cli --scan --pattern 'fedify::_fedify::publicKey::*' | xargs -r redis-cli del +redis-cli --scan --pattern 'fedify::_fedify::httpMessageSignaturesSpec::*' | xargs -r redis-cli del +~~~~ + +Replace the leading `fedify::` with your own `keyPrefix` if you configured a +custom one. + +### Clearing entries in `PostgresKvStore` + +[`PostgresKvStore`] stores every entry as a row keyed by a `text[]` column +(the table is named `fedify_kv_v2` by default, configurable via +`PostgresKvStoreOptions.tableName`). Delete the two Fedify caches with: + +~~~~ sql +DELETE FROM fedify_kv_v2 +WHERE array_length(key, 1) >= 2 AND key[1:2] = ARRAY['_fedify', 'publicKey']; + +DELETE FROM fedify_kv_v2 +WHERE array_length(key, 1) >= 2 + AND key[1:2] = ARRAY['_fedify', 'httpMessageSignaturesSpec']; +~~~~ + +Replace `fedify_kv_v2` with your own `tableName` if you configured a custom +one. + +### Clearing entries in other `KvStore` implementations + +For any other `KvStore`, iterate the two prefixes with [`~KvStore.list()`] +and delete each key you get back: + +~~~~ typescript twoslash +import type { KvStore } from "@fedify/fedify"; +const kv = null as unknown as KvStore; +// ---cut-before--- +for ( + const prefix of [ + ["_fedify", "publicKey"], + ["_fedify", "httpMessageSignaturesSpec"], + ] as const +) { + for await (const entry of kv.list(prefix)) { + await kv.delete(entry.key); + } +} +~~~~ + +[`~KvStore.list()`]: https://jsr.io/@fedify/fedify/doc/federation/~/KvStore#list + + Implementing a custom `KvStore` ------------------------------- diff --git a/packages/fedify/src/federation/keycache.test.ts b/packages/fedify/src/federation/keycache.test.ts index 92a7de8bd..aa86ee1f6 100644 --- a/packages/fedify/src/federation/keycache.test.ts +++ b/packages/fedify/src/federation/keycache.test.ts @@ -128,3 +128,36 @@ test("KvKeyCache unavailable entries expire", async () => { assertEquals(await cache.get(keyId), undefined); assertEquals(await cache.getFetchError(keyId), undefined); }); + +test("KvKeyCache.keyTtl defaults to 30 days", () => { + const kv = new MemoryKvStore(); + const cache = new KvKeyCache(kv, ["pk"]); + assertEquals(cache.keyTtl.total("day"), 30); +}); + +test("KvKeyCache.keyTtl is configurable", () => { + const kv = new MemoryKvStore(); + const cache = new KvKeyCache(kv, ["pk"], { + keyTtl: Temporal.Duration.from({ days: 7 }), + }); + assertEquals(cache.keyTtl.total("day"), 7); +}); + +test("KvKeyCache cached keys expire after keyTtl", async () => { + const kv = new MemoryKvStore(); + const cache = new KvKeyCache(kv, ["pk"], { + keyTtl: Temporal.Duration.from({ milliseconds: 1 }), + }); + const keyId = new URL("https://example.com/key"); + + await cache.set( + keyId, + new CryptographicKey({ id: keyId }), + ); + // The value is written immediately... + assert(await kv.get(["pk", keyId.href]) != null); + + // ...but disappears from the underlying KvStore once keyTtl elapses. + await new Promise((resolve) => setTimeout(resolve, 10)); + assertEquals(await kv.get(["pk", keyId.href]), undefined); +}); diff --git a/packages/fedify/src/federation/keycache.ts b/packages/fedify/src/federation/keycache.ts index 0dacbc3d2..0c1fd6dd2 100644 --- a/packages/fedify/src/federation/keycache.ts +++ b/packages/fedify/src/federation/keycache.ts @@ -7,6 +7,18 @@ export interface KvKeyCacheOptions { documentLoader?: DocumentLoader; contextLoader?: DocumentLoader; unavailableKeyTtl?: Temporal.Duration; + + /** + * The TTL for successfully cached keys. `30` days by default. + * + * Entries written by Fedify versions older than 2.4.0 have no TTL and + * are left untouched by this option; see the *Clearing legacy cache + * entries* section of the key–value store guide if you want to expire + * them proactively. + * @default `Temporal.Duration.from({ days: 30 })` + * @since 2.4.0 + */ + keyTtl?: Temporal.Duration; } export class KvKeyCache implements KeyCache { @@ -14,6 +26,7 @@ export class KvKeyCache implements KeyCache { readonly prefix: KvKey; readonly options: KvKeyCacheOptions; readonly unavailableKeyTtl: Temporal.Duration; + readonly keyTtl: Temporal.Duration; readonly nullKeys: Map; constructor(kv: KvStore, prefix: KvKey, options: KvKeyCacheOptions = {}) { @@ -22,6 +35,7 @@ export class KvKeyCache implements KeyCache { this.options = options; this.unavailableKeyTtl = options.unavailableKeyTtl ?? Temporal.Duration.from({ minutes: 10 }); + this.keyTtl = options.keyTtl ?? Temporal.Duration.from({ days: 30 }); this.nullKeys = new Map(); } @@ -76,7 +90,9 @@ export class KvKeyCache implements KeyCache { } this.nullKeys.delete(keyId.href); const serialized = await key.toJsonLd(this.options); - await this.kv.set([...this.prefix, keyId.href], serialized); + await this.kv.set([...this.prefix, keyId.href], serialized, { + ttl: this.keyTtl, + }); } async getFetchError(keyId: URL): Promise { diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index b63335ecb..8ccc244df 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -11277,6 +11277,45 @@ test("KvSpecDeterminer", async (t) => { spec = await determiner.determineSpec("example.com"); assertEquals(spec, "rfc9421"); }); + + await t.step("should default specTtl to 90 days", () => { + const kv = new MemoryKvStore(); + const prefix = ["test", "spec"] as const; + const determiner = new KvSpecDeterminer(kv, prefix); + assertEquals(determiner.specTtl.total("day"), 90); + }); + + await t.step( + "should accept a configurable specTtl as a 4th positional argument", + () => { + const kv = new MemoryKvStore(); + const prefix = ["test", "spec"] as const; + // The existing 3-argument constructor shape must keep working; the + // options object is purely additive. + const determiner = new KvSpecDeterminer(kv, prefix, "rfc9421", { + specTtl: Temporal.Duration.from({ days: 7 }), + }); + assertEquals(determiner.specTtl.total("day"), 7); + }, + ); + + await t.step("should expire remembered spec after specTtl", async () => { + const kv = new MemoryKvStore(); + const prefix = ["test", "spec"] as const; + const determiner = new KvSpecDeterminer(kv, prefix, "rfc9421", { + specTtl: Temporal.Duration.from({ milliseconds: 1 }), + }); + + await determiner.rememberSpec( + "example.com", + "draft-cavage-http-signatures-12", + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Falls back to the default spec once the remembered entry expires. + const spec = await determiner.determineSpec("example.com"); + assertEquals(spec, "rfc9421"); + }); }); test("createFederation() instruments documentLoader with activitypub.document.fetch", async () => { diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 84309d31b..7b853288e 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -4898,19 +4898,39 @@ interface SendActivityInternalOptions { readonly context: Context; } +/** + * Options for {@link KvSpecDeterminer}. + * @since 2.4.0 + */ +export interface KvSpecDeterminerOptions { + /** + * The TTL for remembered specs. `90` days by default. + * + * Entries written by Fedify versions older than 2.4.0 have no TTL and + * are left untouched by this option; see the *Clearing legacy cache + * entries* section of the key–value store guide if you want to expire + * them proactively. + * @default `Temporal.Duration.from({ days: 90 })` + */ + specTtl?: Temporal.Duration; +} + export class KvSpecDeterminer implements HttpMessageSignaturesSpecDeterminer { kv: KvStore; prefix: KvKey; defaultSpec: HttpMessageSignaturesSpec; + specTtl: Temporal.Duration; constructor( kv: KvStore, prefix: KvKey, defaultSpec: HttpMessageSignaturesSpec = "rfc9421", + options: KvSpecDeterminerOptions = {}, ) { this.kv = kv; this.prefix = prefix; this.defaultSpec = defaultSpec; + this.specTtl = options.specTtl ?? Temporal.Duration.from({ days: 90 }); } async determineSpec( @@ -4926,7 +4946,7 @@ export class KvSpecDeterminer implements HttpMessageSignaturesSpecDeterminer { origin: string, spec: HttpMessageSignaturesSpec, ): Promise { - await this.kv.set([...this.prefix, origin], spec); + await this.kv.set([...this.prefix, origin], spec, { ttl: this.specTtl }); } } From 6c5e723a8d706afcf06bfd39a9054a799d42bdce Mon Sep 17 00:00:00 2001 From: heeeione <68272931+heeoneie@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:04:02 +0900 Subject: [PATCH 2/9] Reference the pull request in the changelog fragment Assisted-by: Claude Code:claude-opus-5 --- CHANGES.md | 3 ++- changes.d/fedify/1017-kv-cache-ttl.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 83b7152f1..1f7ee82f7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -111,7 +111,7 @@ To be released. - `KvKeyCache` and `KvSpecDeterminer` now write their cache entries with a TTL, so a `KvStore` that never sees an explicit clear no longer accumulates entries for actors and origins that have stopped - federating. [[#1017]] + federating. [[#1017], [#1027]] - `KvKeyCache` gained a `KvKeyCacheOptions.keyTtl` option for cached keys, `30` days by default. @@ -151,6 +151,7 @@ To be released. [#934]: https://github.com/fedify-dev/fedify/pull/934 [#968]: https://github.com/fedify-dev/fedify/pull/968 [#1017]: https://github.com/fedify-dev/fedify/issues/1017 +[#1027]: https://github.com/fedify-dev/fedify/pull/1027 ### @fedify/astro diff --git a/changes.d/fedify/1017-kv-cache-ttl.md b/changes.d/fedify/1017-kv-cache-ttl.md index e2faad331..503edf359 100644 --- a/changes.d/fedify/1017-kv-cache-ttl.md +++ b/changes.d/fedify/1017-kv-cache-ttl.md @@ -1,11 +1,12 @@ --- links: '#1017': https://github.com/fedify-dev/fedify/issues/1017 + '#1027': https://github.com/fedify-dev/fedify/pull/1027 --- - `KvKeyCache` and `KvSpecDeterminer` now write their cache entries with a TTL, so a `KvStore` that never sees an explicit clear no longer accumulates entries for actors and origins that have stopped - federating. [[#1017]] + federating. [[#1017], [#1027]] - `KvKeyCache` gained a `KvKeyCacheOptions.keyTtl` option for cached keys, `30` days by default. From 425d32259431c71d56f0cdec7d3c7890f0a570f1 Mon Sep 17 00:00:00 2001 From: heeeione <68272931+heeoneie@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:10:59 +0900 Subject: [PATCH 3/9] Make public-key and signature-spec cache TTLs configurable #1017 agreed that both cache lifetimes should be configurable, but the previous commit only exposed them on the internal `KvKeyCache` and `KvSpecDeterminer` classes, which an application never constructs itself. This wires both through the public federation configuration. `FederationOptions` gains `publicKeyTtl` and `httpMessageSignaturesSpecTtl`, named after the `kvPrefixes` entries they bound, the same way `taskDeduplicationTtl` is named after `kvPrefixes.taskDeduplication`. Both take a `Temporal.DurationLike` and default to the existing 30 and 90 days, so behavior is unchanged when they are omitted. `FederationImpl` normalizes them and passes them to every cache construction site: four `KvSpecDeterminer` sites and two `KvKeyCache` sites. The second key cache site is the inbox handler, which is where the cache is actually written during signature verification, so `InboxHandlerParameters` gains `publicKeyTtl` to carry the value there. The internal `keyTtl` and `specTtl` options and the existing constructor arguments are left as they are. Both `KvKeyCache` sites used to pass the surrounding context object as the options bag, which implicitly supplied `tracerProvider` to `CryptographicKey.fromJsonLd()`. Passing an explicit options literal instead would have dropped that span linkage silently, so `KvKeyCacheOptions` now declares `tracerProvider` and both sites pass it. Assisted-by: Claude Code:claude-opus-5 --- packages/fedify/src/federation/federation.ts | 29 ++++++++++++++++++++ packages/fedify/src/federation/handler.ts | 13 ++++++++- packages/fedify/src/federation/keycache.ts | 2 ++ packages/fedify/src/federation/middleware.ts | 20 +++++++++++++- 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index 06bcebd9e..4285be7c0 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -935,6 +935,35 @@ export interface FederationOptions { */ kvPrefixes?: Partial; + /** + * The time-to-live for a remote actor's public key cached under + * {@link FederationKvPrefixes.publicKey}. Once it expires, the next + * signature verification that needs the key refetches it from the remote + * server and caches it again. + * + * Shortening it bounds how long a revoked or rotated key stays in the cache, + * at the cost of more requests to remote servers; refetching an expired key + * fails while the peer is unavailable, so a very short value makes + * verification depend on the peer being reachable. + * @default `{ days: 30 }` + * @since 2.4.0 + */ + publicKeyTtl?: Temporal.DurationLike; + + /** + * The time-to-live for a remote origin's remembered HTTP Message Signatures + * spec cached under {@link FederationKvPrefixes.httpMessageSignaturesSpec}. + * Once it expires, the next delivery to that origin relearns the spec by + * double-knocking and remembers it again. + * + * Shortening it makes Fedify notice a peer's spec upgrade sooner, at the + * cost of an extra signed request per delivery whenever the first spec tried + * is rejected. + * @default `{ days: 90 }` + * @since 2.4.0 + */ + httpMessageSignaturesSpecTtl?: Temporal.DurationLike; + /** * The message queue for sending and receiving activities. If not provided, * activities will not be queued and will be processed immediately. diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index f3c687e8a..a4364c41e 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -1257,6 +1257,11 @@ export interface InboxHandlerParameters { publicKey: KvKey; acceptSignatureNonce: KvKey; }; + /** + * The TTL for public keys cached under `kvPrefixes.publicKey`. + * @since 2.4.0 + */ + publicKeyTtl?: Temporal.Duration; queue?: MessageQueue; actorDispatcher?: ActorDispatcher; inboxListeners?: ActivityListenerSet>; @@ -1331,6 +1336,7 @@ async function handleInboxInternal( inboxContextFactory, kv, kvPrefixes, + publicKeyTtl, queue, actorDispatcher, inboxListeners, @@ -1405,7 +1411,12 @@ async function handleInboxInternal( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } - const keyCache = new KvKeyCache(kv, kvPrefixes.publicKey, ctx); + const keyCache = new KvKeyCache(kv, kvPrefixes.publicKey, { + documentLoader: ctx.documentLoader, + contextLoader: ctx.contextLoader, + tracerProvider, + keyTtl: publicKeyTtl, + }); const jsonWithoutSig = detachSignature(json); const hasLdSignature = hasSignature(json); const canAttemptAlternateAuthAfterLdSignatureFailure = diff --git a/packages/fedify/src/federation/keycache.ts b/packages/fedify/src/federation/keycache.ts index 0c1fd6dd2..0e8298952 100644 --- a/packages/fedify/src/federation/keycache.ts +++ b/packages/fedify/src/federation/keycache.ts @@ -1,11 +1,13 @@ import { CryptographicKey, Multikey } from "@fedify/vocab"; import type { DocumentLoader } from "@fedify/vocab-runtime"; +import type { TracerProvider } from "@opentelemetry/api"; import type { FetchKeyErrorResult, KeyCache } from "../sig/key.ts"; import type { KvKey, KvStore } from "./kv.ts"; export interface KvKeyCacheOptions { documentLoader?: DocumentLoader; contextLoader?: DocumentLoader; + tracerProvider?: TracerProvider; unavailableKeyTtl?: Temporal.Duration; /** diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 7b853288e..567e7de16 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -603,6 +603,8 @@ export class FederationImpl implements Federation { kv: KvStore; kvPrefixes: FederationKvPrefixes; + publicKeyTtl: Temporal.Duration; + httpMessageSignaturesSpecTtl: Temporal.Duration; inboxQueue?: MessageQueue; outboxQueue?: MessageQueue; fanoutQueue?: MessageQueue; @@ -691,6 +693,12 @@ export class FederationImpl } satisfies FederationKvPrefixes), ...(options.kvPrefixes ?? {}), }; + this.publicKeyTtl = Temporal.Duration.from( + options.publicKeyTtl ?? { days: 30 }, + ); + this.httpMessageSignaturesSpecTtl = Temporal.Duration.from( + options.httpMessageSignaturesSpecTtl ?? { days: 90 }, + ); if (options.queue == null) { this.inboxQueue = undefined; this.outboxQueue = undefined; @@ -893,6 +901,7 @@ export class FederationImpl this.kv, this.kvPrefixes.httpMessageSignaturesSpec, options.firstKnock, + { specTtl: this.httpMessageSignaturesSpecTtl }, ), tracerProvider: this.tracerProvider, }), @@ -1428,6 +1437,7 @@ export class FederationImpl this.kv, this.kvPrefixes.httpMessageSignaturesSpec, this.firstKnock, + { specTtl: this.httpMessageSignaturesSpecTtl }, ), meterProvider: this.meterProvider, tracerProvider: this.tracerProvider, @@ -2443,6 +2453,7 @@ export class FederationImpl this.kv, this.kvPrefixes.httpMessageSignaturesSpec, this.firstKnock, + { specTtl: this.httpMessageSignaturesSpecTtl }, ), meterProvider: this.meterProvider, tracerProvider: this.tracerProvider, @@ -2878,6 +2889,7 @@ export class FederationImpl inboxContextFactory, kv: this.kv, kvPrefixes: this.kvPrefixes, + publicKeyTtl: this.publicKeyTtl, queue: this.inboxQueue, actorDispatcher: this.actorCallbacks?.dispatcher, inboxListeners: this.inboxListeners, @@ -4122,7 +4134,12 @@ export class ContextImpl implements Context { const keyCache = new KvKeyCache( this.federation.kv, this.federation.kvPrefixes.publicKey, - this, + { + documentLoader: this.documentLoader, + contextLoader: this.contextLoader, + tracerProvider: this.tracerProvider, + keyTtl: this.federation.publicKeyTtl, + }, ); const verified = await verifyObject( Activity, @@ -4582,6 +4599,7 @@ async function forwardActivityInternal( ctx.federation.kv, ctx.federation.kvPrefixes.httpMessageSignaturesSpec, ctx.federation.firstKnock, + { specTtl: ctx.federation.httpMessageSignaturesSpecTtl }, ), }), ); From 821ca0c1cfed0c8cf4ca93a4263c0c2c2ee3ef4c Mon Sep 17 00:00:00 2001 From: heeeione <68272931+heeoneie@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:11:10 +0900 Subject: [PATCH 4/9] Cover cache expiry and repopulation through verification and delivery The previous tests only asserted that the TTL fields held the right values and that an entry disappeared from the underlying store. They never showed that an expired entry is relearned, or that signature verification and delivery keep working across that boundary. `KvKeyCache` now covers a cache miss followed by refetching and caching the key, including that the miss surfaces as `undefined` rather than `null`. The distinction matters: `null` means the key is known to be unavailable, so a caller that saw it would treat the actor as keyless instead of refetching. `KvSpecDeterminer` covers a remembered spec expiring, falling back to the default, and being remembered again. Two end-to-end tests drive the same paths through `createFederation()` with overridden TTLs, which also demonstrates that an application can override them. The delivery test sends to a peer that rejects RFC 9421 and accepts draft-cavage, so the first delivery double-knocks and remembers the spec, the second skips the extra knock, and the delivery after expiry double-knocks again; the mock inbox verifies the HTTP signature on every request it accepts. The verification test posts signed activities to an inbox and asserts the key is fetched, reused while cached, and refetched after expiry, with every delivery accepted. Both use a `KvStore` wrapper that records the TTL of each write, so the assertions hold even after the entries themselves have expired. Assisted-by: Claude Code:claude-opus-5 --- .../fedify/src/federation/keycache.test.ts | 13 + .../fedify/src/federation/middleware.test.ts | 264 +++++++++++++++++- 2 files changed, 263 insertions(+), 14 deletions(-) diff --git a/packages/fedify/src/federation/keycache.test.ts b/packages/fedify/src/federation/keycache.test.ts index aa86ee1f6..a1e243d14 100644 --- a/packages/fedify/src/federation/keycache.test.ts +++ b/packages/fedify/src/federation/keycache.test.ts @@ -156,8 +156,21 @@ test("KvKeyCache cached keys expire after keyTtl", async () => { ); // The value is written immediately... assert(await kv.get(["pk", keyId.href]) != null); + assertInstanceOf(await cache.get(keyId), CryptographicKey); // ...but disappears from the underlying KvStore once keyTtl elapses. await new Promise((resolve) => setTimeout(resolve, 10)); assertEquals(await kv.get(["pk", keyId.href]), undefined); + + // A miss is reported as `undefined` (key unknown), not `null` (key known + // to be unavailable), so the caller refetches the key instead of treating + // the actor as keyless. + assertEquals(await cache.get(keyId), undefined); + assertEquals(cache.nullKeys.has(keyId.href), false); + + // Refetching and caching the key again repopulates the cache. + await cache.set(keyId, new CryptographicKey({ id: keyId })); + const refetched = await cache.get(keyId); + assertInstanceOf(refetched, CryptographicKey); + assertEquals(refetched.id?.href, keyId.href); }); diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 8ccc244df..172737b99 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -45,7 +45,11 @@ import personFixture from "../../../fixture/src/fixtures/example.com/person.json import person2Fixture from "../../../fixture/src/fixtures/example.com/person2.json" with { type: "json", }; -import { signRequest, verifyRequest } from "../sig/http.ts"; +import { + type HttpMessageSignaturesSpec, + signRequest, + verifyRequest, +} from "../sig/http.ts"; import type { KeyCache } from "../sig/key.ts"; import { compactJsonLd, @@ -68,7 +72,13 @@ import { getAuthenticatedDocumentLoader } from "../utils/docloader.ts"; import { handleBenchmarkTrigger } from "./bench.ts"; import { CircuitBreaker } from "./circuit-breaker.ts"; import type { Context, GetActorOptions } from "./context.ts"; -import { MemoryKvStore } from "./kv.ts"; +import { + type KvKey, + type KvStore, + type KvStoreListEntry, + type KvStoreSetOptions, + MemoryKvStore, +} from "./kv.ts"; import { recordInboxActivity } from "./metrics.ts"; import { ContextImpl, @@ -11299,23 +11309,249 @@ test("KvSpecDeterminer", async (t) => { }, ); - await t.step("should expire remembered spec after specTtl", async () => { - const kv = new MemoryKvStore(); - const prefix = ["test", "spec"] as const; - const determiner = new KvSpecDeterminer(kv, prefix, "rfc9421", { - specTtl: Temporal.Duration.from({ milliseconds: 1 }), + await t.step( + "should expire, relearn, and remember the spec again", + async () => { + const kv = new MemoryKvStore(); + const prefix = ["test", "spec"] as const; + const determiner = new KvSpecDeterminer(kv, prefix, "rfc9421", { + specTtl: Temporal.Duration.from({ milliseconds: 250 }), + }); + + await determiner.rememberSpec( + "example.com", + "draft-cavage-http-signatures-12", + ); + assertEquals( + await determiner.determineSpec("example.com"), + "draft-cavage-http-signatures-12", + ); + + // Falls back to the default spec once the remembered entry expires, + // which is what makes the next delivery double-knock again. + await new Promise((resolve) => setTimeout(resolve, 400)); + assertEquals(await determiner.determineSpec("example.com"), "rfc9421"); + + // The relearned spec is remembered again, with the TTL reapplied. + await determiner.rememberSpec( + "example.com", + "draft-cavage-http-signatures-12", + ); + assertEquals( + await determiner.determineSpec("example.com"), + "draft-cavage-http-signatures-12", + ); + }, + ); +}); + +/** + * A `KvStore` that records the TTL every write was made with, so tests can + * assert on TTLs even after the entries themselves have expired. + */ +class TtlRecordingKvStore implements KvStore { + readonly inner: MemoryKvStore = new MemoryKvStore(); + readonly writes: { key: KvKey; ttl?: Temporal.Duration }[] = []; + + get(key: KvKey): Promise { + return this.inner.get(key); + } + + set(key: KvKey, value: unknown, options?: KvStoreSetOptions): Promise { + this.writes.push({ key, ttl: options?.ttl }); + return this.inner.set(key, value, options); + } + + delete(key: KvKey): Promise { + return this.inner.delete(key); + } + + list(prefix?: KvKey): AsyncIterable { + return this.inner.list(prefix); + } + + /** The TTL of the most recent write to `key`, or `undefined` if never set. */ + lastTtl(key: KvKey): Temporal.Duration | undefined { + const matches = this.writes.filter((w) => + w.key.length === key.length && w.key.every((p, i) => p === key[i]) + ); + return matches.length < 1 ? undefined : matches[matches.length - 1].ttl; + } +} + +test("createFederation() defaults the cache TTLs and lets them be overridden", () => { + const defaults = new FederationImpl({ kv: new MemoryKvStore() }); + assertEquals(defaults.publicKeyTtl.total("day"), 30); + assertEquals(defaults.httpMessageSignaturesSpecTtl.total("day"), 90); + + const overridden = new FederationImpl({ + kv: new MemoryKvStore(), + publicKeyTtl: { days: 1 }, + httpMessageSignaturesSpecTtl: { hours: 12 }, + }); + assertEquals(overridden.publicKeyTtl.total("day"), 1); + assertEquals(overridden.httpMessageSignaturesSpecTtl.total("hour"), 12); +}); + +test("createFederation() applies httpMessageSignaturesSpecTtl to remembered specs", async () => { + fetchMock.spyGlobal(); + + const attempts: HttpMessageSignaturesSpec[] = []; + fetchMock.post("https://example.com/inbox", async (cl) => { + const request = cl.request!.clone() as Request; + const spec: HttpMessageSignaturesSpec = + request.headers.has("Signature-Input") + ? "rfc9421" + : "draft-cavage-http-signatures-12"; + attempts.push(spec); + // This peer only understands the legacy spec, so the first knock with + // RFC 9421 is rejected and Fedify has to fall back and remember. + if (spec === "rfc9421") return new Response(null, { status: 401 }); + const key = await verifyRequest(request, { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, }); + return new Response(null, { status: key == null ? 401 : 202 }); + }); - await determiner.rememberSpec( - "example.com", - "draft-cavage-http-signatures-12", + const kv = new TtlRecordingKvStore(); + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + httpMessageSignaturesSpecTtl: { milliseconds: 250 }, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + const specKey: KvKey = [ + "_fedify", + "httpMessageSignaturesSpec", + "https://example.com", + ]; + const send = () => + ctx.sendActivity( + [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], + { + id: new URL("https://example.com/recipient"), + inboxId: new URL("https://example.com/inbox"), + }, + new vocab.Create({ + id: new URL(`https://example.com/activities/${crypto.randomUUID()}`), + actor: new URL("https://example.com/person"), + }), ); - await new Promise((resolve) => setTimeout(resolve, 10)); - // Falls back to the default spec once the remembered entry expires. - const spec = await determiner.determineSpec("example.com"); - assertEquals(spec, "rfc9421"); + // The first delivery double-knocks and then remembers the legacy spec, + // using the TTL the application configured rather than the 90-day default. + await send(); + assertEquals(attempts, ["rfc9421", "draft-cavage-http-signatures-12"]); + assertEquals(await kv.get(specKey), "draft-cavage-http-signatures-12"); + assertEquals(kv.lastTtl(specKey)?.total("millisecond"), 250); + + // While the memory is fresh the second delivery skips the double knock. + attempts.length = 0; + await send(); + assertEquals(attempts, ["draft-cavage-http-signatures-12"]); + + // Once it expires the spec is relearned, remembered again, and delivery + // keeps working through that path. + await new Promise((resolve) => setTimeout(resolve, 400)); + assertEquals(await kv.get(specKey), undefined); + attempts.length = 0; + await send(); + assertEquals(attempts, ["rfc9421", "draft-cavage-http-signatures-12"]); + assertEquals(await kv.get(specKey), "draft-cavage-http-signatures-12"); + assertEquals(kv.lastTtl(specKey)?.total("millisecond"), 250); + + fetchMock.hardReset(); +}); + +test("createFederation() applies publicKeyTtl to cached public keys", async () => { + fetchMock.spyGlobal(); + // The inbox handler resolves the signing key through the authenticated + // document loader, which goes out over the network, so count the fetches + // there rather than through `documentLoaderFactory`. + let keyFetches = 0; + fetchMock.get("begin:https://example.com/person2", () => { + keyFetches++; + return { + headers: { "Content-Type": "application/activity+json" }, + body: person2Fixture, + }; }); + + const keyId = "https://example.com/person2#key3"; + const kv = new TtlRecordingKvStore(); + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + publicKeyTtl: { milliseconds: 250 }, + }); + const inbox: vocab.Create[] = []; + federation + .setActorDispatcher( + "/users/{identifier}", + (_, identifier) => identifier === "john" ? new vocab.Person({}) : null, + ) + .setKeyPairsDispatcher(() => [{ + privateKey: rsaPrivateKey2, + publicKey: rsaPublicKey2.publicKey!, + }]); + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(vocab.Create, (_ctx, create) => { + inbox.push(create); + }); + + const deliver = async (): Promise => { + const activity = new vocab.Create({ + id: new URL(`https://example.com/activities/${crypto.randomUUID()}`), + actor: new URL("https://example.com/person2"), + }); + let request = new Request("https://example.com/users/john/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + accept: "application/ld+json", + }, + body: JSON.stringify( + await activity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + request = await signRequest(request, rsaPrivateKey3, new URL(keyId)); + return await federation.fetch(request, { contextData: undefined }); + }; + + const publicKeyKey: KvKey = ["_fedify", "publicKey", keyId]; + + // Verifying the first signed delivery fetches the key and caches it with + // the TTL the application configured rather than the 30-day default. + assertEquals((await deliver()).status, 202); + assertEquals(inbox.length, 1); + assert(keyFetches > 0); + assert(await kv.get(publicKeyKey) != null); + assertEquals(kv.lastTtl(publicKeyKey)?.total("millisecond"), 250); + + // While the cache is warm the key is not refetched. + keyFetches = 0; + assertEquals((await deliver()).status, 202); + assertEquals(inbox.length, 2); + assertEquals(keyFetches, 0); + + // After the TTL elapses the cache misses, the key is refetched and cached + // again, and signature verification keeps working through that path. + await new Promise((resolve) => setTimeout(resolve, 400)); + assertEquals(await kv.get(publicKeyKey), undefined); + keyFetches = 0; + assertEquals((await deliver()).status, 202); + assertEquals(inbox.length, 3); + assert(keyFetches > 0); + assert(await kv.get(publicKeyKey) != null); + assertEquals(kv.lastTtl(publicKeyKey)?.total("millisecond"), 250); + + fetchMock.hardReset(); }); test("createFederation() instruments documentLoader with activitypub.document.fetch", async () => { From d0b5d9254a5c59c73c570f4b4fbe04f19630ce18 Mon Sep 17 00:00:00 2001 From: heeeione <68272931+heeoneie@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:11:20 +0900 Subject: [PATCH 5/9] Document cache TTL options and the retention tradeoff The key-value store guide called the two cache prefixes fixed, which is wrong: applications can override both through `kvPrefixes`, and the adapters add their own namespacing on top of that. The guide also framed clearing the caches as essentially free, mentioning only a few extra fetches afterwards. There is now a section on bounding cache lifetimes that documents the new options and states the tradeoff #1017 asked for: a shorter TTL increases remote requests, and refetching an expired key can fail while the peer is unavailable, so verification that would have succeeded from cache fails instead. The cleanup section describes the prefixes as defaults, says what to substitute when they are overridden, and does the same for the adapter level (`RedisKvStore.keyPrefix`, `PostgresKvStore.tableName`). The cleanup examples now collect the keys before deleting any of them. Deleting while `redis-cli --scan` is still iterating can make the cursor skip entries, and iterating `KvStore.list()` has the same hazard. The federation options reference documents both new options. Assisted-by: Claude Code:claude-opus-5 --- docs/manual/federation.md | 43 ++++++++++++++ docs/manual/kv.md | 122 +++++++++++++++++++++++++++----------- 2 files changed, 131 insertions(+), 34 deletions(-) diff --git a/docs/manual/federation.md b/docs/manual/federation.md index 42bb3c558..6426e5596 100644 --- a/docs/manual/federation.md +++ b/docs/manual/federation.md @@ -96,6 +96,49 @@ that the `Federation` object uses: [double-knocking]: https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions +### `publicKeyTtl` + +*This API is available since Fedify 2.4.0.* + +The `~FederationOptions.publicKeyTtl` property is the time-to-live for +a remote actor's public key cached under +`~FederationKvPrefixes.publicKey`. It is 30 days by default. Once +an entry expires, the next signature verification that needs the key +refetches it from the remote server and caches it again: + +~~~~ typescript twoslash +import { createFederation, MemoryKvStore } from "@fedify/fedify"; + +const federation = createFederation({ + kv: new MemoryKvStore(), + publicKeyTtl: { days: 7 }, // [!code highlight] +}); +~~~~ + +### `httpMessageSignaturesSpecTtl` + +*This API is available since Fedify 2.4.0.* + +The `~FederationOptions.httpMessageSignaturesSpecTtl` property is +the time-to-live for a remote origin's remembered HTTP Message Signatures +spec cached under `~FederationKvPrefixes.httpMessageSignaturesSpec`. +It is 90 days by default. Once an entry expires, the next delivery to that +origin relearns the spec by [double-knocking] and remembers it again: + +~~~~ typescript twoslash +import { createFederation, MemoryKvStore } from "@fedify/fedify"; + +const federation = createFederation({ + kv: new MemoryKvStore(), + httpMessageSignaturesSpecTtl: { days: 30 }, // [!code highlight] +}); +~~~~ + +> [!TIP] +> Both TTLs trade storage against remote requests. See +> [*Bounding how long cache entries live*](./kv.md#bounding-how-long-cache-entries-live) +> for what shortening or lengthening them costs. + ### `queue` *This API is available since Fedify 0.5.0.* diff --git a/docs/manual/kv.md b/docs/manual/kv.md index 4ccf71e44..98cdb282b 100644 --- a/docs/manual/kv.md +++ b/docs/manual/kv.md @@ -512,49 +512,100 @@ export default { [Cloudflare Workers KV]: https://developers.cloudflare.com/kv/ +Bounding how long cache entries live +------------------------------------ + +*This section is relevant since Fedify 2.4.0.* + +Fedify keeps two caches in your `KvStore`: cached actor public keys and +remembered per-origin HTTP Message Signatures specs. Since Fedify 2.4.0 +both are written with a time-to-live, so a `KvStore` that never sees an +explicit clear no longer accumulates entries for actors and origins that have +stopped federating. The defaults are 30 days for cached keys and 90 days for +remembered specs, and applications can override them through +`~FederationOptions.publicKeyTtl` and +`~FederationOptions.httpMessageSignaturesSpecTtl`: + +~~~~ typescript twoslash +import { createFederation, MemoryKvStore } from "@fedify/fedify"; + +const federation = createFederation({ + kv: new MemoryKvStore(), + publicKeyTtl: { days: 7 }, // [!code highlight] + httpMessageSignaturesSpecTtl: { days: 30 }, // [!code highlight] +}); +~~~~ + +Both TTLs are a retention tradeoff, not a free cleanup knob. A shorter TTL +keeps less in the store and bounds how long a revoked or rotated key or an +outdated spec stays cached, but every expiry costs a request to the remote +server: verification has to refetch the key, and delivery has to relearn the +spec by [double-knocking] again. That refetch is not guaranteed to succeed—if +the peer is down, unreachable, or has removed the actor when the entry expires, +verification fails where it would have succeeded from cache. Lengthening +a TTL inverts the tradeoff: fewer remote requests and more tolerance of +unavailable peers, at the cost of holding stale entries longer. + +Pick the shorter end when your store is under space pressure or you need +revoked keys to fall out quickly, and the longer end when you federate with +peers that are frequently unavailable. + +[double-knocking]: https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions + + Clearing legacy cache entries ----------------------------- *This section is relevant since Fedify 2.4.0.* -Fedify keeps two caches in your `KvStore`: cached actor public keys (used by -`KvKeyCache`) and remembered per-origin HTTP Message Signatures specs (used -by `KvSpecDeterminer`). As of Fedify 2.4.0, Fedify writes both with a -TTL it configures internally—30 days for cached keys and 90 days for -remembered specs. - Entries written by Fedify 2.3 or earlier have no TTL. They are *not* migrated or expired automatically: they simply stay in your `KvStore` until something overwrites them, which is the same behavior Fedify has always had. Leaving them alone is a perfectly valid choice—Fedify keeps serving and -refreshing them as before, and they get a TTL the next time they're written. +refreshing them as before, and they get a TTL the next time they are written. -If you'd rather not wait for that, you can clear the old entries yourself. -Both caches live under fixed key prefixes: +If you would rather not wait for that, you can clear the old entries yourself. +Both caches live under their `~FederationOptions.kvPrefixes` entries, which +are `["_fedify", "publicKey"]` and +`["_fedify", "httpMessageSignaturesSpec"]` *by default*: - - `["_fedify", "publicKey"]` — cached actor public keys - - `["_fedify", "httpMessageSignaturesSpec"]` — remembered HTTP + - `~FederationKvPrefixes.publicKey` — cached actor public keys + - `~FederationKvPrefixes.httpMessageSignaturesSpec` — remembered HTTP Message Signatures specs -Deleting everything under these prefixes is always safe. Both caches are -soft state: Fedify relearns them on demand (by refetching the actor's key, or -by renegotiating the signature spec on the next delivery), at the cost of a -few extra fetches right after you clear them. +These are defaults, not fixed values. If you passed your own `kvPrefixes` to +`createFederation()`, substitute your prefixes for `_fedify`, `publicKey`, and +`httpMessageSignaturesSpec` in every example below. The same goes for the +adapter-level namespacing described in each subsection: [`RedisKvStore`] +prepends its own `keyPrefix`, and [`PostgresKvStore`] stores rows in its own +`tableName`. + +Clearing these entries costs the remote requests described in the previous +section: the caches are soft state that Fedify relearns on demand, but every +cleared key has to be refetched before it can be used again, and that refetch +fails while the peer is unavailable. Prefer clearing them while your peers +are reachable, and clear only the prefixes you actually need to reclaim. ### Clearing entries in `RedisKvStore` [`RedisKvStore`] stores every key under a shared prefix (`"fedify::"` by default, configurable via `RedisKvStoreOptions.keyPrefix`), followed by the -`KvKey` parts joined with `"::"`. So with the default prefix, scan for and -delete the two Fedify caches like this: +`KvKey` parts joined with `"::"`. Collect the whole scan result before +deleting anything—deleting keys while `--scan` is still iterating can make +the cursor skip entries: ~~~~ bash -redis-cli --scan --pattern 'fedify::_fedify::publicKey::*' | xargs -r redis-cli del -redis-cli --scan --pattern 'fedify::_fedify::httpMessageSignaturesSpec::*' | xargs -r redis-cli del +for pattern in 'fedify::_fedify::publicKey::*' \ + 'fedify::_fedify::httpMessageSignaturesSpec::*'; do + redis-cli --scan --pattern "$pattern" > /tmp/fedify-keys.txt + test -s /tmp/fedify-keys.txt && xargs -a /tmp/fedify-keys.txt redis-cli del + rm -f /tmp/fedify-keys.txt +done ~~~~ -Replace the leading `fedify::` with your own `keyPrefix` if you configured a -custom one. +Replace the leading `fedify::` with your own `keyPrefix` if you configured +a custom one, and the `_fedify::publicKey` and +`_fedify::httpMessageSignaturesSpec` parts with your own `kvPrefixes`. ### Clearing entries in `PostgresKvStore` @@ -572,29 +623,32 @@ WHERE array_length(key, 1) >= 2 ~~~~ Replace `fedify_kv_v2` with your own `tableName` if you configured a custom -one. +one, and the array literals with your own `kvPrefixes`. ### Clearing entries in other `KvStore` implementations -For any other `KvStore`, iterate the two prefixes with [`~KvStore.list()`] -and delete each key you get back: +For any other `KvStore`, iterate the two prefixes with [`~KvStore.list()`], +collect the keys, and delete them afterwards. Deleting while the iterator is +still open can make an implementation skip entries, the same way it does with +`redis-cli --scan`: ~~~~ typescript twoslash -import type { KvStore } from "@fedify/fedify"; +import type { KvKey, KvStore } from "@fedify/fedify"; const kv = null as unknown as KvStore; // ---cut-before--- -for ( - const prefix of [ - ["_fedify", "publicKey"], - ["_fedify", "httpMessageSignaturesSpec"], - ] as const -) { - for await (const entry of kv.list(prefix)) { - await kv.delete(entry.key); - } +const prefixes: KvKey[] = [ + ["_fedify", "publicKey"], + ["_fedify", "httpMessageSignaturesSpec"], +]; +for (const prefix of prefixes) { + const keys: KvKey[] = []; + for await (const entry of kv.list(prefix)) keys.push(entry.key); + for (const key of keys) await kv.delete(key); } ~~~~ +Substitute your own `kvPrefixes` for the two prefixes if you configured them. + [`~KvStore.list()`]: https://jsr.io/@fedify/fedify/doc/federation/~/KvStore#list From a9fcaf634ef9f186e5b13c5b6415fffbb21cd862 Mon Sep 17 00:00:00 2001 From: heeeione <68272931+heeoneie@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:11:30 +0900 Subject: [PATCH 6/9] Rewrite the KV cache TTL changelog fragment The fragment was named after the issue number and described internal class options and constructor shapes, none of which a user of the release sees. It now has a topic-based name and describes the cache lifetimes themselves, the public options that configure them, the retention tradeoff, and how entries written by earlier versions are handled. The entry starts with a past-tense verb, and the credit uses the repository's `[[#1017], [#1027] by Heewon Chae]` form. `CHANGES.md` was previously edited by hand on this branch. It is now regenerated with `sacho sync`, which leaves every other unreleased entry untouched. Assisted-by: Claude Code:claude-opus-5 --- CHANGES.md | 37 +++++++++++++++------------ changes.d/fedify/1017-kv-cache-ttl.md | 22 ---------------- changes.d/fedify/kv-cache-ttl.md | 25 ++++++++++++++++++ 3 files changed, 45 insertions(+), 39 deletions(-) delete mode 100644 changes.d/fedify/1017-kv-cache-ttl.md create mode 100644 changes.d/fedify/kv-cache-ttl.md diff --git a/CHANGES.md b/CHANGES.md index 1f7ee82f7..c7adc38d9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,25 @@ To be released. ### @fedify/fedify + - Changed cached actor public keys and remembered per-origin HTTP Message + Signatures specs to expire, so a `KvStore` that never sees an explicit + clear no longer accumulates entries for actors and origins that have + stopped federating. Keys expire after 30 days and specs after 90 days + by default, and both windows are configurable through the new + `FederationOptions.publicKeyTtl` and + `FederationOptions.httpMessageSignaturesSpecTtl` options. + [[#1017], [#1027] by Heewon Chae\] + + - Shortening a window trades storage for remote requests: an expired + key has to be refetched before the next signature verification, and + an expired spec has to be relearned by double-knocking on the next + delivery. Refetching fails while the peer is unavailable, so a very + short window makes verification depend on the peer being reachable. + - Entries written by earlier versions of Fedify have no expiry and are + left as they are; they gain one the next time they are written. See + the new *Clearing legacy cache entries* section of the + [key–value store guide] to clear them proactively instead of waiting. + - Fixed `verifyProof()` so Ed25519 JCS proofs authenticate every received proof option except `proofValue`, including `expires`, `domain`, `challenge`, `nonce`, and extension options. It now rejects expired or @@ -108,28 +127,12 @@ To be released. `esnext.temporal` lib reference. [[#823], [#925]] - - `KvKeyCache` and `KvSpecDeterminer` now write their cache entries with a - TTL, so a `KvStore` that never sees an explicit clear no longer - accumulates entries for actors and origins that have stopped - federating. [[#1017], [#1027]] - - - `KvKeyCache` gained a `KvKeyCacheOptions.keyTtl` option for cached - keys, `30` days by default. - - `KvSpecDeterminer`'s constructor gained an optional 4th - `KvSpecDeterminerOptions` argument with a `specTtl` option for - remembered specs, `90` days by default. Its existing 3-argument - constructor shape is unchanged. - - Entries written by earlier Fedify versions have no TTL and are left - as is; see the new *Clearing legacy cache entries* section of the - [key–value store guide] if you want to expire them proactively - instead of waiting for them to be overwritten. - +[key–value store guide]: https://fedify.dev/manual/kv [FEP-ef61]: https://w3id.org/fep/ef61 [FEP-8b32]: https://w3id.org/fep/8b32 [FEP-fe34]: https://w3id.org/fep/fe34 [ActivityPub Media Upload extension]: https://www.w3.org/wiki/SocialCG/ActivityPub/MediaUpload [Standard Schema]: https://standardschema.dev/ -[key–value store guide]: https://fedify.dev/manual/kv [#206]: https://github.com/fedify-dev/fedify/issues/206 [#754]: https://github.com/fedify-dev/fedify/issues/754 [#797]: https://github.com/fedify-dev/fedify/issues/797 diff --git a/changes.d/fedify/1017-kv-cache-ttl.md b/changes.d/fedify/1017-kv-cache-ttl.md deleted file mode 100644 index 503edf359..000000000 --- a/changes.d/fedify/1017-kv-cache-ttl.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -links: - '#1017': https://github.com/fedify-dev/fedify/issues/1017 - '#1027': https://github.com/fedify-dev/fedify/pull/1027 ---- - - `KvKeyCache` and `KvSpecDeterminer` now write their cache entries with a - TTL, so a `KvStore` that never sees an explicit clear no longer - accumulates entries for actors and origins that have stopped - federating. [[#1017], [#1027]] - - - `KvKeyCache` gained a `KvKeyCacheOptions.keyTtl` option for cached - keys, `30` days by default. - - `KvSpecDeterminer`'s constructor gained an optional 4th - `KvSpecDeterminerOptions` argument with a `specTtl` option for - remembered specs, `90` days by default. Its existing 3-argument - constructor shape is unchanged. - - Entries written by earlier Fedify versions have no TTL and are left - as is; see the new *Clearing legacy cache entries* section of the - [key–value store guide] if you want to expire them proactively - instead of waiting for them to be overwritten. - -[key–value store guide]: https://fedify.dev/manual/kv diff --git a/changes.d/fedify/kv-cache-ttl.md b/changes.d/fedify/kv-cache-ttl.md new file mode 100644 index 000000000..f3caf4e8b --- /dev/null +++ b/changes.d/fedify/kv-cache-ttl.md @@ -0,0 +1,25 @@ +--- +links: + '#1017': https://github.com/fedify-dev/fedify/issues/1017 + '#1027': https://github.com/fedify-dev/fedify/pull/1027 +--- + - Changed cached actor public keys and remembered per-origin HTTP Message + Signatures specs to expire, so a `KvStore` that never sees an explicit + clear no longer accumulates entries for actors and origins that have + stopped federating. Keys expire after 30 days and specs after 90 days + by default, and both windows are configurable through the new + `FederationOptions.publicKeyTtl` and + `FederationOptions.httpMessageSignaturesSpecTtl` options. + [[#1017], [#1027] by Heewon Chae] + + - Shortening a window trades storage for remote requests: an expired + key has to be refetched before the next signature verification, and + an expired spec has to be relearned by double-knocking on the next + delivery. Refetching fails while the peer is unavailable, so a very + short window makes verification depend on the peer being reachable. + - Entries written by earlier versions of Fedify have no expiry and are + left as they are; they gain one the next time they are written. See + the new *Clearing legacy cache entries* section of the + [key–value store guide] to clear them proactively instead of waiting. + +[key–value store guide]: https://fedify.dev/manual/kv From 779f79c6dfd36daeac9440fec098b64e3bf5bd78 Mon Sep 17 00:00:00 2001 From: heeeione <68272931+heeoneie@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:56:41 +0900 Subject: [PATCH 7/9] Restore the global fetch spy when a TTL test fails The two `createFederation()` TTL tests called `fetchMock.hardReset()` as their last statement, so an assertion failure anywhere earlier left the global fetch spy installed and leaked it into the tests that run after them in this file. Both bodies are now wrapped in `try` / `finally` with the reset in the `finally` block, matching the pattern the file already uses elsewhere. Assisted-by: Claude Code:claude-opus-5 Changelog: none --- .../fedify/src/federation/middleware.test.ts | 291 +++++++++--------- 1 file changed, 147 insertions(+), 144 deletions(-) diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 172737b99..6aedf60af 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -11395,163 +11395,166 @@ test("createFederation() defaults the cache TTLs and lets them be overridden", ( test("createFederation() applies httpMessageSignaturesSpecTtl to remembered specs", async () => { fetchMock.spyGlobal(); - - const attempts: HttpMessageSignaturesSpec[] = []; - fetchMock.post("https://example.com/inbox", async (cl) => { - const request = cl.request!.clone() as Request; - const spec: HttpMessageSignaturesSpec = - request.headers.has("Signature-Input") - ? "rfc9421" - : "draft-cavage-http-signatures-12"; - attempts.push(spec); - // This peer only understands the legacy spec, so the first knock with - // RFC 9421 is rejected and Fedify has to fall back and remember. - if (spec === "rfc9421") return new Response(null, { status: 401 }); - const key = await verifyRequest(request, { - documentLoader: mockDocumentLoader, - contextLoader: mockDocumentLoader, + try { + const attempts: HttpMessageSignaturesSpec[] = []; + fetchMock.post("https://example.com/inbox", async (cl) => { + const request = cl.request!.clone() as Request; + const spec: HttpMessageSignaturesSpec = + request.headers.has("Signature-Input") + ? "rfc9421" + : "draft-cavage-http-signatures-12"; + attempts.push(spec); + // This peer only understands the legacy spec, so the first knock with + // RFC 9421 is rejected and Fedify has to fall back and remember. + if (spec === "rfc9421") return new Response(null, { status: 401 }); + const key = await verifyRequest(request, { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }); + return new Response(null, { status: key == null ? 401 : 202 }); }); - return new Response(null, { status: key == null ? 401 : 202 }); - }); - const kv = new TtlRecordingKvStore(); - const federation = createFederation({ - kv, - documentLoaderFactory: () => mockDocumentLoader, - contextLoaderFactory: () => mockDocumentLoader, - httpMessageSignaturesSpecTtl: { milliseconds: 250 }, - }); - const ctx = federation.createContext( - new URL("https://example.com/"), - undefined, - ); - const specKey: KvKey = [ - "_fedify", - "httpMessageSignaturesSpec", - "https://example.com", - ]; - const send = () => - ctx.sendActivity( - [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], - { - id: new URL("https://example.com/recipient"), - inboxId: new URL("https://example.com/inbox"), - }, - new vocab.Create({ - id: new URL(`https://example.com/activities/${crypto.randomUUID()}`), - actor: new URL("https://example.com/person"), - }), + const kv = new TtlRecordingKvStore(); + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + httpMessageSignaturesSpecTtl: { milliseconds: 250 }, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, ); + const specKey: KvKey = [ + "_fedify", + "httpMessageSignaturesSpec", + "https://example.com", + ]; + const send = () => + ctx.sendActivity( + [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], + { + id: new URL("https://example.com/recipient"), + inboxId: new URL("https://example.com/inbox"), + }, + new vocab.Create({ + id: new URL(`https://example.com/activities/${crypto.randomUUID()}`), + actor: new URL("https://example.com/person"), + }), + ); - // The first delivery double-knocks and then remembers the legacy spec, - // using the TTL the application configured rather than the 90-day default. - await send(); - assertEquals(attempts, ["rfc9421", "draft-cavage-http-signatures-12"]); - assertEquals(await kv.get(specKey), "draft-cavage-http-signatures-12"); - assertEquals(kv.lastTtl(specKey)?.total("millisecond"), 250); - - // While the memory is fresh the second delivery skips the double knock. - attempts.length = 0; - await send(); - assertEquals(attempts, ["draft-cavage-http-signatures-12"]); - - // Once it expires the spec is relearned, remembered again, and delivery - // keeps working through that path. - await new Promise((resolve) => setTimeout(resolve, 400)); - assertEquals(await kv.get(specKey), undefined); - attempts.length = 0; - await send(); - assertEquals(attempts, ["rfc9421", "draft-cavage-http-signatures-12"]); - assertEquals(await kv.get(specKey), "draft-cavage-http-signatures-12"); - assertEquals(kv.lastTtl(specKey)?.total("millisecond"), 250); - - fetchMock.hardReset(); + // The first delivery double-knocks and then remembers the legacy spec, + // using the TTL the application configured rather than the 90-day default. + await send(); + assertEquals(attempts, ["rfc9421", "draft-cavage-http-signatures-12"]); + assertEquals(await kv.get(specKey), "draft-cavage-http-signatures-12"); + assertEquals(kv.lastTtl(specKey)?.total("millisecond"), 250); + + // While the memory is fresh the second delivery skips the double knock. + attempts.length = 0; + await send(); + assertEquals(attempts, ["draft-cavage-http-signatures-12"]); + + // Once it expires the spec is relearned, remembered again, and delivery + // keeps working through that path. + await new Promise((resolve) => setTimeout(resolve, 400)); + assertEquals(await kv.get(specKey), undefined); + attempts.length = 0; + await send(); + assertEquals(attempts, ["rfc9421", "draft-cavage-http-signatures-12"]); + assertEquals(await kv.get(specKey), "draft-cavage-http-signatures-12"); + assertEquals(kv.lastTtl(specKey)?.total("millisecond"), 250); + } finally { + fetchMock.hardReset(); + } }); test("createFederation() applies publicKeyTtl to cached public keys", async () => { fetchMock.spyGlobal(); - // The inbox handler resolves the signing key through the authenticated - // document loader, which goes out over the network, so count the fetches - // there rather than through `documentLoaderFactory`. - let keyFetches = 0; - fetchMock.get("begin:https://example.com/person2", () => { - keyFetches++; - return { - headers: { "Content-Type": "application/activity+json" }, - body: person2Fixture, - }; - }); - - const keyId = "https://example.com/person2#key3"; - const kv = new TtlRecordingKvStore(); - const federation = createFederation({ - kv, - documentLoaderFactory: () => mockDocumentLoader, - contextLoaderFactory: () => mockDocumentLoader, - publicKeyTtl: { milliseconds: 250 }, - }); - const inbox: vocab.Create[] = []; - federation - .setActorDispatcher( - "/users/{identifier}", - (_, identifier) => identifier === "john" ? new vocab.Person({}) : null, - ) - .setKeyPairsDispatcher(() => [{ - privateKey: rsaPrivateKey2, - publicKey: rsaPublicKey2.publicKey!, - }]); - federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") - .on(vocab.Create, (_ctx, create) => { - inbox.push(create); + try { + // The inbox handler resolves the signing key through the authenticated + // document loader, which goes out over the network, so count the fetches + // there rather than through `documentLoaderFactory`. + let keyFetches = 0; + fetchMock.get("begin:https://example.com/person2", () => { + keyFetches++; + return { + headers: { "Content-Type": "application/activity+json" }, + body: person2Fixture, + }; }); - const deliver = async (): Promise => { - const activity = new vocab.Create({ - id: new URL(`https://example.com/activities/${crypto.randomUUID()}`), - actor: new URL("https://example.com/person2"), - }); - let request = new Request("https://example.com/users/john/inbox", { - method: "POST", - headers: { - "Content-Type": "application/activity+json", - accept: "application/ld+json", - }, - body: JSON.stringify( - await activity.toJsonLd({ contextLoader: mockDocumentLoader }), - ), + const keyId = "https://example.com/person2#key3"; + const kv = new TtlRecordingKvStore(); + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + publicKeyTtl: { milliseconds: 250 }, }); - request = await signRequest(request, rsaPrivateKey3, new URL(keyId)); - return await federation.fetch(request, { contextData: undefined }); - }; + const inbox: vocab.Create[] = []; + federation + .setActorDispatcher( + "/users/{identifier}", + (_, identifier) => identifier === "john" ? new vocab.Person({}) : null, + ) + .setKeyPairsDispatcher(() => [{ + privateKey: rsaPrivateKey2, + publicKey: rsaPublicKey2.publicKey!, + }]); + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(vocab.Create, (_ctx, create) => { + inbox.push(create); + }); + + const deliver = async (): Promise => { + const activity = new vocab.Create({ + id: new URL(`https://example.com/activities/${crypto.randomUUID()}`), + actor: new URL("https://example.com/person2"), + }); + let request = new Request("https://example.com/users/john/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + accept: "application/ld+json", + }, + body: JSON.stringify( + await activity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + request = await signRequest(request, rsaPrivateKey3, new URL(keyId)); + return await federation.fetch(request, { contextData: undefined }); + }; - const publicKeyKey: KvKey = ["_fedify", "publicKey", keyId]; - - // Verifying the first signed delivery fetches the key and caches it with - // the TTL the application configured rather than the 30-day default. - assertEquals((await deliver()).status, 202); - assertEquals(inbox.length, 1); - assert(keyFetches > 0); - assert(await kv.get(publicKeyKey) != null); - assertEquals(kv.lastTtl(publicKeyKey)?.total("millisecond"), 250); - - // While the cache is warm the key is not refetched. - keyFetches = 0; - assertEquals((await deliver()).status, 202); - assertEquals(inbox.length, 2); - assertEquals(keyFetches, 0); - - // After the TTL elapses the cache misses, the key is refetched and cached - // again, and signature verification keeps working through that path. - await new Promise((resolve) => setTimeout(resolve, 400)); - assertEquals(await kv.get(publicKeyKey), undefined); - keyFetches = 0; - assertEquals((await deliver()).status, 202); - assertEquals(inbox.length, 3); - assert(keyFetches > 0); - assert(await kv.get(publicKeyKey) != null); - assertEquals(kv.lastTtl(publicKeyKey)?.total("millisecond"), 250); + const publicKeyKey: KvKey = ["_fedify", "publicKey", keyId]; - fetchMock.hardReset(); + // Verifying the first signed delivery fetches the key and caches it with + // the TTL the application configured rather than the 30-day default. + assertEquals((await deliver()).status, 202); + assertEquals(inbox.length, 1); + assert(keyFetches > 0); + assert(await kv.get(publicKeyKey) != null); + assertEquals(kv.lastTtl(publicKeyKey)?.total("millisecond"), 250); + + // While the cache is warm the key is not refetched. + keyFetches = 0; + assertEquals((await deliver()).status, 202); + assertEquals(inbox.length, 2); + assertEquals(keyFetches, 0); + + // After the TTL elapses the cache misses, the key is refetched and cached + // again, and signature verification keeps working through that path. + await new Promise((resolve) => setTimeout(resolve, 400)); + assertEquals(await kv.get(publicKeyKey), undefined); + keyFetches = 0; + assertEquals((await deliver()).status, 202); + assertEquals(inbox.length, 3); + assert(keyFetches > 0); + assert(await kv.get(publicKeyKey) != null); + assertEquals(kv.lastTtl(publicKeyKey)?.total("millisecond"), 250); + } finally { + fetchMock.hardReset(); + } }); test("createFederation() instruments documentLoader with activitypub.document.fetch", async () => { From f580e0d828ce800aab003ddca7bddfedfc6cea13 Mon Sep 17 00:00:00 2001 From: heeeione <68272931+heeoneie@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:48:44 +0900 Subject: [PATCH 8/9] Drive cache expiry in tests from a virtual clock The TTL tests waited for real time to pass and then asserted that an entry had expired. That made them depend on the runner keeping up: on the Node.js CI job the 1 ms key TTL elapsed before the test could read the value back, and `KvKeyCache cached keys expire after keyTtl` failed with "Expected object to be an instance of CryptographicKey but was undefined" after 7 ms, before it ever reached its sleep. The 250 ms TTLs paired with 400 ms sleeps elsewhere had the same shape, just a wider margin. `MemoryKvStore` decides expiry by comparing timestamps rather than by scheduling timers, so no test has to wait at all. `ManualClockKvStore` wraps a `KvStore`, keeps the TTLs itself instead of passing them down, and expires entries only when `advance()` moves its virtual clock. It lives in *src/testing/*, which is not in the package exports, so this adds no test-only API to the published surface, and it patches no global, so Deno, Node.js and Bun all behave identically. All five affected tests now use it and no longer sleep, and their TTLs are stated in the units the feature actually uses -- 30 days for keys, 90 days for specs -- rather than in milliseconds chosen to keep a test fast. Neutering `advance()` fails all five, so the assertions still depend on expiry actually happening. Assisted-by: Claude Code:claude-opus-5 Changelog: none --- .../fedify/src/federation/keycache.test.ts | 29 ++-- .../fedify/src/federation/middleware.test.ts | 30 ++-- packages/fedify/src/testing/kv.ts | 128 ++++++++++++++++++ packages/fedify/src/testing/mod.ts | 1 + 4 files changed, 165 insertions(+), 23 deletions(-) create mode 100644 packages/fedify/src/testing/kv.ts diff --git a/packages/fedify/src/federation/keycache.test.ts b/packages/fedify/src/federation/keycache.test.ts index a1e243d14..5efaecab7 100644 --- a/packages/fedify/src/federation/keycache.test.ts +++ b/packages/fedify/src/federation/keycache.test.ts @@ -3,6 +3,7 @@ import { CryptographicKey, Multikey } from "@fedify/vocab"; import { assert } from "@std/assert/assert"; import { assertEquals } from "@std/assert/assert-equals"; import { assertInstanceOf } from "@std/assert/assert-instance-of"; +import { ManualClockKvStore } from "../testing/kv.ts"; import { KvKeyCache } from "./keycache.ts"; import { MemoryKvStore } from "./kv.ts"; @@ -112,10 +113,9 @@ test("KvKeyCache fetch error metadata", async () => { }); test("KvKeyCache unavailable entries expire", async () => { - const kv = new MemoryKvStore(); - const cache = new KvKeyCache(kv, ["pk"], { - unavailableKeyTtl: Temporal.Duration.from({ milliseconds: 1 }), - }); + const kv = new ManualClockKvStore(); + const unavailableKeyTtl = Temporal.Duration.from({ minutes: 10 }); + const cache = new KvKeyCache(kv, ["pk"], { unavailableKeyTtl }); const keyId = new URL("https://example.com/expired"); await cache.set(keyId, null); @@ -123,10 +123,16 @@ test("KvKeyCache unavailable entries expire", async () => { status: 410, response: new Response(null, { status: 410 }), }); - await new Promise((resolve) => setTimeout(resolve, 10)); - assertEquals(await cache.get(keyId), undefined); - assertEquals(await cache.getFetchError(keyId), undefined); + kv.advance({ minutes: 20 }); + + // `KvKeyCache` also keeps negative results in an in-process map keyed on + // the real clock. A fresh cache over the same store stands in for a later + // process, whose map starts empty, so these reads go to the store, which is + // what this test is about. + const later = new KvKeyCache(kv, ["pk"], { unavailableKeyTtl }); + assertEquals(await later.get(keyId), undefined); + assertEquals(await later.getFetchError(keyId), undefined); }); test("KvKeyCache.keyTtl defaults to 30 days", () => { @@ -144,9 +150,9 @@ test("KvKeyCache.keyTtl is configurable", () => { }); test("KvKeyCache cached keys expire after keyTtl", async () => { - const kv = new MemoryKvStore(); + const kv = new ManualClockKvStore(); const cache = new KvKeyCache(kv, ["pk"], { - keyTtl: Temporal.Duration.from({ milliseconds: 1 }), + keyTtl: Temporal.Duration.from({ days: 30 }), }); const keyId = new URL("https://example.com/key"); @@ -154,12 +160,13 @@ test("KvKeyCache cached keys expire after keyTtl", async () => { keyId, new CryptographicKey({ id: keyId }), ); - // The value is written immediately... + // The value is written immediately, and stays readable for as long as the + // TTL has not elapsed, however long the test itself takes to run. assert(await kv.get(["pk", keyId.href]) != null); assertInstanceOf(await cache.get(keyId), CryptographicKey); // ...but disappears from the underlying KvStore once keyTtl elapses. - await new Promise((resolve) => setTimeout(resolve, 10)); + kv.advance({ days: 31 }); assertEquals(await kv.get(["pk", keyId.href]), undefined); // A miss is reported as `undefined` (key unknown), not `null` (key known diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 6aedf60af..9073e36db 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -103,6 +103,7 @@ import TaskCodec from "./tasks/codec.ts"; import { type Envelope, envelopeSchema, + ManualClockKvStore, MockQueue, numberSchema, } from "../testing/mod.ts"; @@ -11312,10 +11313,10 @@ test("KvSpecDeterminer", async (t) => { await t.step( "should expire, relearn, and remember the spec again", async () => { - const kv = new MemoryKvStore(); + const kv = new ManualClockKvStore(); const prefix = ["test", "spec"] as const; const determiner = new KvSpecDeterminer(kv, prefix, "rfc9421", { - specTtl: Temporal.Duration.from({ milliseconds: 250 }), + specTtl: Temporal.Duration.from({ days: 90 }), }); await determiner.rememberSpec( @@ -11329,7 +11330,7 @@ test("KvSpecDeterminer", async (t) => { // Falls back to the default spec once the remembered entry expires, // which is what makes the next delivery double-knock again. - await new Promise((resolve) => setTimeout(resolve, 400)); + kv.advance({ days: 91 }); assertEquals(await determiner.determineSpec("example.com"), "rfc9421"); // The relearned spec is remembered again, with the TTL reapplied. @@ -11350,9 +11351,14 @@ test("KvSpecDeterminer", async (t) => { * assert on TTLs even after the entries themselves have expired. */ class TtlRecordingKvStore implements KvStore { - readonly inner: MemoryKvStore = new MemoryKvStore(); + readonly inner: ManualClockKvStore = new ManualClockKvStore(); readonly writes: { key: KvKey; ttl?: Temporal.Duration }[] = []; + /** Moves the wrapped store's virtual clock forward. */ + advance(duration: Temporal.DurationLike): void { + this.inner.advance(duration); + } + get(key: KvKey): Promise { return this.inner.get(key); } @@ -11419,7 +11425,7 @@ test("createFederation() applies httpMessageSignaturesSpecTtl to remembered spec kv, documentLoaderFactory: () => mockDocumentLoader, contextLoaderFactory: () => mockDocumentLoader, - httpMessageSignaturesSpecTtl: { milliseconds: 250 }, + httpMessageSignaturesSpecTtl: { days: 90 }, }); const ctx = federation.createContext( new URL("https://example.com/"), @@ -11448,7 +11454,7 @@ test("createFederation() applies httpMessageSignaturesSpecTtl to remembered spec await send(); assertEquals(attempts, ["rfc9421", "draft-cavage-http-signatures-12"]); assertEquals(await kv.get(specKey), "draft-cavage-http-signatures-12"); - assertEquals(kv.lastTtl(specKey)?.total("millisecond"), 250); + assertEquals(kv.lastTtl(specKey)?.total("day"), 90); // While the memory is fresh the second delivery skips the double knock. attempts.length = 0; @@ -11457,13 +11463,13 @@ test("createFederation() applies httpMessageSignaturesSpecTtl to remembered spec // Once it expires the spec is relearned, remembered again, and delivery // keeps working through that path. - await new Promise((resolve) => setTimeout(resolve, 400)); + kv.advance({ days: 91 }); assertEquals(await kv.get(specKey), undefined); attempts.length = 0; await send(); assertEquals(attempts, ["rfc9421", "draft-cavage-http-signatures-12"]); assertEquals(await kv.get(specKey), "draft-cavage-http-signatures-12"); - assertEquals(kv.lastTtl(specKey)?.total("millisecond"), 250); + assertEquals(kv.lastTtl(specKey)?.total("day"), 90); } finally { fetchMock.hardReset(); } @@ -11490,7 +11496,7 @@ test("createFederation() applies publicKeyTtl to cached public keys", async () = kv, documentLoaderFactory: () => mockDocumentLoader, contextLoaderFactory: () => mockDocumentLoader, - publicKeyTtl: { milliseconds: 250 }, + publicKeyTtl: { days: 30 }, }); const inbox: vocab.Create[] = []; federation @@ -11534,7 +11540,7 @@ test("createFederation() applies publicKeyTtl to cached public keys", async () = assertEquals(inbox.length, 1); assert(keyFetches > 0); assert(await kv.get(publicKeyKey) != null); - assertEquals(kv.lastTtl(publicKeyKey)?.total("millisecond"), 250); + assertEquals(kv.lastTtl(publicKeyKey)?.total("day"), 30); // While the cache is warm the key is not refetched. keyFetches = 0; @@ -11544,14 +11550,14 @@ test("createFederation() applies publicKeyTtl to cached public keys", async () = // After the TTL elapses the cache misses, the key is refetched and cached // again, and signature verification keeps working through that path. - await new Promise((resolve) => setTimeout(resolve, 400)); + kv.advance({ days: 31 }); assertEquals(await kv.get(publicKeyKey), undefined); keyFetches = 0; assertEquals((await deliver()).status, 202); assertEquals(inbox.length, 3); assert(keyFetches > 0); assert(await kv.get(publicKeyKey) != null); - assertEquals(kv.lastTtl(publicKeyKey)?.total("millisecond"), 250); + assertEquals(kv.lastTtl(publicKeyKey)?.total("day"), 30); } finally { fetchMock.hardReset(); } diff --git a/packages/fedify/src/testing/kv.ts b/packages/fedify/src/testing/kv.ts new file mode 100644 index 000000000..3ca33f386 --- /dev/null +++ b/packages/fedify/src/testing/kv.ts @@ -0,0 +1,128 @@ +import type { + KvKey, + KvStore, + KvStoreListEntry, + KvStoreSetOptions, +} from "../federation/kv.ts"; +import { MemoryKvStore } from "../federation/kv.ts"; + +/** + * A {@link KvStore} decorator whose entry expiry is driven by a virtual clock + * instead of wall-clock time. + * + * {@link MemoryKvStore} decides expiry by comparing timestamps rather than by + * scheduling timers, so a test that wants to observe a TTL elapsing would + * otherwise have to sleep for longer than the TTL and hope the runner keeps + * up. That makes such tests both slow and load-sensitive. + * + * This decorator keeps the TTLs itself and never passes them to the store it + * wraps, so nothing expires until {@link advance} moves the clock. Cache + * hits, expiry, and repopulation therefore all become deterministic, without + * patching any global and without a test-only entry point in the library + * itself. The clock starts at the Unix epoch and only ever moves forward. + */ +export class ManualClockKvStore implements KvStore { + readonly #inner: KvStore; + readonly #expirations: Map = new Map(); + #now: Temporal.Instant = Temporal.Instant.fromEpochMilliseconds(0); + + constructor(inner: KvStore = new MemoryKvStore()) { + this.#inner = inner; + } + + /** + * The current reading of the virtual clock. + */ + get now(): Temporal.Instant { + return this.#now; + } + + /** + * Moves the virtual clock forward. Any entry whose TTL has elapsed by the + * new time reads as missing from this point on, exactly as it would once a + * real `KvStore` had let it expire. + */ + advance(duration: Temporal.DurationLike): void { + this.#now = this.#now.add( + Temporal.Duration.from(duration).round({ largestUnit: "hour" }), + ); + } + + #encodeKey(key: KvKey): string { + return JSON.stringify(key); + } + + /** + * Drops the entry if its TTL has elapsed. Mirrors {@link MemoryKvStore}, + * which treats an entry as live up to and including its expiration instant. + */ + async #evictIfExpired(key: KvKey): Promise { + const encodedKey = this.#encodeKey(key); + const expiration = this.#expirations.get(encodedKey); + if (expiration == null) return; + if (this.#now.until(expiration).sign >= 0) return; + this.#expirations.delete(encodedKey); + await this.#inner.delete(key); + } + + async #evictAllExpired(): Promise { + for (const [encodedKey, expiration] of [...this.#expirations]) { + if (this.#now.until(expiration).sign >= 0) continue; + this.#expirations.delete(encodedKey); + await this.#inner.delete(JSON.parse(encodedKey) as KvKey); + } + } + + #recordTtl(key: KvKey, options?: KvStoreSetOptions): void { + const encodedKey = this.#encodeKey(key); + if (options?.ttl == null) { + this.#expirations.delete(encodedKey); + return; + } + this.#expirations.set( + encodedKey, + this.#now.add(options.ttl.round({ largestUnit: "hour" })), + ); + } + + async get(key: KvKey): Promise { + await this.#evictIfExpired(key); + return await this.#inner.get(key); + } + + async set( + key: KvKey, + value: unknown, + options?: KvStoreSetOptions, + ): Promise { + this.#recordTtl(key, options); + // The TTL is deliberately withheld from the wrapped store so that only + // the virtual clock can expire the entry. + await this.#inner.set(key, value); + } + + async delete(key: KvKey): Promise { + this.#expirations.delete(this.#encodeKey(key)); + await this.#inner.delete(key); + } + + async cas( + key: KvKey, + expectedValue: unknown, + newValue: unknown, + options?: KvStoreSetOptions, + ): Promise { + if (this.#inner.cas == null) { + throw new TypeError("The wrapped KvStore does not support cas()."); + } + await this.#evictIfExpired(key); + const swapped = await this.#inner.cas(key, expectedValue, newValue); + if (swapped) this.#recordTtl(key, options); + return swapped; + } + + async *list(prefix?: KvKey): AsyncIterable { + await this.#evictAllExpired(); + yield* this.#inner.list(prefix); + } +} diff --git a/packages/fedify/src/testing/mod.ts b/packages/fedify/src/testing/mod.ts index 393b217f1..071770523 100644 --- a/packages/fedify/src/testing/mod.ts +++ b/packages/fedify/src/testing/mod.ts @@ -3,6 +3,7 @@ export { createOutboxContext, createRequestContext, } from "./context.ts"; +export { ManualClockKvStore } from "./kv.ts"; export { baseOptions, type Envelope, From cd756d3e7bf3af75d02e07d231649ba291d8dc6b Mon Sep 17 00:00:00 2001 From: heeeione <68272931+heeoneie@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:23:23 +0900 Subject: [PATCH 9/9] Pin the KvStore TTL boundary in tests `ManualClockKvStore` keeps an entry at `#now === expiration` because `MemoryKvStore` does, in `get()`, `cas()`, and `list()` alike. Nothing asserted that boundary either way, so either store could have moved across it unnoticed: the existing expiry tests all advance well past the TTL and stay green when the comparison is changed. Assert it from both sides for `get()`, `list()`, and `cas()`, and also pin the TTL-clearing behaviour of a `cas()` that carries no TTL, which mirrors `MemoryKvStore` storing a null expiration in that case. https://github.com/fedify-dev/fedify/pull/1027#discussion_r4002672624 Changelog: none Assisted-by: Claude Code:claude-opus-5 --- packages/fedify/src/testing/kv.test.ts | 80 ++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 packages/fedify/src/testing/kv.test.ts diff --git a/packages/fedify/src/testing/kv.test.ts b/packages/fedify/src/testing/kv.test.ts new file mode 100644 index 000000000..b1775d8ab --- /dev/null +++ b/packages/fedify/src/testing/kv.test.ts @@ -0,0 +1,80 @@ +import { test } from "@fedify/fixture"; +import { assertEquals } from "@std/assert/assert-equals"; +import { MemoryKvStore } from "../federation/kv.ts"; +import { ManualClockKvStore } from "./kv.ts"; + +// `MemoryKvStore` drops an entry only when `until(expiration).sign < 0`, in +// `get()`, `cas()`, and `list()` alike, so an entry is still live at the +// instant it expires and is gone one nanosecond later. `ManualClockKvStore` +// exists to stand in for that store under a virtual clock, so it has to sit on +// the same side of the boundary. These tests pin that down from both sides: +// without them, `advance(ttl)` alone never exercises the comparison, and either +// store could drift across the boundary unnoticed. + +test("ManualClockKvStore keeps an entry at its exact expiration instant", async () => { + const kv = new ManualClockKvStore(); + const ttl = Temporal.Duration.from({ minutes: 10 }); + await kv.set(["k"], "v", { ttl }); + + kv.advance(ttl); + assertEquals(kv.now, Temporal.Instant.fromEpochMilliseconds(600_000)); + assertEquals(await kv.get(["k"]), "v"); + + kv.advance({ nanoseconds: 1 }); + assertEquals(await kv.get(["k"]), undefined); +}); + +test("ManualClockKvStore.list() applies the same boundary", async () => { + const kv = new ManualClockKvStore(); + const ttl = Temporal.Duration.from({ minutes: 10 }); + await kv.set(["k"], "v", { ttl }); + + kv.advance(ttl); + assertEquals(await Array.fromAsync(kv.list()), [{ key: ["k"], value: "v" }]); + + kv.advance({ nanoseconds: 1 }); + assertEquals(await Array.fromAsync(kv.list()), []); +}); + +test("ManualClockKvStore.cas() applies the same boundary", async () => { + const ttl = Temporal.Duration.from({ minutes: 10 }); + + const atBoundary = new ManualClockKvStore(); + await atBoundary.set(["k"], "v", { ttl }); + atBoundary.advance(ttl); + // Still live, so the swap sees "v" as the current value. + assertEquals(await atBoundary.cas(["k"], "v", "w"), true); + + const pastBoundary = new ManualClockKvStore(); + await pastBoundary.set(["k"], "v", { ttl }); + pastBoundary.advance(ttl); + pastBoundary.advance({ nanoseconds: 1 }); + // Gone, so the current value reads as `undefined` and a swap expecting the + // old value fails. + assertEquals(await pastBoundary.cas(["k"], "v", "w"), false); + assertEquals(await pastBoundary.get(["k"]), undefined); +}); + +test("ManualClockKvStore.cas() without a TTL clears the expiration", async () => { + // `MemoryKvStore.cas()` stores `null` for the expiration when no TTL is + // given, so a successful swap makes a previously expiring entry permanent. + // The decorator has to drop its recorded expiration for the same reason. + const kv = new ManualClockKvStore(); + const ttl = Temporal.Duration.from({ minutes: 10 }); + await kv.set(["k"], "v", { ttl }); + + assertEquals(await kv.cas(["k"], "v", "w"), true); + kv.advance({ hours: 1 }); + assertEquals(await kv.get(["k"]), "w"); +}); + +test("ManualClockKvStore withholds the TTL from the wrapped store", async () => { + const inner = new MemoryKvStore(); + const kv = new ManualClockKvStore(inner); + await kv.set(["k"], "v", { ttl: Temporal.Duration.from({ nanoseconds: 1 }) }); + + // A TTL this short would already have elapsed on the real clock. It has not + // on the virtual one, and the wrapped store was never told about it. + assertEquals(await inner.get(["k"]), "v"); + assertEquals(await kv.get(["k"]), "v"); +});