@memberjunction/integration-engine 6.1.0-edge.2 → 6.1.0-edge.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +180 -4
- package/dist/BaseIntegrationConnector.d.ts +115 -4
- package/dist/BaseIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseIntegrationConnector.js +191 -24
- package/dist/BaseIntegrationConnector.js.map +1 -1
- package/dist/BaseRESTIntegrationConnector.d.ts +14 -1
- package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseRESTIntegrationConnector.js +50 -3
- package/dist/BaseRESTIntegrationConnector.js.map +1 -1
- package/dist/BatchIdentity.d.ts +47 -0
- package/dist/BatchIdentity.d.ts.map +1 -0
- package/dist/BatchIdentity.js +78 -0
- package/dist/BatchIdentity.js.map +1 -0
- package/dist/DiscoveryWatchdog.d.ts +103 -0
- package/dist/DiscoveryWatchdog.d.ts.map +1 -0
- package/dist/DiscoveryWatchdog.js +166 -0
- package/dist/DiscoveryWatchdog.js.map +1 -0
- package/dist/FieldMapValidation.d.ts +40 -0
- package/dist/FieldMapValidation.d.ts.map +1 -0
- package/dist/FieldMapValidation.js +58 -0
- package/dist/FieldMapValidation.js.map +1 -0
- package/dist/FieldMappingEngine.d.ts +1 -13
- package/dist/FieldMappingEngine.d.ts.map +1 -1
- package/dist/FieldMappingEngine.js +18 -3
- package/dist/FieldMappingEngine.js.map +1 -1
- package/dist/IntegrationConnectorCreationPipeline.d.ts +92 -1
- package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -1
- package/dist/IntegrationConnectorCreationPipeline.js +186 -11
- package/dist/IntegrationConnectorCreationPipeline.js.map +1 -1
- package/dist/IntegrationEngine.d.ts +198 -21
- package/dist/IntegrationEngine.d.ts.map +1 -1
- package/dist/IntegrationEngine.js +1092 -165
- package/dist/IntegrationEngine.js.map +1 -1
- package/dist/IntegrationSchemaSync.d.ts +8 -0
- package/dist/IntegrationSchemaSync.d.ts.map +1 -1
- package/dist/IntegrationSchemaSync.js +39 -2
- package/dist/IntegrationSchemaSync.js.map +1 -1
- package/dist/KeylessRecordGuard.d.ts +56 -0
- package/dist/KeylessRecordGuard.d.ts.map +1 -0
- package/dist/KeylessRecordGuard.js +80 -0
- package/dist/KeylessRecordGuard.js.map +1 -0
- package/dist/ResumeConcurrency.d.ts +46 -0
- package/dist/ResumeConcurrency.d.ts.map +1 -0
- package/dist/ResumeConcurrency.js +74 -0
- package/dist/ResumeConcurrency.js.map +1 -0
- package/dist/RetryAfter.d.ts +50 -0
- package/dist/RetryAfter.d.ts.map +1 -0
- package/dist/RetryAfter.js +127 -0
- package/dist/RetryAfter.js.map +1 -0
- package/dist/RetryRunner.d.ts +26 -2
- package/dist/RetryRunner.d.ts.map +1 -1
- package/dist/RetryRunner.js +11 -3
- package/dist/RetryRunner.js.map +1 -1
- package/dist/RunOwnershipService.d.ts +177 -0
- package/dist/RunOwnershipService.d.ts.map +1 -0
- package/dist/RunOwnershipService.js +279 -0
- package/dist/RunOwnershipService.js.map +1 -0
- package/dist/SyncDirectives.d.ts +66 -0
- package/dist/SyncDirectives.d.ts.map +1 -0
- package/dist/SyncDirectives.js +123 -0
- package/dist/SyncDirectives.js.map +1 -0
- package/dist/SyncLogger.d.ts +1 -1
- package/dist/SyncLogger.d.ts.map +1 -1
- package/dist/SyncLogger.js.map +1 -1
- package/dist/WatermarkService.d.ts +8 -0
- package/dist/WatermarkService.d.ts.map +1 -1
- package/dist/WatermarkService.js +18 -0
- package/dist/WatermarkService.js.map +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +66 -1
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
1
2
|
import { CompositeKey, DatabaseProviderBase, LogStatusEx, Metadata, RunView } from '@memberjunction/core';
|
|
3
|
+
import { RunOwnershipLostError, RunOwnershipService } from './RunOwnershipService.js';
|
|
2
4
|
import { BaseSingleton, UUIDsEqual } from '@memberjunction/global';
|
|
3
5
|
import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
|
|
4
6
|
import { ClassifyError, IsRetryableError } from './types.js';
|
|
5
7
|
import { WithRetry } from './RetryRunner.js';
|
|
6
|
-
import {
|
|
8
|
+
import { DecideKeylessRefusal, DescribeKeylessRefusal, MissingKeyFieldNames } from './KeylessRecordGuard.js';
|
|
9
|
+
import { WithTimeout, OperationTimeoutError, DEFAULT_OPERATION_TIMEOUTS } from './BaseIntegrationConnector.js';
|
|
7
10
|
import { ConnectorFactory } from './ConnectorFactory.js';
|
|
8
11
|
import { FieldMappingEngine } from './FieldMappingEngine.js';
|
|
9
12
|
import { MatchEngine } from './MatchEngine.js';
|
|
@@ -14,11 +17,15 @@ import { RecordMapBatch } from './RecordMapBatch.js';
|
|
|
14
17
|
import { buildContentHashPrefetchFilter, quoteTextLiteral } from './prefetchFilter.js';
|
|
15
18
|
import { serializeKeyValue } from './KeySerialization.js';
|
|
16
19
|
import { CUSTOM_OVERFLOW_COLUMN, reconcileOverflowValue, foldCustomKeyStats } from './CustomOverflow.js';
|
|
20
|
+
import { ComputeExcludedSourceNames } from './SyncDirectives.js';
|
|
21
|
+
import { DescribeUnbindableFieldMaps, FindUnbindableFieldMaps } from './FieldMapValidation.js';
|
|
17
22
|
import { partitionRecords, partitionRollupHash, diffPartitions, partitionKeyForIdentity } from './HashDiff.js';
|
|
18
23
|
import { RateLimiter } from './RateLimiter.js';
|
|
19
24
|
import { AdaptiveConcurrencyController, RunAdaptive } from './AdaptiveConcurrency.js';
|
|
20
25
|
import { mostRecentWinner } from './ConflictRecency.js';
|
|
21
26
|
import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
|
|
27
|
+
import { CollapseDuplicateIdentities } from './BatchIdentity.js';
|
|
28
|
+
import { ResumeConcurrency, RunResumesBounded } from './ResumeConcurrency.js';
|
|
22
29
|
/** Default batch size for fetching records from external systems */
|
|
23
30
|
const DEFAULT_BATCH_SIZE = 200;
|
|
24
31
|
/**
|
|
@@ -151,22 +158,30 @@ function detectSchemaNotGenerated(entityName, errorMessage) {
|
|
|
151
158
|
return new SchemaNotGeneratedError(entityName, postgres[1]);
|
|
152
159
|
return null;
|
|
153
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Coerces an externally-supplied tuning value — a duration in ms, or a count — to a usable positive
|
|
163
|
+
* integer, or `undefined` when it is not one, so the caller's `??` chain falls through to the next
|
|
164
|
+
* source.
|
|
165
|
+
*
|
|
166
|
+
* Every source of these values is outside the engine's control: operator-authored JSON in
|
|
167
|
+
* `CompanyIntegration.Configuration`, and connector-authored properties like
|
|
168
|
+
* `BaseIntegrationConnector.FetchChangesTimeoutMs`, whose declared type (`number | null`) happily
|
|
169
|
+
* admits `0`, negatives and `NaN` — a connector computing one from an unset env var gets `NaN`
|
|
170
|
+
* without a type error. Handing any of those to `setTimeout` is silently catastrophic rather than
|
|
171
|
+
* loud: it coerces them to ~1ms, so every wrapped operation rejects immediately and the object
|
|
172
|
+
* syncs nothing. Guard BOTH sources, not just the JSON one.
|
|
173
|
+
*
|
|
174
|
+
* Exported so the guard itself is unit-testable without standing up a sync.
|
|
175
|
+
*/
|
|
176
|
+
export function PositiveInt(v) {
|
|
177
|
+
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined;
|
|
178
|
+
}
|
|
154
179
|
export class IntegrationEngine extends BaseSingleton {
|
|
155
180
|
constructor() {
|
|
156
181
|
super();
|
|
157
182
|
this.fieldMappingEngine = new FieldMappingEngine();
|
|
158
183
|
this.matchEngine = new MatchEngine();
|
|
159
184
|
this.watermarkService = new WatermarkService();
|
|
160
|
-
/**
|
|
161
|
-
* Per-engine async mutex serializing the DB-WRITE section across concurrently-synced streams.
|
|
162
|
-
* When a layer runs multiple entity maps in parallel (syncConcurrency > 1), they all share ONE
|
|
163
|
-
* provider connection whose transaction state is singular — so concurrent BeginTransaction /
|
|
164
|
-
* SavePoint / Commit calls corrupt each other ("Transaction has not begun", "Cannot roll back
|
|
165
|
-
* SavePoint"). The fetch phase stays parallel (the real throughput win — it's network-bound);
|
|
166
|
-
* only the per-batch write transaction is serialized through this lock. Keyed per engine
|
|
167
|
-
* instance, which owns the shared provider.
|
|
168
|
-
*/
|
|
169
|
-
this._writeChain = Promise.resolve();
|
|
170
185
|
/** Configurable maximum batch size. Connector batches exceeding this are truncated. */
|
|
171
186
|
this.MaxBatchSize = DEFAULT_BATCH_SIZE;
|
|
172
187
|
/** Per-integration request-spacing chain for the rate limiter (keyed by IntegrationID → last scheduled time). */
|
|
@@ -216,13 +231,26 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
216
231
|
}
|
|
217
232
|
return raw;
|
|
218
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* Per-run execution context (PR 1 item 7). Each run carries its OWN provider,
|
|
236
|
+
* ownership service and abort plumbing through the async call chain — there is
|
|
237
|
+
* deliberately NO shared `_provider` instance field, because a last-writer-wins
|
|
238
|
+
* provider was the reason concurrent runs could write through each other's
|
|
239
|
+
* connection. AsyncLocalStorage propagates the context to every helper the run
|
|
240
|
+
* calls without threading a parameter through ~20 private signatures.
|
|
241
|
+
*/
|
|
242
|
+
static { this.runContext = new AsyncLocalStorage(); }
|
|
243
|
+
/** The current run's context, when called from inside a sync run. */
|
|
244
|
+
get currentRunContext() {
|
|
245
|
+
return IntegrationEngine.runContext.getStore();
|
|
246
|
+
}
|
|
219
247
|
/** Registers (or clears, with undefined) the post-sync custom-column promotion hook. */
|
|
220
248
|
SetPostSyncSchemaPromotionCallback(callback) {
|
|
221
249
|
this.postSyncSchemaPromotionCallback = callback;
|
|
222
250
|
}
|
|
223
|
-
/** Returns the active provider —
|
|
251
|
+
/** Returns the active provider — the current run's own provider when inside a run, otherwise the global default. */
|
|
224
252
|
get ProviderToUse() {
|
|
225
|
-
return this.
|
|
253
|
+
return this.currentRunContext?.provider ?? Metadata.Provider;
|
|
226
254
|
}
|
|
227
255
|
/** In-process lock map to prevent concurrent syncs for the same CompanyIntegration */
|
|
228
256
|
static { this.activeSyncs = new Map(); }
|
|
@@ -256,35 +284,195 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
256
284
|
static GetMaintenanceLock(companyIntegrationID) {
|
|
257
285
|
return IntegrationEngine.maintenanceLocks.get(companyIntegrationID.toLowerCase());
|
|
258
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* Async mutexes serializing the DB-WRITE section, keyed PER PROVIDER (PR 1 item 7 —
|
|
289
|
+
* per-run write chains). A provider connection's transaction state is singular, so
|
|
290
|
+
* concurrent BeginTransaction / SavePoint / Commit calls through the SAME provider
|
|
291
|
+
* corrupt each other ("Transaction has not begun", "Cannot roll back SavePoint") —
|
|
292
|
+
* whether the writers are parallel streams within one run OR two different runs that
|
|
293
|
+
* happen to share a provider instance. Keying the mutex on the provider makes the
|
|
294
|
+
* serialization boundary exactly the hazard boundary: runs on their own providers
|
|
295
|
+
* write fully in parallel; anything sharing a connection is serialized. The fetch
|
|
296
|
+
* phase stays parallel (the real throughput win — it's network-bound). WeakMap so a
|
|
297
|
+
* retired provider's chain entry is collectable.
|
|
298
|
+
*/
|
|
299
|
+
static { this.writeChains = new WeakMap(); }
|
|
259
300
|
runWriteExclusive(fn) {
|
|
301
|
+
const provider = this.ProviderToUse;
|
|
302
|
+
let holder = IntegrationEngine.writeChains.get(provider);
|
|
303
|
+
if (!holder) {
|
|
304
|
+
holder = { chain: Promise.resolve() };
|
|
305
|
+
IntegrationEngine.writeChains.set(provider, holder);
|
|
306
|
+
}
|
|
260
307
|
// Run fn after the prior write completes (whether it resolved or rejected); keep the chain
|
|
261
308
|
// alive past failures so one errored batch never deadlocks subsequent writers.
|
|
262
|
-
const run =
|
|
263
|
-
|
|
309
|
+
const run = holder.chain.then(() => fn(), () => fn());
|
|
310
|
+
holder.chain = run.then(() => undefined, () => undefined);
|
|
264
311
|
return run;
|
|
265
312
|
}
|
|
266
|
-
/**
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
313
|
+
/**
|
|
314
|
+
* Fence check at a batch boundary, BEFORE any write (PR 1 item 3). One SELECT of the run's
|
|
315
|
+
* ownership columns: if the fence has moved (the stale sweep or another worker reclaimed the
|
|
316
|
+
* run) throw RunOwnershipLostError so the loop aborts WITHOUT writing this batch; if a
|
|
317
|
+
* cross-process cancel was requested, trip the run's abort controller so the loop winds down
|
|
318
|
+
* through the normal cancel path. A caller with no ownership context (e.g. unit tests driving
|
|
319
|
+
* internals directly) is a no-op.
|
|
320
|
+
*/
|
|
321
|
+
async assertOwnershipAtBoundary() {
|
|
322
|
+
const ctx = this.currentRunContext;
|
|
323
|
+
if (!ctx?.ownership)
|
|
324
|
+
return;
|
|
325
|
+
const check = await ctx.ownership.CheckBoundary();
|
|
326
|
+
if (!check.Owned) {
|
|
327
|
+
ctx.ownershipLost = true;
|
|
328
|
+
ctx.abortController.abort();
|
|
329
|
+
throw new RunOwnershipLostError(ctx.ownership.RunID, 'fence moved at batch boundary — run was reclaimed by another process');
|
|
330
|
+
}
|
|
331
|
+
if (check.CancelRequested && !ctx.cancelRequested) {
|
|
332
|
+
ctx.cancelRequested = true;
|
|
333
|
+
ctx.abortController.abort();
|
|
334
|
+
}
|
|
273
335
|
}
|
|
274
|
-
/**
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
return
|
|
336
|
+
/**
|
|
337
|
+
* One-time-per-method deprecation notice. Repeating it on every call would flood the
|
|
338
|
+
* log of a caller that polls progress on a timer, which is the usual usage.
|
|
339
|
+
*/
|
|
340
|
+
static { this._deprecationNoticesEmitted = new Set(); }
|
|
341
|
+
static noteDeprecated(oldName, replacement) {
|
|
342
|
+
if (IntegrationEngine._deprecationNoticesEmitted.has(oldName)) {
|
|
343
|
+
return;
|
|
282
344
|
}
|
|
345
|
+
IntegrationEngine._deprecationNoticesEmitted.add(oldName);
|
|
346
|
+
console.warn(`IntegrationEngine.${oldName}() is deprecated and no longer functional: sync progress and ` +
|
|
347
|
+
`cancellation now live on the CompanyIntegrationRun row so they are visible across processes, ` +
|
|
348
|
+
`and the in-process map this method read no longer exists. Use ${replacement}() instead.`);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* @deprecated Superseded by {@link IntegrationEngine.GetSyncProgressAsync}, which reads
|
|
352
|
+
* the run row and therefore sees runs owned by ANY process.
|
|
353
|
+
*
|
|
354
|
+
* Retained with its original signature so a published consumer does not break on a
|
|
355
|
+
* minor upgrade. It cannot be made to work: the static map it used to read was removed
|
|
356
|
+
* when progress moved to the database, and a synchronous method cannot query it. It
|
|
357
|
+
* returns `undefined` — the same value it returned when no run was in progress — and
|
|
358
|
+
* logs once explaining the replacement.
|
|
359
|
+
*/
|
|
360
|
+
static GetSyncProgress(_companyIntegrationID) {
|
|
361
|
+
IntegrationEngine.noteDeprecated('GetSyncProgress', 'GetSyncProgressAsync');
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* @deprecated Superseded by {@link IntegrationEngine.CancelSyncAsync}, which records the
|
|
366
|
+
* cancel on the run row so the owning process observes it at its next batch boundary.
|
|
367
|
+
*
|
|
368
|
+
* Retained with its original signature for published consumers. Returns `false` —
|
|
369
|
+
* truthfully reporting that it cancelled nothing — rather than pretending to succeed,
|
|
370
|
+
* so a caller branching on the result is not silently misled.
|
|
371
|
+
*/
|
|
372
|
+
static CancelSync(_companyIntegrationID) {
|
|
373
|
+
IntegrationEngine.noteDeprecated('CancelSync', 'CancelSyncAsync');
|
|
283
374
|
return false;
|
|
284
375
|
}
|
|
285
|
-
/**
|
|
376
|
+
/**
|
|
377
|
+
* @deprecated No direct replacement. Query `MJ: Company Integration Runs` for rows whose
|
|
378
|
+
* Status is `In Progress` or `Queued` and read their `ProgressJSON`, which is what
|
|
379
|
+
* {@link IntegrationEngine.GetSyncProgressAsync} does for a single connector.
|
|
380
|
+
*
|
|
381
|
+
* Retained with its original signature for published consumers; returns an empty map.
|
|
382
|
+
*/
|
|
286
383
|
static GetAllSyncProgress() {
|
|
287
|
-
|
|
384
|
+
IntegrationEngine.noteDeprecated('GetAllSyncProgress', 'GetSyncProgressAsync');
|
|
385
|
+
return new Map();
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Read current sync progress for a connector FROM THE DATABASE (PR 1 item 4 —
|
|
389
|
+
* progress lives on the run row, so it is visible from ANY process, not just the
|
|
390
|
+
* one executing the sync). Returns undefined when no live run exists. A run is
|
|
391
|
+
* "live" when Status is In Progress/Queued AND its lease has not expired — an
|
|
392
|
+
* expired lease means the owner died and the snapshot is stale history, not
|
|
393
|
+
* progress.
|
|
394
|
+
*/
|
|
395
|
+
static async GetSyncProgressAsync(companyIntegrationID, contextUser, provider) {
|
|
396
|
+
// Server-side providers are DatabaseProviderBase (which implements IRunViewProvider);
|
|
397
|
+
// same narrowing the engine uses for the ownership sprocs.
|
|
398
|
+
const rv = new RunView(provider);
|
|
399
|
+
const result = await rv.RunView({
|
|
400
|
+
EntityName: 'MJ: Company Integration Runs',
|
|
401
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID.replace(/'/g, "''")}' AND Status IN ('In Progress','Queued')`,
|
|
402
|
+
OrderBy: 'StartedAt DESC',
|
|
403
|
+
Fields: ['ProgressJSON', 'LeaseExpiresAt', 'StartedAt'],
|
|
404
|
+
MaxRows: 1,
|
|
405
|
+
ResultType: 'simple',
|
|
406
|
+
BypassCache: true, // live liveness/progress read — must see the current row
|
|
407
|
+
}, contextUser);
|
|
408
|
+
const row = result.Success ? result.Results?.[0] : undefined;
|
|
409
|
+
if (!row)
|
|
410
|
+
return undefined;
|
|
411
|
+
if (row.LeaseExpiresAt != null && new Date(row.LeaseExpiresAt).getTime() < Date.now()) {
|
|
412
|
+
return undefined; // owner's lease lapsed — not live progress
|
|
413
|
+
}
|
|
414
|
+
if (!row.ProgressJSON)
|
|
415
|
+
return undefined;
|
|
416
|
+
try {
|
|
417
|
+
const snapshot = JSON.parse(row.ProgressJSON);
|
|
418
|
+
return { ...snapshot, StartedAt: new Date(snapshot.StartedAt) };
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return undefined; // corrupt snapshot — treat as no progress rather than throwing at a poller
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Request cancellation of a running (or queued) sync by stamping
|
|
426
|
+
* CancelRequestedAt on the live run row (PR 1 item 4 — the DATABASE is the single
|
|
427
|
+
* source of cancellation truth, so a cancel issued in ANY process reaches the
|
|
428
|
+
* owner). The owner observes the stamp at its next batch boundary / lease renewal
|
|
429
|
+
* and stops after the current batch. Returns true when a live run row was stamped.
|
|
430
|
+
*/
|
|
431
|
+
static async CancelSyncAsync(companyIntegrationID, contextUser, provider) {
|
|
432
|
+
const p = (provider ?? Metadata.Provider);
|
|
433
|
+
const d = p.Dialect;
|
|
434
|
+
const schema = d.QuoteIdentifier(p.MJCoreSchemaName);
|
|
435
|
+
const table = d.QuoteIdentifier('CompanyIntegrationRun');
|
|
436
|
+
const ciCol = d.QuoteIdentifier('CompanyIntegrationID');
|
|
437
|
+
const statusCol = d.QuoteIdentifier('Status');
|
|
438
|
+
const cancelCol = d.QuoteIdentifier('CancelRequestedAt');
|
|
439
|
+
const placeholder = p.BuildParameterPlaceholder(0);
|
|
440
|
+
// One UPDATE, no select-then-update: stamp every un-stamped live run for this CI.
|
|
441
|
+
const rows = await p.ExecuteSQL(`UPDATE ${schema}.${table} SET ${cancelCol} = ${d.CurrentTimestampUTC()} ` +
|
|
442
|
+
`WHERE ${ciCol} = ${placeholder} AND ${statusCol} IN ('In Progress','Queued') AND ${cancelCol} IS NULL`, [companyIntegrationID], { isMutation: true, description: 'CancelSync — stamp CancelRequestedAt' }, contextUser);
|
|
443
|
+
console.log(`[IntegrationEngine] Cancel requested for ${companyIntegrationID} (DB stamp)`);
|
|
444
|
+
// ExecuteSQL result shape for UPDATE differs per driver; a thrown error is the failure
|
|
445
|
+
// signal. Verify via a cheap read so the caller gets an honest "was anything live?".
|
|
446
|
+
void rows;
|
|
447
|
+
const check = await p.ExecuteSQL(`SELECT COUNT(*) AS N FROM ${schema}.${table} WHERE ${ciCol} = ${placeholder} ` +
|
|
448
|
+
`AND ${statusCol} IN ('In Progress','Queued') AND ${cancelCol} IS NOT NULL`, [companyIntegrationID], { isMutation: false, description: 'CancelSync — verify stamp' }, contextUser);
|
|
449
|
+
return Number(check?.[0]?.N ?? 0) > 0;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Worker-mode poll (PR 1 item 8): the oldest claimable `Queued` runs. A row is claimable
|
|
453
|
+
* when it is unowned or its lease has lapsed — a Queued row with a LIVE lease is being
|
|
454
|
+
* started by another worker right now and must not be returned. This is only a
|
|
455
|
+
* *candidate* list; {@link ExecuteQueuedRun}'s atomic claim is what actually grants
|
|
456
|
+
* exclusivity, so two workers polling simultaneously is safe by construction.
|
|
457
|
+
*/
|
|
458
|
+
static async PollQueuedRuns(contextUser, maxRows, provider) {
|
|
459
|
+
const p = (provider ?? Metadata.Provider);
|
|
460
|
+
const now = p.Dialect.CurrentTimestampUTC();
|
|
461
|
+
const rv = new RunView(p);
|
|
462
|
+
const result = await rv.RunView({
|
|
463
|
+
EntityName: 'MJ: Company Integration Runs',
|
|
464
|
+
ExtraFilter: `Status='Queued' AND (OwnerToken IS NULL OR LeaseExpiresAt IS NULL OR LeaseExpiresAt < ${now})`,
|
|
465
|
+
OrderBy: 'StartedAt ASC',
|
|
466
|
+
Fields: ['ID', 'CompanyIntegrationID'],
|
|
467
|
+
MaxRows: maxRows,
|
|
468
|
+
ResultType: 'simple',
|
|
469
|
+
BypassCache: true, // queue read — must see the current rows
|
|
470
|
+
}, contextUser);
|
|
471
|
+
if (!result.Success) {
|
|
472
|
+
console.warn(`[IntegrationEngine] Queue poll failed: ${result.ErrorMessage}`);
|
|
473
|
+
return [];
|
|
474
|
+
}
|
|
475
|
+
return result.Results ?? [];
|
|
288
476
|
}
|
|
289
477
|
/**
|
|
290
478
|
* U3 — pure, MONOTONIC progress fold: applies one per-map progress event to the live
|
|
@@ -306,13 +494,17 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
306
494
|
* Call this once during MJAPI startup after metadata is loaded.
|
|
307
495
|
*/
|
|
308
496
|
async ResumeOrphanedSyncs(contextUser, provider) {
|
|
309
|
-
|
|
310
|
-
this._provider = provider;
|
|
497
|
+
const prov = provider ?? Metadata.Provider;
|
|
311
498
|
await IntegrationEngineBase.Instance.Config(false, contextUser, provider);
|
|
499
|
+
// Liveness pre-screen (PR 1 item 6): an 'In Progress' run with a LIVE lease belongs to a
|
|
500
|
+
// healthy process — possibly another worker — and must not be adopted. Only unowned runs or
|
|
501
|
+
// runs whose lease has lapsed are orphan CANDIDATES. The claim sproc below re-evaluates the
|
|
502
|
+
// same predicate atomically, so this filter is a cheap pre-screen, not the correctness gate.
|
|
503
|
+
const dialect = prov.Dialect;
|
|
312
504
|
const rv = new RunView();
|
|
313
505
|
const orphanedRuns = await rv.RunView({
|
|
314
506
|
EntityName: 'MJ: Company Integration Runs',
|
|
315
|
-
ExtraFilter: `Status='In Progress'`,
|
|
507
|
+
ExtraFilter: `Status='In Progress' AND (OwnerToken IS NULL OR LeaseExpiresAt IS NULL OR LeaseExpiresAt < ${dialect.CurrentTimestampUTC()})`,
|
|
316
508
|
ResultType: 'entity_object',
|
|
317
509
|
BypassCache: true, // resume must see the live in-progress runs, not a stale cache
|
|
318
510
|
}, contextUser);
|
|
@@ -323,93 +515,209 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
323
515
|
return;
|
|
324
516
|
}
|
|
325
517
|
console.log(`[IntegrationEngine] Found ${orphanedRuns.Results.length} orphaned sync(s) to resume`);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
518
|
+
// CONCURRENTLY, because that is how these runs were STARTED.
|
|
519
|
+
//
|
|
520
|
+
// This loop used to `await` each resume in turn, which quietly converted a parallel
|
|
521
|
+
// workload into a queue ordered by whatever RunView happened to return. The slowest
|
|
522
|
+
// connector became a head-of-line block for every other connector in the workspace — and
|
|
523
|
+
// a connector that never finishes means the others never start at all.
|
|
524
|
+
//
|
|
525
|
+
// Observed live: a restart orphaned three syncs; one resumed and was still going five
|
|
526
|
+
// hours later, and the other two (99,463 and 13,238 rows) never began. Nothing in their
|
|
527
|
+
// logs said so, because nothing had failed — they had simply never been reached. From
|
|
528
|
+
// outside the process a queued run and a crashed one are identical: IsInFlight true,
|
|
529
|
+
// CompletedAt null, counters frozen at the instant of the restart. The absence of an
|
|
530
|
+
// error is the only tell.
|
|
531
|
+
//
|
|
532
|
+
// Note what is NOT being parallelised. The write section stays serialized by
|
|
533
|
+
// `runWriteExclusive`, because all maps share one provider connection with singular
|
|
534
|
+
// transaction state; that is deliberate and unchanged. Per-CompanyIntegration exclusion
|
|
535
|
+
// stays too, via the `activeSyncs` lock each resume takes. What overlaps here is what
|
|
536
|
+
// overlapped before the restart: different connectors waiting on different sources.
|
|
537
|
+
//
|
|
538
|
+
// Bounded rather than unbounded: a workspace is one Node process, so concurrency buys
|
|
539
|
+
// overlap on network waiting and not more CPU, and a boot that adopted fifty runs at once
|
|
540
|
+
// would trade one pathology for another.
|
|
541
|
+
await RunResumesBounded(orphanedRuns.Results, ResumeConcurrency(), run => this.ResumeOneOrphanedRun(run, prov, rv, contextUser));
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Resume ONE orphaned run, end to end: reserve the per-CompanyIntegration lock, claim the run,
|
|
545
|
+
* work out which entity maps already finished, and execute the rest under a fresh run context.
|
|
546
|
+
*
|
|
547
|
+
* Extracted from {@link ResumeOrphanedSyncs}'s loop so several runs can be in flight at once.
|
|
548
|
+
* NEVER THROWS — every failure path is handled here and recorded on the run row. A resume that
|
|
549
|
+
* threw out of this method would take a pool slot with it and, worse, could abandon the runs
|
|
550
|
+
* queued behind it, which is the exact failure this parallelisation exists to remove.
|
|
551
|
+
*
|
|
552
|
+
* The check-and-reserve on `activeSyncs` still has no `await` in front of it, so it stays
|
|
553
|
+
* atomic with several of these in flight: an async function runs synchronously up to its first
|
|
554
|
+
* await, and the pool always starts one from a synchronous call site.
|
|
555
|
+
*/
|
|
556
|
+
async ResumeOneOrphanedRun(run, prov, rv, contextUser) {
|
|
557
|
+
const companyIntegrationID = run.CompanyIntegrationID;
|
|
558
|
+
const runID = run.ID;
|
|
559
|
+
const lockKey = companyIntegrationID.toLowerCase();
|
|
560
|
+
// C1: respect the SAME in-process concurrency lock RunSync uses. If a live sync for this
|
|
561
|
+
// CompanyIntegration is already running (e.g. the scheduler fired during startup), skip the
|
|
562
|
+
// resume — double-running one CI on the shared provider connection corrupts its singular
|
|
563
|
+
// transaction state (exactly what runWriteExclusive guards against WITHIN a run). The
|
|
564
|
+
// get→set pair below has no await between them, so check-and-reserve is atomic on the loop.
|
|
565
|
+
if (IntegrationEngine.activeSyncs.get(lockKey)) {
|
|
566
|
+
console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — a live sync for ${lockKey} is already running`);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
let resolveResumeLock;
|
|
570
|
+
let resumeResult;
|
|
571
|
+
IntegrationEngine.activeSyncs.set(lockKey, new Promise(res => { resolveResumeLock = res; }));
|
|
572
|
+
const ownership = new RunOwnershipService(prov, runID, undefined, contextUser);
|
|
573
|
+
try {
|
|
574
|
+
// CLAIM BEFORE ADOPTING (PR 1 item 6): a single atomic UPDATE that succeeds only if the
|
|
575
|
+
// run is still unowned/lapsed. Zero rows = another worker adopted it between our RunView
|
|
576
|
+
// and now — skip, never double-run. A successful claim BUMPS the fence, so if the
|
|
577
|
+
// original owner is actually alive-but-slow it aborts at its next boundary check
|
|
578
|
+
// without writing: the sweep-reclaim is itself the abort signal for the abandoned owner.
|
|
579
|
+
const claimed = await ownership.Claim();
|
|
580
|
+
if (!claimed) {
|
|
581
|
+
console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — claim lost (another worker adopted it)`);
|
|
582
|
+
return;
|
|
338
583
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
// Parse-miss falls open (map treated as not-completed → re-runs): at worst a
|
|
362
|
-
// redundant idempotent re-sync, never a silent skip.
|
|
363
|
-
if (m)
|
|
364
|
-
completedMapIDs.add(m[1].toLowerCase());
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
|
|
368
|
-
`for ${companyIntegrationID.substring(0, 8)}... ` +
|
|
369
|
-
`(${completedMapIDs.size} entity maps already completed)`);
|
|
370
|
-
// Load config and filter to only remaining entity maps (by map ID)
|
|
371
|
-
const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser);
|
|
372
|
-
const remainingMaps = config.entityMaps.filter(em => !completedMapIDs.has(em.ID.toLowerCase()));
|
|
373
|
-
if (remainingMaps.length === 0) {
|
|
374
|
-
console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
|
|
375
|
-
run.EndedAt = new Date();
|
|
376
|
-
run.Status = 'Success';
|
|
377
|
-
await run.Save();
|
|
378
|
-
continue;
|
|
584
|
+
// Find which entity MAPS already completed SUCCESSFULLY in this run. We correlate
|
|
585
|
+
// by EntityMapID (parsed from the detail's RecordID, stamped by CreateRunDetail),
|
|
586
|
+
// not EntityID — two maps can target the same MJ Entity, so keying on EntityID
|
|
587
|
+
// could skip a still-pending sibling map. We also require IsSuccess=1: a map that
|
|
588
|
+
// completed WITH errors (RecordsErrored>0, no throw) must be re-attempted on resume,
|
|
589
|
+
// otherwise its errored records are silently abandoned.
|
|
590
|
+
const detailsResult = await rv.RunView({
|
|
591
|
+
EntityName: 'MJ: Company Integration Run Details',
|
|
592
|
+
ExtraFilter: `CompanyIntegrationRunID='${runID}'`,
|
|
593
|
+
Fields: ['RecordID', 'IsSuccess'],
|
|
594
|
+
ResultType: 'simple',
|
|
595
|
+
}, contextUser);
|
|
596
|
+
const completedMapIDs = new Set();
|
|
597
|
+
if (detailsResult.Success) {
|
|
598
|
+
for (const d of detailsResult.Results) {
|
|
599
|
+
if (!d.IsSuccess)
|
|
600
|
+
continue; // completed-with-errors → re-attempt on resume
|
|
601
|
+
const m = /^EntityMap:([0-9a-fA-F-]+)\|/.exec(d.RecordID ?? '');
|
|
602
|
+
// Parse-miss falls open (map treated as not-completed → re-runs): at worst a
|
|
603
|
+
// redundant idempotent re-sync, never a silent skip.
|
|
604
|
+
if (m)
|
|
605
|
+
completedMapIDs.add(m[1].toLowerCase());
|
|
379
606
|
}
|
|
380
|
-
console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
|
|
381
|
-
// Replace entityMaps with only the remaining ones
|
|
382
|
-
config.entityMaps = remainingMaps;
|
|
383
|
-
// Execute remaining maps using the existing run record
|
|
384
|
-
const result = await this.ExecuteEntityMaps(config, run, contextUser);
|
|
385
|
-
result.RunID = runID;
|
|
386
|
-
await this.FinalizeRun(run, result, contextUser);
|
|
387
|
-
resumeResult = result;
|
|
388
|
-
console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
|
|
389
|
-
`${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
|
|
390
|
-
`${result.RecordsErrored} errored`);
|
|
391
607
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
608
|
+
console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
|
|
609
|
+
`for ${companyIntegrationID.substring(0, 8)}... ` +
|
|
610
|
+
`(${completedMapIDs.size} entity maps already completed)`);
|
|
611
|
+
// Recover what this run was ASKED to do. Without this the resume rebuilds config from
|
|
612
|
+
// the CompanyIntegration alone, so an adopted run silently loses its options — most
|
|
613
|
+
// damagingly FullSync, which exists precisely to distrust the watermark. An adopted
|
|
614
|
+
// full sync would resume incrementally, fetch nothing, and report Success.
|
|
615
|
+
// Unparseable/absent ConfigData falls back to defaults rather than refusing to resume.
|
|
616
|
+
let resumeOptions;
|
|
617
|
+
let resumeTriggerType = 'Scheduled';
|
|
618
|
+
try {
|
|
619
|
+
const cfg = JSON.parse(run.ConfigData ?? '{}');
|
|
620
|
+
resumeOptions = cfg.options ?? undefined;
|
|
621
|
+
if (cfg.triggerType)
|
|
622
|
+
resumeTriggerType = cfg.triggerType;
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
console.warn(`[IntegrationEngine] Run ${runID.substring(0, 8)} has unparseable ConfigData; resuming with defaults`);
|
|
626
|
+
}
|
|
627
|
+
if (resumeOptions?.FullSync) {
|
|
628
|
+
console.log(`[IntegrationEngine] Run ${runID.substring(0, 8)} was a FULL sync — resuming as full, not incremental`);
|
|
629
|
+
}
|
|
630
|
+
// Load config and filter to only remaining entity maps (by map ID)
|
|
631
|
+
const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, resumeOptions);
|
|
632
|
+
const remainingMaps = config.entityMaps.filter(em => !completedMapIDs.has(em.ID.toLowerCase()));
|
|
633
|
+
if (remainingMaps.length === 0) {
|
|
634
|
+
console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
|
|
635
|
+
run.EndedAt = new Date();
|
|
636
|
+
run.Status = 'Success';
|
|
637
|
+
ownership.SyncEntityOwnershipFields(run); // full-row save must not clobber the live claim
|
|
638
|
+
await run.Save();
|
|
639
|
+
await ownership.Release('Success');
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
|
|
643
|
+
// Replace entityMaps with only the remaining ones
|
|
644
|
+
config.entityMaps = remainingMaps;
|
|
645
|
+
// Execute remaining maps inside a per-run context: the resume gets its own provider
|
|
646
|
+
// binding, abort controller, and ownership — identical to a fresh RunSync — so the
|
|
647
|
+
// heartbeat renews the lease, the batch boundaries fence-check, and FinalizeRun
|
|
648
|
+
// syncs ownership fields + releases, all through the SAME code paths.
|
|
649
|
+
const abortController = new AbortController();
|
|
650
|
+
const progressSnapshot = {
|
|
651
|
+
StartedAt: new Date(),
|
|
652
|
+
CurrentEntity: '',
|
|
653
|
+
EntityMapsTotal: remainingMaps.length,
|
|
654
|
+
EntityMapsCompleted: 0,
|
|
655
|
+
RecordsProcessed: 0,
|
|
656
|
+
RecordsCreated: 0,
|
|
657
|
+
RecordsUpdated: 0,
|
|
658
|
+
RecordsErrored: 0,
|
|
659
|
+
// The run's OWN trigger type, recovered above — not a hardcoded 'Scheduled'. This is
|
|
660
|
+
// what IntegrationGetSyncProgress reports back ("Sync in progress (Manual)"), so a
|
|
661
|
+
// hardcoded value mislabels every adopted run.
|
|
662
|
+
TriggerType: resumeTriggerType,
|
|
663
|
+
};
|
|
664
|
+
const runCtx = {
|
|
665
|
+
provider: prov,
|
|
666
|
+
ownership,
|
|
667
|
+
abortController,
|
|
668
|
+
progressSnapshot,
|
|
669
|
+
cancelRequested: false,
|
|
670
|
+
ownershipLost: false,
|
|
671
|
+
};
|
|
672
|
+
ownership.StartHeartbeat({
|
|
673
|
+
onLost: () => { runCtx.ownershipLost = true; abortController.abort(); },
|
|
674
|
+
onCancelRequested: () => { runCtx.cancelRequested = true; abortController.abort(); },
|
|
675
|
+
progressSupplier: () => JSON.stringify(progressSnapshot),
|
|
676
|
+
});
|
|
677
|
+
const result = await IntegrationEngine.runContext.run(runCtx, async () => {
|
|
678
|
+
const r = await this.ExecuteEntityMaps(config, run, contextUser, undefined, abortController.signal);
|
|
679
|
+
r.RunID = runID;
|
|
680
|
+
await this.FinalizeRun(run, r, contextUser);
|
|
681
|
+
return r;
|
|
682
|
+
});
|
|
683
|
+
resumeResult = result;
|
|
684
|
+
console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
|
|
685
|
+
`${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
|
|
686
|
+
`${result.RecordsErrored} errored`);
|
|
687
|
+
}
|
|
688
|
+
catch (err) {
|
|
689
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
690
|
+
console.error(`[IntegrationEngine] Failed to resume run ${runID.substring(0, 8)}: ${errMsg}`);
|
|
691
|
+
if (err instanceof RunOwnershipLostError) {
|
|
692
|
+
// We were fenced out mid-resume — the NEW owner now owns the run row.
|
|
693
|
+
// Writing 'Failed' here would clobber the live holder's state.
|
|
694
|
+
console.warn(`[IntegrationEngine] Resume of run ${runID.substring(0, 8)} lost ownership — leaving the run row to its new owner`);
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
395
697
|
// Mark as failed so it doesn't get picked up again
|
|
396
698
|
run.EndedAt = new Date();
|
|
397
699
|
run.Status = 'Failed';
|
|
398
700
|
run.ErrorLog = JSON.stringify([{ ErrorMessage: `Resume failed: ${errMsg}` }]);
|
|
701
|
+
ownership.SyncEntityOwnershipFields(run);
|
|
399
702
|
await run.Save();
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
// waiter hangs. Promise resolve is idempotent and the early-exit `continue` also lands here.
|
|
405
|
-
IntegrationEngine.activeSyncs.delete(lockKey);
|
|
406
|
-
resolveResumeLock(resumeResult ?? {
|
|
407
|
-
Success: false, ErrorMessage: 'Resume produced no result', RecordsProcessed: 0,
|
|
408
|
-
RecordsCreated: 0, RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0,
|
|
409
|
-
RecordsSkipped: 0, Errors: [], EntityMapResults: [], Duration: 0,
|
|
410
|
-
});
|
|
703
|
+
try {
|
|
704
|
+
await ownership.Release('Failed');
|
|
705
|
+
}
|
|
706
|
+
catch { /* lease will simply expire */ }
|
|
411
707
|
}
|
|
412
708
|
}
|
|
709
|
+
finally {
|
|
710
|
+
ownership.StopHeartbeat();
|
|
711
|
+
// Release the C1 lock + unblock any RunSync that began awaiting this resume (RunSync returns
|
|
712
|
+
// `existing`). Resolve with the real result when we have one, else a benign empty result so no
|
|
713
|
+
// waiter hangs. Promise resolve is idempotent and the early-exit `return`s also land here.
|
|
714
|
+
IntegrationEngine.activeSyncs.delete(lockKey);
|
|
715
|
+
resolveResumeLock(resumeResult ?? {
|
|
716
|
+
Success: false, ErrorMessage: 'Resume produced no result', RecordsProcessed: 0,
|
|
717
|
+
RecordsCreated: 0, RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0,
|
|
718
|
+
RecordsSkipped: 0, Errors: [], EntityMapResults: [], Duration: 0,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
413
721
|
}
|
|
414
722
|
/**
|
|
415
723
|
* Executes a full sync run for a company integration.
|
|
@@ -424,8 +732,98 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
424
732
|
* @returns Aggregate sync result with record counts and errors
|
|
425
733
|
*/
|
|
426
734
|
async RunSync(companyIntegrationID, contextUser, triggerType = 'Manual', onProgress, onNotification, options, provider) {
|
|
427
|
-
|
|
428
|
-
|
|
735
|
+
return this.runWithOwnedContext(companyIntegrationID, contextUser, triggerType, onProgress, onNotification, options, provider);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Worker-mode enqueue (PR 1 item 8): create the run row with `Status='Queued'` and
|
|
739
|
+
* return its ID immediately, without executing anything. A worker process picks it up
|
|
740
|
+
* via {@link ExecuteQueuedRun}; the claim sproc provides the mutual exclusion, so any
|
|
741
|
+
* number of workers can poll the same queue safely.
|
|
742
|
+
*
|
|
743
|
+
* The trigger type and sync options are persisted on the run's `ConfigData` so the
|
|
744
|
+
* worker executes exactly what the caller asked for, in a different process.
|
|
745
|
+
*/
|
|
746
|
+
async EnqueueSync(companyIntegrationID, contextUser, triggerType = 'Manual', options, provider) {
|
|
747
|
+
const md = provider ?? Metadata.Provider;
|
|
748
|
+
const run = await md.GetEntityObject('MJ: Company Integration Runs', contextUser);
|
|
749
|
+
run.NewRecord();
|
|
750
|
+
run.CompanyIntegrationID = companyIntegrationID;
|
|
751
|
+
run.RunByUserID = contextUser.ID;
|
|
752
|
+
run.StartedAt = new Date();
|
|
753
|
+
run.Status = 'Queued';
|
|
754
|
+
run.TotalRecords = 0;
|
|
755
|
+
run.ConfigData = JSON.stringify({ triggerType, options: options ?? null });
|
|
756
|
+
if (options?.ScheduledJobRunID) {
|
|
757
|
+
run.Set('ScheduledJobRunID', options.ScheduledJobRunID);
|
|
758
|
+
}
|
|
759
|
+
if (!(await run.Save())) {
|
|
760
|
+
throw new Error(`Failed to enqueue sync run: ${run.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
761
|
+
}
|
|
762
|
+
console.log(`[IntegrationEngine] Enqueued run ${run.ID} for ${companyIntegrationID} (${triggerType})`);
|
|
763
|
+
return run.ID;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Worker-mode execution (PR 1 item 8): take a `Queued` run row, claim it, and execute
|
|
767
|
+
* it in THIS process. Returns a failed result (without side effects) when the run is
|
|
768
|
+
* no longer queued or when another worker won the claim — losing the race is normal
|
|
769
|
+
* and must never be treated as an error condition by the caller's poll loop.
|
|
770
|
+
*/
|
|
771
|
+
async ExecuteQueuedRun(runID, contextUser, provider) {
|
|
772
|
+
const md = provider ?? Metadata.Provider;
|
|
773
|
+
const run = await md.GetEntityObject('MJ: Company Integration Runs', contextUser);
|
|
774
|
+
if (!(await run.Load(runID))) {
|
|
775
|
+
return this.emptyFailedResult(`Queued run ${runID} not found`);
|
|
776
|
+
}
|
|
777
|
+
if (run.Status !== 'Queued') {
|
|
778
|
+
return this.emptyFailedResult(`Run ${runID} is '${run.Status}', not 'Queued' — another worker already took it`);
|
|
779
|
+
}
|
|
780
|
+
// A cancel issued while the run was still QUEUED must stop it here. CancelSync stamps
|
|
781
|
+
// both 'In Progress' and 'Queued' rows, but the only consumer of the stamp is the
|
|
782
|
+
// running loop's batch-boundary / lease-renewal check — a queued run has no loop yet,
|
|
783
|
+
// so without this gate the worker claims the cancelled row moments later and executes
|
|
784
|
+
// it to completion. Verified live: run E3F51F9A was stamped CancelRequestedAt at
|
|
785
|
+
// 15:50:46.643 and still finished Status='Success' at 15:50:48.646.
|
|
786
|
+
// Finalize the same way an aborted in-flight run finalizes (FinalizeRun): 'Cancelled'
|
|
787
|
+
// with an explicit ErrorLog carrying the reason.
|
|
788
|
+
if (run.CancelRequestedAt != null) {
|
|
789
|
+
run.EndedAt = new Date();
|
|
790
|
+
run.Status = 'Cancelled';
|
|
791
|
+
run.ErrorLog = 'Sync cancelled by user before it started';
|
|
792
|
+
if (!(await run.Save())) {
|
|
793
|
+
console.warn(`[IntegrationEngine] Could not finalize cancelled queued run ${runID}: ${run.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
794
|
+
}
|
|
795
|
+
console.log(`[IntegrationEngine] Queued run ${runID} was cancelled before start — not executing`);
|
|
796
|
+
return this.emptyFailedResult('Sync cancelled by user before it started');
|
|
797
|
+
}
|
|
798
|
+
let triggerType = 'Scheduled';
|
|
799
|
+
let options;
|
|
800
|
+
try {
|
|
801
|
+
const config = JSON.parse(run.ConfigData ?? '{}');
|
|
802
|
+
if (config.triggerType)
|
|
803
|
+
triggerType = config.triggerType;
|
|
804
|
+
options = config.options ?? undefined;
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
// A run whose ConfigData we can't read still executes — with defaults, not silently skipped.
|
|
808
|
+
console.warn(`[IntegrationEngine] Run ${runID} has unparseable ConfigData; executing with defaults`);
|
|
809
|
+
}
|
|
810
|
+
return this.runWithOwnedContext(run.CompanyIntegrationID, contextUser, triggerType, undefined, undefined, options, provider, run);
|
|
811
|
+
}
|
|
812
|
+
/** A zero-work failed SyncResult — used for refusals that must not look like partial work. */
|
|
813
|
+
emptyFailedResult(message) {
|
|
814
|
+
return {
|
|
815
|
+
Success: false, ErrorMessage: message, RecordsProcessed: 0, RecordsCreated: 0,
|
|
816
|
+
RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0,
|
|
817
|
+
Errors: [], EntityMapResults: [], Duration: 0,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Shared body of {@link RunSync} and {@link ExecuteQueuedRun}: acquires the per-connection
|
|
822
|
+
* concurrency lock, establishes the per-run AsyncLocalStorage context (own provider, own
|
|
823
|
+
* abort controller, own progress snapshot) and executes. `existingRun` is supplied by the
|
|
824
|
+
* worker path so a queued row is executed rather than a new row created.
|
|
825
|
+
*/
|
|
826
|
+
async runWithOwnedContext(companyIntegrationID, contextUser, triggerType, onProgress, onNotification, options, provider, existingRun) {
|
|
429
827
|
const lockKey = companyIntegrationID.toLowerCase();
|
|
430
828
|
const existing = IntegrationEngine.activeSyncs.get(lockKey);
|
|
431
829
|
if (existing) {
|
|
@@ -446,10 +844,12 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
446
844
|
Errors: [], EntityMapResults: [], Duration: 0,
|
|
447
845
|
};
|
|
448
846
|
}
|
|
449
|
-
//
|
|
847
|
+
// Per-run context (PR 1 item 7): the run's OWN provider (captured, never stored on
|
|
848
|
+
// the engine), a local abort controller (plumbing driven by the DB cancel/fence
|
|
849
|
+
// signals — never a cross-process source of truth), and a local progress snapshot
|
|
850
|
+
// that the ownership service persists to ProgressJSON on the run row.
|
|
450
851
|
const abortController = new AbortController();
|
|
451
|
-
|
|
452
|
-
IntegrationEngine._syncProgress.set(lockKey, {
|
|
852
|
+
const progressSnapshot = {
|
|
453
853
|
StartedAt: new Date(),
|
|
454
854
|
CurrentEntity: '',
|
|
455
855
|
EntityMapsTotal: 0,
|
|
@@ -459,33 +859,41 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
459
859
|
RecordsUpdated: 0,
|
|
460
860
|
RecordsErrored: 0,
|
|
461
861
|
TriggerType: triggerType,
|
|
462
|
-
}
|
|
862
|
+
};
|
|
863
|
+
const runCtx = {
|
|
864
|
+
provider: provider ?? Metadata.Provider,
|
|
865
|
+
abortController,
|
|
866
|
+
progressSnapshot,
|
|
867
|
+
cancelRequested: false,
|
|
868
|
+
ownershipLost: false,
|
|
869
|
+
};
|
|
463
870
|
// Wrap caller's onProgress with internal tracking. U3 — MONOTONIC: with
|
|
464
871
|
// syncConcurrency > 1 the per-map events arrive out of order (map 3 can emit after
|
|
465
872
|
// map 7), so raw assignment made the progress bar go BACKWARDS. The snapshot is a
|
|
466
|
-
// high-water mark, so only ever ratchet the counters upward.
|
|
873
|
+
// high-water mark, so only ever ratchet the counters upward. The snapshot is
|
|
874
|
+
// persisted to the run row's ProgressJSON (throttled) so readers in ANY process see it.
|
|
467
875
|
const wrappedProgress = (progress) => {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
IntegrationEngine.RatchetProgressSnapshot(entry, progress);
|
|
876
|
+
IntegrationEngine.RatchetProgressSnapshot(progressSnapshot, progress);
|
|
877
|
+
void runCtx.ownership?.WriteProgress(JSON.stringify(progressSnapshot));
|
|
471
878
|
if (onProgress)
|
|
472
879
|
onProgress(progress);
|
|
473
880
|
};
|
|
474
|
-
|
|
881
|
+
// Enter the AsyncLocalStorage scope — every helper the run calls resolves
|
|
882
|
+
// ProviderToUse / write chain / ownership from THIS context, isolated per run.
|
|
883
|
+
const syncPromise = IntegrationEngine.runContext.run(runCtx, () => this.executeSyncInternal(companyIntegrationID, contextUser, triggerType, wrappedProgress, onNotification, options, abortController.signal, existingRun));
|
|
475
884
|
IntegrationEngine.activeSyncs.set(lockKey, syncPromise);
|
|
476
885
|
try {
|
|
477
886
|
return await syncPromise;
|
|
478
887
|
}
|
|
479
888
|
finally {
|
|
480
889
|
IntegrationEngine.activeSyncs.delete(lockKey);
|
|
481
|
-
|
|
482
|
-
IntegrationEngine._syncProgress.delete(lockKey);
|
|
890
|
+
runCtx.ownership?.StopHeartbeat();
|
|
483
891
|
}
|
|
484
892
|
}
|
|
485
893
|
/**
|
|
486
894
|
* Internal sync execution method. Contains the full orchestration logic.
|
|
487
895
|
*/
|
|
488
|
-
async executeSyncInternal(companyIntegrationID, contextUser, triggerType, onProgress, onNotification, options, abortSignal) {
|
|
896
|
+
async executeSyncInternal(companyIntegrationID, contextUser, triggerType, onProgress, onNotification, options, abortSignal, existingRun) {
|
|
489
897
|
const startTime = Date.now();
|
|
490
898
|
const logger = new SyncLogger({ ciId: companyIntegrationID, integration: null });
|
|
491
899
|
logger.emit('sync.run.start', {
|
|
@@ -495,6 +903,27 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
495
903
|
entityMapIDsFilter: options?.EntityMapIDs ?? null,
|
|
496
904
|
syncDirectionOverride: options?.SyncDirection ?? null,
|
|
497
905
|
});
|
|
906
|
+
/**
|
|
907
|
+
* Re-read the IO/IOF catalog before the run starts (also covers resumed runs — both paths land here).
|
|
908
|
+
*
|
|
909
|
+
* Everything else a run reads is fresh per run (LoadRunConfiguration and LoadFieldMaps
|
|
910
|
+
* read CI/entity maps/field maps with BypassCache) — but IntegrationObject and
|
|
911
|
+
* IntegrationObjectField are served from this engine's BaseEngine arrays, loaded at
|
|
912
|
+
* process start and auto-refreshed only by IN-PROCESS BaseEntity saves. A catalog edit
|
|
913
|
+
* made by direct SQL, a sproc-based sync push, or another process is therefore invisible
|
|
914
|
+
* to syncs until the host restarts — an AccessPath or field-type correction keeps being
|
|
915
|
+
* ignored run after run with nothing in the log to say why. The apply/evolution
|
|
916
|
+
* resolvers already do an invalidate+reload for exactly this reason; the sync path
|
|
917
|
+
* never got the same treatment.
|
|
918
|
+
*
|
|
919
|
+
* Only the two catalog arrays are refreshed — not Config(true), which reloads all eight
|
|
920
|
+
* datasets unfiltered on every run. The loader is called directly with bypassCache=true
|
|
921
|
+
* because RefreshItem defaults to the local cache — which is the very thing that is
|
|
922
|
+
* stale. Replacing the arrays is also what invalidates this PR's memoised views: both
|
|
923
|
+
* the per-object field index and the per-record GetCachedFields memo key on ARRAY
|
|
924
|
+
* IDENTITY, so they rebuild lazily on first read after the swap.
|
|
925
|
+
*/
|
|
926
|
+
await IntegrationEngineBase.Instance.RefreshCatalog(contextUser);
|
|
498
927
|
const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, options);
|
|
499
928
|
logger.attachIntegrationName(config.companyIntegration.Integration);
|
|
500
929
|
logger.emit('sync.config.loaded', {
|
|
@@ -523,6 +952,15 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
523
952
|
if (config.companyIntegration.IsActive === false) {
|
|
524
953
|
const message = 'Connector is deactivated (IsActive=false); sync not started';
|
|
525
954
|
logger.emit('sync.warning', { reason: 'deactivated', message });
|
|
955
|
+
// A queued row must not sit in the queue forever being re-polled by every worker.
|
|
956
|
+
if (existingRun) {
|
|
957
|
+
existingRun.Status = 'Failed';
|
|
958
|
+
existingRun.ErrorLog = message;
|
|
959
|
+
existingRun.EndedAt = new Date();
|
|
960
|
+
if (!(await existingRun.Save())) {
|
|
961
|
+
console.warn(`[IntegrationEngine] Failed to fail-out deactivated queued run ${existingRun.ID}: ${existingRun.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
526
964
|
return {
|
|
527
965
|
Success: false,
|
|
528
966
|
ErrorMessage: message,
|
|
@@ -537,8 +975,53 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
537
975
|
Duration: Date.now() - startTime,
|
|
538
976
|
};
|
|
539
977
|
}
|
|
540
|
-
|
|
978
|
+
// Worker mode executes a row that already exists (Status='Queued'); the direct path creates one.
|
|
979
|
+
const run = existingRun ?? await this.CreateRunRecord(config.companyIntegration, triggerType, contextUser, options?.ScheduledJobRunID, options);
|
|
541
980
|
logger.attachRunId(run.ID);
|
|
981
|
+
// ── Durable-run ownership (PR 1 item 3): claim before the first batch. ──
|
|
982
|
+
// The claim is ONE atomic UPDATE (unowned OR expired lease) that bumps FenceToken;
|
|
983
|
+
// zero rows back means another process owns this run and we must not proceed.
|
|
984
|
+
// Renewal runs from a TIMER at ~lease/3 (not the batch loop) so a long batch never
|
|
985
|
+
// looks dead; the lease is max(default, MaxRuntimeMinutes) so a configured long run
|
|
986
|
+
// only ever EXTENDS protection. The heartbeat's renewal result doubles as the
|
|
987
|
+
// cross-process cancel poll.
|
|
988
|
+
const runCtx = this.currentRunContext;
|
|
989
|
+
if (runCtx) {
|
|
990
|
+
const ownership = new RunOwnershipService(runCtx.provider, run.ID, options?.MaxRuntimeMinutes ?? undefined, contextUser);
|
|
991
|
+
const claimed = await ownership.Claim();
|
|
992
|
+
if (!claimed) {
|
|
993
|
+
const message = `Run ${run.ID} could not be claimed — another process holds a live lease. Not proceeding.`;
|
|
994
|
+
logger.emit('sync.warning', { reason: 'claim-lost', message });
|
|
995
|
+
return {
|
|
996
|
+
Success: false, ErrorMessage: message, RecordsProcessed: 0, RecordsCreated: 0,
|
|
997
|
+
RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0,
|
|
998
|
+
Errors: [], EntityMapResults: [], Duration: Date.now() - startTime, RunID: run.ID,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
runCtx.ownership = ownership;
|
|
1002
|
+
// Worker mode: the claim is what promotes a Queued row to In Progress. Doing it
|
|
1003
|
+
// AFTER the claim (never before) means a worker that loses the race never touches
|
|
1004
|
+
// the row, so the winner's status is the only one written.
|
|
1005
|
+
if (existingRun && existingRun.Status === 'Queued') {
|
|
1006
|
+
existingRun.Status = 'In Progress';
|
|
1007
|
+
existingRun.StartedAt = new Date();
|
|
1008
|
+
ownership.SyncEntityOwnershipFields(existingRun);
|
|
1009
|
+
if (!(await existingRun.Save())) {
|
|
1010
|
+
console.warn(`[IntegrationEngine] Failed to mark claimed run ${existingRun.ID} In Progress: ${existingRun.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
ownership.StartHeartbeat({
|
|
1014
|
+
onLost: () => {
|
|
1015
|
+
runCtx.ownershipLost = true;
|
|
1016
|
+
runCtx.abortController.abort();
|
|
1017
|
+
},
|
|
1018
|
+
onCancelRequested: () => {
|
|
1019
|
+
runCtx.cancelRequested = true;
|
|
1020
|
+
runCtx.abortController.abort();
|
|
1021
|
+
},
|
|
1022
|
+
progressSupplier: () => JSON.stringify(runCtx.progressSnapshot),
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
542
1025
|
// Durable, queryable, restart-surviving artifact stream for this sync. runID is
|
|
543
1026
|
// the CompanyIntegrationRun.ID so the JSONL artifact cross-correlates with the run
|
|
544
1027
|
// row. Exposed over GraphQL (IntegrationListRuns / IntegrationGetRun /
|
|
@@ -603,6 +1086,20 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
603
1086
|
catch (err) {
|
|
604
1087
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
605
1088
|
logger.emit('sync.run.fail', { error: errMsg, durationMs: Date.now() - startTime });
|
|
1089
|
+
// Ownership lost (fence moved / lease reclaimed): the run row now belongs to
|
|
1090
|
+
// ANOTHER process — writing a terminal status to it here would clobber the new
|
|
1091
|
+
// owner's state. Stop everything locally and walk away without touching the row.
|
|
1092
|
+
const ctx = this.currentRunContext;
|
|
1093
|
+
if (err instanceof RunOwnershipLostError || ctx?.ownershipLost) {
|
|
1094
|
+
ctx?.ownership?.StopHeartbeat();
|
|
1095
|
+
await this.finalizeSyncProgress(progress, 'failed', errMsg);
|
|
1096
|
+
console.warn(`[IntegrationEngine] Run ${run.ID} ownership lost — aborted without writing the run row.`);
|
|
1097
|
+
return {
|
|
1098
|
+
Success: false, ErrorMessage: errMsg, RecordsProcessed: 0, RecordsCreated: 0,
|
|
1099
|
+
RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0,
|
|
1100
|
+
Errors: [], EntityMapResults: [], Duration: Date.now() - startTime, RunID: run.ID,
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
606
1103
|
await this.finalizeSyncProgress(progress, 'failed', errMsg);
|
|
607
1104
|
await this.FailRun(run, err, contextUser, onNotification);
|
|
608
1105
|
throw err;
|
|
@@ -771,7 +1268,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
771
1268
|
/**
|
|
772
1269
|
* Creates a new CompanyIntegrationRun record to track this sync.
|
|
773
1270
|
*/
|
|
774
|
-
async CreateRunRecord(companyIntegration, triggerType, contextUser, scheduledJobRunID) {
|
|
1271
|
+
async CreateRunRecord(companyIntegration, triggerType, contextUser, scheduledJobRunID, options) {
|
|
775
1272
|
const md = this.ProviderToUse;
|
|
776
1273
|
const run = await md.GetEntityObject('MJ: Company Integration Runs', contextUser);
|
|
777
1274
|
run.NewRecord();
|
|
@@ -780,7 +1277,12 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
780
1277
|
run.StartedAt = new Date();
|
|
781
1278
|
run.Status = 'In Progress';
|
|
782
1279
|
run.TotalRecords = 0;
|
|
783
|
-
|
|
1280
|
+
// Persist the OPTIONS alongside the trigger type, in the same shape EnqueueSync writes.
|
|
1281
|
+
// Without them a run that outlives its process loses what it was asked to do: ResumeOrphanedSyncs
|
|
1282
|
+
// rebuilds config from the CompanyIntegration alone, so an adopted `FullSync` run silently
|
|
1283
|
+
// resumed as an incremental one — re-fetching nothing and reporting Success, which is the
|
|
1284
|
+
// opposite of what a full sync is requested for (repairing drift, re-pulling after a remap).
|
|
1285
|
+
run.ConfigData = JSON.stringify({ triggerType, options: options ?? null });
|
|
784
1286
|
// Link to scheduled job run if triggered by the scheduler.
|
|
785
1287
|
// Use Set() because the ScheduledJobRunID column won't exist on the
|
|
786
1288
|
// generated entity type until CodeGen runs after the migration.
|
|
@@ -848,6 +1350,11 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
848
1350
|
return { ok: mapResult.Success, throttled: mapResult.Throttled === true };
|
|
849
1351
|
}
|
|
850
1352
|
catch (err) {
|
|
1353
|
+
// Ownership loss is NOT a per-map failure to record-and-continue: continuing to the
|
|
1354
|
+
// next map would keep writing after another process claimed the run — the exact
|
|
1355
|
+
// split-brain the fence prevents. Propagate so the whole sync aborts immediately.
|
|
1356
|
+
if (err instanceof RunOwnershipLostError)
|
|
1357
|
+
throw err;
|
|
851
1358
|
const objName = entityMap.ExternalObjectName ?? entityMap.ID;
|
|
852
1359
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
853
1360
|
console.error(`[IntegrationEngine] Entity map '${objName}' failed: ${errMsg}`);
|
|
@@ -1282,12 +1789,12 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1282
1789
|
if (!raw)
|
|
1283
1790
|
return {};
|
|
1284
1791
|
const p = JSON.parse(raw);
|
|
1285
|
-
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined);
|
|
1286
1792
|
return {
|
|
1287
|
-
maxConcurrency:
|
|
1793
|
+
maxConcurrency: PositiveInt(p.maxConcurrency),
|
|
1288
1794
|
rateLimitTokensPerSec: typeof p.rateLimitTokensPerSec === 'number' && p.rateLimitTokensPerSec > 0 ? p.rateLimitTokensPerSec : undefined,
|
|
1289
|
-
rateLimitBurst:
|
|
1290
|
-
discoveryTimeBudgetMs:
|
|
1795
|
+
rateLimitBurst: PositiveInt(p.rateLimitBurst),
|
|
1796
|
+
discoveryTimeBudgetMs: PositiveInt(p.discoveryTimeBudgetMs),
|
|
1797
|
+
fetchTimeoutMs: PositiveInt(p.fetchTimeoutMs),
|
|
1291
1798
|
};
|
|
1292
1799
|
}
|
|
1293
1800
|
catch {
|
|
@@ -1373,6 +1880,16 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1373
1880
|
async ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger) {
|
|
1374
1881
|
const entityMapID = entityMap.ID;
|
|
1375
1882
|
const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
|
|
1883
|
+
// Field-level exclusions declared by the connector (SourceFieldInfo.SyncDirective
|
|
1884
|
+
// -> IntegrationObjectField.Configuration). Resolved once per map, applied to every
|
|
1885
|
+
// batch below. Empty set on any lookup miss - exclusion can only ever narrow.
|
|
1886
|
+
const excludedSourceNames = this.ResolveExcludedSourceNames(config.companyIntegration.IntegrationID, entityMap.ExternalObjectName);
|
|
1887
|
+
if (excludedSourceNames.size > 0) {
|
|
1888
|
+
logger?.emit('sync.entity-map.exclusions', {
|
|
1889
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
1890
|
+
excludedFields: Array.from(excludedSourceNames).sort(),
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1376
1893
|
const watermark = await this.runWriteExclusive(() => this.watermarkService.Load(entityMapID, contextUser, 'Pull'));
|
|
1377
1894
|
logger?.emit('sync.entity-map.start', {
|
|
1378
1895
|
phase: 'pull-detail',
|
|
@@ -1388,8 +1905,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1388
1905
|
watermarkType: watermark?.WatermarkType ?? null,
|
|
1389
1906
|
fullSync: config.fullSync,
|
|
1390
1907
|
});
|
|
1908
|
+
this.WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger);
|
|
1391
1909
|
// A6: Validate watermark before using it — skip entirely when FullSync requested
|
|
1392
1910
|
let initialWatermark = config.fullSync ? null : (watermark?.WatermarkValue ?? null);
|
|
1911
|
+
// The value the ROW held before this run touched it — the retract target if a mid-run
|
|
1912
|
+
// durability floor (§8a below) has to be undone after a page-skip gap. Distinct from
|
|
1913
|
+
// initialWatermark, which a fullSync nulls even though the row still holds a real value.
|
|
1914
|
+
const preRunWatermarkValue = watermark?.WatermarkValue ?? null;
|
|
1393
1915
|
if (initialWatermark && watermark) {
|
|
1394
1916
|
const watermarkType = (watermark.WatermarkType ?? 'Timestamp');
|
|
1395
1917
|
if (!this.watermarkService.ValidateWatermark(initialWatermark, watermarkType)) {
|
|
@@ -1473,9 +1995,11 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1473
1995
|
let previousBatchFingerprint;
|
|
1474
1996
|
let fetchCompletedCleanly = true; // flipped to false if fetch aborted or errored mid-way
|
|
1475
1997
|
let hadFetchGap = false; // ≥1 page was skipped after a persistent fetch error (offset/page paging)
|
|
1998
|
+
let watermarkFloorSaved = null; // §8a durability floor last persisted mid-run (null = none)
|
|
1476
1999
|
let fetchGapCount = 0; // CONSECUTIVE skipped pages (reset on any clean fetch)
|
|
1477
2000
|
const MAX_FETCH_GAPS = 25; // give up + hold the watermark if this many pages fail in a row (API down)
|
|
1478
2001
|
let consecutiveEmptyBatches = 0; // P3-D: detect a connector that pages empty-but-HasMore forever
|
|
2002
|
+
let oversizeBatchWarned = false; // pagination rule: warn ONCE per object that the connector ignored BatchSize
|
|
1479
2003
|
const MAX_BATCHES_PER_MAP = 5000;
|
|
1480
2004
|
const EMPTY_BATCH_WARN_THRESHOLD = 5; // warn once after this many empty-but-HasMore batches in a row
|
|
1481
2005
|
const fetchedExternalIDs = new Set(); // Track all IDs seen during this pull for orphan detection
|
|
@@ -1486,6 +2010,17 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1486
2010
|
// one entry per distinct key + a capped value sample. See CustomKeyStat.
|
|
1487
2011
|
const customKeyAgg = new Map();
|
|
1488
2012
|
let customKeyTotalRecords = 0;
|
|
2013
|
+
// Per-page fetch timeout, resolved ONCE per entity map. A connector that fans out one request
|
|
2014
|
+
// per parent does N requests inside a single FetchChanges call, so its page time scales with
|
|
2015
|
+
// BatchSize and with however much concurrency the adaptive controller currently allows — the
|
|
2016
|
+
// fixed 30s default punished exactly those connectors. Deployment config wins over the
|
|
2017
|
+
// connector's own declared default, which wins over the framework default. BOTH overrides go
|
|
2018
|
+
// through PositiveInt: `??` alone would only reject null/undefined, so a connector returning
|
|
2019
|
+
// 0 / -1 / NaN (all legal for its `number | null` type) would be applied verbatim and time
|
|
2020
|
+
// every page out at ~1ms.
|
|
2021
|
+
const fetchTimeoutMs = PositiveInt(this.getConfigOverrides(config).fetchTimeoutMs)
|
|
2022
|
+
?? PositiveInt(config.connector.FetchChangesTimeoutMs)
|
|
2023
|
+
?? DEFAULT_OPERATION_TIMEOUTS.FetchChangesMs;
|
|
1489
2024
|
while (hasMore) {
|
|
1490
2025
|
if (abortSignal?.aborted) {
|
|
1491
2026
|
console.log(`[IntegrationEngine] Sync cancelled for ${entityMap.ExternalObjectName} after ${recordsInMap} records — saving watermark`);
|
|
@@ -1528,18 +2063,69 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1528
2063
|
});
|
|
1529
2064
|
let batch;
|
|
1530
2065
|
const fetchStart = Date.now();
|
|
2066
|
+
// ONE multiplicative decrease per throttle EPISODE, not one per rejected attempt.
|
|
2067
|
+
//
|
|
2068
|
+
// A 429 that survives three retries is three rejections but one congestion event — the
|
|
2069
|
+
// same distinction TCP draws when it halves the window once per loss event rather than
|
|
2070
|
+
// once per lost segment. Decreasing on each attempt compounds: at a 0.5 backoff factor
|
|
2071
|
+
// three attempts take the rate to an eighth, five take it to a thirtieth, so a single
|
|
2072
|
+
// throttled fetch could drive a connector to its floor purely as a function of how
|
|
2073
|
+
// generous its retry budget is. The freeze already covers the interval the source asked
|
|
2074
|
+
// for; the decrease is about the rate AFTER that, and one signal deserves one step.
|
|
2075
|
+
let throttleReported = false;
|
|
1531
2076
|
try {
|
|
1532
2077
|
await this.rateLimit(config);
|
|
1533
2078
|
// Resilient fetch: bound each attempt with a timeout (a hung vendor API must not
|
|
1534
2079
|
// hold the sync lock forever) and retry only transient errors (network/throttle/DB).
|
|
1535
2080
|
// A non-retryable error (auth, 4xx, parse) throws immediately as before.
|
|
1536
|
-
batch = await WithRetry(() => WithTimeout(config.connector.FetchChanges(ctx),
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
2081
|
+
batch = await WithRetry(() => WithTimeout(config.connector.FetchChanges(ctx), fetchTimeoutMs, `FetchChanges(${entityMap.ExternalObjectName})`), undefined,
|
|
2082
|
+
// OUR OWN timeout is terminal for this page; a transport error is not.
|
|
2083
|
+
//
|
|
2084
|
+
// `WithTimeout` is a `Promise.race` with no cancellation, so the abandoned attempt
|
|
2085
|
+
// keeps running. Retrying meant a second full page of vendor requests overlapping
|
|
2086
|
+
// the first, then a third — up to 3x the load on a source that was already too slow
|
|
2087
|
+
// to finish once, which is a good way to earn a real 429 (and THAT does cut
|
|
2088
|
+
// concurrency). And the retry could not succeed on its merits anyway: the same work
|
|
2089
|
+
// under the same budget exceeds it again.
|
|
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
|
+
});
|
|
1543
2129
|
this.reportRateOutcome(config); // clean fetch → ramp the adaptive rate back up
|
|
1544
2130
|
fetchGapCount = 0; // clean fetch → reset the consecutive fetch-gap counter
|
|
1545
2131
|
// §10: connector type-driven post-processing hook (default no-op) — enforce/normalize
|
|
@@ -1554,7 +2140,10 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1554
2140
|
// other errors don't touch the rate. §5 Gap 2: also flag the map result so the per-layer
|
|
1555
2141
|
// AIMD controller reduces in-flight concurrency, not just the per-request token bucket.
|
|
1556
2142
|
if (ClassifyError(fetchErr).Code === 'RATE_LIMIT_EXCEEDED') {
|
|
1557
|
-
|
|
2143
|
+
// Only if the retry hook did not already do it — a fetch that was retried has
|
|
2144
|
+
// already had its one decrease applied, at the first sign rather than here.
|
|
2145
|
+
if (!throttleReported)
|
|
2146
|
+
this.reportRateOutcome(config, fetchErr);
|
|
1558
2147
|
result.Throttled = true;
|
|
1559
2148
|
}
|
|
1560
2149
|
console.error(`[IntegrationEngine] FetchChanges error for ${entityMap.ExternalObjectName}: ${errMsg}`);
|
|
@@ -1574,6 +2163,15 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1574
2163
|
fetchGapCount++;
|
|
1575
2164
|
hadFetchGap = true;
|
|
1576
2165
|
fetchCompletedCleanly = false;
|
|
2166
|
+
// A durability floor written before this gap may sit PAST the hole (the skipped
|
|
2167
|
+
// page can hold records behind the max watermark seen). Put the row back to what
|
|
2168
|
+
// it held before this run, exactly what the post-loop hold does for the in-memory
|
|
2169
|
+
// value — a crash from here on resumes from the pre-run watermark and re-covers
|
|
2170
|
+
// the gap. Later checkpoints stop writing floors (gate above).
|
|
2171
|
+
if (watermarkFloorSaved !== null) {
|
|
2172
|
+
await this.runWriteExclusive(() => this.watermarkService.RestoreValue(entityMapID, preRunWatermarkValue, contextUser));
|
|
2173
|
+
watermarkFloorSaved = null;
|
|
2174
|
+
}
|
|
1577
2175
|
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_PAGE_SKIPPED', `Persistent fetch error at ${currentOffset != null ? 'offset ' + currentOffset : 'page ' + currentPage} for ` +
|
|
1578
2176
|
`'${entityMap.ExternalObjectName}' (batch ${batchCount}); skipped this page and continued — the ` +
|
|
1579
2177
|
`watermark is held so the window is re-fetched next run. Error: ${errMsg}`, { offset: currentOffset ?? null, page: currentPage ?? null, batchIndex: batchCount, error: errMsg });
|
|
@@ -1583,7 +2181,33 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1583
2181
|
currentPage += 1;
|
|
1584
2182
|
continue;
|
|
1585
2183
|
}
|
|
2184
|
+
// Cannot skip past this page (cursor paging, or the gap budget is spent), so the object
|
|
2185
|
+
// stops here with an incomplete result set. The watermark is held below and the run
|
|
2186
|
+
// re-fetches next time — but WITHOUT this warning that outcome is invisible: the map
|
|
2187
|
+
// reports success with the records it did get, so an object whose very first page
|
|
2188
|
+
// failed reads as a clean "0 records, nothing changed" run. Verified live: a sync
|
|
2189
|
+
// whose only page timed out finished Status=Success, errorCount=0, empty ErrorLog.
|
|
1586
2190
|
fetchCompletedCleanly = false;
|
|
2191
|
+
const abortMessage = `Fetch for '${entityMap.ExternalObjectName}' stopped at batch ${batchCount} after a persistent ` +
|
|
2192
|
+
`error and could not continue past it, so this object's result set is INCOMPLETE ` +
|
|
2193
|
+
`(${recordsInMap} record(s) fetched before the failure). The watermark is held, so the ` +
|
|
2194
|
+
`unfetched window is retried next run. Error: ${errMsg}`;
|
|
2195
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_ABORTED_INCOMPLETE', abortMessage, { batchIndex: batchCount, recordsFetchedBeforeFailure: recordsInMap, error: errMsg });
|
|
2196
|
+
// The structured warning above reaches the console and the per-run artifact — neither of
|
|
2197
|
+
// which is queryable run history. The DURABLE record is CompanyIntegrationRun, whose
|
|
2198
|
+
// Status is derived from RecordsErrored (unchanged here, correctly: no record failed) and
|
|
2199
|
+
// whose ErrorLog is written from result.Errors. Without an entry there, a nightly sync
|
|
2200
|
+
// that aborts on its first page every night reads as an unbroken run of clean Successes
|
|
2201
|
+
// with TotalRecords=0. Severity 'Warning' is what keeps Status='Success': MergeResult
|
|
2202
|
+
// only clears Success on RecordsErrored > 0, so this records the condition without
|
|
2203
|
+
// reclassifying a held-watermark retry as a failed run.
|
|
2204
|
+
result.Errors.push({
|
|
2205
|
+
ExternalID: '',
|
|
2206
|
+
ChangeType: 'Skip',
|
|
2207
|
+
ErrorMessage: abortMessage,
|
|
2208
|
+
ErrorCode: 'CONNECTOR_ERROR',
|
|
2209
|
+
Severity: 'Warning',
|
|
2210
|
+
});
|
|
1587
2211
|
break;
|
|
1588
2212
|
}
|
|
1589
2213
|
logger?.emit('sync.fetch.batch.complete', {
|
|
@@ -1605,11 +2229,15 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1605
2229
|
logger?.warning(entityMap.ExternalObjectName ?? 'sync', w.Code, w.Message, w.Data);
|
|
1606
2230
|
}
|
|
1607
2231
|
}
|
|
1608
|
-
//
|
|
1609
|
-
//
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
2232
|
+
// Engine-side half of the pagination rule: a connector MUST honour ctx.BatchSize. We never
|
|
2233
|
+
// truncate — every record is written, just in sub-batches to keep DB transactions manageable —
|
|
2234
|
+
// but an over-size batch is a real connector defect and has to be visible on the structured
|
|
2235
|
+
// run-event stream, not buried in a console.log nobody reads. Warned ONCE per object (the
|
|
2236
|
+
// CONSECUTIVE_EMPTY_BATCHES pattern) so a paginating-but-over-size connector doesn't flood
|
|
2237
|
+
// the artifact with one warning per page.
|
|
2238
|
+
if (batch.Records.length > this.MaxBatchSize && !oversizeBatchWarned) {
|
|
2239
|
+
oversizeBatchWarned = true;
|
|
2240
|
+
this.warnOversizedBatch(entityMap, batch, batchCount, logger);
|
|
1613
2241
|
}
|
|
1614
2242
|
if (batch.Records.length > 0) {
|
|
1615
2243
|
const fingerprint = batch.Records.map(r => r.ExternalID).join(',');
|
|
@@ -1633,7 +2261,20 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1633
2261
|
fetchedExternalIDs.clear();
|
|
1634
2262
|
}
|
|
1635
2263
|
}
|
|
1636
|
-
|
|
2264
|
+
// Within-batch identity, enforced before mapping: two records sharing an ExternalID are
|
|
2265
|
+
// two observations of ONE source record. The write path cannot catch this — it decides
|
|
2266
|
+
// insert-vs-update against the DATABASE, where a first-time identity is absent for both
|
|
2267
|
+
// copies, so both insert and the pair re-inserts every sync. The fingerprint guard above
|
|
2268
|
+
// only sees a batch repeated in FULL. Never silent: a connector emitting duplicate
|
|
2269
|
+
// identities is a defect worth fixing at its source.
|
|
2270
|
+
const identity = CollapseDuplicateIdentities(batch.Records);
|
|
2271
|
+
if (identity.Collapsed > 0) {
|
|
2272
|
+
logger?.warning(entityMap.ExternalObjectName ?? 'sync', 'DUPLICATE_IDENTITIES_IN_BATCH', `${entityMap.ExternalObjectName}: ${identity.Collapsed} record(s) repeated an ExternalID already `
|
|
2273
|
+
+ `present in the same batch and were collapsed (last occurrence kept). Two records sharing an `
|
|
2274
|
+
+ `identity are one source record observed twice; writing both would insert duplicate rows that `
|
|
2275
|
+
+ `no later sync could reconcile. Sample: ${identity.SampleIDs.join(', ')}`, { object: entityMap.ExternalObjectName, collapsed: identity.Collapsed, sample: identity.SampleIDs });
|
|
2276
|
+
}
|
|
2277
|
+
const mapped = this.fieldMappingEngine.Apply(identity.Records, fieldMaps, entityMap.Entity, excludedSourceNames);
|
|
1637
2278
|
// Custom-key stats: aggregate unmapped keys for EVERY mapped record here —
|
|
1638
2279
|
// before any skip decision — so candidates + sizing stats exist even when the
|
|
1639
2280
|
// content-hash fast path skips the row (the hash basis deliberately excludes them).
|
|
@@ -1653,6 +2294,9 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1653
2294
|
`for this object to stream via per-record content-hash instead.`);
|
|
1654
2295
|
}
|
|
1655
2296
|
}
|
|
2297
|
+
// Batch boundary: verify we STILL own the run before this batch's writes begin.
|
|
2298
|
+
// Throws RunOwnershipLostError (aborting with nothing written) if the fence moved.
|
|
2299
|
+
await this.assertOwnershipAtBoundary();
|
|
1656
2300
|
// Serialize the match READ too (record-map / PK lookups). On a shared provider connection
|
|
1657
2301
|
// a read routes through whatever transaction is active, so a match read in this stream
|
|
1658
2302
|
// collides with another concurrent stream's in-flight write transaction ("Transaction has
|
|
@@ -1735,6 +2379,25 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1735
2379
|
if (isKeysetConnector && currentAfterKey) {
|
|
1736
2380
|
await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
|
|
1737
2381
|
}
|
|
2382
|
+
// The WATERMARK twin of the keyset floor above. Without it, a watermark-based
|
|
2383
|
+
// connector had NO durable position at all until the run ended: a SIGKILL / OOM /
|
|
2384
|
+
// container recycle mid-object threw away hours of applied batches and the next run
|
|
2385
|
+
// re-fetched the entire window from the last completed run's watermark. Same safety
|
|
2386
|
+
// argument as the graceful early-exit save below — currentWatermark only ever
|
|
2387
|
+
// advances at the END of a fully-applied batch, so this floor can never point past a
|
|
2388
|
+
// record that was not written. Gated on !hadFetchGap because a skipped page may
|
|
2389
|
+
// contain records BEHIND the max watermark seen (fetch order is not watermark order
|
|
2390
|
+
// on every source), i.e. a hole behind the floor; once a gap exists the floor stops
|
|
2391
|
+
// moving, and the one already written is retracted at the gap site. Skipped for
|
|
2392
|
+
// keyset connectors (their position IS the seek key above) and partition-reconcile
|
|
2393
|
+
// maps (their watermark row stores the rollup snapshot, not a timestamp).
|
|
2394
|
+
if (!isKeysetConnector && !partitionReconcile && !hadFetchGap
|
|
2395
|
+
&& currentWatermark && currentWatermark !== initialWatermark
|
|
2396
|
+
&& currentWatermark !== watermarkFloorSaved) {
|
|
2397
|
+
const floor = currentWatermark;
|
|
2398
|
+
await this.runWriteExclusive(() => this.watermarkService.Update(entityMapID, floor, contextUser, 'Pull'));
|
|
2399
|
+
watermarkFloorSaved = floor;
|
|
2400
|
+
}
|
|
1738
2401
|
}
|
|
1739
2402
|
// P3-D: a connector returning empty pages with HasMore=true would otherwise spin silently
|
|
1740
2403
|
// to MAX_BATCHES_PER_MAP. Surface a structured warning once the empty streak crosses the
|
|
@@ -1819,6 +2482,26 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1819
2482
|
await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
|
|
1820
2483
|
result.WatermarkAfter = currentAfterKey;
|
|
1821
2484
|
}
|
|
2485
|
+
else if (!hadFetchGap && currentWatermark && currentWatermark !== initialWatermark) {
|
|
2486
|
+
// A WATERMARK-based connector stopped early (cancel / safety limit / duplicate batch /
|
|
2487
|
+
// schema-not-generated / unskippable fetch error) but whole batches DID complete. Persist the
|
|
2488
|
+
// max watermark seen so the next run resumes from there instead of re-fetching everything
|
|
2489
|
+
// since the last clean run — the counterpart of the keyset branch above, which until now was
|
|
2490
|
+
// the ONLY early-stop that saved its position. The cancel log has always said "saving
|
|
2491
|
+
// watermark"; for a non-keyset connector it previously saved nothing.
|
|
2492
|
+
//
|
|
2493
|
+
// Safe because currentWatermark only ever advances at the END of a fully-applied batch
|
|
2494
|
+
// (§10, after ApplyRecords) — every early-exit `break` above happens before that, so this
|
|
2495
|
+
// value can never point past a record that wasn't written.
|
|
2496
|
+
//
|
|
2497
|
+
// Deliberately NOT wall-clock "now", even for a full sync: coverage is partial, so advancing
|
|
2498
|
+
// past the point actually reached would skip the (reached, now] window permanently. And
|
|
2499
|
+
// deliberately NOT when hadFetchGap — a skipped page leaves a HOLE behind this watermark,
|
|
2500
|
+
// which is why that path holds it for a full re-fetch next run.
|
|
2501
|
+
const partialWatermark = currentWatermark;
|
|
2502
|
+
await this.runWriteExclusive(() => this.watermarkService.Update(entityMapID, partialWatermark, contextUser, 'Pull'));
|
|
2503
|
+
result.WatermarkAfter = partialWatermark;
|
|
2504
|
+
}
|
|
1822
2505
|
// Orphan detection: delete/tombstone MJ records whose external counterpart no longer exists.
|
|
1823
2506
|
// Runs on a full sync OR a partition-reconcile (both fetch the COMPLETE set, so an MJ record
|
|
1824
2507
|
// whose ExternalID isn't in fetchedExternalIDs is genuinely gone — even one inside an otherwise
|
|
@@ -1857,6 +2540,59 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1857
2540
|
await this.CreateRunDetail(run, entityMap, result, contextUser);
|
|
1858
2541
|
return result;
|
|
1859
2542
|
}
|
|
2543
|
+
/**
|
|
2544
|
+
* Classifies a fetched batch against the `ctx.BatchSize` the engine asked for. Returns null when
|
|
2545
|
+
* the batch is within contract, so the caller can treat "no verdict" as "nothing to warn about".
|
|
2546
|
+
*
|
|
2547
|
+
* Two severities, because they are different defects:
|
|
2548
|
+
* - `CONNECTOR_UNBOUNDED_BATCH` — the FIRST batch came back over-size AND `HasMore` is not true,
|
|
2549
|
+
* i.e. the connector ignored pagination entirely and pulled the whole object into memory in one
|
|
2550
|
+
* request. That is what OOMs a large tenant, and it grows silently with the customer's data.
|
|
2551
|
+
* - `CONNECTOR_IGNORED_BATCH_SIZE` — the connector IS paging but overshoots the requested size
|
|
2552
|
+
* (e.g. a hardcoded page size). Bounded memory, still a contract violation worth fixing.
|
|
2553
|
+
*
|
|
2554
|
+
* Public + static because the decision is the interesting part and deserves a unit test that
|
|
2555
|
+
* doesn't have to stand up a whole sync (same rationale as {@link RatchetProgressSnapshot}).
|
|
2556
|
+
*/
|
|
2557
|
+
static ClassifyOversizedBatch(objectName, recordCount, requestedBatchSize, batchIndex, hasMore) {
|
|
2558
|
+
if (recordCount <= requestedBatchSize)
|
|
2559
|
+
return null;
|
|
2560
|
+
const unbounded = batchIndex === 1 && hasMore !== true;
|
|
2561
|
+
return unbounded
|
|
2562
|
+
? {
|
|
2563
|
+
Code: 'CONNECTOR_UNBOUNDED_BATCH',
|
|
2564
|
+
Unbounded: true,
|
|
2565
|
+
Message: `'${objectName}': connector returned ALL ${recordCount} records in a single batch ` +
|
|
2566
|
+
`(requested ${requestedBatchSize}, HasMore=false) — pagination is not implemented for this ` +
|
|
2567
|
+
`object, so the entire object is held in memory. Every record was written, in chunks.`,
|
|
2568
|
+
}
|
|
2569
|
+
: {
|
|
2570
|
+
Code: 'CONNECTOR_IGNORED_BATCH_SIZE',
|
|
2571
|
+
Unbounded: false,
|
|
2572
|
+
Message: `'${objectName}': connector returned ${recordCount} records for batch ${batchIndex} ` +
|
|
2573
|
+
`(requested ${requestedBatchSize}) — ctx.BatchSize is not being honoured. Every record was ` +
|
|
2574
|
+
`written, in chunks. Warned once per object.`,
|
|
2575
|
+
};
|
|
2576
|
+
}
|
|
2577
|
+
/**
|
|
2578
|
+
* Surfaces an over-size batch onto the structured run-event stream (queryable via
|
|
2579
|
+
* IntegrationTailRunEvents) so the pagination-rule violation is visible instead of console-only.
|
|
2580
|
+
* Never truncates and never fails the sync — the over-size batch is written in chunks either way.
|
|
2581
|
+
*/
|
|
2582
|
+
warnOversizedBatch(entityMap, batch, batchIndex, logger) {
|
|
2583
|
+
const objectName = entityMap.ExternalObjectName ?? entityMap.ID;
|
|
2584
|
+
const verdict = IntegrationEngine.ClassifyOversizedBatch(objectName, batch.Records.length, this.MaxBatchSize, batchIndex, batch.HasMore);
|
|
2585
|
+
if (!verdict)
|
|
2586
|
+
return;
|
|
2587
|
+
logger?.warning(objectName, verdict.Code, verdict.Message, {
|
|
2588
|
+
batchIndex,
|
|
2589
|
+
recordCount: batch.Records.length,
|
|
2590
|
+
requestedBatchSize: this.MaxBatchSize,
|
|
2591
|
+
hasMore: batch.HasMore ?? null,
|
|
2592
|
+
unbounded: verdict.Unbounded,
|
|
2593
|
+
});
|
|
2594
|
+
console.warn(`[IntegrationEngine] ${verdict.Message}`);
|
|
2595
|
+
}
|
|
1860
2596
|
/**
|
|
1861
2597
|
* Push sync: detect local MJ record changes → reverse-map fields → push to external system.
|
|
1862
2598
|
*
|
|
@@ -1867,6 +2603,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1867
2603
|
async ProcessPushSync(config, entityMap, run, contextUser, _entityMapIndex, _totalEntityMaps, _onProgress, _abortSignal, logger) {
|
|
1868
2604
|
const entityMapID = entityMap.ID;
|
|
1869
2605
|
const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
|
|
2606
|
+
this.WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger);
|
|
1870
2607
|
const pushWatermark = await this.watermarkService.Load(entityMapID, contextUser, 'Push');
|
|
1871
2608
|
const lastPushAt = pushWatermark?.WatermarkValue ?? null;
|
|
1872
2609
|
// Check connector write capability
|
|
@@ -1924,7 +2661,19 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1924
2661
|
// lexicographically, matching the SQL `ChangedAt > '...'` filter in LoadChangedMJRecords.
|
|
1925
2662
|
let firstErrorChangeAt = null; // min ChangedAt among failed pushes
|
|
1926
2663
|
const successfulChangeAts = [];
|
|
2664
|
+
let recordsSinceBoundary = 0;
|
|
1927
2665
|
for (const change of changedRecords) {
|
|
2666
|
+
// Push has no natural batch, so impose a fence boundary every MaxBatchSize records:
|
|
2667
|
+
// a reclaimed run must stop writing to the VENDOR promptly, not at end-of-map. The
|
|
2668
|
+
// check sits OUTSIDE the per-record try so RunOwnershipLostError propagates (the
|
|
2669
|
+
// per-record catch must never swallow it). A cross-process cancel trips the abort
|
|
2670
|
+
// signal, honored at the top of each iteration.
|
|
2671
|
+
if (recordsSinceBoundary === 0)
|
|
2672
|
+
await this.assertOwnershipAtBoundary();
|
|
2673
|
+
if (++recordsSinceBoundary >= this.MaxBatchSize)
|
|
2674
|
+
recordsSinceBoundary = 0;
|
|
2675
|
+
if (_abortSignal?.aborted)
|
|
2676
|
+
break;
|
|
1928
2677
|
result.RecordsProcessed++;
|
|
1929
2678
|
try {
|
|
1930
2679
|
await this.PushSingleRecord(change, config, entityMap, pushFieldMaps, result, contextUser, logger);
|
|
@@ -2361,12 +3110,25 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2361
3110
|
const orphans = allMaps.Rows.filter(m => !fetchedExternalIDs.has(m.ExternalSystemRecordID));
|
|
2362
3111
|
if (orphans.length === 0)
|
|
2363
3112
|
return;
|
|
3113
|
+
// The sweep is a DELETE PATH and must answer to the same policy as every other delete.
|
|
3114
|
+
// It used to call entity.Delete() unconditionally — its own warning text promised
|
|
3115
|
+
// "archived/deleted" while the code only ever deleted, so a map configured SoftDelete
|
|
3116
|
+
// (or DoNothing) had its rows physically removed by full syncs. DoNothing short-circuits
|
|
3117
|
+
// the whole sweep: the policy says external deletions never touch MJ rows, and saying so
|
|
3118
|
+
// once beats detecting the same "orphans" forever.
|
|
3119
|
+
if (entityMap.DeleteBehavior === 'DoNothing') {
|
|
3120
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_POLICY_SKIPPED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this ` +
|
|
3121
|
+
`full sync. This map's DeleteBehavior is 'DoNothing', so none were touched.`, { orphanCount: orphans.length });
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
2364
3124
|
console.log(`[IntegrationEngine] Orphan detection for ${entityMap.ExternalObjectName}: ${orphans.length} records in MJ not found in external system`);
|
|
2365
3125
|
// Surface delete-detection in the structured stream (previously console-only). The orphan
|
|
2366
3126
|
// COUNT is already in the run counts via RecordsDeleted, but a dedicated warning makes a
|
|
2367
3127
|
// large/unexpected count visible over GraphQL — the early signal of an incomplete upstream
|
|
2368
3128
|
// fetch silently archiving live records.
|
|
2369
|
-
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_DETECTED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this full sync —
|
|
3129
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_DETECTED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this full sync — ` +
|
|
3130
|
+
`they will be ${entityMap.DeleteBehavior === 'SoftDelete' ? 'archived (SoftDelete)' : 'deleted'} and their record-map rows pruned (delete-detection). ` +
|
|
3131
|
+
`A large or unexpected count can indicate an incomplete upstream fetch, so review before trusting the deletions.`, { orphanCount: orphans.length });
|
|
2370
3132
|
const md = this.ProviderToUse;
|
|
2371
3133
|
const entityInfo = md.EntityByName(entityMap.Entity);
|
|
2372
3134
|
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
@@ -2376,11 +3138,44 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2376
3138
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(orphan.EntityRecordID, pkFields));
|
|
2377
3139
|
if (!loaded) {
|
|
2378
3140
|
console.log(`[IntegrationEngine] Orphan ${orphan.EntityRecordID} already deleted from MJ`);
|
|
3141
|
+
// The map row outlived its record. Nothing anywhere else deletes record-map
|
|
3142
|
+
// rows, so without this the same "orphan" is re-detected on EVERY subsequent
|
|
3143
|
+
// full sync and ORPHANS_DETECTED becomes a cumulative counter of history
|
|
3144
|
+
// rather than a signal about THIS run — observed live as a count that only
|
|
3145
|
+
// ever grew, sync after sync.
|
|
3146
|
+
await this.DeleteRecordMapRow(orphan.ID, contextUser);
|
|
3147
|
+
continue;
|
|
3148
|
+
}
|
|
3149
|
+
if (entityMap.DeleteBehavior === 'SoftDelete') {
|
|
3150
|
+
// Same archive shape as DeleteRecord's SoftDelete branch: the row stays,
|
|
3151
|
+
// marked Archived/tombstoned. The MAP row goes either way — the mapping's
|
|
3152
|
+
// job is done, and keeping it would re-detect this orphan forever.
|
|
3153
|
+
const fields = entity.Fields ?? [];
|
|
3154
|
+
const hasField = (n) => fields.some(f => f.Name === n);
|
|
3155
|
+
if (hasField('__mj_integration_SyncStatus'))
|
|
3156
|
+
entity.Set('__mj_integration_SyncStatus', 'Archived');
|
|
3157
|
+
if (hasField('__mj_integration_LastSyncedAt'))
|
|
3158
|
+
entity.Set('__mj_integration_LastSyncedAt', new Date().toISOString());
|
|
3159
|
+
if (hasField('__mj_integration_IsTombstoned'))
|
|
3160
|
+
entity.Set('__mj_integration_IsTombstoned', true);
|
|
3161
|
+
if (hasField('__mj_integration_DeletedDetectedAt'))
|
|
3162
|
+
entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
|
|
3163
|
+
const archived = await entity.Save();
|
|
3164
|
+
if (archived) {
|
|
3165
|
+
result.RecordsDeleted++;
|
|
3166
|
+
await this.DeleteRecordMapRow(orphan.ID, contextUser);
|
|
3167
|
+
console.log(`[IntegrationEngine] Archived orphan ${entityMap.Entity} ${orphan.EntityRecordID} (external ${orphan.ExternalSystemRecordID} no longer exists)`);
|
|
3168
|
+
}
|
|
3169
|
+
else {
|
|
3170
|
+
const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
|
|
3171
|
+
console.warn(`[IntegrationEngine] Orphan archive blocked for ${entityMap.Entity} ${orphan.EntityRecordID} — ${reason}`);
|
|
3172
|
+
}
|
|
2379
3173
|
continue;
|
|
2380
3174
|
}
|
|
2381
3175
|
const deleted = await entity.Delete();
|
|
2382
3176
|
if (deleted) {
|
|
2383
3177
|
result.RecordsDeleted++;
|
|
3178
|
+
await this.DeleteRecordMapRow(orphan.ID, contextUser);
|
|
2384
3179
|
console.log(`[IntegrationEngine] Deleted orphan ${entityMap.Entity} ${orphan.EntityRecordID} (external ${orphan.ExternalSystemRecordID} no longer exists)`);
|
|
2385
3180
|
}
|
|
2386
3181
|
else {
|
|
@@ -2394,6 +3189,28 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2394
3189
|
}
|
|
2395
3190
|
}
|
|
2396
3191
|
}
|
|
3192
|
+
/**
|
|
3193
|
+
* Removes one 'MJ: Company Integration Record Maps' row after delete-detection has handled
|
|
3194
|
+
* its orphan (deleted, archived, or found already gone). A failure here is logged and
|
|
3195
|
+
* swallowed: the orphan itself was handled, and the worst consequence of a surviving map
|
|
3196
|
+
* row is one redundant re-detection on the next full sync.
|
|
3197
|
+
*/
|
|
3198
|
+
async DeleteRecordMapRow(mapRowID, contextUser) {
|
|
3199
|
+
try {
|
|
3200
|
+
const md = this.ProviderToUse;
|
|
3201
|
+
const mapRow = await md.GetEntityObject('MJ: Company Integration Record Maps', contextUser);
|
|
3202
|
+
const loaded = await mapRow.InnerLoad(CompositeKey.FromID(mapRowID));
|
|
3203
|
+
if (!loaded)
|
|
3204
|
+
return;
|
|
3205
|
+
const ok = await mapRow.Delete();
|
|
3206
|
+
if (!ok) {
|
|
3207
|
+
console.warn(`[IntegrationEngine] Record-map prune blocked for ${mapRowID} — ${mapRow.LatestResult?.CompleteMessage ?? 'unknown reason'}`);
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
catch (err) {
|
|
3211
|
+
console.warn(`[IntegrationEngine] Record-map prune failed for ${mapRowID}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
2397
3214
|
/**
|
|
2398
3215
|
* Strips field-name prefixes from a RecordChange.RecordID to get the raw entity record ID.
|
|
2399
3216
|
* "hs_object_id|465950833372" → "465950833372"
|
|
@@ -2515,6 +3332,8 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2515
3332
|
result.RecordsSkipped += recs.length;
|
|
2516
3333
|
continue;
|
|
2517
3334
|
}
|
|
3335
|
+
// Partition boundary = a batch boundary: fence-check before this partition's writes.
|
|
3336
|
+
await this.assertOwnershipAtBoundary();
|
|
2518
3337
|
// D3: serialize the match READ through the same write-mutex the non-partition path uses
|
|
2519
3338
|
// (~line 1644). matchEngine.Resolve reads existing MJ rows on the SHARED provider
|
|
2520
3339
|
// connection, so when streams run in parallel (syncConcurrency>1) it must not interleave
|
|
@@ -2844,7 +3663,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2844
3663
|
try {
|
|
2845
3664
|
switch (record.ChangeType) {
|
|
2846
3665
|
case 'Create': {
|
|
2847
|
-
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
|
|
3666
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
|
|
2848
3667
|
if (outcome === 'updated')
|
|
2849
3668
|
result.RecordsUpdated++;
|
|
2850
3669
|
else if (outcome === 'skipped')
|
|
@@ -2854,7 +3673,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2854
3673
|
break;
|
|
2855
3674
|
}
|
|
2856
3675
|
case 'Update':
|
|
2857
|
-
await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps);
|
|
3676
|
+
await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps, logger);
|
|
2858
3677
|
break;
|
|
2859
3678
|
case 'Delete': {
|
|
2860
3679
|
const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
|
|
@@ -2914,7 +3733,9 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2914
3733
|
*
|
|
2915
3734
|
* @returns true if an existing row was updated, false if a new row was inserted (so the caller counts correctly).
|
|
2916
3735
|
*/
|
|
2917
|
-
async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps
|
|
3736
|
+
async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps,
|
|
3737
|
+
/** Optional — lets the keyless-key guard below surface on the run's event stream. */
|
|
3738
|
+
keylessLogger) {
|
|
2918
3739
|
const md = this.ProviderToUse;
|
|
2919
3740
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
2920
3741
|
const entityInfo = md.EntityByName(record.MJEntityName);
|
|
@@ -2923,6 +3744,31 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
2923
3744
|
// ID), check whether that row already exists before deciding INSERT vs UPDATE. A null mappedPK
|
|
2924
3745
|
// (e.g. a server-assigned UUID PK not present in the mapped fields) means a genuinely new row.
|
|
2925
3746
|
const mappedPK = this.extractMappedPrimaryKey(record, pkFields);
|
|
3747
|
+
// INVARIANT: a record destined for a SOFT-primary-key table must carry its key.
|
|
3748
|
+
//
|
|
3749
|
+
// A soft PK is INFERRED, not generated — it is the external system's own identifier, stored
|
|
3750
|
+
// as ordinary data with no PRIMARY KEY constraint, no identity, and no unique index (see
|
|
3751
|
+
// DDLGenerator: soft keys get a non-unique index only). So nothing at the database level
|
|
3752
|
+
// rejects a NULL key, and a row written without one can never be matched again: the next
|
|
3753
|
+
// sync's existence check misses it, and inserts another copy. Every pass therefore multiplies
|
|
3754
|
+
// the damage silently — the rows look fully populated, only the key column is empty.
|
|
3755
|
+
//
|
|
3756
|
+
// `mappedPK == null` is legitimate ONLY when the destination generates its own key (an
|
|
3757
|
+
// identity column or a server-assigned UUID), which is why the check is scoped to soft PKs
|
|
3758
|
+
// rather than applied to every table.
|
|
3759
|
+
const keyless = DecideKeylessRefusal(mappedPK, pkFields, MissingKeyFieldNames(record.MappedFields, pkFields, serializeKeyValue));
|
|
3760
|
+
if (keyless.Refuse) {
|
|
3761
|
+
const detail = DescribeKeylessRefusal(record.MJEntityName, keyless.KeyNames);
|
|
3762
|
+
keylessLogger?.emit('sync.record.error', {
|
|
3763
|
+
phase: 'write',
|
|
3764
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
3765
|
+
externalID: record.ExternalRecord?.ExternalID,
|
|
3766
|
+
error: `KEYLESS_RECORD_REFUSED: ${detail}`,
|
|
3767
|
+
});
|
|
3768
|
+
// Reported even without a logger — silence is the failure mode this guard exists to end.
|
|
3769
|
+
console.error(`[IntegrationEngine] KEYLESS_RECORD_REFUSED: ${detail}`);
|
|
3770
|
+
return 'skipped';
|
|
3771
|
+
}
|
|
2926
3772
|
const existed = mappedPK != null
|
|
2927
3773
|
? await entity.InnerLoad(this.BuildEntityPrimaryKey(mappedPK, pkFields))
|
|
2928
3774
|
: false;
|
|
@@ -3008,10 +3854,12 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3008
3854
|
* If the record cannot be loaded (e.g. it was deleted or never fully created),
|
|
3009
3855
|
* falls back to CreateRecord (upsert behavior).
|
|
3010
3856
|
*/
|
|
3011
|
-
async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps
|
|
3857
|
+
async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps,
|
|
3858
|
+
/** Forwarded to CreateRecord's keyless-key guard on the upsert fallback paths. */
|
|
3859
|
+
logger) {
|
|
3012
3860
|
if (!record.MatchedMJRecordID) {
|
|
3013
3861
|
// No matched ID — upsert by PK (insert; or update/skip if the PK already exists)
|
|
3014
|
-
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
|
|
3862
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
|
|
3015
3863
|
if (outcome === 'updated')
|
|
3016
3864
|
result.RecordsUpdated++;
|
|
3017
3865
|
else if (outcome === 'skipped')
|
|
@@ -3059,7 +3907,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3059
3907
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
|
|
3060
3908
|
if (!loaded) {
|
|
3061
3909
|
// Matched-ID row vanished — fall back to upsert by PK (insert; or update/skip if PK exists)
|
|
3062
|
-
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
|
|
3910
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
|
|
3063
3911
|
if (outcome === 'updated')
|
|
3064
3912
|
result.RecordsUpdated++;
|
|
3065
3913
|
else if (outcome === 'skipped')
|
|
@@ -3259,6 +4107,31 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3259
4107
|
}
|
|
3260
4108
|
return key;
|
|
3261
4109
|
}
|
|
4110
|
+
/**
|
|
4111
|
+
* Reports ACTIVE field maps whose MJ column does not exist, once per entity map per run.
|
|
4112
|
+
*
|
|
4113
|
+
* `BaseEntity.Set` no-ops on an unknown field — no throw, no log, no dirty flag — so a map
|
|
4114
|
+
* pointing at a column that was never applied (or was renamed) drops its value for every
|
|
4115
|
+
* record while the run reports those records as written. Checking it here costs one metadata
|
|
4116
|
+
* read and happens before the first fetch, so the warning arrives before the wasted work.
|
|
4117
|
+
*/
|
|
4118
|
+
WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger) {
|
|
4119
|
+
const entityName = entityMap.Entity ?? '';
|
|
4120
|
+
// Diagnostics must never be able to fail a run: an unresolvable entity/provider is reported
|
|
4121
|
+
// by the paths that actually need it, and here it simply means there is nothing to check.
|
|
4122
|
+
let entityFieldNames = [];
|
|
4123
|
+
try {
|
|
4124
|
+
const entityInfo = entityName ? this.ProviderToUse?.EntityByName(entityName) : null;
|
|
4125
|
+
entityFieldNames = entityInfo?.Fields?.map(f => f.Name) ?? [];
|
|
4126
|
+
}
|
|
4127
|
+
catch {
|
|
4128
|
+
return;
|
|
4129
|
+
}
|
|
4130
|
+
const unbindable = FindUnbindableFieldMaps(fieldMaps, entityFieldNames);
|
|
4131
|
+
if (unbindable.length === 0)
|
|
4132
|
+
return;
|
|
4133
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FIELD_MAP_DESTINATION_MISSING', DescribeUnbindableFieldMaps(unbindable, entityMap.ExternalObjectName ?? entityMap.ID, entityName), { fieldMaps: unbindable });
|
|
4134
|
+
}
|
|
3262
4135
|
/**
|
|
3263
4136
|
* Sets fields on a BaseEntity instance from a field value map.
|
|
3264
4137
|
*/
|
|
@@ -3619,12 +4492,19 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3619
4492
|
const existing = await rv.RunView({
|
|
3620
4493
|
EntityName: 'MJ: Company Integration Record Maps',
|
|
3621
4494
|
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND EntityID='${entityID}' AND ExternalSystemRecordID=${quotedExternalID}`,
|
|
3622
|
-
Fields: ['ID'],
|
|
4495
|
+
Fields: ['ID', 'EntityRecordID'],
|
|
3623
4496
|
MaxRows: 1,
|
|
3624
4497
|
ResultType: 'simple',
|
|
3625
4498
|
BypassCache: true, // upsert-by-identity: a stale miss here re-creates a duplicate record map
|
|
3626
4499
|
}, contextUser);
|
|
3627
4500
|
if (existing.Success && existing.Results.length > 0) {
|
|
4501
|
+
// The row already says exactly this. On an incremental sync that is nearly every record
|
|
4502
|
+
// — mappings are stable — and the Load + Save this path used to do anyway were two more
|
|
4503
|
+
// round trips to change nothing, plus a meaningless __mj_UpdatedAt bump on every synced
|
|
4504
|
+
// record's map row. The batched writer (RecordMapBatch.flushChunk) has always skipped
|
|
4505
|
+
// this case; the per-record fallback now agrees with it.
|
|
4506
|
+
if (existing.Results[0].EntityRecordID === entityRecordID)
|
|
4507
|
+
return;
|
|
3628
4508
|
const loaded = await recordMap.Load(existing.Results[0].ID);
|
|
3629
4509
|
if (!loaded)
|
|
3630
4510
|
recordMap.NewRecord();
|
|
@@ -3748,23 +4628,36 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3748
4628
|
async FinalizeRun(run, result, contextUser, onNotification, aborted) {
|
|
3749
4629
|
run.EndedAt = new Date();
|
|
3750
4630
|
run.TotalRecords = result.RecordsProcessed;
|
|
3751
|
-
// A
|
|
3752
|
-
//
|
|
3753
|
-
//
|
|
3754
|
-
//
|
|
3755
|
-
//
|
|
3756
|
-
//
|
|
3757
|
-
//
|
|
4631
|
+
// A cancelled run is neither a success nor a failure, and 'Cancelled' is now a first-class
|
|
4632
|
+
// value in the Status list — so record it as itself. Previously this had to be 'Failed' with
|
|
4633
|
+
// an explanatory ErrorLog, which meant every health/cadence consumer counted deliberate
|
|
4634
|
+
// cancellations as errors unless it string-matched that text. The ErrorLog is still written
|
|
4635
|
+
// (it carries the reason), and the durable progress artifact still carries
|
|
4636
|
+
// exitReason='aborted' (see finalizeSyncProgress).
|
|
4637
|
+
// Held in a local so the release below sends the SAME value. Deriving it there from
|
|
4638
|
+
// `run.Status` with a two-way test collapsed everything non-Success to 'Failed', which would
|
|
4639
|
+
// have overwritten 'Cancelled' in the release UPDATE — the sproc assigns Status = @FinalStatus,
|
|
4640
|
+
// so the row's carefully-set status would be undone one statement later.
|
|
4641
|
+
let terminalStatus;
|
|
3758
4642
|
if (aborted) {
|
|
3759
|
-
|
|
4643
|
+
terminalStatus = 'Cancelled';
|
|
3760
4644
|
run.ErrorLog = result.ErrorMessage ?? 'Sync cancelled by user';
|
|
3761
4645
|
}
|
|
3762
4646
|
else {
|
|
3763
|
-
|
|
4647
|
+
terminalStatus = result.RecordsErrored > 0 ? 'Failed' : 'Success';
|
|
3764
4648
|
if (result.Errors.length > 0) {
|
|
3765
4649
|
run.ErrorLog = JSON.stringify(result.Errors.slice(0, 100));
|
|
3766
4650
|
}
|
|
3767
4651
|
}
|
|
4652
|
+
run.Status = terminalStatus;
|
|
4653
|
+
// The generated spUpdate writes EVERY column from the entity's in-memory state.
|
|
4654
|
+
// This run entity was loaded BEFORE the claim, so without a sync its ownership
|
|
4655
|
+
// columns (FenceToken 0, OwnerToken null, stale lease) would clobber the DB's live
|
|
4656
|
+
// values on save — silently un-fencing the run. Sync them to the service's
|
|
4657
|
+
// last-known-authoritative values first; Release() below then clears ownership
|
|
4658
|
+
// atomically (token-checked, so a stale holder's release no-ops).
|
|
4659
|
+
const ownership = this.currentRunContext?.ownership;
|
|
4660
|
+
ownership?.SyncEntityOwnershipFields(run);
|
|
3768
4661
|
// Retry the finalize save: a failed save leaves the run 'In Progress', which ResumeOrphanedSyncs
|
|
3769
4662
|
// re-queues on next startup → the whole sync re-runs (re-fetch + re-apply). Worth a few retries to
|
|
3770
4663
|
// make the terminal status durable. Both a thrown infra error and a `false` logical-failure retry.
|
|
@@ -3781,6 +4674,16 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3781
4674
|
`${saveErr instanceof Error ? saveErr.message : String(saveErr)}. ` +
|
|
3782
4675
|
`Run may remain 'In Progress' and be re-queued as orphaned on next startup.`);
|
|
3783
4676
|
}
|
|
4677
|
+
// Terminal release: clear OwnerToken/LeaseExpiresAt and (re-)stamp the final status
|
|
4678
|
+
// in one token-checked statement, so the row is immediately claimable-clean.
|
|
4679
|
+
if (ownership) {
|
|
4680
|
+
try {
|
|
4681
|
+
await ownership.Release(terminalStatus);
|
|
4682
|
+
}
|
|
4683
|
+
catch (releaseErr) {
|
|
4684
|
+
console.warn(`[IntegrationEngine] Run ${run.ID} release failed (non-fatal — lease will simply expire): ${releaseErr instanceof Error ? releaseErr.message : String(releaseErr)}`);
|
|
4685
|
+
}
|
|
4686
|
+
}
|
|
3784
4687
|
if (onNotification) {
|
|
3785
4688
|
const notification = this.buildCompletionNotification(run, result);
|
|
3786
4689
|
this.safeNotify(onNotification, notification);
|
|
@@ -3850,7 +4753,17 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3850
4753
|
run.EndedAt = new Date();
|
|
3851
4754
|
run.Status = 'Failed';
|
|
3852
4755
|
run.ErrorLog = err instanceof Error ? err.message : String(err);
|
|
4756
|
+
// Same full-row-save hazard as FinalizeRun: sync the in-memory ownership columns to the
|
|
4757
|
+
// claim's live values before Save, then release (token-checked — a fenced-out holder no-ops).
|
|
4758
|
+
const ownership = this.currentRunContext?.ownership;
|
|
4759
|
+
ownership?.SyncEntityOwnershipFields(run);
|
|
3853
4760
|
await run.Save();
|
|
4761
|
+
if (ownership) {
|
|
4762
|
+
try {
|
|
4763
|
+
await ownership.Release('Failed');
|
|
4764
|
+
}
|
|
4765
|
+
catch { /* non-fatal — lease will expire */ }
|
|
4766
|
+
}
|
|
3854
4767
|
if (onNotification) {
|
|
3855
4768
|
const failResult = {
|
|
3856
4769
|
Success: false,
|
|
@@ -3968,7 +4881,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
3968
4881
|
CompanyIntegrationID: companyIntegrationID,
|
|
3969
4882
|
ContextUser: contextUser,
|
|
3970
4883
|
SyncedEntityNames: syncedEntityNames,
|
|
3971
|
-
Provider: this.
|
|
4884
|
+
Provider: this.currentRunContext?.provider,
|
|
3972
4885
|
// The run's in-memory custom-key candidates — needed because the
|
|
3973
4886
|
// overflow-column scan alone under-reports once the hash basis excludes
|
|
3974
4887
|
// overflow (skipped rows never write their overflow JSON).
|
|
@@ -4054,6 +4967,20 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
4054
4967
|
GetIntegrationObjectFields(objectID) {
|
|
4055
4968
|
return this.Base.GetIntegrationObjectFields(objectID);
|
|
4056
4969
|
}
|
|
4970
|
+
/**
|
|
4971
|
+
* Source field names the connector declared as SyncDirective 'Exclude' for one
|
|
4972
|
+
* integration object, read from IntegrationObjectField.Configuration. Empty set
|
|
4973
|
+
* on any lookup miss (unknown object, no fields, no integration id) - a failed
|
|
4974
|
+
* lookup must never widen or narrow the sync beyond its declared behaviour.
|
|
4975
|
+
*/
|
|
4976
|
+
ResolveExcludedSourceNames(integrationID, externalObjectName) {
|
|
4977
|
+
if (!integrationID || !externalObjectName)
|
|
4978
|
+
return new Set();
|
|
4979
|
+
const obj = this.GetIntegrationObject(integrationID, externalObjectName);
|
|
4980
|
+
if (!obj)
|
|
4981
|
+
return new Set();
|
|
4982
|
+
return ComputeExcludedSourceNames(this.GetIntegrationObjectFields(obj.ID));
|
|
4983
|
+
}
|
|
4057
4984
|
GetActiveIntegrationObjects(integrationID) {
|
|
4058
4985
|
return this.Base.GetActiveIntegrationObjects(integrationID);
|
|
4059
4986
|
}
|