Add custom background task API (defineTask / enqueueTask)#803
Add custom background task API (defineTask / enqueueTask)#8032chanhaeng wants to merge 25 commits into
defineTask / enqueueTask)#803Conversation
Generalize Fedify's enqueue-and-process-later pattern, previously limited to outgoing activity delivery, to arbitrary application-defined background jobs. `Federation` and `FederationBuilder` gain `defineTask()` (via the new `TaskRegistry` interface), and `Context` gains `enqueueTask()`/`enqueueTaskMany()`. Each task carries a Standard Schema that infers the payload type and validates it both at enqueue time and at dequeue time, guarding against schema drift across deployments. Payloads are serialized with devalue so that `Date`, `Map`, `Set`, `URL`, `bigint`, circular references, and Activity Vocabulary objects round-trip faithfully across every message queue backend. Failed handlers retry with exponential backoff by default, configurable per task or federation-wide, and tasks can be isolated onto a dedicated queue or fall back to the outbox queue. The payload codec is implemented twice on purpose: `codec.ts` as a class (`TaskCodec`) and `codec-fn.ts` as standalone utility functions, each with its own tests. Only the class is wired into the runtime; the functional variant is kept temporarily so the team can compare the two styles and decide which reads better before one is removed. fedify-dev#206 fedify-dev#797 Assisted-by: Claude Code:claude-opus-4-8
Split the monolithic `install` task into `install:deno` and `install:pnpm`, with `codegen` as an explicit dependency, so each runtime's setup can be run on its own. `test:deno` now depends on `install:deno` instead of `prepare`, since Deno runs the TypeScript sources directly and does not need the build step. Update AGENTS.md to match: document `mise run prepare`/`prepare-each` for building, `check-each` and `test-each` for scoping work to specific packages, and add a section directing agents to consult `mise tasks`. Assisted-by: Claude Code:claude-opus-4-8
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces a custom background task API to Fedify, allowing applications to define, enqueue, and process arbitrary background jobs. Payloads are validated using Standard Schema and serialized with devalue to support complex types, including Activity Vocabulary objects. The review feedback highlights several critical improvements: refining isPlainObject to support objects with null prototypes, implementing a recursion depth limit in reviveVocab to prevent stack overflow vulnerabilities, replacing Array.fromAsync with Promise.all for Node.js 18/20 compatibility, and defensively handling potential parsing errors when reading message.started with Temporal.Instant.from.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
The `install:deno` task runs `scripts/install.ts`, which `deno cache`s each workspace member's export entry points; those include the generated `packages/vocab/src/vocab.ts`. `install:pnpm` likewise expects the generated sources to be present. Both therefore require `codegen` to have run first. Previously `codegen` sat alongside `install:deno` and `install:pnpm` in the `install` task's `depends` list, which `mise` runs in parallel, so the cache step could start before `vocab.ts` was generated. Move `codegen` into each subtask's own `depends` so it is ordered before them; `mise` dedupes the shared dependency to a single run. As a result `install:deno` and `install:pnpm` are now correct when invoked on their own, not only as part of `install`. Also correct the `install:pnpm` description, which said "for Deno". Assisted-by: Claude Code:claude-opus-4-8
`isPlainObject` in the task codec only accepted objects whose prototype is exactly `Object.prototype`, so an object made with `Object.create(null)` was treated as a non-plain leaf. Any vocab object nested inside such an object was therefore left as its parked holder instead of being revived, even though devalue round-trips null-prototype objects without throwing. Accept a `null` prototype as well, and add a regression test that round-trips a vocab object nested in an `Object.create(null)` object. fedify-dev#803 (comment) Assisted-by: Claude Code:claude-opus-4-8
When a custom task handler throws and the queue does not own retries, the error path computes the elapsed time from `message.started` to feed the retry policy. `message.started` is normally a valid ISO instant set at enqueue time, but a corrupted or drifted queue could hand back an invalid string, in which case `Temporal.Instant.from()` threw out of the error-handling block. That masked the original handler error and aborted the retry, silently dropping the task. Wrap the parse in a try-catch, fall back to a zero elapsed time, and log the offending value. A regression test drives a message with a malformed `started` through a throwing handler and asserts the retry is still enqueued. fedify-dev#803 (comment) Assisted-by: Claude Code:claude-opus-4-8
|
@claude review |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Get the list of changed files in the PR
gh pr view 803 --repo fedify-dev/fedify --json files --jq '.files[].path' | head -60Length of output: 1034 🏁 Script executed: #!/bin/bash
# Get overall PR diff stats
gh pr diff 803 --repo fedify-dev/fedify --name-only 2>/dev/null | head -60Length of output: 1010 🏁 Script executed: #!/bin/bash
# Get the PR diff
gh pr diff 803 --repo fedify-dev/fedify 2>/dev/null | head -3000Length of output: 106514 🏁 Script executed: #!/bin/bash
gh pr diff 803 --repo fedify-dev/fedify 2>/dev/null | tail -n +3000 | head -2000Length of output: 4404 🏁 Script executed: #!/bin/bash
# Check the codec.ts imports from codec-fn.ts
gh pr diff 803 --repo fedify-dev/fedify 2>/dev/null | grep -A5 "import.*codec-fn"Length of output: 383 🏁 Script executed: #!/bin/bash
# Check what's exported from mod.ts tasks barrel
cat packages/fedify/src/federation/tasks/mod.ts 2>/dev/null || echo "File not found in working tree"
# Also look at what's exported from federation/mod.ts
grep -A20 "tasks/mod" packages/fedify/src/federation/mod.ts 2>/dev/nullLength of output: 917 🏁 Script executed: #!/bin/bash
# Check processQueuedTask visibility
grep -n "processQueuedTask" packages/fedify/src/federation/middleware.ts 2>/dev/null | head -20
# Check TaskCodec `#seen` usage
grep -n "seen" packages/fedify/src/federation/tasks/codec.ts 2>/dev/nullLength of output: 950 🏁 Script executed: #!/bin/bash
# Verify clampNegativeDelay exists
grep -n "clampNegativeDelay" packages/fedify/src/federation/middleware.ts | head -10
# Verify isObjectPrototype difference between two codecs
grep -n "isObjectPrototype\|isPlainObject\|getPrototypeOf" packages/fedify/src/federation/tasks/codec.ts packages/fedify/src/federation/tasks/codec-fn.ts 2>/dev/null
# Check devalue upgrade effect on SvelteKit packages
grep -B5 -A5 "devalue.*5.1.1\|devalue.*5.8.1" pnpm-lock.yaml | grep -v "^--$" | head -40Length of output: 3133
Codec style: keep
|
| File | Condition |
|---|---|
codec.ts |
proto === null || proto === Object.prototype |
codec-fn.ts |
proto === Object.prototype only |
Objects created with Object.create(null) are opaque records in codec-fn — any vocab value nested inside them is never revived. Only codec.test.ts covers this case ("revives a vocab object nested in a null-prototype object"); codec-fn.test.ts has no equivalent test.
codec.ts carries its loader state on the instance, which is a natural fit for a context-bound component and easier to extend later.
Issues to resolve before the codec-fn deletion
1. Cross-dependency: codec.ts imports TaskCodecLoaders from codec-fn.ts
// codec.ts line 3
import type { TaskCodecLoaders } from "./codec-fn.ts";Deleting codec-fn.ts will immediately break codec.ts. Before the deletion, move TaskCodecLoaders (and its VocabKind/VocabWire if needed) to task.ts or a new types.ts, then update both codec files to import from there.
2. tasks/mod.ts re-exports all of codec-fn.ts
// tasks/mod.ts
export * from "./codec-fn.ts"; // ← becomes an error once the file is deleted
export { default as TaskCodec } from "./codec.ts";
export * from "./task.ts";After deleting codec-fn.ts, this line must be removed. Also verify that nothing in the internal consumers (middleware.ts, builder.ts, etc.) inadvertently relies on the serializeTaskData / deserializeTaskData / validateTaskData names that currently fall through via this wildcard.
Other issues
3. resolveTaskQueue silent drop under strict mode on handler failure
In #listenTaskMessage, when a handler throws:
const queue = this.resolveTaskQueue(def.name);
if (queue?.nativeRetrial) throw error;
// …
if (delay != null && queue != null) {
// re-enqueue
} else {
logger.error("Custom task … giving up …");
}Under taskQueueResolution: "strict", if a task has no queue configured, resolveTaskQueue returns undefined. Enqueuing that task already throws at call-time, so this path should be unreachable in practice. However, a processQueuedTask call that bypasses enqueueTask (e.g., direct queue injection or a test) would silently drop the task on retry. Consider adding a logger.warn or an assertion in the queue == null branch so the drop is always observable.
4. #enqueueSingular is an arrow-function property, not a prototype method
`#enqueueSingular` = <TData>(…) => async (data: TData): Promise<TaskMessage> => { … };Arrow properties are allocated per instance. For a long-lived context object this is negligible, but it's inconsistent with all other methods on ContextImpl. Converting it to a regular private method — or inlining the curry into #enqueueTasks — would be idiomatic.
5. Two @ts-ignore in codec.ts's #classRevivers
// `@ts-ignore` tsc faults
const out: Revived = await init(node);
// …
// `@ts-ignore` tsc faults
await set(this.#revive(seen), node, out);These suppress real type errors in the generic dispatch through ClassReviver. They're acceptable as a short-term workaround, but leaving them in a permanent implementation is a smell. A narrower ClassReviver union or a discriminated-union approach would let the compiler check these calls properly.
6. __contextData phantom field leaks into the public TaskDefinition interface
export interface TaskDefinition<TContextData, TData> {
readonly name: string;
readonly schema: StandardSchemaV1<unknown, TData>;
/** `@internal` Phantom marker binding the handle to its federation. */
readonly __contextData?: TContextData;
}The @internal comment is accurate, but the field still appears in user-facing docs and IDEs. Consider moving the phantom into the internal TaskDefinitionInternal shape instead, or using a declare symbol key that is invisible at the JS level.
7. processQueuedTask is public on FederationImpl
The test suite calls federation.processQueuedTask(…) directly, which is why it must be public. If it's not meant to be part of the external Federation interface, a /** @internal */ JSDoc tag (or a leading _ convention used elsewhere in the codebase) would signal that to callers.
Minor / doc nits
docs/manual/tasks.md: The "Limitations" section is a good addition. Consider linking each listed limitation to its specific tracking sub-issue (e.g.,#798for deduplication,#799for telemetry) so users can subscribe to individual items.CHANGES.md: The[Add custom background task API (defineTask/enqueueTask) #803]self-reference in the changelog entry is unusual — typically only the resolving issues are cited. Worth double-checking the project's convention.- The devalue bump from 5.7.1 → 5.8.1 also resolved the SvelteKit
devaluedependency (5.1.1 → 5.8.1) via pnpm deduplication. This is harmless since SvelteKit's constraint is^5.x, but worth a quick sanity-check that the SvelteKit docs build still works.
There was a problem hiding this comment.
Code Review
This pull request introduces a custom background task API to Fedify, allowing applications to define and enqueue arbitrary background jobs with type-safe payloads validated via Standard Schema and serialized using devalue. Feedback on the implementation highlights a potential Denial of Service (DoS) vulnerability via stack overflow in the recursive payload revival, recommending a recursion depth limit and optimized reviver lookup. Additionally, suggestions were made to implement lazy cloning to avoid unnecessary container creation, and to fix a bug in codec-fn.ts where null-prototype objects are not recognized as plain objects, preventing nested vocabulary objects from being revived.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c2a2d4c5c6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`FederationBuilderImpl.taskDefinitions` was a plain object, so the duplicate check `name in this.taskDefinitions` and the lookups `this.taskDefinitions[taskName]` consulted the prototype chain. Task names are arbitrary user-supplied strings, so a name such as "constructor", "toString", or "__proto__" was wrongly reported as already defined and resolved to an inherited method on lookup. Switch the registry to a `Map`, which is immune to prototype keys by construction and avoids the clone footgun where a later spread or `Object.assign` would silently reintroduce the prototype. Sibling registries stay plain objects since they are keyed by controlled values (type-id URLs). Add a regression test covering names that collide with `Object.prototype`. fedify-dev#803 (comment) Assisted-by: Claude Code:claude-opus-4-8
`#revive` mapped every node through all five class revivers, allocating five promises per node and resolving them with `Array.fromAsync` before picking the first truthy result. The class filters are mutually exclusive, so it now finds the single matching reviver and runs only that one, cutting the per-node work to a single promise. This keeps the existing behaviour (cycles, repeated references, and Map/Set/Array/plain-object/null-prototype containers all still round-trip, as the codec tests assert) and folds the rationale for two declined suggestions into a comment: the walked tree is devalue's throwaway parse output, so there is no external identity to preserve and nothing to clone lazily; and a recursion-depth cap is moot because this pass recurses with `await` (unwinding the stack each level) while devalue's own recursive `stringify`/`parse` is the binding limit on nesting and would overflow first. fedify-dev#803 (comment) fedify-dev#803 (comment) Assisted-by: Claude Code:claude-opus-4-8
A task may route to its own queue via `defineTask(name, { queue })`, and
`resolveTaskQueue()` enqueues its messages there, but
`_startQueueInternal()` only listened on the four federation-wide queues
(inbox, outbox, fanout, task). A task queue that was none of those got
no worker, so its messages were never processed even while
`startQueue()` was running.
Collect the distinct dedicated queue instances from the task registry
and start a worker for each, treating them as part of the "task"
selector. Dedupe against the standard queues and against task queues
already started on an earlier call so no instance is listened on twice,
and let a deployment whose only queues are per-task ones still start:
the early return no longer bails out when a dedicated task queue exists.
fedify-dev#803 (comment)
Assisted-by: Claude Code:claude-opus-4-8
|
I agree on @coderabbitai's this comment:
|
|
Tip For best results, initiate chat on the files or code changes.
Once those two changes are in place, |
@dahlia, the maintainer picks `TaskCodec` because it carries the loader state on the instance at [a comment](fedify-dev#803 (comment)). Therefore remove the *codec-fn.ts*. `TaskCodecLoaders` moved to *codec.ts* because `TaskCodec` use it.
The task payload schema validates on both sides of the queue: at enqueue time and again at dequeue time. The wire therefore carries the validated *output*, which the same schema must re-accept as input, so transforming schemas (e.g., Zod's .transform()) whose output differs in shape from their input cannot round-trip. This constraint was neither documented nor tested; state it in the manual and the schema option's JSDoc, and pin it with a regression test. fedify-dev#803 Assisted-by: Claude Code:claude-fable-5
resolveTaskQueue() returns the fallback queue even for a task name with no registered definition, so enqueuing a handle created by a different federation instance silently succeeded and the worker later dropped the message with only a warning. The task API's contract is to fail fast at the enqueue call site (it already validates the payload there), so check the registry before resolving a queue and throw a TypeError instead. fedify-dev#803 Assisted-by: Claude Code:claude-fable-5
Small follow-ups from review:
- Document that tasks must be defined before startQueue() (or the
first request); workers for dedicated per-task queues are only
registered when the queue machinery starts, so a queue defined
later never gets a worker.
- Return early from the enqueue path when no payloads are given,
instead of reaching enqueueMany()/Promise.all with an empty
batch, whose backend behavior is undefined.
- Rename #enqueueSingular to #encodeTaskMessage; it encodes and
builds a TaskMessage but does not enqueue anything.
- Fix a comment typo in the codec.
fedify-dev#803
Assisted-by: Claude Code:claude-fable-5
Addressed in e8bd70d! Deleted codec-fn.ts and its test file, moved the |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify the changes in commit e8bd70dfd
gh api repos/fedify-dev/fedify/commits/e8bd70dfd --jq '.commit.message'
echo "---"
gh api repos/fedify-dev/fedify/commits/e8bd70dfd --jq '.files[].filename'Length of output: 758
The remaining open items from the review are issues 3–7 (silent drop under strict mode, If you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! |
The revival dispatch pulled init/set out of a heterogeneous tuple list, losing the correlation between each tuple's filter and its init/set node type, which forced two @ts-ignore suppressions at the call site. Such suppressions hide any future error on those lines, so they are unfit for a permanent implementation. Each entry is now built by a generic classReviver() factory whose single type parameter ties the filter to its init/set, letting the compiler check the calls it previously could not. Also bind the recursive reviver to one inner closure per decode pass instead of allocating a fresh closure on every dispatch. fedify-dev#803 (comment) Assisted-by: Claude Code:claude-fable-5
The __contextData phantom field binds a TaskDefinition handle to its federation's context data type, but as a string-keyed property it leaked into user-facing docs and IDE completions despite its @internal tag. Replace it with a module-private unique symbol key: no value exists at runtime, the marker disappears from completions, and cross-federation handle rejection still type-checks, now guarded by a regression test. Also replace the tasks barrel's wildcard re-export of task.ts with explicit named exports of the six types its consumers actually use, so nothing new falls through the barrel unnoticed. fedify-dev#803 (comment) Assisted-by: Claude Code:claude-fable-5
|
@coderabbitai review Addressed the codec decision and items 1–2 in e8bd70d, and item 4 in a6f9137. Item 5 is addressed in 39e6ec7—a generic classReviver() factory now ties each filter to its init/set node type, removing both @ts-ignore suppressions. Item 6 is addressed in 5237e13—the __contextData marker is replaced with a module-private unique symbol key, keeping it out of completions while a regression test pins the cross-federation handle rejection. Some incorrect points were not fixed or commented on. |
|
@codex review |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify commits mentioned by the user
for commit in a6f9137ce 39e6ec796 5237e133c; do
echo "=== Commit: $commit ==="
gh api repos/fedify-dev/fedify/commits/$commit --jq '.commit.message + "\n---files---"'
gh api repos/fedify-dev/fedify/commits/$commit --jq '.files[].filename'
echo ""
doneLength of output: 3116 🏁 Script executed: #!/bin/bash
# Check the current state of the key files for items 3, 4, 5, 6, 7
# Item 3: resolveTaskQueue silent drop under strict mode
gh api repos/fedify-dev/fedify/contents/packages/fedify/src/federation/middleware.ts?ref=803-add-task-api \
--jq '.content' | base64 -d | grep -n "resolveTaskQueue\|listenTaskMessage\|processQueuedTask\|queue == null\|queue === null" | head -30
echo "=== `#enqueueSingular` (item 4) ==="
gh api repos/fedify-dev/fedify/contents/packages/fedify/src/federation/context.ts?ref=803-add-task-api \
--jq '.content' | base64 -d | grep -n "enqueueSingular\|`#enqueueSingular`" | head -20
echo "=== TaskDefinition symbol (item 6) ==="
gh api repos/fedify-dev/fedify/contents/packages/fedify/src/federation/tasks/task.ts?ref=803-add-task-api \
--jq '.content' | base64 -d | grep -n "symbol\|contextData\|__contextData\|TaskDefinition" | head -30
echo "=== processQueuedTask (item 7) ==="
gh api repos/fedify-dev/fedify/contents/packages/fedify/src/federation/federation.ts?ref=803-add-task-api \
--jq '.content' | base64 -d | grep -n "processQueuedTask" | head -10Length of output: 1578 🏁 Script executed: #!/bin/bash
# Get the correct branch name for PR 803
gh pr view 803 --repo fedify-dev/fedify --json headRefName --jq '.headRefName'Length of output: 181 🏁 Script executed: #!/bin/bash
# Check item 4: `#enqueueSingular` renamed to `#encodeTaskMessage`
gh api repos/fedify-dev/fedify/contents/packages/fedify/src/federation/middleware.ts?ref=main \
--jq '.content' | base64 -d | grep -n "enqueueSingular\|encodeTaskMessage\|enqueueSingular\|`#encode`\|`#enqueue`" | head -20
echo "=== item 3: resolveTaskQueue / silent drop ==="
gh api repos/fedify-dev/fedify/contents/packages/fedify/src/federation/middleware.ts?ref=main \
--jq '.content' | base64 -d | grep -n "resolveTaskQueue\|queue == null\|queue === null\|giving up\|warn\|listenTask" | head -30
echo "=== item 5: `@ts-ignore` in codec.ts ==="
gh api repos/fedify-dev/fedify/contents/packages/fedify/src/federation/tasks/codec.ts?ref=main \
--jq '.content' | base64 -d | grep -n "ts-ignore\|classReviver\|ClassReviver" | head -20
echo "=== item 6: TaskDefinition phantom symbol ==="
gh api repos/fedify-dev/fedify/contents/packages/fedify/src/federation/tasks/task.ts?ref=main \
--jq '.content' | base64 -d | grep -n "symbol\|contextData\|__contextData\|TaskDefinition" | head -30
echo "=== item 7: processQueuedTask ==="
gh api repos/fedify-dev/fedify/contents/packages/fedify/src/federation/federation.ts?ref=main \
--jq '.content' | base64 -d | grep -n "processQueuedTask" | head -10Length of output: 2949 🏁 Script executed: #!/bin/bash
# Get the PR head SHA
HEAD_SHA=$(gh pr view 803 --repo fedify-dev/fedify --json headRefOid --jq '.headRefOid')
echo "HEAD SHA: $HEAD_SHA"
# Check item 4 in context.ts
gh api "repos/fedify-dev/fedify/contents/packages/fedify/src/federation/context.ts?ref=$HEAD_SHA" \
--jq '.content' | base64 -d | grep -n "enqueueSingular\|encodeTaskMessage" | head -20
echo "=== item 5: codec.ts `@ts-ignore` ==="
gh api "repos/fedify-dev/fedify/contents/packages/fedify/src/federation/tasks/codec.ts?ref=$HEAD_SHA" \
--jq '.content' | base64 -d | grep -n "ts-ignore\|classReviver\|ClassReviver" | head -20
echo "=== item 6: task.ts symbol ==="
gh api "repos/fedify-dev/fedify/contents/packages/fedify/src/federation/tasks/task.ts?ref=$HEAD_SHA" \
--jq '.content' | base64 -d | grep -n "symbol\|contextData\|__contextData\|TaskDefinition" | head -30
echo "=== item 3: listenTaskMessage retry/drop ==="
gh api "repos/fedify-dev/fedify/contents/packages/fedify/src/federation/middleware.ts?ref=$HEAD_SHA" \
--jq '.content' | base64 -d | grep -n "listenTask\|processQueuedTask\|resolveTaskQueue\|giving up\|logger.warn\|queue == null" | grep -i "task\|giving up" | head -30
echo "=== item 7: processQueuedTask visibility ==="
gh api "repos/fedify-dev/fedify/contents/packages/fedify/src/federation/federation.ts?ref=$HEAD_SHA" \
--jq '.content' | base64 -d | grep -n "processQueuedTask\|`@internal`" | head -20Length of output: 3630 🏁 Script executed: #!/bin/bash
HEAD_SHA=$(gh pr view 803 --repo fedify-dev/fedify --json headRefOid --jq '.headRefOid')
echo "=== Item 3 context: listenTaskMessage retry/drop area ==="
gh api "repos/fedify-dev/fedify/contents/packages/fedify/src/federation/middleware.ts?ref=$HEAD_SHA" \
--jq '.content' | base64 -d | sed -n '2100,2220p'
echo "=== Item 7: processQueuedTask on interface ==="
gh api "repos/fedify-dev/fedify/contents/packages/fedify/src/federation/federation.ts?ref=$HEAD_SHA" \
--jq '.content' | base64 -d | sed -n '750,770p'Length of output: 5595
Verified addressed:
Items 3 and 7 — you're right, I was wrong:
The PR looks clean and ready. |
There was a problem hiding this comment.
Code Review
This pull request introduces a custom background task API to Fedify, allowing applications to define and enqueue arbitrary background jobs with type-safe payloads validated by Standard Schema and serialized via devalue. It includes extensive updates to the core federation engine, context APIs, documentation, and testing suites. The review feedback focuses on the TaskCodec implementation, suggesting a depth limit in the recursive revive function to prevent stack overflows, optimizing the revivers to avoid cloning unchanged subtrees, and registering initialization promises in the seen map immediately to prevent infinite recursion and deadlocks on circular references.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5237e133c1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Automated reviewers keep proposing a fixed recursion depth cap (~100) in TaskCodec's #revive to guard against stack overflow from deeply nested payloads. The concern does not apply: the revive traversal suspends at an await on every level, so nesting depth consumes heap (promise chains) rather than native stack, and a structure deep enough to threaten the stack would fail inside devalue.parse() before #revive ever ran. A cap would only reject legitimate payloads. Add a regression test that round-trips a payload nested 1,000 levels deep—an order of magnitude above any proposed cap—through alternating objects and arrays down to a vocab leaf, so introducing such a cap now fails the suite. fedify-dev#803 (comment) Assisted-by: Claude Code:claude-fable-5
The enqueue guard only checked that the handle's task name existed in the local registry, so once two federation instances defined the same task name, a handle from the other instance slipped through: the local context encoded the payload under the schema carried by the foreign handle while the worker decoded it under the local definition's schema. A payload the local schema would have rejected at enqueue thus landed in the queue anyway, only to be dropped at decode time—defeating the fail-fast purpose the guard exists for. defineTask() now stores the exact handle object it returns alongside the internal definition, and enqueueTask()/enqueueTaskMany() compare that handle by identity. Handles still work on every federation built from the same builder, since build() shares the stored definitions. The cross-federation regression test now covers the same-name case in addition to the undefined-name case. fedify-dev#803 (comment) Assisted-by: Claude Code:claude-fable-5
MockContext.enqueueTask() invoked the handler with the raw input, while production enqueueTask() validates the payload against the task schema and hands the validated output to the handler. Tests written against @fedify/testing therefore accepted payloads that production rejects at enqueue, and observed the raw input rather than the coerced or normalized value a transforming schema produces—masking integration bugs the mock exists to surface. The mock now runs the registered schema's Standard Schema validator before invoking the handler, throwing the same TypeError production throws on failure and passing the validated output through. enqueueTaskMany() inherits this since it delegates to enqueueTask(). Added tests covering a rejected payload, a coercing schema whose validated output reaches the handler, and per-item validation in the batch path. fedify-dev#803 (comment) Assisted-by: Claude Code:claude-fable-5
The @standard-schema/spec import is shared by the fedify and testing packages, so it belongs at the workspace level rather than being declared per package. The root deno.json already lists it and workspace members inherit the root import map, making the copy in the fedify package's deno.json redundant; drop it. The pnpm side already sources the version from the catalog in pnpm-workspace.yaml, with each package.json referencing it as "catalog:". Assisted-by: Claude Code:claude-fable-5
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 23fc26384c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Code Review
This pull request introduces a custom background task API to Fedify, allowing developers to define and dispatch arbitrary background jobs with Standard Schema validation and robust serialization using devalue. The feedback highlights a compatibility risk with Array.fromAsync in environments running Node.js versions prior to 22.0.0, suggesting standard for...of loops instead. Additionally, the reviewer recommends implementing a depth limit during deserialization to prevent DoS attacks from deeply nested payloads, and addresses a behavioral discrepancy in the testing mock's enqueueTaskMany validation sequence compared to production.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Every other enqueue path (inbox, outbox, fanout, forwarding) calls _startQueueInternal() right before enqueuing unless manuallyStartQueue is set, but #enqueueTasks did not. An application that only uses the custom task API never sends an activity, so with the default configuration its first enqueueTask() accepted the message while no worker ever listened: tasks piled up in the queue unprocessed until startQueue() was called explicitly or an activity happened to be sent. Add the same guard to #enqueueTasks, plus a regression test asserting that the first enqueue starts the task worker exactly once and that a second enqueue does not start another listener. fedify-dev#803 (comment) Assisted-by: Claude Code:claude-fable-5
Production's enqueueTaskMany() validates and encodes every payload with Promise.all() before enqueuing anything, so a batch with one invalid item rejects with no effect. The mock looped enqueueTask() per item instead, invoking handlers for earlier payloads before a later one failed validation—tests could observe a partial processing state that cannot occur in production. Split the definition lookup and the schema validation out of enqueueTask() into helpers, and make enqueueTaskMany() validate the whole batch up front, running handlers only once every payload has passed. The existing batch-validation test now pins that no handler runs at all when the batch rejects. fedify-dev#803 (comment) fedify-dev#803 (comment) Assisted-by: Claude Code:claude-fable-5
Production compares the registered handle by identity (14313a1), so passing a handle from another federation instance throws even when both instances define the same task name. The mock looked definitions up by name only, and defineTask() did not keep the handle it returned, so an identity check was impossible: tests could pass with a handle the real federation rejects. Store the returned handle with the definition and require the enqueued handle to be that very object, with the same error message production uses. A regression test defines the same task name on two mock federations and asserts the foreign handle is rejected without running any handler. fedify-dev#803 (comment) Assisted-by: Claude Code:claude-fable-5
The custom background task APIs added on this branch were annotated with @SInCE 2.3.0, but the release that will include them is not yet decided. Replace those tags with the placeholder 2.x.x so the documentation does not promise a specific version prematurely. Affected APIs: Context.enqueueTask and enqueueTaskMany, the taskRetryPolicy and taskQueueResolution federation options, the task queue option, TaskMessage, and the task definition types (TaskHandler, TaskDefinitionOptions, TaskDefinition, TaskRegistry, and TaskEnqueueOptions). Assisted-by: Claude Code:claude-opus-4-8
Local review of the custom background task PR flagged several
documentation-level problems; no runtime behavior is affected:
- The tasks manual claimed the API ships in Fedify 2.3.0, while the
new APIs' JSDoc had already moved to `@since 2.x.x` because the
containing release is undecided. Align the manual with the JSDoc.
- The manual also claimed a per-task queue defined after the queue
machinery starts "never gets a worker." Without
`manuallyStartQueue`, the next request or enqueue starts the
worker, so soften the claim to match the implementation.
- A comment in codec.test.ts described an instance-level `#seen` map
that does not exist—each `deserialize()` call builds its own
per-decode map—and the first test's title claimed a fresh instance
per operation while the tests share one module-level codec.
Correct both.
- Fix grammar errors in the new *AGENTS.md* paragraph about
`mise tasks`.
fedify-dev#803
Assisted-by: Claude Code:claude-fable-5
Assisted-by: Codex:gpt-5-5
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b444a6e57a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Resolves #797, the first sub-issue of #206.
Summary
Fedify already runs a background worker that processes outgoing and incoming activities through a message queue (enqueue work, return the HTTP response immediately, process the payload on a separate worker later). This PR generalizes that fire-and-then-process pattern to arbitrary application-defined jobs—digest mailers, timeline rebuilds, link-preview fetches, periodic cleanup, and so on—so applications can reuse Fedify's worker instead of standing up a separate task system.
This delivers the end-to-end core only: define a task, enqueue it from a
Context, run it on a worker, retry on failure. Deduplication (#798) and task-specific telemetry attributes (#799) are deliberately left to their own sub-issues to keep this PR reviewable.Public API
Registration
defineTask()lands on a newTaskRegistry<TContextData>interface thatFederatable<TContextData>now extends, so bothFederationandFederationBuilderinherit it. A task always carries a Standard Schema (Zod, Valibot, ArkType, …); the payload type is inferred from the schema viaStandardSchemaV1.InferOutput, so there is no explicit-TDataescape hatch and nounknownfallback. The returnedTaskDefinition<TContextData, TData>is an opaque handle carryingnameandschemaplus a phantomTContextDatamarker—no runtime methods.Defining the same name twice throws a
TypeError, andbuild()clones the registry so two builds stay isolated.Dispatch
Two methods land on
Context<TContextData>alongsidesendActivity/routeActivity:enqueueTaskvalidatesdataagainst the task's schema (fail fast), encodes it, and stores the resulting string in aTaskMessagebefore handing it to the queue.enqueueTaskManyresolves the queue once and usesMessageQueue.enqueueManywhen available, falling back to parallel single enqueues otherwise. Both go through the private#enqueueTaskshelper, which encodes payloads in parallel (a rejected encode rejects the whole batch before anything is enqueued, preserving fail-fast).Payload serialization
Serialization is owned by Fedify, not the caller. Payloads are encoded with devalue, so
Date,Map,Set,URL,RegExp,bigint, typed arrays, circular references, and repeated references all round-trip—not just JSON-safe values. Activity Vocabulary objects (Note,Create,Person, …) keep their state in private fields and cannot be serialized structurally, so devalue's custom-type hook bridges each one through its expanded JSON-LD document (no@context, so decoding never touches the network). Encoding and decoding are therefore asynchronous. In the schema, a vocabulary object is described as an opaqueinstanceofleaf (z.instanceof(Create)/v.instance(Create)), keeping the envelope schema small.Queue routing and worker
task?: MessageQueueslot inFederationQueueOptions.queue(passed todefineTask) →FederationQueueOptions.task→outboxQueuefallback → throw. There is no per-call queue override; a task's destination is declared once.FederationOptions.taskQueueResolution?: "fallback" | "strict"(default"fallback") opts out of theoutboxQueuefallback for deployments that isolate task work.Federation.startQueue(ctxData, { queue: "task" })starts a dedicated task-only worker (the selector now accepts"inbox" | "outbox" | "fanout" | "task").On dispatch, the worker looks up the handler by
taskName(missing → log + drop), decodes and re-validatesdataat the dequeue boundary as drift protection (failure → log + drop, no retry), then invokes the handler. On throw it callsonError, then either rethrows (nativeRetrialqueues, so the backend owns retries) or applies the retry policy (per-task →FederationOptions.taskRetryPolicy→ exponential backoff) and re-enqueues withattempt + 1.TaskMessagekeeps every field a string/number/plain record (the payload is a pre-encoded string, notunknown) so the message survives both JSON serialization and structured clone across every queue backend.Messageis documented as opaque, so the new variant is non-breaking.Forward compatibility
The
TaskRegistryseam exists so Approach 2 from #206 (a separatedWorkerclass) can land without call-site churn:Federatablestops extendingTaskRegistry, aWorker-like class implements it instead,TaskMessage.taskNamestays a location-agnostic string, and thetaskqueue slot becomesWorkerOptions.queueverbatim. A type-level test assertsFederatableis assignable toTaskRegistry<TContextData>.Acceptance criteria
ctx.enqueueTask(task, data)round-trips to a typed handler; a wrong-shaped payload is a compile error.schema(required) infersTData; an invalid decoded payload is dropped without retry.Note/Create) alongsideDate/Mapround-trips faithfully through the devalue codec.nativeRetrialqueue rethrows without re-enqueue; per-taskretryPolicyoverrides the federation default.taskName→ drop + warning.taskQueueResolution: "strict"behave as specified;startQueue({ queue: "task" })starts only the task worker.enqueueTaskManyusesenqueueManywhen available, else parallel enqueue.defineTaskwith a duplicatenamethrows; builder clone isolation holds across twobuild()calls.Federatableis assignable toTaskRegistry<TContextData>.@since 2.3.0JSDoc.Notes for reviewers
The payload codec is committed in two forms on purpose, and only one is wired in.TaskCodecclass is chosen.installintoinstall:deno/install:pnpm(withcodegenas an explicit dependency) and repointstest:denoatinstall:denoinstead ofprepare, since Deno runs the sources directly and does not need the build step; it also updates AGENTS.md. It is unrelated to Custom background tasks: coredefineTask/enqueueTaskAPI #797 and can be split into its own PR if preferred.