fhirsmith 0.18.1 → 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.
Files changed (214) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +190 -9
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +331 -33
  15. package/ips/jurisdictions.js +65 -0
  16. package/ips/nationality.js +120 -0
  17. package/ips/pipeline.js +131 -17
  18. package/ips/presenter.js +1 -1
  19. package/ips/profile-editor.js +283 -4
  20. package/ips/readme.md +170 -20
  21. package/ips/render.js +769 -109
  22. package/ips/scripts.js +46 -0
  23. package/ips/store.js +123 -11
  24. package/ips/structure.js +247 -0
  25. package/ips/terminology.js +1 -1
  26. package/library/body-limit.js +22 -0
  27. package/library/cache-generation.js +15 -0
  28. package/library/cgroup-memory.js +133 -1
  29. package/library/cluster-counts.js +121 -0
  30. package/library/cluster-events.js +153 -0
  31. package/library/cluster-metrics.js +193 -0
  32. package/library/cluster-status.js +468 -0
  33. package/library/config-reader.js +96 -0
  34. package/library/daily-report.js +100 -2
  35. package/library/html-server.js +27 -1
  36. package/library/i18nsupport.js +23 -1
  37. package/library/keep-alive.js +18 -0
  38. package/library/languages.js +17 -3
  39. package/library/logger.js +31 -0
  40. package/library/memory-guard.js +428 -0
  41. package/library/native-memory.js +52 -0
  42. package/library/npm-audit.js +51 -25
  43. package/library/openapi-doc.js +7 -5
  44. package/library/package-manager.js +8 -0
  45. package/library/role.js +197 -0
  46. package/library/server-credentials.js +169 -0
  47. package/library/sqlite-utilities.js +59 -0
  48. package/library/ssrf-guard.js +100 -0
  49. package/library/static-response-stats.js +16 -0
  50. package/library/version-utilities.js +50 -0
  51. package/library/worker-count.js +192 -0
  52. package/nginx.md +30 -11
  53. package/npmprojector/indexer.js +18 -10
  54. package/package.json +11 -8
  55. package/packages/feed-check-page.js +117 -0
  56. package/packages/feed-check.js +279 -0
  57. package/packages/npm-check.js +57 -3
  58. package/packages/npm-signing.js +219 -0
  59. package/packages/openapi.yaml +207 -1
  60. package/packages/package-crawler.js +322 -41
  61. package/packages/packages-template.html +6 -8
  62. package/packages/packages.js +604 -57
  63. package/packages/readme.md +113 -1
  64. package/publisher/publisher-template.html +2 -5
  65. package/publisher/publisher.js +121 -110
  66. package/publisher/readme.md +75 -0
  67. package/registry/api.js +179 -1
  68. package/registry/crawler.js +86 -108
  69. package/registry/documentation/api.md +68 -2
  70. package/registry/documentation/configuration.md +34 -0
  71. package/registry/documentation/internals.md +4 -1
  72. package/registry/documentation/readme.md +3 -2
  73. package/registry/model.js +8 -0
  74. package/registry/openapi.yaml +84 -0
  75. package/registry/registry-template.html +5 -8
  76. package/registry/registry.js +58 -6
  77. package/root-bare-template.html +7 -4
  78. package/root-template.html +5 -8
  79. package/server.js +398 -198
  80. package/shl/shl.js +38 -37
  81. package/static/a11y/a11y-check.js +832 -0
  82. package/static/a11y/axe.min.js +12 -0
  83. package/static/assets/css/bootstrap-fhir.css +15 -15
  84. package/static/assets/images/flags/au.svg +18 -0
  85. package/static/assets/images/flags/unknown.svg +11 -0
  86. package/static/assets/images/flags/us.svg +63 -0
  87. package/static/assets/images/icon_audit.png +0 -0
  88. package/static/assets/images/icon_clinical.png +0 -0
  89. package/static/assets/images/icon_document.png +0 -0
  90. package/static/assets/images/icon_external.png +0 -0
  91. package/static/assets/images/icon_medication.png +0 -0
  92. package/static/assets/images/icon_organization.png +0 -0
  93. package/static/assets/images/icon_other.png +0 -0
  94. package/static/assets/images/icon_person.png +0 -0
  95. package/static/assets/images/icon_section.png +0 -0
  96. package/stats-db.js +45 -0
  97. package/stats.js +197 -10
  98. package/testing/testing-template.html +5 -8
  99. package/token/nginx_passport_setup.md +1 -1
  100. package/token/security_guide.md +1 -1
  101. package/token/token-template.html +2 -5
  102. package/token/token.js +40 -36
  103. package/translations/Messages.properties +19 -0
  104. package/tx/README.md +158 -4
  105. package/tx/closure/closure-store.js +126 -3
  106. package/tx/cm/cm-database.js +69 -57
  107. package/tx/cm/cm-package.js +50 -16
  108. package/tx/cs/cs-api.js +284 -19
  109. package/tx/cs/cs-api.md +41 -4
  110. package/tx/cs/cs-areacode.js +1 -1
  111. package/tx/cs/cs-country.js +17 -5
  112. package/tx/cs/cs-cpt.js +33 -16
  113. package/tx/cs/cs-cs.js +199 -24
  114. package/tx/cs/cs-currency.js +1 -1
  115. package/tx/cs/cs-icd11.js +21 -13
  116. package/tx/cs/cs-icf.js +22 -3
  117. package/tx/cs/cs-lang.js +2 -2
  118. package/tx/cs/cs-loinc.js +193 -67
  119. package/tx/cs/cs-mimetypes.js +12 -2
  120. package/tx/cs/cs-ndc.js +40 -25
  121. package/tx/cs/cs-omop.js +54 -29
  122. package/tx/cs/cs-rxnorm.js +300 -196
  123. package/tx/cs/cs-snomed.js +861 -95
  124. package/tx/cs/cs-ucum.js +14 -4
  125. package/tx/cs/cs-unii.js +18 -14
  126. package/tx/cs/cs-virtual.js +210 -0
  127. package/tx/ecosystem-resolver.js +413 -0
  128. package/tx/html/cluster-dash-metrics.liquid +26 -0
  129. package/tx/html/cluster-home-metrics.liquid +31 -0
  130. package/tx/html/cluster-metrics-script.liquid +251 -0
  131. package/tx/html/codesystem-operations.liquid +3 -3
  132. package/tx/html/ecl-panel.liquid +1 -1
  133. package/tx/html/external-metrics.liquid +17 -0
  134. package/tx/html/operations-form.liquid +17 -17
  135. package/tx/html/search-form.liquid +11 -11
  136. package/tx/html/status-line.liquid +58 -0
  137. package/tx/html/status-panel.liquid +140 -0
  138. package/tx/html/status-tasks.liquid +25 -0
  139. package/tx/html/tx-template.html +2 -5
  140. package/tx/importers/atc-to-fhir.js +17 -16
  141. package/tx/importers/export-icd11-codesystem.js +3 -2
  142. package/tx/importers/import-icd11.js +4 -3
  143. package/tx/importers/import-loinc.module.js +118 -56
  144. package/tx/importers/import-ndc.module.js +56 -44
  145. package/tx/importers/import-rxnorm.module.js +118 -79
  146. package/tx/importers/import-sct.module.js +125 -61
  147. package/tx/importers/import-unii.module.js +37 -36
  148. package/tx/importers/subset-loinc.module.js +49 -48
  149. package/tx/importers/subset-rxnorm.module.js +29 -28
  150. package/tx/importers/terminal.js +18 -0
  151. package/tx/importers/tx-import-base.js +13 -12
  152. package/tx/importers/tx-import-settings.js +5 -4
  153. package/tx/importers/tx-import.js +53 -52
  154. package/tx/library/canonical-resource.js +49 -0
  155. package/tx/library/codesystem.js +94 -5
  156. package/tx/library/designations.js +45 -2
  157. package/tx/library/extensions.js +13 -7
  158. package/tx/library/implicit-valuesets.js +66 -0
  159. package/tx/library/renderer.js +77 -21
  160. package/tx/library/sqlite-utilities.js +2 -0
  161. package/tx/library/tho-precedence.js +40 -0
  162. package/tx/library/ucum-parsers.js +11 -1
  163. package/tx/library/wait-for-cache.js +43 -0
  164. package/tx/library.js +321 -22
  165. package/tx/openapi-operations.js +6 -4
  166. package/tx/operation-context.js +460 -99
  167. package/tx/params.js +25 -0
  168. package/tx/problems.js +3 -5
  169. package/tx/provider.js +314 -67
  170. package/tx/resource-cache-sqlite.js +1050 -0
  171. package/tx/sct/documentation.md +45 -21
  172. package/tx/sct/ecl.js +81 -17
  173. package/tx/sct/editions.js +39 -0
  174. package/tx/sct/expressions.js +10 -1
  175. package/tx/sct/load-config.js +107 -0
  176. package/tx/sct/mmap.js +109 -0
  177. package/tx/sct/structures.js +175 -15
  178. package/tx/sct/text-index.js +5 -1
  179. package/tx/syndication/syndication-feed.js +2 -2
  180. package/tx/tx-html.js +44 -8
  181. package/tx/tx.fhir.org.yml +8 -8
  182. package/tx/tx.js +282 -80
  183. package/tx/usage-tracker.js +39 -11
  184. package/tx/vs/vs-api.js +19 -0
  185. package/tx/vs/vs-database.js +386 -62
  186. package/tx/vs/vs-package.js +44 -17
  187. package/tx/vs/vs-vsac.js +649 -101
  188. package/tx/workers/batch-validate.js +37 -10
  189. package/tx/workers/batch.js +6 -1
  190. package/tx/workers/cache-control.js +15 -4
  191. package/tx/workers/closure.js +156 -13
  192. package/tx/workers/compare.js +8 -5
  193. package/tx/workers/expand.js +1173 -289
  194. package/tx/workers/metadata.js +35 -0
  195. package/tx/workers/search.js +53 -1
  196. package/tx/workers/supplement-filters.js +145 -0
  197. package/tx/workers/translate.js +169 -4
  198. package/tx/workers/validate.js +223 -119
  199. package/tx/workers/worker.js +123 -31
  200. package/tx/xversion/xv-valueset.js +7 -4
  201. package/utilities/submit-test-report.js +3 -1
  202. package/utilities/tx-stress-test.js +482 -0
  203. package/vcl/readme.md +37 -0
  204. package/vcl/vcl-parser.js +15 -0
  205. package/xig/readme.md +52 -0
  206. package/xig/xig-template.html +5 -8
  207. package/xig/xig.js +238 -422
  208. package/changelog-template.md +0 -22
  209. package/registry/test-runner.js +0 -237
  210. package/release.sh +0 -80
  211. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  212. package/tsconfig.eslint.json +0 -9
  213. package/tx/html/dash-metrics.liquid +0 -147
  214. package/tx/html/home-metrics.liquid +0 -225
@@ -110,4 +110,136 @@ function readCgroupMemoryLimit(opts = {}) {
110
110
  return { limit: 0, source: null };
111
111
  }
112
112
 
113
- module.exports = { readCgroupMemoryLimit };
113
+ /**
114
+ * The directory of this process's own cgroup (memory controller), and whether it is v2 or
115
+ * v1. null when there is none to be found (not Linux, or no /proc).
116
+ * @param {Object} [opts] - procFile, cgroupRoot (tests)
117
+ * @returns {{dir: string, version: 2|1}|null}
118
+ */
119
+ function ownCgroupDir(opts = {}) {
120
+ const procFile = opts.procFile || '/proc/self/cgroup';
121
+ const cgroupRoot = opts.cgroupRoot || '/sys/fs/cgroup';
122
+ let lines;
123
+ try {
124
+ lines = fs.readFileSync(procFile, 'utf8').split('\n').filter(l => l.trim() !== '');
125
+ } catch {
126
+ return null;
127
+ }
128
+ const v2 = lines.find(l => l.startsWith('0::'));
129
+ if (v2) {
130
+ const dir = path.join(cgroupRoot, path.posix.normalize(v2.substring(3) || '/'));
131
+ if (fs.existsSync(path.join(dir, 'memory.stat'))) {
132
+ return { dir, version: 2 };
133
+ }
134
+ }
135
+ for (const l of lines) {
136
+ const parts = l.split(':');
137
+ if (parts.length >= 3 && parts[1].split(',').includes('memory')) {
138
+ const dir = path.join(cgroupRoot, 'memory', path.posix.normalize(parts.slice(2).join(':') || '/'));
139
+ if (fs.existsSync(path.join(dir, 'memory.stat'))) {
140
+ return { dir, version: 1 };
141
+ }
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * What memory.stat says a cgroup holds: its private memory (`anon`: what the kernel can't take
149
+ * back without killing a process) and its page cache (`file`: the terminology and SQLite files,
150
+ * mapped or read, which the kernel drops when it needs the room; shared pages counted once).
151
+ * v2: 'anon' and 'file'; v1: 'total_rss' (else 'rss') and 'total_cache' (else 'cache').
152
+ * @param {{dir: string, version: 2|1}} cg - from ownCgroupDir
153
+ * @returns {{anon: number, file: number|null}|null} bytes, or null if it can't be read
154
+ */
155
+ function readCgroupStat(cg) {
156
+ let text;
157
+ try {
158
+ text = fs.readFileSync(path.join(cg.dir, 'memory.stat'), 'utf8');
159
+ } catch {
160
+ return null;
161
+ }
162
+ const values = new Map();
163
+ for (const line of text.split('\n')) {
164
+ const i = line.indexOf(' ');
165
+ if (i > 0) {
166
+ values.set(line.substring(0, i), Number(line.substring(i + 1)));
167
+ }
168
+ }
169
+ const first = keys => {
170
+ for (const key of keys) {
171
+ const v = values.get(key);
172
+ if (Number.isFinite(v) && v >= 0) {
173
+ return v;
174
+ }
175
+ }
176
+ return null;
177
+ };
178
+ const v2 = cg.version === 2;
179
+ const anon = first(v2 ? ['anon'] : ['total_rss', 'rss']);
180
+ return anon === null ? null : { anon, file: first(v2 ? ['file'] : ['total_cache', 'cache']) };
181
+ }
182
+
183
+ /**
184
+ * The private memory of everything in a cgroup (memory.stat 'anon'; see readCgroupStat). The
185
+ * page cache isn't counted: the kernel drops it when it needs the room.
186
+ * @param {{dir: string, version: 2|1}} cg - from ownCgroupDir
187
+ * @returns {number} bytes, or 0 if it can't be read
188
+ */
189
+ function readCgroupAnon(cg) {
190
+ const stat = readCgroupStat(cg);
191
+ return stat ? stat.anon : 0;
192
+ }
193
+
194
+ /**
195
+ * A process's resident memory by kind, from /proc/<pid>/status: RssAnon (its private memory)
196
+ * and RssFile (the file pages it has mapped and touched, such as the SNOMED CT cache files;
197
+ * pages shared with other processes are counted in each). Linux only.
198
+ * @param {number|string} pid - or 'self'
199
+ * @param {string} [procRoot='/proc'] - (tests)
200
+ * @returns {{anon: number, file: number}|null} bytes, or null if it can't be read
201
+ */
202
+ function readProcessMemory(pid, procRoot = '/proc') {
203
+ let status;
204
+ try {
205
+ status = fs.readFileSync(path.join(procRoot, String(pid), 'status'), 'utf8');
206
+ } catch {
207
+ return null;
208
+ }
209
+ const kb = name => {
210
+ const m = new RegExp(`^${name}:\\s+(\\d+) kB`, 'm').exec(status);
211
+ return m ? Number(m[1]) * 1024 : null;
212
+ };
213
+ const anon = kb('RssAnon');
214
+ return anon === null ? null : { anon, file: kb('RssFile') ?? 0 };
215
+ }
216
+
217
+ /**
218
+ * The RSS of each process in a cgroup, from /proc/<pid>/status. Processes that have gone,
219
+ * or can't be read, are left out.
220
+ * @param {{dir: string}} cg - from ownCgroupDir
221
+ * @returns {Map<number, number>} pid -> bytes
222
+ */
223
+ function readCgroupProcessRss(cg) {
224
+ const result = new Map();
225
+ let pids;
226
+ try {
227
+ pids = fs.readFileSync(path.join(cg.dir, 'cgroup.procs'), 'utf8').split('\n').filter(Boolean).map(Number);
228
+ } catch {
229
+ return result;
230
+ }
231
+ for (const pid of pids) {
232
+ try {
233
+ const status = fs.readFileSync(`/proc/${pid}/status`, 'utf8');
234
+ const m = /^VmRSS:\s+(\d+) kB/m.exec(status);
235
+ if (m) {
236
+ result.set(pid, Number(m[1]) * 1024);
237
+ }
238
+ } catch {
239
+ // gone
240
+ }
241
+ }
242
+ return result;
243
+ }
244
+
245
+ module.exports = { readCgroupMemoryLimit, ownCgroupDir, readCgroupStat, readCgroupAnon, readProcessMemory, readCgroupProcessRss };
@@ -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 };