@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.
- package/db/migrations/V0008__segments_updated_at.sql +1 -0
- package/package.json +1 -1
- package/src/application/supervisorSink.js +4 -3
- package/src/domain/segment/normalize.js +2 -1
- package/src/domain/segment/segmentContinue.js +31 -0
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +11 -7
- package/src/infrastructure/ingest/sinks/abutSegments.js +34 -0
- package/src/infrastructure/ingest/sinks/closeSilentOpen.js +3 -2
- package/src/infrastructure/ingest/sinks/continuedSegments.js +49 -0
- package/src/infrastructure/persistence/postgresPool.js +20 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE segments ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|