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
@@ -3,8 +3,15 @@ const inspector = require("inspector");
3
3
  const crypto = require("crypto");
4
4
  const {Languages} = require("../library/languages");
5
5
  const {Issue} = require("./library/operation-outcome");
6
+ const {CanonicalResource} = require("./library/canonical-resource");
6
7
  const Logger = require("../library/logger");
7
- const { readCgroupMemoryLimit } = require('../library/cgroup-memory');
8
+ const memoryGuard = require('../library/memory-guard');
9
+
10
+ // When this process started, in ms, and the compact form of it that cache-ids carry when
11
+ // the server has an instance code ("<code>.<start>.<uuid>"): base 36, 8 characters until
12
+ // 2059. An id with this server's code and an earlier start was issued before a restart.
13
+ const PROCESS_START = Math.round(performance.timeOrigin);
14
+ const PROCESS_START_TOKEN = PROCESS_START.toString(36);
8
15
 
9
16
  /**
10
17
  * Check if running under a debugger
@@ -112,10 +119,71 @@ class ResourceCache {
112
119
  // to poll instead of guessing. Null = not advertised.
113
120
  this.idleTimeoutMs = null;
114
121
  // This server instance's code (modules.tx.instanceCode), prefixed onto every
115
- // cache-id it issues as "<code>.<uuid>". A proxy in front of several instances
122
+ // cache-id it issues as "<code>.<start>.<uuid>" (the shared store's ids are
123
+ // "<code>.<generation>.<random>"). A proxy in front of several instances
116
124
  // routes on the prefix to get a cached request back to the one instance that
117
125
  // holds the cache (see nginx.md). Null = unprefixed ids (a single server).
118
126
  this.instanceCode = null;
127
+ // Most concepts one cache may hold (a request that would take it over drops it), and most
128
+ // all the caches may hold between them (the least recently used caches are
129
+ // dropped to stay under it). Set from cacheMaxConcepts / cacheMaxTotalConcepts.
130
+ this.maxConceptsPerCache = ResourceCache.MAX_CONCEPTS_PER_CACHE;
131
+ this.maxTotalConcepts = ResourceCache.MAX_TOTAL_CONCEPTS;
132
+ }
133
+
134
+ // Defaults for cacheMaxConcepts and cacheMaxTotalConcepts. 250,000 is about 10x the
135
+ // largest IG Publisher session measured on build.fhir.org (Nov 2026: 24,048 concepts);
136
+ // the total is 4 such full caches, about 250 MB of heap at about 250 bytes a concept.
137
+ static MAX_CONCEPTS_PER_CACHE = 250000;
138
+ static MAX_TOTAL_CONCEPTS = 1000000;
139
+
140
+ /**
141
+ * Set the concept limits (see the constructor). Infinity = no limit.
142
+ * @param {number} perCache
143
+ * @param {number} total
144
+ */
145
+ setLimits(perCache, total) {
146
+ this.maxConceptsPerCache = perCache;
147
+ this.maxTotalConcepts = total;
148
+ }
149
+
150
+ /**
151
+ * Refuse a change that would take one cache over its limit.
152
+ * @param {string} cacheId
153
+ * @param {number} concepts - what the cache would hold
154
+ */
155
+ _checkLimit(cacheId, concepts) {
156
+ if (concepts > this.maxConceptsPerCache) {
157
+ // (in English: the worker translates it into the request's language)
158
+ const err = new Issue('error', 'too-costly', null, 'CACHE_START_TOO_BIG',
159
+ `The cache '${cacheId}' would hold ${concepts} concepts, more than this server allows in one cache (${this.maxConceptsPerCache}, cacheMaxConcepts)`, 'too-costly', 422).handleAsOO(422);
160
+ err.msgParams = [cacheId, concepts, this.maxConceptsPerCache];
161
+ throw err;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Drop the least recently used caches (never the one just changed) while the
167
+ * caches hold more concepts than allowed between them.
168
+ * @param {string} keep - the cache-id just changed
169
+ */
170
+ _evict(keep) {
171
+ while (this.totalConcepts > this.maxTotalConcepts) {
172
+ let oldest = null;
173
+ for (const [cacheId, entry] of this.cache.entries()) {
174
+ if (cacheId !== keep && (!oldest || entry.lastUsed < oldest[1].lastUsed)) {
175
+ oldest = [cacheId, entry];
176
+ }
177
+ }
178
+ if (!oldest) {
179
+ return;
180
+ }
181
+ const [cacheId, entry] = oldest;
182
+ this.totalConcepts -= entry.concepts || 0;
183
+ this._recordTombstone(cacheId, entry, 'evicted');
184
+ this.log.info(`cache-id '${cacheId}': dropped to make room (held ${entry.concepts} concepts; the caches may hold ${this.maxTotalConcepts} in all)`);
185
+ this.cache.delete(cacheId);
186
+ }
119
187
  }
120
188
 
121
189
  /**
@@ -141,8 +209,9 @@ class ResourceCache {
141
209
  return null;
142
210
  }
143
211
  // At most 16 characters: the cache-id goes back to the client as a FHIR id
144
- // (valueId), which is capped at 64 - and "<code>." plus a 36-character UUID
145
- // has to fit.
212
+ // (valueId), which is capped at 64 - and "<code>." plus the 8-character start,
213
+ // a '.' and a 36-character UUID (or the shared store's two 22-character tokens)
214
+ // have to fit.
146
215
  if (typeof code !== 'string' || !/^[A-Za-z0-9]{1,16}$/.test(code)) {
147
216
  throw new Error(`tx instanceCode '${code}' is not valid: it must be 1-16 letters and digits`);
148
217
  }
@@ -150,18 +219,51 @@ class ResourceCache {
150
219
  }
151
220
 
152
221
  /**
153
- * Mint a new cache-id: a UUID, prefixed with "<instanceCode>." when this server
154
- * has an instance code. Minting only - the caller creates the entry with set().
222
+ * Mint a new cache-id: a UUID, or "<instanceCode>.<start>.<uuid>" when this server
223
+ * has an instance code, where <start> is when this process started (base 36), so a
224
+ * later process can tell an id issued before a restart from one never issued.
225
+ * Minting only - the caller creates the entry with set(), or uses start().
155
226
  * @returns {string}
156
227
  */
157
228
  newCacheId() {
158
229
  const uuid = crypto.randomUUID();
159
- return this.instanceCode ? `${this.instanceCode}.${uuid}` : uuid;
230
+ return this.instanceCode ? `${this.instanceCode}.${PROCESS_START_TOKEN}.${uuid}` : uuid;
231
+ }
232
+
233
+ /**
234
+ * Create a cache ($cache-control?mode=start): mint its id, store the resources.
235
+ * @param {Array} resources
236
+ * @param {boolean} sealed
237
+ * @returns {string} the new cache-id
238
+ */
239
+ start(resources, sealed = false) {
240
+ const cacheId = this.newCacheId();
241
+ this.set(cacheId, resources, sealed);
242
+ return cacheId;
243
+ }
244
+
245
+ /**
246
+ * When the process that issued a cache-id with this server's instance code started
247
+ * (ms), or null if the id doesn't say (no code, another code, or the older
248
+ * "<code>.<uuid>" form).
249
+ * @param {string} cacheId
250
+ * @returns {number|null}
251
+ */
252
+ issuedStartOf(cacheId) {
253
+ if (!this.instanceCode || typeof cacheId !== 'string') {
254
+ return null;
255
+ }
256
+ const parts = cacheId.split('.');
257
+ if (parts.length !== 3 || parts[0] !== this.instanceCode || !/^[0-9a-z]{1,10}$/.test(parts[1])) {
258
+ return null;
259
+ }
260
+ return parseInt(parts[1], 36);
160
261
  }
161
262
 
162
263
  /**
163
264
  * The instance code in a cache-id, or null if it has none. Ids this server mints
164
- * are either a bare UUID (no '.') or "<code>.<uuid>".
265
+ * are either a bare UUID (no '.') or "<code>.<...>": "<code>.<start>.<uuid>" from the
266
+ * memory store, "<code>.<generation>.<random>" from the shared one.
165
267
  * @param {string} cacheId
166
268
  * @returns {string|null}
167
269
  */
@@ -185,10 +287,12 @@ class ResourceCache {
185
287
  *
186
288
  * @param {string} cacheId - The cache identifier
187
289
  * @param {Object} entry - The entry being removed (for its timings)
188
- * @param {'closed'|'expired'|'cleared'} reason - How it went away:
290
+ * @param {'closed'|'expired'|'cleared'|'evicted'|'discarded'} reason - How it went away:
189
291
  * `closed` = the client released it with $cache-control?mode=end;
190
292
  * `expired` = it went unused for longer than the idle timeout;
191
- * `cleared` = the server dropped every cache at once.
293
+ * `cleared` = the server dropped every cache at once;
294
+ * `evicted` = it was dropped, least recently used, to make room for others.
295
+ * `discarded` = a request would have taken it over cacheMaxConcepts.
192
296
  * @param {Object} [extra] - Reason-specific detail (e.g. idleMs, maxAgeMs)
193
297
  */
194
298
  _recordTombstone(cacheId, entry, reason, extra = {}) {
@@ -235,6 +339,15 @@ class ResourceCache {
235
339
  describeMissing(cacheId) {
236
340
  const t = this.tombstone(cacheId);
237
341
  if (!t) {
342
+ // Our own code: the id says when the process that issued it started. An earlier
343
+ // start is a cache lost to a restart; this process's start, one it never issued
344
+ const issuedStart = this.issuedStartOf(cacheId);
345
+ if (issuedStart !== null && issuedStart < PROCESS_START) {
346
+ return { messageId: 'CACHE_ID_LOST_TO_RESTART', params: [cacheId, formatDuration(Date.now() - PROCESS_START)] };
347
+ }
348
+ if (issuedStart !== null) {
349
+ return { messageId: 'CACHE_ID_NEVER_ISSUED', params: [cacheId, formatDuration(Date.now() - PROCESS_START), this.maxTombstones] };
350
+ }
238
351
  // No record of it here. If its prefix names a different instance, say so:
239
352
  // that's a routing problem (proxy misconfigured, or the owning instance is
240
353
  // down and the request fell through to a backup), not a lifecycle one.
@@ -255,6 +368,10 @@ class ResourceCache {
255
368
  messageId: 'CACHE_ID_EXPIRED',
256
369
  params: [cacheId, formatDuration(t.idleMs), formatDuration(t.maxAgeMs), ago]
257
370
  };
371
+ case 'evicted':
372
+ return { messageId: 'CACHE_ID_EVICTED', params: [cacheId, ago] };
373
+ case 'discarded':
374
+ return { messageId: 'CACHE_ID_DISCARDED', params: [cacheId, ago, t.limit] };
258
375
  default:
259
376
  return { messageId: 'CACHE_ID_CLEARED', params: [cacheId, ago] };
260
377
  }
@@ -400,35 +517,77 @@ class ResourceCache {
400
517
  }
401
518
  }
402
519
 
403
- const entry = this.cache.get(cacheId) || { resources: [], createdAt: Date.now(), lastUsed: Date.now(), concepts: 0 };
520
+ // What this request brings on its own (a resource sent twice counted once). If that alone is
521
+ // more than one cache may hold, no cache can take it - a new one would be dropped too, and the
522
+ // client would retry in vain - so the request is refused as too costly (422), and the cache,
523
+ // which isn't the problem, is kept
524
+ const own = new Map();
525
+ for (const resource of resources) {
526
+ own.set(this._resourceKey(resource), this._conceptsOf(resource));
527
+ }
528
+ let ownConcepts = 0;
529
+ for (const n of own.values()) {
530
+ ownConcepts += n;
531
+ }
532
+ if (ownConcepts > this.maxConceptsPerCache) {
533
+ // (in English: the worker translates it into the request's language - see addToClientCache)
534
+ const err = new Issue('error', 'too-costly', null, 'CACHE_REQUEST_TOO_BIG',
535
+ `This request's resources hold ${ownConcepts} concepts, more than this server allows in one cache (${this.maxConceptsPerCache}, cacheMaxConcepts), so no cache can hold them`, 'too-costly', 422).handleAsOO(422);
536
+ err.msgParams = [ownConcepts, this.maxConceptsPerCache];
537
+ throw err;
538
+ }
404
539
 
405
- // Merge resources, avoiding duplicates by url+version. Keep the entry's concept
406
- // subtotal and the cache-wide total in step with each insertion/replacement.
540
+ const entry = this.cache.get(cacheId) || { resources: [], createdAt: Date.now(), lastUsed: Date.now(), concepts: 0 };
541
+ // Merge resources, avoiding duplicates by url+version, into a copy: a change that would
542
+ // take the cache over its limit discards the cache instead.
543
+ const merged = [...entry.resources];
544
+ let concepts = entry.concepts;
407
545
  for (const resource of resources) {
408
546
  const key = this._resourceKey(resource);
409
- const newConcepts = this._conceptsOf(resource);
410
- const existingIndex = entry.resources.findIndex(r => this._resourceKey(r) === key);
547
+ const existingIndex = merged.findIndex(r => this._resourceKey(r) === key);
411
548
  if (existingIndex >= 0) {
412
- // Replace existing: adjust by the difference
413
- const delta = newConcepts - this._conceptsOf(entry.resources[existingIndex]);
414
- entry.resources[existingIndex] = resource;
415
- entry.concepts += delta;
416
- this.totalConcepts += delta;
549
+ // Replace existing: count the difference
550
+ concepts += this._conceptsOf(resource) - this._conceptsOf(merged[existingIndex]);
551
+ merged[existingIndex] = resource;
417
552
  if (this.logCacheOps) {
418
553
  this.log.info(`cache-id '${cacheId}': replaced ${key}`);
419
554
  }
420
555
  } else {
421
- entry.resources.push(resource);
422
- entry.concepts += newConcepts;
423
- this.totalConcepts += newConcepts;
556
+ concepts += this._conceptsOf(resource);
557
+ merged.push(resource);
424
558
  if (this.logCacheOps) {
425
559
  this.log.info(`cache-id '${cacheId}': added ${key}`);
426
560
  }
427
561
  }
428
562
  }
563
+ if (concepts > this.maxConceptsPerCache) {
564
+ // The client can't do anything with a cache it can't add to, so drop it, and don't
565
+ // perform this request: the next request with this id is told it was discarded
566
+ // (CACHE_ID_DISCARDED).
567
+ if (this.cache.has(cacheId)) {
568
+ this.totalConcepts -= entry.concepts || 0;
569
+ this.cache.delete(cacheId);
570
+ }
571
+ this._recordTombstone(cacheId, entry, 'discarded', { limit: this.maxConceptsPerCache });
572
+ this.log.info(`cache-id '${cacheId}': discarded (would hold ${concepts} concepts; one cache may hold ${this.maxConceptsPerCache})`);
573
+ // Answered as every other cache that is gone is (404, tx-issue-type cache-id-unknown), so a
574
+ // client needs one rule: forget what it thinks the server has, start a new cache, and send
575
+ // the request again (it has not been performed). The message id says why it is gone.
576
+ const params = [cacheId, this.maxConceptsPerCache];
577
+ const err = new Issue('error', 'not-found', null, 'CACHE_ID_DISCARDED_TOO_BIG',
578
+ `The cache '${cacheId}' grew too large: this request would have taken it over ${this.maxConceptsPerCache} concepts, ` +
579
+ 'the most this server allows in one cache (cacheMaxConcepts), so the server has dropped the cache. ' +
580
+ 'This operation has not been performed. Start a new cache, and send the request again', 'cache-id-unknown', 404).handleAsOO(404);
581
+ err.msgParams = params;
582
+ throw err;
583
+ }
584
+ this.totalConcepts += concepts - entry.concepts;
585
+ entry.resources = merged;
586
+ entry.concepts = concepts;
429
587
 
430
588
  entry.lastUsed = Date.now();
431
589
  this.cache.set(cacheId, entry);
590
+ this._evict(cacheId);
432
591
  this._trackMax();
433
592
  }
434
593
 
@@ -452,15 +611,16 @@ class ResourceCache {
452
611
  Object.defineProperty(resource, 'isCached', { value: true, enumerable: false, configurable: true, writable: true });
453
612
  }
454
613
  }
614
+ let concepts = 0;
615
+ for (const resource of resources) {
616
+ concepts += this._conceptsOf(resource);
617
+ }
618
+ this._checkLimit(cacheId, concepts);
455
619
  // Drop the old entry's contribution, then count the replacement.
456
620
  const existing = this.cache.get(cacheId);
457
621
  if (existing) {
458
622
  this.totalConcepts -= existing.concepts || 0;
459
623
  }
460
- let concepts = 0;
461
- for (const resource of resources) {
462
- concepts += this._conceptsOf(resource);
463
- }
464
624
  this.totalConcepts += concepts;
465
625
  this.cache.set(cacheId, {
466
626
  resources: [...resources],
@@ -471,6 +631,7 @@ class ResourceCache {
471
631
  });
472
632
  // This id is alive again, so any record of its previous death is now wrong.
473
633
  this.tombstones.delete(cacheId);
634
+ this._evict(cacheId);
474
635
  this._trackMax();
475
636
  }
476
637
 
@@ -592,10 +753,97 @@ class ResourceCache {
592
753
  }
593
754
  }
594
755
 
756
+ /**
757
+ * How many codes the expansion caches may hold between them - one budget for the server, shared
758
+ * by the caches of all its endpoints. An expansion's cost in memory is roughly proportional to the
759
+ * number of codes in it (about 0.8 KB each, for SNOMED CT pages with displays), so counting codes
760
+ * bounds the memory the caches use without measuring anything. When a new entry takes the total
761
+ * over the budget, the least recently used entries - from whichever cache - are dropped.
762
+ */
763
+ class ExpansionCacheBudget {
764
+ /**
765
+ * Assumed memory per cached code, used to derive the default budget from the heap size
766
+ */
767
+ static BYTES_PER_CODE = 1024;
768
+
769
+ /**
770
+ * Share of the heap the default budget allows the caches
771
+ */
772
+ static HEAP_SHARE = 0.25;
773
+
774
+ /**
775
+ * The default budget: a quarter of the heap limit (node's --max-old-space-size, or its default),
776
+ * at BYTES_PER_CODE a code
777
+ * @returns {number}
778
+ */
779
+ static defaultMaxCodes() {
780
+ const heapLimit = require('v8').getHeapStatistics().heap_size_limit;
781
+ return Math.floor(heapLimit * ExpansionCacheBudget.HEAP_SHARE / ExpansionCacheBudget.BYTES_PER_CODE);
782
+ }
783
+
784
+ /**
785
+ * @param {number} [maxCodes] - how many codes the caches may hold; 0 or missing = the default
786
+ */
787
+ constructor(maxCodes = 0) {
788
+ this.maxCodes = maxCodes > 0 ? maxCodes : ExpansionCacheBudget.defaultMaxCodes();
789
+ this.total = 0;
790
+ // every entry of every cache sharing this budget, least recently used first
791
+ this.lru = new Map();
792
+ }
793
+
794
+ /**
795
+ * Add an entry (already in its cache) and drop the least recently used entries while over budget
796
+ */
797
+ add(entry) {
798
+ this.lru.set(entry, true);
799
+ this.total += entry.codes;
800
+ for (const old of this.lru.keys()) {
801
+ if (this.total <= this.maxCodes) {
802
+ break;
803
+ }
804
+ old.owner.evictEntry(old);
805
+ }
806
+ }
807
+
808
+ touch(entry) {
809
+ if (this.lru.delete(entry)) {
810
+ this.lru.set(entry, true);
811
+ }
812
+ }
813
+
814
+ remove(entry) {
815
+ if (this.lru.delete(entry)) {
816
+ this.total -= entry.codes;
817
+ }
818
+ }
819
+ }
820
+
821
+ /**
822
+ * The number of codes in an expansion (all levels of a hierarchical one)
823
+ * @param {Object} expansion - an expanded ValueSet (wrapper or JSON)
824
+ * @returns {number}
825
+ */
826
+ function countExpansionCodes(expansion) {
827
+ const json = expansion && expansion.jsonObj ? expansion.jsonObj : expansion;
828
+ let count = 0;
829
+ const walk = (list) => {
830
+ for (const c of list || []) {
831
+ count++;
832
+ if (c.contains) {
833
+ walk(c.contains);
834
+ }
835
+ }
836
+ };
837
+ walk(json && json.expansion ? json.expansion.contains : null);
838
+ return count;
839
+ }
840
+
595
841
  /**
596
842
  * Cache for expanded ValueSets
597
843
  * Stores expansions keyed by hash of (valueSet, params, additionalResources)
598
844
  * Only caches expansions that took longer than the minimum cache time
845
+ * Bounded by an entry count, and by a budget of codes (see ExpansionCacheBudget), which may be
846
+ * shared with other caches
599
847
  */
600
848
  class ExpansionCache {
601
849
  /**
@@ -611,9 +859,12 @@ class ExpansionCache {
611
859
  /**
612
860
  * @param {number} maxSize - Maximum number of entries to keep (default 1000)
613
861
  * @param {number} memoryThresholdMB - Heap usage in MB that triggers dropping oldest half (0 = disabled)
862
+ * @param {ExpansionCacheBudget} [budget] - the budget of codes, shared with other caches (default: one of its own)
614
863
  */
615
- constructor(stats, maxSize = ExpansionCache.DEFAULT_MAX_SIZE, memoryThresholdMB = 0) {
864
+ constructor(stats, maxSize = ExpansionCache.DEFAULT_MAX_SIZE, memoryThresholdMB = 0, budget = null) {
616
865
  this.stats = stats;
866
+ this.budget = budget || new ExpansionCacheBudget();
867
+ // least recently used first (get moves an entry to the end)
617
868
  this.cache = new Map();
618
869
  this.maxSize = maxSize;
619
870
  this.memoryThresholdBytes = memoryThresholdMB * 1024 * 1024;
@@ -631,9 +882,12 @@ class ExpansionCache {
631
882
  * @param {Object|ValueSet} valueSet - The ValueSet to expand (wrapper or JSON)
632
883
  * @param {Object} params - Parameters resource (tx-resource and valueSet params excluded)
633
884
  * @param {Array} additionalResources - Additional resources in scope (CodeSystem/ValueSet wrappers)
885
+ * @param {number} [limit] - the most codes the response may hold (the request's
886
+ * x-too-costly-threshold, else the configured externalLimit): the same request under a lower
887
+ * limit can be a 422, or lose its total, so it is a different answer
634
888
  * @returns {string} Hash key
635
889
  */
636
- computeKey(valueSet, params, additionalResources) {
890
+ computeKey(valueSet, params, additionalResources, limit = null) {
637
891
  const keyParts = [];
638
892
 
639
893
  // ValueSet content - always hash the full JSON content
@@ -645,19 +899,22 @@ class ExpansionCache {
645
899
  if (params) {
646
900
  keyParts.push(`params:`+params.hashSource());
647
901
  }
902
+ if (limit != null) {
903
+ keyParts.push(`limit:${limit}`);
904
+ }
648
905
 
649
906
  // Additional resources - hash the full content of each resource
650
907
  // Resources are now CodeSystem/ValueSet wrappers, not raw JSON
651
908
  if (additionalResources && additionalResources.length > 0) {
652
909
  const resourceHashes = additionalResources
653
910
  .map(r => {
654
- // Get the JSON object from wrapper or use directly
655
- const json = r.jsonObj || r;
656
- // Create a content hash for this resource
657
- return crypto.createHash('sha256')
658
- .update(JSON.stringify(json))
659
- .digest('hex')
660
- .substring(0, 16); // Use first 16 chars for brevity
911
+ // A wrapper remembers its hash (CanonicalResource.expansionKeyHash): a cache-id's
912
+ // resources come with every $expand, and serialising and hashing them all
913
+ // each time was most of the cost of a cached request. Raw JSON is hashed the
914
+ // same way, so the key is the same either way.
915
+ return typeof r.expansionKeyHash === 'function'
916
+ ? r.expansionKeyHash()
917
+ : CanonicalResource.computeExpansionKeyHash(r.jsonObj || r);
661
918
  })
662
919
  .sort();
663
920
  keyParts.push(`additional:${resourceHashes.join(',')}`);
@@ -679,6 +936,9 @@ class ExpansionCache {
679
936
  if (entry) {
680
937
  entry.lastUsed = Date.now();
681
938
  entry.hitCount++;
939
+ this.cache.delete(key);
940
+ this.cache.set(key, entry);
941
+ this.budget.touch(entry);
682
942
  return entry.expansion;
683
943
  }
684
944
  return null;
@@ -707,21 +967,44 @@ class ExpansionCache {
707
967
  return false;
708
968
  }
709
969
 
710
- // Enforce max size before adding - evict oldest (by lastUsed) if needed
970
+ // an expansion bigger than the whole budget would only empty the caches
971
+ const codes = countExpansionCodes(expansion);
972
+ if (codes > this.budget.maxCodes) {
973
+ return false;
974
+ }
975
+
976
+ this.clear(key);
977
+ // Enforce max size before adding - evict the least recently used if needed
711
978
  if (this.cache.size >= this.maxSize) {
712
979
  this.evictOldest(1);
713
980
  }
714
981
 
715
- this.cache.set(key, {
982
+ const entry = {
983
+ owner: this,
984
+ key: key,
716
985
  expansion: expansion,
986
+ codes: codes,
717
987
  createdAt: Date.now(),
718
988
  lastUsed: Date.now(),
719
989
  durationMs: durationMs,
720
990
  hitCount: 0
721
- });
991
+ };
992
+ this.cache.set(key, entry);
993
+ this.budget.add(entry);
722
994
  return true;
723
995
  }
724
996
 
997
+ /**
998
+ * Drop an entry (the budget drops this cache's entries through this)
999
+ * @param {Object} entry
1000
+ */
1001
+ evictEntry(entry) {
1002
+ if (this.cache.get(entry.key) === entry) {
1003
+ this.cache.delete(entry.key);
1004
+ }
1005
+ this.budget.remove(entry);
1006
+ }
1007
+
725
1008
  /**
726
1009
  * Evict the N oldest entries by lastUsed time
727
1010
  * @param {number} count - Number of entries to evict
@@ -730,15 +1013,12 @@ class ExpansionCache {
730
1013
  evictOldest(count) {
731
1014
  if (this.cache.size === 0 || count <= 0) return 0;
732
1015
 
733
- // Get entries sorted by lastUsed (oldest first)
734
- const entries = Array.from(this.cache.entries())
735
- .sort((a, b) => a[1].lastUsed - b[1].lastUsed);
736
-
737
- const toEvict = Math.min(count, entries.length);
738
- for (let i = 0; i < toEvict; i++) {
739
- this.cache.delete(entries[i][0]);
1016
+ // the map is in least recently used order
1017
+ const toEvict = [...this.cache.values()].slice(0, count);
1018
+ for (const entry of toEvict) {
1019
+ this.evictEntry(entry);
740
1020
  }
741
- return toEvict;
1021
+ return toEvict.length;
742
1022
  }
743
1023
 
744
1024
  /**
@@ -780,14 +1060,19 @@ class ExpansionCache {
780
1060
  * @param {string} key - Hash key
781
1061
  */
782
1062
  clear(key) {
783
- this.cache.delete(key);
1063
+ const entry = this.cache.get(key);
1064
+ if (entry) {
1065
+ this.evictEntry(entry);
1066
+ }
784
1067
  }
785
1068
 
786
1069
  /**
787
1070
  * Clear all cached entries
788
1071
  */
789
1072
  clearAll() {
790
- this.cache.clear();
1073
+ for (const entry of [...this.cache.values()]) {
1074
+ this.evictEntry(entry);
1075
+ }
791
1076
  }
792
1077
 
793
1078
  /**
@@ -799,12 +1084,17 @@ class ExpansionCache {
799
1084
  */
800
1085
  getStats() {
801
1086
  let totalHits = 0;
1087
+ let codes = 0;
802
1088
  for (const entry of this.cache.values()) {
803
1089
  totalHits += entry.hitCount;
1090
+ codes += entry.codes;
804
1091
  }
805
1092
  return {
806
1093
  size: this.cache.size,
807
1094
  maxSize: this.maxSize,
1095
+ codes,
1096
+ budgetCodes: this.budget.total,
1097
+ budgetMaxCodes: this.budget.maxCodes,
808
1098
  memoryThresholdMB: this.memoryThresholdBytes > 0 ? this.memoryThresholdBytes / (1024 * 1024) : 0,
809
1099
  totalHits
810
1100
  };
@@ -816,17 +1106,6 @@ class ExpansionCache {
816
1106
  }
817
1107
 
818
1108
 
819
- /**
820
- * Read the cgroup memory limit once at startup.
821
- * Returns the byte limit, or 0 if unavailable (disables the check).
822
- */
823
- function readMemoryLimit() {
824
- return readCgroupMemoryLimit().limit;
825
- }
826
-
827
- const MEMORY_LIMIT = readMemoryLimit();
828
- const MEMORY_FRACTION = 0.98;
829
- const MEMORY_THRESHOLD = MEMORY_LIMIT > 0 ? MEMORY_LIMIT * MEMORY_FRACTION : 0; // MEMORY_FRACTION of cgroup limit
830
1109
  const CHECK_FREQUENCY = 100;
831
1110
  // How long an operation may compute without yielding the event loop (ms).
832
1111
  // Node runs all JS on one thread: while a long operation executes, no other
@@ -835,7 +1114,7 @@ const CHECK_FREQUENCY = 100;
835
1114
  // I/O. checkAndYield() awaits a real setImmediate every YIELD_INTERVAL ms of
836
1115
  // compute, so pending I/O (new connections, /metadata, timers) runs between
837
1116
  // slices and one heavy $expand can no longer time out every other client.
838
- const YIELD_INTERVAL = 25;
1117
+ const YIELD_INTERVAL = 10;
839
1118
  const { setImmediate: yieldToEventLoop } = require('timers/promises');
840
1119
 
841
1120
  class OperationContext {
@@ -859,12 +1138,22 @@ class OperationContext {
859
1138
  // Parameters (buildParameters, raw req.body, query/form). An explicit
860
1139
  // cache-id *parameter* still takes precedence over this.
861
1140
  this.cacheId = null;
1141
+ this.ecosystemForwarded = false; // see EcosystemResolver
1142
+ // how many requests answering this one sent to other servers (EcosystemResolver): if any,
1143
+ // it's counted as an external request (ServerStats.countExternalRequest). Shared with the
1144
+ // copies (a batch's entries), so what they send counts against the request
1145
+ this._external = { requests: 0 };
862
1146
  this.debugging = isDebugging();
863
1147
  // Providers opened during this operation that need their underlying
864
1148
  // resources (sqlite connections, etc.) released when the operation ends.
865
1149
  // Shared by reference with copy()'d contexts so a sub-operation's
866
1150
  // providers are cleaned up by the parent request's closeProviders().
867
- this._openProviders = [];
1151
+ // closed: closeProviders() has run, so one registered later (a build that
1152
+ // finished after the client went) is closed at once - nothing else will.
1153
+ // built: the providers built so far, factory -> variant -> provider (see
1154
+ // buildProviderOnce), kept with the list that
1155
+ // closes them, so whatever shares one shares the other.
1156
+ this._providers = { open: [], closed: false, built: new Map() };
868
1157
  // Compute-time accounting for the deadline and for yielding. One object,
869
1158
  // shared by reference with copy()'d contexts, so an operation and its
870
1159
  // sub-operations (e.g. batch entries) draw down a single budget:
@@ -896,11 +1185,27 @@ class OperationContext {
896
1185
  return 'op_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now();
897
1186
  }
898
1187
 
1188
+ /** the number of requests sent to other servers for this operation (see EcosystemResolver) */
1189
+ get externalRequests() {
1190
+ return this._external.requests;
1191
+ }
1192
+
1193
+ /** another request sent to another server for this operation */
1194
+ countExternalRequest() {
1195
+ this._external.requests++;
1196
+ }
1197
+
899
1198
  /**
900
1199
  * Create a copy of this operation context
1200
+ * @param {Object} [options]
1201
+ * @param {boolean} [options.ownProviders=false] - give the copy its own provider
1202
+ * list instead of sharing this one. The caller must then call closeProviders()
1203
+ * on the copy when its sub-operation is done (a batch does this per entry, so
1204
+ * an entry's providers - and through them the entry's context and log - are
1205
+ * released when the entry is answered, not held until the whole batch ends).
901
1206
  * @returns {OperationContext}
902
1207
  */
903
- copy() {
1208
+ copy(options = {}) {
904
1209
  const newContext = new OperationContext(
905
1210
  this.langs, this.i18n, this.id, this.timeLimit / 1000,
906
1211
  this.resourceCache, this.expansionCache
@@ -912,9 +1217,16 @@ class OperationContext {
912
1217
  newContext.debugging = this.debugging;
913
1218
  newContext.cacheId = this.cacheId;
914
1219
  newContext.usageTracker = this.usageTracker;
1220
+ newContext.ecosystemForwarded = this.ecosystemForwarded;
1221
+ newContext._external = this._external;
915
1222
  // Share the same provider-cleanup list so providers opened by the copy
916
- // are released when the parent operation ends.
917
- newContext._openProviders = this._openProviders;
1223
+ // are released when the parent operation ends - unless the caller will
1224
+ // release them itself, with a list of its own.
1225
+ if (options.ownProviders) {
1226
+ newContext._providers = { open: [], closed: false, built: new Map() };
1227
+ } else {
1228
+ newContext._providers = this._providers;
1229
+ }
918
1230
  // Share the compute clock: sub-operations spend the parent's budget.
919
1231
  newContext._clock = this._clock;
920
1232
  return newContext;
@@ -940,6 +1252,8 @@ class OperationContext {
940
1252
  if (this.debugging) {
941
1253
  return false;
942
1254
  }
1255
+ // a flag, so cheap: once the client has gone, stop at the next check, not the next yield
1256
+ this.abandonIfClientGone(place);
943
1257
 
944
1258
  OperationContext._checkCounter++;
945
1259
  if (OperationContext._checkCounter < CHECK_FREQUENCY) {
@@ -960,18 +1274,20 @@ class OperationContext {
960
1274
  throw error;
961
1275
  }
962
1276
 
963
- // Memory check (piggyback on same sample)
964
- if (MEMORY_THRESHOLD > 0) {
965
- const rss = process.memoryUsage.rss();
966
- if (rss > MEMORY_THRESHOLD) {
967
- const usedGB = (rss / 1024 / 1024 / 1024).toFixed(1);
968
- const limitGB = (MEMORY_LIMIT / 1024 / 1024 / 1024).toFixed(1);
969
- this.log(`Memory Limit: ${usedGB} GB of ${limitGB} GB limit @ ${place}`);
970
- const error = new Issue("error", "too-costly", null, null,
971
- `Operation aborted: server memory usage (${usedGB} GB) exceeds safe threshold (${MEMORY_FRACTION * 100}% of ${limitGB} GB limit) at ${place}`, "too-costly", 422);
972
- error.diagnostics = this.diagnostics();
973
- throw error;
974
- }
1277
+ // Memory check (piggyback on same sample). Threshold and limit: library/memory-guard.js
1278
+ const rss = memoryGuard.overAbortThreshold(place);
1279
+ if (rss) {
1280
+ const usedGB = memoryGuard.gb(rss);
1281
+ const limitGB = memoryGuard.gb(memoryGuard._state.limit);
1282
+ const pct = Math.round(memoryGuard._state.abortFraction * 100);
1283
+ this.log(`Memory Limit: ${usedGB} GB of ${limitGB} GB limit @ ${place}`);
1284
+ const error = new Issue("error", "too-costly", null, null,
1285
+ `Operation aborted: server memory usage (${usedGB} GB) exceeds safe threshold (${pct}% of ${limitGB} GB limit) at ${place}`, "too-costly", 422).handleAsOO(422);
1286
+ // the whole operation stops: as an OperationOutcome, not a validation's result, and a
1287
+ // batch doesn't go on to its next entry
1288
+ error.memoryAbort = true;
1289
+ error.diagnostics = this.diagnostics();
1290
+ throw error;
975
1291
  }
976
1292
 
977
1293
  return false;
@@ -989,9 +1305,9 @@ class OperationContext {
989
1305
 
990
1306
  /**
991
1307
  * Mark that the client for this operation disconnected before the response
992
- * was sent. The operation is aborted at its next yield point - there is no
993
- * one left to send the result to. (Wired up by the request middleware from
994
- * the response 'close' event.)
1308
+ * was sent. The operation is aborted at its next check (deadCheck,
1309
+ * checkAndYield, waitFor) - there is no one left to send the result to.
1310
+ * (Wired up by the request middleware from the response 'close' event.)
995
1311
  */
996
1312
  markClientGone() {
997
1313
  this._clock.clientGone = true;
@@ -1024,14 +1340,7 @@ class OperationContext {
1024
1340
  const resumed = performance.now();
1025
1341
  this._clock.sliceStart = resumed;
1026
1342
  this._clock.lastYield = resumed;
1027
- if (this._clock.clientGone) {
1028
- this.log(`Operation abandoned @ ${place}: client disconnected`);
1029
- const error = new Issue("error", "too-costly", null, null,
1030
- `Operation abandoned at ${place}: the client disconnected before the response was ready`, "too-costly", 422);
1031
- error.abandoned = true;
1032
- error.diagnostics = this.diagnostics();
1033
- throw error;
1034
- }
1343
+ this.abandonIfClientGone(place);
1035
1344
  }
1036
1345
  }
1037
1346
 
@@ -1057,15 +1366,25 @@ class OperationContext {
1057
1366
  this._clock.sliceStart = resumed;
1058
1367
  this._clock.lastYield = resumed;
1059
1368
  }
1369
+ this.abandonIfClientGone(place);
1370
+ return result;
1371
+ }
1372
+
1373
+ /**
1374
+ * Throw (422, abandoned) if the client has disconnected (markClientGone). The error is
1375
+ * handled as an OperationOutcome (Issue.handleAsOO), so a validation doesn't report it as
1376
+ * its result, and a batch stops at it rather than carry on with the next entry.
1377
+ * @param {string} place - Location identifier for debugging
1378
+ */
1379
+ abandonIfClientGone(place) {
1060
1380
  if (this._clock.clientGone) {
1061
1381
  this.log(`Operation abandoned @ ${place}: client disconnected`);
1062
1382
  const error = new Issue("error", "too-costly", null, null,
1063
- `Operation abandoned at ${place}: the client disconnected before the response was ready`, "too-costly", 422);
1383
+ `Operation abandoned at ${place}: the client disconnected before the response was ready`, "too-costly", 422).handleAsOO(422);
1064
1384
  error.abandoned = true;
1065
1385
  error.diagnostics = this.diagnostics();
1066
1386
  throw error;
1067
1387
  }
1068
- return result;
1069
1388
  }
1070
1389
 
1071
1390
  unSeeAll() {
@@ -1180,21 +1499,60 @@ class OperationContext {
1180
1499
  * @param {Object} provider - The provider returned from factory.build()
1181
1500
  */
1182
1501
  registerProvider(provider) {
1183
- if (provider && typeof provider.close === 'function') {
1184
- this._openProviders.push(provider);
1502
+ // once: a provider can be shared within an operation (Provider.buildOnce)
1503
+ if (provider && typeof provider.close === 'function' && !this._providers.open.includes(provider)) {
1504
+ this._providers.open.push(provider);
1505
+ if (this._providers.closed) {
1506
+ this.closeProviders().catch(() => {});
1507
+ }
1185
1508
  }
1186
1509
  }
1187
1510
 
1188
1511
  /**
1189
- * Close every provider registered during this operation. Safe to call
1190
- * multiple times — the list is cleared after the first call. Errors
1512
+ * A provider built once for this operation (and the copies that share its providers) and
1513
+ * reused after that: a value set with an include per code asks for the same code system
1514
+ * once per include, and each build of a database-backed one is a connection held until
1515
+ * the operation ends. The promise is kept, so calls that overlap share one build; a failed
1516
+ * build is not kept, so the next call tries again. The caller registers the provider for
1517
+ * closing (registerProvider), as for any other.
1518
+ *
1519
+ * @param {Object} factory - the factory that builds it (the same factory object is the same
1520
+ * code system and version, however it was looked up)
1521
+ * @param {string} variant - what else makes a different provider (see Provider.buildOnce:
1522
+ * the supplements)
1523
+ * @param {function(): Promise<Object>} build
1524
+ * @returns {Promise<Object>}
1525
+ */
1526
+ buildProviderOnce(factory, variant, build) {
1527
+ let built = this._providers.built.get(factory);
1528
+ if (!built) {
1529
+ built = new Map();
1530
+ this._providers.built.set(factory, built);
1531
+ }
1532
+ let provider = built.get(variant);
1533
+ if (!provider) {
1534
+ provider = Promise.resolve(build());
1535
+ provider.catch(() => {
1536
+ if (built.get(variant) === provider) {
1537
+ built.delete(variant);
1538
+ }
1539
+ });
1540
+ built.set(variant, provider);
1541
+ }
1542
+ return provider;
1543
+ }
1544
+
1545
+ /**
1546
+ * Close every provider registered during this operation, including through
1547
+ * a copy. Safe to call multiple times — the list is emptied each time. Errors
1191
1548
  * from individual close() calls are swallowed so one bad provider can't
1192
1549
  * prevent the others from releasing their resources.
1193
1550
  */
1194
1551
  async closeProviders() {
1195
- if (!this._openProviders || this._openProviders.length === 0) return;
1196
- const providers = this._openProviders;
1197
- this._openProviders = [];
1552
+ this._providers.closed = true;
1553
+ // nothing closed is handed out again
1554
+ this._providers.built.clear();
1555
+ const providers = this._providers.open.splice(0);
1198
1556
  for (const p of providers) {
1199
1557
  try {
1200
1558
  await p.close();
@@ -1225,7 +1583,10 @@ module.exports = {
1225
1583
  TimeTracker,
1226
1584
  ResourceCache,
1227
1585
  ExpansionCache,
1586
+ ExpansionCacheBudget,
1587
+ countExpansionCodes,
1228
1588
  isDebugging,
1229
1589
  debugLog,
1230
- formatDuration
1231
- };
1590
+ formatDuration,
1591
+ PROCESS_START
1592
+ };