@open-mercato/core 0.6.8-develop.6986.1.3adb0d0df6 → 0.6.8-develop.6992.1.00c90fecff

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.
Files changed (50) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/components/detail/AssignRoleDialog.js +9 -3
  3. package/dist/modules/customers/components/detail/AssignRoleDialog.js.map +2 -2
  4. package/dist/modules/customers/components/detail/DealsSection.js +2 -3
  5. package/dist/modules/customers/components/detail/DealsSection.js.map +2 -2
  6. package/dist/modules/customers/components/detail/assignableStaff.js +2 -1
  7. package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
  8. package/dist/modules/customers/components/detail/schedule/LinkedEntitiesField.js +11 -12
  9. package/dist/modules/customers/components/detail/schedule/LinkedEntitiesField.js.map +2 -2
  10. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +7 -4
  11. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
  12. package/dist/modules/data_sync/api/run.js +9 -1
  13. package/dist/modules/data_sync/api/run.js.map +2 -2
  14. package/dist/modules/data_sync/api/runs/[id]/retry.js +9 -1
  15. package/dist/modules/data_sync/api/runs/[id]/retry.js.map +2 -2
  16. package/dist/modules/data_sync/lib/adapter-registry.js +10 -1
  17. package/dist/modules/data_sync/lib/adapter-registry.js.map +2 -2
  18. package/dist/modules/data_sync/lib/start-cursor.js +17 -0
  19. package/dist/modules/data_sync/lib/start-cursor.js.map +7 -0
  20. package/dist/modules/data_sync/lib/sync-engine.js +84 -27
  21. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  22. package/dist/modules/data_sync/lib/sync-run-service.js +86 -10
  23. package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
  24. package/dist/modules/data_sync/workers/sync-scheduled.js +9 -6
  25. package/dist/modules/data_sync/workers/sync-scheduled.js.map +2 -2
  26. package/dist/modules/progress/lib/progressService.js +2 -0
  27. package/dist/modules/progress/lib/progressService.js.map +2 -2
  28. package/dist/modules/progress/lib/progressServiceImpl.js +78 -34
  29. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  30. package/dist/modules/sales/api/channels/route.js +1 -1
  31. package/dist/modules/sales/api/channels/route.js.map +2 -2
  32. package/package.json +7 -7
  33. package/src/modules/customers/components/detail/AssignRoleDialog.tsx +12 -3
  34. package/src/modules/customers/components/detail/DealsSection.tsx +6 -11
  35. package/src/modules/customers/components/detail/assignableStaff.ts +9 -1
  36. package/src/modules/customers/components/detail/schedule/LinkedEntitiesField.tsx +16 -13
  37. package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +11 -4
  38. package/src/modules/data_sync/AGENTS.md +6 -1
  39. package/src/modules/data_sync/api/run.ts +9 -1
  40. package/src/modules/data_sync/api/runs/[id]/retry.ts +9 -1
  41. package/src/modules/data_sync/lib/adapter-registry.ts +15 -0
  42. package/src/modules/data_sync/lib/adapter.ts +18 -0
  43. package/src/modules/data_sync/lib/start-cursor.ts +35 -0
  44. package/src/modules/data_sync/lib/sync-engine.ts +101 -27
  45. package/src/modules/data_sync/lib/sync-run-service.ts +118 -10
  46. package/src/modules/data_sync/workers/sync-scheduled.ts +9 -6
  47. package/src/modules/progress/AGENTS.md +2 -1
  48. package/src/modules/progress/lib/progressService.ts +9 -0
  49. package/src/modules/progress/lib/progressServiceImpl.ts +111 -37
  50. package/src/modules/sales/api/channels/route.ts +1 -1
@@ -26,6 +26,25 @@ type SyncScope = {
26
26
  tenantId: string
27
27
  }
28
28
 
29
+ export type CursorCommitOptions = {
30
+ /**
31
+ * Mirror the committed cursor into the shared `sync_cursors` row. Defaults to
32
+ * `true`; the engine passes the adapter's `persistsSharedCursor(entityType)`
33
+ * verdict. `false` keeps the cursor on the run row alone.
34
+ */
35
+ persistSharedCursor?: boolean
36
+ /**
37
+ * Fences the write against a concurrent delivery: the run must still be
38
+ * `running` and still sit on this batch count, or the commit throws
39
+ * {@link SyncRunOwnershipConflictError} and rolls back. Omit to keep the
40
+ * unguarded write for callers outside the engine.
41
+ */
42
+ expectedBatchesCompleted?: number
43
+ }
44
+
45
+ /** {@link CursorCommitOptions} minus the fence, which `updateCursor` does not apply. */
46
+ export type SharedCursorOption = Pick<CursorCommitOptions, 'persistSharedCursor'>
47
+
29
48
  /**
30
49
  * Raised when a batch commit loses the ownership compare-and-swap, meaning
31
50
  * another delivery of the same job advanced the run while this worker was
@@ -64,8 +83,15 @@ export function createSyncRunService(em: EntityManager) {
64
83
  )
65
84
  }
66
85
 
67
- function applyCursorMutation(run: SyncRun, cursorRow: SyncCursor | null, cursor: string, scope: SyncScope): void {
86
+ function applyCursorMutation(
87
+ run: SyncRun,
88
+ cursorRow: SyncCursor | null,
89
+ cursor: string,
90
+ scope: SyncScope,
91
+ persistSharedCursor: boolean,
92
+ ): void {
68
93
  run.cursor = cursor
94
+ if (!persistSharedCursor) return
69
95
  if (cursorRow) {
70
96
  cursorRow.cursor = cursor
71
97
  } else {
@@ -230,25 +256,37 @@ export function createSyncRunService(em: EntityManager) {
230
256
  * @deprecated Use {@link commitBatchProgress}. This method advances the
231
257
  * cursor without the ownership fence, so a stale delivery can move the
232
258
  * cursor of a run another worker owns. Kept for external callers only.
259
+ *
260
+ * It still takes `persistSharedCursor` despite being deprecated: an external
261
+ * caller advancing the cursor of an opted-out entity type would otherwise
262
+ * create the very `sync_cursors` row the opt-out exists to avoid, and a
263
+ * later incremental run would read it as a start position. The deprecated
264
+ * path has to honour the opt-out for as long as it exists.
233
265
  */
234
- async updateCursor(runId: string, cursor: string, scope: SyncScope): Promise<void> {
266
+ async updateCursor(runId: string, cursor: string, scope: SyncScope, options?: SharedCursorOption): Promise<void> {
235
267
  const run = await this.getRun(runId, scope)
236
268
  if (!run) return
237
- const cursorRow = await resolveCursorRow(run, scope)
269
+ const persistSharedCursor = options?.persistSharedCursor ?? true
270
+ const cursorRow = persistSharedCursor ? await resolveCursorRow(run, scope) : null
238
271
  await withAtomicFlush(em, [
239
- () => applyCursorMutation(run, cursorRow, cursor, scope),
272
+ () => applyCursorMutation(run, cursorRow, cursor, scope, persistSharedCursor),
240
273
  ], { transaction: true })
241
274
  },
242
275
 
243
276
  /**
244
277
  * Commits one batch's counters and cursor in a single transaction.
245
278
  *
246
- * Passing `expectedBatchesCompleted` fences the write: the run must still be
247
- * `running` and still sit on that batch count, or another delivery of the
248
- * same BullMQ job owns the run and this commit throws
279
+ * Passing `options.expectedBatchesCompleted` fences the write: the run must
280
+ * still be `running` and still sit on that batch count, or another delivery
281
+ * of the same BullMQ job owns the run and this commit throws
249
282
  * `SyncRunOwnershipConflictError` and rolls back. Omitting it keeps the
250
283
  * legacy unguarded write for callers outside the engine.
251
284
  *
285
+ * `options.persistSharedCursor` is orthogonal to the fence: it decides
286
+ * whether the committed cursor is mirrored into the shared `sync_cursors`
287
+ * row, and the two compose freely — a fenced commit for an opted-out entity
288
+ * type advances the run row alone and still throws on a stale fence.
289
+ *
252
290
  * The fence token is `batchesCompleted` rather than `cursor` because it
253
291
  * advances by construction on every commit. A cursor is a free-form adapter
254
292
  * string that an adapter may legitimately repeat between batches — the
@@ -268,11 +306,13 @@ export function createSyncRunService(em: EntityManager) {
268
306
  delta: Partial<Pick<SyncRun, 'createdCount' | 'updatedCount' | 'skippedCount' | 'failedCount' | 'batchesCompleted'>>,
269
307
  cursor: string,
270
308
  scope: SyncScope,
271
- expectedBatchesCompleted?: number,
309
+ options?: CursorCommitOptions,
272
310
  ): Promise<SyncRun | null> {
273
311
  const run = await this.getRun(runId, scope)
274
312
  if (!run) return null
275
- const cursorRow = await resolveCursorRow(run, scope)
313
+ const { expectedBatchesCompleted } = options ?? {}
314
+ const persistSharedCursor = options?.persistSharedCursor ?? true
315
+ const cursorRow = persistSharedCursor ? await resolveCursorRow(run, scope) : null
276
316
  const claimRunOwnership = async () => {
277
317
  if ((delta.batchesCompleted ?? 0) < 1) {
278
318
  throw new Error(`[internal] A fenced commit for sync run ${runId} must advance batchesCompleted`)
@@ -301,7 +341,7 @@ export function createSyncRunService(em: EntityManager) {
301
341
  run.skippedCount += delta.skippedCount ?? 0
302
342
  run.failedCount += delta.failedCount ?? 0
303
343
  run.batchesCompleted += delta.batchesCompleted ?? 0
304
- applyCursorMutation(run, cursorRow, cursor, scope)
344
+ applyCursorMutation(run, cursorRow, cursor, scope, persistSharedCursor)
305
345
  },
306
346
  ], { transaction: true })
307
347
  return run
@@ -324,6 +364,74 @@ export function createSyncRunService(em: EntityManager) {
324
364
  return row?.cursor ?? null
325
365
  },
326
366
 
367
+ /**
368
+ * Resume position for an entity type whose adapter opted out of the shared
369
+ * `sync_cursors` row: the cursor of the most recent run, unless that run
370
+ * reached `completed`. A finished walk resumes from `null` so the next run
371
+ * starts over rather than skipping everything an older interrupted run had
372
+ * already passed.
373
+ */
374
+ async resolveResumeCursor(integrationId: string, entityType: string, direction: 'import' | 'export', scope: SyncScope): Promise<string | null> {
375
+ const [run] = await findWithDecryption(
376
+ em,
377
+ SyncRun,
378
+ {
379
+ integrationId,
380
+ entityType,
381
+ direction,
382
+ organizationId: scope.organizationId,
383
+ tenantId: scope.tenantId,
384
+ deletedAt: null,
385
+ },
386
+ { orderBy: { createdAt: 'DESC' }, limit: 1 },
387
+ scope,
388
+ )
389
+ if (!run || run.status === 'completed') return null
390
+ return run.cursor ?? null
391
+ },
392
+
393
+ /**
394
+ * Clears the run-scoped resume position for an entity type, so the next
395
+ * non-`fullSync` run starts from the beginning. Returns how many runs were
396
+ * cleared.
397
+ *
398
+ * This is the opt-out's equivalent of deleting the shared `sync_cursors`
399
+ * row. An entity type whose adapter returns `false` from
400
+ * `persistsSharedCursor` has no such row, so a reset flow that only deletes
401
+ * `SyncCursor` would leave {@link resolveResumeCursor} returning the cursor
402
+ * of the interrupted run it just reset against — re-importing the tail of a
403
+ * walk instead of the whole thing. Reset flows MUST call this alongside
404
+ * their `SyncCursor` delete; it is a no-op when nothing is interrupted.
405
+ *
406
+ * The `status` filter here selects which rows to clear. It is deliberately
407
+ * NOT the read-side filter {@link resolveResumeCursor} avoids: that method
408
+ * reads the single most recent run whatever its status, precisely so an
409
+ * older interrupted run cannot outlive a later completed walk. Clearing
410
+ * every interrupted run is enough to start fresh either way — if the latest
411
+ * run was interrupted its cursor is now null, and if it completed the resume
412
+ * path already returns null.
413
+ */
414
+ async resetResumePosition(
415
+ integrationId: string,
416
+ entityType: string,
417
+ direction: 'import' | 'export',
418
+ scope: SyncScope,
419
+ ): Promise<number> {
420
+ return em.nativeUpdate(
421
+ SyncRun,
422
+ {
423
+ integrationId,
424
+ entityType,
425
+ direction,
426
+ status: { $ne: 'completed' },
427
+ organizationId: scope.organizationId,
428
+ tenantId: scope.tenantId,
429
+ deletedAt: null,
430
+ },
431
+ { cursor: null, updatedAt: new Date() },
432
+ )
433
+ },
434
+
327
435
  async findRunningOverlap(integrationId: string, entityType: string, direction: 'import' | 'export', scope: SyncScope): Promise<SyncRun | null> {
328
436
  const [run] = await findWithDecryption(
329
437
  em,
@@ -6,6 +6,7 @@ import type { ProgressService } from '../../progress/lib/progressService'
6
6
  import type { SyncRunService } from '../lib/sync-run-service'
7
7
  import { SyncSchedule } from '../data/entities'
8
8
  import { startDataSyncRun } from '../lib/start-run'
9
+ import { resolveAdapterForIntegration, resolveStartCursor } from '../lib/start-cursor'
9
10
 
10
11
  type ScheduledSyncPayload = {
11
12
  scheduleId: string
@@ -65,12 +66,14 @@ export default async function handle(job: QueuedJob<ScheduledSyncPayload>, ctx:
65
66
 
66
67
  const cursor = schedule.fullSync
67
68
  ? null
68
- : await syncRunService.resolveCursor(
69
- schedule.integrationId,
70
- schedule.entityType,
71
- schedule.direction,
72
- job.payload.scope,
73
- )
69
+ : await resolveStartCursor({
70
+ syncRunService,
71
+ adapter: resolveAdapterForIntegration(schedule.integrationId),
72
+ integrationId: schedule.integrationId,
73
+ entityType: schedule.entityType,
74
+ direction: schedule.direction,
75
+ scope: job.payload.scope,
76
+ })
74
77
 
75
78
  schedule.lastRunAt = new Date()
76
79
  await em.flush()
@@ -93,7 +93,8 @@ Use stable, grep-friendly ids:
93
93
  `progressService` is safe to run across many app/worker instances. Do not reintroduce read-modify-write transitions:
94
94
 
95
95
  - **Every status transition is a status-guarded `nativeUpdate` (CAS).** An update that matches zero rows lost the race — it MUST NOT emit events or overwrite the row. Allowed transitions: start from `pending|failed` (an already-running start is an idempotent no-op; `failed` allows queue retries and recovery from a wrong stale sweep), complete from `pending|running|failed`, fail from `pending|running`, cancel from `pending|running|failed`.
96
- - **Progress writes are guarded on `status IN ('pending','running')`** — once another process finishes/cancels a job, buffered updaters stop writing to it.
96
+ - **Progress writes are guarded on `status IN ('pending','running')`** — once another process finishes/cancels a job, buffered updaters stop writing to it. Exception: when a write path finds the job `failed`, it attempts exactly one revival through the start CAS (a live producer writing progress proves a stale sweep false-positived) before giving up. That revive CAS is narrowed to rows whose `errorMessage` starts with `STALE_SWEEP_ERROR_PREFIX`, so a job that failed for a real reason keeps its message and stack instead of being resurrected by a racing buffered writer; `completed`/`cancelled` are never revived, and genuinely dead producers never write again, so real sweeps stick. Only `startJob` uses the unrestricted CAS, because a queue retry restarts the whole unit of work. A revive never resets `startedAt` — progress counts are absolute across deliveries, so the elapsed window `calculateEta` divides them by must be too.
97
+ - **Long single units of work MUST call `touchJobHeartbeat`** — when one batch/step can outlast `STALE_JOB_TIMEOUT_SECONDS` (60s), run a keepalive that calls the optional `touchJobHeartbeat(jobId, ctx)` while awaiting it (see `withHeartbeat` in `data_sync/lib/sync-engine.ts`). It writes only `heartbeat_at`/`updated_at` on a **forked** EntityManager, so it is safe while the producer's own EM is mid-transaction, and it self-heals a falsely-swept job via the start CAS. Always optional-chain it (`progressService.touchJobHeartbeat?.(...)`) — third-party implementations may not provide it. The "no timers for durable work" rule below bans replacing queue delivery with timers, not an in-process keepalive around a pending await.
97
98
  - **`incrementProgress` deltas persist as atomic SQL increments** (`processed_count + n`), and the service reloads the database winner before returning or emitting, so concurrent writers never lose or report stale counts.
98
99
  - **Update-path reads use `disableIdentityMap: true`** — `isCancellationRequested` and lifecycle reads must always see fresh cross-process state, never a stale managed entity.
99
100
  - **The stale sweep (`markStaleJobsFailed`) re-checks staleness per row inside the CAS**, so concurrent sweepers emit exactly one `JOB_FAILED` per job, and it also fails `pending` jobs that never started within `STALE_PENDING_TIMEOUT_SECONDS` (a late queue delivery recovers them via `startJob`'s `failed → running` transition).
@@ -21,12 +21,21 @@ export interface ProgressService {
21
21
  getRecentlyCompletedJobs(ctx: ProgressServiceContext, sinceSeconds?: number): Promise<ProgressJob[]>
22
22
  getJob(jobId: string, ctx: ProgressServiceContext): Promise<ProgressJob | null>
23
23
  markStaleJobsFailed(tenantId: string, timeoutSeconds?: number, organizationId?: string | null): Promise<number>
24
+ // Optional so third-party ProgressService implementations keep compiling; callers must
25
+ // optional-chain. Runs on a forked EntityManager, so it is safe to call while the shared
26
+ // request/worker EM is mid-transaction (e.g. from a keepalive timer around adapter I/O).
27
+ touchJobHeartbeat?(jobId: string, ctx: ProgressServiceContext): Promise<void>
24
28
  }
25
29
 
26
30
  export const HEARTBEAT_INTERVAL_MS = 5000
27
31
  export const STALE_JOB_TIMEOUT_SECONDS = 60
28
32
  export const STALE_PENDING_TIMEOUT_SECONDS = 900
29
33
 
34
+ // Every `errorMessage` the stale sweep writes starts with this, so recovery paths can tell
35
+ // a job the sweep gave up on from one that failed for a real reason and must keep its
36
+ // diagnostics. Declared here so the sweep and the revive filter cannot drift apart.
37
+ export const STALE_SWEEP_ERROR_PREFIX = 'Job stale:'
38
+
30
39
  export function calculateEta(
31
40
  processedCount: number,
32
41
  totalCount: number,
@@ -9,6 +9,7 @@ import {
9
9
  HEARTBEAT_INTERVAL_MS,
10
10
  STALE_JOB_TIMEOUT_SECONDS,
11
11
  STALE_PENDING_TIMEOUT_SECONDS,
12
+ STALE_SWEEP_ERROR_PREFIX,
12
13
  } from './progressService'
13
14
  import { PROGRESS_EVENTS } from './events'
14
15
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
@@ -131,6 +132,58 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
131
132
  return {}
132
133
  }
133
134
 
135
+ // The start transition (START_FROM_STATUSES) doubles as the recovery path for jobs a
136
+ // stale sweep flipped to `failed` while their producer was alive but slow. Callers pass
137
+ // the EM to run against so the forked heartbeat path can reuse the same CAS.
138
+ //
139
+ // `staleSweptOnly` is for the revive paths: a live write proves the producer is alive,
140
+ // but it does NOT prove the recorded failure was the sweep's. Narrowing the CAS to rows
141
+ // the sweep tagged keeps a genuine failure's message and stack intact instead of
142
+ // resurrecting the job and erasing why it died. `startJob` stays unrestricted so an
143
+ // at-least-once queue can still retry a genuinely failed job from the top.
144
+ async function startJobViaCas(
145
+ targetEm: EntityManager,
146
+ job: ProgressJob,
147
+ ctx: ProgressServiceContext,
148
+ options: { staleSweptOnly?: boolean } = {},
149
+ ): Promise<ProgressJob | null> {
150
+ const now = new Date()
151
+ // Progress counts are absolute and survive across deliveries, so the elapsed window
152
+ // calculateEta divides them by has to survive too. Resetting it on every revive makes
153
+ // a job with hours of work left report seconds remaining.
154
+ const startedAt = job.startedAt ?? now
155
+ const filter: Record<string, unknown> = {
156
+ ...jobScopeFilter(job.id, ctx),
157
+ status: { $in: START_FROM_STATUSES as ProgressJobStatus[] },
158
+ }
159
+ if (options.staleSweptOnly) filter.errorMessage = { $like: `${STALE_SWEEP_ERROR_PREFIX}%` }
160
+ const affected = await targetEm.nativeUpdate(ProgressJob, filter as FilterQuery<ProgressJob>, {
161
+ status: 'running',
162
+ startedAt,
163
+ heartbeatAt: now,
164
+ finishedAt: null,
165
+ errorMessage: null,
166
+ errorStack: null,
167
+ updatedAt: now,
168
+ })
169
+ if (affected === 0) return null
170
+
171
+ job.status = 'running'
172
+ job.startedAt = startedAt
173
+ job.heartbeatAt = now
174
+ job.finishedAt = null
175
+ job.errorMessage = null
176
+ job.errorStack = null
177
+
178
+ await eventBus.emit(PROGRESS_EVENTS.JOB_STARTED, {
179
+ ...buildJobPayload(job),
180
+ tenantId: ctx.tenantId,
181
+ organizationId: job.organizationId ?? null,
182
+ })
183
+
184
+ return job
185
+ }
186
+
134
187
  async function persistAndMaybeBroadcast(entry: JobUpdateThrottleEntry, ctx: ProgressServiceContext): Promise<ProgressJob> {
135
188
  const job = entry.job
136
189
  const now = Date.now()
@@ -153,16 +206,32 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
153
206
  updatedAt: new Date(),
154
207
  ...buildBufferedCountData(entry),
155
208
  }
156
- const affected = await em.nativeUpdate(ProgressJob, {
209
+ const writableFilter = {
157
210
  ...jobScopeFilter(job.id, ctx),
158
211
  status: { $in: PROGRESS_WRITABLE_STATUSES as ProgressJobStatus[] },
159
- } as FilterQuery<ProgressJob>, data)
212
+ } as FilterQuery<ProgressJob>
213
+ let affected = await em.nativeUpdate(ProgressJob, writableFilter, data)
160
214
 
161
215
  if (affected === 0) {
162
- // The job reached a terminal state in another process; stop writing to it.
163
- forgetJobThrottle(job.id)
216
+ // A stale sweep may have flipped a live job to `failed` between writes. Revive it
217
+ // through the start CAS and retry once so the buffered delta isn't silently dropped.
164
218
  const fresh = await loadFreshJob(job.id, ctx)
165
- return fresh ?? job
219
+ let revived = false
220
+ if (fresh && fresh.status === 'failed' && (await startJobViaCas(em, fresh, ctx, { staleSweptOnly: true }))) {
221
+ revived = true
222
+ job.status = 'running'
223
+ job.startedAt = fresh.startedAt
224
+ job.finishedAt = null
225
+ job.errorMessage = null
226
+ job.errorStack = null
227
+ affected = await em.nativeUpdate(ProgressJob, writableFilter, data)
228
+ }
229
+ if (affected === 0) {
230
+ // The job reached a genuinely terminal state in another process; stop writing to it.
231
+ forgetJobThrottle(job.id)
232
+ const latest = revived ? await loadFreshJob(job.id, ctx) : fresh
233
+ return latest ?? job
234
+ }
166
235
  }
167
236
 
168
237
  const persistedJob = usesAtomicIncrement ? (await loadFreshJob(job.id, ctx)) ?? job : job
@@ -219,47 +288,48 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
219
288
  return job
220
289
  }
221
290
 
291
+ const started = await startJobViaCas(em, job, ctx)
292
+ if (started) return started
293
+
294
+ const fresh = await loadFreshJob(jobId, ctx)
295
+ return fresh ?? job
296
+ },
297
+
298
+ async touchJobHeartbeat(jobId, ctx) {
299
+ // Forked EM: keepalive timers call this while the shared EM may be mid-transaction
300
+ // inside a producer's own writes, so the heartbeat must not join that unit of work.
301
+ const fork = em.fork()
222
302
  const now = new Date()
223
- const affected = await em.nativeUpdate(ProgressJob, {
303
+ const affected = await fork.nativeUpdate(ProgressJob, {
224
304
  ...jobScopeFilter(jobId, ctx),
225
- status: { $in: START_FROM_STATUSES as ProgressJobStatus[] },
305
+ status: { $in: PROGRESS_WRITABLE_STATUSES as ProgressJobStatus[] },
226
306
  } as FilterQuery<ProgressJob>, {
227
- status: 'running',
228
- startedAt: now,
229
307
  heartbeatAt: now,
230
- finishedAt: null,
231
- errorMessage: null,
232
- errorStack: null,
233
308
  updatedAt: now,
234
309
  })
310
+ if (affected > 0) return
235
311
 
236
- if (affected === 0) {
237
- const fresh = await loadFreshJob(jobId, ctx)
238
- return fresh ?? job
239
- }
240
-
241
- job.status = 'running'
242
- job.startedAt = now
243
- job.heartbeatAt = now
244
- job.finishedAt = null
245
- job.errorMessage = null
246
- job.errorStack = null
247
-
248
- await eventBus.emit(PROGRESS_EVENTS.JOB_STARTED, {
249
- ...buildJobPayload(job),
250
- tenantId: ctx.tenantId,
251
- organizationId: job.organizationId ?? null,
252
- })
253
-
254
- return job
312
+ const job = await fork.findOne(ProgressJob, jobScopeFilter(jobId, ctx), { disableIdentityMap: true })
313
+ if (!job || job.status !== 'failed') return
314
+ // A live producer heartbeating a `failed` job means a stale sweep false-positived;
315
+ // revive through the start CAS so the card recovers without waiting for a batch boundary.
316
+ await startJobViaCas(fork, job, ctx, { staleSweptOnly: true })
255
317
  },
256
318
 
257
319
  async updateProgress(jobId, input, ctx) {
258
320
  const entry = await ensureThrottleEntry(jobId, ctx)
259
321
  const job = entry.job
260
322
  if (TERMINAL_STATUSES.includes(job.status)) {
261
- forgetJobThrottle(jobId)
262
- return job
323
+ // `failed` may be a false-positive stale sweep on a slow-but-alive producer; the
324
+ // fact that this write is happening proves the producer is alive, so revive and
325
+ // apply the update. `completed`/`cancelled` stay terminal, and a failure the sweep
326
+ // did not write keeps its diagnostics (see startJobViaCas).
327
+ const revived = job.status === 'failed' && (await startJobViaCas(em, job, ctx, { staleSweptOnly: true }))
328
+ if (!revived) {
329
+ forgetJobThrottle(jobId)
330
+ return job
331
+ }
332
+ entry.lastPersistedAt = 0
263
333
  }
264
334
 
265
335
  if (input.processedCount !== undefined) {
@@ -295,8 +365,12 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
295
365
  const entry = await ensureThrottleEntry(jobId, ctx)
296
366
  const job = entry.job
297
367
  if (TERMINAL_STATUSES.includes(job.status)) {
298
- forgetJobThrottle(jobId)
299
- return job
368
+ const revived = job.status === 'failed' && (await startJobViaCas(em, job, ctx, { staleSweptOnly: true }))
369
+ if (!revived) {
370
+ forgetJobThrottle(jobId)
371
+ return job
372
+ }
373
+ entry.lastPersistedAt = 0
300
374
  }
301
375
 
302
376
  job.processedCount += delta
@@ -604,7 +678,7 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
604
678
  const staleRunning = await em.find(ProgressJob, { ...scope, ...staleFilter }, { disableIdentityMap: true })
605
679
 
606
680
  for (const job of staleRunning) {
607
- const errorMessage = `Job stale: no heartbeat for ${timeoutSeconds} seconds`
681
+ const errorMessage = `${STALE_SWEEP_ERROR_PREFIX} no heartbeat for ${timeoutSeconds} seconds`
608
682
  // Per-row CAS (re-checking the staleness condition): exactly one concurrent
609
683
  // sweeper wins, and a heartbeat that landed after the SELECT aborts the failure.
610
684
  const affected = await em.nativeUpdate(ProgressJob, {
@@ -644,7 +718,7 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
644
718
  const stalePending = await em.find(ProgressJob, { ...scope, ...stalePendingFilter }, { disableIdentityMap: true })
645
719
 
646
720
  for (const job of stalePending) {
647
- const errorMessage = `Job stale: never started within ${STALE_PENDING_TIMEOUT_SECONDS} seconds`
721
+ const errorMessage = `${STALE_SWEEP_ERROR_PREFIX} never started within ${STALE_PENDING_TIMEOUT_SECONDS} seconds`
648
722
  const affected = await em.nativeUpdate(ProgressJob, {
649
723
  id: job.id,
650
724
  tenantId: job.tenantId,
@@ -38,7 +38,7 @@ const listSchema = z
38
38
  .passthrough()
39
39
 
40
40
  const routeMetadata = {
41
- GET: { requireAuth: true, requireFeatures: ['sales.channels.manage'] },
41
+ GET: { requireAuth: true, requireFeatures: ['sales.channels.view'] },
42
42
  POST: { requireAuth: true, requireFeatures: ['sales.channels.manage'] },
43
43
  PUT: { requireAuth: true, requireFeatures: ['sales.channels.manage'] },
44
44
  DELETE: { requireAuth: true, requireFeatures: ['sales.channels.manage'] },