ultravisor 1.3.2 → 1.3.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/docs/features/persistence-via-databeacon.md +48 -31
- package/package.json +1 -1
- package/source/services/Ultravisor-ExecutionEngine.cjs +20 -23
- package/source/services/Ultravisor-OutputStore.cjs +317 -0
- package/source/services/Ultravisor-StateManager.cjs +25 -5
- package/source/web_server/Ultravisor-API-Server.cjs +24 -2
- package/webinterface/dist/docs/features/persistence-via-databeacon.md +48 -31
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# Persistence via retold-databeacon
|
|
2
2
|
|
|
3
|
-
**Status:
|
|
4
|
-
for routing ultravisor's queue and manifest persistence
|
|
5
|
-
instead of the specialized `ultravisor-queue-beacon`
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
**Status: shipped. Legacy modules removed 2026-05-03.** This doc is the
|
|
4
|
+
cross-session plan for routing ultravisor's queue and manifest persistence
|
|
5
|
+
through retold-databeacon instead of the specialized `ultravisor-queue-beacon`
|
|
6
|
+
and `ultravisor-manifest-beacon` modules (now deleted from the monorepo —
|
|
7
|
+
see [What changed and why](#what-changed-and-why) for the historical context).
|
|
8
|
+
It captures the architectural decision, the work breakdown, and the hand-off
|
|
9
|
+
state so a fresh context can resume from here.
|
|
8
10
|
|
|
9
11
|
If you are picking this up cold, read this doc top-to-bottom before touching
|
|
10
12
|
code — the decisions below are load-bearing and the cross-module dependencies
|
|
@@ -12,15 +14,18 @@ are not obvious from the source.
|
|
|
12
14
|
|
|
13
15
|
## What changed and why
|
|
14
16
|
|
|
15
|
-
Before this redesign
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
Before this redesign (the modules described here have since been deleted from
|
|
18
|
+
the monorepo on 2026-05-03 — kept here for historical context):
|
|
19
|
+
- `ultravisor-queue-beacon` advertised capability `QueuePersistence` with
|
|
20
|
+
`QP_*` actions. Shipped a `QueuePersistenceProviderBase` and a default
|
|
18
21
|
`MemoryQueuePersistenceProvider`.
|
|
19
|
-
- `ultravisor-manifest-beacon`
|
|
20
|
-
|
|
22
|
+
- `ultravisor-manifest-beacon` advertised `ManifestStore` with `MS_*` actions.
|
|
23
|
+
Shipped a `ManifestStoreProviderBase` and `MemoryManifestStoreProvider`.
|
|
21
24
|
- `Ultravisor-QueuePersistenceBridge.cjs` and
|
|
22
|
-
`Ultravisor-ManifestStoreBridge.cjs`
|
|
23
|
-
capability when a beacon
|
|
25
|
+
`Ultravisor-ManifestStoreBridge.cjs` dispatched into the corresponding
|
|
26
|
+
capability when a beacon was connected, fell back to local storage otherwise.
|
|
27
|
+
Both bridges still exist inside ultravisor — they now translate `QP_*` /
|
|
28
|
+
`MS_*` semantics into `MeadowProxy` calls against retold-databeacon.
|
|
24
29
|
- `bootstrap-flush` (already shipped) replays locally-buffered writes into a
|
|
25
30
|
newly-connected beacon via per-beacon HWM tracking persisted to
|
|
26
31
|
`<DataPath>/persistence-bridge-hwm.json`.
|
|
@@ -36,10 +41,12 @@ The redesign:
|
|
|
36
41
|
`Request` calls** (Method + Path + Body) instead of dispatching to a
|
|
37
42
|
specialized capability. The schema (table names, column shapes) is owned
|
|
38
43
|
by ultravisor; retold-databeacon is a generic gateway.
|
|
39
|
-
- **The two specialized beacon modules
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
- **The two specialized beacon modules have been deleted (2026-05-03).**
|
|
45
|
+
They were marked legacy throughout Session 4 and the migration path proved
|
|
46
|
+
out across all retold-databeacon-supported engines (mysql / mssql / postgres
|
|
47
|
+
/ sqlite). Embedded deployments that prefer in-process Provider-pattern
|
|
48
|
+
persistence can still subclass the bridges' fallback path; the QP/MS
|
|
49
|
+
capability surfaces themselves are gone from the monorepo.
|
|
43
50
|
- **The lab's UV detail view gains a "persistence databeacon" picker.** The
|
|
44
51
|
operator picks a running retold-databeacon (which itself has an
|
|
45
52
|
engine+database picker via `lab-engine-database-picker`); ultravisor's
|
|
@@ -1120,21 +1127,31 @@ engine-coverage work — same files get touched.
|
|
|
1120
1127
|
|
|
1121
1128
|
#### Legacy beacon deprecation
|
|
1122
1129
|
|
|
1123
|
-
`ultravisor-queue-beacon` and
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1130
|
+
Originally Session 4 just labeled `ultravisor-queue-beacon` and
|
|
1131
|
+
`ultravisor-manifest-beacon` as `(legacy)` in the lab UI while keeping
|
|
1132
|
+
the modules selectable. As of 2026-05-03 the modules have been deleted
|
|
1133
|
+
outright — the migration path proved out across every retold-databeacon
|
|
1134
|
+
engine and there are no remaining users.
|
|
1135
|
+
|
|
1136
|
+
What's gone:
|
|
1137
|
+
|
|
1138
|
+
- The two module directories (`modules/apps/ultravisor-queue-beacon`,
|
|
1139
|
+
`modules/apps/ultravisor-manifest-beacon`).
|
|
1140
|
+
- Manifest entries in `Retold-Modules-Manifest.json` and
|
|
1141
|
+
`modules/Include-Retold-Module-List.sh`.
|
|
1142
|
+
- Lab registry entries — `Service-BeaconTypeRegistry.js`'s
|
|
1143
|
+
`SCANNED_MODULES` no longer scans them and `DEPRECATED_BEACON_TYPES`
|
|
1144
|
+
is now an empty Set (kept for future per-module deprecations).
|
|
1145
|
+
|
|
1146
|
+
What stayed:
|
|
1147
|
+
|
|
1148
|
+
- `Ultravisor-QueuePersistenceBridge.cjs` and
|
|
1149
|
+
`Ultravisor-ManifestStoreBridge.cjs` inside ultravisor — they still
|
|
1150
|
+
dispatch `QP_*` / `MS_*` semantics, just into MeadowProxy now.
|
|
1151
|
+
- The `LEGACY_TOOLTIP` constant + per-stanza
|
|
1152
|
+
`retoldBeacon.deprecated: true` plumbing in
|
|
1153
|
+
`Service-BeaconTypeRegistry.js` — useful infrastructure for whatever
|
|
1154
|
+
the next deprecation needs.
|
|
1138
1155
|
|
|
1139
1156
|
#### Test-fable cleanup (tangential hygiene)
|
|
1140
1157
|
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const libPictService = require('pict-serviceproviderbase');
|
|
2
2
|
const libStatus = require('./Ultravisor-Status.cjs');
|
|
3
|
+
const libOutputStore = require('./Ultravisor-OutputStore.cjs');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Event-driven graph executor for Ultravisor operations.
|
|
@@ -449,21 +450,17 @@ class UltravisorExecutionEngine extends libPictService
|
|
|
449
450
|
tmpStateManager.setAddress(tmpWaitingInfo.OutputAddress, pValue, tmpContext, pNodeHash);
|
|
450
451
|
}
|
|
451
452
|
|
|
452
|
-
// Store in task outputs
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// If pValue is a structured object, merge all keys into TaskOutputs (beacon results)
|
|
459
|
-
// If pValue is a scalar, store as InputValue (backward compat for value-input)
|
|
453
|
+
// Store in task outputs. Routes through OutputStore so beacon
|
|
454
|
+
// results that arrive with a multi-megabyte field (typed-op
|
|
455
|
+
// `Result` etc.) get lifted to the run's staging dir instead of
|
|
456
|
+
// pinning the payload in the manifest.
|
|
460
457
|
if (typeof pValue === 'object' && pValue !== null && !Array.isArray(pValue))
|
|
461
458
|
{
|
|
462
|
-
|
|
459
|
+
libOutputStore.mergeAndLift(tmpContext, pNodeHash, pValue, { Fable: this.fable, Logger: this.log });
|
|
463
460
|
}
|
|
464
461
|
else
|
|
465
462
|
{
|
|
466
|
-
tmpContext
|
|
463
|
+
libOutputStore.mergeAndLift(tmpContext, pNodeHash, { InputValue: pValue }, { Fable: this.fable, Logger: this.log });
|
|
467
464
|
}
|
|
468
465
|
|
|
469
466
|
// Update the task's ElapsedMs to include the time spent waiting for input
|
|
@@ -1060,12 +1057,8 @@ class UltravisorExecutionEngine extends libPictService
|
|
|
1060
1057
|
`Intermediate event [${pIntermediateEventName}] from [${pNodeHash}]`, 1);
|
|
1061
1058
|
}
|
|
1062
1059
|
|
|
1063
|
-
// Store the intermediate outputs
|
|
1064
|
-
|
|
1065
|
-
{
|
|
1066
|
-
pContext.TaskOutputs[pNodeHash] = {};
|
|
1067
|
-
}
|
|
1068
|
-
Object.assign(pContext.TaskOutputs[pNodeHash], pIntermediateOutputs);
|
|
1060
|
+
// Store the intermediate outputs (lift large fields per OutputStore policy)
|
|
1061
|
+
libOutputStore.mergeAndLift(pContext, pNodeHash, pIntermediateOutputs, { Fable: this.fable, Logger: this.log });
|
|
1069
1062
|
|
|
1070
1063
|
// Find downstream nodes for this intermediate event
|
|
1071
1064
|
let tmpDownstreamEvents = this._getDownstreamEvents(pNodeHash, pIntermediateEventName, pContext);
|
|
@@ -1167,14 +1160,10 @@ class UltravisorExecutionEngine extends libPictService
|
|
|
1167
1160
|
return fCallback(null);
|
|
1168
1161
|
}
|
|
1169
1162
|
|
|
1170
|
-
// Store outputs in TaskOutputs
|
|
1163
|
+
// Store outputs in TaskOutputs (lift large fields per OutputStore policy)
|
|
1171
1164
|
if (pResult.Outputs && typeof(pResult.Outputs) === 'object')
|
|
1172
1165
|
{
|
|
1173
|
-
|
|
1174
|
-
{
|
|
1175
|
-
pContext.TaskOutputs[pNodeHash] = {};
|
|
1176
|
-
}
|
|
1177
|
-
Object.assign(pContext.TaskOutputs[pNodeHash], pResult.Outputs);
|
|
1166
|
+
libOutputStore.mergeAndLift(pContext, pNodeHash, pResult.Outputs, { Fable: this.fable, Logger: this.log });
|
|
1178
1167
|
}
|
|
1179
1168
|
|
|
1180
1169
|
// Store any state writes from the result
|
|
@@ -1313,9 +1302,17 @@ class UltravisorExecutionEngine extends libPictService
|
|
|
1313
1302
|
let tmpSourcePortName = this._extractPortName(tmpConn.SourcePortHash, tmpPortLabelMap);
|
|
1314
1303
|
let tmpTargetPortName = this._extractPortName(tmpConn.TargetPortHash, tmpPortLabelMap);
|
|
1315
1304
|
|
|
1316
|
-
// Read the source value from the source node's outputs
|
|
1305
|
+
// Read the source value from the source node's outputs.
|
|
1306
|
+
// Large outputs are stored as $$ref objects pointing into the
|
|
1307
|
+
// run's staging dir — materialize before the value reaches a
|
|
1308
|
+
// downstream task handler so the State edge contract stays
|
|
1309
|
+
// transparent (handlers never see a ref).
|
|
1317
1310
|
let tmpSourceNodeOutputs = pContext.TaskOutputs[tmpConn.SourceNodeHash] || {};
|
|
1318
1311
|
let tmpSourceValue = tmpSourceNodeOutputs[tmpSourcePortName];
|
|
1312
|
+
if (libOutputStore.isOutputRef(tmpSourceValue))
|
|
1313
|
+
{
|
|
1314
|
+
tmpSourceValue = libOutputStore.materializeRefValue(pContext.StagingPath, tmpSourceValue, this.log);
|
|
1315
|
+
}
|
|
1319
1316
|
|
|
1320
1317
|
// Apply template if defined
|
|
1321
1318
|
if (tmpConn.Data && tmpConn.Data.Template && typeof(tmpConn.Data.Template) === 'string')
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
const libFS = require('fs');
|
|
2
|
+
const libPath = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bound the in-memory size of TaskOutputs by lifting large per-port
|
|
6
|
+
* payloads to disk and replacing them in the manifest with a small
|
|
7
|
+
* reference object.
|
|
8
|
+
*
|
|
9
|
+
* The fix the lab needed: typed-op pipelines (Pull -> Intersect ->
|
|
10
|
+
* Comprehension -> Write at 100K rows) were emitting ~30 MB of JSON-
|
|
11
|
+
* stringified `Result` per node. UV held those strings in TaskOutputs
|
|
12
|
+
* and serialized the full manifest twice per snapshot (disk + sync
|
|
13
|
+
* /Trigger response), which saturated the V8 heap inside Builtin_
|
|
14
|
+
* JsonParse on sustained stress.
|
|
15
|
+
*
|
|
16
|
+
* Splitting the two jobs TaskOutputs was secretly doing — short-lived
|
|
17
|
+
* State-edge transport vs. durable manifest record — by lifting only
|
|
18
|
+
* the transport payload, breaks the link between manifest size and
|
|
19
|
+
* payload size while preserving the State-edge contract: handlers
|
|
20
|
+
* still see materialized values, never refs.
|
|
21
|
+
*
|
|
22
|
+
* Lift on write, materialize on read. Pure functions; the caller owns
|
|
23
|
+
* the staging path.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const REF_KEY = '$$ref';
|
|
27
|
+
const REF_DIR = 'outputs';
|
|
28
|
+
const DEFAULT_THRESHOLD_BYTES = 1024 * 1024;
|
|
29
|
+
const STREAM_CHUNK_BYTES = 64 * 1024;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the byte threshold for lifting. Order: env var, program
|
|
33
|
+
* config, default. Env var wins so an operator can hot-tune in a
|
|
34
|
+
* stuck container without rebuilding. Returns the default for any
|
|
35
|
+
* non-positive or unparseable value.
|
|
36
|
+
*/
|
|
37
|
+
function getThresholdBytes(pFable)
|
|
38
|
+
{
|
|
39
|
+
let tmpEnv = process.env.UV_OUTPUT_STORE_THRESHOLD_BYTES;
|
|
40
|
+
if (tmpEnv)
|
|
41
|
+
{
|
|
42
|
+
let tmpParsed = parseInt(tmpEnv, 10);
|
|
43
|
+
if (!isNaN(tmpParsed) && tmpParsed > 0)
|
|
44
|
+
{
|
|
45
|
+
return tmpParsed;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (pFable && pFable.ProgramConfiguration
|
|
49
|
+
&& typeof(pFable.ProgramConfiguration.UltravisorOutputStoreThresholdBytes) === 'number'
|
|
50
|
+
&& pFable.ProgramConfiguration.UltravisorOutputStoreThresholdBytes > 0)
|
|
51
|
+
{
|
|
52
|
+
return pFable.ProgramConfiguration.UltravisorOutputStoreThresholdBytes;
|
|
53
|
+
}
|
|
54
|
+
return DEFAULT_THRESHOLD_BYTES;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* True for the {$$ref, Bytes} shape produced by liftValue. Used by
|
|
59
|
+
* read-side materialization and by callers that want to special-case
|
|
60
|
+
* ref-bearing entries (e.g. the optional inline-follow on /Manifest).
|
|
61
|
+
*/
|
|
62
|
+
function isOutputRef(pValue)
|
|
63
|
+
{
|
|
64
|
+
return pValue !== null
|
|
65
|
+
&& typeof(pValue) === 'object'
|
|
66
|
+
&& typeof(pValue[REF_KEY]) === 'string';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Decide whether a single value is large enough to lift. Strings and
|
|
71
|
+
* Buffers carry the bulk of typed-op payloads — they're the only
|
|
72
|
+
* shapes worth measuring without paying the JSON.stringify cost we're
|
|
73
|
+
* trying to avoid. Objects/Arrays under threshold stay inline; if a
|
|
74
|
+
* future workload produces a multi-megabyte object literal, lift the
|
|
75
|
+
* producer side first (cheaper) before reaching for a structural
|
|
76
|
+
* size walk here.
|
|
77
|
+
*/
|
|
78
|
+
function _shouldLift(pValue, pThresholdBytes)
|
|
79
|
+
{
|
|
80
|
+
if (typeof(pValue) === 'string')
|
|
81
|
+
{
|
|
82
|
+
return Buffer.byteLength(pValue, 'utf8') >= pThresholdBytes;
|
|
83
|
+
}
|
|
84
|
+
if (Buffer.isBuffer(pValue))
|
|
85
|
+
{
|
|
86
|
+
return pValue.length >= pThresholdBytes;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Sanitize a path segment so node hashes / port names map to safe
|
|
93
|
+
* filesystem names. Anything outside [A-Za-z0-9_-] becomes '_'.
|
|
94
|
+
*/
|
|
95
|
+
function _safeSegment(pValue)
|
|
96
|
+
{
|
|
97
|
+
let tmpStr = String(pValue == null ? '' : pValue);
|
|
98
|
+
if (tmpStr === '')
|
|
99
|
+
{
|
|
100
|
+
return '_';
|
|
101
|
+
}
|
|
102
|
+
return tmpStr.replace(/[^A-Za-z0-9_\-]/g, '_');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Stream a string or Buffer to disk in fixed-size chunks. Avoids the
|
|
107
|
+
* implicit Buffer.from copy that writeFileSync does for large strings
|
|
108
|
+
* (which is half the OOM blast radius — string + buffer alive at the
|
|
109
|
+
* same time). Peak overhead is one chunk regardless of payload size.
|
|
110
|
+
*/
|
|
111
|
+
function _streamWriteSync(pAbsolutePath, pValue)
|
|
112
|
+
{
|
|
113
|
+
let tmpFd = libFS.openSync(pAbsolutePath, 'w');
|
|
114
|
+
try
|
|
115
|
+
{
|
|
116
|
+
if (typeof(pValue) === 'string')
|
|
117
|
+
{
|
|
118
|
+
let tmpOffset = 0;
|
|
119
|
+
let tmpLen = pValue.length;
|
|
120
|
+
while (tmpOffset < tmpLen)
|
|
121
|
+
{
|
|
122
|
+
let tmpEnd = Math.min(tmpOffset + STREAM_CHUNK_BYTES, tmpLen);
|
|
123
|
+
let tmpChunkBuf = Buffer.from(pValue.slice(tmpOffset, tmpEnd), 'utf8');
|
|
124
|
+
libFS.writeSync(tmpFd, tmpChunkBuf, 0, tmpChunkBuf.length);
|
|
125
|
+
tmpOffset = tmpEnd;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (Buffer.isBuffer(pValue))
|
|
129
|
+
{
|
|
130
|
+
let tmpOffset = 0;
|
|
131
|
+
while (tmpOffset < pValue.length)
|
|
132
|
+
{
|
|
133
|
+
let tmpEnd = Math.min(tmpOffset + STREAM_CHUNK_BYTES, pValue.length);
|
|
134
|
+
libFS.writeSync(tmpFd, pValue, tmpOffset, tmpEnd - tmpOffset);
|
|
135
|
+
tmpOffset = tmpEnd;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else
|
|
139
|
+
{
|
|
140
|
+
throw new Error('OutputStore: _streamWriteSync expects a string or Buffer');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
finally
|
|
144
|
+
{
|
|
145
|
+
libFS.closeSync(tmpFd);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Lift one value to disk and return the ref object that should
|
|
151
|
+
* replace it in TaskOutputs. The ref's path is relative to the run's
|
|
152
|
+
* staging dir; readers join it back with the same staging dir so a
|
|
153
|
+
* staging folder can be moved without rewriting refs.
|
|
154
|
+
*/
|
|
155
|
+
function liftValue(pStagingPath, pNodeHash, pPort, pValue)
|
|
156
|
+
{
|
|
157
|
+
let tmpRelDir = libPath.join(REF_DIR, _safeSegment(pNodeHash));
|
|
158
|
+
let tmpAbsDir = libPath.resolve(pStagingPath, tmpRelDir);
|
|
159
|
+
libFS.mkdirSync(tmpAbsDir, { recursive: true });
|
|
160
|
+
|
|
161
|
+
let tmpFileName = _safeSegment(pPort) + '.json';
|
|
162
|
+
let tmpAbsFile = libPath.resolve(tmpAbsDir, tmpFileName);
|
|
163
|
+
let tmpRelFile = libPath.join(tmpRelDir, tmpFileName);
|
|
164
|
+
|
|
165
|
+
_streamWriteSync(tmpAbsFile, pValue);
|
|
166
|
+
|
|
167
|
+
let tmpBytes;
|
|
168
|
+
if (typeof(pValue) === 'string')
|
|
169
|
+
{
|
|
170
|
+
tmpBytes = Buffer.byteLength(pValue, 'utf8');
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
{
|
|
174
|
+
tmpBytes = pValue.length;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let tmpRef = {};
|
|
178
|
+
tmpRef[REF_KEY] = tmpRelFile.split(libPath.sep).join('/');
|
|
179
|
+
tmpRef.Bytes = tmpBytes;
|
|
180
|
+
return tmpRef;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Read a ref's contents back from disk. Returns the original value
|
|
185
|
+
* (utf8 string — the only shape we lift today) or undefined when the
|
|
186
|
+
* staging file is gone (e.g. retention sweep ran, or a reloaded
|
|
187
|
+
* manifest references a folder that was cleaned).
|
|
188
|
+
*
|
|
189
|
+
* Non-ref values pass through unchanged so callers can wrap any read
|
|
190
|
+
* site without branching.
|
|
191
|
+
*/
|
|
192
|
+
function materializeRefValue(pStagingPath, pValue, pLogger)
|
|
193
|
+
{
|
|
194
|
+
if (!isOutputRef(pValue))
|
|
195
|
+
{
|
|
196
|
+
return pValue;
|
|
197
|
+
}
|
|
198
|
+
if (!pStagingPath)
|
|
199
|
+
{
|
|
200
|
+
if (pLogger) pLogger.warn(`OutputStore: cannot materialize ref [${pValue[REF_KEY]}] without a staging path.`);
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
let tmpAbsFile = libPath.resolve(pStagingPath, pValue[REF_KEY]);
|
|
204
|
+
if (!libFS.existsSync(tmpAbsFile))
|
|
205
|
+
{
|
|
206
|
+
if (pLogger) pLogger.warn(`OutputStore: ref payload missing on disk [${tmpAbsFile}].`);
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
try
|
|
210
|
+
{
|
|
211
|
+
return libFS.readFileSync(tmpAbsFile, 'utf8');
|
|
212
|
+
}
|
|
213
|
+
catch (pError)
|
|
214
|
+
{
|
|
215
|
+
if (pLogger) pLogger.warn(`OutputStore: failed to read ref payload [${tmpAbsFile}]: ${pError.message}`);
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Merge a set of new fields into TaskOutputs[<nodeHash>], lifting any
|
|
222
|
+
* field whose serialized size meets or exceeds the threshold. Falls
|
|
223
|
+
* back to inline storage on any IO failure so a flaky disk doesn't
|
|
224
|
+
* turn into lost outputs.
|
|
225
|
+
*
|
|
226
|
+
* This is the one entry point the engine uses for all three places
|
|
227
|
+
* that write to TaskOutputs (resume, intermediate event, task
|
|
228
|
+
* complete). Centralizing avoids the "I forgot to lift in this code
|
|
229
|
+
* path" bug the original conflated design encouraged.
|
|
230
|
+
*/
|
|
231
|
+
function mergeAndLift(pContext, pNodeHash, pNewFields, pOptions)
|
|
232
|
+
{
|
|
233
|
+
if (!pNewFields || typeof(pNewFields) !== 'object')
|
|
234
|
+
{
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (!pContext.TaskOutputs)
|
|
238
|
+
{
|
|
239
|
+
pContext.TaskOutputs = {};
|
|
240
|
+
}
|
|
241
|
+
if (!pContext.TaskOutputs[pNodeHash])
|
|
242
|
+
{
|
|
243
|
+
pContext.TaskOutputs[pNodeHash] = {};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let tmpTarget = pContext.TaskOutputs[pNodeHash];
|
|
247
|
+
let tmpFable = (pOptions && pOptions.Fable) || null;
|
|
248
|
+
let tmpLogger = (pOptions && pOptions.Logger) || null;
|
|
249
|
+
let tmpThreshold = getThresholdBytes(tmpFable);
|
|
250
|
+
|
|
251
|
+
let tmpKeys = Object.keys(pNewFields);
|
|
252
|
+
for (let i = 0; i < tmpKeys.length; i++)
|
|
253
|
+
{
|
|
254
|
+
let tmpKey = tmpKeys[i];
|
|
255
|
+
let tmpVal = pNewFields[tmpKey];
|
|
256
|
+
|
|
257
|
+
if (pContext.StagingPath && _shouldLift(tmpVal, tmpThreshold))
|
|
258
|
+
{
|
|
259
|
+
try
|
|
260
|
+
{
|
|
261
|
+
tmpTarget[tmpKey] = liftValue(pContext.StagingPath, pNodeHash, tmpKey, tmpVal);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
catch (pError)
|
|
265
|
+
{
|
|
266
|
+
if (tmpLogger) tmpLogger.warn(`OutputStore: lift failed for [${pNodeHash}.${tmpKey}], falling back to inline: ${pError.message}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
tmpTarget[tmpKey] = tmpVal;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Walk a TaskOutputs map and inline every ref into its materialized
|
|
275
|
+
* value. Used by the optional /Manifest?inline=outputs follow path —
|
|
276
|
+
* regular manifest reads return refs as-is.
|
|
277
|
+
*/
|
|
278
|
+
function inlineAllRefs(pStagingPath, pTaskOutputs, pLogger)
|
|
279
|
+
{
|
|
280
|
+
if (!pTaskOutputs || typeof(pTaskOutputs) !== 'object')
|
|
281
|
+
{
|
|
282
|
+
return pTaskOutputs;
|
|
283
|
+
}
|
|
284
|
+
let tmpResult = {};
|
|
285
|
+
let tmpNodeKeys = Object.keys(pTaskOutputs);
|
|
286
|
+
for (let i = 0; i < tmpNodeKeys.length; i++)
|
|
287
|
+
{
|
|
288
|
+
let tmpNodeHash = tmpNodeKeys[i];
|
|
289
|
+
let tmpEntry = pTaskOutputs[tmpNodeHash];
|
|
290
|
+
if (!tmpEntry || typeof(tmpEntry) !== 'object')
|
|
291
|
+
{
|
|
292
|
+
tmpResult[tmpNodeHash] = tmpEntry;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
let tmpInlined = {};
|
|
296
|
+
let tmpFieldKeys = Object.keys(tmpEntry);
|
|
297
|
+
for (let j = 0; j < tmpFieldKeys.length; j++)
|
|
298
|
+
{
|
|
299
|
+
let tmpField = tmpFieldKeys[j];
|
|
300
|
+
tmpInlined[tmpField] = materializeRefValue(pStagingPath, tmpEntry[tmpField], pLogger);
|
|
301
|
+
}
|
|
302
|
+
tmpResult[tmpNodeHash] = tmpInlined;
|
|
303
|
+
}
|
|
304
|
+
return tmpResult;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
module.exports =
|
|
308
|
+
{
|
|
309
|
+
REF_KEY: REF_KEY,
|
|
310
|
+
DEFAULT_THRESHOLD_BYTES: DEFAULT_THRESHOLD_BYTES,
|
|
311
|
+
getThresholdBytes: getThresholdBytes,
|
|
312
|
+
isOutputRef: isOutputRef,
|
|
313
|
+
liftValue: liftValue,
|
|
314
|
+
materializeRefValue: materializeRefValue,
|
|
315
|
+
mergeAndLift: mergeAndLift,
|
|
316
|
+
inlineAllRefs: inlineAllRefs
|
|
317
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const libPictService = require('pict-serviceproviderbase');
|
|
2
|
+
const libOutputStore = require('./Ultravisor-OutputStore.cjs');
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Manages three-level state (Global, Operation, Task) and provides
|
|
@@ -67,9 +68,9 @@ class UltravisorStateManager extends libPictService
|
|
|
67
68
|
this.log.warn(`UltravisorStateManager: resolveAddress for Task.X requires a current node hash.`);
|
|
68
69
|
return undefined;
|
|
69
70
|
}
|
|
70
|
-
return this._resolveFromObject(
|
|
71
|
+
return this._materializeIfRef(pExecutionContext, this._resolveFromObject(
|
|
71
72
|
pExecutionContext.TaskOutputs[pCurrentNodeHash] || {},
|
|
72
|
-
tmpRemainder);
|
|
73
|
+
tmpRemainder));
|
|
73
74
|
|
|
74
75
|
case 'TaskOutput':
|
|
75
76
|
{
|
|
@@ -77,14 +78,19 @@ class UltravisorStateManager extends libPictService
|
|
|
77
78
|
let tmpSecondDot = tmpRemainder.indexOf('.');
|
|
78
79
|
if (tmpSecondDot < 0)
|
|
79
80
|
{
|
|
80
|
-
// Just TaskOutput.{NodeHash} -- return the whole output object
|
|
81
|
+
// Just TaskOutput.{NodeHash} -- return the whole output object.
|
|
82
|
+
// Whole-node return ships ref shapes through to the
|
|
83
|
+
// caller — single-port reads materialize, but a
|
|
84
|
+
// "give me the whole bag" caller has to handle refs
|
|
85
|
+
// itself (or use Task.X / TaskOutput.X.Y to get the
|
|
86
|
+
// materialized leaf).
|
|
81
87
|
return pExecutionContext.TaskOutputs[tmpRemainder] || {};
|
|
82
88
|
}
|
|
83
89
|
let tmpNodeHash = tmpRemainder.substring(0, tmpSecondDot);
|
|
84
90
|
let tmpPath = tmpRemainder.substring(tmpSecondDot + 1);
|
|
85
|
-
return this._resolveFromObject(
|
|
91
|
+
return this._materializeIfRef(pExecutionContext, this._resolveFromObject(
|
|
86
92
|
pExecutionContext.TaskOutputs[tmpNodeHash] || {},
|
|
87
|
-
tmpPath);
|
|
93
|
+
tmpPath));
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
case 'Output':
|
|
@@ -248,6 +254,20 @@ class UltravisorStateManager extends libPictService
|
|
|
248
254
|
this._Manyfest.setValueAtAddress(pObject, pPath, pValue);
|
|
249
255
|
return true;
|
|
250
256
|
}
|
|
257
|
+
|
|
258
|
+
// Resolve $$ref payloads to their materialized value when reading
|
|
259
|
+
// from TaskOutputs. Per-leaf reads (Task.X, TaskOutput.X.Y) go
|
|
260
|
+
// through this; whole-node reads (TaskOutput.X) deliberately do not
|
|
261
|
+
// — see the case-handler comment.
|
|
262
|
+
_materializeIfRef(pExecutionContext, pValue)
|
|
263
|
+
{
|
|
264
|
+
if (!libOutputStore.isOutputRef(pValue))
|
|
265
|
+
{
|
|
266
|
+
return pValue;
|
|
267
|
+
}
|
|
268
|
+
return libOutputStore.materializeRefValue(
|
|
269
|
+
pExecutionContext.StagingPath, pValue, this.log);
|
|
270
|
+
}
|
|
251
271
|
}
|
|
252
272
|
|
|
253
273
|
module.exports = UltravisorStateManager;
|
|
@@ -8,6 +8,7 @@ const libOratorServiceServerRestify = require(`orator-serviceserver-restify`);
|
|
|
8
8
|
const libOratorAuthentication = require('orator-authentication');
|
|
9
9
|
const libWebSocket = require('ws');
|
|
10
10
|
const libStatus = require('../services/Ultravisor-Status.cjs');
|
|
11
|
+
const libOutputStore = require('../services/Ultravisor-OutputStore.cjs');
|
|
11
12
|
|
|
12
13
|
// Strip a manifest down to the JSON-serializable shape the wire
|
|
13
14
|
// expects. The in-memory ExecutionContext can carry closures in
|
|
@@ -41,6 +42,18 @@ function _cleanManifestForWire(pManifest)
|
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
// Optional follow-the-refs pass for /Manifest/:RunHash?inline=outputs.
|
|
46
|
+
// Default response ships ref shapes; this helper is invoked only when
|
|
47
|
+
// the caller explicitly asks for materialized payloads. Reads files
|
|
48
|
+
// synchronously off the staging dir — fine for the rare inspection
|
|
49
|
+
// case but not something to call from a hot path.
|
|
50
|
+
function _inlineIfRequested(pCleanManifest, pStagingPath, pInlineOutputs, pLogger)
|
|
51
|
+
{
|
|
52
|
+
if (!pInlineOutputs || !pCleanManifest) return pCleanManifest;
|
|
53
|
+
pCleanManifest.TaskOutputs = libOutputStore.inlineAllRefs(pStagingPath, pCleanManifest.TaskOutputs, pLogger);
|
|
54
|
+
return pCleanManifest;
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
// Lightweight query-string parser. Restify's queryParser plugin is
|
|
45
58
|
// not enabled on this server (mounting it would change the request
|
|
46
59
|
// shape for every existing handler), so the few routes that need
|
|
@@ -1895,11 +1908,19 @@ class UltravisorAPIServer extends libPictService
|
|
|
1895
1908
|
function (pRequest, pResponse, fNext)
|
|
1896
1909
|
{
|
|
1897
1910
|
let tmpHash = pRequest.params.RunHash;
|
|
1911
|
+
// Default ships TaskOutputs with ref shapes inline. The
|
|
1912
|
+
// `?inline=outputs` flag follows refs to materialized
|
|
1913
|
+
// values for UIs that need to see the full payload —
|
|
1914
|
+
// this is the rare case (e.g. raw inspection in the
|
|
1915
|
+
// manifest viewer) and bypasses the OOM guard, so
|
|
1916
|
+
// callers should know what they're asking for.
|
|
1917
|
+
let tmpQuery = _parseQueryString(pRequest.url);
|
|
1918
|
+
let tmpInlineOutputs = (tmpQuery.inline === 'outputs');
|
|
1898
1919
|
let tmpManifest = this._getService('UltravisorExecutionManifest');
|
|
1899
1920
|
let tmpRun = tmpManifest ? tmpManifest.getRun(tmpHash) : null;
|
|
1900
1921
|
if (tmpRun)
|
|
1901
1922
|
{
|
|
1902
|
-
pResponse.send(_cleanManifestForWire(tmpRun));
|
|
1923
|
+
pResponse.send(_inlineIfRequested(_cleanManifestForWire(tmpRun), tmpRun.StagingPath, tmpInlineOutputs, this.log));
|
|
1903
1924
|
return fNext();
|
|
1904
1925
|
}
|
|
1905
1926
|
// Not in memory — try the bridge (beacon-backed history).
|
|
@@ -1913,7 +1934,8 @@ class UltravisorAPIServer extends libPictService
|
|
|
1913
1934
|
{
|
|
1914
1935
|
if (pResult && pResult.Success && pResult.Manifest)
|
|
1915
1936
|
{
|
|
1916
|
-
|
|
1937
|
+
let tmpClean = _cleanManifestForWire(pResult.Manifest);
|
|
1938
|
+
pResponse.send(_inlineIfRequested(tmpClean, pResult.Manifest.StagingPath, tmpInlineOutputs, this.log));
|
|
1917
1939
|
}
|
|
1918
1940
|
else
|
|
1919
1941
|
{
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# Persistence via retold-databeacon
|
|
2
2
|
|
|
3
|
-
**Status:
|
|
4
|
-
for routing ultravisor's queue and manifest persistence
|
|
5
|
-
instead of the specialized `ultravisor-queue-beacon`
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
**Status: shipped. Legacy modules removed 2026-05-03.** This doc is the
|
|
4
|
+
cross-session plan for routing ultravisor's queue and manifest persistence
|
|
5
|
+
through retold-databeacon instead of the specialized `ultravisor-queue-beacon`
|
|
6
|
+
and `ultravisor-manifest-beacon` modules (now deleted from the monorepo —
|
|
7
|
+
see [What changed and why](#what-changed-and-why) for the historical context).
|
|
8
|
+
It captures the architectural decision, the work breakdown, and the hand-off
|
|
9
|
+
state so a fresh context can resume from here.
|
|
8
10
|
|
|
9
11
|
If you are picking this up cold, read this doc top-to-bottom before touching
|
|
10
12
|
code — the decisions below are load-bearing and the cross-module dependencies
|
|
@@ -12,15 +14,18 @@ are not obvious from the source.
|
|
|
12
14
|
|
|
13
15
|
## What changed and why
|
|
14
16
|
|
|
15
|
-
Before this redesign
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
Before this redesign (the modules described here have since been deleted from
|
|
18
|
+
the monorepo on 2026-05-03 — kept here for historical context):
|
|
19
|
+
- `ultravisor-queue-beacon` advertised capability `QueuePersistence` with
|
|
20
|
+
`QP_*` actions. Shipped a `QueuePersistenceProviderBase` and a default
|
|
18
21
|
`MemoryQueuePersistenceProvider`.
|
|
19
|
-
- `ultravisor-manifest-beacon`
|
|
20
|
-
|
|
22
|
+
- `ultravisor-manifest-beacon` advertised `ManifestStore` with `MS_*` actions.
|
|
23
|
+
Shipped a `ManifestStoreProviderBase` and `MemoryManifestStoreProvider`.
|
|
21
24
|
- `Ultravisor-QueuePersistenceBridge.cjs` and
|
|
22
|
-
`Ultravisor-ManifestStoreBridge.cjs`
|
|
23
|
-
capability when a beacon
|
|
25
|
+
`Ultravisor-ManifestStoreBridge.cjs` dispatched into the corresponding
|
|
26
|
+
capability when a beacon was connected, fell back to local storage otherwise.
|
|
27
|
+
Both bridges still exist inside ultravisor — they now translate `QP_*` /
|
|
28
|
+
`MS_*` semantics into `MeadowProxy` calls against retold-databeacon.
|
|
24
29
|
- `bootstrap-flush` (already shipped) replays locally-buffered writes into a
|
|
25
30
|
newly-connected beacon via per-beacon HWM tracking persisted to
|
|
26
31
|
`<DataPath>/persistence-bridge-hwm.json`.
|
|
@@ -36,10 +41,12 @@ The redesign:
|
|
|
36
41
|
`Request` calls** (Method + Path + Body) instead of dispatching to a
|
|
37
42
|
specialized capability. The schema (table names, column shapes) is owned
|
|
38
43
|
by ultravisor; retold-databeacon is a generic gateway.
|
|
39
|
-
- **The two specialized beacon modules
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
- **The two specialized beacon modules have been deleted (2026-05-03).**
|
|
45
|
+
They were marked legacy throughout Session 4 and the migration path proved
|
|
46
|
+
out across all retold-databeacon-supported engines (mysql / mssql / postgres
|
|
47
|
+
/ sqlite). Embedded deployments that prefer in-process Provider-pattern
|
|
48
|
+
persistence can still subclass the bridges' fallback path; the QP/MS
|
|
49
|
+
capability surfaces themselves are gone from the monorepo.
|
|
43
50
|
- **The lab's UV detail view gains a "persistence databeacon" picker.** The
|
|
44
51
|
operator picks a running retold-databeacon (which itself has an
|
|
45
52
|
engine+database picker via `lab-engine-database-picker`); ultravisor's
|
|
@@ -1120,21 +1127,31 @@ engine-coverage work — same files get touched.
|
|
|
1120
1127
|
|
|
1121
1128
|
#### Legacy beacon deprecation
|
|
1122
1129
|
|
|
1123
|
-
`ultravisor-queue-beacon` and
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1130
|
+
Originally Session 4 just labeled `ultravisor-queue-beacon` and
|
|
1131
|
+
`ultravisor-manifest-beacon` as `(legacy)` in the lab UI while keeping
|
|
1132
|
+
the modules selectable. As of 2026-05-03 the modules have been deleted
|
|
1133
|
+
outright — the migration path proved out across every retold-databeacon
|
|
1134
|
+
engine and there are no remaining users.
|
|
1135
|
+
|
|
1136
|
+
What's gone:
|
|
1137
|
+
|
|
1138
|
+
- The two module directories (`modules/apps/ultravisor-queue-beacon`,
|
|
1139
|
+
`modules/apps/ultravisor-manifest-beacon`).
|
|
1140
|
+
- Manifest entries in `Retold-Modules-Manifest.json` and
|
|
1141
|
+
`modules/Include-Retold-Module-List.sh`.
|
|
1142
|
+
- Lab registry entries — `Service-BeaconTypeRegistry.js`'s
|
|
1143
|
+
`SCANNED_MODULES` no longer scans them and `DEPRECATED_BEACON_TYPES`
|
|
1144
|
+
is now an empty Set (kept for future per-module deprecations).
|
|
1145
|
+
|
|
1146
|
+
What stayed:
|
|
1147
|
+
|
|
1148
|
+
- `Ultravisor-QueuePersistenceBridge.cjs` and
|
|
1149
|
+
`Ultravisor-ManifestStoreBridge.cjs` inside ultravisor — they still
|
|
1150
|
+
dispatch `QP_*` / `MS_*` semantics, just into MeadowProxy now.
|
|
1151
|
+
- The `LEGACY_TOOLTIP` constant + per-stanza
|
|
1152
|
+
`retoldBeacon.deprecated: true` plumbing in
|
|
1153
|
+
`Service-BeaconTypeRegistry.js` — useful infrastructure for whatever
|
|
1154
|
+
the next deprecation needs.
|
|
1138
1155
|
|
|
1139
1156
|
#### Test-fable cleanup (tangential hygiene)
|
|
1140
1157
|
|