fhirsmith 0.18.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ACCESSIBILITY.md +119 -0
- package/CHANGELOG.md +149 -0
- package/CONFIGURATION.md +305 -0
- package/LICENSE +29 -0
- package/README.md +29 -122
- package/cluster.js +1220 -0
- package/cluster.md +707 -0
- package/config-template.json +77 -8
- package/extension-tracker/extension-tracker-template.html +5 -8
- package/extension-tracker/extension-tracker.js +0 -2
- package/folder/folder.js +7 -6
- package/ips/analysis.js +560 -0
- package/ips/ips-template.html +5 -8
- package/ips/ips.js +132 -59
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +74 -21
- package/ips/presenter.js +1 -1
- package/ips/readme.md +132 -25
- package/ips/render.js +466 -77
- package/ips/store.js +79 -22
- package/ips/structure.js +247 -0
- package/ips/terminology.js +1 -1
- package/library/body-limit.js +22 -0
- package/library/cache-generation.js +15 -0
- package/library/cgroup-memory.js +133 -1
- package/library/cluster-counts.js +121 -0
- package/library/cluster-events.js +153 -0
- package/library/cluster-metrics.js +193 -0
- package/library/cluster-status.js +468 -0
- package/library/config-reader.js +96 -0
- package/library/daily-report.js +100 -2
- package/library/html-server.js +27 -1
- package/library/i18nsupport.js +23 -1
- package/library/keep-alive.js +18 -0
- package/library/languages.js +17 -3
- package/library/logger.js +31 -0
- package/library/memory-guard.js +428 -0
- package/library/native-memory.js +52 -0
- package/library/npm-audit.js +51 -25
- package/library/openapi-doc.js +7 -5
- package/library/package-manager.js +8 -0
- package/library/role.js +197 -0
- package/library/server-credentials.js +169 -0
- package/library/sqlite-utilities.js +59 -0
- package/library/ssrf-guard.js +100 -0
- package/library/static-response-stats.js +16 -0
- package/library/version-utilities.js +50 -0
- package/library/worker-count.js +192 -0
- package/nginx.md +30 -11
- package/npmprojector/indexer.js +18 -10
- package/package.json +11 -8
- package/packages/feed-check-page.js +117 -0
- package/packages/feed-check.js +279 -0
- package/packages/npm-check.js +57 -3
- package/packages/npm-signing.js +219 -0
- package/packages/openapi.yaml +207 -1
- package/packages/package-crawler.js +322 -41
- package/packages/packages-template.html +6 -8
- package/packages/packages.js +604 -57
- package/packages/readme.md +113 -1
- package/publisher/publisher-template.html +2 -5
- package/publisher/publisher.js +121 -110
- package/publisher/readme.md +75 -0
- package/registry/crawler.js +84 -108
- package/registry/documentation/configuration.md +34 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +5 -4
- package/root-bare-template.html +7 -4
- package/root-template.html +5 -8
- package/server.js +398 -198
- package/shl/shl.js +38 -37
- package/static/a11y/a11y-check.js +832 -0
- package/static/a11y/axe.min.js +12 -0
- package/static/assets/css/bootstrap-fhir.css +15 -15
- package/static/assets/images/flags/au.svg +18 -0
- package/static/assets/images/flags/unknown.svg +11 -0
- package/static/assets/images/flags/us.svg +63 -0
- package/static/assets/images/icon_audit.png +0 -0
- package/static/assets/images/icon_clinical.png +0 -0
- package/static/assets/images/icon_document.png +0 -0
- package/static/assets/images/icon_external.png +0 -0
- package/static/assets/images/icon_medication.png +0 -0
- package/static/assets/images/icon_organization.png +0 -0
- package/static/assets/images/icon_other.png +0 -0
- package/static/assets/images/icon_person.png +0 -0
- package/static/assets/images/icon_section.png +0 -0
- package/stats-db.js +45 -0
- package/stats.js +197 -10
- package/testing/testing-template.html +5 -8
- package/token/nginx_passport_setup.md +1 -1
- package/token/security_guide.md +1 -1
- package/token/token-template.html +2 -5
- package/token/token.js +40 -36
- package/translations/Messages.properties +19 -0
- package/tx/README.md +154 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +29 -1
- package/tx/cs/cs-api.js +284 -19
- package/tx/cs/cs-api.md +41 -4
- package/tx/cs/cs-areacode.js +1 -1
- package/tx/cs/cs-country.js +17 -5
- package/tx/cs/cs-cpt.js +33 -16
- package/tx/cs/cs-cs.js +148 -14
- package/tx/cs/cs-currency.js +1 -1
- package/tx/cs/cs-icd11.js +21 -13
- package/tx/cs/cs-icf.js +22 -3
- package/tx/cs/cs-lang.js +2 -2
- package/tx/cs/cs-loinc.js +185 -66
- package/tx/cs/cs-mimetypes.js +12 -2
- package/tx/cs/cs-ndc.js +40 -25
- package/tx/cs/cs-omop.js +54 -29
- package/tx/cs/cs-rxnorm.js +300 -196
- package/tx/cs/cs-snomed.js +608 -64
- package/tx/cs/cs-ucum.js +14 -4
- package/tx/cs/cs-unii.js +18 -14
- package/tx/cs/cs-virtual.js +210 -0
- package/tx/ecosystem-resolver.js +413 -0
- package/tx/html/cluster-dash-metrics.liquid +26 -0
- package/tx/html/cluster-home-metrics.liquid +31 -0
- package/tx/html/cluster-metrics-script.liquid +251 -0
- package/tx/html/codesystem-operations.liquid +3 -3
- package/tx/html/ecl-panel.liquid +1 -1
- package/tx/html/external-metrics.liquid +17 -0
- package/tx/html/operations-form.liquid +17 -17
- package/tx/html/search-form.liquid +11 -11
- package/tx/html/status-line.liquid +58 -0
- package/tx/html/status-panel.liquid +140 -0
- package/tx/html/status-tasks.liquid +25 -0
- package/tx/html/tx-template.html +2 -5
- package/tx/importers/atc-to-fhir.js +17 -16
- package/tx/importers/export-icd11-codesystem.js +3 -2
- package/tx/importers/import-icd11.js +4 -3
- package/tx/importers/import-loinc.module.js +118 -56
- package/tx/importers/import-ndc.module.js +56 -44
- package/tx/importers/import-rxnorm.module.js +118 -79
- package/tx/importers/import-sct.module.js +57 -58
- package/tx/importers/import-unii.module.js +37 -36
- package/tx/importers/subset-loinc.module.js +49 -48
- package/tx/importers/subset-rxnorm.module.js +29 -28
- package/tx/importers/terminal.js +18 -0
- package/tx/importers/tx-import-base.js +13 -12
- package/tx/importers/tx-import-settings.js +5 -4
- package/tx/importers/tx-import.js +53 -52
- package/tx/library/canonical-resource.js +49 -0
- package/tx/library/codesystem.js +94 -5
- package/tx/library/designations.js +45 -2
- package/tx/library/extensions.js +13 -7
- package/tx/library/implicit-valuesets.js +66 -0
- package/tx/library/renderer.js +77 -21
- package/tx/library/sqlite-utilities.js +2 -0
- package/tx/library/ucum-parsers.js +11 -1
- package/tx/library/wait-for-cache.js +43 -0
- package/tx/library.js +321 -22
- package/tx/openapi-operations.js +6 -4
- package/tx/operation-context.js +460 -99
- package/tx/params.js +25 -0
- package/tx/problems.js +3 -5
- package/tx/provider.js +278 -42
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/ecl.js +47 -6
- package/tx/sct/editions.js +39 -0
- package/tx/sct/expressions.js +10 -1
- package/tx/sct/load-config.js +107 -0
- package/tx/sct/mmap.js +109 -0
- package/tx/sct/structures.js +166 -13
- package/tx/sct/text-index.js +5 -1
- package/tx/syndication/syndication-feed.js +2 -2
- package/tx/tx-html.js +44 -8
- package/tx/tx.fhir.org.yml +8 -8
- package/tx/tx.js +282 -80
- package/tx/usage-tracker.js +39 -11
- package/tx/vs/vs-api.js +19 -0
- package/tx/vs/vs-database.js +386 -62
- package/tx/vs/vs-package.js +27 -4
- package/tx/vs/vs-vsac.js +648 -100
- package/tx/workers/batch-validate.js +37 -10
- package/tx/workers/batch.js +6 -1
- package/tx/workers/cache-control.js +15 -4
- package/tx/workers/closure.js +156 -13
- package/tx/workers/compare.js +8 -5
- package/tx/workers/expand.js +1173 -289
- package/tx/workers/metadata.js +35 -0
- package/tx/workers/search.js +53 -1
- package/tx/workers/supplement-filters.js +145 -0
- package/tx/workers/translate.js +169 -4
- package/tx/workers/validate.js +223 -119
- package/tx/workers/worker.js +123 -31
- package/tx/xversion/xv-valueset.js +7 -4
- package/utilities/submit-test-report.js +3 -1
- package/utilities/tx-stress-test.js +482 -0
- package/vcl/readme.md +37 -0
- package/vcl/vcl-parser.js +15 -0
- package/xig/readme.md +52 -0
- package/xig/xig-template.html +5 -8
- package/xig/xig.js +238 -422
- package/changelog-template.md +0 -22
- package/registry/test-runner.js +0 -237
- package/release.sh +0 -80
- package/test-scripts/repro-re2-wasm-leak.js +0 -34
- package/tsconfig.eslint.json +0 -9
- package/tx/html/dash-metrics.liquid +0 -147
- package/tx/html/home-metrics.liquid +0 -225
package/tx/operation-context.js
CHANGED
|
@@ -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
|
|
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>"
|
|
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
|
|
145
|
-
//
|
|
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,
|
|
154
|
-
* has an instance code
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
//
|
|
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
|
|
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:
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
-
//
|
|
655
|
-
|
|
656
|
-
//
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
734
|
-
const
|
|
735
|
-
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
|
993
|
-
* one left to send the result to.
|
|
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
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
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
|
-
*
|
|
1190
|
-
*
|
|
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
|
-
|
|
1196
|
-
|
|
1197
|
-
this.
|
|
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
|
+
};
|