@yarkivaev/scada 2.3.115 → 2.4.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.
Files changed (104) hide show
  1. package/README.md +158 -0
  2. package/charts/scada/templates/processes.yaml +22 -0
  3. package/charts/scada/values.schema.json +12 -0
  4. package/db/migrations/V0009__collector_task_state.sql +6 -0
  5. package/package.json +14 -5
  6. package/src/application/deploy/collectorProcesses.js +3 -2
  7. package/src/application/deploy/readDocument.js +4 -1
  8. package/src/application/plantServer.js +1 -1
  9. package/src/application/runtime/additionalAdapters.js +139 -0
  10. package/src/application/runtime/assignment.js +30 -0
  11. package/src/application/runtime/budgetedSink.js +17 -0
  12. package/src/application/runtime/collectorSignals.js +14 -0
  13. package/src/application/runtime/compositeLifecycle.js +49 -0
  14. package/src/application/runtime/createRegistry.js +20 -0
  15. package/src/application/runtime/deadline.js +8 -0
  16. package/src/application/runtime/index.js +10 -0
  17. package/src/application/runtime/manifest.js +38 -0
  18. package/src/application/runtime/pipelineAdapters.js +60 -0
  19. package/src/application/runtime/resolveSecrets.js +12 -0
  20. package/src/application/runtime/resourceClaims.js +21 -0
  21. package/src/application/runtime/runCollector.js +50 -0
  22. package/src/application/runtime/runtime.js +86 -0
  23. package/src/application/runtime/runtimeHealth.js +29 -0
  24. package/src/application/runtime/runtimeServices.js +15 -0
  25. package/src/application/runtime/stateDocument.js +22 -0
  26. package/src/application/runtime/streamPorts.js +42 -0
  27. package/src/application/runtime/streamTask.js +105 -0
  28. package/src/application/runtime/taskBudget.js +31 -0
  29. package/src/application/runtime/taskContext.js +18 -0
  30. package/src/application/runtime/taskQueue.js +21 -0
  31. package/src/application/runtime/taskSlot.js +46 -0
  32. package/src/application/runtime/taskState.js +73 -0
  33. package/src/application/siteCollection.js +14 -0
  34. package/src/application/siteServer.js +7 -6
  35. package/src/application/siteTelemetry.js +1 -1
  36. package/src/application/supervisorSink.js +9 -8
  37. package/src/application/sync/siteSyncBind.js +1 -1
  38. package/src/application/sync/siteSyncTargets.js +1 -1
  39. package/src/bin/collector.js +7 -0
  40. package/src/bin/modbus-to-mqtt.js +11 -9
  41. package/src/infrastructure/deploy/executeRelease.js +45 -3
  42. package/src/infrastructure/ingest/activity/activityTracking.js +15 -21
  43. package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
  44. package/src/infrastructure/ingest/durableConsume.js +2 -169
  45. package/src/infrastructure/ingest/ingestBatch.js +13 -0
  46. package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
  47. package/src/infrastructure/ingest/modbus/silentStreams.js +39 -16
  48. package/src/infrastructure/ingest/mqtt/metricsTransformer.js +4 -5
  49. package/src/infrastructure/ingest/mqtt/modbusMqtt.js +17 -19
  50. package/src/infrastructure/ingest/mqtt/modbusPublish.js +4 -0
  51. package/src/infrastructure/ingest/mqtt/mqttMetrics.js +17 -21
  52. package/src/infrastructure/ingest/mqtt/registerPublish.js +3 -3
  53. package/src/infrastructure/ingest/pipelines/alertPipeline.js +7 -6
  54. package/src/infrastructure/ingest/pipelines/decisionPipeline.js +7 -6
  55. package/src/infrastructure/ingest/pipelines/segmentPipeline.js +8 -17
  56. package/src/infrastructure/ingest/pipelines/stompOptions.js +7 -0
  57. package/src/infrastructure/ingest/silentOpenWatch.js +5 -42
  58. package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +16 -28
  59. package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +17 -22
  60. package/src/infrastructure/ingest/telemetry/foldingSink.js +5 -2
  61. package/src/infrastructure/messaging/stomp/segmentRetags.js +1 -1
  62. package/src/infrastructure/messaging/stomp/userDecisions.js +1 -1
  63. package/src/infrastructure/opcua/certificates.js +11 -0
  64. package/src/infrastructure/opcua/config.js +38 -0
  65. package/src/infrastructure/opcua/delivery.js +38 -0
  66. package/src/infrastructure/opcua/endpoint.js +90 -0
  67. package/src/infrastructure/opcua/index.js +10 -0
  68. package/src/infrastructure/opcua/session.js +70 -0
  69. package/src/infrastructure/opcua/source.js +38 -0
  70. package/src/infrastructure/opcua/subscriptionHealth.js +28 -0
  71. package/src/infrastructure/operators/operatorsSync.js +5 -29
  72. package/src/infrastructure/persistence/clickhouse/streamHub.js +21 -15
  73. package/src/infrastructure/persistence/metricsSensor.js +9 -6
  74. package/src/infrastructure/persistence/runtime/taskStatePg.js +24 -0
  75. package/src/infrastructure/streams/README.md +291 -0
  76. package/src/infrastructure/streams/abortable.js +14 -0
  77. package/src/infrastructure/streams/amqpSink/publication.js +46 -0
  78. package/src/infrastructure/streams/amqpSink/session.js +96 -0
  79. package/src/infrastructure/streams/amqpSink.js +47 -0
  80. package/src/infrastructure/streams/amqpSource/session.js +127 -0
  81. package/src/infrastructure/streams/amqpSource.js +97 -0
  82. package/src/infrastructure/streams/brokerClose.js +19 -0
  83. package/src/infrastructure/streams/budgetAccept.js +6 -0
  84. package/src/infrastructure/streams/clickhouseSink.js +48 -0
  85. package/src/infrastructure/streams/deadline.js +1 -0
  86. package/src/infrastructure/streams/follow.js +41 -0
  87. package/src/infrastructure/streams/index.js +15 -0
  88. package/src/infrastructure/streams/lokiSource.js +88 -0
  89. package/src/infrastructure/streams/modbusConnection.js +173 -0
  90. package/src/infrastructure/streams/modbusRtuBusSource.js +124 -0
  91. package/src/infrastructure/streams/modbusSource.js +185 -0
  92. package/src/infrastructure/streams/mqttSink/session.js +51 -0
  93. package/src/infrastructure/streams/mqttSink.js +54 -0
  94. package/src/infrastructure/streams/mqttSource.js +66 -0
  95. package/src/infrastructure/streams/pollingSource.js +1 -0
  96. package/src/infrastructure/streams/positive.js +5 -0
  97. package/src/infrastructure/streams/postgresSink.js +124 -0
  98. package/src/infrastructure/streams/serialReceive.js +80 -0
  99. package/src/infrastructure/streams/serialSource.js +87 -0
  100. package/src/infrastructure/streams/sqliteSink.js +55 -0
  101. package/src/infrastructure/streams/stompBody.js +46 -0
  102. package/src/infrastructure/streams/stompSend.js +89 -0
  103. package/src/infrastructure/streams/stompSource.js +201 -0
  104. package/src/infrastructure/sync/operationSyncIngest.js +4 -16
package/README.md CHANGED
@@ -269,6 +269,30 @@ Opaque operation `kind` strings and generic ports (`latestForMachine`, list/upse
269
269
  | Export ports `Query` / `Stream` / `Sink` / `Job` | Export jobs + destination adapters |
270
270
  | ClickHouse + PG adapters | Plant wiring (`sokolPlant` / `edgePlant`) |
271
271
 
272
+ ### Stream transport ownership
273
+
274
+ `@yarkivaev/source-to-sink` owns reusable Node.js sources, sinks and transport
275
+ lifecycle: serial input, Modbus polling,
276
+ MQTT/STOMP messaging, AMQP consumption and confirmed publishing, and serialized
277
+ periodic ticks. SCADA composes these primitives with generic codecs, domain ports
278
+ and persistence adapters. Plant packages supply source mappings, payload formats,
279
+ queries, routing and plant-specific behavior.
280
+
281
+ The SCADA AMQP telemetry, MQTT relay and operation-sync pipelines use
282
+ `amqpSource`. It acknowledges after collector acceptance, requeues failures by
283
+ default, and avoids settling explicitly acknowledged or rejected deliveries twice.
284
+ Shutdown cancels consumption, drains accepted work and settlements, then closes
285
+ the channel and connection. Reconnect and restart wait for that drain.
286
+ `durableConsume` remains a compatibility facade over the shared AMQP session.
287
+ Collectors that batch records acknowledge acceptance into that collector; this is
288
+ not a guarantee that a later batch has already reached persistent storage.
289
+
290
+ `intervalSource` schedules operator-cache synchronization, stale-open-segment
291
+ sweeps, silent-stream reconciliation, PostgreSQL metric streams and the shared
292
+ ClickHouse stream hub. Polls do not overlap; metric streams await subscribers
293
+ before advancing their cursors. HTTP keepalive timers and domain coalescing
294
+ windows remain owned by their respective adapters and domain policies.
295
+
272
296
  Depend on a pinned version (`"@yarkivaev/scada": "…#v2.3.46"`), not a local path, in downstream packages.
273
297
 
274
298
  ## CI/CD
@@ -327,3 +351,137 @@ The RabbitMQ definitions builder and management reconciler are exported as
327
351
  `@yarkivaev/scada/rabbitmq/definitions` and `@yarkivaev/scada/rabbitmq/reconcile`.
328
352
  The caller supplies the vhost, connection settings and ownership policy for
329
353
  retired routes. Reconciliation retains stored queues.
354
+
355
+ ### Collect multiple OPC UA endpoints
356
+
357
+ `@yarkivaev/scada/opcua` exports `opcuaConnections(connections, publish, options)`
358
+ and `validateConnections(connections)`. The same entry point exports
359
+ `opcuaSource(connections, collector, options)` for source-to-sink composition;
360
+ the collector accepts `{ channel, value }`. SCADA owns the OPC UA runtime and
361
+ its `node-opcua` dependency; source-to-sink contains no OPC UA implementation.
362
+ Offline deployment planners should import
363
+ `validateConnections` from `@yarkivaev/scada/opcua/config`; this entry point never
364
+ loads the network library or its startup diagnostics. Each connection has a unique `url`,
365
+ positive `publishMs` and `heartbeatMs`, and `channels` with explicit, unique
366
+ `nodeId` values. Additional channel metadata is opaque to this library.
367
+
368
+ ```js
369
+ import opcuaConnections from '@yarkivaev/scada/opcua';
370
+
371
+ const collector = opcuaConnections([
372
+ { url: 'opc.tcp://192.0.2.10:4840', publishMs: 100, heartbeatMs: 1000,
373
+ channels: [{ nodeId: 'ns=1;s=temperature', topic: 'line/temperature' }] }
374
+ ], (channel, value) => publish(channel.topic, value));
375
+ await collector.start();
376
+ // On process shutdown:
377
+ await collector.stop();
378
+ ```
379
+
380
+ Each endpoint owns its client, session and subscription. An unavailable endpoint
381
+ retries independently while others continue. Start schedules these workers; it
382
+ does not wait for every server to be available. Samples are delivered only with
383
+ Good quality, both on change and through serialized fresh heartbeat reads.
384
+ The caller owns value conversion, routing, timestamps and destination transport.
385
+ Options `retryMs`, `connectTimeoutMs` and `requestTimeoutMs` bound retry timing and
386
+ network requests; `onError(error, connection)` receives connection diagnostics.
387
+ Do not replicate a collector with the same sources to distribute load: assign
388
+ disjoint source sets to separate instances instead.
389
+
390
+ A release can replace old collectors using
391
+ `retirements: [{ workload: 'deployment/old-reader', selector: 'app=old-reader' }]`.
392
+ For apply/adopt, the executor validates the new manifests first, foreground-deletes
393
+ only these explicit Deployments, and verifies that no selected old pods remain
394
+ before applying the replacement. This trades a collection gap for no overlap.
395
+ The caller must bind each selector to its old Deployment; no namespace-wide prune
396
+ is performed. Render/check/diff do not delete anything. An optional process `image`
397
+ overrides the release image, so unrelated collectors can retain their pinned runtime.
398
+
399
+ ## Universal collector runtime
400
+
401
+ `scada-collector manifest.json` (or `scada-collector -` for stdin) runs all
402
+ registered adapters inside one Node.js process. Every instance has the same
403
+ registry; assignments select its work. `COLLECTOR_MANIFEST` accepts inline JSON;
404
+ `COLLECTOR_MANIFEST_PATH` selects a file. SIGHUP reconciles the file, SIGTERM/SIGINT
405
+ perform bounded shutdown. No cluster scheduler, lease service or network control
406
+ API is included. HTTP request handlers and their process-local read models remain
407
+ owned by the HTTP application; use `COLLECTOR_MODE=external` to disable its
408
+ transferable background collection before assigning that work elsewhere.
409
+
410
+ ```js
411
+ import { createRegistry, createRuntime } from '@yarkivaev/scada/runtime';
412
+ const runtime = createRuntime({ registry: createRegistry() });
413
+ await runtime.apply({ taskId: 'line-reader', generation: 1, specVersion: 1,
414
+ spec: { adapter: 'modbus', url: 'mqtt://broker', config: {
415
+ addresses: ['tcp://127.0.0.1:502'], streams: [], maps: [], interval: 5
416
+ } } });
417
+ await runtime.remove({ taskId: 'line-reader', generation: 2 });
418
+ await runtime.stop();
419
+ ```
420
+
421
+ Adapters include `stream` (registered source/transform/sinks), `modbus`,
422
+ `activity`, `amqp-relay`, `amqp-metrics`, `mqtt-metrics`, `operation-sync`,
423
+ `amqp-pg-metrics`, `silent-streams` and `stomp-ingest`. Plant packages extend the
424
+ registry with domain-specific adapters; OPC UA belongs here, not in
425
+ source-to-sink. Constructors do not open connections. A task owns independent
426
+ connections and must close them in `stop()`. Factories are trusted code, not
427
+ arbitrary scripts supplied in manifests.
428
+
429
+ Assignments have stable task IDs, externally supplied integer generations and
430
+ `specVersion: 1`. An optional `specHash` is checked against canonical JSON.
431
+ Identical repeats are idempotent; conflicting/stale generations are rejected.
432
+ Replacement waits for the previous owner to stop. Removed IDs retain bounded
433
+ in-memory tombstones. Cleanup timeout quarantines resources and marks the
434
+ runtime fatal; it never authorizes a second owner. Generation and PostgreSQL CAS
435
+ are local/conflict safeguards, **not distributed fencing**. The external agent
436
+ must establish cross-node ownership and provide current assignments after restart.
437
+
438
+ Secrets can be references such as `{ "$secret": "AMQP_URL" }`, resolved by the
439
+ host immediately before adapter construction. Status excludes configuration and
440
+ exception messages. Optional `COLLECTOR_HEALTH_PORT` and
441
+ `COLLECTOR_HEALTH_HOST` (default `127.0.0.1`) expose read-only `/live`, `/ready`
442
+ and `/status`. Readiness represents admission, not the health of every remote
443
+ server. Fatal cleanup makes liveness fail. A blocked event loop cannot answer
444
+ an external probe; synchronous infinite loops/native crashes still affect the
445
+ entire process. No hard CPU or native-memory isolation is claimed.
446
+
447
+ The process defaults to 16 MiB / 16,384 buffered records, each task to 1 MiB /
448
+ 1,024, with bounded control admission and start/stop deadlines. Adapter-owned
449
+ buffers and actual sink promises reserve capacity; driver/internal allocations
450
+ and transient JSON decoding are not a hard RSS limit. Configure capacities for
451
+ all simultaneously retained pipeline stages. MQTT and Loki collectors flush
452
+ individual accepted records because their source delivery is sequential;
453
+ AMQP pipelines retain bounded timed batching. Timeout is not proof that a remote
454
+ side effect did not happen.
455
+
456
+ `stream` requires explicit `delivery: "live" | "durable"`. Durable streams need
457
+ `stateVersion`, `compatibilityKey`, stable `eventId` and a shared state store.
458
+ Durable payloads and checkpoints use JSON; encode binary explicitly as strings.
459
+ They persist an outbox and each recipient acknowledgement before advancing the
460
+ checkpoint. MQTT requires QoS 1/2; AMQP sets persistent/mandatory publication and
461
+ checks returned messages. Broker confirmation does not prove downstream business
462
+ processing. An uncertain send can repeat with the same event ID; receivers must
463
+ deduplicate. Sources with a non-null checkpoint must implement `restore()`;
464
+ unsupported restoration fails closed. Live telemetry cannot replay downtime.
465
+ Loki fails a saturated page rather than silently advancing across truncated data.
466
+
467
+ The runner accepts `COLLECTOR_STATE_URL` for PostgreSQL. Apply
468
+ `db/migrations/V0009__collector_task_state.sql` explicitly and grant the service
469
+ role access before starting stateful assignments; the runner never runs schema
470
+ migrations or falls back to local disk. The storage must be reachable from every
471
+ eligible node. The built-in store rejects unsupported ownership tokens rather
472
+ than pretending to enforce an external fencing protocol.
473
+
474
+ The coordinated review branches require new source-to-sink/scada package releases
475
+ before dependency installation can reproduce all runtime tests. Stable release/pin
476
+ updates and deployment remain separate steps. Unit, transport,
477
+ state recovery and mixed-runtime tests do not constitute a 24-hour production
478
+ soak or prove cluster-wide exactly-once effects.
479
+
480
+ ## Streaming package boundary
481
+
482
+ `@yarkivaev/source-to-sink` 2.x contains only protocol-independent composition
483
+ primitives with no runtime dependencies. Protocol/storage implementations and
484
+ their integration tests live in `src/infrastructure/streams` and are exported as
485
+ `@yarkivaev/scada/streams`. This includes AMQP, MQTT, STOMP, serial, Modbus, Loki,
486
+ PostgreSQL, ClickHouse and SQLite; OPC UA stays in `@yarkivaev/scada/opcua`.
487
+ Plant-specific transformations stay in the plant package.
@@ -58,6 +58,28 @@ spec:
58
58
  image: {{ required "process image is required" (default $.Values.image .image) | quote }}
59
59
  imagePullPolicy: IfNotPresent
60
60
  command: {{ required "process command is required" .command | toJson }}
61
+ {{- with .health }}
62
+ ports:
63
+ - name: health
64
+ containerPort: {{ .port }}
65
+ protocol: TCP
66
+ livenessProbe:
67
+ httpGet:
68
+ path: {{ default "/live" .livenessPath | quote }}
69
+ port: health
70
+ initialDelaySeconds: {{ if hasKey . "initialDelaySeconds" }}{{ .initialDelaySeconds }}{{ else }}2{{ end }}
71
+ periodSeconds: {{ default 10 .periodSeconds }}
72
+ timeoutSeconds: {{ default 2 .timeoutSeconds }}
73
+ failureThreshold: {{ default 3 .failureThreshold }}
74
+ readinessProbe:
75
+ httpGet:
76
+ path: {{ default "/ready" .readinessPath | quote }}
77
+ port: health
78
+ initialDelaySeconds: {{ if hasKey . "initialDelaySeconds" }}{{ .initialDelaySeconds }}{{ else }}2{{ end }}
79
+ periodSeconds: {{ default 10 .periodSeconds }}
80
+ timeoutSeconds: {{ default 2 .timeoutSeconds }}
81
+ failureThreshold: {{ default 3 .failureThreshold }}
82
+ {{- end }}
61
83
  {{- if .hostDevices }}
62
84
  securityContext:
63
85
  privileged: true
@@ -11,6 +11,18 @@
11
11
  "name": { "type": "string", "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" },
12
12
  "command": { "type": "array", "minItems": 1, "items": { "type": "string" } },
13
13
  "env": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
14
+ "health": {
15
+ "type": "object", "required": ["port"], "additionalProperties": false,
16
+ "properties": {
17
+ "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
18
+ "livenessPath": { "type": "string", "pattern": "^/" },
19
+ "readinessPath": { "type": "string", "pattern": "^/" },
20
+ "initialDelaySeconds": { "type": "integer", "minimum": 0 },
21
+ "periodSeconds": { "type": "integer", "minimum": 1 },
22
+ "timeoutSeconds": { "type": "integer", "minimum": 1 },
23
+ "failureThreshold": { "type": "integer", "minimum": 1 }
24
+ }
25
+ },
14
26
  "hostDevices": {
15
27
  "type": "array", "uniqueItems": true,
16
28
  "items": { "type": "string", "pattern": "^/dev/([a-zA-Z0-9_-][a-zA-Z0-9_.-]*/)*[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$" }
@@ -0,0 +1,6 @@
1
+ CREATE TABLE IF NOT EXISTS collector_task_state (
2
+ task_id text PRIMARY KEY,
3
+ version bigint NOT NULL CHECK (version > 0),
4
+ document jsonb NOT NULL,
5
+ updated_at timestamptz NOT NULL DEFAULT now()
6
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.115",
3
+ "version": "2.4.0",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,11 +15,16 @@
15
15
  "./startTestEdgeApi": "./src/infrastructure/http/edge/startTestEdgeApi.js",
16
16
  "./package.json": "./package.json",
17
17
  "./rabbitmq/definitions": "./src/infrastructure/messaging/rabbitmq/definitions.js",
18
- "./rabbitmq/reconcile": "./src/infrastructure/messaging/rabbitmq/reconcile.js"
18
+ "./rabbitmq/reconcile": "./src/infrastructure/messaging/rabbitmq/reconcile.js",
19
+ "./opcua": "./src/infrastructure/opcua/index.js",
20
+ "./opcua/config": "./src/infrastructure/opcua/config.js",
21
+ "./runtime": "./src/application/runtime/index.js",
22
+ "./streams": "./src/infrastructure/streams/index.js"
19
23
  },
20
24
  "bin": {
21
25
  "modbus-to-mqtt": "./src/bin/modbus-to-mqtt.js",
22
- "supervisor-sink": "./src/bin/supervisor-sink.js"
26
+ "supervisor-sink": "./src/bin/supervisor-sink.js",
27
+ "scada-collector": "./src/bin/collector.js"
23
28
  },
24
29
  "scripts": {
25
30
  "test": "mocha --exit 'test/**/test*.js'",
@@ -33,15 +38,19 @@
33
38
  "@clickhouse/client": "^1.0.0",
34
39
  "@modelcontextprotocol/sdk": "^1.24.3",
35
40
  "@yarkivaev/simple-server": "1.0.2",
36
- "@yarkivaev/source-to-sink": "1.3.10",
41
+ "@yarkivaev/source-to-sink": "2.0.0",
37
42
  "amqplib": "^0.10.9",
43
+ "better-sqlite3": "^11.0.0",
44
+ "modbus-serial": "^8.0.0",
45
+ "mqtt": "^5.0.0",
46
+ "node-opcua": "2.179.0",
38
47
  "pg": "^8.0.0",
48
+ "serialport": "^12.0.0",
39
49
  "stompit": "^1.0.0",
40
50
  "zod": "^4.6.7"
41
51
  },
42
52
  "devDependencies": {
43
53
  "@eslint/js": "^9.39.2",
44
- "better-sqlite3": "^11.0.0",
45
54
  "c8": "^10.1.3",
46
55
  "cassandra-driver": "^4.7.2",
47
56
  "eslint": "^9.39.2",
@@ -23,7 +23,7 @@ function grouped(machines, describe) {
23
23
 
24
24
  /**
25
25
  * describe(row) returns a process descriptor with a name, command and optional
26
- * env and resources. All sources describing the same process must agree.
26
+ * env, resources and image. All sources describing the same process must agree.
27
27
  * project(descriptor, rows) supplies adapter-specific env and optional hostDevices.
28
28
  * A physical device may appear in many source rows but belongs to only one process.
29
29
  */
@@ -37,6 +37,7 @@ export default function collectorProcesses(machines, describe, project) {
37
37
  devices.add(device);
38
38
  });
39
39
  return { name: descriptor.name, command: descriptor.command, env: { ...descriptor.env, ...settings.env },
40
- hostDevices, resources: descriptor.resources };
40
+ hostDevices, resources: descriptor.resources,
41
+ ...(descriptor.image === undefined ? {} : { image: descriptor.image }) };
41
42
  });
42
43
  }
@@ -4,10 +4,13 @@ import applyIncludes from './applyIncludes.js';
4
4
  * Parses one scalar token.
5
5
  *
6
6
  * @param {string} text - raw scalar
7
- * @returns {string|number|boolean|null} scalar
7
+ * @returns {string|number|boolean|null|Array|object} scalar
8
8
  */
9
9
  function scalar(text) {
10
10
  const trimmed = text.trim();
11
+ if (trimmed === '[]' || trimmed === '{}') {
12
+ return trimmed === '[]' ? [] : {};
13
+ }
11
14
  if (trimmed === 'true') {
12
15
  return true;
13
16
  }
@@ -1,5 +1,5 @@
1
1
  import http from 'http';
2
- import { stompSource } from '@yarkivaev/source-to-sink';
2
+ import { stompSource } from '../infrastructure/streams/index.js';
3
3
  import plantApi from './plantApi.js';
4
4
  import pgAlerts from '../infrastructure/persistence/pg/alerts.js';
5
5
  import postgresPool from '../infrastructure/persistence/postgresPool.js';
@@ -0,0 +1,139 @@
1
+ import { clickhouseSink } from '../../infrastructure/streams/index.js';
2
+ import amqpMetricsIngest from '../../infrastructure/ingest/telemetry/amqpMetricsIngest.js';
3
+ import operationSyncIngest from '../../infrastructure/sync/operationSyncIngest.js';
4
+ import operationsFromPg from '../../infrastructure/persistence/pg/operations.js';
5
+ import { metricsSinkFromPool } from '../../infrastructure/persistence/pg/metrics.js';
6
+ import postgresPool from '../../infrastructure/persistence/postgresPool.js';
7
+ import foldedMetricsSink from '../foldedMetricsSink.js';
8
+ import silentStreams from '../../infrastructure/ingest/modbus/silentStreams.js';
9
+ import mx210Tcp from '../../infrastructure/ingest/modbus/mx210Tcp.js';
10
+ import segmentPipeline from '../../infrastructure/ingest/pipelines/segmentPipeline.js';
11
+ import decisionPipeline from '../../infrastructure/ingest/pipelines/decisionPipeline.js';
12
+ import alertPipeline from '../../infrastructure/ingest/pipelines/alertPipeline.js';
13
+ import resolveSecrets from './resolveSecrets.js';
14
+
15
+ function urlOptions(spec) {
16
+ const protocols = { amqp: ['amqp:', 'amqps:'], stomp: ['stomp:', 'stomps:'],
17
+ postgres: ['postgres:', 'postgresql:'], clickhouse: ['http:', 'https:'] };
18
+ Object.entries(protocols).forEach(([key, allowed]) => {
19
+ const value = spec[key];
20
+ if (value === undefined || value?.$secret) { return; }
21
+ if (typeof value !== 'string' || !allowed.includes(new URL(value).protocol)) { throw new TypeError(`Invalid ${key} URL`); }
22
+ });
23
+ }
24
+ function boundedOptions(spec) {
25
+ const maxRecords = Math.min(spec.options?.maxRecords ?? 1000, spec.limits?.maxRecords ?? 1024);
26
+ const maxBytes = Math.min(spec.options?.maxBytes ?? 1048576, spec.limits?.maxBytes ?? 1048576);
27
+ if (!Number.isSafeInteger(maxRecords) || maxRecords < 1 || !Number.isSafeInteger(maxBytes) || maxBytes < 1) {
28
+ throw new TypeError('Ingest limits must be positive safe integers');
29
+ }
30
+ return { ...spec.options, maxRecords, maxBytes, maxMessageBytes: maxBytes, maxPendingBytes: maxBytes, size: Math.min(spec.options?.size ?? 100, maxRecords),
31
+ prefetch: Math.min(spec.options?.prefetch ?? 32, maxRecords) };
32
+ }
33
+ function ownedSink(pipeline, sink) {
34
+ return { start() { return pipeline.start(); }, async stop() {
35
+ try { await pipeline.stop(); } finally { await sink.stop?.(); }
36
+ } };
37
+ }
38
+ function together(items) {
39
+ return {
40
+ start() { return Promise.all(items.map((item) => { return item.start(); })); },
41
+ async stop() {
42
+ const results = await Promise.allSettled(items.map((item) => { return item.stop(); }));
43
+ const failure = results.find((result) => { return result.status === 'rejected'; });
44
+ if (failure) { throw failure.reason; }
45
+ }
46
+ };
47
+ }
48
+ function borrowed(pool) {
49
+ return { query(...args) { return pool.query(...args); }, connect() { return pool.connect(); },
50
+ end() { return Promise.resolve(); } };
51
+ }
52
+ function telemetry(spec, pool) {
53
+ const inner = spec.clickhouse ? clickhouseSink(spec.clickhouse, spec.table ?? 'scada.metrics') : metricsSinkFromPool(pool);
54
+ return foldedMetricsSink(inner, pool, spec.devices);
55
+ }
56
+ function metrics(spec, pool, context) {
57
+ const sink = telemetry(spec, pool);
58
+ return ownedSink(amqpMetricsIngest(spec.amqp, spec.queue, sink, { ...boundedOptions(spec), budget: context.budget }), sink);
59
+ }
60
+ function silent(spec, pool, context) {
61
+ const sink = telemetry(spec, pool);
62
+ const streams = silentStreams({ budget: spec.budget, interval: spec.interval,
63
+ sources: spec.sources.map((source) => { return mx210Tcp(source.name, source.host, source.port); }),
64
+ onError() { context.report?.({ state: 'degraded' }); } });
65
+ streams.bind(sink);
66
+ const ingest = amqpMetricsIngest(spec.amqp, spec.queue, sink, {
67
+ ...boundedOptions(spec), budget: context.budget, onSeen(name) { streams.seen(name); }
68
+ });
69
+ return ownedSink(together([ingest, streams]), sink);
70
+ }
71
+ function stomp(spec, pool, context) {
72
+ 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
+ ]);
79
+ }
80
+ function close(state) {
81
+ state.closing ??= Promise.resolve().then(async () => {
82
+ try { await state.pipeline?.stop(); } finally { await state.pool?.end(); }
83
+ });
84
+ return state.closing;
85
+ }
86
+ function task(factory, spec, context) {
87
+ const state = { pipeline: null, pool: null, stopped: false };
88
+ return {
89
+ async start() {
90
+ if (state.stopped) { throw new Error('Ingest task stopped'); }
91
+ context.signal.throwIfAborted();
92
+ const resolved = resolveSecrets(spec, context.secrets);
93
+ urlOptions(resolved);
94
+ state.pool = resolved.postgres ? postgresPool({ connectionString: resolved.postgres }) : null;
95
+ try {
96
+ state.pipeline = factory(resolved, state.pool, context);
97
+ await state.pipeline.start();
98
+ context.signal.throwIfAborted();
99
+ } catch (error) {
100
+ await close(state);
101
+ throw error;
102
+ }
103
+ },
104
+ async stop() {
105
+ state.stopped = true;
106
+ await close(state);
107
+ }
108
+ };
109
+ }
110
+ function required(spec, keys) {
111
+ keys.forEach((key) => { if (!spec[key]) { throw new TypeError(`Missing ingest ${key}`); } });
112
+ }
113
+ function silentConfig(spec) {
114
+ if (!spec.clickhouse && !spec.postgres) { throw new TypeError('Missing silent metrics storage'); }
115
+ if (spec.devices && Object.keys(spec.devices).length && !spec.postgres) { throw new TypeError('Missing folding postgres'); }
116
+ if (![spec.budget, spec.interval].every((value) => { return Number.isFinite(value) && value > 0; })) {
117
+ throw new TypeError('Silent budget and interval must be positive');
118
+ }
119
+ if (!Array.isArray(spec.sources) || !spec.sources.length || spec.sources.some((source) => {
120
+ return !source.name || !source.host || !Number.isInteger(source.port) || source.port < 1 || source.port > 65535;
121
+ })) { throw new TypeError('Invalid silent source'); }
122
+ }
123
+ function descriptor(factory, keys, validate = () => { return undefined; }, resources = () => { return []; }) {
124
+ return { validate(spec) { required(spec, keys); urlOptions(spec); boundedOptions(spec); validate(spec); }, resources,
125
+ create(spec, context) { return task(factory, spec, context); } };
126
+ }
127
+ /** Portable consumers previously embedded in site HTTP processes; migrations remain external. */
128
+ export default function additionalAdapters() {
129
+ return new Map([
130
+ ['operation-sync', descriptor((spec, pool, context) => {
131
+ return operationSyncIngest(spec.amqp, spec.queue, operationsFromPg(pool), { ...boundedOptions(spec), budget: context.budget });
132
+ }, ['amqp', 'queue', 'postgres'])],
133
+ ['amqp-pg-metrics', descriptor(metrics, ['amqp', 'queue', 'postgres'])],
134
+ ['silent-streams', descriptor(silent, ['amqp', 'queue', 'sources'], silentConfig, (spec) => {
135
+ return [...new Set(spec.sources.map((source) => { return `modbus-tcp:${source.host}:${source.port}`; }))];
136
+ })],
137
+ ['stomp-ingest', descriptor(stomp, ['stomp', 'postgres', 'config'])]
138
+ ]);
139
+ }
@@ -0,0 +1,30 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ function canonical(value) {
4
+ if (Array.isArray(value)) { return value.map(canonical); }
5
+ if (value && typeof value === 'object') {
6
+ return Object.fromEntries(Object.keys(value).sort().map((key) => { return [key, canonical(value[key])]; }));
7
+ }
8
+ return value;
9
+ }
10
+ export function identity(input) {
11
+ if (!input || typeof input.taskId !== 'string' || !/^[\w./:-]{1,200}$/u.test(input.taskId)) {
12
+ throw new TypeError('Invalid taskId');
13
+ }
14
+ if (!Number.isSafeInteger(input.generation) || input.generation < 0) { throw new TypeError('Invalid generation'); }
15
+ }
16
+ export default function assignment(input, registry) {
17
+ identity(input);
18
+ if (input.specVersion !== 1) { throw new TypeError('Unsupported specVersion'); }
19
+ const json = JSON.stringify(input.spec);
20
+ if (!json || Buffer.byteLength(json) > 1048576) { throw new TypeError('Invalid or oversized task spec'); }
21
+ const spec = JSON.parse(json);
22
+ Object.entries(spec.limits ?? {}).forEach(([key, value]) => {
23
+ if (!Number.isSafeInteger(value) || value < 1) { throw new TypeError(`Invalid ${key}`); }
24
+ });
25
+ const adapter = registry.get(spec.adapter);
26
+ if (!adapter) { throw new TypeError('Unknown task adapter'); }
27
+ const hash = createHash('sha256').update(JSON.stringify(canonical(spec))).digest('hex');
28
+ if (input.specHash && input.specHash !== hash) { throw new TypeError('Invalid specHash'); }
29
+ return { ...input, spec, specHash: hash, adapter };
30
+ }
@@ -0,0 +1,17 @@
1
+ /** Reservation follows the driver's actual promise, not an outer timeout race. */
2
+ export default function budgetedSink(sink, budget, countRecords = true) {
3
+ return {
4
+ start() { return sink.start?.(); },
5
+ stop() { return sink.stop?.(); },
6
+ write(records, options) {
7
+ try {
8
+ options?.signal?.throwIfAborted();
9
+ const release = budget.reserve({ bytes: Buffer.byteLength(JSON.stringify(records)), records: countRecords ? records.length : 0 });
10
+ return Promise.resolve().then(() => {
11
+ options?.signal?.throwIfAborted();
12
+ return sink.write(records, options);
13
+ }).finally(release);
14
+ } catch (error) { return Promise.reject(error); }
15
+ }
16
+ };
17
+ }
@@ -0,0 +1,14 @@
1
+ /** Only the executable runner owns process-global signal handlers. */
2
+ export default function collectorSignals(collector) {
3
+ function report(error) { process.stderr.write(`collector: ${error.name}\n`); }
4
+ function stop() { void collector.stop().then(() => { process.exit(0); }, () => { process.exit(1); }); }
5
+ function reload() { void collector.reload().catch(report); }
6
+ process.once('SIGTERM', stop);
7
+ process.once('SIGINT', stop);
8
+ process.on('SIGHUP', reload);
9
+ return () => {
10
+ process.off('SIGTERM', stop);
11
+ process.off('SIGINT', stop);
12
+ process.off('SIGHUP', reload);
13
+ };
14
+ }
@@ -0,0 +1,49 @@
1
+ async function close(state) {
2
+ const failures = [];
3
+ for (const part of [...state.opened].reverse()) {
4
+ try {
5
+ // Reverse startup order; every owned resource receives a stop attempt.
6
+ // eslint-disable-next-line no-await-in-loop
7
+ await part.stop();
8
+ } catch (error) { failures.push(error); }
9
+ }
10
+ if (failures.length) { throw new AggregateError(failures, 'Composite cleanup failed'); }
11
+ state.opened = [];
12
+ }
13
+ function cleanup(state) {
14
+ state.closing ??= close(state);
15
+ return state.closing;
16
+ }
17
+ async function open(state, parts) {
18
+ try {
19
+ for (const part of parts) {
20
+ if (state.stopped) { throw new Error('Composite startup aborted'); }
21
+ state.opened.push(part);
22
+ // Register ownership before awaiting potentially cancellable startup.
23
+ // eslint-disable-next-line no-await-in-loop
24
+ await part.start();
25
+ if (state.stopped) { throw new Error('Composite startup aborted'); }
26
+ }
27
+ } catch (error) {
28
+ try { await cleanup(state); } catch (failure) { throw new AggregateError([error, failure], 'Start and cleanup failed'); }
29
+ throw error;
30
+ }
31
+ }
32
+ /** Stop attempts cancellation immediately, then waits for startup to settle. */
33
+ export default function compositeLifecycle({ inputs, outputs }) {
34
+ const state = { opened: [], starting: null, closing: null, stopping: null, stopped: false };
35
+ return {
36
+ start() {
37
+ if (state.stopped) { return Promise.reject(new Error('Composite stopped')); }
38
+ state.starting ??= open(state, [...outputs, ...inputs]);
39
+ return state.starting;
40
+ },
41
+ stop() {
42
+ state.stopped = true;
43
+ state.stopping ??= Promise.all([cleanup(state), Promise.resolve(state.starting).catch(() => {
44
+ // Startup failure remains visible to its own caller.
45
+ })]);
46
+ return state.stopping;
47
+ }
48
+ };
49
+ }
@@ -0,0 +1,20 @@
1
+ import { resources, sinks, sources } from './streamPorts.js';
2
+ import pipelineAdapters from './pipelineAdapters.js';
3
+ import additionalAdapters from './additionalAdapters.js';
4
+ import streamTask from './streamTask.js';
5
+
6
+ function extend(base, additions = new Map()) {
7
+ additions.forEach((value, key) => {
8
+ if (base.has(key)) { throw new Error(`duplicate runtime adapter: ${key}`); }
9
+ base.set(key, value);
10
+ });
11
+ return base;
12
+ }
13
+ /** Same protocol capabilities on every executor; only registered extensions may run. */
14
+ export default function createRegistry(options = {}) {
15
+ const ports = { sources: extend(sources(), options.sources), sinks: extend(sinks(), options.sinks), transforms: options.transforms ?? new Map() };
16
+ const stream = streamTask(ports);
17
+ const registry = extend(pipelineAdapters(), additionalAdapters());
18
+ registry.set('stream', { ...stream, resources(spec) { return [...new Set([...resources(spec), ...(spec.resources ?? [])])]; } });
19
+ return extend(registry, options.adapters);
20
+ }
@@ -0,0 +1,8 @@
1
+ /** Bounds waiting; callers must also abort/close resources and fence late results. */
2
+ export default function deadline(action, milliseconds) {
3
+ let timer;
4
+ const timeout = new Promise((resolve, reject) => {
5
+ timer = setTimeout(() => { reject(new Error('Task operation timed out')); }, milliseconds);
6
+ });
7
+ return Promise.race([Promise.resolve().then(action), timeout]).finally(() => { clearTimeout(timer); });
8
+ }
@@ -0,0 +1,10 @@
1
+ export { default as createRuntime } from './runtime.js';
2
+ export { default as taskBudget } from './taskBudget.js';
3
+ export { default as taskState } from './taskState.js';
4
+ export { default as streamTask } from './streamTask.js';
5
+ export { default as compositeLifecycle } from './compositeLifecycle.js';
6
+ export { default as runCollector } from './runCollector.js';
7
+ export { default as taskQueue } from './taskQueue.js';
8
+ export { default as createRegistry } from './createRegistry.js';
9
+ export { default as resolveSecrets } from './resolveSecrets.js';
10
+ export { default as taskStatePg } from '../../infrastructure/persistence/runtime/taskStatePg.js';