retold-data-service 2.1.5 → 2.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -240,6 +240,46 @@ module.exports = (pDataClonerService, pConfig, pCLIOptions, fCallback) =>
240
240
  });
241
241
  };
242
242
 
243
+ // Step 5b: Converge each deployed table's indexes to the clone index policy.
244
+ //
245
+ // Historically nothing in the headless pipeline touched indices, so headless
246
+ // clones ran with just the clustered primary key — leaving the
247
+ // OngoingEventualConsistency Deleted-filtered range counts clustered-scanning
248
+ // wide rows, which can stall large clones. This step declaratively brings the
249
+ // clone's indexes in line with the desired set (standard operational indexes +
250
+ // any Sync.IndexPolicy.TableIndexes extras): create missing, then drop
251
+ // undeclared per prune scope (default 'managed' — only policy-owned / legacy
252
+ // artifacts, never a hand-authored index). Create-before-drop and
253
+ // no-loss-on-failure keep coverage intact. It must never block the sync, so a
254
+ // failure here is logged and the pipeline proceeds. Opt out with
255
+ // Sync.CreateOperationalIndices=false or Sync.IndexPolicy.Enabled=false.
256
+ let fStep5b_ConvergeIndices = (fNext) =>
257
+ {
258
+ let tmpSync = pConfig.Sync || {};
259
+ let tmpIndexPolicy = (tmpSync.IndexPolicy && typeof(tmpSync.IndexPolicy) === 'object') ? tmpSync.IndexPolicy : {};
260
+ if (tmpSync.CreateOperationalIndices === false || tmpIndexPolicy.Enabled === false)
261
+ {
262
+ tmpFable.log.info('Headless: Index convergence disabled by config; skipping.');
263
+ return fNext();
264
+ }
265
+
266
+ let tmpTables = pConfig.Tables || [];
267
+ tmpFable.log.info(`Headless: Converging indexes on ${tmpTables.length > 0 ? tmpTables.length + ' selected' : 'all'} tables (prune scope: ${tmpIndexPolicy.PruneScope || 'managed'})...`);
268
+ fPost(`${tmpPrefix}/schema/indices/converge`, { Tables: tmpTables, IndexPolicy: tmpIndexPolicy },
269
+ (pError, pData) =>
270
+ {
271
+ if (pError || !pData || !pData.Success)
272
+ {
273
+ tmpFable.log.warn(`Headless: Index convergence reported a problem (continuing to sync): ${pError || (pData && pData.Error) || 'Unknown error'}`);
274
+ }
275
+ else
276
+ {
277
+ tmpFable.log.info(`Headless: ${pData.Message}`);
278
+ }
279
+ return fNext();
280
+ });
281
+ };
282
+
243
283
  // Step 6: Start sync
244
284
  let fStep6_Sync = (fNext) =>
245
285
  {
@@ -254,6 +294,7 @@ module.exports = (pDataClonerService, pConfig, pCLIOptions, fCallback) =>
254
294
  DateTimePrecisionMS: tmpSync.DateTimePrecisionMS,
255
295
  BackSyncTimeLimit: tmpSync.BackSyncTimeLimit,
256
296
  TrueUpPageSize: tmpSync.TrueUpPageSize,
297
+ SyncRecordConcurrency: tmpSync.SyncRecordConcurrency,
257
298
  SyncEntityOptions: tmpSync.SyncEntityOptions
258
299
  });
259
300
 
@@ -268,6 +309,38 @@ module.exports = (pDataClonerService, pConfig, pCLIOptions, fCallback) =>
268
309
  }
269
310
  tmpFable.log.info(`Headless: ${pData.Message}`);
270
311
 
312
+ // Stall detection. Synced/Total freezes during the (time-budgeted)
313
+ // delete phase, so a healthy delete pass looks identical to a hung
314
+ // one from the poll's vantage point. Only treat it as a stall when
315
+ // NOTHING in the status changes for well beyond one phase budget —
316
+ // default max(2x BackSyncTimeLimit, 20 min) — then exit non-zero so
317
+ // the Docker restart loop can recover (cursor/progress state persists
318
+ // on the mounted volume). Configure via Sync.StallTimeoutMs; set it
319
+ // to 0 to disable.
320
+ let tmpStallConfigured = parseInt(tmpSync.StallTimeoutMs, 10);
321
+ let tmpStallTimeoutMs;
322
+ if (tmpStallConfigured === 0)
323
+ {
324
+ tmpStallTimeoutMs = 0;
325
+ }
326
+ else if (!isNaN(tmpStallConfigured) && tmpStallConfigured > 0)
327
+ {
328
+ tmpStallTimeoutMs = tmpStallConfigured;
329
+ }
330
+ else
331
+ {
332
+ let tmpBudget = parseInt(tmpSync.BackSyncTimeLimit, 10);
333
+ if (isNaN(tmpBudget) || tmpBudget <= 0)
334
+ {
335
+ tmpBudget = 600000;
336
+ }
337
+ tmpStallTimeoutMs = Math.max(2 * tmpBudget, 1200000);
338
+ }
339
+ let fLogStall = (typeof(tmpFable.log.fatal) === 'function') ? tmpFable.log.fatal.bind(tmpFable.log) : tmpFable.log.error.bind(tmpFable.log);
340
+ let tmpLastProgressSignature = null;
341
+ let tmpLastProgressAtMs = Date.now();
342
+ tmpFable.log.info(`Headless: Stall detector ${tmpStallTimeoutMs > 0 ? 'armed at ' + (tmpStallTimeoutMs / 60000).toFixed(1) + ' min of no observable progress' : 'disabled'}.`);
343
+
271
344
  // Poll for completion
272
345
  let fPoll = () =>
273
346
  {
@@ -291,6 +364,27 @@ module.exports = (pDataClonerService, pConfig, pCLIOptions, fCallback) =>
291
364
  let tmpA = tmpTables[tmpActive[0]];
292
365
  tmpFable.log.info(`Headless: [${tmpDone.length}/${tmpNames.length}] Syncing ${tmpActive[0]}: ${tmpA.Synced}/${tmpA.Total}`);
293
366
  }
367
+
368
+ // Advance the stall clock only when the observable status
369
+ // changes (any table's Status or Synced/Total counters).
370
+ let tmpProgressSignature = tmpNames.slice().sort().map((pName) =>
371
+ {
372
+ let tmpT = tmpTables[pName];
373
+ return `${pName}:${tmpT.Status}:${tmpT.Synced}/${tmpT.Total}`;
374
+ }).join('|');
375
+ let tmpNowMs = Date.now();
376
+ if (tmpProgressSignature !== tmpLastProgressSignature)
377
+ {
378
+ tmpLastProgressSignature = tmpProgressSignature;
379
+ tmpLastProgressAtMs = tmpNowMs;
380
+ }
381
+ else if (tmpStallTimeoutMs > 0 && (tmpNowMs - tmpLastProgressAtMs) >= tmpStallTimeoutMs)
382
+ {
383
+ let tmpStalledMin = ((tmpNowMs - tmpLastProgressAtMs) / 60000).toFixed(1);
384
+ fLogStall(`Headless: STALL DETECTED — sync status unchanged for ${tmpStalledMin} min (threshold ${(tmpStallTimeoutMs / 60000).toFixed(1)} min). Active: [${tmpActive.join(', ') || 'none'}], ${tmpDone.length}/${tmpNames.length} tables done. Exiting non-zero so the container restart loop can recover; cursor/progress state persists on the mounted volume.`);
385
+ return process.exit(1);
386
+ }
387
+
294
388
  return setTimeout(fPoll, 5000);
295
389
  }
296
390
 
@@ -347,10 +441,13 @@ module.exports = (pDataClonerService, pConfig, pCLIOptions, fCallback) =>
347
441
  {
348
442
  fStep5_Deploy(() =>
349
443
  {
350
- fStep6_Sync(() =>
444
+ fStep5b_ConvergeIndices(() =>
351
445
  {
352
- tmpFable.log.info('Headless: Pipeline complete.');
353
- return fCallback();
446
+ fStep6_Sync(() =>
447
+ {
448
+ tmpFable.log.info('Headless: Pipeline complete.');
449
+ return fCallback();
450
+ });
354
451
  });
355
452
  });
356
453
  });
@@ -16,6 +16,8 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
16
16
 
17
17
  let libFs = require('fs');
18
18
  let _ProviderRegistry = require('./DataCloner-ProviderRegistry.js');
19
+ let libIndexPolicy = require('meadow-integration/source/services/clone/Meadow-Service-IndexPolicy.js');
20
+ let libIndexConvergence = require('meadow-integration/source/services/clone/Meadow-Service-IndexConvergence.js');
19
21
 
20
22
  // POST /clone/schema/fetch
21
23
  // Accepts either:
@@ -889,7 +891,7 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
889
891
  let tmpTableNames = tmpFilterTables || Object.keys(tmpModelTables);
890
892
  let tmpCreatedIndices = [];
891
893
 
892
- tmpFable.log.info(`Data Cloner: Creating missing GUID indices for ${tmpTableNames.length} tables...`);
894
+ tmpFable.log.info(`Data Cloner: Creating missing operational indices (GUID + sync) for ${tmpTableNames.length} tables...`);
893
895
 
894
896
  tmpFable.Utility.eachLimit(tmpTableNames, 1,
895
897
  (pTableName, fNextTable) =>
@@ -903,10 +905,15 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
903
905
  // Generate all index statements for this table (provider-specific)
904
906
  let tmpAllStatements = tmpSchemaProvider.generateCreateIndexStatements(tmpTableSchema);
905
907
 
906
- // Filter to GUID indices only (AK_M_GUID*)
907
- let tmpGUIDStatements = tmpAllStatements.filter((pStmt) => pStmt.Name && pStmt.Name.indexOf('AK_M_GUID') === 0);
908
+ // Filter to the clone's operational indices: GUID uniqueness
909
+ // (AK_M_GUID*) plus the standard sync indices (IX_M_SYNC*) that
910
+ // keep the OngoingEventualConsistency range-count / delete walks
911
+ // seekable. Application ForeignKey / Indexed-column indices are
912
+ // intentionally left out of the clone.
913
+ let tmpCloneIndexStatements = tmpAllStatements.filter((pStmt) => pStmt.Name &&
914
+ (pStmt.Name.indexOf('AK_M_GUID') === 0 || pStmt.Name.indexOf('IX_M_SYNC') === 0));
908
915
 
909
- if (tmpGUIDStatements.length < 1)
916
+ if (tmpCloneIndexStatements.length < 1)
910
917
  {
911
918
  return fNextTable();
912
919
  }
@@ -914,12 +921,12 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
914
921
  // createIndex is idempotent on all providers — safe to call even if index exists
915
922
  let fCreateNext = (pIndex) =>
916
923
  {
917
- if (pIndex >= tmpGUIDStatements.length)
924
+ if (pIndex >= tmpCloneIndexStatements.length)
918
925
  {
919
926
  return fNextTable();
920
927
  }
921
928
 
922
- let tmpStmt = tmpGUIDStatements[pIndex];
929
+ let tmpStmt = tmpCloneIndexStatements[pIndex];
923
930
  tmpSchemaProvider.createIndex(tmpStmt,
924
931
  (pCreateError) =>
925
932
  {
@@ -946,8 +953,8 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
946
953
  () =>
947
954
  {
948
955
  let tmpMessage = tmpCreatedIndices.length > 0
949
- ? `${tmpCreatedIndices.length} GUID index(es) created.`
950
- : 'No new GUID indices were needed.';
956
+ ? `${tmpCreatedIndices.length} operational index(es) created.`
957
+ : 'No new operational indices were needed.';
951
958
 
952
959
  tmpFable.log.info(`Data Cloner: ${tmpMessage}`);
953
960
 
@@ -960,4 +967,94 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
960
967
  return fNext();
961
968
  });
962
969
  });
970
+
971
+ // POST /clone/schema/indices/converge — Converge each deployed table's indexes
972
+ // to the clone index policy (create missing, then drop undeclared per prune
973
+ // scope). This is the authoritative, declarative index mechanism: the desired
974
+ // set = standard operational (Deleted composite + non-unique GUID) + any
975
+ // caller-declared per-table extras (body.IndexPolicy.TableIndexes).
976
+ pOratorServiceServer.post(`${tmpPrefix}/schema/indices/converge`,
977
+ (pRequest, pResponse, fNext) =>
978
+ {
979
+ if (!tmpCloneState.DeployedModelObject)
980
+ {
981
+ pResponse.send(400, { Success: false, Error: 'No schema deployed. Deploy tables first.' });
982
+ return fNext();
983
+ }
984
+
985
+ let tmpProviderName = tmpCloneState.ConnectionProvider;
986
+ let tmpProviderRegistryEntry = _ProviderRegistry[tmpProviderName];
987
+ let tmpActiveProvider = tmpProviderRegistryEntry ? tmpFable[tmpProviderRegistryEntry.serviceName] : null;
988
+
989
+ if (!tmpActiveProvider || !tmpActiveProvider.connected)
990
+ {
991
+ pResponse.send(400, { Success: false, Error: 'No connected provider available.' });
992
+ return fNext();
993
+ }
994
+
995
+ if (typeof(tmpActiveProvider.introspectTableIndices) !== 'function'
996
+ || typeof(tmpActiveProvider.generateCreateIndexStatements) !== 'function'
997
+ || typeof(tmpActiveProvider.createIndex) !== 'function'
998
+ || typeof(tmpActiveProvider.dropIndex) !== 'function')
999
+ {
1000
+ pResponse.send(400, { Success: false, Error: `Provider ${tmpProviderName} does not support index convergence.` });
1001
+ return fNext();
1002
+ }
1003
+
1004
+ let tmpBody = pRequest.body || {};
1005
+ let tmpIndexConfig = (tmpBody.IndexPolicy && typeof(tmpBody.IndexPolicy) === 'object') ? tmpBody.IndexPolicy : {};
1006
+ let tmpPruneScope = tmpIndexConfig.PruneScope || libIndexConvergence.PRUNE_MANAGED;
1007
+ let tmpFilterTables = Array.isArray(tmpBody.Tables) && tmpBody.Tables.length > 0 ? tmpBody.Tables : null;
1008
+
1009
+ let tmpModelTables = tmpCloneState.DeployedModelObject.Tables || {};
1010
+ let tmpTableNames = tmpFilterTables || Object.keys(tmpModelTables);
1011
+ let tmpResults = [];
1012
+
1013
+ tmpFable.log.info(`Data Cloner: Converging indexes for ${tmpTableNames.length} tables (prune scope: ${tmpPruneScope})...`);
1014
+
1015
+ tmpFable.Utility.eachLimit(tmpTableNames, 1,
1016
+ (pTableName, fNextTable) =>
1017
+ {
1018
+ let tmpTableSchema = tmpModelTables[pTableName];
1019
+ if (!tmpTableSchema)
1020
+ {
1021
+ return fNextTable();
1022
+ }
1023
+
1024
+ let tmpDesired = libIndexPolicy.resolveDesiredIndexes(tmpTableSchema, tmpIndexConfig);
1025
+ libIndexConvergence.convergeTableIndexes(tmpActiveProvider, tmpTableSchema, tmpDesired,
1026
+ { PruneScope: tmpPruneScope, log: tmpFable.log },
1027
+ (pConvergeError, pResult) =>
1028
+ {
1029
+ if (pConvergeError)
1030
+ {
1031
+ tmpFable.log.warn(`Data Cloner: Index convergence for ${pTableName} failed: ${pConvergeError}`);
1032
+ }
1033
+ else if (pResult && (pResult.created.length > 0 || pResult.dropped.length > 0 || pResult.skipped.length > 0))
1034
+ {
1035
+ tmpFable.log.info(`Data Cloner: ${pTableName} — created [${pResult.created.join(', ')}], dropped [${pResult.dropped.join(', ')}]${pResult.skipped.length > 0 ? `, skipped [${pResult.skipped.join(', ')}]` : ''}`);
1036
+ tmpResults.push(pResult);
1037
+ }
1038
+ return fNextTable();
1039
+ });
1040
+ },
1041
+ () =>
1042
+ {
1043
+ let tmpCreated = tmpResults.reduce((pSum, pR) => { return pSum + pR.created.length; }, 0);
1044
+ let tmpDropped = tmpResults.reduce((pSum, pR) => { return pSum + pR.dropped.length; }, 0);
1045
+ let tmpMessage = (tmpCreated > 0 || tmpDropped > 0)
1046
+ ? `Index convergence: ${tmpCreated} created, ${tmpDropped} dropped across ${tmpResults.length} table(s).`
1047
+ : 'Index convergence: all tables already match the desired index set.';
1048
+
1049
+ tmpFable.log.info(`Data Cloner: ${tmpMessage}`);
1050
+
1051
+ pResponse.send(200,
1052
+ {
1053
+ Success: true,
1054
+ Results: tmpResults,
1055
+ Message: tmpMessage
1056
+ });
1057
+ return fNext();
1058
+ });
1059
+ });
963
1060
  };
@@ -20,6 +20,7 @@ const _RuntimeSyncProperties = {
20
20
  MaxRecordsPerEntity: (pVal) => { let tmpN = parseInt(pVal, 10); return (!isNaN(tmpN) && tmpN > 0) ? tmpN : null; },
21
21
  DateTimePrecisionMS: (pVal) => { let tmpN = parseInt(pVal, 10); return !isNaN(tmpN) ? tmpN : null; },
22
22
  TrueUpPageSize: (pVal) => { let tmpN = parseInt(pVal, 10); return (!isNaN(tmpN) && tmpN > 0) ? tmpN : null; },
23
+ SyncRecordConcurrency: (pVal) => { let tmpN = parseInt(pVal, 10); return (!isNaN(tmpN) && tmpN > 0) ? tmpN : null; },
23
24
  UseAdvancedIDPagination: (pVal) => !!pVal,
24
25
  SyncDeletedRecords: (pVal) => !!pVal
25
26
  };