ultravisor 1.0.23 → 1.0.24
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/docs/queue-followups/03-config-migration.md +123 -0
- package/docs/queue-followups/04-direct-dispatch-phase-emission.md +128 -0
- package/package.json +3 -1
- package/source/Ultravisor.cjs +4 -0
- package/source/datamodel/Ultravisor-BeaconQueue.json +165 -0
- package/source/services/Ultravisor-Beacon-ActionDefaults.cjs +174 -0
- package/source/services/Ultravisor-Beacon-Coordinator.cjs +382 -6
- package/source/services/Ultravisor-Beacon-RunManager.cjs +169 -0
- package/source/services/Ultravisor-Beacon-Scheduler.cjs +789 -0
- package/source/services/Ultravisor-ExecutionEngine.cjs +227 -4
- package/source/services/Ultravisor-ExecutionManifest.cjs +1 -0
- package/source/services/persistence/Ultravisor-Beacon-QueueStore.cjs +886 -0
- package/source/services/tasks/file-system/Ultravisor-TaskConfigs-FileSystem.cjs +81 -0
- package/source/services/tasks/file-system/definitions/chunked-write.json +38 -0
- package/source/web_server/Ultravisor-API-Server.cjs +354 -0
- package/test/Ultravisor_BeaconQueue_tests.js +502 -0
|
@@ -98,6 +98,45 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
98
98
|
return this._Journal;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Get the SQLite-backed queue store (lazy lookup, not cached so
|
|
103
|
+
* late-bound services are picked up after initialization order
|
|
104
|
+
* finishes settling).
|
|
105
|
+
*
|
|
106
|
+
* @returns {object|null}
|
|
107
|
+
*/
|
|
108
|
+
_getQueueStore()
|
|
109
|
+
{
|
|
110
|
+
let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorBeaconQueueStore;
|
|
111
|
+
if (!tmpMap) return null;
|
|
112
|
+
let tmpStore = Object.values(tmpMap)[0];
|
|
113
|
+
return (tmpStore && tmpStore.isEnabled && tmpStore.isEnabled()) ? tmpStore : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get the scheduler service (lazy lookup).
|
|
118
|
+
*
|
|
119
|
+
* @returns {object|null}
|
|
120
|
+
*/
|
|
121
|
+
_getScheduler()
|
|
122
|
+
{
|
|
123
|
+
let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorBeaconScheduler;
|
|
124
|
+
if (!tmpMap) return null;
|
|
125
|
+
return Object.values(tmpMap)[0];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get the action defaults service (lazy lookup).
|
|
130
|
+
*
|
|
131
|
+
* @returns {object|null}
|
|
132
|
+
*/
|
|
133
|
+
_getActionDefaults()
|
|
134
|
+
{
|
|
135
|
+
let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorBeaconActionDefaults;
|
|
136
|
+
if (!tmpMap) return null;
|
|
137
|
+
return Object.values(tmpMap)[0];
|
|
138
|
+
}
|
|
139
|
+
|
|
101
140
|
/**
|
|
102
141
|
* Restore work queue and affinity bindings from the journal.
|
|
103
142
|
*
|
|
@@ -920,24 +959,57 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
920
959
|
|
|
921
960
|
let tmpDefaultTimeout = this.fable.settings.UltravisorBeaconWorkItemTimeoutMs || 300000;
|
|
922
961
|
|
|
962
|
+
let tmpSettings = pWorkItemInfo.Settings || {};
|
|
963
|
+
let tmpCreatedIso = new Date(tmpTimestamp).toISOString();
|
|
923
964
|
let tmpWorkItem = {
|
|
924
965
|
WorkItemHash: tmpWorkItemHash,
|
|
925
966
|
RunHash: pWorkItemInfo.RunHash || '',
|
|
967
|
+
RunID: pWorkItemInfo.RunID || '',
|
|
926
968
|
NodeHash: pWorkItemInfo.NodeHash || '',
|
|
927
969
|
OperationHash: pWorkItemInfo.OperationHash || '',
|
|
928
970
|
Capability: pWorkItemInfo.Capability || 'Shell',
|
|
929
971
|
Action: pWorkItemInfo.Action || 'Execute',
|
|
930
|
-
Settings:
|
|
972
|
+
Settings: tmpSettings,
|
|
931
973
|
AffinityKey: pWorkItemInfo.AffinityKey || '',
|
|
932
974
|
AssignedBeaconID: null,
|
|
933
975
|
Status: 'Pending',
|
|
934
976
|
TimeoutMs: pWorkItemInfo.TimeoutMs || tmpDefaultTimeout,
|
|
935
|
-
CreatedAt:
|
|
977
|
+
CreatedAt: tmpCreatedIso,
|
|
978
|
+
EnqueuedAt: pWorkItemInfo.EnqueuedAt || tmpCreatedIso,
|
|
979
|
+
AssignedAt: null,
|
|
980
|
+
DispatchedAt: null,
|
|
981
|
+
StartedAt: null,
|
|
936
982
|
ClaimedAt: null,
|
|
937
983
|
CompletedAt: null,
|
|
938
|
-
|
|
984
|
+
CanceledAt: null,
|
|
985
|
+
CancelRequested: false,
|
|
986
|
+
CancelReason: '',
|
|
987
|
+
LastEventAt: tmpCreatedIso,
|
|
988
|
+
QueueWaitMs: 0,
|
|
989
|
+
Priority: (pWorkItemInfo.Priority != null) ? pWorkItemInfo.Priority : (parseInt(tmpSettings.priority, 10) || 0),
|
|
990
|
+
Health: null,
|
|
991
|
+
HealthLabel: 'Unknown',
|
|
992
|
+
HealthReason: '',
|
|
993
|
+
HealthComputedAt: null,
|
|
994
|
+
Result: null,
|
|
995
|
+
// Retry support: configurable per-action via Settings.
|
|
996
|
+
// maxRetries=1 means no retry (single attempt, current behavior).
|
|
997
|
+
AttemptNumber: 0,
|
|
998
|
+
MaxAttempts: parseInt(tmpSettings.maxRetries, 10) || 1,
|
|
999
|
+
RetryBackoffMs: parseInt(tmpSettings.retryBackoffMs, 10) || 5000,
|
|
1000
|
+
RetryAfter: null,
|
|
1001
|
+
LastError: null
|
|
939
1002
|
};
|
|
940
1003
|
|
|
1004
|
+
// Apply action defaults (timeout, retry, priority) if the
|
|
1005
|
+
// BeaconActionDefaults service is available. Per-request
|
|
1006
|
+
// Settings still override, handled inside applyToWorkItem.
|
|
1007
|
+
let tmpDefaults = this._getActionDefaults();
|
|
1008
|
+
if (tmpDefaults)
|
|
1009
|
+
{
|
|
1010
|
+
tmpDefaults.applyToWorkItem(tmpWorkItem, tmpSettings);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
941
1013
|
// Check for affinity binding — pre-assign to a specific Beacon
|
|
942
1014
|
if (tmpWorkItem.AffinityKey)
|
|
943
1015
|
{
|
|
@@ -999,6 +1071,41 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
999
1071
|
}
|
|
1000
1072
|
}
|
|
1001
1073
|
|
|
1074
|
+
// Persist the full record to the SQLite-backed queue store so
|
|
1075
|
+
// the /queue view + historical queries + cross-restart recovery
|
|
1076
|
+
// have a canonical source of truth.
|
|
1077
|
+
let tmpQueueStore = this._getQueueStore();
|
|
1078
|
+
if (tmpQueueStore)
|
|
1079
|
+
{
|
|
1080
|
+
try { tmpQueueStore.upsertWorkItem(tmpWorkItem); }
|
|
1081
|
+
catch (pStoreErr) { this.log.warn(`BeaconCoordinator: queue store upsert failed: ${pStoreErr.message}`); }
|
|
1082
|
+
try
|
|
1083
|
+
{
|
|
1084
|
+
tmpQueueStore.appendEvent({
|
|
1085
|
+
WorkItemHash: tmpWorkItem.WorkItemHash,
|
|
1086
|
+
RunID: tmpWorkItem.RunID,
|
|
1087
|
+
EventType: 'enqueued',
|
|
1088
|
+
FromStatus: '',
|
|
1089
|
+
ToStatus: tmpWorkItem.Status,
|
|
1090
|
+
Payload: {
|
|
1091
|
+
Capability: tmpWorkItem.Capability,
|
|
1092
|
+
Action: tmpWorkItem.Action,
|
|
1093
|
+
Priority: tmpWorkItem.Priority,
|
|
1094
|
+
AffinityKey: tmpWorkItem.AffinityKey
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
catch (pStoreErr2) { /* best effort */ }
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Broadcast the enqueue event. Scheduler owns the ws envelope.
|
|
1102
|
+
let tmpScheduler = this._getScheduler();
|
|
1103
|
+
if (tmpScheduler)
|
|
1104
|
+
{
|
|
1105
|
+
try { tmpScheduler.notifyEnqueued(tmpWorkItem); }
|
|
1106
|
+
catch (pErr) { /* best effort */ }
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1002
1109
|
// Attempt immediate push to a WebSocket-connected beacon
|
|
1003
1110
|
if (tmpWorkItem.Status === 'Pending')
|
|
1004
1111
|
{
|
|
@@ -1164,7 +1271,49 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
1164
1271
|
if (tmpWorkItem.Status === 'Assigned' && tmpWorkItem.AssignedBeaconID === pBeaconID)
|
|
1165
1272
|
{
|
|
1166
1273
|
// This item was pre-assigned to us via affinity
|
|
1274
|
+
let tmpPollNowIso = new Date().toISOString();
|
|
1167
1275
|
tmpWorkItem.Status = 'Running';
|
|
1276
|
+
tmpWorkItem.StartedAt = tmpWorkItem.StartedAt || tmpPollNowIso;
|
|
1277
|
+
tmpWorkItem.DispatchedAt = tmpWorkItem.DispatchedAt || tmpPollNowIso;
|
|
1278
|
+
tmpWorkItem.LastEventAt = tmpPollNowIso;
|
|
1279
|
+
if (!tmpWorkItem.QueueWaitMs && tmpWorkItem.EnqueuedAt)
|
|
1280
|
+
{
|
|
1281
|
+
let tmpEnqMs = Date.parse(tmpWorkItem.EnqueuedAt);
|
|
1282
|
+
if (!isNaN(tmpEnqMs)) tmpWorkItem.QueueWaitMs = Math.max(0, Date.now() - tmpEnqMs);
|
|
1283
|
+
}
|
|
1284
|
+
tmpWorkItem.AttemptNumber = (tmpWorkItem.AttemptNumber || 0) + 1;
|
|
1285
|
+
let tmpPollStoreA = this._getQueueStore();
|
|
1286
|
+
if (tmpPollStoreA)
|
|
1287
|
+
{
|
|
1288
|
+
try
|
|
1289
|
+
{
|
|
1290
|
+
tmpPollStoreA.updateWorkItem(tmpWorkItem.WorkItemHash, {
|
|
1291
|
+
Status: 'Running',
|
|
1292
|
+
StartedAt: tmpWorkItem.StartedAt,
|
|
1293
|
+
DispatchedAt: tmpWorkItem.DispatchedAt,
|
|
1294
|
+
LastEventAt: tmpPollNowIso,
|
|
1295
|
+
QueueWaitMs: tmpWorkItem.QueueWaitMs || 0,
|
|
1296
|
+
AttemptNumber: tmpWorkItem.AttemptNumber
|
|
1297
|
+
});
|
|
1298
|
+
tmpPollStoreA.appendEvent({
|
|
1299
|
+
WorkItemHash: tmpWorkItem.WorkItemHash,
|
|
1300
|
+
RunID: tmpWorkItem.RunID,
|
|
1301
|
+
EventType: 'dispatched',
|
|
1302
|
+
FromStatus: 'Assigned',
|
|
1303
|
+
ToStatus: 'Running',
|
|
1304
|
+
BeaconID: pBeaconID,
|
|
1305
|
+
Payload: { QueueWaitMs: tmpWorkItem.QueueWaitMs || 0, Path: 'poll' }
|
|
1306
|
+
});
|
|
1307
|
+
tmpPollStoreA.insertAttempt({
|
|
1308
|
+
WorkItemHash: tmpWorkItem.WorkItemHash,
|
|
1309
|
+
AttemptNumber: tmpWorkItem.AttemptNumber,
|
|
1310
|
+
BeaconID: pBeaconID,
|
|
1311
|
+
DispatchedAt: tmpWorkItem.DispatchedAt,
|
|
1312
|
+
Outcome: 'Dispatched'
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
catch (pErr) { /* best effort */ }
|
|
1316
|
+
}
|
|
1168
1317
|
this.log.info(`BeaconCoordinator: beacon [${pBeaconID}] picked up affinity-assigned work item [${tmpWorkItem.WorkItemHash}].`);
|
|
1169
1318
|
return this._sanitizeWorkItemForBeacon(tmpWorkItem);
|
|
1170
1319
|
}
|
|
@@ -1193,9 +1342,55 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
1193
1342
|
}
|
|
1194
1343
|
|
|
1195
1344
|
// Claim this work item
|
|
1345
|
+
let tmpPollClaimIso = new Date().toISOString();
|
|
1346
|
+
let tmpPollFromStatus = tmpWorkItem.Status;
|
|
1196
1347
|
tmpWorkItem.Status = 'Running';
|
|
1197
1348
|
tmpWorkItem.AssignedBeaconID = pBeaconID;
|
|
1198
|
-
tmpWorkItem.ClaimedAt =
|
|
1349
|
+
tmpWorkItem.ClaimedAt = tmpPollClaimIso;
|
|
1350
|
+
tmpWorkItem.AssignedAt = tmpWorkItem.AssignedAt || tmpPollClaimIso;
|
|
1351
|
+
tmpWorkItem.DispatchedAt = tmpWorkItem.DispatchedAt || tmpPollClaimIso;
|
|
1352
|
+
tmpWorkItem.StartedAt = tmpWorkItem.StartedAt || tmpPollClaimIso;
|
|
1353
|
+
tmpWorkItem.LastEventAt = tmpPollClaimIso;
|
|
1354
|
+
if (!tmpWorkItem.QueueWaitMs && tmpWorkItem.EnqueuedAt)
|
|
1355
|
+
{
|
|
1356
|
+
let tmpEnqMs2 = Date.parse(tmpWorkItem.EnqueuedAt);
|
|
1357
|
+
if (!isNaN(tmpEnqMs2)) tmpWorkItem.QueueWaitMs = Math.max(0, Date.now() - tmpEnqMs2);
|
|
1358
|
+
}
|
|
1359
|
+
tmpWorkItem.AttemptNumber = (tmpWorkItem.AttemptNumber || 0) + 1;
|
|
1360
|
+
let tmpPollStoreB = this._getQueueStore();
|
|
1361
|
+
if (tmpPollStoreB)
|
|
1362
|
+
{
|
|
1363
|
+
try
|
|
1364
|
+
{
|
|
1365
|
+
tmpPollStoreB.updateWorkItem(tmpWorkItem.WorkItemHash, {
|
|
1366
|
+
Status: 'Running',
|
|
1367
|
+
AssignedBeaconID: pBeaconID,
|
|
1368
|
+
AssignedAt: tmpWorkItem.AssignedAt,
|
|
1369
|
+
DispatchedAt: tmpWorkItem.DispatchedAt,
|
|
1370
|
+
StartedAt: tmpWorkItem.StartedAt,
|
|
1371
|
+
LastEventAt: tmpPollClaimIso,
|
|
1372
|
+
QueueWaitMs: tmpWorkItem.QueueWaitMs || 0,
|
|
1373
|
+
AttemptNumber: tmpWorkItem.AttemptNumber
|
|
1374
|
+
});
|
|
1375
|
+
tmpPollStoreB.appendEvent({
|
|
1376
|
+
WorkItemHash: tmpWorkItem.WorkItemHash,
|
|
1377
|
+
RunID: tmpWorkItem.RunID,
|
|
1378
|
+
EventType: 'dispatched',
|
|
1379
|
+
FromStatus: tmpPollFromStatus,
|
|
1380
|
+
ToStatus: 'Running',
|
|
1381
|
+
BeaconID: pBeaconID,
|
|
1382
|
+
Payload: { QueueWaitMs: tmpWorkItem.QueueWaitMs || 0, Path: 'poll' }
|
|
1383
|
+
});
|
|
1384
|
+
tmpPollStoreB.insertAttempt({
|
|
1385
|
+
WorkItemHash: tmpWorkItem.WorkItemHash,
|
|
1386
|
+
AttemptNumber: tmpWorkItem.AttemptNumber,
|
|
1387
|
+
BeaconID: pBeaconID,
|
|
1388
|
+
DispatchedAt: tmpWorkItem.DispatchedAt,
|
|
1389
|
+
Outcome: 'Dispatched'
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
catch (pErr) { /* best effort */ }
|
|
1393
|
+
}
|
|
1199
1394
|
|
|
1200
1395
|
if (tmpBeacon.CurrentWorkItems.indexOf(tmpWorkItem.WorkItemHash) === -1)
|
|
1201
1396
|
{
|
|
@@ -1248,13 +1443,34 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
1248
1443
|
*/
|
|
1249
1444
|
_sanitizeWorkItemForBeacon(pWorkItem)
|
|
1250
1445
|
{
|
|
1446
|
+
// Populate QueueMetadata on the Settings envelope if the scheduler
|
|
1447
|
+
// hasn't done it yet (direct poll path bypasses the scheduler).
|
|
1448
|
+
let tmpSettings = pWorkItem.Settings || {};
|
|
1449
|
+
if (!tmpSettings.QueueMetadata && pWorkItem.EnqueuedAt)
|
|
1450
|
+
{
|
|
1451
|
+
let tmpNowIso = new Date().toISOString();
|
|
1452
|
+
let tmpEnqMs = Date.parse(pWorkItem.EnqueuedAt);
|
|
1453
|
+
let tmpWaitMs = isNaN(tmpEnqMs) ? 0 : Math.max(0, Date.now() - tmpEnqMs);
|
|
1454
|
+
tmpSettings.QueueMetadata = {
|
|
1455
|
+
RunID: pWorkItem.RunID || '',
|
|
1456
|
+
WorkItemHash: pWorkItem.WorkItemHash,
|
|
1457
|
+
EnqueuedAt: pWorkItem.EnqueuedAt,
|
|
1458
|
+
DispatchedAt: pWorkItem.DispatchedAt || tmpNowIso,
|
|
1459
|
+
QueueWaitMs: pWorkItem.QueueWaitMs || tmpWaitMs,
|
|
1460
|
+
AttemptNumber: pWorkItem.AttemptNumber || 1,
|
|
1461
|
+
HubInstanceID: (this.fable.settings && this.fable.settings.UltravisorHubInstanceID) || ''
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1251
1464
|
return {
|
|
1252
1465
|
WorkItemHash: pWorkItem.WorkItemHash,
|
|
1466
|
+
RunID: pWorkItem.RunID || '',
|
|
1253
1467
|
Capability: pWorkItem.Capability,
|
|
1254
1468
|
Action: pWorkItem.Action,
|
|
1255
|
-
Settings:
|
|
1469
|
+
Settings: tmpSettings,
|
|
1256
1470
|
OperationHash: pWorkItem.OperationHash,
|
|
1257
|
-
TimeoutMs: pWorkItem.TimeoutMs
|
|
1471
|
+
TimeoutMs: pWorkItem.TimeoutMs,
|
|
1472
|
+
AttemptNumber: pWorkItem.AttemptNumber || 1,
|
|
1473
|
+
QueueMetadata: tmpSettings.QueueMetadata
|
|
1258
1474
|
};
|
|
1259
1475
|
}
|
|
1260
1476
|
|
|
@@ -1342,8 +1558,20 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
1342
1558
|
return fCallback(new Error(`BeaconCoordinator: work item [${pWorkItemHash}] already finalized (${tmpWorkItem.Status}).`));
|
|
1343
1559
|
}
|
|
1344
1560
|
|
|
1561
|
+
let tmpFromStatus = tmpWorkItem.Status;
|
|
1345
1562
|
tmpWorkItem.Status = 'Complete';
|
|
1346
1563
|
tmpWorkItem.CompletedAt = new Date().toISOString();
|
|
1564
|
+
tmpWorkItem.LastEventAt = tmpWorkItem.CompletedAt;
|
|
1565
|
+
|
|
1566
|
+
let tmpDurationMs = 0;
|
|
1567
|
+
if (tmpWorkItem.DispatchedAt)
|
|
1568
|
+
{
|
|
1569
|
+
let tmpStartMs = Date.parse(tmpWorkItem.DispatchedAt);
|
|
1570
|
+
if (!isNaN(tmpStartMs))
|
|
1571
|
+
{
|
|
1572
|
+
tmpDurationMs = Math.max(0, Date.now() - tmpStartMs);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1347
1575
|
|
|
1348
1576
|
// Journal the completion
|
|
1349
1577
|
let tmpJournal = this._getJournal();
|
|
@@ -1352,6 +1580,43 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
1352
1580
|
tmpJournal.appendEntry('complete', { WorkItemHash: pWorkItemHash });
|
|
1353
1581
|
}
|
|
1354
1582
|
|
|
1583
|
+
// Persist to the new queue store + append event + update attempt.
|
|
1584
|
+
let tmpQueueStore = this._getQueueStore();
|
|
1585
|
+
if (tmpQueueStore)
|
|
1586
|
+
{
|
|
1587
|
+
try
|
|
1588
|
+
{
|
|
1589
|
+
tmpQueueStore.updateWorkItem(pWorkItemHash, {
|
|
1590
|
+
Status: 'Complete',
|
|
1591
|
+
CompletedAt: tmpWorkItem.CompletedAt,
|
|
1592
|
+
LastEventAt: tmpWorkItem.LastEventAt,
|
|
1593
|
+
Result: pResult || null
|
|
1594
|
+
});
|
|
1595
|
+
tmpQueueStore.appendEvent({
|
|
1596
|
+
WorkItemHash: pWorkItemHash,
|
|
1597
|
+
RunID: tmpWorkItem.RunID,
|
|
1598
|
+
EventType: 'completed',
|
|
1599
|
+
FromStatus: tmpFromStatus,
|
|
1600
|
+
ToStatus: 'Complete',
|
|
1601
|
+
BeaconID: tmpWorkItem.AssignedBeaconID,
|
|
1602
|
+
Payload: { DurationMs: tmpDurationMs }
|
|
1603
|
+
});
|
|
1604
|
+
tmpQueueStore.updateAttemptOutcome(pWorkItemHash, tmpWorkItem.AttemptNumber || 1, {
|
|
1605
|
+
CompletedAt: tmpWorkItem.CompletedAt,
|
|
1606
|
+
Outcome: 'Complete',
|
|
1607
|
+
DurationMs: tmpDurationMs
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
catch (pStoreErr) { /* best effort */ }
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
let tmpScheduler = this._getScheduler();
|
|
1614
|
+
if (tmpScheduler)
|
|
1615
|
+
{
|
|
1616
|
+
try { tmpScheduler.notifyCompleted(tmpWorkItem, tmpDurationMs); }
|
|
1617
|
+
catch (pErr) { /* best effort */ }
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1355
1620
|
// Merge accumulated progress logs with the final completion log
|
|
1356
1621
|
let tmpFinalResult = pResult || {};
|
|
1357
1622
|
if (tmpWorkItem.AccumulatedLog && tmpWorkItem.AccumulatedLog.length > 0)
|
|
@@ -1457,10 +1722,54 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
1457
1722
|
return fCallback(new Error(`BeaconCoordinator: work item [${pWorkItemHash}] already finalized (${tmpWorkItem.Status}).`));
|
|
1458
1723
|
}
|
|
1459
1724
|
|
|
1725
|
+
// ── Retry check: if attempts remain, schedule a retry instead
|
|
1726
|
+
// of routing to the error path. The retry scheduler in
|
|
1727
|
+
// _checkTimeouts() will re-enqueue the item after the backoff.
|
|
1728
|
+
if (tmpWorkItem.AttemptNumber < tmpWorkItem.MaxAttempts - 1)
|
|
1729
|
+
{
|
|
1730
|
+
tmpWorkItem.AttemptNumber++;
|
|
1731
|
+
tmpWorkItem.LastError = (pError && pError.ErrorMessage) || 'Unknown error';
|
|
1732
|
+
tmpWorkItem.Status = 'RetryScheduled';
|
|
1733
|
+
tmpWorkItem.RetryAfter = Date.now() + (tmpWorkItem.RetryBackoffMs * tmpWorkItem.AttemptNumber);
|
|
1734
|
+
tmpWorkItem.ClaimedAt = null;
|
|
1735
|
+
tmpWorkItem.AssignedBeaconID = null;
|
|
1736
|
+
|
|
1737
|
+
// Remove from the beacon's active list so it can accept new work
|
|
1738
|
+
this._removeWorkItemFromBeacon(tmpWorkItem.AssignedBeaconID, pWorkItemHash);
|
|
1739
|
+
|
|
1740
|
+
let tmpJournal = this._getJournal();
|
|
1741
|
+
if (tmpJournal)
|
|
1742
|
+
{
|
|
1743
|
+
tmpJournal.appendEntry('retry-scheduled', {
|
|
1744
|
+
WorkItemHash: pWorkItemHash,
|
|
1745
|
+
AttemptNumber: tmpWorkItem.AttemptNumber,
|
|
1746
|
+
MaxAttempts: tmpWorkItem.MaxAttempts,
|
|
1747
|
+
RetryAfterMs: tmpWorkItem.RetryBackoffMs * tmpWorkItem.AttemptNumber,
|
|
1748
|
+
LastError: tmpWorkItem.LastError
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
this.log.info(`BeaconCoordinator: scheduling retry ${tmpWorkItem.AttemptNumber}/${tmpWorkItem.MaxAttempts} for [${pWorkItemHash}] in ${tmpWorkItem.RetryBackoffMs * tmpWorkItem.AttemptNumber}ms (error: ${tmpWorkItem.LastError.slice(0, 100)})`);
|
|
1753
|
+
return fCallback(null);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
let tmpFromStatus = tmpWorkItem.Status;
|
|
1460
1757
|
tmpWorkItem.Status = 'Error';
|
|
1461
1758
|
tmpWorkItem.CompletedAt = new Date().toISOString();
|
|
1759
|
+
tmpWorkItem.LastEventAt = tmpWorkItem.CompletedAt;
|
|
1760
|
+
tmpWorkItem.LastError = (pError && pError.ErrorMessage) || 'Unknown error';
|
|
1462
1761
|
tmpWorkItem.Result = { Error: pError.ErrorMessage || 'Unknown error', Log: pError.Log || [] };
|
|
1463
1762
|
|
|
1763
|
+
let tmpFailDurationMs = 0;
|
|
1764
|
+
if (tmpWorkItem.DispatchedAt)
|
|
1765
|
+
{
|
|
1766
|
+
let tmpFailStartMs = Date.parse(tmpWorkItem.DispatchedAt);
|
|
1767
|
+
if (!isNaN(tmpFailStartMs))
|
|
1768
|
+
{
|
|
1769
|
+
tmpFailDurationMs = Math.max(0, Date.now() - tmpFailStartMs);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1464
1773
|
// Journal the failure
|
|
1465
1774
|
let tmpJournal = this._getJournal();
|
|
1466
1775
|
if (tmpJournal)
|
|
@@ -1468,6 +1777,45 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
1468
1777
|
tmpJournal.appendEntry('fail', { WorkItemHash: pWorkItemHash });
|
|
1469
1778
|
}
|
|
1470
1779
|
|
|
1780
|
+
// Persist to the new queue store + event + attempt outcome.
|
|
1781
|
+
let tmpFailStore = this._getQueueStore();
|
|
1782
|
+
if (tmpFailStore)
|
|
1783
|
+
{
|
|
1784
|
+
try
|
|
1785
|
+
{
|
|
1786
|
+
tmpFailStore.updateWorkItem(pWorkItemHash, {
|
|
1787
|
+
Status: 'Error',
|
|
1788
|
+
CompletedAt: tmpWorkItem.CompletedAt,
|
|
1789
|
+
LastEventAt: tmpWorkItem.LastEventAt,
|
|
1790
|
+
LastError: tmpWorkItem.LastError,
|
|
1791
|
+
Result: tmpWorkItem.Result
|
|
1792
|
+
});
|
|
1793
|
+
tmpFailStore.appendEvent({
|
|
1794
|
+
WorkItemHash: pWorkItemHash,
|
|
1795
|
+
RunID: tmpWorkItem.RunID,
|
|
1796
|
+
EventType: 'failed',
|
|
1797
|
+
FromStatus: tmpFromStatus,
|
|
1798
|
+
ToStatus: 'Error',
|
|
1799
|
+
BeaconID: tmpWorkItem.AssignedBeaconID,
|
|
1800
|
+
Payload: { Error: tmpWorkItem.LastError, DurationMs: tmpFailDurationMs }
|
|
1801
|
+
});
|
|
1802
|
+
tmpFailStore.updateAttemptOutcome(pWorkItemHash, tmpWorkItem.AttemptNumber || 1, {
|
|
1803
|
+
CompletedAt: tmpWorkItem.CompletedAt,
|
|
1804
|
+
Outcome: 'Error',
|
|
1805
|
+
ErrorMessage: tmpWorkItem.LastError,
|
|
1806
|
+
DurationMs: tmpFailDurationMs
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
catch (pStoreErr) { /* best effort */ }
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
let tmpFailScheduler = this._getScheduler();
|
|
1813
|
+
if (tmpFailScheduler)
|
|
1814
|
+
{
|
|
1815
|
+
try { tmpFailScheduler.notifyFailed(tmpWorkItem, tmpWorkItem.LastError); }
|
|
1816
|
+
catch (pErr) { /* best effort */ }
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1471
1819
|
// Remove from Beacon's current work list
|
|
1472
1820
|
this._removeWorkItemFromBeacon(tmpWorkItem.AssignedBeaconID, pWorkItemHash);
|
|
1473
1821
|
|
|
@@ -1817,6 +2165,21 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
1817
2165
|
TotalSteps: (pProgressData.TotalSteps !== undefined) ? pProgressData.TotalSteps : (tmpWorkItem.Progress ? tmpWorkItem.Progress.TotalSteps : undefined),
|
|
1818
2166
|
UpdatedAt: new Date().toISOString()
|
|
1819
2167
|
};
|
|
2168
|
+
// Mark the item as freshly heard-from for health scoring.
|
|
2169
|
+
tmpWorkItem.LastEventAt = tmpWorkItem.Progress.UpdatedAt;
|
|
2170
|
+
if (!tmpWorkItem.StartedAt) tmpWorkItem.StartedAt = tmpWorkItem.Progress.UpdatedAt;
|
|
2171
|
+
let tmpProgressStore = this._getQueueStore();
|
|
2172
|
+
if (tmpProgressStore)
|
|
2173
|
+
{
|
|
2174
|
+
try
|
|
2175
|
+
{
|
|
2176
|
+
tmpProgressStore.updateWorkItem(pWorkItemHash, {
|
|
2177
|
+
LastEventAt: tmpWorkItem.LastEventAt,
|
|
2178
|
+
StartedAt: tmpWorkItem.StartedAt
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
catch (pErr) { /* best effort */ }
|
|
2182
|
+
}
|
|
1820
2183
|
|
|
1821
2184
|
// Accumulate log entries
|
|
1822
2185
|
if (Array.isArray(pProgressData.Log) && pProgressData.Log.length > 0)
|
|
@@ -2026,6 +2389,19 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
2026
2389
|
}
|
|
2027
2390
|
}
|
|
2028
2391
|
|
|
2392
|
+
// Re-enqueue retry-scheduled work items whose backoff has elapsed
|
|
2393
|
+
for (let j = 0; j < tmpWorkItemHashes.length; j++)
|
|
2394
|
+
{
|
|
2395
|
+
let tmpRetryItem = this._WorkQueue[tmpWorkItemHashes[j]];
|
|
2396
|
+
if (tmpRetryItem.Status === 'RetryScheduled' && tmpRetryItem.RetryAfter && tmpNow >= tmpRetryItem.RetryAfter)
|
|
2397
|
+
{
|
|
2398
|
+
tmpRetryItem.Status = 'Pending';
|
|
2399
|
+
tmpRetryItem.RetryAfter = null;
|
|
2400
|
+
this.log.info(`BeaconCoordinator: re-enqueuing [${tmpRetryItem.WorkItemHash}] for retry attempt ${tmpRetryItem.AttemptNumber + 1}/${tmpRetryItem.MaxAttempts}`);
|
|
2401
|
+
this._tryPushToWebSocketBeacon(tmpRetryItem);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2029
2405
|
// Check Beacon heartbeat timeouts
|
|
2030
2406
|
let tmpHeartbeatTimeout = this.fable.settings.UltravisorBeaconHeartbeatTimeoutMs || 60000;
|
|
2031
2407
|
let tmpBeaconIDs = Object.keys(this._Beacons);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ultravisor Beacon Run Manager
|
|
3
|
+
*
|
|
4
|
+
* Owns the hub-assigned RunID namespace. Clients call startRun() with
|
|
5
|
+
* an optional IdempotencyKey; the manager returns an existing RunID on
|
|
6
|
+
* a duplicate key or mints a fresh one otherwise. The generated RunID
|
|
7
|
+
* shape is "rn-<hub-instance>-<epoch-ms>-<counter>" so the hub prefix
|
|
8
|
+
* cannot collide with any client-space identifier.
|
|
9
|
+
*
|
|
10
|
+
* All work items submitted via /Beacon/Work/Enqueue reference the
|
|
11
|
+
* RunID assigned here; retold-labs and other submitter stacks adopt
|
|
12
|
+
* this value as their phases.jsonl run_id field.
|
|
13
|
+
*
|
|
14
|
+
* @module Ultravisor-Beacon-RunManager
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const libPictService = require('pict-serviceproviderbase');
|
|
18
|
+
const libCrypto = require('crypto');
|
|
19
|
+
|
|
20
|
+
class UltravisorBeaconRunManager extends libPictService
|
|
21
|
+
{
|
|
22
|
+
constructor(pPict, pOptions, pServiceHash)
|
|
23
|
+
{
|
|
24
|
+
super(pPict, pOptions, pServiceHash);
|
|
25
|
+
|
|
26
|
+
this.serviceType = 'UltravisorBeaconRunManager';
|
|
27
|
+
|
|
28
|
+
this._HubInstanceID = (this.fable.settings && this.fable.settings.UltravisorHubInstanceID)
|
|
29
|
+
|| this._shortHostSlug();
|
|
30
|
+
this._RunCounter = 0;
|
|
31
|
+
|
|
32
|
+
// In-memory idempotency cache to short-circuit DB hits for hot
|
|
33
|
+
// submitters replaying the same key. Authoritative store is
|
|
34
|
+
// the BeaconRun table.
|
|
35
|
+
this._IdempotencyCache = {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_shortHostSlug()
|
|
39
|
+
{
|
|
40
|
+
try
|
|
41
|
+
{
|
|
42
|
+
let tmpHost = require('os').hostname() || 'hub';
|
|
43
|
+
return tmpHost.replace(/[^a-zA-Z0-9]/g, '').slice(0, 12).toLowerCase() || 'hub';
|
|
44
|
+
}
|
|
45
|
+
catch (pError)
|
|
46
|
+
{
|
|
47
|
+
return 'hub';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_getStore()
|
|
52
|
+
{
|
|
53
|
+
let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorBeaconQueueStore;
|
|
54
|
+
if (!tmpMap) return null;
|
|
55
|
+
let tmpStore = Object.values(tmpMap)[0];
|
|
56
|
+
return (tmpStore && tmpStore.isEnabled()) ? tmpStore : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_mintRunID()
|
|
60
|
+
{
|
|
61
|
+
this._RunCounter++;
|
|
62
|
+
return `rn-${this._HubInstanceID}-${Date.now()}-${this._RunCounter}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_guid()
|
|
66
|
+
{
|
|
67
|
+
if (typeof libCrypto.randomUUID === 'function')
|
|
68
|
+
{
|
|
69
|
+
return libCrypto.randomUUID();
|
|
70
|
+
}
|
|
71
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) =>
|
|
72
|
+
{
|
|
73
|
+
let r = Math.random() * 16 | 0;
|
|
74
|
+
let v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
75
|
+
return v.toString(16);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Start a run. If pInfo.IdempotencyKey matches an active run, the
|
|
81
|
+
* existing run is returned instead of a new one being created.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} pInfo - { IdempotencyKey?, SubmitterTag?, Metadata? }
|
|
84
|
+
* @returns {object} run record
|
|
85
|
+
*/
|
|
86
|
+
startRun(pInfo)
|
|
87
|
+
{
|
|
88
|
+
let tmpInfo = pInfo || {};
|
|
89
|
+
let tmpStore = this._getStore();
|
|
90
|
+
|
|
91
|
+
if (tmpInfo.IdempotencyKey)
|
|
92
|
+
{
|
|
93
|
+
let tmpCached = this._IdempotencyCache[tmpInfo.IdempotencyKey];
|
|
94
|
+
if (tmpCached) return tmpCached;
|
|
95
|
+
|
|
96
|
+
if (tmpStore)
|
|
97
|
+
{
|
|
98
|
+
let tmpExisting = tmpStore.getRunByIdempotencyKey(tmpInfo.IdempotencyKey);
|
|
99
|
+
if (tmpExisting)
|
|
100
|
+
{
|
|
101
|
+
this._IdempotencyCache[tmpInfo.IdempotencyKey] = tmpExisting;
|
|
102
|
+
return tmpExisting;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let tmpRun = {
|
|
108
|
+
GUIDBeaconRun: this._guid(),
|
|
109
|
+
RunID: this._mintRunID(),
|
|
110
|
+
IdempotencyKey: tmpInfo.IdempotencyKey || '',
|
|
111
|
+
SubmitterTag: tmpInfo.SubmitterTag || '',
|
|
112
|
+
State: 'Active',
|
|
113
|
+
StartedAt: new Date().toISOString(),
|
|
114
|
+
Metadata: tmpInfo.Metadata || {}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (tmpStore)
|
|
118
|
+
{
|
|
119
|
+
let tmpSaved = tmpStore.insertRun(tmpRun);
|
|
120
|
+
if (tmpSaved) tmpRun = tmpSaved;
|
|
121
|
+
}
|
|
122
|
+
if (tmpInfo.IdempotencyKey)
|
|
123
|
+
{
|
|
124
|
+
this._IdempotencyCache[tmpInfo.IdempotencyKey] = tmpRun;
|
|
125
|
+
}
|
|
126
|
+
this.log.info(`BeaconRunManager: started run [${tmpRun.RunID}] (tag=${tmpRun.SubmitterTag || 'none'}, idempotency=${tmpRun.IdempotencyKey || 'none'}).`);
|
|
127
|
+
return tmpRun;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
getRun(pRunID)
|
|
131
|
+
{
|
|
132
|
+
let tmpStore = this._getStore();
|
|
133
|
+
return tmpStore ? tmpStore.getRunByRunID(pRunID) : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
endRun(pRunID, pFinalState)
|
|
137
|
+
{
|
|
138
|
+
let tmpStore = this._getStore();
|
|
139
|
+
if (!tmpStore) return false;
|
|
140
|
+
let tmpRun = tmpStore.getRunByRunID(pRunID);
|
|
141
|
+
if (!tmpRun) return false;
|
|
142
|
+
let tmpState = pFinalState || 'Ended';
|
|
143
|
+
tmpStore.updateRunState(pRunID, tmpState,
|
|
144
|
+
{ EndedAt: new Date().toISOString() });
|
|
145
|
+
if (tmpRun.IdempotencyKey)
|
|
146
|
+
{
|
|
147
|
+
delete this._IdempotencyCache[tmpRun.IdempotencyKey];
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
cancelRun(pRunID, pReason)
|
|
153
|
+
{
|
|
154
|
+
let tmpStore = this._getStore();
|
|
155
|
+
if (!tmpStore) return false;
|
|
156
|
+
let tmpRun = tmpStore.getRunByRunID(pRunID);
|
|
157
|
+
if (!tmpRun) return false;
|
|
158
|
+
let tmpNow = new Date().toISOString();
|
|
159
|
+
tmpStore.updateRunState(pRunID, 'Canceled',
|
|
160
|
+
{ CanceledAt: tmpNow, EndedAt: tmpNow, CancelReason: pReason || '' });
|
|
161
|
+
if (tmpRun.IdempotencyKey)
|
|
162
|
+
{
|
|
163
|
+
delete this._IdempotencyCache[tmpRun.IdempotencyKey];
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = UltravisorBeaconRunManager;
|