@yarkivaev/scada 2.3.116 → 2.4.1

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 (95) hide show
  1. package/README.md +90 -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 +11 -5
  6. package/src/application/deploy/readDocument.js +4 -1
  7. package/src/application/plantServer.js +1 -1
  8. package/src/application/runtime/additionalAdapters.js +139 -0
  9. package/src/application/runtime/assignment.js +30 -0
  10. package/src/application/runtime/budgetedSink.js +17 -0
  11. package/src/application/runtime/collectorSignals.js +14 -0
  12. package/src/application/runtime/compositeLifecycle.js +49 -0
  13. package/src/application/runtime/createRegistry.js +20 -0
  14. package/src/application/runtime/deadline.js +8 -0
  15. package/src/application/runtime/index.js +10 -0
  16. package/src/application/runtime/manifest.js +38 -0
  17. package/src/application/runtime/pipelineAdapters.js +60 -0
  18. package/src/application/runtime/resolveSecrets.js +12 -0
  19. package/src/application/runtime/resourceClaims.js +21 -0
  20. package/src/application/runtime/runCollector.js +50 -0
  21. package/src/application/runtime/runtime.js +86 -0
  22. package/src/application/runtime/runtimeHealth.js +29 -0
  23. package/src/application/runtime/runtimeServices.js +15 -0
  24. package/src/application/runtime/stateDocument.js +22 -0
  25. package/src/application/runtime/streamPorts.js +42 -0
  26. package/src/application/runtime/streamTask.js +105 -0
  27. package/src/application/runtime/taskBudget.js +31 -0
  28. package/src/application/runtime/taskContext.js +18 -0
  29. package/src/application/runtime/taskQueue.js +21 -0
  30. package/src/application/runtime/taskSlot.js +46 -0
  31. package/src/application/runtime/taskState.js +73 -0
  32. package/src/application/siteCollection.js +14 -0
  33. package/src/application/siteServer.js +7 -6
  34. package/src/application/siteTelemetry.js +1 -1
  35. package/src/application/supervisorSink.js +9 -8
  36. package/src/application/sync/siteSyncBind.js +1 -1
  37. package/src/application/sync/siteSyncTargets.js +1 -1
  38. package/src/bin/collector.js +7 -0
  39. package/src/bin/modbus-to-mqtt.js +11 -9
  40. package/src/infrastructure/ingest/activity/activityTracking.js +15 -21
  41. package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
  42. package/src/infrastructure/ingest/durableConsume.js +2 -2
  43. package/src/infrastructure/ingest/ingestBatch.js +13 -0
  44. package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
  45. package/src/infrastructure/ingest/modbus/silentStreams.js +33 -8
  46. package/src/infrastructure/ingest/mqtt/metricsTransformer.js +4 -5
  47. package/src/infrastructure/ingest/mqtt/modbusMqtt.js +17 -19
  48. package/src/infrastructure/ingest/mqtt/modbusPublish.js +4 -0
  49. package/src/infrastructure/ingest/mqtt/mqttMetrics.js +17 -21
  50. package/src/infrastructure/ingest/mqtt/registerPublish.js +3 -3
  51. package/src/infrastructure/ingest/pipelines/alertPipeline.js +7 -6
  52. package/src/infrastructure/ingest/pipelines/decisionPipeline.js +7 -6
  53. package/src/infrastructure/ingest/pipelines/segmentPipeline.js +8 -17
  54. package/src/infrastructure/ingest/pipelines/stompOptions.js +7 -0
  55. package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +10 -11
  56. package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +10 -6
  57. package/src/infrastructure/ingest/telemetry/foldingSink.js +5 -2
  58. package/src/infrastructure/messaging/stomp/segmentRetags.js +1 -1
  59. package/src/infrastructure/messaging/stomp/userDecisions.js +1 -1
  60. package/src/infrastructure/opcua/certificates.js +11 -0
  61. package/src/infrastructure/opcua/delivery.js +38 -0
  62. package/src/infrastructure/opcua/endpoint.js +12 -2
  63. package/src/infrastructure/opcua/session.js +20 -10
  64. package/src/infrastructure/opcua/source.js +6 -3
  65. package/src/infrastructure/persistence/runtime/taskStatePg.js +24 -0
  66. package/src/infrastructure/streams/README.md +291 -0
  67. package/src/infrastructure/streams/abortable.js +14 -0
  68. package/src/infrastructure/streams/amqpSink/publication.js +46 -0
  69. package/src/infrastructure/streams/amqpSink/session.js +105 -0
  70. package/src/infrastructure/streams/amqpSink.js +47 -0
  71. package/src/infrastructure/streams/amqpSource/session.js +127 -0
  72. package/src/infrastructure/streams/amqpSource.js +97 -0
  73. package/src/infrastructure/streams/brokerClose.js +19 -0
  74. package/src/infrastructure/streams/budgetAccept.js +6 -0
  75. package/src/infrastructure/streams/clickhouseSink.js +48 -0
  76. package/src/infrastructure/streams/deadline.js +1 -0
  77. package/src/infrastructure/streams/follow.js +41 -0
  78. package/src/infrastructure/streams/index.js +15 -0
  79. package/src/infrastructure/streams/lokiSource.js +88 -0
  80. package/src/infrastructure/streams/modbusConnection.js +173 -0
  81. package/src/infrastructure/streams/modbusRtuBusSource.js +124 -0
  82. package/src/infrastructure/streams/modbusSource.js +185 -0
  83. package/src/infrastructure/streams/mqttSink/session.js +51 -0
  84. package/src/infrastructure/streams/mqttSink.js +54 -0
  85. package/src/infrastructure/streams/mqttSource.js +66 -0
  86. package/src/infrastructure/streams/pollingSource.js +1 -0
  87. package/src/infrastructure/streams/positive.js +5 -0
  88. package/src/infrastructure/streams/postgresSink.js +124 -0
  89. package/src/infrastructure/streams/serialReceive.js +80 -0
  90. package/src/infrastructure/streams/serialSource.js +87 -0
  91. package/src/infrastructure/streams/sqliteSink.js +55 -0
  92. package/src/infrastructure/streams/stompBody.js +46 -0
  93. package/src/infrastructure/streams/stompSend.js +89 -0
  94. package/src/infrastructure/streams/stompSource.js +201 -0
  95. package/src/infrastructure/sync/operationSyncIngest.js +1 -1
package/README.md CHANGED
@@ -395,3 +395,93 @@ before applying the replacement. This trades a collection gap for no overlap.
395
395
  The caller must bind each selector to its old Deployment; no namespace-wide prune
396
396
  is performed. Render/check/diff do not delete anything. An optional process `image`
397
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.116",
3
+ "version": "2.4.1",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,11 +17,14 @@
17
17
  "./rabbitmq/definitions": "./src/infrastructure/messaging/rabbitmq/definitions.js",
18
18
  "./rabbitmq/reconcile": "./src/infrastructure/messaging/rabbitmq/reconcile.js",
19
19
  "./opcua": "./src/infrastructure/opcua/index.js",
20
- "./opcua/config": "./src/infrastructure/opcua/config.js"
20
+ "./opcua/config": "./src/infrastructure/opcua/config.js",
21
+ "./runtime": "./src/application/runtime/index.js",
22
+ "./streams": "./src/infrastructure/streams/index.js"
21
23
  },
22
24
  "bin": {
23
25
  "modbus-to-mqtt": "./src/bin/modbus-to-mqtt.js",
24
- "supervisor-sink": "./src/bin/supervisor-sink.js"
26
+ "supervisor-sink": "./src/bin/supervisor-sink.js",
27
+ "scada-collector": "./src/bin/collector.js"
25
28
  },
26
29
  "scripts": {
27
30
  "test": "mocha --exit 'test/**/test*.js'",
@@ -35,16 +38,19 @@
35
38
  "@clickhouse/client": "^1.0.0",
36
39
  "@modelcontextprotocol/sdk": "^1.24.3",
37
40
  "@yarkivaev/simple-server": "1.0.2",
38
- "@yarkivaev/source-to-sink": "1.3.11",
41
+ "@yarkivaev/source-to-sink": "2.0.0",
39
42
  "amqplib": "^0.10.9",
43
+ "better-sqlite3": "^11.0.0",
44
+ "modbus-serial": "^8.0.0",
45
+ "mqtt": "^5.0.0",
40
46
  "node-opcua": "2.179.0",
41
47
  "pg": "^8.0.0",
48
+ "serialport": "^12.0.0",
42
49
  "stompit": "^1.0.0",
43
50
  "zod": "^4.6.7"
44
51
  },
45
52
  "devDependencies": {
46
53
  "@eslint/js": "^9.39.2",
47
- "better-sqlite3": "^11.0.0",
48
54
  "c8": "^10.1.3",
49
55
  "cassandra-driver": "^4.7.2",
50
56
  "eslint": "^9.39.2",
@@ -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';
@@ -0,0 +1,38 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import assignment, { identity } from './assignment.js';
3
+
4
+ async function read(stream) {
5
+ const chunks = [];
6
+ let bytes = 0;
7
+ for await (const chunk of stream) {
8
+ bytes += Buffer.byteLength(chunk);
9
+ if (bytes > 1048576) { throw new Error('Manifest capacity exceeded'); }
10
+ chunks.push(Buffer.from(chunk));
11
+ }
12
+ return Buffer.concat(chunks).toString('utf8');
13
+ }
14
+ export async function readManifest(env, argv) {
15
+ const text = env.COLLECTOR_MANIFEST ?? await read(argv[0] === '-' ? process.stdin : createReadStream(argv[0] ?? env.COLLECTOR_MANIFEST_PATH));
16
+ if (Buffer.byteLength(text) > 1048576) { throw new Error('Manifest capacity exceeded'); }
17
+ return JSON.parse(text);
18
+ }
19
+ export async function validateManifest(document, registry) {
20
+ if (!document || !Array.isArray(document.assignments) || !Array.isArray(document.removals ?? [])) {
21
+ throw new TypeError('Manifest requires assignments and optional removals arrays');
22
+ }
23
+ const ids = new Set();
24
+ const resources = new Set();
25
+ for (const input of [...document.assignments, ...(document.removals ?? [])]) {
26
+ identity(input);
27
+ if (ids.has(input.taskId)) { throw new TypeError('Duplicate manifest taskId'); }
28
+ ids.add(input.taskId);
29
+ }
30
+ await Promise.all(document.assignments.map(async (input) => {
31
+ const request = assignment(input, registry);
32
+ await request.adapter.validate(request.spec);
33
+ request.adapter.resources(request.spec).forEach((key) => {
34
+ if (resources.has(key)) { throw new Error('Manifest resource already claimed'); }
35
+ resources.add(key);
36
+ });
37
+ }));
38
+ }
@@ -0,0 +1,60 @@
1
+ import { clickhouseSink, mqttSink } from '../../infrastructure/streams/index.js';
2
+ import activityTracking from '../../infrastructure/ingest/activity/activityTracking.js';
3
+ import amqpMetricsIngest from '../../infrastructure/ingest/telemetry/amqpMetricsIngest.js';
4
+ import amqpMqttRelay from '../../infrastructure/ingest/telemetry/amqpMqttRelay.js';
5
+ import modbusPublish from '../../infrastructure/ingest/mqtt/modbusPublish.js';
6
+ import mqttMetrics from '../../infrastructure/ingest/mqtt/mqttMetrics.js';
7
+ import { parseModbusDeviceSpec } from '../../infrastructure/ingest/mqtt/modbusDeviceSpec.js';
8
+ import compositeLifecycle from './compositeLifecycle.js';
9
+ import resolveSecrets from './resolveSecrets.js';
10
+
11
+ function configured(spec, context) {
12
+ const options = { maxRecords: spec.limits?.maxRecords, maxBytes: spec.limits?.maxBytes, budget: context.budget };
13
+ const defined = Object.fromEntries(Object.entries(options).filter(([, value]) => { return value !== undefined; }));
14
+ return { ...resolveSecrets(spec, context.secrets),
15
+ config: { ...resolveSecrets(spec.config, context.secrets), ...defined },
16
+ options: { ...resolveSecrets(spec.options, context.secrets), ...defined } };
17
+ }
18
+ function adapter(factory, required, resourceKeys = () => { return []; }) {
19
+ return {
20
+ validate(spec) {
21
+ required.forEach((key) => { if (!spec[key]) { throw new TypeError(`Missing pipeline ${key}`); } });
22
+ },
23
+ resources(spec) { return resourceKeys(spec); },
24
+ create(spec, context) {
25
+ let pipeline;
26
+ let stopped = false;
27
+ return {
28
+ start() {
29
+ context.signal.throwIfAborted();
30
+ if (stopped) { throw new Error('Pipeline stopped'); }
31
+ pipeline ??= factory(configured(spec, context));
32
+ return pipeline.start();
33
+ },
34
+ stop() { stopped = true; return pipeline?.stop(); }
35
+ };
36
+ }
37
+ };
38
+ }
39
+ function modbusResources(spec) {
40
+ const devices = spec.config.devices?.split(',').map(parseModbusDeviceSpec) ?? [];
41
+ const paths = devices.filter((device) => { return device.kind === 'rtu'; }).map((device) => { return `serial:${device.path}`; });
42
+ const addresses = spec.config.addresses ?? [];
43
+ addresses.filter((address) => { return address.startsWith('rtu://'); }).forEach((address) => {
44
+ paths.push(`serial:${address.slice(6).split(':')[0]}`);
45
+ });
46
+ return [...new Set(paths)];
47
+ }
48
+ function metrics(spec, factory) {
49
+ const sink = clickhouseSink(spec.clickhouseUrl, spec.table);
50
+ return compositeLifecycle({ outputs: [sink], inputs: [factory(sink)] });
51
+ }
52
+ export default function pipelineAdapters() {
53
+ return new Map([
54
+ ['modbus', adapter((spec) => { return modbusPublish(spec.url, spec.config); }, ['url', 'config'], modbusResources)],
55
+ ['activity', adapter((spec) => { return activityTracking(spec.lokiUrl, spec.clickhouseUrl, spec.query, spec.config); }, ['lokiUrl', 'clickhouseUrl', 'query', 'config'])],
56
+ ['amqp-relay', adapter((spec) => { return amqpMqttRelay(spec.url, spec.queue, mqttSink(spec.mqttUrl, spec.options), spec.options); }, ['url', 'queue', 'mqttUrl'])],
57
+ ['amqp-metrics', adapter((spec) => { return metrics(spec, (sink) => { return amqpMetricsIngest(spec.url, spec.queue, sink, spec.options); }); }, ['url', 'queue', 'clickhouseUrl', 'table'])],
58
+ ['mqtt-metrics', adapter((spec) => { return metrics(spec, (sink) => { return mqttMetrics(spec.url, sink, spec.topic, spec.config); }); }, ['url', 'topic', 'clickhouseUrl', 'table', 'config'])]
59
+ ]);
60
+ }
@@ -0,0 +1,12 @@
1
+ /** Resolve references only at resource construction; hashes and manifests retain refs. */
2
+ export default function resolveSecrets(value, secrets) {
3
+ if (Array.isArray(value)) { return value.map((item) => { return resolveSecrets(item, secrets); }); }
4
+ if (!value || typeof value !== 'object') { return value; }
5
+ if (Object.hasOwn(value, '$secret')) {
6
+ if (Object.keys(value).length !== 1 || typeof value.$secret !== 'string') { throw new TypeError('Invalid secret reference'); }
7
+ const resolved = secrets?.get(value.$secret);
8
+ if (resolved === undefined || resolved === null) { throw new Error('Required collector secret is unavailable'); }
9
+ return resolved;
10
+ }
11
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => { return [key, resolveSecrets(item, secrets)]; }));
12
+ }