diff --git a/AGENTS.md b/AGENTS.md index 57986f30b..6f4432f09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,11 +127,18 @@ Development workflow - **Code Generation**: Run `mise run codegen` whenever vocabulary YAML files or code generation scripts change. - **Building Packages**: After installation, all packages are automatically - built. To rebuild a specific package and its dependencies, run `pnpm build` - in that package's directory. - - **Checking Code**: Run `mise run check` before committing. - - **Running Tests**: Use `mise run test:deno` for Deno tests or - `mise run test` for all environments. + built. To rebuild a specific package and its dependencies, run + `mise run prepare`, or run `mise run prepare-each ` to build + specific packages. + - **Checking Code**: Run `mise run check` before committing, or run + `mise run check-each ` to check specific packages. + - **Running Tests**: + - `mise run test`: Executes all the tests in every runtime. + - `mise run test:`: Executes all the tests by the + runtime. If some specific tests are needed, execute + `mise run test: `. + - `mise run test-each `: Executes tests in packages that include + `pkgs` in every runtime. For detailed contribution guidelines, see *CONTRIBUTING.md*. @@ -151,6 +158,13 @@ When working with federation code: Common tasks ------------ +### **BE WELL-ACQUAINTED WITH `mise tasks`** + +*mise.toml* has useful tasks. **Acquaint all of them** and use them in the right +place at the right time. If it has too much information, use `mise tasks`. This +command shows the summary of the tasks and descriptions. If `mise tasks` does +not make it clear, use `mise tasks ` to check the details for the task. + ### Adding ActivityPub vocabulary types 1. Create a new YAML file in *packages/vocab/vocab/* following existing diff --git a/CHANGES.md b/CHANGES.md index 3a2f6f842..94170b388 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -259,6 +259,47 @@ To be released. the dispatcher overload hot path; other contributors to `check-all` cost may remain. [[#613], [#800] by ChanHaeng Lee] + - Added a custom background task API that generalizes Fedify's + enqueue-and-process-later pattern to arbitrary application-defined jobs: + + - `Federation` and `FederationBuilder` gained a `defineTask()` method + through the new `TaskRegistry` interface, which `Federatable` now + extends. + - `Context` gained `enqueueTask()` and `enqueueTaskMany()` methods, + with `delay` and `orderingKey` options + (new `TaskEnqueueOptions` interface). + - Every task requires a [Standard Schema] + (`schema` option) from which the payload type is inferred; payloads + are validated at enqueue time (fail fast) and again at dequeue time + (protection against schema drift across deployments). + - Payloads are serialized by Fedify with devalue, so `Date`, `Map`, + `Set`, `URL`, `bigint`, circular references, and Activity Vocabulary + objects round-trip faithfully across every message queue backend. + - Failed handlers are retried with exponential backoff by default; + tasks support per-task `retryPolicy` and `onError` options, the new + `FederationOptions.taskRetryPolicy` sets the federation-wide default, + and queues with `nativeRetrial` delegate retries to the backend. + - Tasks can be isolated from activity delivery through the new + `FederationQueueOptions.task` slot or a per-task `queue` option; + without them, tasks fall back to the outbox queue unless the new + `FederationOptions.taskQueueResolution` option is set to `"strict"`. + `Federation.startQueue()` now accepts `queue: "task"` to run + a task-only worker. + - Tasks can request at-most-once enqueue with a `deduplicationKey` + (new `TaskEnqueueOptions.deduplicationKey`). A queue declaring the new + `MessageQueue.nativeDeduplication` capability owns the check and + receives the key through the new + `MessageQueueEnqueueOptions.deduplicationKey`; otherwise Fedify + performs a best-effort key–value guard through the optional + `KvStore.cas` primitive, under a new `taskDeduplication` key prefix. + The marker TTL and the no-`cas` fallback are tunable with the new + `FederationOptions.taskDeduplicationTtl` and + `FederationOptions.taskDeduplicationFallback` options. + + [[#206], [#797], [#798], [#803] by ChanHaeng Lee] + +[Standard Schema]: https://standardschema.dev/ +[#206]: https://github.com/fedify-dev/fedify/issues/206 [#316]: https://github.com/fedify-dev/fedify/issues/316 [#418]: https://github.com/fedify-dev/fedify/issues/418 [#613]: https://github.com/fedify-dev/fedify/issues/613 @@ -288,7 +329,10 @@ To be released. [#778]: https://github.com/fedify-dev/fedify/pull/778 [#782]: https://github.com/fedify-dev/fedify/issues/782 [#787]: https://github.com/fedify-dev/fedify/pull/787 +[#797]: https://github.com/fedify-dev/fedify/issues/797 +[#798]: https://github.com/fedify-dev/fedify/issues/798 [#800]: https://github.com/fedify-dev/fedify/pull/800 +[#803]: https://github.com/fedify-dev/fedify/pull/803 ### @fedify/cli diff --git a/deno.lock b/deno.lock index 884f61e52..99bbf6f5b 100644 --- a/deno.lock +++ b/deno.lock @@ -123,6 +123,7 @@ "npm:cli-highlight@^2.1.11": "2.1.11", "npm:cli-table3@~0.6.5": "0.6.5", "npm:dax@~0.46.1": "0.46.1", + "npm:devalue@^5.8.1": "5.8.1", "npm:enquirer@^2.4.1": "2.4.1", "npm:es-toolkit@^1.46.1": "1.46.1", "npm:esbuild-wasm@~0.25.11": "0.25.12", @@ -4637,8 +4638,8 @@ "base-64" ] }, - "devalue@5.7.1": { - "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==" + "devalue@5.8.1": { + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==" }, "devlop@1.1.0": { "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", @@ -8267,7 +8268,6 @@ "aria-query@5.3.1", "axobject-query", "clsx", - "devalue", "esm-env", "esrap", "is-reference@3.0.3", @@ -9466,6 +9466,7 @@ "jsr:@std/assert@0.226", "jsr:@std/url@~0.225.1", "npm:@multiformats/base-x@^4.0.1", + "npm:devalue@^5.8.1", "npm:fast-check@^3.22.0", "npm:fetch-mock@^12.5.2", "npm:json-canon@^1.0.1", @@ -9478,6 +9479,7 @@ "npm:@js-temporal/polyfill@~0.5.1", "npm:@jsr/std__assert@0.226", "npm:@types/node@^24.2.1", + "npm:devalue@^5.8.1", "npm:json-canon@^1.0.1", "npm:jsonld@9", "npm:miniflare@^4.20250523.0", diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index a71503133..caf1c79d4 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -145,6 +145,7 @@ const MANUAL = { { text: "Pragmatics", link: "/manual/pragmatics.md" }, { text: "Key–value store", link: "/manual/kv.md" }, { text: "Message queue", link: "/manual/mq.md" }, + { text: "Background tasks", link: "/manual/tasks.md" }, { text: "Circuit breaker", link: "/manual/circuit-breaker.md" }, { text: "Integration", link: "/manual/integration.md" }, { text: "Migration", link: "/manual/migrate.md" }, diff --git a/docs/manual/tasks.md b/docs/manual/tasks.md new file mode 100644 index 000000000..9e3863f22 --- /dev/null +++ b/docs/manual/tasks.md @@ -0,0 +1,356 @@ +Background tasks +================ + +*This API is available since Fedify 2.x.x.* + +Fedify already processes outgoing and incoming activities on background +workers through its [message queue](./mq.md). The custom background task API +generalizes the same pattern—enqueue work, return immediately, process the +payload on a separate worker—to arbitrary application-defined jobs: sending +digest e-mails, rebuilding timelines, fetching link previews, and so on. + +A task is *defined* once on the `Federation` (or `FederationBuilder`) object +with `~TaskRegistry.defineTask()`, and *dispatched* from any `Context` with +`~Context.enqueueTask()`. The payload is validated, serialized by Fedify, +delivered through a message queue, and handed—decoded and re-validated—to the +task's handler on a worker. + + +Defining a task +--------------- + +`~TaskRegistry.defineTask()` registers a named task and returns a handle that +`~Context.enqueueTask()` consumes. Every task requires a `schema`, a +[Standard Schema] (implemented by [Zod], [Valibot], [ArkType], and friends) +that validates the payload; the payload type is inferred from it, so handlers +and call sites are fully typed without manual type annotations: + +~~~~ typescript +import { z } from "zod"; + +const sendDigest = federation.defineTask("sendDigest", { + schema: z.object({ + userId: z.string(), + since: z.date(), + }), + handler: async (ctx, data) => { + // data is typed as { userId: string; since: Date } + const digest = await buildDigest(data.userId, data.since); + await sendEmail(data.userId, digest); + }, +}); +~~~~ + +Task names must be unique within a federation; defining the same name twice +throws a `TypeError`. + +[Standard Schema]: https://standardschema.dev/ +[Zod]: https://zod.dev/ +[Valibot]: https://valibot.dev/ +[ArkType]: https://arktype.io/ + + +Payload handling +---------------- + +Task payloads cross a message queue, so they are serialized on enqueue and +deserialized on dispatch. Fedify owns this codec—applications never encode +payloads themselves. The codec is built on [devalue], which means payloads +are not limited to JSON: `Date`, `Map`, `Set`, `URL`, `RegExp`, `bigint`, +typed arrays, `Temporal` values (e.g., `Temporal.Instant`, +`Temporal.Duration`), circular references, and repeated references all +round-trip faithfully. + +Activity Vocabulary objects (`Note`, `Create`, `Person`, `Link`, and so on) +are also supported as payload values. Each vocabulary object is bridged +through expanded JSON-LD on the wire and comes back as a real instance, so +the handler can call its methods and getters as usual: + +~~~~ typescript +import { Note } from "@fedify/vocab"; +import { z } from "zod"; + +const indexNote = federation.defineTask("indexNote", { + schema: z.object({ + note: z.instanceof(Note), // an opaque instanceof leaf + indexedAt: z.date(), + }), + handler: async (ctx, data) => { + await searchIndex.add(data.note.id?.href, data.note.content?.toString()); + }, +}); +~~~~ + +The schema validates the *envelope* of the payload; vocabulary objects are +opaque `instanceof` leaves (e.g., `z.instanceof(Note)`), so no enormous +schema is needed. + +Validation runs twice: once at enqueue time, so a caller passing a wrong +shape fails fast at the call site, and once at dequeue time, which protects +against *schema drift*—a durable queue can hand a new deployment a payload +that an older deployment enqueued. A payload that fails dequeue-time +validation (or cannot be decoded at all) is dropped with an error log rather +than retried, because retrying cannot make it valid. + +Because the same schema validates on both sides of the queue, its validation +must be *idempotent*: the validated output must itself be a valid input. +Transforming schemas (e.g., Zod's `.transform()`) whose output differs in +shape from their input are not supported—the payload type is inferred as the +schema's *output*, so the call site already fails validation at enqueue time. + +[devalue]: https://github.com/sveltejs/devalue + + +Dispatching tasks +----------------- + +`~Context.enqueueTask()` validates the payload, serializes it, and enqueues +it. It returns as soon as the message is accepted by the queue: + +~~~~ typescript +await ctx.enqueueTask(sendDigest, { + userId: "alice", + since: new Date("2026-06-01T00:00:00Z"), +}); +~~~~ + +Passing a payload that does not match the task's schema is a compile-time +type error, and—for shapes the type system cannot catch—a runtime +`TypeError` at the call site. + +`~Context.enqueueTaskMany()` enqueues multiple payloads at once, using the +queue's bulk `~MessageQueue.enqueueMany()` operation when the backend +supports it and falling back to parallel single enqueues otherwise: + +~~~~ typescript +await ctx.enqueueTaskMany(sendDigest, users.map((u) => ({ + userId: u.id, + since: u.lastDigestAt, +}))); +~~~~ + +Both methods accept options: + +`delay` +: A `Temporal.DurationLike` to postpone processing, e.g., + `{ minutes: 30 }`. + +`orderingKey` +: Tasks with the same ordering key are processed sequentially (one at + a time), like the same option on the message queue layer. + +`deduplicationKey` +: Requests at-most-once enqueue for tasks that share the key; see + [Deduplication](#deduplication) below. + +~~~~ typescript +await ctx.enqueueTask(sendDigest, payload, { + delay: { minutes: 30 }, + orderingKey: `digest:${payload.userId}`, +}); +~~~~ + + +Retry and error handling +------------------------ + +When a handler throws, Fedify consults the retry policy and re-enqueues the +message with an incremented attempt counter. The policy is resolved in this +order: + +1. The task's own `retryPolicy` passed to `~TaskRegistry.defineTask()`. +2. The federation-wide `~FederationOptions.taskRetryPolicy`. +3. The default: exponential backoff with a maximum of 10 attempts. + +When the queue backend reports `~MessageQueue.nativeRetrial`, Fedify rethrows +the error instead and lets the backend drive retries. + +A task can also register an `onError` callback, which is invoked with the +error and the decoded payload before a retry is scheduled—useful for +reporting or compensating actions: + +~~~~ typescript +const sendDigest = federation.defineTask("sendDigest", { + schema: digestSchema, + handler: async (ctx, data) => { + await sendEmail(data.userId, await buildDigest(data.userId, data.since)); + }, + retryPolicy: createExponentialBackoffPolicy({ maxAttempts: 3 }), + onError: async (ctx, error, data) => { + await reportFailure("sendDigest", data.userId, error); + }, +}); +~~~~ + +Two failure cases are *dropped without retry*, because retrying cannot help: +a message whose `taskName` has no registered handler (logged as a warning), +and a payload that cannot be decoded or fails dequeue-time validation +(logged as an error). + + +Queue routing and isolation +--------------------------- + +By default tasks share the outbox queue, so no extra configuration is needed +beyond a `queue` on `createFederation()`. For heavier workloads, tasks can +be isolated at two levels. + +A dedicated task queue, separate from activity delivery, is configured with +the `~FederationQueueOptions.task` slot: + +~~~~ typescript +const federation = createFederation({ + // ... + queue: { + inbox: new PostgresMessageQueue(sql, { channel: "inbox" }), + outbox: new PostgresMessageQueue(sql, { channel: "outbox" }), + task: new PostgresMessageQueue(sql, { channel: "task" }), // [!code highlight] + }, +}); +~~~~ + +A single task can also carry its own queue, which takes precedence over +everything else: + +~~~~ typescript +const transcodeVideo = federation.defineTask("transcodeVideo", { + schema: transcodeSchema, + handler: transcodeHandler, + queue: new PostgresMessageQueue(sql, { channel: "transcode" }), +}); +~~~~ + +Workers for dedicated per-task queues are registered when the queue +machinery starts, so define every task before `~Federation.startQueue()` +is called (or, without `~FederationOptions.manuallyStartQueue`, before the +first request is handled); a per-task queue defined later may not get +a worker until the queue machinery is next started. + +The queue for a task is resolved in order: the per-task `queue`, then the +federation's `task` queue, then the outbox queue. Deployments that must +*not* silently share the outbox queue can opt out of the last step with +`~FederationOptions.taskQueueResolution`: + +~~~~ typescript +const federation = createFederation({ + // ... + taskQueueResolution: "strict", // no outbox fallback // [!code highlight] +}); +~~~~ + +Under `"strict"` resolution, enqueuing a task that has no queue throws +a `TypeError` instead of falling back. + +On the worker side, `~Federation.startQueue()` accepts `"task"` in its +`queue` option, so a dedicated worker process can consume only tasks: + +~~~~ typescript +await federation.startQueue(contextData, { queue: "task" }); +~~~~ + +A task that falls back to the outbox queue needs no dedicated worker; the +outbox worker dispatches every message by its type regardless of which queue +delivered it. + +> [!CAUTION] +> Task payloads cross durable queue storage, so treat the queue backend and +> its payloads as internal, trusted storage. Do not place long-lived secrets +> or credentials directly in a task payload; pass an identifier that the +> worker resolves from your application storage instead. When task workloads +> must stay isolated from ActivityPub delivery, give them a dedicated task +> queue and set `taskQueueResolution: "strict"`. + + +Deduplication +------------- + +A task often needs *at-most-once-per-key* enqueue: a digest mailer must not +send twice when a request is retried, and a cleanup job should coalesce +duplicate triggers. Passing a `deduplicationKey` requests this—a second +enqueue with the same key is dropped while the first is still within the +deduplication window: + +~~~~ typescript +await ctx.enqueueTask(sendDigest, payload, { + deduplicationKey: `digest:${payload.userId}`, // [!code highlight] +}); +~~~~ + +How the key is resolved depends on the queue and the key–value store: + +1. **Native backend.** When the task's queue declares + `~MessageQueue.nativeDeduplication`, Fedify forwards the key in the + message queue's `~MessageQueueEnqueueOptions.deduplicationKey` and the + backend owns the check. Fedify does not touch the key–value store. + +2. **Key–value fallback.** Otherwise, if the configured `~KvStore` exposes + the optional compare-and-swap (`~KvStore.cas`) primitive, Fedify records + the key under a dedicated `taskDeduplication` prefix with a TTL and skips + the enqueue while a marker is present. The TTL defaults to one hour and is + configurable with `~FederationOptions.taskDeduplicationTtl`: + + ~~~~ typescript + const federation = createFederation({ + // ... + taskDeduplicationTtl: { minutes: 10 }, // [!code highlight] + }); + ~~~~ + +3. **No conditional write.** When neither applies—no native deduplication and + a key–value store without `~KvStore.cas`—the behavior is governed by + `~FederationOptions.taskDeduplicationFallback`. `"open"` (the default) + lets the enqueue proceed without deduplication after a debug-level log; + `"closed"` throws a `TypeError` before enqueuing: + + ~~~~ typescript + const federation = createFederation({ + // ... + taskDeduplicationFallback: "closed", // [!code highlight] + }); + ~~~~ + +Among the first-party adapters, the in-memory, Deno KV, SQLite, and MySQL +key–value stores implement `~KvStore.cas`; PostgreSQL and Redis do not yet, so +those deployments take the `taskDeduplicationFallback` branch until per-adapter +follow-ups add it. + +For `~Context.enqueueTaskMany()`, a single `deduplicationKey` applies to the +whole batch: the batch enqueues as a unit or is skipped as a unit, never +partially. Per-item deduplication means calling `~Context.enqueueTask()` in +a loop, each with its own key. Deduplicating a multi-item batch requires the +queue to implement `~MessageQueue.enqueueMany()` so the batch enqueues +atomically—whether the check is native or the key–value fallback. Fanning the +key out across separate `~MessageQueue.enqueue()` calls cannot enqueue a whole +batch as one unit: a native per-message key cannot cover it, and a key–value +marker could not be rolled back cleanly if only some of the fanned-out enqueues +failed. So when deduplication is actually applied—a native queue, or a +key–value store with `~KvStore.cas`—Fedify rejects a multi-item batch with a +`deduplicationKey` on a queue without `~MessageQueue.enqueueMany()` instead of +risking duplicates. Under the `"open"` fallback (no native deduplication and no +`cas`), no marker is taken, so the batch simply fans out without deduplication. + +This applies through `~ParallelMessageQueue` as well: wrapping a queue that +lacks `~MessageQueue.enqueueMany()` does not make batch enqueue atomic, so a +deduplicated multi-item batch on such a wrapper is likewise rejected rather than +collapsed onto one message. + +> [!WARNING] +> The key–value fallback is **best-effort, not transactional**. The marker +> write and the enqueue are separate operations. Fedify rolls the marker back +> when an enqueue fails, so a transient failure does not suppress the retry, but +> a crash before that rollback, the `"open"` fallback under concurrency, a +> non-atomic third-party `~KvStore.cas`, or reuse of a key within its TTL window +> can still admit a duplicate or suppress a task. Cleanup is otherwise by TTL +> expiry, not active deletion on handler success. Deployments needing strict +> guarantees use a queue with `nativeDeduplication: true`, where the backend +> owns an atomic check. + + +Limitations +----------- + +The current API intentionally ships without task-specific OpenTelemetry spans +and metrics, cron-style periodic scheduling, result backends, and per-task +priority. Some of these are planned as follow-ups; see the [tracking issue]. + +[tracking issue]: https://github.com/fedify-dev/fedify/issues/206 diff --git a/mise.toml b/mise.toml index cebfcc4dc..cf2b06840 100644 --- a/mise.toml +++ b/mise.toml @@ -14,7 +14,18 @@ linux-arm64 = "hongdown-*-aarch64-unknown-linux-musl.tar.bz2" # Installation and setup [tasks.install] description = "Install all dependencies and set up the development environment" -run = "deno task install" +depends = ["install:deno", "install:pnpm"] + +[tasks."install:deno"] +description = "Install all dependencies and set up for Deno" +depends = ["codegen"] +run = "deno run --allow-read --allow-env --allow-run scripts/install.ts" + +[tasks."install:pnpm"] +description = "Install all dependencies and set up for pnpm" +depends = ["codegen"] +run = "pnpm install" + [tasks.codegen] description = "Generate ActivityPub vocabulary types from YAML definitions" @@ -147,7 +158,7 @@ done # Testing [tasks."test:deno"] description = "Run the test suite using Deno" -depends = ["prepare"] +depends = ["install:deno"] # `prepare` is for building and Deno doesn't need it. run = "deno test --check --doc --allow-all --unstable-kv --trace-leaks --parallel" [tasks."test:node"] diff --git a/packages/fedify/deno.json b/packages/fedify/deno.json index f4c071af8..c15bff077 100644 --- a/packages/fedify/deno.json +++ b/packages/fedify/deno.json @@ -17,6 +17,7 @@ "@multiformats/base-x": "npm:@multiformats/base-x@^4.0.1", "@std/assert": "jsr:@std/assert@^0.226.0", "@std/url": "jsr:@std/url@^0.225.1", + "devalue": "npm:devalue@^5.8.1", "fast-check": "npm:fast-check@^3.22.0", "fetch-mock": "npm:fetch-mock@^12.5.2", "json-canon": "npm:json-canon@^1.0.1", diff --git a/packages/fedify/package.json b/packages/fedify/package.json index 216e72cd2..946159bd1 100644 --- a/packages/fedify/package.json +++ b/packages/fedify/package.json @@ -150,7 +150,9 @@ "@opentelemetry/sdk-metrics": "catalog:", "@opentelemetry/sdk-trace-base": "catalog:", "@opentelemetry/semantic-conventions": "catalog:", + "@standard-schema/spec": "catalog:", "byte-encodings": "catalog:", + "devalue": "^5.8.1", "es-toolkit": "catalog:", "json-canon": "^1.0.1", "jsonld": "^9.0.0", diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index 1503ccf7b..e1c35b6fc 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -67,6 +67,13 @@ import type { CollectionCallbacks, CustomCollectionCallbacks, } from "./handler.ts"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import type { + TaskDefinition, + TaskDefinitionInternal, + TaskDefinitionOptions, + TaskHandler, +} from "./tasks/mod.ts"; export const ACTOR_ALIAS_PREFIX = "actorAlias:"; @@ -181,6 +188,7 @@ export class FederationBuilderImpl TContextData > >; + taskDefinitions: Map>; /** * Symbol registry for unique identification of unnamed symbols. @@ -193,6 +201,7 @@ export class FederationBuilderImpl this.objectTypeIds = {}; this.collectionCallbacks = {}; this.collectionTypeIds = {}; + this.taskDefinitions = new Map(); } /** @@ -258,6 +267,7 @@ export class FederationBuilderImpl f.unverifiedActivityHandler = this.unverifiedActivityHandler; f.outboxPermanentFailureHandler = this.outboxPermanentFailureHandler; f.idempotencyStrategy = this.idempotencyStrategy; + f.taskDefinitions = new Map(this.taskDefinitions); return f; } @@ -593,6 +603,30 @@ export class FederationBuilderImpl this.webFingerLinksDispatcher = dispatcher; } + defineTask( + name: string, + options: TaskDefinitionOptions, + ): TaskDefinition> { + if (this.taskDefinitions.has(name)) { + throw new TypeError(`Task ${JSON.stringify(name)} is already defined.`); + } + const handle: TaskDefinition< + TContextData, + StandardSchemaV1.InferOutput + > = { name, schema: options.schema }; + this.taskDefinitions.set(name, { + name, + schema: options.schema, + handle, + handler: options.handler as TaskHandler, + onError: options + .onError as TaskDefinitionInternal["onError"], + retryPolicy: options.retryPolicy, + queue: options.queue, + }); + return handle; + } + /** * The RFC 6570 template-literal `path` overloads were removed for * type-checking efficiency, so the URI variable types can no longer be diff --git a/packages/fedify/src/federation/context.ts b/packages/fedify/src/federation/context.ts index 3a95bfdde..fda194530 100644 --- a/packages/fedify/src/federation/context.ts +++ b/packages/fedify/src/federation/context.ts @@ -22,6 +22,7 @@ import type { JsonValue, NodeInfo } from "../nodeinfo/types.ts"; import type { GetKeyOwnerOptions } from "../sig/owner.ts"; import type { ConstructorWithTypeId, Federation } from "./federation.ts"; import type { SenderKeyPair } from "./send.ts"; +import type { TaskDefinition, TaskEnqueueOptions } from "./tasks/mod.ts"; /** * A context. @@ -429,6 +430,54 @@ export interface Context { options?: RouteActivityOptions, ): Promise; + /** + * Enqueues a custom background task. The payload is validated against + * the task's schema, serialized, and processed by the task's handler on + * a background worker. + * + * @example + * ``` typescript + * await ctx.enqueueTask(sendDigest, { userId: "alice" }); + * ``` + * + * @template TData The type of the task payload, inferred from the task's + * schema. + * @param task The handle returned by {@link TaskRegistry.defineTask}. + * @param data The task payload. It is validated against the task's + * schema before being enqueued. + * @param options Options for enqueuing the task. + * @throws {TypeError} If the task is not defined on this federation, + * if no message queue is configured for tasks, or if + * the payload fails schema validation. + * @since 2.x.x + */ + enqueueTask( + task: TaskDefinition, + data: TData, + options?: TaskEnqueueOptions, + ): Promise; + + /** + * Enqueues multiple payloads for a custom background task at once. + * Uses the queue's bulk enqueue operation when available, falling back + * to parallel single enqueues. + * @template TData The type of the task payload, inferred from the task's + * schema. + * @param task The handle returned by {@link TaskRegistry.defineTask}. + * @param payloads The task payloads. Each is validated against the + * task's schema before being enqueued. + * @param options Options for enqueuing the tasks. + * @throws {TypeError} If the task is not defined on this federation, + * if no message queue is configured for tasks, or if + * a payload fails schema validation. + * @since 2.x.x + */ + enqueueTaskMany( + task: TaskDefinition, + payloads: readonly TData[], + options?: TaskEnqueueOptions, + ): Promise; + /** * Builds the URI of a collection of objects with the given name and values. * @param name The name of the collection, which can be a string or a symbol. diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index e1d5e9d40..5b4177f80 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -49,6 +49,7 @@ import type { import type { MessageQueue } from "./mq.ts"; import type { Message } from "./queue.ts"; import type { RetryPolicy } from "./retry.ts"; +import type { TaskRegistry } from "./tasks/mod.ts"; /** * Options for {@link Federation.startQueue} method. @@ -62,11 +63,11 @@ export interface FederationStartQueueOptions { /** * Starts the task worker only for the specified queue. If unspecified, - * which is the default, the task worker starts for all three queues: - * inbox, outbox, and fanout. + * which is the default, the task worker starts for all four queues: + * inbox, outbox, fanout, and task. * @since 1.3.0 */ - queue?: "inbox" | "outbox" | "fanout"; + queue?: "inbox" | "outbox" | "fanout" | "task"; } /** @@ -74,7 +75,7 @@ export interface FederationStartQueueOptions { * @template TContextData The context data to pass to the {@link Context}. * @since 1.6.0 */ -export interface Federatable { +export interface Federatable extends TaskRegistry { /** * Registers a NodeInfo dispatcher. * @param path The URI path pattern for the NodeInfo dispatcher. The syntax @@ -1080,6 +1081,54 @@ export interface FederationOptions { */ inboxRetryPolicy?: RetryPolicy; + /** + * The retry policy for processing custom background tasks. By default, + * this uses an exponential backoff strategy with a maximum of 10 attempts + * and a maximum delay of 12 hours. A per-task retry policy + * ({@link TaskDefinitionOptions.retryPolicy}) overrides this. + * @since 2.x.x + */ + taskRetryPolicy?: RetryPolicy; + + /** + * How a queue is resolved for a custom background task when neither + * a per-task queue ({@link TaskDefinitionOptions.queue}) nor a dedicated + * task queue ({@link FederationQueueOptions.task}) is configured. + * + * - `"fallback"` (the default): the task is routed to the outbox queue. + * - `"strict"`: no fallback; enqueuing the task throws instead of + * silently sharing the outbox queue. + * @default `"fallback"` + * @since 2.x.x + */ + taskQueueResolution?: "fallback" | "strict"; + + /** + * The time-to-live for a {@link TaskEnqueueOptions.deduplicationKey} marker + * stored in the key–value deduplication fallback. A second enqueue with the + * same key within this window is skipped; once it expires, the key may + * enqueue again. Ignored when the task's queue declares + * {@link MessageQueue.nativeDeduplication} (the backend owns the window). + * @default `{ hours: 1 }` + * @since 2.x.x + */ + taskDeduplicationTtl?: Temporal.DurationLike; + + /** + * The behavior when a {@link TaskEnqueueOptions.deduplicationKey} is supplied + * but the task's queue does not declare + * {@link MessageQueue.nativeDeduplication} *and* the configured + * {@link KvStore} exposes no `cas` (compare-and-swap) primitive: + * + * - `"open"` (the default): proceeds without deduplication after logging at + * debug level. + * - `"closed"`: rejects with a `TypeError` before enqueuing. + * + * @default `"open"` + * @since 2.x.x + */ + taskDeduplicationFallback?: "open" | "closed"; + /** * Activity transformers that are applied to outgoing activities. It is * useful for adjusting outgoing activities to satisfy some ActivityPub diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index ad073b5df..40b67f189 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -5,9 +5,23 @@ import { test, } from "@fedify/fixture"; import { RouterError } from "@fedify/uri-template"; -import { configure, type LogRecord, reset } from "@logtape/logtape"; import * as vocab from "@fedify/vocab"; -import { Create, getTypeId, lookupObject, Offer, Person } from "@fedify/vocab"; +import { + Create, + getTypeId, + lookupObject, + Note, + Offer, + Person, +} from "@fedify/vocab"; +import { FetchError, getDocumentLoader } from "@fedify/vocab-runtime"; +import { configure, type LogRecord, reset } from "@logtape/logtape"; +import { metrics, SpanStatusCode } from "@opentelemetry/api"; +import { + DataPointType, + MeterProvider, + MetricReader, +} from "@opentelemetry/sdk-metrics"; import { assert, assertEquals, @@ -21,6 +35,7 @@ import { } from "@std/assert"; import fetchMock from "fetch-mock"; import serialize from "json-canon"; +import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; import createFixture from "../../../fixture/src/fixtures/example.com/create.json" with { type: "json", }; @@ -49,20 +64,12 @@ import { rsaPublicKey2, rsaPublicKey3, } from "../testing/keys.ts"; -import { FetchError, getDocumentLoader } from "@fedify/vocab-runtime"; -import { metrics, SpanStatusCode } from "@opentelemetry/api"; -import { - DataPointType, - MeterProvider, - MetricReader, -} from "@opentelemetry/sdk-metrics"; import { getAuthenticatedDocumentLoader } from "../utils/docloader.ts"; -import { CircuitBreaker } from "./circuit-breaker.ts"; import { handleBenchmarkTrigger } from "./bench.ts"; - -const documentLoader = getDocumentLoader(); +import { CircuitBreaker } from "./circuit-breaker.ts"; import type { Context, GetActorOptions } from "./context.ts"; import { MemoryKvStore } from "./kv.ts"; +import { recordInboxActivity } from "./metrics.ts"; import { ContextImpl, createFederation, @@ -70,9 +77,26 @@ import { InboxContextImpl, KvSpecDeterminer, } from "./middleware.ts"; -import { recordInboxActivity } from "./metrics.ts"; -import type { MessageQueue } from "./mq.ts"; -import type { InboxMessage, Message, OutboxMessage } from "./queue.ts"; +import type { + MessageQueue, + MessageQueueEnqueueOptions, + MessageQueueListenOptions, +} from "./mq.ts"; +import type { + InboxMessage, + Message, + OutboxMessage, + TaskMessage, +} from "./queue.ts"; +import TaskCodec from "./tasks/codec.ts"; +import { + type Envelope, + envelopeSchema, + MockQueue, + numberSchema, +} from "../testing/mod.ts"; + +const documentLoader = getDocumentLoader(); type IsEqual = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; @@ -10480,3 +10504,235 @@ test("createFederation() omits instrumentation when no meterProvider is set", () assertStrictEquals(ctx.documentLoader, mockDocumentLoader); assertStrictEquals(ctx.contextLoader, mockDocumentLoader); }); + +const taskCodec = new TaskCodec({ contextLoader: mockDocumentLoader }); +const decodeEnvelope = (message: TaskMessage): Promise => + taskCodec.decode(envelopeSchema, message.data); +const envelope = (title: string): Envelope => ({ + note: new Note({ content: title }), + title, +}); + +class RendezvousQueue implements MessageQueue { + readonly enqueued: { + message: TaskMessage; + options?: MessageQueueEnqueueOptions; + }[] = []; + #count = 0; + #markDispatched!: () => void; + #openGate!: () => void; + readonly dispatched: Promise; + readonly #gate: Promise; + + constructor(readonly expected: number) { + this.dispatched = new Promise((resolve) => { + this.#markDispatched = resolve; + }); + this.#gate = new Promise((resolve) => { + this.#openGate = resolve; + }); + } + + release(): void { + this.#openGate(); + } + + // deno-lint-ignore no-explicit-any + enqueue(message: any, options?: MessageQueueEnqueueOptions): Promise { + this.enqueued.push({ message, options }); + if (++this.#count >= this.expected) this.#markDispatched(); + return this.#gate; + } + + listen( + // deno-lint-ignore no-explicit-any + _handler: (message: any) => Promise | void, + _options?: MessageQueueListenOptions, + ): Promise { + return new Promise(() => {}); + } +} + +const withTimeout = ( + promise: Promise, + ms: number, + message: string, +): Promise => { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +}; + +const taskFederationOptions = { + kv: new MemoryKvStore(), + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + manuallyStartQueue: true, +}; + +test("ContextImpl.enqueueTask()", async (t) => { + await t.step( + "builds the task message envelope and round-trips a vocab payload", + async () => { + const queue = new MockQueue({ supportsEnqueueMany: true }); + const federation = createFederation({ + ...taskFederationOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("greet", { + schema: envelopeSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + ok(ctx instanceof ContextImpl); + await ctx.enqueueTask(task, envelope("greeting")); + strictEqual(queue.enqueuedMany.length, 0); + strictEqual(queue.enqueued.length, 1); + const { message } = queue.enqueued[0]; + strictEqual(message.type, "task"); + strictEqual(message.taskName, "greet"); + strictEqual(message.baseUrl, "https://example.com"); + strictEqual(message.attempt, 0); + ok(/^[0-9a-f-]{36}$/i.test(message.id)); + // `started` must be a parseable instant; Temporal.Instant.from throws + // otherwise. + ok(Temporal.Instant.from(message.started) instanceof Temporal.Instant); + strictEqual(typeof message.traceContext, "object"); + ok(message.traceContext != null); + // A vocab object must survive the producer-side encode as JSON-LD. + const decoded = await decodeEnvelope(message); + ok(decoded.note instanceof Note); + strictEqual(decoded.note.content?.toString(), "greeting"); + strictEqual(decoded.title, "greeting"); + }, + ); +}); + +test("ContextImpl.enqueueTaskMany()", async (t) => { + await t.step( + "round-trips every payload through enqueueMany in order, forwarding options", + async () => { + const queue = new MockQueue({ supportsEnqueueMany: true }); + const federation = createFederation({ + ...taskFederationOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("bulk", { + schema: envelopeSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + const payloads = [envelope("a"), envelope("b"), envelope("c")]; + await ctx.enqueueTaskMany(task, payloads, { + delay: { seconds: 30 }, + orderingKey: "batch", + }); + strictEqual(queue.enqueued.length, 0); + strictEqual(queue.enqueuedMany.length, 1); + const { messages, options } = queue.enqueuedMany[0]; + ok(options?.delay instanceof Temporal.Duration); + strictEqual(options.delay.total("second"), 30); + strictEqual(options.orderingKey, "batch"); + const decoded = await Promise.all(messages.map(decodeEnvelope)); + deepStrictEqual(decoded.map((d) => d.title), ["a", "b", "c"]); + for (const message of messages) strictEqual(message.orderingKey, "batch"); + }, + ); + + await t.step( + "with a single payload uses enqueue() instead of enqueueMany", + async () => { + const queue = new MockQueue({ supportsEnqueueMany: true }); + const federation = createFederation({ + ...taskFederationOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("bulk-single", { + schema: envelopeSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTaskMany(task, [envelope("solo")]); + strictEqual(queue.enqueuedMany.length, 0); + strictEqual(queue.enqueued.length, 1); + }, + ); + + await t.step( + "falls back to concurrent single enqueues, preserving order and options", + async () => { + const queue = new RendezvousQueue(2); + const federation = createFederation({ + ...taskFederationOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("bulk-fallback", { + schema: envelopeSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + const payloads = [envelope("x"), envelope("y")]; + const pending = ctx.enqueueTaskMany(task, payloads, { + orderingKey: "batch", + }); + try { + await withTimeout( + queue.dispatched, + 2000, + "fallback did not dispatch enqueues concurrently", + ); + strictEqual(queue.enqueued.length, 2); + } finally { + queue.release(); + await pending; + } + const decoded = await Promise.all( + queue.enqueued.map(({ message }) => decodeEnvelope(message)), + ); + deepStrictEqual(decoded.map((d) => d.title), ["x", "y"]); + for (const { message, options } of queue.enqueued) { + strictEqual(message.orderingKey, "batch"); + strictEqual(options?.orderingKey, "batch"); + } + }, + ); + + await t.step( + "fallback path aborts the whole batch when one payload is invalid", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...taskFederationOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("bulk-typed", { + schema: numberSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await rejects( + // deno-lint-ignore no-explicit-any + () => ctx.enqueueTaskMany(task, [1, "two", 3] as any), + { name: "TypeError", message: /Task data failed schema validation/ }, + ); + strictEqual(queue.enqueued.length, 0); + }, + ); +}); diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index d38a17a45..9a0e0780e 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -57,13 +57,6 @@ import type { ActivityTransformer } from "../compat/types.ts"; import { getNodeInfo, type GetNodeInfoOptions } from "../nodeinfo/client.ts"; import { handleNodeInfo, handleNodeInfoJrd } from "../nodeinfo/handler.ts"; import type { JsonValue, NodeInfo } from "../nodeinfo/types.ts"; -import { - type BenchmarkMetricReader, - type BenchmarkTriggerOptions, - createBenchmarkMeterProvider, - handleBenchmarkStats, - handleBenchmarkTrigger, -} from "./bench.ts"; import { type HttpMessageSignaturesSpec, type HttpMessageSignaturesSpecDeterminer, @@ -87,6 +80,13 @@ import { getKeyOwner, type GetKeyOwnerOptions } from "../sig/owner.ts"; import { hasProofLike, signObject, verifyObject } from "../sig/proof.ts"; import { getAuthenticatedDocumentLoader } from "../utils/docloader.ts"; import { kvCache } from "../utils/kv-cache.ts"; +import { + type BenchmarkMetricReader, + type BenchmarkTriggerOptions, + createBenchmarkMeterProvider, + handleBenchmarkStats, + handleBenchmarkTrigger, +} from "./bench.ts"; import { ACTOR_ALIAS_PREFIX, FederationBuilderImpl } from "./builder.ts"; import type { OutboxErrorHandler } from "./callback.ts"; import { @@ -158,6 +158,7 @@ import type { Message, OutboxMessage, SenderKeyJwkPair, + TaskMessage, } from "./queue.ts"; import { createExponentialBackoffPolicy, type RetryPolicy } from "./retry.ts"; import { @@ -166,8 +167,14 @@ import { SendActivityError, type SenderKeyPair, } from "./send.ts"; -import { handleWebFinger } from "./webfinger.ts"; +import { + enqueueTasks, + TaskCodec, + type TaskDefinition, + type TaskEnqueueOptions, +} from "./tasks/mod.ts"; import { hasMalformedKnownTemporalLiteral } from "./temporal.ts"; +import { handleWebFinger } from "./webfinger.ts"; const circuitBreakerCasWarningKvStores = new WeakSet(); let nextQueueDepthGaugeSourceId = 0; @@ -449,6 +456,14 @@ export interface FederationQueueOptions { * {@link Context.sendActivity} calls. */ readonly fanout?: MessageQueue; + + /** + * The message queue for custom background tasks. If not provided, + * tasks are routed to the outbox queue (unless + * {@link FederationOptions.taskQueueResolution} is `"strict"`). + * @since 2.x.x + */ + readonly task?: MessageQueue; } /** @@ -498,6 +513,15 @@ export interface FederationKvPrefixes { * @since 2.3.0 */ readonly circuitBreaker: KvKey; + + /** + * The key prefix used for storing custom background task deduplication + * markers. Kept separate from {@link activityIdempotence} so the two key + * spaces never collide. + * @default `["_fedify", "taskDeduplication"]` + * @since 2.x.x + */ + readonly taskDeduplication: KvKey; } /** @@ -542,9 +566,12 @@ export class FederationImpl inboxQueue?: MessageQueue; outboxQueue?: MessageQueue; fanoutQueue?: MessageQueue; + taskQueue?: MessageQueue; inboxQueueStarted: boolean; outboxQueueStarted: boolean; fanoutQueueStarted: boolean; + taskQueueStarted: boolean; + startedTaskQueues: Set; manuallyStartQueue: boolean; origin?: FederationOrigin; documentLoaderFactory: DocumentLoaderFactory; @@ -558,6 +585,10 @@ export class FederationImpl skipSignatureVerification: boolean; outboxRetryPolicy: RetryPolicy; inboxRetryPolicy: RetryPolicy; + taskRetryPolicy: RetryPolicy; + taskQueueResolution: "fallback" | "strict"; + taskDeduplicationTtl: Temporal.Duration; + taskDeduplicationFallback: "open" | "closed"; circuitBreaker?: CircuitBreaker; activityTransformers: readonly ActivityTransformer[]; _tracerProvider: TracerProvider | undefined; @@ -619,6 +650,7 @@ export class FederationImpl httpMessageSignaturesSpec: ["_fedify", "httpMessageSignaturesSpec"], acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], circuitBreaker: ["_fedify", "circuit"], + taskDeduplication: ["_fedify", "taskDeduplication"], } satisfies FederationKvPrefixes), ...(options.kvPrefixes ?? {}), }; @@ -626,14 +658,19 @@ export class FederationImpl this.inboxQueue = undefined; this.outboxQueue = undefined; this.fanoutQueue = undefined; + this.taskQueue = undefined; } else if ("enqueue" in options.queue && "listen" in options.queue) { this.inboxQueue = options.queue; this.outboxQueue = options.queue; this.fanoutQueue = options.queue; + // A bare queue leaves taskQueue undefined; tasks are served through + // the outboxQueue fallback. + this.taskQueue = undefined; } else { this.inboxQueue = options.queue.inbox; this.outboxQueue = options.queue.outbox; this.fanoutQueue = options.queue.fanout; + this.taskQueue = options.queue.task; } if (options.circuitBreaker !== false && this.outboxQueue != null) { this.circuitBreaker = new CircuitBreaker({ @@ -664,6 +701,8 @@ export class FederationImpl this.inboxQueueStarted = false; this.outboxQueueStarted = false; this.fanoutQueueStarted = false; + this.taskQueueStarted = false; + this.startedTaskQueues = new Set(); this.manuallyStartQueue = options.manuallyStartQueue ?? false; if (options.origin != null) { if (typeof options.origin === "string") { @@ -842,6 +881,14 @@ export class FederationImpl createExponentialBackoffPolicy(); this.inboxRetryPolicy = options.inboxRetryPolicy ?? createExponentialBackoffPolicy(); + this.taskRetryPolicy = options.taskRetryPolicy ?? + createExponentialBackoffPolicy(); + this.taskQueueResolution = options.taskQueueResolution ?? "fallback"; + this.taskDeduplicationTtl = Temporal.Duration.from( + options.taskDeduplicationTtl ?? { hours: 1 }, + ); + this.taskDeduplicationFallback = options.taskDeduplicationFallback ?? + "open"; this.activityTransformers = options.activityTransformers ?? getDefaultActivityTransformers(); this._tracerProvider = options.tracerProvider; @@ -894,12 +941,31 @@ export class FederationImpl return this.tracerProvider.getTracer(metadata.name, metadata.version); } + resolveTaskQueue(taskName: string): MessageQueue | undefined { + const def = this.taskDefinitions.get(taskName); + const resolved = def?.queue ?? this.taskQueue; + if (resolved != null) return resolved; + return this.taskQueueResolution === "strict" ? undefined : this.outboxQueue; + } + async _startQueueInternal( ctxData: TContextData, signal?: AbortSignal, queue?: keyof FederationQueueOptions, ): Promise { - if (this.inboxQueue == null && this.outboxQueue == null) return; + // Tasks may route to a dedicated queue of their own (defineTask({ queue })) + // even when no federation-wide queue is configured, so a deployment with + // only per-task queues still has work to start. + const hasDedicatedTaskQueue = [...this.taskDefinitions.values()].some( + (def) => def.queue != null, + ); + if ( + this.inboxQueue == null && this.outboxQueue == null && + this.fanoutQueue == null && this.taskQueue == null && + !hasDedicatedTaskQueue + ) { + return; + } const logger = getLogger(["fedify", "federation", "queue"]); const promises: Promise[] = []; if ( @@ -946,6 +1012,50 @@ export class FederationImpl ), ); } + if ( + this.taskQueue != null && + this.taskQueue !== this.inboxQueue && + this.taskQueue !== this.outboxQueue && + this.taskQueue !== this.fanoutQueue && + (queue == null || queue === "task") && + !this.taskQueueStarted + ) { + logger.debug("Starting a task worker."); + this.taskQueueStarted = true; + promises.push( + this.taskQueue.listen( + (msg) => this.processQueuedTask(ctxData, msg), + { signal }, + ), + ); + } + // Dedicated per-task queues belong to the "task" selector. Each distinct + // instance needs its own worker; dedupe against the standard queues and + // against task queues already started on an earlier call so no instance is + // listened on twice. + if (queue == null || queue === "task") { + const standardQueues = new Set( + [this.inboxQueue, this.outboxQueue, this.fanoutQueue, this.taskQueue] + .filter((q): q is MessageQueue => q != null), + ); + for (const def of this.taskDefinitions.values()) { + const taskQueue = def.queue; + if ( + taskQueue == null || standardQueues.has(taskQueue) || + this.startedTaskQueues.has(taskQueue) + ) { + continue; + } + logger.debug("Starting a worker for a dedicated per-task queue."); + this.startedTaskQueues.add(taskQueue); + promises.push( + taskQueue.listen( + (msg) => this.processQueuedTask(ctxData, msg), + { signal }, + ), + ); + } + } await Promise.all(promises); } @@ -1126,6 +1236,8 @@ export class FederationImpl ); }, ); + } else if (message.type === "task") { + await this.#listenTaskMessage(contextData, message); } }); } @@ -2021,6 +2133,97 @@ export class FederationImpl ); } + async #listenTaskMessage( + contextData: TContextData, + message: TaskMessage, + ): Promise { + const logger = getLogger(["fedify", "federation", "task"]); + const def = this.taskDefinitions.get(message.taskName); + if (def == null) { + // Unknown task: a handler won't appear by retrying. Drop and log. + logger.warn( + "Received a custom task {taskName} with no registered handler; " + + "dropping.", + { taskName: message.taskName }, + ); + return; + } + const context = this.#createContext(new URL(message.baseUrl), contextData); + let data: unknown; + try { + // decode() deserializes then re-validates at the dequeue boundary + // (drift protection): a durable queue can hand a new deploy a payload + // an old deploy enqueued. + data = await context.codec.decode(def.schema, message.data); + } catch (error) { + // A malformed or incompatible payload won't succeed by retrying. + logger.error( + "Custom task {taskName} payload could not be decoded or validated; " + + "dropping:\n{error}", + { taskName: message.taskName, error }, + ); + return; + } + try { + await def.handler(context, data); + } catch (error) { + if (def.onError != null) { + try { + await def.onError(context, error, data); + } catch (onErrorError) { + logger.error( + "onError for custom task {taskName} threw:\n{error}", + { taskName: message.taskName, error: onErrorError }, + ); + } + } + const queue = this.resolveTaskQueue(def.name); + if (queue?.nativeRetrial) throw error; // the backend owns retries + const retryPolicy = def.retryPolicy ?? this.taskRetryPolicy; + // A corrupted `started` must not throw here and abort the retry. + let elapsedTime = Temporal.Duration.from({ seconds: 0 }); + try { + elapsedTime = Temporal.Instant.from(message.started) + .until(Temporal.Now.instant()); + } catch (parseError) { + logger.error( + "Custom task {taskName} has an unparsable started time " + + "{started}; treating elapsedTime as zero:\n{error}", + { + taskName: message.taskName, + started: message.started, + error: parseError, + }, + ); + } + const delay = retryPolicy({ + elapsedTime, + attempts: message.attempt, + }); + if (delay != null && queue != null) { + logger.error( + "Custom task {taskName} failed (attempt #{attempt}); retry...:" + + "\n{error}", + { taskName: message.taskName, attempt: message.attempt, error }, + ); + const retryMessage = { + ...message, + attempt: message.attempt + 1, + } satisfies TaskMessage; + await queue.enqueue(retryMessage, { + delay: clampNegativeDelay(delay), + orderingKey: message.orderingKey, + }); + } else { + logger.error( + "Custom task {taskName} failed after {attempt} attempts; giving " + + "up:\n{error}", + { taskName: message.taskName, attempt: message.attempt, error }, + ); + } + } + } + startQueue( contextData: TContextData, options: FederationStartQueueOptions = {}, @@ -2890,6 +3093,7 @@ export class ContextImpl implements Context { readonly documentLoader: DocumentLoader; readonly contextLoader: DocumentLoader; readonly invokedFromActorKeyPairsDispatcher?: { identifier: string }; + #codec?: TaskCodec; constructor( { @@ -2910,6 +3114,20 @@ export class ContextImpl implements Context { invokedFromActorKeyPairsDispatcher; } + /** + * A {@link TaskCodec} bound to this context's loaders, used to encode + * and decode custom task payloads. Lazily created and cached so a context + * that never enqueues or dispatches a task pays nothing. + * @internal + */ + get codec(): TaskCodec { + return this.#codec ??= new TaskCodec(this); + } + + get #enqueueTasks() { + return enqueueTasks(this); + } + clone(data: TContextData): Context { return new ContextImpl({ url: this.url, @@ -3446,6 +3664,22 @@ export class ContextImpl implements Context { }); } + async enqueueTask( + task: TaskDefinition, + data: TData, + options: TaskEnqueueOptions = {}, + ): Promise { + await this.#enqueueTasks(task, [data], options); + } + + async enqueueTaskMany( + task: TaskDefinition, + payloads: readonly TData[], + options: TaskEnqueueOptions = {}, + ): Promise { + await this.#enqueueTasks(task, payloads, options); + } + sendActivity( sender: | SenderKeyPair diff --git a/packages/fedify/src/federation/mod.ts b/packages/fedify/src/federation/mod.ts index 5a87fdf0f..6a3bb1f6e 100644 --- a/packages/fedify/src/federation/mod.ts +++ b/packages/fedify/src/federation/mod.ts @@ -25,6 +25,13 @@ export * from "./mq.ts"; export type { Message } from "./queue.ts"; export * from "./retry.ts"; export * from "./router.ts"; +export type { + TaskDefinition, + TaskDefinitionOptions, + TaskEnqueueOptions, + TaskHandler, + TaskRegistry, +} from "./tasks/mod.ts"; export { SendActivityError, type SenderKeyPair } from "./send.ts"; export { handleWebFinger, diff --git a/packages/fedify/src/federation/mq.test.ts b/packages/fedify/src/federation/mq.test.ts index e7c402908..3bb5c0d88 100644 --- a/packages/fedify/src/federation/mq.test.ts +++ b/packages/fedify/src/federation/mq.test.ts @@ -5,11 +5,13 @@ import { assertFalse, assertGreater, assertGreaterOrEqual, + assertRejects, } from "@std/assert"; import { delay } from "es-toolkit"; import { InProcessMessageQueue, type MessageQueue, + type MessageQueueEnqueueOptions, ParallelMessageQueue, } from "./mq.ts"; @@ -34,6 +36,10 @@ test("InProcessMessageQueue", async (t) => { assertFalse(mq.nativeRetrial); }); + await t.step("nativeDeduplication property", () => { + assertFalse(mq.nativeDeduplication); + }); + await t.step("getDepth() [empty]", async () => { assertEquals(await mq.getDepth(), { queued: 0, @@ -419,6 +425,107 @@ test("MessageQueue.nativeRetrial", async (t) => { }); }); +test("ParallelMessageQueue inherits nativeDeduplication", () => { + class NativeDeduplicationQueue implements MessageQueue { + readonly nativeDeduplication = true; + enqueue(): Promise { + return Promise.resolve(); + } + listen(): Promise { + return Promise.resolve(); + } + } + + const workers = new ParallelMessageQueue(new NativeDeduplicationQueue(), 5); + assert(workers.nativeDeduplication); +}); + +test( + "ParallelMessageQueue forwards deduplicationKey to the wrapped queue", + async () => { + class RecordingQueue implements MessageQueue { + readonly nativeDeduplication = true; + readonly singles: (MessageQueueEnqueueOptions | undefined)[] = []; + readonly batches: (MessageQueueEnqueueOptions | undefined)[] = []; + enqueue( + _message: unknown, + options?: MessageQueueEnqueueOptions, + ): Promise { + this.singles.push(options); + return Promise.resolve(); + } + enqueueMany( + _messages: readonly unknown[], + options?: MessageQueueEnqueueOptions, + ): Promise { + this.batches.push(options); + return Promise.resolve(); + } + listen(): Promise { + return Promise.resolve(); + } + } + + const inner = new RecordingQueue(); + const workers = new ParallelMessageQueue(inner, 5); + await workers.enqueue({ x: 1 }, { deduplicationKey: "k1" }); + await workers.enqueueMany([{ x: 1 }, { x: 2 }], { deduplicationKey: "k2" }); + assertEquals(inner.singles[0]?.deduplicationKey, "k1"); + assertEquals(inner.batches[0]?.deduplicationKey, "k2"); + }, +); + +test( + "ParallelMessageQueue rejects a deduplicated batch when the wrapped queue " + + "lacks enqueueMany", + async () => { + class NoBulkQueue implements MessageQueue { + readonly nativeDeduplication = true; + readonly enqueued: unknown[] = []; + enqueue(message: unknown): Promise { + this.enqueued.push(message); + return Promise.resolve(); + } + listen(): Promise { + return Promise.resolve(); + } + } + + const inner = new NoBulkQueue(); + const workers = new ParallelMessageQueue(inner, 5); + await assertRejects( + () => + workers.enqueueMany([{ x: 1 }, { x: 2 }], { deduplicationKey: "k" }), + TypeError, + "enqueueMany", + ); + // It threw before enqueuing anything. + assertEquals(inner.enqueued.length, 0); + }, +); + +test( + "ParallelMessageQueue still fans out a non-deduplicated batch when the " + + "wrapped queue lacks enqueueMany", + async () => { + class NoBulkQueue implements MessageQueue { + readonly enqueued: unknown[] = []; + enqueue(message: unknown): Promise { + this.enqueued.push(message); + return Promise.resolve(); + } + listen(): Promise { + return Promise.resolve(); + } + } + + const inner = new NoBulkQueue(); + const workers = new ParallelMessageQueue(inner, 5); + await workers.enqueueMany([{ x: 1 }, { x: 2 }, { x: 3 }]); + assertEquals(inner.enqueued.length, 3); + }, +); + const queues: Record Promise> = { InProcessMessageQueue: () => Promise.resolve(new InProcessMessageQueue()), }; @@ -450,6 +557,10 @@ for (const mqName in queues) { assertEquals(workers.nativeRetrial, mq.nativeRetrial); }); + await t.step("nativeDeduplication property inheritance", () => { + assertEquals(workers.nativeDeduplication, mq.nativeDeduplication); + }); + await t.step("getDepth() delegation", async () => { if (mq.getDepth == null) { assertEquals(workers.getDepth, undefined); diff --git a/packages/fedify/src/federation/mq.ts b/packages/fedify/src/federation/mq.ts index 36ba9900c..c99cc63e7 100644 --- a/packages/fedify/src/federation/mq.ts +++ b/packages/fedify/src/federation/mq.ts @@ -25,6 +25,20 @@ export interface MessageQueueEnqueueOptions { * @since 2.0.0 */ readonly orderingKey?: string; + + /** + * An optional key requesting at-most-once enqueue semantics for messages + * that share it. A backend that declares + * {@link MessageQueue.nativeDeduplication} `true` owns the check: a message + * whose `deduplicationKey` was already seen within the backend's + * deduplication window is dropped instead of enqueued. Backends without + * native deduplication ignore this field; Fedify performs its own + * best-effort deduplication before reaching them on the paths that support + * it. + * + * @since 2.x.x + */ + readonly deduplicationKey?: string; } /** @@ -87,6 +101,18 @@ export interface MessageQueue { */ readonly nativeRetrial?: boolean; + /** + * Whether the message queue backend deduplicates messages that share a + * {@link MessageQueueEnqueueOptions.deduplicationKey} natively. When `true`, + * Fedify forwards the `deduplicationKey` and relies on the backend to drop + * duplicates; when `false` or omitted, Fedify applies its own best-effort + * key–value deduplication on the paths that request it. + * + * @default `false` + * @since 2.x.x + */ + readonly nativeDeduplication?: boolean; + /** * Enqueues a message in the queue. * @param message The message to enqueue. @@ -176,6 +202,12 @@ export class InProcessMessageQueue implements MessageQueue { */ readonly nativeRetrial = false; + /** + * In-process message queue does not deduplicate messages natively. + * @since 2.x.x + */ + readonly nativeDeduplication = false; + /** * Constructs a new {@link InProcessMessageQueue} with the given options. * @param options Additional options for the in-process message queue. @@ -365,6 +397,12 @@ export class ParallelMessageQueue implements MessageQueue { * @since 1.7.0 */ readonly nativeRetrial?: boolean; + + /** + * Inherits the native deduplication capability from the wrapped queue. + * @since 2.x.x + */ + readonly nativeDeduplication?: boolean; readonly getDepth?: () => Promise; /** @@ -398,6 +436,7 @@ export class ParallelMessageQueue implements MessageQueue { this.queue = queue; this.workers = workers; this.nativeRetrial = queue.nativeRetrial; + this.nativeDeduplication = queue.nativeDeduplication; if (queue.getDepth != null) { this.getDepth = () => queue.getDepth!(); } @@ -412,6 +451,15 @@ export class ParallelMessageQueue implements MessageQueue { options?: MessageQueueEnqueueOptions, ): Promise { if (this.queue.enqueueMany == null) { + if (options?.deduplicationKey != null) { + throw new TypeError( + "Cannot enqueue a batch with a deduplicationKey: the wrapped queue " + + "does not implement enqueueMany, so ParallelMessageQueue would " + + "have to fan out to individual enqueue() calls that cannot share " + + "one deduplicationKey atomically. Wrap a queue that implements " + + "enqueueMany instead.", + ); + } const results = await Promise.allSettled( messages.map((message) => this.queue.enqueue(message, options)), ); diff --git a/packages/fedify/src/federation/queue.ts b/packages/fedify/src/federation/queue.ts index 36f35ad02..a8e862a1e 100644 --- a/packages/fedify/src/federation/queue.ts +++ b/packages/fedify/src/federation/queue.ts @@ -12,7 +12,11 @@ export interface SenderKeyJwkPair { * type. * @since 1.6.0 */ -export type Message = FanoutMessage | OutboxMessage | InboxMessage; +export type Message = + | FanoutMessage + | OutboxMessage + | InboxMessage + | TaskMessage; export interface FanoutMessage { readonly type: "fanout"; @@ -71,6 +75,25 @@ export interface OutboxMessage { readonly traceContext: Readonly>; } +/** + * A message that carries a custom background task. Every field is + * a string, number, or plain record so that the message survives both + * JSON serialization and structured clone on every queue backend. + * @since 2.x.x + */ +export interface TaskMessage { + readonly type: "task"; + readonly id: ReturnType; + readonly baseUrl: string; + readonly taskName: string; + /** devalue-encoded task data; vocab objects bridged to expanded JSON-LD. */ + readonly data: string; + readonly started: string; + readonly attempt: number; + readonly orderingKey?: string; + readonly traceContext: Readonly>; +} + export interface InboxMessage { readonly type: "inbox"; readonly id: ReturnType; diff --git a/packages/fedify/src/federation/tasks/codec.test.ts b/packages/fedify/src/federation/tasks/codec.test.ts new file mode 100644 index 000000000..fcc84de0e --- /dev/null +++ b/packages/fedify/src/federation/tasks/codec.test.ts @@ -0,0 +1,379 @@ +import { mockDocumentLoader, test } from "@fedify/fixture"; +import { Create, Link, Note, Person } from "@fedify/vocab"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; +import TaskCodec from "./codec.ts"; + +const loaders = { + contextLoader: mockDocumentLoader, + documentLoader: mockDocumentLoader, +}; + +const codec = new TaskCodec(loaders); + +function makeSchema( + check: (data: unknown) => data is T, +): StandardSchemaV1 { + return { + "~standard": { + version: 1, + vendor: "fedify-test", + validate(value: unknown) { + return check(value) + ? { value } + : { issues: [{ message: "Invalid task data." }] }; + }, + }, + }; +} + +test("TaskCodec.serialize() / deserialize()", async (t) => { + const note = new Note({ + id: new URL("https://example.com/notes/1"), + content: "Hello, world!", + }); + const person = new Person({ + id: new URL("https://example.com/users/alice"), + name: "Alice", + }); + const link = new Link({ + href: new URL("https://example.com/"), + mediaType: "text/html", + }); + const create = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + object: note, + }); + + await t.step("round-trips a mixed payload", async () => { + const payload = { + note, + when: new Date("2026-01-02T03:04:05Z"), + big: 1234567890123456789n, + url: new URL("https://example.com/some/path"), + list: [person, link], + map: new Map([["create", create], ["n", 42]]), + set: new Set([1, 2, 3]), + nested: { create }, + }; + const encoded = await codec.serialize(payload); + strictEqual(typeof encoded, "string"); + const decoded = await codec.deserialize(encoded) as Record< + string, + unknown + >; + ok(decoded.note instanceof Note); + strictEqual(decoded.note.content?.toString(), "Hello, world!"); + strictEqual(decoded.note.id?.href, "https://example.com/notes/1"); + ok(decoded.when instanceof Date); + strictEqual(decoded.when.toISOString(), "2026-01-02T03:04:05.000Z"); + strictEqual(decoded.big, 1234567890123456789n); + ok(decoded.url instanceof URL); + strictEqual(decoded.url.href, "https://example.com/some/path"); + const list = decoded.list as unknown[]; + ok(list[0] instanceof Person); + strictEqual(list[0].name?.toString(), "Alice"); + ok(list[1] instanceof Link); + strictEqual(list[1].href?.href, "https://example.com/"); + const map = decoded.map as Map; + ok(map.get("create") instanceof Create); + strictEqual(map.get("n"), 42); + deepStrictEqual(decoded.set, new Set([1, 2, 3])); + const nested = decoded.nested as Record; + ok(nested.create instanceof Create); + const nestedObject = await nested.create.getObject({ + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }); + ok(nestedObject instanceof Note); + strictEqual(nestedObject.content?.toString(), "Hello, world!"); + }); + + await t.step( + "encodes vocab objects in expand form (no @context)", + async () => { + const encoded = await codec.serialize({ note }); + ok(!encoded.includes("@context")); + }, + ); + + await t.step("leaves a non-vocab payload untouched", async () => { + const payload = { + text: "plain", + n: 1, + flag: true, + nothing: null, + when: new Date("2026-06-10T00:00:00Z"), + list: [1, "two", 3n], + }; + const encoded = await codec.serialize(payload); + const decoded = await codec.deserialize(encoded); + deepStrictEqual(decoded, payload); + }); + + await t.step("throws on a malformed wire string", async () => { + await rejects(async () => await codec.deserialize("garbage")); + }); + + await t.step("preserves circular and repeated references", async () => { + const shared = new Note({ content: "shared" }); + interface Cyclic { + name: string; + self?: Cyclic; + notes: Note[]; + } + const payload: Cyclic = { name: "root", notes: [shared, shared] }; + payload.self = payload; + const encoded = await codec.serialize(payload); + const decoded = await codec.deserialize(encoded) as Cyclic; + strictEqual(decoded.self, decoded); + ok(decoded.notes[0] instanceof Note); + strictEqual(decoded.notes[0], decoded.notes[1]); + strictEqual(decoded.notes[0].content?.toString(), "shared"); + }); + + await t.step("preserves a cycle through an array", async () => { + const list: unknown[] = ["head"]; + list.push(list); + const encoded = await codec.serialize({ list }); + const decoded = await codec.deserialize(encoded) as { + list: unknown[]; + }; + strictEqual(decoded.list[0], "head"); + strictEqual(decoded.list[1], decoded.list); + }); + + await t.step("preserves cycles re-entering at a Map and a Set", async () => { + const set = new Set(); + set.add({ set }); + const map = new Map(); + map.set("entry", { map }); + const encoded = await codec.serialize({ set, map }); + const decoded = await codec.deserialize(encoded) as { + set: Set<{ set: Set }>; + map: Map }>; + }; + const [member] = decoded.set; + strictEqual(member.set, decoded.set); + strictEqual(decoded.map.get("entry")?.map, decoded.map); + }); + + await t.step( + "revives a vocab object nested in a null-prototype object", + async () => { + const nullProto = Object.create(null) as Record; + nullProto.note = note; + const encoded = await codec.serialize({ wrap: nullProto }); + const decoded = await codec.deserialize(encoded) as { + wrap: Record; + }; + ok(decoded.wrap.note instanceof Note); + strictEqual(decoded.wrap.note.content?.toString(), "Hello, world!"); + }, + ); + + await t.step( + "revives a payload nested far deeper than any fixed depth cap", + async () => { + // `#revive` suspends at an `await` on every level, so nesting depth + // consumes heap (promise chains) rather than native stack—deep + // payloads cannot overflow it, and a fixed depth cap would only + // reject legitimate data. Pins a depth an order of magnitude above + // any such cap. + const depth = 1000; + let payload: unknown = new Note({ content: "deep" }); + for (let i = 0; i < depth; i++) { + payload = i % 2 === 0 ? { inner: payload } : [payload]; + } + const encoded = await codec.serialize(payload); + let decoded = await codec.deserialize(encoded); + for (let i = depth - 1; i >= 0; i--) { + decoded = i % 2 === 0 + ? (decoded as { inner: unknown }).inner + : (decoded as unknown[])[0]; + } + ok(decoded instanceof Note); + strictEqual(decoded.content?.toString(), "deep"); + }, + ); + + await t.step( + "round-trips an array too large to spread into push()", + async () => { + const length = 200_000; + const payload = { big: Array.from({ length }, (_, i) => i) }; + const encoded = await codec.serialize(payload); + const decoded = await codec.deserialize(encoded) as { big: number[] }; + strictEqual(decoded.big.length, length); + strictEqual(decoded.big[0], 0); + strictEqual(decoded.big[length - 1], length - 1); + }, + ); + + await t.step({ + name: "round-trips Temporal values", + ignore: "Bun" in globalThis, + async fn() { + const payload = { + instant: Temporal.Instant.from("2026-01-02T03:04:05Z"), + duration: Temporal.Duration.from({ hours: 1, minutes: 30 }), + }; + const encoded = await codec.serialize(payload); + const decoded = await codec.deserialize(encoded) as { + instant: Temporal.Instant; + duration: Temporal.Duration; + }; + ok(decoded.instant instanceof Temporal.Instant); + ok(decoded.instant.equals(payload.instant)); + ok(decoded.duration instanceof Temporal.Duration); + strictEqual(decoded.duration.toString(), payload.duration.toString()); + }, + }); +}); + +test("TaskCodec (one instance reused across decodes)", async (t) => { + // Each deserialize() call builds its own per-decode `seen` map, so no + // cycle-tracking state crosses calls and a reused instance decodes every + // payload independently. + await t.step("two sequential decodes stay independent", async () => { + const codec = new TaskCodec(loaders); + const first = await codec.serialize({ + note: new Note({ content: "A" }), + }); + const second = await codec.serialize({ + note: new Note({ content: "B" }), + }); + const a = await codec.deserialize(first) as { note: Note }; + const b = await codec.deserialize(second) as { note: Note }; + ok(a.note instanceof Note); + ok(b.note instanceof Note); + strictEqual(a.note.content?.toString(), "A"); + strictEqual(b.note.content?.toString(), "B"); + }); +}); + +test("TaskCodec.validate()", async (t) => { + interface Envelope { + note: Note; + title: string; + } + const schema = makeSchema( + (data): data is Envelope => + typeof data === "object" && data != null && + (data as Envelope).note instanceof Note && + typeof (data as Envelope).title === "string", + ); + + await t.step("accepts a payload with a vocab instanceof leaf", async () => { + const payload = { note: new Note({ content: "Hi" }), title: "greeting" }; + const validated = await TaskCodec.validate(schema, payload); + deepStrictEqual(validated, payload); + }); + + await t.step("rejects a wrong-shaped payload", async () => { + await rejects( + () => TaskCodec.validate(schema, { note: "not a Note", title: 42 }), + { name: "TypeError", message: /Task data failed schema validation/ }, + ); + }); + + await t.step("supports async validation", async () => { + const asyncSchema: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "fedify-test", + validate: (value: unknown) => + Promise.resolve( + typeof value === "number" + ? { value } + : { issues: [{ message: "not a number" }] }, + ), + }, + }; + strictEqual(await TaskCodec.validate(asyncSchema, 42), 42); + await rejects(() => TaskCodec.validate(asyncSchema, "nope")); + }); + + await t.step( + "round-trip then validate (same schema on both sides)", + async () => { + const payload = { note: new Note({ content: "Hi" }), title: "greeting" }; + const encoded = await codec.serialize(payload); + const decoded = await codec.deserialize(encoded); + const validated = await TaskCodec.validate(schema, decoded); + ok(validated.note instanceof Note); + strictEqual(validated.title, "greeting"); + strictEqual(validated.note.content?.toString(), "Hi"); + }, + ); +}); + +test("TaskCodec.encode() / decode()", async (t) => { + interface Envelope { + note: Note; + title: string; + } + const schema = makeSchema( + (data): data is Envelope => + typeof data === "object" && data != null && + (data as Envelope).note instanceof Note && + typeof (data as Envelope).title === "string", + ); + + await t.step( + "encode() validates then serializes; decode() round-trips", + async () => { + const payload = { note: new Note({ content: "Hi" }), title: "greeting" }; + const wire = await codec.encode(schema, payload); + strictEqual(typeof wire, "string"); + const back = await codec.decode(schema, wire); + ok(back.note instanceof Note); + strictEqual(back.note.content?.toString(), "Hi"); + strictEqual(back.title, "greeting"); + }, + ); + + await t.step("encode() rejects a wrong-shaped payload", async () => { + await rejects( + () => codec.encode(schema, { note: "nope", title: 42 }), + { name: "TypeError", message: /Task data failed schema validation/ }, + ); + }); + + await t.step( + "decode() re-validates and rejects a drifted payload", + async () => { + // Encode under a permissive schema, decode under the strict one. + const loose = makeSchema((_data): _data is unknown => true); + const wire = await codec.encode(loose, { note: "not a note" }); + await rejects( + () => codec.decode(schema, wire), + { name: "TypeError", message: /Task data failed schema validation/ }, + ); + }, + ); + + await t.step( + "a non-idempotent (transforming) schema fails to round-trip", + async () => { + // Validation must be idempotent: the wire carries the validated + // output, which the same schema re-validates as input at dequeue. + const transforming: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "fedify-test", + validate: (value: unknown) => + typeof value === "string" + ? { value: value.length } + : { issues: [{ message: "Expected a string." }] }, + }, + }; + const wire = await codec.encode(transforming, "hello"); + await rejects( + () => codec.decode(transforming, wire), + { name: "TypeError", message: /Task data failed schema validation/ }, + ); + }, + ); +}); diff --git a/packages/fedify/src/federation/tasks/codec.ts b/packages/fedify/src/federation/tasks/codec.ts new file mode 100644 index 000000000..f13d85581 --- /dev/null +++ b/packages/fedify/src/federation/tasks/codec.ts @@ -0,0 +1,191 @@ +import { Link, Object as APObject } from "@fedify/vocab"; +import type { DocumentLoader } from "@fedify/vocab-runtime"; +import type { TracerProvider } from "@opentelemetry/api"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { parse, stringifyAsync } from "devalue"; + +export default class TaskCodec { + constructor(readonly options: TaskCodecLoaders) {} + + serialize = (data: unknown): Promise => + stringifyAsync(data, { Vocab: this.#stringifyVocab }); + + deserialize = (raw: string): Promise => + this.#revive(new Map())(parse(raw, { Vocab: VocabHolder.from })); + + /** Validates `data` against `schema`, then serializes it. */ + encode = async ( + schema: S, + data: StandardSchemaV1.InferInput, + ): Promise => this.serialize(await TaskCodec.validate(schema, data)); + + /** Deserializes `raw`, then validates the result against `schema`. */ + decode = async ( + schema: S, + raw: string, + ): Promise> => + TaskCodec.validate(schema, await this.deserialize(raw)); + + static validate = async ( + schema: S, + data: unknown, + ): Promise> => + getValueIfSchema(await schema["~standard"].validate(data)); + + #stringifyVocab = (value: unknown) => isVocab(value) && this.#toWire(value); + + #toWire = async (value: APObject | Link): Promise => ({ + kind: value instanceof Link ? "link" : "object", + jsonLd: await value.toJsonLd({ format: "expand", ...this.options }), + }); + + #revive = (seen: Seen): Revive => { + const inner: Revive = async (node) => { + if (node === null || typeof node !== "object") return node; + if (seen.has(node)) return seen.get(node); + for (const reviver of this.#classRevivers) { + const out = reviver(seen, inner, node); + if (out !== undefined) return await out; + } + // devalue can handle non-container objects. + return node; + }; + return inner; + }; + + #classRevivers: readonly ClassReviver[] = [ + classReviver( + isInstanceOf(VocabHolder), + ({ kind, jsonLd }): Promise => + kind === "link" + ? Link.fromJsonLd(jsonLd, this.options) + : APObject.fromJsonLd(jsonLd, this.options), + () => {}, + ), + classReviver( + isInstanceOf(Array), + (): unknown[] => [], + async (revive, node, arr) => { + for (const item of await Array.fromAsync(node, revive)) arr.push(item); + }, + ), + classReviver( + isInstanceOf(Map), + () => new Map(), + async (revive, node, map) => { + for (const [k, v] of node) map.set(await revive(k), await revive(v)); + }, + ), + classReviver( + isInstanceOf(Set), + () => new Set(), + async (revive, node, set) => { + for (const v of await Array.fromAsync(node, revive)) set.add(v); + }, + ), + classReviver( + isPlainObject, + (): Record => ({}), + async (revive, node, obj) => { + for (const [k, v] of globalThis.Object.entries(node)) { + obj[k] = await revive(v); + } + }, + ), + ]; +} + +const isVocab = (value: unknown): value is APObject | Link => + value instanceof APObject || value instanceof Link; + +const isPlainObject = (value: unknown): value is Record => + value === null || typeof value !== "object" + ? false + : isObjectPrototype(globalThis.Object.getPrototypeOf(value)); + +const isObjectPrototype = (proto: unknown): boolean => + proto === null || proto === globalThis.Object.prototype; + +const isInstanceOf = (cls: Constructor) => (v: unknown): v is T => + v instanceof cls; + +function getValueIfSchema(result: StandardSchemaV1.Result) { + assertSchema(result); + return result.value; +} + +function assertSchema( + result: StandardSchemaV1.Result, +): asserts result is StandardSchemaV1.SuccessResult { + if (result.issues && result.issues.length > 0) { + throw new TypeError( + `Task data failed schema validation: ${JSON.stringify(result.issues)}`, + ); + } +} + +/** + * The loaders a worker {@link Context} already exposes; both decode passes + * use them. + * @internal + */ +interface TaskCodecLoaders { + readonly contextLoader?: DocumentLoader; + readonly documentLoader?: DocumentLoader; + readonly tracerProvider?: TracerProvider; + readonly baseUrl?: URL; +} + +/** Which `fromJsonLd` entry point rebuilds a given vocabulary object. */ +type VocabKind = "object" | "link"; + +/** A vocabulary object reduced to its wire form: a kind tag plus JSON-LD. */ +interface VocabWire { + readonly kind: VocabKind; + readonly jsonLd: unknown; +} + +/** + * A vocabulary object parked by the synchronous decode reviver, held until + * the async revive pass can `fromJsonLd()` it back into an instance. + */ +class VocabHolder implements VocabWire { + constructor(readonly kind: VocabKind, readonly jsonLd: unknown) {} + static from = ({ kind, jsonLd }: VocabWire) => new VocabHolder(kind, jsonLd); +} + +/** Per-decode map from each visited container to its revived counterpart. */ +type Seen = Map; + +/** Revives one node, sharing the per-decode {@link Seen} map via closure. */ +type Revive = (node: unknown) => Promise; + +/** Revives one matched container, or `undefined` when the node isn't its kind. */ +type ClassReviver = ( + seen: Seen, + revive: Revive, + node: object, +) => Promise | undefined; + +/** + * Ties a container filter to its empty-shell `init` and child-filling `set` + * through one type parameter—a correlation the heterogeneous reviver list + * cannot carry, which previously forced `@ts-ignore` at the dispatch site. + */ +const classReviver = ( + filter: (v: unknown) => v is TNode, + init: (node: TNode) => TOut | Promise, + set: (revive: Revive, node: TNode, out: TOut) => void | Promise, +): ClassReviver => +(seen, revive, node) => { + if (!filter(node)) return undefined; + return (async () => { + const out = await init(node); + seen.set(node, out); + await set(revive, node, out); + return out; + })(); +}; + +// deno-lint-ignore no-explicit-any +type Constructor = new (...arg: any[]) => T; diff --git a/packages/fedify/src/federation/tasks/enqueue.test.ts b/packages/fedify/src/federation/tasks/enqueue.test.ts new file mode 100644 index 000000000..ee7db8014 --- /dev/null +++ b/packages/fedify/src/federation/tasks/enqueue.test.ts @@ -0,0 +1,1372 @@ +import { test } from "@fedify/fixture"; +import { configure, type LogRecord, reset } from "@logtape/logtape"; +import { delay } from "es-toolkit"; +import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; +import { + baseOptions, + makeSchema, + MockQueue, + numberSchema, + stringSchema, +} from "../../testing/mod.ts"; +import { + type KvKey, + type KvStore, + type KvStoreListEntry, + type KvStoreSetOptions, + MemoryKvStore, +} from "../kv.ts"; +import { createFederation } from "../middleware.ts"; +import { + type MessageQueue, + type MessageQueueEnqueueOptions, + ParallelMessageQueue, +} from "../mq.ts"; +import type { TaskMessage } from "../queue.ts"; + +/** + * A {@link KvStore} that delegates to an in-memory store but deliberately + * omits `cas`, so that `kv.cas == null`. This drives the deduplication + * fallback branches that fire when no conditional-write primitive exists. + */ +class CaslessKvStore implements KvStore { + readonly inner = new MemoryKvStore(); + get(key: KvKey): Promise { + return this.inner.get(key); + } + set(key: KvKey, value: unknown, options?: KvStoreSetOptions): Promise { + 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); + } + // No `cas`: the fallback branch is reached precisely when `kv.cas == null`. +} + +async function collectKeys(kv: KvStore, prefix: KvKey): Promise { + const keys: KvKey[] = []; + for await (const { key } of kv.list(prefix)) keys.push(key); + return keys; +} + +const TASK_DEDUP_PREFIX: KvKey = ["_fedify", "taskDeduplication"]; +const ACTIVITY_IDEMPOTENCE_PREFIX: KvKey = ["_fedify", "activityIdempotence"]; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +test("enqueueTasks() validation and dispatch", async (t) => { + await t.step("rejects an invalid payload at enqueue", async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("strictly-typed", { + schema: numberSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await rejects( + // deno-lint-ignore no-explicit-any + () => ctx.enqueueTask(task, "not a number" as any), + { name: "TypeError", message: /Task data failed schema validation/ }, + ); + strictEqual(queue.enqueued.length, 0); + }); + + await t.step("stamps the message envelope", async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("envelope", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "payload"); + strictEqual(queue.enqueued.length, 1); + const { message } = queue.enqueued[0]; + strictEqual(message.type, "task"); + strictEqual(message.taskName, "envelope"); + // encodeTaskMessage stamps the context's origin (no trailing slash). + strictEqual(message.baseUrl, "https://example.com"); + strictEqual(message.attempt, 0); + ok(UUID_RE.test(message.id)); + ok(typeof message.data === "string" && message.data.length > 0); + // `started` is a serialized Temporal.Instant. + ok(Temporal.Instant.from(message.started) instanceof Temporal.Instant); + // propagation.inject always populates a (possibly empty) carrier object. + ok( + typeof message.traceContext === "object" && message.traceContext != null, + ); + }); + + await t.step("passes delay and orderingKey through", async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("delayed", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "payload", { + delay: { seconds: 30 }, + orderingKey: "user:alice", + }); + strictEqual(queue.enqueued.length, 1); + const { message, options } = queue.enqueued[0]; + strictEqual(message.taskName, "delayed"); + strictEqual(message.orderingKey, "user:alice"); + strictEqual(message.attempt, 0); + ok(options?.delay instanceof Temporal.Duration); + strictEqual(options.delay.total("second"), 30); + strictEqual(options.orderingKey, "user:alice"); + }); + + await t.step( + "starts the task worker on first enqueue without startQueue()", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + manuallyStartQueue: false, + queue: { task: queue }, + }); + const task = federation.defineTask("auto-start", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + // An app that only uses the custom task API never sends an activity, + // so enqueueTask() itself must start the worker like the other + // enqueue paths do; otherwise tasks pile up unprocessed forever. + await ctx.enqueueTask(task, "first"); + strictEqual(queue.listenCount, 1); + // The started flag keeps a second enqueue from re-listening. + await ctx.enqueueTask(task, "second"); + strictEqual(queue.listenCount, 1); + strictEqual(queue.enqueued.length, 2); + }, + ); + + await t.step("throws when the resolved task queue is null", async () => { + // No queue is configured at all, so resolveTaskQueue() returns null and + // the enqueue pipeline must fail fast before encoding any payload. + const federation = createFederation({ ...baseOptions }); + const task = federation.defineTask("queueless", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await rejects( + () => ctx.enqueueTask(task, "data"), + { name: "TypeError", message: /No message queue is configured/ }, + ); + }); + + await t.step( + "rejects a handle from another federation at enqueue", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const other = createFederation({ + ...baseOptions, + queue: { task: new MockQueue() }, + }); + const foreignTask = other.defineTask("foreign", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await rejects( + () => ctx.enqueueTask(foreignTask, "data"), + { name: "TypeError", message: /is not defined on this federation/ }, + ); + strictEqual(queue.enqueued.length, 0); + }, + ); + + await t.step( + "rejects a same-named handle from another federation", + async () => { + // Name lookup alone cannot tell a foreign handle apart once both + // instances define the same task name: the local context would + // encode under the *schema carried by the foreign handle*, so a + // payload the local schema rejects would enqueue anyway, only to be + // dropped by the worker decoding under the local schema. Both + // instances share TContextData = void, so the phantom-brand check + // cannot reject this at compile time; the handle-identity guard is + // the only defense. + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + let called = 0; + federation.defineTask("rename", { + schema: numberSchema, // the local "rename" takes a number… + handler: () => { + called++; + }, + }); + const other = createFederation({ + ...baseOptions, + queue: { task: new MockQueue() }, + }); + // …while the other instance's "rename" takes a string: + const foreignTask = other.defineTask("rename", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await rejects( + () => ctx.enqueueTask(foreignTask, "not a number"), + { name: "TypeError", message: /is not defined on this federation/ }, + ); + strictEqual(queue.enqueued.length, 0); + strictEqual(called, 0); + }, + ); + + await t.step( + "enqueueTaskMany() uses enqueueMany when available", + async () => { + const queue = new MockQueue({ supportsEnqueueMany: true }); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("bulk", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTaskMany(task, ["a", "b", "c"]); + strictEqual(queue.enqueued.length, 0); + strictEqual(queue.enqueuedMany.length, 1); + strictEqual(queue.enqueuedMany[0].messages.length, 3); + }, + ); + + await t.step( + "enqueueTaskMany() falls back to parallel enqueues", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("bulk-fallback", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTaskMany(task, ["a", "b"]); + strictEqual(queue.enqueued.length, 2); + }, + ); + + await t.step( + "enqueueTaskMany() with no payloads touches no queue", + async () => { + const queue = new MockQueue({ supportsEnqueueMany: true }); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("bulk-empty", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTaskMany(task, []); + strictEqual(queue.enqueued.length, 0); + strictEqual(queue.enqueuedMany.length, 0); + }, + ); +}); + +test("task deduplication", async (t) => { + await t.step( + "forwards the key to a nativeDeduplication queue without writing KV", + async () => { + const queue = new MockQueue({ nativeDeduplication: true }); + const kv = new MemoryKvStore(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + }); + const task = federation.defineTask("native-dedup", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "payload", { deduplicationKey: "k" }); + strictEqual(queue.enqueued.length, 1); + strictEqual(queue.enqueued[0].options?.deduplicationKey, "k"); + // The backend owns the check, so Fedify must not write any KV marker. + strictEqual((await collectKeys(kv, TASK_DEDUP_PREFIX)).length, 0); + }, + ); + + await t.step( + "skips a second enqueue with the same key within the TTL", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + kv: new MemoryKvStore(), + queue: { task: queue }, + }); + const task = federation.defineTask("kv-dedup", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "first", { deduplicationKey: "k" }); + await ctx.enqueueTask(task, "second", { deduplicationKey: "k" }); + strictEqual(queue.enqueued.length, 1); + strictEqual(queue.enqueued[0].message.taskName, "kv-dedup"); + // A non-native queue never receives a key it would ignore. + strictEqual(queue.enqueued[0].options?.deduplicationKey, undefined); + }, + ); + + await t.step( + "re-enqueues with the same key after the TTL expires", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + kv: new MemoryKvStore(), + queue: { task: queue }, + taskDeduplicationTtl: { milliseconds: 100 }, + }); + const task = federation.defineTask("kv-dedup-ttl", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "first", { deduplicationKey: "k" }); + strictEqual(queue.enqueued.length, 1); + // Wait comfortably past the 100 ms TTL so the marker expires. + await delay(300); + await ctx.enqueueTask(task, "second", { deduplicationKey: "k" }); + strictEqual(queue.enqueued.length, 2); + }, + ); + + await t.step( + 'rejects with TypeError when fallback is "closed" and no cas exists', + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + kv: new CaslessKvStore(), + queue: { task: queue }, + taskDeduplicationFallback: "closed", + }); + const task = federation.defineTask("closed-fallback", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await rejects( + () => ctx.enqueueTask(task, "payload", { deduplicationKey: "k" }), + { name: "TypeError" }, + ); + strictEqual(queue.enqueued.length, 0); + }, + ); + + await t.step( + 'proceeds when fallback is "open" and no cas exists', + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + kv: new CaslessKvStore(), + queue: { task: queue }, + taskDeduplicationFallback: "open", + }); + const task = federation.defineTask("open-fallback", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "payload", { deduplicationKey: "k" }); + strictEqual(queue.enqueued.length, 1); + // Best-effort fallback never forwards the key to a non-native queue. + strictEqual(queue.enqueued[0].options?.deduplicationKey, undefined); + }, + ); + + await t.step( + "writes only under taskDeduplication, never activityIdempotence", + async () => { + const queue = new MockQueue(); + const kv = new MemoryKvStore(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + }); + const task = federation.defineTask("prefix-isolation", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "payload", { deduplicationKey: "k" }); + strictEqual((await collectKeys(kv, TASK_DEDUP_PREFIX)).length, 1); + strictEqual( + (await collectKeys(kv, ACTIVITY_IDEMPOTENCE_PREFIX)).length, + 0, + ); + }, + ); + + await t.step("applies one batch-level key to enqueueTaskMany", async () => { + const queue = new MockQueue({ supportsEnqueueMany: true }); + const federation = createFederation({ + ...baseOptions, + kv: new MemoryKvStore(), + queue: { task: queue }, + }); + const task = federation.defineTask("batch-dedup", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTaskMany(task, ["a", "b", "c"], { + deduplicationKey: "batch", + }); + await ctx.enqueueTaskMany(task, ["a", "b", "c"], { + deduplicationKey: "batch", + }); + // First batch enqueues all three; the second is skipped entirely. + strictEqual(queue.enqueuedMany.length, 1); + strictEqual(queue.enqueuedMany[0].messages.length, 3); + }); +}); + +test( + "task deduplication validates every payload before reserving the key", + async () => { + const queue = new MockQueue(); + const kv = new MemoryKvStore(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + }); + const task = federation.defineTask("dedup-validation", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + + // A rejected payload must neither enqueue nor consume the key. + await rejects(() => + ctx.enqueueTask(task, 123 as unknown as string, { + deduplicationKey: "k", + }) + ); + strictEqual(queue.enqueued.length, 0); + deepStrictEqual(await collectKeys(kv, TASK_DEDUP_PREFIX), []); + + // The same key must remain usable by the first valid enqueue. + await ctx.enqueueTask(task, "valid", { deduplicationKey: "k" }); + strictEqual(queue.enqueued.length, 1); + deepStrictEqual( + await collectKeys(kv, TASK_DEDUP_PREFIX), + [[...TASK_DEDUP_PREFIX, "k"]], + ); + + // Once the valid enqueue reserves it, the same key must deduplicate. + await ctx.enqueueTask(task, "duplicate", { deduplicationKey: "k" }); + strictEqual(queue.enqueued.length, 1); + }, +); + +test( + "native task batch deduplication is one enqueueMany operation per call", + async () => { + class NativeBatchDeduplicatingQueue implements MessageQueue { + readonly nativeDeduplication = true; + readonly #seen = new Set(); + readonly attempts: { + messages: readonly TaskMessage[]; + options?: MessageQueueEnqueueOptions; + }[] = []; + readonly accepted: { + messages: readonly TaskMessage[]; + options?: MessageQueueEnqueueOptions; + }[] = []; + + enqueue(): Promise { + throw new Error("A multi-item native batch must use enqueueMany()."); + } + + enqueueMany( + messages: readonly TaskMessage[], + options?: MessageQueueEnqueueOptions, + ): Promise { + const key = options?.deduplicationKey; + if (key == null) { + throw new TypeError( + "Native batch enqueue requires a deduplication key.", + ); + } + this.attempts.push({ messages, options }); + if (this.#seen.has(key)) return Promise.resolve(); + this.#seen.add(key); + this.accepted.push({ messages, options }); + return Promise.resolve(); + } + + listen(): Promise { + return Promise.resolve(); + } + } + + const queue = new NativeBatchDeduplicatingQueue(); + const kv = new MemoryKvStore(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + }); + const task = federation.defineTask("native-batch-dedup", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + + await ctx.enqueueTaskMany(task, ["a1", "a2", "a3"], { + deduplicationKey: "batch-a", + }); + await ctx.enqueueTaskMany( + task, + ["duplicate1", "duplicate2", "duplicate3"], + { + deduplicationKey: "batch-a", + }, + ); + await ctx.enqueueTaskMany(task, ["b1", "b2", "b3"], { + deduplicationKey: "batch-b", + }); + + // Every API call reaches the backend exactly once, with one key governing + // all three messages. The backend accepts complete batches or none. + strictEqual(queue.attempts.length, 3); + deepStrictEqual( + queue.attempts.map(({ messages }) => messages.length), + [3, 3, 3], + ); + deepStrictEqual( + queue.attempts.map(({ options }) => options?.deduplicationKey), + ["batch-a", "batch-a", "batch-b"], + ); + strictEqual(queue.accepted.length, 2); + deepStrictEqual( + queue.accepted.map(({ messages }) => messages.length), + [3, 3], + ); + deepStrictEqual( + queue.accepted.map(({ options }) => options?.deduplicationKey), + ["batch-a", "batch-b"], + ); + deepStrictEqual(await collectKeys(kv, TASK_DEDUP_PREFIX), []); + }, +); + +test( + "native task batch deduplication rejects without enqueueMany", + async () => { + const queue = new MockQueue({ nativeDeduplication: true }); + const kv = new MemoryKvStore(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + }); + const task = federation.defineTask("native-batch-without-enqueue-many", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + + await rejects( + () => + ctx.enqueueTaskMany(task, ["a", "b", "c"], { + deduplicationKey: "batch", + }), + { name: "TypeError", message: /enqueueMany/ }, + ); + + // Reject before any partial enqueue or fallback KV write. Silently + // dropping the key from items 2..n cannot satisfy these assertions. + strictEqual(queue.enqueued.length, 0); + strictEqual(queue.enqueuedMany.length, 0); + deepStrictEqual(await collectKeys(kv, TASK_DEDUP_PREFIX), []); + + // A one-item batch is representable by enqueue() and must remain valid. + await ctx.enqueueTaskMany(task, ["single"], { + deduplicationKey: "single", + }); + strictEqual(queue.enqueued.length, 1); + strictEqual(queue.enqueued[0].options?.deduplicationKey, "single"); + }, +); + +test( + "deduplication - native batch capability errors precede payload validation", + async () => { + let validationCalls = 0; + const schema = makeSchema((data): data is string => { + validationCalls++; + return typeof data === "string"; + }); + const queue = new MockQueue({ nativeDeduplication: true }); + const kv = new MemoryKvStore(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + }); + const task = federation.defineTask("native-batch-capability-order", { + schema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + + let caught: unknown; + try { + await ctx.enqueueTaskMany( + task, + [1, 2, 3] as unknown as readonly string[], + { deduplicationKey: "batch" }, + ); + } catch (error) { + caught = error; + } + + // The queue capability makes this request impossible regardless of the + // payload, so no user-supplied validator may run first. + strictEqual(validationCalls, 0); + ok(caught instanceof TypeError); + ok(caught.message.includes("enqueueMany")); + strictEqual(queue.enqueued.length, 0); + strictEqual(queue.enqueuedMany.length, 0); + deepStrictEqual(await collectKeys(kv, TASK_DEDUP_PREFIX), []); + }, +); + +test( + "closed deduplication fallback errors precede payload validation", + async () => { + let validationCalls = 0; + const schema = makeSchema((data): data is string => { + validationCalls++; + return typeof data === "string"; + }); + const queue = new MockQueue(); + const kv = new CaslessKvStore(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + taskDeduplicationFallback: "closed", + }); + const task = federation.defineTask("closed-fallback-order", { + schema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + + let caught: unknown; + try { + await ctx.enqueueTask( + task, + 1 as unknown as string, + { deduplicationKey: "k" }, + ); + } catch (error) { + caught = error; + } + + // Closed fallback is a configuration-level rejection. It must be + // deterministic and independent of user payload validation. + strictEqual(validationCalls, 0); + ok(caught instanceof TypeError); + ok(caught.message.includes("conditional write")); + strictEqual(queue.enqueued.length, 0); + strictEqual(queue.enqueuedMany.length, 0); + deepStrictEqual(await collectKeys(kv, TASK_DEDUP_PREFIX), []); + }, +); + +/** + * A {@link MessageQueue} that fails its first enqueue—single or batch—with a + * transient error, then records every later enqueue. One class covers both the + * `enqueue()` and `enqueueMany()` rollback paths; each test instantiates its own + * copy, so the one-shot `#failNext` flag never leaks between them. + */ +class FlakyQueue implements MessageQueue { + readonly nativeDeduplication = false; + #failNext = true; + readonly enqueued: TaskMessage[] = []; + readonly enqueuedMany: TaskMessage[][] = []; + + #failOnce(): boolean { + if (!this.#failNext) return false; + this.#failNext = false; + return true; + } + + enqueue(message: TaskMessage): Promise { + if (this.#failOnce()) { + return Promise.reject(new Error("transient backend failure")); + } + this.enqueued.push(message); + return Promise.resolve(); + } + + enqueueMany(messages: readonly TaskMessage[]): Promise { + if (this.#failOnce()) { + return Promise.reject(new Error("transient backend failure")); + } + this.enqueuedMany.push([...messages]); + return Promise.resolve(); + } + + listen(): Promise { + return Promise.resolve(); + } +} + +test( + "a failed enqueue rolls back its dedup marker so the retry is not dropped", + async () => { + const queue = new FlakyQueue(); + const kv = new MemoryKvStore(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + }); + const task = federation.defineTask("flaky-enqueue", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + const markerKey: KvKey = [...TASK_DEDUP_PREFIX, "k"]; + + // First enqueue: the marker is claimed, then dispatch rejects. + await rejects( + () => ctx.enqueueTask(task, "first", { deduplicationKey: "k" }), + { message: /transient backend failure/ }, + ); + strictEqual(queue.enqueued.length, 0); + strictEqual(await kv.get(markerKey), undefined); + + // The retry (queue healthy again) must enqueue the task, not be dropped. + await ctx.enqueueTask(task, "first-retry", { deduplicationKey: "k" }); + strictEqual(queue.enqueued.length, 1); + + // A successful retry must keep its marker so later duplicates are dropped. + ok(await kv.get(markerKey) != null); + await ctx.enqueueTask(task, "duplicate", { deduplicationKey: "k" }); + strictEqual(queue.enqueued.length, 1); + }, +); + +test( + "a failed batch enqueue rolls back its dedup marker so the retry is not " + + "dropped", + async () => { + const queue = new FlakyQueue(); + const kv = new MemoryKvStore(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + }); + const task = federation.defineTask("flaky-batch-enqueue", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + const markerKey: KvKey = [...TASK_DEDUP_PREFIX, "batch"]; + + await rejects( + () => + ctx.enqueueTaskMany(task, ["first", "second"], { + deduplicationKey: "batch", + }), + { message: /transient backend failure/ }, + ); + strictEqual(queue.enqueuedMany.length, 0); + // Asserted via get() for the same reason as the single-item rollback test + // above (MemoryKvStore.cas leaves a `value: undefined` entry). + strictEqual(await kv.get(markerKey), undefined); + + await ctx.enqueueTaskMany(task, ["first-retry", "second-retry"], { + deduplicationKey: "batch", + }); + strictEqual(queue.enqueuedMany.length, 1); + strictEqual(queue.enqueuedMany[0].length, 2); + ok(await kv.get(markerKey) != null); + + await ctx.enqueueTaskMany(task, ["duplicate-first", "duplicate-second"], { + deduplicationKey: "batch", + }); + strictEqual(queue.enqueuedMany.length, 1); + }, +); + +test( + "a stale rollback does not clear a marker another enqueue re-claimed", + async () => { + const kv = new MemoryKvStore(); + const markerKey: KvKey = [...TASK_DEDUP_PREFIX, "k"]; + let signalFirstEntered!: () => void; + const firstEntered = new Promise((resolve) => { + signalFirstEntered = resolve; + }); + let releaseFirst!: () => void; + const firstReleased = new Promise((resolve) => { + releaseFirst = resolve; + }); + class BlockingThenFailingQueue implements MessageQueue { + readonly nativeDeduplication = false; + #calls = 0; + async enqueue(): Promise { + this.#calls++; + if (this.#calls === 1) { + signalFirstEntered(); + await firstReleased; + throw new Error("transient backend failure"); + } + } + listen(): Promise { + return Promise.resolve(); + } + } + const queue = new BlockingThenFailingQueue(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + taskDeduplicationTtl: { milliseconds: 1 }, + }); + const task = federation.defineTask("stale-rollback", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + + const first = ctx.enqueueTask(task, "first", { deduplicationKey: "k" }); + await firstEntered; + await delay(20); + await ctx.enqueueTask(task, "second", { deduplicationKey: "k" }); + const secondToken = await kv.get(markerKey); + ok(secondToken != null); + releaseFirst(); + await rejects(() => first, { message: /transient backend failure/ }); + strictEqual(await kv.get(markerKey), secondToken); + }, +); + +test( + "a multi-item batch dedup without enqueueMany is rejected on the cas path", + async () => { + const queue = new MockQueue(); + const kv = new MemoryKvStore(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + }); + const task = federation.defineTask("cas-batch-without-enqueue-many", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + + await rejects( + () => + ctx.enqueueTaskMany(task, ["a", "b", "c"], { + deduplicationKey: "batch", + }), + { name: "TypeError", message: /enqueueMany/ }, + ); + strictEqual(queue.enqueued.length, 0); + strictEqual(queue.enqueuedMany.length, 0); + deepStrictEqual(await collectKeys(kv, TASK_DEDUP_PREFIX), []); + + await ctx.enqueueTaskMany(task, ["single"], { deduplicationKey: "single" }); + strictEqual(queue.enqueued.length, 1); + }, +); + +test( + "a failed rollback is swallowed; the original enqueue error reaches the caller", + async () => { + class ClearFailingKvStore implements KvStore { + readonly inner = new MemoryKvStore(); + clearAttempts = 0; + get(key: KvKey): Promise { + return this.inner.get(key); + } + set( + key: KvKey, + value: unknown, + options?: KvStoreSetOptions, + ): Promise { + 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); + } + cas( + key: KvKey, + expectedValue: unknown, + newValue: unknown, + options?: KvStoreSetOptions, + ): Promise { + if (newValue === undefined) { + this.clearAttempts++; + return Promise.reject(new Error("kv cas clear failed")); + } + return this.inner.cas(key, expectedValue, newValue, options); + } + } + + const queue = new FlakyQueue(); + const kv = new ClearFailingKvStore(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + }); + const task = federation.defineTask("rollback-failure", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + + await rejects( + () => ctx.enqueueTask(task, "first", { deduplicationKey: "k" }), + { message: /transient backend failure/ }, + ); + strictEqual(queue.enqueued.length, 0); + strictEqual(kv.clearAttempts, 1); + }, +); + +/** + * A native-deduplication backend that drops repeat-key single enqueues and + * does **not** implement `enqueueMany`. Wrapping it in + * {@link ParallelMessageQueue} used to fan a batch out to one `enqueue()` per + * message, all carrying the same `deduplicationKey`, so the backend collapsed + * the whole batch onto its first message. + */ +class NativeDedupNoBulkQueue implements MessageQueue { + readonly nativeDeduplication = true; + readonly #seen = new Set(); + readonly enqueued: { + message: TaskMessage; + options?: MessageQueueEnqueueOptions; + }[] = []; + + enqueue( + message: TaskMessage, + options?: MessageQueueEnqueueOptions, + ): Promise { + const key = options?.deduplicationKey; + if (key != null) { + if (this.#seen.has(key)) return Promise.resolve(); + this.#seen.add(key); + } + this.enqueued.push({ message, options }); + return Promise.resolve(); + } + + listen(): Promise { + return Promise.resolve(); + } +} + +test( + "a deduplicated batch over a ParallelMessageQueue wrapping a native, " + + "no-enqueueMany backend is rejected, not collapsed", + async () => { + const backend = new NativeDedupNoBulkQueue(); + const queue = new ParallelMessageQueue(backend, 5); + const kv = new MemoryKvStore(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + }); + const task = federation.defineTask("parallel-native-no-bulk", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + + // The wrapper cannot enqueue the batch atomically under one key, so the + // multi-item batch must be rejected rather than silently collapsed to one. + await rejects( + () => + ctx.enqueueTaskMany(task, ["a", "b", "c"], { + deduplicationKey: "batch", + }), + { name: "TypeError", message: /enqueueMany/ }, + ); + strictEqual(backend.enqueued.length, 0); + // A native plan never touches KV, even when it rejects. + deepStrictEqual(await collectKeys(kv, TASK_DEDUP_PREFIX), []); + + // A single-item batch needs no bulk path, so the key is still forwarded. + await ctx.enqueueTaskMany(task, ["solo"], { deduplicationKey: "solo" }); + strictEqual(backend.enqueued.length, 1); + strictEqual(backend.enqueued[0].options?.deduplicationKey, "solo"); + }, +); + +test( + "a deduplicated batch over a ParallelMessageQueue wrapping a native " + + "enqueueMany backend forwards the key atomically", + async () => { + class NativeBatchQueue implements MessageQueue { + readonly nativeDeduplication = true; + readonly #seen = new Set(); + readonly batches: { + messages: readonly TaskMessage[]; + options?: MessageQueueEnqueueOptions; + }[] = []; + + enqueue(): Promise { + throw new Error("A multi-item native batch must use enqueueMany()."); + } + + enqueueMany( + messages: readonly TaskMessage[], + options?: MessageQueueEnqueueOptions, + ): Promise { + const key = options?.deduplicationKey; + if (key != null && this.#seen.has(key)) return Promise.resolve(); + if (key != null) this.#seen.add(key); + this.batches.push({ messages, options }); + return Promise.resolve(); + } + + listen(): Promise { + return Promise.resolve(); + } + } + + const backend = new NativeBatchQueue(); + const queue = new ParallelMessageQueue(backend, 5); + const kv = new MemoryKvStore(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + }); + const task = federation.defineTask("parallel-native-bulk", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + + await ctx.enqueueTaskMany(task, ["a", "b", "c"], { + deduplicationKey: "batch", + }); + // The duplicate batch is dropped by the backend's native check. + await ctx.enqueueTaskMany(task, ["x", "y", "z"], { + deduplicationKey: "batch", + }); + + strictEqual(backend.batches.length, 1); + strictEqual(backend.batches[0].messages.length, 3); + strictEqual(backend.batches[0].options?.deduplicationKey, "batch"); + // The native path never writes KV, even through the wrapper. + deepStrictEqual(await collectKeys(kv, TASK_DEDUP_PREFIX), []); + }, +); + +test( + 'an "open" fallback fans out a multi-item batch without enqueueMany ' + + "instead of rejecting it", + async () => { + const queue = new MockQueue(); // no enqueueMany, not native + const kv = new CaslessKvStore(); // no cas + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + taskDeduplicationFallback: "open", + }); + const task = federation.defineTask("open-batch-fanout", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + + // With neither native dedup nor cas under "open", the batch proceeds by + // fanning out every item; it must not throw the enqueueMany requirement. + await ctx.enqueueTaskMany(task, ["a", "b", "c"], { + deduplicationKey: "batch", + }); + strictEqual(queue.enqueued.length, 3); + for (const { options } of queue.enqueued) { + strictEqual(options?.deduplicationKey, undefined); + } + // The open path records nothing in the key–value store. + deepStrictEqual(await collectKeys(kv.inner, TASK_DEDUP_PREFIX), []); + }, +); + +test( + 'an "open" fallback logs a debug record when it ignores the key', + async () => { + const records: LogRecord[] = []; + await reset(); + try { + await configure({ + sinks: { + buffer(record: LogRecord): void { + records.push(record); + }, + }, + filters: {}, + loggers: [ + { category: [], lowestLevel: "debug", sinks: ["buffer"] }, + ], + }); + + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + kv: new CaslessKvStore(), + queue: { task: queue }, + taskDeduplicationFallback: "open", + }); + const task = federation.defineTask("open-debug-log", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "payload", { deduplicationKey: "k" }); + + const matched = records.filter((record) => + record.level === "debug" && + record.properties.deduplicationKey === "k" && + record.properties.taskName === "open-debug-log" + ); + strictEqual(matched.length, 1); + } finally { + await reset(); + } + }, +); + +test( + "two concurrent enqueues sharing a key: exactly one wins the cas claim", + async () => { + let signalEntered!: () => void; + const entered = new Promise((resolve) => { + signalEntered = resolve; + }); + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + class BlockingQueue implements MessageQueue { + readonly nativeDeduplication = false; + readonly enqueued: TaskMessage[] = []; + #first = true; + async enqueue(message: TaskMessage): Promise { + if (this.#first) { + this.#first = false; + signalEntered(); + await released; + } + this.enqueued.push(message); + } + listen(): Promise { + return Promise.resolve(); + } + } + const queue = new BlockingQueue(); + const kv = new MemoryKvStore(); + const federation = createFederation({ + ...baseOptions, + kv, + queue: { task: queue }, + }); + const task = federation.defineTask("concurrent-claim", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + + // The first enqueue claims the marker, then blocks inside the queue. + const first = ctx.enqueueTask(task, "first", { deduplicationKey: "k" }); + await entered; + // With the first still in flight, the second must lose the cas claim and + // skip the queue entirely. + await ctx.enqueueTask(task, "second", { deduplicationKey: "k" }); + release(); + await first; + strictEqual(queue.enqueued.length, 1); + + // The winner kept its marker, so a later duplicate is still dropped. + await ctx.enqueueTask(task, "third", { deduplicationKey: "k" }); + strictEqual(queue.enqueued.length, 1); + }, +); + +test( + "a native enqueue forwards orderingKey and deduplicationKey together", + async () => { + const queue = new MockQueue({ nativeDeduplication: true }); + const federation = createFederation({ + ...baseOptions, + kv: new MemoryKvStore(), + queue: { task: queue }, + }); + const task = federation.defineTask("native-both-keys", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "payload", { + orderingKey: "user:alice", + deduplicationKey: "dedup:alice", + }); + strictEqual(queue.enqueued.length, 1); + const { message, options } = queue.enqueued[0]; + strictEqual(message.orderingKey, "user:alice"); + strictEqual(options?.orderingKey, "user:alice"); + strictEqual(options?.deduplicationKey, "dedup:alice"); + }, +); diff --git a/packages/fedify/src/federation/tasks/enqueue.ts b/packages/fedify/src/federation/tasks/enqueue.ts new file mode 100644 index 000000000..6500301af --- /dev/null +++ b/packages/fedify/src/federation/tasks/enqueue.ts @@ -0,0 +1,294 @@ +/** + * The enqueue pipeline for custom background tasks. `ContextImpl.enqueueTask` + * and `ContextImpl.enqueueTaskMany` delegate to {@link enqueueTasks} so the + * handle validation, deduplication planning, payload encoding, and queue + * dispatch live in one cohesive place instead of one oversized method. + * + * @module + */ +import { getLogger } from "@logtape/logtape"; +import { context, propagation } from "@opentelemetry/api"; +import type { KvKey } from "../kv.ts"; +import type { FederationImpl } from "../middleware.ts"; +import type { MessageQueue } from "../mq.ts"; +import type { TaskMessage } from "../queue.ts"; +import type TaskCodec from "./codec.ts"; +import type { TaskDefinition, TaskEnqueueOptions } from "./task.ts"; + +/** + * The slice of an enqueueing {@link Context} that {@link enqueueTasks} needs: + * its federation plus the few values that are the context's own. `ContextImpl` + * assembles it from itself, so the enqueue pipeline stays out of that class. + * @template TContextData The context data to pass to the {@link Context}. + * @internal + */ +interface EnqueueTasksContext { + /** + * The federation that owns the task registry, queue resolution and start, + * the key-value store, and the deduplication configuration. The public + * {@link Federation} interface exposes none of these, so the concrete + * {@link FederationImpl} is required. + */ + readonly federation: FederationImpl; + + /** The codec, bound to this context's loaders, that encodes payloads. */ + readonly codec: TaskCodec; + + /** The context's origin, stamped onto each message as its `baseUrl`. */ + readonly origin: string; + + /** The context data handed to the queue worker when it auto-starts. */ + readonly data: TContextData; +} + +/** + * Validates the task handle, plans deduplication, encodes every payload, then + * dispatches the resulting messages to the queue. A single item flows through + * the same pipeline as a batch, so {@link Context.enqueueTask} and + * {@link Context.enqueueTaskMany} share one implementation. + * @template TContextData The context data to pass to the {@link Context}. + * @template TData The type of the task payload, inferred from the task's schema. + * @param ctx The enqueueing dependencies assembled by `ContextImpl`. + * @param task The handle returned by `defineTask()`. + * @param items The payloads to enqueue, in order. + * @param options The enqueue options governing delay, ordering, and dedup. + * @internal + */ +const enqueueTasks = ( + ctx: EnqueueTasksContext, +) => + async function ( + task: TaskDefinition, + items: readonly TData[], + options: TaskEnqueueOptions, + ): Promise { + const def = ctx.federation.taskDefinitions.get(task.name); + if (def == null || def.handle !== task) { + throw new TypeError( + `Task ${ + JSON.stringify(task.name) + } is not defined on this federation; ` + + "pass a handle returned by its defineTask().", + ); + } + const queue = ctx.federation.resolveTaskQueue(task.name); + if (queue == null) { + throw new TypeError( + "No message queue is configured for tasks; pass `queue` to " + + "createFederation() or to defineTask().", + ); + } + if (items.length < 1) return; + const plan = planDeduplication( + ctx, + queue, + task.name, + options, + items.length, + ); + const messages: TaskMessage[] = await Promise.all( + items.map(encodeTaskMessage(ctx.codec, ctx.origin, task, options)), + ); + const claim = await claimDeduplication(ctx, plan, task.name); + if (!claim.proceed) return; + if (!ctx.federation.manuallyStartQueue) { + ctx.federation._startQueueInternal(ctx.data); + } + try { + await dispatch(queue, messages, { + delay: getDurationIfDefined(options.delay), + orderingKey: options.orderingKey, + deduplicationKey: claim.forwardedDeduplicationKey, + }); + } catch (error) { + if (claim.rollback != null) { + try { + await claim.rollback(); + } catch (rollbackError) { + logger.warn( + "Failed to roll back the deduplication marker for task " + + "{taskName} after a failed enqueue; it will expire by TTL. " + + "{rollbackError}", + { taskName: task.name, rollbackError }, + ); + } + } + throw error; + } + }; + +export default enqueueTasks; + +const getDurationIfDefined = (item: Temporal.DurationLike | undefined) => + item == null ? undefined : Temporal.Duration.from(item); + +/** + * The deduplication strategy chosen for an enqueue, settled before any payload + * is encoded so the fail-fast errors surface first. + */ +type DedupPlan = + | { readonly kind: "none" } + | { readonly kind: "native"; readonly key: string } + | { readonly kind: "cas"; readonly key: string } + | { readonly kind: "open"; readonly key: string }; + +/** + * Decides how a `deduplicationKey` (if any) is honored: forwarded to a native + * queue, claimed via `cas`, or—when neither is available—dropped or rejected + * per the federation's `taskDeduplicationFallback`. Throws the fail-fast + * `TypeError`s so they precede the encode. + */ +function planDeduplication( + ctx: EnqueueTasksContext, + queue: MessageQueue, + taskName: string, + options: TaskEnqueueOptions, + itemCount: number, +): DedupPlan { + if (options.deduplicationKey == null) return { kind: "none" }; + const key = options.deduplicationKey; + const native = queue.nativeDeduplication === true; + const canCas = ctx.federation.kv.cas != null; + if (itemCount > 1 && queue.enqueueMany == null && (native || canCas)) { + throw new TypeError( + `Task ${ + JSON.stringify(taskName) + } was enqueued as a batch with a deduplicationKey, but its message ` + + "queue does not implement enqueueMany; a multi-item batch cannot be " + + "deduplicated atomically without it. Implement enqueueMany on the " + + "queue, or enqueue the tasks individually with enqueueTask().", + ); + } + if (native) return { kind: "native", key }; + if (canCas) return { kind: "cas", key }; + if (ctx.federation.taskDeduplicationFallback === "closed") { + // No conditional write, closed: fail fast before any side effect. + throw new TypeError( + "deduplicationKey was set but the message queue does not declare " + + "nativeDeduplication and the key-value store exposes no " + + 'conditional write (cas); set taskDeduplicationFallback to "open" ' + + "to proceed without deduplication, or use a backend that " + + "supports it.", + ); + } + return { kind: "open", key }; +} + +/** + * Executes the planned deduplication once the payloads are encoded. A native + * plan forwards its key to the queue; a `cas` plan claims the marker and stops + * the enqueue when it loses the race; an `open` plan logs and proceeds. + * @returns Whether to proceed, and the key (if any) to forward to the queue. + */ +async function claimDeduplication( + ctx: EnqueueTasksContext, + plan: DedupPlan, + taskName: string, +): Promise<{ + proceed: boolean; + forwardedDeduplicationKey?: string; + /** + * Undoes a reserved marker when dispatch fails. Present only for a `cas` + * plan that won its claim; a failed enqueue calls it so the retry is not + * deduplicated against a task that never reached the queue. + */ + rollback?: () => Promise; +}> { + switch (plan.kind) { + case "native": + return { proceed: true, forwardedDeduplicationKey: plan.key }; + case "cas": { + const cacheKey = [ + ...ctx.federation.kvPrefixes.taskDeduplication, + plan.key, + ] satisfies KvKey; + const token = crypto.randomUUID(); + const won = await ctx.federation.kv.cas!(cacheKey, undefined, token, { + ttl: ctx.federation.taskDeduplicationTtl, + }); + if (!won) return { proceed: false }; + return { + proceed: true, + // Conditional clear: cas succeeds only while the stored value is still + // our token, so we never delete a marker another enqueue now owns. + rollback: async () => { + await ctx.federation.kv.cas!(cacheKey, token, undefined); + }, + }; + } + case "open": + logger.debug( + "deduplicationKey {deduplicationKey} for task {taskName} ignored: " + + "the message queue declares no nativeDeduplication and the " + + "key-value store has no cas; proceeding (taskDeduplicationFallback " + + 'is "open").', + { deduplicationKey: plan.key, taskName }, + ); /* falls through */ + case "none": + return { proceed: true }; + default: { + const _exhaustive: never = plan; + throw new TypeError( + `Unknown deduplication plan: ${JSON.stringify(_exhaustive)}`, + ); + } + } +} + +/** + * Sends the encoded messages to the queue, picking the bulk path when the + * queue implements `enqueueMany` and otherwise fanning out parallel single + * enqueues. The fan-out drops `deduplicationKey`, which is only ever set for a + * native plan that the bulk paths already cover. + */ +async function dispatch( + queue: MessageQueue, + messages: readonly TaskMessage[], + options: { + delay?: Temporal.Duration; + orderingKey?: string; + deduplicationKey?: string; + }, +): Promise { + if (messages.length === 1) { + await queue.enqueue(messages[0], options); + } else if (queue.enqueueMany != null) { + await queue.enqueueMany(messages, options); + } else { + const fanoutOptions = { + delay: options.delay, + orderingKey: options.orderingKey, + }; + await Promise.all(messages.map((m) => queue.enqueue(m, fanoutOptions))); + } +} + +/** + * Builds the per-payload encoder: validates and serializes the payload, then + * stamps the message envelope with a fresh id, the context's origin, and the + * active trace context. Curried so the batch encode reuses one bound encoder. + */ +const encodeTaskMessage = ( + codec: TaskCodec, + origin: string, + task: TaskDefinition, + options: TaskEnqueueOptions, +) => +async (data: TData): Promise => { + const encoded = await codec.encode(task.schema, data); + const carrier: Record = {}; + propagation.inject(context.active(), carrier); + return { + type: "task", + id: crypto.randomUUID(), + baseUrl: origin, + taskName: task.name, + data: encoded, + started: Temporal.Now.instant().toString(), + attempt: 0, + orderingKey: options.orderingKey, + traceContext: carrier, + }; +}; + +const logger = getLogger(["fedify", "federation", "task"]); diff --git a/packages/fedify/src/federation/tasks/mod.ts b/packages/fedify/src/federation/tasks/mod.ts new file mode 100644 index 000000000..62072ac45 --- /dev/null +++ b/packages/fedify/src/federation/tasks/mod.ts @@ -0,0 +1,18 @@ +/** + * The internal barrel for the custom background task API. Cross-directory + * consumers (*federation.ts*, *builder.ts*, *context.ts*, *middleware.ts*) + * import from this module, not the individual files. Only the public subset + * is re-exported from *federation/mod.ts*. + * + * @module + */ +export { default as TaskCodec } from "./codec.ts"; +export { default as enqueueTasks } from "./enqueue.ts"; +export type { + TaskDefinition, + TaskDefinitionInternal, + TaskDefinitionOptions, + TaskEnqueueOptions, + TaskHandler, + TaskRegistry, +} from "./task.ts"; diff --git a/packages/fedify/src/federation/tasks/task.ts b/packages/fedify/src/federation/tasks/task.ts new file mode 100644 index 000000000..a095e713d --- /dev/null +++ b/packages/fedify/src/federation/tasks/task.ts @@ -0,0 +1,206 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import type { Context } from "../context.ts"; +import type { MessageQueue } from "../mq.ts"; +import type { RetryPolicy } from "../retry.ts"; + +/** + * A callback that processes a custom background task. + * @template TContextData The context data to pass to the {@link Context}. + * @template TData The type of the task payload, inferred from the task's + * schema. + * @param ctx The context for the worker processing the task. + * @param data The decoded and validated task payload. + * @since 2.x.x + */ +export type TaskHandler = ( + ctx: Context, + data: TData, +) => Promise | void; + +/** + * Options for {@link TaskRegistry.defineTask}. + * @template TContextData The context data to pass to the {@link Context}. + * @template TSchema The [Standard Schema](https://standardschema.dev/) that + * validates the task payload. + * @since 2.x.x + */ +export interface TaskDefinitionOptions< + TContextData, + TSchema extends StandardSchemaV1, +> { + /** + * The [Standard Schema](https://standardschema.dev/) that validates the + * task payload. The payload type is inferred from this schema. + * + * The payload is validated twice: once at enqueue time (fail fast) and + * once at dequeue time (drift protection against payloads enqueued by an + * older deployment). Because the same schema runs on both sides, its + * validation must be idempotent: the validated output must itself be + * a valid input. Transforming schemas (e.g., Zod's `.transform()`) whose + * output differs in shape from their input are not supported. + */ + readonly schema: TSchema; + + /** + * The callback that processes the task on a background worker. + */ + readonly handler: TaskHandler< + TContextData, + StandardSchemaV1.InferOutput + >; + + /** + * The retry policy for this task. If omitted, the federation-wide + * task retry policy is used, which defaults to an exponential backoff + * policy. + */ + readonly retryPolicy?: RetryPolicy; + + /** + * A callback invoked when the {@link handler} throws an error, before + * a retry is scheduled. + * @param ctx The context for the worker processing the task. + * @param error The error thrown by the handler. + * @param data The decoded and validated task payload. + */ + readonly onError?: ( + ctx: Context, + error: unknown, + data: StandardSchemaV1.InferOutput, + ) => Promise | void; + + /** + * The message queue dedicated to this task. If omitted, the task is + * routed to the federation-wide task queue, falling back to the outbox + * queue (unless `taskQueueResolution: "strict"` is configured). + */ + readonly queue?: MessageQueue; +} + +/** + * Phantom key binding a {@link TaskDefinition} to its federation's context + * data type. Declared only—no value exists at runtime, and the symbol is + * not exported, so the marker stays out of user-facing completions. + */ +declare const contextDataBrand: unique symbol; + +/** + * The handle returned by {@link TaskRegistry.defineTask}. It carries the + * task name and schema so that {@link Context.enqueueTask} can validate the + * payload and infer its type at every call site. + * @template TContextData The context data to pass to the {@link Context}. + * @template TData The type of the task payload, inferred from the task's + * schema. + * @since 2.x.x + */ +export interface TaskDefinition { + /** + * The unique name of the task. + */ + readonly name: string; + + /** + * The [Standard Schema](https://standardschema.dev/) that validates the + * task payload. + */ + readonly schema: StandardSchemaV1; + + /** + * @internal Phantom marker binding the handle to its federation. + */ + readonly [contextDataBrand]?: TContextData; +} + +/** + * Registration of custom background tasks. Both {@link Federation} and + * {@link FederationBuilder} implement this interface. + * @template TContextData The context data to pass to the {@link Context}. + * @since 2.x.x + */ +export interface TaskRegistry { + /** + * Defines a custom background task. The returned handle is passed to + * {@link Context.enqueueTask} to enqueue the task. + * + * @example + * ``` typescript + * const sendDigest = federation.defineTask("sendDigest", { + * schema: digestSchema, + * handler: async (ctx, data) => { + * // …process the payload on a background worker… + * }, + * }); + * ``` + * + * @param name The unique name of the task. + * @param options The task definition options. The payload type is + * inferred from `options.schema`. + * @returns The handle to pass to {@link Context.enqueueTask}. + * @throws {TypeError} If a task with the same name is already defined. + */ + defineTask( + name: string, + options: TaskDefinitionOptions, + ): TaskDefinition>; +} + +/** + * Options for {@link Context.enqueueTask} and {@link Context.enqueueTaskMany}. + * @since 2.x.x + */ +export interface TaskEnqueueOptions { + /** + * The delay before the task is processed. No delay by default. + */ + readonly delay?: Temporal.DurationLike; + + /** + * An optional key that ensures tasks with the same ordering key are + * processed sequentially (one at a time). + */ + readonly orderingKey?: string; + + /** + * An optional key requesting at-most-once enqueue for tasks that share it. + * + * A queue with {@link MessageQueue.nativeDeduplication} `true` enforces it + * strictly; otherwise deduplication is best-effort via {@link KvStore.cas}, + * and {@link FederationOptions.taskDeduplicationFallback} decides whether a + * missing `cas` proceeds without deduplication or throws. + * + * For {@link Context.enqueueTaskMany}, one key governs the whole batch. When + * deduplication is actually applied—a native queue, or the key–value + * fallback through {@link KvStore.cas}—a multi-item batch with a + * `deduplicationKey` requires the queue to implement + * {@link MessageQueue.enqueueMany} so it enqueues atomically, or the call + * throws a `TypeError`. Under the `"open"` fallback with no `cas`, no marker + * is taken, so such a batch instead fans out without deduplication. + * + * @since 2.x.x + */ + readonly deduplicationKey?: string; +} + +/** + * The stored shape of a task definition, read at dispatch time. + * @internal + */ +export interface TaskDefinitionInternal { + readonly name: string; + readonly schema: StandardSchemaV1; + /** + * The exact handle object {@link TaskRegistry.defineTask} returned for + * this definition. {@link Context.enqueueTask} compares it by identity: + * another federation instance may define the same task name with a + * different schema, so name lookup alone cannot tell its handle apart. + */ + readonly handle: TaskDefinition; + readonly handler: TaskHandler; + readonly retryPolicy?: RetryPolicy; + readonly onError?: ( + ctx: Context, + error: unknown, + data: unknown, + ) => Promise | void; + readonly queue?: MessageQueue; +} diff --git a/packages/fedify/src/federation/tasks/tasks.test.ts b/packages/fedify/src/federation/tasks/tasks.test.ts new file mode 100644 index 000000000..8b4983f79 --- /dev/null +++ b/packages/fedify/src/federation/tasks/tasks.test.ts @@ -0,0 +1,637 @@ +import { mockDocumentLoader, test } from "@fedify/fixture"; +import { Note } from "@fedify/vocab"; +import { delay } from "es-toolkit"; +import { + deepStrictEqual, + ok, + rejects, + strictEqual, + throws, +} from "node:assert/strict"; +import { + baseOptions, + makeSchema, + MockQueue, + numberSchema, + stringSchema, +} from "../../testing/mod.ts"; +import { createFederationBuilder } from "../builder.ts"; +import type { Context } from "../context.ts"; +import type { Federatable } from "../federation.ts"; +import { createFederation, type FederationImpl } from "../middleware.ts"; +import { InProcessMessageQueue } from "../mq.ts"; +import type { TaskMessage } from "../queue.ts"; +import TaskCodec from "./codec.ts"; +import type { TaskDefinition, TaskRegistry } from "./task.ts"; + +type Assert = T; + +interface Envelope { + note: Note; + title: string; +} + +const envelopeSchema = makeSchema( + (data): data is Envelope => + typeof data === "object" && data != null && + (data as Envelope).note instanceof Note && + typeof (data as Envelope).title === "string", +); + +const makeTaskMessage = async ( + taskName: string, + data: unknown, + overrides: Partial = {}, +): Promise => ({ + type: "task", + id: crypto.randomUUID(), + baseUrl: "https://example.com/", + taskName, + data: await new TaskCodec({ contextLoader: mockDocumentLoader }) + .serialize(data), + started: Temporal.Now.instant().toString(), + attempt: 0, + traceContext: {}, + ...overrides, +}); + +async function waitFor( + predicate: () => boolean, + timeoutMs: number, +): Promise { + const started = Date.now(); + while (!predicate()) { + await delay(50); + if (Date.now() - started > timeoutMs) throw new Error("Timeout"); + } +} + +test("defineTask()", async (t) => { + await t.step("returns a handle carrying name and schema", () => { + const federation = createFederation({ + ...baseOptions, + queue: { task: new MockQueue() }, + }); + const task = federation.defineTask("greet", { + schema: stringSchema, + handler: () => {}, + }); + strictEqual(task.name, "greet"); + strictEqual(task.schema, stringSchema); + }); + + await t.step("throws on a duplicate name", () => { + const federation = createFederation({ + ...baseOptions, + queue: { task: new MockQueue() }, + }); + federation.defineTask("dup", { + schema: stringSchema, + handler: () => {}, + }); + throws( + () => + federation.defineTask("dup", { + schema: stringSchema, + handler: () => {}, + }), + { name: "TypeError", message: /already defined/ }, + ); + }); + + await t.step("accepts names that collide with Object.prototype", () => { + const federation = createFederation({ + ...baseOptions, + queue: { task: new MockQueue() }, + }) as FederationImpl; + // These names exist on Object.prototype; a plain-object registry would + // mistake them for already-defined tasks (`name in {}`) and would return + // an inherited method on lookup. + for (const name of ["constructor", "toString", "hasOwnProperty"]) { + const task = federation.defineTask(name, { + schema: stringSchema, + handler: () => {}, + }); + strictEqual(task.name, name); + strictEqual(federation.taskDefinitions.get(name)?.name, name); + } + // A genuine duplicate still throws. + throws( + () => + federation.defineTask("toString", { + schema: stringSchema, + handler: () => {}, + }), + { name: "TypeError", message: /already defined/ }, + ); + }); + + await t.step("build() clones the task registry", async () => { + const builder = createFederationBuilder(); + builder.defineTask("first", { + schema: stringSchema, + handler: () => {}, + }); + const f1 = await builder.build({ + ...baseOptions, + queue: { task: new MockQueue() }, + }) as FederationImpl; + builder.defineTask("second", { + schema: stringSchema, + handler: () => {}, + }); + const f2 = await builder.build({ + ...baseOptions, + queue: { task: new MockQueue() }, + }) as FederationImpl; + deepStrictEqual([...f1.taskDefinitions.keys()], ["first"]); + deepStrictEqual([...f2.taskDefinitions.keys()], ["first", "second"]); + // Defining on a built federation does not leak back into the builder: + f1.defineTask("third", { schema: stringSchema, handler: () => {} }); + deepStrictEqual([...f2.taskDefinitions.keys()], ["first", "second"]); + }); +}); + +test("task type-level guards", () => { + // Forward-compat seam: Federatable must remain assignable to TaskRegistry, + // so a future Worker can implement TaskRegistry directly. + type _ForwardCompat = Assert< + Federatable extends TaskRegistry ? true : false + >; + const _wrongPayloadIsACompileError = ( + ctx: Context, + task: TaskDefinition, + ) => { + // @ts-expect-error: a wrong-shaped payload must not type-check. + return ctx.enqueueTask(task, { n: "not a number" }); + }; + const _crossContextHandleIsACompileError = ( + ctx: Context, + task: TaskDefinition<{ tenant: string }, { n: number }>, + ) => { + // @ts-expect-error: a handle bound to different context data must not + // type-check. + return ctx.enqueueTask(task, { n: 1 }); + }; +}); + +test("Context.enqueueTask() end-to-end", async (t) => { + await t.step("round-trips a typed payload to the handler", async () => { + const queue = new InProcessMessageQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const received: { ctx: Context; data: Envelope }[] = []; + const task = federation.defineTask("notify", { + schema: envelopeSchema, + handler: (ctx, data) => { + received.push({ ctx, data }); + }, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, { + note: new Note({ content: "Hello, world!" }), + title: "greeting", + }); + const controller = new AbortController(); + const listening = federation.startQueue(undefined, { + signal: controller.signal, + queue: "task", + }); + try { + await waitFor(() => received.length > 0, 15_000); + } finally { + controller.abort(); + await listening; + } + strictEqual(received.length, 1); + const { ctx: handlerCtx, data } = received[0]; + ok(data.note instanceof Note); + strictEqual(data.note.content?.toString(), "Hello, world!"); + strictEqual(data.title, "greeting"); + strictEqual(handlerCtx.origin, "https://example.com"); + }); +}); + +test("task queue routing", async (t) => { + await t.step("prefers the per-task queue", async () => { + const taskQueue = new MockQueue(); + const perTaskQueue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: taskQueue }, + }); + const task = federation.defineTask("isolated", { + schema: stringSchema, + handler: () => {}, + queue: perTaskQueue, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "data"); + strictEqual(perTaskQueue.enqueued.length, 1); + strictEqual(taskQueue.enqueued.length, 0); + }); + + await t.step("falls back to the outbox queue by default", async () => { + const outboxQueue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { outbox: outboxQueue }, + }); + const task = federation.defineTask("fallback", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "data"); + strictEqual(outboxQueue.enqueued.length, 1); + }); + + await t.step( + 'taskQueueResolution: "strict" throws at enqueue instead', + async () => { + const outboxQueue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { outbox: outboxQueue }, + taskQueueResolution: "strict", + }); + const task = federation.defineTask("strict", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await rejects( + () => ctx.enqueueTask(task, "data"), + { name: "TypeError", message: /No message queue is configured/ }, + ); + strictEqual(outboxQueue.enqueued.length, 0); + }, + ); + + await t.step("throws when no queue is configured at all", async () => { + const federation = createFederation({ ...baseOptions }); + const task = federation.defineTask("queueless", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await rejects( + () => ctx.enqueueTask(task, "data"), + { name: "TypeError", message: /No message queue is configured/ }, + ); + }); +}); + +test("startQueue() task worker", async (t) => { + await t.step('starts only the task worker for queue: "task"', async () => { + const inbox = new MockQueue(); + const outbox = new MockQueue(); + const fanout = new MockQueue(); + const taskQueue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { inbox, outbox, fanout, task: taskQueue }, + }); + const controller = new AbortController(); + const listening = federation.startQueue(undefined, { + signal: controller.signal, + queue: "task", + }); + strictEqual(taskQueue.listenCount, 1); + strictEqual(inbox.listenCount, 0); + strictEqual(outbox.listenCount, 0); + strictEqual(fanout.listenCount, 0); + controller.abort(); + await listening; + }); + + await t.step("starts the worker for a task-only deployment", async () => { + const taskQueue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: taskQueue }, + }); + const controller = new AbortController(); + const listening = federation.startQueue(undefined, { + signal: controller.signal, + }); + strictEqual(taskQueue.listenCount, 1); + controller.abort(); + await listening; + }); + + await t.step("does not double-listen on a shared queue", async () => { + const shared = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { outbox: shared, task: shared }, + }); + const controller = new AbortController(); + const listening = federation.startQueue(undefined, { + signal: controller.signal, + }); + strictEqual(shared.listenCount, 1); + controller.abort(); + await listening; + }); + + await t.step("starts a worker for a dedicated per-task queue", async () => { + const taskQueue = new MockQueue(); + const dedicated = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: taskQueue }, + }); + federation.defineTask("dedicated", { + schema: stringSchema, + handler: () => {}, + queue: dedicated, + }); + const controller = new AbortController(); + const listening = federation.startQueue(undefined, { + signal: controller.signal, + }); + strictEqual(taskQueue.listenCount, 1); + strictEqual(dedicated.listenCount, 1); + controller.abort(); + await listening; + }); + + await t.step( + "starts a per-task queue even without a federation queue", + async () => { + const dedicated = new MockQueue(); + const federation = createFederation({ ...baseOptions }); + federation.defineTask("dedicated", { + schema: stringSchema, + handler: () => {}, + queue: dedicated, + }); + const controller = new AbortController(); + const listening = federation.startQueue(undefined, { + signal: controller.signal, + }); + strictEqual(dedicated.listenCount, 1); + controller.abort(); + await listening; + }, + ); + + await t.step( + "does not listen twice on a per-task queue shared with a standard queue", + async () => { + const shared = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: shared }, + }); + federation.defineTask("reuses-task-queue", { + schema: stringSchema, + handler: () => {}, + queue: shared, + }); + const controller = new AbortController(); + const listening = federation.startQueue(undefined, { + signal: controller.signal, + }); + strictEqual(shared.listenCount, 1); + controller.abort(); + await listening; + }, + ); + + await t.step( + "routes an enqueued task on a dedicated queue to its handler", + async () => { + const dedicated = new MockQueue(); + const federation = createFederation({ ...baseOptions }); + let received: string | undefined; + const task = federation.defineTask("dedicated-end-to-end", { + schema: stringSchema, + handler: (_ctx, data) => { + received = data; + }, + queue: dedicated, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "payload"); + strictEqual(dedicated.enqueued.length, 1); + await (federation as FederationImpl).processQueuedTask( + undefined, + dedicated.enqueued[0].message, + ); + strictEqual(received, "payload"); + }, + ); +}); + +test("processQueuedTask() task dispatch", async (t) => { + await t.step("drops an unknown task with a warning", async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }) as FederationImpl; + const message = await makeTaskMessage("never-defined", "data"); + await federation.processQueuedTask(undefined, message); + strictEqual(queue.enqueued.length, 0); + }); + + await t.step("drops an undecodable payload without retry", async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }) as FederationImpl; + let called = 0; + federation.defineTask("broken-wire", { + schema: stringSchema, + handler: () => { + called++; + }, + }); + const message = await makeTaskMessage("broken-wire", "data"); + await federation.processQueuedTask(undefined, { + ...message, + data: "garbage that is not devalue", + }); + strictEqual(called, 0); + strictEqual(queue.enqueued.length, 0); + }); + + await t.step("drops a drifted payload without retry", async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }) as FederationImpl; + let called = 0; + federation.defineTask("drifted", { + schema: numberSchema, // the "new deploy" expects a number… + handler: () => { + called++; + }, + }); + // …but the payload was enqueued by an "old deploy" as a string: + const message = await makeTaskMessage("drifted", "stringly-typed"); + await federation.processQueuedTask(undefined, message); + strictEqual(called, 0); + strictEqual(queue.enqueued.length, 0); + }); + + await t.step( + "re-enqueues with attempt + 1 when the handler throws", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }) as FederationImpl; + const errors: unknown[] = []; + federation.defineTask("flaky", { + schema: stringSchema, + handler: () => { + throw new Error("boom"); + }, + retryPolicy: () => Temporal.Duration.from({ milliseconds: 1 }), + onError: (_ctx, error, data) => { + errors.push([error, data]); + }, + }); + const message = await makeTaskMessage("flaky", "data", { + orderingKey: "k", + }); + await federation.processQueuedTask(undefined, message); + strictEqual(queue.enqueued.length, 1); + const retry = queue.enqueued[0]; + strictEqual(retry.message.attempt, 1); + strictEqual(retry.message.taskName, "flaky"); + strictEqual(retry.message.orderingKey, "k"); + strictEqual(retry.options?.orderingKey, "k"); + ok(retry.options?.delay instanceof Temporal.Duration); + strictEqual(errors.length, 1); + deepStrictEqual(errors[0], [new Error("boom"), "data"]); + }, + ); + + await t.step("gives up when the retry policy returns null", async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }) as FederationImpl; + federation.defineTask("hopeless", { + schema: stringSchema, + handler: () => { + throw new Error("boom"); + }, + retryPolicy: () => null, + }); + const message = await makeTaskMessage("hopeless", "data"); + await federation.processQueuedTask(undefined, message); + strictEqual(queue.enqueued.length, 0); + }); + + await t.step( + "per-task retryPolicy overrides the federation default", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + taskRetryPolicy: () => null, // the federation default gives up… + }) as FederationImpl; + federation.defineTask("override", { + schema: stringSchema, + handler: () => { + throw new Error("boom"); + }, + // …but the per-task policy retries: + retryPolicy: () => Temporal.Duration.from({ milliseconds: 1 }), + }); + federation.defineTask("default", { + schema: stringSchema, + handler: () => { + throw new Error("boom"); + }, + }); + await federation.processQueuedTask( + undefined, + await makeTaskMessage("override", "data"), + ); + strictEqual(queue.enqueued.length, 1); + await federation.processQueuedTask( + undefined, + await makeTaskMessage("default", "data"), + ); + strictEqual(queue.enqueued.length, 1); // unchanged: gave up + }, + ); + + await t.step( + "still retries when message.started is malformed", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }) as FederationImpl; + federation.defineTask("bad-started", { + schema: stringSchema, + handler: () => { + throw new Error("boom"); + }, + retryPolicy: () => Temporal.Duration.from({ milliseconds: 1 }), + }); + // A corrupted or drifted queue can hand back an invalid `started`; + // computing elapsedTime must not throw out of the error path and abort + // the retry. + const message = await makeTaskMessage("bad-started", "data", { + started: "not-an-instant", + }); + await federation.processQueuedTask(undefined, message); + strictEqual(queue.enqueued.length, 1); + strictEqual(queue.enqueued[0].message.attempt, 1); + }, + ); + + await t.step("rethrows on a nativeRetrial queue", async () => { + const queue = new MockQueue({ nativeRetrial: true }); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }) as FederationImpl; + federation.defineTask("native", { + schema: stringSchema, + handler: () => { + throw new Error("boom"); + }, + }); + const message = await makeTaskMessage("native", "data"); + await rejects( + () => federation.processQueuedTask(undefined, message), + { message: /boom/ }, + ); + strictEqual(queue.enqueued.length, 0); + }); +}); diff --git a/packages/fedify/src/testing/context.ts b/packages/fedify/src/testing/context.ts index 0f9781d14..c613c93c4 100644 --- a/packages/fedify/src/testing/context.ts +++ b/packages/fedify/src/testing/context.ts @@ -51,6 +51,8 @@ export function createContext( lookupWebFinger, sendActivity, routeActivity, + enqueueTask, + enqueueTaskMany, } = values; function throwRouterError(): URL { throw new RouterError("Not implemented"); @@ -113,6 +115,12 @@ export function createContext( routeActivity: routeActivity ?? ((_params) => { throw new Error("Not implemented"); }), + enqueueTask: enqueueTask ?? ((_task, _data, _options) => { + throw new Error("Not implemented"); + }), + enqueueTaskMany: enqueueTaskMany ?? ((_task, _payloads, _options) => { + throw new Error("Not implemented"); + }), }; } diff --git a/packages/fedify/src/testing/mod.ts b/packages/fedify/src/testing/mod.ts index fe72cbdda..393b217f1 100644 --- a/packages/fedify/src/testing/mod.ts +++ b/packages/fedify/src/testing/mod.ts @@ -3,5 +3,15 @@ export { createOutboxContext, createRequestContext, } from "./context.ts"; +export { + baseOptions, + type Envelope, + envelopeSchema, + makeSchema, + MockQueue, + type MockQueueOptions, + numberSchema, + stringSchema, +} from "./tasks.ts"; // Without the export below, `test:cfworkers` makes an error. export { testDefinitions } from "@fedify/fixture"; diff --git a/packages/fedify/src/testing/tasks.ts b/packages/fedify/src/testing/tasks.ts new file mode 100644 index 000000000..7ccf727fc --- /dev/null +++ b/packages/fedify/src/testing/tasks.ts @@ -0,0 +1,136 @@ +/** + * Test-only utilities shared by the task suites: the schema factory and stock + * schemas, the base federation options, and the recording {@link MockQueue}. + * + * These helpers live beside the suites that use them rather than in a shared + * package because {@link MockQueue} needs the package-internal + * {@link TaskMessage} type, and *deno.json*'s `publish.exclude` keeps this + * module out of the published sources. + * + * @module + */ +import { mockDocumentLoader } from "@fedify/fixture"; +import { Note } from "@fedify/vocab"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import type { FederationOptions } from "../federation/federation.ts"; +import { MemoryKvStore } from "../federation/kv.ts"; +import type { + MessageQueue, + MessageQueueEnqueueOptions, + MessageQueueListenOptions, +} from "../federation/mq.ts"; +import type { TaskMessage } from "../federation/queue.ts"; + +/** Federation options (sans `queue`) shared by the task suites. */ +export const baseOptions: Omit, "queue"> = { + kv: new MemoryKvStore(), + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + manuallyStartQueue: true, +}; + +/** + * Builds a minimal [Standard Schema](https://standardschema.dev/) from a type + * guard, for use as a task payload schema in tests. + */ +export const makeSchema = ( + check: (data: unknown) => data is T, +): StandardSchemaV1 => ({ + "~standard": { + version: 1, + vendor: "fedify-test", + validate: (value: unknown) => + check(value) + ? { value } + : { issues: [{ message: "Invalid task data." }] }, + }, +}); + +export const stringSchema = makeSchema( + (d): d is string => typeof d === "string", +); +export const numberSchema = makeSchema( + (d): d is number => typeof d === "number", +); + +/** A task payload that carries a vocabulary object, to exercise the codec's + * vocab-to-JSON-LD bridging. */ +export interface Envelope { + note: Note; + title: string; +} + +export const envelopeSchema = makeSchema( + (data): data is Envelope => + typeof data === "object" && data != null && + (data as Envelope).note instanceof Note && + typeof (data as Envelope).title === "string", +); + +/** + * Options for the {@link MockQueue} constructor. + */ +export interface MockQueueOptions { + /** Sets {@link MessageQueue.nativeRetrial}. Defaults to `false`. */ + readonly nativeRetrial?: boolean; + /** Sets {@link MessageQueue.nativeDeduplication}. Defaults to `false`. */ + readonly nativeDeduplication?: boolean; + /** + * When `true`, the queue exposes {@link MockQueue.enqueueMany} and records + * bulk enqueues; when omitted, the method is absent so callers exercise the + * per-message fan-out path. + */ + readonly supportsEnqueueMany?: boolean; +} + +/** + * An in-memory {@link MessageQueue} that records task enqueues for assertions + * instead of delivering anything. Its {@link listen} resolves only when the + * abort signal fires. + */ +export class MockQueue implements MessageQueue { + readonly nativeRetrial: boolean; + readonly nativeDeduplication: boolean; + readonly enqueued: { + message: TaskMessage; + options?: MessageQueueEnqueueOptions; + }[] = []; + readonly enqueuedMany: { + messages: readonly TaskMessage[]; + options?: MessageQueueEnqueueOptions; + }[] = []; + listenCount = 0; + enqueueMany?: ( + messages: readonly TaskMessage[], + options?: MessageQueueEnqueueOptions, + ) => Promise; + + constructor(options: MockQueueOptions = {}) { + this.nativeRetrial = options.nativeRetrial ?? false; + this.nativeDeduplication = options.nativeDeduplication ?? false; + if (options.supportsEnqueueMany) { + this.enqueueMany = (messages, opts) => { + this.enqueuedMany.push({ messages, options: opts }); + return Promise.resolve(); + }; + } + } + + enqueue( + message: TaskMessage, + options?: MessageQueueEnqueueOptions, + ): Promise { + this.enqueued.push({ message, options }); + return Promise.resolve(); + } + + listen( + _handler: (message: TaskMessage) => Promise | void, + options?: MessageQueueListenOptions, + ): Promise { + this.listenCount++; + return new Promise((resolve) => { + options?.signal?.addEventListener("abort", () => resolve()); + }); + } +} diff --git a/packages/testing/package.json b/packages/testing/package.json index 6ab038136..b83458a88 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -58,6 +58,7 @@ "devDependencies": { "@fedify/fixture": "workspace:^", "@js-temporal/polyfill": "catalog:", + "@standard-schema/spec": "catalog:", "@std/assert": "catalog:", "@std/async": "catalog:", "tsdown": "catalog:", diff --git a/packages/testing/src/context.ts b/packages/testing/src/context.ts index a4771cf14..c8e91a2af 100644 --- a/packages/testing/src/context.ts +++ b/packages/testing/src/context.ts @@ -100,6 +100,8 @@ function createContext( lookupWebFinger, sendActivity, routeActivity, + enqueueTask, + enqueueTaskMany, } = values; function throwRouterError(): URL { throw new RouterError("Not implemented"); @@ -165,6 +167,12 @@ function createContext( routeActivity: routeActivity ?? ((_params) => { throw new Error("Not implemented"); }), + enqueueTask: enqueueTask ?? ((_task, _data, _options) => { + throw new Error("Not implemented"); + }), + enqueueTaskMany: enqueueTaskMany ?? ((_task, _payloads, _options) => { + throw new Error("Not implemented"); + }), }; } diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 9f2fcd85e..c6048b5a6 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1,5 +1,6 @@ import type { InboxContext, OutboxContext } from "@fedify/fedify/federation"; import { signJsonLd } from "@fedify/fedify/sig"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; import { mockDocumentLoader, test } from "@fedify/fixture"; import { Activity, @@ -1711,3 +1712,119 @@ test("MockContext.getActorKeyPairs() returns empty array when no dispatcher regi const keyPairs = await context.getActorKeyPairs("alice"); assertEquals(keyPairs, []); }); + +// A Standard Schema that only accepts numbers; mirrors a strict task schema. +const numberSchema: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "fedify-test", + validate: (value: unknown) => + typeof value === "number" + ? { value } + : { issues: [{ message: "Expected a number." }] }, + }, +}; + +test("MockContext.enqueueTask rejects a payload the schema refuses", async () => { + const federation = createFederation(); + let called = 0; + const task = federation.defineTask("count", { + schema: numberSchema, + handler: () => { + called++; + }, + }); + const context = federation.createContext( + new URL("https://example.com"), + undefined, + ); + await assertRejects( + () => context.enqueueTask(task, "not a number" as unknown as number), + TypeError, + "Task data failed schema validation", + ); + assertEquals(called, 0); // production fails fast at enqueue; so must the mock +}); + +test("MockContext.enqueueTask passes the schema's validated output to the handler", async () => { + const federation = createFederation(); + // A coercing schema: uppercases the string. Input and output share the + // same type, but the validated value differs from the raw input. + const upper: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "fedify-test", + validate: (value: unknown) => + typeof value === "string" + ? { value: value.toUpperCase() } + : { issues: [{ message: "Expected a string." }] }, + }, + }; + let received = "UNSET"; + const task = federation.defineTask("shout", { + schema: upper, + handler: (_ctx, data) => { + received = data; + }, + }); + const context = federation.createContext( + new URL("https://example.com"), + undefined, + ); + await context.enqueueTask(task, "hi"); + // The handler observes the validated output, not the raw "hi". + assertEquals(received, "HI"); +}); + +test("MockContext.enqueueTaskMany validates the whole batch before any handler runs", async () => { + const federation = createFederation(); + const seen: number[] = []; + const task = federation.defineTask("count-many", { + schema: numberSchema, + handler: (_ctx, data) => { + seen.push(data); + }, + }); + const context = federation.createContext( + new URL("https://example.com"), + undefined, + ); + // The second item is invalid: production validates every payload before + // enqueuing anything, so the whole batch rejects with no effect. The + // mock must not let the first handler run before the batch is vetted. + await assertRejects( + () => context.enqueueTaskMany(task, [1, "two" as unknown as number]), + TypeError, + "Task data failed schema validation", + ); + assertEquals(seen, []); +}); + +test("MockContext.enqueueTask rejects a handle from another federation", async () => { + const federation = createFederation(); + const other = createFederation(); + let called = 0; + federation.defineTask("shared-name", { + schema: numberSchema, + handler: () => { + called++; + }, + }); + // Same task name on another federation: production compares the registered + // handle by identity and rejects the foreign one, so a name-only lookup in + // the mock would let tests pass with a handle the real federation refuses. + const foreign = other.defineTask("shared-name", { + schema: numberSchema, + handler: () => {}, + }); + const context = federation.createContext( + new URL("https://example.com"), + undefined, + ); + await assertRejects( + () => context.enqueueTask(foreign, 1), + TypeError, + "is not defined on this federation", + ); + assertEquals(called, 0); +}); diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 271387c25..60fb98db8 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -263,7 +263,7 @@ type ActivityConstructor = new (...args: any[]) => Activity; class MockFederation implements Federation { public sentActivities: SentActivity[] = []; public queueStarted = false; - private activeQueues: Set<"inbox" | "outbox" | "fanout"> = new Set(); + private activeQueues: Set<"inbox" | "outbox" | "fanout" | "task"> = new Set(); public sentCounter = 0; private nodeInfoDispatcher?: any; // Note: Using `any` instead of WebFingerLinksDispatcher to avoid JSR hang. @@ -285,6 +285,7 @@ class MockFederation implements Federation { public sharedInboxPath?: string; public objectPaths: Map = new Map(); public objectDispatchers: Map = new Map(); + public taskDefinitions: Map = new Map(); private inboxDispatcher?: any; private outboxDispatcher?: any; private outboxAuthorizePredicate?: any; @@ -317,6 +318,20 @@ class MockFederation implements Federation { this.nodeInfoPath = path; } + // Note: Parameter and return types are `any` like the other mock methods; + // the structural shape follows TaskRegistry.defineTask(). + defineTask(name: string, options: any): any { + if (this.taskDefinitions.has(name)) { + throw new TypeError(`Task ${JSON.stringify(name)} is already defined.`); + } + // Keep the returned handle with the definition: enqueue compares the + // handle by identity, as production does, so a same-named handle from + // another federation instance is rejected rather than looked up by name. + const handle = { name, schema: options.schema }; + this.taskDefinitions.set(name, { name, ...options, handle }); + return handle; + } + // Note: Parameter type is `any` instead of WebFingerLinksDispatcher to avoid // JSR type analyzer hang (issue #468). See comment on webFingerDispatcher field. setWebFingerLinksDispatcher( @@ -504,10 +519,11 @@ class MockFederation implements Federation { if (options?.queue) { this.activeQueues.add(options.queue); } else { - // If no specific queue, activate all three + // If no specific queue, activate all four this.activeQueues.add("inbox"); this.activeQueues.add("outbox"); this.activeQueues.add("fanout"); + this.activeQueues.add("task"); } } @@ -955,6 +971,57 @@ class MockContext implements Context { return Promise.resolve(null); } + #resolveTaskDefinition(task: any): any { + if (!(this.federation instanceof MockFederation)) { + throw new TypeError("No task definitions are available."); + } + const def = this.federation.taskDefinitions.get(task.name); + if (def == null || def.handle !== task) { + throw new TypeError( + `Task ${ + JSON.stringify(task.name) + } is not defined on this federation; ` + + "pass a handle returned by its defineTask().", + ); + } + return def; + } + + // Mirror production: validate against the schema and hand the *validated* + // output to the handler. Without this, the mock would accept payloads + // that production rejects at enqueue, and a normalizing schema's output + // (defaults, coercions) would differ between tests and production. + async #validateTaskPayload(def: any, data: any): Promise { + const result = await def.schema["~standard"].validate(data); + if (result.issues != null && result.issues.length > 0) { + throw new TypeError( + `Task data failed schema validation: ${JSON.stringify(result.issues)}`, + ); + } + return result.value; + } + + // No queue in mock type: the task handler is invoked immediately, + // mirroring how processQueuedTask() processes immediately. + async enqueueTask(task: any, data: any, _options?: any): Promise { + const def = this.#resolveTaskDefinition(task); + await def.handler(this, await this.#validateTaskPayload(def, data)); + } + + async enqueueTaskMany( + task: any, + payloads: readonly any[], + _options?: any, + ): Promise { + const def = this.#resolveTaskDefinition(task); + // Mirror production: the whole batch validates before anything runs, so + // a failing payload rejects the batch with no partial processing. + const values = await Promise.all( + payloads.map((data) => this.#validateTaskPayload(def, data)), + ); + for (const value of values) await def.handler(this, value); + } + clone(data: TContextData): TestContext { return new MockContext({ url: this.url, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84a59ab96..f0dea9e1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1188,9 +1188,15 @@ importers: '@opentelemetry/semantic-conventions': specifier: 'catalog:' version: 1.40.0 + '@standard-schema/spec': + specifier: 'catalog:' + version: 1.1.0 byte-encodings: specifier: 'catalog:' version: 1.0.11 + devalue: + specifier: ^5.8.1 + version: 5.8.1 es-toolkit: specifier: 'catalog:' version: 1.46.1 @@ -1668,6 +1674,9 @@ importers: '@js-temporal/polyfill': specifier: 'catalog:' version: 0.5.1 + '@standard-schema/spec': + specifier: 'catalog:' + version: 1.1.0 '@std/assert': specifier: 'catalog:' version: '@jsr/std__assert@1.0.13' @@ -8116,15 +8125,15 @@ packages: resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} engines: {node: '>=18'} - devalue@5.1.1: - resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==} - devalue@5.6.1: resolution: {integrity: sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==} devalue@5.7.1: resolution: {integrity: sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==} + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -15506,7 +15515,7 @@ snapshots: consola: 3.4.2 defu: 6.1.7 destr: 2.0.5 - devalue: 5.7.1 + devalue: 5.8.1 errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 @@ -17108,7 +17117,7 @@ snapshots: '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 - devalue: 5.1.1 + devalue: 5.8.1 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.17 @@ -17129,7 +17138,7 @@ snapshots: '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 - devalue: 5.1.1 + devalue: 5.8.1 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.17 @@ -19968,12 +19977,12 @@ snapshots: dependencies: base-64: 1.0.0 - devalue@5.1.1: {} - devalue@5.6.1: {} devalue@5.7.1: {} + devalue@5.8.1: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 diff --git a/scripts/check_fixture_usage.ts b/scripts/check_fixture_usage.ts index 7ca5e28ff..ad4c921cc 100644 --- a/scripts/check_fixture_usage.ts +++ b/scripts/check_fixture_usage.ts @@ -10,6 +10,7 @@ * Reviewers must NOT treat a passing run as proof of safety; code * review and the published package contents remain the source of truth. */ +import { expandGlobSync } from "@std/fs/expand-glob"; import { walk } from "@std/fs/walk"; import { dirname, @@ -20,6 +21,15 @@ import { SEPARATOR, } from "@std/path"; +const projectRoot = resolve(dirname(fromFileUrl(import.meta.url)), ".."); +const packagesDir = resolve(projectRoot, "packages"); + +const expandGlobPattern = (pattern: string) => + Array.from( + expandGlobSync(pattern, { root: projectRoot, includeDirs: false }), + (file) => relative(projectRoot, file.path), + ); + /** * Files exempt from the "@fedify/fixture imports must live in *.test.ts" * rule. Every entry MUST be accompanied by an inline comment explaining @@ -27,21 +37,13 @@ import { * necessary or not. */ const ALLOWLIST: readonly string[] = [ - // cfworkers test harness re-exports `mockDocumentLoader`; bundled in via - // tsdown `noExternal` so consumers never resolve `@fedify/fixture` at - // runtime. - "packages/fedify/src/testing/context.ts", - // cfworkers test harness re-exports `testDefinitions`; bundled in via - // tsdown `noExternal` so consumers never resolve `@fedify/fixture` at - // runtime. - "packages/fedify/src/testing/mod.ts", + // Utils for tests. + "packages/fedify/src/testing/*", // JSDoc `@example` block mentions `import { test } from "@fedify/fixture"` // as documentation; not a real runtime import. "packages/testing/src/mq-tester.ts", -].map((path) => join(...path.split("/") as [string, ...string[]])); - -const projectRoot = resolve(dirname(fromFileUrl(import.meta.url)), ".."); -const packagesDir = resolve(projectRoot, "packages"); +].map((path) => join(...path.split("/") as [string, ...string[]])) + .flatMap((path) => path.includes("*") ? expandGlobPattern(path) : path); /** * Statement-level pattern for any `import` or `export ... from`