@yarkivaev/scada 2.3.56 → 2.3.58

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.3.56",
3
+ "version": "2.3.58",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,7 +29,7 @@
29
29
  "dependencies": {
30
30
  "@clickhouse/client": "^1.0.0",
31
31
  "@yarkivaev/simple-server": "1.0.1",
32
- "@yarkivaev/source-to-sink": "1.3.2",
32
+ "@yarkivaev/source-to-sink": "1.3.4",
33
33
  "amqplib": "^0.10.9",
34
34
  "pg": "^8.0.0",
35
35
  "stompit": "^1.0.0"
@@ -1,31 +1,69 @@
1
+ /**
2
+ * Persists a pending heartbeat, optionally inside one pool transaction.
3
+ *
4
+ * @param {object} segmentSink - upsert sink
5
+ * @param {object} closer - orphan closer
6
+ * @param {object} [pool] - optional pg pool
7
+ * @param {object} record - normalized segment record
8
+ * @returns {Promise<void>}
9
+ */
10
+ async function persistPending(segmentSink, closer, pool, record) {
11
+ const kind = record.kind || 'phase';
12
+ if (!pool || typeof pool.connect !== 'function') {
13
+ await closer.close(record.machine, record.start_time, kind);
14
+ await segmentSink.write([record]);
15
+ return;
16
+ }
17
+ const client = await pool.connect();
18
+ try {
19
+ await client.query('BEGIN');
20
+ await closer.close(record.machine, record.start_time, kind, client);
21
+ await segmentSink.write([record], client);
22
+ await client.query('COMMIT');
23
+ } catch (error) {
24
+ await client.query('ROLLBACK').catch((ignored) => { return ignored; });
25
+ throw error;
26
+ } finally {
27
+ client.release();
28
+ }
29
+ }
30
+
1
31
  /**
2
32
  * Routes normalized segment records to the correct persistence sink.
3
33
  *
34
+ * When a pool is supplied, pending segment heartbeats (`duration === 0`)
35
+ * run orphan-close and upsert inside one transaction on a single client.
36
+ *
4
37
  * @param {object} segmentSink - batch insert/update sink
5
38
  * @param {object} retag - retag sink
6
39
  * @param {object} splitSink - split update sink
7
40
  * @param {object} closer - orphan open segment closer
41
+ * @param {object} [pool] - optional pg pool for transactional pending writes
8
42
  * @returns {object} collector with accept(record)
9
43
  *
10
44
  * @example
11
- * const route = segmentDispatch(segmentSink, retag, splitSink, closer);
45
+ * const route = segmentDispatch(segmentSink, retag, splitSink, closer, pool);
12
46
  * await route.accept({ type: 'segment', machine: 'm1', start_time: '...', duration: 0 });
13
47
  */
14
- export default function segmentDispatch(segmentSink, retag, splitSink, closer) {
48
+ export default function segmentDispatch(segmentSink, retag, splitSink, closer, pool) {
15
49
  return {
16
50
  async accept(record) {
17
51
  if (record.type === 'retag') {
18
52
  await retag.accept(record);
19
- } else if (record.type === 'split') {
53
+ return;
54
+ }
55
+ if (record.type === 'split') {
20
56
  await splitSink.write([record]);
21
- } else if (record.type === 'segment') {
22
- if (record.duration === 0) {
23
- await closer.close(record.machine, record.start_time, record.kind || 'phase');
24
- }
25
- await segmentSink.write([record]);
26
- } else {
57
+ return;
58
+ }
59
+ if (record.type !== 'segment') {
27
60
  throw new Error(`Segment command type ${record.type} is not defined`);
28
61
  }
62
+ if (record.duration !== 0) {
63
+ await segmentSink.write([record]);
64
+ return;
65
+ }
66
+ await persistPending(segmentSink, closer, pool, record);
29
67
  }
30
68
  };
31
69
  }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Returns the per-machine lane state, creating it when missing.
3
+ *
4
+ * @param {Map} lanes - machine → lane map
5
+ * @param {string} machine - machine id
6
+ * @returns {object} lane state
7
+ */
8
+ function laneState(lanes, machine) {
9
+ if (!lanes.has(machine)) {
10
+ lanes.set(machine, { queue: Promise.resolve(), pending: null, timer: null });
11
+ }
12
+ return lanes.get(machine);
13
+ }
14
+
15
+ /**
16
+ * Builds the coalesce key for a pending heartbeat.
17
+ *
18
+ * @param {object} parsed - raw segment JSON
19
+ * @returns {string} coalesce key
20
+ */
21
+ function heartbeatKey(parsed) {
22
+ const kind = typeof parsed.kind === 'string' && parsed.kind.length > 0 ? parsed.kind : 'phase';
23
+ return `${parsed.machine}\0${kind}\0${parsed.start}`;
24
+ }
25
+
26
+ /**
27
+ * Whether the payload is a pending open-segment heartbeat.
28
+ *
29
+ * @param {object} parsed - raw segment JSON
30
+ * @returns {boolean}
31
+ */
32
+ function isHeartbeat(parsed) {
33
+ return parsed.type === 'segment' && parsed.duration === 0;
34
+ }
35
+
36
+ /**
37
+ * Flushes a buffered pending heartbeat batch through the codec.
38
+ *
39
+ * @param {object} lane - lane state
40
+ * @param {object} codec - downstream codec
41
+ * @returns {Promise<void>}
42
+ */
43
+ async function flushLane(lane, codec) {
44
+ const batch = lane.pending;
45
+ if (!batch) {
46
+ return;
47
+ }
48
+ lane.pending = null;
49
+ if (lane.timer) {
50
+ clearTimeout(lane.timer);
51
+ lane.timer = null;
52
+ }
53
+ try {
54
+ await codec.accept({ destination: batch.destination, payload: batch.payload });
55
+ for (const settle of batch.settles) {
56
+ settle.ack();
57
+ }
58
+ } catch (error) {
59
+ for (const settle of batch.settles) {
60
+ settle.nack();
61
+ }
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Buffers or flushes one pending heartbeat on a lane.
68
+ *
69
+ * @param {object} ctx - lane context with api/size/intervalMs
70
+ * @param {object} envelope - STOMP envelope
71
+ * @param {object} parsed - parsed payload
72
+ * @returns {Promise<void>}
73
+ */
74
+ async function bufferHeartbeat(ctx, envelope, parsed) {
75
+ const key = heartbeatKey(parsed);
76
+ const { api, lane, size, intervalMs } = ctx;
77
+ if (lane.pending && lane.pending.key === key) {
78
+ lane.pending.payload = envelope.payload;
79
+ lane.pending.settles.push(envelope.settle);
80
+ } else {
81
+ await api.flush(lane);
82
+ const pending = {
83
+ key,
84
+ destination: envelope.destination,
85
+ payload: envelope.payload,
86
+ settles: [envelope.settle]
87
+ };
88
+ const timer = setTimeout(() => {
89
+ lane.queue = lane.queue.then(() => {
90
+ return api.flush(lane);
91
+ });
92
+ }, intervalMs);
93
+ Object.assign(lane, { pending, timer });
94
+ }
95
+ if (lane.pending && lane.pending.settles.length >= size) {
96
+ await api.flush(lane);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Handles one envelope on a machine lane.
102
+ *
103
+ * @param {object} ctx - lane context
104
+ * @param {object} envelope - STOMP envelope with settle
105
+ * @param {object} parsed - parsed payload
106
+ * @returns {Promise<void>}
107
+ */
108
+ async function step(ctx, envelope, parsed) {
109
+ if (isHeartbeat(parsed)) {
110
+ await bufferHeartbeat(ctx, envelope, parsed);
111
+ return;
112
+ }
113
+ await ctx.api.flush(ctx.lane);
114
+ try {
115
+ await ctx.codec.accept({ destination: envelope.destination, payload: envelope.payload });
116
+ envelope.settle.ack();
117
+ } catch (error) {
118
+ envelope.settle.nack();
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Coalesces pending segment heartbeats per machine before the codec.
125
+ *
126
+ * Expects STOMP envelopes with manual settle: `{ destination, payload, settle }`.
127
+ * Consecutive `type=segment` + `duration=0` messages that share
128
+ * `(machine, kind, start)` keep only the latest payload; all settles ack
129
+ * after one successful downstream accept. Retag, split, completed segments,
130
+ * and key changes flush immediately. Machines run on independent lanes.
131
+ *
132
+ * @param {object} codec - Downstream collector with accept({destination, payload})
133
+ * @param {object} [options] - Coalesce tuning
134
+ * @param {number} [options.size=50] - Max pending heartbeats before forced flush
135
+ * @param {number} [options.interval=2] - Flush interval in seconds
136
+ * @returns {object} Collector with accept(envelope)
137
+ *
138
+ * @example
139
+ * const coalesce = segmentCoalesce(segmentCodec(dispatch), { size: 50, interval: 2 });
140
+ * await coalesce.accept({ destination, payload, settle });
141
+ */
142
+ export default function segmentCoalesce(codec, options = {}) {
143
+ if (!codec || typeof codec.accept !== 'function') {
144
+ throw new Error('Codec must have an accept() method');
145
+ }
146
+ const size = options.size || 50;
147
+ const intervalMs = (options.interval || 2) * 1000;
148
+ const lanes = new Map();
149
+ const api = {
150
+ accept(envelope) {
151
+ const parsed = JSON.parse(envelope.payload);
152
+ const lane = laneState(lanes, parsed.machine);
153
+ const ctx = { api, lane, codec, size, intervalMs };
154
+ lane.queue = lane.queue.then(() => {
155
+ return step(ctx, envelope, parsed);
156
+ });
157
+ return lane.queue;
158
+ },
159
+ async flush(lane) {
160
+ await flushLane(lane, codec);
161
+ }
162
+ };
163
+ return api;
164
+ }
@@ -18,37 +18,37 @@ function isSilent(lastSeen, name, clk, budgetMs) {
18
18
  }
19
19
 
20
20
  /**
21
- * Stops one active poll if present.
21
+ * Stops one started poll if present. The poll instance is kept for reuse.
22
22
  *
23
- * @param {Map<string, object>} active - Active polls by name
23
+ * @param {object} state - Gate mutable state
24
24
  * @param {string} name - Stream id
25
25
  */
26
- function stopOne(active, name) {
27
- const poll = active.get(name);
28
- if (!poll) {
26
+ function stopOne(state, name) {
27
+ if (!state.live.has(name)) {
29
28
  return;
30
29
  }
31
- poll.stop();
32
- active.delete(name);
30
+ state.held.get(name).stop();
31
+ state.live.delete(name);
33
32
  }
34
33
 
35
34
  /**
36
- * Starts one poll when not already active.
35
+ * Starts one poll, opening the source only the first time.
37
36
  *
38
- * @param {Map<string, object>} active - Active polls by name
37
+ * @param {object} state - Gate mutable state
39
38
  * @param {object} source - Stream source
40
- * @param {object} collector - Metrics collector
41
- * @param {object} clk - Clock
42
- * @param {number} intervalSec - Poll interval seconds
43
39
  */
44
- function startOne(active, source, collector, clk, intervalSec) {
40
+ function startOne(state, source) {
45
41
  const name = source.name();
46
- if (active.has(name)) {
42
+ if (state.live.has(name)) {
47
43
  return;
48
44
  }
49
- const poll = source.open(collector, clk, intervalSec);
50
- active.set(name, poll);
45
+ let poll = state.held.get(name);
46
+ if (!poll) {
47
+ poll = source.open(state.collector, state.clk, state.intervalSec);
48
+ state.held.set(name, poll);
49
+ }
51
50
  poll.start();
51
+ state.live.add(name);
52
52
  }
53
53
 
54
54
  /**
@@ -63,9 +63,9 @@ function pulse(state) {
63
63
  for (const source of state.sources) {
64
64
  const name = source.name();
65
65
  if (isSilent(state.lastSeen, name, state.clk, state.budgetMs)) {
66
- startOne(state.active, source, state.collector, state.clk, state.intervalSec);
66
+ startOne(state, source);
67
67
  } else {
68
- stopOne(state.active, name);
68
+ stopOne(state, name);
69
69
  }
70
70
  }
71
71
  }
@@ -88,7 +88,8 @@ function gateState(config) {
88
88
  }),
89
89
  clear: config.clear || clearInterval,
90
90
  lastSeen: new Map(),
91
- active: new Map(),
91
+ held: new Map(),
92
+ live: new Set(),
92
93
  collector: undefined,
93
94
  timer: undefined,
94
95
  running: false
@@ -143,9 +144,10 @@ export default function silentStreams(config) {
143
144
  state.clear(state.timer);
144
145
  state.timer = undefined;
145
146
  }
146
- for (const name of [...state.active.keys()]) {
147
- stopOne(state.active, name);
147
+ for (const name of [...state.live]) {
148
+ stopOne(state, name);
148
149
  }
150
+ state.held.clear();
149
151
  },
150
152
  pulse: runPulse
151
153
  };
@@ -3,6 +3,7 @@ import {
3
3
  stompSource
4
4
  } from '@yarkivaev/source-to-sink';
5
5
  import segmentDispatch from '../../../domain/segment/dispatch.js';
6
+ import segmentCoalesce from '../../../domain/segment/segmentCoalesce.js';
6
7
  import silenceBudget from '../../../domain/segment/silenceBudget.js';
7
8
  import segmentCodec from '../codecs/segmentCodec.js';
8
9
  import silentOpenWatch from '../silentOpenWatch.js';
@@ -19,9 +20,27 @@ export const segmentsIngestDestination = '/queue/scada.segments.ingest';
19
20
 
20
21
  export { default as segmentDispatch } from '../../../domain/segment/dispatch.js';
21
22
 
23
+ /**
24
+ * Builds segment and split postgres sinks that share one pool.
25
+ *
26
+ * @param {string} postgres - PostgreSQL URL
27
+ * @param {object} pool - shared pg pool
28
+ * @returns {{ segmentSink: object, splitSink: object }}
29
+ */
30
+ function segmentSinks(postgres, pool) {
31
+ const shared = { pool, conflict: segmentConflict };
32
+ return {
33
+ segmentSink: postgresSink(postgres, 'segments', segmentColumns,
34
+ { ...shared, update: segmentUpdateColumns }),
35
+ splitSink: postgresSink(postgres, 'segments', segmentColumns,
36
+ { ...shared, update: splitUpdateColumns })
37
+ };
38
+ }
39
+
22
40
  /**
23
41
  * Pipeline for streaming STOMP segment data to PostgreSQL segments table.
24
42
  * Subscribes to the durable ingest queue so shovel traffic survives consumer gaps.
43
+ * Pending heartbeats are coalesced per machine; machines run in parallel lanes.
25
44
  *
26
45
  * @param {string} stomp - STOMP broker URL
27
46
  * @param {string} postgres - PostgreSQL connection URL
@@ -30,21 +49,24 @@ export { default as segmentDispatch } from '../../../domain/segment/dispatch.js'
30
49
  * @returns {object} Pipeline with start() and stop() methods
31
50
  */
32
51
  export default function segmentPipeline(stomp, postgres, pool, config) {
33
- const segmentSink = postgresSink(postgres, 'segments', segmentColumns,
34
- { conflict: segmentConflict, update: segmentUpdateColumns });
35
- const splitSink = postgresSink(postgres, 'segments', segmentColumns,
36
- { conflict: segmentConflict, update: splitUpdateColumns });
37
- const retag = retagSink(pool);
38
- const closer = closeOrphanOpen(pool);
39
- const dispatch = segmentDispatch(segmentSink, retag, splitSink, closer);
40
- const codec = segmentCodec(dispatch);
52
+ const { segmentSink, splitSink } = segmentSinks(postgres, pool);
53
+ const dispatch = segmentDispatch(segmentSink, retagSink(pool), splitSink, closeOrphanOpen(pool), pool);
54
+ const coalesce = segmentCoalesce(segmentCodec(dispatch), {
55
+ size: config.size,
56
+ interval: config.interval
57
+ });
41
58
  const destination = config.segmentsDestination || segmentsIngestDestination;
42
- const source = stompSource(stomp, destination, codec,
43
- { login: config.login, passcode: config.passcode, host: config.host });
44
- const window = config.segmentWindow || 15;
45
- const budget = silenceBudget(window);
46
- const pollMs = (config.poll || 5) * 1000;
47
- const silence = silentOpenWatch(closeSilentOpen(pool), budget, { intervalMs: pollMs });
59
+ const source = stompSource(stomp, destination, coalesce, {
60
+ login: config.login,
61
+ passcode: config.passcode,
62
+ host: config.host,
63
+ serial: false,
64
+ manualAck: true
65
+ });
66
+ const budget = silenceBudget(config.segmentWindow || 15);
67
+ const silence = silentOpenWatch(closeSilentOpen(pool), budget, {
68
+ intervalMs: (config.poll || 5) * 1000
69
+ });
48
70
  return {
49
71
  destination,
50
72
  start() {
@@ -4,7 +4,7 @@ import processingErrorLog from '../processingErrorLog.js';
4
4
  * Closes stale open segment rows for one machine before a new open segment lands.
5
5
  *
6
6
  * @param {object} pool - pg Pool with query() method
7
- * @returns {object} closer with close(machine, startTime, kind)
7
+ * @returns {object} closer with close(machine, startTime, kind, client?)
8
8
  *
9
9
  * @example
10
10
  * const closer = closeOrphanOpen(pool);
@@ -15,10 +15,11 @@ export default function closeOrphanOpen(pool) {
15
15
  throw new Error('Pool must have a query() method');
16
16
  }
17
17
  return {
18
- async close(machine, startTime, kind) {
18
+ async close(machine, startTime, kind, client) {
19
19
  const track = kind || 'phase';
20
+ const runner = client && typeof client.query === 'function' ? client : pool;
20
21
  try {
21
- await pool.query(
22
+ await runner.query(
22
23
  `UPDATE segments
23
24
  SET end_time = start_time + interval '1 second', duration = 1
24
25
  WHERE machine = $1 AND kind = $3 AND duration = 0 AND start_time <> $2`,