@memberjunction/integration-engine 6.1.0-edge.1 → 6.1.0-edge.3
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/LICENSE +180 -4
- package/dist/BaseIntegrationConnector.d.ts +97 -3
- package/dist/BaseIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseIntegrationConnector.js +82 -6
- package/dist/BaseIntegrationConnector.js.map +1 -1
- package/dist/BaseRESTIntegrationConnector.d.ts +1 -1
- package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseRESTIntegrationConnector.js +12 -2
- package/dist/BaseRESTIntegrationConnector.js.map +1 -1
- package/dist/IntegrationConnectorCreationPipeline.d.ts +92 -1
- package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -1
- package/dist/IntegrationConnectorCreationPipeline.js +173 -11
- package/dist/IntegrationConnectorCreationPipeline.js.map +1 -1
- package/dist/IntegrationEngine.d.ts +161 -21
- package/dist/IntegrationEngine.d.ts.map +1 -1
- package/dist/IntegrationEngine.js +705 -82
- package/dist/IntegrationEngine.js.map +1 -1
- package/dist/RunOwnershipService.d.ts +177 -0
- package/dist/RunOwnershipService.d.ts.map +1 -0
- package/dist/RunOwnershipService.js +279 -0
- package/dist/RunOwnershipService.js.map +1 -0
- package/dist/auth-helpers/OAuth2TokenManager.d.ts.map +1 -1
- package/dist/auth-helpers/OAuth2TokenManager.js +8 -2
- package/dist/auth-helpers/OAuth2TokenManager.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -8
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
1
2
|
import { CompositeKey, DatabaseProviderBase, LogStatusEx, Metadata, RunView } from '@memberjunction/core';
|
|
3
|
+
import { RunOwnershipLostError, RunOwnershipService } from './RunOwnershipService.js';
|
|
2
4
|
import { BaseSingleton, UUIDsEqual } from '@memberjunction/global';
|
|
3
5
|
import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
|
|
4
6
|
import { ClassifyError, IsRetryableError } from './types.js';
|
|
5
7
|
import { WithRetry } from './RetryRunner.js';
|
|
6
|
-
import { WithTimeout, DEFAULT_OPERATION_TIMEOUTS } from './BaseIntegrationConnector.js';
|
|
8
|
+
import { WithTimeout, OperationTimeoutError, DEFAULT_OPERATION_TIMEOUTS } from './BaseIntegrationConnector.js';
|
|
7
9
|
import { ConnectorFactory } from './ConnectorFactory.js';
|
|
8
10
|
import { FieldMappingEngine } from './FieldMappingEngine.js';
|
|
9
11
|
import { MatchEngine } from './MatchEngine.js';
|
|
@@ -151,22 +153,30 @@ function detectSchemaNotGenerated(entityName, errorMessage) {
|
|
|
151
153
|
return new SchemaNotGeneratedError(entityName, postgres[1]);
|
|
152
154
|
return null;
|
|
153
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Coerces an externally-supplied tuning value — a duration in ms, or a count — to a usable positive
|
|
158
|
+
* integer, or `undefined` when it is not one, so the caller's `??` chain falls through to the next
|
|
159
|
+
* source.
|
|
160
|
+
*
|
|
161
|
+
* Every source of these values is outside the engine's control: operator-authored JSON in
|
|
162
|
+
* `CompanyIntegration.Configuration`, and connector-authored properties like
|
|
163
|
+
* `BaseIntegrationConnector.FetchChangesTimeoutMs`, whose declared type (`number | null`) happily
|
|
164
|
+
* admits `0`, negatives and `NaN` — a connector computing one from an unset env var gets `NaN`
|
|
165
|
+
* without a type error. Handing any of those to `setTimeout` is silently catastrophic rather than
|
|
166
|
+
* loud: it coerces them to ~1ms, so every wrapped operation rejects immediately and the object
|
|
167
|
+
* syncs nothing. Guard BOTH sources, not just the JSON one.
|
|
168
|
+
*
|
|
169
|
+
* Exported so the guard itself is unit-testable without standing up a sync.
|
|
170
|
+
*/
|
|
171
|
+
export function PositiveInt(v) {
|
|
172
|
+
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined;
|
|
173
|
+
}
|
|
154
174
|
export class IntegrationEngine extends BaseSingleton {
|
|
155
175
|
constructor() {
|
|
156
176
|
super();
|
|
157
177
|
this.fieldMappingEngine = new FieldMappingEngine();
|
|
158
178
|
this.matchEngine = new MatchEngine();
|
|
159
179
|
this.watermarkService = new WatermarkService();
|
|
160
|
-
/**
|
|
161
|
-
* Per-engine async mutex serializing the DB-WRITE section across concurrently-synced streams.
|
|
162
|
-
* When a layer runs multiple entity maps in parallel (syncConcurrency > 1), they all share ONE
|
|
163
|
-
* provider connection whose transaction state is singular — so concurrent BeginTransaction /
|
|
164
|
-
* SavePoint / Commit calls corrupt each other ("Transaction has not begun", "Cannot roll back
|
|
165
|
-
* SavePoint"). The fetch phase stays parallel (the real throughput win — it's network-bound);
|
|
166
|
-
* only the per-batch write transaction is serialized through this lock. Keyed per engine
|
|
167
|
-
* instance, which owns the shared provider.
|
|
168
|
-
*/
|
|
169
|
-
this._writeChain = Promise.resolve();
|
|
170
180
|
/** Configurable maximum batch size. Connector batches exceeding this are truncated. */
|
|
171
181
|
this.MaxBatchSize = DEFAULT_BATCH_SIZE;
|
|
172
182
|
/** Per-integration request-spacing chain for the rate limiter (keyed by IntegrationID → last scheduled time). */
|
|
@@ -216,13 +226,26 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
216
226
|
}
|
|
217
227
|
return raw;
|
|
218
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Per-run execution context (PR 1 item 7). Each run carries its OWN provider,
|
|
231
|
+
* ownership service and abort plumbing through the async call chain — there is
|
|
232
|
+
* deliberately NO shared `_provider` instance field, because a last-writer-wins
|
|
233
|
+
* provider was the reason concurrent runs could write through each other's
|
|
234
|
+
* connection. AsyncLocalStorage propagates the context to every helper the run
|
|
235
|
+
* calls without threading a parameter through ~20 private signatures.
|
|
236
|
+
*/
|
|
237
|
+
static { this.runContext = new AsyncLocalStorage(); }
|
|
238
|
+
/** The current run's context, when called from inside a sync run. */
|
|
239
|
+
get currentRunContext() {
|
|
240
|
+
return IntegrationEngine.runContext.getStore();
|
|
241
|
+
}
|
|
219
242
|
/** Registers (or clears, with undefined) the post-sync custom-column promotion hook. */
|
|
220
243
|
SetPostSyncSchemaPromotionCallback(callback) {
|
|
221
244
|
this.postSyncSchemaPromotionCallback = callback;
|
|
222
245
|
}
|
|
223
|
-
/** Returns the active provider —
|
|
246
|
+
/** Returns the active provider — the current run's own provider when inside a run, otherwise the global default. */
|
|
224
247
|
get ProviderToUse() {
|
|
225
|
-
return this.
|
|
248
|
+
return this.currentRunContext?.provider ?? Metadata.Provider;
|
|
226
249
|
}
|
|
227
250
|
/** In-process lock map to prevent concurrent syncs for the same CompanyIntegration */
|
|
228
251
|
static { this.activeSyncs = new Map(); }
|
|
@@ -256,35 +279,195 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
256
279
|
static GetMaintenanceLock(companyIntegrationID) {
|
|
257
280
|
return IntegrationEngine.maintenanceLocks.get(companyIntegrationID.toLowerCase());
|
|
258
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Async mutexes serializing the DB-WRITE section, keyed PER PROVIDER (PR 1 item 7 —
|
|
284
|
+
* per-run write chains). A provider connection's transaction state is singular, so
|
|
285
|
+
* concurrent BeginTransaction / SavePoint / Commit calls through the SAME provider
|
|
286
|
+
* corrupt each other ("Transaction has not begun", "Cannot roll back SavePoint") —
|
|
287
|
+
* whether the writers are parallel streams within one run OR two different runs that
|
|
288
|
+
* happen to share a provider instance. Keying the mutex on the provider makes the
|
|
289
|
+
* serialization boundary exactly the hazard boundary: runs on their own providers
|
|
290
|
+
* write fully in parallel; anything sharing a connection is serialized. The fetch
|
|
291
|
+
* phase stays parallel (the real throughput win — it's network-bound). WeakMap so a
|
|
292
|
+
* retired provider's chain entry is collectable.
|
|
293
|
+
*/
|
|
294
|
+
static { this.writeChains = new WeakMap(); }
|
|
259
295
|
runWriteExclusive(fn) {
|
|
296
|
+
const provider = this.ProviderToUse;
|
|
297
|
+
let holder = IntegrationEngine.writeChains.get(provider);
|
|
298
|
+
if (!holder) {
|
|
299
|
+
holder = { chain: Promise.resolve() };
|
|
300
|
+
IntegrationEngine.writeChains.set(provider, holder);
|
|
301
|
+
}
|
|
260
302
|
// Run fn after the prior write completes (whether it resolved or rejected); keep the chain
|
|
261
303
|
// alive past failures so one errored batch never deadlocks subsequent writers.
|
|
262
|
-
const run =
|
|
263
|
-
|
|
304
|
+
const run = holder.chain.then(() => fn(), () => fn());
|
|
305
|
+
holder.chain = run.then(() => undefined, () => undefined);
|
|
264
306
|
return run;
|
|
265
307
|
}
|
|
266
|
-
/**
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Fence check at a batch boundary, BEFORE any write (PR 1 item 3). One SELECT of the run's
|
|
310
|
+
* ownership columns: if the fence has moved (the stale sweep or another worker reclaimed the
|
|
311
|
+
* run) throw RunOwnershipLostError so the loop aborts WITHOUT writing this batch; if a
|
|
312
|
+
* cross-process cancel was requested, trip the run's abort controller so the loop winds down
|
|
313
|
+
* through the normal cancel path. A caller with no ownership context (e.g. unit tests driving
|
|
314
|
+
* internals directly) is a no-op.
|
|
315
|
+
*/
|
|
316
|
+
async assertOwnershipAtBoundary() {
|
|
317
|
+
const ctx = this.currentRunContext;
|
|
318
|
+
if (!ctx?.ownership)
|
|
319
|
+
return;
|
|
320
|
+
const check = await ctx.ownership.CheckBoundary();
|
|
321
|
+
if (!check.Owned) {
|
|
322
|
+
ctx.ownershipLost = true;
|
|
323
|
+
ctx.abortController.abort();
|
|
324
|
+
throw new RunOwnershipLostError(ctx.ownership.RunID, 'fence moved at batch boundary — run was reclaimed by another process');
|
|
325
|
+
}
|
|
326
|
+
if (check.CancelRequested && !ctx.cancelRequested) {
|
|
327
|
+
ctx.cancelRequested = true;
|
|
328
|
+
ctx.abortController.abort();
|
|
329
|
+
}
|
|
273
330
|
}
|
|
274
|
-
/**
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
return
|
|
331
|
+
/**
|
|
332
|
+
* One-time-per-method deprecation notice. Repeating it on every call would flood the
|
|
333
|
+
* log of a caller that polls progress on a timer, which is the usual usage.
|
|
334
|
+
*/
|
|
335
|
+
static { this._deprecationNoticesEmitted = new Set(); }
|
|
336
|
+
static noteDeprecated(oldName, replacement) {
|
|
337
|
+
if (IntegrationEngine._deprecationNoticesEmitted.has(oldName)) {
|
|
338
|
+
return;
|
|
282
339
|
}
|
|
340
|
+
IntegrationEngine._deprecationNoticesEmitted.add(oldName);
|
|
341
|
+
console.warn(`IntegrationEngine.${oldName}() is deprecated and no longer functional: sync progress and ` +
|
|
342
|
+
`cancellation now live on the CompanyIntegrationRun row so they are visible across processes, ` +
|
|
343
|
+
`and the in-process map this method read no longer exists. Use ${replacement}() instead.`);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* @deprecated Superseded by {@link IntegrationEngine.GetSyncProgressAsync}, which reads
|
|
347
|
+
* the run row and therefore sees runs owned by ANY process.
|
|
348
|
+
*
|
|
349
|
+
* Retained with its original signature so a published consumer does not break on a
|
|
350
|
+
* minor upgrade. It cannot be made to work: the static map it used to read was removed
|
|
351
|
+
* when progress moved to the database, and a synchronous method cannot query it. It
|
|
352
|
+
* returns `undefined` — the same value it returned when no run was in progress — and
|
|
353
|
+
* logs once explaining the replacement.
|
|
354
|
+
*/
|
|
355
|
+
static GetSyncProgress(_companyIntegrationID) {
|
|
356
|
+
IntegrationEngine.noteDeprecated('GetSyncProgress', 'GetSyncProgressAsync');
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* @deprecated Superseded by {@link IntegrationEngine.CancelSyncAsync}, which records the
|
|
361
|
+
* cancel on the run row so the owning process observes it at its next batch boundary.
|
|
362
|
+
*
|
|
363
|
+
* Retained with its original signature for published consumers. Returns `false` —
|
|
364
|
+
* truthfully reporting that it cancelled nothing — rather than pretending to succeed,
|
|
365
|
+
* so a caller branching on the result is not silently misled.
|
|
366
|
+
*/
|
|
367
|
+
static CancelSync(_companyIntegrationID) {
|
|
368
|
+
IntegrationEngine.noteDeprecated('CancelSync', 'CancelSyncAsync');
|
|
283
369
|
return false;
|
|
284
370
|
}
|
|
285
|
-
/**
|
|
371
|
+
/**
|
|
372
|
+
* @deprecated No direct replacement. Query `MJ: Company Integration Runs` for rows whose
|
|
373
|
+
* Status is `In Progress` or `Queued` and read their `ProgressJSON`, which is what
|
|
374
|
+
* {@link IntegrationEngine.GetSyncProgressAsync} does for a single connector.
|
|
375
|
+
*
|
|
376
|
+
* Retained with its original signature for published consumers; returns an empty map.
|
|
377
|
+
*/
|
|
286
378
|
static GetAllSyncProgress() {
|
|
287
|
-
|
|
379
|
+
IntegrationEngine.noteDeprecated('GetAllSyncProgress', 'GetSyncProgressAsync');
|
|
380
|
+
return new Map();
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Read current sync progress for a connector FROM THE DATABASE (PR 1 item 4 —
|
|
384
|
+
* progress lives on the run row, so it is visible from ANY process, not just the
|
|
385
|
+
* one executing the sync). Returns undefined when no live run exists. A run is
|
|
386
|
+
* "live" when Status is In Progress/Queued AND its lease has not expired — an
|
|
387
|
+
* expired lease means the owner died and the snapshot is stale history, not
|
|
388
|
+
* progress.
|
|
389
|
+
*/
|
|
390
|
+
static async GetSyncProgressAsync(companyIntegrationID, contextUser, provider) {
|
|
391
|
+
// Server-side providers are DatabaseProviderBase (which implements IRunViewProvider);
|
|
392
|
+
// same narrowing the engine uses for the ownership sprocs.
|
|
393
|
+
const rv = new RunView(provider);
|
|
394
|
+
const result = await rv.RunView({
|
|
395
|
+
EntityName: 'MJ: Company Integration Runs',
|
|
396
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID.replace(/'/g, "''")}' AND Status IN ('In Progress','Queued')`,
|
|
397
|
+
OrderBy: 'StartedAt DESC',
|
|
398
|
+
Fields: ['ProgressJSON', 'LeaseExpiresAt', 'StartedAt'],
|
|
399
|
+
MaxRows: 1,
|
|
400
|
+
ResultType: 'simple',
|
|
401
|
+
BypassCache: true, // live liveness/progress read — must see the current row
|
|
402
|
+
}, contextUser);
|
|
403
|
+
const row = result.Success ? result.Results?.[0] : undefined;
|
|
404
|
+
if (!row)
|
|
405
|
+
return undefined;
|
|
406
|
+
if (row.LeaseExpiresAt != null && new Date(row.LeaseExpiresAt).getTime() < Date.now()) {
|
|
407
|
+
return undefined; // owner's lease lapsed — not live progress
|
|
408
|
+
}
|
|
409
|
+
if (!row.ProgressJSON)
|
|
410
|
+
return undefined;
|
|
411
|
+
try {
|
|
412
|
+
const snapshot = JSON.parse(row.ProgressJSON);
|
|
413
|
+
return { ...snapshot, StartedAt: new Date(snapshot.StartedAt) };
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return undefined; // corrupt snapshot — treat as no progress rather than throwing at a poller
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Request cancellation of a running (or queued) sync by stamping
|
|
421
|
+
* CancelRequestedAt on the live run row (PR 1 item 4 — the DATABASE is the single
|
|
422
|
+
* source of cancellation truth, so a cancel issued in ANY process reaches the
|
|
423
|
+
* owner). The owner observes the stamp at its next batch boundary / lease renewal
|
|
424
|
+
* and stops after the current batch. Returns true when a live run row was stamped.
|
|
425
|
+
*/
|
|
426
|
+
static async CancelSyncAsync(companyIntegrationID, contextUser, provider) {
|
|
427
|
+
const p = (provider ?? Metadata.Provider);
|
|
428
|
+
const d = p.Dialect;
|
|
429
|
+
const schema = d.QuoteIdentifier(p.MJCoreSchemaName);
|
|
430
|
+
const table = d.QuoteIdentifier('CompanyIntegrationRun');
|
|
431
|
+
const ciCol = d.QuoteIdentifier('CompanyIntegrationID');
|
|
432
|
+
const statusCol = d.QuoteIdentifier('Status');
|
|
433
|
+
const cancelCol = d.QuoteIdentifier('CancelRequestedAt');
|
|
434
|
+
const placeholder = p.BuildParameterPlaceholder(0);
|
|
435
|
+
// One UPDATE, no select-then-update: stamp every un-stamped live run for this CI.
|
|
436
|
+
const rows = await p.ExecuteSQL(`UPDATE ${schema}.${table} SET ${cancelCol} = ${d.CurrentTimestampUTC()} ` +
|
|
437
|
+
`WHERE ${ciCol} = ${placeholder} AND ${statusCol} IN ('In Progress','Queued') AND ${cancelCol} IS NULL`, [companyIntegrationID], { isMutation: true, description: 'CancelSync — stamp CancelRequestedAt' }, contextUser);
|
|
438
|
+
console.log(`[IntegrationEngine] Cancel requested for ${companyIntegrationID} (DB stamp)`);
|
|
439
|
+
// ExecuteSQL result shape for UPDATE differs per driver; a thrown error is the failure
|
|
440
|
+
// signal. Verify via a cheap read so the caller gets an honest "was anything live?".
|
|
441
|
+
void rows;
|
|
442
|
+
const check = await p.ExecuteSQL(`SELECT COUNT(*) AS N FROM ${schema}.${table} WHERE ${ciCol} = ${placeholder} ` +
|
|
443
|
+
`AND ${statusCol} IN ('In Progress','Queued') AND ${cancelCol} IS NOT NULL`, [companyIntegrationID], { isMutation: false, description: 'CancelSync — verify stamp' }, contextUser);
|
|
444
|
+
return Number(check?.[0]?.N ?? 0) > 0;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Worker-mode poll (PR 1 item 8): the oldest claimable `Queued` runs. A row is claimable
|
|
448
|
+
* when it is unowned or its lease has lapsed — a Queued row with a LIVE lease is being
|
|
449
|
+
* started by another worker right now and must not be returned. This is only a
|
|
450
|
+
* *candidate* list; {@link ExecuteQueuedRun}'s atomic claim is what actually grants
|
|
451
|
+
* exclusivity, so two workers polling simultaneously is safe by construction.
|
|
452
|
+
*/
|
|
453
|
+
static async PollQueuedRuns(contextUser, maxRows, provider) {
|
|
454
|
+
const p = (provider ?? Metadata.Provider);
|
|
455
|
+
const now = p.Dialect.CurrentTimestampUTC();
|
|
456
|
+
const rv = new RunView(p);
|
|
457
|
+
const result = await rv.RunView({
|
|
458
|
+
EntityName: 'MJ: Company Integration Runs',
|
|
459
|
+
ExtraFilter: `Status='Queued' AND (OwnerToken IS NULL OR LeaseExpiresAt IS NULL OR LeaseExpiresAt < ${now})`,
|
|
460
|
+
OrderBy: 'StartedAt ASC',
|
|
461
|
+
Fields: ['ID', 'CompanyIntegrationID'],
|
|
462
|
+
MaxRows: maxRows,
|
|
463
|
+
ResultType: 'simple',
|
|
464
|
+
BypassCache: true, // queue read — must see the current rows
|
|
465
|
+
}, contextUser);
|
|
466
|
+
if (!result.Success) {
|
|
467
|
+
console.warn(`[IntegrationEngine] Queue poll failed: ${result.ErrorMessage}`);
|
|
468
|
+
return [];
|
|
469
|
+
}
|
|
470
|
+
return result.Results ?? [];
|
|
288
471
|
}
|
|
289
472
|
/**
|
|
290
473
|
* U3 — pure, MONOTONIC progress fold: applies one per-map progress event to the live
|
|
@@ -306,13 +489,17 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
306
489
|
* Call this once during MJAPI startup after metadata is loaded.
|
|
307
490
|
*/
|
|
308
491
|
async ResumeOrphanedSyncs(contextUser, provider) {
|
|
309
|
-
|
|
310
|
-
this._provider = provider;
|
|
492
|
+
const prov = provider ?? Metadata.Provider;
|
|
311
493
|
await IntegrationEngineBase.Instance.Config(false, contextUser, provider);
|
|
494
|
+
// Liveness pre-screen (PR 1 item 6): an 'In Progress' run with a LIVE lease belongs to a
|
|
495
|
+
// healthy process — possibly another worker — and must not be adopted. Only unowned runs or
|
|
496
|
+
// runs whose lease has lapsed are orphan CANDIDATES. The claim sproc below re-evaluates the
|
|
497
|
+
// same predicate atomically, so this filter is a cheap pre-screen, not the correctness gate.
|
|
498
|
+
const dialect = prov.Dialect;
|
|
312
499
|
const rv = new RunView();
|
|
313
500
|
const orphanedRuns = await rv.RunView({
|
|
314
501
|
EntityName: 'MJ: Company Integration Runs',
|
|
315
|
-
ExtraFilter: `Status='In Progress'`,
|
|
502
|
+
ExtraFilter: `Status='In Progress' AND (OwnerToken IS NULL OR LeaseExpiresAt IS NULL OR LeaseExpiresAt < ${dialect.CurrentTimestampUTC()})`,
|
|
316
503
|
ResultType: 'entity_object',
|
|
317
504
|
BypassCache: true, // resume must see the live in-progress runs, not a stale cache
|
|
318
505
|
}, contextUser);
|
|
@@ -339,7 +526,18 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
339
526
|
let resolveResumeLock;
|
|
340
527
|
let resumeResult;
|
|
341
528
|
IntegrationEngine.activeSyncs.set(lockKey, new Promise(res => { resolveResumeLock = res; }));
|
|
529
|
+
const ownership = new RunOwnershipService(prov, runID, undefined, contextUser);
|
|
342
530
|
try {
|
|
531
|
+
// CLAIM BEFORE ADOPTING (PR 1 item 6): a single atomic UPDATE that succeeds only if the
|
|
532
|
+
// run is still unowned/lapsed. Zero rows = another worker adopted it between our RunView
|
|
533
|
+
// and now — skip, never double-run. A successful claim BUMPS the fence, so if the
|
|
534
|
+
// original owner is actually alive-but-slow it aborts at its next boundary check
|
|
535
|
+
// without writing: the sweep-reclaim is itself the abort signal for the abandoned owner.
|
|
536
|
+
const claimed = await ownership.Claim();
|
|
537
|
+
if (!claimed) {
|
|
538
|
+
console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — claim lost (another worker adopted it)`);
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
343
541
|
// Find which entity MAPS already completed SUCCESSFULLY in this run. We correlate
|
|
344
542
|
// by EntityMapID (parsed from the detail's RecordID, stamped by CreateRunDetail),
|
|
345
543
|
// not EntityID — two maps can target the same MJ Entity, so keying on EntityID
|
|
@@ -367,23 +565,78 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
367
565
|
console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
|
|
368
566
|
`for ${companyIntegrationID.substring(0, 8)}... ` +
|
|
369
567
|
`(${completedMapIDs.size} entity maps already completed)`);
|
|
568
|
+
// Recover what this run was ASKED to do. Without this the resume rebuilds config from
|
|
569
|
+
// the CompanyIntegration alone, so an adopted run silently loses its options — most
|
|
570
|
+
// damagingly FullSync, which exists precisely to distrust the watermark. An adopted
|
|
571
|
+
// full sync would resume incrementally, fetch nothing, and report Success.
|
|
572
|
+
// Unparseable/absent ConfigData falls back to defaults rather than refusing to resume.
|
|
573
|
+
let resumeOptions;
|
|
574
|
+
let resumeTriggerType = 'Scheduled';
|
|
575
|
+
try {
|
|
576
|
+
const cfg = JSON.parse(run.ConfigData ?? '{}');
|
|
577
|
+
resumeOptions = cfg.options ?? undefined;
|
|
578
|
+
if (cfg.triggerType)
|
|
579
|
+
resumeTriggerType = cfg.triggerType;
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
console.warn(`[IntegrationEngine] Run ${runID.substring(0, 8)} has unparseable ConfigData; resuming with defaults`);
|
|
583
|
+
}
|
|
584
|
+
if (resumeOptions?.FullSync) {
|
|
585
|
+
console.log(`[IntegrationEngine] Run ${runID.substring(0, 8)} was a FULL sync — resuming as full, not incremental`);
|
|
586
|
+
}
|
|
370
587
|
// Load config and filter to only remaining entity maps (by map ID)
|
|
371
|
-
const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser);
|
|
588
|
+
const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, resumeOptions);
|
|
372
589
|
const remainingMaps = config.entityMaps.filter(em => !completedMapIDs.has(em.ID.toLowerCase()));
|
|
373
590
|
if (remainingMaps.length === 0) {
|
|
374
591
|
console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
|
|
375
592
|
run.EndedAt = new Date();
|
|
376
593
|
run.Status = 'Success';
|
|
594
|
+
ownership.SyncEntityOwnershipFields(run); // full-row save must not clobber the live claim
|
|
377
595
|
await run.Save();
|
|
596
|
+
await ownership.Release('Success');
|
|
378
597
|
continue;
|
|
379
598
|
}
|
|
380
599
|
console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
|
|
381
600
|
// Replace entityMaps with only the remaining ones
|
|
382
601
|
config.entityMaps = remainingMaps;
|
|
383
|
-
// Execute remaining maps
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
602
|
+
// Execute remaining maps inside a per-run context: the resume gets its own provider
|
|
603
|
+
// binding, abort controller, and ownership — identical to a fresh RunSync — so the
|
|
604
|
+
// heartbeat renews the lease, the batch boundaries fence-check, and FinalizeRun
|
|
605
|
+
// syncs ownership fields + releases, all through the SAME code paths.
|
|
606
|
+
const abortController = new AbortController();
|
|
607
|
+
const progressSnapshot = {
|
|
608
|
+
StartedAt: new Date(),
|
|
609
|
+
CurrentEntity: '',
|
|
610
|
+
EntityMapsTotal: remainingMaps.length,
|
|
611
|
+
EntityMapsCompleted: 0,
|
|
612
|
+
RecordsProcessed: 0,
|
|
613
|
+
RecordsCreated: 0,
|
|
614
|
+
RecordsUpdated: 0,
|
|
615
|
+
RecordsErrored: 0,
|
|
616
|
+
// The run's OWN trigger type, recovered above — not a hardcoded 'Scheduled'. This is
|
|
617
|
+
// what IntegrationGetSyncProgress reports back ("Sync in progress (Manual)"), so a
|
|
618
|
+
// hardcoded value mislabels every adopted run.
|
|
619
|
+
TriggerType: resumeTriggerType,
|
|
620
|
+
};
|
|
621
|
+
const runCtx = {
|
|
622
|
+
provider: prov,
|
|
623
|
+
ownership,
|
|
624
|
+
abortController,
|
|
625
|
+
progressSnapshot,
|
|
626
|
+
cancelRequested: false,
|
|
627
|
+
ownershipLost: false,
|
|
628
|
+
};
|
|
629
|
+
ownership.StartHeartbeat({
|
|
630
|
+
onLost: () => { runCtx.ownershipLost = true; abortController.abort(); },
|
|
631
|
+
onCancelRequested: () => { runCtx.cancelRequested = true; abortController.abort(); },
|
|
632
|
+
progressSupplier: () => JSON.stringify(progressSnapshot),
|
|
633
|
+
});
|
|
634
|
+
const result = await IntegrationEngine.runContext.run(runCtx, async () => {
|
|
635
|
+
const r = await this.ExecuteEntityMaps(config, run, contextUser, undefined, abortController.signal);
|
|
636
|
+
r.RunID = runID;
|
|
637
|
+
await this.FinalizeRun(run, r, contextUser);
|
|
638
|
+
return r;
|
|
639
|
+
});
|
|
387
640
|
resumeResult = result;
|
|
388
641
|
console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
|
|
389
642
|
`${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
|
|
@@ -392,13 +645,26 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
392
645
|
catch (err) {
|
|
393
646
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
394
647
|
console.error(`[IntegrationEngine] Failed to resume run ${runID.substring(0, 8)}: ${errMsg}`);
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
648
|
+
if (err instanceof RunOwnershipLostError) {
|
|
649
|
+
// We were fenced out mid-resume — the NEW owner now owns the run row.
|
|
650
|
+
// Writing 'Failed' here would clobber the live holder's state.
|
|
651
|
+
console.warn(`[IntegrationEngine] Resume of run ${runID.substring(0, 8)} lost ownership — leaving the run row to its new owner`);
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
// Mark as failed so it doesn't get picked up again
|
|
655
|
+
run.EndedAt = new Date();
|
|
656
|
+
run.Status = 'Failed';
|
|
657
|
+
run.ErrorLog = JSON.stringify([{ ErrorMessage: `Resume failed: ${errMsg}` }]);
|
|
658
|
+
ownership.SyncEntityOwnershipFields(run);
|
|
659
|
+
await run.Save();
|
|
660
|
+
try {
|
|
661
|
+
await ownership.Release('Failed');
|
|
662
|
+
}
|
|
663
|
+
catch { /* lease will simply expire */ }
|
|
664
|
+
}
|
|
400
665
|
}
|
|
401
666
|
finally {
|
|
667
|
+
ownership.StopHeartbeat();
|
|
402
668
|
// Release the C1 lock + unblock any RunSync that began awaiting this resume (RunSync returns
|
|
403
669
|
// `existing`). Resolve with the real result when we have one, else a benign empty result so no
|
|
404
670
|
// waiter hangs. Promise resolve is idempotent and the early-exit `continue` also lands here.
|
|
@@ -424,8 +690,98 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
424
690
|
* @returns Aggregate sync result with record counts and errors
|
|
425
691
|
*/
|
|
426
692
|
async RunSync(companyIntegrationID, contextUser, triggerType = 'Manual', onProgress, onNotification, options, provider) {
|
|
427
|
-
|
|
428
|
-
|
|
693
|
+
return this.runWithOwnedContext(companyIntegrationID, contextUser, triggerType, onProgress, onNotification, options, provider);
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Worker-mode enqueue (PR 1 item 8): create the run row with `Status='Queued'` and
|
|
697
|
+
* return its ID immediately, without executing anything. A worker process picks it up
|
|
698
|
+
* via {@link ExecuteQueuedRun}; the claim sproc provides the mutual exclusion, so any
|
|
699
|
+
* number of workers can poll the same queue safely.
|
|
700
|
+
*
|
|
701
|
+
* The trigger type and sync options are persisted on the run's `ConfigData` so the
|
|
702
|
+
* worker executes exactly what the caller asked for, in a different process.
|
|
703
|
+
*/
|
|
704
|
+
async EnqueueSync(companyIntegrationID, contextUser, triggerType = 'Manual', options, provider) {
|
|
705
|
+
const md = provider ?? Metadata.Provider;
|
|
706
|
+
const run = await md.GetEntityObject('MJ: Company Integration Runs', contextUser);
|
|
707
|
+
run.NewRecord();
|
|
708
|
+
run.CompanyIntegrationID = companyIntegrationID;
|
|
709
|
+
run.RunByUserID = contextUser.ID;
|
|
710
|
+
run.StartedAt = new Date();
|
|
711
|
+
run.Status = 'Queued';
|
|
712
|
+
run.TotalRecords = 0;
|
|
713
|
+
run.ConfigData = JSON.stringify({ triggerType, options: options ?? null });
|
|
714
|
+
if (options?.ScheduledJobRunID) {
|
|
715
|
+
run.Set('ScheduledJobRunID', options.ScheduledJobRunID);
|
|
716
|
+
}
|
|
717
|
+
if (!(await run.Save())) {
|
|
718
|
+
throw new Error(`Failed to enqueue sync run: ${run.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
719
|
+
}
|
|
720
|
+
console.log(`[IntegrationEngine] Enqueued run ${run.ID} for ${companyIntegrationID} (${triggerType})`);
|
|
721
|
+
return run.ID;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Worker-mode execution (PR 1 item 8): take a `Queued` run row, claim it, and execute
|
|
725
|
+
* it in THIS process. Returns a failed result (without side effects) when the run is
|
|
726
|
+
* no longer queued or when another worker won the claim — losing the race is normal
|
|
727
|
+
* and must never be treated as an error condition by the caller's poll loop.
|
|
728
|
+
*/
|
|
729
|
+
async ExecuteQueuedRun(runID, contextUser, provider) {
|
|
730
|
+
const md = provider ?? Metadata.Provider;
|
|
731
|
+
const run = await md.GetEntityObject('MJ: Company Integration Runs', contextUser);
|
|
732
|
+
if (!(await run.Load(runID))) {
|
|
733
|
+
return this.emptyFailedResult(`Queued run ${runID} not found`);
|
|
734
|
+
}
|
|
735
|
+
if (run.Status !== 'Queued') {
|
|
736
|
+
return this.emptyFailedResult(`Run ${runID} is '${run.Status}', not 'Queued' — another worker already took it`);
|
|
737
|
+
}
|
|
738
|
+
// A cancel issued while the run was still QUEUED must stop it here. CancelSync stamps
|
|
739
|
+
// both 'In Progress' and 'Queued' rows, but the only consumer of the stamp is the
|
|
740
|
+
// running loop's batch-boundary / lease-renewal check — a queued run has no loop yet,
|
|
741
|
+
// so without this gate the worker claims the cancelled row moments later and executes
|
|
742
|
+
// it to completion. Verified live: run E3F51F9A was stamped CancelRequestedAt at
|
|
743
|
+
// 15:50:46.643 and still finished Status='Success' at 15:50:48.646.
|
|
744
|
+
// Finalize the same way an aborted in-flight run finalizes (FinalizeRun): 'Cancelled'
|
|
745
|
+
// with an explicit ErrorLog carrying the reason.
|
|
746
|
+
if (run.CancelRequestedAt != null) {
|
|
747
|
+
run.EndedAt = new Date();
|
|
748
|
+
run.Status = 'Cancelled';
|
|
749
|
+
run.ErrorLog = 'Sync cancelled by user before it started';
|
|
750
|
+
if (!(await run.Save())) {
|
|
751
|
+
console.warn(`[IntegrationEngine] Could not finalize cancelled queued run ${runID}: ${run.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
752
|
+
}
|
|
753
|
+
console.log(`[IntegrationEngine] Queued run ${runID} was cancelled before start — not executing`);
|
|
754
|
+
return this.emptyFailedResult('Sync cancelled by user before it started');
|
|
755
|
+
}
|
|
756
|
+
let triggerType = 'Scheduled';
|
|
757
|
+
let options;
|
|
758
|
+
try {
|
|
759
|
+
const config = JSON.parse(run.ConfigData ?? '{}');
|
|
760
|
+
if (config.triggerType)
|
|
761
|
+
triggerType = config.triggerType;
|
|
762
|
+
options = config.options ?? undefined;
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
// A run whose ConfigData we can't read still executes — with defaults, not silently skipped.
|
|
766
|
+
console.warn(`[IntegrationEngine] Run ${runID} has unparseable ConfigData; executing with defaults`);
|
|
767
|
+
}
|
|
768
|
+
return this.runWithOwnedContext(run.CompanyIntegrationID, contextUser, triggerType, undefined, undefined, options, provider, run);
|
|
769
|
+
}
|
|
770
|
+
/** A zero-work failed SyncResult — used for refusals that must not look like partial work. */
|
|
771
|
+
emptyFailedResult(message) {
|
|
772
|
+
return {
|
|
773
|
+
Success: false, ErrorMessage: message, RecordsProcessed: 0, RecordsCreated: 0,
|
|
774
|
+
RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0,
|
|
775
|
+
Errors: [], EntityMapResults: [], Duration: 0,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Shared body of {@link RunSync} and {@link ExecuteQueuedRun}: acquires the per-connection
|
|
780
|
+
* concurrency lock, establishes the per-run AsyncLocalStorage context (own provider, own
|
|
781
|
+
* abort controller, own progress snapshot) and executes. `existingRun` is supplied by the
|
|
782
|
+
* worker path so a queued row is executed rather than a new row created.
|
|
783
|
+
*/
|
|
784
|
+
async runWithOwnedContext(companyIntegrationID, contextUser, triggerType, onProgress, onNotification, options, provider, existingRun) {
|
|
429
785
|
const lockKey = companyIntegrationID.toLowerCase();
|
|
430
786
|
const existing = IntegrationEngine.activeSyncs.get(lockKey);
|
|
431
787
|
if (existing) {
|
|
@@ -446,10 +802,12 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
446
802
|
Errors: [], EntityMapResults: [], Duration: 0,
|
|
447
803
|
};
|
|
448
804
|
}
|
|
449
|
-
//
|
|
805
|
+
// Per-run context (PR 1 item 7): the run's OWN provider (captured, never stored on
|
|
806
|
+
// the engine), a local abort controller (plumbing driven by the DB cancel/fence
|
|
807
|
+
// signals — never a cross-process source of truth), and a local progress snapshot
|
|
808
|
+
// that the ownership service persists to ProgressJSON on the run row.
|
|
450
809
|
const abortController = new AbortController();
|
|
451
|
-
|
|
452
|
-
IntegrationEngine._syncProgress.set(lockKey, {
|
|
810
|
+
const progressSnapshot = {
|
|
453
811
|
StartedAt: new Date(),
|
|
454
812
|
CurrentEntity: '',
|
|
455
813
|
EntityMapsTotal: 0,
|
|
@@ -459,33 +817,41 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
459
817
|
RecordsUpdated: 0,
|
|
460
818
|
RecordsErrored: 0,
|
|
461
819
|
TriggerType: triggerType,
|
|
462
|
-
}
|
|
820
|
+
};
|
|
821
|
+
const runCtx = {
|
|
822
|
+
provider: provider ?? Metadata.Provider,
|
|
823
|
+
abortController,
|
|
824
|
+
progressSnapshot,
|
|
825
|
+
cancelRequested: false,
|
|
826
|
+
ownershipLost: false,
|
|
827
|
+
};
|
|
463
828
|
// Wrap caller's onProgress with internal tracking. U3 — MONOTONIC: with
|
|
464
829
|
// syncConcurrency > 1 the per-map events arrive out of order (map 3 can emit after
|
|
465
830
|
// map 7), so raw assignment made the progress bar go BACKWARDS. The snapshot is a
|
|
466
|
-
// high-water mark, so only ever ratchet the counters upward.
|
|
831
|
+
// high-water mark, so only ever ratchet the counters upward. The snapshot is
|
|
832
|
+
// persisted to the run row's ProgressJSON (throttled) so readers in ANY process see it.
|
|
467
833
|
const wrappedProgress = (progress) => {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
IntegrationEngine.RatchetProgressSnapshot(entry, progress);
|
|
834
|
+
IntegrationEngine.RatchetProgressSnapshot(progressSnapshot, progress);
|
|
835
|
+
void runCtx.ownership?.WriteProgress(JSON.stringify(progressSnapshot));
|
|
471
836
|
if (onProgress)
|
|
472
837
|
onProgress(progress);
|
|
473
838
|
};
|
|
474
|
-
|
|
839
|
+
// Enter the AsyncLocalStorage scope — every helper the run calls resolves
|
|
840
|
+
// ProviderToUse / write chain / ownership from THIS context, isolated per run.
|
|
841
|
+
const syncPromise = IntegrationEngine.runContext.run(runCtx, () => this.executeSyncInternal(companyIntegrationID, contextUser, triggerType, wrappedProgress, onNotification, options, abortController.signal, existingRun));
|
|
475
842
|
IntegrationEngine.activeSyncs.set(lockKey, syncPromise);
|
|
476
843
|
try {
|
|
477
844
|
return await syncPromise;
|
|
478
845
|
}
|
|
479
846
|
finally {
|
|
480
847
|
IntegrationEngine.activeSyncs.delete(lockKey);
|
|
481
|
-
|
|
482
|
-
IntegrationEngine._syncProgress.delete(lockKey);
|
|
848
|
+
runCtx.ownership?.StopHeartbeat();
|
|
483
849
|
}
|
|
484
850
|
}
|
|
485
851
|
/**
|
|
486
852
|
* Internal sync execution method. Contains the full orchestration logic.
|
|
487
853
|
*/
|
|
488
|
-
async executeSyncInternal(companyIntegrationID, contextUser, triggerType, onProgress, onNotification, options, abortSignal) {
|
|
854
|
+
async executeSyncInternal(companyIntegrationID, contextUser, triggerType, onProgress, onNotification, options, abortSignal, existingRun) {
|
|
489
855
|
const startTime = Date.now();
|
|
490
856
|
const logger = new SyncLogger({ ciId: companyIntegrationID, integration: null });
|
|
491
857
|
logger.emit('sync.run.start', {
|
|
@@ -523,6 +889,15 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
523
889
|
if (config.companyIntegration.IsActive === false) {
|
|
524
890
|
const message = 'Connector is deactivated (IsActive=false); sync not started';
|
|
525
891
|
logger.emit('sync.warning', { reason: 'deactivated', message });
|
|
892
|
+
// A queued row must not sit in the queue forever being re-polled by every worker.
|
|
893
|
+
if (existingRun) {
|
|
894
|
+
existingRun.Status = 'Failed';
|
|
895
|
+
existingRun.ErrorLog = message;
|
|
896
|
+
existingRun.EndedAt = new Date();
|
|
897
|
+
if (!(await existingRun.Save())) {
|
|
898
|
+
console.warn(`[IntegrationEngine] Failed to fail-out deactivated queued run ${existingRun.ID}: ${existingRun.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
526
901
|
return {
|
|
527
902
|
Success: false,
|
|
528
903
|
ErrorMessage: message,
|
|
@@ -537,8 +912,53 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
537
912
|
Duration: Date.now() - startTime,
|
|
538
913
|
};
|
|
539
914
|
}
|
|
540
|
-
|
|
915
|
+
// Worker mode executes a row that already exists (Status='Queued'); the direct path creates one.
|
|
916
|
+
const run = existingRun ?? await this.CreateRunRecord(config.companyIntegration, triggerType, contextUser, options?.ScheduledJobRunID, options);
|
|
541
917
|
logger.attachRunId(run.ID);
|
|
918
|
+
// ── Durable-run ownership (PR 1 item 3): claim before the first batch. ──
|
|
919
|
+
// The claim is ONE atomic UPDATE (unowned OR expired lease) that bumps FenceToken;
|
|
920
|
+
// zero rows back means another process owns this run and we must not proceed.
|
|
921
|
+
// Renewal runs from a TIMER at ~lease/3 (not the batch loop) so a long batch never
|
|
922
|
+
// looks dead; the lease is max(default, MaxRuntimeMinutes) so a configured long run
|
|
923
|
+
// only ever EXTENDS protection. The heartbeat's renewal result doubles as the
|
|
924
|
+
// cross-process cancel poll.
|
|
925
|
+
const runCtx = this.currentRunContext;
|
|
926
|
+
if (runCtx) {
|
|
927
|
+
const ownership = new RunOwnershipService(runCtx.provider, run.ID, options?.MaxRuntimeMinutes ?? undefined, contextUser);
|
|
928
|
+
const claimed = await ownership.Claim();
|
|
929
|
+
if (!claimed) {
|
|
930
|
+
const message = `Run ${run.ID} could not be claimed — another process holds a live lease. Not proceeding.`;
|
|
931
|
+
logger.emit('sync.warning', { reason: 'claim-lost', message });
|
|
932
|
+
return {
|
|
933
|
+
Success: false, ErrorMessage: message, RecordsProcessed: 0, RecordsCreated: 0,
|
|
934
|
+
RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0,
|
|
935
|
+
Errors: [], EntityMapResults: [], Duration: Date.now() - startTime, RunID: run.ID,
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
runCtx.ownership = ownership;
|
|
939
|
+
// Worker mode: the claim is what promotes a Queued row to In Progress. Doing it
|
|
940
|
+
// AFTER the claim (never before) means a worker that loses the race never touches
|
|
941
|
+
// the row, so the winner's status is the only one written.
|
|
942
|
+
if (existingRun && existingRun.Status === 'Queued') {
|
|
943
|
+
existingRun.Status = 'In Progress';
|
|
944
|
+
existingRun.StartedAt = new Date();
|
|
945
|
+
ownership.SyncEntityOwnershipFields(existingRun);
|
|
946
|
+
if (!(await existingRun.Save())) {
|
|
947
|
+
console.warn(`[IntegrationEngine] Failed to mark claimed run ${existingRun.ID} In Progress: ${existingRun.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
ownership.StartHeartbeat({
|
|
951
|
+
onLost: () => {
|
|
952
|
+
runCtx.ownershipLost = true;
|
|
953
|
+
runCtx.abortController.abort();
|
|
954
|
+
},
|
|
955
|
+
onCancelRequested: () => {
|
|
956
|
+
runCtx.cancelRequested = true;
|
|
957
|
+
runCtx.abortController.abort();
|
|
958
|
+
},
|
|
959
|
+
progressSupplier: () => JSON.stringify(runCtx.progressSnapshot),
|
|
960
|
+
});
|
|
961
|
+
}
|
|
542
962
|
// Durable, queryable, restart-surviving artifact stream for this sync. runID is
|
|
543
963
|
// the CompanyIntegrationRun.ID so the JSONL artifact cross-correlates with the run
|
|
544
964
|
// row. Exposed over GraphQL (IntegrationListRuns / IntegrationGetRun /
|
|
@@ -603,6 +1023,20 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
603
1023
|
catch (err) {
|
|
604
1024
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
605
1025
|
logger.emit('sync.run.fail', { error: errMsg, durationMs: Date.now() - startTime });
|
|
1026
|
+
// Ownership lost (fence moved / lease reclaimed): the run row now belongs to
|
|
1027
|
+
// ANOTHER process — writing a terminal status to it here would clobber the new
|
|
1028
|
+
// owner's state. Stop everything locally and walk away without touching the row.
|
|
1029
|
+
const ctx = this.currentRunContext;
|
|
1030
|
+
if (err instanceof RunOwnershipLostError || ctx?.ownershipLost) {
|
|
1031
|
+
ctx?.ownership?.StopHeartbeat();
|
|
1032
|
+
await this.finalizeSyncProgress(progress, 'failed', errMsg);
|
|
1033
|
+
console.warn(`[IntegrationEngine] Run ${run.ID} ownership lost — aborted without writing the run row.`);
|
|
1034
|
+
return {
|
|
1035
|
+
Success: false, ErrorMessage: errMsg, RecordsProcessed: 0, RecordsCreated: 0,
|
|
1036
|
+
RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0,
|
|
1037
|
+
Errors: [], EntityMapResults: [], Duration: Date.now() - startTime, RunID: run.ID,
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
606
1040
|
await this.finalizeSyncProgress(progress, 'failed', errMsg);
|
|
607
1041
|
await this.FailRun(run, err, contextUser, onNotification);
|
|
608
1042
|
throw err;
|
|
@@ -771,7 +1205,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
771
1205
|
/**
|
|
772
1206
|
* Creates a new CompanyIntegrationRun record to track this sync.
|
|
773
1207
|
*/
|
|
774
|
-
async CreateRunRecord(companyIntegration, triggerType, contextUser, scheduledJobRunID) {
|
|
1208
|
+
async CreateRunRecord(companyIntegration, triggerType, contextUser, scheduledJobRunID, options) {
|
|
775
1209
|
const md = this.ProviderToUse;
|
|
776
1210
|
const run = await md.GetEntityObject('MJ: Company Integration Runs', contextUser);
|
|
777
1211
|
run.NewRecord();
|
|
@@ -780,7 +1214,12 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
780
1214
|
run.StartedAt = new Date();
|
|
781
1215
|
run.Status = 'In Progress';
|
|
782
1216
|
run.TotalRecords = 0;
|
|
783
|
-
|
|
1217
|
+
// Persist the OPTIONS alongside the trigger type, in the same shape EnqueueSync writes.
|
|
1218
|
+
// Without them a run that outlives its process loses what it was asked to do: ResumeOrphanedSyncs
|
|
1219
|
+
// rebuilds config from the CompanyIntegration alone, so an adopted `FullSync` run silently
|
|
1220
|
+
// resumed as an incremental one — re-fetching nothing and reporting Success, which is the
|
|
1221
|
+
// opposite of what a full sync is requested for (repairing drift, re-pulling after a remap).
|
|
1222
|
+
run.ConfigData = JSON.stringify({ triggerType, options: options ?? null });
|
|
784
1223
|
// Link to scheduled job run if triggered by the scheduler.
|
|
785
1224
|
// Use Set() because the ScheduledJobRunID column won't exist on the
|
|
786
1225
|
// generated entity type until CodeGen runs after the migration.
|
|
@@ -848,6 +1287,11 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
848
1287
|
return { ok: mapResult.Success, throttled: mapResult.Throttled === true };
|
|
849
1288
|
}
|
|
850
1289
|
catch (err) {
|
|
1290
|
+
// Ownership loss is NOT a per-map failure to record-and-continue: continuing to the
|
|
1291
|
+
// next map would keep writing after another process claimed the run — the exact
|
|
1292
|
+
// split-brain the fence prevents. Propagate so the whole sync aborts immediately.
|
|
1293
|
+
if (err instanceof RunOwnershipLostError)
|
|
1294
|
+
throw err;
|
|
851
1295
|
const objName = entityMap.ExternalObjectName ?? entityMap.ID;
|
|
852
1296
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
853
1297
|
console.error(`[IntegrationEngine] Entity map '${objName}' failed: ${errMsg}`);
|
|
@@ -1282,12 +1726,12 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1282
1726
|
if (!raw)
|
|
1283
1727
|
return {};
|
|
1284
1728
|
const p = JSON.parse(raw);
|
|
1285
|
-
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined);
|
|
1286
1729
|
return {
|
|
1287
|
-
maxConcurrency:
|
|
1730
|
+
maxConcurrency: PositiveInt(p.maxConcurrency),
|
|
1288
1731
|
rateLimitTokensPerSec: typeof p.rateLimitTokensPerSec === 'number' && p.rateLimitTokensPerSec > 0 ? p.rateLimitTokensPerSec : undefined,
|
|
1289
|
-
rateLimitBurst:
|
|
1290
|
-
discoveryTimeBudgetMs:
|
|
1732
|
+
rateLimitBurst: PositiveInt(p.rateLimitBurst),
|
|
1733
|
+
discoveryTimeBudgetMs: PositiveInt(p.discoveryTimeBudgetMs),
|
|
1734
|
+
fetchTimeoutMs: PositiveInt(p.fetchTimeoutMs),
|
|
1291
1735
|
};
|
|
1292
1736
|
}
|
|
1293
1737
|
catch {
|
|
@@ -1476,6 +1920,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1476
1920
|
let fetchGapCount = 0; // CONSECUTIVE skipped pages (reset on any clean fetch)
|
|
1477
1921
|
const MAX_FETCH_GAPS = 25; // give up + hold the watermark if this many pages fail in a row (API down)
|
|
1478
1922
|
let consecutiveEmptyBatches = 0; // P3-D: detect a connector that pages empty-but-HasMore forever
|
|
1923
|
+
let oversizeBatchWarned = false; // pagination rule: warn ONCE per object that the connector ignored BatchSize
|
|
1479
1924
|
const MAX_BATCHES_PER_MAP = 5000;
|
|
1480
1925
|
const EMPTY_BATCH_WARN_THRESHOLD = 5; // warn once after this many empty-but-HasMore batches in a row
|
|
1481
1926
|
const fetchedExternalIDs = new Set(); // Track all IDs seen during this pull for orphan detection
|
|
@@ -1486,6 +1931,17 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1486
1931
|
// one entry per distinct key + a capped value sample. See CustomKeyStat.
|
|
1487
1932
|
const customKeyAgg = new Map();
|
|
1488
1933
|
let customKeyTotalRecords = 0;
|
|
1934
|
+
// Per-page fetch timeout, resolved ONCE per entity map. A connector that fans out one request
|
|
1935
|
+
// per parent does N requests inside a single FetchChanges call, so its page time scales with
|
|
1936
|
+
// BatchSize and with however much concurrency the adaptive controller currently allows — the
|
|
1937
|
+
// fixed 30s default punished exactly those connectors. Deployment config wins over the
|
|
1938
|
+
// connector's own declared default, which wins over the framework default. BOTH overrides go
|
|
1939
|
+
// through PositiveInt: `??` alone would only reject null/undefined, so a connector returning
|
|
1940
|
+
// 0 / -1 / NaN (all legal for its `number | null` type) would be applied verbatim and time
|
|
1941
|
+
// every page out at ~1ms.
|
|
1942
|
+
const fetchTimeoutMs = PositiveInt(this.getConfigOverrides(config).fetchTimeoutMs)
|
|
1943
|
+
?? PositiveInt(config.connector.FetchChangesTimeoutMs)
|
|
1944
|
+
?? DEFAULT_OPERATION_TIMEOUTS.FetchChangesMs;
|
|
1489
1945
|
while (hasMore) {
|
|
1490
1946
|
if (abortSignal?.aborted) {
|
|
1491
1947
|
console.log(`[IntegrationEngine] Sync cancelled for ${entityMap.ExternalObjectName} after ${recordsInMap} records — saving watermark`);
|
|
@@ -1533,7 +1989,21 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1533
1989
|
// Resilient fetch: bound each attempt with a timeout (a hung vendor API must not
|
|
1534
1990
|
// hold the sync lock forever) and retry only transient errors (network/throttle/DB).
|
|
1535
1991
|
// A non-retryable error (auth, 4xx, parse) throws immediately as before.
|
|
1536
|
-
batch = await WithRetry(() => WithTimeout(config.connector.FetchChanges(ctx),
|
|
1992
|
+
batch = await WithRetry(() => WithTimeout(config.connector.FetchChanges(ctx), fetchTimeoutMs, `FetchChanges(${entityMap.ExternalObjectName})`), undefined,
|
|
1993
|
+
// OUR OWN timeout is terminal for this page; a transport error is not.
|
|
1994
|
+
//
|
|
1995
|
+
// `WithTimeout` is a `Promise.race` with no cancellation, so the abandoned attempt
|
|
1996
|
+
// keeps running. Retrying meant a second full page of vendor requests overlapping
|
|
1997
|
+
// the first, then a third — up to 3x the load on a source that was already too slow
|
|
1998
|
+
// to finish once, which is a good way to earn a real 429 (and THAT does cut
|
|
1999
|
+
// concurrency). And the retry could not succeed on its merits anyway: the same work
|
|
2000
|
+
// under the same budget exceeds it again.
|
|
2001
|
+
//
|
|
2002
|
+
// Deliberately `instanceof` rather than the classified code. `ClassifyError` folds
|
|
2003
|
+
// `econnreset` in with timeouts under `NETWORK_TIMEOUT`, and a reset socket IS worth
|
|
2004
|
+
// retrying — so excluding the whole code would lose real resilience. Only the error
|
|
2005
|
+
// WithTimeout itself minted is excluded.
|
|
2006
|
+
(err) => !(err instanceof OperationTimeoutError) && IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => logger?.emit('sync.fetch.retry', {
|
|
1537
2007
|
externalObjectName: entityMap.ExternalObjectName,
|
|
1538
2008
|
batchIndex: batchCount,
|
|
1539
2009
|
attempt,
|
|
@@ -1583,7 +2053,33 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1583
2053
|
currentPage += 1;
|
|
1584
2054
|
continue;
|
|
1585
2055
|
}
|
|
2056
|
+
// Cannot skip past this page (cursor paging, or the gap budget is spent), so the object
|
|
2057
|
+
// stops here with an incomplete result set. The watermark is held below and the run
|
|
2058
|
+
// re-fetches next time — but WITHOUT this warning that outcome is invisible: the map
|
|
2059
|
+
// reports success with the records it did get, so an object whose very first page
|
|
2060
|
+
// failed reads as a clean "0 records, nothing changed" run. Verified live: a sync
|
|
2061
|
+
// whose only page timed out finished Status=Success, errorCount=0, empty ErrorLog.
|
|
1586
2062
|
fetchCompletedCleanly = false;
|
|
2063
|
+
const abortMessage = `Fetch for '${entityMap.ExternalObjectName}' stopped at batch ${batchCount} after a persistent ` +
|
|
2064
|
+
`error and could not continue past it, so this object's result set is INCOMPLETE ` +
|
|
2065
|
+
`(${recordsInMap} record(s) fetched before the failure). The watermark is held, so the ` +
|
|
2066
|
+
`unfetched window is retried next run. Error: ${errMsg}`;
|
|
2067
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_ABORTED_INCOMPLETE', abortMessage, { batchIndex: batchCount, recordsFetchedBeforeFailure: recordsInMap, error: errMsg });
|
|
2068
|
+
// The structured warning above reaches the console and the per-run artifact — neither of
|
|
2069
|
+
// which is queryable run history. The DURABLE record is CompanyIntegrationRun, whose
|
|
2070
|
+
// Status is derived from RecordsErrored (unchanged here, correctly: no record failed) and
|
|
2071
|
+
// whose ErrorLog is written from result.Errors. Without an entry there, a nightly sync
|
|
2072
|
+
// that aborts on its first page every night reads as an unbroken run of clean Successes
|
|
2073
|
+
// with TotalRecords=0. Severity 'Warning' is what keeps Status='Success': MergeResult
|
|
2074
|
+
// only clears Success on RecordsErrored > 0, so this records the condition without
|
|
2075
|
+
// reclassifying a held-watermark retry as a failed run.
|
|
2076
|
+
result.Errors.push({
|
|
2077
|
+
ExternalID: '',
|
|
2078
|
+
ChangeType: 'Skip',
|
|
2079
|
+
ErrorMessage: abortMessage,
|
|
2080
|
+
ErrorCode: 'CONNECTOR_ERROR',
|
|
2081
|
+
Severity: 'Warning',
|
|
2082
|
+
});
|
|
1587
2083
|
break;
|
|
1588
2084
|
}
|
|
1589
2085
|
logger?.emit('sync.fetch.batch.complete', {
|
|
@@ -1605,11 +2101,15 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1605
2101
|
logger?.warning(entityMap.ExternalObjectName ?? 'sync', w.Code, w.Message, w.Data);
|
|
1606
2102
|
}
|
|
1607
2103
|
}
|
|
1608
|
-
//
|
|
1609
|
-
//
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
2104
|
+
// Engine-side half of the pagination rule: a connector MUST honour ctx.BatchSize. We never
|
|
2105
|
+
// truncate — every record is written, just in sub-batches to keep DB transactions manageable —
|
|
2106
|
+
// but an over-size batch is a real connector defect and has to be visible on the structured
|
|
2107
|
+
// run-event stream, not buried in a console.log nobody reads. Warned ONCE per object (the
|
|
2108
|
+
// CONSECUTIVE_EMPTY_BATCHES pattern) so a paginating-but-over-size connector doesn't flood
|
|
2109
|
+
// the artifact with one warning per page.
|
|
2110
|
+
if (batch.Records.length > this.MaxBatchSize && !oversizeBatchWarned) {
|
|
2111
|
+
oversizeBatchWarned = true;
|
|
2112
|
+
this.warnOversizedBatch(entityMap, batch, batchCount, logger);
|
|
1613
2113
|
}
|
|
1614
2114
|
if (batch.Records.length > 0) {
|
|
1615
2115
|
const fingerprint = batch.Records.map(r => r.ExternalID).join(',');
|
|
@@ -1653,6 +2153,9 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1653
2153
|
`for this object to stream via per-record content-hash instead.`);
|
|
1654
2154
|
}
|
|
1655
2155
|
}
|
|
2156
|
+
// Batch boundary: verify we STILL own the run before this batch's writes begin.
|
|
2157
|
+
// Throws RunOwnershipLostError (aborting with nothing written) if the fence moved.
|
|
2158
|
+
await this.assertOwnershipAtBoundary();
|
|
1656
2159
|
// Serialize the match READ too (record-map / PK lookups). On a shared provider connection
|
|
1657
2160
|
// a read routes through whatever transaction is active, so a match read in this stream
|
|
1658
2161
|
// collides with another concurrent stream's in-flight write transaction ("Transaction has
|
|
@@ -1819,6 +2322,26 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1819
2322
|
await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
|
|
1820
2323
|
result.WatermarkAfter = currentAfterKey;
|
|
1821
2324
|
}
|
|
2325
|
+
else if (!hadFetchGap && currentWatermark && currentWatermark !== initialWatermark) {
|
|
2326
|
+
// A WATERMARK-based connector stopped early (cancel / safety limit / duplicate batch /
|
|
2327
|
+
// schema-not-generated / unskippable fetch error) but whole batches DID complete. Persist the
|
|
2328
|
+
// max watermark seen so the next run resumes from there instead of re-fetching everything
|
|
2329
|
+
// since the last clean run — the counterpart of the keyset branch above, which until now was
|
|
2330
|
+
// the ONLY early-stop that saved its position. The cancel log has always said "saving
|
|
2331
|
+
// watermark"; for a non-keyset connector it previously saved nothing.
|
|
2332
|
+
//
|
|
2333
|
+
// Safe because currentWatermark only ever advances at the END of a fully-applied batch
|
|
2334
|
+
// (§10, after ApplyRecords) — every early-exit `break` above happens before that, so this
|
|
2335
|
+
// value can never point past a record that wasn't written.
|
|
2336
|
+
//
|
|
2337
|
+
// Deliberately NOT wall-clock "now", even for a full sync: coverage is partial, so advancing
|
|
2338
|
+
// past the point actually reached would skip the (reached, now] window permanently. And
|
|
2339
|
+
// deliberately NOT when hadFetchGap — a skipped page leaves a HOLE behind this watermark,
|
|
2340
|
+
// which is why that path holds it for a full re-fetch next run.
|
|
2341
|
+
const partialWatermark = currentWatermark;
|
|
2342
|
+
await this.runWriteExclusive(() => this.watermarkService.Update(entityMapID, partialWatermark, contextUser, 'Pull'));
|
|
2343
|
+
result.WatermarkAfter = partialWatermark;
|
|
2344
|
+
}
|
|
1822
2345
|
// Orphan detection: delete/tombstone MJ records whose external counterpart no longer exists.
|
|
1823
2346
|
// Runs on a full sync OR a partition-reconcile (both fetch the COMPLETE set, so an MJ record
|
|
1824
2347
|
// whose ExternalID isn't in fetchedExternalIDs is genuinely gone — even one inside an otherwise
|
|
@@ -1857,6 +2380,59 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1857
2380
|
await this.CreateRunDetail(run, entityMap, result, contextUser);
|
|
1858
2381
|
return result;
|
|
1859
2382
|
}
|
|
2383
|
+
/**
|
|
2384
|
+
* Classifies a fetched batch against the `ctx.BatchSize` the engine asked for. Returns null when
|
|
2385
|
+
* the batch is within contract, so the caller can treat "no verdict" as "nothing to warn about".
|
|
2386
|
+
*
|
|
2387
|
+
* Two severities, because they are different defects:
|
|
2388
|
+
* - `CONNECTOR_UNBOUNDED_BATCH` — the FIRST batch came back over-size AND `HasMore` is not true,
|
|
2389
|
+
* i.e. the connector ignored pagination entirely and pulled the whole object into memory in one
|
|
2390
|
+
* request. That is what OOMs a large tenant, and it grows silently with the customer's data.
|
|
2391
|
+
* - `CONNECTOR_IGNORED_BATCH_SIZE` — the connector IS paging but overshoots the requested size
|
|
2392
|
+
* (e.g. a hardcoded page size). Bounded memory, still a contract violation worth fixing.
|
|
2393
|
+
*
|
|
2394
|
+
* Public + static because the decision is the interesting part and deserves a unit test that
|
|
2395
|
+
* doesn't have to stand up a whole sync (same rationale as {@link RatchetProgressSnapshot}).
|
|
2396
|
+
*/
|
|
2397
|
+
static ClassifyOversizedBatch(objectName, recordCount, requestedBatchSize, batchIndex, hasMore) {
|
|
2398
|
+
if (recordCount <= requestedBatchSize)
|
|
2399
|
+
return null;
|
|
2400
|
+
const unbounded = batchIndex === 1 && hasMore !== true;
|
|
2401
|
+
return unbounded
|
|
2402
|
+
? {
|
|
2403
|
+
Code: 'CONNECTOR_UNBOUNDED_BATCH',
|
|
2404
|
+
Unbounded: true,
|
|
2405
|
+
Message: `'${objectName}': connector returned ALL ${recordCount} records in a single batch ` +
|
|
2406
|
+
`(requested ${requestedBatchSize}, HasMore=false) — pagination is not implemented for this ` +
|
|
2407
|
+
`object, so the entire object is held in memory. Every record was written, in chunks.`,
|
|
2408
|
+
}
|
|
2409
|
+
: {
|
|
2410
|
+
Code: 'CONNECTOR_IGNORED_BATCH_SIZE',
|
|
2411
|
+
Unbounded: false,
|
|
2412
|
+
Message: `'${objectName}': connector returned ${recordCount} records for batch ${batchIndex} ` +
|
|
2413
|
+
`(requested ${requestedBatchSize}) — ctx.BatchSize is not being honoured. Every record was ` +
|
|
2414
|
+
`written, in chunks. Warned once per object.`,
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* Surfaces an over-size batch onto the structured run-event stream (queryable via
|
|
2419
|
+
* IntegrationTailRunEvents) so the pagination-rule violation is visible instead of console-only.
|
|
2420
|
+
* Never truncates and never fails the sync — the over-size batch is written in chunks either way.
|
|
2421
|
+
*/
|
|
2422
|
+
warnOversizedBatch(entityMap, batch, batchIndex, logger) {
|
|
2423
|
+
const objectName = entityMap.ExternalObjectName ?? entityMap.ID;
|
|
2424
|
+
const verdict = IntegrationEngine.ClassifyOversizedBatch(objectName, batch.Records.length, this.MaxBatchSize, batchIndex, batch.HasMore);
|
|
2425
|
+
if (!verdict)
|
|
2426
|
+
return;
|
|
2427
|
+
logger?.warning(objectName, verdict.Code, verdict.Message, {
|
|
2428
|
+
batchIndex,
|
|
2429
|
+
recordCount: batch.Records.length,
|
|
2430
|
+
requestedBatchSize: this.MaxBatchSize,
|
|
2431
|
+
hasMore: batch.HasMore ?? null,
|
|
2432
|
+
unbounded: verdict.Unbounded,
|
|
2433
|
+
});
|
|
2434
|
+
console.warn(`[IntegrationEngine] ${verdict.Message}`);
|
|
2435
|
+
}
|
|
1860
2436
|
/**
|
|
1861
2437
|
* Push sync: detect local MJ record changes → reverse-map fields → push to external system.
|
|
1862
2438
|
*
|
|
@@ -1924,7 +2500,19 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1924
2500
|
// lexicographically, matching the SQL `ChangedAt > '...'` filter in LoadChangedMJRecords.
|
|
1925
2501
|
let firstErrorChangeAt = null; // min ChangedAt among failed pushes
|
|
1926
2502
|
const successfulChangeAts = [];
|
|
2503
|
+
let recordsSinceBoundary = 0;
|
|
1927
2504
|
for (const change of changedRecords) {
|
|
2505
|
+
// Push has no natural batch, so impose a fence boundary every MaxBatchSize records:
|
|
2506
|
+
// a reclaimed run must stop writing to the VENDOR promptly, not at end-of-map. The
|
|
2507
|
+
// check sits OUTSIDE the per-record try so RunOwnershipLostError propagates (the
|
|
2508
|
+
// per-record catch must never swallow it). A cross-process cancel trips the abort
|
|
2509
|
+
// signal, honored at the top of each iteration.
|
|
2510
|
+
if (recordsSinceBoundary === 0)
|
|
2511
|
+
await this.assertOwnershipAtBoundary();
|
|
2512
|
+
if (++recordsSinceBoundary >= this.MaxBatchSize)
|
|
2513
|
+
recordsSinceBoundary = 0;
|
|
2514
|
+
if (_abortSignal?.aborted)
|
|
2515
|
+
break;
|
|
1928
2516
|
result.RecordsProcessed++;
|
|
1929
2517
|
try {
|
|
1930
2518
|
await this.PushSingleRecord(change, config, entityMap, pushFieldMaps, result, contextUser, logger);
|
|
@@ -2515,6 +3103,8 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2515
3103
|
result.RecordsSkipped += recs.length;
|
|
2516
3104
|
continue;
|
|
2517
3105
|
}
|
|
3106
|
+
// Partition boundary = a batch boundary: fence-check before this partition's writes.
|
|
3107
|
+
await this.assertOwnershipAtBoundary();
|
|
2518
3108
|
// D3: serialize the match READ through the same write-mutex the non-partition path uses
|
|
2519
3109
|
// (~line 1644). matchEngine.Resolve reads existing MJ rows on the SHARED provider
|
|
2520
3110
|
// connection, so when streams run in parallel (syncConcurrency>1) it must not interleave
|
|
@@ -3748,23 +4338,36 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3748
4338
|
async FinalizeRun(run, result, contextUser, onNotification, aborted) {
|
|
3749
4339
|
run.EndedAt = new Date();
|
|
3750
4340
|
run.TotalRecords = result.RecordsProcessed;
|
|
3751
|
-
// A
|
|
3752
|
-
//
|
|
3753
|
-
//
|
|
3754
|
-
//
|
|
3755
|
-
//
|
|
3756
|
-
//
|
|
3757
|
-
//
|
|
4341
|
+
// A cancelled run is neither a success nor a failure, and 'Cancelled' is now a first-class
|
|
4342
|
+
// value in the Status list — so record it as itself. Previously this had to be 'Failed' with
|
|
4343
|
+
// an explanatory ErrorLog, which meant every health/cadence consumer counted deliberate
|
|
4344
|
+
// cancellations as errors unless it string-matched that text. The ErrorLog is still written
|
|
4345
|
+
// (it carries the reason), and the durable progress artifact still carries
|
|
4346
|
+
// exitReason='aborted' (see finalizeSyncProgress).
|
|
4347
|
+
// Held in a local so the release below sends the SAME value. Deriving it there from
|
|
4348
|
+
// `run.Status` with a two-way test collapsed everything non-Success to 'Failed', which would
|
|
4349
|
+
// have overwritten 'Cancelled' in the release UPDATE — the sproc assigns Status = @FinalStatus,
|
|
4350
|
+
// so the row's carefully-set status would be undone one statement later.
|
|
4351
|
+
let terminalStatus;
|
|
3758
4352
|
if (aborted) {
|
|
3759
|
-
|
|
4353
|
+
terminalStatus = 'Cancelled';
|
|
3760
4354
|
run.ErrorLog = result.ErrorMessage ?? 'Sync cancelled by user';
|
|
3761
4355
|
}
|
|
3762
4356
|
else {
|
|
3763
|
-
|
|
4357
|
+
terminalStatus = result.RecordsErrored > 0 ? 'Failed' : 'Success';
|
|
3764
4358
|
if (result.Errors.length > 0) {
|
|
3765
4359
|
run.ErrorLog = JSON.stringify(result.Errors.slice(0, 100));
|
|
3766
4360
|
}
|
|
3767
4361
|
}
|
|
4362
|
+
run.Status = terminalStatus;
|
|
4363
|
+
// The generated spUpdate writes EVERY column from the entity's in-memory state.
|
|
4364
|
+
// This run entity was loaded BEFORE the claim, so without a sync its ownership
|
|
4365
|
+
// columns (FenceToken 0, OwnerToken null, stale lease) would clobber the DB's live
|
|
4366
|
+
// values on save — silently un-fencing the run. Sync them to the service's
|
|
4367
|
+
// last-known-authoritative values first; Release() below then clears ownership
|
|
4368
|
+
// atomically (token-checked, so a stale holder's release no-ops).
|
|
4369
|
+
const ownership = this.currentRunContext?.ownership;
|
|
4370
|
+
ownership?.SyncEntityOwnershipFields(run);
|
|
3768
4371
|
// Retry the finalize save: a failed save leaves the run 'In Progress', which ResumeOrphanedSyncs
|
|
3769
4372
|
// re-queues on next startup → the whole sync re-runs (re-fetch + re-apply). Worth a few retries to
|
|
3770
4373
|
// make the terminal status durable. Both a thrown infra error and a `false` logical-failure retry.
|
|
@@ -3781,6 +4384,16 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3781
4384
|
`${saveErr instanceof Error ? saveErr.message : String(saveErr)}. ` +
|
|
3782
4385
|
`Run may remain 'In Progress' and be re-queued as orphaned on next startup.`);
|
|
3783
4386
|
}
|
|
4387
|
+
// Terminal release: clear OwnerToken/LeaseExpiresAt and (re-)stamp the final status
|
|
4388
|
+
// in one token-checked statement, so the row is immediately claimable-clean.
|
|
4389
|
+
if (ownership) {
|
|
4390
|
+
try {
|
|
4391
|
+
await ownership.Release(terminalStatus);
|
|
4392
|
+
}
|
|
4393
|
+
catch (releaseErr) {
|
|
4394
|
+
console.warn(`[IntegrationEngine] Run ${run.ID} release failed (non-fatal — lease will simply expire): ${releaseErr instanceof Error ? releaseErr.message : String(releaseErr)}`);
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
3784
4397
|
if (onNotification) {
|
|
3785
4398
|
const notification = this.buildCompletionNotification(run, result);
|
|
3786
4399
|
this.safeNotify(onNotification, notification);
|
|
@@ -3850,7 +4463,17 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3850
4463
|
run.EndedAt = new Date();
|
|
3851
4464
|
run.Status = 'Failed';
|
|
3852
4465
|
run.ErrorLog = err instanceof Error ? err.message : String(err);
|
|
4466
|
+
// Same full-row-save hazard as FinalizeRun: sync the in-memory ownership columns to the
|
|
4467
|
+
// claim's live values before Save, then release (token-checked — a fenced-out holder no-ops).
|
|
4468
|
+
const ownership = this.currentRunContext?.ownership;
|
|
4469
|
+
ownership?.SyncEntityOwnershipFields(run);
|
|
3853
4470
|
await run.Save();
|
|
4471
|
+
if (ownership) {
|
|
4472
|
+
try {
|
|
4473
|
+
await ownership.Release('Failed');
|
|
4474
|
+
}
|
|
4475
|
+
catch { /* non-fatal — lease will expire */ }
|
|
4476
|
+
}
|
|
3854
4477
|
if (onNotification) {
|
|
3855
4478
|
const failResult = {
|
|
3856
4479
|
Success: false,
|
|
@@ -3968,7 +4591,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3968
4591
|
CompanyIntegrationID: companyIntegrationID,
|
|
3969
4592
|
ContextUser: contextUser,
|
|
3970
4593
|
SyncedEntityNames: syncedEntityNames,
|
|
3971
|
-
Provider: this.
|
|
4594
|
+
Provider: this.currentRunContext?.provider,
|
|
3972
4595
|
// The run's in-memory custom-key candidates — needed because the
|
|
3973
4596
|
// overflow-column scan alone under-reports once the hash basis excludes
|
|
3974
4597
|
// overflow (skipped rows never write their overflow JSON).
|