@memberjunction/integration-engine 6.1.0-edge.3 → 6.1.0-edge.4
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/dist/BaseIntegrationConnector.d.ts +19 -2
- package/dist/BaseIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseIntegrationConnector.js +112 -21
- package/dist/BaseIntegrationConnector.js.map +1 -1
- package/dist/BaseRESTIntegrationConnector.d.ts +14 -1
- package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseRESTIntegrationConnector.js +40 -3
- package/dist/BaseRESTIntegrationConnector.js.map +1 -1
- package/dist/BatchIdentity.d.ts +47 -0
- package/dist/BatchIdentity.d.ts.map +1 -0
- package/dist/BatchIdentity.js +78 -0
- package/dist/BatchIdentity.js.map +1 -0
- package/dist/DiscoveryWatchdog.d.ts +103 -0
- package/dist/DiscoveryWatchdog.d.ts.map +1 -0
- package/dist/DiscoveryWatchdog.js +166 -0
- package/dist/DiscoveryWatchdog.js.map +1 -0
- package/dist/FieldMapValidation.d.ts +40 -0
- package/dist/FieldMapValidation.d.ts.map +1 -0
- package/dist/FieldMapValidation.js +58 -0
- package/dist/FieldMapValidation.js.map +1 -0
- package/dist/FieldMappingEngine.d.ts +1 -13
- package/dist/FieldMappingEngine.d.ts.map +1 -1
- package/dist/FieldMappingEngine.js +18 -3
- package/dist/FieldMappingEngine.js.map +1 -1
- package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -1
- package/dist/IntegrationConnectorCreationPipeline.js +13 -0
- package/dist/IntegrationConnectorCreationPipeline.js.map +1 -1
- package/dist/IntegrationEngine.d.ts +37 -0
- package/dist/IntegrationEngine.d.ts.map +1 -1
- package/dist/IntegrationEngine.js +481 -177
- package/dist/IntegrationEngine.js.map +1 -1
- package/dist/IntegrationSchemaSync.d.ts +8 -0
- package/dist/IntegrationSchemaSync.d.ts.map +1 -1
- package/dist/IntegrationSchemaSync.js +39 -2
- package/dist/IntegrationSchemaSync.js.map +1 -1
- package/dist/KeylessRecordGuard.d.ts +56 -0
- package/dist/KeylessRecordGuard.d.ts.map +1 -0
- package/dist/KeylessRecordGuard.js +80 -0
- package/dist/KeylessRecordGuard.js.map +1 -0
- package/dist/ResumeConcurrency.d.ts +46 -0
- package/dist/ResumeConcurrency.d.ts.map +1 -0
- package/dist/ResumeConcurrency.js +74 -0
- package/dist/ResumeConcurrency.js.map +1 -0
- package/dist/RetryAfter.d.ts +50 -0
- package/dist/RetryAfter.d.ts.map +1 -0
- package/dist/RetryAfter.js +127 -0
- package/dist/RetryAfter.js.map +1 -0
- package/dist/RetryRunner.d.ts +26 -2
- package/dist/RetryRunner.d.ts.map +1 -1
- package/dist/RetryRunner.js +11 -3
- package/dist/RetryRunner.js.map +1 -1
- package/dist/SyncDirectives.d.ts +66 -0
- package/dist/SyncDirectives.d.ts.map +1 -0
- package/dist/SyncDirectives.js +123 -0
- package/dist/SyncDirectives.js.map +1 -0
- package/dist/SyncLogger.d.ts +1 -1
- package/dist/SyncLogger.d.ts.map +1 -1
- package/dist/SyncLogger.js.map +1 -1
- package/dist/WatermarkService.d.ts +8 -0
- package/dist/WatermarkService.d.ts.map +1 -1
- package/dist/WatermarkService.js +18 -0
- package/dist/WatermarkService.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +66 -1
- package/dist/types.js.map +1 -1
- package/package.json +7 -7
|
@@ -5,6 +5,7 @@ import { BaseSingleton, UUIDsEqual } from '@memberjunction/global';
|
|
|
5
5
|
import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
|
|
6
6
|
import { ClassifyError, IsRetryableError } from './types.js';
|
|
7
7
|
import { WithRetry } from './RetryRunner.js';
|
|
8
|
+
import { DecideKeylessRefusal, DescribeKeylessRefusal, MissingKeyFieldNames } from './KeylessRecordGuard.js';
|
|
8
9
|
import { WithTimeout, OperationTimeoutError, DEFAULT_OPERATION_TIMEOUTS } from './BaseIntegrationConnector.js';
|
|
9
10
|
import { ConnectorFactory } from './ConnectorFactory.js';
|
|
10
11
|
import { FieldMappingEngine } from './FieldMappingEngine.js';
|
|
@@ -16,11 +17,15 @@ import { RecordMapBatch } from './RecordMapBatch.js';
|
|
|
16
17
|
import { buildContentHashPrefetchFilter, quoteTextLiteral } from './prefetchFilter.js';
|
|
17
18
|
import { serializeKeyValue } from './KeySerialization.js';
|
|
18
19
|
import { CUSTOM_OVERFLOW_COLUMN, reconcileOverflowValue, foldCustomKeyStats } from './CustomOverflow.js';
|
|
20
|
+
import { ComputeExcludedSourceNames } from './SyncDirectives.js';
|
|
21
|
+
import { DescribeUnbindableFieldMaps, FindUnbindableFieldMaps } from './FieldMapValidation.js';
|
|
19
22
|
import { partitionRecords, partitionRollupHash, diffPartitions, partitionKeyForIdentity } from './HashDiff.js';
|
|
20
23
|
import { RateLimiter } from './RateLimiter.js';
|
|
21
24
|
import { AdaptiveConcurrencyController, RunAdaptive } from './AdaptiveConcurrency.js';
|
|
22
25
|
import { mostRecentWinner } from './ConflictRecency.js';
|
|
23
26
|
import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
|
|
27
|
+
import { CollapseDuplicateIdentities } from './BatchIdentity.js';
|
|
28
|
+
import { ResumeConcurrency, RunResumesBounded } from './ResumeConcurrency.js';
|
|
24
29
|
/** Default batch size for fetching records from external systems */
|
|
25
30
|
const DEFAULT_BATCH_SIZE = 200;
|
|
26
31
|
/**
|
|
@@ -510,171 +515,208 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
510
515
|
return;
|
|
511
516
|
}
|
|
512
517
|
console.log(`[IntegrationEngine] Found ${orphanedRuns.Results.length} orphaned sync(s) to resume`);
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
518
|
+
// CONCURRENTLY, because that is how these runs were STARTED.
|
|
519
|
+
//
|
|
520
|
+
// This loop used to `await` each resume in turn, which quietly converted a parallel
|
|
521
|
+
// workload into a queue ordered by whatever RunView happened to return. The slowest
|
|
522
|
+
// connector became a head-of-line block for every other connector in the workspace — and
|
|
523
|
+
// a connector that never finishes means the others never start at all.
|
|
524
|
+
//
|
|
525
|
+
// Observed live: a restart orphaned three syncs; one resumed and was still going five
|
|
526
|
+
// hours later, and the other two (99,463 and 13,238 rows) never began. Nothing in their
|
|
527
|
+
// logs said so, because nothing had failed — they had simply never been reached. From
|
|
528
|
+
// outside the process a queued run and a crashed one are identical: IsInFlight true,
|
|
529
|
+
// CompletedAt null, counters frozen at the instant of the restart. The absence of an
|
|
530
|
+
// error is the only tell.
|
|
531
|
+
//
|
|
532
|
+
// Note what is NOT being parallelised. The write section stays serialized by
|
|
533
|
+
// `runWriteExclusive`, because all maps share one provider connection with singular
|
|
534
|
+
// transaction state; that is deliberate and unchanged. Per-CompanyIntegration exclusion
|
|
535
|
+
// stays too, via the `activeSyncs` lock each resume takes. What overlaps here is what
|
|
536
|
+
// overlapped before the restart: different connectors waiting on different sources.
|
|
537
|
+
//
|
|
538
|
+
// Bounded rather than unbounded: a workspace is one Node process, so concurrency buys
|
|
539
|
+
// overlap on network waiting and not more CPU, and a boot that adopted fifty runs at once
|
|
540
|
+
// would trade one pathology for another.
|
|
541
|
+
await RunResumesBounded(orphanedRuns.Results, ResumeConcurrency(), run => this.ResumeOneOrphanedRun(run, prov, rv, contextUser));
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Resume ONE orphaned run, end to end: reserve the per-CompanyIntegration lock, claim the run,
|
|
545
|
+
* work out which entity maps already finished, and execute the rest under a fresh run context.
|
|
546
|
+
*
|
|
547
|
+
* Extracted from {@link ResumeOrphanedSyncs}'s loop so several runs can be in flight at once.
|
|
548
|
+
* NEVER THROWS — every failure path is handled here and recorded on the run row. A resume that
|
|
549
|
+
* threw out of this method would take a pool slot with it and, worse, could abandon the runs
|
|
550
|
+
* queued behind it, which is the exact failure this parallelisation exists to remove.
|
|
551
|
+
*
|
|
552
|
+
* The check-and-reserve on `activeSyncs` still has no `await` in front of it, so it stays
|
|
553
|
+
* atomic with several of these in flight: an async function runs synchronously up to its first
|
|
554
|
+
* await, and the pool always starts one from a synchronous call site.
|
|
555
|
+
*/
|
|
556
|
+
async ResumeOneOrphanedRun(run, prov, rv, contextUser) {
|
|
557
|
+
const companyIntegrationID = run.CompanyIntegrationID;
|
|
558
|
+
const runID = run.ID;
|
|
559
|
+
const lockKey = companyIntegrationID.toLowerCase();
|
|
560
|
+
// C1: respect the SAME in-process concurrency lock RunSync uses. If a live sync for this
|
|
561
|
+
// CompanyIntegration is already running (e.g. the scheduler fired during startup), skip the
|
|
562
|
+
// resume — double-running one CI on the shared provider connection corrupts its singular
|
|
563
|
+
// transaction state (exactly what runWriteExclusive guards against WITHIN a run). The
|
|
564
|
+
// get→set pair below has no await between them, so check-and-reserve is atomic on the loop.
|
|
565
|
+
if (IntegrationEngine.activeSyncs.get(lockKey)) {
|
|
566
|
+
console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — a live sync for ${lockKey} is already running`);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
let resolveResumeLock;
|
|
570
|
+
let resumeResult;
|
|
571
|
+
IntegrationEngine.activeSyncs.set(lockKey, new Promise(res => { resolveResumeLock = res; }));
|
|
572
|
+
const ownership = new RunOwnershipService(prov, runID, undefined, contextUser);
|
|
573
|
+
try {
|
|
574
|
+
// CLAIM BEFORE ADOPTING (PR 1 item 6): a single atomic UPDATE that succeeds only if the
|
|
575
|
+
// run is still unowned/lapsed. Zero rows = another worker adopted it between our RunView
|
|
576
|
+
// and now — skip, never double-run. A successful claim BUMPS the fence, so if the
|
|
577
|
+
// original owner is actually alive-but-slow it aborts at its next boundary check
|
|
578
|
+
// without writing: the sweep-reclaim is itself the abort signal for the abandoned owner.
|
|
579
|
+
const claimed = await ownership.Claim();
|
|
580
|
+
if (!claimed) {
|
|
581
|
+
console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — claim lost (another worker adopted it)`);
|
|
582
|
+
return;
|
|
525
583
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
EntityName: 'MJ: Company Integration Run Details',
|
|
549
|
-
ExtraFilter: `CompanyIntegrationRunID='${runID}'`,
|
|
550
|
-
Fields: ['RecordID', 'IsSuccess'],
|
|
551
|
-
ResultType: 'simple',
|
|
552
|
-
}, contextUser);
|
|
553
|
-
const completedMapIDs = new Set();
|
|
554
|
-
if (detailsResult.Success) {
|
|
555
|
-
for (const d of detailsResult.Results) {
|
|
556
|
-
if (!d.IsSuccess)
|
|
557
|
-
continue; // completed-with-errors → re-attempt on resume
|
|
558
|
-
const m = /^EntityMap:([0-9a-fA-F-]+)\|/.exec(d.RecordID ?? '');
|
|
559
|
-
// Parse-miss falls open (map treated as not-completed → re-runs): at worst a
|
|
560
|
-
// redundant idempotent re-sync, never a silent skip.
|
|
561
|
-
if (m)
|
|
562
|
-
completedMapIDs.add(m[1].toLowerCase());
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
|
|
566
|
-
`for ${companyIntegrationID.substring(0, 8)}... ` +
|
|
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
|
-
}
|
|
587
|
-
// Load config and filter to only remaining entity maps (by map ID)
|
|
588
|
-
const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, resumeOptions);
|
|
589
|
-
const remainingMaps = config.entityMaps.filter(em => !completedMapIDs.has(em.ID.toLowerCase()));
|
|
590
|
-
if (remainingMaps.length === 0) {
|
|
591
|
-
console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
|
|
592
|
-
run.EndedAt = new Date();
|
|
593
|
-
run.Status = 'Success';
|
|
594
|
-
ownership.SyncEntityOwnershipFields(run); // full-row save must not clobber the live claim
|
|
595
|
-
await run.Save();
|
|
596
|
-
await ownership.Release('Success');
|
|
597
|
-
continue;
|
|
584
|
+
// Find which entity MAPS already completed SUCCESSFULLY in this run. We correlate
|
|
585
|
+
// by EntityMapID (parsed from the detail's RecordID, stamped by CreateRunDetail),
|
|
586
|
+
// not EntityID — two maps can target the same MJ Entity, so keying on EntityID
|
|
587
|
+
// could skip a still-pending sibling map. We also require IsSuccess=1: a map that
|
|
588
|
+
// completed WITH errors (RecordsErrored>0, no throw) must be re-attempted on resume,
|
|
589
|
+
// otherwise its errored records are silently abandoned.
|
|
590
|
+
const detailsResult = await rv.RunView({
|
|
591
|
+
EntityName: 'MJ: Company Integration Run Details',
|
|
592
|
+
ExtraFilter: `CompanyIntegrationRunID='${runID}'`,
|
|
593
|
+
Fields: ['RecordID', 'IsSuccess'],
|
|
594
|
+
ResultType: 'simple',
|
|
595
|
+
}, contextUser);
|
|
596
|
+
const completedMapIDs = new Set();
|
|
597
|
+
if (detailsResult.Success) {
|
|
598
|
+
for (const d of detailsResult.Results) {
|
|
599
|
+
if (!d.IsSuccess)
|
|
600
|
+
continue; // completed-with-errors → re-attempt on resume
|
|
601
|
+
const m = /^EntityMap:([0-9a-fA-F-]+)\|/.exec(d.RecordID ?? '');
|
|
602
|
+
// Parse-miss falls open (map treated as not-completed → re-runs): at worst a
|
|
603
|
+
// redundant idempotent re-sync, never a silent skip.
|
|
604
|
+
if (m)
|
|
605
|
+
completedMapIDs.add(m[1].toLowerCase());
|
|
598
606
|
}
|
|
599
|
-
console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
|
|
600
|
-
// Replace entityMaps with only the remaining ones
|
|
601
|
-
config.entityMaps = remainingMaps;
|
|
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
|
-
});
|
|
640
|
-
resumeResult = result;
|
|
641
|
-
console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
|
|
642
|
-
`${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
|
|
643
|
-
`${result.RecordsErrored} errored`);
|
|
644
607
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
try {
|
|
661
|
-
await ownership.Release('Failed');
|
|
662
|
-
}
|
|
663
|
-
catch { /* lease will simply expire */ }
|
|
664
|
-
}
|
|
608
|
+
console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
|
|
609
|
+
`for ${companyIntegrationID.substring(0, 8)}... ` +
|
|
610
|
+
`(${completedMapIDs.size} entity maps already completed)`);
|
|
611
|
+
// Recover what this run was ASKED to do. Without this the resume rebuilds config from
|
|
612
|
+
// the CompanyIntegration alone, so an adopted run silently loses its options — most
|
|
613
|
+
// damagingly FullSync, which exists precisely to distrust the watermark. An adopted
|
|
614
|
+
// full sync would resume incrementally, fetch nothing, and report Success.
|
|
615
|
+
// Unparseable/absent ConfigData falls back to defaults rather than refusing to resume.
|
|
616
|
+
let resumeOptions;
|
|
617
|
+
let resumeTriggerType = 'Scheduled';
|
|
618
|
+
try {
|
|
619
|
+
const cfg = JSON.parse(run.ConfigData ?? '{}');
|
|
620
|
+
resumeOptions = cfg.options ?? undefined;
|
|
621
|
+
if (cfg.triggerType)
|
|
622
|
+
resumeTriggerType = cfg.triggerType;
|
|
665
623
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
IntegrationEngine.activeSyncs.delete(lockKey);
|
|
672
|
-
resolveResumeLock(resumeResult ?? {
|
|
673
|
-
Success: false, ErrorMessage: 'Resume produced no result', RecordsProcessed: 0,
|
|
674
|
-
RecordsCreated: 0, RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0,
|
|
675
|
-
RecordsSkipped: 0, Errors: [], EntityMapResults: [], Duration: 0,
|
|
676
|
-
});
|
|
624
|
+
catch {
|
|
625
|
+
console.warn(`[IntegrationEngine] Run ${runID.substring(0, 8)} has unparseable ConfigData; resuming with defaults`);
|
|
626
|
+
}
|
|
627
|
+
if (resumeOptions?.FullSync) {
|
|
628
|
+
console.log(`[IntegrationEngine] Run ${runID.substring(0, 8)} was a FULL sync — resuming as full, not incremental`);
|
|
677
629
|
}
|
|
630
|
+
// Load config and filter to only remaining entity maps (by map ID)
|
|
631
|
+
const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, resumeOptions);
|
|
632
|
+
const remainingMaps = config.entityMaps.filter(em => !completedMapIDs.has(em.ID.toLowerCase()));
|
|
633
|
+
if (remainingMaps.length === 0) {
|
|
634
|
+
console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
|
|
635
|
+
run.EndedAt = new Date();
|
|
636
|
+
run.Status = 'Success';
|
|
637
|
+
ownership.SyncEntityOwnershipFields(run); // full-row save must not clobber the live claim
|
|
638
|
+
await run.Save();
|
|
639
|
+
await ownership.Release('Success');
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
|
|
643
|
+
// Replace entityMaps with only the remaining ones
|
|
644
|
+
config.entityMaps = remainingMaps;
|
|
645
|
+
// Execute remaining maps inside a per-run context: the resume gets its own provider
|
|
646
|
+
// binding, abort controller, and ownership — identical to a fresh RunSync — so the
|
|
647
|
+
// heartbeat renews the lease, the batch boundaries fence-check, and FinalizeRun
|
|
648
|
+
// syncs ownership fields + releases, all through the SAME code paths.
|
|
649
|
+
const abortController = new AbortController();
|
|
650
|
+
const progressSnapshot = {
|
|
651
|
+
StartedAt: new Date(),
|
|
652
|
+
CurrentEntity: '',
|
|
653
|
+
EntityMapsTotal: remainingMaps.length,
|
|
654
|
+
EntityMapsCompleted: 0,
|
|
655
|
+
RecordsProcessed: 0,
|
|
656
|
+
RecordsCreated: 0,
|
|
657
|
+
RecordsUpdated: 0,
|
|
658
|
+
RecordsErrored: 0,
|
|
659
|
+
// The run's OWN trigger type, recovered above — not a hardcoded 'Scheduled'. This is
|
|
660
|
+
// what IntegrationGetSyncProgress reports back ("Sync in progress (Manual)"), so a
|
|
661
|
+
// hardcoded value mislabels every adopted run.
|
|
662
|
+
TriggerType: resumeTriggerType,
|
|
663
|
+
};
|
|
664
|
+
const runCtx = {
|
|
665
|
+
provider: prov,
|
|
666
|
+
ownership,
|
|
667
|
+
abortController,
|
|
668
|
+
progressSnapshot,
|
|
669
|
+
cancelRequested: false,
|
|
670
|
+
ownershipLost: false,
|
|
671
|
+
};
|
|
672
|
+
ownership.StartHeartbeat({
|
|
673
|
+
onLost: () => { runCtx.ownershipLost = true; abortController.abort(); },
|
|
674
|
+
onCancelRequested: () => { runCtx.cancelRequested = true; abortController.abort(); },
|
|
675
|
+
progressSupplier: () => JSON.stringify(progressSnapshot),
|
|
676
|
+
});
|
|
677
|
+
const result = await IntegrationEngine.runContext.run(runCtx, async () => {
|
|
678
|
+
const r = await this.ExecuteEntityMaps(config, run, contextUser, undefined, abortController.signal);
|
|
679
|
+
r.RunID = runID;
|
|
680
|
+
await this.FinalizeRun(run, r, contextUser);
|
|
681
|
+
return r;
|
|
682
|
+
});
|
|
683
|
+
resumeResult = result;
|
|
684
|
+
console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
|
|
685
|
+
`${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
|
|
686
|
+
`${result.RecordsErrored} errored`);
|
|
687
|
+
}
|
|
688
|
+
catch (err) {
|
|
689
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
690
|
+
console.error(`[IntegrationEngine] Failed to resume run ${runID.substring(0, 8)}: ${errMsg}`);
|
|
691
|
+
if (err instanceof RunOwnershipLostError) {
|
|
692
|
+
// We were fenced out mid-resume — the NEW owner now owns the run row.
|
|
693
|
+
// Writing 'Failed' here would clobber the live holder's state.
|
|
694
|
+
console.warn(`[IntegrationEngine] Resume of run ${runID.substring(0, 8)} lost ownership — leaving the run row to its new owner`);
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
// Mark as failed so it doesn't get picked up again
|
|
698
|
+
run.EndedAt = new Date();
|
|
699
|
+
run.Status = 'Failed';
|
|
700
|
+
run.ErrorLog = JSON.stringify([{ ErrorMessage: `Resume failed: ${errMsg}` }]);
|
|
701
|
+
ownership.SyncEntityOwnershipFields(run);
|
|
702
|
+
await run.Save();
|
|
703
|
+
try {
|
|
704
|
+
await ownership.Release('Failed');
|
|
705
|
+
}
|
|
706
|
+
catch { /* lease will simply expire */ }
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
finally {
|
|
710
|
+
ownership.StopHeartbeat();
|
|
711
|
+
// Release the C1 lock + unblock any RunSync that began awaiting this resume (RunSync returns
|
|
712
|
+
// `existing`). Resolve with the real result when we have one, else a benign empty result so no
|
|
713
|
+
// waiter hangs. Promise resolve is idempotent and the early-exit `return`s also land here.
|
|
714
|
+
IntegrationEngine.activeSyncs.delete(lockKey);
|
|
715
|
+
resolveResumeLock(resumeResult ?? {
|
|
716
|
+
Success: false, ErrorMessage: 'Resume produced no result', RecordsProcessed: 0,
|
|
717
|
+
RecordsCreated: 0, RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0,
|
|
718
|
+
RecordsSkipped: 0, Errors: [], EntityMapResults: [], Duration: 0,
|
|
719
|
+
});
|
|
678
720
|
}
|
|
679
721
|
}
|
|
680
722
|
/**
|
|
@@ -861,6 +903,27 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
861
903
|
entityMapIDsFilter: options?.EntityMapIDs ?? null,
|
|
862
904
|
syncDirectionOverride: options?.SyncDirection ?? null,
|
|
863
905
|
});
|
|
906
|
+
/**
|
|
907
|
+
* Re-read the IO/IOF catalog before the run starts (also covers resumed runs — both paths land here).
|
|
908
|
+
*
|
|
909
|
+
* Everything else a run reads is fresh per run (LoadRunConfiguration and LoadFieldMaps
|
|
910
|
+
* read CI/entity maps/field maps with BypassCache) — but IntegrationObject and
|
|
911
|
+
* IntegrationObjectField are served from this engine's BaseEngine arrays, loaded at
|
|
912
|
+
* process start and auto-refreshed only by IN-PROCESS BaseEntity saves. A catalog edit
|
|
913
|
+
* made by direct SQL, a sproc-based sync push, or another process is therefore invisible
|
|
914
|
+
* to syncs until the host restarts — an AccessPath or field-type correction keeps being
|
|
915
|
+
* ignored run after run with nothing in the log to say why. The apply/evolution
|
|
916
|
+
* resolvers already do an invalidate+reload for exactly this reason; the sync path
|
|
917
|
+
* never got the same treatment.
|
|
918
|
+
*
|
|
919
|
+
* Only the two catalog arrays are refreshed — not Config(true), which reloads all eight
|
|
920
|
+
* datasets unfiltered on every run. The loader is called directly with bypassCache=true
|
|
921
|
+
* because RefreshItem defaults to the local cache — which is the very thing that is
|
|
922
|
+
* stale. Replacing the arrays is also what invalidates this PR's memoised views: both
|
|
923
|
+
* the per-object field index and the per-record GetCachedFields memo key on ARRAY
|
|
924
|
+
* IDENTITY, so they rebuild lazily on first read after the swap.
|
|
925
|
+
*/
|
|
926
|
+
await IntegrationEngineBase.Instance.RefreshCatalog(contextUser);
|
|
864
927
|
const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, options);
|
|
865
928
|
logger.attachIntegrationName(config.companyIntegration.Integration);
|
|
866
929
|
logger.emit('sync.config.loaded', {
|
|
@@ -1817,6 +1880,16 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1817
1880
|
async ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger) {
|
|
1818
1881
|
const entityMapID = entityMap.ID;
|
|
1819
1882
|
const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
|
|
1883
|
+
// Field-level exclusions declared by the connector (SourceFieldInfo.SyncDirective
|
|
1884
|
+
// -> IntegrationObjectField.Configuration). Resolved once per map, applied to every
|
|
1885
|
+
// batch below. Empty set on any lookup miss - exclusion can only ever narrow.
|
|
1886
|
+
const excludedSourceNames = this.ResolveExcludedSourceNames(config.companyIntegration.IntegrationID, entityMap.ExternalObjectName);
|
|
1887
|
+
if (excludedSourceNames.size > 0) {
|
|
1888
|
+
logger?.emit('sync.entity-map.exclusions', {
|
|
1889
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
1890
|
+
excludedFields: Array.from(excludedSourceNames).sort(),
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1820
1893
|
const watermark = await this.runWriteExclusive(() => this.watermarkService.Load(entityMapID, contextUser, 'Pull'));
|
|
1821
1894
|
logger?.emit('sync.entity-map.start', {
|
|
1822
1895
|
phase: 'pull-detail',
|
|
@@ -1832,8 +1905,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1832
1905
|
watermarkType: watermark?.WatermarkType ?? null,
|
|
1833
1906
|
fullSync: config.fullSync,
|
|
1834
1907
|
});
|
|
1908
|
+
this.WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger);
|
|
1835
1909
|
// A6: Validate watermark before using it — skip entirely when FullSync requested
|
|
1836
1910
|
let initialWatermark = config.fullSync ? null : (watermark?.WatermarkValue ?? null);
|
|
1911
|
+
// The value the ROW held before this run touched it — the retract target if a mid-run
|
|
1912
|
+
// durability floor (§8a below) has to be undone after a page-skip gap. Distinct from
|
|
1913
|
+
// initialWatermark, which a fullSync nulls even though the row still holds a real value.
|
|
1914
|
+
const preRunWatermarkValue = watermark?.WatermarkValue ?? null;
|
|
1837
1915
|
if (initialWatermark && watermark) {
|
|
1838
1916
|
const watermarkType = (watermark.WatermarkType ?? 'Timestamp');
|
|
1839
1917
|
if (!this.watermarkService.ValidateWatermark(initialWatermark, watermarkType)) {
|
|
@@ -1917,6 +1995,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1917
1995
|
let previousBatchFingerprint;
|
|
1918
1996
|
let fetchCompletedCleanly = true; // flipped to false if fetch aborted or errored mid-way
|
|
1919
1997
|
let hadFetchGap = false; // ≥1 page was skipped after a persistent fetch error (offset/page paging)
|
|
1998
|
+
let watermarkFloorSaved = null; // §8a durability floor last persisted mid-run (null = none)
|
|
1920
1999
|
let fetchGapCount = 0; // CONSECUTIVE skipped pages (reset on any clean fetch)
|
|
1921
2000
|
const MAX_FETCH_GAPS = 25; // give up + hold the watermark if this many pages fail in a row (API down)
|
|
1922
2001
|
let consecutiveEmptyBatches = 0; // P3-D: detect a connector that pages empty-but-HasMore forever
|
|
@@ -1984,6 +2063,16 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1984
2063
|
});
|
|
1985
2064
|
let batch;
|
|
1986
2065
|
const fetchStart = Date.now();
|
|
2066
|
+
// ONE multiplicative decrease per throttle EPISODE, not one per rejected attempt.
|
|
2067
|
+
//
|
|
2068
|
+
// A 429 that survives three retries is three rejections but one congestion event — the
|
|
2069
|
+
// same distinction TCP draws when it halves the window once per loss event rather than
|
|
2070
|
+
// once per lost segment. Decreasing on each attempt compounds: at a 0.5 backoff factor
|
|
2071
|
+
// three attempts take the rate to an eighth, five take it to a thirtieth, so a single
|
|
2072
|
+
// throttled fetch could drive a connector to its floor purely as a function of how
|
|
2073
|
+
// generous its retry budget is. The freeze already covers the interval the source asked
|
|
2074
|
+
// for; the decrease is about the rate AFTER that, and one signal deserves one step.
|
|
2075
|
+
let throttleReported = false;
|
|
1987
2076
|
try {
|
|
1988
2077
|
await this.rateLimit(config);
|
|
1989
2078
|
// Resilient fetch: bound each attempt with a timeout (a hung vendor API must not
|
|
@@ -2003,13 +2092,40 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2003
2092
|
// `econnreset` in with timeouts under `NETWORK_TIMEOUT`, and a reset socket IS worth
|
|
2004
2093
|
// retrying — so excluding the whole code would lose real resilience. Only the error
|
|
2005
2094
|
// WithTimeout itself minted is excluded.
|
|
2006
|
-
(err) => !(err instanceof OperationTimeoutError) && IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) =>
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2095
|
+
(err) => !(err instanceof OperationTimeoutError) && IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => {
|
|
2096
|
+
// Report a throttle NOW, not after the retries are spent. ReportThrottle
|
|
2097
|
+
// freezes the shared bucket for this CompanyIntegration, so every other
|
|
2098
|
+
// object fetching concurrently backs off too — reporting it only in the
|
|
2099
|
+
// catch below meant the rest of the connector kept hammering a source that
|
|
2100
|
+
// had already said stop.
|
|
2101
|
+
//
|
|
2102
|
+
// Once per episode: see `throttleReported` above. Later attempts still get
|
|
2103
|
+
// their own Retry-After honoured via DelayForError, which is what actually
|
|
2104
|
+
// paces this loop; what they must not do is halve the rate again.
|
|
2105
|
+
if (!throttleReported && ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED') {
|
|
2106
|
+
throttleReported = true;
|
|
2107
|
+
this.reportRateOutcome(config, err);
|
|
2108
|
+
}
|
|
2109
|
+
logger?.emit('sync.fetch.retry', {
|
|
2110
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
2111
|
+
batchIndex: batchCount,
|
|
2112
|
+
attempt,
|
|
2113
|
+
delayMs,
|
|
2114
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2115
|
+
});
|
|
2116
|
+
}, {
|
|
2117
|
+
// Honour the source's own instruction. A 429 usually carries Retry-After;
|
|
2118
|
+
// blind exponential backoff ignored it and retried early, which is how a
|
|
2119
|
+
// soft throttle becomes a hard one. Falls back to backoff when the
|
|
2120
|
+
// connector cannot parse one.
|
|
2121
|
+
DelayForError: (err) => ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED'
|
|
2122
|
+
? config.connector.ExtractRetryAfterMs(err)
|
|
2123
|
+
: undefined,
|
|
2124
|
+
// A retry must pass through the same gate the first attempt did. The token
|
|
2125
|
+
// was acquired once before WithRetry, so retries previously bypassed the
|
|
2126
|
+
// limiter entirely — including the freeze the line above just applied.
|
|
2127
|
+
BeforeRetry: () => this.rateLimit(config),
|
|
2128
|
+
});
|
|
2013
2129
|
this.reportRateOutcome(config); // clean fetch → ramp the adaptive rate back up
|
|
2014
2130
|
fetchGapCount = 0; // clean fetch → reset the consecutive fetch-gap counter
|
|
2015
2131
|
// §10: connector type-driven post-processing hook (default no-op) — enforce/normalize
|
|
@@ -2024,7 +2140,10 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2024
2140
|
// other errors don't touch the rate. §5 Gap 2: also flag the map result so the per-layer
|
|
2025
2141
|
// AIMD controller reduces in-flight concurrency, not just the per-request token bucket.
|
|
2026
2142
|
if (ClassifyError(fetchErr).Code === 'RATE_LIMIT_EXCEEDED') {
|
|
2027
|
-
|
|
2143
|
+
// Only if the retry hook did not already do it — a fetch that was retried has
|
|
2144
|
+
// already had its one decrease applied, at the first sign rather than here.
|
|
2145
|
+
if (!throttleReported)
|
|
2146
|
+
this.reportRateOutcome(config, fetchErr);
|
|
2028
2147
|
result.Throttled = true;
|
|
2029
2148
|
}
|
|
2030
2149
|
console.error(`[IntegrationEngine] FetchChanges error for ${entityMap.ExternalObjectName}: ${errMsg}`);
|
|
@@ -2044,6 +2163,15 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2044
2163
|
fetchGapCount++;
|
|
2045
2164
|
hadFetchGap = true;
|
|
2046
2165
|
fetchCompletedCleanly = false;
|
|
2166
|
+
// A durability floor written before this gap may sit PAST the hole (the skipped
|
|
2167
|
+
// page can hold records behind the max watermark seen). Put the row back to what
|
|
2168
|
+
// it held before this run, exactly what the post-loop hold does for the in-memory
|
|
2169
|
+
// value — a crash from here on resumes from the pre-run watermark and re-covers
|
|
2170
|
+
// the gap. Later checkpoints stop writing floors (gate above).
|
|
2171
|
+
if (watermarkFloorSaved !== null) {
|
|
2172
|
+
await this.runWriteExclusive(() => this.watermarkService.RestoreValue(entityMapID, preRunWatermarkValue, contextUser));
|
|
2173
|
+
watermarkFloorSaved = null;
|
|
2174
|
+
}
|
|
2047
2175
|
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_PAGE_SKIPPED', `Persistent fetch error at ${currentOffset != null ? 'offset ' + currentOffset : 'page ' + currentPage} for ` +
|
|
2048
2176
|
`'${entityMap.ExternalObjectName}' (batch ${batchCount}); skipped this page and continued — the ` +
|
|
2049
2177
|
`watermark is held so the window is re-fetched next run. Error: ${errMsg}`, { offset: currentOffset ?? null, page: currentPage ?? null, batchIndex: batchCount, error: errMsg });
|
|
@@ -2133,7 +2261,20 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2133
2261
|
fetchedExternalIDs.clear();
|
|
2134
2262
|
}
|
|
2135
2263
|
}
|
|
2136
|
-
|
|
2264
|
+
// Within-batch identity, enforced before mapping: two records sharing an ExternalID are
|
|
2265
|
+
// two observations of ONE source record. The write path cannot catch this — it decides
|
|
2266
|
+
// insert-vs-update against the DATABASE, where a first-time identity is absent for both
|
|
2267
|
+
// copies, so both insert and the pair re-inserts every sync. The fingerprint guard above
|
|
2268
|
+
// only sees a batch repeated in FULL. Never silent: a connector emitting duplicate
|
|
2269
|
+
// identities is a defect worth fixing at its source.
|
|
2270
|
+
const identity = CollapseDuplicateIdentities(batch.Records);
|
|
2271
|
+
if (identity.Collapsed > 0) {
|
|
2272
|
+
logger?.warning(entityMap.ExternalObjectName ?? 'sync', 'DUPLICATE_IDENTITIES_IN_BATCH', `${entityMap.ExternalObjectName}: ${identity.Collapsed} record(s) repeated an ExternalID already `
|
|
2273
|
+
+ `present in the same batch and were collapsed (last occurrence kept). Two records sharing an `
|
|
2274
|
+
+ `identity are one source record observed twice; writing both would insert duplicate rows that `
|
|
2275
|
+
+ `no later sync could reconcile. Sample: ${identity.SampleIDs.join(', ')}`, { object: entityMap.ExternalObjectName, collapsed: identity.Collapsed, sample: identity.SampleIDs });
|
|
2276
|
+
}
|
|
2277
|
+
const mapped = this.fieldMappingEngine.Apply(identity.Records, fieldMaps, entityMap.Entity, excludedSourceNames);
|
|
2137
2278
|
// Custom-key stats: aggregate unmapped keys for EVERY mapped record here —
|
|
2138
2279
|
// before any skip decision — so candidates + sizing stats exist even when the
|
|
2139
2280
|
// content-hash fast path skips the row (the hash basis deliberately excludes them).
|
|
@@ -2238,6 +2379,25 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2238
2379
|
if (isKeysetConnector && currentAfterKey) {
|
|
2239
2380
|
await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
|
|
2240
2381
|
}
|
|
2382
|
+
// The WATERMARK twin of the keyset floor above. Without it, a watermark-based
|
|
2383
|
+
// connector had NO durable position at all until the run ended: a SIGKILL / OOM /
|
|
2384
|
+
// container recycle mid-object threw away hours of applied batches and the next run
|
|
2385
|
+
// re-fetched the entire window from the last completed run's watermark. Same safety
|
|
2386
|
+
// argument as the graceful early-exit save below — currentWatermark only ever
|
|
2387
|
+
// advances at the END of a fully-applied batch, so this floor can never point past a
|
|
2388
|
+
// record that was not written. Gated on !hadFetchGap because a skipped page may
|
|
2389
|
+
// contain records BEHIND the max watermark seen (fetch order is not watermark order
|
|
2390
|
+
// on every source), i.e. a hole behind the floor; once a gap exists the floor stops
|
|
2391
|
+
// moving, and the one already written is retracted at the gap site. Skipped for
|
|
2392
|
+
// keyset connectors (their position IS the seek key above) and partition-reconcile
|
|
2393
|
+
// maps (their watermark row stores the rollup snapshot, not a timestamp).
|
|
2394
|
+
if (!isKeysetConnector && !partitionReconcile && !hadFetchGap
|
|
2395
|
+
&& currentWatermark && currentWatermark !== initialWatermark
|
|
2396
|
+
&& currentWatermark !== watermarkFloorSaved) {
|
|
2397
|
+
const floor = currentWatermark;
|
|
2398
|
+
await this.runWriteExclusive(() => this.watermarkService.Update(entityMapID, floor, contextUser, 'Pull'));
|
|
2399
|
+
watermarkFloorSaved = floor;
|
|
2400
|
+
}
|
|
2241
2401
|
}
|
|
2242
2402
|
// P3-D: a connector returning empty pages with HasMore=true would otherwise spin silently
|
|
2243
2403
|
// to MAX_BATCHES_PER_MAP. Surface a structured warning once the empty streak crosses the
|
|
@@ -2443,6 +2603,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2443
2603
|
async ProcessPushSync(config, entityMap, run, contextUser, _entityMapIndex, _totalEntityMaps, _onProgress, _abortSignal, logger) {
|
|
2444
2604
|
const entityMapID = entityMap.ID;
|
|
2445
2605
|
const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
|
|
2606
|
+
this.WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger);
|
|
2446
2607
|
const pushWatermark = await this.watermarkService.Load(entityMapID, contextUser, 'Push');
|
|
2447
2608
|
const lastPushAt = pushWatermark?.WatermarkValue ?? null;
|
|
2448
2609
|
// Check connector write capability
|
|
@@ -2949,12 +3110,25 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2949
3110
|
const orphans = allMaps.Rows.filter(m => !fetchedExternalIDs.has(m.ExternalSystemRecordID));
|
|
2950
3111
|
if (orphans.length === 0)
|
|
2951
3112
|
return;
|
|
3113
|
+
// The sweep is a DELETE PATH and must answer to the same policy as every other delete.
|
|
3114
|
+
// It used to call entity.Delete() unconditionally — its own warning text promised
|
|
3115
|
+
// "archived/deleted" while the code only ever deleted, so a map configured SoftDelete
|
|
3116
|
+
// (or DoNothing) had its rows physically removed by full syncs. DoNothing short-circuits
|
|
3117
|
+
// the whole sweep: the policy says external deletions never touch MJ rows, and saying so
|
|
3118
|
+
// once beats detecting the same "orphans" forever.
|
|
3119
|
+
if (entityMap.DeleteBehavior === 'DoNothing') {
|
|
3120
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_POLICY_SKIPPED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this ` +
|
|
3121
|
+
`full sync. This map's DeleteBehavior is 'DoNothing', so none were touched.`, { orphanCount: orphans.length });
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
2952
3124
|
console.log(`[IntegrationEngine] Orphan detection for ${entityMap.ExternalObjectName}: ${orphans.length} records in MJ not found in external system`);
|
|
2953
3125
|
// Surface delete-detection in the structured stream (previously console-only). The orphan
|
|
2954
3126
|
// COUNT is already in the run counts via RecordsDeleted, but a dedicated warning makes a
|
|
2955
3127
|
// large/unexpected count visible over GraphQL — the early signal of an incomplete upstream
|
|
2956
3128
|
// fetch silently archiving live records.
|
|
2957
|
-
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_DETECTED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this full sync —
|
|
3129
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_DETECTED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this full sync — ` +
|
|
3130
|
+
`they will be ${entityMap.DeleteBehavior === 'SoftDelete' ? 'archived (SoftDelete)' : 'deleted'} and their record-map rows pruned (delete-detection). ` +
|
|
3131
|
+
`A large or unexpected count can indicate an incomplete upstream fetch, so review before trusting the deletions.`, { orphanCount: orphans.length });
|
|
2958
3132
|
const md = this.ProviderToUse;
|
|
2959
3133
|
const entityInfo = md.EntityByName(entityMap.Entity);
|
|
2960
3134
|
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
@@ -2964,11 +3138,44 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2964
3138
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(orphan.EntityRecordID, pkFields));
|
|
2965
3139
|
if (!loaded) {
|
|
2966
3140
|
console.log(`[IntegrationEngine] Orphan ${orphan.EntityRecordID} already deleted from MJ`);
|
|
3141
|
+
// The map row outlived its record. Nothing anywhere else deletes record-map
|
|
3142
|
+
// rows, so without this the same "orphan" is re-detected on EVERY subsequent
|
|
3143
|
+
// full sync and ORPHANS_DETECTED becomes a cumulative counter of history
|
|
3144
|
+
// rather than a signal about THIS run — observed live as a count that only
|
|
3145
|
+
// ever grew, sync after sync.
|
|
3146
|
+
await this.DeleteRecordMapRow(orphan.ID, contextUser);
|
|
3147
|
+
continue;
|
|
3148
|
+
}
|
|
3149
|
+
if (entityMap.DeleteBehavior === 'SoftDelete') {
|
|
3150
|
+
// Same archive shape as DeleteRecord's SoftDelete branch: the row stays,
|
|
3151
|
+
// marked Archived/tombstoned. The MAP row goes either way — the mapping's
|
|
3152
|
+
// job is done, and keeping it would re-detect this orphan forever.
|
|
3153
|
+
const fields = entity.Fields ?? [];
|
|
3154
|
+
const hasField = (n) => fields.some(f => f.Name === n);
|
|
3155
|
+
if (hasField('__mj_integration_SyncStatus'))
|
|
3156
|
+
entity.Set('__mj_integration_SyncStatus', 'Archived');
|
|
3157
|
+
if (hasField('__mj_integration_LastSyncedAt'))
|
|
3158
|
+
entity.Set('__mj_integration_LastSyncedAt', new Date().toISOString());
|
|
3159
|
+
if (hasField('__mj_integration_IsTombstoned'))
|
|
3160
|
+
entity.Set('__mj_integration_IsTombstoned', true);
|
|
3161
|
+
if (hasField('__mj_integration_DeletedDetectedAt'))
|
|
3162
|
+
entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
|
|
3163
|
+
const archived = await entity.Save();
|
|
3164
|
+
if (archived) {
|
|
3165
|
+
result.RecordsDeleted++;
|
|
3166
|
+
await this.DeleteRecordMapRow(orphan.ID, contextUser);
|
|
3167
|
+
console.log(`[IntegrationEngine] Archived orphan ${entityMap.Entity} ${orphan.EntityRecordID} (external ${orphan.ExternalSystemRecordID} no longer exists)`);
|
|
3168
|
+
}
|
|
3169
|
+
else {
|
|
3170
|
+
const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
|
|
3171
|
+
console.warn(`[IntegrationEngine] Orphan archive blocked for ${entityMap.Entity} ${orphan.EntityRecordID} — ${reason}`);
|
|
3172
|
+
}
|
|
2967
3173
|
continue;
|
|
2968
3174
|
}
|
|
2969
3175
|
const deleted = await entity.Delete();
|
|
2970
3176
|
if (deleted) {
|
|
2971
3177
|
result.RecordsDeleted++;
|
|
3178
|
+
await this.DeleteRecordMapRow(orphan.ID, contextUser);
|
|
2972
3179
|
console.log(`[IntegrationEngine] Deleted orphan ${entityMap.Entity} ${orphan.EntityRecordID} (external ${orphan.ExternalSystemRecordID} no longer exists)`);
|
|
2973
3180
|
}
|
|
2974
3181
|
else {
|
|
@@ -2982,6 +3189,28 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2982
3189
|
}
|
|
2983
3190
|
}
|
|
2984
3191
|
}
|
|
3192
|
+
/**
|
|
3193
|
+
* Removes one 'MJ: Company Integration Record Maps' row after delete-detection has handled
|
|
3194
|
+
* its orphan (deleted, archived, or found already gone). A failure here is logged and
|
|
3195
|
+
* swallowed: the orphan itself was handled, and the worst consequence of a surviving map
|
|
3196
|
+
* row is one redundant re-detection on the next full sync.
|
|
3197
|
+
*/
|
|
3198
|
+
async DeleteRecordMapRow(mapRowID, contextUser) {
|
|
3199
|
+
try {
|
|
3200
|
+
const md = this.ProviderToUse;
|
|
3201
|
+
const mapRow = await md.GetEntityObject('MJ: Company Integration Record Maps', contextUser);
|
|
3202
|
+
const loaded = await mapRow.InnerLoad(CompositeKey.FromID(mapRowID));
|
|
3203
|
+
if (!loaded)
|
|
3204
|
+
return;
|
|
3205
|
+
const ok = await mapRow.Delete();
|
|
3206
|
+
if (!ok) {
|
|
3207
|
+
console.warn(`[IntegrationEngine] Record-map prune blocked for ${mapRowID} — ${mapRow.LatestResult?.CompleteMessage ?? 'unknown reason'}`);
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
catch (err) {
|
|
3211
|
+
console.warn(`[IntegrationEngine] Record-map prune failed for ${mapRowID}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
2985
3214
|
/**
|
|
2986
3215
|
* Strips field-name prefixes from a RecordChange.RecordID to get the raw entity record ID.
|
|
2987
3216
|
* "hs_object_id|465950833372" → "465950833372"
|
|
@@ -3434,7 +3663,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3434
3663
|
try {
|
|
3435
3664
|
switch (record.ChangeType) {
|
|
3436
3665
|
case 'Create': {
|
|
3437
|
-
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
|
|
3666
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
|
|
3438
3667
|
if (outcome === 'updated')
|
|
3439
3668
|
result.RecordsUpdated++;
|
|
3440
3669
|
else if (outcome === 'skipped')
|
|
@@ -3444,7 +3673,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3444
3673
|
break;
|
|
3445
3674
|
}
|
|
3446
3675
|
case 'Update':
|
|
3447
|
-
await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps);
|
|
3676
|
+
await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps, logger);
|
|
3448
3677
|
break;
|
|
3449
3678
|
case 'Delete': {
|
|
3450
3679
|
const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
|
|
@@ -3504,7 +3733,9 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3504
3733
|
*
|
|
3505
3734
|
* @returns true if an existing row was updated, false if a new row was inserted (so the caller counts correctly).
|
|
3506
3735
|
*/
|
|
3507
|
-
async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps
|
|
3736
|
+
async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps,
|
|
3737
|
+
/** Optional — lets the keyless-key guard below surface on the run's event stream. */
|
|
3738
|
+
keylessLogger) {
|
|
3508
3739
|
const md = this.ProviderToUse;
|
|
3509
3740
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
3510
3741
|
const entityInfo = md.EntityByName(record.MJEntityName);
|
|
@@ -3513,6 +3744,31 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3513
3744
|
// ID), check whether that row already exists before deciding INSERT vs UPDATE. A null mappedPK
|
|
3514
3745
|
// (e.g. a server-assigned UUID PK not present in the mapped fields) means a genuinely new row.
|
|
3515
3746
|
const mappedPK = this.extractMappedPrimaryKey(record, pkFields);
|
|
3747
|
+
// INVARIANT: a record destined for a SOFT-primary-key table must carry its key.
|
|
3748
|
+
//
|
|
3749
|
+
// A soft PK is INFERRED, not generated — it is the external system's own identifier, stored
|
|
3750
|
+
// as ordinary data with no PRIMARY KEY constraint, no identity, and no unique index (see
|
|
3751
|
+
// DDLGenerator: soft keys get a non-unique index only). So nothing at the database level
|
|
3752
|
+
// rejects a NULL key, and a row written without one can never be matched again: the next
|
|
3753
|
+
// sync's existence check misses it, and inserts another copy. Every pass therefore multiplies
|
|
3754
|
+
// the damage silently — the rows look fully populated, only the key column is empty.
|
|
3755
|
+
//
|
|
3756
|
+
// `mappedPK == null` is legitimate ONLY when the destination generates its own key (an
|
|
3757
|
+
// identity column or a server-assigned UUID), which is why the check is scoped to soft PKs
|
|
3758
|
+
// rather than applied to every table.
|
|
3759
|
+
const keyless = DecideKeylessRefusal(mappedPK, pkFields, MissingKeyFieldNames(record.MappedFields, pkFields, serializeKeyValue));
|
|
3760
|
+
if (keyless.Refuse) {
|
|
3761
|
+
const detail = DescribeKeylessRefusal(record.MJEntityName, keyless.KeyNames);
|
|
3762
|
+
keylessLogger?.emit('sync.record.error', {
|
|
3763
|
+
phase: 'write',
|
|
3764
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
3765
|
+
externalID: record.ExternalRecord?.ExternalID,
|
|
3766
|
+
error: `KEYLESS_RECORD_REFUSED: ${detail}`,
|
|
3767
|
+
});
|
|
3768
|
+
// Reported even without a logger — silence is the failure mode this guard exists to end.
|
|
3769
|
+
console.error(`[IntegrationEngine] KEYLESS_RECORD_REFUSED: ${detail}`);
|
|
3770
|
+
return 'skipped';
|
|
3771
|
+
}
|
|
3516
3772
|
const existed = mappedPK != null
|
|
3517
3773
|
? await entity.InnerLoad(this.BuildEntityPrimaryKey(mappedPK, pkFields))
|
|
3518
3774
|
: false;
|
|
@@ -3598,10 +3854,12 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3598
3854
|
* If the record cannot be loaded (e.g. it was deleted or never fully created),
|
|
3599
3855
|
* falls back to CreateRecord (upsert behavior).
|
|
3600
3856
|
*/
|
|
3601
|
-
async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps
|
|
3857
|
+
async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps,
|
|
3858
|
+
/** Forwarded to CreateRecord's keyless-key guard on the upsert fallback paths. */
|
|
3859
|
+
logger) {
|
|
3602
3860
|
if (!record.MatchedMJRecordID) {
|
|
3603
3861
|
// No matched ID — upsert by PK (insert; or update/skip if the PK already exists)
|
|
3604
|
-
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
|
|
3862
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
|
|
3605
3863
|
if (outcome === 'updated')
|
|
3606
3864
|
result.RecordsUpdated++;
|
|
3607
3865
|
else if (outcome === 'skipped')
|
|
@@ -3649,7 +3907,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3649
3907
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
|
|
3650
3908
|
if (!loaded) {
|
|
3651
3909
|
// Matched-ID row vanished — fall back to upsert by PK (insert; or update/skip if PK exists)
|
|
3652
|
-
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
|
|
3910
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
|
|
3653
3911
|
if (outcome === 'updated')
|
|
3654
3912
|
result.RecordsUpdated++;
|
|
3655
3913
|
else if (outcome === 'skipped')
|
|
@@ -3849,6 +4107,31 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3849
4107
|
}
|
|
3850
4108
|
return key;
|
|
3851
4109
|
}
|
|
4110
|
+
/**
|
|
4111
|
+
* Reports ACTIVE field maps whose MJ column does not exist, once per entity map per run.
|
|
4112
|
+
*
|
|
4113
|
+
* `BaseEntity.Set` no-ops on an unknown field — no throw, no log, no dirty flag — so a map
|
|
4114
|
+
* pointing at a column that was never applied (or was renamed) drops its value for every
|
|
4115
|
+
* record while the run reports those records as written. Checking it here costs one metadata
|
|
4116
|
+
* read and happens before the first fetch, so the warning arrives before the wasted work.
|
|
4117
|
+
*/
|
|
4118
|
+
WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger) {
|
|
4119
|
+
const entityName = entityMap.Entity ?? '';
|
|
4120
|
+
// Diagnostics must never be able to fail a run: an unresolvable entity/provider is reported
|
|
4121
|
+
// by the paths that actually need it, and here it simply means there is nothing to check.
|
|
4122
|
+
let entityFieldNames = [];
|
|
4123
|
+
try {
|
|
4124
|
+
const entityInfo = entityName ? this.ProviderToUse?.EntityByName(entityName) : null;
|
|
4125
|
+
entityFieldNames = entityInfo?.Fields?.map(f => f.Name) ?? [];
|
|
4126
|
+
}
|
|
4127
|
+
catch {
|
|
4128
|
+
return;
|
|
4129
|
+
}
|
|
4130
|
+
const unbindable = FindUnbindableFieldMaps(fieldMaps, entityFieldNames);
|
|
4131
|
+
if (unbindable.length === 0)
|
|
4132
|
+
return;
|
|
4133
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FIELD_MAP_DESTINATION_MISSING', DescribeUnbindableFieldMaps(unbindable, entityMap.ExternalObjectName ?? entityMap.ID, entityName), { fieldMaps: unbindable });
|
|
4134
|
+
}
|
|
3852
4135
|
/**
|
|
3853
4136
|
* Sets fields on a BaseEntity instance from a field value map.
|
|
3854
4137
|
*/
|
|
@@ -4209,12 +4492,19 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4209
4492
|
const existing = await rv.RunView({
|
|
4210
4493
|
EntityName: 'MJ: Company Integration Record Maps',
|
|
4211
4494
|
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND EntityID='${entityID}' AND ExternalSystemRecordID=${quotedExternalID}`,
|
|
4212
|
-
Fields: ['ID'],
|
|
4495
|
+
Fields: ['ID', 'EntityRecordID'],
|
|
4213
4496
|
MaxRows: 1,
|
|
4214
4497
|
ResultType: 'simple',
|
|
4215
4498
|
BypassCache: true, // upsert-by-identity: a stale miss here re-creates a duplicate record map
|
|
4216
4499
|
}, contextUser);
|
|
4217
4500
|
if (existing.Success && existing.Results.length > 0) {
|
|
4501
|
+
// The row already says exactly this. On an incremental sync that is nearly every record
|
|
4502
|
+
// — mappings are stable — and the Load + Save this path used to do anyway were two more
|
|
4503
|
+
// round trips to change nothing, plus a meaningless __mj_UpdatedAt bump on every synced
|
|
4504
|
+
// record's map row. The batched writer (RecordMapBatch.flushChunk) has always skipped
|
|
4505
|
+
// this case; the per-record fallback now agrees with it.
|
|
4506
|
+
if (existing.Results[0].EntityRecordID === entityRecordID)
|
|
4507
|
+
return;
|
|
4218
4508
|
const loaded = await recordMap.Load(existing.Results[0].ID);
|
|
4219
4509
|
if (!loaded)
|
|
4220
4510
|
recordMap.NewRecord();
|
|
@@ -4677,6 +4967,20 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4677
4967
|
GetIntegrationObjectFields(objectID) {
|
|
4678
4968
|
return this.Base.GetIntegrationObjectFields(objectID);
|
|
4679
4969
|
}
|
|
4970
|
+
/**
|
|
4971
|
+
* Source field names the connector declared as SyncDirective 'Exclude' for one
|
|
4972
|
+
* integration object, read from IntegrationObjectField.Configuration. Empty set
|
|
4973
|
+
* on any lookup miss (unknown object, no fields, no integration id) - a failed
|
|
4974
|
+
* lookup must never widen or narrow the sync beyond its declared behaviour.
|
|
4975
|
+
*/
|
|
4976
|
+
ResolveExcludedSourceNames(integrationID, externalObjectName) {
|
|
4977
|
+
if (!integrationID || !externalObjectName)
|
|
4978
|
+
return new Set();
|
|
4979
|
+
const obj = this.GetIntegrationObject(integrationID, externalObjectName);
|
|
4980
|
+
if (!obj)
|
|
4981
|
+
return new Set();
|
|
4982
|
+
return ComputeExcludedSourceNames(this.GetIntegrationObjectFields(obj.ID));
|
|
4983
|
+
}
|
|
4680
4984
|
GetActiveIntegrationObjects(integrationID) {
|
|
4681
4985
|
return this.Base.GetActiveIntegrationObjects(integrationID);
|
|
4682
4986
|
}
|