@yarkivaev/scada 2.3.76 → 2.3.78

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.76",
3
+ "version": "2.3.78",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -7,6 +7,7 @@ import migrate, { migratePrivileged } from '../infrastructure/ingest/db/migrate.
7
7
  import stateDataFromPool from '../infrastructure/persistence/stateDataFromPool.js';
8
8
  import { parseRequestTimeoutMs } from '../infrastructure/ingest/parseRequestTimeoutMs.js';
9
9
  import pg from 'pg';
10
+ import { holdClient } from '../infrastructure/persistence/postgresPool.js';
10
11
  import path from 'path';
11
12
  import { fileURLToPath } from 'url';
12
13
 
@@ -59,7 +60,7 @@ export function readDeploymentConfig(env) {
59
60
  */
60
61
  export default function supervisorSink(env = process.env) {
61
62
  const cfg = readDeploymentConfig(env);
62
- const pool = new pg.Pool({ connectionString: cfg.postgres });
63
+ const pool = holdClient(new pg.Pool({ connectionString: cfg.postgres }));
63
64
  const segments = segmentPipeline(cfg.stomp, cfg.postgres, pool, cfg.pipeline);
64
65
  const decisions = decisionPipeline(cfg.stomp, pool, cfg.pipeline);
65
66
  const alerts = alertPipeline(cfg.stomp, pool, cfg.pipeline);
@@ -79,14 +80,14 @@ export default function supervisorSink(env = process.env) {
79
80
  if (!cfg.postgresAdmin) {
80
81
  throw new Error('POSTGRES_ADMIN_URL is required for supervisor-sink database migrations');
81
82
  }
82
- const adminPool = new pg.Pool({ connectionString: cfg.postgresAdmin });
83
+ const adminPool = holdClient(new pg.Pool({ connectionString: cfg.postgresAdmin }));
83
84
  try {
84
85
  await migrate(adminPool, cfg.migrationsDir, cfg.sinkDbProfile);
85
86
  await migratePrivileged(adminPool, cfg.migrationsDir, cfg.sinkDbProfile);
86
87
  } finally {
87
88
  await adminPool.end();
88
89
  }
89
- segments.start();
90
+ await segments.start();
90
91
  decisions.start();
91
92
  alerts.start();
92
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
+ }
@@ -7,16 +7,18 @@ 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
9
  import silentOpenWatch from '../silentOpenWatch.js';
10
+ import abutSegments from '../sinks/abutSegments.js';
10
11
  import closeOrphanOpen from '../sinks/closeOrphanOpen.js';
11
12
  import closeSilentOpen from '../sinks/closeSilentOpen.js';
13
+ import continuedSegments from '../sinks/continuedSegments.js';
12
14
  import retagSink from '../sinks/retagSink.js';
13
15
 
14
16
  export const segmentColumns = ['machine', 'kind', 'name', 'start_time', 'end_time', 'duration',
15
- 'options', 'tags', 'properties', 'resolved'];
17
+ 'options', 'tags', 'properties', 'resolved', 'updated_at'];
16
18
  export const segmentConflict = ['machine', 'kind', 'start_time'];
17
- export const segmentUpdateColumns = ['name', 'end_time', 'duration', 'options', 'resolved'];
19
+ export const segmentUpdateColumns = ['name', 'end_time', 'duration', 'options', 'resolved', 'updated_at'];
18
20
  export const splitUpdateColumns = ['name', 'end_time', 'duration', 'tags', 'options', 'resolved'];
19
- export const segmentUpdateWhere = 'segments.duration = 0 OR EXCLUDED.duration > 0';
21
+ export const segmentUpdateWhere = 'segments.duration = 0 OR EXCLUDED.duration > 0 OR EXCLUDED.end_time > segments.end_time';
20
22
  export const segmentsIngestDestination = '/queue/scada.segments.ingest';
21
23
 
22
24
  export { default as segmentDispatch } from '../../../domain/segment/dispatch.js';
@@ -51,8 +53,10 @@ function segmentSinks(postgres, pool) {
51
53
  * @returns {object} Pipeline with start() and stop() methods
52
54
  */
53
55
  export default function segmentPipeline(stomp, postgres, pool, config) {
56
+ const budget = silenceBudget(config.segmentWindow || 15);
54
57
  const { segmentSink, splitSink } = segmentSinks(postgres, pool);
55
- const dispatch = segmentDispatch(segmentSink, retagSink(pool), splitSink, closeOrphanOpen(pool), pool);
58
+ const continued = continuedSegments(segmentSink, pool, budget);
59
+ const dispatch = segmentDispatch(continued, retagSink(pool), splitSink, closeOrphanOpen(pool), pool);
56
60
  const coalesce = segmentCoalesce(segmentCodec(dispatch), {
57
61
  size: config.size,
58
62
  interval: config.interval
@@ -65,15 +69,15 @@ export default function segmentPipeline(stomp, postgres, pool, config) {
65
69
  serial: false,
66
70
  manualAck: true
67
71
  });
68
- const budget = silenceBudget(config.segmentWindow || 15);
69
72
  const silence = silentOpenWatch(closeSilentOpen(pool), budget, {
70
73
  intervalMs: (config.poll || 5) * 1000
71
74
  });
72
75
  return {
73
76
  destination,
74
- start() {
77
+ async start() {
78
+ await abutSegments(pool, budget);
75
79
  source.start();
76
- void silence.start();
80
+ await silence.start();
77
81
  },
78
82
  stop() {
79
83
  silence.stop();
@@ -0,0 +1,34 @@
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 segments AS right_row
19
+ WHERE left_row.machine = right_row.machine
20
+ AND left_row.kind = right_row.kind
21
+ AND left_row.name = right_row.name
22
+ AND left_row.duration > 0
23
+ AND right_row.start_time > left_row.end_time
24
+ AND right_row.start_time - left_row.end_time <= make_interval(secs => $1)
25
+ AND right_row.start_time = (
26
+ SELECT MIN(next.start_time)
27
+ FROM segments AS next
28
+ WHERE next.machine = left_row.machine
29
+ AND next.kind = left_row.kind
30
+ AND next.start_time > left_row.start_time
31
+ )`,
32
+ [budgetSeconds]
33
+ );
34
+ }
@@ -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
+ }
@@ -1,5 +1,24 @@
1
1
  import pg from 'pg';
2
2
 
3
+ /**
4
+ * Logs an idle postgres client failure and leaves the pool open.
5
+ * node-postgres emits `error` when an idle socket dies. With no listener
6
+ * the process exits.
7
+ *
8
+ * @param {import('pg').Pool} pool - long-lived pool
9
+ * @returns {import('pg').Pool} the same pool
10
+ *
11
+ * @example
12
+ * holdClient(new pg.Pool({ connectionString: process.env.POSTGRES_URL }));
13
+ */
14
+ export function holdClient(pool) {
15
+ const host = pool.options && pool.options.host ? pool.options.host : 'postgres';
16
+ pool.on('error', (err) => {
17
+ process.stderr.write(`[scada] postgres idle client failed host=${host} message=${err.message}\n`);
18
+ });
19
+ return pool;
20
+ }
21
+
3
22
  /**
4
23
  * Creates a PostgreSQL pool for supervisor state reads.
5
24
  *
@@ -20,5 +39,5 @@ export default function postgresPool(options = {}) {
20
39
  if (!url) {
21
40
  return undefined;
22
41
  }
23
- return new pg.Pool({ connectionString: url });
42
+ return holdClient(new pg.Pool({ connectionString: url }));
24
43
  }