@open-mercato/core 0.6.7-develop.6726.1.983ae8a07e → 0.6.7-develop.6749.1.6b54c56dfe
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/generated/entities/resources_resource/index.js +2 -0
- package/dist/generated/entities/resources_resource/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/salesFixtures.js +31 -2
- package/dist/helpers/integration/salesFixtures.js.map +2 -2
- package/dist/helpers/integration/salesUi.js +5 -0
- package/dist/helpers/integration/salesUi.js.map +2 -2
- package/dist/modules/auth/api/profile/route.js +8 -2
- package/dist/modules/auth/api/profile/route.js.map +2 -2
- package/dist/modules/auth/backend/auth/profile/page.js.map +2 -2
- package/dist/modules/auth/backend/profile/change-password/page.js.map +2 -2
- package/dist/modules/communication_channels/data/enrichers.js +4 -4
- package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
- package/dist/modules/customers/analytics.js +1 -0
- package/dist/modules/customers/analytics.js.map +2 -2
- package/dist/modules/customers/components/detail/DealForm.js +2 -1
- package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
- package/dist/modules/customers/components/detail/DealsSection.js +1 -1
- package/dist/modules/customers/components/detail/DealsSection.js.map +2 -2
- package/dist/modules/customers/data/validators.js +3 -1
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js +52 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +42 -26
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js.map +2 -2
- package/dist/modules/data_sync/lib/queue-policy.js +15 -0
- package/dist/modules/data_sync/lib/queue-policy.js.map +7 -0
- package/dist/modules/data_sync/lib/queue.js +16 -1
- package/dist/modules/data_sync/lib/queue.js.map +2 -2
- package/dist/modules/data_sync/lib/start-run.js +2 -1
- package/dist/modules/data_sync/lib/start-run.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js +35 -2
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-run-service.js +70 -2
- package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
- package/dist/modules/data_sync/workers/sync-export.js +9 -2
- package/dist/modules/data_sync/workers/sync-export.js.map +2 -2
- package/dist/modules/data_sync/workers/sync-import.js +9 -2
- package/dist/modules/data_sync/workers/sync-import.js.map +2 -2
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js +34 -12
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js.map +2 -2
- package/dist/modules/dictionaries/api/openapi.js +15 -2
- package/dist/modules/dictionaries/api/openapi.js.map +2 -2
- package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js +30 -29
- package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js.map +2 -2
- package/dist/modules/dictionaries/data/validators.js +7 -0
- package/dist/modules/dictionaries/data/validators.js.map +2 -2
- package/dist/modules/dictionaries/lib/clientEntries.js +4 -3
- package/dist/modules/dictionaries/lib/clientEntries.js.map +2 -2
- package/dist/modules/dictionaries/lib/fetchAllEntries.js +43 -0
- package/dist/modules/dictionaries/lib/fetchAllEntries.js.map +7 -0
- package/dist/modules/entities/api/definitions.js +51 -3
- package/dist/modules/entities/api/definitions.js.map +2 -2
- package/dist/modules/entities/api/entities.js +21 -2
- package/dist/modules/entities/api/entities.js.map +2 -2
- package/dist/modules/entities/api/records.js +3 -24
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/api/sidebar-entities.js +34 -12
- package/dist/modules/entities/api/sidebar-entities.js.map +2 -2
- package/dist/modules/entities/lib/entityAcl.js +46 -0
- package/dist/modules/entities/lib/entityAcl.js.map +2 -2
- package/dist/modules/messages/api/route.js +2 -5
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/lib/participantScope.js +15 -0
- package/dist/modules/messages/lib/participantScope.js.map +7 -0
- package/dist/modules/notifications/api/route.js +3 -1
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/notifications/api/unread-count/route.js +25 -6
- package/dist/modules/notifications/api/unread-count/route.js.map +2 -2
- package/dist/modules/notifications/lib/notificationScope.js +26 -0
- package/dist/modules/notifications/lib/notificationScope.js.map +7 -0
- package/dist/modules/notifications/lib/notificationService.js +7 -3
- package/dist/modules/notifications/lib/notificationService.js.map +2 -2
- package/dist/modules/notifications/lib/routeHelpers.js +16 -2
- package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
- package/dist/modules/planner/components/unavailabilityReasons.js +3 -3
- package/dist/modules/planner/components/unavailabilityReasons.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 +322 -104
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/dist/modules/resources/api/resources.js +2 -0
- package/dist/modules/resources/api/resources.js.map +2 -2
- package/dist/modules/resources/backend/resources/resources/[id]/page.js +3 -2
- package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
- package/dist/modules/resources/commands/resources.js +9 -0
- package/dist/modules/resources/commands/resources.js.map +2 -2
- package/dist/modules/resources/components/detail/dictionaries.js +3 -3
- package/dist/modules/resources/components/detail/dictionaries.js.map +2 -2
- package/dist/modules/resources/data/entities.js +3 -0
- package/dist/modules/resources/data/entities.js.map +2 -2
- package/dist/modules/resources/data/validators.js +6 -2
- package/dist/modules/resources/data/validators.js.map +2 -2
- package/dist/modules/resources/lib/seeds.js +16 -9
- package/dist/modules/resources/lib/seeds.js.map +2 -2
- package/dist/modules/resources/migrations/Migration20260608231000.js +13 -0
- package/dist/modules/resources/migrations/Migration20260608231000.js.map +7 -0
- package/dist/modules/sales/backend/sales/documents/create/page.js +11 -14
- package/dist/modules/sales/backend/sales/documents/create/page.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +8 -0
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/components/documents/ItemsSection.js +18 -9
- package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/LineItemDialog.js +12 -5
- package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesDocumentForm.js +48 -2
- package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +167 -0
- package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +7 -0
- package/dist/modules/sales/data/validators.js +5 -1
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/staff/components/detail/dictionaries.js +3 -3
- package/dist/modules/staff/components/detail/dictionaries.js.map +2 -2
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +11 -4
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
- package/generated/entities/resources_resource/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/helpers/integration/salesFixtures.ts +34 -2
- package/src/helpers/integration/salesUi.ts +8 -0
- package/src/modules/auth/api/profile/route.ts +8 -2
- package/src/modules/auth/backend/auth/profile/page.tsx +1 -0
- package/src/modules/auth/backend/profile/change-password/page.tsx +1 -0
- package/src/modules/communication_channels/data/enrichers.ts +6 -26
- package/src/modules/customers/analytics.ts +1 -0
- package/src/modules/customers/components/detail/DealForm.tsx +5 -1
- package/src/modules/customers/components/detail/DealsSection.tsx +1 -1
- package/src/modules/customers/data/validators.ts +5 -1
- package/src/modules/dashboards/i18n/de.json +3 -0
- package/src/modules/dashboards/i18n/en.json +3 -0
- package/src/modules/dashboards/i18n/es.json +3 -0
- package/src/modules/dashboards/i18n/pl.json +3 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +86 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +33 -18
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.ts +2 -2
- package/src/modules/data_sync/lib/adapter.ts +18 -0
- package/src/modules/data_sync/lib/queue-policy.ts +30 -0
- package/src/modules/data_sync/lib/queue.ts +19 -1
- package/src/modules/data_sync/lib/start-run.ts +2 -1
- package/src/modules/data_sync/lib/sync-engine.ts +46 -0
- package/src/modules/data_sync/lib/sync-run-service.ts +82 -1
- package/src/modules/data_sync/workers/sync-export.ts +8 -1
- package/src/modules/data_sync/workers/sync-import.ts +8 -1
- package/src/modules/dictionaries/api/[dictionaryId]/entries/route.ts +39 -11
- package/src/modules/dictionaries/api/openapi.ts +19 -0
- package/src/modules/dictionaries/components/hooks/useDictionaryEntries.ts +35 -36
- package/src/modules/dictionaries/data/validators.ts +14 -0
- package/src/modules/dictionaries/lib/clientEntries.ts +4 -3
- package/src/modules/dictionaries/lib/fetchAllEntries.ts +72 -0
- package/src/modules/entities/api/definitions.ts +51 -3
- package/src/modules/entities/api/entities.ts +30 -2
- package/src/modules/entities/api/records.ts +3 -27
- package/src/modules/entities/api/sidebar-entities.ts +43 -14
- package/src/modules/entities/lib/entityAcl.ts +58 -0
- package/src/modules/messages/api/route.ts +4 -5
- package/src/modules/messages/lib/participantScope.ts +64 -0
- package/src/modules/notifications/api/route.ts +2 -0
- package/src/modules/notifications/api/unread-count/route.ts +42 -5
- package/src/modules/notifications/lib/notificationScope.ts +40 -0
- package/src/modules/notifications/lib/notificationService.ts +5 -0
- package/src/modules/notifications/lib/routeHelpers.ts +19 -2
- package/src/modules/planner/components/unavailabilityReasons.ts +3 -3
- package/src/modules/progress/AGENTS.md +11 -1
- package/src/modules/progress/lib/progressService.ts +1 -0
- package/src/modules/progress/lib/progressServiceImpl.ts +385 -117
- package/src/modules/resources/api/resources.ts +2 -0
- package/src/modules/resources/backend/resources/resources/[id]/page.tsx +9 -3
- package/src/modules/resources/commands/resources.ts +10 -0
- package/src/modules/resources/components/detail/dictionaries.ts +3 -3
- package/src/modules/resources/data/entities.ts +3 -0
- package/src/modules/resources/data/validators.ts +5 -0
- package/src/modules/resources/lib/seeds.ts +16 -9
- package/src/modules/resources/migrations/.snapshot-open-mercato.json +17 -1
- package/src/modules/resources/migrations/Migration20260608231000.ts +13 -0
- package/src/modules/sales/backend/sales/documents/create/page.tsx +2 -4
- package/src/modules/sales/commands/documents.ts +8 -0
- package/src/modules/sales/components/documents/ItemsSection.tsx +19 -10
- package/src/modules/sales/components/documents/LineItemDialog.tsx +13 -5
- package/src/modules/sales/components/documents/SalesDocumentForm.tsx +48 -6
- package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +198 -0
- package/src/modules/sales/data/validators.ts +7 -1
- package/src/modules/sales/i18n/de.json +2 -0
- package/src/modules/sales/i18n/en.json +2 -0
- package/src/modules/sales/i18n/es.json +2 -0
- package/src/modules/sales/i18n/pl.json +2 -0
- package/src/modules/staff/components/detail/dictionaries.ts +3 -3
- package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +12 -5
|
@@ -1,28 +1,55 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
-
import {
|
|
2
|
+
import { raw } from '@mikro-orm/core'
|
|
3
|
+
import type { EntityData, FilterQuery } from '@mikro-orm/core'
|
|
4
|
+
import { ProgressJob, type ProgressJobStatus } from '../data/entities'
|
|
3
5
|
import type { ProgressService, ProgressServiceContext } from './progressService'
|
|
4
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
calculateEta,
|
|
8
|
+
calculateProgressPercent,
|
|
9
|
+
HEARTBEAT_INTERVAL_MS,
|
|
10
|
+
STALE_JOB_TIMEOUT_SECONDS,
|
|
11
|
+
STALE_PENDING_TIMEOUT_SECONDS,
|
|
12
|
+
} from './progressService'
|
|
5
13
|
import { PROGRESS_EVENTS } from './events'
|
|
6
14
|
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
7
15
|
|
|
8
16
|
const DEFAULT_BROADCAST_MIN_INTERVAL_MS = 250
|
|
9
17
|
|
|
10
|
-
// Minimum elapsed time between coalesced `progress.job.updated`
|
|
18
|
+
// Minimum elapsed time between coalesced `progress.job.updated` broadcasts for a
|
|
11
19
|
// single job. Bulk workers call updateProgress/incrementProgress once per record, and
|
|
12
20
|
// every emit of the `clientBroadcast: true` event pays a serialized pg_notify roundtrip
|
|
13
21
|
// plus a tenant-wide SSE fan-out. Setting the knob to 0 restores per-record emission.
|
|
22
|
+
// Persistence is throttled separately: heartbeats hit the database at least every
|
|
23
|
+
// HEARTBEAT_INTERVAL_MS regardless of this knob, so a high broadcast interval can
|
|
24
|
+
// never starve the stale-job sweep of heartbeats.
|
|
14
25
|
function resolveBroadcastMinIntervalMs(): number {
|
|
15
|
-
const
|
|
16
|
-
if (
|
|
17
|
-
const parsed = Number.parseInt(
|
|
26
|
+
const rawValue = process.env.OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS
|
|
27
|
+
if (rawValue == null || rawValue.trim() === '') return DEFAULT_BROADCAST_MIN_INTERVAL_MS
|
|
28
|
+
const parsed = Number.parseInt(rawValue, 10)
|
|
18
29
|
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_BROADCAST_MIN_INTERVAL_MS
|
|
19
30
|
return parsed
|
|
20
31
|
}
|
|
21
32
|
|
|
33
|
+
// Statuses a given transition is allowed to move FROM. Every write below is a
|
|
34
|
+
// compare-and-swap (`nativeUpdate` guarded on status), so concurrent writers in other
|
|
35
|
+
// processes can never resurrect a terminal job or double-apply a transition — the
|
|
36
|
+
// UPDATE that matches zero rows simply lost the race and must not emit events.
|
|
37
|
+
const TERMINAL_STATUSES: readonly ProgressJobStatus[] = ['completed', 'failed', 'cancelled']
|
|
38
|
+
const PROGRESS_WRITABLE_STATUSES: readonly ProgressJobStatus[] = ['pending', 'running']
|
|
39
|
+
// `failed` is restartable/completable so a job wrongly swept as stale (worker alive but
|
|
40
|
+
// slow) or retried by an at-least-once queue converges to its true outcome.
|
|
41
|
+
const START_FROM_STATUSES: readonly ProgressJobStatus[] = ['pending', 'failed']
|
|
42
|
+
const COMPLETE_FROM_STATUSES: readonly ProgressJobStatus[] = ['pending', 'running', 'failed']
|
|
43
|
+
const FAIL_FROM_STATUSES: readonly ProgressJobStatus[] = ['pending', 'running']
|
|
44
|
+
const CANCEL_FROM_STATUSES: readonly ProgressJobStatus[] = ['pending', 'running', 'failed']
|
|
45
|
+
|
|
22
46
|
type JobUpdateThrottleEntry = {
|
|
23
47
|
job: ProgressJob
|
|
24
48
|
lastBroadcastAt: number
|
|
49
|
+
lastPersistedAt: number
|
|
25
50
|
lastBroadcastPercent: number
|
|
51
|
+
pendingDelta: number
|
|
52
|
+
absoluteCountsPending: boolean
|
|
26
53
|
}
|
|
27
54
|
|
|
28
55
|
function buildJobPayload(job: ProgressJob): Record<string, unknown> {
|
|
@@ -46,10 +73,10 @@ function buildJobPayload(job: ProgressJob): Record<string, unknown> {
|
|
|
46
73
|
export function createProgressService(em: EntityManager, eventBus: { emit: (event: string, payload: Record<string, unknown>) => Promise<void> }): ProgressService {
|
|
47
74
|
const broadcastMinIntervalMs = resolveBroadcastMinIntervalMs()
|
|
48
75
|
// Per-job coalescing state, scoped to this service instance (request/worker scope).
|
|
49
|
-
// The cached
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
76
|
+
// The cached entity is a DETACHED snapshot (loaded with disableIdentityMap) that doubles
|
|
77
|
+
// as the in-memory buffer: intermediate updates mutate it without touching the shared
|
|
78
|
+
// identity map or UnitOfWork, so an unrelated em.flush() elsewhere can never write the
|
|
79
|
+
// buffered values. Persistence happens exclusively through the CAS nativeUpdate below.
|
|
53
80
|
const jobUpdateThrottle = new Map<string, JobUpdateThrottleEntry>()
|
|
54
81
|
|
|
55
82
|
function jobScopeFilter(jobId: string, ctx: ProgressServiceContext) {
|
|
@@ -60,42 +87,101 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
60
87
|
}
|
|
61
88
|
}
|
|
62
89
|
|
|
63
|
-
async function
|
|
90
|
+
async function loadFreshJob(jobId: string, ctx: ProgressServiceContext): Promise<ProgressJob | null> {
|
|
91
|
+
return em.findOne(ProgressJob, jobScopeFilter(jobId, ctx), { disableIdentityMap: true })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function ensureThrottleEntry(jobId: string, ctx: ProgressServiceContext): Promise<JobUpdateThrottleEntry> {
|
|
64
95
|
const cached = jobUpdateThrottle.get(jobId)
|
|
65
|
-
if (cached) return cached
|
|
66
|
-
|
|
96
|
+
if (cached) return cached
|
|
97
|
+
const job = await em.findOneOrFail(ProgressJob, jobScopeFilter(jobId, ctx), { disableIdentityMap: true })
|
|
98
|
+
const entry: JobUpdateThrottleEntry = {
|
|
99
|
+
job,
|
|
100
|
+
lastBroadcastAt: 0,
|
|
101
|
+
lastPersistedAt: 0,
|
|
102
|
+
lastBroadcastPercent: job.progressPercent,
|
|
103
|
+
pendingDelta: 0,
|
|
104
|
+
absoluteCountsPending: false,
|
|
105
|
+
}
|
|
106
|
+
jobUpdateThrottle.set(jobId, entry)
|
|
107
|
+
return entry
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function forgetJobThrottle(jobId: string) {
|
|
111
|
+
jobUpdateThrottle.delete(jobId)
|
|
67
112
|
}
|
|
68
113
|
|
|
69
|
-
|
|
114
|
+
function buildBufferedCountData(entry: JobUpdateThrottleEntry): EntityData<ProgressJob> {
|
|
115
|
+
if (entry.absoluteCountsPending || !Number.isSafeInteger(entry.pendingDelta)) {
|
|
116
|
+
return { processedCount: entry.job.processedCount }
|
|
117
|
+
}
|
|
118
|
+
if (entry.pendingDelta !== 0) {
|
|
119
|
+
const processedCount = raw(`processed_count + ${entry.pendingDelta}`)
|
|
120
|
+
const totalCount = entry.job.totalCount
|
|
121
|
+
if (Number.isSafeInteger(totalCount) && totalCount != null && totalCount > 0) {
|
|
122
|
+
return {
|
|
123
|
+
processedCount,
|
|
124
|
+
progressPercent: raw(
|
|
125
|
+
`least(100, round(((processed_count + ${entry.pendingDelta})::numeric / ${totalCount}) * 100))`,
|
|
126
|
+
),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { processedCount }
|
|
130
|
+
}
|
|
131
|
+
return {}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function persistAndMaybeBroadcast(entry: JobUpdateThrottleEntry, ctx: ProgressServiceContext): Promise<ProgressJob> {
|
|
135
|
+
const job = entry.job
|
|
70
136
|
const now = Date.now()
|
|
71
|
-
const cached = jobUpdateThrottle.get(job.id)
|
|
72
137
|
const shouldBroadcast =
|
|
73
138
|
broadcastMinIntervalMs <= 0 ||
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
139
|
+
now - entry.lastBroadcastAt >= broadcastMinIntervalMs ||
|
|
140
|
+
Math.abs(job.progressPercent - entry.lastBroadcastPercent) >= 1
|
|
141
|
+
const shouldPersist = shouldBroadcast || now - entry.lastPersistedAt >= HEARTBEAT_INTERVAL_MS
|
|
142
|
+
|
|
143
|
+
if (!shouldPersist) return job
|
|
144
|
+
|
|
145
|
+
const usesAtomicIncrement =
|
|
146
|
+
!entry.absoluteCountsPending && Number.isSafeInteger(entry.pendingDelta) && entry.pendingDelta !== 0
|
|
147
|
+
const data: EntityData<ProgressJob> = {
|
|
148
|
+
progressPercent: job.progressPercent,
|
|
149
|
+
totalCount: job.totalCount ?? null,
|
|
150
|
+
etaSeconds: job.etaSeconds ?? null,
|
|
151
|
+
meta: job.meta ?? null,
|
|
152
|
+
heartbeatAt: job.heartbeatAt ?? new Date(),
|
|
153
|
+
updatedAt: new Date(),
|
|
154
|
+
...buildBufferedCountData(entry),
|
|
155
|
+
}
|
|
156
|
+
const affected = await em.nativeUpdate(ProgressJob, {
|
|
157
|
+
...jobScopeFilter(job.id, ctx),
|
|
158
|
+
status: { $in: PROGRESS_WRITABLE_STATUSES as ProgressJobStatus[] },
|
|
159
|
+
} as FilterQuery<ProgressJob>, data)
|
|
160
|
+
|
|
161
|
+
if (affected === 0) {
|
|
162
|
+
// The job reached a terminal state in another process; stop writing to it.
|
|
163
|
+
forgetJobThrottle(job.id)
|
|
164
|
+
const fresh = await loadFreshJob(job.id, ctx)
|
|
165
|
+
return fresh ?? job
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const persistedJob = usesAtomicIncrement ? (await loadFreshJob(job.id, ctx)) ?? job : job
|
|
169
|
+
entry.job = persistedJob
|
|
170
|
+
entry.pendingDelta = 0
|
|
171
|
+
entry.absoluteCountsPending = false
|
|
172
|
+
entry.lastPersistedAt = now
|
|
77
173
|
|
|
78
174
|
if (shouldBroadcast) {
|
|
79
|
-
await em.flush()
|
|
80
175
|
await eventBus.emit(PROGRESS_EVENTS.JOB_UPDATED, {
|
|
81
|
-
...buildJobPayload(
|
|
176
|
+
...buildJobPayload(persistedJob),
|
|
82
177
|
tenantId: ctx.tenantId,
|
|
83
|
-
organizationId:
|
|
84
|
-
})
|
|
85
|
-
jobUpdateThrottle.set(job.id, { job, lastBroadcastAt: now, lastBroadcastPercent: job.progressPercent })
|
|
86
|
-
} else {
|
|
87
|
-
jobUpdateThrottle.set(job.id, {
|
|
88
|
-
job,
|
|
89
|
-
lastBroadcastAt: cached.lastBroadcastAt,
|
|
90
|
-
lastBroadcastPercent: cached.lastBroadcastPercent,
|
|
178
|
+
organizationId: persistedJob.organizationId ?? null,
|
|
91
179
|
})
|
|
180
|
+
entry.lastBroadcastAt = now
|
|
181
|
+
entry.lastBroadcastPercent = persistedJob.progressPercent
|
|
92
182
|
}
|
|
93
183
|
|
|
94
|
-
return
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function forgetJobThrottle(jobId: string) {
|
|
98
|
-
jobUpdateThrottle.delete(jobId)
|
|
184
|
+
return persistedJob
|
|
99
185
|
}
|
|
100
186
|
|
|
101
187
|
return {
|
|
@@ -128,20 +214,36 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
128
214
|
},
|
|
129
215
|
|
|
130
216
|
async startJob(jobId, ctx) {
|
|
131
|
-
const job = await em.findOneOrFail(ProgressJob, {
|
|
132
|
-
|
|
133
|
-
tenantId: ctx.tenantId,
|
|
134
|
-
...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
|
|
135
|
-
})
|
|
136
|
-
if (job.status === 'cancelled') {
|
|
217
|
+
const job = await em.findOneOrFail(ProgressJob, jobScopeFilter(jobId, ctx), { disableIdentityMap: true })
|
|
218
|
+
if (job.status === 'running' || job.status === 'cancelled' || job.status === 'completed') {
|
|
137
219
|
return job
|
|
138
220
|
}
|
|
139
221
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
222
|
+
const now = new Date()
|
|
223
|
+
const affected = await em.nativeUpdate(ProgressJob, {
|
|
224
|
+
...jobScopeFilter(jobId, ctx),
|
|
225
|
+
status: { $in: START_FROM_STATUSES as ProgressJobStatus[] },
|
|
226
|
+
} as FilterQuery<ProgressJob>, {
|
|
227
|
+
status: 'running',
|
|
228
|
+
startedAt: now,
|
|
229
|
+
heartbeatAt: now,
|
|
230
|
+
finishedAt: null,
|
|
231
|
+
errorMessage: null,
|
|
232
|
+
errorStack: null,
|
|
233
|
+
updatedAt: now,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
if (affected === 0) {
|
|
237
|
+
const fresh = await loadFreshJob(jobId, ctx)
|
|
238
|
+
return fresh ?? job
|
|
239
|
+
}
|
|
143
240
|
|
|
144
|
-
|
|
241
|
+
job.status = 'running'
|
|
242
|
+
job.startedAt = now
|
|
243
|
+
job.heartbeatAt = now
|
|
244
|
+
job.finishedAt = null
|
|
245
|
+
job.errorMessage = null
|
|
246
|
+
job.errorStack = null
|
|
145
247
|
|
|
146
248
|
await eventBus.emit(PROGRESS_EVENTS.JOB_STARTED, {
|
|
147
249
|
...buildJobPayload(job),
|
|
@@ -153,14 +255,17 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
153
255
|
},
|
|
154
256
|
|
|
155
257
|
async updateProgress(jobId, input, ctx) {
|
|
156
|
-
const
|
|
157
|
-
|
|
258
|
+
const entry = await ensureThrottleEntry(jobId, ctx)
|
|
259
|
+
const job = entry.job
|
|
260
|
+
if (TERMINAL_STATUSES.includes(job.status)) {
|
|
158
261
|
forgetJobThrottle(jobId)
|
|
159
262
|
return job
|
|
160
263
|
}
|
|
161
264
|
|
|
162
265
|
if (input.processedCount !== undefined) {
|
|
163
266
|
job.processedCount = input.processedCount
|
|
267
|
+
entry.absoluteCountsPending = true
|
|
268
|
+
entry.pendingDelta = 0
|
|
164
269
|
}
|
|
165
270
|
if (input.totalCount !== undefined) {
|
|
166
271
|
job.totalCount = input.totalCount
|
|
@@ -183,17 +288,19 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
183
288
|
|
|
184
289
|
job.heartbeatAt = new Date()
|
|
185
290
|
|
|
186
|
-
return
|
|
291
|
+
return persistAndMaybeBroadcast(entry, ctx)
|
|
187
292
|
},
|
|
188
293
|
|
|
189
294
|
async incrementProgress(jobId, delta, ctx) {
|
|
190
|
-
const
|
|
191
|
-
|
|
295
|
+
const entry = await ensureThrottleEntry(jobId, ctx)
|
|
296
|
+
const job = entry.job
|
|
297
|
+
if (TERMINAL_STATUSES.includes(job.status)) {
|
|
192
298
|
forgetJobThrottle(jobId)
|
|
193
299
|
return job
|
|
194
300
|
}
|
|
195
301
|
|
|
196
302
|
job.processedCount += delta
|
|
303
|
+
entry.pendingDelta += delta
|
|
197
304
|
job.heartbeatAt = new Date()
|
|
198
305
|
|
|
199
306
|
if (job.totalCount) {
|
|
@@ -203,88 +310,172 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
203
310
|
}
|
|
204
311
|
}
|
|
205
312
|
|
|
206
|
-
return
|
|
313
|
+
return persistAndMaybeBroadcast(entry, ctx)
|
|
207
314
|
},
|
|
208
315
|
|
|
209
316
|
async completeJob(jobId, input, ctx) {
|
|
210
|
-
const job = await
|
|
211
|
-
id: jobId,
|
|
212
|
-
tenantId: ctx.tenantId,
|
|
213
|
-
...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
|
|
214
|
-
})
|
|
317
|
+
const job = await loadFreshJob(jobId, ctx)
|
|
215
318
|
if (!job) throw new Error(`Job ${jobId} not found`)
|
|
216
|
-
if (job.status === 'cancelled') {
|
|
319
|
+
if (job.status === 'cancelled' || job.status === 'completed') {
|
|
320
|
+
forgetJobThrottle(jobId)
|
|
217
321
|
return job
|
|
218
322
|
}
|
|
219
323
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
324
|
+
const entry = jobUpdateThrottle.get(jobId)
|
|
325
|
+
const snapshot = entry?.job ?? job
|
|
326
|
+
const now = new Date()
|
|
327
|
+
const data: EntityData<ProgressJob> = {
|
|
328
|
+
status: 'completed',
|
|
329
|
+
finishedAt: now,
|
|
330
|
+
etaSeconds: 0,
|
|
331
|
+
updatedAt: now,
|
|
332
|
+
...(input?.resultSummary ? { resultSummary: input.resultSummary } : {}),
|
|
333
|
+
...(entry
|
|
334
|
+
? {
|
|
335
|
+
totalCount: snapshot.totalCount ?? null,
|
|
336
|
+
meta: snapshot.meta ?? null,
|
|
337
|
+
...buildBufferedCountData(entry),
|
|
338
|
+
}
|
|
339
|
+
: {}),
|
|
340
|
+
progressPercent: 100,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const affected = await em.nativeUpdate(ProgressJob, {
|
|
344
|
+
...jobScopeFilter(jobId, ctx),
|
|
345
|
+
status: { $in: COMPLETE_FROM_STATUSES as ProgressJobStatus[] },
|
|
346
|
+
} as FilterQuery<ProgressJob>, data)
|
|
347
|
+
forgetJobThrottle(jobId)
|
|
348
|
+
|
|
349
|
+
if (affected === 0) {
|
|
350
|
+
const fresh = await loadFreshJob(jobId, ctx)
|
|
351
|
+
return fresh ?? job
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
snapshot.status = 'completed'
|
|
355
|
+
snapshot.finishedAt = now
|
|
356
|
+
snapshot.progressPercent = 100
|
|
357
|
+
snapshot.etaSeconds = 0
|
|
224
358
|
if (input?.resultSummary) {
|
|
225
|
-
|
|
359
|
+
snapshot.resultSummary = input.resultSummary
|
|
226
360
|
}
|
|
227
361
|
|
|
228
|
-
await
|
|
362
|
+
const persistedJob = (await loadFreshJob(jobId, ctx)) ?? snapshot
|
|
229
363
|
|
|
230
364
|
await eventBus.emit(PROGRESS_EVENTS.JOB_COMPLETED, {
|
|
231
|
-
...buildJobPayload(
|
|
232
|
-
resultSummary:
|
|
365
|
+
...buildJobPayload(persistedJob),
|
|
366
|
+
resultSummary: persistedJob.resultSummary,
|
|
233
367
|
tenantId: ctx.tenantId,
|
|
234
|
-
organizationId:
|
|
368
|
+
organizationId: persistedJob.organizationId ?? null,
|
|
235
369
|
})
|
|
236
370
|
|
|
237
|
-
|
|
238
|
-
return job
|
|
371
|
+
return persistedJob
|
|
239
372
|
},
|
|
240
373
|
|
|
241
374
|
async failJob(jobId, input, ctx) {
|
|
242
|
-
const job = await
|
|
243
|
-
id: jobId,
|
|
244
|
-
tenantId: ctx.tenantId,
|
|
245
|
-
...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
|
|
246
|
-
})
|
|
375
|
+
const job = await loadFreshJob(jobId, ctx)
|
|
247
376
|
if (!job) throw new Error(`Job ${jobId} not found`)
|
|
248
|
-
if (job.status
|
|
377
|
+
if (TERMINAL_STATUSES.includes(job.status)) {
|
|
378
|
+
forgetJobThrottle(jobId)
|
|
249
379
|
return job
|
|
250
380
|
}
|
|
251
381
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
382
|
+
const entry = jobUpdateThrottle.get(jobId)
|
|
383
|
+
const snapshot = entry?.job ?? job
|
|
384
|
+
const now = new Date()
|
|
385
|
+
const data: EntityData<ProgressJob> = {
|
|
386
|
+
status: 'failed',
|
|
387
|
+
finishedAt: now,
|
|
388
|
+
errorMessage: input.errorMessage,
|
|
389
|
+
errorStack: input.errorStack,
|
|
390
|
+
updatedAt: now,
|
|
391
|
+
...(entry
|
|
392
|
+
? {
|
|
393
|
+
totalCount: snapshot.totalCount ?? null,
|
|
394
|
+
meta: snapshot.meta ?? null,
|
|
395
|
+
...buildBufferedCountData(entry),
|
|
396
|
+
}
|
|
397
|
+
: {}),
|
|
398
|
+
}
|
|
256
399
|
|
|
257
|
-
await em.
|
|
400
|
+
const affected = await em.nativeUpdate(ProgressJob, {
|
|
401
|
+
...jobScopeFilter(jobId, ctx),
|
|
402
|
+
status: { $in: FAIL_FROM_STATUSES as ProgressJobStatus[] },
|
|
403
|
+
} as FilterQuery<ProgressJob>, data)
|
|
404
|
+
forgetJobThrottle(jobId)
|
|
405
|
+
|
|
406
|
+
if (affected === 0) {
|
|
407
|
+
const fresh = await loadFreshJob(jobId, ctx)
|
|
408
|
+
return fresh ?? job
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
snapshot.status = 'failed'
|
|
412
|
+
snapshot.finishedAt = now
|
|
413
|
+
snapshot.errorMessage = input.errorMessage
|
|
414
|
+
snapshot.errorStack = input.errorStack
|
|
415
|
+
|
|
416
|
+
const persistedJob = (await loadFreshJob(jobId, ctx)) ?? snapshot
|
|
258
417
|
|
|
259
418
|
await eventBus.emit(PROGRESS_EVENTS.JOB_FAILED, {
|
|
260
|
-
...buildJobPayload(
|
|
261
|
-
errorMessage:
|
|
419
|
+
...buildJobPayload(persistedJob),
|
|
420
|
+
errorMessage: persistedJob.errorMessage,
|
|
262
421
|
tenantId: ctx.tenantId,
|
|
263
|
-
organizationId:
|
|
422
|
+
organizationId: persistedJob.organizationId ?? null,
|
|
264
423
|
})
|
|
265
424
|
|
|
266
|
-
|
|
267
|
-
return job
|
|
425
|
+
return persistedJob
|
|
268
426
|
},
|
|
269
427
|
|
|
270
428
|
async cancelJob(jobId, ctx) {
|
|
271
|
-
const job = await em.findOneOrFail(ProgressJob, {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
429
|
+
const job = await em.findOneOrFail(ProgressJob, jobScopeFilter(jobId, ctx), { disableIdentityMap: true })
|
|
430
|
+
if (TERMINAL_STATUSES.includes(job.status)) {
|
|
431
|
+
// The job finished while the user clicked cancel — a benign race, not an error.
|
|
432
|
+
return job
|
|
433
|
+
}
|
|
434
|
+
if (!job.cancellable) {
|
|
435
|
+
throw new Error(`Job ${jobId} is not cancellable`)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const now = new Date()
|
|
439
|
+
const cancelledNow = await em.nativeUpdate(ProgressJob, {
|
|
440
|
+
...jobScopeFilter(jobId, ctx),
|
|
275
441
|
cancellable: true,
|
|
276
|
-
status:
|
|
442
|
+
status: 'pending',
|
|
443
|
+
} as FilterQuery<ProgressJob>, {
|
|
444
|
+
status: 'cancelled',
|
|
445
|
+
cancelRequestedAt: now,
|
|
446
|
+
cancelledByUserId: ctx.userId ?? null,
|
|
447
|
+
finishedAt: now,
|
|
448
|
+
etaSeconds: 0,
|
|
449
|
+
updatedAt: now,
|
|
277
450
|
})
|
|
278
451
|
|
|
279
|
-
|
|
280
|
-
|
|
452
|
+
let requested = 0
|
|
453
|
+
if (cancelledNow === 0) {
|
|
454
|
+
requested = await em.nativeUpdate(ProgressJob, {
|
|
455
|
+
...jobScopeFilter(jobId, ctx),
|
|
456
|
+
cancellable: true,
|
|
457
|
+
status: 'running',
|
|
458
|
+
} as FilterQuery<ProgressJob>, {
|
|
459
|
+
cancelRequestedAt: now,
|
|
460
|
+
cancelledByUserId: ctx.userId ?? null,
|
|
461
|
+
updatedAt: now,
|
|
462
|
+
})
|
|
463
|
+
}
|
|
281
464
|
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
|
|
465
|
+
if (cancelledNow === 0 && requested === 0) {
|
|
466
|
+
// Raced into a terminal state between the read and the CAS.
|
|
467
|
+
const fresh = await loadFreshJob(jobId, ctx)
|
|
468
|
+
return fresh ?? job
|
|
285
469
|
}
|
|
286
470
|
|
|
287
|
-
|
|
471
|
+
forgetJobThrottle(jobId)
|
|
472
|
+
if (cancelledNow > 0) {
|
|
473
|
+
job.status = 'cancelled'
|
|
474
|
+
job.finishedAt = now
|
|
475
|
+
job.etaSeconds = 0
|
|
476
|
+
}
|
|
477
|
+
job.cancelRequestedAt = now
|
|
478
|
+
job.cancelledByUserId = ctx.userId ?? null
|
|
288
479
|
|
|
289
480
|
await eventBus.emit(PROGRESS_EVENTS.JOB_CANCELLED, {
|
|
290
481
|
...buildJobPayload(job),
|
|
@@ -292,45 +483,64 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
292
483
|
organizationId: job.organizationId ?? null,
|
|
293
484
|
})
|
|
294
485
|
|
|
295
|
-
forgetJobThrottle(jobId)
|
|
296
486
|
return job
|
|
297
487
|
},
|
|
298
488
|
|
|
299
489
|
async markCancelled(jobId, ctx) {
|
|
300
|
-
const job = await
|
|
301
|
-
id: jobId,
|
|
302
|
-
tenantId: ctx.tenantId,
|
|
303
|
-
...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
|
|
304
|
-
})
|
|
490
|
+
const job = await loadFreshJob(jobId, ctx)
|
|
305
491
|
if (!job) throw new Error(`Job ${jobId} not found`)
|
|
306
|
-
if (job.status === 'cancelled') {
|
|
492
|
+
if (job.status === 'cancelled' || job.status === 'completed') {
|
|
493
|
+
forgetJobThrottle(jobId)
|
|
307
494
|
return job
|
|
308
495
|
}
|
|
309
496
|
|
|
310
|
-
|
|
311
|
-
job.
|
|
497
|
+
const now = new Date()
|
|
498
|
+
const cancelRequestedAt = job.cancelRequestedAt ?? now
|
|
499
|
+
const cancelledByUserId = job.cancelledByUserId ?? ctx.userId ?? null
|
|
500
|
+
const finishedAt = job.finishedAt ?? now
|
|
501
|
+
|
|
502
|
+
const affected = await em.nativeUpdate(ProgressJob, {
|
|
503
|
+
...jobScopeFilter(jobId, ctx),
|
|
504
|
+
status: { $in: CANCEL_FROM_STATUSES as ProgressJobStatus[] },
|
|
505
|
+
} as FilterQuery<ProgressJob>, {
|
|
506
|
+
status: 'cancelled',
|
|
507
|
+
cancelRequestedAt,
|
|
508
|
+
cancelledByUserId,
|
|
509
|
+
finishedAt,
|
|
510
|
+
etaSeconds: 0,
|
|
511
|
+
updatedAt: now,
|
|
512
|
+
})
|
|
513
|
+
forgetJobThrottle(jobId)
|
|
514
|
+
|
|
515
|
+
if (affected === 0) {
|
|
516
|
+
const fresh = await loadFreshJob(jobId, ctx)
|
|
517
|
+
return fresh ?? job
|
|
518
|
+
}
|
|
519
|
+
|
|
312
520
|
job.status = 'cancelled'
|
|
313
|
-
job.
|
|
521
|
+
job.cancelRequestedAt = cancelRequestedAt
|
|
522
|
+
job.cancelledByUserId = cancelledByUserId
|
|
523
|
+
job.finishedAt = finishedAt
|
|
314
524
|
job.etaSeconds = 0
|
|
315
525
|
|
|
316
|
-
await em.flush()
|
|
317
|
-
|
|
318
526
|
await eventBus.emit(PROGRESS_EVENTS.JOB_CANCELLED, {
|
|
319
527
|
...buildJobPayload(job),
|
|
320
528
|
tenantId: ctx.tenantId,
|
|
321
529
|
organizationId: job.organizationId ?? null,
|
|
322
530
|
})
|
|
323
531
|
|
|
324
|
-
forgetJobThrottle(jobId)
|
|
325
532
|
return job
|
|
326
533
|
},
|
|
327
534
|
|
|
328
535
|
async isCancellationRequested(jobId, tenantId, organizationId) {
|
|
536
|
+
// disableIdentityMap forces a fresh read: a managed copy of the job in this
|
|
537
|
+
// EntityManager (loaded by updateProgress) must never mask a cancellation
|
|
538
|
+
// requested from another process.
|
|
329
539
|
const job = await findOneWithDecryption(em, ProgressJob, {
|
|
330
540
|
id: jobId,
|
|
331
541
|
tenantId,
|
|
332
542
|
...(organizationId ? { organizationId } : {}),
|
|
333
|
-
})
|
|
543
|
+
}, { disableIdentityMap: true })
|
|
334
544
|
return job?.cancelRequestedAt != null
|
|
335
545
|
},
|
|
336
546
|
|
|
@@ -369,12 +579,16 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
369
579
|
},
|
|
370
580
|
|
|
371
581
|
async markStaleJobsFailed(tenantId: string, timeoutSeconds = STALE_JOB_TIMEOUT_SECONDS, organizationId?: string | null) {
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
const
|
|
582
|
+
const now = new Date()
|
|
583
|
+
const cutoff = new Date(now.getTime() - timeoutSeconds * 1000)
|
|
584
|
+
const scope = {
|
|
375
585
|
tenantId,
|
|
376
586
|
...(organizationId ? { organizationId } : {}),
|
|
377
|
-
|
|
587
|
+
}
|
|
588
|
+
let failedCount = 0
|
|
589
|
+
|
|
590
|
+
const staleFilter = {
|
|
591
|
+
status: 'running' as ProgressJobStatus,
|
|
378
592
|
$or: [
|
|
379
593
|
{ heartbeatAt: { $lt: cutoff } },
|
|
380
594
|
{
|
|
@@ -382,12 +596,67 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
382
596
|
startedAt: { $lt: cutoff },
|
|
383
597
|
},
|
|
384
598
|
],
|
|
385
|
-
}
|
|
599
|
+
}
|
|
600
|
+
const staleRunning = await em.find(ProgressJob, { ...scope, ...staleFilter }, { disableIdentityMap: true })
|
|
601
|
+
|
|
602
|
+
for (const job of staleRunning) {
|
|
603
|
+
const errorMessage = `Job stale: no heartbeat for ${timeoutSeconds} seconds`
|
|
604
|
+
// Per-row CAS (re-checking the staleness condition): exactly one concurrent
|
|
605
|
+
// sweeper wins, and a heartbeat that landed after the SELECT aborts the failure.
|
|
606
|
+
const affected = await em.nativeUpdate(ProgressJob, {
|
|
607
|
+
id: job.id,
|
|
608
|
+
tenantId: job.tenantId,
|
|
609
|
+
...staleFilter,
|
|
610
|
+
} as FilterQuery<ProgressJob>, {
|
|
611
|
+
status: 'failed',
|
|
612
|
+
finishedAt: now,
|
|
613
|
+
errorMessage,
|
|
614
|
+
updatedAt: now,
|
|
615
|
+
})
|
|
616
|
+
if (affected === 0) continue
|
|
617
|
+
|
|
618
|
+
failedCount += 1
|
|
619
|
+
job.status = 'failed'
|
|
620
|
+
job.finishedAt = now
|
|
621
|
+
job.errorMessage = errorMessage
|
|
622
|
+
|
|
623
|
+
await eventBus.emit(PROGRESS_EVENTS.JOB_FAILED, {
|
|
624
|
+
...buildJobPayload(job),
|
|
625
|
+
errorMessage: job.errorMessage,
|
|
626
|
+
tenantId: job.tenantId,
|
|
627
|
+
stale: true,
|
|
628
|
+
organizationId: job.organizationId ?? null,
|
|
629
|
+
})
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Jobs stuck in `pending` (enqueue failed, worker died before startJob) have no
|
|
633
|
+
// heartbeat, so they need their own sweep or they stay in the active list forever.
|
|
634
|
+
// A late queue delivery still recovers such a job: startJob transitions failed → running.
|
|
635
|
+
const pendingCutoff = new Date(now.getTime() - STALE_PENDING_TIMEOUT_SECONDS * 1000)
|
|
636
|
+
const stalePendingFilter = {
|
|
637
|
+
status: 'pending' as ProgressJobStatus,
|
|
638
|
+
createdAt: { $lt: pendingCutoff },
|
|
639
|
+
}
|
|
640
|
+
const stalePending = await em.find(ProgressJob, { ...scope, ...stalePendingFilter }, { disableIdentityMap: true })
|
|
641
|
+
|
|
642
|
+
for (const job of stalePending) {
|
|
643
|
+
const errorMessage = `Job stale: never started within ${STALE_PENDING_TIMEOUT_SECONDS} seconds`
|
|
644
|
+
const affected = await em.nativeUpdate(ProgressJob, {
|
|
645
|
+
id: job.id,
|
|
646
|
+
tenantId: job.tenantId,
|
|
647
|
+
...stalePendingFilter,
|
|
648
|
+
} as FilterQuery<ProgressJob>, {
|
|
649
|
+
status: 'failed',
|
|
650
|
+
finishedAt: now,
|
|
651
|
+
errorMessage,
|
|
652
|
+
updatedAt: now,
|
|
653
|
+
})
|
|
654
|
+
if (affected === 0) continue
|
|
386
655
|
|
|
387
|
-
|
|
656
|
+
failedCount += 1
|
|
388
657
|
job.status = 'failed'
|
|
389
|
-
job.finishedAt =
|
|
390
|
-
job.errorMessage =
|
|
658
|
+
job.finishedAt = now
|
|
659
|
+
job.errorMessage = errorMessage
|
|
391
660
|
|
|
392
661
|
await eventBus.emit(PROGRESS_EVENTS.JOB_FAILED, {
|
|
393
662
|
...buildJobPayload(job),
|
|
@@ -398,8 +667,7 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
398
667
|
})
|
|
399
668
|
}
|
|
400
669
|
|
|
401
|
-
|
|
402
|
-
return staleJobs.length
|
|
670
|
+
return failedCount
|
|
403
671
|
},
|
|
404
672
|
}
|
|
405
673
|
}
|