@memberjunction/integration-engine 6.1.0-edge.2 → 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.
@@ -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 — explicit override if set, otherwise the global default. */
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._provider ?? Metadata.Provider;
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 = this._writeChain.then(() => fn(), () => fn());
263
- this._writeChain = run.then(() => undefined, () => undefined);
304
+ const run = holder.chain.then(() => fn(), () => fn());
305
+ holder.chain = run.then(() => undefined, () => undefined);
264
306
  return run;
265
307
  }
266
- /** Abort controllers for cancelling running syncs */
267
- static { this._abortControllers = new Map(); }
268
- /** Live sync progress updated on every batch for ALL syncs regardless of caller */
269
- static { this._syncProgress = new Map(); }
270
- /** Read current sync progress for a connector. Returns undefined if no sync is running. */
271
- static GetSyncProgress(companyIntegrationID) {
272
- return IntegrationEngine._syncProgress.get(companyIntegrationID.toLowerCase());
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
- /** Cancel a running sync for a connector. Returns true if a sync was found and signalled. */
275
- static CancelSync(companyIntegrationID) {
276
- const key = companyIntegrationID.toLowerCase();
277
- const controller = IntegrationEngine._abortControllers.get(key);
278
- if (controller) {
279
- console.log(`[IntegrationEngine] Cancelling sync for ${companyIntegrationID}`);
280
- controller.abort();
281
- return true;
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
- /** Get all active sync progress entries */
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
- return new Map(IntegrationEngine._syncProgress);
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
- if (provider)
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 using the existing run record
384
- const result = await this.ExecuteEntityMaps(config, run, contextUser);
385
- result.RunID = runID;
386
- await this.FinalizeRun(run, result, contextUser);
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
- // Mark as failed so it doesn't get picked up again
396
- run.EndedAt = new Date();
397
- run.Status = 'Failed';
398
- run.ErrorLog = JSON.stringify([{ ErrorMessage: `Resume failed: ${errMsg}` }]);
399
- await run.Save();
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
- if (provider)
428
- this._provider = provider;
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
- // Initialize abort controller and progress tracking
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
- IntegrationEngine._abortControllers.set(lockKey, abortController);
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
- const entry = IntegrationEngine._syncProgress.get(lockKey);
469
- if (entry)
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
- const syncPromise = this.executeSyncInternal(companyIntegrationID, contextUser, triggerType, wrappedProgress, onNotification, options, abortController.signal);
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
- IntegrationEngine._abortControllers.delete(lockKey);
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
- const run = await this.CreateRunRecord(config.companyIntegration, triggerType, contextUser, options?.ScheduledJobRunID);
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
- run.ConfigData = JSON.stringify({ triggerType });
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: num(p.maxConcurrency),
1730
+ maxConcurrency: PositiveInt(p.maxConcurrency),
1288
1731
  rateLimitTokensPerSec: typeof p.rateLimitTokensPerSec === 'number' && p.rateLimitTokensPerSec > 0 ? p.rateLimitTokensPerSec : undefined,
1289
- rateLimitBurst: num(p.rateLimitBurst),
1290
- discoveryTimeBudgetMs: num(p.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), DEFAULT_OPERATION_TIMEOUTS.FetchChangesMs, `FetchChanges(${entityMap.ExternalObjectName})`), undefined, (err) => IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => logger?.emit('sync.fetch.retry', {
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
- // If the connector returned more records than MaxBatchSize, log it but never truncate —
1609
- // all records are written, just in sub-batches to keep DB transactions manageable.
1610
- if (batch.Records.length > this.MaxBatchSize) {
1611
- console.log(`[IntegrationEngine] ${entityMap.ExternalObjectName}: connector returned ` +
1612
- `${batch.Records.length} records (> MaxBatchSize ${this.MaxBatchSize}), writing in chunks.`);
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 user/system-cancelled run must NOT be recorded as 'Success' that hides the
3752
- // cancellation in run history (indistinguishable from a clean completion) and is wrong
3753
- // for any downstream cadence/health logic. Until a first-class 'Cancelled' status value
3754
- // exists on CompanyIntegrationRun (Status value list is Pending/In Progress/Success/Failed),
3755
- // finalize an aborted run as 'Failed' with an explicit ErrorLog. The durable progress
3756
- // artifact additionally carries exitReason='aborted' (see finalizeSyncProgress) so a stopped
3757
- // run stays distinguishable from a real failure over GraphQL.
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
- run.Status = 'Failed';
4353
+ terminalStatus = 'Cancelled';
3760
4354
  run.ErrorLog = result.ErrorMessage ?? 'Sync cancelled by user';
3761
4355
  }
3762
4356
  else {
3763
- run.Status = result.RecordsErrored > 0 ? 'Failed' : 'Success';
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._provider,
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).