diff --git a/apps/dev/src/integration/translator/callEndpoint.ts b/apps/dev/src/integration/translator/callEndpoint.ts index 878cd8dbd..0689abce4 100644 --- a/apps/dev/src/integration/translator/callEndpoint.ts +++ b/apps/dev/src/integration/translator/callEndpoint.ts @@ -9,7 +9,8 @@ import type { Payload, PayloadRequest } from "payload"; * handler code path. This is the faithful way to trigger a manual translation (`POST /translate/enqueue`) * or read staleness (`GET /translate/stale/:collection_slug/:collection_id`) from a local-API test. * - * @returns the endpoint's HTTP status + parsed JSON body. + * @returns the endpoint's HTTP status, and the parsed JSON body — `undefined` when the response + * has no body (204). */ export async function callEndpoint( payload: Payload, @@ -31,6 +32,6 @@ export async function callEndpoint( } as unknown as PayloadRequest; const res = await endpoint.handler(req); - const data = await res.json(); - return { status: res.status, data }; + const text = await res.text(); + return { status: res.status, data: text ? JSON.parse(text) : undefined }; } diff --git a/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts b/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts index ccda800cc..81477fed5 100644 --- a/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts +++ b/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts @@ -96,16 +96,13 @@ describe("when one locale's provider fails", () => { ["fr", "failed"], ]); - // Payload backs a failed job off exponentially, so the picker would skip it for the next few - // seconds. Clearing the delay lets the retry happen now rather than making the spec wait. - await failing.payload.update({ - collection: "payload-jobs" as "pages", - id: (docs[0] as { id: string | number }).id, - data: { waitUntil: null, processing: false } as never, - }); - + // Payload backs a failed job off exponentially; only the manual-run endpoint lifts that, so the + // retry has to go through it. const before = failing.translateCount(); - await failing.payload.jobs.run({ queue: "translations", limit: CRON_BATCH_LIMIT }); + const retry = await callEndpoint(failing.payload, "post", "/translate/run/:id", { + routeParams: { id: String((docs[0] as { id: string | number }).id) }, + }); + expect(retry.status, "the retry endpoint did not accept the job").toBe(204); expect( failing.translateCount() - before, diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.test.ts index 82282e40e..7b2175830 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.test.ts @@ -457,20 +457,20 @@ describe("PayloadJobsTaskRunner", () => { }); }); - it("clears what blocks the picker before retrying a failed job", async () => { - mockPayload.find.mockResolvedValue({ - docs: [createJob({ error: { message: "provider down" } })], - }); + it("clears the blockers even for a job that is neither running nor failed", async () => { + const stillDebounced = new Date(Date.now() + 60_000).toISOString(); + const queued = createJob({ processing: false, waitUntil: stillDebounced }); + mockPayload.find.mockResolvedValue({ docs: [queued] }); - await runner.run("job-123"); + const result = await runner.run("job-123"); expect(mockPayload.update).toHaveBeenCalledWith( expect.objectContaining({ - collection: "payload-jobs", where: { id: { equals: "job-123" } }, data: { processing: false, hasError: false, error: null, waitUntil: null }, }) ); + expect(result).toEqual({ success: true }); }); it("returns already_running when a job is genuinely in flight (fresh lock)", async () => { @@ -484,6 +484,9 @@ describe("PayloadJobsTaskRunner", () => { expect(result).toEqual({ success: false, error: "already_running" }); expect(mockPayload.jobs.run).not.toHaveBeenCalled(); + // Clearing above this guard would reset `processing` on a live job and let a second worker + // take the same document. + expect(mockPayload.update).not.toHaveBeenCalled(); }); it("re-runs a job whose processing lock is stale (killed mid-run)", async () => { @@ -549,7 +552,9 @@ describe("PayloadJobsTaskRunner", () => { where: { id: { equals: "job-123" } }, limit: 1, }); - expect(mockPayload.update).not.toHaveBeenCalled(); + expect(mockPayload.update.mock.invocationCallOrder[0]).toBeLessThan( + mockPayload.jobs.run.mock.invocationCallOrder[0] + ); expect(mockPayload.find).toHaveBeenCalledWith( expect.objectContaining({ where: { diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts index 4d8a6b254..b7a270d23 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts @@ -187,17 +187,16 @@ export class PayloadJobsTaskRunner implements TaskRunner { if (job.processing && !this.isStale(job.updatedAt)) { return { success: false, error: "already_running" }; } - if (job.processing || job.error) { - await this.clearPickerBlockers(taskId); - } + // Unconditional: a manual run also lifts the auto-translate debounce, not just the lock. + await this.clearPickerBlockers(taskId); // Not `jobs.runByID`: payload 3.84.1 builds the picker guard (processing / hasError / waitUntil) // only on the `where` path, so the id path re-runs a job that exhausted its retries. - const result = (await this.payload.jobs.run({ + const result = await this.payload.jobs.run({ queue: this.config.queueName, where: { id: { equals: taskId } }, limit: 1, - })) as { jobStatus?: Record }; + }); const pickerTookNothing = Object.keys(result?.jobStatus ?? {}).length === 0; if (pickerTookNothing) { diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/types.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/types.ts index fbf79f851..b6395dba5 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/types.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/types.ts @@ -89,6 +89,7 @@ export type PayloadJob = { updatedAt: string; error?: unknown; processing?: boolean | null; + waitUntil?: string | null; input?: { /** Document reference (flat text, ID-agnostic). Current shape. */ collection_slug?: string;