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
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Process memory guard - the one place that decides how close to its memory limit the
3
+ * server is, so the request gate (503) and the in-operation check (deadCheck, 422) agree.
4
+ *
5
+ * The limit is the cgroup limit the kernel will enforce (MemoryMax on the systemd unit, or
6
+ * the container limit); with none, the machine's total RAM. Two thresholds, as fractions of
7
+ * the limit, both measured against process RSS (with a role, the cgroup's private memory: see
8
+ * below):
9
+ *
10
+ * - rejectFraction: new requests get 503 + Retry-After before any work (or body parsing)
11
+ * is done. Shedding load early is what gives in-flight work and the GC room to recover.
12
+ * - abortFraction: operations already running are aborted at their next deadCheck (422
13
+ * too-costly). Higher than rejectFraction, so the gate acts first.
14
+ *
15
+ * Both are configurable in config.server.memory: { "rejectFraction": 0.85, "abortFraction": 0.92 }
16
+ *
17
+ * While refusing, the guard looks now and then for memory it can get back: a forced last-resort
18
+ * GC (the JS heap, and the freed pages V8 keeps for reuse), then malloc_trim (memory the C
19
+ * allocator holds free - closed SQLite connections, above all; library/native-memory.js), if
20
+ * "trimAllocator" is true: by default only with a role (library/role.js), so a server with no
21
+ * role only forces the GC, as before.
22
+ *
23
+ * A master and its tx-workers (--role, cluster.md) share one cgroup, and the kernel's limit is
24
+ * on all of them together, so with a role (config.server.memory.group) the measure is the
25
+ * group's: the cgroup's private memory (memory.stat anon), read by each process itself. Every
26
+ * process refuses new requests above rejectFraction; above abortFraction every process aborts
27
+ * the operations it has running. Not only the largest: a process's size doesn't say whether it
28
+ * is busy, and a large idle one (the master, say) would leave a worker's runaway $expand to the
29
+ * kernel's OOM killer. This needs a cgroup with a limit of its own (MemoryMax on the unit or
30
+ * scope, a container limit); without one each process measures its own RSS, as one does.
31
+ *
32
+ * @module library/memory-guard
33
+ */
34
+
35
+ const fs = require('fs');
36
+ const os = require('os');
37
+ const v8 = require('v8');
38
+ const vm = require('vm');
39
+ const path = require('path');
40
+ const { readCgroupMemoryLimit, ownCgroupDir, readCgroupAnon, readCgroupProcessRss } = require('./cgroup-memory');
41
+ const { trimNativeMemory, canTrimNativeMemory } = require('./native-memory');
42
+
43
+ const DEFAULT_REJECT_FRACTION = 0.85;
44
+ // malloc_trim (trimAllocator): off unless asked for, so a plain server is as it was. With a
45
+ // role, library/role.js turns it on (ROLE_TRIM_ALLOCATOR) unless the config says false
46
+ const DEFAULT_TRIM_ALLOCATOR = false;
47
+ const DEFAULT_ABORT_FRACTION = 0.92;
48
+ // RSS is cheap to read, but the gate runs on every request; a sample this old is fine
49
+ const SAMPLE_MS = 250;
50
+ // While refusing, look for memory to get back (a forced GC, then malloc_trim) at most this
51
+ // often. Refusing requests removes the allocation activity that would otherwise trigger a GC,
52
+ // and nothing returns the C allocator's free memory by itself, so without this RSS might never
53
+ // fall back below the threshold and the server would stay shut.
54
+ const GC_INTERVAL_MS = 5000;
55
+ // A look that gets back less than a tenth of the excess doubles the wait before the next, up
56
+ // to this: when most of RSS is in use (the SNOMED and LOINC cache buffers), they free little,
57
+ // and every full GC stops the process. One that gets back more keeps the wait: closing
58
+ // thousands of SQLite connections takes seconds, and the next look gets back the rest
59
+ const GC_MAX_INTERVAL_MS = 60000;
60
+ // Between those looks, malloc_trim alone, before a request is refused, at most this often and
61
+ // with no back-off: memory used in a burst is freed when the burst ends, and the C allocator
62
+ // keeps it resident until asked, so a quiet server could sit just over the threshold and
63
+ // refuse everything until the next look, up to GC_MAX_INTERVAL_MS away. With nothing to give
64
+ // back, a trim takes well under a millisecond (about 20 us measured, on a 3.3 GB heap)
65
+ const TRIM_INTERVAL_MS = 1000;
66
+ // The forced GC's form. A plain gc() collects the JS heap but keeps the pages it frees for
67
+ // reuse, resident and outside heapTotal, so they still count against the limit: after bursts of
68
+ // value set searches, a server held 2.7 GB of anon memory with a 1.0 GB heap, and plain GCs gave
69
+ // back nothing. A last-resort GC gives those pages back (that server: 1.4 GB, 1.1 GB with
70
+ // malloc_trim). "flavor" is in V8 12 (Node 22) on; Node 18 took the object for a lesser GC than
71
+ // a plain gc(), so before V8 12, plain gc()
72
+ const LAST_RESORT_GC = Number(process.versions.v8.split('.')[0]) >= 12;
73
+ const LAST_RESORT_GC_ARG = { type: 'major', execution: 'sync', flavor: 'last-resort' };
74
+ // the group measure reads a file: the in-operation check (about ten a request) reuses one
75
+ // at most this old
76
+ const GROUP_SAMPLE_MS = 100;
77
+ // RssFile (/proc/self/status) is read at most this often. The in-operation check runs many
78
+ // times a request, and a read of /proc/self/status costs twice what the RSS itself does;
79
+ // between reads, a fresh RSS less the last RssFile is a close enough estimate
80
+ const FILE_SAMPLE_MS = 200;
81
+ // the in-operation check reuses a measure at most this old. It runs about ten times a request,
82
+ // and RSS moves little in 10 ms
83
+ const ABORT_SAMPLE_MS = 10;
84
+
85
+ const state = {
86
+ limit: 0,
87
+ source: null,
88
+ rejectFraction: DEFAULT_REJECT_FRACTION,
89
+ abortFraction: DEFAULT_ABORT_FRACTION,
90
+ rejectAt: 0,
91
+ abortAt: 0,
92
+ trimAllocator: DEFAULT_TRIM_ALLOCATOR,
93
+ lastResortGc: LAST_RESORT_GC, // the forced GC's form (see LAST_RESORT_GC)
94
+ sampleTime: 0,
95
+ sampleRss: 0,
96
+ // a role (master, tx-worker): the cgroup measured, from ownCgroupDir; else null
97
+ group: null,
98
+ groupWanted: false,
99
+ // set by measureExcludingFileBacked()
100
+ excludeFileBacked: false,
101
+ fileTime: 0,
102
+ fileRss: 0 // the last RssFile read (see fileBackedRss)
103
+ };
104
+
105
+ function fraction(v, dflt) {
106
+ return (typeof v === 'number' && v > 0 && v <= 1) ? v : dflt;
107
+ }
108
+
109
+ /**
110
+ * The limit and the two thresholds for config.server.memory, and with opts.group the cgroup to
111
+ * measure (one with the limit on it, else null). configure() uses it, and so does the launcher
112
+ * (cluster.js), so both agree on where the threshold is.
113
+ * @param {object} [opts] - { rejectFraction, abortFraction, group, limit and cgroup (tests) }
114
+ */
115
+ function thresholds(opts = {}) {
116
+ let limit = opts.limit;
117
+ let source = limit ? 'configured' : null;
118
+ let cg = null;
119
+ if (!limit) {
120
+ cg = readCgroupMemoryLimit();
121
+ limit = cg.limit;
122
+ source = cg.source;
123
+ }
124
+ let group = null;
125
+ if (opts.group) {
126
+ // only a cgroup that is the group's own, with the limit on it
127
+ const own = opts.cgroup !== undefined ? opts.cgroup : ownCgroupDir();
128
+ const limitFile = opts.cgroup !== undefined ? null : (cg || readCgroupMemoryLimit()).source;
129
+ if (own && (opts.cgroup !== undefined || (limitFile && path.dirname(limitFile) === own.dir)) && readCgroupAnon(own) > 0) {
130
+ group = own;
131
+ }
132
+ }
133
+ if (!limit) {
134
+ limit = os.totalmem();
135
+ source = 'os.totalmem()';
136
+ }
137
+ const rejectFraction = fraction(opts.rejectFraction, DEFAULT_REJECT_FRACTION);
138
+ const abortFraction = fraction(opts.abortFraction, DEFAULT_ABORT_FRACTION);
139
+ return { limit, source, group, rejectFraction, abortFraction, rejectAt: limit * rejectFraction, abortAt: limit * abortFraction };
140
+ }
141
+
142
+ /**
143
+ * (Re)initialise. Called once at startup from server.js with config.server.memory; the module
144
+ * also self-initialises with defaults so code that loads without server.js (tests, tools) works.
145
+ * @param {object} [opts] - as thresholds(), and trimAllocator
146
+ */
147
+ function configure(opts = {}) {
148
+ Object.assign(state, thresholds(opts));
149
+ state.groupWanted = !!opts.group;
150
+ state.trimAllocator = (opts.trimAllocator ?? DEFAULT_TRIM_ALLOCATOR) === true;
151
+ state.sampleTime = 0;
152
+ }
153
+
154
+ /**
155
+ * Measure RSS less its file-backed pages (Linux only). With SNOMED CT cache files memory-mapped
156
+ * ("snomed": {"load": "mmap"} in config.json) RSS includes every mapped page that has been read, which the kernel drops
157
+ * and re-reads as it needs to - counting them would refuse requests on a server that is fine.
158
+ */
159
+ function measureExcludingFileBacked() {
160
+ state.excludeFileBacked = process.platform === 'linux';
161
+ state.sampleTime = 0;
162
+ }
163
+
164
+ /**
165
+ * RssFile as last read, read again if that was FILE_SAMPLE_MS ago or more, or if the sample
166
+ * has been dropped (sampleTime 0: configure, measureExcludingFileBacked, a test).
167
+ */
168
+ function fileBackedRss() {
169
+ const now = Date.now();
170
+ if (state.sampleTime === 0 || now - state.fileTime >= FILE_SAMPLE_MS) {
171
+ state.fileRss = rssFileBytes();
172
+ state.fileTime = now;
173
+ }
174
+ return state.fileRss;
175
+ }
176
+
177
+ function rssFileBytes() {
178
+ try {
179
+ const m = /^RssFile:\s+(\d+) kB/m.exec(fs.readFileSync('/proc/self/status', 'utf8'));
180
+ return m ? Number(m[1]) * 1024 : 0;
181
+ } catch {
182
+ return 0;
183
+ }
184
+ }
185
+
186
+ /** This process's RSS, less reclaimable file-backed pages when using mapped files. */
187
+ function ownRss() {
188
+ return process.memoryUsage.rss() - (state.excludeFileBacked ? fileBackedRss() : 0);
189
+ }
190
+
191
+ function rss(fresh = false) {
192
+ const now = Date.now();
193
+ if (fresh || now - state.sampleTime > SAMPLE_MS) {
194
+ state.sampleRss = (state.group && readCgroupAnon(state.group)) || ownRss();
195
+ state.sampleTime = now;
196
+ }
197
+ return state.sampleRss;
198
+ }
199
+
200
+ /** True if new requests should be refused (uses a sample at most SAMPLE_MS old). */
201
+ function shouldReject() {
202
+ return state.rejectAt > 0 && rss() > state.rejectAt;
203
+ }
204
+
205
+ let gcFn;
206
+
207
+ /**
208
+ * global.gc if node was started with --expose-gc, which is how it should be started (the
209
+ * Dockerfile and npm start do; add it to the node command of a systemd unit). Otherwise
210
+ * expose it at runtime: the flag can be set late, and a fresh context then has gc - this
211
+ * works, but Node does not promise it will keep working. null if neither works.
212
+ */
213
+ function getGc() {
214
+ if (gcFn !== undefined) {
215
+ return gcFn;
216
+ }
217
+ gcFn = null;
218
+ if (typeof global.gc === 'function') {
219
+ gcFn = global.gc;
220
+ } else {
221
+ try {
222
+ v8.setFlagsFromString('--expose-gc');
223
+ const fn = vm.runInNewContext('gc');
224
+ if (typeof fn === 'function') {
225
+ gcFn = fn;
226
+ }
227
+ } catch {
228
+ // no gc available - the guard still works, it just recovers more slowly
229
+ }
230
+ }
231
+ return gcFn;
232
+ }
233
+
234
+ let aborts = 0;
235
+ let lastAbortLog = 0;
236
+ let abortLog = null;
237
+
238
+ /**
239
+ * Current RSS in bytes if running operations should be aborted, else 0. A measure at most
240
+ * ABORT_SAMPLE_MS old (GROUP_SAMPLE_MS for a group; file-backed pages are read at most
241
+ * FILE_SAMPLE_MS ago), or fresh
242
+ * if the sample was dropped (configure, measureExcludingFileBacked).
243
+ * A trip is written to the server log (at most once a minute, with a running count) - the
244
+ * caller's own message only goes to the aborted operation's diagnostics.
245
+ * @param {string} [place] - where the check was made, for the log
246
+ */
247
+ function overAbortThreshold(place = 'unknown') {
248
+ if (state.abortAt <= 0) {
249
+ return 0;
250
+ }
251
+ const r = rss(Date.now() - state.sampleTime > (state.group ? GROUP_SAMPLE_MS : ABORT_SAMPLE_MS));
252
+ if (r <= state.abortAt) {
253
+ return 0;
254
+ }
255
+ aborts++;
256
+ const now = Date.now();
257
+ if (now - lastAbortLog > 60000) {
258
+ lastAbortLog = now;
259
+ try {
260
+ if (!abortLog) {
261
+ abortLog = require('./logger').getInstance().child({ module: 'memory' });
262
+ }
263
+ abortLog.warn(`Memory critical: ${gb(r)} GB of ${gb(state.limit)} GB - aborting running operations (at ${place}; ${aborts} aborted so far)`);
264
+ } catch {
265
+ // logging must never stop the abort
266
+ }
267
+ }
268
+ return r;
269
+ }
270
+
271
+ function gb(bytes) {
272
+ return (bytes / 1024 / 1024 / 1024).toFixed(1);
273
+ }
274
+
275
+ function describe() {
276
+ const group = !state.groupWanted ? '' : state.group
277
+ ? `, for the whole cgroup ${state.group.dir} (its private memory)`
278
+ : ', measured as this process\'s RSS (no cgroup limit of the service\'s own to measure as a group)';
279
+ return `Memory limit: ${gb(state.limit)} GB (from ${state.source})${group}; ` +
280
+ (state.excludeFileBacked && !state.group ? 'measured as RSS less file-backed pages; ' : '') +
281
+ `new requests refused above ${gb(state.rejectAt)} GB (${Math.round(state.rejectFraction * 100)}%), ` +
282
+ `operations aborted above ${gb(state.abortAt)} GB (${Math.round(state.abortFraction * 100)}%); ` +
283
+ `while refusing, a forced ${state.lastResortGc ? 'last-resort ' : ''}GC` + (!state.trimAllocator ? ' (no malloc_trim: trimAllocator is off)'
284
+ : canTrimNativeMemory() ? ' and malloc_trim' : ' (malloc_trim not available here)');
285
+ }
286
+
287
+ /**
288
+ * Express middleware: 503 + Retry-After while RSS is over the reject threshold. Install it
289
+ * before the body parsers so a refused request costs nothing. While refusing, it looks for
290
+ * memory to get back now and then and re-measures - every gcInterval ms, backing off to
291
+ * gcMaxInterval while the looks get back little: a forced last-resort GC (where the runtime has
292
+ * only a plain one, not while the JS heap is too small for that to help), then malloc_trim
293
+ * (unless trimAllocator is false); and between those, malloc_trim alone at most every
294
+ * trimInterval ms, before refusing a request. It logs when refusing starts (then once a minute,
295
+ * with how long and how many) and when it stops.
296
+ * @param {object} [opts] - { log, exempt: [path prefixes never refused], retryAfter (seconds),
297
+ * gcInterval (ms), gcMaxInterval (ms), heapUsed (function returning bytes, for tests),
298
+ * gc (function, for tests: as global.gc),
299
+ * trim (function, for tests: as trimNativeMemory), trimInterval (ms), onReject (optional observation callback,
300
+ * called only when sending a 503) }
301
+ */
302
+ function middleware(opts = {}) {
303
+ const exempt = opts.exempt || [];
304
+ const retryAfter = String(opts.retryAfter || 30);
305
+ const log = opts.log;
306
+ const gcInterval = opts.gcInterval || GC_INTERVAL_MS;
307
+ const gcMaxInterval = Math.max(opts.gcMaxInterval || GC_MAX_INTERVAL_MS, gcInterval);
308
+ const heapUsed = opts.heapUsed || (() => process.memoryUsage().heapUsed);
309
+ const trim = opts.trim || trimNativeMemory;
310
+ const trimInterval = opts.trimInterval || TRIM_INTERVAL_MS;
311
+ let gcWait = gcInterval; // the current wait between forced GCs (backs off, see below)
312
+ let refusingSince = 0; // when the current spell of refusing began (0 = not refusing)
313
+ let refused = 0; // refused in the current spell
314
+ let lastLog = 0;
315
+ let lastGc = 0;
316
+ let lastTrim = 0;
317
+
318
+ const secs = (ms) => Math.round(ms / 1000);
319
+
320
+ return (req, res, next) => {
321
+ if (exempt.some(p => req.path === p || req.path.startsWith(p + '/'))) {
322
+ return next();
323
+ }
324
+ let over = shouldReject();
325
+ const now = Date.now();
326
+
327
+ // Refusing starves the GC of the allocation that would normally trigger it, so while
328
+ // refusing, force a full collection now and then. A last-resort GC also gives back the
329
+ // pages V8 keeps for reuse, which no heap figure counts (0.9 GB of them under a 0.13 GB
330
+ // heapTotal, in one measure), so the heap's size says nothing of what it can free: it
331
+ // always runs, and the back-off bounds its cost. A plain gc() frees heap and nothing else,
332
+ // so with one, only if the JS heap is at least the amount over the threshold. Then
333
+ // malloc_trim: the C allocator keeps what is freed outside the JS heap (closed SQLite
334
+ // connections) resident until asked.
335
+ // Re-measure; a look that got back little doubles the wait before the next
336
+ if (over && now - lastGc >= gcWait) {
337
+ lastGc = now;
338
+ const before = state.sampleRss;
339
+ const excess = before - state.rejectAt;
340
+ const heap = heapUsed();
341
+ const gc = opts.gc || getGc();
342
+ // a group: every process refusing does this, so each need only free its share
343
+ const share = state.group ? Math.max(1, readCgroupProcessRss(state.group).size) : 1;
344
+ const done = [];
345
+ if (!state.lastResortGc && heap < excess / share) {
346
+ if (log) {
347
+ log.info(`Memory: no forced GC - the JS heap (${gb(heap)} GB) is less than the ${gb(excess)} GB over the threshold`);
348
+ }
349
+ } else if (gc) {
350
+ try {
351
+ if (state.lastResortGc) {
352
+ gc(LAST_RESORT_GC_ARG);
353
+ } else {
354
+ gc();
355
+ }
356
+ done.push(state.lastResortGc ? 'forced last-resort GC' : 'forced GC');
357
+ } catch {
358
+ // ignore
359
+ }
360
+ }
361
+ if (state.trimAllocator && trim() !== null) {
362
+ done.push('malloc_trim');
363
+ lastTrim = now;
364
+ }
365
+ if (done.length) {
366
+ const after = rss(true);
367
+ over = after > state.rejectAt;
368
+ gcWait = !over ? gcInterval : before - after >= excess / 10 ? gcWait : Math.min(gcWait * 2, gcMaxInterval);
369
+ if (log) {
370
+ log.info(`Memory: ${done.join(' and ')} while refusing - ${gb(before)} GB -> ${gb(after)} GB` +
371
+ (over ? `; next in ${secs(gcWait)}s` : ''));
372
+ }
373
+ } else {
374
+ gcWait = Math.min(gcWait * 2, gcMaxInterval);
375
+ }
376
+ } else if (over && state.trimAllocator && now - lastTrim >= trimInterval) {
377
+ lastTrim = now;
378
+ const before = state.sampleRss;
379
+ if (trim() !== null) {
380
+ const after = rss(true);
381
+ over = after > state.rejectAt;
382
+ if (log && (!over || before - after >= 0.1 * 2 ** 30)) {
383
+ log.info(`Memory: malloc_trim before refusing - ${gb(before)} GB -> ${gb(after)} GB`);
384
+ }
385
+ }
386
+ }
387
+
388
+ if (!over) {
389
+ gcWait = gcInterval;
390
+ if (refusingSince) {
391
+ if (log) {
392
+ log.warn(`Memory recovered: ${gb(state.sampleRss)} GB of ${gb(state.limit)} GB - accepting requests again after ${secs(now - refusingSince)}s (${refused} refused)`);
393
+ }
394
+ refusingSince = 0;
395
+ refused = 0;
396
+ }
397
+ return next();
398
+ }
399
+
400
+ if (!refusingSince) {
401
+ refusingSince = now;
402
+ lastLog = 0;
403
+ }
404
+ refused++;
405
+ try {
406
+ opts.onReject?.();
407
+ } catch {
408
+ // Observability must never change whether this request is refused.
409
+ }
410
+ if (log && now - lastLog > 60000) {
411
+ log.warn(`Memory high: ${gb(state.sampleRss)} GB of ${gb(state.limit)} GB - refusing new requests with 503 for ${secs(now - refusingSince)}s (${refused} refused)`);
412
+ lastLog = now;
413
+ }
414
+ const text = `Server is under memory pressure (${gb(state.sampleRss)} GB of ${gb(state.limit)} GB); try again shortly`;
415
+ res.status(503);
416
+ res.setHeader('Retry-After', retryAfter);
417
+ res.setHeader('Connection', 'close');
418
+ res.setHeader('Content-Type', 'application/fhir+json');
419
+ res.end(JSON.stringify({
420
+ resourceType: 'OperationOutcome',
421
+ issue: [{ severity: 'error', code: 'transient', details: { text }, diagnostics: text }]
422
+ }));
423
+ };
424
+ }
425
+
426
+ configure();
427
+
428
+ module.exports = { thresholds, configure, measureExcludingFileBacked, shouldReject, overAbortThreshold, describe, middleware, gb, getGc, _state: state };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Return the C allocator's free memory to the OS: glibc malloc_trim(0), through the optional
3
+ * koffi FFI dependency that the SNOMED mapping uses (tx/sct/mmap.js).
4
+ *
5
+ * Why: SQLite connections (one per code system provider, and an operation can open thousands)
6
+ * are allocated by the C library's malloc in libuv's threads, in its per-thread arenas. When
7
+ * they close, glibc keeps the freed memory in those arenas, resident, and gives back only a
8
+ * free stretch at the very top of each. After a 20,000-entry LOINC $batch-validate-code was
9
+ * aborted, a worker held 4.7 GB of such free memory: no GC can touch it, so the memory guard
10
+ * stayed shut. malloc_trim(0) releases the free pages inside the arenas as well (it took that
11
+ * worker from 8.0 GB to 3.9 GB in half a second). It frees nothing that is in use.
12
+ *
13
+ * Linux with glibc only. Elsewhere (macOS, Windows, musl without malloc_trim), or without
14
+ * koffi, it does nothing and returns null.
15
+ *
16
+ * @module library/native-memory
17
+ */
18
+ let trim; // undefined: not tried yet; null: not available
19
+
20
+ function load() {
21
+ if (trim === undefined) {
22
+ trim = null;
23
+ if (process.platform === 'linux') {
24
+ try {
25
+ trim = require('koffi').load(null).func('int malloc_trim(size_t pad)');
26
+ } catch {
27
+ // no koffi, or no malloc_trim in this C library
28
+ }
29
+ }
30
+ }
31
+ return trim;
32
+ }
33
+
34
+ /** @returns {boolean} whether trimNativeMemory() can do anything here */
35
+ function canTrimNativeMemory() {
36
+ return !!load();
37
+ }
38
+
39
+ /** @returns {boolean|null} whether memory was released, or null if this can't be done here */
40
+ function trimNativeMemory() {
41
+ if (!load()) {
42
+ return null;
43
+ }
44
+ try {
45
+ return trim(0) !== 0;
46
+ } catch {
47
+ trim = null;
48
+ return null;
49
+ }
50
+ }
51
+
52
+ module.exports = { trimNativeMemory, canTrimNativeMemory };
@@ -205,10 +205,16 @@ class NpmAudit {
205
205
  * Dashboard line: this server's version, and whether there is a newer release
206
206
  */
207
207
  renderRelease() {
208
+ const text = this.releaseText();
209
+ return text ? `<p>${text}</p>` : '';
210
+ }
211
+
212
+ /** The release, as inline html ('' if the installed version isn't known) */
213
+ releaseText() {
208
214
  if (!this.installedVersion) {
209
215
  return '';
210
216
  }
211
- let html = `<p><strong>FHIRsmith release:</strong> this server runs ${escape(this.installedVersion)}`;
217
+ let html = `<strong>FHIRsmith release:</strong> this server runs ${escape(this.installedVersion)}`;
212
218
  const cmp = compareVersions(this.installedVersion, this.latestVersion);
213
219
  if (cmp === null) {
214
220
  html += ' (the latest release could not be checked)';
@@ -218,7 +224,7 @@ class NpmAudit {
218
224
  } else {
219
225
  html += ', the latest release';
220
226
  }
221
- return html + '</p>';
227
+ return html;
222
228
  }
223
229
 
224
230
  /**
@@ -414,35 +420,55 @@ class NpmAudit {
414
420
  /**
415
421
  * Dashboard: always shown, so a check that is failing or switched off is visible too
416
422
  */
417
- renderDashboard(now = Date.now()) {
418
- let html = '<div id="npm-audit">';
423
+ /**
424
+ * The release and the audit, as one line of inline html (the top of /dashboard): the advice
425
+ * and the findings are in renderFindings
426
+ */
427
+ renderLine(now = Date.now()) {
419
428
  if (!this.enabled) {
420
- return html + '<p><strong>npm audit:</strong> disabled (npmAudit.enabled = false)</p></div>';
429
+ return '<strong>npm audit:</strong> disabled (npmAudit.enabled = false)';
430
+ }
431
+ const parts = [];
432
+ const release = this.releaseText();
433
+ if (release) {
434
+ parts.push(release);
421
435
  }
422
- html += this.renderRelease();
423
436
  if (!this.checkedAt) {
424
- html += '<p><strong>npm audit:</strong> ';
425
- html += this.lastError ? `<span style="color:#b00">could not be run: ${escape(this.lastError)}</span>` : 'not run yet';
426
- return html + '</p></div>';
437
+ parts.push('<strong>npm audit:</strong> ' +
438
+ (this.lastError ? `<span style="color:#b00">could not be run: ${escape(this.lastError)}</span>` : 'not run yet'));
439
+ } else {
440
+ const colour = this.findings.length === 0 ? '#070' : (this.isSerious() ? '#b00' : '#b60');
441
+ let audit = `<strong>npm audit:</strong> <span style="color:${colour}">${escape(this.summary())}</span>` +
442
+ ` in ${this.packageCount} packages, checked ${escape(describeAgo(this.checkedAt.getTime(), now))}`;
443
+ if (this.lastError) {
444
+ audit += ` <span style="color:#b00">(the latest check failed: ${escape(this.lastError)})</span>`;
445
+ }
446
+ parts.push(audit);
427
447
  }
428
- const colour = this.findings.length === 0 ? '#070' : (this.isSerious() ? '#b00' : '#b60');
429
- html += `<p><strong>npm audit:</strong> <span style="color:${colour}">${escape(this.summary())}</span>` +
430
- ` in ${this.packageCount} packages, checked ${escape(describeAgo(this.checkedAt.getTime(), now))}`;
431
- if (this.lastError) {
432
- html += ` <span style="color:#b00">(the latest check failed: ${escape(this.lastError)})</span>`;
448
+ return parts.join(' · ');
449
+ }
450
+
451
+ /** The advice and the table of findings, when there are any ('' otherwise) */
452
+ renderFindings() {
453
+ if (!this.enabled || !this.checkedAt || this.findings.length === 0) {
454
+ return '';
433
455
  }
434
- html += '</p>';
435
- if (this.findings.length > 0) {
436
- html += `<p>${escape(this.advice())}</p>`;
437
- html += '<table class="grid"><tr><th>Severity</th><th>Package</th><th>Installed</th><th>Vulnerable</th><th>Advisory</th></tr>';
438
- for (const f of this.findings) {
439
- const link = /^https:\/\//.test(f.url) ? `<a href="${escape(f.url)}">${escape(f.title || f.url)}</a>` : escape(f.title);
440
- html += `<tr><td>${escape(f.severity)}</td><td>${escape(f.name)}</td><td>${escape(f.installed.join(', '))}</td>` +
441
- `<td>${escape(f.range)}</td><td>${link}</td></tr>`;
442
- }
443
- html += '</table>';
456
+ let html = `<p>${escape(this.advice())}</p>`;
457
+ html += '<table class="grid"><tr><th>Severity</th><th>Package</th><th>Installed</th><th>Vulnerable</th><th>Advisory</th></tr>';
458
+ for (const f of this.findings) {
459
+ const link = /^https:\/\//.test(f.url) ? `<a href="${escape(f.url)}">${escape(f.title || f.url)}</a>` : escape(f.title);
460
+ html += `<tr><td>${escape(f.severity)}</td><td>${escape(f.name)}</td><td>${escape(f.installed.join(', '))}</td>` +
461
+ `<td>${escape(f.range)}</td><td>${link}</td></tr>`;
444
462
  }
445
- return html + '</div>';
463
+ return html + '</table>';
464
+ }
465
+
466
+ /**
467
+ * The dashboard's npm audit: one line (renderLine), and only when there are findings, the advice
468
+ * and the table. The home page's banner links here (#npm-audit)
469
+ */
470
+ renderDashboard(now = Date.now()) {
471
+ return `<div id="npm-audit"><p>${this.renderLine(now)}</p>${this.renderFindings()}</div>`;
446
472
  }
447
473
  }
448
474
 
@@ -429,10 +429,12 @@ const PAGE_STYLE = `<style>
429
429
  .openapi-ref .try-body { max-height: 30em; overflow: auto; }
430
430
  </style>`;
431
431
 
432
+ // Headings: the page this goes into is titled by the module's template with an <h2>, so the
433
+ // reference starts at <h3> (Endpoints, Schemas) - <h4> per operation and per schema, <h5> for
434
+ // the parts of an operation - and the API's title is not a heading of its own
432
435
  function buildHtml(spec, BASE_PATH) {
433
436
  let html = PAGE_STYLE + '<div class="openapi-ref">';
434
- html += `<h1>${escape(spec.info.title)} API</h1>`;
435
- html += `<p class="lead">${escape(spec.info.summary || '')}</p>`;
437
+ html += `<p class="lead"><strong>${escape(spec.info.title)} API</strong>${spec.info.summary ? ': ' + escape(spec.info.summary) : ''}</p>`;
436
438
  html += `<p>Machine-readable description (OpenAPI ${escape(spec.openapi)}): ` +
437
439
  `<a href="${BASE_PATH}/openapi.json">openapi.json</a> &middot; ` +
438
440
  `<a href="${BASE_PATH}/openapi.yaml">openapi.yaml</a></p>`;
@@ -441,7 +443,7 @@ function buildHtml(spec, BASE_PATH) {
441
443
  html += `<p>See also: <a href="${escape(spec.externalDocs.url)}">${escape(spec.externalDocs.description || spec.externalDocs.url)}</a></p>`;
442
444
  }
443
445
 
444
- html += '<h2>Endpoints</h2>';
446
+ html += '<h3>Endpoints</h3>';
445
447
  // an index, when there are enough of them to need one
446
448
  const all = Object.entries(spec.paths).flatMap(([p, item]) => METHODS.filter(m => item[m]).map(m => [p, m, item[m]]));
447
449
  if (all.length > 12) {
@@ -464,7 +466,7 @@ function buildHtml(spec, BASE_PATH) {
464
466
  }
465
467
  first = false;
466
468
  html += `<div class="panel panel-default" id="${escape(op.operationId || '')}"><div class="panel-heading">`;
467
- html += `<span class="label label-primary">${method.toUpperCase()}</span> <code>${escape(BASE_PATH + p)}</code> &mdash; ${escape(op.summary || '')}`;
469
+ html += `<h4 class="panel-title"><span class="label label-primary">${method.toUpperCase()}</span> <code>${escape(BASE_PATH + p)}</code> &mdash; ${escape(op.summary || '')}</h4>`;
468
470
  html += '</div><div class="panel-body">';
469
471
  html += markdown(op.description);
470
472
  html += renderParameters(spec, [...(item.parameters || []), ...(op.parameters || [])]);
@@ -474,7 +476,7 @@ function buildHtml(spec, BASE_PATH) {
474
476
  }
475
477
  }
476
478
 
477
- html += '<hr/><h2>Schemas</h2>';
479
+ html += '<hr/><h3>Schemas</h3>';
478
480
  for (const [name, schema] of Object.entries((spec.components && spec.components.schemas) || {})) {
479
481
  html += renderSchema(name, schema);
480
482
  }
@@ -351,6 +351,11 @@ class CIBuildClient {
351
351
 
352
352
  class PackageManager {
353
353
  totalDownloaded = 0;
354
+ /**
355
+ * {Set<string>} the package folders (id#version, in the cache folder) that fetch and
356
+ * fetchUrl have returned - what has been used, for pruning the rest (Library.pruneUnusedCaches)
357
+ */
358
+ usedPackages = new Set();
354
359
 
355
360
  /**
356
361
  * @param {string[]} packageServers - Ordered list of package server URLs
@@ -377,6 +382,7 @@ class PackageManager {
377
382
  // Check cache first
378
383
  const cachedPath = await this.checkCache(packageId, resolvedVersion);
379
384
  if (cachedPath) {
385
+ this.usedPackages.add(cachedPath);
380
386
  return cachedPath;
381
387
  }
382
388
 
@@ -386,6 +392,7 @@ class PackageManager {
386
392
  this.totalDownloaded = this.totalDownloaded + packageData.length;
387
393
  // Extract to cache
388
394
  const extractedPath = await this.extractToCache(packageId, resolvedVersion, packageData);
395
+ this.usedPackages.add(extractedPath);
389
396
 
390
397
  return extractedPath;
391
398
  }
@@ -629,6 +636,7 @@ class PackageManager {
629
636
  }
630
637
 
631
638
  this.totalDownloaded = this.totalDownloaded + packageData.length;
639
+ this.usedPackages.add(finalName);
632
640
  return finalName;
633
641
  } catch (error) {
634
642
  console.error(`Error fetching package from URL ${url}: ${error.message}`);