@focus-reactive/payload-plugin-translator 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/features/cancel/handler.d.ts +2 -2
- package/dist/server/features/cancel/handler.js +3 -3
- package/dist/server/features/cancel-by-collection/handler.d.ts +3 -3
- package/dist/server/features/cancel-by-collection/handler.js +5 -5
- package/dist/server/features/enqueue-translation/handler.d.ts +3 -3
- package/dist/server/features/enqueue-translation/handler.js +4 -4
- package/dist/server/features/get-collection-status/handler.d.ts +3 -3
- package/dist/server/features/get-collection-status/handler.js +4 -4
- package/dist/server/features/get-document-status/handler.d.ts +3 -3
- package/dist/server/features/get-document-status/handler.js +4 -4
- package/dist/server/features/run-translation/handler.d.ts +2 -2
- package/dist/server/features/run-translation/handler.js +7 -7
- package/dist/server/modules/task-runner/TaskRunnerProvider.interface.d.ts +1 -0
- package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.js +26 -0
- package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.d.ts +29 -0
- package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.js +101 -6
- package/dist/server/modules/task-runner/payload-jobs-runner/types.d.ts +17 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { PayloadRequest } from
|
|
2
|
-
import type { TaskRunnerProvider } from
|
|
1
|
+
import type { PayloadRequest } from "payload";
|
|
2
|
+
import type { TaskRunnerProvider } from "../../modules/task-runner";
|
|
3
3
|
/**
|
|
4
4
|
* Cancels and deletes translation tasks by IDs
|
|
5
5
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ServerResponse } from
|
|
2
|
-
import { CancelInputSchema } from
|
|
1
|
+
import { ServerResponse } from "../../shared";
|
|
2
|
+
import { CancelInputSchema } from "./model";
|
|
3
3
|
/**
|
|
4
4
|
* Cancels and deletes translation tasks by IDs
|
|
5
5
|
*/ export class CancelHandler {
|
|
@@ -9,7 +9,7 @@ import { CancelInputSchema } from './model';
|
|
|
9
9
|
}
|
|
10
10
|
async handle(req) {
|
|
11
11
|
const validationResult = CancelInputSchema.safeParse(await req.json?.());
|
|
12
|
-
if (validationResult.error) return ServerResponse.validationError(validationResult.error.
|
|
12
|
+
if (validationResult.error) return ServerResponse.validationError(validationResult.error.issues);
|
|
13
13
|
const { ids } = validationResult.data;
|
|
14
14
|
const runner = this.taskRunnerFactory.create(req.payload);
|
|
15
15
|
await runner.cancel(ids);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { PayloadRequest } from
|
|
2
|
-
import type { TaskRunnerProvider } from
|
|
3
|
-
import {
|
|
1
|
+
import type { PayloadRequest } from "payload";
|
|
2
|
+
import type { TaskRunnerProvider } from "../../modules/task-runner";
|
|
3
|
+
import type { CancelConfig } from './model';
|
|
4
4
|
/**
|
|
5
5
|
* Cancels all pending translation tasks for a collection
|
|
6
6
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ServerResponse } from
|
|
2
|
-
import { isCollectionAvailable } from
|
|
1
|
+
import { ServerResponse } from "../../shared";
|
|
2
|
+
import { isCollectionAvailable } from "../_lib/collection-utils";
|
|
3
3
|
import { CancelByCollectionInputSchema } from './model';
|
|
4
4
|
/**
|
|
5
5
|
* Cancels all pending translation tasks for a collection
|
|
@@ -12,13 +12,13 @@ import { CancelByCollectionInputSchema } from './model';
|
|
|
12
12
|
}
|
|
13
13
|
async handle(req) {
|
|
14
14
|
const validationResult = CancelByCollectionInputSchema.safeParse(req.routeParams);
|
|
15
|
-
if (validationResult.error) return ServerResponse.validationError(validationResult.error.
|
|
15
|
+
if (validationResult.error) return ServerResponse.validationError(validationResult.error.issues);
|
|
16
16
|
const collectionSlug = isCollectionAvailable(validationResult.data.collection_slug, this.config.availableCollections);
|
|
17
|
-
if (!collectionSlug) return ServerResponse.badRequest(
|
|
17
|
+
if (!collectionSlug) return ServerResponse.badRequest("Collection not available for translation");
|
|
18
18
|
const runner = this.taskRunnerFactory.create(req.payload);
|
|
19
19
|
const tasks = await runner.findByCollection(collectionSlug);
|
|
20
20
|
if (tasks.length === 0) return ServerResponse.noContent();
|
|
21
|
-
const pendingTaskIds = tasks.filter((task)=>task.status ===
|
|
21
|
+
const pendingTaskIds = tasks.filter((task)=>task.status === "pending").map((task)=>task.id);
|
|
22
22
|
if (pendingTaskIds.length === 0) return ServerResponse.noContent();
|
|
23
23
|
await runner.cancel(pendingTaskIds);
|
|
24
24
|
return ServerResponse.noContent();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { PayloadRequest } from
|
|
2
|
-
import type { TaskRunnerProvider } from
|
|
3
|
-
import {
|
|
1
|
+
import type { PayloadRequest } from "payload";
|
|
2
|
+
import type { TaskRunnerProvider } from "../../modules/task-runner";
|
|
3
|
+
import type { EnqueueConfig } from './model';
|
|
4
4
|
/**
|
|
5
5
|
* Enqueues translation tasks for documents
|
|
6
6
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ServerResponse } from
|
|
2
|
-
import { isCollectionAvailable, getAllCollectionIds } from
|
|
1
|
+
import { ServerResponse } from "../../shared";
|
|
2
|
+
import { isCollectionAvailable, getAllCollectionIds } from "../_lib/collection-utils";
|
|
3
3
|
import { EnqueueInputSchema } from './model';
|
|
4
4
|
/**
|
|
5
5
|
* Enqueues translation tasks for documents
|
|
@@ -12,10 +12,10 @@ import { EnqueueInputSchema } from './model';
|
|
|
12
12
|
}
|
|
13
13
|
async handle(req) {
|
|
14
14
|
const validationResult = EnqueueInputSchema.safeParse(await req.json?.());
|
|
15
|
-
if (validationResult.error) return ServerResponse.validationError(validationResult.error.
|
|
15
|
+
if (validationResult.error) return ServerResponse.validationError(validationResult.error.issues);
|
|
16
16
|
const { source_lng, target_lng, collection_slug, collection_id, select_all, strategy, publish_on_translation } = validationResult.data;
|
|
17
17
|
const collectionSlug = isCollectionAvailable(collection_slug, this.config.availableCollections);
|
|
18
|
-
if (!collectionSlug) return ServerResponse.badRequest(
|
|
18
|
+
if (!collectionSlug) return ServerResponse.badRequest("Content of this collection is not available for translation");
|
|
19
19
|
const collectionIds = select_all ? await getAllCollectionIds(req.payload, collectionSlug) : collection_id;
|
|
20
20
|
const runner = this.taskRunnerFactory.create(req.payload);
|
|
21
21
|
const tasks = collectionIds.map((id)=>({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { PayloadRequest } from
|
|
2
|
-
import type { TaskRunnerProvider } from
|
|
3
|
-
import {
|
|
1
|
+
import type { PayloadRequest } from "payload";
|
|
2
|
+
import type { TaskRunnerProvider } from "../../modules/task-runner";
|
|
3
|
+
import type { GetCollectionStatusConfig } from './model';
|
|
4
4
|
/**
|
|
5
5
|
* Gets translation status for all documents in a collection
|
|
6
6
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ServerResponse } from
|
|
2
|
-
import { isCollectionAvailable } from
|
|
1
|
+
import { ServerResponse } from "../../shared";
|
|
2
|
+
import { isCollectionAvailable } from "../_lib/collection-utils";
|
|
3
3
|
import { GetCollectionStatusInputSchema } from './model';
|
|
4
4
|
/**
|
|
5
5
|
* Gets translation status for all documents in a collection
|
|
@@ -12,9 +12,9 @@ import { GetCollectionStatusInputSchema } from './model';
|
|
|
12
12
|
}
|
|
13
13
|
async handle(req) {
|
|
14
14
|
const validationResult = GetCollectionStatusInputSchema.safeParse(req.routeParams);
|
|
15
|
-
if (validationResult.error) return ServerResponse.validationError(validationResult.error.
|
|
15
|
+
if (validationResult.error) return ServerResponse.validationError(validationResult.error.issues);
|
|
16
16
|
const collectionSlug = isCollectionAvailable(validationResult.data.collection_slug, this.config.availableCollections);
|
|
17
|
-
if (!collectionSlug) return ServerResponse.badRequest(
|
|
17
|
+
if (!collectionSlug) return ServerResponse.badRequest("Collection not available for translation");
|
|
18
18
|
const runner = this.taskRunnerFactory.create(req.payload);
|
|
19
19
|
const tasks = await runner.findByCollection(collectionSlug);
|
|
20
20
|
return ServerResponse.success({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { PayloadRequest } from
|
|
2
|
-
import type { TaskRunnerProvider } from
|
|
3
|
-
import {
|
|
1
|
+
import type { PayloadRequest } from "payload";
|
|
2
|
+
import type { TaskRunnerProvider } from "../../modules/task-runner";
|
|
3
|
+
import type { GetDocumentStatusConfig } from './model';
|
|
4
4
|
/**
|
|
5
5
|
* Gets the translation status for a specific document
|
|
6
6
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ServerResponse } from
|
|
2
|
-
import { isCollectionAvailable } from
|
|
1
|
+
import { ServerResponse } from "../../shared";
|
|
2
|
+
import { isCollectionAvailable } from "../_lib/collection-utils";
|
|
3
3
|
import { GetDocumentStatusInputSchema, taskToJobStatusOutput } from './model';
|
|
4
4
|
/**
|
|
5
5
|
* Gets the translation status for a specific document
|
|
@@ -12,10 +12,10 @@ import { GetDocumentStatusInputSchema, taskToJobStatusOutput } from './model';
|
|
|
12
12
|
}
|
|
13
13
|
async handle(req) {
|
|
14
14
|
const validationResult = GetDocumentStatusInputSchema.safeParse(req.routeParams);
|
|
15
|
-
if (validationResult.error) return ServerResponse.validationError(validationResult.error.
|
|
15
|
+
if (validationResult.error) return ServerResponse.validationError(validationResult.error.issues);
|
|
16
16
|
const { collection_slug, collection_id } = validationResult.data;
|
|
17
17
|
const collectionSlug = isCollectionAvailable(collection_slug, this.config.availableCollections);
|
|
18
|
-
if (!collectionSlug) return ServerResponse.badRequest(
|
|
18
|
+
if (!collectionSlug) return ServerResponse.badRequest("Collection not available for translation");
|
|
19
19
|
const runner = this.taskRunnerFactory.create(req.payload);
|
|
20
20
|
const tasks = await runner.findByCollection(collectionSlug, [
|
|
21
21
|
collection_id
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { PayloadRequest } from
|
|
2
|
-
import type { TaskRunnerProvider } from
|
|
1
|
+
import type { PayloadRequest } from "payload";
|
|
2
|
+
import type { TaskRunnerProvider } from "../../modules/task-runner";
|
|
3
3
|
/**
|
|
4
4
|
* Runs a translation task by ID
|
|
5
5
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ServerResponse } from
|
|
2
|
-
import { RunInputSchema } from
|
|
1
|
+
import { ServerResponse } from "../../shared";
|
|
2
|
+
import { RunInputSchema } from "./model";
|
|
3
3
|
/**
|
|
4
4
|
* Runs a translation task by ID
|
|
5
5
|
*/ export class RunTranslationHandler {
|
|
@@ -9,16 +9,16 @@ import { RunInputSchema } from './model';
|
|
|
9
9
|
}
|
|
10
10
|
async handle(req) {
|
|
11
11
|
const validationResult = RunInputSchema.safeParse(req.routeParams);
|
|
12
|
-
if (validationResult.error) return ServerResponse.validationError(validationResult.error.
|
|
12
|
+
if (validationResult.error) return ServerResponse.validationError(validationResult.error.issues);
|
|
13
13
|
const { id } = validationResult.data;
|
|
14
14
|
const runner = this.taskRunnerFactory.create(req.payload);
|
|
15
15
|
const result = await runner.run(id);
|
|
16
16
|
if (!result.success) {
|
|
17
|
-
if (result.error ===
|
|
18
|
-
return ServerResponse.notFound(
|
|
17
|
+
if (result.error === "not_found" || result.error === "already_completed") {
|
|
18
|
+
return ServerResponse.notFound("Queued task not found");
|
|
19
19
|
}
|
|
20
|
-
if (result.error ===
|
|
21
|
-
return ServerResponse.tooManyRequests(
|
|
20
|
+
if (result.error === "already_running") {
|
|
21
|
+
return ServerResponse.tooManyRequests("Translation task is already in progress");
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
return ServerResponse.noContent();
|
|
@@ -41,6 +41,7 @@ export interface TaskRunnerProvider {
|
|
|
41
41
|
/**
|
|
42
42
|
* Configures the runner and returns a Payload config modifier.
|
|
43
43
|
* The modifier adds necessary tasks, jobs, queues to Payload config.
|
|
44
|
+
* Implementations may also install a `config.onInit` hook for boot-time initialization (e.g. stale-lock recovery).
|
|
44
45
|
*/
|
|
45
46
|
configure(context: TaskRunnerContext): (config: Config) => Config;
|
|
46
47
|
}
|
|
@@ -4,11 +4,13 @@ const defaultAutoRun = {
|
|
|
4
4
|
cron: "* * * * *",
|
|
5
5
|
limit: 50
|
|
6
6
|
};
|
|
7
|
+
const DEFAULT_STALE_JOB_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
7
8
|
const defaultValues = {
|
|
8
9
|
taskName: "translate_document",
|
|
9
10
|
queueName: "translations",
|
|
10
11
|
jobsCollection: "payload-jobs",
|
|
11
12
|
autoRun: defaultAutoRun,
|
|
13
|
+
staleJobTimeoutMs: DEFAULT_STALE_JOB_TIMEOUT_MS,
|
|
12
14
|
retries: {
|
|
13
15
|
attempts: 3,
|
|
14
16
|
backoff: {
|
|
@@ -28,11 +30,16 @@ const defaultValues = {
|
|
|
28
30
|
...defaultAutoRun,
|
|
29
31
|
...options.autoRun
|
|
30
32
|
} : defaultAutoRun;
|
|
33
|
+
const staleJobTimeoutMs = options?.staleJobTimeoutMs ?? defaultValues.staleJobTimeoutMs;
|
|
34
|
+
if (!Number.isFinite(staleJobTimeoutMs) || staleJobTimeoutMs <= 0) {
|
|
35
|
+
throw new Error(`[payload-plugin-translator] staleJobTimeoutMs must be a positive finite number (got ${staleJobTimeoutMs})`);
|
|
36
|
+
}
|
|
31
37
|
this.config = {
|
|
32
38
|
taskName: options?.taskName ?? defaultValues.taskName,
|
|
33
39
|
queueName: options?.queueName ?? defaultValues.queueName,
|
|
34
40
|
jobsCollection: options?.jobsCollection ?? defaultValues.jobsCollection,
|
|
35
41
|
autoRun,
|
|
42
|
+
staleJobTimeoutMs,
|
|
36
43
|
retries: options?.retries ?? defaultValues.retries
|
|
37
44
|
};
|
|
38
45
|
}
|
|
@@ -141,6 +148,25 @@ const defaultValues = {
|
|
|
141
148
|
];
|
|
142
149
|
}
|
|
143
150
|
}
|
|
151
|
+
// Reset stale locks on boot so jobs abandoned by a killed process
|
|
152
|
+
// (deploy/crash/timeout) become eligible for the autorun picker again.
|
|
153
|
+
// The picker requires processing:false, no error, and no pending waitUntil;
|
|
154
|
+
// a mid-run casualty (no error, no waitUntil) satisfies the rest, so
|
|
155
|
+
// clearing processing is sufficient for that case. Threshold-based, so a
|
|
156
|
+
// job genuinely in flight on another live instance (fresh updatedAt) is
|
|
157
|
+
// left alone. Wrapped so a reclaim failure never blocks startup.
|
|
158
|
+
const existingOnInit = config.onInit;
|
|
159
|
+
config.onInit = async (payload)=>{
|
|
160
|
+
if (existingOnInit) await existingOnInit(payload);
|
|
161
|
+
try {
|
|
162
|
+
await new PayloadJobsTaskRunner(payload, this.config).reclaimStaleJobs();
|
|
163
|
+
} catch (err) {
|
|
164
|
+
payload.logger?.error?.({
|
|
165
|
+
err,
|
|
166
|
+
msg: "[translator] failed to reclaim stale translation jobs"
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
};
|
|
144
170
|
return config;
|
|
145
171
|
};
|
|
146
172
|
}
|
|
@@ -14,6 +14,35 @@ export declare class PayloadJobsTaskRunner implements TaskRunner {
|
|
|
14
14
|
enqueue(tasks: TaskInput[]): Promise<void>;
|
|
15
15
|
cancel(taskIds: string[]): Promise<void>;
|
|
16
16
|
run(taskId: string): Promise<RunResult>;
|
|
17
|
+
/**
|
|
18
|
+
* Reset stale processing locks so abandoned jobs become eligible for the
|
|
19
|
+
* autorun picker again. The picker requires processing:false, no error, and
|
|
20
|
+
* no pending waitUntil; a job abandoned mid-run (no error, no waitUntil)
|
|
21
|
+
* satisfies the rest, so clearing processing is sufficient for that case.
|
|
22
|
+
* A job that already exhausted retries (hasError:true) stays excluded from
|
|
23
|
+
* autorun and is only recoverable via a manual run().
|
|
24
|
+
*
|
|
25
|
+
* A job is stale when it is still `processing: true`, not yet completed, and
|
|
26
|
+
* its `updatedAt` is older than `staleJobTimeoutMs` — i.e. a process was
|
|
27
|
+
* killed mid-run (deploy/crash/timeout). Threshold-based, so a job genuinely
|
|
28
|
+
* in flight on another live instance (fresh `updatedAt`) is left alone.
|
|
29
|
+
* Filters on real `payload-jobs` columns only (no JSON-path traversal), so
|
|
30
|
+
* the drizzle SQLite issue in `findByCollection` does not apply here.
|
|
31
|
+
* @returns the number of jobs reclaimed.
|
|
32
|
+
*/
|
|
33
|
+
reclaimStaleJobs(): Promise<number>;
|
|
34
|
+
/**
|
|
35
|
+
* Clear the `processing` lock on every job matching `where`, returning how
|
|
36
|
+
* many were reset. Shared by the per-job reset in `run()` (a stale lock) and
|
|
37
|
+
* the bulk boot/recovery reset in `reclaimStaleJobs()`. `depth: 0` because
|
|
38
|
+
* only the count is needed — no relationships to populate.
|
|
39
|
+
*/
|
|
40
|
+
private resetProcessing;
|
|
41
|
+
/**
|
|
42
|
+
* A processing lock is stale once `updatedAt` is older than the configured
|
|
43
|
+
* timeout — the owning run is presumed dead.
|
|
44
|
+
*/
|
|
45
|
+
private isStale;
|
|
17
46
|
/**
|
|
18
47
|
* Find translation jobs for a collection, optionally narrowed by document IDs.
|
|
19
48
|
*
|
|
@@ -63,19 +63,114 @@ import { normalizeJob } from "./normalizeJob";
|
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
65
|
if (task.status === "running") {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
// A genuinely in-flight job is refused. A stale processing lock (left by
|
|
67
|
+
// a process killed mid-run) is reclaimable: clear it first so the queue
|
|
68
|
+
// picker below — which only selects `processing: false` — can re-run it.
|
|
69
|
+
if (!this.isStale(task.updatedAt)) {
|
|
70
|
+
return {
|
|
71
|
+
success: false,
|
|
72
|
+
error: "already_running"
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
await this.resetProcessing({
|
|
76
|
+
id: {
|
|
77
|
+
equals: taskId
|
|
78
|
+
}
|
|
79
|
+
});
|
|
70
80
|
}
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
// Execute synchronously via the queue + `where` picker so the job runs to
|
|
82
|
+
// completion within this request (nothing is abandoned after the HTTP
|
|
83
|
+
// response — reliable on serverless too).
|
|
84
|
+
//
|
|
85
|
+
// NOT `payload.jobs.runByID({ id })`: on the drizzle adapter the id-path
|
|
86
|
+
// (`db.updateJobs({ id })`) writes `processing: true` but returns no rows,
|
|
87
|
+
// so `runJobs` reports `noJobsRemaining` and the handler never runs —
|
|
88
|
+
// leaving the job stuck at `processing: true` forever. The `where`-based
|
|
89
|
+
// picker selects, runs, and finalizes the job correctly (verified against
|
|
90
|
+
// sqlite). The picker also enforces processing:false / no-error / no
|
|
91
|
+
// pending waitUntil, so a failed (max-retries) job is not re-run here.
|
|
92
|
+
await this.payload.jobs.run({
|
|
93
|
+
queue: this.config.queueName,
|
|
94
|
+
where: {
|
|
95
|
+
id: {
|
|
96
|
+
equals: taskId
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
limit: 1
|
|
73
100
|
});
|
|
74
101
|
return {
|
|
75
102
|
success: true
|
|
76
103
|
};
|
|
77
104
|
}
|
|
78
105
|
/**
|
|
106
|
+
* Reset stale processing locks so abandoned jobs become eligible for the
|
|
107
|
+
* autorun picker again. The picker requires processing:false, no error, and
|
|
108
|
+
* no pending waitUntil; a job abandoned mid-run (no error, no waitUntil)
|
|
109
|
+
* satisfies the rest, so clearing processing is sufficient for that case.
|
|
110
|
+
* A job that already exhausted retries (hasError:true) stays excluded from
|
|
111
|
+
* autorun and is only recoverable via a manual run().
|
|
112
|
+
*
|
|
113
|
+
* A job is stale when it is still `processing: true`, not yet completed, and
|
|
114
|
+
* its `updatedAt` is older than `staleJobTimeoutMs` — i.e. a process was
|
|
115
|
+
* killed mid-run (deploy/crash/timeout). Threshold-based, so a job genuinely
|
|
116
|
+
* in flight on another live instance (fresh `updatedAt`) is left alone.
|
|
117
|
+
* Filters on real `payload-jobs` columns only (no JSON-path traversal), so
|
|
118
|
+
* the drizzle SQLite issue in `findByCollection` does not apply here.
|
|
119
|
+
* @returns the number of jobs reclaimed.
|
|
120
|
+
*/ async reclaimStaleJobs() {
|
|
121
|
+
const cutoff = new Date(Date.now() - this.config.staleJobTimeoutMs).toISOString();
|
|
122
|
+
return this.resetProcessing({
|
|
123
|
+
and: [
|
|
124
|
+
{
|
|
125
|
+
taskSlug: {
|
|
126
|
+
equals: this.config.taskName
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
processing: {
|
|
131
|
+
equals: true
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
completedAt: {
|
|
136
|
+
exists: false
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
updatedAt: {
|
|
141
|
+
less_than: cutoff
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Clear the `processing` lock on every job matching `where`, returning how
|
|
149
|
+
* many were reset. Shared by the per-job reset in `run()` (a stale lock) and
|
|
150
|
+
* the bulk boot/recovery reset in `reclaimStaleJobs()`. `depth: 0` because
|
|
151
|
+
* only the count is needed — no relationships to populate.
|
|
152
|
+
*/ async resetProcessing(where) {
|
|
153
|
+
const result = await this.payload.update({
|
|
154
|
+
collection: this.config.jobsCollection,
|
|
155
|
+
depth: 0,
|
|
156
|
+
where,
|
|
157
|
+
data: {
|
|
158
|
+
processing: false
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
return result.docs.length;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* A processing lock is stale once `updatedAt` is older than the configured
|
|
165
|
+
* timeout — the owning run is presumed dead.
|
|
166
|
+
*/ isStale(updatedAt) {
|
|
167
|
+
const parsed = Date.parse(updatedAt);
|
|
168
|
+
// Unknown/corrupt timestamp → treat as stale so the job can be recovered
|
|
169
|
+
// rather than permanently refused as already-running.
|
|
170
|
+
if (Number.isNaN(parsed)) return true;
|
|
171
|
+
return Date.now() - parsed > this.config.staleJobTimeoutMs;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
79
174
|
* Find translation jobs for a collection, optionally narrowed by document IDs.
|
|
80
175
|
*
|
|
81
176
|
* Narrowing is by `taskSlug` only in SQL; the collection slug and document
|
|
@@ -40,6 +40,22 @@ export type PayloadJobsRunnerOptions = {
|
|
|
40
40
|
* @default { cron: '* * * * *', limit: 50 }
|
|
41
41
|
*/
|
|
42
42
|
autoRun?: false | AutoRunConfig;
|
|
43
|
+
/**
|
|
44
|
+
* How long (ms) a job may stay `processing: true` before its lock is
|
|
45
|
+
* considered stale and the job becomes eligible to be re-run.
|
|
46
|
+
*
|
|
47
|
+
* A process killed mid-run (deploy, crash, request timeout) leaves a job
|
|
48
|
+
* stuck at `processing: true`; the autorun picker only takes
|
|
49
|
+
* `processing: false`, so without recovery such a job would hang forever.
|
|
50
|
+
* On boot the runner resets stale locks, and manual `run()` will re-claim a
|
|
51
|
+
* stale-locked job instead of refusing it as already-running.
|
|
52
|
+
*
|
|
53
|
+
* MUST be larger than the longest a single document translation can
|
|
54
|
+
* legitimately take, otherwise a genuinely in-flight job could be reclaimed
|
|
55
|
+
* and run twice (safe under the idempotent `overwrite` strategy, but wasteful).
|
|
56
|
+
* @default 300000 (5 minutes)
|
|
57
|
+
*/
|
|
58
|
+
staleJobTimeoutMs?: number;
|
|
43
59
|
/**
|
|
44
60
|
* Retry configuration for failed jobs.
|
|
45
61
|
*/
|
|
@@ -59,6 +75,7 @@ export type PayloadJobsRunnerConfig = {
|
|
|
59
75
|
queueName: string;
|
|
60
76
|
jobsCollection: CollectionSlug;
|
|
61
77
|
autoRun: false | Required<AutoRunConfig>;
|
|
78
|
+
staleJobTimeoutMs: number;
|
|
62
79
|
retries?: PayloadJobsRunnerOptions["retries"];
|
|
63
80
|
};
|
|
64
81
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@focus-reactive/payload-plugin-translator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Translation plugin for Payload CMS 3.x. Automatically translate your localized content using any translation provider.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|