@memberjunction/integration-engine 6.1.0-edge.4 → 6.1.0-edge.6
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 +7 -0
- package/dist/BaseIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseIntegrationConnector.js +33 -2
- package/dist/BaseIntegrationConnector.js.map +1 -1
- package/dist/BaseRESTIntegrationConnector.d.ts +48 -7
- package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseRESTIntegrationConnector.js +160 -17
- package/dist/BaseRESTIntegrationConnector.js.map +1 -1
- 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/IntegrationConnectorCreationPipeline.d.ts +20 -0
- package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -1
- package/dist/IntegrationConnectorCreationPipeline.js +190 -30
- package/dist/IntegrationConnectorCreationPipeline.js.map +1 -1
- package/dist/IntegrationEngine.d.ts +173 -1
- package/dist/IntegrationEngine.d.ts.map +1 -1
- package/dist/IntegrationEngine.js +834 -152
- package/dist/IntegrationEngine.js.map +1 -1
- package/dist/IntegrationSchemaSync.d.ts +46 -2
- package/dist/IntegrationSchemaSync.d.ts.map +1 -1
- package/dist/IntegrationSchemaSync.js +83 -14
- package/dist/IntegrationSchemaSync.js.map +1 -1
- package/dist/MatchEngine.js +1 -1
- package/dist/MatchEngine.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/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 +7 -0
- package/dist/WatermarkService.d.ts.map +1 -1
- package/dist/WatermarkService.js +8 -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 +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +25 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +7 -7
|
@@ -1,5 +1,5 @@
|
|
|
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';
|
|
@@ -25,6 +25,7 @@ import { AdaptiveConcurrencyController, RunAdaptive } from './AdaptiveConcurrenc
|
|
|
25
25
|
import { mostRecentWinner } from './ConflictRecency.js';
|
|
26
26
|
import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
|
|
27
27
|
import { CollapseDuplicateIdentities } from './BatchIdentity.js';
|
|
28
|
+
import { WriteSerializer } from './WriteSerializer.js';
|
|
28
29
|
import { ResumeConcurrency, RunResumesBounded } from './ResumeConcurrency.js';
|
|
29
30
|
/** Default batch size for fetching records from external systems */
|
|
30
31
|
const DEFAULT_BATCH_SIZE = 200;
|
|
@@ -94,6 +95,33 @@ export class SchemaNotGeneratedError extends Error {
|
|
|
94
95
|
this.StoredProcedureName = storedProcedureName;
|
|
95
96
|
}
|
|
96
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
|
+
}
|
|
97
125
|
/**
|
|
98
126
|
* §29 — base of the VALUE-FIT error family: a mapped value cannot fit/coerce into its destination
|
|
99
127
|
* column's type. Per the bounded-typing policy (small columns; large content is an explicit text/json
|
|
@@ -149,6 +177,24 @@ const INTEGER_SQL_BOUNDS = {
|
|
|
149
177
|
* functions are generated, so it must be classified too (else the run produces
|
|
150
178
|
* per-record errors instead of one fail-fast SchemaNotGeneratedError).
|
|
151
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
|
+
}
|
|
152
198
|
function detectSchemaNotGenerated(entityName, errorMessage) {
|
|
153
199
|
const sqlServer = errorMessage.match(/Could not find stored procedure '([^']+)'/i);
|
|
154
200
|
if (sqlServer)
|
|
@@ -176,6 +222,17 @@ function detectSchemaNotGenerated(entityName, errorMessage) {
|
|
|
176
222
|
export function PositiveInt(v) {
|
|
177
223
|
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined;
|
|
178
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
|
+
}
|
|
179
236
|
export class IntegrationEngine extends BaseSingleton {
|
|
180
237
|
constructor() {
|
|
181
238
|
super();
|
|
@@ -240,10 +297,133 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
240
297
|
* calls without threading a parameter through ~20 private signatures.
|
|
241
298
|
*/
|
|
242
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
|
+
}
|
|
243
319
|
/** The current run's context, when called from inside a sync run. */
|
|
244
320
|
get currentRunContext() {
|
|
245
321
|
return IntegrationEngine.runContext.getStore();
|
|
246
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
|
+
}
|
|
247
427
|
/** Registers (or clears, with undefined) the post-sync custom-column promotion hook. */
|
|
248
428
|
SetPostSyncSchemaPromotionCallback(callback) {
|
|
249
429
|
this.postSyncSchemaPromotionCallback = callback;
|
|
@@ -296,19 +476,34 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
296
476
|
* phase stays parallel (the real throughput win — it's network-bound). WeakMap so a
|
|
297
477
|
* retired provider's chain entry is collectable.
|
|
298
478
|
*/
|
|
299
|
-
static { this.
|
|
300
|
-
|
|
479
|
+
static { this.writeSerializers = new WeakMap(); }
|
|
480
|
+
/** The write lock for this engine's provider, created on first use. */
|
|
481
|
+
writeSerializer() {
|
|
301
482
|
const provider = this.ProviderToUse;
|
|
302
|
-
let
|
|
303
|
-
if (!
|
|
304
|
-
|
|
305
|
-
IntegrationEngine.
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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);
|
|
312
507
|
}
|
|
313
508
|
/**
|
|
314
509
|
* Fence check at a batch boundary, BEFORE any write (PR 1 item 3). One SELECT of the run's
|
|
@@ -1299,6 +1494,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1299
1494
|
* Processes all entity maps, aggregating results with progress tracking.
|
|
1300
1495
|
*/
|
|
1301
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
|
+
}
|
|
1302
1504
|
const aggregate = {
|
|
1303
1505
|
Success: true,
|
|
1304
1506
|
RecordsProcessed: 0,
|
|
@@ -1748,6 +1950,107 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1748
1950
|
});
|
|
1749
1951
|
await Promise.all(workers);
|
|
1750
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
|
+
}
|
|
1751
2054
|
/** Minimum ms between outbound requests for this integration (Integration.BatchRequestWaitTime; 0 = disabled). */
|
|
1752
2055
|
getRequestSpacingMs(config) {
|
|
1753
2056
|
try {
|
|
@@ -1795,6 +2098,8 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1795
2098
|
rateLimitBurst: PositiveInt(p.rateLimitBurst),
|
|
1796
2099
|
discoveryTimeBudgetMs: PositiveInt(p.discoveryTimeBudgetMs),
|
|
1797
2100
|
fetchTimeoutMs: PositiveInt(p.fetchTimeoutMs),
|
|
2101
|
+
// Ceiling on SIMULTANEOUS vendor fetches for this connection (see getFetchGate).
|
|
2102
|
+
fetchConcurrency: PositiveInt(p.fetchConcurrency),
|
|
1798
2103
|
};
|
|
1799
2104
|
}
|
|
1800
2105
|
catch {
|
|
@@ -1832,6 +2137,17 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1832
2137
|
*/
|
|
1833
2138
|
reportRateOutcome(config, throttledErr) {
|
|
1834
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
|
+
}
|
|
1835
2151
|
const rl = this._rateLimiters.get(key);
|
|
1836
2152
|
if (!rl)
|
|
1837
2153
|
return;
|
|
@@ -1874,11 +2190,27 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1874
2190
|
this.MergeResult(pullResult, pushResult);
|
|
1875
2191
|
return pullResult;
|
|
1876
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
|
+
}
|
|
1877
2205
|
/**
|
|
1878
2206
|
* Pull sync: fetch from external → map → match → validate → apply to MJ.
|
|
1879
2207
|
*/
|
|
1880
2208
|
async ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger) {
|
|
1881
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.
|
|
1882
2214
|
const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
|
|
1883
2215
|
// Field-level exclusions declared by the connector (SourceFieldInfo.SyncDirective
|
|
1884
2216
|
// -> IntegrationObjectField.Configuration). Resolved once per map, applied to every
|
|
@@ -1890,7 +2222,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1890
2222
|
excludedFields: Array.from(excludedSourceNames).sort(),
|
|
1891
2223
|
});
|
|
1892
2224
|
}
|
|
1893
|
-
const watermark = await this.
|
|
2225
|
+
const watermark = await this.runWriteForMap(entityMapID, () => this.watermarkService.Load(entityMapID, contextUser, 'Pull'));
|
|
1894
2226
|
logger?.emit('sync.entity-map.start', {
|
|
1895
2227
|
phase: 'pull-detail',
|
|
1896
2228
|
externalObjectName: entityMap.ExternalObjectName,
|
|
@@ -2021,6 +2353,11 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2021
2353
|
const fetchTimeoutMs = PositiveInt(this.getConfigOverrides(config).fetchTimeoutMs)
|
|
2022
2354
|
?? PositiveInt(config.connector.FetchChangesTimeoutMs)
|
|
2023
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;
|
|
2024
2361
|
while (hasMore) {
|
|
2025
2362
|
if (abortSignal?.aborted) {
|
|
2026
2363
|
console.log(`[IntegrationEngine] Sync cancelled for ${entityMap.ExternalObjectName} after ${recordsInMap} records — saving watermark`);
|
|
@@ -2063,69 +2400,20 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2063
2400
|
});
|
|
2064
2401
|
let batch;
|
|
2065
2402
|
const fetchStart = Date.now();
|
|
2066
|
-
// ONE multiplicative decrease per throttle EPISODE, not one per rejected attempt.
|
|
2067
|
-
//
|
|
2068
|
-
// A 429 that survives three retries is three rejections but one congestion event — the
|
|
2069
|
-
// same distinction TCP draws when it halves the window once per loss event rather than
|
|
2070
|
-
// once per lost segment. Decreasing on each attempt compounds: at a 0.5 backoff factor
|
|
2071
|
-
// three attempts take the rate to an eighth, five take it to a thirtieth, so a single
|
|
2072
|
-
// throttled fetch could drive a connector to its floor purely as a function of how
|
|
2073
|
-
// generous its retry budget is. The freeze already covers the interval the source asked
|
|
2074
|
-
// for; the decrease is about the rate AFTER that, and one signal deserves one step.
|
|
2075
|
-
let throttleReported = false;
|
|
2076
2403
|
try {
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
//
|
|
2091
|
-
// Deliberately `instanceof` rather than the classified code. `ClassifyError` folds
|
|
2092
|
-
// `econnreset` in with timeouts under `NETWORK_TIMEOUT`, and a reset socket IS worth
|
|
2093
|
-
// retrying — so excluding the whole code would lose real resilience. Only the error
|
|
2094
|
-
// WithTimeout itself minted is excluded.
|
|
2095
|
-
(err) => !(err instanceof OperationTimeoutError) && IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => {
|
|
2096
|
-
// Report a throttle NOW, not after the retries are spent. ReportThrottle
|
|
2097
|
-
// freezes the shared bucket for this CompanyIntegration, so every other
|
|
2098
|
-
// object fetching concurrently backs off too — reporting it only in the
|
|
2099
|
-
// catch below meant the rest of the connector kept hammering a source that
|
|
2100
|
-
// had already said stop.
|
|
2101
|
-
//
|
|
2102
|
-
// Once per episode: see `throttleReported` above. Later attempts still get
|
|
2103
|
-
// their own Retry-After honoured via DelayForError, which is what actually
|
|
2104
|
-
// paces this loop; what they must not do is halve the rate again.
|
|
2105
|
-
if (!throttleReported && ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED') {
|
|
2106
|
-
throttleReported = true;
|
|
2107
|
-
this.reportRateOutcome(config, err);
|
|
2108
|
-
}
|
|
2109
|
-
logger?.emit('sync.fetch.retry', {
|
|
2110
|
-
externalObjectName: entityMap.ExternalObjectName,
|
|
2111
|
-
batchIndex: batchCount,
|
|
2112
|
-
attempt,
|
|
2113
|
-
delayMs,
|
|
2114
|
-
error: err instanceof Error ? err.message : String(err),
|
|
2115
|
-
});
|
|
2116
|
-
}, {
|
|
2117
|
-
// Honour the source's own instruction. A 429 usually carries Retry-After;
|
|
2118
|
-
// blind exponential backoff ignored it and retried early, which is how a
|
|
2119
|
-
// soft throttle becomes a hard one. Falls back to backoff when the
|
|
2120
|
-
// connector cannot parse one.
|
|
2121
|
-
DelayForError: (err) => ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED'
|
|
2122
|
-
? config.connector.ExtractRetryAfterMs(err)
|
|
2123
|
-
: undefined,
|
|
2124
|
-
// A retry must pass through the same gate the first attempt did. The token
|
|
2125
|
-
// was acquired once before WithRetry, so retries previously bypassed the
|
|
2126
|
-
// limiter entirely — including the freeze the line above just applied.
|
|
2127
|
-
BeforeRetry: () => this.rateLimit(config),
|
|
2128
|
-
});
|
|
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
|
+
}
|
|
2129
2417
|
this.reportRateOutcome(config); // clean fetch → ramp the adaptive rate back up
|
|
2130
2418
|
fetchGapCount = 0; // clean fetch → reset the consecutive fetch-gap counter
|
|
2131
2419
|
// §10: connector type-driven post-processing hook (default no-op) — enforce/normalize
|
|
@@ -2133,17 +2421,68 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2133
2421
|
if (batch.Records.length > 0) {
|
|
2134
2422
|
batch.Records = batch.Records.map(r => config.connector.PostProcessRecord(r));
|
|
2135
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
|
+
}
|
|
2136
2447
|
}
|
|
2137
2448
|
catch (fetchErr) {
|
|
2138
2449
|
const errMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
|
2139
2450
|
// A throttle (429 / rate-limit) backs the adaptive limiter off (honoring Retry-After);
|
|
2140
2451
|
// other errors don't touch the rate. §5 Gap 2: also flag the map result so the per-layer
|
|
2141
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
|
+
}
|
|
2142
2482
|
if (ClassifyError(fetchErr).Code === 'RATE_LIMIT_EXCEEDED') {
|
|
2143
|
-
//
|
|
2144
|
-
//
|
|
2145
|
-
|
|
2146
|
-
this.reportRateOutcome(config, fetchErr);
|
|
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.
|
|
2147
2486
|
result.Throttled = true;
|
|
2148
2487
|
}
|
|
2149
2488
|
console.error(`[IntegrationEngine] FetchChanges error for ${entityMap.ExternalObjectName}: ${errMsg}`);
|
|
@@ -2169,7 +2508,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2169
2508
|
// value — a crash from here on resumes from the pre-run watermark and re-covers
|
|
2170
2509
|
// the gap. Later checkpoints stop writing floors (gate above).
|
|
2171
2510
|
if (watermarkFloorSaved !== null) {
|
|
2172
|
-
await this.
|
|
2511
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.RestoreValue(entityMapID, preRunWatermarkValue, contextUser));
|
|
2173
2512
|
watermarkFloorSaved = null;
|
|
2174
2513
|
}
|
|
2175
2514
|
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_PAGE_SKIPPED', `Persistent fetch error at ${currentOffset != null ? 'offset ' + currentOffset : 'page ' + currentPage} for ` +
|
|
@@ -2303,11 +2642,11 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2303
2642
|
// not begun"). Holding the same write-lock for the read keeps the connection single-owner.
|
|
2304
2643
|
const resolved = partitionReconcile
|
|
2305
2644
|
? []
|
|
2306
|
-
: await this.
|
|
2645
|
+
: await this.runWriteForMap(entityMap.ID, () => this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser));
|
|
2307
2646
|
const beforeApply = result.RecordsCreated + result.RecordsUpdated + result.RecordsSkipped + result.RecordsErrored;
|
|
2308
2647
|
try {
|
|
2309
2648
|
if (!partitionReconcile)
|
|
2310
|
-
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));
|
|
2311
2650
|
}
|
|
2312
2651
|
catch (applyErr) {
|
|
2313
2652
|
if (applyErr instanceof SchemaNotGeneratedError) {
|
|
@@ -2343,7 +2682,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2343
2682
|
(batch.HasMore ? ` | more batches pending` : ` | batch complete`));
|
|
2344
2683
|
// Update progress on the watermark record so the DB reflects live sync state
|
|
2345
2684
|
if (batch.HasMore) {
|
|
2346
|
-
await this.
|
|
2685
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.UpdateProgress(entityMapID, afterApply, contextUser));
|
|
2347
2686
|
}
|
|
2348
2687
|
}
|
|
2349
2688
|
recordsInMap += batch.Records.length;
|
|
@@ -2377,7 +2716,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2377
2716
|
// post-loop save below handles graceful early-exits precisely; this covers a SIGKILL
|
|
2378
2717
|
// between graceful checkpoints, costing at most ~25 batches of re-fetch on resume.
|
|
2379
2718
|
if (isKeysetConnector && currentAfterKey) {
|
|
2380
|
-
await this.
|
|
2719
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
|
|
2381
2720
|
}
|
|
2382
2721
|
// The WATERMARK twin of the keyset floor above. Without it, a watermark-based
|
|
2383
2722
|
// connector had NO durable position at all until the run ended: a SIGKILL / OOM /
|
|
@@ -2395,7 +2734,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2395
2734
|
&& currentWatermark && currentWatermark !== initialWatermark
|
|
2396
2735
|
&& currentWatermark !== watermarkFloorSaved) {
|
|
2397
2736
|
const floor = currentWatermark;
|
|
2398
|
-
await this.
|
|
2737
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.Update(entityMapID, floor, contextUser, 'Pull'));
|
|
2399
2738
|
watermarkFloorSaved = floor;
|
|
2400
2739
|
}
|
|
2401
2740
|
}
|
|
@@ -2432,7 +2771,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2432
2771
|
// NOTE: a connector that ALSO returns a monotonic watermark (MonotonicWatermark=true) skips
|
|
2433
2772
|
// this branch and falls through to SAVE that watermark below, so its next incremental NARROWS
|
|
2434
2773
|
// (microtime > watermark) instead of re-scanning the whole object every run.
|
|
2435
|
-
await this.
|
|
2774
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.ClearKeysetPosition(entityMapID, contextUser));
|
|
2436
2775
|
result.WatermarkAfter = null;
|
|
2437
2776
|
}
|
|
2438
2777
|
else if (fetchCompletedCleanly) {
|
|
@@ -2473,13 +2812,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2473
2812
|
else {
|
|
2474
2813
|
finalWatermark = new Date().toISOString();
|
|
2475
2814
|
}
|
|
2476
|
-
await this.
|
|
2815
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.Update(entityMapID, finalWatermark, contextUser, 'Pull'));
|
|
2477
2816
|
result.WatermarkAfter = finalWatermark;
|
|
2478
2817
|
}
|
|
2479
2818
|
else if (isKeysetConnector && currentAfterKey) {
|
|
2480
2819
|
// The keyset scan stopped early (cancel / fetch error / safety limit). Persist the precise
|
|
2481
2820
|
// last ordering key so the next run resumes the seek from here instead of restarting.
|
|
2482
|
-
await this.
|
|
2821
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
|
|
2483
2822
|
result.WatermarkAfter = currentAfterKey;
|
|
2484
2823
|
}
|
|
2485
2824
|
else if (!hadFetchGap && currentWatermark && currentWatermark !== initialWatermark) {
|
|
@@ -2499,7 +2838,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2499
2838
|
// deliberately NOT when hadFetchGap — a skipped page leaves a HOLE behind this watermark,
|
|
2500
2839
|
// which is why that path holds it for a full re-fetch next run.
|
|
2501
2840
|
const partialWatermark = currentWatermark;
|
|
2502
|
-
await this.
|
|
2841
|
+
await this.runWriteForMap(entityMapID, () => this.watermarkService.Update(entityMapID, partialWatermark, contextUser, 'Pull'));
|
|
2503
2842
|
result.WatermarkAfter = partialWatermark;
|
|
2504
2843
|
}
|
|
2505
2844
|
// Orphan detection: delete/tombstone MJ records whose external counterpart no longer exists.
|
|
@@ -2750,13 +3089,19 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2750
3089
|
// Load current field values for each changed record
|
|
2751
3090
|
const md = this.ProviderToUse;
|
|
2752
3091
|
const entityInfo = md.EntityByName(entityMap.Entity);
|
|
2753
|
-
|
|
3092
|
+
if (!entityInfo) {
|
|
3093
|
+
throw new Error(`Cannot push ${entityMap.Entity}: entity not found in metadata.`);
|
|
3094
|
+
}
|
|
3095
|
+
// The normalized RecordID is the '|'-joined value(s) of EVERY key column — the shape the record
|
|
3096
|
+
// map stores — so it is parsed against all of the entity's PrimaryKeys. Pinning it to the first
|
|
3097
|
+
// key column (or a made-up `ID`) handed a composite key to one column as "v1|v2" and failed the load.
|
|
3098
|
+
const pkFields = entityInfo.PrimaryKeys;
|
|
2754
3099
|
for (const [recordID, change] of latestByRecord) {
|
|
2755
3100
|
if (change.Type === 'Delete')
|
|
2756
3101
|
continue; // No fields to load for deletes
|
|
2757
3102
|
try {
|
|
2758
3103
|
const entity = await md.GetEntityObject(entityMap.Entity, contextUser);
|
|
2759
|
-
const loaded = await entity.InnerLoad(
|
|
3104
|
+
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(recordID, pkFields));
|
|
2760
3105
|
if (loaded) {
|
|
2761
3106
|
change.Fields = entity.GetAll();
|
|
2762
3107
|
}
|
|
@@ -2812,10 +3157,15 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2812
3157
|
}
|
|
2813
3158
|
const md = this.ProviderToUse;
|
|
2814
3159
|
const entityInfo = md.EntityByName(entityMap.Entity);
|
|
2815
|
-
|
|
3160
|
+
if (!entityInfo) {
|
|
3161
|
+
throw new Error(`Cannot push ${entityMap.Entity}: entity not found in metadata.`);
|
|
3162
|
+
}
|
|
3163
|
+
const pkFields = entityInfo.PrimaryKeys;
|
|
2816
3164
|
const now = new Date().toISOString();
|
|
2817
3165
|
return allResult.Results.map(record => {
|
|
2818
|
-
|
|
3166
|
+
// '|'-joined across EVERY key column — the shape EntityRecordID is stored in — so the
|
|
3167
|
+
// existingMaps lookup (Create vs Update) matches a composite key, not just its first column.
|
|
3168
|
+
const recordID = this.ComposeEntityRecordID(record, pkFields);
|
|
2819
3169
|
return {
|
|
2820
3170
|
RecordID: recordID,
|
|
2821
3171
|
Type: existingMaps.has(recordID) ? 'Update' : 'Create',
|
|
@@ -3066,7 +3416,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3066
3416
|
const md = this.ProviderToUse;
|
|
3067
3417
|
const entity = await md.GetEntityObject(entityMap.Entity, contextUser);
|
|
3068
3418
|
const entityInfo = md.EntityByName(entityMap.Entity);
|
|
3069
|
-
const pkFields = entityInfo?.PrimaryKeys ??
|
|
3419
|
+
const pkFields = entityInfo?.PrimaryKeys ?? [];
|
|
3070
3420
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(mjRecordID, pkFields));
|
|
3071
3421
|
if (!loaded)
|
|
3072
3422
|
return;
|
|
@@ -3079,7 +3429,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3079
3429
|
}
|
|
3080
3430
|
// Surface a failed conflict-mark: the engine thinks the row is quarantined, but without the
|
|
3081
3431
|
// marker the operator has no signal. A silent failure here leaves the record in limbo.
|
|
3082
|
-
const ok = await entity.Save();
|
|
3432
|
+
const ok = await entity.Save(this.syncSaveOptions);
|
|
3083
3433
|
if (!ok) {
|
|
3084
3434
|
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 });
|
|
3085
3435
|
}
|
|
@@ -3131,7 +3481,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3131
3481
|
`A large or unexpected count can indicate an incomplete upstream fetch, so review before trusting the deletions.`, { orphanCount: orphans.length });
|
|
3132
3482
|
const md = this.ProviderToUse;
|
|
3133
3483
|
const entityInfo = md.EntityByName(entityMap.Entity);
|
|
3134
|
-
const pkFields = entityInfo?.PrimaryKeys ??
|
|
3484
|
+
const pkFields = entityInfo?.PrimaryKeys ?? [];
|
|
3135
3485
|
for (const orphan of orphans) {
|
|
3136
3486
|
try {
|
|
3137
3487
|
const entity = await md.GetEntityObject(entityMap.Entity, contextUser);
|
|
@@ -3160,7 +3510,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3160
3510
|
entity.Set('__mj_integration_IsTombstoned', true);
|
|
3161
3511
|
if (hasField('__mj_integration_DeletedDetectedAt'))
|
|
3162
3512
|
entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
|
|
3163
|
-
const archived = await entity.Save();
|
|
3513
|
+
const archived = await entity.Save(this.syncSaveOptions);
|
|
3164
3514
|
if (archived) {
|
|
3165
3515
|
result.RecordsDeleted++;
|
|
3166
3516
|
await this.DeleteRecordMapRow(orphan.ID, contextUser);
|
|
@@ -3172,7 +3522,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3172
3522
|
}
|
|
3173
3523
|
continue;
|
|
3174
3524
|
}
|
|
3175
|
-
const deleted = await entity.Delete();
|
|
3525
|
+
const deleted = await entity.Delete(this.syncDeleteOptions);
|
|
3176
3526
|
if (deleted) {
|
|
3177
3527
|
result.RecordsDeleted++;
|
|
3178
3528
|
await this.DeleteRecordMapRow(orphan.ID, contextUser);
|
|
@@ -3338,8 +3688,8 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3338
3688
|
// (~line 1644). matchEngine.Resolve reads existing MJ rows on the SHARED provider
|
|
3339
3689
|
// connection, so when streams run in parallel (syncConcurrency>1) it must not interleave
|
|
3340
3690
|
// with another stream's open write transaction (else "Transaction in progress" / dirty read).
|
|
3341
|
-
const resolved = await this.
|
|
3342
|
-
await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1);
|
|
3691
|
+
const resolved = await this.runWriteForMap(entityMap.ID, () => this.matchEngine.Resolve(recs, entityMap, fieldMaps, contextUser));
|
|
3692
|
+
await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1, this.getSyncConcurrency(config));
|
|
3343
3693
|
appliedRecords += recs.length;
|
|
3344
3694
|
}
|
|
3345
3695
|
}
|
|
@@ -3377,7 +3727,14 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3377
3727
|
// lost batch atomicity is absorbed by the engine's idempotency (upsert-by-identity + content
|
|
3378
3728
|
// hash) and the safe-floor watermark (advances only on a clean batch). Default true = the
|
|
3379
3729
|
// proven atomic serial path, unchanged.
|
|
3380
|
-
useTransaction = true
|
|
3730
|
+
useTransaction = true,
|
|
3731
|
+
/**
|
|
3732
|
+
* Requested apply concurrency. Only consulted on the transaction-free path (the one
|
|
3733
|
+
* `useTransaction: false` selects), where records auto-commit independently and can
|
|
3734
|
+
* therefore overlap. Defaults to 1, so a caller that does not pass it keeps the serial
|
|
3735
|
+
* behaviour exactly.
|
|
3736
|
+
*/
|
|
3737
|
+
concurrency = 1) {
|
|
3381
3738
|
// Batched application with per-record failure isolation (the "grace gap" fix).
|
|
3382
3739
|
// Happy path: each batch of up to APPLY_BATCH_SIZE records commits as a single
|
|
3383
3740
|
// transaction — small enough to avoid SQL Server lock escalation (~5000 rows) while
|
|
@@ -3404,27 +3761,174 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3404
3761
|
// otherwise load one-by-one. For a watermark-less re-sync where nothing changed,
|
|
3405
3762
|
// this lets UpdateRecord skip every per-record load. Best-effort: undefined → the
|
|
3406
3763
|
// existing dirty-flag path runs unchanged.
|
|
3407
|
-
//
|
|
3408
|
-
//
|
|
3409
|
-
//
|
|
3410
|
-
//
|
|
3411
|
-
|
|
3764
|
+
// WHAT ACTUALLY NEEDS THE MUTEX.
|
|
3765
|
+
//
|
|
3766
|
+
// The shared provider connection holds one transaction at a time, so the section that
|
|
3767
|
+
// OWNS a transaction must be mutually exclusive. `BeginTransaction` + per-record
|
|
3768
|
+
// `Save()` owns one for the whole batch, and stays serialized exactly as before.
|
|
3769
|
+
//
|
|
3770
|
+
// A batched batch does not. A `TransactionGroup` is an in-memory list until `Submit()`:
|
|
3771
|
+
// enrolling an entity validates, checks row scope and renders the CRUD procedure call,
|
|
3772
|
+
// then parks it — no statement travels, no transaction is open. Only `Submit` touches
|
|
3773
|
+
// the connection. Holding the mutex across the whole apply block was therefore
|
|
3774
|
+
// serializing work that never needed it, and that is what made batching and concurrency
|
|
3775
|
+
// mutually exclusive: maps could not overlap on the part where the time actually goes
|
|
3776
|
+
// (fetch, paging, transform, enrolment) because they were queued behind each other's
|
|
3777
|
+
// writes.
|
|
3778
|
+
//
|
|
3779
|
+
// So the batched path takes the mutex only around the writes themselves. One
|
|
3780
|
+
// transaction is still in flight at a time — the invariant is unchanged — but maps
|
|
3781
|
+
// overlap everywhere else, and each keeps its OWN group, so a poison record fails the
|
|
3782
|
+
// map that owns it instead of every map that happened to be batching alongside it.
|
|
3783
|
+
// Deliberately NOT gated on `useTransaction`. That gate is
|
|
3784
|
+
// `getSyncConcurrency(config) <= 1`, so keeping it here would mean batching only ever
|
|
3785
|
+
// happened at concurrency 1 — the exact tradeoff this change exists to remove.
|
|
3786
|
+
// Batching is a property of how the writes TRAVEL; concurrency is a property of how
|
|
3787
|
+
// many maps fetch at once. They are independent.
|
|
3788
|
+
// Batching is a property of the ENTITY MAP, not only of the connection: a map whose
|
|
3789
|
+
// target's whole identity is server-assigned can never enrol a record, so batching it
|
|
3790
|
+
// would produce an empty group and a non-atomic batch reporting success. See
|
|
3791
|
+
// entityMapHasIdentityOnlyPK.
|
|
3792
|
+
const batchedWrites = this.ReadWriteMode(companyIntegration) === 'batched'
|
|
3793
|
+
&& !this.entityMapHasIdentityOnlyPK(entityMap);
|
|
3794
|
+
// NEVER nest `runWriteExclusive`: the inner call waits on a chain that already contains
|
|
3795
|
+
// the outer one, which deadlocks. Under the outer mutex the writes are already
|
|
3796
|
+
// serialized, so they run inline; without it they take the mutex individually.
|
|
3797
|
+
const serializeWrite = batchedWrites
|
|
3798
|
+
? (fn) => this.runWriteForMap(entityMap.ID, fn)
|
|
3799
|
+
: (fn) => fn();
|
|
3800
|
+
const applyOneBatch = async () => {
|
|
3412
3801
|
const precheckHashes = await this.PrefetchContentHashes(batch, contextUser);
|
|
3413
3802
|
// PKs of records the content-hash fast path skipped this batch — still present and
|
|
3414
3803
|
// confirmed-unchanged on the source. Collected so we can refresh LastReconciledAt for
|
|
3415
3804
|
// all of them in ONE set-based touch after the batch (instead of a frozen-forever stamp).
|
|
3416
3805
|
let reconciledSkipIds = [];
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3806
|
+
// Batched writes make a batch atomic by construction — the group is one
|
|
3807
|
+
// transaction — so this branch is entered for them regardless of concurrency. At
|
|
3808
|
+
// concurrency > 1 the atomicity is per entity map, and a group failure still
|
|
3809
|
+
// degrades to the record-by-record retry below.
|
|
3810
|
+
if (useTransaction || batchedWrites) {
|
|
3811
|
+
// Two ways to make this batch atomic, and they differ ONLY in how the writes
|
|
3812
|
+
// travel. `BeginTransaction` + per-record `Save()` sends one statement per
|
|
3813
|
+
// record; a TransactionGroup defers each `Save()` to `Submit()`, which sends
|
|
3814
|
+
// them together. Everything `Save()` does either way — validation, row scope,
|
|
3815
|
+
// the generated CRUD procedure, Record Changes, `OnAfterSaveExecute` — is
|
|
3816
|
+
// identical, because the group defers the WRITE and nothing else.
|
|
3817
|
+
//
|
|
3818
|
+
// Opt-in per connection, and it fails closed: an absent, unparseable or
|
|
3819
|
+
// unrecognised `writeMode` keeps the proven path, so the default never changes
|
|
3820
|
+
// underneath an existing tenant.
|
|
3821
|
+
const writeGroup = batchedWrites ? await provider.CreateTransactionGroup() : null;
|
|
3822
|
+
if (writeGroup) {
|
|
3823
|
+
// The batching itself (MJ#4087). Without this the group is atomic but still
|
|
3824
|
+
// SERIAL — one round trip per item inside the transaction, which is exactly
|
|
3825
|
+
// today's behaviour and why this PR was safe to land before the providers
|
|
3826
|
+
// could honour it. With it, the whole group travels as one statement.
|
|
3827
|
+
//
|
|
3828
|
+
// Set here rather than at construction because it is a property of how THIS
|
|
3829
|
+
// engine wants its batch submitted, not of the provider: any other caller
|
|
3830
|
+
// creating a group still gets the sequential default.
|
|
3831
|
+
writeGroup.BatchedSubmit = true;
|
|
3832
|
+
}
|
|
3833
|
+
const runCtx = this.currentRunContext;
|
|
3834
|
+
// A batched batch REQUIRES a run context: the group is handed to
|
|
3835
|
+
// ApplySingleRecord's frames through a nested scope of it, so without one
|
|
3836
|
+
// every record would silently save unenrolled and Submit would commit an
|
|
3837
|
+
// empty group — which returns true, so the batch would report success while
|
|
3838
|
+
// having quietly run the per-record path. Every sync path enters a run
|
|
3839
|
+
// context (RunSync and the adopted-run path both wrap their work in one), so
|
|
3840
|
+
// this cannot happen today; stating it makes that a requirement rather than
|
|
3841
|
+
// an accident, and turns a future regression into an error instead of a
|
|
3842
|
+
// silent loss of the feature.
|
|
3843
|
+
if (writeGroup && !runCtx) {
|
|
3844
|
+
throw new Error('batched writes were requested but no run context is active — the write group cannot reach the record ' +
|
|
3845
|
+
'frames, and an unenrolled batch would report success while saving per-record. This is a wiring bug.');
|
|
3846
|
+
}
|
|
3847
|
+
if (!writeGroup)
|
|
3848
|
+
await provider.BeginTransaction();
|
|
3849
|
+
// EACH BATCH GETS ITS OWN GROUP, IN ITS OWN CONTEXT SCOPE.
|
|
3850
|
+
//
|
|
3851
|
+
// Assigning onto the shared run context would be a single slot: the moment two
|
|
3852
|
+
// maps overlap — which narrowing the mutex now allows — the second would
|
|
3853
|
+
// overwrite the first's group and enrol its records into the wrong batch.
|
|
3854
|
+
// Entering a nested AsyncLocalStorage scope instead gives every concurrent
|
|
3855
|
+
// batch its own `writeGroup`, inherited by the ApplySingleRecord frames below
|
|
3856
|
+
// that actually construct the entities, and torn down with the scope.
|
|
3857
|
+
//
|
|
3858
|
+
// Per-batch groups are also what keeps failures isolated: a poison record fails
|
|
3859
|
+
// the group its own map owns, and every other map in flight is untouched.
|
|
3860
|
+
// OPT-IN ceiling on how many writes may sit deferred in one group.
|
|
3861
|
+
//
|
|
3862
|
+
// A group holds every enrolled record's rendered SQL and parameters until Submit,
|
|
3863
|
+
// so peak memory for a batched apply is roughly (maps in flight x group size x row
|
|
3864
|
+
// size). With wide rows that is the largest allocation a sync makes, and a box that
|
|
3865
|
+
// has run out of heap has no way to trade a little throughput for headroom.
|
|
3866
|
+
//
|
|
3867
|
+
// Unset (the default) means NO mid-batch flush: the batch stays exactly one group
|
|
3868
|
+
// and one transaction, as today. Setting it below the batch size splits the batch
|
|
3869
|
+
// into several transactions, which is a real trade — an earlier flush stays
|
|
3870
|
+
// committed if a later one fails — and is why it is off unless asked for. The
|
|
3871
|
+
// per-record fallback that follows a failed batch is idempotent, so the split is
|
|
3872
|
+
// recoverable; it is simply no longer all-or-nothing.
|
|
3873
|
+
const flushAt = ReadFlushCeiling(process.env);
|
|
3874
|
+
const runBatch = async () => {
|
|
3875
|
+
let enrolledSinceFlush = 0;
|
|
3420
3876
|
for (const record of batch) {
|
|
3421
3877
|
result.RecordsProcessed++;
|
|
3422
3878
|
await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
|
|
3879
|
+
if (flushAt === undefined)
|
|
3880
|
+
continue;
|
|
3881
|
+
if (++enrolledSinceFlush < flushAt)
|
|
3882
|
+
continue;
|
|
3883
|
+
enrolledSinceFlush = 0;
|
|
3884
|
+
// Mutating the context object is what makes the swap visible: every frame
|
|
3885
|
+
// below shares this same object through AsyncLocalStorage, so replacing the
|
|
3886
|
+
// group here is what the next record's enrolment sees.
|
|
3887
|
+
const ctx = this.currentRunContext;
|
|
3888
|
+
const full = ctx?.writeGroup;
|
|
3889
|
+
if (!full)
|
|
3890
|
+
continue;
|
|
3891
|
+
const submitted = await serializeWrite(() => full.Submit());
|
|
3892
|
+
if (!submitted)
|
|
3893
|
+
throw new Error('Batched write group did not commit');
|
|
3894
|
+
const fresh = await provider.CreateTransactionGroup();
|
|
3895
|
+
fresh.BatchedSubmit = true;
|
|
3896
|
+
ctx.writeGroup = fresh;
|
|
3897
|
+
}
|
|
3898
|
+
};
|
|
3899
|
+
try {
|
|
3900
|
+
if (writeGroup) {
|
|
3901
|
+
// Guarded above: a batched batch always has a run context to nest.
|
|
3902
|
+
await IntegrationEngine.runContext.run({ ...runCtx, writeGroup }, runBatch);
|
|
3903
|
+
}
|
|
3904
|
+
else {
|
|
3905
|
+
await runBatch();
|
|
3906
|
+
}
|
|
3907
|
+
if (writeGroup) {
|
|
3908
|
+
// The group holds every deferred write; Submit is where they land, in
|
|
3909
|
+
// one transaction. A false return means the group failed as a whole —
|
|
3910
|
+
// routed into the same catch, so the fallback below is reached by both
|
|
3911
|
+
// shapes rather than only by a throw.
|
|
3912
|
+
//
|
|
3913
|
+
// This is the ONLY part of a batched batch that touches the connection,
|
|
3914
|
+
// so it is the only part that takes the write mutex.
|
|
3915
|
+
const submitted = await serializeWrite(() => writeGroup.Submit());
|
|
3916
|
+
if (!submitted)
|
|
3917
|
+
throw new Error('Batched write group did not commit');
|
|
3918
|
+
}
|
|
3919
|
+
else {
|
|
3920
|
+
await provider.CommitTransaction();
|
|
3423
3921
|
}
|
|
3424
|
-
await provider.CommitTransaction();
|
|
3425
3922
|
}
|
|
3426
3923
|
catch (err) {
|
|
3427
|
-
|
|
3924
|
+
// No shared slot to clear: the group lived in the batch's own context
|
|
3925
|
+
// scope, which has already unwound.
|
|
3926
|
+
//
|
|
3927
|
+
// A group that failed has already rolled itself back and there is no
|
|
3928
|
+
// provider-level transaction open to roll back — calling it would throw
|
|
3929
|
+
// over the real error.
|
|
3930
|
+
if (!batchedWrites)
|
|
3931
|
+
await provider.RollbackTransaction();
|
|
3428
3932
|
// The batch transaction rolled back; the skip-IDs collected during the failed attempt
|
|
3429
3933
|
// never committed. Reset and let the per-record retry re-collect only what commits.
|
|
3430
3934
|
reconciledSkipIds = [];
|
|
@@ -3446,7 +3950,11 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3446
3950
|
}
|
|
3447
3951
|
// Degrade to per-record application so the failure isolates to the poison
|
|
3448
3952
|
// record(s) and every good record in this batch still commits.
|
|
3449
|
-
|
|
3953
|
+
// A batched batch runs concurrently with other entity maps, so the
|
|
3954
|
+
// per-record fallback must NOT open a provider transaction — that state
|
|
3955
|
+
// is global to the shared provider. Auto-commit instead; see the
|
|
3956
|
+
// `useProviderTransaction` doc on applyRecordsIndividually.
|
|
3957
|
+
await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps, !batchedWrites);
|
|
3450
3958
|
}
|
|
3451
3959
|
}
|
|
3452
3960
|
else {
|
|
@@ -3456,7 +3964,23 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3456
3964
|
// connection. Per-record error isolation: a poison record is logged + counted; the
|
|
3457
3965
|
// rest still commit; the idempotent re-sync + safe-floor watermark reconcile any
|
|
3458
3966
|
// partial batch (the atomicity the transactional path provides is not needed here).
|
|
3459
|
-
|
|
3967
|
+
// The records are independent and each auto-commits on its own pooled
|
|
3968
|
+
// connection, so this is the one place in the apply path where the requested
|
|
3969
|
+
// concurrency can actually be spent. Running them one at a time made
|
|
3970
|
+
// syncConcurrency a fetch-only setting: the caller opted into concurrency, paid
|
|
3971
|
+
// for it by giving up batch atomicity, and then still wrote serially.
|
|
3972
|
+
//
|
|
3973
|
+
// A fixed pool of workers pulling from a shared cursor, rather than
|
|
3974
|
+
// Promise.all over the batch: 500 simultaneous saves would swamp the connection
|
|
3975
|
+
// pool. The cap is the same knob the fetch side uses, clamped to a sane ceiling.
|
|
3976
|
+
const applyLimit = Math.max(1, Math.min(16, Math.floor(concurrency) || 1));
|
|
3977
|
+
let cursor = 0;
|
|
3978
|
+
// Set by whichever worker sees it; every worker stops at the next pull and the
|
|
3979
|
+
// error is rethrown after they settle. SchemaNotGeneratedError means the whole
|
|
3980
|
+
// map cannot proceed, so finishing the remaining records would be wasted work
|
|
3981
|
+
// against a table that does not exist.
|
|
3982
|
+
let fatal;
|
|
3983
|
+
const applyOne = async (record) => {
|
|
3460
3984
|
result.RecordsProcessed++;
|
|
3461
3985
|
try {
|
|
3462
3986
|
// §10 — bounded inline retry for provably-transient save failures (auto-commit per
|
|
@@ -3470,7 +3994,8 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3470
3994
|
}
|
|
3471
3995
|
catch (err) {
|
|
3472
3996
|
if (err instanceof SchemaNotGeneratedError) {
|
|
3473
|
-
|
|
3997
|
+
fatal ??= err;
|
|
3998
|
+
return;
|
|
3474
3999
|
}
|
|
3475
4000
|
// §10 — permanent / retry-exhausted → dead-letter (count + log), move on; watermark advances regardless.
|
|
3476
4001
|
result.RecordsErrored++;
|
|
@@ -3483,20 +4008,40 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3483
4008
|
Severity: classified.Severity,
|
|
3484
4009
|
});
|
|
3485
4010
|
}
|
|
3486
|
-
}
|
|
4011
|
+
};
|
|
4012
|
+
// allSettled, not all: a worker must never reject, and the counters/dead-letter
|
|
4013
|
+
// list are only coherent once every worker has stopped touching them.
|
|
4014
|
+
await Promise.allSettled(Array.from({ length: Math.min(applyLimit, batch.length) }, async () => {
|
|
4015
|
+
for (;;) {
|
|
4016
|
+
if (fatal !== undefined)
|
|
4017
|
+
return;
|
|
4018
|
+
const next = cursor++;
|
|
4019
|
+
if (next >= batch.length)
|
|
4020
|
+
return;
|
|
4021
|
+
await applyOne(batch[next]);
|
|
4022
|
+
}
|
|
4023
|
+
}));
|
|
4024
|
+
if (fatal !== undefined)
|
|
4025
|
+
throw fatal;
|
|
3487
4026
|
}
|
|
3488
4027
|
// After the batch settles (committed, or per-record retried), refresh
|
|
3489
4028
|
// LastReconciledAt for every content-hash-skipped row in ONE set-based touch.
|
|
3490
4029
|
// Best-effort — a touch failure must never break the sync.
|
|
3491
4030
|
if (reconciledSkipIds.length > 0) {
|
|
3492
|
-
await this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger);
|
|
4031
|
+
await serializeWrite(() => this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger));
|
|
3493
4032
|
}
|
|
3494
4033
|
// Write the batch's record maps set-based, now that the records they point at are
|
|
3495
4034
|
// committed. Deliberately AFTER the transaction rather than inside it: the mapping
|
|
3496
4035
|
// is derived data that the next sync can re-establish by primary key, and keeping
|
|
3497
4036
|
// it out of the write transaction keeps that transaction as short as possible.
|
|
3498
|
-
await this.FlushRecordMaps(recordMaps, entityMap, logger);
|
|
3499
|
-
}
|
|
4037
|
+
await serializeWrite(() => this.FlushRecordMaps(recordMaps, entityMap, logger));
|
|
4038
|
+
};
|
|
4039
|
+
// Batched: overlap freely, serializing only the writes above. Otherwise: the whole
|
|
4040
|
+
// block stays under the mutex, because it owns a provider transaction throughout.
|
|
4041
|
+
if (batchedWrites)
|
|
4042
|
+
await applyOneBatch();
|
|
4043
|
+
else
|
|
4044
|
+
await this.runWriteExclusive(applyOneBatch);
|
|
3500
4045
|
}
|
|
3501
4046
|
}
|
|
3502
4047
|
/**
|
|
@@ -3579,17 +4124,45 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3579
4124
|
*
|
|
3580
4125
|
* Begin/Commit/Rollback are always matched per record (no leaked open transaction).
|
|
3581
4126
|
*/
|
|
3582
|
-
async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps
|
|
4127
|
+
async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps,
|
|
4128
|
+
// When false, each record is applied WITHOUT a provider transaction (auto-commit).
|
|
4129
|
+
//
|
|
4130
|
+
// The provider holds its transaction state — `_transactionDepth`, the active
|
|
4131
|
+
// `Transaction`, the savepoint counter — as SINGLE FIELDS on the one shared provider
|
|
4132
|
+
// instance. `BeginTransaction` from two concurrently-syncing entity maps therefore
|
|
4133
|
+
// races that counter: the second caller sees depth 2, issues `SAVE TRANSACTION` against
|
|
4134
|
+
// a transaction the first caller may have already committed, and the leaked depth then
|
|
4135
|
+
// fails every subsequent query on the connection with "Transaction has not begun".
|
|
4136
|
+
//
|
|
4137
|
+
// That is safe ONLY while the engine owns the provider exclusively, i.e. sequential
|
|
4138
|
+
// sync (`useTransaction === true`). The batched path is not sequential, and it does not
|
|
4139
|
+
// need a transaction here either: `ApplySingleRecord` performs exactly ONE write
|
|
4140
|
+
// (Create | Update | Delete — record-maps are queued into `RecordMapBatch` and flushed
|
|
4141
|
+
// set-based later), so there is nothing for a transaction to make atomic. A single
|
|
4142
|
+
// statement either commits or it does not, and `WithRetry`'s next attempt starts clean
|
|
4143
|
+
// without a rollback of a transaction that never held anything.
|
|
4144
|
+
//
|
|
4145
|
+
// This mirrors exactly what the concurrent non-batched path already does: it applies
|
|
4146
|
+
// records through the SAME `ApplySingleRecord` with no transaction at all, and has run
|
|
4147
|
+
// at concurrency > 1 in production.
|
|
4148
|
+
useProviderTransaction = true) {
|
|
3583
4149
|
const provider = this.ProviderToUse;
|
|
3584
4150
|
for (const record of batch) {
|
|
3585
4151
|
result.RecordsProcessed++;
|
|
3586
4152
|
try {
|
|
3587
|
-
// §10 —
|
|
3588
|
-
//
|
|
3589
|
-
//
|
|
3590
|
-
//
|
|
3591
|
-
// throws straight out to the dead-letter path below.
|
|
4153
|
+
// §10 — bounded inline retry for PROVABLY-TRANSIENT save failures
|
|
4154
|
+
// (NETWORK_TIMEOUT / RATE_LIMIT_EXCEEDED / DATABASE_ERROR per IsRetryableError).
|
|
4155
|
+
// A PERMANENT error (validation/FK/duplicate/config) is NOT retried — it throws
|
|
4156
|
+
// straight out to the dead-letter path below.
|
|
3592
4157
|
await WithRetry(async () => {
|
|
4158
|
+
if (!useProviderTransaction) {
|
|
4159
|
+
// Auto-commit: never touches shared provider transaction state, so
|
|
4160
|
+
// concurrent entity maps cannot corrupt each other. See the parameter doc.
|
|
4161
|
+
await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
|
|
4162
|
+
return;
|
|
4163
|
+
}
|
|
4164
|
+
// Sequential path, unchanged: apply in its own transaction so a deadlock or
|
|
4165
|
+
// momentary timeout rolls back and the next attempt starts clean.
|
|
3593
4166
|
await provider.BeginTransaction();
|
|
3594
4167
|
try {
|
|
3595
4168
|
await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
|
|
@@ -3663,7 +4236,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3663
4236
|
try {
|
|
3664
4237
|
switch (record.ChangeType) {
|
|
3665
4238
|
case 'Create': {
|
|
3666
|
-
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
|
|
4239
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
|
|
3667
4240
|
if (outcome === 'updated')
|
|
3668
4241
|
result.RecordsUpdated++;
|
|
3669
4242
|
else if (outcome === 'skipped')
|
|
@@ -3735,11 +4308,14 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3735
4308
|
*/
|
|
3736
4309
|
async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps,
|
|
3737
4310
|
/** Optional — lets the keyless-key guard below surface on the run's event stream. */
|
|
3738
|
-
keylessLogger
|
|
4311
|
+
keylessLogger,
|
|
4312
|
+
/** What the batch already proved about its destination rows, if anything. */
|
|
4313
|
+
precheck) {
|
|
3739
4314
|
const md = this.ProviderToUse;
|
|
3740
4315
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
4316
|
+
this.enrolInWriteGroup(entity);
|
|
3741
4317
|
const entityInfo = md.EntityByName(record.MJEntityName);
|
|
3742
|
-
const pkFields = entityInfo?.PrimaryKeys ??
|
|
4318
|
+
const pkFields = entityInfo?.PrimaryKeys ?? [];
|
|
3743
4319
|
// Upsert-safe: if the record's mapped fields carry a PK (soft-PK dest tables key on the external
|
|
3744
4320
|
// ID), check whether that row already exists before deciding INSERT vs UPDATE. A null mappedPK
|
|
3745
4321
|
// (e.g. a server-assigned UUID PK not present in the mapped fields) means a genuinely new row.
|
|
@@ -3769,7 +4345,16 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3769
4345
|
console.error(`[IntegrationEngine] KEYLESS_RECORD_REFUSED: ${detail}`);
|
|
3770
4346
|
return 'skipped';
|
|
3771
4347
|
}
|
|
3772
|
-
|
|
4348
|
+
// The batch's prefetch may already have proved this row absent. When it did, the load below
|
|
4349
|
+
// is a SELECT * — every column including any NVARCHAR(MAX) — issued once per record, to
|
|
4350
|
+
// learn something the batch established in a single query. Skipping it is the whole point
|
|
4351
|
+
// of widening that prefetch to the create path.
|
|
4352
|
+
//
|
|
4353
|
+
// Only ever used to skip work when absence is PROVEN: the prefetch must have covered every
|
|
4354
|
+
// record in the batch, and this key must be missing from it. Anything less falls through to
|
|
4355
|
+
// the load, because a wrong "absent" turns an update into a duplicate insert.
|
|
4356
|
+
const provablyAbsent = this.isProvablyAbsent(mappedPK, precheck);
|
|
4357
|
+
const existed = mappedPK != null && !provablyAbsent
|
|
3773
4358
|
? await entity.InnerLoad(this.BuildEntityPrimaryKey(mappedPK, pkFields))
|
|
3774
4359
|
: false;
|
|
3775
4360
|
if (existed) {
|
|
@@ -3796,7 +4381,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3796
4381
|
// re-establish the possibly-cleared record map and SKIP the write — leaving __mj_UpdatedAt
|
|
3797
4382
|
// and the integration LastSynced columns untouched, exactly like the content-hash skip path.
|
|
3798
4383
|
this.SetEntityFields(entity, record.MappedFields);
|
|
3799
|
-
if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo)) {
|
|
4384
|
+
if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo, record)) {
|
|
3800
4385
|
await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
|
|
3801
4386
|
return 'skipped';
|
|
3802
4387
|
}
|
|
@@ -3808,7 +4393,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3808
4393
|
this.SetStandardIntegrationFields(entity, record);
|
|
3809
4394
|
// A5: Pre-write validation
|
|
3810
4395
|
this.validateEntity(entity, record.MJEntityName);
|
|
3811
|
-
const saved = await entity.Save();
|
|
4396
|
+
const saved = await entity.Save(this.syncSaveOptions);
|
|
3812
4397
|
if (!saved) {
|
|
3813
4398
|
const errMsg = entity.LatestResult?.CompleteMessage ?? 'unknown error';
|
|
3814
4399
|
const schemaErr = detectSchemaNotGenerated(record.MJEntityName, errMsg);
|
|
@@ -3822,6 +4407,17 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3822
4407
|
// incremental sync. SaveRecordMap is an upsert keyed on (CompanyIntegration, Entity, ExternalID),
|
|
3823
4408
|
// so this also re-establishes a map that was previously cleared.
|
|
3824
4409
|
const entityRecordID = entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|');
|
|
4410
|
+
// The prefetch's absence proof is only true until this process inserts the row. A mid-batch
|
|
4411
|
+
// flush (MJ_INTEGRATION_BATCH_FLUSH_AT) COMMITS part of a batch; if a later record then fails,
|
|
4412
|
+
// the per-record fallback re-applies the whole batch against the SAME precheck — and the
|
|
4413
|
+
// committed rows' keys, honestly absent at prefetch time, would still "prove" absent and
|
|
4414
|
+
// insert again. Recording the key the moment we create keeps the proof truthful for any
|
|
4415
|
+
// replay in this run. Deliberately unconditional on commit outcome: if the group later rolls
|
|
4416
|
+
// back, an over-included key merely costs that record one existence load on retry — while an
|
|
4417
|
+
// under-included key costs a duplicate row. Only ever err toward the load.
|
|
4418
|
+
if (!existed && mappedPK != null && precheck) {
|
|
4419
|
+
precheck.Present.add(mappedPK);
|
|
4420
|
+
}
|
|
3825
4421
|
await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entityRecordID, contextUser);
|
|
3826
4422
|
return existed ? 'updated' : 'created';
|
|
3827
4423
|
}
|
|
@@ -3849,6 +4445,23 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3849
4445
|
}
|
|
3850
4446
|
return values.join('|');
|
|
3851
4447
|
}
|
|
4448
|
+
/**
|
|
4449
|
+
* True ONLY when the batch prefetch PROVED this record's row does not exist: the prefetch covered
|
|
4450
|
+
* every record in the batch AND this key is missing from the rows it found. Anything less is
|
|
4451
|
+
* "unknown", and unknown must load — a wrong "absent" turns an update into a duplicate INSERT.
|
|
4452
|
+
*
|
|
4453
|
+
* `mappedPK` must be the '|'-joined key {@link extractMappedPrimaryKey} returns — the SAME shape
|
|
4454
|
+
* {@link PrefetchContentHashes} keys `Present` with (`pkNames.map(n => row[n] ?? '').join('|')`).
|
|
4455
|
+
* The first version of this check re-derived a key by indexing that string with PK field names,
|
|
4456
|
+
* which evaluates to `''` for every record — so "provably absent" was unconditionally true and
|
|
4457
|
+
* every upsert of an existing row became a blind duplicate INSERT. Kept as its own method so the
|
|
4458
|
+
* decision is testable against the real extractor's output rather than a re-implementation.
|
|
4459
|
+
*/
|
|
4460
|
+
isProvablyAbsent(mappedPK, precheck) {
|
|
4461
|
+
return mappedPK != null
|
|
4462
|
+
&& precheck?.CoversWholeBatch === true
|
|
4463
|
+
&& !precheck.Present.has(mappedPK);
|
|
4464
|
+
}
|
|
3852
4465
|
/**
|
|
3853
4466
|
* Updates an existing MJ record with pre-write validation.
|
|
3854
4467
|
* If the record cannot be loaded (e.g. it was deleted or never fully created),
|
|
@@ -3859,7 +4472,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3859
4472
|
logger) {
|
|
3860
4473
|
if (!record.MatchedMJRecordID) {
|
|
3861
4474
|
// No matched ID — upsert by PK (insert; or update/skip if the PK already exists)
|
|
3862
|
-
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
|
|
4475
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
|
|
3863
4476
|
if (outcome === 'updated')
|
|
3864
4477
|
result.RecordsUpdated++;
|
|
3865
4478
|
else if (outcome === 'skipped')
|
|
@@ -3874,7 +4487,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3874
4487
|
// unchanged — skip the per-record DB load AND the write. The dirty-flag check
|
|
3875
4488
|
// below is the fallback for entities without the hash column.
|
|
3876
4489
|
if (precheckHashes) {
|
|
3877
|
-
const stored = precheckHashes.get(record.MatchedMJRecordID);
|
|
4490
|
+
const stored = precheckHashes.Hashes.get(record.MatchedMJRecordID);
|
|
3878
4491
|
if (stored && stored === computeContentHash(record.MappedFields ?? {})) {
|
|
3879
4492
|
result.RecordsSkipped++;
|
|
3880
4493
|
// Re-establish the external↔MJ record map even on the content-hash skip. A record can
|
|
@@ -3902,12 +4515,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3902
4515
|
}
|
|
3903
4516
|
const md = this.ProviderToUse;
|
|
3904
4517
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
4518
|
+
this.enrolInWriteGroup(entity);
|
|
3905
4519
|
const entityInfo = md.EntityByName(record.MJEntityName);
|
|
3906
|
-
const pkFields = entityInfo?.PrimaryKeys ??
|
|
4520
|
+
const pkFields = entityInfo?.PrimaryKeys ?? [];
|
|
3907
4521
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
|
|
3908
4522
|
if (!loaded) {
|
|
3909
4523
|
// Matched-ID row vanished — fall back to upsert by PK (insert; or update/skip if PK exists)
|
|
3910
|
-
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
|
|
4524
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
|
|
3911
4525
|
if (outcome === 'updated')
|
|
3912
4526
|
result.RecordsUpdated++;
|
|
3913
4527
|
else if (outcome === 'skipped')
|
|
@@ -3929,7 +4543,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3929
4543
|
// Uses MJ's built-in dirty tracking (zero custom comparison logic). Critical for
|
|
3930
4544
|
// connectors without server-side date filtering (e.g., YM) where every sync re-fetches
|
|
3931
4545
|
// all records. Without this, 50k+ records get re-written every run.
|
|
3932
|
-
if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo)) {
|
|
4546
|
+
if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo, record)) {
|
|
3933
4547
|
result.RecordsSkipped++;
|
|
3934
4548
|
// Re-establish the record map even when the write is skipped — see the content-hash skip
|
|
3935
4549
|
// above for the full rationale (a key-field/PK match can land here with no map row, and
|
|
@@ -3946,7 +4560,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3946
4560
|
this.SetStandardIntegrationFields(entity, record);
|
|
3947
4561
|
// A5: Pre-write validation
|
|
3948
4562
|
this.validateEntity(entity, record.MJEntityName);
|
|
3949
|
-
const saved = await entity.Save();
|
|
4563
|
+
const saved = await entity.Save(this.syncSaveOptions);
|
|
3950
4564
|
if (!saved) {
|
|
3951
4565
|
const errMsg = entity.LatestResult?.CompleteMessage ?? 'unknown error';
|
|
3952
4566
|
const schemaErr = detectSchemaNotGenerated(record.MJEntityName, errMsg);
|
|
@@ -3973,19 +4587,39 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3973
4587
|
* - the read fails (best-effort — a logging/optimization read must never break a sync).
|
|
3974
4588
|
*/
|
|
3975
4589
|
async PrefetchContentHashes(batch, contextUser) {
|
|
3976
|
-
const ids = Array.from(new Set(batch.filter(r => r.ChangeType === 'Update' && r.MatchedMJRecordID)
|
|
3977
|
-
.map(r => r.MatchedMJRecordID)));
|
|
3978
|
-
if (ids.length === 0)
|
|
3979
|
-
return undefined;
|
|
3980
4590
|
const entityName = batch[0].MJEntityName;
|
|
3981
4591
|
const entityInfo = this.ProviderToUse.EntityByName(entityName);
|
|
3982
4592
|
if (!entityInfo)
|
|
3983
4593
|
return undefined;
|
|
3984
|
-
if (!entityInfo.Fields
|
|
4594
|
+
if (!entityInfo.Fields?.some(f => f.Name === CONTENT_HASH_COLUMN))
|
|
3985
4595
|
return undefined;
|
|
3986
4596
|
const pkFields = entityInfo.PrimaryKeys ?? [];
|
|
3987
4597
|
if (pkFields.length === 0)
|
|
3988
4598
|
return undefined;
|
|
4599
|
+
// Matched rows contribute the key the matcher already resolved. Unmatched rows contribute
|
|
4600
|
+
// the key their MAPPED FIELDS carry (soft-PK tables key on the external id), which is the
|
|
4601
|
+
// same key CreateRecord is about to probe for one at a time. Asking for all of them in the
|
|
4602
|
+
// one query we are already issuing is what lets that per-record probe be skipped.
|
|
4603
|
+
const wanted = new Set();
|
|
4604
|
+
let everyRecordCovered = true;
|
|
4605
|
+
for (const r of batch) {
|
|
4606
|
+
if (r.ChangeType === 'Update' && r.MatchedMJRecordID) {
|
|
4607
|
+
wanted.add(r.MatchedMJRecordID);
|
|
4608
|
+
continue;
|
|
4609
|
+
}
|
|
4610
|
+
const mappedPK = this.extractMappedPrimaryKey(r, pkFields);
|
|
4611
|
+
if (mappedPK == null) {
|
|
4612
|
+
// A destination-generated key (identity / server-assigned UUID) cannot be known
|
|
4613
|
+
// before the insert, so this record's existence is genuinely unknowable here.
|
|
4614
|
+
everyRecordCovered = false;
|
|
4615
|
+
continue;
|
|
4616
|
+
}
|
|
4617
|
+
// Already the '|'-joined key in pkFields order — add it as-is (see extractMappedPrimaryKey).
|
|
4618
|
+
wanted.add(mappedPK);
|
|
4619
|
+
}
|
|
4620
|
+
const ids = Array.from(wanted);
|
|
4621
|
+
if (ids.length === 0)
|
|
4622
|
+
return undefined;
|
|
3989
4623
|
// Map keys must match `record.MatchedMJRecordID`, which is the PK value(s) joined by '|' in
|
|
3990
4624
|
// PrimaryKeys order (single value for single-PK, "v1|v2" for composite — see MatchEngine).
|
|
3991
4625
|
const pkNames = pkFields.map(f => f.Name);
|
|
@@ -4003,19 +4637,32 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4003
4637
|
Fields: [...pkNames, CONTENT_HASH_COLUMN],
|
|
4004
4638
|
ExtraFilter: extraFilter,
|
|
4005
4639
|
ResultType: 'simple',
|
|
4640
|
+
// A plain RunView is NOT unbounded — it falls back to the entity's UserViewMaxRows
|
|
4641
|
+
// (default 1000). This result is what `CoversWholeBatch` absence proofs are judged
|
|
4642
|
+
// against, and coverage is computed from the REQUEST side, never reconciled with
|
|
4643
|
+
// res.Results.length: a silently truncated response would mark every existing row
|
|
4644
|
+
// beyond the cap "provably absent" and re-INSERT it as a duplicate on every sync.
|
|
4645
|
+
// Today the apply batch (500) sits under the default cap, so nothing fires — but a
|
|
4646
|
+
// 2x margin defended by nothing is not a guard. Same reasoning as baseEngine's own
|
|
4647
|
+
// IgnoreMaxRows use, and this file documents the identical trap on the push side.
|
|
4648
|
+
IgnoreMaxRows: true,
|
|
4006
4649
|
}, contextUser);
|
|
4007
4650
|
if (!res.Success)
|
|
4008
4651
|
return undefined;
|
|
4009
|
-
const
|
|
4652
|
+
const Hashes = new Map();
|
|
4653
|
+
const Present = new Set();
|
|
4010
4654
|
for (const row of res.Results) {
|
|
4011
4655
|
// Re-key by the same '|'-join the matcher produced, so the lookup in ApplySingleRecord hits.
|
|
4012
4656
|
const key = pkNames.map(n => row[n] ?? '').join('|');
|
|
4657
|
+
// Presence is recorded for EVERY returned row. A row whose hash is NULL still exists,
|
|
4658
|
+
// and treating "no hash" as "no row" would turn an update into a duplicate insert.
|
|
4659
|
+
Present.add(key);
|
|
4013
4660
|
const hash = row[CONTENT_HASH_COLUMN];
|
|
4014
4661
|
if (typeof hash === 'string' && hash.length > 0) {
|
|
4015
|
-
|
|
4662
|
+
Hashes.set(key, hash);
|
|
4016
4663
|
}
|
|
4017
4664
|
}
|
|
4018
|
-
return
|
|
4665
|
+
return { Hashes, Present, CoversWholeBatch: everyRecordCovered };
|
|
4019
4666
|
}
|
|
4020
4667
|
catch (err) {
|
|
4021
4668
|
// MJ#3047 lesson: this best-effort catch was SILENT, so a failing prefetch (e.g. a reserved-word
|
|
@@ -4051,8 +4698,9 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4051
4698
|
return false;
|
|
4052
4699
|
const md = this.ProviderToUse;
|
|
4053
4700
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
4701
|
+
this.enrolInWriteGroup(entity);
|
|
4054
4702
|
const entityInfo = md.EntityByName(record.MJEntityName);
|
|
4055
|
-
const pkFields = entityInfo?.PrimaryKeys ??
|
|
4703
|
+
const pkFields = entityInfo?.PrimaryKeys ?? [];
|
|
4056
4704
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
|
|
4057
4705
|
if (!loaded) {
|
|
4058
4706
|
console.log(`[IntegrationEngine] Skipping delete for ${record.MJEntityName} ${record.MatchedMJRecordID} — record not found in MJ DB (may have been deleted already)`);
|
|
@@ -4074,14 +4722,14 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4074
4722
|
entity.Set('__mj_integration_IsTombstoned', true);
|
|
4075
4723
|
if (hasField('__mj_integration_DeletedDetectedAt'))
|
|
4076
4724
|
entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
|
|
4077
|
-
const archived = await entity.Save();
|
|
4725
|
+
const archived = await entity.Save(this.syncSaveOptions);
|
|
4078
4726
|
if (!archived) {
|
|
4079
4727
|
const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
|
|
4080
4728
|
console.warn(`[IntegrationEngine] Soft-delete (archive) failed for ${record.MJEntityName} ${record.MatchedMJRecordID} — ${reason}`);
|
|
4081
4729
|
}
|
|
4082
4730
|
return archived;
|
|
4083
4731
|
}
|
|
4084
|
-
const deleted = await entity.Delete();
|
|
4732
|
+
const deleted = await entity.Delete(this.syncDeleteOptions);
|
|
4085
4733
|
if (!deleted) {
|
|
4086
4734
|
const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
|
|
4087
4735
|
console.warn(`[IntegrationEngine] Delete blocked for ${record.MJEntityName} ${record.MatchedMJRecordID} — ${reason}`);
|
|
@@ -4096,8 +4744,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4096
4744
|
*/
|
|
4097
4745
|
BuildEntityPrimaryKey(recordID, pkFields) {
|
|
4098
4746
|
const key = new CompositeKey();
|
|
4099
|
-
if (pkFields.length
|
|
4100
|
-
|
|
4747
|
+
if (pkFields.length === 0) {
|
|
4748
|
+
// Never invent an `ID` column: MJ keys can have any name, and a load against a made-up
|
|
4749
|
+
// field fails with "Primary key ID not found in entity ..." — surface the real cause instead.
|
|
4750
|
+
throw new Error(`Cannot build a primary key for record '${recordID}': the entity has no primary key fields in metadata.`);
|
|
4751
|
+
}
|
|
4752
|
+
if (pkFields.length === 1) {
|
|
4753
|
+
key.KeyValuePairs.push({ FieldName: pkFields[0].Name, Value: recordID });
|
|
4101
4754
|
}
|
|
4102
4755
|
else {
|
|
4103
4756
|
const parts = recordID.split('|');
|
|
@@ -4107,6 +4760,14 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4107
4760
|
}
|
|
4108
4761
|
return key;
|
|
4109
4762
|
}
|
|
4763
|
+
/**
|
|
4764
|
+
* The exact inverse of {@link BuildEntityPrimaryKey}: the '|'-joined value(s) of a data row's
|
|
4765
|
+
* primary-key column(s), in PK-field order — the shape CompanyIntegrationRecordMap.EntityRecordID
|
|
4766
|
+
* and a normalized RecordChange.RecordID carry. A single-column key is just its value.
|
|
4767
|
+
*/
|
|
4768
|
+
ComposeEntityRecordID(row, pkFields) {
|
|
4769
|
+
return pkFields.map(pk => serializeKeyValue(row[pk.Name])).join('|');
|
|
4770
|
+
}
|
|
4110
4771
|
/**
|
|
4111
4772
|
* Reports ACTIVE field maps whose MJ column does not exist, once per entity map per run.
|
|
4112
4773
|
*
|
|
@@ -4291,7 +4952,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4291
4952
|
* runtime-created target tables have no generated entity type, which is why the engine
|
|
4292
4953
|
* already `.Set()`s the very same columns in SetStandardIntegrationFields.
|
|
4293
4954
|
*/
|
|
4294
|
-
needsSyncStateRepair(entity, entityInfo) {
|
|
4955
|
+
needsSyncStateRepair(entity, entityInfo, record) {
|
|
4295
4956
|
if (!entityInfo)
|
|
4296
4957
|
return false;
|
|
4297
4958
|
const has = (name) => entityInfo.Fields.some(f => f.Name === name);
|
|
@@ -4301,6 +4962,27 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4301
4962
|
return true;
|
|
4302
4963
|
if (has('__mj_integration_SyncStatus') && entity.Get('__mj_integration_SyncStatus') !== 'Active')
|
|
4303
4964
|
return true;
|
|
4965
|
+
// A STALE CONTENT HASH is repair-worthy for the same reason: skipping the write freezes it.
|
|
4966
|
+
//
|
|
4967
|
+
// The case that produces one is a source that stops sending a column. The mapper OMITS an
|
|
4968
|
+
// absent key rather than mapping it to null (a missing value is not a null value), so the
|
|
4969
|
+
// recomputed hash differs — but SetEntityFields never touches that column either, so the
|
|
4970
|
+
// entity is NOT dirty and the skip above fires. The stored hash is therefore never refreshed
|
|
4971
|
+
// and the mismatch is permanent: that row loses the content-hash fast path FOREVER, paying a
|
|
4972
|
+
// full load and field-by-field compare on every sync until some other field happens to
|
|
4973
|
+
// change. One repair write here re-converges it, and every later sync skips it cheaply.
|
|
4974
|
+
//
|
|
4975
|
+
// Deliberately NOT treated as "the column is gone" — absence in the data is not evidence of
|
|
4976
|
+
// absence in the schema (§ the same rule the field-level deactivation follows). The value is
|
|
4977
|
+
// left exactly as it is; only the hash is brought back in line with what we are actually
|
|
4978
|
+
// mapping.
|
|
4979
|
+
if (record && has(CONTENT_HASH_COLUMN)) {
|
|
4980
|
+
const storedHash = entity.Get(CONTENT_HASH_COLUMN);
|
|
4981
|
+
if (typeof storedHash === 'string' && storedHash.length > 0
|
|
4982
|
+
&& storedHash !== computeContentHash(record.MappedFields ?? {})) {
|
|
4983
|
+
return true;
|
|
4984
|
+
}
|
|
4985
|
+
}
|
|
4304
4986
|
return false;
|
|
4305
4987
|
}
|
|
4306
4988
|
/**
|
|
@@ -4731,7 +5413,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4731
5413
|
const d = provider.Dialect;
|
|
4732
5414
|
const runTable = `${d.QuoteIdentifier(runInfo.SchemaName)}.${d.QuoteIdentifier(runInfo.BaseTable)}`;
|
|
4733
5415
|
const detailTable = `${d.QuoteIdentifier(detailInfo.SchemaName)}.${d.QuoteIdentifier(detailInfo.BaseTable)}`;
|
|
4734
|
-
const runPk = d.QuoteIdentifier(runInfo.
|
|
5416
|
+
const runPk = d.QuoteIdentifier(runInfo.FirstPrimaryKey.Name); // first-pk-ok: runInfo is MJ: Company Integration Runs (core entity, single ID key), the FK target of Run Details.CompanyIntegrationRunID
|
|
4735
5417
|
const ciCol = d.QuoteIdentifier('CompanyIntegrationID');
|
|
4736
5418
|
const startedCol = d.QuoteIdentifier('StartedAt');
|
|
4737
5419
|
const detailFk = d.QuoteIdentifier('CompanyIntegrationRunID');
|