fhirsmith 0.18.2 → 1.0.0
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/ACCESSIBILITY.md +119 -0
- package/CHANGELOG.md +149 -0
- package/CONFIGURATION.md +305 -0
- package/LICENSE +29 -0
- package/README.md +29 -122
- package/cluster.js +1220 -0
- package/cluster.md +707 -0
- package/config-template.json +77 -8
- package/extension-tracker/extension-tracker-template.html +5 -8
- package/extension-tracker/extension-tracker.js +0 -2
- package/folder/folder.js +7 -6
- package/ips/analysis.js +560 -0
- package/ips/ips-template.html +5 -8
- package/ips/ips.js +132 -59
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +74 -21
- package/ips/presenter.js +1 -1
- package/ips/readme.md +132 -25
- package/ips/render.js +466 -77
- package/ips/store.js +79 -22
- package/ips/structure.js +247 -0
- package/ips/terminology.js +1 -1
- package/library/body-limit.js +22 -0
- package/library/cache-generation.js +15 -0
- package/library/cgroup-memory.js +133 -1
- package/library/cluster-counts.js +121 -0
- package/library/cluster-events.js +153 -0
- package/library/cluster-metrics.js +193 -0
- package/library/cluster-status.js +468 -0
- package/library/config-reader.js +96 -0
- package/library/daily-report.js +100 -2
- package/library/html-server.js +27 -1
- package/library/i18nsupport.js +23 -1
- package/library/keep-alive.js +18 -0
- package/library/languages.js +17 -3
- package/library/logger.js +31 -0
- package/library/memory-guard.js +428 -0
- package/library/native-memory.js +52 -0
- package/library/npm-audit.js +51 -25
- package/library/openapi-doc.js +7 -5
- package/library/package-manager.js +8 -0
- package/library/role.js +197 -0
- package/library/server-credentials.js +169 -0
- package/library/sqlite-utilities.js +59 -0
- package/library/ssrf-guard.js +100 -0
- package/library/static-response-stats.js +16 -0
- package/library/version-utilities.js +50 -0
- package/library/worker-count.js +192 -0
- package/nginx.md +30 -11
- package/npmprojector/indexer.js +18 -10
- package/package.json +11 -8
- package/packages/feed-check-page.js +117 -0
- package/packages/feed-check.js +279 -0
- package/packages/npm-check.js +57 -3
- package/packages/npm-signing.js +219 -0
- package/packages/openapi.yaml +207 -1
- package/packages/package-crawler.js +322 -41
- package/packages/packages-template.html +6 -8
- package/packages/packages.js +604 -57
- package/packages/readme.md +113 -1
- package/publisher/publisher-template.html +2 -5
- package/publisher/publisher.js +121 -110
- package/publisher/readme.md +75 -0
- package/registry/crawler.js +84 -108
- package/registry/documentation/configuration.md +34 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +5 -4
- package/root-bare-template.html +7 -4
- package/root-template.html +5 -8
- package/server.js +398 -198
- package/shl/shl.js +38 -37
- package/static/a11y/a11y-check.js +832 -0
- package/static/a11y/axe.min.js +12 -0
- package/static/assets/css/bootstrap-fhir.css +15 -15
- package/static/assets/images/flags/au.svg +18 -0
- package/static/assets/images/flags/unknown.svg +11 -0
- package/static/assets/images/flags/us.svg +63 -0
- package/static/assets/images/icon_audit.png +0 -0
- package/static/assets/images/icon_clinical.png +0 -0
- package/static/assets/images/icon_document.png +0 -0
- package/static/assets/images/icon_external.png +0 -0
- package/static/assets/images/icon_medication.png +0 -0
- package/static/assets/images/icon_organization.png +0 -0
- package/static/assets/images/icon_other.png +0 -0
- package/static/assets/images/icon_person.png +0 -0
- package/static/assets/images/icon_section.png +0 -0
- package/stats-db.js +45 -0
- package/stats.js +197 -10
- package/testing/testing-template.html +5 -8
- package/token/nginx_passport_setup.md +1 -1
- package/token/security_guide.md +1 -1
- package/token/token-template.html +2 -5
- package/token/token.js +40 -36
- package/translations/Messages.properties +19 -0
- package/tx/README.md +154 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +29 -1
- package/tx/cs/cs-api.js +284 -19
- package/tx/cs/cs-api.md +41 -4
- package/tx/cs/cs-areacode.js +1 -1
- package/tx/cs/cs-country.js +17 -5
- package/tx/cs/cs-cpt.js +33 -16
- package/tx/cs/cs-cs.js +148 -14
- package/tx/cs/cs-currency.js +1 -1
- package/tx/cs/cs-icd11.js +21 -13
- package/tx/cs/cs-icf.js +22 -3
- package/tx/cs/cs-lang.js +2 -2
- package/tx/cs/cs-loinc.js +185 -66
- package/tx/cs/cs-mimetypes.js +12 -2
- package/tx/cs/cs-ndc.js +40 -25
- package/tx/cs/cs-omop.js +54 -29
- package/tx/cs/cs-rxnorm.js +300 -196
- package/tx/cs/cs-snomed.js +608 -64
- package/tx/cs/cs-ucum.js +14 -4
- package/tx/cs/cs-unii.js +18 -14
- package/tx/cs/cs-virtual.js +210 -0
- package/tx/ecosystem-resolver.js +413 -0
- package/tx/html/cluster-dash-metrics.liquid +26 -0
- package/tx/html/cluster-home-metrics.liquid +31 -0
- package/tx/html/cluster-metrics-script.liquid +251 -0
- package/tx/html/codesystem-operations.liquid +3 -3
- package/tx/html/ecl-panel.liquid +1 -1
- package/tx/html/external-metrics.liquid +17 -0
- package/tx/html/operations-form.liquid +17 -17
- package/tx/html/search-form.liquid +11 -11
- package/tx/html/status-line.liquid +58 -0
- package/tx/html/status-panel.liquid +140 -0
- package/tx/html/status-tasks.liquid +25 -0
- package/tx/html/tx-template.html +2 -5
- package/tx/importers/atc-to-fhir.js +17 -16
- package/tx/importers/export-icd11-codesystem.js +3 -2
- package/tx/importers/import-icd11.js +4 -3
- package/tx/importers/import-loinc.module.js +118 -56
- package/tx/importers/import-ndc.module.js +56 -44
- package/tx/importers/import-rxnorm.module.js +118 -79
- package/tx/importers/import-sct.module.js +57 -58
- package/tx/importers/import-unii.module.js +37 -36
- package/tx/importers/subset-loinc.module.js +49 -48
- package/tx/importers/subset-rxnorm.module.js +29 -28
- package/tx/importers/terminal.js +18 -0
- package/tx/importers/tx-import-base.js +13 -12
- package/tx/importers/tx-import-settings.js +5 -4
- package/tx/importers/tx-import.js +53 -52
- package/tx/library/canonical-resource.js +49 -0
- package/tx/library/codesystem.js +94 -5
- package/tx/library/designations.js +45 -2
- package/tx/library/extensions.js +13 -7
- package/tx/library/implicit-valuesets.js +66 -0
- package/tx/library/renderer.js +77 -21
- package/tx/library/sqlite-utilities.js +2 -0
- package/tx/library/ucum-parsers.js +11 -1
- package/tx/library/wait-for-cache.js +43 -0
- package/tx/library.js +321 -22
- package/tx/openapi-operations.js +6 -4
- package/tx/operation-context.js +460 -99
- package/tx/params.js +25 -0
- package/tx/problems.js +3 -5
- package/tx/provider.js +278 -42
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/ecl.js +47 -6
- package/tx/sct/editions.js +39 -0
- package/tx/sct/expressions.js +10 -1
- package/tx/sct/load-config.js +107 -0
- package/tx/sct/mmap.js +109 -0
- package/tx/sct/structures.js +166 -13
- package/tx/sct/text-index.js +5 -1
- package/tx/syndication/syndication-feed.js +2 -2
- package/tx/tx-html.js +44 -8
- package/tx/tx.fhir.org.yml +8 -8
- package/tx/tx.js +282 -80
- package/tx/usage-tracker.js +39 -11
- package/tx/vs/vs-api.js +19 -0
- package/tx/vs/vs-database.js +386 -62
- package/tx/vs/vs-package.js +27 -4
- package/tx/vs/vs-vsac.js +648 -100
- package/tx/workers/batch-validate.js +37 -10
- package/tx/workers/batch.js +6 -1
- package/tx/workers/cache-control.js +15 -4
- package/tx/workers/closure.js +156 -13
- package/tx/workers/compare.js +8 -5
- package/tx/workers/expand.js +1173 -289
- package/tx/workers/metadata.js +35 -0
- package/tx/workers/search.js +53 -1
- package/tx/workers/supplement-filters.js +145 -0
- package/tx/workers/translate.js +169 -4
- package/tx/workers/validate.js +223 -119
- package/tx/workers/worker.js +123 -31
- package/tx/xversion/xv-valueset.js +7 -4
- package/utilities/submit-test-report.js +3 -1
- package/utilities/tx-stress-test.js +482 -0
- package/vcl/readme.md +37 -0
- package/vcl/vcl-parser.js +15 -0
- package/xig/readme.md +52 -0
- package/xig/xig-template.html +5 -8
- package/xig/xig.js +238 -422
- package/changelog-template.md +0 -22
- package/registry/test-runner.js +0 -237
- package/release.sh +0 -80
- package/test-scripts/repro-re2-wasm-leak.js +0 -34
- package/tsconfig.eslint.json +0 -9
- package/tx/html/dash-metrics.liquid +0 -147
- package/tx/html/home-metrics.liquid +0 -225
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tx-workers' request counts, for the master's statistics (cluster.md, "Statistics").
|
|
3
|
+
*
|
|
4
|
+
* A tx-worker has no statistics database and sends no daily report (library/role.js). Under
|
|
5
|
+
* the launcher (cluster.js), each worker sends the requests it counted since the last time
|
|
6
|
+
* (module, endpoint, operation, count, time) over its IPC channel, every minute and when it
|
|
7
|
+
* stops. The launcher sums what it has not yet delivered and passes it on to the master,
|
|
8
|
+
* which adds it to its own counters (ServerStats.mergeCounts). The master's statistics
|
|
9
|
+
* database, and the daily report built from it, then count every process, each request once:
|
|
10
|
+
* a worker's counts are taken when sent, and only the master writes them.
|
|
11
|
+
*
|
|
12
|
+
* Without the launcher (processes started by hand behind nginx) there is no IPC channel, and
|
|
13
|
+
* a worker's requests are not in the master's statistics.
|
|
14
|
+
*/
|
|
15
|
+
const COUNTS = 'fhirsmith:counts';
|
|
16
|
+
// how often a worker sends its request counts
|
|
17
|
+
const COUNTS_INTERVAL_MS = 60 * 1000;
|
|
18
|
+
// how long a stopping worker waits for its last counts to be sent
|
|
19
|
+
const COUNTS_FLUSH_MS = 2000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Attach a process with a role to the launcher's IPC channel. A process started without the
|
|
23
|
+
* launcher has none, and this does nothing.
|
|
24
|
+
* @param {Object} stats - the ServerStats
|
|
25
|
+
* @param {Object} [channel] - process, or a stand-in (tests)
|
|
26
|
+
* @param {Object} [options]
|
|
27
|
+
* @param {string} [options.role] - 'master' takes the workers' counts; 'tx-worker' sends its own
|
|
28
|
+
* @param {number} [options.intervalMs]
|
|
29
|
+
* @returns {Function} disconnect; its flush() sends a worker's last counts (on shutdown)
|
|
30
|
+
*/
|
|
31
|
+
function connectCounts(stats, channel = process, { role = null, intervalMs = COUNTS_INTERVAL_MS } = {}) {
|
|
32
|
+
if (typeof channel.send !== 'function') {
|
|
33
|
+
const none = () => {};
|
|
34
|
+
none.flush = async () => {};
|
|
35
|
+
return none;
|
|
36
|
+
}
|
|
37
|
+
// A worker's counts are taken only when sent, one lot at a time; a lot that can't be sent
|
|
38
|
+
// is put back, to go with the next
|
|
39
|
+
let inFlight = null;
|
|
40
|
+
const send = () => {
|
|
41
|
+
if (inFlight) return inFlight;
|
|
42
|
+
if (role !== 'tx-worker' || channel.connected === false) return Promise.resolve();
|
|
43
|
+
const rows = stats.takeCounts();
|
|
44
|
+
if (rows.length === 0) return Promise.resolve();
|
|
45
|
+
inFlight = new Promise(resolve => {
|
|
46
|
+
const done = error => {
|
|
47
|
+
if (error) stats.restoreCounts(rows);
|
|
48
|
+
inFlight = null;
|
|
49
|
+
resolve();
|
|
50
|
+
};
|
|
51
|
+
try {
|
|
52
|
+
channel.send({ type: COUNTS, rows }, done);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
done(error);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return inFlight;
|
|
58
|
+
};
|
|
59
|
+
const timer = role === 'tx-worker' ? setInterval(send, intervalMs) : null;
|
|
60
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
61
|
+
|
|
62
|
+
const receive = message => {
|
|
63
|
+
if (role === 'master' && message && message.type === COUNTS) {
|
|
64
|
+
stats.mergeCounts(message.rows);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
channel.on('message', receive);
|
|
68
|
+
|
|
69
|
+
const disconnect = () => {
|
|
70
|
+
if (timer) clearInterval(timer);
|
|
71
|
+
channel.removeListener('message', receive);
|
|
72
|
+
};
|
|
73
|
+
disconnect.flush = async () => {
|
|
74
|
+
let wait;
|
|
75
|
+
const timeout = new Promise(resolve => { wait = setTimeout(resolve, COUNTS_FLUSH_MS); });
|
|
76
|
+
try {
|
|
77
|
+
// the lot in flight, then whatever was counted since
|
|
78
|
+
await Promise.race([(async () => { await inFlight; await send(); })(), timeout]);
|
|
79
|
+
} finally {
|
|
80
|
+
clearTimeout(wait);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
return disconnect;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The launcher's side: what the workers sent and the master hasn't had yet, summed by
|
|
88
|
+
* (module, endpoint, operation).
|
|
89
|
+
*/
|
|
90
|
+
class PendingCounts {
|
|
91
|
+
constructor() {
|
|
92
|
+
this.rows = new Map();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
add(rows) {
|
|
96
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
97
|
+
if (!row || !Number.isInteger(row.count) || row.count <= 0 || !Number.isFinite(row.time)) continue;
|
|
98
|
+
const key = `${row.module}\u001f${row.endpoint}\u001f${row.operation}`;
|
|
99
|
+
const sum = this.rows.get(key);
|
|
100
|
+
if (sum) {
|
|
101
|
+
sum.count += row.count;
|
|
102
|
+
sum.time += row.time;
|
|
103
|
+
} else {
|
|
104
|
+
this.rows.set(key, { module: row.module, endpoint: row.endpoint, operation: row.operation, count: row.count, time: row.time });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Everything pending, now no longer pending (add it back if it can't be delivered). */
|
|
110
|
+
take() {
|
|
111
|
+
const rows = [...this.rows.values()];
|
|
112
|
+
this.rows.clear();
|
|
113
|
+
return rows;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get size() {
|
|
117
|
+
return this.rows.size;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { connectCounts, PendingCounts, COUNTS, COUNTS_INTERVAL_MS };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the launcher (cluster.js) saw, for the master's daily report (cluster.md, "The daily
|
|
3
|
+
* report").
|
|
4
|
+
*
|
|
5
|
+
* Only the launcher knows when a process exited, was recycled or came back, and what it
|
|
6
|
+
* refused or abandoned itself. It keeps those as rows: an event (a process exited, was
|
|
7
|
+
* recycled, was ready again after a time unavailable; the launcher started) is a row of its
|
|
8
|
+
* own; what the launcher answered itself (503, 504, 502, a cache-id lost to a restart, a
|
|
9
|
+
* client that took none of a response) is counted, one row per kind and process per lot. It
|
|
10
|
+
* passes them to the master over its IPC channel, once the master is ready, as it passes the
|
|
11
|
+
* workers' request counts, and the master writes them to its statistics database
|
|
12
|
+
* (ServerStats.recordClusterEvents), where the daily report reads them.
|
|
13
|
+
*
|
|
14
|
+
* A row is {time, kind, process, detail, count, ms}:
|
|
15
|
+
* - time: when it happened (a counted row: the last time in the lot)
|
|
16
|
+
* - kind: one of KINDS
|
|
17
|
+
* - process: the process's name ('master', 'worker b'), or null for the launcher itself
|
|
18
|
+
* - detail: the exit code or signal, or why a process was recycled; otherwise null
|
|
19
|
+
* - count: 1 for an event; how many for a counted row
|
|
20
|
+
* - ms: for 'ready', how long the process had been unavailable; otherwise 0
|
|
21
|
+
*/
|
|
22
|
+
const CLUSTER_EVENTS = 'fhirsmith:cluster-events';
|
|
23
|
+
|
|
24
|
+
const KINDS = {
|
|
25
|
+
// events
|
|
26
|
+
launcherStarted: 'launcher-started',
|
|
27
|
+
exited: 'exited',
|
|
28
|
+
recycled: 'recycled',
|
|
29
|
+
ready: 'ready',
|
|
30
|
+
// counted: what the launcher answered itself
|
|
31
|
+
busy: 'refused-busy',
|
|
32
|
+
stopping: 'refused-stopping',
|
|
33
|
+
notReady: 'refused-not-ready',
|
|
34
|
+
lostToRestart: 'lost-to-restart',
|
|
35
|
+
timeout: 'upstream-timeout',
|
|
36
|
+
failed: 'upstream-failed',
|
|
37
|
+
writeTimeout: 'write-timeout'
|
|
38
|
+
};
|
|
39
|
+
const KNOWN = new Set(Object.values(KINDS));
|
|
40
|
+
// kept one by one; the rest are counted
|
|
41
|
+
const EVENT_KINDS = new Set([KINDS.launcherStarted, KINDS.exited, KINDS.recycled, KINDS.ready]);
|
|
42
|
+
// events kept while the master can't take them (it is down, or not ready yet); the oldest go
|
|
43
|
+
// first. Counted rows are one per kind and process, so they don't grow
|
|
44
|
+
const MAX_PENDING = 5000;
|
|
45
|
+
const MAX_DETAIL = 500;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A row as the master takes it, or null if it isn't one.
|
|
49
|
+
*/
|
|
50
|
+
function validRow(row) {
|
|
51
|
+
if (!row || !KNOWN.has(row.kind) || !Number.isFinite(row.time) || !Number.isInteger(row.count) || row.count <= 0) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const text = (v) => (typeof v === 'string' && v ? v.slice(0, MAX_DETAIL) : null);
|
|
55
|
+
return {
|
|
56
|
+
time: Math.round(row.time),
|
|
57
|
+
kind: row.kind,
|
|
58
|
+
process: text(row.process),
|
|
59
|
+
detail: text(row.detail),
|
|
60
|
+
count: row.count,
|
|
61
|
+
ms: Number.isFinite(row.ms) && row.ms > 0 ? Math.round(row.ms) : 0
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The launcher's side: what hasn't reached the master yet. Shaped like PendingCounts
|
|
67
|
+
* (library/cluster-counts.js), so the launcher delivers both the same way.
|
|
68
|
+
*/
|
|
69
|
+
class PendingClusterEvents {
|
|
70
|
+
constructor(now = Date.now) {
|
|
71
|
+
this.now = now;
|
|
72
|
+
this.events = [];
|
|
73
|
+
this.counted = new Map();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Something that happened once: a process exited, was recycled, was ready again. */
|
|
77
|
+
event(kind, process = null, { detail = null, ms = 0 } = {}) {
|
|
78
|
+
this.events.push({ time: this.now(), kind, process, detail: detail == null ? null : String(detail), count: 1, ms });
|
|
79
|
+
if (this.events.length > MAX_PENDING) {
|
|
80
|
+
this.events.splice(0, this.events.length - MAX_PENDING);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Something the launcher answered itself: counted, not kept one by one. */
|
|
85
|
+
count(kind, process = null) {
|
|
86
|
+
const key = `${kind}\u001f${process || ''}`;
|
|
87
|
+
let row = this.counted.get(key);
|
|
88
|
+
if (!row) {
|
|
89
|
+
row = { time: 0, kind, process, detail: null, count: 0, ms: 0 };
|
|
90
|
+
this.counted.set(key, row);
|
|
91
|
+
}
|
|
92
|
+
row.count++;
|
|
93
|
+
row.time = this.now();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Put back a lot that couldn't be delivered. */
|
|
97
|
+
add(rows) {
|
|
98
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
99
|
+
if (!EVENT_KINDS.has(row.kind)) {
|
|
100
|
+
const key = `${row.kind}\u001f${row.process || ''}`;
|
|
101
|
+
const sum = this.counted.get(key);
|
|
102
|
+
if (sum) {
|
|
103
|
+
sum.count += row.count;
|
|
104
|
+
sum.time = Math.max(sum.time, row.time);
|
|
105
|
+
} else {
|
|
106
|
+
this.counted.set(key, { ...row });
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
this.events.push(row);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
this.events.sort((a, b) => a.time - b.time);
|
|
113
|
+
if (this.events.length > MAX_PENDING) {
|
|
114
|
+
this.events.splice(0, this.events.length - MAX_PENDING);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Everything pending, now no longer pending (add it back if it can't be delivered). */
|
|
119
|
+
take() {
|
|
120
|
+
const rows = [...this.events, ...this.counted.values()];
|
|
121
|
+
this.events = [];
|
|
122
|
+
this.counted.clear();
|
|
123
|
+
return rows;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get size() {
|
|
127
|
+
return this.events.length + this.counted.size;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The master's side: take what the launcher sends, into the statistics database. A process
|
|
133
|
+
* without the launcher, or that isn't the master, does nothing.
|
|
134
|
+
* @param {Object} stats - the ServerStats
|
|
135
|
+
* @param {Object} [channel] - process, or a stand-in (tests)
|
|
136
|
+
* @param {Object} [options]
|
|
137
|
+
* @param {string} [options.role]
|
|
138
|
+
* @returns {Function} disconnect
|
|
139
|
+
*/
|
|
140
|
+
function connectClusterEvents(stats, channel = process, { role = null } = {}) {
|
|
141
|
+
if (role !== 'master' || typeof channel.send !== 'function') {
|
|
142
|
+
return () => {};
|
|
143
|
+
}
|
|
144
|
+
const receive = message => {
|
|
145
|
+
if (message && message.type === CLUSTER_EVENTS) {
|
|
146
|
+
stats.recordClusterEvents(message.rows);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
channel.on('message', receive);
|
|
150
|
+
return () => channel.removeListener('message', receive);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { CLUSTER_EVENTS, KINDS, EVENT_KINDS, PendingClusterEvents, connectClusterEvents, validRow };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The launcher's small, in-memory view of each server's existing graph samples.
|
|
3
|
+
* No request counters are collected by the proxy and no averages are combined.
|
|
4
|
+
* Histories live until the launcher stops, so a child restart leaves a visible
|
|
5
|
+
* gap instead of erasing the worker's earlier samples. The one figure the launcher
|
|
6
|
+
* adds is the memory graph's Total: the cgroup's private memory (or, without a cgroup
|
|
7
|
+
* of the service's own, the processes' summed), read when the master's sample arrives.
|
|
8
|
+
*/
|
|
9
|
+
const METRIC = 'fhirsmith:metric';
|
|
10
|
+
const SNAPSHOT = 'fhirsmith:metrics';
|
|
11
|
+
const HISTORY_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
const MAX_POINTS = 1440;
|
|
13
|
+
const FIELDS = ['mem', 'anon', 'rpm', 'tat', 'block', 'blockMax', 'clientConcepts', 'refused', 'intervalStart', 'ext', 'extTat'];
|
|
14
|
+
|
|
15
|
+
class ClusterMetrics {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object[]} procs - code, role, servesTx
|
|
18
|
+
* @param {Object} [options] - rejectAt (the memory guard's refusal threshold, bytes), group
|
|
19
|
+
* (whether the guard measures the cgroup, which the Total then is), sharedCache
|
|
20
|
+
*/
|
|
21
|
+
constructor(procs, { rejectAt = null, group = false, sharedCache = false } = {}) {
|
|
22
|
+
this.rejectAt = rejectAt;
|
|
23
|
+
this.group = group;
|
|
24
|
+
this.sharedCache = sharedCache;
|
|
25
|
+
this.total = [];
|
|
26
|
+
this.series = new Map(procs.map(p => [p.code, {
|
|
27
|
+
id: p.code, label: `${p.role === 'master' ? 'Master' : 'Worker'} ${p.code}`,
|
|
28
|
+
role: p.role, servesTx: p.servesTx, status: 'starting', restarts: 0,
|
|
29
|
+
startedAt: null, intervalMs: null, history: []
|
|
30
|
+
}]));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The memory graph's Total at a time (null: not known, a gap). */
|
|
34
|
+
recordTotal(time, anon, now = Date.now()) {
|
|
35
|
+
const last = this.total[this.total.length - 1];
|
|
36
|
+
if (!Number.isFinite(time) || (last && time <= last.time)) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.total.push({ time, anon: Number.isFinite(anon) ? anon : null });
|
|
40
|
+
this.prune(this, now, 'total');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
prune(series, now, field = 'history') {
|
|
44
|
+
const cutoff = now - HISTORY_MS;
|
|
45
|
+
series[field] = series[field].filter(point => point.time > cutoff).slice(-MAX_POINTS);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
state(code, status, now = Date.now()) {
|
|
49
|
+
const series = this.series.get(code);
|
|
50
|
+
if (!series) return;
|
|
51
|
+
if (status === 'restarting' && series.status !== 'restarting') {
|
|
52
|
+
series.restarts++;
|
|
53
|
+
if (series.history.length) {
|
|
54
|
+
// Chart.js must not connect the last sample to the replacement process.
|
|
55
|
+
series.history.push({ time: now, ...Object.fromEntries(FIELDS.map(field => [field, null])) });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
series.status = status;
|
|
59
|
+
this.prune(series, now);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
record(code, message, now = Date.now()) {
|
|
63
|
+
const series = this.series.get(code);
|
|
64
|
+
const point = message && message.point;
|
|
65
|
+
if (!series || !point || !Number.isFinite(point.time) ||
|
|
66
|
+
!Number.isFinite(message.startedAt) || !Number.isFinite(message.intervalMs) || message.intervalMs <= 0) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
// Reject malformed or out-of-order samples; a delayed old child is also
|
|
70
|
+
// rejected by the launcher before it reaches this store.
|
|
71
|
+
const last = series.history[series.history.length - 1];
|
|
72
|
+
if (point.time < now - HISTORY_MS || point.time > now + 60000 || (last && point.time <= last.time)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const clean = { time: point.time };
|
|
76
|
+
for (const field of FIELDS) {
|
|
77
|
+
clean[field] = Number.isFinite(point[field]) ? point[field] : null;
|
|
78
|
+
}
|
|
79
|
+
series.startedAt = message.startedAt;
|
|
80
|
+
series.intervalMs = message.intervalMs;
|
|
81
|
+
if (last && point.time - last.time > 1.5 * message.intervalMs && FIELDS.some(field => last[field] !== null)) {
|
|
82
|
+
series.history.push({ time: last.time + message.intervalMs, ...Object.fromEntries(FIELDS.map(field => [field, null])) });
|
|
83
|
+
}
|
|
84
|
+
series.history.push(clean);
|
|
85
|
+
this.prune(series, now);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
snapshot(now = Date.now()) {
|
|
90
|
+
this.prune(this, now, 'total');
|
|
91
|
+
return {
|
|
92
|
+
cluster: true, sampledAt: now, sharedCache: this.sharedCache,
|
|
93
|
+
total: { group: this.group, rejectAt: this.rejectAt, history: this.total.map(point => ({ ...point })) },
|
|
94
|
+
series: [...this.series.values()].map(series => {
|
|
95
|
+
this.prune(series, now);
|
|
96
|
+
const last = series.history[series.history.length - 1];
|
|
97
|
+
const stale = series.status === 'ready' && (!last || now - last.time > Math.max(60000, 2 * series.intervalMs));
|
|
98
|
+
return { ...series, status: stale ? 'no recent sample' : series.status,
|
|
99
|
+
history: series.history.map(point => ({ ...point })) };
|
|
100
|
+
})
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Attach only to a launcher-created IPC channel; standalone servers stay local. */
|
|
106
|
+
function connectMetrics(stats, channel = process) {
|
|
107
|
+
if (typeof channel.send !== 'function') return () => {};
|
|
108
|
+
let sending = false;
|
|
109
|
+
const publish = point => {
|
|
110
|
+
// Statistics must never block request work or build an IPC backlog.
|
|
111
|
+
if (channel.connected === false || sending) return;
|
|
112
|
+
sending = true;
|
|
113
|
+
try {
|
|
114
|
+
channel.send({ type: METRIC, point, startedAt: stats.startTime, intervalMs: stats.intervalMs }, () => {
|
|
115
|
+
sending = false;
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
sending = false;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const receive = message => {
|
|
122
|
+
if (message && message.type === SNAPSHOT && Array.isArray(message.series)) {
|
|
123
|
+
stats.clusterMetrics = { cluster: true, sampledAt: message.sampledAt, sharedCache: message.sharedCache === true,
|
|
124
|
+
total: message.total || null, series: message.series };
|
|
125
|
+
stats.clusterMetricsUnavailable = false;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const disconnected = () => { stats.clusterMetricsUnavailable = true; };
|
|
129
|
+
stats.onMetric = publish;
|
|
130
|
+
channel.on('message', receive);
|
|
131
|
+
channel.on('disconnect', disconnected);
|
|
132
|
+
return () => {
|
|
133
|
+
if (stats.onMetric === publish) stats.onMetric = null;
|
|
134
|
+
channel.removeListener('message', receive);
|
|
135
|
+
channel.removeListener('disconnect', disconnected);
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Serialize only graph data, safely inside an inline script in either page. Without the
|
|
141
|
+
* launcher's snapshot (no role, a worker's own page, a master before its first), this process's
|
|
142
|
+
* own samples as one series, with its memory guard's refusal threshold (guard: rejectAt, group:
|
|
143
|
+
* whether the guard measures the cgroup) and no Total.
|
|
144
|
+
*/
|
|
145
|
+
function graphData(stats, label = 'Server', now = Date.now(), guard = {}) {
|
|
146
|
+
return JSON.stringify(graphSeries(stats, label, now, guard)).replace(/</g, '\\u003c');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The graphs' data (as graphData, not serialized): the launcher's snapshot, or this process's
|
|
151
|
+
* own samples as one series
|
|
152
|
+
*/
|
|
153
|
+
function graphSeries(stats, label = 'Server', now = Date.now(), guard = {}) {
|
|
154
|
+
const data = stats.clusterMetrics || {
|
|
155
|
+
cluster: false, sampledAt: now,
|
|
156
|
+
total: Number.isFinite(guard.rejectAt) && guard.rejectAt > 0
|
|
157
|
+
? { group: Boolean(guard.group), rejectAt: guard.rejectAt, history: [], local: true } : null,
|
|
158
|
+
series: [{ id: 'local', label, status: 'ready', restarts: 0, startedAt: stats.startTime,
|
|
159
|
+
intervalMs: stats.intervalMs, history: stats.history }]
|
|
160
|
+
};
|
|
161
|
+
const series = data.series.map(item => {
|
|
162
|
+
const last = item.history[item.history.length - 1];
|
|
163
|
+
const stale = item.status === 'ready' && (!last || now - last.time > Math.max(60000, 2 * item.intervalMs));
|
|
164
|
+
return { ...item, status: data.cluster && stats.clusterMetricsUnavailable ? 'unavailable' : stale ? 'no recent sample' : item.status };
|
|
165
|
+
});
|
|
166
|
+
return { ...data, series };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The external requests (those that had to ask another server: ServerStats.countExternalRequest)
|
|
171
|
+
* in the graphs' samples - every process's, on the master under the launcher: {count, average
|
|
172
|
+
* (ms), hours (how far back the samples go)}, or null if there were none
|
|
173
|
+
*/
|
|
174
|
+
function externalSummary(stats, now = Date.now()) {
|
|
175
|
+
let count = 0;
|
|
176
|
+
let time = 0;
|
|
177
|
+
let earliest = now;
|
|
178
|
+
for (const series of graphSeries(stats, 'external', now).series || []) {
|
|
179
|
+
for (const point of series.history || []) {
|
|
180
|
+
const from = Number.isFinite(point.intervalStart) ? point.intervalStart : point.time;
|
|
181
|
+
if (Number.isFinite(from)) {
|
|
182
|
+
earliest = Math.min(earliest, from);
|
|
183
|
+
}
|
|
184
|
+
if (point.ext > 0 && Number.isFinite(point.extTat)) {
|
|
185
|
+
count += point.ext;
|
|
186
|
+
time += point.ext * point.extTat;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return count > 0 ? { count, average: time / count, hours: (now - earliest) / 3600000 } : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { ClusterMetrics, connectMetrics, graphData, graphSeries, externalSummary, METRIC, SNAPSHOT, HISTORY_MS, MAX_POINTS };
|