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.
Files changed (203) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +149 -0
  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 +132 -59
  15. package/ips/nationality.js +120 -0
  16. package/ips/pipeline.js +74 -21
  17. package/ips/presenter.js +1 -1
  18. package/ips/readme.md +132 -25
  19. package/ips/render.js +466 -77
  20. package/ips/store.js +79 -22
  21. package/ips/structure.js +247 -0
  22. package/ips/terminology.js +1 -1
  23. package/library/body-limit.js +22 -0
  24. package/library/cache-generation.js +15 -0
  25. package/library/cgroup-memory.js +133 -1
  26. package/library/cluster-counts.js +121 -0
  27. package/library/cluster-events.js +153 -0
  28. package/library/cluster-metrics.js +193 -0
  29. package/library/cluster-status.js +468 -0
  30. package/library/config-reader.js +96 -0
  31. package/library/daily-report.js +100 -2
  32. package/library/html-server.js +27 -1
  33. package/library/i18nsupport.js +23 -1
  34. package/library/keep-alive.js +18 -0
  35. package/library/languages.js +17 -3
  36. package/library/logger.js +31 -0
  37. package/library/memory-guard.js +428 -0
  38. package/library/native-memory.js +52 -0
  39. package/library/npm-audit.js +51 -25
  40. package/library/openapi-doc.js +7 -5
  41. package/library/package-manager.js +8 -0
  42. package/library/role.js +197 -0
  43. package/library/server-credentials.js +169 -0
  44. package/library/sqlite-utilities.js +59 -0
  45. package/library/ssrf-guard.js +100 -0
  46. package/library/static-response-stats.js +16 -0
  47. package/library/version-utilities.js +50 -0
  48. package/library/worker-count.js +192 -0
  49. package/nginx.md +30 -11
  50. package/npmprojector/indexer.js +18 -10
  51. package/package.json +11 -8
  52. package/packages/feed-check-page.js +117 -0
  53. package/packages/feed-check.js +279 -0
  54. package/packages/npm-check.js +57 -3
  55. package/packages/npm-signing.js +219 -0
  56. package/packages/openapi.yaml +207 -1
  57. package/packages/package-crawler.js +322 -41
  58. package/packages/packages-template.html +6 -8
  59. package/packages/packages.js +604 -57
  60. package/packages/readme.md +113 -1
  61. package/publisher/publisher-template.html +2 -5
  62. package/publisher/publisher.js +121 -110
  63. package/publisher/readme.md +75 -0
  64. package/registry/crawler.js +84 -108
  65. package/registry/documentation/configuration.md +34 -0
  66. package/registry/registry-template.html +5 -8
  67. package/registry/registry.js +5 -4
  68. package/root-bare-template.html +7 -4
  69. package/root-template.html +5 -8
  70. package/server.js +398 -198
  71. package/shl/shl.js +38 -37
  72. package/static/a11y/a11y-check.js +832 -0
  73. package/static/a11y/axe.min.js +12 -0
  74. package/static/assets/css/bootstrap-fhir.css +15 -15
  75. package/static/assets/images/flags/au.svg +18 -0
  76. package/static/assets/images/flags/unknown.svg +11 -0
  77. package/static/assets/images/flags/us.svg +63 -0
  78. package/static/assets/images/icon_audit.png +0 -0
  79. package/static/assets/images/icon_clinical.png +0 -0
  80. package/static/assets/images/icon_document.png +0 -0
  81. package/static/assets/images/icon_external.png +0 -0
  82. package/static/assets/images/icon_medication.png +0 -0
  83. package/static/assets/images/icon_organization.png +0 -0
  84. package/static/assets/images/icon_other.png +0 -0
  85. package/static/assets/images/icon_person.png +0 -0
  86. package/static/assets/images/icon_section.png +0 -0
  87. package/stats-db.js +45 -0
  88. package/stats.js +197 -10
  89. package/testing/testing-template.html +5 -8
  90. package/token/nginx_passport_setup.md +1 -1
  91. package/token/security_guide.md +1 -1
  92. package/token/token-template.html +2 -5
  93. package/token/token.js +40 -36
  94. package/translations/Messages.properties +19 -0
  95. package/tx/README.md +154 -4
  96. package/tx/closure/closure-store.js +126 -3
  97. package/tx/cm/cm-database.js +69 -57
  98. package/tx/cm/cm-package.js +29 -1
  99. package/tx/cs/cs-api.js +284 -19
  100. package/tx/cs/cs-api.md +41 -4
  101. package/tx/cs/cs-areacode.js +1 -1
  102. package/tx/cs/cs-country.js +17 -5
  103. package/tx/cs/cs-cpt.js +33 -16
  104. package/tx/cs/cs-cs.js +148 -14
  105. package/tx/cs/cs-currency.js +1 -1
  106. package/tx/cs/cs-icd11.js +21 -13
  107. package/tx/cs/cs-icf.js +22 -3
  108. package/tx/cs/cs-lang.js +2 -2
  109. package/tx/cs/cs-loinc.js +185 -66
  110. package/tx/cs/cs-mimetypes.js +12 -2
  111. package/tx/cs/cs-ndc.js +40 -25
  112. package/tx/cs/cs-omop.js +54 -29
  113. package/tx/cs/cs-rxnorm.js +300 -196
  114. package/tx/cs/cs-snomed.js +608 -64
  115. package/tx/cs/cs-ucum.js +14 -4
  116. package/tx/cs/cs-unii.js +18 -14
  117. package/tx/cs/cs-virtual.js +210 -0
  118. package/tx/ecosystem-resolver.js +413 -0
  119. package/tx/html/cluster-dash-metrics.liquid +26 -0
  120. package/tx/html/cluster-home-metrics.liquid +31 -0
  121. package/tx/html/cluster-metrics-script.liquid +251 -0
  122. package/tx/html/codesystem-operations.liquid +3 -3
  123. package/tx/html/ecl-panel.liquid +1 -1
  124. package/tx/html/external-metrics.liquid +17 -0
  125. package/tx/html/operations-form.liquid +17 -17
  126. package/tx/html/search-form.liquid +11 -11
  127. package/tx/html/status-line.liquid +58 -0
  128. package/tx/html/status-panel.liquid +140 -0
  129. package/tx/html/status-tasks.liquid +25 -0
  130. package/tx/html/tx-template.html +2 -5
  131. package/tx/importers/atc-to-fhir.js +17 -16
  132. package/tx/importers/export-icd11-codesystem.js +3 -2
  133. package/tx/importers/import-icd11.js +4 -3
  134. package/tx/importers/import-loinc.module.js +118 -56
  135. package/tx/importers/import-ndc.module.js +56 -44
  136. package/tx/importers/import-rxnorm.module.js +118 -79
  137. package/tx/importers/import-sct.module.js +57 -58
  138. package/tx/importers/import-unii.module.js +37 -36
  139. package/tx/importers/subset-loinc.module.js +49 -48
  140. package/tx/importers/subset-rxnorm.module.js +29 -28
  141. package/tx/importers/terminal.js +18 -0
  142. package/tx/importers/tx-import-base.js +13 -12
  143. package/tx/importers/tx-import-settings.js +5 -4
  144. package/tx/importers/tx-import.js +53 -52
  145. package/tx/library/canonical-resource.js +49 -0
  146. package/tx/library/codesystem.js +94 -5
  147. package/tx/library/designations.js +45 -2
  148. package/tx/library/extensions.js +13 -7
  149. package/tx/library/implicit-valuesets.js +66 -0
  150. package/tx/library/renderer.js +77 -21
  151. package/tx/library/sqlite-utilities.js +2 -0
  152. package/tx/library/ucum-parsers.js +11 -1
  153. package/tx/library/wait-for-cache.js +43 -0
  154. package/tx/library.js +321 -22
  155. package/tx/openapi-operations.js +6 -4
  156. package/tx/operation-context.js +460 -99
  157. package/tx/params.js +25 -0
  158. package/tx/problems.js +3 -5
  159. package/tx/provider.js +278 -42
  160. package/tx/resource-cache-sqlite.js +1050 -0
  161. package/tx/sct/ecl.js +47 -6
  162. package/tx/sct/editions.js +39 -0
  163. package/tx/sct/expressions.js +10 -1
  164. package/tx/sct/load-config.js +107 -0
  165. package/tx/sct/mmap.js +109 -0
  166. package/tx/sct/structures.js +166 -13
  167. package/tx/sct/text-index.js +5 -1
  168. package/tx/syndication/syndication-feed.js +2 -2
  169. package/tx/tx-html.js +44 -8
  170. package/tx/tx.fhir.org.yml +8 -8
  171. package/tx/tx.js +282 -80
  172. package/tx/usage-tracker.js +39 -11
  173. package/tx/vs/vs-api.js +19 -0
  174. package/tx/vs/vs-database.js +386 -62
  175. package/tx/vs/vs-package.js +27 -4
  176. package/tx/vs/vs-vsac.js +648 -100
  177. package/tx/workers/batch-validate.js +37 -10
  178. package/tx/workers/batch.js +6 -1
  179. package/tx/workers/cache-control.js +15 -4
  180. package/tx/workers/closure.js +156 -13
  181. package/tx/workers/compare.js +8 -5
  182. package/tx/workers/expand.js +1173 -289
  183. package/tx/workers/metadata.js +35 -0
  184. package/tx/workers/search.js +53 -1
  185. package/tx/workers/supplement-filters.js +145 -0
  186. package/tx/workers/translate.js +169 -4
  187. package/tx/workers/validate.js +223 -119
  188. package/tx/workers/worker.js +123 -31
  189. package/tx/xversion/xv-valueset.js +7 -4
  190. package/utilities/submit-test-report.js +3 -1
  191. package/utilities/tx-stress-test.js +482 -0
  192. package/vcl/readme.md +37 -0
  193. package/vcl/vcl-parser.js +15 -0
  194. package/xig/readme.md +52 -0
  195. package/xig/xig-template.html +5 -8
  196. package/xig/xig.js +238 -422
  197. package/changelog-template.md +0 -22
  198. package/registry/test-runner.js +0 -237
  199. package/release.sh +0 -80
  200. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  201. package/tsconfig.eslint.json +0 -9
  202. package/tx/html/dash-metrics.liquid +0 -147
  203. package/tx/html/home-metrics.liquid +0 -225
package/cluster.md ADDED
@@ -0,0 +1,707 @@
1
+ # A master and terminology workers on one machine
2
+
3
+ By default, FHIRsmith runs as a simple server, where one Node.js process runs JavaScript on one CPU core.
4
+ A terminology server under load from many clients can use more than one CPU core. To support such usage,
5
+ FHIRsmith can run as one **master** and several **tx-workers** on the same machine and data directory, with
6
+ a front door that sends each request to the right process:
7
+
8
+ ```bash
9
+ node cluster.js # the master, workers by CPU, and a proxy on the configured port
10
+ ```
11
+
12
+ Note that nginx is the front door in either simple server or cluster mode: the launcher is not built to face clients itself.
13
+
14
+ The cluster runs behind a reverse proxy to manage security etc. Note that the reverse proxy can
15
+ also be used to manage a virtual cluster of servers, but under such an arrangement, the servers
16
+ are not tightly connected the way they are when FHIRsmith is run as a single cluster; we do not
17
+ recommend mixing these two approaches together.
18
+
19
+ Also note that if you run `node server.js` directly, the cluster settings are ignored, and you get
20
+ a normal single process server.
21
+
22
+ * [How the Cluster works](#how-the-cluster-works)
23
+ * [Status](#status)
24
+ * [Graphs](#graphs)
25
+ * [Configuration](#configuration)
26
+ * [Options](#options)
27
+ * [Memory](#memory)
28
+ * [How many workers](#how-many-workers)
29
+ * [Limits of this arrangement](#limits-of-this-arrangement)
30
+
31
+ ## How the Cluster works
32
+
33
+ If you are looking to configure a cluster, see [below](#configuration). You don't need to concern yourself with roles etc.;
34
+ this section just explains how the cluster works.
35
+
36
+ `node server.js` with no `--role` is unchanged: one process, every module, as before. One
37
+ difference: `--port` or `--instance-code` without `--role` now stops it with an error, where
38
+ before they were ignored.
39
+
40
+ The settings this uses are under `server.cluster` and `server.memory` in `config.json`;
41
+ [CONFIGURATION.md](CONFIGURATION.md#cluster) lists them with the rest of the server's
42
+ configuration and links back to the tables here.
43
+
44
+ ## Roles
45
+
46
+ ```bash
47
+ node --expose-gc server.js --role=master --instance-code=m0 --port=3001
48
+ node --expose-gc server.js --role=tx-worker --instance-code=tx1 --port=3002
49
+ node --expose-gc server.js --role=tx-worker --instance-code=tx2 --port=3003
50
+ ```
51
+
52
+ (`--expose-gc` lets the memory guard force a GC; the launcher adds it.)
53
+
54
+ | Flag | Environment | Config | Meaning |
55
+ |---|---|---|---|
56
+ | `--role` | `FHIRSMITH_ROLE` | | `master` or `tx-worker`. None: an ordinary single server. |
57
+ | `--instance-code` | `FHIRSMITH_INSTANCE_CODE` | `modules.tx.instanceCode` (master only) | Prefixed onto every cache-id the process issues (see [nginx.md](nginx.md#instance-codes)). Each process needs its own, 1-16 letters and digits. A worker must be given one, since the config's is shared with the master. Started by hand, a process without one stops with an error: a master with none here nor in `modules.tx.instanceCode`, or any worker. The launcher gives them `m0` (or `modules.tx.instanceCode`), `tx1`, `tx2`, ... |
58
+ | `--port` | `PORT` | `server.port` | The port the process listens on. |
59
+
60
+ All the processes use the same data directory and the same `config.json`. The role decides
61
+ which parts of the configuration each one acts on.
62
+
63
+ With a role, a process listens on `127.0.0.1` only, so only a front door on the same host
64
+ (the launcher, or nginx) reaches it. `server.cluster.bindAddress` changes that: `"0.0.0.0"`
65
+ (or `"::"`) for every interface, or one address, for an nginx on another machine. Each
66
+ process logs the address as it starts. With no role, a server listens on every interface,
67
+ as before.
68
+
69
+ ## What runs where
70
+
71
+ | | master | tx-worker |
72
+ |---|---|---|
73
+ | tx module (`/r4`, `/r5` and the other endpoints) | yes (may also serve tx requests) | yes |
74
+ | Every other module (packages, xig, registry, publisher, token, shl, ips, ...) | yes | no |
75
+ | `$closure` (the closure tables) | yes | no: routed to the master (yes, with [`sharedClosure`](#options)) |
76
+ | Syndication feed (the store) | yes | no: routed to the master |
77
+ | VSAC sync, and the resync form on `/info/vsac` | yes | no: reads what the master wrote |
78
+ | Package crawler, XIG download, registry crawl, daily report, npm audit | yes | no (those modules don't run) |
79
+ | IANA media types registry (`terminology-cache/media-types.xml`), downloaded at each load | yes | no: reads the master's copy (none: a warning, and no `registered` filter) |
80
+ | Statistics database, uptime history | yes | no: in memory only |
81
+ | `$cache-control` caches | its own, in memory (shared, with [`sharedCache`](#options)) | its own, in memory (shared, with [`sharedCache`](#options)) |
82
+ | Expansion cache, CodeSystem list refresh | its own | its own |
83
+ | Log file | `logs/server-<date>.log` | the same file (lines interleave) |
84
+
85
+ The master loads the terminologies like any tx server: `$closure` needs subsumption.
86
+
87
+ **VSAC.** The master syncs, and when a sync has written its value sets it adds one to a
88
+ generation counter in the VSAC database. A worker remembers the generation its value set list
89
+ was loaded at. When a request uses the VSAC value sets, the worker reads the counter (one read
90
+ by key, at most once a second) and reloads the list in the background if it has moved, so a
91
+ sync reaches every worker about a second after it finishes. The database is in WAL mode with a
92
+ 5 second busy timeout, so a worker's reload doesn't make the master's writes fail.
93
+
94
+ **The package caches are the master's.** The value set and concept map caches in each
95
+ package folder (`.valuesets-2.db`, `.conceptmaps-2.db`) are built, rebuilt and migrated only
96
+ by the master; it has finished every one before it answers `/health`. A worker opens them
97
+ read-only, and never creates, rebuilds or migrates one: if one is missing, incomplete or still
98
+ needs a migration (a worker started on its own, before the master), the worker waits for the
99
+ master to finish it, and after 5 minutes (`FHIRSMITH_CACHE_WAIT_MS`) stops with an error that
100
+ says so. Starting the master first is what makes the wait unnecessary, not what keeps the
101
+ workers from writing.
102
+
103
+ The same goes for the caches of a `folder:` or `file:` library source
104
+ (`terminology-cache/folder-source-master-<hash>`, one folder for every process). Each time the
105
+ master loads the library it rebuilds them in a temporary sibling folder and renames it into
106
+ place, so a running worker never finds one half built; a worker reads the new one when it next
107
+ starts. Only the master removes the folders of sources no longer loaded.
108
+
109
+ **The VSAC, LOINC and RxNorm databases are the master's too.** The master sets them up as it
110
+ loads: the VSAC schema and content hashes, LOINC's journal mode, and the LOINC and RxNorm
111
+ indexes. A worker opens them read-only and writes nothing, so they can be on read-only storage
112
+ for the workers. The NDC, UNII, CPT and OMOP databases need no setup, and a worker opens them
113
+ read-only as well. A worker started before the master uses LOINC and RxNorm as they are
114
+ (without an index the master hasn't made yet, lookups are slower), and has no VSAC value sets
115
+ until the master has made and migrated that database.
116
+
117
+ **Nothing else is shared.** The workers run no module but tx, so they never read what the
118
+ package crawler, the XIG download or the registry crawl write.
119
+
120
+ ## Routing
121
+
122
+ Whatever the front door, the rules are the same:
123
+
124
+ 1. `$closure` (in any endpoint, e.g. `/r4/$closure`), the syndication feed
125
+ (`/r4/syndication...`) and the source info pages (`/r4/info/...`) go to the **master**.
126
+ 2. Every path outside the tx endpoints (`/`, `/packages`, `/xig`, `/dashboard`, `/health`, ...)
127
+ goes to the **master**.
128
+ 3. A tx request with an `X-Cache-Id` goes to the process whose code prefixes the id
129
+ (`tx2.mhp4x2k9.4f0c...` to `tx2`). If that process is down (restarting), its caches are gone, and the
130
+ launcher answers at once with `404` and `cache-id-unknown`, "lost to a server restart"
131
+ (with `sharedCache`, any tx process takes it instead). If the prefix is unknown, it goes to
132
+ any tx process, which answers `cache-id-unknown`. Either way the client starts a new cache.
133
+ 4. Any other tx request goes to the tx process with the fewest requests outstanding: the
134
+ workers, and the master too unless it is set not to serve tx.
135
+
136
+ The [options](#options) change rules 1 and 3: with `sharedClosure`, `$closure` goes by rule 4;
137
+ with `sharedCache`, so does a request with a cache-id (unless `cacheAffinity` is on).
138
+
139
+ ## Nginx note about clusters
140
+
141
+ **Always run the launcher behind nginx, on the same host, with request buffering on**
142
+ (`proxy_request_buffering on`, nginx's default), as one upstream:
143
+
144
+ ```nginx
145
+ upstream fhirsmith { server 127.0.0.1:3000; keepalive 32; }
146
+ # in the server block, for every location:
147
+ # include fhirsmith-proxy.conf; (nginx.md: the X-Forwarded-* headers)
148
+ # proxy_pass http://fhirsmith;
149
+ ```
150
+
151
+ With buffering on, nginx reads each request body whole and sends it on with a
152
+ `Content-Length`. The launcher passes a chunked body on chunked, but it has none of nginx's
153
+ limits on what clients send. The same host, because the launcher believes `X-Forwarded-Proto`
154
+ only from a loopback address: from any other it sets it to `http`, so behind an HTTPS nginx on
155
+ another machine the servers would advertise `http://` URLs, and the publisher's `Secure` login
156
+ cookie would be lost (see [nginx.md](nginx.md#headers-to-pass-to-the-server)).
157
+
158
+ ## The launcher (cluster.js)
159
+
160
+ `node cluster.js` reads `config.json` from the data directory and:
161
+
162
+ * starts the master and waits until it is ready: each process the launcher starts says so on
163
+ its IPC channel as it begins to listen, so neither a process still shutting down on the same
164
+ port nor the memory guard (which may refuse `/health`) can mislead it. On a new data
165
+ directory the master builds the package caches and creates the VSAC database
166
+ * listens on the configured port, and starts the workers one after another, each once the one
167
+ before is ready
168
+ * proxies each request by the rules above, over keep-alive connections to each process. It
169
+ adds the client address to `X-Forwarded-For`, so the servers' logs and rate limits see the
170
+ real client (its own address is a loopback one, which `server.trustProxy` trusts by default)
171
+ * sends each process at most `maxOutstanding` tx requests at once (16); the rest wait in the
172
+ launcher, oldest first, and one that has waited `queueSeconds` (1) is refused with 503 and
173
+ `Retry-After`. Without this, a surge queues inside the processes, where everything waits
174
+ behind everything else: a 4 ms `$lookup` waited 16 s in a 3x surge, and the first 503s came
175
+ after 29 s. A request with a cache-id waits for the process that issued it, which no other
176
+ can stand in for, so it waits longer: `ownerQueueSeconds` (30). So does any request routed
177
+ by its cache-id's prefix (rule 3): one whose prefix no process has (any tx process may take
178
+ it, but it waits as long), and, with `sharedCache` and `cacheAffinity`, one whose issuer is
179
+ full. The master's own pages and paths (the dashboard, the syndication feed, `$closure`
180
+ unless `sharedClosure` sends it to the tx processes, where it waits like any tx request,
181
+ ...) and CORS preflights (`OPTIONS`) neither wait nor count against `maxOutstanding`, so a long page on the master leaves its tx slots free; while
182
+ stopping nothing waits. These refusals are the launcher's, logged at most once a minute; they
183
+ are not in the graphs' refusals
184
+ * gives cheap requests a fast lane. A process has `maxOutstanding` + `fastLaneExtra` (16 + 4)
185
+ slots. Cheap requests may use any of them, and when a slot frees they get it first. Other
186
+ requests use up to `maxOutstanding`, or more (up to `maxOutstanding` + `fastLaneExtra`)
187
+ while fewer than `normalMinSlots` of them are running on the process. While they wait, the
188
+ lane leaves them enough slots to have `normalMinSlots` (4) running on each process, so heavy work goes on during a flood of
189
+ cheap requests. Without the lane, a 4 ms `$lookup` waits in line behind the surge (3 s, then
190
+ 503), and after it, for the line to drain. The launcher
191
+ doesn't read bodies, so the rule is by method and path: a `GET` (or `HEAD`) of `metadata`,
192
+ `$lookup`, `$subsumes` or `$validate-code`, whose parameters are all in the URL, so it carries
193
+ no inline value set. A `POST` of `$lookup`, `$subsumes` or `$validate-code` takes the lane
194
+ only with a `Content-Length` of at most `fastLaneMaxBytes` (1 KB: one coding, no inline value
195
+ set or code system). Reads (`/CodeSystem/{id}`: a large code system is costly to render),
196
+ searches and `$expand` never do. A request in the lane that turns out to be costly (a
197
+ `GET $validate-code` against a value set that must be expanded) costs at most `fastLaneExtra`
198
+ more requests at once on a process
199
+ * sends no new requests to a process that answered 503 (its memory guard is refusing) for
200
+ `cooldownSeconds` (2), unless every process is refusing. Otherwise the refusing process,
201
+ quick to answer and so the least busy, would draw the waiting requests
202
+ * answers `504` for a process that hasn't begun its response `upstreamTimeoutSeconds` (60)
203
+ after the request was sent to it (a stuck event loop, a hung native call), aborts that
204
+ request, which frees its slot, and logs a warning naming the process. After 3 in a row from
205
+ one process, it gets no new requests for `cooldownSeconds`, as after a 503, while another
206
+ can take them. It is not restarted for this. A response that has begun is not timed: a
207
+ long stream goes on
208
+ * frees a tx request's slot when the process's response has ended, not when the client has
209
+ read it: the launcher reads a tx response from the process as fast as it comes (the process
210
+ held it whole in memory already) and holds what the client hasn't taken yet. Any other
211
+ response (a package download can be large) is passed on only as fast as the client takes
212
+ it. Either way, a client that takes none of what is waiting for it for `writeTimeoutSeconds`
213
+ (60) has its connection closed, and the launcher logs it (at most once a minute, with a
214
+ count). Without this, about 20 clients asking for large responses and never reading them
215
+ would hold every slot of a process, and every tx request would get 503
216
+ * logs its limits (`maxOutstanding`, the three waits, `upstreamTimeoutSeconds`, `writeTimeoutSeconds`, the fast lane's two, `memoryHeadroomGB`,
217
+ `normalMinSlots`, `drainSeconds`, `trimAllocator`, `recycleSeconds`) as it starts, in one line
218
+ * starts again a process that exits. One that keeps failing waits longer each time: 1 s,
219
+ 2 s, 4 s ... up to a minute. A process that exits before it is ready, or within a minute of
220
+ being ready, counts as failing; one that ran longer is restarted after 1 s. If the master
221
+ exits `maxStartFailures` (6) times in a row without ever having been ready, the launcher
222
+ logs an `ERROR` and exits 1, so the service manager sees the failure
223
+ * restarts a tx-worker whose memory has stayed over the memory guard's refusal threshold for
224
+ `recycleSeconds` (30) with no request at work on it (see [Memory](#memory)). It sends it no
225
+ new requests, lets those it has finish (up to `drainSeconds`), stops it as on shutdown and
226
+ starts it again at once
227
+ * starts each process with its own Node flags (`node --max-old-space-size=... cluster.js`
228
+ passes them on, with `--expose-gc` added), except a debugger's (`--inspect...`) and a
229
+ watcher's (`--watch...`), which are the launcher's own
230
+ * on `SIGTERM` or `SIGINT`: stops taking connections, closes idle keep-alive connections,
231
+ waits for the requests in progress (up to `drainSeconds`), then sends each process
232
+ `SIGTERM` (each shuts down as a single server does; `SIGKILL` after 30 seconds), and exits
233
+ * if the launcher itself dies (even by `SIGKILL`), each process it started sees its IPC channel
234
+ close and shuts down, so none is left holding its port against the next launcher. A
235
+ process started by hand has no channel, and this doesn't apply to it
236
+
237
+ ## Status
238
+
239
+ Under the launcher, the master's `/` and `/dashboard` show the whole cluster. Every
240
+ `statusSeconds` (15) each process sends the launcher a few figures of its own over its IPC
241
+ channel: its JS heap, its client caches and their concepts, its expansion cache, the requests
242
+ it has in flight and whether its memory guard is refusing. The launcher adds what it reads
243
+ itself, merges them, and passes the snapshot to the master.
244
+
245
+ The page keeps to the space it had before (it is often on a wall screen). Top to bottom:
246
+
247
+ * **One line**: Requests (the processes' own counts, summed, and the average per minute),
248
+ Uptime (the master's), Heap (every process's JS heap and the launcher's, summed, and how many
249
+ that is), and Problems, only when there are any.
250
+ * **The problems**, a line each with a ⚠ and a sentence: processes refusing requests, or the
251
+ memory guard's measure at 90% or more of its refusal threshold; a process that isn't ready
252
+ (starting, restarting); figures gone stale; restarts since launch; under 10% of the disk
253
+ free; a background task whose last run failed; and a serious npm audit finding.
254
+ * **The graphs** (see [Graphs](#graphs)); their key is under More.
255
+ * **Background tasks**: one line naming those at work, those whose last run failed (each is
256
+ also a problem, with its state) and the idle ones, then a row for each task at work.
257
+ * **One line** for the server's own upkeep: on `/dashboard`, the FHIRsmith release (and whether
258
+ a newer one is out) and npm audit's result; and, in red, low disk space (under 10% free).
259
+ The npm audit advice and findings follow it when there are any.
260
+ * **More** (closed until opened): the graphs' key, the status panel below, Storage, every
261
+ background task, and on `/dashboard` the data folder.
262
+
263
+ The status panel, under More:
264
+
265
+ * **Cluster** · the number of processes · how long ago the snapshot was taken.
266
+ * **Tiles**:
267
+ * Requests: those the processes served, each its own count since it started, summed; the
268
+ sub-label is the average per minute.
269
+ * Client caches: summed, with their concepts; with `sharedCache`, the one store, counted once
270
+ (the master's count).
271
+ * Expansion cache: summed; each process has its own.
272
+ * In flight: summed, and which processes are refusing.
273
+ * Restarts: since the launcher started.
274
+ * Uptime: the master's.
275
+ * **Memory** · cgroup private: the cgroup's `memory.stat` `anon` (cgroup v1 `total_rss`), what
276
+ the memory guard measures (see [Memory](#memory)), as a bar against the limit. It has a
277
+ segment per process (its `RssAnon`), in the graph lines' colours, and ticks at the refuse
278
+ and abort thresholds, each labelled on one line under its tick (the refuse label moves left
279
+ of the abort label when they would meet, and to a second line only when there is no room).
280
+ The host's memory in use is under it.
281
+ * **A row per process and the launcher**: status, uptime, restarts, requests, private and
282
+ mapped memory in MB (`RssAnon` and `RssFile` from `/proc/<pid>/status`), heap in MB (with
283
+ the limit in the header when every process has the same), client caches and concepts,
284
+ requests in flight, and the age of its figures. The launcher's requests are left out: what
285
+ it passes on includes static files and pages, so it would not add up. `—` means a figure
286
+ doesn't apply or isn't known. A process whose figures are more than three intervals old is
287
+ `stale` and left out of the totals; one that is restarting has none until its replacement
288
+ sends some.
289
+ * **Footnotes**: one line on mapped memory, and anything that couldn't be read. A page
290
+ several processes map counts in each process's mapped figure, and the kernel charges a page
291
+ to the cgroup that first read it, so after a restart pages still cached from the last run are
292
+ charged elsewhere: mapped memory is not in the meter.
293
+ * Then **Storage** (closure tables, entries and concepts, the closure database, the logs and
294
+ the free disk, as many columns as there are tiles), then **All background tasks** in the
295
+ table's style.
296
+
297
+ Nothing is made up. Without `/proc` (macOS) there is no per-process memory. Without a cgroup
298
+ with a memory limit of the service's own (no `MemoryMax`, or the limit only on an enclosing
299
+ slice), the meter is the processes' private memory summed, against the limit each process's
300
+ guard measures itself against. A footnote says what is missing and why.
301
+
302
+ The same snapshot is at **`/dashboard/status.json`**: `scope: "cluster"`, `sampledAt`,
303
+ `intervalMs`, `memory` (`cgroup` `{anon, file}` or null, `processAnon`, `limit`, `limitSource`,
304
+ `rejectAt`, `abortAt`, `rejectFraction`, `abortFraction`, `group`), `caches` (`scope`:
305
+ `summed` or `shared`, `clientCaches`, `clientConcepts`, `expansionItems`), `inFlight`,
306
+ `refusing` (instance codes), `processes` (one object per process, with the columns above, in
307
+ bytes and milliseconds; null where not known), `launcher`, `missing`, and `requests`: the
308
+ master's statistics, its own count and the workers' it has merged (see
309
+ [Request counts](#request-counts)). Under `/dashboard`, the memory guard never refuses it. A
310
+ worker, a server with no role, or a master before its first snapshot answers with its own
311
+ figures: `scope: "process"`, `process`, `memory` (its guard's limit and thresholds) and
312
+ `requests`.
313
+
314
+ A worker's own page, a master before the launcher's first snapshot, and a server with no role
315
+ show the same line for the one process (Heap: its own, of its limit), and under More the same
316
+ tiles and meter, headed **Worker tx1** · this process only,
317
+ **Master m0** · waiting for the launcher's first snapshot, or **Server** · one process. Their
318
+ meter is what their memory guard measures: the cgroup's private memory with a role (this
319
+ process's segment, the rest hatched as "other"), or the process's own RSS without.
320
+
321
+ ## Graphs
322
+
323
+ With the launcher, `/` and `/dashboard` show the master and every worker as lines on the
324
+ existing metric panels, with one legend above them. Colour (by lightness), line pattern and
325
+ marker each tell the processes apart; up to eight are drawn, and past eight the legend says
326
+ how many are left out. Each process takes a sample every `stats.sampleSeconds`: with a role,
327
+ a minute unless set.
328
+
329
+ The panels sit on the tiles' grid, two tiles' width each. Until a panel has two samples to draw
330
+ a line between, it says it is waiting for them. The memory panel is each process's private
331
+ memory (`RssAnon`), with a thick dark Total: the
332
+ cgroup's private memory, what the memory guard measures, which the launcher reads when the
333
+ master's sample arrives (without a cgroup of the service's own, the processes' summed), and a
334
+ grey dash-dot line, labelled with its value, at the guard's refusal threshold. Where the
335
+ private memory can't be read (not Linux), the panel is the heap change since each process
336
+ started, as before, and its title says so. Requests per Minute and Client Cache add a Total
337
+ too: every process's samples summed per sampling interval. With `sharedCache`, every process
338
+ samples the same shared client-cache counts, so those lines overlap, and the Total takes the
339
+ count once rather than summing it. Turnaround and the blocking panels have no total, since a
340
+ summed turnaround means nothing.
341
+
342
+ The memory guard counts its 503 refusals before body parsing. Grey hatching on all
343
+ charts marks sampling intervals containing those refusals; it does not mean the guard
344
+ was closed continuously throughout an interval. The note above the charts counts
345
+ refusals in the displayed samples, and hovering a process point shows that sample's
346
+ count. Request-rate lines and turnaround averages continue to describe admitted work.
347
+ Client timeouts, unsent load-generator arrivals and proxy errors are not guard refusals.
348
+
349
+ Samples keep their actual timestamps. A restarting process leaves a gap and retains its
350
+ earlier history; a skipped sampling interval also leaves a gap. The legend reports
351
+ starting, restarting, unavailable or no recent sample when applicable. History lives in
352
+ the launcher for up to 24 hours, capped at 1,440 points per process (including gaps), so
353
+ a `sampleSeconds` under a minute covers less than 24 hours. A launcher restart clears
354
+ the graph history. The table above the graphs is described under [Status](#status).
355
+
356
+ ### Request counts
357
+
358
+ A tx-worker has no statistics database and sends no daily report. Under the launcher, each
359
+ worker sends the requests it counted to the master every minute, and once more when it stops
360
+ (the launcher stops the workers before the master). The master adds them to its own counts,
361
+ so its statistics database (`databases/stats.db`) and the daily report count every process,
362
+ each request once. The counting has these limits:
363
+
364
+ * **A worker that crashes loses up to a minute of its counts**: those it hadn't sent yet. (A
365
+ worker that is stopped sends them as it stops.)
366
+ * **A master that crashes loses the counts it hasn't written yet**, its own and the workers'
367
+ it has merged, as a single server loses its own today.
368
+ * **Counts can land in the next day's report.** A worker's requests reach the database with
369
+ the master's next write, up to a minute or so after they were served, so a request just
370
+ before midnight can be counted in the next day.
371
+ * **Workers started by hand behind nginx have no IPC channel**, so their requests never reach
372
+ the master's `stats.db` or daily report (see [Limits](#limits-of-this-arrangement)).
373
+
374
+ `/dashboard/status.json` gives this total (`requests`: the master's and the workers' parts),
375
+ the workers' up to a minute behind. The status panel's Requests tile counts differently: the
376
+ sum of what each process reports every `statusSeconds`, since each started (see
377
+ [Status](#status)). A worker's own page shows only its own.
378
+
379
+ A standalone server, or roles started separately behind nginx, shows only its own graph
380
+ data. Combining process histories requires the launcher's IPC connection; no separate
381
+ metrics service or HTTP polling is used. Its page draws the same panels on the same grid, with
382
+ one series ("This process", or the worker's code on a worker's own page): its private memory
383
+ with its memory guard's refusal threshold, and no Total. A server with no role samples every
384
+ `stats.intervalMinutes` (10), as before, unless `stats.sampleSeconds` is set.
385
+
386
+ ### The daily report
387
+
388
+ The daily report (see [CONFIGURATION.md](CONFIGURATION.md#email-and-the-daily-report)) is the
389
+ master's. Its stability paragraph is about the master's own runs. Only the launcher knows what
390
+ happened to the workers, and what it answered itself, so it sends the master what it saw, the
391
+ same way it passes on the workers' request counts, and the master keeps it in `stats.db`
392
+ (table `cluster_events`). The report has a **Cluster** paragraph after the stability one:
393
+
394
+ * when the launcher started (a launcher restart restarts every process);
395
+ * for each process that exited or was recycled: how many times and how (the exit code or
396
+ signal, or why it was recycled), how long it was unavailable in all (from its exit, or the
397
+ start of its recycle, until it was ready again), and the requests that were lost to it (504:
398
+ no response in `upstreamTimeoutSeconds`; 502: it didn't answer; a cache-id that came while
399
+ it was restarting);
400
+ * the requests the launcher refused itself (503: no process free in `queueSeconds`, no process
401
+ ready, or stopping), and the connections it closed because the client took none of a
402
+ response in `writeTimeoutSeconds`.
403
+
404
+ Events go to the master as they happen, and what was counted every minute; while the master is
405
+ down they wait in the launcher (up to 5,000 events, the oldest dropped first) and go when it
406
+ is ready again. So:
407
+
408
+ * **a process still unavailable at midnight** has that time in the next day's report, when it
409
+ is ready again;
410
+ * **what the launcher saw is lost if the launcher stops while the master is down**, including
411
+ when it gives up starting the master (`maxStartFailures`). That shows as an outage in the
412
+ stability paragraph;
413
+ * **without the launcher** (roles behind nginx), there is no Cluster paragraph.
414
+
415
+ ## Configuration
416
+
417
+ ```json
418
+ "server": {
419
+ "port": 3000,
420
+ "cluster": { "workers": 4, "masterServesTx": true, "internalPort": 3001, "drainSeconds": 30 }
421
+ }
422
+ ```
423
+
424
+ | Setting | Flag | Default | Meaning |
425
+ |---|---|---|---|
426
+ | `server.port` | `--port`, `PORT` | 3000 | The port clients use. |
427
+ | `server.cluster.workers` | `--workers`, `FHIRSMITH_WORKERS` | from the CPUs, or fewer if memory is short | How many tx-workers. Not given: by the engine's rule from the physical cores and vCPUs (see [How many workers](#how-many-workers)), then lowered to as many as fit under the memory guard's refusal threshold with `memoryHeadroomGB` to spare once loaded (at least 1; see [Memory](#memory)). Given: that many, always, with a warning if they don't fit. A whole number (anything else stops the launcher with an error); 0: the master alone. |
428
+ | `server.cluster.cores` | | detected | The physical cores the rule uses, instead of the detected ones: for a machine whose topology the launcher can't see or reports wrongly. |
429
+ | `server.cluster.workerRule` | | the engine's | The rule by name (`classic`, or one an engine adds). |
430
+ | `server.cluster.memoryHeadroomGB` | | 2 | Memory to leave free under the refusal threshold once every process is loaded and idle. 2: about what one heavy operation uses (a large SNOMED `$expand`, a big `$batch-validate-code`); with less, the first one shuts the gate for everyone. |
431
+ | `server.cluster.masterServesTx` | `--master-serves-tx=false` | true | Whether the master also takes ordinary tx requests. |
432
+ | `server.cluster.internalPort` | `--internal-port` | port + 1 | The master's port; worker *n* gets this + *n*. A range that includes `server.port` stops the launcher with an error. |
433
+ | `server.cluster.bindAddress` | | `127.0.0.1` | The address the master and the workers listen on (see [Roles](#roles)), and the launcher reaches them at (loopback for `"0.0.0.0"` or `"::"`). Not the launcher's own port, which is `server.port` on every interface. |
434
+ | `server.cluster.drainSeconds` | | 30 | How long the requests in progress get to finish when the launcher stops, and on a tx-worker before a memory recycle stops it; a request still running then is cut short. 30: half again the operation limit (`modules.tx.operationTimeLimit`, 20 s), so an operation in progress finishes or reaches its limit. 0: no wait. |
435
+ | `server.cluster.maxOutstanding` | | 16 | Tx requests sent to one process at once; the rest wait in the launcher. The master's own paths and CORS preflights don't count. 16: a process is one event loop whose operations take turns every 25 ms, so a cheap request waits a turn behind each heavy one, and 16 keeps that under half a second. More cores don't change it. 0: no limit (nothing waits). |
436
+ | `server.cluster.queueSeconds` | | 1 | Seconds a tx request may wait for a process with room. 1: a client is better told the server is busy than left to its own timeout, and when a surge ends, the line it left drains within this long (with 3, the line took 3 s to clear and the requests just after the surge waited behind it: a `$lookup` took 1.4 s for 5 s; with 1, under 10 ms after 2 s, at the same goodput). When hit, the client gets 503, `Retry-After: 5` and `Connection: close`. 0: that 503 comes at once. |
437
+ | `server.cluster.ownerQueueSeconds` | | 30 | The same, for a request with a cache-id, which only the process that issued the id can take. 30: the request has nowhere else to go, so it waits about as long as a client's own timeout. 0: 503 at once. |
438
+ | `server.cluster.cooldownSeconds` | | 2 | Seconds a process that answered 503 (its memory guard refusing) gets no new requests while another can take them. 2: the guard re-measures only when a request arrives, so a longer pause holds its recovery back. Clients see nothing; their requests go to another process. 0: no pause. |
439
+ | `server.cluster.upstreamTimeoutSeconds` | | 60 | Seconds from sending a request to a process until its response begins, after which the client gets 504 (an OperationOutcome naming the process) and the request is aborted. 60: an operation stops at its limit (`modules.tx.operationTimeLimit`, 20 s), but that limit counts the operation's own compute, not the turns it waits while the others on its event loop run (up to `maxOutstanding` of them), so a heavy operation in a busy process takes longer than 20 s by the clock. Three times the limit leaves room for that; a process silent for longer is taken to be wedged (a stuck event loop, a hung native call), not working. The time a request waits in the launcher's queue is not counted. Raise it with the operation limit. 0: no timeout. |
440
+ | `server.cluster.writeTimeoutSeconds` | | 60 | Seconds a client may take none of the response waiting for it in the launcher before its connection is closed (the client sees the response cut short). It bounds how long a client that doesn't read holds the launcher's memory (a tx response) or a process's request (any other). 60: a client that reads takes some within milliseconds, so this is about a thousand times what a reading client needs; it is nginx's own `send_timeout`, and behind nginx with buffering on (the default), nginx takes the response at once and this never fires. 0: no timeout. |
441
+ | `server.cluster.maxStartFailures` | | 6 | Times in a row the master may exit without ever having been ready before the launcher gives up and exits 1. Without it, a master that can't start left a launcher that ran but never listened, which systemd counts as up. 6: the waits between starts (1, 2, 4, 8, 16 s) put the sixth start 31 s after the first failure, past the 30 s an old process still shutting down may hold the port for. Once the master has been ready, it is started again however often it exits, as a worker always is. Clients see no difference: nothing listens either way. Logged with the master's first start. 0: never give up. |
442
+ | `server.cluster.recycleSeconds` | | 30 | Seconds a tx-worker may stay over the memory guard's refusal threshold with no request at work before the launcher restarts it. 30: long enough for the guard's own recovery (a forced last-resort GC and `malloc_trim` at once and again 5 s later, `malloc_trim` alone up to once a second) to have its effect, short next to a client's retries; meanwhile every request to the server is refused. The restart costs the worker's caches (see [Limits](#limits-of-this-arrangement)) and the about 15 s it takes to start; the others serve meanwhile, and the gate opens once the old process has exited. 0: never. |
443
+ | `server.cluster.fastLaneExtra` | | 4 | How many more than `maxOutstanding` a process may have when the request is in the fast lane (above). 4: room for the cheap requests of a surge without letting a lane request that turns out to be costly crowd a process. 0: no fast lane. |
444
+ | `server.cluster.normalMinSlots` | | 4 | While other requests wait, the fast lane leaves each process enough slots for this many of them to run, so heavy work keeps going during a flood of cheap requests. 4: in a 3x surge on 4 CPUs (1 master + 2 workers) the heavy requests answered were the same with 4, 8 or no minimum (about 7,100-7,400 in 30 s), but each heavy request running slows the cheap ones on its event loop: the `$lookup` probe took 88 ms (median) with 4, 150-210 ms with 8. With the lane held to 4 slots of its own, the surge was served at 457/s, not about 1,200/s, and the probe took 1.1 s. 0: no minimum. At most `maxOutstanding`. |
445
+ | `server.cluster.fastLaneMaxBytes` | | 1024 | The largest `Content-Length` with which a `POST` `$lookup`, `$subsumes` or `$validate-code` takes the fast lane. 1024: a single coding with its parameters is about 450 bytes; an inline value set or code system is larger. Half of a validator's calls are such `POST CodeSystem/$validate-code`s, and with them in the lane a 3x surge on 4 CPUs was served at twice the rate (1210/s vs 608/s, with the same CPU) and they took 24 ms instead of 1 s; the other requests did no worse. 0: a `POST` never does. |
446
+ | `server.memory.trimAllocator` | | true with a role; false without | Whether a process calls `malloc_trim` (Linux with glibc, and the optional `koffi` dependency): with a role, once after loading the library (it gives back 0.6-0.7 GB a process), and while the memory guard is refusing (see [Memory](#memory)). true: the C allocator keeps freed memory resident, and no GC touches it. false with a role: for diagnosis. Without a role it is off unless set true. Each process's memory line at start says whether it is on. |
447
+ | `modules.tx.instanceCode` | | `m0` under the launcher | The master's instance code. The workers are `tx1`, `tx2`, ... The `m0` default is the launcher's: a master started by hand needs this, `--instance-code` or `FHIRSMITH_INSTANCE_CODE` (see [Roles](#roles)). |
448
+ | `server.cluster.statusSeconds` | | 15 | How often each process sends the launcher its figures, and the launcher sends the master its snapshot of them all, for the status panel and `/dashboard/status.json` (see [Status](#status)). 15: the panel is never more than half a minute behind, and each send is a few hundred bytes. A number above 0. |
449
+ | `stats.sampleSeconds` | | 60, with a role | How often each process takes a graph sample (see [Graphs](#graphs)). Not set: a minute with a role (or `stats.intervalMinutes`, if shorter), which with the launcher's 1,440 points a process keeps 24 hours of history; with no role, `stats.intervalMinutes` (10) as before. Shorter shows a short session in more detail, and keeps less history. The request counters are still written every `intervalMinutes`. |
450
+
451
+ Under systemd, the launcher and its processes are one unit and one cgroup:
452
+
453
+ ```ini
454
+ [Service]
455
+ WorkingDirectory=/opt/fhirsmith
456
+ Environment=FHIRSMITH_DATA_DIR=/var/lib/fhirsmith
457
+ ExecStart=/usr/bin/node cluster.js
458
+ KillMode=mixed
459
+ MemoryMax=16G
460
+ TimeoutStopSec=2min
461
+ ```
462
+
463
+ A stop can take `drainSeconds` (30), then up to 30 s for the workers to shut down, 5 s for
464
+ their last request counts, and up to 30 s for the master: 95 s at the most, usually a few
465
+ seconds. So give the service manager longer than that before it kills everything. systemd's
466
+ default `TimeoutStopSec` is 90 s. Docker's default stop timeout is 10 s, shorter than the drain
467
+ alone, after which it kills the launcher and every process with it, mid-request; set
468
+ `stop_grace_period: 2m` (Compose) or `docker stop -t 120`, and run the launcher as the
469
+ container's main process (`CMD ["node", "cluster.js"]` in place of the image's `server.js`,
470
+ not under a shell) so it gets the `SIGTERM`.
471
+
472
+ ## Options
473
+
474
+ Two options in `server.cluster` change what the processes share. Both are off by default, and
475
+ both only apply with a role: `node server.js` with no role ignores them.
476
+
477
+ ```json
478
+ "server": {
479
+ "cluster": { "workers": 4, "sharedCache": true, "sharedClosure": true }
480
+ }
481
+ ```
482
+
483
+ | Setting | Default | Meaning |
484
+ |---|---|---|
485
+ | `server.cluster.sharedCache` | false | The `$cache-control` caches go in one SQLite file, `databases/resource-cache.db`, that every process reads and writes. |
486
+ | `server.cluster.cacheAffinity` | false | With `sharedCache`: still send a request with a cache-id to the process that issued it, while it is up. |
487
+ | `server.cluster.sharedCacheMaxMB` | 256 | With `sharedCache`: the most client resources (their JSON, in MB) the file holds, every endpoint's caches together. Past it, the caches least recently used are dropped whole, and a request with a dropped cache-id gets `404` `cache-id-unknown` (`CACHE_ID_EVICTED_SIZE`, which names the cap), so the client starts a new cache. 256: the largest IG Publisher cache session in a corpus of 2,817 from builds against tx.fhir.org held 3.5 MB of resources (p99 2.8 MB, median 55 KB), so this is about 70 times the largest; `cacheMaxTotalConcepts` gives one endpoint about 40 times the largest session's concepts, and this is that room for two busy endpoints. The file on disk is larger than what it holds (indexes, and the pages SQLite keeps for reuse). Each process logs the cap as it starts. 0: no cap. |
488
+ | `server.cluster.sharedClosure` | false | Every tx process answers `$closure`, on the one closure database. |
489
+ | `modules.tx.closure.writeRetries` | 10 | With `sharedClosure`: how many times an add is worked out again when another process changed the table first. |
490
+
491
+ ### Shared cache
492
+
493
+ Without it, a cache lives in the memory of the process that issued it, and the routing has to
494
+ send every request that uses it there. A restarted process has lost its caches, and its
495
+ clients get `cache-id-unknown` and start new ones. The load a client brings stays on one
496
+ process. Its cache-ids are `<code>.<start>.<uuid>`, where `<start>` is when the process
497
+ started (base 36), so a restarted process tells an id it issued before the restart ("lost to
498
+ a server restart") from one it never issued.
499
+
500
+ With it, any process can answer any cache-id. **Caches are not carried over a server
501
+ restart**: a client can't be sure a cache is intact across operations it tried while the
502
+ server was down, and the server can't either. So **whenever the master starts** (a master
503
+ restarted by the launcher, a new launcher run, or a master started by hand behind nginx),
504
+ every live cache becomes a tombstone, and a request that uses one gets `cache-id-unknown`
505
+ with "lost to a server restart"; the client starts a new cache. A tx-worker that restarts
506
+ while the master carries on keeps every cache. Workers that survive a master restart issue
507
+ new caches in its new generation. An id that was never issued is reported as never issued.
508
+
509
+ The launcher sends a request with a cache-id to the least-loaded tx process,
510
+ like any other. The cost is a read of the file on each use of a cache: a request reads the
511
+ list of what the cache holds, and only loads the resources it uses. Writes are short
512
+ transactions in WAL mode, so readers don't wait for them. `last_used` is written at most every
513
+ 30 seconds per cache. The idle sweep (`modules.tx.cacheTimeout`) runs in every process, and
514
+ running it twice does no harm. Cache-ids are `<code>.<generation>.<random>`: two 22-character
515
+ tokens with no `.` in them, 62 characters with a 16-character code, inside a FHIR id's 64.
516
+ Clients should treat them as opaque. Writes check the generation inside their transaction, so
517
+ a request in progress across a master start cannot recreate an old cache, and a new cache is
518
+ given its id inside the transaction that creates it. A lazy read that comes after a master
519
+ start also gets `404` and `cache-id-unknown`.
520
+
521
+ If another request replaces a resource after its index was read but before its content is
522
+ loaded, the request returns `409` (`CACHE_ID_CHANGED_DURING_REQUEST`, tx-issue-type
523
+ `cannot-determine`). If the cache ends meanwhile (closed, expired, dropped), it returns `404`
524
+ `cache-id-unknown` (`CACHE_ID_GONE_DURING_REQUEST`), and the client starts a new cache. After
525
+ a `409`, retry the whole request to read the new contents together. This prevents using new content under an old
526
+ expansion-cache key; unchanged resources and additions of unrelated resources still work.
527
+
528
+ `cacheAffinity: true` keeps the prefix routing on top. The expansion cache is still per
529
+ process, so a client that stays on one process finds its expansions there; without affinity,
530
+ each process does the first expansion again. The file must be on a local disk.
531
+
532
+ ### Shared closure
533
+
534
+ Without it, the master answers every `$closure` request, and its per-table lock keeps the
535
+ calls on one table in order.
536
+
537
+ With it, any tx process answers `$closure`, against the same `databases/closure.db`, and the
538
+ launcher sends `$closure` to the least-loaded tx process. Within a process the per-table lock
539
+ still queues the calls on a table. Across processes the writes are **checked, not locked**. A
540
+ call works out its add against the table as it read it (its version *n*). It writes in one
541
+ `BEGIN IMMEDIATE` transaction that first checks the table is still at version *n*, and was
542
+ not reset meanwhile. If it isn't, nothing is written: the call reads the table again and works
543
+ the add out again, with the code lookups and subsumption tests it already did remembered. It
544
+ waits a few milliseconds, with jitter, between tries. After the first try and `writeRetries`
545
+ retries (`writeRetries` + 1 tries in all) it answers
546
+ `429` (`CLOSURE_TABLE_BUSY`), as a full queue does. Creating a table checks for one of the
547
+ same name, and for the table limit, in the same transaction as the insert. A resync reports
548
+ only entries up to the version it read. The master alone drops stale tables
549
+ (`retentionDays`).
550
+
551
+ Shared table IDs are never reused after reset or disposal, including across restarts; an
552
+ existing database is upgraded in place. An add that races a reset retries internally with
553
+ the new configuration and fresh lookup state. A resync interrupted by reset returns `409`
554
+ (`CLOSURE_TABLE_CHANGED`); retry against the new table, whose version starts at zero. If the
555
+ table was disposed without being recreated, the existing unknown-table `404` applies.
556
+
557
+ This spreads the subsumption work of `$closure` over the processes. When many processes add
558
+ to the same table at once, some adds are worked out more than once.
559
+
560
+ ### What changes, by combination
561
+
562
+ | `sharedCache` | `sharedClosure` | A request with a cache-id goes to | `$closure` goes to | After a worker restart | After the master starts |
563
+ |---|---|---|---|---|---|
564
+ | off | off | the process that issued it (if it is down, the launcher answers `cache-id-unknown`, lost to a restart) | the master | its caches are lost | its caches are lost |
565
+ | on | off | the least-loaded tx process (the issuer, with `cacheAffinity`) | the master | every cache is kept | every cache is lost |
566
+ | off | on | the process that issued it | the least-loaded tx process | its caches are lost | its caches are lost |
567
+ | on | on | the least-loaded tx process (the issuer, with `cacheAffinity`) | the least-loaded tx process | every cache is kept | every cache is lost |
568
+
569
+ The syndication feed, the `/info` pages and everything outside the tx endpoints go to the
570
+ master in every combination. For nginx, see
571
+ [nginx.md](nginx.md#with-the-shared-cache). Under the launcher, a lost cache is reported as
572
+ "lost to a server restart" in every combination. Behind nginx.md's configuration, without the
573
+ shared cache, that is so only once the restarted process answers: while a worker is down its
574
+ cache-ids go to its `backup` worker, which answers `cache-id-unknown` saying the id is another
575
+ instance's (`CACHE_ID_OTHER_INSTANCE`), and while the master is down nginx answers its
576
+ cache-ids (`m0.`) with a `502`, since `fs_master` has no backup. Either way the client starts a
577
+ new cache. Closure tables are kept across restarts, as on a single server.
578
+
579
+ ## Memory
580
+
581
+ With a role, the memory guard measures the whole cgroup, not the process's RSS, since the kernel's limit is on all the
582
+ processes in it together: the cgroup's private memory (`memory.stat` `anon`; cgroup v1
583
+ `total_rss`). The page cache (the terminology and SQLite files) isn't counted: the kernel drops
584
+ it when it needs the room.
585
+
586
+ * Above `server.memory.rejectFraction` (85%) of the limit, **every** process refuses new
587
+ requests with 503 and `Retry-After`.
588
+ * Above `abortFraction` (92%), **every** process aborts the operations it has running (422
589
+ too-costly); a process with none running carries on. Not only the largest: a process's size
590
+ doesn't say whether it is busy, and a large idle master would otherwise leave a worker's
591
+ runaway `$expand` to the kernel's OOM killer, which would likely kill the master.
592
+
593
+ While refusing, each process looks for memory to get back, at once and then every 5 s (backing
594
+ off to a minute while it gets back little): a forced last-resort GC, then, on Linux with glibc
595
+ and the optional `koffi` dependency, `malloc_trim`. A last-resort GC also gives back the pages
596
+ an ordinary GC frees but V8 keeps for reuse: they count as private memory, and no heap figure
597
+ shows them, so it runs whatever the size of the heap. (Where the runtime has only a plain
598
+ `gc()`, before V8 12, the GC is skipped when the JS heap is too small for it to help.)
599
+ Between those looks, before it refuses a request, it calls `malloc_trim` alone, at most once a
600
+ second and without backing off, and measures again: what a burst used is freed when the burst
601
+ ends but stays resident, and without this a quiet server could sit just over the line until
602
+ the next look, a minute away. (With nothing to give back, a trim takes well under a
603
+ millisecond.) The trim matters: an operation that opens thousands of SQLite connections (a 20,000-entry LOINC
604
+ `$batch-validate-code`, which opens one per entry) leaves gigabytes that the C library's
605
+ allocator keeps resident after the connections close, and no GC touches them. After such a
606
+ batch was aborted, a worker held 8.0 GB, 4.7 GB of it free in malloc's arenas; `malloc_trim`
607
+ took it to 3.9 GB in half a second, and without it the server refused everything until it was
608
+ restarted. Setting `MALLOC_ARENA_MAX=2` in the environment was tried instead and did not give
609
+ the memory back (the worker was still refusing 90 s after the abort); `malloc_trim` did (back
610
+ in 6 s), so the trim is a call through `koffi`, not an environment setting.
611
+ This is on with a role (`server.memory.trimAllocator`, true unless set false, for diagnosis).
612
+ With no role it is off unless set true, so a single server only forces the GC, as before: the
613
+ trim is synchronous native code, not yet run on a single server's 20 GB process. Where it is
614
+ not available (macOS, Windows, musl, no `koffi`), only the GC runs. With a role, each process
615
+ also trims once after loading the library, before it takes requests: the load leaves 0.6-0.7
616
+ GB free but resident, and N+1 processes share the one limit.
617
+
618
+ If a tx-worker stays over the refusal threshold anyway, for `server.cluster.recycleSeconds`
619
+ (30) with no request at work on it, the launcher restarts it: whatever it holds, nothing
620
+ running will free it. With the cgroup measured, only a worker that has grown, since it was
621
+ ready, by more than the cgroup is over the threshold is restarted (a smaller one would come
622
+ back at the same size, and the gate would stay shut); the largest first, one at a time. Without,
623
+ a worker over the threshold on its own, and again only if it has grown since it was ready by
624
+ more than it is over: one already over when it became ready would come back over, and be
625
+ restarted every 45 s or so for good. The launcher logs a `WARNING` for such a worker, once,
626
+ and leaves it. The launcher reads each worker's `RssAnon` from `/proc`,
627
+ so this is Linux only. The master is never restarted for this: it holds what only it has. The
628
+ launcher logs each restart and why, and its limits line says whether this is on.
629
+
630
+ This needs the limit on the processes' own cgroup: `MemoryMax` on the unit that runs
631
+ `cluster.js`, or a container limit. If the limit is only on an enclosing slice, or there is
632
+ none, each process measures its own RSS against the limit, as a single server does. If you
633
+ start the processes as separate systemd units, give each its own `MemoryMax`: each then guards
634
+ its own.
635
+
636
+ Each process is a whole terminology server: it loads the library, so N workers and a master
637
+ use roughly N+1 times the memory of one server (about 2.8 to 3.8 GB each with the full library).
638
+ Size the limit for that. The guard counts them all against the one limit, so N+1 idle processes
639
+ sitting at the refusal threshold refuse every request. In a test with a 16 GiB limit and
640
+ `rejectFraction` set to 0.75 (a test setting; the default is 0.85), so a threshold of 12 GB,
641
+ 1 master and 3 workers idle at 12.05 GB refused everything for minutes after a burst, while a
642
+ single server on the same data stayed near 3.5 GB and never refused. With the defaults on a
643
+ 32 GB limit the threshold is 27.2 GB (refusing) and 29.4 GB (aborting). So the launcher
644
+ checks: once the master is loaded, it takes the master's memory as each worker's, and if
645
+ `server.cluster.workers` is not given, it starts only as many workers as fit under the
646
+ threshold with `memoryHeadroomGB` (2) to spare, at least one. If it is given, they all start, with
647
+ a `WARNING` line. If even one worker doesn't fit, that is a `WARNING` too, with the numbers. Once every worker is ready it logs what they all use idle, with a `WARNING` if
648
+ less than the headroom is left.
649
+
650
+ ## How many workers
651
+
652
+ With `server.cluster.workers` not given, the launcher derives it from the CPUs and logs the
653
+ inputs and the decision in one line as the master is loaded:
654
+
655
+ ```
656
+ workers 4: 4 physical cores (8 vCPU, SMT 2), quota none, memory fits 11 → classic rule min(4, round(0.6×8))
657
+ ```
658
+
659
+ * **vCPU**: `os.availableParallelism()`, the CPUs this process may run on.
660
+ * **physical cores**: the distinct cores among those CPUs, from
661
+ `/sys/devices/system/cpu/cpuN/topology/core_cpus_list` (`thread_siblings_list` on older
662
+ kernels). Where that can't be read (macOS, Windows, some hypervisors), the vCPUs.
663
+ `server.cluster.cores` overrides it.
664
+ * **quota**: a cgroup v2 `cpu.max` on the launcher's cgroup or one enclosing it caps the cores
665
+ at `ceil(quota)`.
666
+
667
+ | Engine | Rule | 4 vCPU, 4 cores | 8 vCPU, 4 cores (SMT) | 8 vCPU, 8 cores | 16 vCPU, 8 cores (SMT) |
668
+ |---|---|---|---|---|---|
669
+ | classic | `min(cores, round(0.6 × vCPU))` | 2 | 4 | 5 | 8 |
670
+
671
+ Then at least 1, then lowered if the processes won't fit in memory (above). The master serves tx
672
+ too, so there is one more tx process than workers. Physical cores, not vCPUs: a second
673
+ hyperthread adds little to a process that is CPU-bound, and more processes than that only take
674
+ turns on the same cores while each holds its own copy of the library in memory.
675
+
676
+ What it rests on (classic engine; surge goodput in req/s, the same load generator and library;
677
+ `w3` = 3 workers):
678
+
679
+ | Machine | Measured | Rule |
680
+ |---|---|---|
681
+ | local, 4 vCPU = 4 cores | w2 1,678; w3 1,539 (cheap probe 918 ms, timeouts) | 2 |
682
+ | local, 8 vCPU = 4 cores SMT | w3 1,592; w4 1,630; w5 1,615 | 4 |
683
+ | GCP n2-standard-8, 8 vCPU = 4 cores SMT | about 1,550, flat from w3 to w6 | 4 |
684
+ | GCP n2-standard-16, 1 thread per core: 8 vCPU = 8 cores | about 2,430, flat from w4 to w6 | 5 |
685
+
686
+ The classic engine is flat past its rule's number: a process uses about 1.4-1.9 cores under load
687
+ (its libuv pool), so more add memory, not throughput. An engine whose processes use the CPU
688
+ differently adds its own rule (`registerWorkerRule` in `library/worker-count.js`). Machines of
689
+ 16 cores and more were not measured: the rule there is extrapolation, and `server.cluster.workers`
690
+ is the way to choose.
691
+
692
+ ## Limits of this arrangement
693
+
694
+ * **Caches are per process** (unless [`sharedCache`](#options) is on). A `$cache-control`
695
+ cache lives in the process that issued it; a restarted process loses its caches, and
696
+ clients start new ones, as after any restart. That includes a tx-worker the launcher restarts
697
+ because it stayed over the memory guard's refusal threshold (`recycleSeconds`): its
698
+ `$cache-control` caches, its expansion cache and the code systems it had loaded are gone,
699
+ and its first requests after the restart are slower.
700
+ * **Without the launcher, statistics are per process.** The workers' graphs and request
701
+ counts reach the master over the launcher's IPC channels. With processes started by hand
702
+ behind nginx there are none: `/` and `/dashboard` on the master show only the master, and
703
+ the workers' requests are not in its statistics database or daily report. (Running
704
+ `cluster.js` behind nginx, as one upstream, keeps both.)
705
+ * **Everything the master runs is a single point.** While the master restarts, `$closure`
706
+ (unless [`sharedClosure`](#options) is on), the non-tx modules and the dashboard answer 503
707
+ through the launcher; tx requests carry on.