Skip to content

feat: the plugin keeps its own record of runs, and the runner port narrows - #213

Merged
SearheiParkhamchuk merged 2 commits into
major/translatorfrom
feat/translator-the-plugin-keeps-its-own-record
Oct 27, 2026
Merged

SearheiParkhamchuk merged 2 commits into
major/translatorfrom
feat/translator-the-plugin-keeps-its-own-record

Conversation

@SearheiParkhamchuk

Copy link
Copy Markdown
Contributor

Closes #200. Part of #192.

What changes

The plugin stops asking the runner what it holds. A new collection, translator-runs, carries one row per (handle, targetLng) and is the source of truth for every status, cancel and run-now route.

TaskRunner drops from five required members to one:

interface TaskRunner {
  enqueue(tasks: AuthorisedWork[], scope?: RequestScope): Promise<EnqueueAssignment[]>;
  stop?(handles: AuthorisedHandle[]): Promise<void>;
  run?(handle: AuthorisedHandle): Promise<RunResult>;
}

stop and run are capabilities a provider declares. A route whose capability is absent is never registered, and the control that would call it is not drawn — so a host never sees a button that answers 404. assertTaskRunnerContract, the published conformance suite, asserts that a declared capability is really implemented.

The Payload job row becomes a pointer — { handle, locales } on the workflow, { handle, target_lng } on the task. Everything else is read back from the record by the plugin's own handler, so a runner never learns which collection it is translating.

Decisions worth reviewing

  • One run per document at a time. A second request over a document with any unsettled run is refused rather than merged — merging loses work, because Payload rebuilds the whole document from the state a run read. The rule is a pure function in core/domain/runs, with two callers.
  • Rows are written before the job is queued. The other order has a race: a cron that picked the job up first would find no record, translate nothing, and leave the document blocked for good.
  • The queued row write is fatal; the stamps and settlements are not. A lost record is a correctness problem — the next request cannot see the run and queues a second one. A lost timestamp is not worth a retried translation the host pays for twice.
  • Old jobs are adopted, not read in parallel. A job queued before the upgrade is given its pointer on first encounter and is thereafter indistinguishable from a new one. The compatibility lives in payload-jobs-runner/legacy/; deleting that folder and its two call sites drops it entirely.
  • "No rights" and "busy" are separate fields of the answer. They mean opposite things to a caller.

Host-facing consequences

  • SQL hosts need a migration for the new table (payload migrate:create + payload migrate; dev push in development). MongoDB infers it.
  • A row whose settledAt is empty must not be pruned. The queue now holds only a pointer, so deleting such a row strands work that cannot be run. Pruning settled rows stays the host's job.
  • runs.slug exists only to resolve a collision with a collection the host already owns.

Deliberately not done

  • No "abandoned" state: a row stuck in "running" after a lost final report stays there. Deriving abandonment from age would need a second timeout beside the runner's staleJobTimeoutMs, and two unrelated numbers would contradict each other on one row.
  • A handle no runner owns is never settled. Switching runners leaves rows whose handles the new runner never issued; they stay pending by design.

Verification

From packages/payload-plugin-translator:

Check Result
bunx vitest run 207 files, 2527 tests + 9 skipped
bun run check-types 0
bunx oxlint . 35 warnings, 0 errors

From the root: bunx turbo run build (real tsc declaration build) and bunx turbo run check-types (9/9) both clean.

Integration, from apps/dev, every adapter and both queue modes:

Database Default enableConcurrencyControl
SQLite 247 passed, 8 skipped 248 passed, 7 skipped
PostgreSQL 253 passed, 2 skipped 253 passed, 2 skipped
MongoDB 245 passed, 10 skipped 245 passed, 10 skipped

The skips are deliberate: the transaction cases run only on PostgreSQL, two cases need an adapter without transactions, and MongoDB builds the unique index in the background after the first write.

Design record

packages/payload-plugin-translator/docs/plans/2026-10-26-the-plugin-keeps-its-own-record.design.md, with six step contracts beside it.

…arrows

The plugin no longer asks the runner what it holds. A new collection,
`translator-runs`, carries one row per (handle, targetLng) and is the source of
truth for every status, cancel and run-now route. Pruning a row whose
`settledAt` is empty strands work the queue still holds, so such a row must be
left alone.

`TaskRunner` drops from five required members to one. `enqueue` is required;
`stop` and `run` are capabilities a provider declares, and a route whose
capability is absent is never registered — so a host never draws a control that
would answer 404. `assertTaskRunnerContract` asserts that a declared capability
is really implemented.

The Payload job row becomes a pointer: `{ handle, locales }` on the workflow and
`{ handle, target_lng }` on the task. Everything else is read back from the
record by the plugin's own handler, so a runner never learns which collection it
is translating. A job queued before the upgrade is adopted on first encounter
rather than read in parallel, which keeps the compatibility in one folder that a
single step can delete.

One run per document at a time: a second request over a document with any
unsettled run is refused rather than merged, decided by a pure rule in
`core/domain/runs`. "No rights" and "busy" are separate fields of the answer.

Rows are written before the job is queued, because a cron that picked the job up
first would find no record and leave the document blocked for good.

Closes #200
@vercel

vercel Bot commented Oct 27, 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 27, 2026 11:37pm UTC

Request Review

@SearheiParkhamchuk SearheiParkhamchuk changed the title feat!: the plugin keeps its own record of runs, and the runner port narrows feat: the plugin keeps its own record of runs, and the runner port narrows Oct 27, 2026
@SearheiParkhamchuk
SearheiParkhamchuk merged commit a7702e8 into major/translator Oct 27, 2026
2 checks passed
SearheiParkhamchuk added a commit that referenced this pull request Oct 27, 2026
The merge of #213 ran the release workflow, found three commits since
1.0.0-next.6 and still reported no relevant changes, so the runner-port step
has no prerelease under the `next` dist-tag. This empty commit exists to tell
the two causes apart: if it releases, the fault lies in the shape of that
merge; if it does not, the fault is in the release configuration.
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.0.0-next.7 🎉

The release is available on npm package (@next dist-tag)

Your semantic-release bot 📦🚀

This branch was successfully deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[translator] The runner port: narrow the mandatory core to enqueue

1 participant