Skip to content

fix(translator): translate a document's locales in one job, extended rather than replaced - #133

Merged
SearheiParkhamchuk merged 7 commits into
mainfrom
fix/translator-locale-workflow
Sep 10, 2026
Merged

SearheiParkhamchuk merged 7 commits into
mainfrom
fix/translator-locale-workflow

Conversation

@SearheiParkhamchuk

Copy link
Copy Markdown
Contributor

Closes #114.

The defect

Translating a document into several locales lost translations. Each locale was queued as its own job; Payload runs a batch through Promise.all, and every write it makes is a whole-document version snapshot — so two locales built their snapshots from the same base and the second silently dropped the first's work. Measured on the plugin's real path: one translation of two landing, nine trials of nine, on all three adapters. No error, both jobs reporting success.

The suite never caught it because every integration spec booted the sync runner, which translates inline and in order. The jobs runner — the production default — had no coverage.

The fix, in two parts

A document's locales are one workflow job whose handler awaits one task per locale. Ordering comes from await, not from a scheduler setting the cron cannot express. Different documents still run in parallel; they share no version chain.

A later request extends that job instead of replacing it. The first version of this branch cancelled the live job and queued a replacement carrying only the current request's locales — which quietly dropped everything the old job still owed. Since the panel's per-row "re-translate" button sends exactly one locale, that was the ordinary path, not an edge case.

Extending needed one thing proven rather than assumed: does a locale written into a running job's row reach its handler? It does — Payload re-reads the row onto the live job object after each task settles. Measured on SQLite, Postgres and MongoDB, and pinned by locale-append.int.test.ts, because that behaviour is an implementation detail rather than a documented contract. If a future Payload stops doing it, the test goes red instead of translations going missing.

Two details the measurements decided:

  • The write is narrow — the input column only, through the adapter. payload.update re-reads and rewrites the whole row, which reverts log entries written in between: 3 of 120 runs on Postgres, 1 of 120 on MongoDB. A narrow write showed none across ~1000 rounds.
  • After writing it verifies. The job can finish between the read the plan was built from and the write; a competing append can replace the list. It retries once, then gives anything still undelivered a job of its own — which races nothing, because by then the old job is done.

A job carries one source locale, one strategy and one publish flag for all its locales, so it only takes work from a request that chose the same three.

Also fixed, found by review

The translation panel crashed it read a failed locale's error message unconditionally, but a job carries no final error until it stops retrying
/translate/cancel deleted any row in payload-jobs ids come from the request body and the delete was not narrowed to this plugin's jobs
cancel-by-collection skipped jobs waiting to retry it selected per locale row; such a job has every locale logged, so no row reads "pending"
Manual run lied and could not retry it reported success without checking whether the picker took anything, and never cleared what makes the picker skip a failed job
Boot-time stale-lock recovery had gone dead it filtered on taskSlug, which workflow jobs do not carry

Optional stricter mode

When the host sets jobs: { enableConcurrencyControl: true }, the workflow declares a per-document concurrency key and the queue itself holds a second job until the running one finishes. The plugin never sets that flag: it adds an indexed column and needs a migration on SQL, which is the host's decision. Without it the plugin adapts and extends the running job instead.

The whole integration suite runs in both modes (EXCLUSIVE_QUEUE=1), so enabling it is covered rather than assumed.

Verification

  • Unit: 1359 in the plugin.
  • Integration on three adapters × two queue modes — six runs. SQLite and MongoDB 82/82 in each; Postgres 79/82 in each, the three failures being the known auto-translate cases from translator: auto-translate does nothing on Postgres — the hook's work runs outside the triggering transaction #124, red on main as well.
  • check-types clean in both packages; lint 8 warnings on the changed files against 10 on main for the same files, 0 errors.
  • The tests were audited by mutation: 7 of 13 mutations survived the first pass, including deleting the per-document filter on live jobs. Each gap now has a check, and each mutation was re-run to confirm it dies.

Left as a follow-up

Several panel rows share one job id, so cancelling one locale's row acts on the whole document. Splitting "unit of work" from "panel row" changes the TaskRunner contract, both runners, five handlers and the client — its own change, not the tail of this one.

…parallel jobs

Translating one document into several locales lost translations. Measured on the
real path — the enqueue endpoint, the jobs runner, and the same `jobs.run` call
the autorun cron makes — nine trials of nine across SQLite, Postgres and MongoDB
landed one translation of two. Which one varied. No error was raised and every
job reported success.

Every write Payload makes is a whole-document version snapshot, drafts included:
publishing or saving a locale reads the current version as its base and merges
that locale onto it. The plugin queued one job per target locale and Payload runs
a batch through `Promise.all`, so two locales built their snapshots from the same
base and the second silently dropped the first's work.

The parallelism was ours, so the fix removes it rather than coordinating it. A
document's locales are now one workflow whose handler awaits one task per locale.
Ordering comes from `await` rather than from any lock, all the locales finish
inside one cron tick, and nothing is asked of the host.

Payload's own concurrency control was measured and rejected: a job blocked on a
key is not held for it but deferred to the next cron tick — about a minute per
locale at the default autorun — and enabling it adds an indexed column to the
jobs collection. Recorded in #128.

Per-locale detail survives. Payload writes a log entry per task inside a
workflow, so the status endpoints rebuild their per-locale rows from it; a locale
with no entry yet reports the job's own state. Jobs queued in the old per-locale
shape still read correctly, the same expand-and-contract `readCollectionRef`
already does for the collection reference.

Supersession is now per document and narrowed to work that has not begun. A
running workflow holds locales it has already translated, and cancelling it would
throw them away — which is the loss this change exists to stop.

The suite never caught any of this because every integration spec booted the sync
runner, which translates inline and in order. These specs boot the jobs runner,
the production default, which had no coverage until the harness gained a runner
option.

Closes #114
A second request for a document used to cancel its live job and queue a
replacement carrying only the locales of that request, so every locale
the old job still owed disappeared without a trace. The panel's per-row
"re-translate" button sends exactly one locale, which made this the
ordinary path, not an edge case.

A request now adds its locales to the live job's stored list. The write
touches only the input column: the full document operation re-reads and
rewrites the whole row, which was measured to revert log entries written
in between. After writing it verifies, retries once if a competing append
replaced the list, and gives anything still undelivered a job of its own —
which races nothing, because that only happens once the old job is done.

A job carries one source locale, one strategy and one publish flag for all
its locales, so it only takes work from a request that chose the same
three.

Also fixed, found by review: the panel crashed rendering a failed locale
whose job carried no final error yet; /translate/cancel deleted any row in
payload-jobs, not only ours; cancel-by-collection skipped jobs waiting to
retry; and manual run reported success without running anything, while
being unable to retry a failed job at all.

When the host enables Payload's enableConcurrencyControl, the workflow
declares a per-document concurrency key so the queue holds a second job
until the running one finishes. The plugin never sets that flag — it adds
an indexed column and needs a migration on SQL, which is the host's call.

Closes #114
@vercel

vercel Bot commented Sep 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 Sep 8, 2026 2:52pm UTC

Request Review

…ng it away

`as never` on the workflow object and on the `jobs.queue` call silenced the
whole shape, so a typo in a field name would have compiled. Payload's own
`WorkflowConfig<TInput>` and a signature naming what `jobs.queue` actually
accepts cover both, and the concurrency key and the handler's `job.input`
are now checked rather than opaque.

Comments removed from PayloadJobsRunnerProvider.
The runner file carried 64 comment lines over 340 — most of them restating
the line below, or a third copy of a fact the task contract and a named test
already hold. It is now 39 over 318, and the diff as a whole 138 over 1650.

Two of them were stranded rather than merely noisy: cancel-by-collection's
docblock still described the "pending" predicate the change removed, and the
client's DocumentTranslation type still said one job carries one locale.

The pre-workflow task shape now has a deprecation-register entry, so the
fallback read paths point at a record of when they may be dropped instead of
explaining themselves in three places. A duplicate test case left by an
earlier rename is gone, and the "does not enable the host's concurrency
control" assertion is its own case rather than a comment inside another.
The file had grown to 340 lines and 64 comments. Four review angles and a
complexity pass later it is 302 and 35, with two members fewer and no
behaviour change.

The substantive removals: extendJob carried an unreachable guard (its job
comes from a read that already excludes completed rows) and computed its
undelivered set twice; run() normalised a job to read three fields the raw
row already has; two one-call private helpers were inlined into their only
callers.

Two invariants stopped depending on a reader's attention. The grouping key
is now the request shape itself, so "the key covers at least what pickHost
compares" holds by construction rather than by a comment — that is what
keeps parallel appends off the same row. And the stored job input is
declared once instead of twice under one name in two files.

Enqueue also serves documents in bounded batches rather than all at once,
and findByCollection narrows raw jobs before expanding them per locale
instead of after.

One comment was wrong rather than merely noisy: it said cancelling marks a
row so a running handler aborts, while the task contract records the
opposite — the delete removes the row first. It now says so and points at
the measurement.
@SearheiParkhamchuk
SearheiParkhamchuk merged commit 252fa6a into main Sep 10, 2026
2 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.11.4 🎉

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

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

translator: concurrent multi-locale publish silently drops one locale

1 participant