@skedulo/integration-sdk 0.11.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +6 -2
  3. package/dist/actions/sync/run-flow.action.js +1 -1
  4. package/dist/actions/sync/run-flow.action.js.map +1 -1
  5. package/dist/actions/sync/run-job.action.d.ts +6 -3
  6. package/dist/actions/sync/run-job.action.d.ts.map +1 -1
  7. package/dist/actions/sync/run-job.action.js +105 -14
  8. package/dist/actions/sync/run-job.action.js.map +1 -1
  9. package/dist/browser.d.ts +4 -3
  10. package/dist/browser.d.ts.map +1 -1
  11. package/dist/browser.js +7 -1
  12. package/dist/browser.js.map +1 -1
  13. package/dist/clients/dataflow.client.d.ts +5 -1
  14. package/dist/clients/dataflow.client.d.ts.map +1 -1
  15. package/dist/clients/dataflow.client.js +11 -1
  16. package/dist/clients/dataflow.client.js.map +1 -1
  17. package/dist/constants.d.ts +8 -0
  18. package/dist/constants.d.ts.map +1 -1
  19. package/dist/constants.js +15 -1
  20. package/dist/constants.js.map +1 -1
  21. package/dist/index.d.ts +4 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +9 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/interfaces/adapter.interface.d.ts +27 -0
  26. package/dist/interfaces/adapter.interface.d.ts.map +1 -1
  27. package/dist/interfaces/data-flow-spec.interface.d.ts +28 -1
  28. package/dist/interfaces/data-flow-spec.interface.d.ts.map +1 -1
  29. package/dist/interfaces/sync-flow-strategy.interface.d.ts +2 -0
  30. package/dist/interfaces/sync-flow-strategy.interface.d.ts.map +1 -1
  31. package/dist/interfaces/sync-result.interface.d.ts +5 -1
  32. package/dist/interfaces/sync-result.interface.d.ts.map +1 -1
  33. package/dist/services/dataflow.service.d.ts +5 -1
  34. package/dist/services/dataflow.service.d.ts.map +1 -1
  35. package/dist/services/dataflow.service.js +3 -0
  36. package/dist/services/dataflow.service.js.map +1 -1
  37. package/dist/services/id-mapping-sync.strategy.d.ts.map +1 -1
  38. package/dist/services/id-mapping-sync.strategy.js +2 -1
  39. package/dist/services/id-mapping-sync.strategy.js.map +1 -1
  40. package/dist/services/run-log-builder.d.ts +23 -1
  41. package/dist/services/run-log-builder.d.ts.map +1 -1
  42. package/dist/services/run-log-builder.js +84 -2
  43. package/dist/services/run-log-builder.js.map +1 -1
  44. package/dist/services/sync-runner.d.ts +3 -0
  45. package/dist/services/sync-runner.d.ts.map +1 -1
  46. package/dist/services/sync-runner.js +434 -60
  47. package/dist/services/sync-runner.js.map +1 -1
  48. package/dist/services/sync.service.d.ts +13 -2
  49. package/dist/services/sync.service.d.ts.map +1 -1
  50. package/dist/services/sync.service.js +133 -51
  51. package/dist/services/sync.service.js.map +1 -1
  52. package/dist/types.d.ts +3 -3
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/types.js.map +1 -1
  55. package/package.json +1 -1
@@ -1,7 +1,19 @@
1
1
  "use strict";
2
+ var __rest = (this && this.__rest) || function (s, e) {
3
+ var t = {};
4
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
5
+ t[p] = s[p];
6
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
7
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
8
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
9
+ t[p[i]] = s[p[i]];
10
+ }
11
+ return t;
12
+ };
2
13
  Object.defineProperty(exports, "__esModule", { value: true });
3
14
  exports.SyncRunner = exports.AdapterNotRegisteredError = exports.AdapterRoleError = void 0;
4
15
  exports.decideCursor = decideCursor;
16
+ exports.syncScope = syncScope;
5
17
  const crypto_1 = require("crypto");
6
18
  const constants_1 = require("../constants");
7
19
  const integration_log_interface_1 = require("../interfaces/integration-log.interface");
@@ -13,6 +25,13 @@ const id_mapping_sync_strategy_1 = require("./id-mapping-sync.strategy");
13
25
  const id_mapping_service_1 = require("./id-mapping.service");
14
26
  const sync_service_1 = require("./sync.service");
15
27
  const helpers_1 = require("../utils/helpers");
28
+ // The largest fields a hold or _countFailedResume can add later to a checkpoint that fit.
29
+ const LATER_FIELDS = {
30
+ holds: 9999,
31
+ heldSince: new Date(0).toISOString(),
32
+ failedResumes: 9999,
33
+ failingSince: new Date(0).toISOString(),
34
+ };
16
35
  const emptyBatchResult = () => ({
17
36
  succeeded: 0,
18
37
  filtered: 0,
@@ -51,10 +70,16 @@ class SyncRunner {
51
70
  // sourceSystem / targetSystem fallbacks are used when the flow's own values are null
52
71
  // (e.g. flows created before these columns were added to the schema).
53
72
  async run(args, ctx) {
54
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
73
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s;
55
74
  // One execution_id per run; correlates the run_summary + record_error logs.
56
75
  const executionId = (0, crypto_1.randomUUID)();
57
76
  const startedAt = Date.now();
77
+ let pendingResume = null;
78
+ let savedPages = 0;
79
+ let unsavedPages = 0;
80
+ let rowsWritten = 0;
81
+ let checkpointedFailed = 0;
82
+ let checkpointedSucceeded = 0;
58
83
  try {
59
84
  const flow = await ctx.dataFlowService.getFlowById(args.flowId);
60
85
  if (!flow) {
@@ -97,40 +122,291 @@ class SyncRunner {
97
122
  const lastRunAt = args.overrideSince !== undefined ? args.overrideSince : ((_f = flow.lastRunAt) !== null && _f !== void 0 ? _f : args.since);
98
123
  const overlapMs = (_h = (_g = args.options) === null || _g === void 0 ? void 0 : _g.cursorOverlapMs) !== null && _h !== void 0 ? _h : 60000;
99
124
  const effectiveCursor = lastRunAt ? new Date(lastRunAt.getTime() - overlapMs) : null; // null = full sync (no prior run)
100
- const result = await new sync_service_1.SyncService(source, target, strategy).run({ flow, since: effectiveCursor }, ctx);
125
+ // Every run but a backfill saves progress. Of the backfills, only a full resync that finishes moves the cursor.
126
+ const resumable = typeof source.fetchResumable === 'function';
127
+ const backfill = args.overrideSince !== undefined;
128
+ const fullResync = args.overrideSince === null;
129
+ const canSave = resumable && !backfill;
130
+ const windowSince = (_j = effectiveCursor === null || effectiveCursor === void 0 ? void 0 : effectiveCursor.toISOString()) !== null && _j !== void 0 ? _j : null;
131
+ const scope = syncScope(flow);
132
+ const saved = flow.syncProgress;
133
+ let prior = canSave && (saved === null || saved === void 0 ? void 0 : saved.scope) === scope && saved.windowSince === windowSince ? saved : null;
134
+ if (canSave && saved && !prior) {
135
+ ctx.logger.warn(`sync_checkpoint_ignored: flowId=${args.flowId} changed=${saved.scope !== scope ? 'scope' : 'window'} — ` +
136
+ `the flow or its cursor changed since the checkpoint was saved; starting the load over`);
137
+ }
138
+ // A saved checkpoint must fit a later hold; a future startedAt would move lastRunAt past the load's start.
139
+ if (prior &&
140
+ (!(Date.parse(prior.startedAt) <= runStartedAt.getTime()) ||
141
+ !hasRoom(prior) ||
142
+ [prior.processed, prior.holds, prior.failedResumes].some(notCount))) {
143
+ ctx.logger.warn(`sync_checkpoint_invalid: flowId=${args.flowId} startedAt="${prior.startedAt}" size=${JSON.stringify(prior).length} — starting the load over`);
144
+ prior = null;
145
+ }
146
+ const loadStartedAt = prior ? new Date(prior.startedAt) : runStartedAt;
147
+ if (prior && runStartedAt.getTime() - loadStartedAt.getTime() > constants_1.SLOW_LOAD_MS) {
148
+ ctx.logger.warn(`sync_load_slow: flowId=${args.flowId} loadStartedAt=${prior.startedAt} processed=${prior.processed} — ` +
149
+ `this load started over ${constants_1.SLOW_LOAD_MS / 3600000} hours ago and hasn't finished; check its run logs for held pages or failed runs`);
150
+ }
151
+ pendingResume = (prior === null || prior === void 0 ? void 0 : prior.token) ? prior : null;
152
+ let last = prior;
153
+ let processed = (_k = prior === null || prior === void 0 ? void 0 : prior.processed) !== null && _k !== void 0 ? _k : 0;
154
+ let heldTries = 0;
155
+ let unrecordableAfterHolds = 0;
156
+ let unrecordableAtOnce = 0;
157
+ let tooLarge = false;
158
+ let droppedRows = 0;
159
+ const recordedErrors = new Set();
160
+ const countHeld = (reason) => ctx.metrics.counter('sync.checkpoint.held', 1, {
161
+ integrationType: ctx.integrationType,
162
+ reason,
163
+ });
164
+ const fits = (progress) => {
165
+ if (hasRoom(progress))
166
+ return true;
167
+ const size = JSON.stringify(progress).length;
168
+ if (tooLarge)
169
+ return false;
170
+ tooLarge = true;
171
+ ctx.logger.error(`sync_checkpoint_too_large: flowId=${args.flowId} size=${size} max=${constants_1.MAX_SYNC_PROGRESS_CHARS} — the adapter's resume token is too large to save; skipping the save for that page`);
172
+ ctx.metrics.counter('sync.checkpoint.too_large', 1, {
173
+ integrationType: ctx.integrationType,
174
+ });
175
+ return false;
176
+ };
177
+ const capRows = (entries) => canSave ? entries : entries.slice(0, run_log_builder_1.MAX_RECORD_ERRORS - rowsWritten);
178
+ const saveProgress = async (progress) => {
179
+ await ctx.dataFlowService.patchCursor(args.flowId, { syncProgress: progress });
180
+ last = progress;
181
+ pendingResume = null;
182
+ };
183
+ const checkpoint = async (token, pageErrors, recordCount, succeeded, failed) => {
184
+ var _a, _b;
185
+ const entries = this._buildFailureEntries(ctx, flow, source, executionId, pageErrors);
186
+ const outage = looksLikeOutage(succeeded, entries);
187
+ const holdReason = entries.length < pageErrors.length || (failed > 0 && pageErrors.length === 0)
188
+ ? 'unrecorded_failures'
189
+ : outage
190
+ ? 'outage_suspected'
191
+ : undefined;
192
+ const progress = {
193
+ token,
194
+ windowSince,
195
+ scope,
196
+ startedAt: loadStartedAt.toISOString(),
197
+ processed: processed + recordCount,
198
+ };
199
+ let skipped;
200
+ let skippedAfterHolds = false;
201
+ if (holdReason) {
202
+ const { holds, heldSince, movePast } = nextHold(last);
203
+ const held = Object.assign(Object.assign({}, progress), { token: (_a = last === null || last === void 0 ? void 0 : last.token) !== null && _a !== void 0 ? _a : null, processed: (_b = last === null || last === void 0 ? void 0 : last.processed) !== null && _b !== void 0 ? _b : 0, holds,
204
+ heldSince });
205
+ const saveHold = canSave && !movePast;
206
+ // A run that can't save has no hold count, so only an outage stops it: waiting can't give a record an id.
207
+ const stopReason = saveHold
208
+ ? holdReason
209
+ : !canSave && outage
210
+ ? 'outage_suspected'
211
+ : undefined;
212
+ if (stopReason) {
213
+ if (saveHold) {
214
+ try {
215
+ await saveProgress(held);
216
+ heldTries = holds;
217
+ }
218
+ catch (err) {
219
+ ctx.logger.error(`sync_checkpoint_hold_save_failed: flowId=${args.flowId} error="${err instanceof Error ? err.message : String(err)}"`);
220
+ return 'checkpoint_held';
221
+ }
222
+ }
223
+ ctx.logger.warn(`sync_checkpoint_held: flowId=${args.flowId} reason=${stopReason} ` +
224
+ `errors=${pageErrors.length} recordable=${entries.length} ` +
225
+ `${saveHold ? `holds=${holds}` : 'saves no progress'} — stopping on this page`);
226
+ countHeld(stopReason);
227
+ return 'checkpoint_held';
228
+ }
229
+ skipped = `reason=${holdReason} ${canSave ? `holds=${holds}` : 'saves no progress'}`;
230
+ skippedAfterHolds = canSave;
231
+ }
232
+ const save = canSave && fits(progress);
233
+ const rows = capRows(entries);
234
+ const countFailed = () => ctx.metrics.counter('sync.checkpoint.failed', 1, {
235
+ integrationType: ctx.integrationType,
236
+ });
237
+ try {
238
+ if (rows.length > 0)
239
+ await ctx.failedRecordClient.upsert(rows);
240
+ }
241
+ catch (err) {
242
+ ctx.logger.error(`sync_checkpoint_failed: flowId=${args.flowId} error="${err instanceof Error ? err.message : String(err)}" — failed records not saved, stopping; the next run resumes from the previous checkpoint`);
243
+ countFailed();
244
+ return 'checkpoint_failed';
245
+ }
246
+ if (save) {
247
+ try {
248
+ await saveProgress(progress);
249
+ savedPages++;
250
+ unsavedPages = 0;
251
+ }
252
+ catch (err) {
253
+ ctx.logger.warn(`sync_progress_save_failed: flowId=${args.flowId} error="${err instanceof Error ? err.message : String(err)}" — still reading; the next run may re-read this page`);
254
+ countFailed();
255
+ unsavedPages++;
256
+ // The stored token's holds belong to an earlier page; the next held page starts its own count.
257
+ if (last)
258
+ last = Object.assign(Object.assign({}, last), { holds: undefined, heldSince: undefined });
259
+ }
260
+ }
261
+ rowsWritten += rows.length;
262
+ droppedRows += entries.length - rows.length;
263
+ if (skipped) {
264
+ const unrecordable = pageErrors.length - entries.length;
265
+ if (skippedAfterHolds)
266
+ unrecordableAfterHolds += unrecordable;
267
+ else
268
+ unrecordableAtOnce += unrecordable;
269
+ ctx.logger.warn(`sync_checkpoint_hold_skipped: flowId=${args.flowId} ${skipped} ` +
270
+ `recorded=${rows.length} unrecordable=${unrecordable} — moving past the page`);
271
+ }
272
+ if (rows.length > 0) {
273
+ ctx.metrics.counter('sync.failures.recorded', rows.length, {
274
+ integrationType: ctx.integrationType,
275
+ });
276
+ }
277
+ // Only the first MAX_RECORD_ERRORS can be in result.errors, so that's all the finish needs.
278
+ for (const e of pageErrors) {
279
+ if (recordedErrors.size >= run_log_builder_1.MAX_RECORD_ERRORS)
280
+ break;
281
+ recordedErrors.add(e);
282
+ }
283
+ checkpointedFailed += failed;
284
+ checkpointedSucceeded += succeeded;
285
+ processed = progress.processed;
286
+ return undefined;
287
+ };
288
+ const result = await new sync_service_1.SyncService(source, target, strategy).run(Object.assign({ flow, since: effectiveCursor }, (resumable
289
+ ? {
290
+ resume: {
291
+ token: (_l = prior === null || prior === void 0 ? void 0 : prior.token) !== null && _l !== void 0 ? _l : null,
292
+ deadlineAt: (_m = args.options) === null || _m === void 0 ? void 0 : _m.deadlineAt,
293
+ checkpoint,
294
+ },
295
+ }
296
+ : {})), ctx);
297
+ if (pendingResume === null || pendingResume === void 0 ? void 0 : pendingResume.failedResumes) {
298
+ try {
299
+ await saveProgress(Object.assign(Object.assign({}, pendingResume), { failedResumes: undefined, failingSince: undefined }));
300
+ }
301
+ catch (err) {
302
+ ctx.logger.error(`sync_failed_resume_clear_failed: flowId=${args.flowId} error="${err instanceof Error ? err.message : String(err)}"`);
303
+ }
304
+ }
305
+ const pending = result.stoppedEarly
306
+ ? []
307
+ : ((_o = result.errors) !== null && _o !== void 0 ? _o : []).filter((e) => !recordedErrors.has(e));
308
+ const failures = this._buildFailureEntries(ctx, flow, source, executionId, pending);
309
+ // A run that saves progress never drops a failure; the others keep the 0.11.x cap.
310
+ const reportDropped = () => {
311
+ var _a, _b, _c, _d, _e;
312
+ const dropped = resumable
313
+ ? droppedRows
314
+ : ((_a = result.errorCount) !== null && _a !== void 0 ? _a : 0) - ((_c = (_b = result.errors) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : 0);
315
+ if (dropped <= 0)
316
+ return undefined;
317
+ ctx.metrics.counter('sync.failures.dropped', dropped, {
318
+ integrationType: ctx.integrationType,
319
+ });
320
+ ctx.logger.warn(`failed_records_truncated: flowId=${args.flowId} recorded=${resumable ? rowsWritten : ((_e = (_d = result.errors) === null || _d === void 0 ? void 0 : _d.length) !== null && _e !== void 0 ? _e : 0)} ` +
321
+ `dropped=${dropped} — ` +
322
+ (resumable
323
+ ? `a backfill or full resync records at most ${run_log_builder_1.MAX_RECORD_ERRORS} failures; retry the recorded ones, then re-run it to record the rest`
324
+ : 'replay the window (run-flow --since) to recover the remainder'));
325
+ return (0, run_log_builder_1.droppedNote)(dropped);
326
+ };
327
+ // Failures were saved at each checkpoint. The cursor moves only when the load finishes.
328
+ if (result.stoppedEarly) {
329
+ const note = joinNotes((0, run_log_builder_1.stoppedEarlyNote)({
330
+ stoppedEarly: result.stoppedEarly,
331
+ backfill,
332
+ fullResync,
333
+ tooLarge,
334
+ saved: Boolean(last === null || last === void 0 ? void 0 : last.token),
335
+ heldTries,
336
+ }), (0, run_log_builder_1.unrecordableNote)(unrecordableAfterHolds, unrecordableAtOnce), reportDropped(), unsavedPages > 0
337
+ ? `saving progress failed on ${unsavedPages} page${unsavedPages === 1 ? ', the next run may re-read it' : 's, the next run may re-read them'}`
338
+ : undefined);
339
+ ctx.logger.info(`sync_stopped_early: flowId=${args.flowId} reason=${result.stoppedEarly} processed=${processed} ` +
340
+ `loadStartedAt=${loadStartedAt.toISOString()} — ${note}`);
341
+ ctx.metrics.counter('sync.run.stopped_early', 1, {
342
+ integrationType: ctx.integrationType,
343
+ reason: result.stoppedEarly,
344
+ });
345
+ await this._emitRunLog(ctx, Object.assign(Object.assign({ executionId, flowId: args.flowId, flowName: flow.name, startedAt, summary: result }, (unsavedPages > 0 && result.failed === 0 ? { summaryLevel: integration_log_interface_1.LogLevel.WARNING } : {})), { windowFrom: effectiveCursor, windowTo: loadStartedAt, note }));
346
+ return result;
347
+ }
101
348
  // Record each failed record, then always advance — one bad record must not wedge the
102
349
  // flow (SOL-1198). We only advance once the failures are safely written: if the write
103
350
  // throws, a retained error has no source id to record, or the run looks like an outage,
104
351
  // hold the cursor and let the next run redo the window.
105
- const failures = this._buildFailureEntries(ctx, flow, source, executionId, (_j = result.errors) !== null && _j !== void 0 ? _j : []);
106
- // Deliberate limitation: the retained-errors cap (MAX_RECORD_ERRORS) bounds how many rows a
107
- // mass-failure run dead-letters, so the overflow is NOT written and the cursor still advances
108
- // past it. That window is replay-recoverable (run-flow --since), not lost for good, but it
109
- // does not auto-recover — surface the dropped count (metric + warn) so it isn't silent.
110
- const dropped = ((_k = result.errorCount) !== null && _k !== void 0 ? _k : 0) - ((_m = (_l = result.errors) === null || _l === void 0 ? void 0 : _l.length) !== null && _m !== void 0 ? _m : 0);
111
- if (dropped > 0) {
112
- ctx.metrics.counter('sync.failures.dropped', dropped, {
113
- integrationType: ctx.integrationType,
114
- });
115
- ctx.logger.warn(`failed_records_truncated: flowId=${args.flowId} recorded=${(_p = (_o = result.errors) === null || _o === void 0 ? void 0 : _o.length) !== null && _p !== void 0 ? _p : 0} ` +
116
- `dropped=${dropped} — replay the window (run-flow --since) to recover the remainder`);
352
+ const lastPageSucceeded = result.succeeded - checkpointedSucceeded;
353
+ const decision = decideCursor(Object.assign(Object.assign({}, result), { succeeded: lastPageSucceeded, failed: result.failed - checkpointedFailed, errors: pending }), failures);
354
+ // Same as a mid page of a run that can't save: an outage wins over a record with no source id.
355
+ if (resumable &&
356
+ !canSave &&
357
+ decision.reason === 'unrecorded_failures' &&
358
+ looksLikeOutage(lastPageSucceeded, failures)) {
359
+ decision.reason = 'outage_suspected';
360
+ }
361
+ let advance = decision.advance;
362
+ let lastPageHolds = 0;
363
+ if (!advance && !result.holdCursor && last) {
364
+ const { holds, heldSince, movePast } = nextHold(last);
365
+ if (movePast) {
366
+ advance = true;
367
+ lastPageHolds = holds;
368
+ }
369
+ else {
370
+ const held = Object.assign(Object.assign({}, last), { holds,
371
+ heldSince, failedResumes: undefined, failingSince: undefined });
372
+ try {
373
+ await saveProgress(held);
374
+ heldTries = holds;
375
+ countHeld(decision.reason);
376
+ }
377
+ catch (err) {
378
+ ctx.logger.error(`sync_checkpoint_hold_save_failed: flowId=${args.flowId} error="${err instanceof Error ? err.message : String(err)}"`);
379
+ }
380
+ }
117
381
  }
118
- const decision = decideCursor(result, failures);
119
382
  // Failures become rows when advancing (fail-closed gate: advancing past an unrecorded
120
383
  // failure would be silent data loss) AND during delete-window holds (result.holdCursor)
121
384
  // — a delete hold can persist across many runs, and hiding the run's other failures
122
385
  // from the Activity drill-in for that long re-creates the COR-818 wedge. Outage and
123
- // unrecorded holds stay row-free as designed (transient flood / unattributable errors).
386
+ // unrecorded holds stay row-free as designed (transient flood / unattributable errors),
387
+ // except an unrecorded hold in a resumable run that saves no progress: its mid pages
388
+ // record what they can, and a re-run can't give a record a source id.
124
389
  // Rows are keyed (tenant, flow, source_id), so re-recording each held re-run is idempotent.
125
390
  let recorded = true;
126
- if (decision.advance || result.holdCursor) {
391
+ const recordHeld = resumable && !canSave && decision.reason === 'unrecorded_failures';
392
+ const rows = capRows(failures);
393
+ let rowsRecorded = 0;
394
+ if (advance || result.holdCursor || recordHeld) {
127
395
  try {
128
- await ctx.failedRecordClient.upsert(failures);
129
- if (failures.length > 0) {
130
- ctx.metrics.counter('sync.failures.recorded', failures.length, {
396
+ await ctx.failedRecordClient.upsert(rows);
397
+ droppedRows += failures.length - rows.length;
398
+ rowsWritten += rows.length;
399
+ rowsRecorded = rows.length;
400
+ if (rows.length > 0) {
401
+ ctx.metrics.counter('sync.failures.recorded', rows.length, {
131
402
  integrationType: ctx.integrationType,
132
403
  });
133
404
  }
405
+ if (lastPageHolds > 0) {
406
+ unrecordableAfterHolds += pending.length - failures.length;
407
+ ctx.logger.warn(`sync_checkpoint_hold_skipped: flowId=${args.flowId} reason=${decision.reason} holds=${lastPageHolds} ` +
408
+ `recorded=${rows.length} unrecordable=${pending.length - failures.length} — moving past the last page`);
409
+ }
134
410
  }
135
411
  catch (err) {
136
412
  recorded = false;
@@ -140,28 +416,55 @@ class SyncRunner {
140
416
  });
141
417
  }
142
418
  }
143
- if (decision.advance && recorded) {
144
- try {
145
- await ctx.dataFlowService.updateCursor(args.flowId, runStartedAt);
419
+ // Use the load's start so changes made during the load are picked up next run.
420
+ const cursor = loadStartedAt;
421
+ const keptBy = !backfill
422
+ ? undefined
423
+ : !fullResync
424
+ ? 'backfill does not move the cursor'
425
+ : droppedRows > 0 || unrecordableAtOnce > 0
426
+ ? 'full resync did not record every failure, so it does not move the cursor'
427
+ : undefined;
428
+ let note = keptBy;
429
+ if (advance && recorded) {
430
+ if (keptBy) {
431
+ ctx.logger.info(`sync_cursor_kept: flowId=${args.flowId} — ${keptBy} or touch the checkpoint`);
146
432
  }
147
- catch (err) {
148
- // Failures ARE recorded; only the cursor didn't persist. Idempotent upserts make
149
- // re-processing the window next run harmless.
150
- ctx.logger.error(`update_cursor_failed: flowId=${args.flowId} error="${err instanceof Error ? err.message : String(err)}" — next run will re-process same window`);
433
+ else {
434
+ try {
435
+ // A fullResync read every record, so it may also clear a scheduled load's checkpoint.
436
+ await ctx.dataFlowService.patchCursor(args.flowId, Object.assign({ lastRunAt: cursor }, (savedPages > 0 || flow.syncProgress ? { syncProgress: null } : {})));
437
+ }
438
+ catch (err) {
439
+ // Failures ARE recorded; only the cursor didn't persist. Idempotent upserts make
440
+ // re-processing the window next run harmless.
441
+ ctx.logger.error(`update_cursor_failed: flowId=${args.flowId} error="${err instanceof Error ? err.message : String(err)}" — next run will re-process same window`);
442
+ }
151
443
  }
152
444
  }
153
445
  else {
154
446
  // Stamp the hold reason on the summary so the tenant-facing run log says the
155
447
  // window will repeat — not just the log line and metric.
156
- const reason = (_q = decision.reason) !== null && _q !== void 0 ? _q : 'failed_record_write_failed';
157
- (_r = result.holdCursor) !== null && _r !== void 0 ? _r : (result.holdCursor = reason);
448
+ const reason = advance
449
+ ? 'failed_record_write_failed'
450
+ : ((_p = decision.reason) !== null && _p !== void 0 ? _p : 'failed_record_write_failed');
451
+ (_q = result.holdCursor) !== null && _q !== void 0 ? _q : (result.holdCursor = reason);
452
+ note = (0, run_log_builder_1.heldCursorNote)({
453
+ reason,
454
+ backfill,
455
+ fullResync,
456
+ heldTries,
457
+ rowsSaved: recordHeld && recorded && rows.length > 0,
458
+ writeFailed: recordHeld && !recorded,
459
+ });
158
460
  ctx.logger.warn(`cursor_held: flowId=${args.flowId} reason=${reason} failed=${result.failed} ` +
159
- `recorded=${failures.length} retained=${(_t = (_s = result.errors) === null || _s === void 0 ? void 0 : _s.length) !== null && _t !== void 0 ? _t : 0} — window retries next run`);
461
+ `recorded=${rowsRecorded} retained=${(_s = (_r = result.errors) === null || _r === void 0 ? void 0 : _r.length) !== null && _s !== void 0 ? _s : 0} — ${note}`);
160
462
  ctx.metrics.counter('sync.cursor.held', 1, {
161
463
  integrationType: ctx.integrationType,
162
464
  reason,
163
465
  });
164
466
  }
467
+ note = joinNotes(note, (0, run_log_builder_1.unrecordableNote)(unrecordableAfterHolds, unrecordableAtOnce), reportDropped());
165
468
  await this._emitRunLog(ctx, {
166
469
  executionId,
167
470
  flowId: args.flowId,
@@ -169,7 +472,8 @@ class SyncRunner {
169
472
  startedAt,
170
473
  summary: result,
171
474
  windowFrom: effectiveCursor,
172
- windowTo: runStartedAt,
475
+ windowTo: cursor,
476
+ note,
173
477
  });
174
478
  return result;
175
479
  }
@@ -178,11 +482,14 @@ class SyncRunner {
178
482
  // then either swallow adapter-config errors (return) or re-throw unexpected ones.
179
483
  const msg = err instanceof Error ? err.message : String(err);
180
484
  ctx.logger.error(`sync_runner_run: ${msg} flowId=${args.flowId}`);
485
+ if (pendingResume && err instanceof sync_service_1.ResumeTokenError) {
486
+ await this._countFailedResume(ctx, args.flowId, pendingResume);
487
+ }
181
488
  const failedResult = {
182
- succeeded: 0,
489
+ succeeded: checkpointedSucceeded,
183
490
  filtered: 0,
184
491
  skipped: 0,
185
- failed: 1,
492
+ failed: checkpointedFailed + 1,
186
493
  dlqWrites: 0,
187
494
  };
188
495
  await this._emitRunLog(ctx, {
@@ -190,7 +497,7 @@ class SyncRunner {
190
497
  flowId: args.flowId,
191
498
  startedAt,
192
499
  summary: failedResult,
193
- summaryMessage: `Run failed: ${msg}`,
500
+ summaryMessage: `Run failed: ${msg}${savedBeforeFailure(savedPages, rowsWritten, checkpointedSucceeded)}`,
194
501
  });
195
502
  if (err instanceof AdapterNotRegisteredError || err instanceof AdapterRoleError) {
196
503
  return failedResult;
@@ -198,33 +505,47 @@ class SyncRunner {
198
505
  throw err;
199
506
  }
200
507
  }
508
+ // A token the adapter keeps failing on would fail every run; the 1-hour minimum keeps a short outage from dropping it.
509
+ async _countFailedResume(ctx, flowId, resumed) {
510
+ var _a;
511
+ const now = Date.now();
512
+ const failedResumes = ((_a = resumed.failedResumes) !== null && _a !== void 0 ? _a : 0) + 1;
513
+ const firstFailure = pastOrNow(resumed.failingSince, now);
514
+ const failingSince = new Date(firstFailure).toISOString();
515
+ const drop = failedResumes >= constants_1.MAX_FAILED_RESUMES && now - firstFailure >= constants_1.FAILED_RESUME_MIN_MS;
516
+ try {
517
+ await ctx.dataFlowService.patchCursor(flowId, {
518
+ syncProgress: drop
519
+ ? {
520
+ token: null,
521
+ windowSince: resumed.windowSince,
522
+ scope: resumed.scope,
523
+ startedAt: resumed.startedAt,
524
+ processed: 0,
525
+ }
526
+ : Object.assign(Object.assign({}, resumed), { failedResumes, failingSince }),
527
+ });
528
+ }
529
+ catch (err) {
530
+ ctx.logger.error(`sync_failed_resume_save_failed: flowId=${flowId} failedResumes=${failedResumes} error="${err instanceof Error ? err.message : String(err)}" — the failed resume isn't counted`);
531
+ return;
532
+ }
533
+ if (drop) {
534
+ ctx.logger.warn(`sync_checkpoint_dropped: flowId=${flowId} failedResumes=${failedResumes} failingSince=${failingSince} — the first page after the saved token keeps failing to load; the next run reads from the start, keeping the load's startedAt`);
535
+ }
536
+ else {
537
+ ctx.logger.warn(`sync_checkpoint_failed_resume: flowId=${flowId} failedResumes=${failedResumes} failingSince=${failingSince} — ` +
538
+ `the checkpoint drops after ${constants_1.MAX_FAILED_RESUMES} failed resumes over at least ${constants_1.FAILED_RESUME_MIN_MS / 60000} min`);
539
+ }
540
+ }
201
541
  // Build and flush this run's logs. Failures are swallowed + logged — logging
202
542
  // must never break a sync. source defaults to scheduled_job when part of a job,
203
543
  // else batch; the event paths pass 'event' explicitly.
204
544
  async _emitRunLog(ctx, args) {
205
- var _a, _b;
206
- const entries = (0, run_log_builder_1.buildRunLogEntries)({
207
- integrationType: ctx.integrationType,
208
- flowId: args.flowId,
209
- flowName: (_a = args.flowName) !== null && _a !== void 0 ? _a : null,
210
- executionId: args.executionId,
211
- source: (_b = args.source) !== null && _b !== void 0 ? _b : (ctx.jobRunId ? integration_log_interface_1.LogSource.SCHEDULED_JOB : integration_log_interface_1.LogSource.BATCH),
212
- durationMs: Date.now() - args.startedAt,
213
- summary: args.summary,
214
- errors: args.summary.errors,
215
- errorCount: args.summary.errorCount,
216
- jobRunId: ctx.jobRunId,
217
- windowFrom: args.windowFrom,
218
- windowTo: args.windowTo,
219
- summaryLevel: args.summaryLevel,
220
- summaryMessage: args.summaryMessage,
221
- });
222
- await ctx.integrationLogClient.create(entries).catch((err) => {
223
- // A dropped flush would hide the very failures this feature exists to surface —
224
- // emit a metric so the silent drop is observable, not just a stdout line.
225
- ctx.metrics.counter('run_log.flush_failed', 1, { integrationType: ctx.integrationType });
226
- ctx.logger.error(`run_log_flush_failed: flowId=${args.flowId} error="${err instanceof Error ? err.message : String(err)}"`);
227
- });
545
+ var _a;
546
+ const { startedAt } = args, rest = __rest(args, ["startedAt"]);
547
+ const entries = (0, run_log_builder_1.buildRunLogEntries)(Object.assign(Object.assign({}, rest), { integrationType: ctx.integrationType, source: (_a = args.source) !== null && _a !== void 0 ? _a : (ctx.jobRunId ? integration_log_interface_1.LogSource.SCHEDULED_JOB : integration_log_interface_1.LogSource.BATCH), durationMs: Date.now() - startedAt, errors: args.summary.errors, errorCount: args.summary.errorCount, jobRunId: ctx.jobRunId }));
548
+ await (0, run_log_builder_1.flushRunLog)(ctx, entries, args.flowId);
228
549
  }
229
550
  // Inbound (vendor→Skedulo) batch path. Fans one delivery out to every flow
230
551
  // matching the changed object, but processes each flow's share as ONE batch
@@ -593,13 +914,66 @@ function decideCursor(result, failures) {
593
914
  if (retained === 0 || failures.length < retained) {
594
915
  return { advance: false, reason: 'unrecorded_failures' };
595
916
  }
596
- if (result.succeeded === 0 &&
597
- failures.length > 0 &&
598
- failures.every((f) => f.errorClass === 'transient')) {
917
+ if (looksLikeOutage(result.succeeded, failures)) {
599
918
  return { advance: false, reason: 'outage_suspected' };
600
919
  }
601
920
  return { advance: true };
602
921
  }
922
+ // Every hold gets the 1-hour minimum: an outage page with one id-less record is held as unrecorded_failures.
923
+ function nextHold(last) {
924
+ var _a;
925
+ const now = Date.now();
926
+ const holds = ((_a = last === null || last === void 0 ? void 0 : last.holds) !== null && _a !== void 0 ? _a : 0) + 1;
927
+ const since = pastOrNow(last === null || last === void 0 ? void 0 : last.heldSince, now);
928
+ const movePast = holds >= constants_1.MAX_CHECKPOINT_HOLDS && now - since >= constants_1.FAILED_RESUME_MIN_MS;
929
+ return { holds, heldSince: new Date(since).toISOString(), movePast };
930
+ }
931
+ // A future or unparsable time (clock skew, bad data) counts as now, or it would never be old enough.
932
+ function pastOrNow(iso, now) {
933
+ return Math.min(Date.parse(iso !== null && iso !== void 0 ? iso : '') || now, now);
934
+ }
935
+ function savedBeforeFailure(pages, rows, synced) {
936
+ if (pages === 0 && rows === 0)
937
+ return '';
938
+ const saved = pages > 0
939
+ ? `saved ${pages} page${pages === 1 ? '' : 's'} (${synced} synced), the next run continues from the last one`
940
+ : 'saved no progress';
941
+ return ` — before it failed the run ${saved}; ${rows} failed record${rows === 1 ? '' : 's'} recorded`;
942
+ }
943
+ function notCount(value) {
944
+ return value !== undefined && !(Number.isInteger(value) && Number(value) >= 0);
945
+ }
946
+ function hasRoom(progress) {
947
+ return JSON.stringify(Object.assign(Object.assign({}, progress), LATER_FIELDS)).length <= constants_1.MAX_SYNC_PROGRESS_CHARS;
948
+ }
949
+ function joinNotes(...notes) {
950
+ return notes.filter(Boolean).join('; ') || undefined;
951
+ }
952
+ function looksLikeOutage(succeeded, failures) {
953
+ return (succeeded === 0 && failures.length > 0 && failures.every((f) => f.errorClass === 'transient'));
954
+ }
955
+ function syncScope(flow) {
956
+ var _a;
957
+ const fields = [
958
+ flow.sourceObject,
959
+ flow.targetObject,
960
+ (_a = flow.filter) !== null && _a !== void 0 ? _a : null,
961
+ flow.mappingConfig,
962
+ flow.matchKeyField,
963
+ ];
964
+ return (0, crypto_1.createHash)('sha256').update(stableJson(fields)).digest('hex').slice(0, 16);
965
+ }
966
+ function stableJson(value) {
967
+ var _a;
968
+ if (Array.isArray(value))
969
+ return `[${value.map(stableJson).join(',')}]`;
970
+ if (value && typeof value === 'object') {
971
+ const obj = value;
972
+ const keys = Object.keys(obj).sort();
973
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`;
974
+ }
975
+ return (_a = JSON.stringify(value)) !== null && _a !== void 0 ? _a : 'null';
976
+ }
603
977
  function createSyncStrategy(options, _ctx) {
604
978
  if (options.lookupStrategy === constants_1.LookupStrategy.IdMapping) {
605
979
  return new id_mapping_sync_strategy_1.IdMappingSyncStrategy(new id_mapping_service_1.IdMappingService());