Skip to content

feat(translator): carry a run's handle through the translation lifecycle - #175

Closed
SearheiParkhamchuk wants to merge 10 commits into
mainfrom
feat/translator-lifecycle-run-context
Closed

SearheiParkhamchuk wants to merge 10 commits into
mainfrom
feat/translator-lifecycle-run-context

Conversation

@SearheiParkhamchuk

@SearheiParkhamchuk SearheiParkhamchuk commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Superseded. The work continued past this pull request; the issues are linked from its
replacement instead, so that closing this one leaves them open.

The plugin had a handle for every run it queued and threw it away. Four issues from one
consumer building an editorial workflow on top of it reduce to that.

What changes

enqueue answers with what it arranged — one entry per requested target locale, each
naming the handle that will run it. Per locale rather than per handle, because one job row
covers a document's whole locale list: two locales of one document name the same run, and
a request spanning several documents still reads unambiguously. A locale an existing run
already covers is answered too, with that run's handle.

Every lifecycle callback carries that handle, onQueued included, so a host can join a
callback back to the request behind it without keeping its own index. The runner is what says
queued, and it says it for work it has already taken — so the handle exists by then, and a
synchronous runner cannot announce a completion before the start it belongs to.

onFailed now means the locale will not be translated. It fired on every failed
attempt, so "failed" did not mean failed and the consumer ran with retries disabled. When
a run gives up it reports every locale it still owed, not only the one that threw — the
locales after it never started, so nothing ran for them to throw from.

Cancelling says what it stopped, once per locale the run still owed, before the row is
deleted.

Collection status names the document and locale beside the handle, which a caller
previously could not resolve back to anything.

One channel, owned by the runner

The first four commits left the telling spread across three places: a wrapper around the
runner raised queued and cancelled, a catch inside the translation handler raised
completed and failed, and a callback handed to the runner raised failed for locales a
run had given up on. A boolean, reportsFinalFailure, picked between the last two — a
switch that existed only because the knowledge of whether another attempt is coming lives in
the runner while the decision to speak was taken in the handler.

The runner now owns the whole story. It is handed report in its context and calls it for
every assignment it accepted: queued when the work is taken, then at most one of
delivered, failed or cancelled. The plugin turns those four into the host's callbacks
and stays the only thing that knows a host exists.

Gone with the switch: the wrapper and its 24 checks, reportFinalFailure,
TaskRunner.findByIds — cancellation reports itself now — TaskHandlerInput.handle, and
two of the three task mappers.

Decisions worth a reviewer's attention

  • TaskRunner.enqueue widens its return rather than changing it. The old void answer
    still satisfies the contract, deprecated for the next major with a ledger entry. An
    overload pair is not usable: an implementation must satisfy every overload, so it would
    break every third-party runner.
  • report is required, not optional. A runner that silently omitted it would leave its
    host hearing nothing and no compiler would say so. Required means such a runner fails to
    build, which is the loudest available signal.
  • No attempt counter, although translator: pass run context into lifecycle callbacks #110 asks for one by name. An attempt number still would
    not tell a host whether another try is coming, which is what it actually needs. Recorded
    as a need this does not meet.
  • Nothing was added to the published barrel. src/index.ts is byte-identical to main:
    a compile-only fixture shows a complete outside runner compiles while naming only
    TaskRunnerProvider and inferring the rest. The rule and the case that produced it are
    written down in ADR-0002.
  • Both runners are held to one contract by one call. TaskRunner.interface.ts states the
    obligations every implementation owes, and TaskRunner.invariants.ts asserts them against
    any runner. Its checks were written against the contract alone, by an author that could not
    read the implementations because they did not exist yet; they ran red on a stub, 29 of 29.
    Writing them found five sentences the contract was missing — when a report becomes
    observable, that the reported assignment carries the handle the enqueue answer gave, that a
    locale an existing run already covers still earns its queued, that cancelled only
    follows a cancel, and that a locale already reported failed is not owed.

Behaviour changes

  • A run whose retries are spent is no longer offered as a host. It kept hasError and never
    got a completion date, so it stayed in the plugin's live set although Payload's own picker
    had excluded it for good — asking again for that locale produced no work at all.
  • The wire changes in one place. GET /translate/document/:slug/:id and
    GET /translate/collection/:slug answer "id": "12" where they answered "id": 12. A
    run's handle had three spellings in the code; it has one now. The declared type always said
    string, so nothing documented changes — but the bytes do.
  • Payload hands back a completed task's stored output instead of re-running it, so the
    workflow loop reported delivered again on every later pass. It now reads the task log
    before the call.
  • Cancelling a run that had already given up announced onCancelled for locales that had just
    been told onFailed — a second ending for the same locale. Spending the last attempt settles
    everything the run had not delivered, so a spent run now owes nothing to a cancellation. A run
    merely waiting to try again still owes its undelivered locales and still announces them.

Breaking, and for whom

For a runner implemented outside this package: create takes the context rather than the
handler, report is required, and the removals above are compiler errors. All of it predates
any release — reportsFinalFailure was added on this branch and never shipped.

For a host that merely configures the plugin: nothing. The callbacks, their arguments, the
runner factories and every HTTP response are as they were.

Public API

Added: TranslationTask.handle, TranslationLifecycleCallbacks.onCancelled,
TaskRunnerContext.report, and the widened TaskRunner.enqueue return. All additive for a
host that registers nothing new.

README.md is deliberately untouched; its lifecycle section still describes the per-attempt
behaviour this change removes, and needs a pass from its owner.

Known gaps, not fixed here

  • Cancelling does not stop a locale already being translated: it is announced cancelled and
    still finishes, so one locale can produce two events. cancel is byte-identical to main,
    so this predates the change; fixing it means changing what cancellation does. The contract
    states the exception instead of implying otherwise.
  • A handle is unique only while its row exists. SQLite reuses a deleted row's id, so a handle
    kept across a cancellation can later name another run. Written into the contract as a limit
    rather than left as a promise the code cannot keep.

Verification

Check Result
unit 2017 passed, 143 files
check-types, every package clean
declaration build (tsc) green
oxlint 0 errors, and a two-run subtraction against main reports nothing introduced
integration — SQLite 224 passed / 7 skipped
integration — PostgreSQL 229 passed / 2 skipped
integration — MongoDB 222 passed / 9 skipped

Each adapter was run twice, with and without Payload's exclusive queue — six combinations,
no failures.

The skips are adapter-specific: transaction behaviour that only one of the three has, and
one check that cancels from inside a running job, which on a transactional adapter blocks on
a row its own transaction holds.

Three reviewers read the diff — correctness, regression and intent. Five findings, all acted on:
the cancellation defect above, the missing onQueued check, and three places whose prose described
code this work had deleted. One acceptance criterion turned out to be false as written and was
restated rather than ticked: "a runner that does not implement report fails to build" cannot hold,
because the silent conformance fixture never calls report and compiles — no type can require
that a function be called. What the required field does enforce, that nothing can build a context
without it, breaks nine files when removed.

Five mutations proved the new checks discriminate: removing report from the context, narrowing
enqueue, reporting queued after the work, deleting the jobs runner's queued loop, and dropping
the give-up guard. Each was restored and the restore verified.

Run against the pinned Payload 3.90.1. The three places in Payload's own source this change
reasons about — the per-task give-up check, the per-workflow one, and where a task's error is
replaced by a message-only copy — were re-read against that version.

`enqueue` returned a count. A host that wanted to cancel a run later, or record on its
own row which run is translating a locale, had nothing to store.

It now answers with one entry per requested target locale, each naming the handle that
will run it. Per locale rather than per handle, because a single job row covers a
document's whole locale list: two locales of one document name the same run, and a
request spanning several documents still reads unambiguously.

`TaskRunner.enqueue` widens its return rather than changing it. The old `void` answer
still satisfies the contract and is deprecated for the next major, with a ledger entry.
An overload pair is not usable here: an implementation must satisfy every overload, so it
would break every third-party runner. `EnqueueAssignment` and `Task` are exported so an
implementation outside this package can name what it answers with.

A locale an existing run already covers is answered too, with that run's handle — it is
being translated, and leaving it out would report nothing for work genuinely under way.
`EnqueuePlan` gained `covered`/`coveredBy` for it, and every requested locale now appears
in exactly one of `append`, `queue` and `covered`.

Two things fell out of making the answer honest.

A run whose retries are spent keeps `hasError` and never gets a completion date, so it
stayed in the plugin's live set although Payload's own picker had excluded it for good.
Appending to it promised a translation that could not happen; `pickHost` now refuses it.

A run's handle had three spellings: two helpers stringified the row id and `normalizeJob`
passed it through, so one run arrived as `"12"` down one path and `12` down another. One
helper forms it now. This changes the wire: `GET /translate/document/:slug/:id` and
`GET /translate/collection/:slug` answer `"id": "12"` where they answered `"id": 12`. The
declared type always said `string`, so nothing documented changes.
A callback said which document and locale it was about, but nothing identifying the
request behind it. A host layering editorial process on top had to write an intent record
before enqueue and join on (collection, document, locale) plus "is a run open" — which
forced one open run per document and locale, a constraint it would rather not need.

Every callback now carries the handle the enqueue answer named, so the join is direct.
`onQueued` is the exception and carries none: it fires before the runner is asked, which
is what keeps a synchronous runner's `completed` from arriving first, and at that moment
no run exists.

`onFailed` now means the locale will not be translated. It used to fire on every failed
attempt, so "failed" did not mean failed and the consumer ran with retries disabled. Only
a runner that retries can tell a transient failure from a final one, so it reports the
final one itself and declares `reportsFinalFailure`; a runner that does not retry is
reported where the throw happens, as before. There is deliberately no attempt counter: an
attempt number still would not say whether another try is coming.

When a run gives up it reports every locale it still owed, not only the one that threw.
The locales after it never started, so nothing ran for them to throw from and no caller
could otherwise learn they are dead. Payload gives up two ways — the locale's attempts
against the task limit and the whole run's executions against the workflow limit — and
the two diverge as soon as attempts are spent on more than one locale, so both are asked.

The error a host receives is the plugin's own. Payload replaces a task's error with a
message-only copy, which silently stopped matching the exported error classes, so the
handler's error is kept and handed back.
A cancelled run simply stopped existing. The plugin cancels the Payload job and then
deletes its row, so a host tracking per-document state had to infer that the work was no
longer happening.

`onCancelled` now fires once per target locale the run still owed, before the row is
deleted, so a host can still read it. A locale the run had already translated is not
announced — it was not cancelled. A locale already in flight is announced and still
finishes: cancelling does not stop work already running, and the callback contract says
so rather than pretending otherwise.

The cancel route holds only handles, so naming the locales needs a read back: a new
**optional** `findByIds` on `TaskRunner`. A runner that omits it keeps working and never
fires the callback — the synchronous runner has nothing to cancel, so it omits it. A
failed read is logged and never costs the caller their cancellation, and a runner that
cannot resolve handles is not asked, so silence stays distinguishable from a read that
failed quietly.

The wrapper is now applied for any callback it fires. A host registering only
`onCancelled` used to be handed an undecorated runner and heard nothing.

Both runners are now held to one contract by one call. `TaskRunner.interface.ts` states
the obligations every implementation owes — what an empty request answers, that a handle
is a non-empty string, that only requested locales come back, that an unknown handle is
an answer rather than a failure — and `TaskRunner.invariants.ts` asserts them against any
runner. It was written against the contract alone, run red against a stub first, and it
found a real divergence: the synchronous runner answered twice, and translated twice, for
a locale a request named twice.
`GET /translate/collection/:slug` answered with a run handle and a status, and a caller
could resolve neither back to what was being translated. A run covers a document's whole
locale list, so the handle repeats across entries and cannot identify one on its own —
the queue view an admin dashboard wanted could not be rendered from it.

Each entry now names the document and the target locale beside the handle. The runner's
normalized task already carried both; only the response mapping discarded them.
@vercel

vercel Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ideal-cms Ready Ready Preview Oct 9, 2026 9:15am UTC

Request Review

…tually mean

Two things the owner caught in review, both about names and promises rather than behaviour.

**The enqueue answer no longer renames a handle into a job id.** It answered with
`{ ..., job_id }` under a `jobs` key, so the HTTP handler was translating the runner's
opaque handle into the vocabulary of one particular runner — Payload jobs — in a response
that is meant to be runner-neutral. The synchronous runner has no jobs at all. The answer
now carries `handle` under `assignments`, and the mapping left in the handler is a pure
conversion to the snake_case this surface already answers in.

Putting that mapping inside `TaskRunner.enqueue` instead would push the knowledge the
wrong way: the contract is implemented outside this package, and a third-party runner
would have to produce HTTP field names. The field is new in this change and unreleased,
so renaming it costs nothing.

**The contract now promises only what every store can keep.** It said an unknown handle
resolves rather than rejecting. That is false on PostgreSQL and MongoDB: the job id is an
integer there, so a handle no runner could have issued fails inside the query instead of
finding nothing. The conformance suite found it — those two checks failed on both
databases and passed on SQLite, which is lenient.

The obligation now reaches handles **this runner issued**, and the suite exercises a
handle it obtained for real and then had cancelled: unknown by then, and shaped as that
runner shapes handles. The limit is written down beside the obligations, including its
consequence — `DELETE /translate/cancel` answers with a server error for a value the store
cannot parse. That is left unfixed on purpose: every way to fix it costs something decided
deliberately elsewhere — adapter-specific error codes, an unbounded scan the project
already removed once, or parsing a handle the contract says is never parsed.
The plugin decided whether to wrap a runner in `withQueuedNotification` by asking which
callbacks the host had registered: wrap for `onQueued` or `onCancelled`, skip otherwise.
The gate bought one avoided read — `cancel` resolves handles back to tasks only so it can
name what it is stopping, and a host with no `onCancelled` has no use for that read.

It cost more than it bought. Two hosts got structurally different runners depending on a
setting unrelated to the difference: a wrapped runner normalizes `findByCollection`'s
deprecated array argument, an unwrapped one does not. And the list of callbacks the
wrapper raises was written twice — once in the wrapper, once in the gate — with nothing
tying them together. That already produced a bug this branch had to fix: the gate asked
only about `onQueued`, so a host registering only `onCancelled` was handed an undecorated
runner and heard nothing.

The runner is wrapped unconditionally now. Every callback the notifier fires is already a
no-op when the host registered nothing, so wrapping costs a host that wants no reporting
one resolved promise per enqueued task, and on cancel one read whose result is discarded.

The check that pinned the old rule pinned the behaviour being removed, so it was rewritten
to pin the new one instead, over two cases: a host registering only callbacks the task
handler fires, and a host registering nothing at all.
…ot need

All three came out of review, none changes behaviour.

**`LifecycleNotifier.cancelled` folded into `cancelling`.** It was a private two-line
helper with exactly one call site, inside the loop of the method it sat next to. It was
kept separate for symmetry with `queued`, `completed` and `failed` — but the symmetry does
not hold: those three are public and take a task from the caller, while this one was
private and took a task the class had just read itself.

**The non-null assertion on `findByIds` is gone.** The wrapper re-exposed the optional
method as `runner.findByIds!(ids)`, asserting something the compiler could not check: the
guard sat in a ternary and the call inside an arrow, so the narrowing never reached it.
The method is now bound into a local once, which both narrows honestly and keeps the
receiver a class implementation needs.

**The run-to-loop error channel no longer lives at module scope.** Payload replaces a
task's error with a message-only copy, so the plugin keeps its own error in a `WeakMap`
until the loop that reports it can read it back. That map was a module-level `const`,
shared by every plugin instance in the process, although both handlers that use it are
created in the same `configure()` call. It is a local there now, and
`thrownByTheHandler.ts` — the module and its three exported functions — is deleted.

The generated per-file suite loses one check with that file, which is the check that was
scanning it.
`EnqueueAssignment` and `Task` were added to the barrel so a third-party runner could annotate
what it returns. A compile-only probe then showed a complete outside runner compiles **without
naming either**: the shapes are inferred from `TaskRunnerProvider`, the one name that genuinely
crosses the boundary. The export enabled nothing, so it goes.

It never made them private, and the ADR says so plainly: `TaskRunnerProvider` is exported, its
`create` returns a `TaskRunner`, and that interface answers with both types. They are emitted into
the published declarations and reachable through `ReturnType` either way. A barrel decides how
conveniently a consumer can name a type, not whether the type is part of the contract — so keeping
a loose type out of it buys tidiness, never freedom to change it.

ADR-0002 records the rule and the three questions it turns on, and the package guide points at it
next to the `@since` rule, which governs the same file.
Three places told the host what happened to a translation: a wrapper around the runner raised
`queued` and `cancelled`, a catch inside the translation handler raised `completed` and `failed`,
and a callback handed to the runner raised `failed` for locales a run gave up on. A boolean on the
provider, `reportsFinalFailure`, picked between the last two — a switch that existed only because
the knowledge of whether another attempt is coming lives in the runner while the decision to speak
was taken in the handler.

The runner now owns the whole story. It is handed `report` in its context and calls it for every
assignment it accepted: `queued` when the work is taken, then at most one of `delivered`, `failed`
or `cancelled`. The plugin turns those four into the host's callbacks and remains the only thing
that knows a host exists.

Gone with the switch: `withQueuedNotification` and its 24 checks, `TaskRunnerContext.reportFinalFailure`,
`TaskRunner.findByIds` — cancellation reports itself now — `TaskHandlerInput.handle`, which nothing
read once the handler stopped reporting, and two of the three task mappers. `EnqueueAssignment`
gained `sourceLng` and `strategy` so one mapper can turn it into what the host sees; three existed
only because the event arrived in three shapes.

**Breaking for a third-party runner, and for nobody else.** `create` takes the context rather than
the handler, `report` is required so that omitting it fails the build rather than leaving a host
silent, and the removals above are compiler errors. A host that merely configures the plugin sees
no change: the callbacks, their arguments, the runner factories and every HTTP response are as they
were. All of it predates any release — `reportsFinalFailure` was added on this branch and never
shipped.

The invariants were written against the contract alone by an author that could not read the
implementations, which did not exist yet. They ran red on a stub, 29 of 29, and the writing of them
found five sentences the contract was missing: when a report becomes observable, that the reported
assignment carries the handle the enqueue answer gave, that a locale an existing run already covers
still earns its `queued`, that `cancelled` only follows a `cancel`, and that a locale already
reported `failed` is not owed.

Two defects surfaced on a real database. Payload hands back a completed task's stored output
instead of re-running it, so the loop reported `delivered` again on every later pass; it now reads
the log before the call. And a handle is unique only while its row exists — SQLite reuses a deleted
row's id, so a handle kept across a cancellation can later name another run. That one is a limit
now written into the contract rather than a promise the code cannot keep.
Cancelling a run whose retries were spent announced `onCancelled` for the very locales that had
just been told `onFailed` — a second ending for one locale, which the event contract says cannot
happen. `stillOwed` keeps every locale that is not `completed`, and Payload only writes
`completedAt` when a whole workflow succeeds, so a spent run keeps its error, keeps no completion
date, and its settled locales still read as owed.

The cancel path now asks its own question through `owedOnCancel`: a run that has given up owes
nothing, because spending the last attempt already settled everything it had not delivered; a run
merely waiting to try again owes its undelivered locales, logged failures among them. Keeping the
two apart matters — the obvious narrowing, "not completed and not failed", would have silenced the
legitimate case, where a job between attempts has every locale logged as failed and every one of
them still owed.

Reachable without doing anything unusual: `DELETE /translate/cancel-by-collection/:slug` sweeps up
whatever is neither completed nor running, and a spent run is both. Covered on a real database from
both the by-handle and the by-collection route, and in a unit check that pins the between-attempts
case in the other direction.

Also here, from the same verification pass:

- **`onQueued` had no check at all.** Its criterion asked for a host registering the callback on a
  real database, for both runners; nothing did. Two specs now do, and a mutation proves each —
  reporting `queued` after the work makes the inline runner's spec read "de ended before the host
  was told it had started", and deleting the queued loop makes the jobs runner's spec red.
- **Three places described code that no longer exists.** `TranslationTask.handle` claimed to be
  absent on `onQueued`; it has not been since the runner became what reports `queued`, and a host
  branching on its absence would have been misled. `wireTranslateRunner`'s docblock still described
  the deleted decoration. `DEPRECATIONS.md` pointed a reader at the removed `reportsFinalFailure`
  instead of at `report`'s `failed` event.
- **One criterion was false and is restated.** "A runner that does not implement `report` fails to
  build" cannot be true: the `silent` conformance fixture never calls `report` and compiles, because
  no type can require that a function be called. What the required field does enforce — that nothing
  can build a context without it — breaks nine files when removed, and that is what the criterion,
  the decision behind it and the fixture's docblock now claim.
@SearheiParkhamchuk

Copy link
Copy Markdown
Contributor Author

Superseded. The work carried on well past what this pull request described — the stored job input collapsed to one declaration, the config machinery got a name, and the workflow handler was recomposed. A replacement opens from the same branch with the issues linked there.

This branch was successfully deployed

1 active deployment
Preview — dc4234dd Deployed Oct 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant