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