@yarkivaev/scada 2.4.4 → 2.4.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.4.4",
3
+ "version": "2.4.5",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,17 @@
1
+ /** Process health follows all owned readers, not merely the HTTP listener. */
2
+ export default function ingestHealth({ onFatal } = {}) {
3
+ const state = { sources: [], started: false, fatal: null };
4
+ return {
5
+ track(sources) { state.sources = sources; },
6
+ started() { state.started = true; },
7
+ fail(error) {
8
+ if (state.fatal) { return; }
9
+ state.fatal = error;
10
+ onFatal?.(error);
11
+ },
12
+ status() {
13
+ return { live: !state.fatal, ready: state.started && !state.fatal
14
+ && state.sources.every((source) => {return source.health().state === 'running'}) };
15
+ }
16
+ };
17
+ }
@@ -0,0 +1,16 @@
1
+ import supervisorSink from './supervisorSink.js';
2
+ import runRetention from '../infrastructure/ingest/db/runRetention.js';
3
+ import edgeApi from '../infrastructure/http/edge/edgeApi.js';
4
+
5
+ /** A terminal reader error rejects the process lifetime, not only one detached task. */
6
+ export default async function runSupervisorSink(env, { sinkFactory = supervisorSink, apiFactory = edgeApi } = {}) {
7
+ let fail;
8
+ const failed = new Promise((_resolve, reject) => { fail = reject; });
9
+ const sink = sinkFactory(env, { onFatal: fail });
10
+ const api = apiFactory(sink.dataAccess, {
11
+ port: sink.apiPort, token: sink.apiToken, metricsEnabled: sink.metricsEnabled,
12
+ retentionEnabled: sink.retentionEnabled, retentionDays: sink.retentionDays,
13
+ requestTimeoutMs: sink.requestTimeoutMs, pool: sink.pool, runRetention, health: sink.health
14
+ });
15
+ await Promise.all([sink.run(api), failed]);
16
+ }
@@ -1,3 +1,4 @@
1
+ import ingestHealth from '../ingestHealth.js';
1
2
  import { clickhouseSink } from '../../infrastructure/streams/index.js';
2
3
  import amqpMetricsIngest from '../../infrastructure/ingest/telemetry/amqpMetricsIngest.js';
3
4
  import operationSyncIngest from '../../infrastructure/sync/operationSyncIngest.js';
@@ -70,12 +71,23 @@ function silent(spec, pool, context) {
70
71
  }
71
72
  function stomp(spec, pool, context) {
72
73
  const shared = borrowed(pool);
73
- const config = { ...spec.config, ...boundedOptions({ ...spec, options: spec.config }), budget: context.budget };
74
- return together([
75
- segmentPipeline(spec.stomp, spec.postgres, shared, config),
76
- decisionPipeline(spec.stomp, shared, config),
77
- alertPipeline(spec.stomp, shared, config)
78
- ]);
74
+ const health = ingestHealth({ onFatal(error) {
75
+ context.report?.({ state: 'failed', fatal: true, reason: error.code ?? error.name });
76
+ } });
77
+ function report() {
78
+ const status = health.status();
79
+ let state = status.ready ? 'running' : 'degraded';
80
+ if (!status.live) { state = 'failed'; }
81
+ context.report?.({ state, fatal: !status.live });
82
+ }
83
+ const config = { ...spec.config, ...boundedOptions({ ...spec, options: spec.config }), budget: context.budget,
84
+ onFatal(error) { health.fail(error); }, onState: report };
85
+ const items = [segmentPipeline(spec.stomp, spec.postgres, shared, config),
86
+ decisionPipeline(spec.stomp, shared, config), alertPipeline(spec.stomp, shared, config)];
87
+ health.track(items);
88
+ const group = together(items);
89
+ return { async start() { await group.start(); health.started(); report(); },
90
+ stop() { return group.stop(); } };
79
91
  }
80
92
  function close(state) {
81
93
  state.closing ??= Promise.resolve().then(async () => {
@@ -9,6 +9,7 @@ export default function taskContext(slot, request, ctx) {
9
9
  report(event) {
10
10
  if (controller.signal.aborted) { return; }
11
11
  slot.health = event.state;
12
+ if (event.fatal === true) { ctx.fatal = true; }
12
13
  if (event.delivered) { slot.lastDelivery = Date.now(); }
13
14
  try { ctx.services.report?.({ taskId: slot.taskId, generation: slot.generation, ...event }); } catch {
14
15
  // Observability cannot interrupt collection.
@@ -1,3 +1,4 @@
1
+ import ingestHealth from './ingestHealth.js';
1
2
  import segmentPipeline, {
2
3
  segmentsIngestDestination
3
4
  } from '../infrastructure/ingest/pipelines/segmentPipeline.js';
@@ -21,6 +22,12 @@ const appRoot = path.dirname(fileURLToPath(import.meta.url));
21
22
  * @param {NodeJS.ProcessEnv} env - process environment
22
23
  * @returns {object} deployment configuration
23
24
  */
25
+ function milliseconds(env, key, fallback) {
26
+ const value = Number(env[key] ?? fallback);
27
+ if (!Number.isSafeInteger(value) || value < 1) { throw new TypeError(`${key} must be a positive integer`); }
28
+ return value;
29
+ }
30
+
24
31
  export function readDeploymentConfig(env) {
25
32
  const root = path.join(appRoot, '../..');
26
33
  const prefetch = Number(env.STOMP_PREFETCH || '128');
@@ -44,6 +51,8 @@ export function readDeploymentConfig(env) {
44
51
  pipelines: env.COLLECTOR_MODE !== 'external' && env.SINK_PIPELINES !== 'false',
45
52
  pipeline: {
46
53
  prefetch,
54
+ acceptTimeoutMs: milliseconds(env, 'STOMP_ACCEPT_TIMEOUT_MS', 10000),
55
+ stopTimeoutMs: milliseconds(env, 'STOMP_STOP_TIMEOUT_MS', 5000),
47
56
  deadLetter: env.SEGMENTS_DEAD_LETTER_ENABLED === 'true',
48
57
  size: parseInt(env.BATCH_SIZE || '50', 10),
49
58
  interval: parseInt(env.FLUSH_INTERVAL || '2', 10),
@@ -60,9 +69,10 @@ export function readDeploymentConfig(env) {
60
69
  };
61
70
  }
62
71
 
63
- function startPipelines(cfg, pool) {
72
+ function startPipelines(cfg, pool, health) {
64
73
  const pipelines = [segmentPipeline(cfg.stomp, cfg.postgres, pool, cfg.pipeline),
65
74
  decisionPipeline(cfg.stomp, pool, cfg.pipeline), alertPipeline(cfg.stomp, pool, cfg.pipeline)];
75
+ health.track(pipelines);
66
76
  return Promise.all(pipelines.map((pipeline) => { return pipeline.start(); }));
67
77
  }
68
78
 
@@ -73,12 +83,15 @@ function startPipelines(cfg, pool) {
73
83
  * @param {NodeJS.ProcessEnv} [env] - process environment, defaults to process.env
74
84
  * @returns {object} sink runtime with pool, dataAccess, and run(httpServer?)
75
85
  */
76
- export default function supervisorSink(env = process.env) {
86
+ export default function supervisorSink(env = process.env, options = {}) {
77
87
  const cfg = readDeploymentConfig(env);
88
+ const health = ingestHealth(options);
89
+ cfg.pipeline.onFatal = (error) => { health.fail(error); };
78
90
  const pool = holdClient(new pg.Pool({ connectionString: cfg.postgres }));
79
91
  const metricsEnabled = cfg.sinkDbProfile === 'edge';
80
92
  const dataAccess = stateDataFromPool(pool, { metricsEnabled });
81
93
  return {
94
+ health: () => {return health.status()},
82
95
  apiPort: cfg.apiPort,
83
96
  apiToken: cfg.apiToken,
84
97
  sinkDbProfile: cfg.sinkDbProfile,
@@ -100,8 +113,9 @@ export default function supervisorSink(env = process.env) {
100
113
  await adminPool.end();
101
114
  }
102
115
  if (cfg.pipelines) {
103
- await startPipelines(cfg, pool);
116
+ await startPipelines(cfg, pool, health);
104
117
  }
118
+ health.started();
105
119
  if (httpServer) {
106
120
  httpServer.start();
107
121
  }
@@ -1,28 +1,12 @@
1
- /**
2
- * Supervisor-sink service entry point.
3
- */
4
- process.on('unhandledRejection', (err) => {
5
- process.stderr.write(`Unhandled rejection: ${String(err)}\n`);
6
- process.exit(1);
7
- });
1
+ /** Supervisor-sink process; a fenced reader must not leave a healthy zombie HTTP server. */
2
+ import runSupervisorSink from '../application/runSupervisorSink.js';
8
3
 
9
- import supervisorSink from '../application/supervisorSink.js';
10
- import runRetention from '../infrastructure/ingest/db/runRetention.js';
11
- import edgeApi from '../infrastructure/http/edge/edgeApi.js';
12
-
13
- const sink = supervisorSink(process.env);
14
- const http = edgeApi(sink.dataAccess, {
15
- port: sink.apiPort,
16
- token: sink.apiToken,
17
- metricsEnabled: sink.metricsEnabled,
18
- retentionEnabled: sink.retentionEnabled,
19
- retentionDays: sink.retentionDays,
20
- requestTimeoutMs: sink.requestTimeoutMs,
21
- pool: sink.pool,
22
- runRetention
4
+ process.on('unhandledRejection', (error) => {
5
+ process.stderr.write(`Unhandled rejection: ${String(error)}\n`);
6
+ process.exit(1);
23
7
  });
24
8
 
25
- sink.run(http).catch((error) => {
26
- process.stderr.write(`${String(error)}\n`);
9
+ runSupervisorSink(process.env).catch((error) => {
10
+ process.stderr.write(`Supervisor sink failed: ${String(error)}\n`);
27
11
  process.exit(1);
28
12
  });
@@ -1,4 +1,5 @@
1
1
  import http from 'http';
2
+ import ingestHealthRoutes from './routes/ingestHealthRoutes.js';
2
3
  import checkpointRoutes from './routes/checkpoint/checkpointRoutes.js';
3
4
  import metricsRoutes from './routes/metrics/metricsRoutes.js';
4
5
  import retentionRoutes from './routes/retention/retentionRoutes.js';
@@ -25,6 +26,7 @@ export function createEdgeApi(dataAccess, options) {
25
26
  ? metricsRoutes(token, metrics)
26
27
  : [];
27
28
  return routes([
29
+ ...(opt.health ? ingestHealthRoutes(opt.health) : []),
28
30
  ...admin,
29
31
  ...checkpointRoutes(token, checkpoints),
30
32
  ...metricRoutes
@@ -0,0 +1,10 @@
1
+ import { route } from '@yarkivaev/simple-server';
2
+
3
+ /** Health never exposes credentials, messages or application records. */
4
+ export default function ingestHealthRoutes(health) {
5
+ return [['/ready', 'ready'], ['/live', 'live']].map(([path, key]) => {return route('GET', path, (_req, res) => {
6
+ const available = health()[key] === true;
7
+ res.writeHead(available ? 200 : 503, { 'content-type': 'application/json', 'cache-control': 'no-store' });
8
+ res.end(JSON.stringify({ [key]: available }));
9
+ })});
10
+ }
@@ -32,6 +32,7 @@ export default function alertPipeline(stomp, pool, config) {
32
32
  const source = stompSource(stomp, '/exchange/scada.alerts', codec,
33
33
  stompOptions(config));
34
34
  return {
35
+ health() { return source.health(); },
35
36
  /**
36
37
  * Starts the pipeline.
37
38
  */
@@ -29,6 +29,7 @@ export default function decisionPipeline(stomp, pool, config) {
29
29
  const source = stompSource(stomp, '/exchange/scada.user_decisions', codec,
30
30
  stompOptions(config));
31
31
  return {
32
+ health() { return source.health(); },
32
33
  /**
33
34
  * Starts consuming user decisions from RabbitMQ.
34
35
  */
@@ -1,4 +1,5 @@
1
1
  import stompOptions from './stompOptions.js';
2
+ import deadline from '../../streams/deadline.js';
2
3
  import { postgresSink, stompSource } from '../../streams/index.js';
3
4
  import segmentDispatch from '../../../domain/segment/dispatch.js';
4
5
  import segmentCoalesce from '../../../domain/segment/segmentCoalesce.js';
@@ -42,8 +43,8 @@ function segmentSinks(postgres, pool) {
42
43
  /**
43
44
  * Pipeline for streaming STOMP segment data to PostgreSQL segments table.
44
45
  * Subscribes to the durable ingest queue so shovel traffic survives consumer gaps.
45
- * Pending heartbeats are coalesced per machine; machines persist in
46
- * parallel lanes. Arrivals enqueue in broker FIFO before coalesce.
46
+ * Pending heartbeats are coalesced per machine. Source acceptance is FIFO:
47
+ * queued lane work must not consume another delivery's execution deadline.
47
48
  *
48
49
  * @param {string} stomp - STOMP broker URL
49
50
  * @param {string} postgres - PostgreSQL connection URL
@@ -56,17 +57,26 @@ export default function segmentPipeline(stomp, postgres, pool, config) {
56
57
  const { segmentSink, splitSink } = segmentSinks(postgres, pool);
57
58
  const continued = continuedSegments(segmentSink, pool, budget);
58
59
  const dispatch = segmentDispatch(continued, retagSink(pool), splitSink, closeOrphanOpen(pool), pool);
59
- const coalesce = segmentCoalesce(segmentCodec(dispatch), {
60
+ // eslint-disable-next-line prefer-const -- The timer codec closes over the source constructed with that codec.
61
+ let source;
62
+ const codec = segmentCodec(dispatch);
63
+ const bounded = { accept(envelope) {
64
+ return deadline(Promise.resolve().then(() => { return codec.accept(envelope); }),
65
+ config.acceptTimeoutMs ?? 10000, 'STOMP acceptance', (error) => { source.fail(error); });
66
+ } };
67
+ // Heartbeat timer flushes outlive source.accept and need their own execution deadline.
68
+ const coalesce = segmentCoalesce(bounded, {
60
69
  size: config.size,
61
70
  deadLetter: config.deadLetter,
62
71
  interval: config.interval
63
72
  });
64
73
  const destination = config.segmentsDestination || segmentsIngestDestination;
65
- const source = stompSource(stomp, destination, coalesce, stompOptions(config, { serial: false, manualAck: true }));
74
+ source = stompSource(stomp, destination, coalesce, stompOptions(config, { serial: true, manualAck: true }));
66
75
  const silence = silentOpenWatch(closeSilentOpen(pool), budget, {
67
76
  intervalMs: (config.poll || 5) * 1000
68
77
  });
69
78
  return {
79
+ health() { return source.health(); },
70
80
  destination,
71
81
  async start() {
72
82
  const repair = abutSegments(pool, budget);
@@ -2,6 +2,8 @@
2
2
  export default function stompOptions(config, overrides = {}) {
3
3
  const options = { login: config.login, passcode: config.passcode, host: config.host,
4
4
  prefetch: config.prefetch, maxMessageBytes: config.maxMessageBytes,
5
- maxPendingBytes: config.maxPendingBytes, budget: config.budget, ...overrides };
5
+ maxPendingBytes: config.maxPendingBytes, budget: config.budget,
6
+ acceptTimeoutMs: config.acceptTimeoutMs, stopTimeoutMs: config.stopTimeoutMs,
7
+ onError: config.onError, onFatal: config.onFatal, onState: config.onState, ...overrides };
6
8
  return Object.fromEntries(Object.entries(options).filter(([, value]) => { return value !== undefined; }));
7
9
  }
@@ -26,7 +26,7 @@ function resume(pending) {
26
26
  * let pending = Promise.resolve();
27
27
  * pending = follow(pending, () => Promise.resolve('a'), write, false);
28
28
  */
29
- export default function follow(pending, read, deliver, serial) {
29
+ export default function follow(pending, read, deliver, serial, onError = (error) => { process.stderr.write(`Delivery failed: ${error.message}\n`); }) {
30
30
  return resume(pending).then(async () => {
31
31
  const body = await read();
32
32
  const run = deliver(body);
@@ -34,7 +34,9 @@ export default function follow(pending, read, deliver, serial) {
34
34
  return run;
35
35
  }
36
36
  void run.catch((error) => {
37
- return error;
37
+ try { onError(error); } catch {
38
+ // Reporting cannot turn a detached delivery into an unhandled rejection.
39
+ }
38
40
  });
39
41
  return undefined;
40
42
  });
@@ -0,0 +1,46 @@
1
+ /** A fenced subscription reports failure once and cannot be revived by late callbacks. */
2
+ export default function stompLifecycle(settings, channel, destination) {
3
+ const state = { ...settings, active: true, fatal: null, phase: 'starting', connected: false,
4
+ controller: new AbortController(), pending: new Set(), bytes: 0, closed: false };
5
+ function notify(callback, value) {
6
+ try { callback?.(value); } catch {
7
+ // An observer cannot interrupt transport fencing or broker callbacks.
8
+ }
9
+ }
10
+ function close() {
11
+ if (!state.closed) { state.closed = true; channel.close(); }
12
+ }
13
+ function report(error) {
14
+ if (settings.onError) { notify(settings.onError, error); }
15
+ else { process.stderr.write(`STOMP source ${destination}: ${error.message}\n`); }
16
+ }
17
+ return Object.assign(state, {
18
+ transition(phase) {
19
+ if (!state.active || state.fatal) { return; }
20
+ state.phase = phase;
21
+ if (phase === 'running') { state.connected = true; }
22
+ notify(settings.onState, { state: phase });
23
+ },
24
+ fail(error) {
25
+ if (!state.active || state.fatal) { return; }
26
+ state.fatal = error;
27
+ state.active = false;
28
+ state.phase = 'failed';
29
+ state.controller.abort(error);
30
+ close();
31
+ report(error);
32
+ notify(settings.onState, { state: 'failed', reason: error.message });
33
+ notify(settings.onFatal, error);
34
+ },
35
+ report(error) {
36
+ if (state.active && error !== state.fatal) { report(error); }
37
+ },
38
+ halt() {
39
+ state.active = false;
40
+ state.controller.abort(new Error('STOMP source stopped'));
41
+ if (!state.fatal) { state.phase = 'stopped'; }
42
+ close();
43
+ },
44
+ health() { return { state: state.phase, reason: state.fatal?.message ?? null }; }
45
+ });
46
+ }
@@ -1,5 +1,6 @@
1
1
  import stompit from 'stompit';
2
2
  import follow from './follow.js';
3
+ import stompLifecycle from './stompLifecycle.js';
3
4
  import deadline from './deadline.js';
4
5
  import { acceptStomp, readStomp, stompSettings } from './stompBody.js';
5
6
 
@@ -85,7 +86,7 @@ async function deliver(ctx, body) {
85
86
  await deadline(Promise.resolve().then(() => {
86
87
  return acceptStomp(ctx, body, settle);
87
88
  }), ctx.lifecycle.acceptTimeoutMs, 'STOMP acceptance', (error) => {
88
- ctx.lifecycle.fatal = error; ctx.lifecycle.active = false; ctx.lifecycle.controller.abort(error); ctx.channel.close();
89
+ ctx.lifecycle.fail(error);
89
90
  });
90
91
  if (!ctx.manualAck) {
91
92
  settle.ack();
@@ -113,25 +114,22 @@ function bind(channel, destination, collector, delivery) {
113
114
  let pending = Promise.resolve();
114
115
  channel.subscribe({ destination, ack: 'client-individual', 'prefetch-count': String(prefetch) }, (err, message) => {
115
116
  if (err) {
117
+ lifecycle.fail(err);
116
118
  return;
117
119
  }
118
120
  const ctx = { channel, message, destination, collector, manualAck, lifecycle };
119
121
  pending = follow(pending, () => {
120
122
  if (!lifecycle.active) { return Promise.reject(new Error('STOMP source stopped')); }
121
123
  return deadline(readStomp(message, lifecycle.maxMessageBytes), lifecycle.acceptTimeoutMs, 'STOMP read').catch((error) => {
122
- lifecycle.fatal = error; lifecycle.active = false; channel.close(); throw error;
124
+ lifecycle.fail(error); throw error;
123
125
  });
124
126
  }, (body) => {
125
127
  const task = deliver(ctx, body);
126
128
  lifecycle.pending.add(task);
127
129
  return task.finally(() => { lifecycle.pending.delete(task); });
128
- }, serial);
130
+ }, serial, (error) => { lifecycle.report(error); });
129
131
  lifecycle.chain = pending;
130
- void pending.catch((error) => {
131
- try { lifecycle.onError?.(error); } catch {
132
- // Reporting cannot reject a transport callback.
133
- }
134
- });
132
+ void pending.catch((error) => { lifecycle.report(error); });
135
133
  });
136
134
  }
137
135
 
@@ -150,6 +148,12 @@ function bind(channel, destination, collector, delivery) {
150
148
  * @param {number} [options.prefetch=32] - Positive integer delivery window
151
149
  * @returns {object} Source with start() and stop() methods
152
150
  */
151
+ function watchConnection(failover, lifecycle) {
152
+ failover.on('connect', () => { lifecycle.transition('running'); });
153
+ failover.on('connecting', () => { lifecycle.transition(lifecycle.connected ? 'degraded' : 'starting'); });
154
+ failover.on('error', (error) => { lifecycle.transition('degraded'); lifecycle.report(error); });
155
+ }
156
+
153
157
  export default function stompSource(url, destination, collector, options = {}) {
154
158
  if (typeof url !== 'string' || url.length === 0) {
155
159
  throw new Error('URL must be a non-empty string');
@@ -172,6 +176,7 @@ export default function stompSource(url, destination, collector, options = {}) {
172
176
  return {
173
177
  start() {
174
178
  if (state.subscribed()) {
179
+ if (state.delivery.fatal) { throw state.delivery.fatal; }
175
180
  return;
176
181
  }
177
182
  const failover = new stompit.ConnectFailover([serverConfig(parsed, options)], {
@@ -180,18 +185,18 @@ export default function stompSource(url, destination, collector, options = {}) {
180
185
  maxReconnects: -1
181
186
  });
182
187
  const channel = new stompit.Channel(failover);
183
- const lifecycle = { ...settings, active: true, controller: new AbortController(), pending: new Set(), bytes: 0 };
188
+ const lifecycle = stompLifecycle(settings, channel, destination);
189
+ watchConnection(failover, lifecycle);
184
190
  bind(channel, destination, collector, { serial, manualAck, prefetch, lifecycle });
185
191
  state = subscribed(channel, lifecycle);
186
192
  },
193
+ // Asynchronous downstream work uses the same fence as delivery deadlines.
194
+ fail(error) { if (state.subscribed()) { state.delivery.fail(error); } },
195
+ health() { return state.subscribed() ? state.delivery.health() : { state: 'idle', reason: null }; },
187
196
  stop() {
188
- if (!state.subscribed()) {
189
- return Promise.resolve();
190
- }
197
+ if (!state.subscribed()) { return Promise.resolve(); }
191
198
  const stopped = state;
192
- stopped.delivery.active = false;
193
- stopped.delivery.controller.abort(new Error('STOMP source stopped'));
194
- stopped.channel().close();
199
+ stopped.delivery.halt();
195
200
  return deadline(Promise.allSettled([stopped.delivery.chain, ...stopped.delivery.pending]), options.stopTimeoutMs ?? 5000, 'STOMP stop').then(() => {
196
201
  if (stopped.delivery.fatal) { throw stopped.delivery.fatal; }
197
202
  if (state === stopped) { state = idle(); }