@memberjunction/integration-engine 6.1.0-edge.3 → 6.1.0-edge.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BaseIntegrationConnector.d.ts +26 -2
- package/dist/BaseIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseIntegrationConnector.js +145 -23
- package/dist/BaseIntegrationConnector.js.map +1 -1
- package/dist/BaseRESTIntegrationConnector.d.ts +62 -8
- package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseRESTIntegrationConnector.js +200 -20
- package/dist/BaseRESTIntegrationConnector.js.map +1 -1
- package/dist/BatchIdentity.d.ts +47 -0
- package/dist/BatchIdentity.d.ts.map +1 -0
- package/dist/BatchIdentity.js +78 -0
- package/dist/BatchIdentity.js.map +1 -0
- package/dist/DeclaredSampleMerge.d.ts +74 -0
- package/dist/DeclaredSampleMerge.d.ts.map +1 -0
- package/dist/DeclaredSampleMerge.js +104 -0
- package/dist/DeclaredSampleMerge.js.map +1 -0
- package/dist/DiscoveryWatchdog.d.ts +103 -0
- package/dist/DiscoveryWatchdog.d.ts.map +1 -0
- package/dist/DiscoveryWatchdog.js +166 -0
- package/dist/DiscoveryWatchdog.js.map +1 -0
- package/dist/FieldMapValidation.d.ts +40 -0
- package/dist/FieldMapValidation.d.ts.map +1 -0
- package/dist/FieldMapValidation.js +58 -0
- package/dist/FieldMapValidation.js.map +1 -0
- package/dist/FieldMappingEngine.d.ts +1 -13
- package/dist/FieldMappingEngine.d.ts.map +1 -1
- package/dist/FieldMappingEngine.js +18 -3
- package/dist/FieldMappingEngine.js.map +1 -1
- package/dist/IntegrationConnectorCreationPipeline.d.ts +20 -0
- package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -1
- package/dist/IntegrationConnectorCreationPipeline.js +203 -30
- package/dist/IntegrationConnectorCreationPipeline.js.map +1 -1
- package/dist/IntegrationEngine.d.ts +204 -1
- package/dist/IntegrationEngine.d.ts.map +1 -1
- package/dist/IntegrationEngine.js +1224 -262
- package/dist/IntegrationEngine.js.map +1 -1
- package/dist/IntegrationSchemaSync.d.ts +54 -2
- package/dist/IntegrationSchemaSync.d.ts.map +1 -1
- package/dist/IntegrationSchemaSync.js +122 -16
- package/dist/IntegrationSchemaSync.js.map +1 -1
- package/dist/KeylessRecordGuard.d.ts +56 -0
- package/dist/KeylessRecordGuard.d.ts.map +1 -0
- package/dist/KeylessRecordGuard.js +80 -0
- package/dist/KeylessRecordGuard.js.map +1 -0
- package/dist/ResumeConcurrency.d.ts +46 -0
- package/dist/ResumeConcurrency.d.ts.map +1 -0
- package/dist/ResumeConcurrency.js +74 -0
- package/dist/ResumeConcurrency.js.map +1 -0
- package/dist/RetryAfter.d.ts +50 -0
- package/dist/RetryAfter.d.ts.map +1 -0
- package/dist/RetryAfter.js +127 -0
- package/dist/RetryAfter.js.map +1 -0
- package/dist/RetryRunner.d.ts +26 -2
- package/dist/RetryRunner.d.ts.map +1 -1
- package/dist/RetryRunner.js +11 -3
- package/dist/RetryRunner.js.map +1 -1
- package/dist/StreamingDiscovery.d.ts +11 -0
- package/dist/StreamingDiscovery.d.ts.map +1 -1
- package/dist/StreamingDiscovery.js +13 -3
- package/dist/StreamingDiscovery.js.map +1 -1
- package/dist/SyncDirectives.d.ts +66 -0
- package/dist/SyncDirectives.d.ts.map +1 -0
- package/dist/SyncDirectives.js +123 -0
- package/dist/SyncDirectives.js.map +1 -0
- package/dist/SyncLogger.d.ts +2 -1
- package/dist/SyncLogger.d.ts.map +1 -1
- package/dist/SyncLogger.js +1 -0
- package/dist/SyncLogger.js.map +1 -1
- package/dist/WatermarkService.d.ts +15 -0
- package/dist/WatermarkService.d.ts.map +1 -1
- package/dist/WatermarkService.js +26 -0
- package/dist/WatermarkService.js.map +1 -1
- package/dist/WriteSerializer.d.ts +44 -0
- package/dist/WriteSerializer.d.ts.map +1 -0
- package/dist/WriteSerializer.js +70 -0
- package/dist/WriteSerializer.js.map +1 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +45 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +66 -1
- package/dist/types.js.map +1 -1
- package/package.json +7 -7
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
-
import { CompositeKey, DatabaseProviderBase, LogStatusEx, Metadata, RunView } from '@memberjunction/core';
|
|
2
|
+
import { CompositeKey, DatabaseProviderBase, LogStatusEx, Metadata, RunView, EntitySaveOptions, EntityDeleteOptions } from '@memberjunction/core';
|
|
3
3
|
import { RunOwnershipLostError, RunOwnershipService } from './RunOwnershipService.js';
|
|
4
4
|
import { BaseSingleton, UUIDsEqual } from '@memberjunction/global';
|
|
5
5
|
import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
|
|
6
6
|
import { ClassifyError, IsRetryableError } from './types.js';
|
|
7
7
|
import { WithRetry } from './RetryRunner.js';
|
|
8
|
+
import { DecideKeylessRefusal, DescribeKeylessRefusal, MissingKeyFieldNames } from './KeylessRecordGuard.js';
|
|
8
9
|
import { WithTimeout, OperationTimeoutError, DEFAULT_OPERATION_TIMEOUTS } from './BaseIntegrationConnector.js';
|
|
9
10
|
import { ConnectorFactory } from './ConnectorFactory.js';
|
|
10
11
|
import { FieldMappingEngine } from './FieldMappingEngine.js';
|
|
@@ -16,11 +17,16 @@ import { RecordMapBatch } from './RecordMapBatch.js';
|
|
|
16
17
|
import { buildContentHashPrefetchFilter, quoteTextLiteral } from './prefetchFilter.js';
|
|
17
18
|
import { serializeKeyValue } from './KeySerialization.js';
|
|
18
19
|
import { CUSTOM_OVERFLOW_COLUMN, reconcileOverflowValue, foldCustomKeyStats } from './CustomOverflow.js';
|
|
20
|
+
import { ComputeExcludedSourceNames } from './SyncDirectives.js';
|
|
21
|
+
import { DescribeUnbindableFieldMaps, FindUnbindableFieldMaps } from './FieldMapValidation.js';
|
|
19
22
|
import { partitionRecords, partitionRollupHash, diffPartitions, partitionKeyForIdentity } from './HashDiff.js';
|
|
20
23
|
import { RateLimiter } from './RateLimiter.js';
|
|
21
24
|
import { AdaptiveConcurrencyController, RunAdaptive } from './AdaptiveConcurrency.js';
|
|
22
25
|
import { mostRecentWinner } from './ConflictRecency.js';
|
|
23
26
|
import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
|
|
27
|
+
import { CollapseDuplicateIdentities } from './BatchIdentity.js';
|
|
28
|
+
import { WriteSerializer } from './WriteSerializer.js';
|
|
29
|
+
import { ResumeConcurrency, RunResumesBounded } from './ResumeConcurrency.js';
|
|
24
30
|
/** Default batch size for fetching records from external systems */
|
|
25
31
|
const DEFAULT_BATCH_SIZE = 200;
|
|
26
32
|
/**
|
|
@@ -89,6 +95,33 @@ export class SchemaNotGeneratedError extends Error {
|
|
|
89
95
|
this.StoredProcedureName = storedProcedureName;
|
|
90
96
|
}
|
|
91
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Thrown by a connector when the vendor says THIS account cannot serve an object it nonetheless
|
|
100
|
+
* lists in its catalog (e.g. a record type the account has not enabled). Distinct from a fetch
|
|
101
|
+
* failure: retrying cannot help WITHIN a run, so the map ends cleanly instead of climbing a retry
|
|
102
|
+
* ladder — one warning, watermark untouched.
|
|
103
|
+
*
|
|
104
|
+
* It is deliberately not remembered ACROSS runs. The next run simply asks again, which is what makes
|
|
105
|
+
* an account change self-healing with no marker, no recheck clock and no override to get right.
|
|
106
|
+
*
|
|
107
|
+
* Connectors are NOT required to import this class — the engine also recognises any error carrying
|
|
108
|
+
* `code === 'OBJECT_UNAVAILABLE'`, so a connector can classify one without a peer version bump.
|
|
109
|
+
*/
|
|
110
|
+
export class ObjectUnavailableError extends Error {
|
|
111
|
+
constructor(objectName, vendorMessage) {
|
|
112
|
+
super(`The source cannot serve "${objectName}" for this account: ${vendorMessage}`);
|
|
113
|
+
this.code = 'OBJECT_UNAVAILABLE';
|
|
114
|
+
this.name = 'ObjectUnavailableError';
|
|
115
|
+
this.VendorMessage = vendorMessage;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Recognises an unavailability signal from a connector that imported the class OR just set the code. */
|
|
119
|
+
export function IsObjectUnavailable(err) {
|
|
120
|
+
if (err instanceof ObjectUnavailableError)
|
|
121
|
+
return true;
|
|
122
|
+
return typeof err === 'object' && err !== null
|
|
123
|
+
&& err.code === 'OBJECT_UNAVAILABLE';
|
|
124
|
+
}
|
|
92
125
|
/**
|
|
93
126
|
* §29 — base of the VALUE-FIT error family: a mapped value cannot fit/coerce into its destination
|
|
94
127
|
* column's type. Per the bounded-typing policy (small columns; large content is an explicit text/json
|
|
@@ -144,6 +177,24 @@ const INTEGER_SQL_BOUNDS = {
|
|
|
144
177
|
* functions are generated, so it must be classified too (else the run produces
|
|
145
178
|
* per-record errors instead of one fail-fast SchemaNotGeneratedError).
|
|
146
179
|
*/
|
|
180
|
+
/**
|
|
181
|
+
* The one primary-key shape a TransactionGroup cannot carry: a SINGLE auto-increment column.
|
|
182
|
+
*
|
|
183
|
+
* An enrolled `Save()` returns true immediately and the row lands at `Submit()`, so the caller
|
|
184
|
+
* reads `entity.PrimaryKey` while the write is still queued. Every other shape survives that —
|
|
185
|
+
* `NewRecord()` client-generates a `uniqueidentifier`, and a composite/soft key takes its values
|
|
186
|
+
* from the mapped fields before the save. A server-assigned identity does not exist until the
|
|
187
|
+
* insert executes, so the record map would be written with a blank EntityRecordID and every
|
|
188
|
+
* incremental sync would re-insert the row.
|
|
189
|
+
*
|
|
190
|
+
* Defined ONCE, module-scope, because it is asked at two different levels — per record at the
|
|
191
|
+
* enrolment seam, and per entity map before a group is created. Two copies of this rule drifting
|
|
192
|
+
* apart is how a map ends up batching records that individually refuse to enrol.
|
|
193
|
+
*/
|
|
194
|
+
function IsIdentityOnlyPrimaryKey(primaryKeys) {
|
|
195
|
+
const pks = primaryKeys ?? [];
|
|
196
|
+
return pks.length === 1 && pks[0]?.AutoIncrement === true;
|
|
197
|
+
}
|
|
147
198
|
function detectSchemaNotGenerated(entityName, errorMessage) {
|
|
148
199
|
const sqlServer = errorMessage.match(/Could not find stored procedure '([^']+)'/i);
|
|
149
200
|
if (sqlServer)
|
|
@@ -171,6 +222,17 @@ function detectSchemaNotGenerated(entityName, errorMessage) {
|
|
|
171
222
|
export function PositiveInt(v) {
|
|
172
223
|
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined;
|
|
173
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* How many deferred writes may accumulate in one batched group before it is submitted and replaced.
|
|
227
|
+
*
|
|
228
|
+
* `undefined` (unset, or any unusable value) means no mid-batch flush at all — the batch is one
|
|
229
|
+
* group and one transaction, which is the default and the behaviour that shipped. An explicit value
|
|
230
|
+
* trades that atomicity for bounded memory on a heap-constrained box.
|
|
231
|
+
*/
|
|
232
|
+
export function ReadFlushCeiling(env) {
|
|
233
|
+
const raw = parseInt(env.MJ_INTEGRATION_BATCH_FLUSH_AT ?? '', 10);
|
|
234
|
+
return Number.isFinite(raw) && raw >= 1 ? raw : undefined;
|
|
235
|
+
}
|
|
174
236
|
export class IntegrationEngine extends BaseSingleton {
|
|
175
237
|
constructor() {
|
|
176
238
|
super();
|
|
@@ -235,10 +297,133 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
235
297
|
* calls without threading a parameter through ~20 private signatures.
|
|
236
298
|
*/
|
|
237
299
|
static { this.runContext = new AsyncLocalStorage(); }
|
|
300
|
+
/**
|
|
301
|
+
* The connection's opt-in write mode, from `CompanyIntegration.Configuration`.
|
|
302
|
+
*
|
|
303
|
+
* Returns `''` for absent, unparseable or wrongly-typed configuration. Every failure mode
|
|
304
|
+
* therefore lands on the proven per-record path: a connection has to ASK for batched writes,
|
|
305
|
+
* and a malformed request is not an ask.
|
|
306
|
+
*/
|
|
307
|
+
ReadWriteMode(companyIntegration) {
|
|
308
|
+
try {
|
|
309
|
+
const raw = companyIntegration.Configuration;
|
|
310
|
+
if (!raw)
|
|
311
|
+
return '';
|
|
312
|
+
const parsed = JSON.parse(raw);
|
|
313
|
+
return typeof parsed.writeMode === 'string' ? parsed.writeMode : '';
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return '';
|
|
317
|
+
}
|
|
318
|
+
}
|
|
238
319
|
/** The current run's context, when called from inside a sync run. */
|
|
239
320
|
get currentRunContext() {
|
|
240
321
|
return IntegrationEngine.runContext.getStore();
|
|
241
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Defers this entity's write into the batch's group, when the connection asked for that.
|
|
325
|
+
*
|
|
326
|
+
* A no-op when no group is active, which is every existing caller — an entity constructed
|
|
327
|
+
* outside a batched apply saves immediately, exactly as before. What this does NOT do is skip
|
|
328
|
+
* any part of the save: `Save()` still validates, still checks row scope, still renders the
|
|
329
|
+
* generated procedure call, and still fires its post-save hook. Only the moment the SQL travels
|
|
330
|
+
* changes, and the group is what makes N of them travel together.
|
|
331
|
+
*/
|
|
332
|
+
enrolInWriteGroup(entity) {
|
|
333
|
+
const group = this.currentRunContext?.writeGroup;
|
|
334
|
+
if (!group)
|
|
335
|
+
return;
|
|
336
|
+
// BATCHING REQUIRES A PK THAT EXISTS BEFORE SUBMIT.
|
|
337
|
+
//
|
|
338
|
+
// An enrolled `Save()` returns true immediately and the row lands at `Submit()`, so the
|
|
339
|
+
// caller reads `entity.PrimaryKey` while the write is still queued. That is fine for the
|
|
340
|
+
// shapes sync actually produces — `NewRecord()` client-generates the UUID for a single
|
|
341
|
+
// `uniqueidentifier` PK, and a composite/soft PK takes its values from the mapped fields
|
|
342
|
+
// before the save. It is NOT fine for a single AUTO-INCREMENT PK, whose value only exists
|
|
343
|
+
// after the insert executes: the record map would be written with a blank EntityRecordID,
|
|
344
|
+
// which is precisely the "duplicates on every incremental sync" failure the record-map
|
|
345
|
+
// comment in CreateRecord documents.
|
|
346
|
+
//
|
|
347
|
+
// So such an entity is left OUT of the group and saves immediately — correct, one round
|
|
348
|
+
// trip slower for that entity, and impossible to get silently wrong.
|
|
349
|
+
if (this.hasIdentityOnlyPrimaryKey(entity))
|
|
350
|
+
return;
|
|
351
|
+
entity.TransactionGroup = group;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* True when the entity's identity is a SINGLE auto-increment column — the one primary-key
|
|
355
|
+
* shape whose value cannot be known until the insert has executed. See {@link enrolInWriteGroup}.
|
|
356
|
+
*/
|
|
357
|
+
hasIdentityOnlyPrimaryKey(entity) {
|
|
358
|
+
return IsIdentityOnlyPrimaryKey(entity.EntityInfo?.PrimaryKeys);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* The MAP-level twin of {@link hasIdentityOnlyPrimaryKey}, asked BEFORE a write group is
|
|
362
|
+
* created. The entity-level guard refuses enrolment per record, which is total at the seam —
|
|
363
|
+
* but for a map whose target is that shape it refuses EVERY record, and the branch around it
|
|
364
|
+
* has already committed to being atomic by construction: it skips `BeginTransaction` because
|
|
365
|
+
* a group exists. The result is a group that stays empty, no transaction, records saving
|
|
366
|
+
* one-by-one on their own, and an empty `Submit()` returning true — a NON-ATOMIC batch
|
|
367
|
+
* reporting success as an atomic one.
|
|
368
|
+
*
|
|
369
|
+
* Worse on the failure path: a mid-batch throw correctly skips the rollback (there is no
|
|
370
|
+
* transaction), then the fallback re-applies the WHOLE batch including rows that already
|
|
371
|
+
* committed. With a server-assigned identity there is nothing to recognise the first copy
|
|
372
|
+
* by, so those become duplicates — the very failure the enrolment guard exists to prevent,
|
|
373
|
+
* reached from the other side.
|
|
374
|
+
*
|
|
375
|
+
* So the decision is made here, at the map, and such a map falls to the `useTransaction`
|
|
376
|
+
* path and gets REAL atomicity instead of an empty group.
|
|
377
|
+
*
|
|
378
|
+
* Deliberately not asserted at `Submit()` instead: an empty group is a legitimate state —
|
|
379
|
+
* when the content-hash fast path skips every record in a batch, nothing enrols and the
|
|
380
|
+
* group is correctly empty. At submit time the harmful and benign cases are
|
|
381
|
+
* indistinguishable, which is exactly why this has to be decided up front.
|
|
382
|
+
*/
|
|
383
|
+
entityMapHasIdentityOnlyPK(entityMap) {
|
|
384
|
+
if (!entityMap?.Entity)
|
|
385
|
+
return false;
|
|
386
|
+
const info = this.ProviderToUse?.EntityByName(entityMap.Entity);
|
|
387
|
+
return IsIdentityOnlyPrimaryKey(info?.PrimaryKeys);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* The connection's write-side-effect mode from `CompanyIntegration.Configuration`. Returns ''
|
|
391
|
+
* for absent, unparseable or wrongly-typed configuration — every failure mode keeps the side
|
|
392
|
+
* effects ON. A connection has to ask, and a malformed request is not an ask.
|
|
393
|
+
*/
|
|
394
|
+
ReadWriteSideEffects(companyIntegration) {
|
|
395
|
+
try {
|
|
396
|
+
const raw = companyIntegration.Get('Configuration');
|
|
397
|
+
if (!raw)
|
|
398
|
+
return '';
|
|
399
|
+
const parsed = JSON.parse(raw);
|
|
400
|
+
return typeof parsed.writeSideEffects === 'string' ? parsed.writeSideEffects : '';
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return '';
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Save options for THIS run's machine writes: suppress the audit row + geocode lookup when
|
|
408
|
+
* the connection asked for it, undefined otherwise (identical to the pre-feature call).
|
|
409
|
+
* Scoped to the sync's own saves — never to any other writer of the same entities.
|
|
410
|
+
*/
|
|
411
|
+
get syncSaveOptions() {
|
|
412
|
+
if (!this.currentRunContext?.suppressWriteSideEffects)
|
|
413
|
+
return undefined;
|
|
414
|
+
const opts = new EntitySaveOptions();
|
|
415
|
+
opts.SkipRecordChanges = true;
|
|
416
|
+
opts.SkipGeoCoding = true;
|
|
417
|
+
return opts;
|
|
418
|
+
}
|
|
419
|
+
/** Delete twin of {@link syncSaveOptions}. */
|
|
420
|
+
get syncDeleteOptions() {
|
|
421
|
+
if (!this.currentRunContext?.suppressWriteSideEffects)
|
|
422
|
+
return undefined;
|
|
423
|
+
const opts = new EntityDeleteOptions();
|
|
424
|
+
opts.SkipRecordChanges = true;
|
|
425
|
+
return opts;
|
|
426
|
+
}
|
|
242
427
|
/** Registers (or clears, with undefined) the post-sync custom-column promotion hook. */
|
|
243
428
|
SetPostSyncSchemaPromotionCallback(callback) {
|
|
244
429
|
this.postSyncSchemaPromotionCallback = callback;
|
|
@@ -291,19 +476,34 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
291
476
|
* phase stays parallel (the real throughput win — it's network-bound). WeakMap so a
|
|
292
477
|
* retired provider's chain entry is collectable.
|
|
293
478
|
*/
|
|
294
|
-
static { this.
|
|
295
|
-
|
|
479
|
+
static { this.writeSerializers = new WeakMap(); }
|
|
480
|
+
/** The write lock for this engine's provider, created on first use. */
|
|
481
|
+
writeSerializer() {
|
|
296
482
|
const provider = this.ProviderToUse;
|
|
297
|
-
let
|
|
298
|
-
if (!
|
|
299
|
-
|
|
300
|
-
IntegrationEngine.
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
483
|
+
let s = IntegrationEngine.writeSerializers.get(provider);
|
|
484
|
+
if (!s) {
|
|
485
|
+
s = new WriteSerializer();
|
|
486
|
+
IntegrationEngine.writeSerializers.set(provider, s);
|
|
487
|
+
}
|
|
488
|
+
return s;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Runs `fn` with NO other write in flight against the provider. Required for work that opens
|
|
492
|
+
* the provider's single global transaction — while it is open, any other write would join it.
|
|
493
|
+
*/
|
|
494
|
+
runWriteExclusive(fn) {
|
|
495
|
+
return this.writeSerializer().RunExclusive(fn);
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Runs `fn` ordered against other writes for the SAME entity map, concurrently with other maps.
|
|
499
|
+
*
|
|
500
|
+
* Only for work that opens no provider transaction — watermark bookkeeping, match resolution,
|
|
501
|
+
* and the post-batch flushes of a batched (TransactionGroup-carrying) apply. Those were queued
|
|
502
|
+
* behind every other map's writes purely because the lock could not tell them apart from a
|
|
503
|
+
* transaction-holding section.
|
|
504
|
+
*/
|
|
505
|
+
runWriteForMap(entityMapID, fn) {
|
|
506
|
+
return this.writeSerializer().RunKeyed(entityMapID, fn);
|
|
307
507
|
}
|
|
308
508
|
/**
|
|
309
509
|
* Fence check at a batch boundary, BEFORE any write (PR 1 item 3). One SELECT of the run's
|
|
@@ -510,172 +710,209 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
510
710
|
return;
|
|
511
711
|
}
|
|
512
712
|
console.log(`[IntegrationEngine] Found ${orphanedRuns.Results.length} orphaned sync(s) to resume`);
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
713
|
+
// CONCURRENTLY, because that is how these runs were STARTED.
|
|
714
|
+
//
|
|
715
|
+
// This loop used to `await` each resume in turn, which quietly converted a parallel
|
|
716
|
+
// workload into a queue ordered by whatever RunView happened to return. The slowest
|
|
717
|
+
// connector became a head-of-line block for every other connector in the workspace — and
|
|
718
|
+
// a connector that never finishes means the others never start at all.
|
|
719
|
+
//
|
|
720
|
+
// Observed live: a restart orphaned three syncs; one resumed and was still going five
|
|
721
|
+
// hours later, and the other two (99,463 and 13,238 rows) never began. Nothing in their
|
|
722
|
+
// logs said so, because nothing had failed — they had simply never been reached. From
|
|
723
|
+
// outside the process a queued run and a crashed one are identical: IsInFlight true,
|
|
724
|
+
// CompletedAt null, counters frozen at the instant of the restart. The absence of an
|
|
725
|
+
// error is the only tell.
|
|
726
|
+
//
|
|
727
|
+
// Note what is NOT being parallelised. The write section stays serialized by
|
|
728
|
+
// `runWriteExclusive`, because all maps share one provider connection with singular
|
|
729
|
+
// transaction state; that is deliberate and unchanged. Per-CompanyIntegration exclusion
|
|
730
|
+
// stays too, via the `activeSyncs` lock each resume takes. What overlaps here is what
|
|
731
|
+
// overlapped before the restart: different connectors waiting on different sources.
|
|
732
|
+
//
|
|
733
|
+
// Bounded rather than unbounded: a workspace is one Node process, so concurrency buys
|
|
734
|
+
// overlap on network waiting and not more CPU, and a boot that adopted fifty runs at once
|
|
735
|
+
// would trade one pathology for another.
|
|
736
|
+
await RunResumesBounded(orphanedRuns.Results, ResumeConcurrency(), run => this.ResumeOneOrphanedRun(run, prov, rv, contextUser));
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Resume ONE orphaned run, end to end: reserve the per-CompanyIntegration lock, claim the run,
|
|
740
|
+
* work out which entity maps already finished, and execute the rest under a fresh run context.
|
|
741
|
+
*
|
|
742
|
+
* Extracted from {@link ResumeOrphanedSyncs}'s loop so several runs can be in flight at once.
|
|
743
|
+
* NEVER THROWS — every failure path is handled here and recorded on the run row. A resume that
|
|
744
|
+
* threw out of this method would take a pool slot with it and, worse, could abandon the runs
|
|
745
|
+
* queued behind it, which is the exact failure this parallelisation exists to remove.
|
|
746
|
+
*
|
|
747
|
+
* The check-and-reserve on `activeSyncs` still has no `await` in front of it, so it stays
|
|
748
|
+
* atomic with several of these in flight: an async function runs synchronously up to its first
|
|
749
|
+
* await, and the pool always starts one from a synchronous call site.
|
|
750
|
+
*/
|
|
751
|
+
async ResumeOneOrphanedRun(run, prov, rv, contextUser) {
|
|
752
|
+
const companyIntegrationID = run.CompanyIntegrationID;
|
|
753
|
+
const runID = run.ID;
|
|
754
|
+
const lockKey = companyIntegrationID.toLowerCase();
|
|
755
|
+
// C1: respect the SAME in-process concurrency lock RunSync uses. If a live sync for this
|
|
756
|
+
// CompanyIntegration is already running (e.g. the scheduler fired during startup), skip the
|
|
757
|
+
// resume — double-running one CI on the shared provider connection corrupts its singular
|
|
758
|
+
// transaction state (exactly what runWriteExclusive guards against WITHIN a run). The
|
|
759
|
+
// get→set pair below has no await between them, so check-and-reserve is atomic on the loop.
|
|
760
|
+
if (IntegrationEngine.activeSyncs.get(lockKey)) {
|
|
761
|
+
console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — a live sync for ${lockKey} is already running`);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
let resolveResumeLock;
|
|
765
|
+
let resumeResult;
|
|
766
|
+
IntegrationEngine.activeSyncs.set(lockKey, new Promise(res => { resolveResumeLock = res; }));
|
|
767
|
+
const ownership = new RunOwnershipService(prov, runID, undefined, contextUser);
|
|
768
|
+
try {
|
|
769
|
+
// CLAIM BEFORE ADOPTING (PR 1 item 6): a single atomic UPDATE that succeeds only if the
|
|
770
|
+
// run is still unowned/lapsed. Zero rows = another worker adopted it between our RunView
|
|
771
|
+
// and now — skip, never double-run. A successful claim BUMPS the fence, so if the
|
|
772
|
+
// original owner is actually alive-but-slow it aborts at its next boundary check
|
|
773
|
+
// without writing: the sweep-reclaim is itself the abort signal for the abandoned owner.
|
|
774
|
+
const claimed = await ownership.Claim();
|
|
775
|
+
if (!claimed) {
|
|
776
|
+
console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — claim lost (another worker adopted it)`);
|
|
777
|
+
return;
|
|
525
778
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
EntityName: 'MJ: Company Integration Run Details',
|
|
549
|
-
ExtraFilter: `CompanyIntegrationRunID='${runID}'`,
|
|
550
|
-
Fields: ['RecordID', 'IsSuccess'],
|
|
551
|
-
ResultType: 'simple',
|
|
552
|
-
}, contextUser);
|
|
553
|
-
const completedMapIDs = new Set();
|
|
554
|
-
if (detailsResult.Success) {
|
|
555
|
-
for (const d of detailsResult.Results) {
|
|
556
|
-
if (!d.IsSuccess)
|
|
557
|
-
continue; // completed-with-errors → re-attempt on resume
|
|
558
|
-
const m = /^EntityMap:([0-9a-fA-F-]+)\|/.exec(d.RecordID ?? '');
|
|
559
|
-
// Parse-miss falls open (map treated as not-completed → re-runs): at worst a
|
|
560
|
-
// redundant idempotent re-sync, never a silent skip.
|
|
561
|
-
if (m)
|
|
562
|
-
completedMapIDs.add(m[1].toLowerCase());
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
|
|
566
|
-
`for ${companyIntegrationID.substring(0, 8)}... ` +
|
|
567
|
-
`(${completedMapIDs.size} entity maps already completed)`);
|
|
568
|
-
// Recover what this run was ASKED to do. Without this the resume rebuilds config from
|
|
569
|
-
// the CompanyIntegration alone, so an adopted run silently loses its options — most
|
|
570
|
-
// damagingly FullSync, which exists precisely to distrust the watermark. An adopted
|
|
571
|
-
// full sync would resume incrementally, fetch nothing, and report Success.
|
|
572
|
-
// Unparseable/absent ConfigData falls back to defaults rather than refusing to resume.
|
|
573
|
-
let resumeOptions;
|
|
574
|
-
let resumeTriggerType = 'Scheduled';
|
|
575
|
-
try {
|
|
576
|
-
const cfg = JSON.parse(run.ConfigData ?? '{}');
|
|
577
|
-
resumeOptions = cfg.options ?? undefined;
|
|
578
|
-
if (cfg.triggerType)
|
|
579
|
-
resumeTriggerType = cfg.triggerType;
|
|
580
|
-
}
|
|
581
|
-
catch {
|
|
582
|
-
console.warn(`[IntegrationEngine] Run ${runID.substring(0, 8)} has unparseable ConfigData; resuming with defaults`);
|
|
583
|
-
}
|
|
584
|
-
if (resumeOptions?.FullSync) {
|
|
585
|
-
console.log(`[IntegrationEngine] Run ${runID.substring(0, 8)} was a FULL sync — resuming as full, not incremental`);
|
|
586
|
-
}
|
|
587
|
-
// Load config and filter to only remaining entity maps (by map ID)
|
|
588
|
-
const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, resumeOptions);
|
|
589
|
-
const remainingMaps = config.entityMaps.filter(em => !completedMapIDs.has(em.ID.toLowerCase()));
|
|
590
|
-
if (remainingMaps.length === 0) {
|
|
591
|
-
console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
|
|
592
|
-
run.EndedAt = new Date();
|
|
593
|
-
run.Status = 'Success';
|
|
594
|
-
ownership.SyncEntityOwnershipFields(run); // full-row save must not clobber the live claim
|
|
595
|
-
await run.Save();
|
|
596
|
-
await ownership.Release('Success');
|
|
597
|
-
continue;
|
|
779
|
+
// Find which entity MAPS already completed SUCCESSFULLY in this run. We correlate
|
|
780
|
+
// by EntityMapID (parsed from the detail's RecordID, stamped by CreateRunDetail),
|
|
781
|
+
// not EntityID — two maps can target the same MJ Entity, so keying on EntityID
|
|
782
|
+
// could skip a still-pending sibling map. We also require IsSuccess=1: a map that
|
|
783
|
+
// completed WITH errors (RecordsErrored>0, no throw) must be re-attempted on resume,
|
|
784
|
+
// otherwise its errored records are silently abandoned.
|
|
785
|
+
const detailsResult = await rv.RunView({
|
|
786
|
+
EntityName: 'MJ: Company Integration Run Details',
|
|
787
|
+
ExtraFilter: `CompanyIntegrationRunID='${runID}'`,
|
|
788
|
+
Fields: ['RecordID', 'IsSuccess'],
|
|
789
|
+
ResultType: 'simple',
|
|
790
|
+
}, contextUser);
|
|
791
|
+
const completedMapIDs = new Set();
|
|
792
|
+
if (detailsResult.Success) {
|
|
793
|
+
for (const d of detailsResult.Results) {
|
|
794
|
+
if (!d.IsSuccess)
|
|
795
|
+
continue; // completed-with-errors → re-attempt on resume
|
|
796
|
+
const m = /^EntityMap:([0-9a-fA-F-]+)\|/.exec(d.RecordID ?? '');
|
|
797
|
+
// Parse-miss falls open (map treated as not-completed → re-runs): at worst a
|
|
798
|
+
// redundant idempotent re-sync, never a silent skip.
|
|
799
|
+
if (m)
|
|
800
|
+
completedMapIDs.add(m[1].toLowerCase());
|
|
598
801
|
}
|
|
599
|
-
console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
|
|
600
|
-
// Replace entityMaps with only the remaining ones
|
|
601
|
-
config.entityMaps = remainingMaps;
|
|
602
|
-
// Execute remaining maps inside a per-run context: the resume gets its own provider
|
|
603
|
-
// binding, abort controller, and ownership — identical to a fresh RunSync — so the
|
|
604
|
-
// heartbeat renews the lease, the batch boundaries fence-check, and FinalizeRun
|
|
605
|
-
// syncs ownership fields + releases, all through the SAME code paths.
|
|
606
|
-
const abortController = new AbortController();
|
|
607
|
-
const progressSnapshot = {
|
|
608
|
-
StartedAt: new Date(),
|
|
609
|
-
CurrentEntity: '',
|
|
610
|
-
EntityMapsTotal: remainingMaps.length,
|
|
611
|
-
EntityMapsCompleted: 0,
|
|
612
|
-
RecordsProcessed: 0,
|
|
613
|
-
RecordsCreated: 0,
|
|
614
|
-
RecordsUpdated: 0,
|
|
615
|
-
RecordsErrored: 0,
|
|
616
|
-
// The run's OWN trigger type, recovered above — not a hardcoded 'Scheduled'. This is
|
|
617
|
-
// what IntegrationGetSyncProgress reports back ("Sync in progress (Manual)"), so a
|
|
618
|
-
// hardcoded value mislabels every adopted run.
|
|
619
|
-
TriggerType: resumeTriggerType,
|
|
620
|
-
};
|
|
621
|
-
const runCtx = {
|
|
622
|
-
provider: prov,
|
|
623
|
-
ownership,
|
|
624
|
-
abortController,
|
|
625
|
-
progressSnapshot,
|
|
626
|
-
cancelRequested: false,
|
|
627
|
-
ownershipLost: false,
|
|
628
|
-
};
|
|
629
|
-
ownership.StartHeartbeat({
|
|
630
|
-
onLost: () => { runCtx.ownershipLost = true; abortController.abort(); },
|
|
631
|
-
onCancelRequested: () => { runCtx.cancelRequested = true; abortController.abort(); },
|
|
632
|
-
progressSupplier: () => JSON.stringify(progressSnapshot),
|
|
633
|
-
});
|
|
634
|
-
const result = await IntegrationEngine.runContext.run(runCtx, async () => {
|
|
635
|
-
const r = await this.ExecuteEntityMaps(config, run, contextUser, undefined, abortController.signal);
|
|
636
|
-
r.RunID = runID;
|
|
637
|
-
await this.FinalizeRun(run, r, contextUser);
|
|
638
|
-
return r;
|
|
639
|
-
});
|
|
640
|
-
resumeResult = result;
|
|
641
|
-
console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
|
|
642
|
-
`${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
|
|
643
|
-
`${result.RecordsErrored} errored`);
|
|
644
802
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
try {
|
|
661
|
-
await ownership.Release('Failed');
|
|
662
|
-
}
|
|
663
|
-
catch { /* lease will simply expire */ }
|
|
664
|
-
}
|
|
803
|
+
console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
|
|
804
|
+
`for ${companyIntegrationID.substring(0, 8)}... ` +
|
|
805
|
+
`(${completedMapIDs.size} entity maps already completed)`);
|
|
806
|
+
// Recover what this run was ASKED to do. Without this the resume rebuilds config from
|
|
807
|
+
// the CompanyIntegration alone, so an adopted run silently loses its options — most
|
|
808
|
+
// damagingly FullSync, which exists precisely to distrust the watermark. An adopted
|
|
809
|
+
// full sync would resume incrementally, fetch nothing, and report Success.
|
|
810
|
+
// Unparseable/absent ConfigData falls back to defaults rather than refusing to resume.
|
|
811
|
+
let resumeOptions;
|
|
812
|
+
let resumeTriggerType = 'Scheduled';
|
|
813
|
+
try {
|
|
814
|
+
const cfg = JSON.parse(run.ConfigData ?? '{}');
|
|
815
|
+
resumeOptions = cfg.options ?? undefined;
|
|
816
|
+
if (cfg.triggerType)
|
|
817
|
+
resumeTriggerType = cfg.triggerType;
|
|
665
818
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
});
|
|
819
|
+
catch {
|
|
820
|
+
console.warn(`[IntegrationEngine] Run ${runID.substring(0, 8)} has unparseable ConfigData; resuming with defaults`);
|
|
821
|
+
}
|
|
822
|
+
if (resumeOptions?.FullSync) {
|
|
823
|
+
console.log(`[IntegrationEngine] Run ${runID.substring(0, 8)} was a FULL sync — resuming as full, not incremental`);
|
|
824
|
+
}
|
|
825
|
+
// Load config and filter to only remaining entity maps (by map ID)
|
|
826
|
+
const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, resumeOptions);
|
|
827
|
+
const remainingMaps = config.entityMaps.filter(em => !completedMapIDs.has(em.ID.toLowerCase()));
|
|
828
|
+
if (remainingMaps.length === 0) {
|
|
829
|
+
console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
|
|
830
|
+
run.EndedAt = new Date();
|
|
831
|
+
run.Status = 'Success';
|
|
832
|
+
ownership.SyncEntityOwnershipFields(run); // full-row save must not clobber the live claim
|
|
833
|
+
await run.Save();
|
|
834
|
+
await ownership.Release('Success');
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
|
|
838
|
+
// Replace entityMaps with only the remaining ones
|
|
839
|
+
config.entityMaps = remainingMaps;
|
|
840
|
+
// Execute remaining maps inside a per-run context: the resume gets its own provider
|
|
841
|
+
// binding, abort controller, and ownership — identical to a fresh RunSync — so the
|
|
842
|
+
// heartbeat renews the lease, the batch boundaries fence-check, and FinalizeRun
|
|
843
|
+
// syncs ownership fields + releases, all through the SAME code paths.
|
|
844
|
+
const abortController = new AbortController();
|
|
845
|
+
const progressSnapshot = {
|
|
846
|
+
StartedAt: new Date(),
|
|
847
|
+
CurrentEntity: '',
|
|
848
|
+
EntityMapsTotal: remainingMaps.length,
|
|
849
|
+
EntityMapsCompleted: 0,
|
|
850
|
+
RecordsProcessed: 0,
|
|
851
|
+
RecordsCreated: 0,
|
|
852
|
+
RecordsUpdated: 0,
|
|
853
|
+
RecordsErrored: 0,
|
|
854
|
+
// The run's OWN trigger type, recovered above — not a hardcoded 'Scheduled'. This is
|
|
855
|
+
// what IntegrationGetSyncProgress reports back ("Sync in progress (Manual)"), so a
|
|
856
|
+
// hardcoded value mislabels every adopted run.
|
|
857
|
+
TriggerType: resumeTriggerType,
|
|
858
|
+
};
|
|
859
|
+
const runCtx = {
|
|
860
|
+
provider: prov,
|
|
861
|
+
ownership,
|
|
862
|
+
abortController,
|
|
863
|
+
progressSnapshot,
|
|
864
|
+
cancelRequested: false,
|
|
865
|
+
ownershipLost: false,
|
|
866
|
+
};
|
|
867
|
+
ownership.StartHeartbeat({
|
|
868
|
+
onLost: () => { runCtx.ownershipLost = true; abortController.abort(); },
|
|
869
|
+
onCancelRequested: () => { runCtx.cancelRequested = true; abortController.abort(); },
|
|
870
|
+
progressSupplier: () => JSON.stringify(progressSnapshot),
|
|
871
|
+
});
|
|
872
|
+
const result = await IntegrationEngine.runContext.run(runCtx, async () => {
|
|
873
|
+
const r = await this.ExecuteEntityMaps(config, run, contextUser, undefined, abortController.signal);
|
|
874
|
+
r.RunID = runID;
|
|
875
|
+
await this.FinalizeRun(run, r, contextUser);
|
|
876
|
+
return r;
|
|
877
|
+
});
|
|
878
|
+
resumeResult = result;
|
|
879
|
+
console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
|
|
880
|
+
`${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
|
|
881
|
+
`${result.RecordsErrored} errored`);
|
|
882
|
+
}
|
|
883
|
+
catch (err) {
|
|
884
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
885
|
+
console.error(`[IntegrationEngine] Failed to resume run ${runID.substring(0, 8)}: ${errMsg}`);
|
|
886
|
+
if (err instanceof RunOwnershipLostError) {
|
|
887
|
+
// We were fenced out mid-resume — the NEW owner now owns the run row.
|
|
888
|
+
// Writing 'Failed' here would clobber the live holder's state.
|
|
889
|
+
console.warn(`[IntegrationEngine] Resume of run ${runID.substring(0, 8)} lost ownership — leaving the run row to its new owner`);
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
// Mark as failed so it doesn't get picked up again
|
|
893
|
+
run.EndedAt = new Date();
|
|
894
|
+
run.Status = 'Failed';
|
|
895
|
+
run.ErrorLog = JSON.stringify([{ ErrorMessage: `Resume failed: ${errMsg}` }]);
|
|
896
|
+
ownership.SyncEntityOwnershipFields(run);
|
|
897
|
+
await run.Save();
|
|
898
|
+
try {
|
|
899
|
+
await ownership.Release('Failed');
|
|
900
|
+
}
|
|
901
|
+
catch { /* lease will simply expire */ }
|
|
677
902
|
}
|
|
678
903
|
}
|
|
904
|
+
finally {
|
|
905
|
+
ownership.StopHeartbeat();
|
|
906
|
+
// Release the C1 lock + unblock any RunSync that began awaiting this resume (RunSync returns
|
|
907
|
+
// `existing`). Resolve with the real result when we have one, else a benign empty result so no
|
|
908
|
+
// waiter hangs. Promise resolve is idempotent and the early-exit `return`s also land here.
|
|
909
|
+
IntegrationEngine.activeSyncs.delete(lockKey);
|
|
910
|
+
resolveResumeLock(resumeResult ?? {
|
|
911
|
+
Success: false, ErrorMessage: 'Resume produced no result', RecordsProcessed: 0,
|
|
912
|
+
RecordsCreated: 0, RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0,
|
|
913
|
+
RecordsSkipped: 0, Errors: [], EntityMapResults: [], Duration: 0,
|
|
914
|
+
});
|
|
915
|
+
}
|
|
679
916
|
}
|
|
680
917
|
/**
|
|
681
918
|
* Executes a full sync run for a company integration.
|
|
@@ -861,6 +1098,27 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
861
1098
|
entityMapIDsFilter: options?.EntityMapIDs ?? null,
|
|
862
1099
|
syncDirectionOverride: options?.SyncDirection ?? null,
|
|
863
1100
|
});
|
|
1101
|
+
/**
|
|
1102
|
+
* Re-read the IO/IOF catalog before the run starts (also covers resumed runs — both paths land here).
|
|
1103
|
+
*
|
|
1104
|
+
* Everything else a run reads is fresh per run (LoadRunConfiguration and LoadFieldMaps
|
|
1105
|
+
* read CI/entity maps/field maps with BypassCache) — but IntegrationObject and
|
|
1106
|
+
* IntegrationObjectField are served from this engine's BaseEngine arrays, loaded at
|
|
1107
|
+
* process start and auto-refreshed only by IN-PROCESS BaseEntity saves. A catalog edit
|
|
1108
|
+
* made by direct SQL, a sproc-based sync push, or another process is therefore invisible
|
|
1109
|
+
* to syncs until the host restarts — an AccessPath or field-type correction keeps being
|
|
1110
|
+
* ignored run after run with nothing in the log to say why. The apply/evolution
|
|
1111
|
+
* resolvers already do an invalidate+reload for exactly this reason; the sync path
|
|
1112
|
+
* never got the same treatment.
|
|
1113
|
+
*
|
|
1114
|
+
* Only the two catalog arrays are refreshed — not Config(true), which reloads all eight
|
|
1115
|
+
* datasets unfiltered on every run. The loader is called directly with bypassCache=true
|
|
1116
|
+
* because RefreshItem defaults to the local cache — which is the very thing that is
|
|
1117
|
+
* stale. Replacing the arrays is also what invalidates this PR's memoised views: both
|
|
1118
|
+
* the per-object field index and the per-record GetCachedFields memo key on ARRAY
|
|
1119
|
+
* IDENTITY, so they rebuild lazily on first read after the swap.
|
|
1120
|
+
*/
|
|
1121
|
+
await IntegrationEngineBase.Instance.RefreshCatalog(contextUser);
|
|
864
1122
|
const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, options);
|
|
865
1123
|
logger.attachIntegrationName(config.companyIntegration.Integration);
|
|
866
1124
|
logger.emit('sync.config.loaded', {
|
|
@@ -1236,6 +1494,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1236
1494
|
* Processes all entity maps, aggregating results with progress tracking.
|
|
1237
1495
|
*/
|
|
1238
1496
|
async ExecuteEntityMaps(config, run, contextUser, onProgress, abortSignal, logger) {
|
|
1497
|
+
// Resolve the per-connection side-effect suppression ONCE for the run and stamp it on the
|
|
1498
|
+
// run context, where CreateRecord/UpdateRecord/DeleteRecord (several frames below, no
|
|
1499
|
+
// config parameter) read it back. Both run paths — direct and adopted — come through here.
|
|
1500
|
+
const runCtxForFlags = this.currentRunContext;
|
|
1501
|
+
if (runCtxForFlags) {
|
|
1502
|
+
runCtxForFlags.suppressWriteSideEffects = this.ReadWriteSideEffects(config.companyIntegration) === 'suppressed';
|
|
1503
|
+
}
|
|
1239
1504
|
const aggregate = {
|
|
1240
1505
|
Success: true,
|
|
1241
1506
|
RecordsProcessed: 0,
|
|
@@ -1685,6 +1950,107 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1685
1950
|
});
|
|
1686
1951
|
await Promise.all(workers);
|
|
1687
1952
|
}
|
|
1953
|
+
getFetchGate(config) {
|
|
1954
|
+
// OPT-IN: the gate only exists when someone declared a ceiling — a per-connection
|
|
1955
|
+
// `fetchConcurrency` override or the connector's own MaxConcurrencyHint. A connector that
|
|
1956
|
+
// declares neither has never exhibited concurrency-governed throttling, and imposing a
|
|
1957
|
+
// default cap on it would newly constrain fan-outs (lanes × prefetch) that run fine
|
|
1958
|
+
// ungated today. No declaration → no gate → behavior identical to before this feature.
|
|
1959
|
+
const declared = this.getConfigOverrides(config).fetchConcurrency
|
|
1960
|
+
?? config.connector.MaxConcurrencyHint;
|
|
1961
|
+
if (declared == null)
|
|
1962
|
+
return undefined;
|
|
1963
|
+
this._fetchGates ??= new Map();
|
|
1964
|
+
const key = config.companyIntegration.ID;
|
|
1965
|
+
const ceiling = Math.max(1, declared);
|
|
1966
|
+
let gate = this._fetchGates.get(key);
|
|
1967
|
+
if (!gate || gate.ceiling !== ceiling) {
|
|
1968
|
+
gate = { ceiling, controller: new AdaptiveConcurrencyController({ start: ceiling, min: 1, max: ceiling }), inFlight: 0, waiters: [] };
|
|
1969
|
+
this._fetchGates.set(key, gate);
|
|
1970
|
+
}
|
|
1971
|
+
return gate;
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* Runs `fn` holding one slot of the connection's fetch gate. A whole retry episode holds a
|
|
1975
|
+
* single slot — retries of a throttled call must not add pressure to the account that just
|
|
1976
|
+
* throttled us. FIFO: waiters resolve in arrival order as slots free or the cap grows.
|
|
1977
|
+
*/
|
|
1978
|
+
async withFetchGate(config, fn) {
|
|
1979
|
+
const gate = this.getFetchGate(config);
|
|
1980
|
+
if (!gate)
|
|
1981
|
+
return fn(); // no declared ceiling → ungated, exactly as before
|
|
1982
|
+
while (gate.inFlight >= gate.controller.Cap) {
|
|
1983
|
+
await new Promise((resolve) => gate.waiters.push(resolve));
|
|
1984
|
+
}
|
|
1985
|
+
gate.inFlight++;
|
|
1986
|
+
try {
|
|
1987
|
+
return await fn();
|
|
1988
|
+
}
|
|
1989
|
+
finally {
|
|
1990
|
+
gate.inFlight--;
|
|
1991
|
+
// Wake as many waiters as the CURRENT cap allows — it may have grown (or shrunk)
|
|
1992
|
+
// while they slept; each woken waiter re-checks the cap before taking a slot.
|
|
1993
|
+
while (gate.waiters.length > 0 && gate.inFlight < gate.controller.Cap) {
|
|
1994
|
+
const next = gate.waiters.shift();
|
|
1995
|
+
if (next)
|
|
1996
|
+
next();
|
|
1997
|
+
else
|
|
1998
|
+
break;
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
/**
|
|
2003
|
+
* One governed vendor fetch: rate-limit token, adaptive fetch gate, per-attempt timeout,
|
|
2004
|
+
* transient-only retry with Retry-After pacing, and ONE multiplicative decrease per throttle
|
|
2005
|
+
* EPISODE (not per rejected attempt — a 429 that survives three retries is three rejections
|
|
2006
|
+
* but one congestion event, the same distinction TCP draws when it halves the window once per
|
|
2007
|
+
* loss event). Extracted so the loop-top fetch and the pipelined prefetch (which starts the
|
|
2008
|
+
* next page while the current one is processed) share EXACTLY the same pacing and error
|
|
2009
|
+
* semantics — a prefetched page must be indistinguishable from a loop-top fetch to the vendor.
|
|
2010
|
+
*
|
|
2011
|
+
* Retry predicate: our OWN timeout is terminal for this page (WithTimeout is a Promise.race
|
|
2012
|
+
* with no cancellation, so the abandoned attempt keeps running — retrying stacks a second full
|
|
2013
|
+
* page of vendor requests on a source already too slow to finish one); a transport error is
|
|
2014
|
+
* not (a reset socket IS worth retrying). A throttle honors the source's Retry-After via
|
|
2015
|
+
* DelayForError, and every retry re-passes the rate limiter via BeforeRetry so it cannot
|
|
2016
|
+
* bypass a freeze the throttle just applied.
|
|
2017
|
+
*/
|
|
2018
|
+
async governedFetch(config, ctx, objectName, fetchTimeoutMs, batchIndex, logger) {
|
|
2019
|
+
let throttleReported = false;
|
|
2020
|
+
try {
|
|
2021
|
+
await this.rateLimit(config);
|
|
2022
|
+
return await this.withFetchGate(config, () => WithRetry(() => WithTimeout(config.connector.FetchChanges(ctx), fetchTimeoutMs, `FetchChanges(${objectName})`), undefined, (err) => !(err instanceof OperationTimeoutError) && IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => {
|
|
2023
|
+
// Report a throttle NOW, not after the retries are spent. ReportThrottle
|
|
2024
|
+
// freezes the shared bucket for this CompanyIntegration, so every other
|
|
2025
|
+
// object fetching concurrently backs off too. Once per episode; later
|
|
2026
|
+
// attempts still get their own Retry-After honoured via DelayForError.
|
|
2027
|
+
if (!throttleReported && ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED') {
|
|
2028
|
+
throttleReported = true;
|
|
2029
|
+
this.reportRateOutcome(config, err);
|
|
2030
|
+
}
|
|
2031
|
+
logger?.emit('sync.fetch.retry', {
|
|
2032
|
+
externalObjectName: objectName,
|
|
2033
|
+
batchIndex,
|
|
2034
|
+
attempt,
|
|
2035
|
+
delayMs,
|
|
2036
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2037
|
+
});
|
|
2038
|
+
}, {
|
|
2039
|
+
DelayForError: (err) => ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED'
|
|
2040
|
+
? config.connector.ExtractRetryAfterMs(err)
|
|
2041
|
+
: undefined,
|
|
2042
|
+
BeforeRetry: () => this.rateLimit(config),
|
|
2043
|
+
}));
|
|
2044
|
+
}
|
|
2045
|
+
catch (err) {
|
|
2046
|
+
// Retries spent (or none applicable). If the terminal error is itself the throttle
|
|
2047
|
+
// and the retry hook never saw one, apply the episode's one decrease here.
|
|
2048
|
+
if (!throttleReported && ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED') {
|
|
2049
|
+
this.reportRateOutcome(config, err);
|
|
2050
|
+
}
|
|
2051
|
+
throw err;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
1688
2054
|
/** Minimum ms between outbound requests for this integration (Integration.BatchRequestWaitTime; 0 = disabled). */
|
|
1689
2055
|
getRequestSpacingMs(config) {
|
|
1690
2056
|
try {
|
|
@@ -1732,6 +2098,8 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1732
2098
|
rateLimitBurst: PositiveInt(p.rateLimitBurst),
|
|
1733
2099
|
discoveryTimeBudgetMs: PositiveInt(p.discoveryTimeBudgetMs),
|
|
1734
2100
|
fetchTimeoutMs: PositiveInt(p.fetchTimeoutMs),
|
|
2101
|
+
// Ceiling on SIMULTANEOUS vendor fetches for this connection (see getFetchGate).
|
|
2102
|
+
fetchConcurrency: PositiveInt(p.fetchConcurrency),
|
|
1735
2103
|
};
|
|
1736
2104
|
}
|
|
1737
2105
|
catch {
|
|
@@ -1769,6 +2137,17 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1769
2137
|
*/
|
|
1770
2138
|
reportRateOutcome(config, throttledErr) {
|
|
1771
2139
|
const key = config.companyIntegration.ID;
|
|
2140
|
+
// The adaptive fetch gate learns from the SAME signal: a clean outcome creeps the
|
|
2141
|
+
// in-flight cap up by 1; a throttle halves it. This is what makes the gate find the
|
|
2142
|
+
// account's real concurrency grant with zero configuration — including throttles the
|
|
2143
|
+
// connector absorbed inside its own retry and surfaced via ctx.RateLimitReport.
|
|
2144
|
+
const gate = this._fetchGates?.get(key);
|
|
2145
|
+
if (gate) {
|
|
2146
|
+
if (throttledErr === undefined)
|
|
2147
|
+
gate.controller.OnSuccess();
|
|
2148
|
+
else
|
|
2149
|
+
gate.controller.OnThrottleOrError();
|
|
2150
|
+
}
|
|
1772
2151
|
const rl = this._rateLimiters.get(key);
|
|
1773
2152
|
if (!rl)
|
|
1774
2153
|
return;
|
|
@@ -1811,13 +2190,39 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1811
2190
|
this.MergeResult(pullResult, pushResult);
|
|
1812
2191
|
return pullResult;
|
|
1813
2192
|
}
|
|
2193
|
+
/** Loads the map fresh and writes its Configuration through Save(), like every other engine write. */
|
|
2194
|
+
async SaveEntityMapConfiguration(entityMap, configurationJSON, contextUser) {
|
|
2195
|
+
const md = new Metadata(); // global-provider-ok: entity-map bookkeeping — single-provider context
|
|
2196
|
+
const row = await md.GetEntityObject('MJ: Company Integration Entity Maps', contextUser);
|
|
2197
|
+
if (!(await row.Load(entityMap.ID)))
|
|
2198
|
+
return;
|
|
2199
|
+
row.Configuration = configurationJSON;
|
|
2200
|
+
await this.runWriteExclusive(() => row.Save());
|
|
2201
|
+
// Keep the in-memory map coherent with what was just persisted, so a later decision in this
|
|
2202
|
+
// same run reads the value that is actually stored.
|
|
2203
|
+
entityMap.Configuration = configurationJSON;
|
|
2204
|
+
}
|
|
1814
2205
|
/**
|
|
1815
2206
|
* Pull sync: fetch from external → map → match → validate → apply to MJ.
|
|
1816
2207
|
*/
|
|
1817
2208
|
async ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger) {
|
|
1818
2209
|
const entityMapID = entityMap.ID;
|
|
2210
|
+
// An object this ACCOUNT cannot serve costs a request, an error event and a retry ladder
|
|
2211
|
+
// every run, forever, and says nothing new after the first time. While the marker is fresh
|
|
2212
|
+
// we spend nothing on it; once it ages out the next attempt IS the recheck, so an object
|
|
2213
|
+
// the account later enables heals itself with no operator action.
|
|
1819
2214
|
const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
|
|
1820
|
-
|
|
2215
|
+
// Field-level exclusions declared by the connector (SourceFieldInfo.SyncDirective
|
|
2216
|
+
// -> IntegrationObjectField.Configuration). Resolved once per map, applied to every
|
|
2217
|
+
// batch below. Empty set on any lookup miss - exclusion can only ever narrow.
|
|
2218
|
+
const excludedSourceNames = this.ResolveExcludedSourceNames(config.companyIntegration.IntegrationID, entityMap.ExternalObjectName);
|
|
2219
|
+
if (excludedSourceNames.size > 0) {
|
|
2220
|
+
logger?.emit('sync.entity-map.exclusions', {
|
|
2221
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
2222
|
+
excludedFields: Array.from(excludedSourceNames).sort(),
|
|
2223
|
+
});
|
|
2224
|
+
}
|
|
2225
|
+
const watermark = await this.runWriteForMap(entityMapID, () => this.watermarkService.Load(entityMapID, contextUser, 'Pull'));
|
|
1821
2226
|
logger?.emit('sync.entity-map.start', {
|
|
1822
2227
|
phase: 'pull-detail',
|
|
1823
2228
|
externalObjectName: entityMap.ExternalObjectName,
|
|
@@ -1832,8 +2237,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1832
2237
|
watermarkType: watermark?.WatermarkType ?? null,
|
|
1833
2238
|
fullSync: config.fullSync,
|
|
1834
2239
|
});
|
|
2240
|
+
this.WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger);
|
|
1835
2241
|
// A6: Validate watermark before using it — skip entirely when FullSync requested
|
|
1836
2242
|
let initialWatermark = config.fullSync ? null : (watermark?.WatermarkValue ?? null);
|
|
2243
|
+
// The value the ROW held before this run touched it — the retract target if a mid-run
|
|
2244
|
+
// durability floor (§8a below) has to be undone after a page-skip gap. Distinct from
|
|
2245
|
+
// initialWatermark, which a fullSync nulls even though the row still holds a real value.
|
|
2246
|
+
const preRunWatermarkValue = watermark?.WatermarkValue ?? null;
|
|
1837
2247
|
if (initialWatermark && watermark) {
|
|
1838
2248
|
const watermarkType = (watermark.WatermarkType ?? 'Timestamp');
|
|
1839
2249
|
if (!this.watermarkService.ValidateWatermark(initialWatermark, watermarkType)) {
|
|
@@ -1917,6 +2327,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1917
2327
|
let previousBatchFingerprint;
|
|
1918
2328
|
let fetchCompletedCleanly = true; // flipped to false if fetch aborted or errored mid-way
|
|
1919
2329
|
let hadFetchGap = false; // ≥1 page was skipped after a persistent fetch error (offset/page paging)
|
|
2330
|
+
let watermarkFloorSaved = null; // §8a durability floor last persisted mid-run (null = none)
|
|
1920
2331
|
let fetchGapCount = 0; // CONSECUTIVE skipped pages (reset on any clean fetch)
|
|
1921
2332
|
const MAX_FETCH_GAPS = 25; // give up + hold the watermark if this many pages fail in a row (API down)
|
|
1922
2333
|
let consecutiveEmptyBatches = 0; // P3-D: detect a connector that pages empty-but-HasMore forever
|
|
@@ -1942,6 +2353,11 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1942
2353
|
const fetchTimeoutMs = PositiveInt(this.getConfigOverrides(config).fetchTimeoutMs)
|
|
1943
2354
|
?? PositiveInt(config.connector.FetchChangesTimeoutMs)
|
|
1944
2355
|
?? DEFAULT_OPERATION_TIMEOUTS.FetchChangesMs;
|
|
2356
|
+
// Pipelined prefetch state: at most ONE page in flight ahead of processing, keyed by the
|
|
2357
|
+
// cursor it was built from so a drifted position (gap-skip/reset) discards it instead of
|
|
2358
|
+
// consuming the wrong page. If the loop exits with a prefetch still in flight, the promise
|
|
2359
|
+
// settles in the background and its result is discarded (its .catch keeps that silent).
|
|
2360
|
+
let prefetchedNext = null;
|
|
1945
2361
|
while (hasMore) {
|
|
1946
2362
|
if (abortSignal?.aborted) {
|
|
1947
2363
|
console.log(`[IntegrationEngine] Sync cancelled for ${entityMap.ExternalObjectName} after ${recordsInMap} records — saving watermark`);
|
|
@@ -1985,31 +2401,19 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1985
2401
|
let batch;
|
|
1986
2402
|
const fetchStart = Date.now();
|
|
1987
2403
|
try {
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
//
|
|
2002
|
-
// Deliberately `instanceof` rather than the classified code. `ClassifyError` folds
|
|
2003
|
-
// `econnreset` in with timeouts under `NETWORK_TIMEOUT`, and a reset socket IS worth
|
|
2004
|
-
// retrying — so excluding the whole code would lose real resilience. Only the error
|
|
2005
|
-
// WithTimeout itself minted is excluded.
|
|
2006
|
-
(err) => !(err instanceof OperationTimeoutError) && IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => logger?.emit('sync.fetch.retry', {
|
|
2007
|
-
externalObjectName: entityMap.ExternalObjectName,
|
|
2008
|
-
batchIndex: batchCount,
|
|
2009
|
-
attempt,
|
|
2010
|
-
delayMs,
|
|
2011
|
-
error: err instanceof Error ? err.message : String(err),
|
|
2012
|
-
}));
|
|
2404
|
+
if (prefetchedNext && prefetchedNext.key === (currentCursor ?? '')) {
|
|
2405
|
+
// The page already in flight IS this page — consume it. The rate limiter, the
|
|
2406
|
+
// fetch gate, the timeout/retry envelope, and once-per-episode throttle
|
|
2407
|
+
// reporting all ran inside governedFetch when the prefetch was launched, so
|
|
2408
|
+
// consuming it here adds no vendor pressure and loses no error semantics.
|
|
2409
|
+
const inFlight = prefetchedNext;
|
|
2410
|
+
prefetchedNext = null;
|
|
2411
|
+
batch = await inFlight.promise;
|
|
2412
|
+
}
|
|
2413
|
+
else {
|
|
2414
|
+
prefetchedNext = null; // position drifted (gap-skip/reset) — discard the stale prefetch
|
|
2415
|
+
batch = await this.governedFetch(config, ctx, entityMap.ExternalObjectName, fetchTimeoutMs, batchCount, logger);
|
|
2416
|
+
}
|
|
2013
2417
|
this.reportRateOutcome(config); // clean fetch → ramp the adaptive rate back up
|
|
2014
2418
|
fetchGapCount = 0; // clean fetch → reset the consecutive fetch-gap counter
|
|
2015
2419
|
// §10: connector type-driven post-processing hook (default no-op) — enforce/normalize
|
|
@@ -2017,14 +2421,68 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2017
2421
|
if (batch.Records.length > 0) {
|
|
2018
2422
|
batch.Records = batch.Records.map(r => config.connector.PostProcessRecord(r));
|
|
2019
2423
|
}
|
|
2424
|
+
// Pipelined prefetch (cursor-paged connectors): the next cursor is known the
|
|
2425
|
+
// moment a page arrives, so start downloading page N+1 while page N is mapped and
|
|
2426
|
+
// written — the shorter leg hides under the longer (~20-30% cycle reduction
|
|
2427
|
+
// measured at a ~6s fetch / ~1-2s process split). Cursor mode ONLY: offset/page
|
|
2428
|
+
// modes interact with the gap-skip resume logic and stay serial. Kill switch:
|
|
2429
|
+
// MJ_INTEGRATION_PREFETCH=off.
|
|
2430
|
+
if ((process.env.MJ_INTEGRATION_PREFETCH ?? 'on') !== 'off' && batch.HasMore === true && batch.NextCursor) {
|
|
2431
|
+
// Built from the ADVANCED position, exactly as the loop-top rebuild does.
|
|
2432
|
+
// Spreading ctx with only CurrentCursor once left AfterKeyValue (and
|
|
2433
|
+
// CurrentOffset) stale, so a keyset connector's "next" page re-ran the
|
|
2434
|
+
// previous seek: page N+1 was page N again, the duplicate-batch fingerprint
|
|
2435
|
+
// killed the walk, and every keyset object stopped at exactly two server pages.
|
|
2436
|
+
const nextCtx = {
|
|
2437
|
+
...ctx,
|
|
2438
|
+
CurrentPage: batch.NextPage,
|
|
2439
|
+
CurrentOffset: batch.NextOffset,
|
|
2440
|
+
CurrentCursor: batch.NextCursor,
|
|
2441
|
+
AfterKeyValue: batch.NextAfterKeyValue ?? ctx.AfterKeyValue,
|
|
2442
|
+
};
|
|
2443
|
+
const nextPage = this.governedFetch(config, nextCtx, entityMap.ExternalObjectName, fetchTimeoutMs, batchCount + 1, logger);
|
|
2444
|
+
nextPage.catch(() => { });
|
|
2445
|
+
prefetchedNext = { key: batch.NextCursor, promise: nextPage };
|
|
2446
|
+
}
|
|
2020
2447
|
}
|
|
2021
2448
|
catch (fetchErr) {
|
|
2022
2449
|
const errMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
|
2023
2450
|
// A throttle (429 / rate-limit) backs the adaptive limiter off (honoring Retry-After);
|
|
2024
2451
|
// other errors don't touch the rate. §5 Gap 2: also flag the map result so the per-layer
|
|
2025
2452
|
// AIMD controller reduces in-flight concurrency, not just the per-request token bucket.
|
|
2453
|
+
if (IsObjectUnavailable(fetchErr)) {
|
|
2454
|
+
// Not a failure to retry: the vendor is telling us this account does not serve
|
|
2455
|
+
// this object. Warn once and end the map cleanly — no retry ladder, no
|
|
2456
|
+
// FETCH_INCOMPLETE.
|
|
2457
|
+
//
|
|
2458
|
+
// Deliberately NOT remembered between runs. Persisting it would buy one probe
|
|
2459
|
+
// per object per run, and the object count in any real system is small enough
|
|
2460
|
+
// that this is not worth a stored marker, a recheck clock, and the staleness
|
|
2461
|
+
// both bring: a remembered skip is wrong from the moment the account changes,
|
|
2462
|
+
// and every scheme for noticing that is another thing to get right. Re-asking
|
|
2463
|
+
// every run is self-healing by construction and has no configuration.
|
|
2464
|
+
//
|
|
2465
|
+
// But the map fetched NOTHING, so it is NOT a clean fetch, and every consequence
|
|
2466
|
+
// of "we saw the complete set" must be withheld. Breaking out with the flag still
|
|
2467
|
+
// true fell through to the clean-fetch branch and:
|
|
2468
|
+
// - minted a wall-clock Timestamp watermark for an object that returned zero
|
|
2469
|
+
// records. When the account later enables the object, the next incremental
|
|
2470
|
+
// filters `modified > <that stamp>` and permanently misses every record that
|
|
2471
|
+
// already existed — destroying the self-healing described above. (An
|
|
2472
|
+
// incremental over an EXISTING watermark merely rewrote the same value; the
|
|
2473
|
+
// damage lands on a full sync and on the first encounter, where no watermark
|
|
2474
|
+
// row exists yet and one is created at "now".)
|
|
2475
|
+
// - ran orphan detection. An empty fetch is not evidence that MJ's rows are gone.
|
|
2476
|
+
// - overwrote the partition rollup snapshot with an empty map, forcing a full
|
|
2477
|
+
// re-diff next run.
|
|
2478
|
+
fetchCompletedCleanly = false;
|
|
2479
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'OBJECT_UNAVAILABLE', `"${entityMap.ExternalObjectName}" is not available to this account; skipping it until the source starts serving it: ${errMsg}`, { externalObjectName: entityMap.ExternalObjectName });
|
|
2480
|
+
break;
|
|
2481
|
+
}
|
|
2026
2482
|
if (ClassifyError(fetchErr).Code === 'RATE_LIMIT_EXCEEDED') {
|
|
2027
|
-
|
|
2483
|
+
// The adaptive decrease already happened inside governedFetch (once per
|
|
2484
|
+
// throttle episode); here we only flag the map result so the per-layer AIMD
|
|
2485
|
+
// controller reduces in-flight concurrency too.
|
|
2028
2486
|
result.Throttled = true;
|
|
2029
2487
|
}
|
|
2030
2488
|
console.error(`[IntegrationEngine] FetchChanges error for ${entityMap.ExternalObjectName}: ${errMsg}`);
|
|
@@ -2044,6 +2502,15 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2044
2502
|
fetchGapCount++;
|
|
2045
2503
|
hadFetchGap = true;
|
|
2046
2504
|
fetchCompletedCleanly = false;
|
|
2505
|
+
// A durability floor written before this gap may sit PAST the hole (the skipped
|
|
2506
|
+
// page can hold records behind the max watermark seen). Put the row back to what
|
|
2507
|
+
// it held before this run, exactly what the post-loop hold does for the in-memory
|
|
2508
|
+
// value — a crash from here on resumes from the pre-run watermark and re-covers
|
|
2509
|
+
// the gap. Later checkpoints stop writing floors (gate above).
|
|
2510
|
+
if (watermarkFloorSaved !== null) {
|
|
2511
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.RestoreValue(entityMapID, preRunWatermarkValue, contextUser));
|
|
2512
|
+
watermarkFloorSaved = null;
|
|
2513
|
+
}
|
|
2047
2514
|
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_PAGE_SKIPPED', `Persistent fetch error at ${currentOffset != null ? 'offset ' + currentOffset : 'page ' + currentPage} for ` +
|
|
2048
2515
|
`'${entityMap.ExternalObjectName}' (batch ${batchCount}); skipped this page and continued — the ` +
|
|
2049
2516
|
`watermark is held so the window is re-fetched next run. Error: ${errMsg}`, { offset: currentOffset ?? null, page: currentPage ?? null, batchIndex: batchCount, error: errMsg });
|
|
@@ -2133,7 +2600,20 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2133
2600
|
fetchedExternalIDs.clear();
|
|
2134
2601
|
}
|
|
2135
2602
|
}
|
|
2136
|
-
|
|
2603
|
+
// Within-batch identity, enforced before mapping: two records sharing an ExternalID are
|
|
2604
|
+
// two observations of ONE source record. The write path cannot catch this — it decides
|
|
2605
|
+
// insert-vs-update against the DATABASE, where a first-time identity is absent for both
|
|
2606
|
+
// copies, so both insert and the pair re-inserts every sync. The fingerprint guard above
|
|
2607
|
+
// only sees a batch repeated in FULL. Never silent: a connector emitting duplicate
|
|
2608
|
+
// identities is a defect worth fixing at its source.
|
|
2609
|
+
const identity = CollapseDuplicateIdentities(batch.Records);
|
|
2610
|
+
if (identity.Collapsed > 0) {
|
|
2611
|
+
logger?.warning(entityMap.ExternalObjectName ?? 'sync', 'DUPLICATE_IDENTITIES_IN_BATCH', `${entityMap.ExternalObjectName}: ${identity.Collapsed} record(s) repeated an ExternalID already `
|
|
2612
|
+
+ `present in the same batch and were collapsed (last occurrence kept). Two records sharing an `
|
|
2613
|
+
+ `identity are one source record observed twice; writing both would insert duplicate rows that `
|
|
2614
|
+
+ `no later sync could reconcile. Sample: ${identity.SampleIDs.join(', ')}`, { object: entityMap.ExternalObjectName, collapsed: identity.Collapsed, sample: identity.SampleIDs });
|
|
2615
|
+
}
|
|
2616
|
+
const mapped = this.fieldMappingEngine.Apply(identity.Records, fieldMaps, entityMap.Entity, excludedSourceNames);
|
|
2137
2617
|
// Custom-key stats: aggregate unmapped keys for EVERY mapped record here —
|
|
2138
2618
|
// before any skip decision — so candidates + sizing stats exist even when the
|
|
2139
2619
|
// content-hash fast path skips the row (the hash basis deliberately excludes them).
|
|
@@ -2162,11 +2642,11 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2162
2642
|
// not begun"). Holding the same write-lock for the read keeps the connection single-owner.
|
|
2163
2643
|
const resolved = partitionReconcile
|
|
2164
2644
|
? []
|
|
2165
|
-
: await this.
|
|
2645
|
+
: await this.runWriteForMap(entityMap.ID, () => this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser));
|
|
2166
2646
|
const beforeApply = result.RecordsCreated + result.RecordsUpdated + result.RecordsSkipped + result.RecordsErrored;
|
|
2167
2647
|
try {
|
|
2168
2648
|
if (!partitionReconcile)
|
|
2169
|
-
await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1);
|
|
2649
|
+
await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1, this.getSyncConcurrency(config));
|
|
2170
2650
|
}
|
|
2171
2651
|
catch (applyErr) {
|
|
2172
2652
|
if (applyErr instanceof SchemaNotGeneratedError) {
|
|
@@ -2202,7 +2682,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2202
2682
|
(batch.HasMore ? ` | more batches pending` : ` | batch complete`));
|
|
2203
2683
|
// Update progress on the watermark record so the DB reflects live sync state
|
|
2204
2684
|
if (batch.HasMore) {
|
|
2205
|
-
await this.
|
|
2685
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.UpdateProgress(entityMapID, afterApply, contextUser));
|
|
2206
2686
|
}
|
|
2207
2687
|
}
|
|
2208
2688
|
recordsInMap += batch.Records.length;
|
|
@@ -2236,7 +2716,26 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2236
2716
|
// post-loop save below handles graceful early-exits precisely; this covers a SIGKILL
|
|
2237
2717
|
// between graceful checkpoints, costing at most ~25 batches of re-fetch on resume.
|
|
2238
2718
|
if (isKeysetConnector && currentAfterKey) {
|
|
2239
|
-
await this.
|
|
2719
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
|
|
2720
|
+
}
|
|
2721
|
+
// The WATERMARK twin of the keyset floor above. Without it, a watermark-based
|
|
2722
|
+
// connector had NO durable position at all until the run ended: a SIGKILL / OOM /
|
|
2723
|
+
// container recycle mid-object threw away hours of applied batches and the next run
|
|
2724
|
+
// re-fetched the entire window from the last completed run's watermark. Same safety
|
|
2725
|
+
// argument as the graceful early-exit save below — currentWatermark only ever
|
|
2726
|
+
// advances at the END of a fully-applied batch, so this floor can never point past a
|
|
2727
|
+
// record that was not written. Gated on !hadFetchGap because a skipped page may
|
|
2728
|
+
// contain records BEHIND the max watermark seen (fetch order is not watermark order
|
|
2729
|
+
// on every source), i.e. a hole behind the floor; once a gap exists the floor stops
|
|
2730
|
+
// moving, and the one already written is retracted at the gap site. Skipped for
|
|
2731
|
+
// keyset connectors (their position IS the seek key above) and partition-reconcile
|
|
2732
|
+
// maps (their watermark row stores the rollup snapshot, not a timestamp).
|
|
2733
|
+
if (!isKeysetConnector && !partitionReconcile && !hadFetchGap
|
|
2734
|
+
&& currentWatermark && currentWatermark !== initialWatermark
|
|
2735
|
+
&& currentWatermark !== watermarkFloorSaved) {
|
|
2736
|
+
const floor = currentWatermark;
|
|
2737
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.Update(entityMapID, floor, contextUser, 'Pull'));
|
|
2738
|
+
watermarkFloorSaved = floor;
|
|
2240
2739
|
}
|
|
2241
2740
|
}
|
|
2242
2741
|
// P3-D: a connector returning empty pages with HasMore=true would otherwise spin silently
|
|
@@ -2272,7 +2771,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2272
2771
|
// NOTE: a connector that ALSO returns a monotonic watermark (MonotonicWatermark=true) skips
|
|
2273
2772
|
// this branch and falls through to SAVE that watermark below, so its next incremental NARROWS
|
|
2274
2773
|
// (microtime > watermark) instead of re-scanning the whole object every run.
|
|
2275
|
-
await this.
|
|
2774
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.ClearKeysetPosition(entityMapID, contextUser));
|
|
2276
2775
|
result.WatermarkAfter = null;
|
|
2277
2776
|
}
|
|
2278
2777
|
else if (fetchCompletedCleanly) {
|
|
@@ -2313,13 +2812,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2313
2812
|
else {
|
|
2314
2813
|
finalWatermark = new Date().toISOString();
|
|
2315
2814
|
}
|
|
2316
|
-
await this.
|
|
2815
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.Update(entityMapID, finalWatermark, contextUser, 'Pull'));
|
|
2317
2816
|
result.WatermarkAfter = finalWatermark;
|
|
2318
2817
|
}
|
|
2319
2818
|
else if (isKeysetConnector && currentAfterKey) {
|
|
2320
2819
|
// The keyset scan stopped early (cancel / fetch error / safety limit). Persist the precise
|
|
2321
2820
|
// last ordering key so the next run resumes the seek from here instead of restarting.
|
|
2322
|
-
await this.
|
|
2821
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
|
|
2323
2822
|
result.WatermarkAfter = currentAfterKey;
|
|
2324
2823
|
}
|
|
2325
2824
|
else if (!hadFetchGap && currentWatermark && currentWatermark !== initialWatermark) {
|
|
@@ -2339,7 +2838,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2339
2838
|
// deliberately NOT when hadFetchGap — a skipped page leaves a HOLE behind this watermark,
|
|
2340
2839
|
// which is why that path holds it for a full re-fetch next run.
|
|
2341
2840
|
const partialWatermark = currentWatermark;
|
|
2342
|
-
await this.
|
|
2841
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.Update(entityMapID, partialWatermark, contextUser, 'Pull'));
|
|
2343
2842
|
result.WatermarkAfter = partialWatermark;
|
|
2344
2843
|
}
|
|
2345
2844
|
// Orphan detection: delete/tombstone MJ records whose external counterpart no longer exists.
|
|
@@ -2443,6 +2942,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2443
2942
|
async ProcessPushSync(config, entityMap, run, contextUser, _entityMapIndex, _totalEntityMaps, _onProgress, _abortSignal, logger) {
|
|
2444
2943
|
const entityMapID = entityMap.ID;
|
|
2445
2944
|
const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
|
|
2945
|
+
this.WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger);
|
|
2446
2946
|
const pushWatermark = await this.watermarkService.Load(entityMapID, contextUser, 'Push');
|
|
2447
2947
|
const lastPushAt = pushWatermark?.WatermarkValue ?? null;
|
|
2448
2948
|
// Check connector write capability
|
|
@@ -2918,7 +3418,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2918
3418
|
}
|
|
2919
3419
|
// Surface a failed conflict-mark: the engine thinks the row is quarantined, but without the
|
|
2920
3420
|
// marker the operator has no signal. A silent failure here leaves the record in limbo.
|
|
2921
|
-
const ok = await entity.Save();
|
|
3421
|
+
const ok = await entity.Save(this.syncSaveOptions);
|
|
2922
3422
|
if (!ok) {
|
|
2923
3423
|
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 });
|
|
2924
3424
|
}
|
|
@@ -2949,12 +3449,25 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2949
3449
|
const orphans = allMaps.Rows.filter(m => !fetchedExternalIDs.has(m.ExternalSystemRecordID));
|
|
2950
3450
|
if (orphans.length === 0)
|
|
2951
3451
|
return;
|
|
3452
|
+
// The sweep is a DELETE PATH and must answer to the same policy as every other delete.
|
|
3453
|
+
// It used to call entity.Delete() unconditionally — its own warning text promised
|
|
3454
|
+
// "archived/deleted" while the code only ever deleted, so a map configured SoftDelete
|
|
3455
|
+
// (or DoNothing) had its rows physically removed by full syncs. DoNothing short-circuits
|
|
3456
|
+
// the whole sweep: the policy says external deletions never touch MJ rows, and saying so
|
|
3457
|
+
// once beats detecting the same "orphans" forever.
|
|
3458
|
+
if (entityMap.DeleteBehavior === 'DoNothing') {
|
|
3459
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_POLICY_SKIPPED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this ` +
|
|
3460
|
+
`full sync. This map's DeleteBehavior is 'DoNothing', so none were touched.`, { orphanCount: orphans.length });
|
|
3461
|
+
return;
|
|
3462
|
+
}
|
|
2952
3463
|
console.log(`[IntegrationEngine] Orphan detection for ${entityMap.ExternalObjectName}: ${orphans.length} records in MJ not found in external system`);
|
|
2953
3464
|
// Surface delete-detection in the structured stream (previously console-only). The orphan
|
|
2954
3465
|
// COUNT is already in the run counts via RecordsDeleted, but a dedicated warning makes a
|
|
2955
3466
|
// large/unexpected count visible over GraphQL — the early signal of an incomplete upstream
|
|
2956
3467
|
// fetch silently archiving live records.
|
|
2957
|
-
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_DETECTED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this full sync —
|
|
3468
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_DETECTED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this full sync — ` +
|
|
3469
|
+
`they will be ${entityMap.DeleteBehavior === 'SoftDelete' ? 'archived (SoftDelete)' : 'deleted'} and their record-map rows pruned (delete-detection). ` +
|
|
3470
|
+
`A large or unexpected count can indicate an incomplete upstream fetch, so review before trusting the deletions.`, { orphanCount: orphans.length });
|
|
2958
3471
|
const md = this.ProviderToUse;
|
|
2959
3472
|
const entityInfo = md.EntityByName(entityMap.Entity);
|
|
2960
3473
|
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
@@ -2964,11 +3477,44 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2964
3477
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(orphan.EntityRecordID, pkFields));
|
|
2965
3478
|
if (!loaded) {
|
|
2966
3479
|
console.log(`[IntegrationEngine] Orphan ${orphan.EntityRecordID} already deleted from MJ`);
|
|
3480
|
+
// The map row outlived its record. Nothing anywhere else deletes record-map
|
|
3481
|
+
// rows, so without this the same "orphan" is re-detected on EVERY subsequent
|
|
3482
|
+
// full sync and ORPHANS_DETECTED becomes a cumulative counter of history
|
|
3483
|
+
// rather than a signal about THIS run — observed live as a count that only
|
|
3484
|
+
// ever grew, sync after sync.
|
|
3485
|
+
await this.DeleteRecordMapRow(orphan.ID, contextUser);
|
|
2967
3486
|
continue;
|
|
2968
3487
|
}
|
|
2969
|
-
|
|
3488
|
+
if (entityMap.DeleteBehavior === 'SoftDelete') {
|
|
3489
|
+
// Same archive shape as DeleteRecord's SoftDelete branch: the row stays,
|
|
3490
|
+
// marked Archived/tombstoned. The MAP row goes either way — the mapping's
|
|
3491
|
+
// job is done, and keeping it would re-detect this orphan forever.
|
|
3492
|
+
const fields = entity.Fields ?? [];
|
|
3493
|
+
const hasField = (n) => fields.some(f => f.Name === n);
|
|
3494
|
+
if (hasField('__mj_integration_SyncStatus'))
|
|
3495
|
+
entity.Set('__mj_integration_SyncStatus', 'Archived');
|
|
3496
|
+
if (hasField('__mj_integration_LastSyncedAt'))
|
|
3497
|
+
entity.Set('__mj_integration_LastSyncedAt', new Date().toISOString());
|
|
3498
|
+
if (hasField('__mj_integration_IsTombstoned'))
|
|
3499
|
+
entity.Set('__mj_integration_IsTombstoned', true);
|
|
3500
|
+
if (hasField('__mj_integration_DeletedDetectedAt'))
|
|
3501
|
+
entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
|
|
3502
|
+
const archived = await entity.Save(this.syncSaveOptions);
|
|
3503
|
+
if (archived) {
|
|
3504
|
+
result.RecordsDeleted++;
|
|
3505
|
+
await this.DeleteRecordMapRow(orphan.ID, contextUser);
|
|
3506
|
+
console.log(`[IntegrationEngine] Archived orphan ${entityMap.Entity} ${orphan.EntityRecordID} (external ${orphan.ExternalSystemRecordID} no longer exists)`);
|
|
3507
|
+
}
|
|
3508
|
+
else {
|
|
3509
|
+
const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
|
|
3510
|
+
console.warn(`[IntegrationEngine] Orphan archive blocked for ${entityMap.Entity} ${orphan.EntityRecordID} — ${reason}`);
|
|
3511
|
+
}
|
|
3512
|
+
continue;
|
|
3513
|
+
}
|
|
3514
|
+
const deleted = await entity.Delete(this.syncDeleteOptions);
|
|
2970
3515
|
if (deleted) {
|
|
2971
3516
|
result.RecordsDeleted++;
|
|
3517
|
+
await this.DeleteRecordMapRow(orphan.ID, contextUser);
|
|
2972
3518
|
console.log(`[IntegrationEngine] Deleted orphan ${entityMap.Entity} ${orphan.EntityRecordID} (external ${orphan.ExternalSystemRecordID} no longer exists)`);
|
|
2973
3519
|
}
|
|
2974
3520
|
else {
|
|
@@ -2982,6 +3528,28 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2982
3528
|
}
|
|
2983
3529
|
}
|
|
2984
3530
|
}
|
|
3531
|
+
/**
|
|
3532
|
+
* Removes one 'MJ: Company Integration Record Maps' row after delete-detection has handled
|
|
3533
|
+
* its orphan (deleted, archived, or found already gone). A failure here is logged and
|
|
3534
|
+
* swallowed: the orphan itself was handled, and the worst consequence of a surviving map
|
|
3535
|
+
* row is one redundant re-detection on the next full sync.
|
|
3536
|
+
*/
|
|
3537
|
+
async DeleteRecordMapRow(mapRowID, contextUser) {
|
|
3538
|
+
try {
|
|
3539
|
+
const md = this.ProviderToUse;
|
|
3540
|
+
const mapRow = await md.GetEntityObject('MJ: Company Integration Record Maps', contextUser);
|
|
3541
|
+
const loaded = await mapRow.InnerLoad(CompositeKey.FromID(mapRowID));
|
|
3542
|
+
if (!loaded)
|
|
3543
|
+
return;
|
|
3544
|
+
const ok = await mapRow.Delete();
|
|
3545
|
+
if (!ok) {
|
|
3546
|
+
console.warn(`[IntegrationEngine] Record-map prune blocked for ${mapRowID} — ${mapRow.LatestResult?.CompleteMessage ?? 'unknown reason'}`);
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
catch (err) {
|
|
3550
|
+
console.warn(`[IntegrationEngine] Record-map prune failed for ${mapRowID}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
2985
3553
|
/**
|
|
2986
3554
|
* Strips field-name prefixes from a RecordChange.RecordID to get the raw entity record ID.
|
|
2987
3555
|
* "hs_object_id|465950833372" → "465950833372"
|
|
@@ -3109,8 +3677,8 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3109
3677
|
// (~line 1644). matchEngine.Resolve reads existing MJ rows on the SHARED provider
|
|
3110
3678
|
// connection, so when streams run in parallel (syncConcurrency>1) it must not interleave
|
|
3111
3679
|
// with another stream's open write transaction (else "Transaction in progress" / dirty read).
|
|
3112
|
-
const resolved = await this.
|
|
3113
|
-
await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1);
|
|
3680
|
+
const resolved = await this.runWriteForMap(entityMap.ID, () => this.matchEngine.Resolve(recs, entityMap, fieldMaps, contextUser));
|
|
3681
|
+
await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1, this.getSyncConcurrency(config));
|
|
3114
3682
|
appliedRecords += recs.length;
|
|
3115
3683
|
}
|
|
3116
3684
|
}
|
|
@@ -3148,7 +3716,14 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3148
3716
|
// lost batch atomicity is absorbed by the engine's idempotency (upsert-by-identity + content
|
|
3149
3717
|
// hash) and the safe-floor watermark (advances only on a clean batch). Default true = the
|
|
3150
3718
|
// proven atomic serial path, unchanged.
|
|
3151
|
-
useTransaction = true
|
|
3719
|
+
useTransaction = true,
|
|
3720
|
+
/**
|
|
3721
|
+
* Requested apply concurrency. Only consulted on the transaction-free path (the one
|
|
3722
|
+
* `useTransaction: false` selects), where records auto-commit independently and can
|
|
3723
|
+
* therefore overlap. Defaults to 1, so a caller that does not pass it keeps the serial
|
|
3724
|
+
* behaviour exactly.
|
|
3725
|
+
*/
|
|
3726
|
+
concurrency = 1) {
|
|
3152
3727
|
// Batched application with per-record failure isolation (the "grace gap" fix).
|
|
3153
3728
|
// Happy path: each batch of up to APPLY_BATCH_SIZE records commits as a single
|
|
3154
3729
|
// transaction — small enough to avoid SQL Server lock escalation (~5000 rows) while
|
|
@@ -3175,27 +3750,174 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3175
3750
|
// otherwise load one-by-one. For a watermark-less re-sync where nothing changed,
|
|
3176
3751
|
// this lets UpdateRecord skip every per-record load. Best-effort: undefined → the
|
|
3177
3752
|
// existing dirty-flag path runs unchanged.
|
|
3178
|
-
//
|
|
3179
|
-
//
|
|
3180
|
-
//
|
|
3181
|
-
//
|
|
3182
|
-
|
|
3753
|
+
// WHAT ACTUALLY NEEDS THE MUTEX.
|
|
3754
|
+
//
|
|
3755
|
+
// The shared provider connection holds one transaction at a time, so the section that
|
|
3756
|
+
// OWNS a transaction must be mutually exclusive. `BeginTransaction` + per-record
|
|
3757
|
+
// `Save()` owns one for the whole batch, and stays serialized exactly as before.
|
|
3758
|
+
//
|
|
3759
|
+
// A batched batch does not. A `TransactionGroup` is an in-memory list until `Submit()`:
|
|
3760
|
+
// enrolling an entity validates, checks row scope and renders the CRUD procedure call,
|
|
3761
|
+
// then parks it — no statement travels, no transaction is open. Only `Submit` touches
|
|
3762
|
+
// the connection. Holding the mutex across the whole apply block was therefore
|
|
3763
|
+
// serializing work that never needed it, and that is what made batching and concurrency
|
|
3764
|
+
// mutually exclusive: maps could not overlap on the part where the time actually goes
|
|
3765
|
+
// (fetch, paging, transform, enrolment) because they were queued behind each other's
|
|
3766
|
+
// writes.
|
|
3767
|
+
//
|
|
3768
|
+
// So the batched path takes the mutex only around the writes themselves. One
|
|
3769
|
+
// transaction is still in flight at a time — the invariant is unchanged — but maps
|
|
3770
|
+
// overlap everywhere else, and each keeps its OWN group, so a poison record fails the
|
|
3771
|
+
// map that owns it instead of every map that happened to be batching alongside it.
|
|
3772
|
+
// Deliberately NOT gated on `useTransaction`. That gate is
|
|
3773
|
+
// `getSyncConcurrency(config) <= 1`, so keeping it here would mean batching only ever
|
|
3774
|
+
// happened at concurrency 1 — the exact tradeoff this change exists to remove.
|
|
3775
|
+
// Batching is a property of how the writes TRAVEL; concurrency is a property of how
|
|
3776
|
+
// many maps fetch at once. They are independent.
|
|
3777
|
+
// Batching is a property of the ENTITY MAP, not only of the connection: a map whose
|
|
3778
|
+
// target's whole identity is server-assigned can never enrol a record, so batching it
|
|
3779
|
+
// would produce an empty group and a non-atomic batch reporting success. See
|
|
3780
|
+
// entityMapHasIdentityOnlyPK.
|
|
3781
|
+
const batchedWrites = this.ReadWriteMode(companyIntegration) === 'batched'
|
|
3782
|
+
&& !this.entityMapHasIdentityOnlyPK(entityMap);
|
|
3783
|
+
// NEVER nest `runWriteExclusive`: the inner call waits on a chain that already contains
|
|
3784
|
+
// the outer one, which deadlocks. Under the outer mutex the writes are already
|
|
3785
|
+
// serialized, so they run inline; without it they take the mutex individually.
|
|
3786
|
+
const serializeWrite = batchedWrites
|
|
3787
|
+
? (fn) => this.runWriteForMap(entityMap.ID, fn)
|
|
3788
|
+
: (fn) => fn();
|
|
3789
|
+
const applyOneBatch = async () => {
|
|
3183
3790
|
const precheckHashes = await this.PrefetchContentHashes(batch, contextUser);
|
|
3184
3791
|
// PKs of records the content-hash fast path skipped this batch — still present and
|
|
3185
3792
|
// confirmed-unchanged on the source. Collected so we can refresh LastReconciledAt for
|
|
3186
3793
|
// all of them in ONE set-based touch after the batch (instead of a frozen-forever stamp).
|
|
3187
3794
|
let reconciledSkipIds = [];
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3795
|
+
// Batched writes make a batch atomic by construction — the group is one
|
|
3796
|
+
// transaction — so this branch is entered for them regardless of concurrency. At
|
|
3797
|
+
// concurrency > 1 the atomicity is per entity map, and a group failure still
|
|
3798
|
+
// degrades to the record-by-record retry below.
|
|
3799
|
+
if (useTransaction || batchedWrites) {
|
|
3800
|
+
// Two ways to make this batch atomic, and they differ ONLY in how the writes
|
|
3801
|
+
// travel. `BeginTransaction` + per-record `Save()` sends one statement per
|
|
3802
|
+
// record; a TransactionGroup defers each `Save()` to `Submit()`, which sends
|
|
3803
|
+
// them together. Everything `Save()` does either way — validation, row scope,
|
|
3804
|
+
// the generated CRUD procedure, Record Changes, `OnAfterSaveExecute` — is
|
|
3805
|
+
// identical, because the group defers the WRITE and nothing else.
|
|
3806
|
+
//
|
|
3807
|
+
// Opt-in per connection, and it fails closed: an absent, unparseable or
|
|
3808
|
+
// unrecognised `writeMode` keeps the proven path, so the default never changes
|
|
3809
|
+
// underneath an existing tenant.
|
|
3810
|
+
const writeGroup = batchedWrites ? await provider.CreateTransactionGroup() : null;
|
|
3811
|
+
if (writeGroup) {
|
|
3812
|
+
// The batching itself (MJ#4087). Without this the group is atomic but still
|
|
3813
|
+
// SERIAL — one round trip per item inside the transaction, which is exactly
|
|
3814
|
+
// today's behaviour and why this PR was safe to land before the providers
|
|
3815
|
+
// could honour it. With it, the whole group travels as one statement.
|
|
3816
|
+
//
|
|
3817
|
+
// Set here rather than at construction because it is a property of how THIS
|
|
3818
|
+
// engine wants its batch submitted, not of the provider: any other caller
|
|
3819
|
+
// creating a group still gets the sequential default.
|
|
3820
|
+
writeGroup.BatchedSubmit = true;
|
|
3821
|
+
}
|
|
3822
|
+
const runCtx = this.currentRunContext;
|
|
3823
|
+
// A batched batch REQUIRES a run context: the group is handed to
|
|
3824
|
+
// ApplySingleRecord's frames through a nested scope of it, so without one
|
|
3825
|
+
// every record would silently save unenrolled and Submit would commit an
|
|
3826
|
+
// empty group — which returns true, so the batch would report success while
|
|
3827
|
+
// having quietly run the per-record path. Every sync path enters a run
|
|
3828
|
+
// context (RunSync and the adopted-run path both wrap their work in one), so
|
|
3829
|
+
// this cannot happen today; stating it makes that a requirement rather than
|
|
3830
|
+
// an accident, and turns a future regression into an error instead of a
|
|
3831
|
+
// silent loss of the feature.
|
|
3832
|
+
if (writeGroup && !runCtx) {
|
|
3833
|
+
throw new Error('batched writes were requested but no run context is active — the write group cannot reach the record ' +
|
|
3834
|
+
'frames, and an unenrolled batch would report success while saving per-record. This is a wiring bug.');
|
|
3835
|
+
}
|
|
3836
|
+
if (!writeGroup)
|
|
3837
|
+
await provider.BeginTransaction();
|
|
3838
|
+
// EACH BATCH GETS ITS OWN GROUP, IN ITS OWN CONTEXT SCOPE.
|
|
3839
|
+
//
|
|
3840
|
+
// Assigning onto the shared run context would be a single slot: the moment two
|
|
3841
|
+
// maps overlap — which narrowing the mutex now allows — the second would
|
|
3842
|
+
// overwrite the first's group and enrol its records into the wrong batch.
|
|
3843
|
+
// Entering a nested AsyncLocalStorage scope instead gives every concurrent
|
|
3844
|
+
// batch its own `writeGroup`, inherited by the ApplySingleRecord frames below
|
|
3845
|
+
// that actually construct the entities, and torn down with the scope.
|
|
3846
|
+
//
|
|
3847
|
+
// Per-batch groups are also what keeps failures isolated: a poison record fails
|
|
3848
|
+
// the group its own map owns, and every other map in flight is untouched.
|
|
3849
|
+
// OPT-IN ceiling on how many writes may sit deferred in one group.
|
|
3850
|
+
//
|
|
3851
|
+
// A group holds every enrolled record's rendered SQL and parameters until Submit,
|
|
3852
|
+
// so peak memory for a batched apply is roughly (maps in flight x group size x row
|
|
3853
|
+
// size). With wide rows that is the largest allocation a sync makes, and a box that
|
|
3854
|
+
// has run out of heap has no way to trade a little throughput for headroom.
|
|
3855
|
+
//
|
|
3856
|
+
// Unset (the default) means NO mid-batch flush: the batch stays exactly one group
|
|
3857
|
+
// and one transaction, as today. Setting it below the batch size splits the batch
|
|
3858
|
+
// into several transactions, which is a real trade — an earlier flush stays
|
|
3859
|
+
// committed if a later one fails — and is why it is off unless asked for. The
|
|
3860
|
+
// per-record fallback that follows a failed batch is idempotent, so the split is
|
|
3861
|
+
// recoverable; it is simply no longer all-or-nothing.
|
|
3862
|
+
const flushAt = ReadFlushCeiling(process.env);
|
|
3863
|
+
const runBatch = async () => {
|
|
3864
|
+
let enrolledSinceFlush = 0;
|
|
3191
3865
|
for (const record of batch) {
|
|
3192
3866
|
result.RecordsProcessed++;
|
|
3193
3867
|
await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
|
|
3868
|
+
if (flushAt === undefined)
|
|
3869
|
+
continue;
|
|
3870
|
+
if (++enrolledSinceFlush < flushAt)
|
|
3871
|
+
continue;
|
|
3872
|
+
enrolledSinceFlush = 0;
|
|
3873
|
+
// Mutating the context object is what makes the swap visible: every frame
|
|
3874
|
+
// below shares this same object through AsyncLocalStorage, so replacing the
|
|
3875
|
+
// group here is what the next record's enrolment sees.
|
|
3876
|
+
const ctx = this.currentRunContext;
|
|
3877
|
+
const full = ctx?.writeGroup;
|
|
3878
|
+
if (!full)
|
|
3879
|
+
continue;
|
|
3880
|
+
const submitted = await serializeWrite(() => full.Submit());
|
|
3881
|
+
if (!submitted)
|
|
3882
|
+
throw new Error('Batched write group did not commit');
|
|
3883
|
+
const fresh = await provider.CreateTransactionGroup();
|
|
3884
|
+
fresh.BatchedSubmit = true;
|
|
3885
|
+
ctx.writeGroup = fresh;
|
|
3886
|
+
}
|
|
3887
|
+
};
|
|
3888
|
+
try {
|
|
3889
|
+
if (writeGroup) {
|
|
3890
|
+
// Guarded above: a batched batch always has a run context to nest.
|
|
3891
|
+
await IntegrationEngine.runContext.run({ ...runCtx, writeGroup }, runBatch);
|
|
3892
|
+
}
|
|
3893
|
+
else {
|
|
3894
|
+
await runBatch();
|
|
3895
|
+
}
|
|
3896
|
+
if (writeGroup) {
|
|
3897
|
+
// The group holds every deferred write; Submit is where they land, in
|
|
3898
|
+
// one transaction. A false return means the group failed as a whole —
|
|
3899
|
+
// routed into the same catch, so the fallback below is reached by both
|
|
3900
|
+
// shapes rather than only by a throw.
|
|
3901
|
+
//
|
|
3902
|
+
// This is the ONLY part of a batched batch that touches the connection,
|
|
3903
|
+
// so it is the only part that takes the write mutex.
|
|
3904
|
+
const submitted = await serializeWrite(() => writeGroup.Submit());
|
|
3905
|
+
if (!submitted)
|
|
3906
|
+
throw new Error('Batched write group did not commit');
|
|
3907
|
+
}
|
|
3908
|
+
else {
|
|
3909
|
+
await provider.CommitTransaction();
|
|
3194
3910
|
}
|
|
3195
|
-
await provider.CommitTransaction();
|
|
3196
3911
|
}
|
|
3197
3912
|
catch (err) {
|
|
3198
|
-
|
|
3913
|
+
// No shared slot to clear: the group lived in the batch's own context
|
|
3914
|
+
// scope, which has already unwound.
|
|
3915
|
+
//
|
|
3916
|
+
// A group that failed has already rolled itself back and there is no
|
|
3917
|
+
// provider-level transaction open to roll back — calling it would throw
|
|
3918
|
+
// over the real error.
|
|
3919
|
+
if (!batchedWrites)
|
|
3920
|
+
await provider.RollbackTransaction();
|
|
3199
3921
|
// The batch transaction rolled back; the skip-IDs collected during the failed attempt
|
|
3200
3922
|
// never committed. Reset and let the per-record retry re-collect only what commits.
|
|
3201
3923
|
reconciledSkipIds = [];
|
|
@@ -3217,7 +3939,11 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3217
3939
|
}
|
|
3218
3940
|
// Degrade to per-record application so the failure isolates to the poison
|
|
3219
3941
|
// record(s) and every good record in this batch still commits.
|
|
3220
|
-
|
|
3942
|
+
// A batched batch runs concurrently with other entity maps, so the
|
|
3943
|
+
// per-record fallback must NOT open a provider transaction — that state
|
|
3944
|
+
// is global to the shared provider. Auto-commit instead; see the
|
|
3945
|
+
// `useProviderTransaction` doc on applyRecordsIndividually.
|
|
3946
|
+
await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps, !batchedWrites);
|
|
3221
3947
|
}
|
|
3222
3948
|
}
|
|
3223
3949
|
else {
|
|
@@ -3227,7 +3953,23 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3227
3953
|
// connection. Per-record error isolation: a poison record is logged + counted; the
|
|
3228
3954
|
// rest still commit; the idempotent re-sync + safe-floor watermark reconcile any
|
|
3229
3955
|
// partial batch (the atomicity the transactional path provides is not needed here).
|
|
3230
|
-
|
|
3956
|
+
// The records are independent and each auto-commits on its own pooled
|
|
3957
|
+
// connection, so this is the one place in the apply path where the requested
|
|
3958
|
+
// concurrency can actually be spent. Running them one at a time made
|
|
3959
|
+
// syncConcurrency a fetch-only setting: the caller opted into concurrency, paid
|
|
3960
|
+
// for it by giving up batch atomicity, and then still wrote serially.
|
|
3961
|
+
//
|
|
3962
|
+
// A fixed pool of workers pulling from a shared cursor, rather than
|
|
3963
|
+
// Promise.all over the batch: 500 simultaneous saves would swamp the connection
|
|
3964
|
+
// pool. The cap is the same knob the fetch side uses, clamped to a sane ceiling.
|
|
3965
|
+
const applyLimit = Math.max(1, Math.min(16, Math.floor(concurrency) || 1));
|
|
3966
|
+
let cursor = 0;
|
|
3967
|
+
// Set by whichever worker sees it; every worker stops at the next pull and the
|
|
3968
|
+
// error is rethrown after they settle. SchemaNotGeneratedError means the whole
|
|
3969
|
+
// map cannot proceed, so finishing the remaining records would be wasted work
|
|
3970
|
+
// against a table that does not exist.
|
|
3971
|
+
let fatal;
|
|
3972
|
+
const applyOne = async (record) => {
|
|
3231
3973
|
result.RecordsProcessed++;
|
|
3232
3974
|
try {
|
|
3233
3975
|
// §10 — bounded inline retry for provably-transient save failures (auto-commit per
|
|
@@ -3241,7 +3983,8 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3241
3983
|
}
|
|
3242
3984
|
catch (err) {
|
|
3243
3985
|
if (err instanceof SchemaNotGeneratedError) {
|
|
3244
|
-
|
|
3986
|
+
fatal ??= err;
|
|
3987
|
+
return;
|
|
3245
3988
|
}
|
|
3246
3989
|
// §10 — permanent / retry-exhausted → dead-letter (count + log), move on; watermark advances regardless.
|
|
3247
3990
|
result.RecordsErrored++;
|
|
@@ -3254,20 +3997,40 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3254
3997
|
Severity: classified.Severity,
|
|
3255
3998
|
});
|
|
3256
3999
|
}
|
|
3257
|
-
}
|
|
4000
|
+
};
|
|
4001
|
+
// allSettled, not all: a worker must never reject, and the counters/dead-letter
|
|
4002
|
+
// list are only coherent once every worker has stopped touching them.
|
|
4003
|
+
await Promise.allSettled(Array.from({ length: Math.min(applyLimit, batch.length) }, async () => {
|
|
4004
|
+
for (;;) {
|
|
4005
|
+
if (fatal !== undefined)
|
|
4006
|
+
return;
|
|
4007
|
+
const next = cursor++;
|
|
4008
|
+
if (next >= batch.length)
|
|
4009
|
+
return;
|
|
4010
|
+
await applyOne(batch[next]);
|
|
4011
|
+
}
|
|
4012
|
+
}));
|
|
4013
|
+
if (fatal !== undefined)
|
|
4014
|
+
throw fatal;
|
|
3258
4015
|
}
|
|
3259
4016
|
// After the batch settles (committed, or per-record retried), refresh
|
|
3260
4017
|
// LastReconciledAt for every content-hash-skipped row in ONE set-based touch.
|
|
3261
4018
|
// Best-effort — a touch failure must never break the sync.
|
|
3262
4019
|
if (reconciledSkipIds.length > 0) {
|
|
3263
|
-
await this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger);
|
|
4020
|
+
await serializeWrite(() => this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger));
|
|
3264
4021
|
}
|
|
3265
4022
|
// Write the batch's record maps set-based, now that the records they point at are
|
|
3266
4023
|
// committed. Deliberately AFTER the transaction rather than inside it: the mapping
|
|
3267
4024
|
// is derived data that the next sync can re-establish by primary key, and keeping
|
|
3268
4025
|
// it out of the write transaction keeps that transaction as short as possible.
|
|
3269
|
-
await this.FlushRecordMaps(recordMaps, entityMap, logger);
|
|
3270
|
-
}
|
|
4026
|
+
await serializeWrite(() => this.FlushRecordMaps(recordMaps, entityMap, logger));
|
|
4027
|
+
};
|
|
4028
|
+
// Batched: overlap freely, serializing only the writes above. Otherwise: the whole
|
|
4029
|
+
// block stays under the mutex, because it owns a provider transaction throughout.
|
|
4030
|
+
if (batchedWrites)
|
|
4031
|
+
await applyOneBatch();
|
|
4032
|
+
else
|
|
4033
|
+
await this.runWriteExclusive(applyOneBatch);
|
|
3271
4034
|
}
|
|
3272
4035
|
}
|
|
3273
4036
|
/**
|
|
@@ -3350,17 +4113,45 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3350
4113
|
*
|
|
3351
4114
|
* Begin/Commit/Rollback are always matched per record (no leaked open transaction).
|
|
3352
4115
|
*/
|
|
3353
|
-
async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps
|
|
4116
|
+
async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps,
|
|
4117
|
+
// When false, each record is applied WITHOUT a provider transaction (auto-commit).
|
|
4118
|
+
//
|
|
4119
|
+
// The provider holds its transaction state — `_transactionDepth`, the active
|
|
4120
|
+
// `Transaction`, the savepoint counter — as SINGLE FIELDS on the one shared provider
|
|
4121
|
+
// instance. `BeginTransaction` from two concurrently-syncing entity maps therefore
|
|
4122
|
+
// races that counter: the second caller sees depth 2, issues `SAVE TRANSACTION` against
|
|
4123
|
+
// a transaction the first caller may have already committed, and the leaked depth then
|
|
4124
|
+
// fails every subsequent query on the connection with "Transaction has not begun".
|
|
4125
|
+
//
|
|
4126
|
+
// That is safe ONLY while the engine owns the provider exclusively, i.e. sequential
|
|
4127
|
+
// sync (`useTransaction === true`). The batched path is not sequential, and it does not
|
|
4128
|
+
// need a transaction here either: `ApplySingleRecord` performs exactly ONE write
|
|
4129
|
+
// (Create | Update | Delete — record-maps are queued into `RecordMapBatch` and flushed
|
|
4130
|
+
// set-based later), so there is nothing for a transaction to make atomic. A single
|
|
4131
|
+
// statement either commits or it does not, and `WithRetry`'s next attempt starts clean
|
|
4132
|
+
// without a rollback of a transaction that never held anything.
|
|
4133
|
+
//
|
|
4134
|
+
// This mirrors exactly what the concurrent non-batched path already does: it applies
|
|
4135
|
+
// records through the SAME `ApplySingleRecord` with no transaction at all, and has run
|
|
4136
|
+
// at concurrency > 1 in production.
|
|
4137
|
+
useProviderTransaction = true) {
|
|
3354
4138
|
const provider = this.ProviderToUse;
|
|
3355
4139
|
for (const record of batch) {
|
|
3356
4140
|
result.RecordsProcessed++;
|
|
3357
4141
|
try {
|
|
3358
|
-
// §10 —
|
|
3359
|
-
//
|
|
3360
|
-
//
|
|
3361
|
-
//
|
|
3362
|
-
// throws straight out to the dead-letter path below.
|
|
4142
|
+
// §10 — bounded inline retry for PROVABLY-TRANSIENT save failures
|
|
4143
|
+
// (NETWORK_TIMEOUT / RATE_LIMIT_EXCEEDED / DATABASE_ERROR per IsRetryableError).
|
|
4144
|
+
// A PERMANENT error (validation/FK/duplicate/config) is NOT retried — it throws
|
|
4145
|
+
// straight out to the dead-letter path below.
|
|
3363
4146
|
await WithRetry(async () => {
|
|
4147
|
+
if (!useProviderTransaction) {
|
|
4148
|
+
// Auto-commit: never touches shared provider transaction state, so
|
|
4149
|
+
// concurrent entity maps cannot corrupt each other. See the parameter doc.
|
|
4150
|
+
await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
|
|
4151
|
+
return;
|
|
4152
|
+
}
|
|
4153
|
+
// Sequential path, unchanged: apply in its own transaction so a deadlock or
|
|
4154
|
+
// momentary timeout rolls back and the next attempt starts clean.
|
|
3364
4155
|
await provider.BeginTransaction();
|
|
3365
4156
|
try {
|
|
3366
4157
|
await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
|
|
@@ -3434,7 +4225,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3434
4225
|
try {
|
|
3435
4226
|
switch (record.ChangeType) {
|
|
3436
4227
|
case 'Create': {
|
|
3437
|
-
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
|
|
4228
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
|
|
3438
4229
|
if (outcome === 'updated')
|
|
3439
4230
|
result.RecordsUpdated++;
|
|
3440
4231
|
else if (outcome === 'skipped')
|
|
@@ -3444,7 +4235,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3444
4235
|
break;
|
|
3445
4236
|
}
|
|
3446
4237
|
case 'Update':
|
|
3447
|
-
await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps);
|
|
4238
|
+
await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps, logger);
|
|
3448
4239
|
break;
|
|
3449
4240
|
case 'Delete': {
|
|
3450
4241
|
const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
|
|
@@ -3504,16 +4295,55 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3504
4295
|
*
|
|
3505
4296
|
* @returns true if an existing row was updated, false if a new row was inserted (so the caller counts correctly).
|
|
3506
4297
|
*/
|
|
3507
|
-
async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps
|
|
4298
|
+
async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps,
|
|
4299
|
+
/** Optional — lets the keyless-key guard below surface on the run's event stream. */
|
|
4300
|
+
keylessLogger,
|
|
4301
|
+
/** What the batch already proved about its destination rows, if anything. */
|
|
4302
|
+
precheck) {
|
|
3508
4303
|
const md = this.ProviderToUse;
|
|
3509
4304
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
4305
|
+
this.enrolInWriteGroup(entity);
|
|
3510
4306
|
const entityInfo = md.EntityByName(record.MJEntityName);
|
|
3511
4307
|
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
3512
4308
|
// Upsert-safe: if the record's mapped fields carry a PK (soft-PK dest tables key on the external
|
|
3513
4309
|
// ID), check whether that row already exists before deciding INSERT vs UPDATE. A null mappedPK
|
|
3514
4310
|
// (e.g. a server-assigned UUID PK not present in the mapped fields) means a genuinely new row.
|
|
3515
4311
|
const mappedPK = this.extractMappedPrimaryKey(record, pkFields);
|
|
3516
|
-
|
|
4312
|
+
// INVARIANT: a record destined for a SOFT-primary-key table must carry its key.
|
|
4313
|
+
//
|
|
4314
|
+
// A soft PK is INFERRED, not generated — it is the external system's own identifier, stored
|
|
4315
|
+
// as ordinary data with no PRIMARY KEY constraint, no identity, and no unique index (see
|
|
4316
|
+
// DDLGenerator: soft keys get a non-unique index only). So nothing at the database level
|
|
4317
|
+
// rejects a NULL key, and a row written without one can never be matched again: the next
|
|
4318
|
+
// sync's existence check misses it, and inserts another copy. Every pass therefore multiplies
|
|
4319
|
+
// the damage silently — the rows look fully populated, only the key column is empty.
|
|
4320
|
+
//
|
|
4321
|
+
// `mappedPK == null` is legitimate ONLY when the destination generates its own key (an
|
|
4322
|
+
// identity column or a server-assigned UUID), which is why the check is scoped to soft PKs
|
|
4323
|
+
// rather than applied to every table.
|
|
4324
|
+
const keyless = DecideKeylessRefusal(mappedPK, pkFields, MissingKeyFieldNames(record.MappedFields, pkFields, serializeKeyValue));
|
|
4325
|
+
if (keyless.Refuse) {
|
|
4326
|
+
const detail = DescribeKeylessRefusal(record.MJEntityName, keyless.KeyNames);
|
|
4327
|
+
keylessLogger?.emit('sync.record.error', {
|
|
4328
|
+
phase: 'write',
|
|
4329
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
4330
|
+
externalID: record.ExternalRecord?.ExternalID,
|
|
4331
|
+
error: `KEYLESS_RECORD_REFUSED: ${detail}`,
|
|
4332
|
+
});
|
|
4333
|
+
// Reported even without a logger — silence is the failure mode this guard exists to end.
|
|
4334
|
+
console.error(`[IntegrationEngine] KEYLESS_RECORD_REFUSED: ${detail}`);
|
|
4335
|
+
return 'skipped';
|
|
4336
|
+
}
|
|
4337
|
+
// The batch's prefetch may already have proved this row absent. When it did, the load below
|
|
4338
|
+
// is a SELECT * — every column including any NVARCHAR(MAX) — issued once per record, to
|
|
4339
|
+
// learn something the batch established in a single query. Skipping it is the whole point
|
|
4340
|
+
// of widening that prefetch to the create path.
|
|
4341
|
+
//
|
|
4342
|
+
// Only ever used to skip work when absence is PROVEN: the prefetch must have covered every
|
|
4343
|
+
// record in the batch, and this key must be missing from it. Anything less falls through to
|
|
4344
|
+
// the load, because a wrong "absent" turns an update into a duplicate insert.
|
|
4345
|
+
const provablyAbsent = this.isProvablyAbsent(mappedPK, precheck);
|
|
4346
|
+
const existed = mappedPK != null && !provablyAbsent
|
|
3517
4347
|
? await entity.InnerLoad(this.BuildEntityPrimaryKey(mappedPK, pkFields))
|
|
3518
4348
|
: false;
|
|
3519
4349
|
if (existed) {
|
|
@@ -3540,7 +4370,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3540
4370
|
// re-establish the possibly-cleared record map and SKIP the write — leaving __mj_UpdatedAt
|
|
3541
4371
|
// and the integration LastSynced columns untouched, exactly like the content-hash skip path.
|
|
3542
4372
|
this.SetEntityFields(entity, record.MappedFields);
|
|
3543
|
-
if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo)) {
|
|
4373
|
+
if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo, record)) {
|
|
3544
4374
|
await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
|
|
3545
4375
|
return 'skipped';
|
|
3546
4376
|
}
|
|
@@ -3552,7 +4382,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3552
4382
|
this.SetStandardIntegrationFields(entity, record);
|
|
3553
4383
|
// A5: Pre-write validation
|
|
3554
4384
|
this.validateEntity(entity, record.MJEntityName);
|
|
3555
|
-
const saved = await entity.Save();
|
|
4385
|
+
const saved = await entity.Save(this.syncSaveOptions);
|
|
3556
4386
|
if (!saved) {
|
|
3557
4387
|
const errMsg = entity.LatestResult?.CompleteMessage ?? 'unknown error';
|
|
3558
4388
|
const schemaErr = detectSchemaNotGenerated(record.MJEntityName, errMsg);
|
|
@@ -3566,6 +4396,17 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3566
4396
|
// incremental sync. SaveRecordMap is an upsert keyed on (CompanyIntegration, Entity, ExternalID),
|
|
3567
4397
|
// so this also re-establishes a map that was previously cleared.
|
|
3568
4398
|
const entityRecordID = entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|');
|
|
4399
|
+
// The prefetch's absence proof is only true until this process inserts the row. A mid-batch
|
|
4400
|
+
// flush (MJ_INTEGRATION_BATCH_FLUSH_AT) COMMITS part of a batch; if a later record then fails,
|
|
4401
|
+
// the per-record fallback re-applies the whole batch against the SAME precheck — and the
|
|
4402
|
+
// committed rows' keys, honestly absent at prefetch time, would still "prove" absent and
|
|
4403
|
+
// insert again. Recording the key the moment we create keeps the proof truthful for any
|
|
4404
|
+
// replay in this run. Deliberately unconditional on commit outcome: if the group later rolls
|
|
4405
|
+
// back, an over-included key merely costs that record one existence load on retry — while an
|
|
4406
|
+
// under-included key costs a duplicate row. Only ever err toward the load.
|
|
4407
|
+
if (!existed && mappedPK != null && precheck) {
|
|
4408
|
+
precheck.Present.add(mappedPK);
|
|
4409
|
+
}
|
|
3569
4410
|
await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entityRecordID, contextUser);
|
|
3570
4411
|
return existed ? 'updated' : 'created';
|
|
3571
4412
|
}
|
|
@@ -3593,15 +4434,34 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3593
4434
|
}
|
|
3594
4435
|
return values.join('|');
|
|
3595
4436
|
}
|
|
4437
|
+
/**
|
|
4438
|
+
* True ONLY when the batch prefetch PROVED this record's row does not exist: the prefetch covered
|
|
4439
|
+
* every record in the batch AND this key is missing from the rows it found. Anything less is
|
|
4440
|
+
* "unknown", and unknown must load — a wrong "absent" turns an update into a duplicate INSERT.
|
|
4441
|
+
*
|
|
4442
|
+
* `mappedPK` must be the '|'-joined key {@link extractMappedPrimaryKey} returns — the SAME shape
|
|
4443
|
+
* {@link PrefetchContentHashes} keys `Present` with (`pkNames.map(n => row[n] ?? '').join('|')`).
|
|
4444
|
+
* The first version of this check re-derived a key by indexing that string with PK field names,
|
|
4445
|
+
* which evaluates to `''` for every record — so "provably absent" was unconditionally true and
|
|
4446
|
+
* every upsert of an existing row became a blind duplicate INSERT. Kept as its own method so the
|
|
4447
|
+
* decision is testable against the real extractor's output rather than a re-implementation.
|
|
4448
|
+
*/
|
|
4449
|
+
isProvablyAbsent(mappedPK, precheck) {
|
|
4450
|
+
return mappedPK != null
|
|
4451
|
+
&& precheck?.CoversWholeBatch === true
|
|
4452
|
+
&& !precheck.Present.has(mappedPK);
|
|
4453
|
+
}
|
|
3596
4454
|
/**
|
|
3597
4455
|
* Updates an existing MJ record with pre-write validation.
|
|
3598
4456
|
* If the record cannot be loaded (e.g. it was deleted or never fully created),
|
|
3599
4457
|
* falls back to CreateRecord (upsert behavior).
|
|
3600
4458
|
*/
|
|
3601
|
-
async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps
|
|
4459
|
+
async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps,
|
|
4460
|
+
/** Forwarded to CreateRecord's keyless-key guard on the upsert fallback paths. */
|
|
4461
|
+
logger) {
|
|
3602
4462
|
if (!record.MatchedMJRecordID) {
|
|
3603
4463
|
// No matched ID — upsert by PK (insert; or update/skip if the PK already exists)
|
|
3604
|
-
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
|
|
4464
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
|
|
3605
4465
|
if (outcome === 'updated')
|
|
3606
4466
|
result.RecordsUpdated++;
|
|
3607
4467
|
else if (outcome === 'skipped')
|
|
@@ -3616,7 +4476,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3616
4476
|
// unchanged — skip the per-record DB load AND the write. The dirty-flag check
|
|
3617
4477
|
// below is the fallback for entities without the hash column.
|
|
3618
4478
|
if (precheckHashes) {
|
|
3619
|
-
const stored = precheckHashes.get(record.MatchedMJRecordID);
|
|
4479
|
+
const stored = precheckHashes.Hashes.get(record.MatchedMJRecordID);
|
|
3620
4480
|
if (stored && stored === computeContentHash(record.MappedFields ?? {})) {
|
|
3621
4481
|
result.RecordsSkipped++;
|
|
3622
4482
|
// Re-establish the external↔MJ record map even on the content-hash skip. A record can
|
|
@@ -3644,12 +4504,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3644
4504
|
}
|
|
3645
4505
|
const md = this.ProviderToUse;
|
|
3646
4506
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
4507
|
+
this.enrolInWriteGroup(entity);
|
|
3647
4508
|
const entityInfo = md.EntityByName(record.MJEntityName);
|
|
3648
4509
|
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
3649
4510
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
|
|
3650
4511
|
if (!loaded) {
|
|
3651
4512
|
// Matched-ID row vanished — fall back to upsert by PK (insert; or update/skip if PK exists)
|
|
3652
|
-
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
|
|
4513
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
|
|
3653
4514
|
if (outcome === 'updated')
|
|
3654
4515
|
result.RecordsUpdated++;
|
|
3655
4516
|
else if (outcome === 'skipped')
|
|
@@ -3671,7 +4532,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3671
4532
|
// Uses MJ's built-in dirty tracking (zero custom comparison logic). Critical for
|
|
3672
4533
|
// connectors without server-side date filtering (e.g., YM) where every sync re-fetches
|
|
3673
4534
|
// all records. Without this, 50k+ records get re-written every run.
|
|
3674
|
-
if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo)) {
|
|
4535
|
+
if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo, record)) {
|
|
3675
4536
|
result.RecordsSkipped++;
|
|
3676
4537
|
// Re-establish the record map even when the write is skipped — see the content-hash skip
|
|
3677
4538
|
// above for the full rationale (a key-field/PK match can land here with no map row, and
|
|
@@ -3688,7 +4549,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3688
4549
|
this.SetStandardIntegrationFields(entity, record);
|
|
3689
4550
|
// A5: Pre-write validation
|
|
3690
4551
|
this.validateEntity(entity, record.MJEntityName);
|
|
3691
|
-
const saved = await entity.Save();
|
|
4552
|
+
const saved = await entity.Save(this.syncSaveOptions);
|
|
3692
4553
|
if (!saved) {
|
|
3693
4554
|
const errMsg = entity.LatestResult?.CompleteMessage ?? 'unknown error';
|
|
3694
4555
|
const schemaErr = detectSchemaNotGenerated(record.MJEntityName, errMsg);
|
|
@@ -3715,19 +4576,39 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3715
4576
|
* - the read fails (best-effort — a logging/optimization read must never break a sync).
|
|
3716
4577
|
*/
|
|
3717
4578
|
async PrefetchContentHashes(batch, contextUser) {
|
|
3718
|
-
const ids = Array.from(new Set(batch.filter(r => r.ChangeType === 'Update' && r.MatchedMJRecordID)
|
|
3719
|
-
.map(r => r.MatchedMJRecordID)));
|
|
3720
|
-
if (ids.length === 0)
|
|
3721
|
-
return undefined;
|
|
3722
4579
|
const entityName = batch[0].MJEntityName;
|
|
3723
4580
|
const entityInfo = this.ProviderToUse.EntityByName(entityName);
|
|
3724
4581
|
if (!entityInfo)
|
|
3725
4582
|
return undefined;
|
|
3726
|
-
if (!entityInfo.Fields
|
|
4583
|
+
if (!entityInfo.Fields?.some(f => f.Name === CONTENT_HASH_COLUMN))
|
|
3727
4584
|
return undefined;
|
|
3728
4585
|
const pkFields = entityInfo.PrimaryKeys ?? [];
|
|
3729
4586
|
if (pkFields.length === 0)
|
|
3730
4587
|
return undefined;
|
|
4588
|
+
// Matched rows contribute the key the matcher already resolved. Unmatched rows contribute
|
|
4589
|
+
// the key their MAPPED FIELDS carry (soft-PK tables key on the external id), which is the
|
|
4590
|
+
// same key CreateRecord is about to probe for one at a time. Asking for all of them in the
|
|
4591
|
+
// one query we are already issuing is what lets that per-record probe be skipped.
|
|
4592
|
+
const wanted = new Set();
|
|
4593
|
+
let everyRecordCovered = true;
|
|
4594
|
+
for (const r of batch) {
|
|
4595
|
+
if (r.ChangeType === 'Update' && r.MatchedMJRecordID) {
|
|
4596
|
+
wanted.add(r.MatchedMJRecordID);
|
|
4597
|
+
continue;
|
|
4598
|
+
}
|
|
4599
|
+
const mappedPK = this.extractMappedPrimaryKey(r, pkFields);
|
|
4600
|
+
if (mappedPK == null) {
|
|
4601
|
+
// A destination-generated key (identity / server-assigned UUID) cannot be known
|
|
4602
|
+
// before the insert, so this record's existence is genuinely unknowable here.
|
|
4603
|
+
everyRecordCovered = false;
|
|
4604
|
+
continue;
|
|
4605
|
+
}
|
|
4606
|
+
// Already the '|'-joined key in pkFields order — add it as-is (see extractMappedPrimaryKey).
|
|
4607
|
+
wanted.add(mappedPK);
|
|
4608
|
+
}
|
|
4609
|
+
const ids = Array.from(wanted);
|
|
4610
|
+
if (ids.length === 0)
|
|
4611
|
+
return undefined;
|
|
3731
4612
|
// Map keys must match `record.MatchedMJRecordID`, which is the PK value(s) joined by '|' in
|
|
3732
4613
|
// PrimaryKeys order (single value for single-PK, "v1|v2" for composite — see MatchEngine).
|
|
3733
4614
|
const pkNames = pkFields.map(f => f.Name);
|
|
@@ -3745,19 +4626,32 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3745
4626
|
Fields: [...pkNames, CONTENT_HASH_COLUMN],
|
|
3746
4627
|
ExtraFilter: extraFilter,
|
|
3747
4628
|
ResultType: 'simple',
|
|
4629
|
+
// A plain RunView is NOT unbounded — it falls back to the entity's UserViewMaxRows
|
|
4630
|
+
// (default 1000). This result is what `CoversWholeBatch` absence proofs are judged
|
|
4631
|
+
// against, and coverage is computed from the REQUEST side, never reconciled with
|
|
4632
|
+
// res.Results.length: a silently truncated response would mark every existing row
|
|
4633
|
+
// beyond the cap "provably absent" and re-INSERT it as a duplicate on every sync.
|
|
4634
|
+
// Today the apply batch (500) sits under the default cap, so nothing fires — but a
|
|
4635
|
+
// 2x margin defended by nothing is not a guard. Same reasoning as baseEngine's own
|
|
4636
|
+
// IgnoreMaxRows use, and this file documents the identical trap on the push side.
|
|
4637
|
+
IgnoreMaxRows: true,
|
|
3748
4638
|
}, contextUser);
|
|
3749
4639
|
if (!res.Success)
|
|
3750
4640
|
return undefined;
|
|
3751
|
-
const
|
|
4641
|
+
const Hashes = new Map();
|
|
4642
|
+
const Present = new Set();
|
|
3752
4643
|
for (const row of res.Results) {
|
|
3753
4644
|
// Re-key by the same '|'-join the matcher produced, so the lookup in ApplySingleRecord hits.
|
|
3754
4645
|
const key = pkNames.map(n => row[n] ?? '').join('|');
|
|
4646
|
+
// Presence is recorded for EVERY returned row. A row whose hash is NULL still exists,
|
|
4647
|
+
// and treating "no hash" as "no row" would turn an update into a duplicate insert.
|
|
4648
|
+
Present.add(key);
|
|
3755
4649
|
const hash = row[CONTENT_HASH_COLUMN];
|
|
3756
4650
|
if (typeof hash === 'string' && hash.length > 0) {
|
|
3757
|
-
|
|
4651
|
+
Hashes.set(key, hash);
|
|
3758
4652
|
}
|
|
3759
4653
|
}
|
|
3760
|
-
return
|
|
4654
|
+
return { Hashes, Present, CoversWholeBatch: everyRecordCovered };
|
|
3761
4655
|
}
|
|
3762
4656
|
catch (err) {
|
|
3763
4657
|
// MJ#3047 lesson: this best-effort catch was SILENT, so a failing prefetch (e.g. a reserved-word
|
|
@@ -3793,6 +4687,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3793
4687
|
return false;
|
|
3794
4688
|
const md = this.ProviderToUse;
|
|
3795
4689
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
4690
|
+
this.enrolInWriteGroup(entity);
|
|
3796
4691
|
const entityInfo = md.EntityByName(record.MJEntityName);
|
|
3797
4692
|
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
3798
4693
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
|
|
@@ -3816,14 +4711,14 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3816
4711
|
entity.Set('__mj_integration_IsTombstoned', true);
|
|
3817
4712
|
if (hasField('__mj_integration_DeletedDetectedAt'))
|
|
3818
4713
|
entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
|
|
3819
|
-
const archived = await entity.Save();
|
|
4714
|
+
const archived = await entity.Save(this.syncSaveOptions);
|
|
3820
4715
|
if (!archived) {
|
|
3821
4716
|
const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
|
|
3822
4717
|
console.warn(`[IntegrationEngine] Soft-delete (archive) failed for ${record.MJEntityName} ${record.MatchedMJRecordID} — ${reason}`);
|
|
3823
4718
|
}
|
|
3824
4719
|
return archived;
|
|
3825
4720
|
}
|
|
3826
|
-
const deleted = await entity.Delete();
|
|
4721
|
+
const deleted = await entity.Delete(this.syncDeleteOptions);
|
|
3827
4722
|
if (!deleted) {
|
|
3828
4723
|
const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
|
|
3829
4724
|
console.warn(`[IntegrationEngine] Delete blocked for ${record.MJEntityName} ${record.MatchedMJRecordID} — ${reason}`);
|
|
@@ -3849,6 +4744,31 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3849
4744
|
}
|
|
3850
4745
|
return key;
|
|
3851
4746
|
}
|
|
4747
|
+
/**
|
|
4748
|
+
* Reports ACTIVE field maps whose MJ column does not exist, once per entity map per run.
|
|
4749
|
+
*
|
|
4750
|
+
* `BaseEntity.Set` no-ops on an unknown field — no throw, no log, no dirty flag — so a map
|
|
4751
|
+
* pointing at a column that was never applied (or was renamed) drops its value for every
|
|
4752
|
+
* record while the run reports those records as written. Checking it here costs one metadata
|
|
4753
|
+
* read and happens before the first fetch, so the warning arrives before the wasted work.
|
|
4754
|
+
*/
|
|
4755
|
+
WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger) {
|
|
4756
|
+
const entityName = entityMap.Entity ?? '';
|
|
4757
|
+
// Diagnostics must never be able to fail a run: an unresolvable entity/provider is reported
|
|
4758
|
+
// by the paths that actually need it, and here it simply means there is nothing to check.
|
|
4759
|
+
let entityFieldNames = [];
|
|
4760
|
+
try {
|
|
4761
|
+
const entityInfo = entityName ? this.ProviderToUse?.EntityByName(entityName) : null;
|
|
4762
|
+
entityFieldNames = entityInfo?.Fields?.map(f => f.Name) ?? [];
|
|
4763
|
+
}
|
|
4764
|
+
catch {
|
|
4765
|
+
return;
|
|
4766
|
+
}
|
|
4767
|
+
const unbindable = FindUnbindableFieldMaps(fieldMaps, entityFieldNames);
|
|
4768
|
+
if (unbindable.length === 0)
|
|
4769
|
+
return;
|
|
4770
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FIELD_MAP_DESTINATION_MISSING', DescribeUnbindableFieldMaps(unbindable, entityMap.ExternalObjectName ?? entityMap.ID, entityName), { fieldMaps: unbindable });
|
|
4771
|
+
}
|
|
3852
4772
|
/**
|
|
3853
4773
|
* Sets fields on a BaseEntity instance from a field value map.
|
|
3854
4774
|
*/
|
|
@@ -4008,7 +4928,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4008
4928
|
* runtime-created target tables have no generated entity type, which is why the engine
|
|
4009
4929
|
* already `.Set()`s the very same columns in SetStandardIntegrationFields.
|
|
4010
4930
|
*/
|
|
4011
|
-
needsSyncStateRepair(entity, entityInfo) {
|
|
4931
|
+
needsSyncStateRepair(entity, entityInfo, record) {
|
|
4012
4932
|
if (!entityInfo)
|
|
4013
4933
|
return false;
|
|
4014
4934
|
const has = (name) => entityInfo.Fields.some(f => f.Name === name);
|
|
@@ -4018,6 +4938,27 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4018
4938
|
return true;
|
|
4019
4939
|
if (has('__mj_integration_SyncStatus') && entity.Get('__mj_integration_SyncStatus') !== 'Active')
|
|
4020
4940
|
return true;
|
|
4941
|
+
// A STALE CONTENT HASH is repair-worthy for the same reason: skipping the write freezes it.
|
|
4942
|
+
//
|
|
4943
|
+
// The case that produces one is a source that stops sending a column. The mapper OMITS an
|
|
4944
|
+
// absent key rather than mapping it to null (a missing value is not a null value), so the
|
|
4945
|
+
// recomputed hash differs — but SetEntityFields never touches that column either, so the
|
|
4946
|
+
// entity is NOT dirty and the skip above fires. The stored hash is therefore never refreshed
|
|
4947
|
+
// and the mismatch is permanent: that row loses the content-hash fast path FOREVER, paying a
|
|
4948
|
+
// full load and field-by-field compare on every sync until some other field happens to
|
|
4949
|
+
// change. One repair write here re-converges it, and every later sync skips it cheaply.
|
|
4950
|
+
//
|
|
4951
|
+
// Deliberately NOT treated as "the column is gone" — absence in the data is not evidence of
|
|
4952
|
+
// absence in the schema (§ the same rule the field-level deactivation follows). The value is
|
|
4953
|
+
// left exactly as it is; only the hash is brought back in line with what we are actually
|
|
4954
|
+
// mapping.
|
|
4955
|
+
if (record && has(CONTENT_HASH_COLUMN)) {
|
|
4956
|
+
const storedHash = entity.Get(CONTENT_HASH_COLUMN);
|
|
4957
|
+
if (typeof storedHash === 'string' && storedHash.length > 0
|
|
4958
|
+
&& storedHash !== computeContentHash(record.MappedFields ?? {})) {
|
|
4959
|
+
return true;
|
|
4960
|
+
}
|
|
4961
|
+
}
|
|
4021
4962
|
return false;
|
|
4022
4963
|
}
|
|
4023
4964
|
/**
|
|
@@ -4209,12 +5150,19 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4209
5150
|
const existing = await rv.RunView({
|
|
4210
5151
|
EntityName: 'MJ: Company Integration Record Maps',
|
|
4211
5152
|
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND EntityID='${entityID}' AND ExternalSystemRecordID=${quotedExternalID}`,
|
|
4212
|
-
Fields: ['ID'],
|
|
5153
|
+
Fields: ['ID', 'EntityRecordID'],
|
|
4213
5154
|
MaxRows: 1,
|
|
4214
5155
|
ResultType: 'simple',
|
|
4215
5156
|
BypassCache: true, // upsert-by-identity: a stale miss here re-creates a duplicate record map
|
|
4216
5157
|
}, contextUser);
|
|
4217
5158
|
if (existing.Success && existing.Results.length > 0) {
|
|
5159
|
+
// The row already says exactly this. On an incremental sync that is nearly every record
|
|
5160
|
+
// — mappings are stable — and the Load + Save this path used to do anyway were two more
|
|
5161
|
+
// round trips to change nothing, plus a meaningless __mj_UpdatedAt bump on every synced
|
|
5162
|
+
// record's map row. The batched writer (RecordMapBatch.flushChunk) has always skipped
|
|
5163
|
+
// this case; the per-record fallback now agrees with it.
|
|
5164
|
+
if (existing.Results[0].EntityRecordID === entityRecordID)
|
|
5165
|
+
return;
|
|
4218
5166
|
const loaded = await recordMap.Load(existing.Results[0].ID);
|
|
4219
5167
|
if (!loaded)
|
|
4220
5168
|
recordMap.NewRecord();
|
|
@@ -4677,6 +5625,20 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4677
5625
|
GetIntegrationObjectFields(objectID) {
|
|
4678
5626
|
return this.Base.GetIntegrationObjectFields(objectID);
|
|
4679
5627
|
}
|
|
5628
|
+
/**
|
|
5629
|
+
* Source field names the connector declared as SyncDirective 'Exclude' for one
|
|
5630
|
+
* integration object, read from IntegrationObjectField.Configuration. Empty set
|
|
5631
|
+
* on any lookup miss (unknown object, no fields, no integration id) - a failed
|
|
5632
|
+
* lookup must never widen or narrow the sync beyond its declared behaviour.
|
|
5633
|
+
*/
|
|
5634
|
+
ResolveExcludedSourceNames(integrationID, externalObjectName) {
|
|
5635
|
+
if (!integrationID || !externalObjectName)
|
|
5636
|
+
return new Set();
|
|
5637
|
+
const obj = this.GetIntegrationObject(integrationID, externalObjectName);
|
|
5638
|
+
if (!obj)
|
|
5639
|
+
return new Set();
|
|
5640
|
+
return ComputeExcludedSourceNames(this.GetIntegrationObjectFields(obj.ID));
|
|
5641
|
+
}
|
|
4680
5642
|
GetActiveIntegrationObjects(integrationID) {
|
|
4681
5643
|
return this.Base.GetActiveIntegrationObjects(integrationID);
|
|
4682
5644
|
}
|