@winkjs/composer 0.5.1 → 0.7.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.
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/composer.js +1 -1
- package/src/core/batch/aoo-encoder.js +4 -4
- package/src/core/batch/sliding-window.js +2 -2
- package/src/core/codec/index.js +4 -4
- package/src/core/emitter-manager/mqtt/connection.js +209 -0
- package/src/core/emitter-manager/mqtt/constants.js +43 -29
- package/src/core/emitter-manager/mqtt/emitter.js +108 -633
- package/src/core/emitter-manager/mqtt/health.js +151 -0
- package/src/core/emitter-manager/mqtt/index.js +68 -4
- package/src/core/emitter-manager/mqtt/publish.js +292 -0
- package/src/core/emitter-manager/mqtt/resolve-config.js +312 -0
- package/src/core/emitter-manager/mqtt/shutdown-drain.js +247 -0
- package/src/core/emitter-manager/terminal/assert-columns.js +1 -1
- package/src/core/emitter-manager/terminal/index.js +20 -5
- package/src/core/env-vars.js +143 -16
- package/src/core/logger/index.js +174 -0
- package/src/core/mqtt-protocol.js +2 -2
- package/src/core/partition-manager/get-stats.js +31 -0
- package/src/core/partition-manager/index.js +1 -0
- package/src/core/partition-manager/init.js +15 -0
- package/src/core/partition-manager/update.js +143 -87
- package/src/core/partition-manager/validate-flow.js +1 -1
- package/src/core/semantics/loader.js +1 -1
- package/src/core/semantics/warnings.js +5 -3
- package/src/core/shutdown-manager/index.js +71 -21
- package/src/core/source-manager/csv/start.js +109 -35
- package/src/core/source-manager/mqtt/client.js +276 -83
- package/src/core/source-manager/mqtt/dedup.js +19 -14
- package/src/core/source-manager/mqtt/index.js +33 -6
- package/src/core/source-manager/mqtt/status.js +211 -46
- package/src/core/source-manager/record-shape.js +55 -0
- package/src/core/source-manager/test-harness/field-generator.js +1 -1
- package/src/core/source-manager/test-harness/start.js +52 -10
- package/src/core/source-manager/test-harness/validate.js +1 -1
- package/src/core/storage-manager/questdb/address-policy.js +264 -0
- package/src/core/storage-manager/questdb/assert-columns.js +2 -2
- package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
- package/src/core/storage-manager/questdb/ensure-tables.js +1 -1
- package/src/core/storage-manager/questdb/flush-engine.js +852 -0
- package/src/core/storage-manager/questdb/flush-tracker.js +331 -0
- package/src/core/storage-manager/questdb/index.js +592 -827
- package/src/core/storage-manager/questdb/persist-plan.js +147 -162
- package/src/core/storage-manager/questdb/resolve-options.js +403 -0
- package/src/core/storage-manager/questdb/shutdown-drain.js +181 -0
- package/src/core/storage-manager/questdb/skip-warnings.js +180 -0
- package/src/core/test-utils/tcp-proxy.js +194 -1
- package/src/core/tunable/helpers.js +1 -1
- package/src/core/utils/address/index.js +362 -0
- package/src/core/utils/address/probe.js +210 -0
- package/src/core/utils/callback-guard/index.js +176 -0
- package/src/core/utils/clock/index.js +34 -0
- package/src/core/utils/half-life/index.js +4 -4
- package/src/core/utils/jitter/index.js +51 -0
- package/src/core/utils/line-rate/index.js +104 -0
- package/src/core/utils/quantize/index.js +43 -19
- package/src/core/utils/validate/composer-validators.js +2 -2
- package/src/core/utils/validate/index.js +1 -1
- package/src/core/utils/validate/validate.js +1 -1
- package/src/core/wiring/assert-handle.js +5 -5
- package/src/core/wiring/assert-module.js +2 -2
- package/src/core/wiring/wire-emitters.js +7 -4
- package/src/core/wiring/wire-linear-graph.js +1 -1
- package/src/core/wiring/wire-node.js +6 -6
- package/src/core/wiring/wire-semantics.js +1 -1
- package/src/core/wiring/wire-storages.js +14 -5
- package/src/flow/driver.js +31 -6
- package/src/flow/expand-single-input-array.js +4 -4
- package/src/flow/flow-api-schemas.js +4 -4
- package/src/flow/flow.js +62 -41
- package/src/flow/for-each.js +12 -11
- package/src/flow/get-target-array.js +2 -2
- package/src/flow/load-node-module.js +1 -1
- package/src/flow/make-node-method.js +4 -4
- package/src/flow/resolve-pick-by-field.js +1 -1
- package/src/flow/run.js +200 -40
- package/src/flow/serialize.js +1 -1
- package/src/flow/spec-builder.js +1 -1
- package/src/flow/validate.js +1 -1
- package/src/nodes/butterworth-filter/init.js +5 -4
- package/src/nodes/categorize/update.js +3 -1
- package/src/nodes/controller/update.js +2 -1
- package/src/nodes/dwell-time-tracker/update.js +2 -1
- package/src/nodes/emit-if/helpers.js +57 -11
- package/src/nodes/emit-if/init.js +1 -1
- package/src/nodes/emit-if/update.js +8 -11
- package/src/nodes/page-hinkley/update.js +2 -1
- package/src/nodes/pass-if/update.js +3 -1
- package/src/nodes/persist-if/helpers.js +60 -4
- package/src/nodes/persist-if/init.js +1 -1
- package/src/nodes/persist-if/update.js +8 -16
- package/src/nodes/persistence-check/update.js +2 -1
- package/src/nodes/sanitize/update.js +4 -2
- package/src/nodes/swing-watch/update.js +3 -2
- package/src/nodes/threshold/update.js +2 -1
- package/src/nodes/trend/compute-confidence.js +4 -4
- package/src/nodes/trend/update.js +2 -1
- package/src/nodes/winnow/update.js +3 -1
- package/src/tools/training/features.js +13 -13
- package/src/tools/training/logistic.js +1 -1
- package/src/tools/training/metrics.js +2 -2
- package/src/tools/training/scale.js +2 -2
- package/src/tools/training/validate.js +7 -7
- package/src/core/utils/console-formatter/index.js +0 -207
- package/src/nodes/archive/csv/index.js +0 -6
- package/src/nodes/archive/csv/init.js +0 -36
- package/src/nodes/archive/csv/load.js +0 -70
- package/src/nodes/archive/csv/reset.js +0 -14
- package/src/nodes/archive/csv/simple-parser.js +0 -216
- package/src/nodes/archive/csv/test.js +0 -173
- package/src/nodes/archive/csv/update.js +0 -63
- package/src/nodes/sse-emitter/index.js +0 -7
- package/src/nodes/sse-emitter/init.js +0 -105
- package/src/nodes/sse-emitter/introspect.js +0 -134
- package/src/nodes/sse-emitter/publish-to.js +0 -6
- package/src/nodes/sse-emitter/recompute.js +0 -16
- package/src/nodes/sse-emitter/reset.js +0 -19
- package/src/nodes/sse-emitter/update.js +0 -69
- package/src/nodes/sse-emitter/utils/broadcast-heartbeat.js +0 -36
- package/src/nodes/sse-emitter/utils/build-message.js +0 -31
- package/src/nodes/sse-emitter/utils/connection-manager.js +0 -45
- package/src/nodes/sse-emitter/utils/endpoint-initializer.js +0 -20
- package/src/nodes/sse-emitter/utils/health-monitor.js +0 -38
- package/src/nodes/sse-emitter/utils/qos-handler.js +0 -41
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import nodeTypeToModule from '../wiring/node-type-to-module.js';
|
|
2
2
|
|
|
3
3
|
import { ENV_VARS } from '../env-vars.js';
|
|
4
|
+
import { logger } from '../logger/index.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* This function transforms trigger specifications that reference nodes by name
|
|
@@ -44,12 +45,12 @@ const resolveTriggers = function ( stateStore, triggers, scopedFlow ) {
|
|
|
44
45
|
// Resolve first target to establish reference node type
|
|
45
46
|
const firstTargetName = targets[ 0 ];
|
|
46
47
|
if ( typeof firstTargetName !== 'string' || firstTargetName.length === 0 ) {
|
|
47
|
-
throw new TypeError( `Target name at trigger ${i}, target 0 must be a non-empty string; found '${firstTargetName}'` );
|
|
48
|
+
throw new TypeError( `winkComposer/partitionManager: Target name at trigger ${i}, target 0 must be a non-empty string; found '${firstTargetName}'` );
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
const firstTargetIndex = scopedFlow.specs.findIndex( ( spec ) => spec.name === firstTargetName );
|
|
51
52
|
if ( firstTargetIndex === -1 ) {
|
|
52
|
-
throw new Error( `Target node '${firstTargetName}' not found in flow specs for trigger ${i}` );
|
|
53
|
+
throw new Error( `winkComposer/partitionManager: Target node '${firstTargetName}' not found in flow specs for trigger ${i}` );
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
// Get reference node module and validate control method exists
|
|
@@ -58,11 +59,11 @@ const resolveTriggers = function ( stateStore, triggers, scopedFlow ) {
|
|
|
58
59
|
const referenceNode = scopedFlow.nodeModules[ moduleName ];
|
|
59
60
|
|
|
60
61
|
if ( !referenceNode ) {
|
|
61
|
-
throw new Error( `Node module '${moduleName}' not found for nodeType '${firstSpec.nodeType}' in trigger ${i}` );
|
|
62
|
+
throw new Error( `winkComposer/partitionManager: Node module '${moduleName}' not found for nodeType '${firstSpec.nodeType}' in trigger ${i}` );
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
if ( typeof referenceNode[ control ] !== 'function' ) {
|
|
65
|
-
throw new Error( `Control method '${control}' not found on node type '${referenceNode.getNodeType?.() || firstSpec.nodeType}' for trigger ${i}` );
|
|
66
|
+
throw new Error( `winkComposer/partitionManager: Control method '${control}' not found on node type '${referenceNode.getNodeType?.() || firstSpec.nodeType}' for trigger ${i}` );
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
// Store first resolved target
|
|
@@ -73,12 +74,12 @@ const resolveTriggers = function ( stateStore, triggers, scopedFlow ) {
|
|
|
73
74
|
const targetName = targets[ j ];
|
|
74
75
|
|
|
75
76
|
if ( typeof targetName !== 'string' || targetName.length === 0 ) {
|
|
76
|
-
throw new TypeError( `Target name at trigger ${i}, target ${j} must be a non-empty string; found '${targetName}'` );
|
|
77
|
+
throw new TypeError( `winkComposer/partitionManager: Target name at trigger ${i}, target ${j} must be a non-empty string; found '${targetName}'` );
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
const targetIndex = scopedFlow.specs.findIndex( ( spec ) => spec.name === targetName );
|
|
80
81
|
if ( targetIndex === -1 ) {
|
|
81
|
-
throw new Error( `Target node '${targetName}' not found in flow specs for trigger ${i}, target ${j}` );
|
|
82
|
+
throw new Error( `winkComposer/partitionManager: Target node '${targetName}' not found in flow specs for trigger ${i}, target ${j}` );
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
// Ensure all targets are homogeneous (same node type)
|
|
@@ -89,7 +90,7 @@ const resolveTriggers = function ( stateStore, triggers, scopedFlow ) {
|
|
|
89
90
|
if ( targetNode !== referenceNode ) {
|
|
90
91
|
const refNodeType = referenceNode.getNodeType?.() || firstSpec.nodeType;
|
|
91
92
|
const targetNodeType = targetNode.getNodeType?.() || targetSpec.nodeType;
|
|
92
|
-
throw new Error( `Target node '${targetName}' (type: ${targetNodeType}) is incompatible with reference node type '${refNodeType}' for trigger ${i}` );
|
|
93
|
+
throw new Error( `winkComposer/partitionManager: Target node '${targetName}' (type: ${targetNodeType}) is incompatible with reference node type '${refNodeType}' for trigger ${i}` );
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
resolvedTargets[ j ] = stateStore[ targetIndex ];
|
|
@@ -104,6 +105,102 @@ const resolveTriggers = function ( stateStore, triggers, scopedFlow ) {
|
|
|
104
105
|
return resolvedTriggers;
|
|
105
106
|
}; // resolveTriggers()
|
|
106
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Builds one specialization's graph: node states, per-partition
|
|
110
|
+
* injections, and resolved triggers. Extracted from update() so the
|
|
111
|
+
* creation attempt is one guardable unit — a throw from any node's
|
|
112
|
+
* init, a missing module, or trigger resolution leaves no partial
|
|
113
|
+
* graph published (the caller publishes only on success).
|
|
114
|
+
*
|
|
115
|
+
* @param {Object} flow - Flow specification with nodeModules
|
|
116
|
+
* @param {Array} specs - This specialization's node specs
|
|
117
|
+
* @param {*} partitionId - Partition key, for topic/storage injection
|
|
118
|
+
* @param {*} specializationType - Specialization key, for topic injection
|
|
119
|
+
* @returns {Array} Initialized node state array with resolved triggers
|
|
120
|
+
*/
|
|
121
|
+
const buildGraph = function ( flow, specs, partitionId, specializationType ) {
|
|
122
|
+
const graph = new Array( specs.length );
|
|
123
|
+
|
|
124
|
+
for ( let k = 0; k < specs.length; k += 1 ) {
|
|
125
|
+
const spec = specs[ k ];
|
|
126
|
+
|
|
127
|
+
// Look up node module using nodeTypeToModule
|
|
128
|
+
const moduleName = nodeTypeToModule( spec.nodeType );
|
|
129
|
+
const node = flow.nodeModules[ moduleName ];
|
|
130
|
+
|
|
131
|
+
if ( !node ) {
|
|
132
|
+
throw new Error( `winkComposer/partitionManager: Node module '${moduleName}' not found for nodeType '${spec.nodeType}' at index ${k}` );
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Initialize node state
|
|
136
|
+
graph[ k ] = node.init( spec );
|
|
137
|
+
|
|
138
|
+
// ====================================================================
|
|
139
|
+
// MQTT TOPIC INJECTION IN EMIT IF NODE
|
|
140
|
+
// ====================================================================
|
|
141
|
+
// Precompute partition-specific MQTT topic at initialization to avoid
|
|
142
|
+
// reconstruction on every emission (performance optimization).
|
|
143
|
+
//
|
|
144
|
+
// Topic structure: edgeDeviceId/partitionId/specialization/insightType
|
|
145
|
+
// Example: "edge-001/sensor-42/temperature/changeDetected"
|
|
146
|
+
//
|
|
147
|
+
// The edgeDeviceId can encode ISA-95 hierarchy for Unified Namespace:
|
|
148
|
+
// - Simple: "edge-001"
|
|
149
|
+
// - UNS: "northwind/riverton/plant2/line3" (enterprise/site/area/cell)
|
|
150
|
+
//
|
|
151
|
+
// Enables powerful MQTT subscription patterns:
|
|
152
|
+
// - All from edge: "edge-001/#" or "northwind/riverton/#"
|
|
153
|
+
// - Specific sensor: "+/sensor-42/#"
|
|
154
|
+
if ( spec.nodeType === 'Emit If' && ( graph[ k ].target === 'mqtt' || graph[ k ].target === 'terminal' ) ) {
|
|
155
|
+
graph[ k ].topic = `${ENV_VARS.edgeDeviceId}/${partitionId}/${specializationType}/${graph[ k ].insightType}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ====================================================================
|
|
159
|
+
// STORAGE AND PARTITION INJECTION IN PERSIST IF NODE
|
|
160
|
+
// ====================================================================
|
|
161
|
+
// Inject the storage singleton and partition ID at initialization.
|
|
162
|
+
// Storage reference is set during wiring (spec.storage), but partition
|
|
163
|
+
// ID is partition-specific and must be injected here.
|
|
164
|
+
//
|
|
165
|
+
// Zero hot-path overhead: All references are pre-resolved.
|
|
166
|
+
if ( spec.nodeType === 'Persist If' && spec.storage ) {
|
|
167
|
+
graph[ k ].storage = spec.storage;
|
|
168
|
+
graph[ k ].partitionId = partitionId;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Resolve triggers with specialization-scoped data
|
|
172
|
+
graph[ k ].resolvedTriggers = resolveTriggers( graph, spec.triggers, {
|
|
173
|
+
specs: specs,
|
|
174
|
+
nodeModules: flow.nodeModules
|
|
175
|
+
});
|
|
176
|
+
} // for ( let k = 0; k < specs.length; k += 1 )
|
|
177
|
+
|
|
178
|
+
// Special handling for Controller nodes with nested triggers
|
|
179
|
+
// Only controller node allows FORWARD node control.
|
|
180
|
+
// That is why it has to be performed only after all nodes have
|
|
181
|
+
// been initialized.
|
|
182
|
+
for ( let k = 0; k < specs.length; k += 1 ) {
|
|
183
|
+
const spec = specs[ k ];
|
|
184
|
+
if ( spec.nodeType === 'Controller' && graph[ k ].logic ) {
|
|
185
|
+
// Resolve triggers for each condition in the logic array
|
|
186
|
+
for ( let j = 0; j < graph[ k ].logic.length; j += 1 ) {
|
|
187
|
+
graph[ k ].logic[ j ].resolvedTriggers = resolveTriggers(
|
|
188
|
+
graph,
|
|
189
|
+
graph[ k ].logic[ j ].triggers,
|
|
190
|
+
{
|
|
191
|
+
specs: specs,
|
|
192
|
+
nodeModules: flow.nodeModules
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
// No top-level resolvedTriggers for controller
|
|
197
|
+
graph[ k ].resolvedTriggers = null;
|
|
198
|
+
}
|
|
199
|
+
} // for ( let k = 0; k < specs.length; k += 1 )
|
|
200
|
+
|
|
201
|
+
return graph;
|
|
202
|
+
};
|
|
203
|
+
|
|
107
204
|
/**
|
|
108
205
|
* Partition state manager's update with integrated trigger resolution and specialization support.
|
|
109
206
|
* Resolved triggers are co-located with each node's state for zero hot-path overhead.
|
|
@@ -137,9 +234,10 @@ const update = function ( composerState, msg ) {
|
|
|
137
234
|
const specs = flow.specsBySpecialization[ specializationType ];
|
|
138
235
|
|
|
139
236
|
if ( !specs ) {
|
|
140
|
-
//
|
|
237
|
+
// Count, log, and skip the message - no default fallback
|
|
141
238
|
// Note: Do NOT create partition entry for unknown specialization
|
|
142
|
-
|
|
239
|
+
composerState.droppedUnknownSpecialization += 1;
|
|
240
|
+
logger.error( `winkComposer/partitionManager: Unknown specialization '${specializationType}' for partition '${partitionId}'. Message dropped.` );
|
|
143
241
|
return null;
|
|
144
242
|
}
|
|
145
243
|
|
|
@@ -152,8 +250,8 @@ const update = function ( composerState, msg ) {
|
|
|
152
250
|
if ( !specializedGraphs ) {
|
|
153
251
|
composerState.totalPartitionsCreated += 1;
|
|
154
252
|
if ( composerState.totalPartitionsCreated > ENV_VARS.maxPartitionsAllowed ) {
|
|
155
|
-
|
|
156
|
-
`
|
|
253
|
+
logger.error(
|
|
254
|
+
`winkComposer/partitionManager: Partition creation for assetId '${partitionId}' ` +
|
|
157
255
|
`failed — maxPartitionsAllowed (${ENV_VARS.maxPartitionsAllowed}) reached. ` +
|
|
158
256
|
'Message dropped.'
|
|
159
257
|
);
|
|
@@ -163,85 +261,43 @@ const update = function ( composerState, msg ) {
|
|
|
163
261
|
partitionSpecializations.set( partitionId, specializedGraphs );
|
|
164
262
|
}
|
|
165
263
|
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if ( !node ) {
|
|
177
|
-
throw new Error( `Node module '${moduleName}' not found for nodeType '${spec.nodeType}' at index ${k}` );
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Initialize node state
|
|
181
|
-
graph[ k ] = node.init( spec );
|
|
182
|
-
|
|
183
|
-
// ====================================================================
|
|
184
|
-
// MQTT TOPIC INJECTION IN EMIT IF NODE
|
|
185
|
-
// ====================================================================
|
|
186
|
-
// Precompute partition-specific MQTT topic at initialization to avoid
|
|
187
|
-
// reconstruction on every emission (performance optimization).
|
|
188
|
-
//
|
|
189
|
-
// Topic structure: edgeDeviceId/partitionId/specialization/insightType
|
|
190
|
-
// Example: "edge-001/sensor-42/temperature/changeDetected"
|
|
191
|
-
//
|
|
192
|
-
// The edgeDeviceId can encode ISA-95 hierarchy for Unified Namespace:
|
|
193
|
-
// - Simple: "edge-001"
|
|
194
|
-
// - UNS: "northwind/riverton/plant2/line3" (enterprise/site/area/cell)
|
|
195
|
-
//
|
|
196
|
-
// Enables powerful MQTT subscription patterns:
|
|
197
|
-
// - All from edge: "edge-001/#" or "northwind/riverton/#"
|
|
198
|
-
// - Specific sensor: "+/sensor-42/#"
|
|
199
|
-
if ( spec.nodeType === 'Emit If' && ( graph[ k ].target === 'mqtt' || graph[ k ].target === 'terminal' ) ) {
|
|
200
|
-
graph[ k ].topic = `${ENV_VARS.edgeDeviceId}/${partitionId}/${specializationType}/${graph[ k ].insightType}`;
|
|
201
|
-
}
|
|
264
|
+
// Quarantine check. A partition with no failure history (a
|
|
265
|
+
// fresh entry, or a healthy one adding a new specialization)
|
|
266
|
+
// has no ledger and passes through. A quarantined partition's
|
|
267
|
+
// messages drop here, before any node init runs — creation is
|
|
268
|
+
// blocked, not re-attempted. One WeakMap read on this cold
|
|
269
|
+
// path; the healthy hot path (graph exists) never reaches it.
|
|
270
|
+
const priorLedger = composerState.creationFailures.get( specializedGraphs );
|
|
271
|
+
if ( priorLedger && priorLedger.quarantined ) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
202
274
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
275
|
+
// Build the graph under the quarantine ledger. A throw anywhere
|
|
276
|
+
// in the build (a node init, trigger resolution, a missing
|
|
277
|
+
// module) counts one consecutive creation failure for this
|
|
278
|
+
// partition and RETHROWS — the dispatch guard owns reporting.
|
|
279
|
+
// At the shared threshold the partition is quarantined with one
|
|
280
|
+
// classified report. A successful build clears the ledger.
|
|
281
|
+
try {
|
|
282
|
+
graph = buildGraph( flow, specs, partitionId, specializationType );
|
|
283
|
+
} catch ( creationError ) {
|
|
284
|
+
let ledger = composerState.creationFailures.get( specializedGraphs );
|
|
285
|
+
if ( !ledger ) {
|
|
286
|
+
ledger = { failures: 0, quarantined: false };
|
|
287
|
+
composerState.creationFailures.set( specializedGraphs, ledger );
|
|
214
288
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// Special handling for Controller nodes with nested triggers
|
|
224
|
-
// Only controller node allows FORWARD node control.
|
|
225
|
-
// That is why it has to be performed only after all nodes have
|
|
226
|
-
// been initialized.
|
|
227
|
-
for ( let k = 0; k < specs.length; k += 1 ) {
|
|
228
|
-
const spec = specs[ k ];
|
|
229
|
-
if ( spec.nodeType === 'Controller' && graph[ k ].logic ) {
|
|
230
|
-
// Resolve triggers for each condition in the logic array
|
|
231
|
-
for ( let j = 0; j < graph[ k ].logic.length; j += 1 ) {
|
|
232
|
-
graph[ k ].logic[ j ].resolvedTriggers = resolveTriggers(
|
|
233
|
-
graph,
|
|
234
|
-
graph[ k ].logic[ j ].triggers,
|
|
235
|
-
{
|
|
236
|
-
specs: specs,
|
|
237
|
-
nodeModules: flow.nodeModules
|
|
238
|
-
}
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
// No top-level resolvedTriggers for controller
|
|
242
|
-
graph[ k ].resolvedTriggers = null;
|
|
289
|
+
ledger.failures += 1;
|
|
290
|
+
if ( ledger.failures >= ENV_VARS.messageFailureThreshold ) {
|
|
291
|
+
ledger.quarantined = true;
|
|
292
|
+
logger.error(
|
|
293
|
+
`winkComposer/partitionManager: Partition '${partitionId}' quarantined after ` +
|
|
294
|
+
`${ledger.failures} consecutive creation failures ` +
|
|
295
|
+
`(last: ${creationError.message}). Later messages for it are dropped.`
|
|
296
|
+
);
|
|
243
297
|
}
|
|
244
|
-
|
|
298
|
+
throw creationError;
|
|
299
|
+
}
|
|
300
|
+
composerState.creationFailures.delete( specializedGraphs );
|
|
245
301
|
|
|
246
302
|
specializedGraphs[ specializationType ] = graph;
|
|
247
303
|
}
|
|
@@ -145,7 +145,7 @@ const validateFlow = function ( flow ) {
|
|
|
145
145
|
actualType = typeof flow;
|
|
146
146
|
}
|
|
147
147
|
throw new TypeError(
|
|
148
|
-
`
|
|
148
|
+
`winkComposer/partitionManager: validation failed:\n flow: Expected object, got ${actualType}`
|
|
149
149
|
);
|
|
150
150
|
}
|
|
151
151
|
|
|
@@ -27,7 +27,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
27
27
|
import { basename } from 'node:path';
|
|
28
28
|
|
|
29
29
|
// Standard error prefix for all semantic loader errors
|
|
30
|
-
const ERR_PREFIX = '
|
|
30
|
+
const ERR_PREFIX = 'winkComposer/semantics: ';
|
|
31
31
|
import { glob } from 'glob';
|
|
32
32
|
import { validateWithSchema } from '../utils/validate/index.js';
|
|
33
33
|
import {
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* @see core/storage-manager/questdb/persist-plan.js for onWarning callback pattern
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { logger } from '../logger/index.js';
|
|
20
|
+
|
|
19
21
|
// ============================================================================
|
|
20
22
|
// CONSTANTS
|
|
21
23
|
// ============================================================================
|
|
@@ -24,7 +26,7 @@
|
|
|
24
26
|
* Standard warning prefix for semantic validation warnings.
|
|
25
27
|
* @type {string}
|
|
26
28
|
*/
|
|
27
|
-
const WARNING_PREFIX = '
|
|
29
|
+
const WARNING_PREFIX = 'winkComposer/semantics';
|
|
28
30
|
|
|
29
31
|
// ============================================================================
|
|
30
32
|
// DEFAULT WARNING HANDLER
|
|
@@ -32,12 +34,12 @@ const WARNING_PREFIX = 'WinkComposer/semantics';
|
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
36
|
* Default warning handler for semantic validation issues.
|
|
35
|
-
* Logs to console in
|
|
37
|
+
* Logs to console in winkComposer format.
|
|
36
38
|
*
|
|
37
39
|
* @param {string} message - Warning message describing the issue
|
|
38
40
|
*/
|
|
39
41
|
const defaultOnWarning = function ( message ) {
|
|
40
|
-
|
|
42
|
+
logger.warn( `${WARNING_PREFIX}: ${message}` );
|
|
41
43
|
};
|
|
42
44
|
|
|
43
45
|
// ============================================================================
|
|
@@ -21,6 +21,15 @@
|
|
|
21
21
|
* wider scope: the per-adapter `{ timeout }` (one adapter's drain),
|
|
22
22
|
* the per-flow stage budget, and this process-level ceiling on the
|
|
23
23
|
* whole exit.
|
|
24
|
+
* - **Exit 1 on a data-losing stop** (the 2026-08-29 ruling). A drain
|
|
25
|
+
* that rejects means that flow lost data at shutdown; the adapter
|
|
26
|
+
* said so with a classified error. Each rejection prints one
|
|
27
|
+
* classified console line, and the process exits 1 — a supervisor
|
|
28
|
+
* (systemd, Docker, Kubernetes, a batch script) must never read a
|
|
29
|
+
* data-losing stop as clean. This extends ADR-018's delivery
|
|
30
|
+
* invariant to the process boundary. Callers who invoke
|
|
31
|
+
* `handle.shutdown()` directly are unaffected: they receive the
|
|
32
|
+
* rejection themselves and own their own response.
|
|
24
33
|
*
|
|
25
34
|
* This layer owns NO storage. The storage-directory cleanup it used to
|
|
26
35
|
* run (an `fs.rm` of STORAGE_DIR at dev/test shutdown) was removed
|
|
@@ -57,6 +66,7 @@
|
|
|
57
66
|
|
|
58
67
|
import { emitters } from '../wiring/index.js';
|
|
59
68
|
import { ENV_VARS } from '../env-vars.js';
|
|
69
|
+
import { logger } from '../logger/index.js';
|
|
60
70
|
|
|
61
71
|
/**
|
|
62
72
|
* Factory that produces a fresh shutdown-manager instance with isolated
|
|
@@ -120,15 +130,24 @@ const createShutdownManager = function () {
|
|
|
120
130
|
/**
|
|
121
131
|
* Iterate every registered flow handle, calling `handle.shutdown()`
|
|
122
132
|
* on each in parallel via `Promise.allSettled`. Race the whole drain
|
|
123
|
-
* against `timeoutMs
|
|
124
|
-
* caller can log + force-exit with code 1.
|
|
133
|
+
* against `timeoutMs`.
|
|
125
134
|
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
135
|
+
* A drain that REJECTS means that flow lost data at shutdown — the
|
|
136
|
+
* adapter said so with a classified error (`DELIVERY_FAILED` or
|
|
137
|
+
* `SHUTDOWN_TIMEOUT`, per ADR-018 a shutdown never resolves cleanly
|
|
138
|
+
* over a loss). Each rejection prints one classified console line
|
|
139
|
+
* and counts in `failedDrains`, so the caller can refuse the clean
|
|
140
|
+
* exit code (the 2026-08-29 exit-1 ruling).
|
|
141
|
+
*
|
|
142
|
+
* @param {number} timeoutMs - Ceiling for the whole drain
|
|
143
|
+
* @returns {Promise<{timedOut: boolean, failedDrains: number}>}
|
|
144
|
+
* `timedOut` — the race hit the ceiling; the drains are still in
|
|
145
|
+
* flight, so their outcomes are unknown and `failedDrains` stays 0.
|
|
146
|
+
* `failedDrains` — how many drains rejected (0 with no handles).
|
|
128
147
|
*/
|
|
129
148
|
const drainHandles = async function ( timeoutMs ) {
|
|
130
149
|
if ( handles.size === 0 ) {
|
|
131
|
-
return
|
|
150
|
+
return { timedOut: false, failedDrains: 0 };
|
|
132
151
|
}
|
|
133
152
|
|
|
134
153
|
const drainAll = Promise.allSettled(
|
|
@@ -153,7 +172,25 @@ const createShutdownManager = function () {
|
|
|
153
172
|
// that runs the manager and then expects to exit cleanly.
|
|
154
173
|
// Same canonical pattern used by every source `stopFn`.
|
|
155
174
|
clearTimeout( timerHandle );
|
|
156
|
-
|
|
175
|
+
if ( winner === TIMED_OUT ) {
|
|
176
|
+
return { timedOut: true, failedDrains: 0 };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The drain settled in time: read every outcome. The reads are
|
|
180
|
+
// null-safe because a rejection reason can be anything.
|
|
181
|
+
let failedDrains = 0;
|
|
182
|
+
for ( let i = 0; i < winner.length; i += 1 ) {
|
|
183
|
+
if ( winner[ i ].status === 'rejected' ) {
|
|
184
|
+
failedDrains += 1;
|
|
185
|
+
const reason = winner[ i ].reason;
|
|
186
|
+
const code = ( reason && reason.code ) || 'UNKNOWN';
|
|
187
|
+
const detail = ( reason && reason.message ) || String( reason );
|
|
188
|
+
const count = reason && reason.dropped && reason.dropped.count;
|
|
189
|
+
const droppedNote = ( typeof count === 'number' ) ? ` — ${count} message(s) dropped` : '';
|
|
190
|
+
logger.error( `winkComposer/shutdownManager: Flow drain failed [${code}]: ${detail}${droppedNote}` );
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { timedOut: false, failedDrains };
|
|
157
194
|
};
|
|
158
195
|
|
|
159
196
|
// ========================================================================
|
|
@@ -167,11 +204,16 @@ const createShutdownManager = function () {
|
|
|
167
204
|
* 1. Drain registered flow handles (modern path).
|
|
168
205
|
* 2. Run the legacy path block (clustered for retirement; warns once
|
|
169
206
|
* when actually doing work).
|
|
170
|
-
* 3. Return graceful (true) /
|
|
171
|
-
* the exit code.
|
|
207
|
+
* 3. Return graceful (true) / not-graceful (false) — the caller
|
|
208
|
+
* decides the exit code.
|
|
209
|
+
*
|
|
210
|
+
* Graceful means BOTH: the drain finished inside the timeout, AND
|
|
211
|
+
* every drain resolved clean. A drain that rejected lost data, so
|
|
212
|
+
* it must not read as a clean stop (the 2026-08-29 exit-1 ruling;
|
|
213
|
+
* ADR-018's delivery invariant, extended to the process boundary).
|
|
172
214
|
*
|
|
173
|
-
* @returns {Promise<boolean>} true on graceful completion
|
|
174
|
-
* forced timeout
|
|
215
|
+
* @returns {Promise<boolean>} true on graceful completion; false on
|
|
216
|
+
* forced timeout or on any rejected drain
|
|
175
217
|
*/
|
|
176
218
|
const shutdown = async function () {
|
|
177
219
|
if ( isShuttingDown ) {
|
|
@@ -180,17 +222,23 @@ const createShutdownManager = function () {
|
|
|
180
222
|
isShuttingDown = true;
|
|
181
223
|
|
|
182
224
|
const timestamp = new Date().toISOString();
|
|
183
|
-
|
|
225
|
+
logger.info( `\nwinkComposer/shutdownManager: [${timestamp}] Shutdown initiated` );
|
|
184
226
|
|
|
185
227
|
// Phase 1 (modern path): drain registered flow handles, racing
|
|
186
228
|
// against the forced-shutdown timeout.
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
`
|
|
229
|
+
const drain = await drainHandles( ENV_VARS.shutdownForceTimeoutMs );
|
|
230
|
+
if ( drain.timedOut ) {
|
|
231
|
+
logger.warn(
|
|
232
|
+
`winkComposer/shutdownManager: Forced shutdown — flow drain exceeded ${ENV_VARS.shutdownForceTimeoutMs}ms; ` +
|
|
191
233
|
'some adapters may not have completed cleanly'
|
|
192
234
|
);
|
|
193
235
|
}
|
|
236
|
+
if ( drain.failedDrains > 0 ) {
|
|
237
|
+
logger.warn(
|
|
238
|
+
`winkComposer/shutdownManager: ${drain.failedDrains} flow drain(s) lost data (lines above) — shutdown is not clean`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const graceful = !drain.timedOut && ( drain.failedDrains === 0 );
|
|
194
242
|
|
|
195
243
|
try {
|
|
196
244
|
// ================================================================
|
|
@@ -219,9 +267,9 @@ const createShutdownManager = function () {
|
|
|
219
267
|
const failedEmitters = emitterResults.filter( ( r ) => r.status === 'rejected' );
|
|
220
268
|
if ( emitterResults.length > 0 ) {
|
|
221
269
|
if ( failedEmitters.length > 0 ) {
|
|
222
|
-
|
|
270
|
+
logger.warn( `winkComposer/shutdownManager: ${failedEmitters.length} legacy emitter(s) failed to shutdown cleanly` );
|
|
223
271
|
} else {
|
|
224
|
-
|
|
272
|
+
logger.info( 'winkComposer/shutdownManager: Legacy message queues flushed' );
|
|
225
273
|
}
|
|
226
274
|
}
|
|
227
275
|
|
|
@@ -229,12 +277,12 @@ const createShutdownManager = function () {
|
|
|
229
277
|
// END LEGACY PATH
|
|
230
278
|
// ================================================================
|
|
231
279
|
|
|
232
|
-
|
|
280
|
+
logger.info( `winkComposer/shutdownManager: [${new Date().toISOString()}] Shutdown complete\n` );
|
|
233
281
|
|
|
234
282
|
return graceful;
|
|
235
283
|
|
|
236
284
|
} catch ( error ) {
|
|
237
|
-
|
|
285
|
+
logger.error( 'winkComposer/shutdownManager: Shutdown error: ' + ( ( error && error.stack ) || String( error ) ) );
|
|
238
286
|
process.exitCode = 1;
|
|
239
287
|
return false;
|
|
240
288
|
}
|
|
@@ -251,7 +299,9 @@ const createShutdownManager = function () {
|
|
|
251
299
|
* process regardless of caller.
|
|
252
300
|
*
|
|
253
301
|
* On signal: runs `shutdown()`, then `process.exit(0)` on graceful
|
|
254
|
-
*
|
|
302
|
+
* completion. Exit is 1 on a forced timeout or when any flow's
|
|
303
|
+
* drain rejected — a rejected drain lost data, and the exit code
|
|
304
|
+
* must say so (the 2026-08-29 exit-1 ruling).
|
|
255
305
|
*/
|
|
256
306
|
const attachHandlers = function () {
|
|
257
307
|
if ( attached ) {
|
|
@@ -260,7 +310,7 @@ const createShutdownManager = function () {
|
|
|
260
310
|
attached = true;
|
|
261
311
|
|
|
262
312
|
const signalHandler = async function ( signalName ) {
|
|
263
|
-
|
|
313
|
+
logger.info( `\nwinkComposer/shutdownManager: Received ${signalName}` );
|
|
264
314
|
const graceful = await shutdown();
|
|
265
315
|
|
|
266
316
|
/* c8 ignore next 2 -- fundamentally untestable: invoking the signal handler from a test would call process.exit and kill the test runner. The graceful/forced exit-code mapping is verified by the shutdown() return-value tests above. */
|