@purposeinplay/payload-ai-translate 0.1.0 → 0.1.2

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.
@@ -75,6 +75,7 @@ export type LegacyLockResult = {
75
75
  } | {
76
76
  acquired: false;
77
77
  release?: undefined;
78
+ reason: 'sibling_running' | 'not_pending';
78
79
  } | {
79
80
  acquired: 'noop';
80
81
  release: () => Promise<void>;
@@ -131,7 +131,8 @@ export async function tryClaimAsLock(args) {
131
131
  };
132
132
  }
133
133
  return {
134
- acquired: false
134
+ acquired: false,
135
+ reason: r.reason
135
136
  };
136
137
  }
137
138
  // Re-export the units collection slug used by callers (kept here so the
@@ -134,7 +134,11 @@ export async function runWorkerForUnit(params) {
134
134
  // batches: a janitor-reset unit whose batch already ended must not
135
135
  // run again.
136
136
  // -------------------------------------------------------------------
137
- if (batchStatus === 'cancelling' || batchStatus === 'cancelled' || batchStatus === 'reverted') {
137
+ if (batchStatus === 'cancelling' || batchStatus === 'cancelled' || batchStatus === 'reverted' || batchStatus === 'success' || batchStatus === 'failed' || batchStatus === 'partial') {
138
+ // Preserve the existing `cancelled_by_admin` reason for the cancel /
139
+ // revert path (callers + tests rely on it); a batch that finished on
140
+ // its own (`success`/`failed`/`partial`) gets a distinct reason.
141
+ const skipReason = batchStatus === 'success' || batchStatus === 'failed' || batchStatus === 'partial' ? 'batch_already_terminal' : 'cancelled_by_admin';
138
142
  // Only rewrite units that are still open — a terminal unit (e.g.
139
143
  // `failed`) re-fired by a duplicate job must keep its real outcome.
140
144
  if (unit.status === 'pending' || unit.status === 'running') {
@@ -143,7 +147,7 @@ export async function runWorkerForUnit(params) {
143
147
  id: unitId,
144
148
  data: {
145
149
  status: 'skipped',
146
- failureMessage: 'cancelled_by_admin',
150
+ failureMessage: skipReason,
147
151
  completedAt: now().toISOString()
148
152
  },
149
153
  overrideAccess: true
@@ -155,7 +159,7 @@ export async function runWorkerForUnit(params) {
155
159
  await maybeTransitionBatch(payload, batchesSlug, unitsSlug, unit.batchId, callbacks);
156
160
  return {
157
161
  status: 'skipped',
158
- failureMessage: 'cancelled_by_admin'
162
+ failureMessage: skipReason
159
163
  };
160
164
  }
161
165
  // -------------------------------------------------------------------
@@ -242,11 +246,38 @@ export async function runWorkerForUnit(params) {
242
246
  documentId: unit.documentId
243
247
  });
244
248
  if (claim.acquired === false) {
245
- // Couldn't claim sibling unit for the same doc is running, OR
246
- // the unit was already moved out of `pending` by a competing fire
247
- // of the same job. Re-enqueue this unit so a later cron tick
248
- // retries; don't bump attempts (deferral isn't a translation
249
- // failure).
249
+ // Two very different failure modes, previously conflated into an
250
+ // unconditional re-enqueue which is what caused the 2026-06-16
251
+ // outage: a cancelled/finished batch's leftover jobs re-enqueued
252
+ // themselves every cron tick forever, each re-reading the source doc.
253
+ //
254
+ // - `not_pending`: this unit is no longer `pending` (it's terminal,
255
+ // or another fire of the SAME job claimed it). There is no work to
256
+ // retry. Complete the job WITHOUT re-enqueuing — re-enqueuing here
257
+ // is the infinite loop. Return the unit's real current status so
258
+ // the job output is truthful; never write the unit row (a running
259
+ // sibling fire owns its outcome).
260
+ // - `sibling_running`: a DIFFERENT locale of the same doc is running
261
+ // and this unit is still `pending` and genuinely needs to retry on
262
+ // a later tick. This is the only case that warrants a re-enqueue.
263
+ if (claim.reason === 'not_pending') {
264
+ const fresh = await payload.findByID({
265
+ collection: unitsSlug,
266
+ id: unitId,
267
+ overrideAccess: true,
268
+ depth: 0
269
+ });
270
+ const freshStatus = fresh?.status;
271
+ const mapped = freshStatus === 'success' ? 'success' : freshStatus === 'failed' ? 'failed' : 'skipped';
272
+ earlyLog.event('info', 'bulk.worker.unit.terminal-unit-no-requeue', {
273
+ unitStatus: freshStatus ?? 'unknown'
274
+ });
275
+ return {
276
+ status: mapped
277
+ };
278
+ }
279
+ // sibling_running — re-enqueue this unit so a later cron tick retries;
280
+ // don't bump attempts (deferral isn't a translation failure).
250
281
  try {
251
282
  await payload.jobs.queue({
252
283
  task: BULK_TRANSLATE_DOC_TASK_SLUG,
@@ -85,12 +85,72 @@ export async function runJanitorSweep(params) {
85
85
  const stale = result.docs;
86
86
  const batchesSlug = params.batchesSlug ?? DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG;
87
87
  const affectedBatchIds = new Set();
88
- const noteBatch = (u)=>{
88
+ const extractBatchId = (u)=>{
89
89
  const raw = typeof u.batchId === 'object' && u.batchId !== null ? u.batchId.id : u.batchId;
90
- if (raw !== undefined && raw !== null && String(raw).length > 0) {
91
- affectedBatchIds.add(String(raw));
90
+ return raw !== undefined && raw !== null && String(raw).length > 0 ? String(raw) : null;
91
+ };
92
+ const noteBatch = (u)=>{
93
+ const id = extractBatchId(u);
94
+ if (id) {
95
+ affectedBatchIds.add(id);
92
96
  }
93
97
  };
98
+ // A batch in a terminal state must never have its units re-queued: the
99
+ // run is over, and a re-queued job would only re-read the source doc
100
+ // before being skipped. Re-queueing terminal-batch units (left behind by
101
+ // a cancel/finish) is what produced the 2026-06-16 zombie-job storm.
102
+ // `queued`/`running`/`cancelling` are still live and safe to re-queue
103
+ // into (the worker's cancel gate skips cancelling units on entry).
104
+ const REQUEUEABLE_BATCH_STATUSES = new Set([
105
+ 'queued',
106
+ 'running',
107
+ 'cancelling'
108
+ ]);
109
+ const batchStatusCache = new Map();
110
+ const getBatchStatus = async (batchId)=>{
111
+ if (batchStatusCache.has(batchId)) {
112
+ return batchStatusCache.get(batchId);
113
+ }
114
+ let status;
115
+ try {
116
+ const batch = await payload.findByID({
117
+ collection: batchesSlug,
118
+ id: batchId,
119
+ overrideAccess: true,
120
+ depth: 0
121
+ });
122
+ status = batch?.status;
123
+ } catch {
124
+ status = undefined;
125
+ }
126
+ batchStatusCache.set(batchId, status);
127
+ return status;
128
+ };
129
+ // Whether a unit's batch is still live enough to re-queue into. Tolerant
130
+ // on a missing/unreadable batch (undefined) — preserve prior behaviour
131
+ // and let the worker's own guards handle it.
132
+ const batchIsRequeueable = async (u)=>{
133
+ const batchId = extractBatchId(u);
134
+ if (!batchId) {
135
+ return true;
136
+ }
137
+ const status = await getBatchStatus(batchId);
138
+ return status === undefined || REQUEUEABLE_BATCH_STATUSES.has(status);
139
+ };
140
+ // Mark a unit terminal (skipped) so a dead/terminal-batch orphan stops
141
+ // being rescanned every sweep, without creating a replacement job.
142
+ const skipTerminalBatchUnit = async (unitId)=>{
143
+ await payload.update({
144
+ collection: unitsSlug,
145
+ id: unitId,
146
+ data: {
147
+ status: 'skipped',
148
+ failureMessage: 'batch_already_terminal',
149
+ completedAt: new Date(now()).toISOString()
150
+ },
151
+ overrideAccess: true
152
+ });
153
+ };
94
154
  // A unit's worker job is queued exactly once (at enumeration, or by the
95
155
  // worker's own deferral re-queue). The worker task has `retries: 0`, and
96
156
  // the coordinator never re-runs after enumeration finishes — so a reset
@@ -111,6 +171,12 @@ export async function runJanitorSweep(params) {
111
171
  for (const u of stale){
112
172
  const attempts = u.attempts ?? 0;
113
173
  try {
174
+ // Don't resurrect a unit whose batch already ended — mark it
175
+ // terminal instead so it leaves the stale scan without spawning a job.
176
+ if (!await batchIsRequeueable(u)) {
177
+ await skipTerminalBatchUnit(u.id);
178
+ continue;
179
+ }
114
180
  if (attempts < maxAttempts) {
115
181
  await payload.update({
116
182
  collection: unitsSlug,
@@ -205,6 +271,12 @@ export async function runJanitorSweep(params) {
205
271
  });
206
272
  for (const u of orphans.docs){
207
273
  try {
274
+ // Skip — don't re-queue — units whose batch already ended; mark
275
+ // them terminal so they drop out of the orphan scan.
276
+ if (!await batchIsRequeueable(u)) {
277
+ await skipTerminalBatchUnit(u.id);
278
+ continue;
279
+ }
208
280
  await requeueUnit(u.id);
209
281
  await payload.update({
210
282
  collection: unitsSlug,
package/package.json CHANGED
@@ -1,46 +1,98 @@
1
1
  {
2
2
  "name": "@purposeinplay/payload-ai-translate",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "AI translation plugin for Payload CMS 3 — multi-provider (OpenAI, Anthropic, Gemini, custom), bulk translation, Lexical-aware, with an admin Translation Hub.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "main": "./dist/exports/index.js",
8
- "types": "./dist/exports/index.d.ts",
7
+ "packageManager": "pnpm@9.15.0",
8
+ "main": "./src/exports/index.ts",
9
+ "types": "./src/exports/index.ts",
9
10
  "exports": {
10
11
  ".": {
11
- "types": "./dist/exports/index.d.ts",
12
- "import": "./dist/exports/index.js",
13
- "default": "./dist/exports/index.js"
12
+ "types": "./src/exports/index.ts",
13
+ "import": "./src/exports/index.ts",
14
+ "default": "./src/exports/index.ts"
14
15
  },
15
16
  "./providers": {
16
- "types": "./dist/exports/providers.d.ts",
17
- "import": "./dist/exports/providers.js",
18
- "default": "./dist/exports/providers.js"
17
+ "types": "./src/exports/providers.ts",
18
+ "import": "./src/exports/providers.ts",
19
+ "default": "./src/exports/providers.ts"
19
20
  },
20
21
  "./views": {
21
- "types": "./dist/exports/views.d.ts",
22
- "import": "./dist/exports/views.js",
23
- "default": "./dist/exports/views.js"
22
+ "types": "./src/exports/views.ts",
23
+ "import": "./src/exports/views.ts",
24
+ "default": "./src/exports/views.ts"
24
25
  },
25
26
  "./views-client": {
26
- "types": "./dist/exports/views-client.d.ts",
27
- "import": "./dist/exports/views-client.js",
28
- "default": "./dist/exports/views-client.js"
27
+ "types": "./src/exports/views-client.ts",
28
+ "import": "./src/exports/views-client.ts",
29
+ "default": "./src/exports/views-client.ts"
29
30
  },
30
31
  "./client": {
31
- "types": "./dist/exports/client.d.ts",
32
- "import": "./dist/exports/client.js",
33
- "default": "./dist/exports/client.js"
32
+ "types": "./src/exports/client.ts",
33
+ "import": "./src/exports/client.ts",
34
+ "default": "./src/exports/client.ts"
34
35
  },
35
36
  "./components": {
36
- "types": "./dist/exports/components.d.ts",
37
- "import": "./dist/exports/components.js",
38
- "default": "./dist/exports/components.js"
37
+ "types": "./src/exports/components.ts",
38
+ "import": "./src/exports/components.ts",
39
+ "default": "./src/exports/components.ts"
39
40
  }
40
41
  },
41
42
  "files": [
42
43
  "dist"
43
44
  ],
45
+ "publishConfig": {
46
+ "main": "./dist/exports/index.js",
47
+ "types": "./dist/exports/index.d.ts",
48
+ "exports": {
49
+ ".": {
50
+ "types": "./dist/exports/index.d.ts",
51
+ "import": "./dist/exports/index.js",
52
+ "default": "./dist/exports/index.js"
53
+ },
54
+ "./providers": {
55
+ "types": "./dist/exports/providers.d.ts",
56
+ "import": "./dist/exports/providers.js",
57
+ "default": "./dist/exports/providers.js"
58
+ },
59
+ "./views": {
60
+ "types": "./dist/exports/views.d.ts",
61
+ "import": "./dist/exports/views.js",
62
+ "default": "./dist/exports/views.js"
63
+ },
64
+ "./views-client": {
65
+ "types": "./dist/exports/views-client.d.ts",
66
+ "import": "./dist/exports/views-client.js",
67
+ "default": "./dist/exports/views-client.js"
68
+ },
69
+ "./client": {
70
+ "types": "./dist/exports/client.d.ts",
71
+ "import": "./dist/exports/client.js",
72
+ "default": "./dist/exports/client.js"
73
+ },
74
+ "./components": {
75
+ "types": "./dist/exports/components.d.ts",
76
+ "import": "./dist/exports/components.js",
77
+ "default": "./dist/exports/components.js"
78
+ }
79
+ }
80
+ },
81
+ "scripts": {
82
+ "build": "pnpm run build:types && pnpm run build:swc && pnpm run build:fix-esm",
83
+ "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
84
+ "build:swc": "swc ./src -d ./dist --config-file ./.swcrc --strip-leading-paths --ignore \"**/__tests__/**\"",
85
+ "build:fix-esm": "node ./scripts/fix-dist-extensions.mjs dist && node ./scripts/check-dist-esm.mjs dist",
86
+ "typecheck": "tsc -p tsconfig.json --noEmit",
87
+ "test": "vitest run",
88
+ "lint": "biome check .",
89
+ "format": "biome format --write .",
90
+ "check": "biome check . && pnpm typecheck && pnpm build && pnpm test",
91
+ "clean": "rm -rf dist *.tsbuildinfo",
92
+ "changeset": "changeset",
93
+ "version-packages": "changeset version",
94
+ "release": "pnpm build && changeset publish"
95
+ },
44
96
  "peerDependencies": {
45
97
  "@ai-sdk/anthropic": "^3.0.0",
46
98
  "@ai-sdk/google": "^3.0.0",
@@ -122,20 +174,5 @@
122
174
  "openai",
123
175
  "anthropic",
124
176
  "gemini"
125
- ],
126
- "scripts": {
127
- "build": "pnpm run build:types && pnpm run build:swc && pnpm run build:fix-esm",
128
- "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
129
- "build:swc": "swc ./src -d ./dist --config-file ./.swcrc --strip-leading-paths --ignore \"**/__tests__/**\"",
130
- "build:fix-esm": "node ./scripts/fix-dist-extensions.mjs dist && node ./scripts/check-dist-esm.mjs dist",
131
- "typecheck": "tsc -p tsconfig.json --noEmit",
132
- "test": "vitest run",
133
- "lint": "biome check .",
134
- "format": "biome format --write .",
135
- "check": "biome check . && pnpm typecheck && pnpm build && pnpm test",
136
- "clean": "rm -rf dist *.tsbuildinfo",
137
- "changeset": "changeset",
138
- "version-packages": "changeset version",
139
- "release": "pnpm build && changeset publish"
140
- }
141
- }
177
+ ]
178
+ }