@memberjunction/integration-engine 5.41.0 → 5.43.0
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/README.md +51 -0
- package/dist/BaseIntegrationConnector.d.ts +109 -0
- package/dist/BaseIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseIntegrationConnector.js +308 -93
- package/dist/BaseIntegrationConnector.js.map +1 -1
- package/dist/BaseRESTIntegrationConnector.d.ts +36 -0
- package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseRESTIntegrationConnector.js +228 -28
- package/dist/BaseRESTIntegrationConnector.js.map +1 -1
- package/dist/ContentHash.d.ts +22 -0
- package/dist/ContentHash.d.ts.map +1 -1
- package/dist/ContentHash.js +35 -0
- package/dist/ContentHash.js.map +1 -1
- package/dist/CustomColumnPromotion.d.ts +108 -0
- package/dist/CustomColumnPromotion.d.ts.map +1 -0
- package/dist/CustomColumnPromotion.js +202 -0
- package/dist/CustomColumnPromotion.js.map +1 -0
- package/dist/CustomOverflow.d.ts +64 -0
- package/dist/CustomOverflow.d.ts.map +1 -0
- package/dist/CustomOverflow.js +74 -0
- package/dist/CustomOverflow.js.map +1 -0
- package/dist/FieldMappingEngine.d.ts +16 -90
- package/dist/FieldMappingEngine.d.ts.map +1 -1
- package/dist/FieldMappingEngine.js +42 -257
- package/dist/FieldMappingEngine.js.map +1 -1
- package/dist/IntegrationConnectorCreationPipeline.d.ts +33 -0
- package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -1
- package/dist/IntegrationConnectorCreationPipeline.js +157 -0
- package/dist/IntegrationConnectorCreationPipeline.js.map +1 -1
- package/dist/IntegrationEngine.d.ts +120 -6
- package/dist/IntegrationEngine.d.ts.map +1 -1
- package/dist/IntegrationEngine.js +890 -168
- package/dist/IntegrationEngine.js.map +1 -1
- package/dist/IntegrationSchemaSync.d.ts +84 -13
- package/dist/IntegrationSchemaSync.d.ts.map +1 -1
- package/dist/IntegrationSchemaSync.js +198 -36
- package/dist/IntegrationSchemaSync.js.map +1 -1
- package/dist/KeySerialization.d.ts +21 -0
- package/dist/KeySerialization.d.ts.map +1 -0
- package/dist/KeySerialization.js +29 -0
- package/dist/KeySerialization.js.map +1 -0
- package/dist/MatchEngine.d.ts.map +1 -1
- package/dist/MatchEngine.js +12 -4
- package/dist/MatchEngine.js.map +1 -1
- package/dist/RecordFlatten.d.ts +49 -0
- package/dist/RecordFlatten.d.ts.map +1 -0
- package/dist/RecordFlatten.js +50 -0
- package/dist/RecordFlatten.js.map +1 -0
- package/dist/StreamingDiscovery.d.ts +145 -0
- package/dist/StreamingDiscovery.d.ts.map +1 -0
- package/dist/StreamingDiscovery.js +306 -0
- package/dist/StreamingDiscovery.js.map +1 -0
- package/dist/SyncLogger.d.ts +1 -1
- package/dist/SyncLogger.d.ts.map +1 -1
- package/dist/SyncLogger.js +19 -1
- package/dist/SyncLogger.js.map +1 -1
- package/dist/auth-helpers/BasicAuthHeaderBuilder.d.ts +36 -0
- package/dist/auth-helpers/BasicAuthHeaderBuilder.d.ts.map +1 -0
- package/dist/auth-helpers/BasicAuthHeaderBuilder.js +38 -0
- package/dist/auth-helpers/BasicAuthHeaderBuilder.js.map +1 -0
- package/dist/auth-helpers/OAuth1aSigner.d.ts +56 -0
- package/dist/auth-helpers/OAuth1aSigner.d.ts.map +1 -0
- package/dist/auth-helpers/OAuth1aSigner.js +91 -0
- package/dist/auth-helpers/OAuth1aSigner.js.map +1 -0
- package/dist/auth-helpers/OAuth2TokenManager.d.ts +103 -0
- package/dist/auth-helpers/OAuth2TokenManager.d.ts.map +1 -0
- package/dist/auth-helpers/OAuth2TokenManager.js +143 -0
- package/dist/auth-helpers/OAuth2TokenManager.js.map +1 -0
- package/dist/auth-helpers/index.d.ts +11 -0
- package/dist/auth-helpers/index.d.ts.map +1 -0
- package/dist/auth-helpers/index.js +8 -0
- package/dist/auth-helpers/index.js.map +1 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +73 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -7
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { CompositeKey, LogStatusEx, Metadata, RunView } from '@memberjunction/core';
|
|
2
2
|
import { BaseSingleton, UUIDsEqual } from '@memberjunction/global';
|
|
3
3
|
import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
|
|
4
|
-
import { ClassifyError } from './types.js';
|
|
4
|
+
import { ClassifyError, IsRetryableError } from './types.js';
|
|
5
|
+
import { WithRetry } from './RetryRunner.js';
|
|
6
|
+
import { WithTimeout, DEFAULT_OPERATION_TIMEOUTS } from './BaseIntegrationConnector.js';
|
|
5
7
|
import { ConnectorFactory } from './ConnectorFactory.js';
|
|
6
8
|
import { FieldMappingEngine } from './FieldMappingEngine.js';
|
|
7
9
|
import { MatchEngine } from './MatchEngine.js';
|
|
8
10
|
import { WatermarkService } from './WatermarkService.js';
|
|
9
11
|
import { SyncLogger } from './SyncLogger.js';
|
|
10
|
-
import { CONTENT_HASH_COLUMN,
|
|
12
|
+
import { CONTENT_HASH_COLUMN, computeContentHashWithOverflow, contentHashBasis } from './ContentHash.js';
|
|
13
|
+
import { serializeKeyValue } from './KeySerialization.js';
|
|
14
|
+
import { CUSTOM_OVERFLOW_COLUMN, hasUnmappedFields } from './CustomOverflow.js';
|
|
11
15
|
import { partitionRecords, partitionRollupHash, diffPartitions, partitionKeyForIdentity } from './HashDiff.js';
|
|
12
16
|
import { RateLimiter } from './RateLimiter.js';
|
|
13
17
|
import { AdaptiveConcurrencyController, RunAdaptive } from './AdaptiveConcurrency.js';
|
|
@@ -15,6 +19,37 @@ import { mostRecentWinner } from './ConflictRecency.js';
|
|
|
15
19
|
import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
|
|
16
20
|
/** Default batch size for fetching records from external systems */
|
|
17
21
|
const DEFAULT_BATCH_SIZE = 200;
|
|
22
|
+
/**
|
|
23
|
+
* Hard ceiling on records the opt-in partitionReconcile (Merkle) mode may accumulate in RAM
|
|
24
|
+
* before it fails loud rather than risking an OOM crash. That mode buffers the ENTIRE fetched
|
|
25
|
+
* set (see applyViaPartitionReconcile) — intended for watermark-less small/medium objects. This
|
|
26
|
+
* cap is generous (well above the "tens of thousands" the mode targets); crossing it means the
|
|
27
|
+
* object is too large for partitionReconcile and the operator should disable it (the default
|
|
28
|
+
* streaming path has no such limit). Turns a silent OOM into an actionable error.
|
|
29
|
+
*/
|
|
30
|
+
const PARTITION_RECONCILE_MAX_ACCUMULATION = 500_000;
|
|
31
|
+
/**
|
|
32
|
+
* Ceiling on the in-memory set of fetched ExternalIDs used for full-sync orphan detection. Past this,
|
|
33
|
+
* we stop tracking + skip the orphan sweep for that entity-map (surfaced as a SyncWarning) rather than
|
|
34
|
+
* grow the set unbounded and risk OOM on a multi-million-row full sync. Incremental syncs don't build
|
|
35
|
+
* this set; only full-sync / partition-reconcile do. Generous on purpose — most objects never hit it.
|
|
36
|
+
*/
|
|
37
|
+
const ORPHAN_DETECTION_MAX_IDS = 1_000_000;
|
|
38
|
+
/**
|
|
39
|
+
* Cap on the retained per-record error SAMPLE in the aggregate run result. RecordsErrored keeps the
|
|
40
|
+
* true count; only this many error objects are kept for diagnostics (FinalizeRun persists the first
|
|
41
|
+
* 100). Prevents a multi-million-row failing run from holding every error object in RAM.
|
|
42
|
+
*/
|
|
43
|
+
const MAX_AGGREGATE_ERRORS = 200;
|
|
44
|
+
/**
|
|
45
|
+
* In-memory safety ceiling for a FULL push (every row of an MJ entity is loaded into RAM before
|
|
46
|
+
* pushing). Past this we fail loud + actionable instead of risking an OOM. Env-overridable for the
|
|
47
|
+
* rare legitimate large full push. The proper long-term fix is a streaming/keyset push.
|
|
48
|
+
*/
|
|
49
|
+
const FULL_PUSH_MAX_RECORDS = (() => {
|
|
50
|
+
const override = Number(process.env.MJ_INTEGRATION_FULL_PUSH_MAX_RECORDS);
|
|
51
|
+
return Number.isFinite(override) && override > 0 ? Math.floor(override) : 1_000_000;
|
|
52
|
+
})();
|
|
18
53
|
/**
|
|
19
54
|
* Server-side Integration Engine.
|
|
20
55
|
*
|
|
@@ -50,6 +85,50 @@ export class SchemaNotGeneratedError extends Error {
|
|
|
50
85
|
this.StoredProcedureName = storedProcedureName;
|
|
51
86
|
}
|
|
52
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* §29 — base of the VALUE-FIT error family: a mapped value cannot fit/coerce into its destination
|
|
90
|
+
* column's type. Per the bounded-typing policy (small columns; large content is an explicit text/json
|
|
91
|
+
* modality), such a value is NEVER truncated/clamped and NEVER widens the column — the offending
|
|
92
|
+
* RECORD is skipped and surfaced as a structured SyncWarning so the gap is visible, not silent, and
|
|
93
|
+
* the rest of the batch still commits. Caught per-record in ApplySingleRecord (catches the base, so
|
|
94
|
+
* every family member is handled uniformly). Add a subclass for each new fit failure mode.
|
|
95
|
+
*/
|
|
96
|
+
export class ValueFitError extends Error {
|
|
97
|
+
constructor(message, fieldName) {
|
|
98
|
+
super(message);
|
|
99
|
+
this.name = new.target.name;
|
|
100
|
+
this.FieldName = fieldName;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** A string value wider than its bounded NVARCHAR column. */
|
|
104
|
+
export class StringOverflowError extends ValueFitError {
|
|
105
|
+
constructor(fieldName, valueLength, maxLength) {
|
|
106
|
+
super(`Value for '${fieldName}' is ${valueLength} chars, exceeding the column width ${maxLength}; record skipped (not truncated).`, fieldName);
|
|
107
|
+
this.WarningCode = 'STRING_OVERFLOW_SKIPPED';
|
|
108
|
+
this.ValueLength = valueLength;
|
|
109
|
+
this.MaxLength = maxLength;
|
|
110
|
+
}
|
|
111
|
+
Details() { return { valueLength: this.ValueLength, maxLength: this.MaxLength }; }
|
|
112
|
+
}
|
|
113
|
+
/** A numeric value outside its integer column's representable range (e.g. > INT max → would sink the batch at bind time). */
|
|
114
|
+
export class NumericOverflowError extends ValueFitError {
|
|
115
|
+
constructor(fieldName, value, sqlType) {
|
|
116
|
+
super(`Value for '${fieldName}' (${value}) is outside the range of its ${sqlType} column; record skipped (not clamped).`, fieldName);
|
|
117
|
+
this.WarningCode = 'NUMERIC_OVERFLOW_SKIPPED';
|
|
118
|
+
this.Value = value;
|
|
119
|
+
this.SqlType = sqlType;
|
|
120
|
+
}
|
|
121
|
+
Details() { return { value: this.Value, sqlType: this.SqlType }; }
|
|
122
|
+
}
|
|
123
|
+
/** §29 — integer SQL-type ranges (JS-number-comparable). BIGINT bounds exceed 2^53 so the check is a
|
|
124
|
+
* no-op there (any JS number already fits); the real overflow risk is INT/SMALLINT/TINYINT. */
|
|
125
|
+
const INTEGER_SQL_BOUNDS = {
|
|
126
|
+
tinyint: { min: 0, max: 255 },
|
|
127
|
+
smallint: { min: -32768, max: 32767 },
|
|
128
|
+
int: { min: -2147483648, max: 2147483647 },
|
|
129
|
+
integer: { min: -2147483648, max: 2147483647 },
|
|
130
|
+
bigint: { min: -9223372036854775808, max: 9223372036854775807 },
|
|
131
|
+
};
|
|
53
132
|
/**
|
|
54
133
|
* Returns a SchemaNotGeneratedError if the given Save() failure message matches the
|
|
55
134
|
* "CRUD routine doesn't exist yet" pattern for either dialect, otherwise null. When
|
|
@@ -76,10 +155,44 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
76
155
|
this.fieldMappingEngine = new FieldMappingEngine();
|
|
77
156
|
this.matchEngine = new MatchEngine();
|
|
78
157
|
this.watermarkService = new WatermarkService();
|
|
158
|
+
/**
|
|
159
|
+
* Per-engine async mutex serializing the DB-WRITE section across concurrently-synced streams.
|
|
160
|
+
* When a layer runs multiple entity maps in parallel (syncConcurrency > 1), they all share ONE
|
|
161
|
+
* provider connection whose transaction state is singular — so concurrent BeginTransaction /
|
|
162
|
+
* SavePoint / Commit calls corrupt each other ("Transaction has not begun", "Cannot roll back
|
|
163
|
+
* SavePoint"). The fetch phase stays parallel (the real throughput win — it's network-bound);
|
|
164
|
+
* only the per-batch write transaction is serialized through this lock. Keyed per engine
|
|
165
|
+
* instance, which owns the shared provider.
|
|
166
|
+
*/
|
|
167
|
+
this._writeChain = Promise.resolve();
|
|
79
168
|
/** Configurable maximum batch size. Connector batches exceeding this are truncated. */
|
|
80
169
|
this.MaxBatchSize = DEFAULT_BATCH_SIZE;
|
|
81
170
|
/** Per-integration request-spacing chain for the rate limiter (keyed by IntegrationID → last scheduled time). */
|
|
82
171
|
this._rateLimiters = new Map();
|
|
172
|
+
this.MaxColumnsPerTable = IntegrationEngine.computeMaxColumnsPerTable();
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* SQL Server's hard per-table column limit is 1024; the framework reserves headroom for its own
|
|
176
|
+
* sync/system columns (the __mj_integration_* set + ID + timestamps), so the effective ceiling is 1000.
|
|
177
|
+
* MJ_INTEGRATION_MAX_COLUMNS_PER_TABLE can only LOWER this, never raise it past 1000.
|
|
178
|
+
*/
|
|
179
|
+
static { this.MAX_COLUMNS_CEILING = 1000; }
|
|
180
|
+
/** Reads + clamps the column limit from env at startup; logs once when a configured value is clamped down. */
|
|
181
|
+
static computeMaxColumnsPerTable() {
|
|
182
|
+
const ceiling = IntegrationEngine.MAX_COLUMNS_CEILING;
|
|
183
|
+
const raw = parseInt(process.env.MJ_INTEGRATION_MAX_COLUMNS_PER_TABLE ?? '', 10);
|
|
184
|
+
if (Number.isFinite(raw) && raw > 0) {
|
|
185
|
+
if (raw > ceiling) {
|
|
186
|
+
LogStatusEx({ message: `[IntegrationEngine] MJ_INTEGRATION_MAX_COLUMNS_PER_TABLE=${raw} exceeds the maximum ${ceiling} (SQL Server's 1024-column limit minus framework column headroom) — clamped to ${ceiling}.` });
|
|
187
|
+
return ceiling;
|
|
188
|
+
}
|
|
189
|
+
return raw;
|
|
190
|
+
}
|
|
191
|
+
return ceiling;
|
|
192
|
+
}
|
|
193
|
+
/** Registers (or clears, with undefined) the post-sync custom-column promotion hook. */
|
|
194
|
+
SetPostSyncSchemaPromotionCallback(callback) {
|
|
195
|
+
this.postSyncSchemaPromotionCallback = callback;
|
|
83
196
|
}
|
|
84
197
|
/** Returns the active provider — explicit override if set, otherwise the global default. */
|
|
85
198
|
get ProviderToUse() {
|
|
@@ -87,6 +200,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
87
200
|
}
|
|
88
201
|
/** In-process lock map to prevent concurrent syncs for the same CompanyIntegration */
|
|
89
202
|
static { this.activeSyncs = new Map(); }
|
|
203
|
+
runWriteExclusive(fn) {
|
|
204
|
+
// Run fn after the prior write completes (whether it resolved or rejected); keep the chain
|
|
205
|
+
// alive past failures so one errored batch never deadlocks subsequent writers.
|
|
206
|
+
const run = this._writeChain.then(() => fn(), () => fn());
|
|
207
|
+
this._writeChain = run.then(() => undefined, () => undefined);
|
|
208
|
+
return run;
|
|
209
|
+
}
|
|
90
210
|
/** Abort controllers for cancelling running syncs */
|
|
91
211
|
static { this._abortControllers = new Map(); }
|
|
92
212
|
/** Live sync progress — updated on every batch for ALL syncs regardless of caller */
|
|
@@ -139,6 +259,19 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
139
259
|
for (const run of orphanedRuns.Results) {
|
|
140
260
|
const companyIntegrationID = run.CompanyIntegrationID;
|
|
141
261
|
const runID = run.ID;
|
|
262
|
+
const lockKey = companyIntegrationID.toLowerCase();
|
|
263
|
+
// C1: respect the SAME in-process concurrency lock RunSync uses. If a live sync for this
|
|
264
|
+
// CompanyIntegration is already running (e.g. the scheduler fired during startup), skip the
|
|
265
|
+
// resume — double-running one CI on the shared provider connection corrupts its singular
|
|
266
|
+
// transaction state (exactly what runWriteExclusive guards against WITHIN a run). The
|
|
267
|
+
// get→set pair below has no await between them, so check-and-reserve is atomic on the loop.
|
|
268
|
+
if (IntegrationEngine.activeSyncs.get(lockKey)) {
|
|
269
|
+
console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — a live sync for ${lockKey} is already running`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
let resolveResumeLock;
|
|
273
|
+
let resumeResult;
|
|
274
|
+
IntegrationEngine.activeSyncs.set(lockKey, new Promise(res => { resolveResumeLock = res; }));
|
|
142
275
|
try {
|
|
143
276
|
// Find which entity MAPS already completed SUCCESSFULLY in this run. We correlate
|
|
144
277
|
// by EntityMapID (parsed from the detail's RecordID, stamped by CreateRunDetail),
|
|
@@ -184,6 +317,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
184
317
|
const result = await this.ExecuteEntityMaps(config, run, contextUser);
|
|
185
318
|
result.RunID = runID;
|
|
186
319
|
await this.FinalizeRun(run, result, contextUser);
|
|
320
|
+
resumeResult = result;
|
|
187
321
|
console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
|
|
188
322
|
`${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
|
|
189
323
|
`${result.RecordsErrored} errored`);
|
|
@@ -197,6 +331,17 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
197
331
|
run.ErrorLog = JSON.stringify([{ ErrorMessage: `Resume failed: ${errMsg}` }]);
|
|
198
332
|
await run.Save();
|
|
199
333
|
}
|
|
334
|
+
finally {
|
|
335
|
+
// Release the C1 lock + unblock any RunSync that began awaiting this resume (RunSync returns
|
|
336
|
+
// `existing`). Resolve with the real result when we have one, else a benign empty result so no
|
|
337
|
+
// waiter hangs. Promise resolve is idempotent and the early-exit `continue` also lands here.
|
|
338
|
+
IntegrationEngine.activeSyncs.delete(lockKey);
|
|
339
|
+
resolveResumeLock(resumeResult ?? {
|
|
340
|
+
Success: false, ErrorMessage: 'Resume produced no result', RecordsProcessed: 0,
|
|
341
|
+
RecordsCreated: 0, RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0,
|
|
342
|
+
RecordsSkipped: 0, Errors: [], EntityMapResults: [], Duration: 0,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
200
345
|
}
|
|
201
346
|
}
|
|
202
347
|
/**
|
|
@@ -334,6 +479,28 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
334
479
|
result.ErrorMessage = `Sync completed with ${result.RecordsErrored} error(s)`;
|
|
335
480
|
}
|
|
336
481
|
await this.FinalizeRun(run, result, contextUser, onNotification, abortSignal?.aborted);
|
|
482
|
+
// Post-sync custom-column promotion (gaps.md §2 / M2). Self-gated server-side: a
|
|
483
|
+
// customs-free sync does no work. Skipped for an aborted run. Never throws into the sync.
|
|
484
|
+
if (!abortSignal?.aborted) {
|
|
485
|
+
result.SchemaUpdate = await this.invokePostSyncPromotionSafe(companyIntegrationID, contextUser, result);
|
|
486
|
+
// Restart-signal (M3): when columns were promoted, surface it on the structured
|
|
487
|
+
// stream so a watching client (IntegrationTailRunEvents) knows an MJAPI restart is
|
|
488
|
+
// needed to expose the new columns over GraphQL — read as intentional, not a crash.
|
|
489
|
+
// No new columns ⇒ no event ⇒ no restart (the convergence/1× guarantee). The new
|
|
490
|
+
// columns are already usable by the NEXT sync without a restart (metadata refreshed).
|
|
491
|
+
if (result.SchemaUpdate?.SchemaUpdatePending) {
|
|
492
|
+
logger.emit('sync.schema_update', {
|
|
493
|
+
columnsAdded: result.SchemaUpdate.ColumnsAdded,
|
|
494
|
+
restartRequiredForGraphQL: true,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
// Surface any non-fatal promotion problems on the structured stream so the operator
|
|
498
|
+
// sees what DIDN'T promote (RSU/DDL/save failure, per-pass cap deferral, missing map) —
|
|
499
|
+
// promotion never fails the sync, but a swallowed problem must not be invisible (§4).
|
|
500
|
+
for (const w of result.SchemaUpdate?.Warnings ?? []) {
|
|
501
|
+
logger.warning('schema-promotion', 'CUSTOM_COLUMN_PROMOTION_WARNING', w);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
337
504
|
const summary = this.buildSyncResultBody(config.companyIntegration.Integration, result);
|
|
338
505
|
logger.emit('sync.run.complete', {
|
|
339
506
|
success: result.Success && result.RecordsErrored === 0,
|
|
@@ -567,7 +734,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
567
734
|
// single-threaded async, so concurrent maps in a layer are safe.
|
|
568
735
|
const processOne = async (entityMap) => {
|
|
569
736
|
if (abortSignal?.aborted)
|
|
570
|
-
return true;
|
|
737
|
+
return { ok: true, throttled: false };
|
|
571
738
|
const i = globalIndex++;
|
|
572
739
|
const mapStartTime = Date.now();
|
|
573
740
|
const direction = config.syncDirection ?? entityMap.SyncDirection ?? 'Pull';
|
|
@@ -597,7 +764,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
597
764
|
recordsErrored: mapResult.RecordsErrored,
|
|
598
765
|
});
|
|
599
766
|
this.checkSecondLayerEmpty(entityMap, mapResult, depGraph, processedByIoId, ioNameById, ioCategoryById, logger);
|
|
600
|
-
return mapResult.Success;
|
|
767
|
+
return { ok: mapResult.Success, throttled: mapResult.Throttled === true };
|
|
601
768
|
}
|
|
602
769
|
catch (err) {
|
|
603
770
|
const objName = entityMap.ExternalObjectName ?? entityMap.ID;
|
|
@@ -633,7 +800,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
633
800
|
RecordsSkipped: 0,
|
|
634
801
|
Duration: Date.now() - mapStartTime,
|
|
635
802
|
});
|
|
636
|
-
return false;
|
|
803
|
+
return { ok: false, throttled: ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED' };
|
|
637
804
|
}
|
|
638
805
|
};
|
|
639
806
|
// Group maps into dependency layers (parents before children) via the IntegrationObject FK
|
|
@@ -648,7 +815,8 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
648
815
|
// MaxConcurrencyHint on clean maps, cut on map failure. With no hint and default
|
|
649
816
|
// syncConcurrency=1, min=max=1 → strictly sequential (unchanged behavior). The per-request
|
|
650
817
|
// RateLimiter is the backstop that keeps the source within its real rate as parallelism rises.
|
|
651
|
-
|
|
818
|
+
// Configuration override (IntegrationSetSyncConfig) wins over the connector's MaxConcurrencyHint constant.
|
|
819
|
+
const maxConcurrency = Math.max(concurrency, this.getConfigOverrides(config).maxConcurrency ?? config.connector.MaxConcurrencyHint ?? concurrency);
|
|
652
820
|
const concController = new AdaptiveConcurrencyController({ start: concurrency, min: 1, max: maxConcurrency });
|
|
653
821
|
// Second-layer silent-empty detection state (see checkSecondLayerEmpty): a per-IO running
|
|
654
822
|
// record count + the FK dependency graph, so an association/dependent object that fetches
|
|
@@ -665,6 +833,19 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
665
833
|
ioCategoryById.set(io.ID.toUpperCase(), io.Category);
|
|
666
834
|
}
|
|
667
835
|
}
|
|
836
|
+
// §4 OPT-IN: cross-layer pipelining overlaps independent DAG branches (a child starts when ITS
|
|
837
|
+
// parents finish, not when the whole parent layer does). Default OFF → the strict layer-barrier
|
|
838
|
+
// loop below (unchanged). Falls back to the barrier if the per-map dependency graph can't build.
|
|
839
|
+
const mapDeps = this.getCrossLayerPipelineEnabled(config) ? this.buildMapDependencies(config) : null;
|
|
840
|
+
if (mapDeps) {
|
|
841
|
+
logger?.emit('sync.config.loaded', { crossLayerPipeline: true, totalMaps });
|
|
842
|
+
await this.runPipelinedDAG(config.entityMaps, mapDeps, concController, processOne, abortSignal);
|
|
843
|
+
if (abortSignal?.aborted) {
|
|
844
|
+
aggregate.Success = false;
|
|
845
|
+
aggregate.ErrorMessage = 'Sync cancelled by user';
|
|
846
|
+
}
|
|
847
|
+
return aggregate;
|
|
848
|
+
}
|
|
668
849
|
for (const layer of layers) {
|
|
669
850
|
if (abortSignal?.aborted) {
|
|
670
851
|
console.log(`[IntegrationEngine] Sync cancelled (${globalIndex}/${totalMaps} maps processed)`);
|
|
@@ -675,12 +856,12 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
675
856
|
await RunAdaptive(layer, async (m) => {
|
|
676
857
|
if (abortSignal?.aborted)
|
|
677
858
|
return { ok: true, throttled: false };
|
|
678
|
-
const ok = await processOne(m);
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
return { ok, throttled
|
|
859
|
+
const { ok, throttled } = await processOne(m);
|
|
860
|
+
// §5 Gap 2: a real source throttle (RATE_LIMIT_EXCEEDED on fetch) now also cuts the
|
|
861
|
+
// per-layer in-flight cap, not just the per-request token bucket. A plain data failure
|
|
862
|
+
// (FK/validation/transform) carries throttled=false, so concurrency only drops on an
|
|
863
|
+
// actual rate-limit signal — never on ordinary record errors.
|
|
864
|
+
return { ok, throttled };
|
|
684
865
|
}, concController);
|
|
685
866
|
}
|
|
686
867
|
return aggregate;
|
|
@@ -755,8 +936,11 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
755
936
|
if (!ios || ios.length === 0)
|
|
756
937
|
return null;
|
|
757
938
|
const ioByName = new Map(); // lower(IO.Name) → IO.ID (upper)
|
|
758
|
-
|
|
939
|
+
const ioById = new Map(); // IO.ID (upper) → IO
|
|
940
|
+
for (const io of ios) {
|
|
759
941
|
ioByName.set(io.Name.toLowerCase(), io.ID.toUpperCase());
|
|
942
|
+
ioById.set(io.ID.toUpperCase(), io);
|
|
943
|
+
}
|
|
760
944
|
const mapToIoId = new Map(); // entityMap.ID → IO.ID
|
|
761
945
|
const selectedIoIds = new Set();
|
|
762
946
|
for (const m of config.entityMaps) {
|
|
@@ -771,11 +955,30 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
771
955
|
const parentsByIoId = new Map();
|
|
772
956
|
for (const ioId of selectedIoIds) {
|
|
773
957
|
const set = new Set();
|
|
958
|
+
// (1) hard FK pointer on a field (RelatedIntegrationObjectID).
|
|
774
959
|
for (const f of this.GetIntegrationObjectFields(ioId)) {
|
|
775
960
|
const parent = f.RelatedIntegrationObjectID?.toUpperCase();
|
|
776
961
|
if (parent && parent !== ioId && selectedIoIds.has(parent))
|
|
777
962
|
set.add(parent);
|
|
778
963
|
}
|
|
964
|
+
// (2) SOFT-FK form (parent-iterated children): the parent is named in the IO's
|
|
965
|
+
// Configuration (parentObjectName / ReferencedType), NOT via RelatedIntegrationObjectID —
|
|
966
|
+
// which is null for soft-FK connectors. Without this, nulling the FK pointer collapses the
|
|
967
|
+
// dependency graph → children run in layer 0 alongside their door → ZERO_PARENTS on the
|
|
968
|
+
// first sync (door not yet populated). Resolve the parent name → its IO id so doors are
|
|
969
|
+
// ordered before their children in a SINGLE pass.
|
|
970
|
+
const cfgRaw = ioById.get(ioId)?.Configuration;
|
|
971
|
+
if (cfgRaw) {
|
|
972
|
+
try {
|
|
973
|
+
const cfg = JSON.parse(cfgRaw);
|
|
974
|
+
for (const name of [cfg.parentObjectName, cfg.ReferencedType]) {
|
|
975
|
+
const parent = name ? ioByName.get(name.toLowerCase()) : undefined;
|
|
976
|
+
if (parent && parent !== ioId && selectedIoIds.has(parent))
|
|
977
|
+
set.add(parent);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
catch { /* non-JSON Configuration → no soft-FK parent to add */ }
|
|
981
|
+
}
|
|
779
982
|
parentsByIoId.set(ioId, set);
|
|
780
983
|
}
|
|
781
984
|
return { mapToIoId, parentsByIoId };
|
|
@@ -833,6 +1036,101 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
833
1036
|
catch { /* fall through */ }
|
|
834
1037
|
return 1;
|
|
835
1038
|
}
|
|
1039
|
+
/**
|
|
1040
|
+
* §4 OPT-IN cross-layer pipelining (CompanyIntegration.Configuration {"crossLayerPipeline": true}).
|
|
1041
|
+
* Default OFF → strict layer-barrier execution (unchanged). When ON, a child map starts as soon as
|
|
1042
|
+
* ITS OWN parents finish, instead of waiting for the entire parent LAYER — overlapping independent
|
|
1043
|
+
* branches of the DAG. Correctness is preserved because a child still gates on parent COMPLETION
|
|
1044
|
+
* (the parent's row writes are committed and its record-count recorded before the child begins).
|
|
1045
|
+
* Off by default because the throughput win is only measurable against a live run.
|
|
1046
|
+
*/
|
|
1047
|
+
getCrossLayerPipelineEnabled(config) {
|
|
1048
|
+
try {
|
|
1049
|
+
const raw = config.companyIntegration.Configuration;
|
|
1050
|
+
if (raw)
|
|
1051
|
+
return JSON.parse(raw).crossLayerPipeline === true;
|
|
1052
|
+
}
|
|
1053
|
+
catch { /* fall through */ }
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Per-map parent dependencies (entityMap.ID → set of parent entityMap.IDs) from the FK graph.
|
|
1058
|
+
* Used by the §4 pipelined scheduler. Returns null when the graph can't be resolved (caller then
|
|
1059
|
+
* falls back to layer-barrier execution).
|
|
1060
|
+
*/
|
|
1061
|
+
buildMapDependencies(config) {
|
|
1062
|
+
const graph = this.computeSelectedDependencyGraph(config);
|
|
1063
|
+
if (!graph)
|
|
1064
|
+
return null;
|
|
1065
|
+
const { mapToIoId, parentsByIoId } = graph;
|
|
1066
|
+
// Invert mapToIoId: IO.ID → the maps targeting it (an IO can back more than one map).
|
|
1067
|
+
const mapsByIoId = new Map();
|
|
1068
|
+
for (const [mapId, ioId] of mapToIoId) {
|
|
1069
|
+
if (!mapsByIoId.has(ioId))
|
|
1070
|
+
mapsByIoId.set(ioId, []);
|
|
1071
|
+
mapsByIoId.get(ioId).push(mapId);
|
|
1072
|
+
}
|
|
1073
|
+
const deps = new Map();
|
|
1074
|
+
for (const m of config.entityMaps) {
|
|
1075
|
+
const ioId = mapToIoId.get(m.ID);
|
|
1076
|
+
const parentMapIds = new Set();
|
|
1077
|
+
if (ioId) {
|
|
1078
|
+
for (const parentIoId of parentsByIoId.get(ioId) ?? []) {
|
|
1079
|
+
for (const pm of mapsByIoId.get(parentIoId) ?? []) {
|
|
1080
|
+
if (!UUIDsEqual(pm, m.ID))
|
|
1081
|
+
parentMapIds.add(pm);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
deps.set(m.ID, parentMapIds);
|
|
1086
|
+
}
|
|
1087
|
+
return deps;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* §4 dependency-aware scheduler: runs every map concurrently, but each map first awaits its
|
|
1091
|
+
* parents' completion, THEN gates on the live AIMD cap before doing work. Deadlock-free even at
|
|
1092
|
+
* cap=1: a map awaiting parents does NOT hold a concurrency slot (the slot is acquired only AFTER
|
|
1093
|
+
* the parent-await resolves), so parents always get to run. The cap gate counts only ACTIVE work.
|
|
1094
|
+
*/
|
|
1095
|
+
async runPipelinedDAG(maps, mapDeps, controller, process, abortSignal) {
|
|
1096
|
+
const resolvers = new Map();
|
|
1097
|
+
const donePromises = new Map();
|
|
1098
|
+
for (const m of maps)
|
|
1099
|
+
donePromises.set(m.ID, new Promise(r => resolvers.set(m.ID, r)));
|
|
1100
|
+
const inFlight = { count: 0 };
|
|
1101
|
+
const scheduleOne = async (m) => {
|
|
1102
|
+
try {
|
|
1103
|
+
const deps = mapDeps.get(m.ID);
|
|
1104
|
+
if (deps && deps.size > 0) {
|
|
1105
|
+
await Promise.all([...deps].map(d => donePromises.get(d)).filter((p) => !!p));
|
|
1106
|
+
}
|
|
1107
|
+
if (abortSignal?.aborted)
|
|
1108
|
+
return;
|
|
1109
|
+
// Gate on the LIVE cap (yield on a macrotask so in-flight I/O isn't starved — mirrors
|
|
1110
|
+
// adaptiveWorker). Only ACTIVE work counts toward the cap; waiting-on-parents does not.
|
|
1111
|
+
while (inFlight.count >= controller.Cap) {
|
|
1112
|
+
await new Promise(r => setTimeout(r, 0));
|
|
1113
|
+
if (abortSignal?.aborted)
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
inFlight.count++;
|
|
1117
|
+
try {
|
|
1118
|
+
const outcome = await process(m);
|
|
1119
|
+
if (outcome.throttled || !outcome.ok)
|
|
1120
|
+
controller.OnThrottleOrError();
|
|
1121
|
+
else
|
|
1122
|
+
controller.OnSuccess();
|
|
1123
|
+
}
|
|
1124
|
+
finally {
|
|
1125
|
+
inFlight.count--;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
finally {
|
|
1129
|
+
resolvers.get(m.ID)(); // unblock children whether we ran, aborted, or threw
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
await Promise.all(maps.map(m => scheduleOne(m)));
|
|
1133
|
+
}
|
|
836
1134
|
/** Runs `fn` over items with at most `cap` concurrent executions. cap<=1 → strictly sequential. */
|
|
837
1135
|
async runBounded(items, cap, fn) {
|
|
838
1136
|
if (cap <= 1) {
|
|
@@ -880,19 +1178,49 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
880
1178
|
* the same vendor have independent budgets and must not share one bucket. {@link reportRateOutcome}
|
|
881
1179
|
* feeds 429s/successes back so the rate auto-tunes (AIMD).
|
|
882
1180
|
*/
|
|
1181
|
+
/**
|
|
1182
|
+
* Per-connection numeric tuning overrides from CompanyIntegration.Configuration — the typed
|
|
1183
|
+
* fields the IntegrationSetSyncConfig GraphQL mutation writes. These let an operator override
|
|
1184
|
+
* the connector's code-constant rate limit / concurrency / discovery budget per connection,
|
|
1185
|
+
* via the API, instead of editing code. Only positive finite values are honored.
|
|
1186
|
+
*/
|
|
1187
|
+
getConfigOverrides(config) {
|
|
1188
|
+
try {
|
|
1189
|
+
const raw = config.companyIntegration.Configuration;
|
|
1190
|
+
if (!raw)
|
|
1191
|
+
return {};
|
|
1192
|
+
const p = JSON.parse(raw);
|
|
1193
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined);
|
|
1194
|
+
return {
|
|
1195
|
+
maxConcurrency: num(p.maxConcurrency),
|
|
1196
|
+
rateLimitTokensPerSec: typeof p.rateLimitTokensPerSec === 'number' && p.rateLimitTokensPerSec > 0 ? p.rateLimitTokensPerSec : undefined,
|
|
1197
|
+
rateLimitBurst: num(p.rateLimitBurst),
|
|
1198
|
+
discoveryTimeBudgetMs: num(p.discoveryTimeBudgetMs),
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
catch {
|
|
1202
|
+
return {};
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
883
1205
|
getRateLimiter(config) {
|
|
884
1206
|
const key = config.companyIntegration.ID;
|
|
885
1207
|
let rl = this._rateLimiters.get(key);
|
|
886
1208
|
if (!rl) {
|
|
887
1209
|
const policy = config.connector.RateLimitPolicy;
|
|
1210
|
+
const overrides = this.getConfigOverrides(config);
|
|
888
1211
|
const spacingMs = this.getRequestSpacingMs(config);
|
|
889
|
-
|
|
1212
|
+
// Configuration override wins over the connector's code constant (the "not just constants" goal).
|
|
1213
|
+
const tokensPerSec = overrides.rateLimitTokensPerSec ?? policy?.TokensPerSec ?? (spacingMs > 0 ? 1000 / spacingMs : 10);
|
|
890
1214
|
rl = new RateLimiter({
|
|
891
1215
|
TokensPerSec: tokensPerSec,
|
|
892
1216
|
// Floor Burst at 1 so a slow-spacing integration (fractional tokens/sec) still gets
|
|
893
1217
|
// one immediate token instead of stalling ~1s on the very first request.
|
|
894
|
-
Burst: Math.max(1, policy?.Burst ?? Math.ceil(tokensPerSec)),
|
|
1218
|
+
Burst: Math.max(1, overrides.rateLimitBurst ?? policy?.Burst ?? Math.ceil(tokensPerSec)),
|
|
895
1219
|
ThrottleBackoffFactor: policy?.ThrottleBackoffFactor,
|
|
1220
|
+
// Thread the connector's recovery tuning through; RateLimiter applies its own sane
|
|
1221
|
+
// defaults (ramp = rate/10, floor = rate/20) when these are omitted.
|
|
1222
|
+
SuccessRampPerCall: policy?.SuccessRampPerCall,
|
|
1223
|
+
MinTokensPerSec: policy?.MinTokensPerSec,
|
|
896
1224
|
});
|
|
897
1225
|
this._rateLimiters.set(key, rl);
|
|
898
1226
|
}
|
|
@@ -953,7 +1281,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
953
1281
|
async ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger) {
|
|
954
1282
|
const entityMapID = entityMap.ID;
|
|
955
1283
|
const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
|
|
956
|
-
const watermark = await this.watermarkService.Load(entityMapID, contextUser, 'Pull');
|
|
1284
|
+
const watermark = await this.runWriteExclusive(() => this.watermarkService.Load(entityMapID, contextUser, 'Pull'));
|
|
957
1285
|
logger?.emit('sync.entity-map.start', {
|
|
958
1286
|
phase: 'pull-detail',
|
|
959
1287
|
externalObjectName: entityMap.ExternalObjectName,
|
|
@@ -1036,12 +1364,14 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1036
1364
|
};
|
|
1037
1365
|
let hasMore = true;
|
|
1038
1366
|
let currentWatermark = initialWatermark;
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
1041
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
// watermark
|
|
1044
|
-
|
|
1367
|
+
// §10 — the watermark ALWAYS advances to the max value seen on a clean fetch; it is NEVER held back
|
|
1368
|
+
// by a per-record failure. A failed record is classified: a PROVABLY-TRANSIENT save error is retried
|
|
1369
|
+
// inline within the batch (ApplyRecords); a PERMANENT (or retry-exhausted) failure is dead-lettered —
|
|
1370
|
+
// counted errored, logged to result.Errors (queryable over GraphQL), and the sync moves on. Holding
|
|
1371
|
+
// the watermark at the last-clean batch was the old safe-floor; it poison-pilled the stream when an
|
|
1372
|
+
// EARLY record failed permanently (the whole window re-fetched + re-failed every run, watermark frozen
|
|
1373
|
+
// at the start). Recovery for dead-lettered records is an operator-triggered full sync (which ignores
|
|
1374
|
+
// the watermark). The run still reports Status='Failed' whenever ANY record errored.
|
|
1045
1375
|
let recordsInMap = 0;
|
|
1046
1376
|
let currentPage;
|
|
1047
1377
|
let currentOffset;
|
|
@@ -1050,8 +1380,14 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1050
1380
|
let batchCount = 0;
|
|
1051
1381
|
let previousBatchFingerprint;
|
|
1052
1382
|
let fetchCompletedCleanly = true; // flipped to false if fetch aborted or errored mid-way
|
|
1383
|
+
let hadFetchGap = false; // ≥1 page was skipped after a persistent fetch error (offset/page paging)
|
|
1384
|
+
let fetchGapCount = 0; // CONSECUTIVE skipped pages (reset on any clean fetch)
|
|
1385
|
+
const MAX_FETCH_GAPS = 25; // give up + hold the watermark if this many pages fail in a row (API down)
|
|
1386
|
+
let consecutiveEmptyBatches = 0; // P3-D: detect a connector that pages empty-but-HasMore forever
|
|
1053
1387
|
const MAX_BATCHES_PER_MAP = 5000;
|
|
1388
|
+
const EMPTY_BATCH_WARN_THRESHOLD = 5; // warn once after this many empty-but-HasMore batches in a row
|
|
1054
1389
|
const fetchedExternalIDs = new Set(); // Track all IDs seen during this pull for orphan detection
|
|
1390
|
+
let orphanTrackingOverflowed = false; // set if the ID set exceeds ORPHAN_DETECTION_MAX_IDS → skip the sweep, don't OOM
|
|
1055
1391
|
const accumulatedMapped = []; // partition-reconcile mode: collect mapped records, apply post-loop
|
|
1056
1392
|
while (hasMore) {
|
|
1057
1393
|
if (abortSignal?.aborted) {
|
|
@@ -1076,6 +1412,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1076
1412
|
CurrentOffset: currentOffset,
|
|
1077
1413
|
CurrentCursor: currentCursor,
|
|
1078
1414
|
AfterKeyValue: currentAfterKey ?? null, // §7 keyset/seek resume (connector opt-in)
|
|
1415
|
+
// §7: expose the per-credential adaptive AIMD bucket + concurrency cap so a connector's
|
|
1416
|
+
// INNER request loop (second-layer / parent-iterated objects) is governed by the SAME
|
|
1417
|
+
// adaptive rate as the object level, instead of a fixed self-throttle that defeats
|
|
1418
|
+
// concurrency and ignores 429 back-off. Back-compat: connectors that ignore these are unchanged.
|
|
1419
|
+
RateLimitAcquire: () => this.rateLimit(config),
|
|
1420
|
+
RateLimitReport: (throttledErr) => this.reportRateOutcome(config, throttledErr),
|
|
1421
|
+
MaxConcurrency: Math.max(1, this.getConfigOverrides(config).maxConcurrency ?? config.connector.MaxConcurrencyHint ?? 4),
|
|
1079
1422
|
};
|
|
1080
1423
|
logger?.emit('sync.fetch.batch.start', {
|
|
1081
1424
|
externalObjectName: entityMap.ExternalObjectName,
|
|
@@ -1090,8 +1433,18 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1090
1433
|
const fetchStart = Date.now();
|
|
1091
1434
|
try {
|
|
1092
1435
|
await this.rateLimit(config);
|
|
1093
|
-
|
|
1436
|
+
// Resilient fetch: bound each attempt with a timeout (a hung vendor API must not
|
|
1437
|
+
// hold the sync lock forever) and retry only transient errors (network/throttle/DB).
|
|
1438
|
+
// A non-retryable error (auth, 4xx, parse) throws immediately as before.
|
|
1439
|
+
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', {
|
|
1440
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
1441
|
+
batchIndex: batchCount,
|
|
1442
|
+
attempt,
|
|
1443
|
+
delayMs,
|
|
1444
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1445
|
+
}));
|
|
1094
1446
|
this.reportRateOutcome(config); // clean fetch → ramp the adaptive rate back up
|
|
1447
|
+
fetchGapCount = 0; // clean fetch → reset the consecutive fetch-gap counter
|
|
1095
1448
|
// §10: connector type-driven post-processing hook (default no-op) — enforce/normalize
|
|
1096
1449
|
// record values to their resolved formats before mapping + write.
|
|
1097
1450
|
if (batch.Records.length > 0) {
|
|
@@ -1101,9 +1454,12 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1101
1454
|
catch (fetchErr) {
|
|
1102
1455
|
const errMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
|
1103
1456
|
// A throttle (429 / rate-limit) backs the adaptive limiter off (honoring Retry-After);
|
|
1104
|
-
// other errors don't touch the rate.
|
|
1105
|
-
|
|
1457
|
+
// other errors don't touch the rate. §5 Gap 2: also flag the map result so the per-layer
|
|
1458
|
+
// AIMD controller reduces in-flight concurrency, not just the per-request token bucket.
|
|
1459
|
+
if (ClassifyError(fetchErr).Code === 'RATE_LIMIT_EXCEEDED') {
|
|
1106
1460
|
this.reportRateOutcome(config, fetchErr);
|
|
1461
|
+
result.Throttled = true;
|
|
1462
|
+
}
|
|
1107
1463
|
console.error(`[IntegrationEngine] FetchChanges error for ${entityMap.ExternalObjectName}: ${errMsg}`);
|
|
1108
1464
|
logger?.emit('sync.record.error', {
|
|
1109
1465
|
phase: 'fetch',
|
|
@@ -1111,6 +1467,25 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1111
1467
|
batchIndex: batchCount,
|
|
1112
1468
|
error: errMsg,
|
|
1113
1469
|
});
|
|
1470
|
+
// Resilience: a persistent fetch failure on ONE page shouldn't abandon the whole object.
|
|
1471
|
+
// POSITION-based paging (offset/page) can step past the failed page and keep going; we mark
|
|
1472
|
+
// the fetch incomplete (so the watermark is HELD below + the orphan/partition sweep is skipped)
|
|
1473
|
+
// — the skipped window is re-fetched next run (idempotent upsert + content-hash skip reconcile
|
|
1474
|
+
// it), no data lost. CURSOR paging CAN'T continue (the next cursor lives in the failed response).
|
|
1475
|
+
const canSkipPage = currentOffset != null || currentPage != null;
|
|
1476
|
+
if (canSkipPage && fetchGapCount < MAX_FETCH_GAPS) {
|
|
1477
|
+
fetchGapCount++;
|
|
1478
|
+
hadFetchGap = true;
|
|
1479
|
+
fetchCompletedCleanly = false;
|
|
1480
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_PAGE_SKIPPED', `Persistent fetch error at ${currentOffset != null ? 'offset ' + currentOffset : 'page ' + currentPage} for ` +
|
|
1481
|
+
`'${entityMap.ExternalObjectName}' (batch ${batchCount}); skipped this page and continued — the ` +
|
|
1482
|
+
`watermark is held so the window is re-fetched next run. Error: ${errMsg}`, { offset: currentOffset ?? null, page: currentPage ?? null, batchIndex: batchCount, error: errMsg });
|
|
1483
|
+
if (currentOffset != null)
|
|
1484
|
+
currentOffset += this.MaxBatchSize;
|
|
1485
|
+
else if (currentPage != null)
|
|
1486
|
+
currentPage += 1;
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1114
1489
|
fetchCompletedCleanly = false;
|
|
1115
1490
|
break;
|
|
1116
1491
|
}
|
|
@@ -1149,22 +1524,44 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1149
1524
|
}
|
|
1150
1525
|
previousBatchFingerprint = fingerprint;
|
|
1151
1526
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1527
|
+
if (!orphanTrackingOverflowed) {
|
|
1528
|
+
for (const rec of batch.Records) {
|
|
1529
|
+
fetchedExternalIDs.add(rec.ExternalID);
|
|
1530
|
+
}
|
|
1531
|
+
// OOM guard: a multi-million-row full sync would grow this set without bound. Past the
|
|
1532
|
+
// ceiling, drop it + flag so orphan detection is skipped (a SyncWarning is emitted below)
|
|
1533
|
+
// rather than risk crashing the run.
|
|
1534
|
+
if (fetchedExternalIDs.size > ORPHAN_DETECTION_MAX_IDS) {
|
|
1535
|
+
orphanTrackingOverflowed = true;
|
|
1536
|
+
fetchedExternalIDs.clear();
|
|
1537
|
+
}
|
|
1154
1538
|
}
|
|
1155
1539
|
const mapped = this.fieldMappingEngine.Apply(batch.Records, fieldMaps, entityMap.Entity);
|
|
1156
1540
|
// Partition (Merkle) reconcile defers match + apply: accumulate mapped records now; the
|
|
1157
1541
|
// partition-diff + selective apply runs once after the full fetch (applyViaPartitionReconcile).
|
|
1158
|
-
if (partitionReconcile)
|
|
1542
|
+
if (partitionReconcile) {
|
|
1159
1543
|
accumulatedMapped.push(...mapped);
|
|
1544
|
+
// OOM guard: this mode buffers the ENTIRE fetched set in RAM. Past a generous ceiling,
|
|
1545
|
+
// fail loud (caught by the per-map error path → other maps continue) instead of crashing
|
|
1546
|
+
// the whole run with an OOM. The default streaming path has no such limit.
|
|
1547
|
+
if (accumulatedMapped.length > PARTITION_RECONCILE_MAX_ACCUMULATION) {
|
|
1548
|
+
throw new Error(`partitionReconcile accumulated ${accumulatedMapped.length} records for ` +
|
|
1549
|
+
`${entityMap.ExternalObjectName} (cap ${PARTITION_RECONCILE_MAX_ACCUMULATION}). This mode ` +
|
|
1550
|
+
`buffers the whole set in RAM and is unsafe at this size — disable Configuration.partitionReconcile ` +
|
|
1551
|
+
`for this object to stream via per-record content-hash instead.`);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
// Serialize the match READ too (record-map / PK lookups). On a shared provider connection
|
|
1555
|
+
// a read routes through whatever transaction is active, so a match read in this stream
|
|
1556
|
+
// collides with another concurrent stream's in-flight write transaction ("Transaction has
|
|
1557
|
+
// not begun"). Holding the same write-lock for the read keeps the connection single-owner.
|
|
1160
1558
|
const resolved = partitionReconcile
|
|
1161
1559
|
? []
|
|
1162
|
-
: await this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser);
|
|
1560
|
+
: await this.runWriteExclusive(() => this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser));
|
|
1163
1561
|
const beforeApply = result.RecordsCreated + result.RecordsUpdated + result.RecordsSkipped + result.RecordsErrored;
|
|
1164
|
-
const erroredBeforeApply = result.RecordsErrored;
|
|
1165
1562
|
try {
|
|
1166
1563
|
if (!partitionReconcile)
|
|
1167
|
-
await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger);
|
|
1564
|
+
await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1);
|
|
1168
1565
|
}
|
|
1169
1566
|
catch (applyErr) {
|
|
1170
1567
|
if (applyErr instanceof SchemaNotGeneratedError) {
|
|
@@ -1200,7 +1597,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1200
1597
|
(batch.HasMore ? ` | more batches pending` : ` | batch complete`));
|
|
1201
1598
|
// Update progress on the watermark record so the DB reflects live sync state
|
|
1202
1599
|
if (batch.HasMore) {
|
|
1203
|
-
await this.watermarkService.UpdateProgress(entityMapID, afterApply, contextUser);
|
|
1600
|
+
await this.runWriteExclusive(() => this.watermarkService.UpdateProgress(entityMapID, afterApply, contextUser));
|
|
1204
1601
|
}
|
|
1205
1602
|
}
|
|
1206
1603
|
recordsInMap += batch.Records.length;
|
|
@@ -1209,11 +1606,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1209
1606
|
this.emitProgress(onProgress, entityMapIndex, totalEntityMaps, recordsInMap, recordsInMap);
|
|
1210
1607
|
}
|
|
1211
1608
|
if (batch.NewWatermarkValue) {
|
|
1212
|
-
currentWatermark = batch.NewWatermarkValue;
|
|
1213
|
-
// Only raise the safe floor when this batch applied with ZERO errors. A batch that rolled back
|
|
1214
|
-
// (RecordsErrored increased) must NOT advance the floor, so its records stay re-fetchable next run.
|
|
1215
|
-
if (result.RecordsErrored === erroredBeforeApply)
|
|
1216
|
-
lastCleanWatermark = currentWatermark;
|
|
1609
|
+
currentWatermark = batch.NewWatermarkValue; // §10 — track the max seen; failures never hold it back
|
|
1217
1610
|
}
|
|
1218
1611
|
currentPage = batch.NextPage;
|
|
1219
1612
|
currentOffset = batch.NextOffset;
|
|
@@ -1238,9 +1631,22 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1238
1631
|
// post-loop save below handles graceful early-exits precisely; this covers a SIGKILL
|
|
1239
1632
|
// between graceful checkpoints, costing at most ~25 batches of re-fetch on resume.
|
|
1240
1633
|
if (isKeysetConnector && currentAfterKey) {
|
|
1241
|
-
await this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser);
|
|
1634
|
+
await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
// P3-D: a connector returning empty pages with HasMore=true would otherwise spin silently
|
|
1638
|
+
// to MAX_BATCHES_PER_MAP. Surface a structured warning once the empty streak crosses the
|
|
1639
|
+
// threshold so the connector bug is visible, not buried.
|
|
1640
|
+
if (batch.Records.length === 0 && batch.HasMore === true) {
|
|
1641
|
+
consecutiveEmptyBatches++;
|
|
1642
|
+
if (consecutiveEmptyBatches === EMPTY_BATCH_WARN_THRESHOLD) {
|
|
1643
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'CONSECUTIVE_EMPTY_BATCHES', `'${entityMap.ExternalObjectName}' returned ${EMPTY_BATCH_WARN_THRESHOLD} empty batches in a row while still reporting HasMore=true ` +
|
|
1644
|
+
`(batch ${batchCount}/${MAX_BATCHES_PER_MAP}). Likely a connector pagination bug (cursor not advancing / HasMore stuck true).`, { batchCount, consecutiveEmptyBatches });
|
|
1242
1645
|
}
|
|
1243
1646
|
}
|
|
1647
|
+
else {
|
|
1648
|
+
consecutiveEmptyBatches = 0;
|
|
1649
|
+
}
|
|
1244
1650
|
hasMore = batch.HasMore === true; // Explicit boolean check — prevents truthy undefined from looping
|
|
1245
1651
|
}
|
|
1246
1652
|
// Partition (Merkle) reconcile: the full set is now accumulated — diff it against last sync's
|
|
@@ -1253,12 +1659,15 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1253
1659
|
// The rollup snapshot (not a timestamp) was saved by applyViaPartitionReconcile above.
|
|
1254
1660
|
result.WatermarkAfter = null;
|
|
1255
1661
|
}
|
|
1256
|
-
else if (fetchCompletedCleanly && isKeysetConnector) {
|
|
1257
|
-
// A clean keyset scan covered the whole ordering range.
|
|
1258
|
-
// timestamp filter — the next scheduled sync re-seeks from the start
|
|
1259
|
-
// unchanged rows write-free) — so clear the resume marker rather than
|
|
1260
|
-
// into it, which the restore logic would otherwise mis-read as a seek key.
|
|
1261
|
-
|
|
1662
|
+
else if (fetchCompletedCleanly && isKeysetConnector && !config.connector.MonotonicWatermark) {
|
|
1663
|
+
// A clean keyset scan covered the whole ordering range. A PURE-keyset connector (no reliable
|
|
1664
|
+
// watermark) has no timestamp filter — the next scheduled sync re-seeks from the start
|
|
1665
|
+
// (content-hash keeps unchanged rows write-free) — so clear the resume marker rather than
|
|
1666
|
+
// writing a timestamp into it, which the restore logic would otherwise mis-read as a seek key.
|
|
1667
|
+
// NOTE: a connector that ALSO returns a monotonic watermark (MonotonicWatermark=true) skips
|
|
1668
|
+
// this branch and falls through to SAVE that watermark below, so its next incremental NARROWS
|
|
1669
|
+
// (microtime > watermark) instead of re-scanning the whole object every run.
|
|
1670
|
+
await this.runWriteExclusive(() => this.watermarkService.ClearKeysetPosition(entityMapID, contextUser));
|
|
1262
1671
|
result.WatermarkAfter = null;
|
|
1263
1672
|
}
|
|
1264
1673
|
else if (fetchCompletedCleanly) {
|
|
@@ -1281,22 +1690,31 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1281
1690
|
// but at least a watermark row exists for bookkeeping.
|
|
1282
1691
|
let finalWatermark;
|
|
1283
1692
|
if (currentWatermark) {
|
|
1284
|
-
//
|
|
1285
|
-
//
|
|
1286
|
-
//
|
|
1287
|
-
|
|
1288
|
-
|
|
1693
|
+
// §10 — advance to the max watermark seen, ALWAYS. Errored records are dead-lettered (logged +
|
|
1694
|
+
// counted), never held against the watermark — a permanently-failing record must not freeze the
|
|
1695
|
+
// stream. A full sync advances to wall-clock "now" — EXCEPT a connector whose watermark is a
|
|
1696
|
+
// reliable monotonic max (MonotonicWatermark=true): for it, "now" in the source's OWN watermark
|
|
1697
|
+
// format IS currentWatermark (the max seen), so advancing to that — not an ISO timestamp the
|
|
1698
|
+
// connector can't compare against — lets the next incremental narrow.
|
|
1699
|
+
const incrementalWatermark = currentWatermark;
|
|
1700
|
+
// D2 clock-skew safety net: a clean full sync advances to wall-clock "now", but if the source's
|
|
1701
|
+
// own max watermark is AHEAD of our clock, advancing to "now" would skip the (now, sourceMax]
|
|
1702
|
+
// window on the next incremental — so never advance the watermark below the max value seen.
|
|
1703
|
+
let fullSyncWatermark = new Date().toISOString();
|
|
1704
|
+
if (currentWatermark > fullSyncWatermark)
|
|
1705
|
+
fullSyncWatermark = currentWatermark;
|
|
1706
|
+
finalWatermark = config.fullSync && !config.connector.MonotonicWatermark ? fullSyncWatermark : incrementalWatermark;
|
|
1289
1707
|
}
|
|
1290
1708
|
else {
|
|
1291
1709
|
finalWatermark = new Date().toISOString();
|
|
1292
1710
|
}
|
|
1293
|
-
await this.watermarkService.Update(entityMapID, finalWatermark, contextUser, 'Pull');
|
|
1711
|
+
await this.runWriteExclusive(() => this.watermarkService.Update(entityMapID, finalWatermark, contextUser, 'Pull'));
|
|
1294
1712
|
result.WatermarkAfter = finalWatermark;
|
|
1295
1713
|
}
|
|
1296
1714
|
else if (isKeysetConnector && currentAfterKey) {
|
|
1297
1715
|
// The keyset scan stopped early (cancel / fetch error / safety limit). Persist the precise
|
|
1298
1716
|
// last ordering key so the next run resumes the seek from here instead of restarting.
|
|
1299
|
-
await this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser);
|
|
1717
|
+
await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
|
|
1300
1718
|
result.WatermarkAfter = currentAfterKey;
|
|
1301
1719
|
}
|
|
1302
1720
|
// Orphan detection: delete/tombstone MJ records whose external counterpart no longer exists.
|
|
@@ -1306,6 +1724,21 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1306
1724
|
if ((config.fullSync || partitionReconcile) && fetchedExternalIDs.size > 0 && fetchCompletedCleanly) {
|
|
1307
1725
|
await this.DeleteOrphanedRecords(config.companyIntegration, entityMap, fetchedExternalIDs, result, contextUser, logger);
|
|
1308
1726
|
}
|
|
1727
|
+
else if (orphanTrackingOverflowed && fetchCompletedCleanly) {
|
|
1728
|
+
// We deliberately stopped tracking IDs to avoid OOM — orphan/delete detection can't run
|
|
1729
|
+
// safely this pass (a partial set would delete live records). Surface it, don't hide it.
|
|
1730
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHAN_DETECTION_SKIPPED_TOO_LARGE', `'${entityMap.ExternalObjectName}' returned more than ${ORPHAN_DETECTION_MAX_IDS.toLocaleString()} records, ` +
|
|
1731
|
+
`so orphan/delete detection was skipped this run to avoid excessive memory use. Records were synced normally; ` +
|
|
1732
|
+
`deletions in the source will not be reflected until a smaller/incremental run or a raised cap.`, { cap: ORPHAN_DETECTION_MAX_IDS });
|
|
1733
|
+
}
|
|
1734
|
+
// If any page was skipped after a persistent fetch error, surface ONE summary warning so the
|
|
1735
|
+
// operator knows this object's data is INCOMPLETE this run (the per-page warnings carry the
|
|
1736
|
+
// offsets). The watermark was held above, so the skipped window is re-fetched on the next run.
|
|
1737
|
+
if (hadFetchGap) {
|
|
1738
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_INCOMPLETE_PAGES_SKIPPED', `'${entityMap.ExternalObjectName}' finished with one or more pages skipped after persistent fetch errors — ` +
|
|
1739
|
+
`the result set is INCOMPLETE. The watermark was held, so the skipped window is re-fetched next run; ` +
|
|
1740
|
+
`records on the reachable pages were synced normally.`, { skipped: true });
|
|
1741
|
+
}
|
|
1309
1742
|
await this.CreateRunDetail(run, entityMap, result, contextUser);
|
|
1310
1743
|
return result;
|
|
1311
1744
|
}
|
|
@@ -1477,13 +1910,26 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1477
1910
|
*/
|
|
1478
1911
|
async LoadAllMJRecords(entityMap, companyIntegration, contextUser) {
|
|
1479
1912
|
const rv = new RunView();
|
|
1480
|
-
// Load all records from the MJ entity
|
|
1913
|
+
// Load all records from the MJ entity. OOM SAFETY VALVE: a full push materializes every row of
|
|
1914
|
+
// the MJ entity in RAM. Past a generous ceiling, fail LOUD + actionable (caught by the per-map
|
|
1915
|
+
// error path → other maps continue) rather than risk an OOM that kills the whole run. A genuine
|
|
1916
|
+
// full push of a multi-million-row entity should use incremental push or raise the limit; we do
|
|
1917
|
+
// NOT silently partial-push (that would advance the watermark past unsent rows → dropped data).
|
|
1918
|
+
// NOTE: a true streaming/keyset push refactor is the proper long-term fix; this guard makes the
|
|
1919
|
+
// failure mode safe in the meantime without touching the delicate push-watermark clamp logic.
|
|
1481
1920
|
const allResult = await rv.RunView({
|
|
1482
1921
|
EntityName: entityMap.Entity,
|
|
1483
1922
|
ResultType: 'simple',
|
|
1923
|
+
MaxRows: FULL_PUSH_MAX_RECORDS + 1, // +1 so we can detect "exceeded" vs "exactly at cap"
|
|
1484
1924
|
}, contextUser);
|
|
1485
1925
|
if (!allResult.Success || allResult.Results.length === 0)
|
|
1486
1926
|
return [];
|
|
1927
|
+
if (allResult.Results.length > FULL_PUSH_MAX_RECORDS) {
|
|
1928
|
+
throw new Error(`Full push of '${entityMap.Entity}' exceeds the in-memory safety limit of ` +
|
|
1929
|
+
`${FULL_PUSH_MAX_RECORDS.toLocaleString()} records. A full push loads every row into memory; ` +
|
|
1930
|
+
`use incremental push (set a push watermark) or raise MJ_INTEGRATION_FULL_PUSH_MAX_RECORDS. ` +
|
|
1931
|
+
`Refusing to partial-push (that would advance the watermark past unsent rows and drop data).`);
|
|
1932
|
+
}
|
|
1487
1933
|
// Load existing record maps to know which records already exist externally
|
|
1488
1934
|
const mapResult = await rv.RunView({
|
|
1489
1935
|
EntityName: 'MJ: Company Integration Record Maps',
|
|
@@ -1722,7 +2168,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1722
2168
|
resolution,
|
|
1723
2169
|
});
|
|
1724
2170
|
if (manualConflict) {
|
|
1725
|
-
await this.markConflictOnMJRecord(change.RecordID, entityMap, conflictFields, contextUser);
|
|
2171
|
+
await this.markConflictOnMJRecord(change.RecordID, entityMap, conflictFields, contextUser, logger);
|
|
1726
2172
|
return { action: 'skip', attributes: {} };
|
|
1727
2173
|
}
|
|
1728
2174
|
}
|
|
@@ -1749,7 +2195,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1749
2195
|
return mostRecentWinner(mjFields['__mj_UpdatedAt'], ext.ModifiedAt);
|
|
1750
2196
|
}
|
|
1751
2197
|
/** Marks an MJ mirror record in-conflict (Manual resolution) via its standard sync columns. Best-effort. */
|
|
1752
|
-
async markConflictOnMJRecord(mjRecordID, entityMap, conflictFields, contextUser) {
|
|
2198
|
+
async markConflictOnMJRecord(mjRecordID, entityMap, conflictFields, contextUser, logger) {
|
|
1753
2199
|
try {
|
|
1754
2200
|
const md = this.ProviderToUse;
|
|
1755
2201
|
const entity = await md.GetEntityObject(entityMap.Entity, contextUser);
|
|
@@ -1765,10 +2211,15 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1765
2211
|
if (hasField('__mj_integration_SyncMessage')) {
|
|
1766
2212
|
entity.Set('__mj_integration_SyncMessage', `Bidirectional conflict: external changed ${conflictFields.join(', ')} since last sync; awaiting manual resolution.`);
|
|
1767
2213
|
}
|
|
1768
|
-
|
|
2214
|
+
// Surface a failed conflict-mark: the engine thinks the row is quarantined, but without the
|
|
2215
|
+
// marker the operator has no signal. A silent failure here leaves the record in limbo.
|
|
2216
|
+
const ok = await entity.Save();
|
|
2217
|
+
if (!ok) {
|
|
2218
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.Entity ?? entityMap.ID, 'CONFLICT_MARK_FAILED', `Could not mark MJ record ${mjRecordID} in-conflict: ${entity.LatestResult?.CompleteMessage ?? 'Save() returned false'}`, { mjRecordID, conflictFields });
|
|
2219
|
+
}
|
|
1769
2220
|
}
|
|
1770
|
-
catch {
|
|
1771
|
-
|
|
2221
|
+
catch (markErr) {
|
|
2222
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.Entity ?? entityMap.ID, 'CONFLICT_MARK_FAILED', `Could not mark MJ record ${mjRecordID} in-conflict: ${markErr instanceof Error ? markErr.message : String(markErr)}`, { mjRecordID, conflictFields });
|
|
1772
2223
|
}
|
|
1773
2224
|
}
|
|
1774
2225
|
/**
|
|
@@ -1916,7 +2367,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1916
2367
|
const buckets = partitionRecords(mappedRecords, idOf, partitionOf);
|
|
1917
2368
|
const newRollups = new Map();
|
|
1918
2369
|
for (const [partition, recs] of buckets) {
|
|
1919
|
-
newRollups.set(partition, partitionRollupHash(recs, r => r.MappedFields));
|
|
2370
|
+
newRollups.set(partition, partitionRollupHash(recs, r => contentHashBasis(r.MappedFields, r.UnmappedFields)));
|
|
1920
2371
|
}
|
|
1921
2372
|
// Diff against last sync's snapshot; only changed/added partitions need a deep apply. On a FORCED
|
|
1922
2373
|
// FULL SYNC, treat the snapshot as empty so EVERY partition is re-applied: fullSync is the operator's
|
|
@@ -1943,8 +2394,12 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1943
2394
|
result.RecordsSkipped += recs.length;
|
|
1944
2395
|
continue;
|
|
1945
2396
|
}
|
|
1946
|
-
|
|
1947
|
-
|
|
2397
|
+
// D3: serialize the match READ through the same write-mutex the non-partition path uses
|
|
2398
|
+
// (~line 1644). matchEngine.Resolve reads existing MJ rows on the SHARED provider
|
|
2399
|
+
// connection, so when streams run in parallel (syncConcurrency>1) it must not interleave
|
|
2400
|
+
// with another stream's open write transaction (else "Transaction in progress" / dirty read).
|
|
2401
|
+
const resolved = await this.runWriteExclusive(() => this.matchEngine.Resolve(recs, entityMap, fieldMaps, contextUser));
|
|
2402
|
+
await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1);
|
|
1948
2403
|
appliedRecords += recs.length;
|
|
1949
2404
|
}
|
|
1950
2405
|
}
|
|
@@ -1973,7 +2428,16 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1973
2428
|
/**
|
|
1974
2429
|
* Applies resolved records to MJ, handling each individually for error isolation.
|
|
1975
2430
|
*/
|
|
1976
|
-
async ApplyRecords(records, companyIntegration, entityMap, result, contextUser, logger
|
|
2431
|
+
async ApplyRecords(records, companyIntegration, entityMap, result, contextUser, logger,
|
|
2432
|
+
// OPT-IN concurrency (syncConcurrency>1): when false, the batch is applied WITHOUT a provider
|
|
2433
|
+
// transaction (per-record auto-commit on pooled connections). Rationale: the provider holds ONE
|
|
2434
|
+
// global transaction bound to one connection, so a held transaction makes every concurrent
|
|
2435
|
+
// stream's fetch-phase reads (credentials, etc.) collide on that connection. Running the
|
|
2436
|
+
// concurrent write transaction-free keeps the global transaction null → zero collisions. The
|
|
2437
|
+
// lost batch atomicity is absorbed by the engine's idempotency (upsert-by-identity + content
|
|
2438
|
+
// hash) and the safe-floor watermark (advances only on a clean batch). Default true = the
|
|
2439
|
+
// proven atomic serial path, unchanged.
|
|
2440
|
+
useTransaction = true) {
|
|
1977
2441
|
// Batched application with per-record failure isolation (the "grace gap" fix).
|
|
1978
2442
|
// Happy path: each batch of up to APPLY_BATCH_SIZE records commits as a single
|
|
1979
2443
|
// transaction — small enough to avoid SQL Server lock escalation (~5000 rows) while
|
|
@@ -1995,47 +2459,91 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1995
2459
|
// otherwise load one-by-one. For a watermark-less re-sync where nothing changed,
|
|
1996
2460
|
// this lets UpdateRecord skip every per-record load. Best-effort: undefined → the
|
|
1997
2461
|
// existing dirty-flag path runs unchanged.
|
|
1998
|
-
|
|
1999
|
-
//
|
|
2000
|
-
//
|
|
2001
|
-
//
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2462
|
+
// Serialize the per-batch DB-write across concurrently-synced streams (shared provider
|
|
2463
|
+
// connection ⇒ one transaction at a time). Fetch already happened in parallel upstream;
|
|
2464
|
+
// only this write section is mutually exclusive. A throw inside (e.g. SchemaNotGenerated)
|
|
2465
|
+
// propagates out to fail-stop this entity map, exactly as before.
|
|
2466
|
+
await this.runWriteExclusive(async () => {
|
|
2467
|
+
const precheckHashes = await this.PrefetchContentHashes(batch, contextUser);
|
|
2468
|
+
// PKs of records the content-hash fast path skipped this batch — still present and
|
|
2469
|
+
// confirmed-unchanged on the source. Collected so we can refresh LastReconciledAt for
|
|
2470
|
+
// all of them in ONE set-based touch after the batch (instead of a frozen-forever stamp).
|
|
2471
|
+
let reconciledSkipIds = [];
|
|
2472
|
+
if (useTransaction) {
|
|
2473
|
+
await provider.BeginTransaction();
|
|
2474
|
+
try {
|
|
2475
|
+
for (const record of batch) {
|
|
2476
|
+
result.RecordsProcessed++;
|
|
2477
|
+
await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
|
|
2478
|
+
}
|
|
2479
|
+
await provider.CommitTransaction();
|
|
2480
|
+
}
|
|
2481
|
+
catch (err) {
|
|
2482
|
+
await provider.RollbackTransaction();
|
|
2483
|
+
// The batch transaction rolled back; the skip-IDs collected during the failed attempt
|
|
2484
|
+
// never committed. Reset and let the per-record retry re-collect only what commits.
|
|
2485
|
+
reconciledSkipIds = [];
|
|
2486
|
+
// Roll back the in-memory counters that ApplySingleRecord bumped inside the failed batch
|
|
2487
|
+
result.RecordsProcessed = batchStartProcessed;
|
|
2488
|
+
result.RecordsCreated = batchStartCreated;
|
|
2489
|
+
result.RecordsUpdated = batchStartUpdated;
|
|
2490
|
+
result.RecordsDeleted = batchStartDeleted;
|
|
2491
|
+
result.RecordsSkipped = batchStartSkipped;
|
|
2492
|
+
// SchemaNotGeneratedError is per-entity-deterministic — every record in
|
|
2493
|
+
// this object will fail the same way. Bubble it up so ProcessPullSync
|
|
2494
|
+
// can fail-stop the entityMap with one log line instead of producing
|
|
2495
|
+
// per-record duplicates. Rollback + counter restore above already ran.
|
|
2496
|
+
if (err instanceof SchemaNotGeneratedError) {
|
|
2497
|
+
throw err;
|
|
2498
|
+
}
|
|
2499
|
+
// Degrade to per-record application so the failure isolates to the poison
|
|
2500
|
+
// record(s) and every good record in this batch still commits.
|
|
2501
|
+
await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
|
|
2502
|
+
}
|
|
2008
2503
|
}
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2504
|
+
else {
|
|
2505
|
+
// OPT-IN concurrent path (syncConcurrency>1): NO batch transaction. Each record
|
|
2506
|
+
// auto-commits on its own pooled connection, so the global transaction is never
|
|
2507
|
+
// held and concurrent streams' fetch-phase reads can't collide on the shared
|
|
2508
|
+
// connection. Per-record error isolation: a poison record is logged + counted; the
|
|
2509
|
+
// rest still commit; the idempotent re-sync + safe-floor watermark reconcile any
|
|
2510
|
+
// partial batch (the atomicity the transactional path provides is not needed here).
|
|
2511
|
+
for (const record of batch) {
|
|
2512
|
+
result.RecordsProcessed++;
|
|
2513
|
+
try {
|
|
2514
|
+
// §10 — bounded inline retry for provably-transient save failures (auto-commit per
|
|
2515
|
+
// record, so no transaction to manage); permanent errors throw straight to dead-letter.
|
|
2516
|
+
await WithRetry(() => this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds), undefined, (e) => !(e instanceof SchemaNotGeneratedError) && IsRetryableError(ClassifyError(e).Code), (attempt, e, delayMs) => logger?.emit('sync.record.retry', {
|
|
2517
|
+
phase: 'save',
|
|
2518
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
2519
|
+
externalId: record.ExternalRecord?.ExternalID ?? '',
|
|
2520
|
+
attempt, delayMs, errorCode: ClassifyError(e).Code,
|
|
2521
|
+
}));
|
|
2522
|
+
}
|
|
2523
|
+
catch (err) {
|
|
2524
|
+
if (err instanceof SchemaNotGeneratedError) {
|
|
2525
|
+
throw err;
|
|
2526
|
+
}
|
|
2527
|
+
// §10 — permanent / retry-exhausted → dead-letter (count + log), move on; watermark advances regardless.
|
|
2528
|
+
result.RecordsErrored++;
|
|
2529
|
+
const classified = ClassifyError(err);
|
|
2530
|
+
result.Errors.push({
|
|
2531
|
+
ExternalID: record.ExternalRecord?.ExternalID ?? '',
|
|
2532
|
+
ChangeType: record.ChangeType ?? 'Create',
|
|
2533
|
+
ErrorMessage: err instanceof Error ? err.message : String(err),
|
|
2534
|
+
ErrorCode: classified.Code,
|
|
2535
|
+
Severity: classified.Severity,
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2028
2539
|
}
|
|
2029
|
-
//
|
|
2030
|
-
//
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
if (reconciledSkipIds.length > 0) {
|
|
2037
|
-
await this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger);
|
|
2038
|
-
}
|
|
2540
|
+
// After the batch settles (committed, or per-record retried), refresh
|
|
2541
|
+
// LastReconciledAt for every content-hash-skipped row in ONE set-based touch.
|
|
2542
|
+
// Best-effort — a touch failure must never break the sync.
|
|
2543
|
+
if (reconciledSkipIds.length > 0) {
|
|
2544
|
+
await this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger);
|
|
2545
|
+
}
|
|
2546
|
+
});
|
|
2039
2547
|
}
|
|
2040
2548
|
}
|
|
2041
2549
|
/**
|
|
@@ -2100,20 +2608,44 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2100
2608
|
async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds) {
|
|
2101
2609
|
const provider = this.ProviderToUse;
|
|
2102
2610
|
for (const record of batch) {
|
|
2103
|
-
|
|
2611
|
+
result.RecordsProcessed++;
|
|
2104
2612
|
try {
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2613
|
+
// §10 — apply in its own transaction, with bounded inline retry for PROVABLY-TRANSIENT
|
|
2614
|
+
// save failures (NETWORK_TIMEOUT / RATE_LIMIT_EXCEEDED / DATABASE_ERROR per IsRetryableError).
|
|
2615
|
+
// Each attempt rolls back on throw so the next starts clean; a deadlock/momentary timeout
|
|
2616
|
+
// self-heals here. A PERMANENT error (validation/FK/duplicate/config) is NOT retried — it
|
|
2617
|
+
// throws straight out to the dead-letter path below.
|
|
2618
|
+
await WithRetry(async () => {
|
|
2619
|
+
await provider.BeginTransaction();
|
|
2620
|
+
try {
|
|
2621
|
+
await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
|
|
2622
|
+
await provider.CommitTransaction();
|
|
2623
|
+
}
|
|
2624
|
+
catch (e) {
|
|
2625
|
+
await provider.RollbackTransaction();
|
|
2626
|
+
throw e;
|
|
2627
|
+
}
|
|
2628
|
+
}, undefined, (err) => !(err instanceof SchemaNotGeneratedError) && IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => logger?.emit('sync.record.retry', {
|
|
2629
|
+
phase: 'save',
|
|
2630
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
2631
|
+
externalId: record.ExternalRecord.ExternalID,
|
|
2632
|
+
changeType: record.ChangeType,
|
|
2633
|
+
attempt,
|
|
2634
|
+
delayMs,
|
|
2635
|
+
errorCode: ClassifyError(err).Code,
|
|
2636
|
+
}));
|
|
2108
2637
|
}
|
|
2109
2638
|
catch (recErr) {
|
|
2110
|
-
await provider.RollbackTransaction();
|
|
2111
2639
|
// A schema-not-generated failure on one record means EVERY record in this
|
|
2112
2640
|
// object will fail identically — bubble it up so the entityMap fail-stops
|
|
2113
2641
|
// once rather than emitting per-record duplicates for the whole batch.
|
|
2114
2642
|
if (recErr instanceof SchemaNotGeneratedError) {
|
|
2115
2643
|
throw recErr;
|
|
2116
2644
|
}
|
|
2645
|
+
// §10 — permanent (or retry-exhausted transient) failure → DEAD-LETTER: count it errored,
|
|
2646
|
+
// log it (queryable over GraphQL), and move on. The watermark is NOT held back (it advances
|
|
2647
|
+
// to max-seen regardless), so a permanently-bad record never poison-pills the stream; recovery
|
|
2648
|
+
// is an operator-triggered full sync. The run still reports Status='Failed' (RecordsErrored>0).
|
|
2117
2649
|
const classified = ClassifyError(recErr);
|
|
2118
2650
|
const msg = recErr instanceof Error ? recErr.message : String(recErr);
|
|
2119
2651
|
result.RecordsErrored++;
|
|
@@ -2154,31 +2686,47 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2154
2686
|
c: result.RecordsCreated, u: result.RecordsUpdated,
|
|
2155
2687
|
d: result.RecordsDeleted, s: result.RecordsSkipped,
|
|
2156
2688
|
};
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2689
|
+
try {
|
|
2690
|
+
switch (record.ChangeType) {
|
|
2691
|
+
case 'Create': {
|
|
2692
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
|
|
2693
|
+
if (outcome === 'updated')
|
|
2694
|
+
result.RecordsUpdated++;
|
|
2695
|
+
else if (outcome === 'skipped')
|
|
2696
|
+
result.RecordsSkipped++;
|
|
2697
|
+
else
|
|
2698
|
+
result.RecordsCreated++;
|
|
2699
|
+
break;
|
|
2700
|
+
}
|
|
2701
|
+
case 'Update':
|
|
2702
|
+
await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds);
|
|
2703
|
+
break;
|
|
2704
|
+
case 'Delete': {
|
|
2705
|
+
const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
|
|
2706
|
+
if (didDelete)
|
|
2707
|
+
result.RecordsDeleted++;
|
|
2708
|
+
else
|
|
2709
|
+
result.RecordsErrored++;
|
|
2710
|
+
break;
|
|
2711
|
+
}
|
|
2712
|
+
case 'Skip':
|
|
2163
2713
|
result.RecordsSkipped++;
|
|
2164
|
-
|
|
2165
|
-
result.RecordsCreated++;
|
|
2166
|
-
break;
|
|
2167
|
-
}
|
|
2168
|
-
case 'Update':
|
|
2169
|
-
await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds);
|
|
2170
|
-
break;
|
|
2171
|
-
case 'Delete': {
|
|
2172
|
-
const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
|
|
2173
|
-
if (didDelete)
|
|
2174
|
-
result.RecordsDeleted++;
|
|
2175
|
-
else
|
|
2176
|
-
result.RecordsErrored++;
|
|
2177
|
-
break;
|
|
2714
|
+
break;
|
|
2178
2715
|
}
|
|
2179
|
-
|
|
2716
|
+
}
|
|
2717
|
+
catch (err) {
|
|
2718
|
+
// §29 — a value that doesn't fit its column type (string too wide, integer out of range) is a
|
|
2719
|
+
// per-RECORD skip, not a batch failure and not truncation/clamping. Surface it as a structured
|
|
2720
|
+
// SyncWarning (visible over GraphQL) and move on so the rest of the batch still commits. The
|
|
2721
|
+
// base ValueFitError is caught, so every family member is handled uniformly. Any other error
|
|
2722
|
+
// propagates (batch isolation handles it).
|
|
2723
|
+
if (err instanceof ValueFitError) {
|
|
2180
2724
|
result.RecordsSkipped++;
|
|
2181
|
-
|
|
2725
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.Entity ?? entityMap.ID, err.WarningCode, `Record skipped — ${err.message}`, { externalId: record.ExternalRecord.ExternalID, field: err.FieldName, ...err.Details() });
|
|
2726
|
+
}
|
|
2727
|
+
else {
|
|
2728
|
+
throw err;
|
|
2729
|
+
}
|
|
2182
2730
|
}
|
|
2183
2731
|
if (logger) {
|
|
2184
2732
|
const outcome = result.RecordsCreated > before.c ? 'created' :
|
|
@@ -2224,6 +2772,24 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2224
2772
|
? await entity.InnerLoad(this.BuildEntityPrimaryKey(mappedPK, pkFields))
|
|
2225
2773
|
: false;
|
|
2226
2774
|
if (existed) {
|
|
2775
|
+
// Content-hash fast path (the upsert-path complement to UpdateRecord's matched-path precheck):
|
|
2776
|
+
// if the existing row's STORED hash equals the recomputed hash of the incoming mapped fields,
|
|
2777
|
+
// the record is provably unchanged — re-establish the (possibly-cleared) record map and SKIP
|
|
2778
|
+
// the write. Without this, object-valued mapped fields (persisted as JSON strings) make MJ's
|
|
2779
|
+
// field-level dirty tracking fire spuriously on every re-sync, re-writing unchanged rows — the
|
|
2780
|
+
// PropFuel `opens` class of redundant write that UpdateRecord's content-hash skip already avoids
|
|
2781
|
+
// on the MATCHED path but the upsert (unmatched / soft-PK) path did not. .Get on the dynamic
|
|
2782
|
+
// integration column is the sanctioned access here — these runtime-created tables have no
|
|
2783
|
+
// generated entity type (the engine already .Set()s the same __mj_integration_* columns).
|
|
2784
|
+
const hasHashColumn = entityInfo?.Fields.some(f => f.Name === CONTENT_HASH_COLUMN) ?? false;
|
|
2785
|
+
if (hasHashColumn) {
|
|
2786
|
+
const storedHash = entity.Get(CONTENT_HASH_COLUMN);
|
|
2787
|
+
if (typeof storedHash === 'string' && storedHash.length > 0
|
|
2788
|
+
&& storedHash === computeContentHashWithOverflow(record.MappedFields ?? {}, record.UnmappedFields)) {
|
|
2789
|
+
await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
|
|
2790
|
+
return 'skipped';
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2227
2793
|
// Footprint-clean upsert: set only the BUSINESS fields first; if nothing actually changed
|
|
2228
2794
|
// (dirty tracking after SetEntityFields, BEFORE the always-changing integration metadata),
|
|
2229
2795
|
// re-establish the possibly-cleared record map and SKIP the write — leaving __mj_UpdatedAt
|
|
@@ -2273,9 +2839,12 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2273
2839
|
const values = [];
|
|
2274
2840
|
for (const pk of pkFields) {
|
|
2275
2841
|
const v = (pk.Name in fields) ? fields[pk.Name] : lower.get(pk.Name.toLowerCase());
|
|
2276
|
-
|
|
2842
|
+
// serializeKeyValue mirrors the write-side coercion (objects → JSON, not "[object Object]")
|
|
2843
|
+
// so the load key equals the value stored in the column for object-valued PKs.
|
|
2844
|
+
const s = serializeKeyValue(v);
|
|
2845
|
+
if (s === '')
|
|
2277
2846
|
return null;
|
|
2278
|
-
values.push(
|
|
2847
|
+
values.push(s);
|
|
2279
2848
|
}
|
|
2280
2849
|
return values.join('|');
|
|
2281
2850
|
}
|
|
@@ -2303,8 +2872,20 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2303
2872
|
// below is the fallback for entities without the hash column.
|
|
2304
2873
|
if (precheckHashes) {
|
|
2305
2874
|
const stored = precheckHashes.get(record.MatchedMJRecordID);
|
|
2306
|
-
if (stored && stored ===
|
|
2875
|
+
if (stored && stored === computeContentHashWithOverflow(record.MappedFields ?? {}, record.UnmappedFields)) {
|
|
2307
2876
|
result.RecordsSkipped++;
|
|
2877
|
+
// Re-establish the external↔MJ record map even on the content-hash skip. A record can
|
|
2878
|
+
// reach UpdateRecord matched by KEY FIELDS / PK (MatchEngine.FindByKeyFields queries the
|
|
2879
|
+
// dest table directly, NOT the RecordMap) with NO map row pointing at it — e.g. after the
|
|
2880
|
+
// entity maps (and their cascaded record maps) were deleted while the dest rows persisted
|
|
2881
|
+
// (a maps delete+re-add, partial cleanup, or a fresh CompanyIntegration over pre-existing
|
|
2882
|
+
// rows). Skipping the write here without writing the map leaves the RecordMap empty for
|
|
2883
|
+
// every unchanged-but-matched record, so orphan/delete detection and the 1:1 completeness
|
|
2884
|
+
// invariant silently degrade. SaveRecordMap is an upsert keyed on
|
|
2885
|
+
// (CompanyIntegration, Entity, ExternalID) — idempotent for already-mapped records, and the
|
|
2886
|
+
// CreateRecord skip branches already do exactly this. MatchedMJRecordID IS the dest PK
|
|
2887
|
+
// (PrimaryKeys order, '|'-joined), which is the EntityRecordID the map stores.
|
|
2888
|
+
await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, record.MatchedMJRecordID, contextUser);
|
|
2308
2889
|
// The record IS still present and confirmed-unchanged on the source — but skipping
|
|
2309
2890
|
// the write here means SetStandardIntegrationFields never runs, so __mj_integration_
|
|
2310
2891
|
// LastReconciledAt would freeze at first-sync time. Record the PK so the batch can
|
|
@@ -2340,6 +2921,11 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2340
2921
|
// sync re-fetches all records. Without this, 50k+ records get re-written every run.
|
|
2341
2922
|
if (!entity.Dirty) {
|
|
2342
2923
|
result.RecordsSkipped++;
|
|
2924
|
+
// Re-establish the record map even when the write is skipped — see the content-hash skip
|
|
2925
|
+
// above for the full rationale (a key-field/PK match can land here with no map row, and
|
|
2926
|
+
// dropping the map silently breaks the 1:1 completeness invariant + orphan detection).
|
|
2927
|
+
// The entity is loaded here, so use its actual PK as the EntityRecordID. Idempotent upsert.
|
|
2928
|
+
await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
|
|
2343
2929
|
return;
|
|
2344
2930
|
}
|
|
2345
2931
|
// A5: Pre-write validation
|
|
@@ -2382,26 +2968,43 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2382
2968
|
if (!entityInfo.Fields.some(f => f.Name === CONTENT_HASH_COLUMN))
|
|
2383
2969
|
return undefined;
|
|
2384
2970
|
const pkFields = entityInfo.PrimaryKeys ?? [];
|
|
2385
|
-
if (pkFields.length
|
|
2386
|
-
return undefined;
|
|
2387
|
-
|
|
2971
|
+
if (pkFields.length === 0)
|
|
2972
|
+
return undefined;
|
|
2973
|
+
// Map keys must match `record.MatchedMJRecordID`, which is the PK value(s) joined by '|' in
|
|
2974
|
+
// PrimaryKeys order (single value for single-PK, "v1|v2" for composite — see MatchEngine).
|
|
2975
|
+
const pkNames = pkFields.map(f => f.Name);
|
|
2388
2976
|
try {
|
|
2389
|
-
const escaped = ids.map(id => `'${String(id).replace(/'/g, "''")}'`).join(',');
|
|
2390
2977
|
const rv = new RunView();
|
|
2978
|
+
let extraFilter;
|
|
2979
|
+
if (pkNames.length === 1) {
|
|
2980
|
+
// Single-PK fast path: WHERE pk IN (...).
|
|
2981
|
+
const escaped = ids.map(id => `'${String(id).replace(/'/g, "''")}'`).join(',');
|
|
2982
|
+
extraFilter = `${pkNames[0]} IN (${escaped})`;
|
|
2983
|
+
}
|
|
2984
|
+
else {
|
|
2985
|
+
// Composite-PK: each MatchedMJRecordID is "v1|v2|..." in PrimaryKeys order. Build
|
|
2986
|
+
// an OR of per-record (pk1='v1' AND pk2='v2') clauses — bounded by batch size.
|
|
2987
|
+
// Plain (unbracketed) identifiers → dialect-agnostic (SS brackets break Postgres).
|
|
2988
|
+
extraFilter = ids.map(mid => {
|
|
2989
|
+
const parts = String(mid).split('|');
|
|
2990
|
+
return '(' + pkNames.map((name, i) => `${name} = '${String(parts[i] ?? '').replace(/'/g, "''")}'`).join(' AND ') + ')';
|
|
2991
|
+
}).join(' OR ');
|
|
2992
|
+
}
|
|
2391
2993
|
const res = await rv.RunView({
|
|
2392
2994
|
EntityName: entityName,
|
|
2393
|
-
Fields: [
|
|
2394
|
-
ExtraFilter:
|
|
2995
|
+
Fields: [...pkNames, CONTENT_HASH_COLUMN],
|
|
2996
|
+
ExtraFilter: extraFilter,
|
|
2395
2997
|
ResultType: 'simple',
|
|
2396
2998
|
}, contextUser);
|
|
2397
2999
|
if (!res.Success)
|
|
2398
3000
|
return undefined;
|
|
2399
3001
|
const map = new Map();
|
|
2400
3002
|
for (const row of res.Results) {
|
|
2401
|
-
|
|
3003
|
+
// Re-key by the same '|'-join the matcher produced, so the lookup in ApplySingleRecord hits.
|
|
3004
|
+
const key = pkNames.map(n => row[n] ?? '').join('|');
|
|
2402
3005
|
const hash = row[CONTENT_HASH_COLUMN];
|
|
2403
|
-
if (
|
|
2404
|
-
map.set(
|
|
3006
|
+
if (typeof hash === 'string' && hash.length > 0) {
|
|
3007
|
+
map.set(key, hash);
|
|
2405
3008
|
}
|
|
2406
3009
|
}
|
|
2407
3010
|
return map;
|
|
@@ -2514,27 +3117,29 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2514
3117
|
for (const [fieldName, value] of Object.entries(fields)) {
|
|
2515
3118
|
const key = fieldName.toLowerCase();
|
|
2516
3119
|
const coerced = this.coerceIncomingValue(value, typeLookup.get(key));
|
|
2517
|
-
entity.Set(fieldName, this.
|
|
3120
|
+
entity.Set(fieldName, this.enforceValueFit(coerced, typeLookup.get(key), maxLenLookup.get(key), fieldName));
|
|
2518
3121
|
}
|
|
2519
3122
|
}
|
|
2520
3123
|
/**
|
|
2521
|
-
* §5/§10
|
|
2522
|
-
* (
|
|
2523
|
-
*
|
|
2524
|
-
*
|
|
3124
|
+
* §5/§10/§29 value-fit enforcement: a value that cannot fit its bounded destination column is NOT
|
|
3125
|
+
* truncated/clamped (silent corruption) and does NOT widen the column (space is the priority) —
|
|
3126
|
+
* instead it raises a {@link ValueFitError}, which ApplySingleRecord catches to SKIP that one record
|
|
3127
|
+
* and surface a structured SyncWarning. Two fit failures are enforced: a string wider than its column
|
|
3128
|
+
* ({@link StringOverflowError}) and an integer outside its column's range ({@link NumericOverflowError},
|
|
3129
|
+
* which would otherwise sink the whole batch at SQL bind time). Values that fit / unlimited columns /
|
|
3130
|
+
* non-enforced types pass through unchanged.
|
|
2525
3131
|
*/
|
|
2526
|
-
|
|
2527
|
-
if (typeof value
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
return value.slice(0, cut);
|
|
3132
|
+
enforceValueFit(value, targetType, maxLength, fieldName) {
|
|
3133
|
+
if (typeof value === 'string' && maxLength !== undefined && value.length > maxLength) {
|
|
3134
|
+
throw new StringOverflowError(fieldName, value.length, maxLength);
|
|
3135
|
+
}
|
|
3136
|
+
if (typeof value === 'number' && Number.isFinite(value) && targetType) {
|
|
3137
|
+
const bound = INTEGER_SQL_BOUNDS[targetType.toLowerCase()];
|
|
3138
|
+
if (bound && (value < bound.min || value > bound.max)) {
|
|
3139
|
+
throw new NumericOverflowError(fieldName, value, targetType);
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
return value;
|
|
2538
3143
|
}
|
|
2539
3144
|
/**
|
|
2540
3145
|
* Coerce external values to something MJ's SQL provider can bind safely.
|
|
@@ -2663,7 +3268,15 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2663
3268
|
// hash equals the stored hash can be skipped without loading it (see
|
|
2664
3269
|
// PrefetchContentHashes / UpdateRecord). No-op on tables predating the column.
|
|
2665
3270
|
if (hasField(CONTENT_HASH_COLUMN)) {
|
|
2666
|
-
entity.Set(CONTENT_HASH_COLUMN,
|
|
3271
|
+
entity.Set(CONTENT_HASH_COLUMN, computeContentHashWithOverflow(record.MappedFields ?? {}, record.UnmappedFields));
|
|
3272
|
+
}
|
|
3273
|
+
// Custom-overflow capture (gaps.md §2): park any source keys with no field map as JSON,
|
|
3274
|
+
// in THIS same row write (no extra round-trip → a customs-free sync stays byte-identical).
|
|
3275
|
+
// Only written when there ARE extras; when empty, this is the signal that no post-sync RSU
|
|
3276
|
+
// promotion is needed for this row. Backend staging only — never user-facing metadata until
|
|
3277
|
+
// a key is promoted to a real column. No-op on tables predating the column. See CustomOverflow.
|
|
3278
|
+
if (hasField(CUSTOM_OVERFLOW_COLUMN) && hasUnmappedFields(record.UnmappedFields)) {
|
|
3279
|
+
entity.Set(CUSTOM_OVERFLOW_COLUMN, JSON.stringify(record.UnmappedFields));
|
|
2667
3280
|
}
|
|
2668
3281
|
// ── Per-record sync ledger (plan §2.5) ───────────────────────────────────────
|
|
2669
3282
|
// The external system's version token for optimistic-concurrency on bidirectional
|
|
@@ -2750,10 +3363,20 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2750
3363
|
detail.RecordID = `EntityMap:${entityMap.ID}|Processed:${result.RecordsProcessed}`;
|
|
2751
3364
|
detail.Action = result.RecordsCreated > 0 ? 'INSERT' : 'UPDATE';
|
|
2752
3365
|
detail.IsSuccess = result.RecordsErrored === 0;
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
3366
|
+
// Retry: this row IS the per-entity-map audit trail the GraphQL GetRun API reads. A lost save
|
|
3367
|
+
// makes a working sync look like it processed zero maps. Retry transient failures before giving up.
|
|
3368
|
+
try {
|
|
3369
|
+
await WithRetry(async () => {
|
|
3370
|
+
const ok = await detail.Save();
|
|
3371
|
+
if (!ok)
|
|
3372
|
+
throw new Error(detail.LatestResult?.CompleteMessage ?? 'detail.Save() returned false');
|
|
3373
|
+
return true;
|
|
3374
|
+
}, { MaxAttempts: 3, InitialBackoffMs: 500, MaxBackoffMs: 5000, JitterFraction: 0.1 });
|
|
3375
|
+
}
|
|
3376
|
+
catch (detailErr) {
|
|
3377
|
+
console.error(`[IntegrationEngine] Failed to save run detail for entity map ${entityMap.ID} after retries: ` +
|
|
3378
|
+
`${detailErr instanceof Error ? detailErr.message : String(detailErr)}. ` +
|
|
3379
|
+
`The run's per-map audit row is missing; GetRun will under-report processed maps.`);
|
|
2757
3380
|
}
|
|
2758
3381
|
}
|
|
2759
3382
|
/**
|
|
@@ -2764,9 +3387,15 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2764
3387
|
aggregate.RecordsCreated += mapResult.RecordsCreated;
|
|
2765
3388
|
aggregate.RecordsUpdated += mapResult.RecordsUpdated;
|
|
2766
3389
|
aggregate.RecordsDeleted += mapResult.RecordsDeleted;
|
|
2767
|
-
aggregate.RecordsErrored += mapResult.RecordsErrored;
|
|
3390
|
+
aggregate.RecordsErrored += mapResult.RecordsErrored; // true count, always exact
|
|
2768
3391
|
aggregate.RecordsSkipped += mapResult.RecordsSkipped;
|
|
2769
|
-
|
|
3392
|
+
// Bound the retained error SAMPLE: only the first MAX_AGGREGATE_ERRORS are ever persisted
|
|
3393
|
+
// (FinalizeRun slices to 100). Accumulating every per-record error across a multi-million-row
|
|
3394
|
+
// failing run would hold the whole set in RAM for no gain — RecordsErrored already has the count.
|
|
3395
|
+
if (aggregate.Errors.length < MAX_AGGREGATE_ERRORS && mapResult.Errors.length > 0) {
|
|
3396
|
+
const room = MAX_AGGREGATE_ERRORS - aggregate.Errors.length;
|
|
3397
|
+
aggregate.Errors.push(...mapResult.Errors.slice(0, room));
|
|
3398
|
+
}
|
|
2770
3399
|
if (mapResult.RecordsErrored > 0) {
|
|
2771
3400
|
aggregate.Success = false;
|
|
2772
3401
|
}
|
|
@@ -2792,7 +3421,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2792
3421
|
/**
|
|
2793
3422
|
* Finalizes a successful run with aggregate totals and emits a completion notification.
|
|
2794
3423
|
*/
|
|
2795
|
-
async FinalizeRun(run, result,
|
|
3424
|
+
async FinalizeRun(run, result, contextUser, onNotification, aborted) {
|
|
2796
3425
|
run.EndedAt = new Date();
|
|
2797
3426
|
run.TotalRecords = result.RecordsProcessed;
|
|
2798
3427
|
// A user/system-cancelled run must NOT be recorded as 'Success' — that hides the
|
|
@@ -2812,15 +3441,83 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2812
3441
|
run.ErrorLog = JSON.stringify(result.Errors.slice(0, 100));
|
|
2813
3442
|
}
|
|
2814
3443
|
}
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
3444
|
+
// Retry the finalize save: a failed save leaves the run 'In Progress', which ResumeOrphanedSyncs
|
|
3445
|
+
// re-queues on next startup → the whole sync re-runs (re-fetch + re-apply). Worth a few retries to
|
|
3446
|
+
// make the terminal status durable. Both a thrown infra error and a `false` logical-failure retry.
|
|
3447
|
+
try {
|
|
3448
|
+
await WithRetry(async () => {
|
|
3449
|
+
const ok = await run.Save();
|
|
3450
|
+
if (!ok)
|
|
3451
|
+
throw new Error(run.LatestResult?.CompleteMessage ?? 'run.Save() returned false');
|
|
3452
|
+
return true;
|
|
3453
|
+
}, { MaxAttempts: 3, InitialBackoffMs: 500, MaxBackoffMs: 5000, JitterFraction: 0.1 });
|
|
3454
|
+
}
|
|
3455
|
+
catch (saveErr) {
|
|
3456
|
+
console.error(`[IntegrationEngine] Failed to finalize run ${run.ID} after retries: ` +
|
|
3457
|
+
`${saveErr instanceof Error ? saveErr.message : String(saveErr)}. ` +
|
|
3458
|
+
`Run may remain 'In Progress' and be re-queued as orphaned on next startup.`);
|
|
2819
3459
|
}
|
|
2820
3460
|
if (onNotification) {
|
|
2821
3461
|
const notification = this.buildCompletionNotification(run, result);
|
|
2822
3462
|
this.safeNotify(onNotification, notification);
|
|
2823
3463
|
}
|
|
3464
|
+
// Retention: keep the run-history audit tables bounded (best-effort; never fails the sync).
|
|
3465
|
+
await this.pruneOldRunHistory(run, contextUser);
|
|
3466
|
+
}
|
|
3467
|
+
/**
|
|
3468
|
+
* Retention: prune old CompanyIntegrationRun + RunDetail rows for THIS connection so the audit
|
|
3469
|
+
* tables don't grow without bound (a row accumulates per sync forever otherwise — the unbounded-log
|
|
3470
|
+
* gap). Keeps the `MJ_INTEGRATION_MAX_RUNS_PER_CI` most-recent runs (default 100; <=0 disables).
|
|
3471
|
+
* Uses dialect-safe BULK DELETEs — NOT per-row BaseEntity.Delete, which would just shift the
|
|
3472
|
+
* unbounded growth into RecordChanges. Best-effort: a prune failure must NEVER fail a sync that
|
|
3473
|
+
* already landed data, and one bulk statement drains a large backlog in a single round-trip.
|
|
3474
|
+
*/
|
|
3475
|
+
async pruneOldRunHistory(run, contextUser) {
|
|
3476
|
+
const keep = parseInt(process.env.MJ_INTEGRATION_MAX_RUNS_PER_CI ?? '100', 10);
|
|
3477
|
+
if (!Number.isFinite(keep) || keep <= 0)
|
|
3478
|
+
return; // retention disabled
|
|
3479
|
+
try {
|
|
3480
|
+
const md = this.ProviderToUse;
|
|
3481
|
+
const runInfo = run.EntityInfo;
|
|
3482
|
+
const detailInfo = md.EntityByName('MJ: Company Integration Run Details');
|
|
3483
|
+
const ciId = run.CompanyIntegrationID;
|
|
3484
|
+
if (!runInfo?.SchemaName || !runInfo.BaseTable || !runInfo.PrimaryKeys?.length)
|
|
3485
|
+
return;
|
|
3486
|
+
if (!detailInfo?.SchemaName || !detailInfo.BaseTable || !ciId)
|
|
3487
|
+
return;
|
|
3488
|
+
// Cutoff = StartedAt of the Nth-most-recent run for this CI. If there aren't MORE than
|
|
3489
|
+
// `keep` runs, there's nothing to prune.
|
|
3490
|
+
const recent = await new RunView().RunView({
|
|
3491
|
+
EntityName: runInfo.Name,
|
|
3492
|
+
ExtraFilter: `CompanyIntegrationID='${String(ciId).replace(/'/g, "''")}'`,
|
|
3493
|
+
OrderBy: 'StartedAt DESC',
|
|
3494
|
+
Fields: ['StartedAt'],
|
|
3495
|
+
MaxRows: keep,
|
|
3496
|
+
ResultType: 'simple',
|
|
3497
|
+
}, contextUser);
|
|
3498
|
+
if (!recent.Success || (recent.TotalRowCount ?? 0) <= keep)
|
|
3499
|
+
return;
|
|
3500
|
+
const cutoffRaw = recent.Results?.[recent.Results.length - 1]?.StartedAt;
|
|
3501
|
+
if (!cutoffRaw)
|
|
3502
|
+
return;
|
|
3503
|
+
const provider = md;
|
|
3504
|
+
const d = provider.Dialect;
|
|
3505
|
+
const runTable = `${d.QuoteIdentifier(runInfo.SchemaName)}.${d.QuoteIdentifier(runInfo.BaseTable)}`;
|
|
3506
|
+
const detailTable = `${d.QuoteIdentifier(detailInfo.SchemaName)}.${d.QuoteIdentifier(detailInfo.BaseTable)}`;
|
|
3507
|
+
const runPk = d.QuoteIdentifier(runInfo.PrimaryKeys[0].Name);
|
|
3508
|
+
const ciCol = d.QuoteIdentifier('CompanyIntegrationID');
|
|
3509
|
+
const startedCol = d.QuoteIdentifier('StartedAt');
|
|
3510
|
+
const detailFk = d.QuoteIdentifier('CompanyIntegrationRunID');
|
|
3511
|
+
const ci = d.QuoteStringLiteral(String(ciId));
|
|
3512
|
+
const cut = d.QuoteStringLiteral(new Date(cutoffRaw).toISOString());
|
|
3513
|
+
const oldRuns = `SELECT ${runPk} FROM ${runTable} WHERE ${ciCol}=${ci} AND ${startedCol} < ${cut}`;
|
|
3514
|
+
// Details first (FK → run; these entities don't cascade-delete), then the runs.
|
|
3515
|
+
await provider.ExecuteSQL(`DELETE FROM ${detailTable} WHERE ${detailFk} IN (${oldRuns})`, undefined, undefined, contextUser);
|
|
3516
|
+
await provider.ExecuteSQL(`DELETE FROM ${runTable} WHERE ${ciCol}=${ci} AND ${startedCol} < ${cut}`, undefined, undefined, contextUser);
|
|
3517
|
+
}
|
|
3518
|
+
catch (err) {
|
|
3519
|
+
console.warn(`[IntegrationEngine] Run-history retention prune skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
3520
|
+
}
|
|
2824
3521
|
}
|
|
2825
3522
|
/**
|
|
2826
3523
|
* Marks a run as failed after an unrecoverable error and emits a failure notification.
|
|
@@ -2930,6 +3627,31 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2930
3627
|
console.warn('[IntegrationEngine] Notification callback threw:', notifyErr);
|
|
2931
3628
|
}
|
|
2932
3629
|
}
|
|
3630
|
+
/**
|
|
3631
|
+
* Invokes the post-sync custom-column promotion hook (gaps.md §2 / M2) without ever
|
|
3632
|
+
* propagating an exception into the sync — a promotion failure must never fail a sync that
|
|
3633
|
+
* already landed its data. Returns undefined when no hook is registered (a no-op host) or
|
|
3634
|
+
* the hook throws. The hook is self-gated: a customs-free sync does no work.
|
|
3635
|
+
*/
|
|
3636
|
+
async invokePostSyncPromotionSafe(companyIntegrationID, contextUser, result) {
|
|
3637
|
+
if (!this.postSyncSchemaPromotionCallback)
|
|
3638
|
+
return undefined;
|
|
3639
|
+
try {
|
|
3640
|
+
const syncedEntityNames = [
|
|
3641
|
+
...new Set((result.EntityMapResults ?? []).map(r => r.EntityName).filter(Boolean)),
|
|
3642
|
+
];
|
|
3643
|
+
return await this.postSyncSchemaPromotionCallback({
|
|
3644
|
+
CompanyIntegrationID: companyIntegrationID,
|
|
3645
|
+
ContextUser: contextUser,
|
|
3646
|
+
SyncedEntityNames: syncedEntityNames,
|
|
3647
|
+
Provider: this._provider,
|
|
3648
|
+
});
|
|
3649
|
+
}
|
|
3650
|
+
catch (promoteErr) {
|
|
3651
|
+
console.warn('[IntegrationEngine] Post-sync schema promotion callback threw:', promoteErr);
|
|
3652
|
+
return undefined;
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
2933
3655
|
// ── Composition: delegate metadata to IntegrationEngineBase ───────
|
|
2934
3656
|
get Base() {
|
|
2935
3657
|
return IntegrationEngineBase.Instance;
|