@memberjunction/integration-engine 6.1.0-edge.4 → 6.1.0-edge.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BaseIntegrationConnector.d.ts +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 +167 -1
- package/dist/IntegrationEngine.d.ts.map +1 -1
- package/dist/IntegrationEngine.js +798 -140
- 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/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.
|
|
@@ -3079,7 +3418,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3079
3418
|
}
|
|
3080
3419
|
// Surface a failed conflict-mark: the engine thinks the row is quarantined, but without the
|
|
3081
3420
|
// marker the operator has no signal. A silent failure here leaves the record in limbo.
|
|
3082
|
-
const ok = await entity.Save();
|
|
3421
|
+
const ok = await entity.Save(this.syncSaveOptions);
|
|
3083
3422
|
if (!ok) {
|
|
3084
3423
|
logger?.warning(entityMap.ExternalObjectName ?? entityMap.Entity ?? entityMap.ID, 'CONFLICT_MARK_FAILED', `Could not mark MJ record ${mjRecordID} in-conflict: ${entity.LatestResult?.CompleteMessage ?? 'Save() returned false'}`, { mjRecordID, conflictFields });
|
|
3085
3424
|
}
|
|
@@ -3160,7 +3499,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3160
3499
|
entity.Set('__mj_integration_IsTombstoned', true);
|
|
3161
3500
|
if (hasField('__mj_integration_DeletedDetectedAt'))
|
|
3162
3501
|
entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
|
|
3163
|
-
const archived = await entity.Save();
|
|
3502
|
+
const archived = await entity.Save(this.syncSaveOptions);
|
|
3164
3503
|
if (archived) {
|
|
3165
3504
|
result.RecordsDeleted++;
|
|
3166
3505
|
await this.DeleteRecordMapRow(orphan.ID, contextUser);
|
|
@@ -3172,7 +3511,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3172
3511
|
}
|
|
3173
3512
|
continue;
|
|
3174
3513
|
}
|
|
3175
|
-
const deleted = await entity.Delete();
|
|
3514
|
+
const deleted = await entity.Delete(this.syncDeleteOptions);
|
|
3176
3515
|
if (deleted) {
|
|
3177
3516
|
result.RecordsDeleted++;
|
|
3178
3517
|
await this.DeleteRecordMapRow(orphan.ID, contextUser);
|
|
@@ -3338,8 +3677,8 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3338
3677
|
// (~line 1644). matchEngine.Resolve reads existing MJ rows on the SHARED provider
|
|
3339
3678
|
// connection, so when streams run in parallel (syncConcurrency>1) it must not interleave
|
|
3340
3679
|
// 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);
|
|
3680
|
+
const resolved = await this.runWriteForMap(entityMap.ID, () => this.matchEngine.Resolve(recs, entityMap, fieldMaps, contextUser));
|
|
3681
|
+
await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1, this.getSyncConcurrency(config));
|
|
3343
3682
|
appliedRecords += recs.length;
|
|
3344
3683
|
}
|
|
3345
3684
|
}
|
|
@@ -3377,7 +3716,14 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3377
3716
|
// lost batch atomicity is absorbed by the engine's idempotency (upsert-by-identity + content
|
|
3378
3717
|
// hash) and the safe-floor watermark (advances only on a clean batch). Default true = the
|
|
3379
3718
|
// proven atomic serial path, unchanged.
|
|
3380
|
-
useTransaction = true
|
|
3719
|
+
useTransaction = true,
|
|
3720
|
+
/**
|
|
3721
|
+
* Requested apply concurrency. Only consulted on the transaction-free path (the one
|
|
3722
|
+
* `useTransaction: false` selects), where records auto-commit independently and can
|
|
3723
|
+
* therefore overlap. Defaults to 1, so a caller that does not pass it keeps the serial
|
|
3724
|
+
* behaviour exactly.
|
|
3725
|
+
*/
|
|
3726
|
+
concurrency = 1) {
|
|
3381
3727
|
// Batched application with per-record failure isolation (the "grace gap" fix).
|
|
3382
3728
|
// Happy path: each batch of up to APPLY_BATCH_SIZE records commits as a single
|
|
3383
3729
|
// transaction — small enough to avoid SQL Server lock escalation (~5000 rows) while
|
|
@@ -3404,27 +3750,174 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3404
3750
|
// otherwise load one-by-one. For a watermark-less re-sync where nothing changed,
|
|
3405
3751
|
// this lets UpdateRecord skip every per-record load. Best-effort: undefined → the
|
|
3406
3752
|
// existing dirty-flag path runs unchanged.
|
|
3407
|
-
//
|
|
3408
|
-
//
|
|
3409
|
-
//
|
|
3410
|
-
//
|
|
3411
|
-
|
|
3753
|
+
// WHAT ACTUALLY NEEDS THE MUTEX.
|
|
3754
|
+
//
|
|
3755
|
+
// The shared provider connection holds one transaction at a time, so the section that
|
|
3756
|
+
// OWNS a transaction must be mutually exclusive. `BeginTransaction` + per-record
|
|
3757
|
+
// `Save()` owns one for the whole batch, and stays serialized exactly as before.
|
|
3758
|
+
//
|
|
3759
|
+
// A batched batch does not. A `TransactionGroup` is an in-memory list until `Submit()`:
|
|
3760
|
+
// enrolling an entity validates, checks row scope and renders the CRUD procedure call,
|
|
3761
|
+
// then parks it — no statement travels, no transaction is open. Only `Submit` touches
|
|
3762
|
+
// the connection. Holding the mutex across the whole apply block was therefore
|
|
3763
|
+
// serializing work that never needed it, and that is what made batching and concurrency
|
|
3764
|
+
// mutually exclusive: maps could not overlap on the part where the time actually goes
|
|
3765
|
+
// (fetch, paging, transform, enrolment) because they were queued behind each other's
|
|
3766
|
+
// writes.
|
|
3767
|
+
//
|
|
3768
|
+
// So the batched path takes the mutex only around the writes themselves. One
|
|
3769
|
+
// transaction is still in flight at a time — the invariant is unchanged — but maps
|
|
3770
|
+
// overlap everywhere else, and each keeps its OWN group, so a poison record fails the
|
|
3771
|
+
// map that owns it instead of every map that happened to be batching alongside it.
|
|
3772
|
+
// Deliberately NOT gated on `useTransaction`. That gate is
|
|
3773
|
+
// `getSyncConcurrency(config) <= 1`, so keeping it here would mean batching only ever
|
|
3774
|
+
// happened at concurrency 1 — the exact tradeoff this change exists to remove.
|
|
3775
|
+
// Batching is a property of how the writes TRAVEL; concurrency is a property of how
|
|
3776
|
+
// many maps fetch at once. They are independent.
|
|
3777
|
+
// Batching is a property of the ENTITY MAP, not only of the connection: a map whose
|
|
3778
|
+
// target's whole identity is server-assigned can never enrol a record, so batching it
|
|
3779
|
+
// would produce an empty group and a non-atomic batch reporting success. See
|
|
3780
|
+
// entityMapHasIdentityOnlyPK.
|
|
3781
|
+
const batchedWrites = this.ReadWriteMode(companyIntegration) === 'batched'
|
|
3782
|
+
&& !this.entityMapHasIdentityOnlyPK(entityMap);
|
|
3783
|
+
// NEVER nest `runWriteExclusive`: the inner call waits on a chain that already contains
|
|
3784
|
+
// the outer one, which deadlocks. Under the outer mutex the writes are already
|
|
3785
|
+
// serialized, so they run inline; without it they take the mutex individually.
|
|
3786
|
+
const serializeWrite = batchedWrites
|
|
3787
|
+
? (fn) => this.runWriteForMap(entityMap.ID, fn)
|
|
3788
|
+
: (fn) => fn();
|
|
3789
|
+
const applyOneBatch = async () => {
|
|
3412
3790
|
const precheckHashes = await this.PrefetchContentHashes(batch, contextUser);
|
|
3413
3791
|
// PKs of records the content-hash fast path skipped this batch — still present and
|
|
3414
3792
|
// confirmed-unchanged on the source. Collected so we can refresh LastReconciledAt for
|
|
3415
3793
|
// all of them in ONE set-based touch after the batch (instead of a frozen-forever stamp).
|
|
3416
3794
|
let reconciledSkipIds = [];
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3795
|
+
// Batched writes make a batch atomic by construction — the group is one
|
|
3796
|
+
// transaction — so this branch is entered for them regardless of concurrency. At
|
|
3797
|
+
// concurrency > 1 the atomicity is per entity map, and a group failure still
|
|
3798
|
+
// degrades to the record-by-record retry below.
|
|
3799
|
+
if (useTransaction || batchedWrites) {
|
|
3800
|
+
// Two ways to make this batch atomic, and they differ ONLY in how the writes
|
|
3801
|
+
// travel. `BeginTransaction` + per-record `Save()` sends one statement per
|
|
3802
|
+
// record; a TransactionGroup defers each `Save()` to `Submit()`, which sends
|
|
3803
|
+
// them together. Everything `Save()` does either way — validation, row scope,
|
|
3804
|
+
// the generated CRUD procedure, Record Changes, `OnAfterSaveExecute` — is
|
|
3805
|
+
// identical, because the group defers the WRITE and nothing else.
|
|
3806
|
+
//
|
|
3807
|
+
// Opt-in per connection, and it fails closed: an absent, unparseable or
|
|
3808
|
+
// unrecognised `writeMode` keeps the proven path, so the default never changes
|
|
3809
|
+
// underneath an existing tenant.
|
|
3810
|
+
const writeGroup = batchedWrites ? await provider.CreateTransactionGroup() : null;
|
|
3811
|
+
if (writeGroup) {
|
|
3812
|
+
// The batching itself (MJ#4087). Without this the group is atomic but still
|
|
3813
|
+
// SERIAL — one round trip per item inside the transaction, which is exactly
|
|
3814
|
+
// today's behaviour and why this PR was safe to land before the providers
|
|
3815
|
+
// could honour it. With it, the whole group travels as one statement.
|
|
3816
|
+
//
|
|
3817
|
+
// Set here rather than at construction because it is a property of how THIS
|
|
3818
|
+
// engine wants its batch submitted, not of the provider: any other caller
|
|
3819
|
+
// creating a group still gets the sequential default.
|
|
3820
|
+
writeGroup.BatchedSubmit = true;
|
|
3821
|
+
}
|
|
3822
|
+
const runCtx = this.currentRunContext;
|
|
3823
|
+
// A batched batch REQUIRES a run context: the group is handed to
|
|
3824
|
+
// ApplySingleRecord's frames through a nested scope of it, so without one
|
|
3825
|
+
// every record would silently save unenrolled and Submit would commit an
|
|
3826
|
+
// empty group — which returns true, so the batch would report success while
|
|
3827
|
+
// having quietly run the per-record path. Every sync path enters a run
|
|
3828
|
+
// context (RunSync and the adopted-run path both wrap their work in one), so
|
|
3829
|
+
// this cannot happen today; stating it makes that a requirement rather than
|
|
3830
|
+
// an accident, and turns a future regression into an error instead of a
|
|
3831
|
+
// silent loss of the feature.
|
|
3832
|
+
if (writeGroup && !runCtx) {
|
|
3833
|
+
throw new Error('batched writes were requested but no run context is active — the write group cannot reach the record ' +
|
|
3834
|
+
'frames, and an unenrolled batch would report success while saving per-record. This is a wiring bug.');
|
|
3835
|
+
}
|
|
3836
|
+
if (!writeGroup)
|
|
3837
|
+
await provider.BeginTransaction();
|
|
3838
|
+
// EACH BATCH GETS ITS OWN GROUP, IN ITS OWN CONTEXT SCOPE.
|
|
3839
|
+
//
|
|
3840
|
+
// Assigning onto the shared run context would be a single slot: the moment two
|
|
3841
|
+
// maps overlap — which narrowing the mutex now allows — the second would
|
|
3842
|
+
// overwrite the first's group and enrol its records into the wrong batch.
|
|
3843
|
+
// Entering a nested AsyncLocalStorage scope instead gives every concurrent
|
|
3844
|
+
// batch its own `writeGroup`, inherited by the ApplySingleRecord frames below
|
|
3845
|
+
// that actually construct the entities, and torn down with the scope.
|
|
3846
|
+
//
|
|
3847
|
+
// Per-batch groups are also what keeps failures isolated: a poison record fails
|
|
3848
|
+
// the group its own map owns, and every other map in flight is untouched.
|
|
3849
|
+
// OPT-IN ceiling on how many writes may sit deferred in one group.
|
|
3850
|
+
//
|
|
3851
|
+
// A group holds every enrolled record's rendered SQL and parameters until Submit,
|
|
3852
|
+
// so peak memory for a batched apply is roughly (maps in flight x group size x row
|
|
3853
|
+
// size). With wide rows that is the largest allocation a sync makes, and a box that
|
|
3854
|
+
// has run out of heap has no way to trade a little throughput for headroom.
|
|
3855
|
+
//
|
|
3856
|
+
// Unset (the default) means NO mid-batch flush: the batch stays exactly one group
|
|
3857
|
+
// and one transaction, as today. Setting it below the batch size splits the batch
|
|
3858
|
+
// into several transactions, which is a real trade — an earlier flush stays
|
|
3859
|
+
// committed if a later one fails — and is why it is off unless asked for. The
|
|
3860
|
+
// per-record fallback that follows a failed batch is idempotent, so the split is
|
|
3861
|
+
// recoverable; it is simply no longer all-or-nothing.
|
|
3862
|
+
const flushAt = ReadFlushCeiling(process.env);
|
|
3863
|
+
const runBatch = async () => {
|
|
3864
|
+
let enrolledSinceFlush = 0;
|
|
3420
3865
|
for (const record of batch) {
|
|
3421
3866
|
result.RecordsProcessed++;
|
|
3422
3867
|
await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
|
|
3868
|
+
if (flushAt === undefined)
|
|
3869
|
+
continue;
|
|
3870
|
+
if (++enrolledSinceFlush < flushAt)
|
|
3871
|
+
continue;
|
|
3872
|
+
enrolledSinceFlush = 0;
|
|
3873
|
+
// Mutating the context object is what makes the swap visible: every frame
|
|
3874
|
+
// below shares this same object through AsyncLocalStorage, so replacing the
|
|
3875
|
+
// group here is what the next record's enrolment sees.
|
|
3876
|
+
const ctx = this.currentRunContext;
|
|
3877
|
+
const full = ctx?.writeGroup;
|
|
3878
|
+
if (!full)
|
|
3879
|
+
continue;
|
|
3880
|
+
const submitted = await serializeWrite(() => full.Submit());
|
|
3881
|
+
if (!submitted)
|
|
3882
|
+
throw new Error('Batched write group did not commit');
|
|
3883
|
+
const fresh = await provider.CreateTransactionGroup();
|
|
3884
|
+
fresh.BatchedSubmit = true;
|
|
3885
|
+
ctx.writeGroup = fresh;
|
|
3886
|
+
}
|
|
3887
|
+
};
|
|
3888
|
+
try {
|
|
3889
|
+
if (writeGroup) {
|
|
3890
|
+
// Guarded above: a batched batch always has a run context to nest.
|
|
3891
|
+
await IntegrationEngine.runContext.run({ ...runCtx, writeGroup }, runBatch);
|
|
3892
|
+
}
|
|
3893
|
+
else {
|
|
3894
|
+
await runBatch();
|
|
3895
|
+
}
|
|
3896
|
+
if (writeGroup) {
|
|
3897
|
+
// The group holds every deferred write; Submit is where they land, in
|
|
3898
|
+
// one transaction. A false return means the group failed as a whole —
|
|
3899
|
+
// routed into the same catch, so the fallback below is reached by both
|
|
3900
|
+
// shapes rather than only by a throw.
|
|
3901
|
+
//
|
|
3902
|
+
// This is the ONLY part of a batched batch that touches the connection,
|
|
3903
|
+
// so it is the only part that takes the write mutex.
|
|
3904
|
+
const submitted = await serializeWrite(() => writeGroup.Submit());
|
|
3905
|
+
if (!submitted)
|
|
3906
|
+
throw new Error('Batched write group did not commit');
|
|
3907
|
+
}
|
|
3908
|
+
else {
|
|
3909
|
+
await provider.CommitTransaction();
|
|
3423
3910
|
}
|
|
3424
|
-
await provider.CommitTransaction();
|
|
3425
3911
|
}
|
|
3426
3912
|
catch (err) {
|
|
3427
|
-
|
|
3913
|
+
// No shared slot to clear: the group lived in the batch's own context
|
|
3914
|
+
// scope, which has already unwound.
|
|
3915
|
+
//
|
|
3916
|
+
// A group that failed has already rolled itself back and there is no
|
|
3917
|
+
// provider-level transaction open to roll back — calling it would throw
|
|
3918
|
+
// over the real error.
|
|
3919
|
+
if (!batchedWrites)
|
|
3920
|
+
await provider.RollbackTransaction();
|
|
3428
3921
|
// The batch transaction rolled back; the skip-IDs collected during the failed attempt
|
|
3429
3922
|
// never committed. Reset and let the per-record retry re-collect only what commits.
|
|
3430
3923
|
reconciledSkipIds = [];
|
|
@@ -3446,7 +3939,11 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3446
3939
|
}
|
|
3447
3940
|
// Degrade to per-record application so the failure isolates to the poison
|
|
3448
3941
|
// record(s) and every good record in this batch still commits.
|
|
3449
|
-
|
|
3942
|
+
// A batched batch runs concurrently with other entity maps, so the
|
|
3943
|
+
// per-record fallback must NOT open a provider transaction — that state
|
|
3944
|
+
// is global to the shared provider. Auto-commit instead; see the
|
|
3945
|
+
// `useProviderTransaction` doc on applyRecordsIndividually.
|
|
3946
|
+
await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps, !batchedWrites);
|
|
3450
3947
|
}
|
|
3451
3948
|
}
|
|
3452
3949
|
else {
|
|
@@ -3456,7 +3953,23 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3456
3953
|
// connection. Per-record error isolation: a poison record is logged + counted; the
|
|
3457
3954
|
// rest still commit; the idempotent re-sync + safe-floor watermark reconcile any
|
|
3458
3955
|
// partial batch (the atomicity the transactional path provides is not needed here).
|
|
3459
|
-
|
|
3956
|
+
// The records are independent and each auto-commits on its own pooled
|
|
3957
|
+
// connection, so this is the one place in the apply path where the requested
|
|
3958
|
+
// concurrency can actually be spent. Running them one at a time made
|
|
3959
|
+
// syncConcurrency a fetch-only setting: the caller opted into concurrency, paid
|
|
3960
|
+
// for it by giving up batch atomicity, and then still wrote serially.
|
|
3961
|
+
//
|
|
3962
|
+
// A fixed pool of workers pulling from a shared cursor, rather than
|
|
3963
|
+
// Promise.all over the batch: 500 simultaneous saves would swamp the connection
|
|
3964
|
+
// pool. The cap is the same knob the fetch side uses, clamped to a sane ceiling.
|
|
3965
|
+
const applyLimit = Math.max(1, Math.min(16, Math.floor(concurrency) || 1));
|
|
3966
|
+
let cursor = 0;
|
|
3967
|
+
// Set by whichever worker sees it; every worker stops at the next pull and the
|
|
3968
|
+
// error is rethrown after they settle. SchemaNotGeneratedError means the whole
|
|
3969
|
+
// map cannot proceed, so finishing the remaining records would be wasted work
|
|
3970
|
+
// against a table that does not exist.
|
|
3971
|
+
let fatal;
|
|
3972
|
+
const applyOne = async (record) => {
|
|
3460
3973
|
result.RecordsProcessed++;
|
|
3461
3974
|
try {
|
|
3462
3975
|
// §10 — bounded inline retry for provably-transient save failures (auto-commit per
|
|
@@ -3470,7 +3983,8 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3470
3983
|
}
|
|
3471
3984
|
catch (err) {
|
|
3472
3985
|
if (err instanceof SchemaNotGeneratedError) {
|
|
3473
|
-
|
|
3986
|
+
fatal ??= err;
|
|
3987
|
+
return;
|
|
3474
3988
|
}
|
|
3475
3989
|
// §10 — permanent / retry-exhausted → dead-letter (count + log), move on; watermark advances regardless.
|
|
3476
3990
|
result.RecordsErrored++;
|
|
@@ -3483,20 +3997,40 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3483
3997
|
Severity: classified.Severity,
|
|
3484
3998
|
});
|
|
3485
3999
|
}
|
|
3486
|
-
}
|
|
4000
|
+
};
|
|
4001
|
+
// allSettled, not all: a worker must never reject, and the counters/dead-letter
|
|
4002
|
+
// list are only coherent once every worker has stopped touching them.
|
|
4003
|
+
await Promise.allSettled(Array.from({ length: Math.min(applyLimit, batch.length) }, async () => {
|
|
4004
|
+
for (;;) {
|
|
4005
|
+
if (fatal !== undefined)
|
|
4006
|
+
return;
|
|
4007
|
+
const next = cursor++;
|
|
4008
|
+
if (next >= batch.length)
|
|
4009
|
+
return;
|
|
4010
|
+
await applyOne(batch[next]);
|
|
4011
|
+
}
|
|
4012
|
+
}));
|
|
4013
|
+
if (fatal !== undefined)
|
|
4014
|
+
throw fatal;
|
|
3487
4015
|
}
|
|
3488
4016
|
// After the batch settles (committed, or per-record retried), refresh
|
|
3489
4017
|
// LastReconciledAt for every content-hash-skipped row in ONE set-based touch.
|
|
3490
4018
|
// Best-effort — a touch failure must never break the sync.
|
|
3491
4019
|
if (reconciledSkipIds.length > 0) {
|
|
3492
|
-
await this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger);
|
|
4020
|
+
await serializeWrite(() => this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger));
|
|
3493
4021
|
}
|
|
3494
4022
|
// Write the batch's record maps set-based, now that the records they point at are
|
|
3495
4023
|
// committed. Deliberately AFTER the transaction rather than inside it: the mapping
|
|
3496
4024
|
// is derived data that the next sync can re-establish by primary key, and keeping
|
|
3497
4025
|
// it out of the write transaction keeps that transaction as short as possible.
|
|
3498
|
-
await this.FlushRecordMaps(recordMaps, entityMap, logger);
|
|
3499
|
-
}
|
|
4026
|
+
await serializeWrite(() => this.FlushRecordMaps(recordMaps, entityMap, logger));
|
|
4027
|
+
};
|
|
4028
|
+
// Batched: overlap freely, serializing only the writes above. Otherwise: the whole
|
|
4029
|
+
// block stays under the mutex, because it owns a provider transaction throughout.
|
|
4030
|
+
if (batchedWrites)
|
|
4031
|
+
await applyOneBatch();
|
|
4032
|
+
else
|
|
4033
|
+
await this.runWriteExclusive(applyOneBatch);
|
|
3500
4034
|
}
|
|
3501
4035
|
}
|
|
3502
4036
|
/**
|
|
@@ -3579,17 +4113,45 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3579
4113
|
*
|
|
3580
4114
|
* Begin/Commit/Rollback are always matched per record (no leaked open transaction).
|
|
3581
4115
|
*/
|
|
3582
|
-
async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps
|
|
4116
|
+
async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps,
|
|
4117
|
+
// When false, each record is applied WITHOUT a provider transaction (auto-commit).
|
|
4118
|
+
//
|
|
4119
|
+
// The provider holds its transaction state — `_transactionDepth`, the active
|
|
4120
|
+
// `Transaction`, the savepoint counter — as SINGLE FIELDS on the one shared provider
|
|
4121
|
+
// instance. `BeginTransaction` from two concurrently-syncing entity maps therefore
|
|
4122
|
+
// races that counter: the second caller sees depth 2, issues `SAVE TRANSACTION` against
|
|
4123
|
+
// a transaction the first caller may have already committed, and the leaked depth then
|
|
4124
|
+
// fails every subsequent query on the connection with "Transaction has not begun".
|
|
4125
|
+
//
|
|
4126
|
+
// That is safe ONLY while the engine owns the provider exclusively, i.e. sequential
|
|
4127
|
+
// sync (`useTransaction === true`). The batched path is not sequential, and it does not
|
|
4128
|
+
// need a transaction here either: `ApplySingleRecord` performs exactly ONE write
|
|
4129
|
+
// (Create | Update | Delete — record-maps are queued into `RecordMapBatch` and flushed
|
|
4130
|
+
// set-based later), so there is nothing for a transaction to make atomic. A single
|
|
4131
|
+
// statement either commits or it does not, and `WithRetry`'s next attempt starts clean
|
|
4132
|
+
// without a rollback of a transaction that never held anything.
|
|
4133
|
+
//
|
|
4134
|
+
// This mirrors exactly what the concurrent non-batched path already does: it applies
|
|
4135
|
+
// records through the SAME `ApplySingleRecord` with no transaction at all, and has run
|
|
4136
|
+
// at concurrency > 1 in production.
|
|
4137
|
+
useProviderTransaction = true) {
|
|
3583
4138
|
const provider = this.ProviderToUse;
|
|
3584
4139
|
for (const record of batch) {
|
|
3585
4140
|
result.RecordsProcessed++;
|
|
3586
4141
|
try {
|
|
3587
|
-
// §10 —
|
|
3588
|
-
//
|
|
3589
|
-
//
|
|
3590
|
-
//
|
|
3591
|
-
// throws straight out to the dead-letter path below.
|
|
4142
|
+
// §10 — bounded inline retry for PROVABLY-TRANSIENT save failures
|
|
4143
|
+
// (NETWORK_TIMEOUT / RATE_LIMIT_EXCEEDED / DATABASE_ERROR per IsRetryableError).
|
|
4144
|
+
// A PERMANENT error (validation/FK/duplicate/config) is NOT retried — it throws
|
|
4145
|
+
// straight out to the dead-letter path below.
|
|
3592
4146
|
await WithRetry(async () => {
|
|
4147
|
+
if (!useProviderTransaction) {
|
|
4148
|
+
// Auto-commit: never touches shared provider transaction state, so
|
|
4149
|
+
// concurrent entity maps cannot corrupt each other. See the parameter doc.
|
|
4150
|
+
await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
|
|
4151
|
+
return;
|
|
4152
|
+
}
|
|
4153
|
+
// Sequential path, unchanged: apply in its own transaction so a deadlock or
|
|
4154
|
+
// momentary timeout rolls back and the next attempt starts clean.
|
|
3593
4155
|
await provider.BeginTransaction();
|
|
3594
4156
|
try {
|
|
3595
4157
|
await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
|
|
@@ -3663,7 +4225,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3663
4225
|
try {
|
|
3664
4226
|
switch (record.ChangeType) {
|
|
3665
4227
|
case 'Create': {
|
|
3666
|
-
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
|
|
4228
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
|
|
3667
4229
|
if (outcome === 'updated')
|
|
3668
4230
|
result.RecordsUpdated++;
|
|
3669
4231
|
else if (outcome === 'skipped')
|
|
@@ -3735,9 +4297,12 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3735
4297
|
*/
|
|
3736
4298
|
async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps,
|
|
3737
4299
|
/** Optional — lets the keyless-key guard below surface on the run's event stream. */
|
|
3738
|
-
keylessLogger
|
|
4300
|
+
keylessLogger,
|
|
4301
|
+
/** What the batch already proved about its destination rows, if anything. */
|
|
4302
|
+
precheck) {
|
|
3739
4303
|
const md = this.ProviderToUse;
|
|
3740
4304
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
4305
|
+
this.enrolInWriteGroup(entity);
|
|
3741
4306
|
const entityInfo = md.EntityByName(record.MJEntityName);
|
|
3742
4307
|
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
3743
4308
|
// Upsert-safe: if the record's mapped fields carry a PK (soft-PK dest tables key on the external
|
|
@@ -3769,7 +4334,16 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3769
4334
|
console.error(`[IntegrationEngine] KEYLESS_RECORD_REFUSED: ${detail}`);
|
|
3770
4335
|
return 'skipped';
|
|
3771
4336
|
}
|
|
3772
|
-
|
|
4337
|
+
// The batch's prefetch may already have proved this row absent. When it did, the load below
|
|
4338
|
+
// is a SELECT * — every column including any NVARCHAR(MAX) — issued once per record, to
|
|
4339
|
+
// learn something the batch established in a single query. Skipping it is the whole point
|
|
4340
|
+
// of widening that prefetch to the create path.
|
|
4341
|
+
//
|
|
4342
|
+
// Only ever used to skip work when absence is PROVEN: the prefetch must have covered every
|
|
4343
|
+
// record in the batch, and this key must be missing from it. Anything less falls through to
|
|
4344
|
+
// the load, because a wrong "absent" turns an update into a duplicate insert.
|
|
4345
|
+
const provablyAbsent = this.isProvablyAbsent(mappedPK, precheck);
|
|
4346
|
+
const existed = mappedPK != null && !provablyAbsent
|
|
3773
4347
|
? await entity.InnerLoad(this.BuildEntityPrimaryKey(mappedPK, pkFields))
|
|
3774
4348
|
: false;
|
|
3775
4349
|
if (existed) {
|
|
@@ -3796,7 +4370,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3796
4370
|
// re-establish the possibly-cleared record map and SKIP the write — leaving __mj_UpdatedAt
|
|
3797
4371
|
// and the integration LastSynced columns untouched, exactly like the content-hash skip path.
|
|
3798
4372
|
this.SetEntityFields(entity, record.MappedFields);
|
|
3799
|
-
if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo)) {
|
|
4373
|
+
if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo, record)) {
|
|
3800
4374
|
await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
|
|
3801
4375
|
return 'skipped';
|
|
3802
4376
|
}
|
|
@@ -3808,7 +4382,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3808
4382
|
this.SetStandardIntegrationFields(entity, record);
|
|
3809
4383
|
// A5: Pre-write validation
|
|
3810
4384
|
this.validateEntity(entity, record.MJEntityName);
|
|
3811
|
-
const saved = await entity.Save();
|
|
4385
|
+
const saved = await entity.Save(this.syncSaveOptions);
|
|
3812
4386
|
if (!saved) {
|
|
3813
4387
|
const errMsg = entity.LatestResult?.CompleteMessage ?? 'unknown error';
|
|
3814
4388
|
const schemaErr = detectSchemaNotGenerated(record.MJEntityName, errMsg);
|
|
@@ -3822,6 +4396,17 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3822
4396
|
// incremental sync. SaveRecordMap is an upsert keyed on (CompanyIntegration, Entity, ExternalID),
|
|
3823
4397
|
// so this also re-establishes a map that was previously cleared.
|
|
3824
4398
|
const entityRecordID = entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|');
|
|
4399
|
+
// The prefetch's absence proof is only true until this process inserts the row. A mid-batch
|
|
4400
|
+
// flush (MJ_INTEGRATION_BATCH_FLUSH_AT) COMMITS part of a batch; if a later record then fails,
|
|
4401
|
+
// the per-record fallback re-applies the whole batch against the SAME precheck — and the
|
|
4402
|
+
// committed rows' keys, honestly absent at prefetch time, would still "prove" absent and
|
|
4403
|
+
// insert again. Recording the key the moment we create keeps the proof truthful for any
|
|
4404
|
+
// replay in this run. Deliberately unconditional on commit outcome: if the group later rolls
|
|
4405
|
+
// back, an over-included key merely costs that record one existence load on retry — while an
|
|
4406
|
+
// under-included key costs a duplicate row. Only ever err toward the load.
|
|
4407
|
+
if (!existed && mappedPK != null && precheck) {
|
|
4408
|
+
precheck.Present.add(mappedPK);
|
|
4409
|
+
}
|
|
3825
4410
|
await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entityRecordID, contextUser);
|
|
3826
4411
|
return existed ? 'updated' : 'created';
|
|
3827
4412
|
}
|
|
@@ -3849,6 +4434,23 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3849
4434
|
}
|
|
3850
4435
|
return values.join('|');
|
|
3851
4436
|
}
|
|
4437
|
+
/**
|
|
4438
|
+
* True ONLY when the batch prefetch PROVED this record's row does not exist: the prefetch covered
|
|
4439
|
+
* every record in the batch AND this key is missing from the rows it found. Anything less is
|
|
4440
|
+
* "unknown", and unknown must load — a wrong "absent" turns an update into a duplicate INSERT.
|
|
4441
|
+
*
|
|
4442
|
+
* `mappedPK` must be the '|'-joined key {@link extractMappedPrimaryKey} returns — the SAME shape
|
|
4443
|
+
* {@link PrefetchContentHashes} keys `Present` with (`pkNames.map(n => row[n] ?? '').join('|')`).
|
|
4444
|
+
* The first version of this check re-derived a key by indexing that string with PK field names,
|
|
4445
|
+
* which evaluates to `''` for every record — so "provably absent" was unconditionally true and
|
|
4446
|
+
* every upsert of an existing row became a blind duplicate INSERT. Kept as its own method so the
|
|
4447
|
+
* decision is testable against the real extractor's output rather than a re-implementation.
|
|
4448
|
+
*/
|
|
4449
|
+
isProvablyAbsent(mappedPK, precheck) {
|
|
4450
|
+
return mappedPK != null
|
|
4451
|
+
&& precheck?.CoversWholeBatch === true
|
|
4452
|
+
&& !precheck.Present.has(mappedPK);
|
|
4453
|
+
}
|
|
3852
4454
|
/**
|
|
3853
4455
|
* Updates an existing MJ record with pre-write validation.
|
|
3854
4456
|
* If the record cannot be loaded (e.g. it was deleted or never fully created),
|
|
@@ -3859,7 +4461,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3859
4461
|
logger) {
|
|
3860
4462
|
if (!record.MatchedMJRecordID) {
|
|
3861
4463
|
// 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);
|
|
4464
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
|
|
3863
4465
|
if (outcome === 'updated')
|
|
3864
4466
|
result.RecordsUpdated++;
|
|
3865
4467
|
else if (outcome === 'skipped')
|
|
@@ -3874,7 +4476,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3874
4476
|
// unchanged — skip the per-record DB load AND the write. The dirty-flag check
|
|
3875
4477
|
// below is the fallback for entities without the hash column.
|
|
3876
4478
|
if (precheckHashes) {
|
|
3877
|
-
const stored = precheckHashes.get(record.MatchedMJRecordID);
|
|
4479
|
+
const stored = precheckHashes.Hashes.get(record.MatchedMJRecordID);
|
|
3878
4480
|
if (stored && stored === computeContentHash(record.MappedFields ?? {})) {
|
|
3879
4481
|
result.RecordsSkipped++;
|
|
3880
4482
|
// Re-establish the external↔MJ record map even on the content-hash skip. A record can
|
|
@@ -3902,12 +4504,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3902
4504
|
}
|
|
3903
4505
|
const md = this.ProviderToUse;
|
|
3904
4506
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
4507
|
+
this.enrolInWriteGroup(entity);
|
|
3905
4508
|
const entityInfo = md.EntityByName(record.MJEntityName);
|
|
3906
4509
|
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
3907
4510
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
|
|
3908
4511
|
if (!loaded) {
|
|
3909
4512
|
// 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);
|
|
4513
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
|
|
3911
4514
|
if (outcome === 'updated')
|
|
3912
4515
|
result.RecordsUpdated++;
|
|
3913
4516
|
else if (outcome === 'skipped')
|
|
@@ -3929,7 +4532,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3929
4532
|
// Uses MJ's built-in dirty tracking (zero custom comparison logic). Critical for
|
|
3930
4533
|
// connectors without server-side date filtering (e.g., YM) where every sync re-fetches
|
|
3931
4534
|
// all records. Without this, 50k+ records get re-written every run.
|
|
3932
|
-
if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo)) {
|
|
4535
|
+
if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo, record)) {
|
|
3933
4536
|
result.RecordsSkipped++;
|
|
3934
4537
|
// Re-establish the record map even when the write is skipped — see the content-hash skip
|
|
3935
4538
|
// above for the full rationale (a key-field/PK match can land here with no map row, and
|
|
@@ -3946,7 +4549,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3946
4549
|
this.SetStandardIntegrationFields(entity, record);
|
|
3947
4550
|
// A5: Pre-write validation
|
|
3948
4551
|
this.validateEntity(entity, record.MJEntityName);
|
|
3949
|
-
const saved = await entity.Save();
|
|
4552
|
+
const saved = await entity.Save(this.syncSaveOptions);
|
|
3950
4553
|
if (!saved) {
|
|
3951
4554
|
const errMsg = entity.LatestResult?.CompleteMessage ?? 'unknown error';
|
|
3952
4555
|
const schemaErr = detectSchemaNotGenerated(record.MJEntityName, errMsg);
|
|
@@ -3973,19 +4576,39 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3973
4576
|
* - the read fails (best-effort — a logging/optimization read must never break a sync).
|
|
3974
4577
|
*/
|
|
3975
4578
|
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
4579
|
const entityName = batch[0].MJEntityName;
|
|
3981
4580
|
const entityInfo = this.ProviderToUse.EntityByName(entityName);
|
|
3982
4581
|
if (!entityInfo)
|
|
3983
4582
|
return undefined;
|
|
3984
|
-
if (!entityInfo.Fields
|
|
4583
|
+
if (!entityInfo.Fields?.some(f => f.Name === CONTENT_HASH_COLUMN))
|
|
3985
4584
|
return undefined;
|
|
3986
4585
|
const pkFields = entityInfo.PrimaryKeys ?? [];
|
|
3987
4586
|
if (pkFields.length === 0)
|
|
3988
4587
|
return undefined;
|
|
4588
|
+
// Matched rows contribute the key the matcher already resolved. Unmatched rows contribute
|
|
4589
|
+
// the key their MAPPED FIELDS carry (soft-PK tables key on the external id), which is the
|
|
4590
|
+
// same key CreateRecord is about to probe for one at a time. Asking for all of them in the
|
|
4591
|
+
// one query we are already issuing is what lets that per-record probe be skipped.
|
|
4592
|
+
const wanted = new Set();
|
|
4593
|
+
let everyRecordCovered = true;
|
|
4594
|
+
for (const r of batch) {
|
|
4595
|
+
if (r.ChangeType === 'Update' && r.MatchedMJRecordID) {
|
|
4596
|
+
wanted.add(r.MatchedMJRecordID);
|
|
4597
|
+
continue;
|
|
4598
|
+
}
|
|
4599
|
+
const mappedPK = this.extractMappedPrimaryKey(r, pkFields);
|
|
4600
|
+
if (mappedPK == null) {
|
|
4601
|
+
// A destination-generated key (identity / server-assigned UUID) cannot be known
|
|
4602
|
+
// before the insert, so this record's existence is genuinely unknowable here.
|
|
4603
|
+
everyRecordCovered = false;
|
|
4604
|
+
continue;
|
|
4605
|
+
}
|
|
4606
|
+
// Already the '|'-joined key in pkFields order — add it as-is (see extractMappedPrimaryKey).
|
|
4607
|
+
wanted.add(mappedPK);
|
|
4608
|
+
}
|
|
4609
|
+
const ids = Array.from(wanted);
|
|
4610
|
+
if (ids.length === 0)
|
|
4611
|
+
return undefined;
|
|
3989
4612
|
// Map keys must match `record.MatchedMJRecordID`, which is the PK value(s) joined by '|' in
|
|
3990
4613
|
// PrimaryKeys order (single value for single-PK, "v1|v2" for composite — see MatchEngine).
|
|
3991
4614
|
const pkNames = pkFields.map(f => f.Name);
|
|
@@ -4003,19 +4626,32 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4003
4626
|
Fields: [...pkNames, CONTENT_HASH_COLUMN],
|
|
4004
4627
|
ExtraFilter: extraFilter,
|
|
4005
4628
|
ResultType: 'simple',
|
|
4629
|
+
// A plain RunView is NOT unbounded — it falls back to the entity's UserViewMaxRows
|
|
4630
|
+
// (default 1000). This result is what `CoversWholeBatch` absence proofs are judged
|
|
4631
|
+
// against, and coverage is computed from the REQUEST side, never reconciled with
|
|
4632
|
+
// res.Results.length: a silently truncated response would mark every existing row
|
|
4633
|
+
// beyond the cap "provably absent" and re-INSERT it as a duplicate on every sync.
|
|
4634
|
+
// Today the apply batch (500) sits under the default cap, so nothing fires — but a
|
|
4635
|
+
// 2x margin defended by nothing is not a guard. Same reasoning as baseEngine's own
|
|
4636
|
+
// IgnoreMaxRows use, and this file documents the identical trap on the push side.
|
|
4637
|
+
IgnoreMaxRows: true,
|
|
4006
4638
|
}, contextUser);
|
|
4007
4639
|
if (!res.Success)
|
|
4008
4640
|
return undefined;
|
|
4009
|
-
const
|
|
4641
|
+
const Hashes = new Map();
|
|
4642
|
+
const Present = new Set();
|
|
4010
4643
|
for (const row of res.Results) {
|
|
4011
4644
|
// Re-key by the same '|'-join the matcher produced, so the lookup in ApplySingleRecord hits.
|
|
4012
4645
|
const key = pkNames.map(n => row[n] ?? '').join('|');
|
|
4646
|
+
// Presence is recorded for EVERY returned row. A row whose hash is NULL still exists,
|
|
4647
|
+
// and treating "no hash" as "no row" would turn an update into a duplicate insert.
|
|
4648
|
+
Present.add(key);
|
|
4013
4649
|
const hash = row[CONTENT_HASH_COLUMN];
|
|
4014
4650
|
if (typeof hash === 'string' && hash.length > 0) {
|
|
4015
|
-
|
|
4651
|
+
Hashes.set(key, hash);
|
|
4016
4652
|
}
|
|
4017
4653
|
}
|
|
4018
|
-
return
|
|
4654
|
+
return { Hashes, Present, CoversWholeBatch: everyRecordCovered };
|
|
4019
4655
|
}
|
|
4020
4656
|
catch (err) {
|
|
4021
4657
|
// MJ#3047 lesson: this best-effort catch was SILENT, so a failing prefetch (e.g. a reserved-word
|
|
@@ -4051,6 +4687,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4051
4687
|
return false;
|
|
4052
4688
|
const md = this.ProviderToUse;
|
|
4053
4689
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
4690
|
+
this.enrolInWriteGroup(entity);
|
|
4054
4691
|
const entityInfo = md.EntityByName(record.MJEntityName);
|
|
4055
4692
|
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
4056
4693
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
|
|
@@ -4074,14 +4711,14 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4074
4711
|
entity.Set('__mj_integration_IsTombstoned', true);
|
|
4075
4712
|
if (hasField('__mj_integration_DeletedDetectedAt'))
|
|
4076
4713
|
entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
|
|
4077
|
-
const archived = await entity.Save();
|
|
4714
|
+
const archived = await entity.Save(this.syncSaveOptions);
|
|
4078
4715
|
if (!archived) {
|
|
4079
4716
|
const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
|
|
4080
4717
|
console.warn(`[IntegrationEngine] Soft-delete (archive) failed for ${record.MJEntityName} ${record.MatchedMJRecordID} — ${reason}`);
|
|
4081
4718
|
}
|
|
4082
4719
|
return archived;
|
|
4083
4720
|
}
|
|
4084
|
-
const deleted = await entity.Delete();
|
|
4721
|
+
const deleted = await entity.Delete(this.syncDeleteOptions);
|
|
4085
4722
|
if (!deleted) {
|
|
4086
4723
|
const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
|
|
4087
4724
|
console.warn(`[IntegrationEngine] Delete blocked for ${record.MJEntityName} ${record.MatchedMJRecordID} — ${reason}`);
|
|
@@ -4291,7 +4928,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4291
4928
|
* runtime-created target tables have no generated entity type, which is why the engine
|
|
4292
4929
|
* already `.Set()`s the very same columns in SetStandardIntegrationFields.
|
|
4293
4930
|
*/
|
|
4294
|
-
needsSyncStateRepair(entity, entityInfo) {
|
|
4931
|
+
needsSyncStateRepair(entity, entityInfo, record) {
|
|
4295
4932
|
if (!entityInfo)
|
|
4296
4933
|
return false;
|
|
4297
4934
|
const has = (name) => entityInfo.Fields.some(f => f.Name === name);
|
|
@@ -4301,6 +4938,27 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4301
4938
|
return true;
|
|
4302
4939
|
if (has('__mj_integration_SyncStatus') && entity.Get('__mj_integration_SyncStatus') !== 'Active')
|
|
4303
4940
|
return true;
|
|
4941
|
+
// A STALE CONTENT HASH is repair-worthy for the same reason: skipping the write freezes it.
|
|
4942
|
+
//
|
|
4943
|
+
// The case that produces one is a source that stops sending a column. The mapper OMITS an
|
|
4944
|
+
// absent key rather than mapping it to null (a missing value is not a null value), so the
|
|
4945
|
+
// recomputed hash differs — but SetEntityFields never touches that column either, so the
|
|
4946
|
+
// entity is NOT dirty and the skip above fires. The stored hash is therefore never refreshed
|
|
4947
|
+
// and the mismatch is permanent: that row loses the content-hash fast path FOREVER, paying a
|
|
4948
|
+
// full load and field-by-field compare on every sync until some other field happens to
|
|
4949
|
+
// change. One repair write here re-converges it, and every later sync skips it cheaply.
|
|
4950
|
+
//
|
|
4951
|
+
// Deliberately NOT treated as "the column is gone" — absence in the data is not evidence of
|
|
4952
|
+
// absence in the schema (§ the same rule the field-level deactivation follows). The value is
|
|
4953
|
+
// left exactly as it is; only the hash is brought back in line with what we are actually
|
|
4954
|
+
// mapping.
|
|
4955
|
+
if (record && has(CONTENT_HASH_COLUMN)) {
|
|
4956
|
+
const storedHash = entity.Get(CONTENT_HASH_COLUMN);
|
|
4957
|
+
if (typeof storedHash === 'string' && storedHash.length > 0
|
|
4958
|
+
&& storedHash !== computeContentHash(record.MappedFields ?? {})) {
|
|
4959
|
+
return true;
|
|
4960
|
+
}
|
|
4961
|
+
}
|
|
4304
4962
|
return false;
|
|
4305
4963
|
}
|
|
4306
4964
|
/**
|