@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/customers/components/detail/AssignRoleDialog.js +9 -3
- package/dist/modules/customers/components/detail/AssignRoleDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/DealsSection.js +2 -3
- package/dist/modules/customers/components/detail/DealsSection.js.map +2 -2
- package/dist/modules/customers/components/detail/assignableStaff.js +2 -1
- package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/LinkedEntitiesField.js +11 -12
- package/dist/modules/customers/components/detail/schedule/LinkedEntitiesField.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +7 -4
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
- package/dist/modules/data_sync/api/run.js +9 -1
- package/dist/modules/data_sync/api/run.js.map +2 -2
- package/dist/modules/data_sync/api/runs/[id]/retry.js +9 -1
- package/dist/modules/data_sync/api/runs/[id]/retry.js.map +2 -2
- package/dist/modules/data_sync/lib/adapter-registry.js +10 -1
- package/dist/modules/data_sync/lib/adapter-registry.js.map +2 -2
- package/dist/modules/data_sync/lib/start-cursor.js +17 -0
- package/dist/modules/data_sync/lib/start-cursor.js.map +7 -0
- package/dist/modules/data_sync/lib/sync-engine.js +84 -27
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-run-service.js +86 -10
- package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
- package/dist/modules/data_sync/workers/sync-scheduled.js +9 -6
- package/dist/modules/data_sync/workers/sync-scheduled.js.map +2 -2
- package/dist/modules/progress/lib/progressService.js +2 -0
- package/dist/modules/progress/lib/progressService.js.map +2 -2
- package/dist/modules/progress/lib/progressServiceImpl.js +78 -34
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/dist/modules/sales/api/channels/route.js +1 -1
- package/dist/modules/sales/api/channels/route.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customers/components/detail/AssignRoleDialog.tsx +12 -3
- package/src/modules/customers/components/detail/DealsSection.tsx +6 -11
- package/src/modules/customers/components/detail/assignableStaff.ts +9 -1
- package/src/modules/customers/components/detail/schedule/LinkedEntitiesField.tsx +16 -13
- package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +11 -4
- package/src/modules/data_sync/AGENTS.md +6 -1
- package/src/modules/data_sync/api/run.ts +9 -1
- package/src/modules/data_sync/api/runs/[id]/retry.ts +9 -1
- package/src/modules/data_sync/lib/adapter-registry.ts +15 -0
- package/src/modules/data_sync/lib/adapter.ts +18 -0
- package/src/modules/data_sync/lib/start-cursor.ts +35 -0
- package/src/modules/data_sync/lib/sync-engine.ts +101 -27
- package/src/modules/data_sync/lib/sync-run-service.ts +118 -10
- package/src/modules/data_sync/workers/sync-scheduled.ts +9 -6
- package/src/modules/progress/AGENTS.md +2 -1
- package/src/modules/progress/lib/progressService.ts +9 -0
- package/src/modules/progress/lib/progressServiceImpl.ts +111 -37
- 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(
|
|
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
|
|
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
|
|
247
|
-
* `running` and still sit on that batch count, or another delivery
|
|
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
|
-
|
|
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
|
|
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
|
|
69
|
-
|
|
70
|
-
schedule.
|
|
71
|
-
schedule.
|
|
72
|
-
|
|
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
|
|
209
|
+
const writableFilter = {
|
|
157
210
|
...jobScopeFilter(job.id, ctx),
|
|
158
211
|
status: { $in: PROGRESS_WRITABLE_STATUSES as ProgressJobStatus[] },
|
|
159
|
-
} as FilterQuery<ProgressJob
|
|
212
|
+
} as FilterQuery<ProgressJob>
|
|
213
|
+
let affected = await em.nativeUpdate(ProgressJob, writableFilter, data)
|
|
160
214
|
|
|
161
215
|
if (affected === 0) {
|
|
162
|
-
//
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|
303
|
+
const affected = await fork.nativeUpdate(ProgressJob, {
|
|
224
304
|
...jobScopeFilter(jobId, ctx),
|
|
225
|
-
status: { $in:
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
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'] },
|