@yarkivaev/scada 2.3.77 → 2.3.79

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.
@@ -0,0 +1 @@
1
+ ALTER TABLE segments ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.77",
3
+ "version": "2.3.79",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -87,7 +87,7 @@ export default function supervisorSink(env = process.env) {
87
87
  } finally {
88
88
  await adminPool.end();
89
89
  }
90
- segments.start();
90
+ await segments.start();
91
91
  decisions.start();
92
92
  alerts.start();
93
93
  if (httpServer) {
@@ -27,6 +27,7 @@ export default function segmentNormalize(parsed) {
27
27
  options: parsed.options ? JSON.stringify(parsed.options) : null,
28
28
  tags: parsed.tags ? JSON.stringify(parsed.tags) : null,
29
29
  properties: parsed.properties ? JSON.stringify(parsed.properties) : null,
30
- resolved
30
+ resolved,
31
+ updated_at: new Date().toISOString()
31
32
  };
32
33
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Pulls a later fragment of the same segment back onto the previous row
3
+ * when the uncovered gap is still inside the segmentation continuity budget.
4
+ * A supervisor restart then does not open a hole where metric samples continued.
5
+ *
6
+ * @param {number} budgetSeconds - maximum uncovered gap that stays one segment
7
+ * @returns {function} apply(record, prior) -> record
8
+ *
9
+ * @example
10
+ * const apply = segmentContinue(30);
11
+ * const row = apply(incoming, prior);
12
+ */
13
+ export default function segmentContinue(budgetSeconds) {
14
+ return function apply(record, prior) {
15
+ if (!prior || prior.name !== record.name) {
16
+ return record;
17
+ }
18
+ const priorStart = new Date(prior.start_time).toISOString();
19
+ if (priorStart === record.start_time) {
20
+ return record;
21
+ }
22
+ const gap = Date.parse(record.start_time) - Date.parse(prior.end_time);
23
+ if (gap < 0 || gap > budgetSeconds * 1000) {
24
+ return record;
25
+ }
26
+ const startMs = Date.parse(priorStart);
27
+ const endMs = Date.parse(record.end_time);
28
+ const duration = record.duration === 0 ? 0 : Math.max(1, (endMs - startMs) / 1000);
29
+ return { ...record, start_time: priorStart, duration };
30
+ };
31
+ }
@@ -6,17 +6,20 @@ import segmentDispatch from '../../../domain/segment/dispatch.js';
6
6
  import segmentCoalesce from '../../../domain/segment/segmentCoalesce.js';
7
7
  import silenceBudget from '../../../domain/segment/silenceBudget.js';
8
8
  import segmentCodec from '../codecs/segmentCodec.js';
9
+ import processingErrorLog from '../processingErrorLog.js';
9
10
  import silentOpenWatch from '../silentOpenWatch.js';
11
+ import abutSegments from '../sinks/abutSegments.js';
10
12
  import closeOrphanOpen from '../sinks/closeOrphanOpen.js';
11
13
  import closeSilentOpen from '../sinks/closeSilentOpen.js';
14
+ import continuedSegments from '../sinks/continuedSegments.js';
12
15
  import retagSink from '../sinks/retagSink.js';
13
16
 
14
17
  export const segmentColumns = ['machine', 'kind', 'name', 'start_time', 'end_time', 'duration',
15
- 'options', 'tags', 'properties', 'resolved'];
18
+ 'options', 'tags', 'properties', 'resolved', 'updated_at'];
16
19
  export const segmentConflict = ['machine', 'kind', 'start_time'];
17
- export const segmentUpdateColumns = ['name', 'end_time', 'duration', 'options', 'resolved'];
20
+ export const segmentUpdateColumns = ['name', 'end_time', 'duration', 'options', 'resolved', 'updated_at'];
18
21
  export const splitUpdateColumns = ['name', 'end_time', 'duration', 'tags', 'options', 'resolved'];
19
- export const segmentUpdateWhere = 'segments.duration = 0 OR EXCLUDED.duration > 0';
22
+ export const segmentUpdateWhere = 'segments.duration = 0 OR EXCLUDED.duration > 0 OR EXCLUDED.end_time > segments.end_time';
20
23
  export const segmentsIngestDestination = '/queue/scada.segments.ingest';
21
24
 
22
25
  export { default as segmentDispatch } from '../../../domain/segment/dispatch.js';
@@ -51,8 +54,10 @@ function segmentSinks(postgres, pool) {
51
54
  * @returns {object} Pipeline with start() and stop() methods
52
55
  */
53
56
  export default function segmentPipeline(stomp, postgres, pool, config) {
57
+ const budget = silenceBudget(config.segmentWindow || 15);
54
58
  const { segmentSink, splitSink } = segmentSinks(postgres, pool);
55
- const dispatch = segmentDispatch(segmentSink, retagSink(pool), splitSink, closeOrphanOpen(pool), pool);
59
+ const continued = continuedSegments(segmentSink, pool, budget);
60
+ const dispatch = segmentDispatch(continued, retagSink(pool), splitSink, closeOrphanOpen(pool), pool);
56
61
  const coalesce = segmentCoalesce(segmentCodec(dispatch), {
57
62
  size: config.size,
58
63
  interval: config.interval
@@ -65,15 +70,18 @@ export default function segmentPipeline(stomp, postgres, pool, config) {
65
70
  serial: false,
66
71
  manualAck: true
67
72
  });
68
- const budget = silenceBudget(config.segmentWindow || 15);
69
73
  const silence = silentOpenWatch(closeSilentOpen(pool), budget, {
70
74
  intervalMs: (config.poll || 5) * 1000
71
75
  });
72
76
  return {
73
77
  destination,
74
- start() {
78
+ async start() {
79
+ const repair = abutSegments(pool, budget);
80
+ void repair.catch((error) => {
81
+ processingErrorLog('abut_segments', error, { budgetSeconds: budget });
82
+ });
75
83
  source.start();
76
- void silence.start();
84
+ await silence.start();
77
85
  },
78
86
  stop() {
79
87
  silence.stop();
@@ -18,14 +18,27 @@ export default function silentOpenWatch(closer, budget, options) {
18
18
  throw new Error(`Budget must be a positive number: ${budget}`);
19
19
  }
20
20
  const intervalMs = options && options.intervalMs ? options.intervalMs : 5000;
21
+ const gate = { busy: false };
21
22
  let timer;
22
23
  let active = false;
24
+ function claim() {
25
+ const free = !gate.busy;
26
+ gate.busy = true;
27
+ return free;
28
+ }
29
+ function release() {
30
+ gate.busy = false;
31
+ }
23
32
  async function tick() {
33
+ if (!claim()) {
34
+ return;
35
+ }
24
36
  try {
25
37
  await closer.close(budget);
26
38
  } catch {
27
39
  /* next tick retries */
28
40
  }
41
+ release();
29
42
  }
30
43
  return {
31
44
  async start() {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Closes uncovered time between adjacent same-name segments that stayed
3
+ * inside the continuity budget. Existing restart fragments then meet on
4
+ * the timeline without deleting rows.
5
+ *
6
+ * @param {object} pool - pg pool
7
+ * @param {number} budgetSeconds - maximum gap that still counts as continuous
8
+ * @returns {Promise<void>}
9
+ *
10
+ * @example
11
+ * await abutSegments(pool, 30);
12
+ */
13
+ export default async function abutSegments(pool, budgetSeconds) {
14
+ await pool.query(
15
+ `UPDATE segments AS left_row
16
+ SET end_time = right_row.start_time,
17
+ duration = EXTRACT(EPOCH FROM (right_row.start_time - left_row.start_time))
18
+ FROM LATERAL (
19
+ SELECT next.start_time, next.name
20
+ FROM segments AS next
21
+ WHERE next.machine = left_row.machine
22
+ AND next.kind = left_row.kind
23
+ AND next.start_time > left_row.start_time
24
+ ORDER BY next.start_time
25
+ LIMIT 1
26
+ ) AS right_row
27
+ WHERE left_row.duration > 0
28
+ AND left_row.name = right_row.name
29
+ AND right_row.start_time > left_row.end_time
30
+ AND right_row.start_time - left_row.end_time <= make_interval(secs => $1)`,
31
+ [budgetSeconds]
32
+ );
33
+ }
@@ -1,7 +1,8 @@
1
1
  import processingErrorLog from '../processingErrorLog.js';
2
2
 
3
3
  /**
4
- * Closes open segment rows whose advanced end boundary is older than a wall-clock silence budget.
4
+ * Closes open segment rows that have not been written for a wall-clock silence budget.
5
+ * Freshness is updated_at, so replaying historical samples does not look idle.
5
6
  * Requires end_time > start_time so a lone Started (end equals start) is never treated as silence.
6
7
  * Does not insert a replacement Started row.
7
8
  *
@@ -27,7 +28,7 @@ export default function closeSilentOpen(pool) {
27
28
  )
28
29
  WHERE duration = 0
29
30
  AND end_time > start_time
30
- AND end_time < NOW() - make_interval(secs => $1)`,
31
+ AND updated_at < NOW() - make_interval(secs => $1)`,
31
32
  [budgetSeconds]
32
33
  );
33
34
  } catch (error) {
@@ -0,0 +1,49 @@
1
+ import segmentContinue from '../../../domain/segment/segmentContinue.js';
2
+
3
+ /**
4
+ * Loads the latest segment row visible to this write.
5
+ *
6
+ * @param {object} runner - pg client or pool
7
+ * @param {object} record - incoming segment record
8
+ * @returns {Promise<object|undefined>} latest row
9
+ */
10
+ async function latest(runner, record) {
11
+ const result = await runner.query(
12
+ `SELECT name, start_time, end_time
13
+ FROM segments
14
+ WHERE machine = $1 AND kind = $2
15
+ ORDER BY start_time DESC
16
+ LIMIT 1`,
17
+ [record.machine, record.kind || 'phase']
18
+ );
19
+ return result.rows[0];
20
+ }
21
+
22
+ /**
23
+ * Segment sink that continues the previous row across a restart
24
+ * when metric samples never left the continuity budget.
25
+ *
26
+ * @param {object} inner - postgres segment sink
27
+ * @param {object} pool - pg pool used when no transaction client is passed
28
+ * @param {number} budgetSeconds - segmentation continuity budget
29
+ * @returns {object} sink with write(records, client)
30
+ *
31
+ * @example
32
+ * const sink = continuedSegments(segmentSink, pool, 30);
33
+ * await sink.write([record], client);
34
+ */
35
+ export default function continuedSegments(inner, pool, budgetSeconds) {
36
+ const apply = segmentContinue(budgetSeconds);
37
+ return {
38
+ async write(records, client) {
39
+ const runner = client && typeof client.query === 'function' ? client : pool;
40
+ const next = await records.reduce(async (prior, record) => {
41
+ const rows = await prior;
42
+ const previous = await latest(runner, record);
43
+ rows.push(apply(record, previous));
44
+ return rows;
45
+ }, Promise.resolve([]));
46
+ return inner.write(next, client);
47
+ }
48
+ };
49
+ }