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
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
//
|
|
2
|
+
// SqliteResourceCache - the $cache-control resource cache kept in one SQLite file
|
|
3
|
+
// that every server process on the machine shares.
|
|
4
|
+
//
|
|
5
|
+
// Used when server.cluster.sharedCache is on (cluster.md, "Options"): the master and the
|
|
6
|
+
// tx-workers then keep their $cache-control caches here instead of in their own memory, so
|
|
7
|
+
// any process can answer any cache-id. The memory store (ResourceCache) is the default.
|
|
8
|
+
// This class is the same interface with the same semantics, over a file:
|
|
9
|
+
//
|
|
10
|
+
// - Caches are not carried over a server restart. A client can't be sure a cache is
|
|
11
|
+
// intact across operations it tried while the server was down, and the server can't
|
|
12
|
+
// either. So whenever the master starts (under the launcher or by hand), every live
|
|
13
|
+
// cache, in every endpoint, becomes a tombstone "lost to a server restart", and a new
|
|
14
|
+
// generation begins. A tx-worker restarting while the master carries on joins the
|
|
15
|
+
// current generation and keeps every cache. Cache-ids carry their generation
|
|
16
|
+
// ("<code>.<generation>.<random>"); writes and lazy reads check it inside their
|
|
17
|
+
// transaction, and the generations of earlier runs are remembered, so an id from
|
|
18
|
+
// before a restart is reported as lost to it, not as never issued.
|
|
19
|
+
// - Nothing about a cache's contents is held in memory between requests. Each use
|
|
20
|
+
// reads the rows; there is no per-process copy that could go stale when another
|
|
21
|
+
// process changes the cache.
|
|
22
|
+
// - A request only pays for the resources it uses. get() reads the list of what the
|
|
23
|
+
// cache holds (type, url, version - not the content) and returns a stand-in for
|
|
24
|
+
// each CodeSystem / ValueSet / ConceptMap that answers those from the list; the
|
|
25
|
+
// first time a request touches anything else on one, its JSON is read and the
|
|
26
|
+
// real wrapper built (see lazyResource). Most requests look up a handful of
|
|
27
|
+
// resources by url in a cache of tens or hundreds.
|
|
28
|
+
// - Every write is one short BEGIN IMMEDIATE transaction; WAL mode lets readers
|
|
29
|
+
// carry on while one process writes; busy_timeout makes a writer wait for another
|
|
30
|
+
// rather than fail.
|
|
31
|
+
// - last_used is refreshed at most every touchIntervalMs (default 30 s) per cache,
|
|
32
|
+
// not on every read, so steady use of a cache is reads only.
|
|
33
|
+
// - The idle sweep (prune) and the size cap (maxBytes) move the caches they drop to
|
|
34
|
+
// tombstones in the same transaction, so whichever process asks about a dead
|
|
35
|
+
// cache-id later is told what happened to it. Any process may sweep; a sweep that
|
|
36
|
+
// finds nothing to do (because another process just did it) does nothing.
|
|
37
|
+
//
|
|
38
|
+
// The file must be on a local disk. SQLite's locking is not reliable on network
|
|
39
|
+
// filesystems (NFS, SMB, most container volume drivers backed by them), and a
|
|
40
|
+
// broken lock there corrupts the file.
|
|
41
|
+
//
|
|
42
|
+
|
|
43
|
+
const fs = require('fs');
|
|
44
|
+
const path = require('path');
|
|
45
|
+
const crypto = require('crypto');
|
|
46
|
+
const { newCacheGeneration, TOKEN } = require('../library/cache-generation');
|
|
47
|
+
const Database = require('better-sqlite3');
|
|
48
|
+
const { ResourceCache, formatDuration } = require('./operation-context');
|
|
49
|
+
const { Issue } = require('./library/operation-outcome');
|
|
50
|
+
const { englishMessage } = require('../library/i18nsupport');
|
|
51
|
+
|
|
52
|
+
// An Issue with its message id and parameters, in English (the worker puts it in the
|
|
53
|
+
// request's language where it can: msgParams, translateCacheIssue)
|
|
54
|
+
function cacheIssue(level, cause, messageId, params, txIssueType, status) {
|
|
55
|
+
const issue = new Issue(level, cause, null, messageId, englishMessage(messageId, params), txIssueType, status);
|
|
56
|
+
issue.msgParams = params;
|
|
57
|
+
return issue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const SCHEMA_VERSION = 2;
|
|
61
|
+
const DEFAULT_TOUCH_INTERVAL_MS = 30 * 1000;
|
|
62
|
+
const DEFAULT_BUSY_TIMEOUT_MS = 5000;
|
|
63
|
+
// how many earlier generations are remembered, to report their ids as lost to a restart
|
|
64
|
+
const MAX_PAST_GENERATIONS = 1000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Render a byte count for a human: "512 KB", "3.4 MB", "2 GB".
|
|
68
|
+
* @param {number} bytes
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
function formatBytes(bytes) {
|
|
72
|
+
if (bytes === null || bytes === undefined || !isFinite(bytes) || bytes < 0) {
|
|
73
|
+
return 'an unknown size';
|
|
74
|
+
}
|
|
75
|
+
if (bytes < 1024 * 1024) {
|
|
76
|
+
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
77
|
+
}
|
|
78
|
+
if (bytes < 1024 * 1024 * 1024) {
|
|
79
|
+
return `${(bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, '')} MB`;
|
|
80
|
+
}
|
|
81
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1).replace(/\.0$/, '')} GB`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// One connection per file per process, shared by the endpoints (each has its own scope)
|
|
85
|
+
const connections = new Map(); // absolute path -> { db, refs }
|
|
86
|
+
|
|
87
|
+
function openDatabase(file, busyTimeoutMs) {
|
|
88
|
+
const abs = path.resolve(file);
|
|
89
|
+
const existing = connections.get(abs);
|
|
90
|
+
if (existing) {
|
|
91
|
+
existing.refs++;
|
|
92
|
+
return existing.db;
|
|
93
|
+
}
|
|
94
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
95
|
+
const db = new Database(abs);
|
|
96
|
+
// busy_timeout first: switching to WAL and creating the tables need locks that
|
|
97
|
+
// another process starting at the same moment may hold
|
|
98
|
+
db.pragma(`busy_timeout = ${busyTimeoutMs}`);
|
|
99
|
+
db.pragma('journal_mode = WAL');
|
|
100
|
+
// NORMAL is safe with WAL (no corruption); a power cut can lose the last
|
|
101
|
+
// transactions, which for a cache of client resources is acceptable
|
|
102
|
+
db.pragma('synchronous = NORMAL');
|
|
103
|
+
db.transaction(() => {
|
|
104
|
+
db.exec(`
|
|
105
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
106
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
107
|
+
scope TEXT NOT NULL,
|
|
108
|
+
cache_id TEXT NOT NULL,
|
|
109
|
+
created_at INTEGER NOT NULL,
|
|
110
|
+
last_used INTEGER NOT NULL,
|
|
111
|
+
sealed INTEGER NOT NULL DEFAULT 0,
|
|
112
|
+
concepts INTEGER NOT NULL DEFAULT 0,
|
|
113
|
+
resources INTEGER NOT NULL DEFAULT 0,
|
|
114
|
+
bytes INTEGER NOT NULL DEFAULT 0,
|
|
115
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
116
|
+
PRIMARY KEY (scope, cache_id)
|
|
117
|
+
);
|
|
118
|
+
CREATE INDEX IF NOT EXISTS sessions_last_used ON sessions (last_used);
|
|
119
|
+
CREATE TABLE IF NOT EXISTS resources (
|
|
120
|
+
id INTEGER PRIMARY KEY,
|
|
121
|
+
scope TEXT NOT NULL,
|
|
122
|
+
cache_id TEXT NOT NULL,
|
|
123
|
+
key TEXT NOT NULL,
|
|
124
|
+
resource_type TEXT,
|
|
125
|
+
url TEXT,
|
|
126
|
+
version TEXT,
|
|
127
|
+
fhir_version TEXT,
|
|
128
|
+
kind TEXT NOT NULL,
|
|
129
|
+
hash TEXT NOT NULL,
|
|
130
|
+
json TEXT NOT NULL,
|
|
131
|
+
bytes INTEGER NOT NULL,
|
|
132
|
+
concepts INTEGER NOT NULL DEFAULT 0,
|
|
133
|
+
supplements TEXT,
|
|
134
|
+
global_supplement INTEGER,
|
|
135
|
+
UNIQUE (scope, cache_id, key)
|
|
136
|
+
);
|
|
137
|
+
CREATE TABLE IF NOT EXISTS generations (
|
|
138
|
+
generation TEXT PRIMARY KEY,
|
|
139
|
+
started INTEGER NOT NULL,
|
|
140
|
+
ended INTEGER
|
|
141
|
+
);
|
|
142
|
+
CREATE TABLE IF NOT EXISTS tombstones (
|
|
143
|
+
scope TEXT NOT NULL,
|
|
144
|
+
cache_id TEXT NOT NULL,
|
|
145
|
+
reason TEXT NOT NULL,
|
|
146
|
+
at INTEGER NOT NULL,
|
|
147
|
+
created_at INTEGER,
|
|
148
|
+
last_used INTEGER,
|
|
149
|
+
idle_ms INTEGER,
|
|
150
|
+
max_age_ms INTEGER,
|
|
151
|
+
max_bytes INTEGER,
|
|
152
|
+
PRIMARY KEY (scope, cache_id)
|
|
153
|
+
);
|
|
154
|
+
`);
|
|
155
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = 'schema'").get();
|
|
156
|
+
if (!row) {
|
|
157
|
+
db.prepare("INSERT INTO meta (key, value) VALUES ('schema', ?)").run(String(SCHEMA_VERSION));
|
|
158
|
+
} else if (row.value !== String(SCHEMA_VERSION)) {
|
|
159
|
+
throw new Error(`Resource cache ${abs} has schema version ${row.value}; this server uses ${SCHEMA_VERSION}. Delete the file (it only holds client caches) and restart`);
|
|
160
|
+
}
|
|
161
|
+
}).immediate();
|
|
162
|
+
connections.set(abs, { db, refs: 1 });
|
|
163
|
+
return db;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function releaseDatabase(file) {
|
|
167
|
+
const abs = path.resolve(file);
|
|
168
|
+
const c = connections.get(abs);
|
|
169
|
+
if (!c) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
c.refs--;
|
|
173
|
+
if (c.refs <= 0) {
|
|
174
|
+
connections.delete(abs);
|
|
175
|
+
try {
|
|
176
|
+
c.db.close();
|
|
177
|
+
} catch (e) {
|
|
178
|
+
// closing is best effort
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The wrapper classes are loaded on first use: they pull in a good deal of the server
|
|
184
|
+
let wrappers = null;
|
|
185
|
+
function wrapperClass(resourceType) {
|
|
186
|
+
if (!wrappers) {
|
|
187
|
+
wrappers = {
|
|
188
|
+
CodeSystem: require('./library/codesystem').CodeSystem,
|
|
189
|
+
ValueSet: require('./library/valueset'),
|
|
190
|
+
ConceptMap: require('./library/conceptmap').ConceptMap
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return wrappers[resourceType] || null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Content hash of a resource's JSON, as the expansion cache key uses it
|
|
198
|
+
* (CanonicalResource.computeExpansionKeyHash: the first 16 hex characters of a SHA-256
|
|
199
|
+
* of JSON.stringify(jsonObj)). Stored with the row, and handed to each wrapper built from
|
|
200
|
+
* it (setExpansionKeyHash), so a cached resource is never serialised again to be hashed
|
|
201
|
+
* and its expansion keys are the ones the memory store would make.
|
|
202
|
+
* @param {string} json
|
|
203
|
+
* @returns {string}
|
|
204
|
+
*/
|
|
205
|
+
function contentHash(json) {
|
|
206
|
+
return crypto.createHash('sha256').update(json).digest('hex').substring(0, 16);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Read from the index row, without building the resource: what callers check while
|
|
210
|
+
// looking through the cache for the one they want (see TerminologyWorker
|
|
211
|
+
// findInAdditionalResources, listVersions, loadSupplements; ExpansionCache.computeKey)
|
|
212
|
+
const INDEX_PROPERTIES = new Set(['url', 'version', 'vurl', 'versionedUrl', 'resourceType', 'fhirType',
|
|
213
|
+
'supplementsUrl', 'isCached', 'isGlobalSupplement', 'expansionKeyHash']);
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A stand-in for a cached CodeSystem / ValueSet / ConceptMap that is only built
|
|
217
|
+
* when it is used. It is an instance of the wrapper class (instanceof works), and
|
|
218
|
+
* answers url, version, resourceType and the like from the index row. Touching
|
|
219
|
+
* anything else - jsonObj, a method, any other property - loads the JSON and builds
|
|
220
|
+
* the real wrapper, once; from then on everything goes to that wrapper, and methods
|
|
221
|
+
* run on it directly (bound), so a resource that is used costs the same as one
|
|
222
|
+
* built eagerly, and one that isn't costs nothing.
|
|
223
|
+
*
|
|
224
|
+
* @param {Function} Cls - the wrapper class
|
|
225
|
+
* @param {Object} row - index row: key, resource_type, url, version, supplements, global_supplement, hash
|
|
226
|
+
* @param {Function} load - () => the real wrapper (reads the JSON)
|
|
227
|
+
*/
|
|
228
|
+
function lazyResource(Cls, row, load) {
|
|
229
|
+
let real = null;
|
|
230
|
+
const bound = new Map();
|
|
231
|
+
const target = Object.create(Cls.prototype);
|
|
232
|
+
const url = row.url === null ? undefined : row.url;
|
|
233
|
+
const version = row.version === null ? undefined : row.version;
|
|
234
|
+
const index = {
|
|
235
|
+
url,
|
|
236
|
+
version,
|
|
237
|
+
vurl: version ? url + '|' + version : url,
|
|
238
|
+
versionedUrl: version ? url + '|' + version : url,
|
|
239
|
+
resourceType: row.resource_type,
|
|
240
|
+
fhirType: row.resource_type,
|
|
241
|
+
supplementsUrl: row.supplements === null ? undefined : row.supplements,
|
|
242
|
+
isCached: true,
|
|
243
|
+
// the expansion cache key's hash of this resource (see contentHash): known from the row
|
|
244
|
+
expansionKeyHash: () => row.hash,
|
|
245
|
+
// CodeSystem.isGlobalSupplement(), worked out when it was stored (null: not known)
|
|
246
|
+
isGlobalSupplement: row.global_supplement === null || row.global_supplement === undefined
|
|
247
|
+
? undefined : () => row.global_supplement === 1
|
|
248
|
+
};
|
|
249
|
+
const materialise = () => {
|
|
250
|
+
if (!real) {
|
|
251
|
+
real = load();
|
|
252
|
+
}
|
|
253
|
+
return real;
|
|
254
|
+
};
|
|
255
|
+
return new Proxy(target, {
|
|
256
|
+
get(t, prop) {
|
|
257
|
+
if (!real && INDEX_PROPERTIES.has(prop) && index[prop] !== undefined) {
|
|
258
|
+
return index[prop];
|
|
259
|
+
}
|
|
260
|
+
if (!real && INDEX_PROPERTIES.has(prop) && prop !== 'isGlobalSupplement') {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
const r = materialise();
|
|
264
|
+
const v = Reflect.get(r, prop, r);
|
|
265
|
+
if (typeof v === 'function' && prop !== 'constructor') {
|
|
266
|
+
let b = bound.get(prop);
|
|
267
|
+
if (!b || b.fn !== v) {
|
|
268
|
+
b = { fn: v, bound: v.bind(r) };
|
|
269
|
+
bound.set(prop, b);
|
|
270
|
+
}
|
|
271
|
+
return b.bound;
|
|
272
|
+
}
|
|
273
|
+
return v;
|
|
274
|
+
},
|
|
275
|
+
set(t, prop, value) {
|
|
276
|
+
return Reflect.set(materialise(), prop, value);
|
|
277
|
+
},
|
|
278
|
+
has(t, prop) {
|
|
279
|
+
return Reflect.has(materialise(), prop);
|
|
280
|
+
},
|
|
281
|
+
deleteProperty(t, prop) {
|
|
282
|
+
return Reflect.deleteProperty(materialise(), prop);
|
|
283
|
+
},
|
|
284
|
+
defineProperty(t, prop, desc) {
|
|
285
|
+
return Reflect.defineProperty(materialise(), prop, desc);
|
|
286
|
+
},
|
|
287
|
+
ownKeys() {
|
|
288
|
+
return Reflect.ownKeys(materialise());
|
|
289
|
+
},
|
|
290
|
+
getOwnPropertyDescriptor(t, prop) {
|
|
291
|
+
const d = Reflect.getOwnPropertyDescriptor(materialise(), prop);
|
|
292
|
+
if (d) {
|
|
293
|
+
d.configurable = true; // a proxy may not report a non-configurable property its target lacks
|
|
294
|
+
}
|
|
295
|
+
return d;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function markCached(resource) {
|
|
301
|
+
if (resource && typeof resource === 'object') {
|
|
302
|
+
// the same non-enumerable marker the memory store sets (see ResourceCache.add)
|
|
303
|
+
Object.defineProperty(resource, 'isCached', { value: true, enumerable: false, configurable: true, writable: true });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
class SqliteResourceCache extends ResourceCache {
|
|
308
|
+
/**
|
|
309
|
+
* @param {Object} stats - server stats (may be null)
|
|
310
|
+
* @param {Object} options
|
|
311
|
+
* @param {string} options.path - the SQLite file (local disk only)
|
|
312
|
+
* @param {string} [options.scope=''] - which endpoint's caches these are; endpoints
|
|
313
|
+
* share the file but not caches
|
|
314
|
+
* @param {number} [options.maxTombstones=1000]
|
|
315
|
+
* @param {number} [options.maxBytes=0] - size cap for the whole file, 0 = none
|
|
316
|
+
* @param {number} [options.touchIntervalMs=30000] - least time between last_used refreshes
|
|
317
|
+
* @param {number} [options.busyTimeoutMs=5000]
|
|
318
|
+
* @param {string|null} [options.generation] - the server lifecycle (tx.js): the master
|
|
319
|
+
* passes a new token each time it starts, which moves every live cache to a "lost to
|
|
320
|
+
* a server restart" tombstone and begins that generation; a tx-worker passes null and
|
|
321
|
+
* joins the current one. Omit for a store that keeps caches across opens and accepts
|
|
322
|
+
* any id (tests).
|
|
323
|
+
*/
|
|
324
|
+
constructor(stats, options = {}) {
|
|
325
|
+
super(stats, options.maxTombstones || 1000);
|
|
326
|
+
this.maxBytes = options.maxBytes > 0 ? options.maxBytes : 0;
|
|
327
|
+
if (!options.path) {
|
|
328
|
+
throw new Error('SqliteResourceCache needs a path');
|
|
329
|
+
}
|
|
330
|
+
this.path = path.resolve(options.path);
|
|
331
|
+
this.scope = options.scope || '';
|
|
332
|
+
this.touchIntervalMs = options.touchIntervalMs >= 0 ? options.touchIntervalMs : DEFAULT_TOUCH_INTERVAL_MS;
|
|
333
|
+
this.managedLifecycle = Object.hasOwn(options, 'generation');
|
|
334
|
+
if (this.managedLifecycle && options.generation !== null &&
|
|
335
|
+
(typeof options.generation !== 'string' || !TOKEN.test(options.generation))) {
|
|
336
|
+
throw new Error('Invalid internal resource-cache generation');
|
|
337
|
+
}
|
|
338
|
+
this.db = openDatabase(this.path, options.busyTimeoutMs || DEFAULT_BUSY_TIMEOUT_MS);
|
|
339
|
+
this.closed = false;
|
|
340
|
+
this._prepare();
|
|
341
|
+
try {
|
|
342
|
+
if (this.managedLifecycle) this._initializeGeneration(options.generation);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
this.close();
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_prepare() {
|
|
350
|
+
const db = this.db;
|
|
351
|
+
this.q = {
|
|
352
|
+
generation: db.prepare("SELECT value FROM meta WHERE key = 'generation'"),
|
|
353
|
+
pastGeneration: db.prepare('SELECT * FROM generations WHERE generation = ? AND ended IS NOT NULL'),
|
|
354
|
+
session: db.prepare('SELECT * FROM sessions WHERE scope = ? AND cache_id = ?'),
|
|
355
|
+
sessionExists: db.prepare('SELECT 1 FROM sessions WHERE scope = ? AND cache_id = ?'),
|
|
356
|
+
sealed: db.prepare('SELECT sealed FROM sessions WHERE scope = ? AND cache_id = ?'),
|
|
357
|
+
resources: db.prepare('SELECT key, resource_type, fhir_version, kind, hash, json FROM resources WHERE scope = ? AND cache_id = ? ORDER BY id'),
|
|
358
|
+
// the index: everything but the content (and the content only of what isn't a wrapper)
|
|
359
|
+
index: db.prepare(`SELECT key, resource_type, url, version, supplements, global_supplement, fhir_version, kind, hash,
|
|
360
|
+
CASE WHEN kind = 'wrapper' THEN NULL ELSE json END AS json
|
|
361
|
+
FROM resources WHERE scope = ? AND cache_id = ? ORDER BY id`),
|
|
362
|
+
content: db.prepare('SELECT json, fhir_version, hash FROM resources WHERE scope = ? AND cache_id = ? AND key = ?'),
|
|
363
|
+
resourceHash: db.prepare('SELECT hash, concepts FROM resources WHERE scope = ? AND cache_id = ? AND key = ?'),
|
|
364
|
+
touch: db.prepare('UPDATE sessions SET last_used = ? WHERE scope = ? AND cache_id = ? AND last_used < ?'),
|
|
365
|
+
insertSession: db.prepare('INSERT INTO sessions (scope, cache_id, created_at, last_used, sealed) VALUES (?, ?, ?, ?, ?)'),
|
|
366
|
+
upsertResource: db.prepare(`
|
|
367
|
+
INSERT INTO resources (scope, cache_id, key, resource_type, url, version, supplements, global_supplement, fhir_version, kind, hash, json, bytes, concepts)
|
|
368
|
+
VALUES (@scope, @cacheId, @key, @resourceType, @url, @version, @supplements, @globalSupplement, @fhirVersion, @kind, @hash, @json, @bytes, @concepts)
|
|
369
|
+
ON CONFLICT (scope, cache_id, key) DO UPDATE SET
|
|
370
|
+
resource_type = excluded.resource_type, url = excluded.url, version = excluded.version, supplements = excluded.supplements,
|
|
371
|
+
global_supplement = excluded.global_supplement,
|
|
372
|
+
fhir_version = excluded.fhir_version, kind = excluded.kind, hash = excluded.hash,
|
|
373
|
+
json = excluded.json, bytes = excluded.bytes, concepts = excluded.concepts`),
|
|
374
|
+
totals: db.prepare(`
|
|
375
|
+
UPDATE sessions SET
|
|
376
|
+
concepts = (SELECT COALESCE(SUM(concepts), 0) FROM resources r WHERE r.scope = sessions.scope AND r.cache_id = sessions.cache_id),
|
|
377
|
+
bytes = (SELECT COALESCE(SUM(bytes), 0) FROM resources r WHERE r.scope = sessions.scope AND r.cache_id = sessions.cache_id),
|
|
378
|
+
resources = (SELECT COUNT(*) FROM resources r WHERE r.scope = sessions.scope AND r.cache_id = sessions.cache_id),
|
|
379
|
+
last_used = ?, version = version + 1
|
|
380
|
+
WHERE scope = ? AND cache_id = ?`),
|
|
381
|
+
deleteResources: db.prepare('DELETE FROM resources WHERE scope = ? AND cache_id = ?'),
|
|
382
|
+
deleteSession: db.prepare('DELETE FROM sessions WHERE scope = ? AND cache_id = ?'),
|
|
383
|
+
tombstone: db.prepare('SELECT * FROM tombstones WHERE scope = ? AND cache_id = ?'),
|
|
384
|
+
insertTombstone: db.prepare(`
|
|
385
|
+
INSERT OR REPLACE INTO tombstones (scope, cache_id, reason, at, created_at, last_used, idle_ms, max_age_ms, max_bytes)
|
|
386
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
387
|
+
deleteTombstone: db.prepare('DELETE FROM tombstones WHERE scope = ? AND cache_id = ?'),
|
|
388
|
+
trimTombstones: db.prepare(`
|
|
389
|
+
DELETE FROM tombstones WHERE scope = ? AND rowid NOT IN
|
|
390
|
+
(SELECT rowid FROM tombstones WHERE scope = ? ORDER BY rowid DESC LIMIT ?)`),
|
|
391
|
+
tombstoneCount: db.prepare('SELECT COUNT(*) AS n FROM tombstones WHERE scope = ?'),
|
|
392
|
+
expired: db.prepare('SELECT * FROM sessions WHERE scope = ? AND last_used < ?'),
|
|
393
|
+
allSessions: db.prepare('SELECT * FROM sessions WHERE scope = ?'),
|
|
394
|
+
scopeTotals: db.prepare('SELECT COUNT(*) AS n, COALESCE(SUM(concepts), 0) AS concepts, COALESCE(SUM(bytes), 0) AS bytes FROM sessions WHERE scope = ?'),
|
|
395
|
+
fileBytes: db.prepare('SELECT COALESCE(SUM(bytes), 0) AS bytes FROM sessions'),
|
|
396
|
+
lru: db.prepare('SELECT * FROM sessions WHERE NOT (scope = ? AND cache_id = ?) ORDER BY last_used, created_at LIMIT 16'),
|
|
397
|
+
// the concept caps (#350) are per endpoint, as in the memory store
|
|
398
|
+
lruScope: db.prepare('SELECT * FROM sessions WHERE scope = ? AND cache_id <> ? ORDER BY last_used, created_at LIMIT 16')
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
_initializeGeneration(generation) {
|
|
403
|
+
this.lostToRestart = 0;
|
|
404
|
+
this.db.transaction(() => {
|
|
405
|
+
const current = this.q.generation.get()?.value;
|
|
406
|
+
if (generation === null) {
|
|
407
|
+
if (!current) throw new Error('Start the master before workers using the shared resource cache');
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (current === generation) {
|
|
411
|
+
return; // another endpoint of this same master: the first one did it
|
|
412
|
+
}
|
|
413
|
+
// The master is starting: no cache, in any endpoint, is carried over
|
|
414
|
+
const now = Date.now();
|
|
415
|
+
this.lostToRestart = this.db.prepare('SELECT COUNT(*) AS n FROM sessions').get().n;
|
|
416
|
+
this.db.prepare(`
|
|
417
|
+
INSERT OR REPLACE INTO tombstones (scope, cache_id, reason, at, created_at, last_used)
|
|
418
|
+
SELECT scope, cache_id, 'restart', ?, created_at, last_used FROM sessions`).run(now);
|
|
419
|
+
this.db.exec('DELETE FROM resources; DELETE FROM sessions;');
|
|
420
|
+
for (const { scope } of this.db.prepare('SELECT DISTINCT scope FROM tombstones').all()) {
|
|
421
|
+
this.q.trimTombstones.run(scope, scope, this.maxTombstones);
|
|
422
|
+
}
|
|
423
|
+
if (current) {
|
|
424
|
+
this.db.prepare('INSERT OR IGNORE INTO generations (generation, started) VALUES (?, ?)').run(current, now);
|
|
425
|
+
this.db.prepare('UPDATE generations SET ended = ? WHERE generation = ?').run(now, current);
|
|
426
|
+
}
|
|
427
|
+
this.db.prepare('INSERT OR REPLACE INTO generations (generation, started) VALUES (?, ?)').run(generation, now);
|
|
428
|
+
this.db.prepare(`DELETE FROM generations WHERE rowid NOT IN
|
|
429
|
+
(SELECT rowid FROM generations ORDER BY rowid DESC LIMIT ?)`).run(MAX_PAST_GENERATIONS + 1);
|
|
430
|
+
this.db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('generation', ?)").run(generation);
|
|
431
|
+
}).immediate();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
newCacheId() {
|
|
435
|
+
if (!this.managedLifecycle) return super.newCacheId();
|
|
436
|
+
// Read the current token each time: surviving manual workers join a replacement
|
|
437
|
+
// master's lifecycle without restarting. Minting before a rotation is safe;
|
|
438
|
+
// set() checks the token again inside its write transaction.
|
|
439
|
+
const generation = this.q.generation.get().value;
|
|
440
|
+
const id = `${generation}.${newCacheGeneration()}`;
|
|
441
|
+
return this.instanceCode ? `${this.instanceCode}.${id}` : id;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The generation in a cache-id this store issued ("[<code>.]<generation>.<random>"), or
|
|
446
|
+
* null if it isn't in that form. Tokens have no '.', so the id splits into its parts.
|
|
447
|
+
*/
|
|
448
|
+
static generationOf(cacheId) {
|
|
449
|
+
if (typeof cacheId !== 'string') return null;
|
|
450
|
+
const parts = cacheId.split('.');
|
|
451
|
+
if (parts.length < 2 || parts.length > 3) return null;
|
|
452
|
+
const [generation, random] = parts.slice(-2);
|
|
453
|
+
return TOKEN.test(generation) && TOKEN.test(random) ? generation : null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
_isCurrentGeneration(cacheId) {
|
|
457
|
+
if (!this.managedLifecycle) return true;
|
|
458
|
+
const generation = SqliteResourceCache.generationOf(cacheId);
|
|
459
|
+
return generation !== null && generation === this.q.generation.get()?.value;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
_assertGeneration(cacheId) {
|
|
463
|
+
if (!this._isCurrentGeneration(cacheId)) {
|
|
464
|
+
// has() said it was here: the master restarted while this request was using it
|
|
465
|
+
const generation = SqliteResourceCache.generationOf(cacheId);
|
|
466
|
+
const past = generation ? this.q.pastGeneration.get(generation) : null;
|
|
467
|
+
throw cacheIssue('error', 'not-found', 'CACHE_ID_LOST_TO_RESTART',
|
|
468
|
+
[cacheId, formatDuration(past ? Date.now() - past.ended : null)], 'cache-id-unknown', 404);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ---------- reads ----------
|
|
473
|
+
|
|
474
|
+
status(cacheId) {
|
|
475
|
+
if (!this._isCurrentGeneration(cacheId)) return { exists: false };
|
|
476
|
+
const s = this.q.session.get(this.scope, cacheId);
|
|
477
|
+
if (!s) {
|
|
478
|
+
return { exists: false };
|
|
479
|
+
}
|
|
480
|
+
const now = Date.now();
|
|
481
|
+
return {
|
|
482
|
+
exists: true,
|
|
483
|
+
sealed: !!s.sealed,
|
|
484
|
+
resources: s.resources,
|
|
485
|
+
concepts: s.concepts || 0,
|
|
486
|
+
idleMs: Math.max(0, now - s.last_used),
|
|
487
|
+
ageMs: s.created_at ? now - s.created_at : null,
|
|
488
|
+
timeoutMs: this.idleTimeoutMs
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
has(cacheId) {
|
|
493
|
+
return this._isCurrentGeneration(cacheId) && !!this.q.sessionExists.get(this.scope, cacheId);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
isSealed(cacheId) {
|
|
497
|
+
if (!this._isCurrentGeneration(cacheId)) return false;
|
|
498
|
+
const s = this.q.sealed.get(this.scope, cacheId);
|
|
499
|
+
return s ? !!s.sealed : false;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The resources held under a cache-id: a fresh set of objects every call, nothing
|
|
504
|
+
* shared with other requests. Wrappers are stand-ins that are only built when the
|
|
505
|
+
* request uses them (see lazyResource). The session row and the index are read in
|
|
506
|
+
* one read transaction. Later lazy reads check the indexed content hash: if another
|
|
507
|
+
* process replaced a resource meanwhile, this request fails so the client can retry
|
|
508
|
+
* from a fresh index, rather than mixing an old expansion key with new content.
|
|
509
|
+
*/
|
|
510
|
+
get(cacheId) {
|
|
511
|
+
let session = null;
|
|
512
|
+
let rows = [];
|
|
513
|
+
this.db.transaction(() => {
|
|
514
|
+
this._assertGeneration(cacheId);
|
|
515
|
+
session = this.q.session.get(this.scope, cacheId);
|
|
516
|
+
if (session) {
|
|
517
|
+
rows = this.q.index.all(this.scope, cacheId);
|
|
518
|
+
}
|
|
519
|
+
}).deferred();
|
|
520
|
+
if (!session) {
|
|
521
|
+
if (this.logCacheOps) {
|
|
522
|
+
this.log.info(`cache-id '${cacheId}': miss (no entry)`);
|
|
523
|
+
}
|
|
524
|
+
return [];
|
|
525
|
+
}
|
|
526
|
+
this._maybeTouch(cacheId, session.last_used);
|
|
527
|
+
const result = rows.map(r => this._hydrate({ ...r, cacheId }));
|
|
528
|
+
if (this.logCacheOps) {
|
|
529
|
+
this.log.info(`cache-id '${cacheId}': hit, returning ${result.length} resource(s): ${rows.map(r => r.key).join(', ')}`);
|
|
530
|
+
}
|
|
531
|
+
return result;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Rebuild one stored resource. Wrappers (CodeSystem / ValueSet / ConceptMap) are
|
|
536
|
+
* stored in their internal R5 form, so they are rebuilt as R5 and then given back
|
|
537
|
+
* the FHIR version they arrived in.
|
|
538
|
+
*/
|
|
539
|
+
_hydrate(row) {
|
|
540
|
+
if (row.kind === 'wrapper' && row.json === null) {
|
|
541
|
+
const Cls = wrapperClass(row.resource_type);
|
|
542
|
+
if (Cls) {
|
|
543
|
+
const cacheId = row.cacheId;
|
|
544
|
+
return lazyResource(Cls, row, () => this._loadWrapper(Cls, cacheId, row));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return this._build(row);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Build the real wrapper behind a stand-in. If the resource is no longer there
|
|
552
|
+
* (the client ended the cache, or it expired or was evicted, while this request
|
|
553
|
+
* was using it), the request fails the way a request on a dead cache-id does.
|
|
554
|
+
*/
|
|
555
|
+
_loadWrapper(Cls, cacheId, row) {
|
|
556
|
+
this._assertGeneration(cacheId);
|
|
557
|
+
const c = this.q.content.get(this.scope, cacheId, row.key);
|
|
558
|
+
if (!c) {
|
|
559
|
+
const t = this.tombstone(cacheId);
|
|
560
|
+
throw cacheIssue('error', 'not-found', 'CACHE_ID_GONE_DURING_REQUEST',
|
|
561
|
+
[cacheId, t ? t.reason : 'gone', row.key.replace(/\|/g, ' ')], 'cache-id-unknown', 404);
|
|
562
|
+
}
|
|
563
|
+
if (c.hash !== row.hash || c.fhir_version !== row.fhir_version) {
|
|
564
|
+
// cannot-determine: an answer from a mix of the old and new contents could be wrong
|
|
565
|
+
throw cacheIssue('error', 'conflict', 'CACHE_ID_CHANGED_DURING_REQUEST',
|
|
566
|
+
[cacheId, row.key.replace(/\|/g, ' ')], 'cannot-determine', 409);
|
|
567
|
+
}
|
|
568
|
+
return this._build({ kind: 'wrapper', resource_type: row.resource_type, fhir_version: c.fhir_version, json: c.json, hash: c.hash });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
_build(row) {
|
|
572
|
+
const json = JSON.parse(row.json);
|
|
573
|
+
let resource = json;
|
|
574
|
+
if (row.kind === 'wrapper') {
|
|
575
|
+
const Cls = wrapperClass(row.resource_type);
|
|
576
|
+
if (Cls) {
|
|
577
|
+
resource = new Cls(json, 'R5');
|
|
578
|
+
resource.fhirVersion = row.fhir_version || 'R5';
|
|
579
|
+
if (row.hash && typeof resource.setExpansionKeyHash === 'function') {
|
|
580
|
+
// the hash of the JSON this was stored as, which is what the memory store's
|
|
581
|
+
// resource (the one the client sent) hashes to
|
|
582
|
+
resource.setExpansionKeyHash(row.hash);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
markCached(resource);
|
|
587
|
+
return resource;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* As for the memory store, except:
|
|
592
|
+
* - a cache the master's start moved to a tombstone, or an id from an earlier
|
|
593
|
+
* generation, is CACHE_ID_LOST_TO_RESTART.
|
|
594
|
+
* - any other id with no record is CACHE_ID_UNKNOWN_SHARED (never issued), whatever its
|
|
595
|
+
* prefix: every process sees every cache, so neither a restart of one process nor a
|
|
596
|
+
* request routed to the wrong one can be the reason.
|
|
597
|
+
* - an id dropped by the size cap (maxBytes) is CACHE_ID_EVICTED_SIZE.
|
|
598
|
+
*/
|
|
599
|
+
describeMissing(cacheId) {
|
|
600
|
+
const t = this.tombstone(cacheId);
|
|
601
|
+
if (!t) {
|
|
602
|
+
const generation = SqliteResourceCache.generationOf(cacheId);
|
|
603
|
+
const past = generation ? this.q.pastGeneration.get(generation) : null;
|
|
604
|
+
if (past) {
|
|
605
|
+
return { messageId: 'CACHE_ID_LOST_TO_RESTART', params: [cacheId, formatDuration(Date.now() - past.ended)] };
|
|
606
|
+
}
|
|
607
|
+
return { messageId: 'CACHE_ID_UNKNOWN_SHARED', params: [cacheId] };
|
|
608
|
+
}
|
|
609
|
+
if (t.reason === 'restart') {
|
|
610
|
+
return { messageId: 'CACHE_ID_LOST_TO_RESTART', params: [cacheId, formatDuration(Date.now() - t.at)] };
|
|
611
|
+
}
|
|
612
|
+
if (t.reason === 'evicted' && t.maxBytes) {
|
|
613
|
+
return {
|
|
614
|
+
messageId: 'CACHE_ID_EVICTED_SIZE',
|
|
615
|
+
params: [cacheId, formatDuration(Date.now() - t.at), formatBytes(t.maxBytes), formatDuration(t.idleMs)]
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
return super.describeMissing(cacheId);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
tombstone(cacheId) {
|
|
622
|
+
const t = this.q.tombstone.get(this.scope, cacheId);
|
|
623
|
+
if (!t) {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
reason: t.reason,
|
|
628
|
+
at: t.at,
|
|
629
|
+
createdAt: t.created_at,
|
|
630
|
+
lastUsed: t.last_used,
|
|
631
|
+
idleMs: t.idle_ms,
|
|
632
|
+
maxAgeMs: t.max_age_ms,
|
|
633
|
+
maxBytes: t.max_bytes,
|
|
634
|
+
// the tombstones table has no column for it: a discarded cache went over this
|
|
635
|
+
// process's cacheMaxConcepts, which is set by configuration for the whole run
|
|
636
|
+
limit: t.reason === 'discarded' ? this.maxConceptsPerCache : undefined
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
tombstoneCount() {
|
|
641
|
+
return this.q.tombstoneCount.get(this.scope).n;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
size() {
|
|
645
|
+
return this.q.scopeTotals.get(this.scope).n;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
conceptCount() {
|
|
649
|
+
return this.q.scopeTotals.get(this.scope).concepts;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
conceptCountFor(cacheId) {
|
|
653
|
+
if (!this._isCurrentGeneration(cacheId)) return 0;
|
|
654
|
+
const s = this.q.session.get(this.scope, cacheId);
|
|
655
|
+
return s ? (s.concepts || 0) : 0;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Bytes held across the whole file (every endpoint): that is what maxBytes caps.
|
|
660
|
+
*/
|
|
661
|
+
byteCount() {
|
|
662
|
+
return this.q.fileBytes.get().bytes;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// High-water marks are this process's view (what it saw after its own writes)
|
|
666
|
+
_trackMax() {
|
|
667
|
+
try {
|
|
668
|
+
const t = this.q.scopeTotals.get(this.scope);
|
|
669
|
+
if (t.concepts > this.maxConcepts) {
|
|
670
|
+
this.maxConcepts = t.concepts;
|
|
671
|
+
}
|
|
672
|
+
if (t.n > this.maxSizeValue) {
|
|
673
|
+
this.maxSizeValue = t.n;
|
|
674
|
+
}
|
|
675
|
+
} catch (e) {
|
|
676
|
+
// statistics only
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ---------- writes ----------
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Run a write transaction (BEGIN IMMEDIATE). If it fails it is tried once more;
|
|
684
|
+
* if that fails too, the request gets a 503 (the tx router adds Retry-After), so
|
|
685
|
+
* the client knows to retry rather than carrying on without its resources.
|
|
686
|
+
*/
|
|
687
|
+
_write(what, fn) {
|
|
688
|
+
const tx = this.db.transaction(fn);
|
|
689
|
+
try {
|
|
690
|
+
return tx.immediate();
|
|
691
|
+
} catch (first) {
|
|
692
|
+
if (first instanceof Issue) throw first;
|
|
693
|
+
this.log.warn(`resource cache: ${what} failed (${first.code || first.message}); retrying once`);
|
|
694
|
+
try {
|
|
695
|
+
return tx.immediate();
|
|
696
|
+
} catch (second) {
|
|
697
|
+
if (second instanceof Issue) throw second;
|
|
698
|
+
this.log.error(`resource cache: ${what} failed twice: ${second.message}`);
|
|
699
|
+
throw new Issue('error', 'transient', null, 'CACHE_WRITE_FAILED',
|
|
700
|
+
`The server could not save to its resource cache just now (${what}: ${second.code || second.message}). Nothing was changed; try the request again`,
|
|
701
|
+
null, 503);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Refresh last_used, at most once per touchIntervalMs: a cache in steady use
|
|
708
|
+
* then costs reads, not a write per request. A failed refresh is ignored - the
|
|
709
|
+
* worst it can do is let a cache look idle up to one interval sooner.
|
|
710
|
+
*/
|
|
711
|
+
_maybeTouch(cacheId, lastUsed) {
|
|
712
|
+
const now = Date.now();
|
|
713
|
+
if (now - lastUsed < this.touchIntervalMs) {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
try {
|
|
717
|
+
this.db.transaction(() => {
|
|
718
|
+
this.q.touch.run(now, this.scope, cacheId, now - this.touchIntervalMs);
|
|
719
|
+
}).immediate();
|
|
720
|
+
} catch (e) {
|
|
721
|
+
// ignored, see above
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
touch(cacheId) {
|
|
726
|
+
if (!this.has(cacheId)) {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
try {
|
|
730
|
+
// an explicit keepalive ($cache-control?mode=check) is not throttled
|
|
731
|
+
const now = Date.now();
|
|
732
|
+
this.db.transaction(() => {
|
|
733
|
+
this.q.touch.run(now, this.scope, cacheId, now);
|
|
734
|
+
}).immediate();
|
|
735
|
+
} catch (e) {
|
|
736
|
+
// a failed refresh is ignored: the cache is still there
|
|
737
|
+
}
|
|
738
|
+
if (this.logCacheOps) {
|
|
739
|
+
this.log.info(`cache-id '${cacheId}': touched (idle clock reset)`);
|
|
740
|
+
}
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* The row to store for a resource. Wrappers are stored as their (R5) jsonObj plus
|
|
746
|
+
* the FHIR version they came in as; anything else is stored as itself.
|
|
747
|
+
*/
|
|
748
|
+
_row(cacheId, resource) {
|
|
749
|
+
const isWrapper = resource && typeof resource.jsonObj === 'object' && resource.jsonObj !== null
|
|
750
|
+
&& !!wrapperClass(resource.resourceType);
|
|
751
|
+
const json = JSON.stringify(isWrapper ? resource.jsonObj : resource);
|
|
752
|
+
return {
|
|
753
|
+
scope: this.scope,
|
|
754
|
+
cacheId,
|
|
755
|
+
key: this._resourceKey(resource),
|
|
756
|
+
resourceType: resource.resourceType || null,
|
|
757
|
+
url: resource.url ?? null,
|
|
758
|
+
version: resource.version ?? null,
|
|
759
|
+
supplements: isWrapper && typeof resource.jsonObj.supplements === 'string' ? resource.jsonObj.supplements : null,
|
|
760
|
+
globalSupplement: isWrapper && typeof resource.isGlobalSupplement === 'function' ? this._globalSupplement(resource) : null,
|
|
761
|
+
fhirVersion: isWrapper ? (resource.fhirVersion || 'R5') : null,
|
|
762
|
+
kind: isWrapper ? 'wrapper' : 'plain',
|
|
763
|
+
hash: contentHash(json),
|
|
764
|
+
json,
|
|
765
|
+
bytes: Buffer.byteLength(json),
|
|
766
|
+
concepts: this._conceptsOf(resource)
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
_globalSupplement(resource) {
|
|
771
|
+
try {
|
|
772
|
+
return resource.isGlobalSupplement() ? 1 : 0;
|
|
773
|
+
} catch (e) {
|
|
774
|
+
return null; // not known: the stand-in loads the resource to ask it
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
add(cacheId, resources) {
|
|
779
|
+
this._assertGeneration(cacheId);
|
|
780
|
+
if (!resources || resources.length === 0) return;
|
|
781
|
+
resources = resources.filter(r => !!r);
|
|
782
|
+
for (const resource of resources) {
|
|
783
|
+
markCached(resource);
|
|
784
|
+
}
|
|
785
|
+
// the last copy of a key wins, as it would in the memory store
|
|
786
|
+
const byKey = new Map();
|
|
787
|
+
for (const resource of resources) {
|
|
788
|
+
const row = this._row(cacheId, resource);
|
|
789
|
+
byKey.delete(row.key);
|
|
790
|
+
byKey.set(row.key, row);
|
|
791
|
+
}
|
|
792
|
+
const rows = [...byKey.values()];
|
|
793
|
+
|
|
794
|
+
// As in the memory store's add (#356): a request whose own resources hold more than one
|
|
795
|
+
// cache may hold is refused as too costly (422), and the cache is kept
|
|
796
|
+
const ownConcepts = rows.reduce((sum, r) => sum + r.concepts, 0);
|
|
797
|
+
if (ownConcepts > this.maxConceptsPerCache) {
|
|
798
|
+
// (in English: the worker translates it into the request's language - see addToClientCache)
|
|
799
|
+
const err = new Issue('error', 'too-costly', null, 'CACHE_REQUEST_TOO_BIG',
|
|
800
|
+
`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);
|
|
801
|
+
err.msgParams = [ownConcepts, this.maxConceptsPerCache];
|
|
802
|
+
throw err;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Most requests re-send resources the cache already has, unchanged. Check
|
|
806
|
+
// that with reads first, so they don't take the write lock at all.
|
|
807
|
+
const session = this.q.session.get(this.scope, cacheId);
|
|
808
|
+
if (session) {
|
|
809
|
+
const changed = rows.some(r => {
|
|
810
|
+
const existing = this.q.resourceHash.get(this.scope, cacheId, r.key);
|
|
811
|
+
return !existing || existing.hash !== r.hash || existing.concepts !== r.concepts;
|
|
812
|
+
});
|
|
813
|
+
if (!changed) {
|
|
814
|
+
this._maybeTouch(cacheId, session.last_used);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
let discarded = null;
|
|
820
|
+
this._write('add', () => {
|
|
821
|
+
this._assertGeneration(cacheId);
|
|
822
|
+
const now = Date.now();
|
|
823
|
+
if (!this.q.sessionExists.get(this.scope, cacheId)) {
|
|
824
|
+
if (this.q.tombstone.get(this.scope, cacheId)) {
|
|
825
|
+
// Ended or expired (by another process) between the caller's check and
|
|
826
|
+
// now: don't bring it back from the dead.
|
|
827
|
+
this.log.info(`cache-id '${cacheId}': not adding ${rows.length} resource(s), the cache is gone`);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
this.q.insertSession.run(this.scope, cacheId, now, now, 0);
|
|
831
|
+
}
|
|
832
|
+
for (const row of rows) {
|
|
833
|
+
this.q.upsertResource.run(row);
|
|
834
|
+
if (this.logCacheOps) {
|
|
835
|
+
this.log.info(`cache-id '${cacheId}': stored ${row.key}`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
this.q.totals.run(now, this.scope, cacheId);
|
|
839
|
+
// As in the memory store, an add that takes a cache over cacheMaxConcepts discards
|
|
840
|
+
// it (the client can't use a cache it can't add to). The burial is committed; the
|
|
841
|
+
// error is thrown once the transaction is done, so it doesn't roll it back.
|
|
842
|
+
const after = this.q.session.get(this.scope, cacheId);
|
|
843
|
+
if (after && (after.concepts || 0) > this.maxConceptsPerCache) {
|
|
844
|
+
this._bury(after, 'discarded', now);
|
|
845
|
+
discarded = after.concepts || 0;
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
this._enforceConceptLimitsTx(cacheId, now);
|
|
849
|
+
this._enforceMaxBytesTx(cacheId, now);
|
|
850
|
+
});
|
|
851
|
+
if (discarded !== null) {
|
|
852
|
+
this.log.info(`cache-id '${cacheId}': discarded (would hold ${discarded} concepts; one cache may hold ${this.maxConceptsPerCache})`);
|
|
853
|
+
// As in the memory store: answered as any cache that is gone (404, tx-issue-type
|
|
854
|
+
// cache-id-unknown); the operation is not performed.
|
|
855
|
+
const err = new Issue('error', 'not-found', null, 'CACHE_ID_DISCARDED_TOO_BIG',
|
|
856
|
+
`The cache '${cacheId}' grew too large: this request would have taken it over ${this.maxConceptsPerCache} concepts, ` +
|
|
857
|
+
'the most this server allows in one cache (cacheMaxConcepts), so the server has dropped the cache. ' +
|
|
858
|
+
'This operation has not been performed. Start a new cache, and send the request again', 'cache-id-unknown', 404).handleAsOO(404);
|
|
859
|
+
err.msgParams = [cacheId, this.maxConceptsPerCache];
|
|
860
|
+
throw err;
|
|
861
|
+
}
|
|
862
|
+
this._trackMax();
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
set(cacheId, resources, sealed = false) {
|
|
866
|
+
resources = this._prepareSet(cacheId, resources, sealed);
|
|
867
|
+
const rows = resources.map(r => this._row(cacheId, r));
|
|
868
|
+
this._write('start', () => {
|
|
869
|
+
this._assertGeneration(cacheId);
|
|
870
|
+
this._replaceTx(cacheId, rows, sealed);
|
|
871
|
+
});
|
|
872
|
+
this._trackMax();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* $cache-control?mode=start. The id is minted inside the write transaction, from the
|
|
877
|
+
* generation as it is then: a master starting between minting and writing could
|
|
878
|
+
* otherwise leave a new cache with an id from the generation before, which would be
|
|
879
|
+
* reported as lost before the client ever used it.
|
|
880
|
+
*/
|
|
881
|
+
start(resources, sealed = false) {
|
|
882
|
+
if (!this.managedLifecycle) {
|
|
883
|
+
return super.start(resources, sealed);
|
|
884
|
+
}
|
|
885
|
+
resources = this._prepareSet(null, resources, sealed);
|
|
886
|
+
const rows = resources.map(r => this._row(null, r));
|
|
887
|
+
let cacheId = null;
|
|
888
|
+
this._write('start', () => {
|
|
889
|
+
cacheId = this.newCacheId();
|
|
890
|
+
this._replaceTx(cacheId, rows.map(row => ({ ...row, cacheId })), sealed);
|
|
891
|
+
});
|
|
892
|
+
if (this.logCacheOps) {
|
|
893
|
+
this.log.info(`cache-id '${cacheId}': started`);
|
|
894
|
+
}
|
|
895
|
+
this._trackMax();
|
|
896
|
+
return cacheId;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
_prepareSet(cacheId, resources, sealed) {
|
|
900
|
+
resources = (resources || []).filter(r => !!r);
|
|
901
|
+
if (this.logCacheOps) {
|
|
902
|
+
this.log.info(`cache-id '${cacheId ?? '(new)'}': set (replace all, sealed=${!!sealed}) with ${resources.length} resource(s): ${resources.map(r => this._resourceKey(r)).join(', ')}`);
|
|
903
|
+
}
|
|
904
|
+
for (const resource of resources) {
|
|
905
|
+
markCached(resource);
|
|
906
|
+
}
|
|
907
|
+
return resources;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Replace a cache's content (inside a write transaction). */
|
|
911
|
+
_replaceTx(cacheId, rows, sealed) {
|
|
912
|
+
const now = Date.now();
|
|
913
|
+
const existing = this.q.session.get(this.scope, cacheId);
|
|
914
|
+
this.q.deleteResources.run(this.scope, cacheId);
|
|
915
|
+
this.q.deleteSession.run(this.scope, cacheId);
|
|
916
|
+
this.q.insertSession.run(this.scope, cacheId, existing ? existing.created_at : now, now, sealed ? 1 : 0);
|
|
917
|
+
for (const row of rows) {
|
|
918
|
+
this.q.upsertResource.run(row);
|
|
919
|
+
}
|
|
920
|
+
this.q.totals.run(now, this.scope, cacheId);
|
|
921
|
+
// alive again, so any record of an earlier death is now wrong
|
|
922
|
+
this.q.deleteTombstone.run(this.scope, cacheId);
|
|
923
|
+
this._enforceConceptLimitsTx(cacheId, now);
|
|
924
|
+
this._enforceMaxBytesTx(cacheId, now);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* The concept limits of the memory store (ResourceCache.setLimits, cacheMaxConcepts and
|
|
929
|
+
* cacheMaxTotalConcepts), inside the write transaction: a change that takes one cache over
|
|
930
|
+
* its limit is refused (the throw rolls the transaction back, so the cache is kept as it
|
|
931
|
+
* was; add() has already discarded a cache it took over the limit); over the endpoint's
|
|
932
|
+
* total, the least recently used caches of this endpoint are dropped whole (never the one
|
|
933
|
+
* just changed), and their ids then say why (CACHE_ID_EVICTED).
|
|
934
|
+
*/
|
|
935
|
+
_enforceConceptLimitsTx(cacheId, now) {
|
|
936
|
+
const session = this.q.session.get(this.scope, cacheId);
|
|
937
|
+
this._checkLimit(cacheId, session ? (session.concepts || 0) : 0);
|
|
938
|
+
let total = this.q.scopeTotals.get(this.scope).concepts;
|
|
939
|
+
while (total > this.maxTotalConcepts) {
|
|
940
|
+
const victims = this.q.lruScope.all(this.scope, cacheId);
|
|
941
|
+
if (victims.length === 0) {
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
for (const v of victims) {
|
|
945
|
+
if (total <= this.maxTotalConcepts) {
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
this._bury(v, 'evicted', now, { idleMs: now - v.last_used });
|
|
949
|
+
total -= v.concepts || 0;
|
|
950
|
+
this.log.info(`cache-id '${v.cache_id}': dropped to make room (held ${v.concepts} concepts; the caches may hold ${this.maxTotalConcepts} in all)`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** Move a session to the tombstones (inside a write transaction). */
|
|
956
|
+
_bury(session, reason, now, extra = {}) {
|
|
957
|
+
this.q.insertTombstone.run(session.scope, session.cache_id, reason, now, session.created_at, session.last_used,
|
|
958
|
+
extra.idleMs ?? null, extra.maxAgeMs ?? null, extra.maxBytes ?? null);
|
|
959
|
+
this.q.deleteResources.run(session.scope, session.cache_id);
|
|
960
|
+
this.q.deleteSession.run(session.scope, session.cache_id);
|
|
961
|
+
this.q.trimTombstones.run(session.scope, session.scope, this.maxTombstones);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* The size cap covers the whole file (all endpoints): drop the least recently
|
|
966
|
+
* used caches until the file is under it. Never the cache being written.
|
|
967
|
+
*/
|
|
968
|
+
_enforceMaxBytesTx(keepId, now) {
|
|
969
|
+
if (!this.maxBytes) {
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
let total = this.q.fileBytes.get().bytes;
|
|
973
|
+
while (total > this.maxBytes) {
|
|
974
|
+
const victims = this.q.lru.all(this.scope, keepId);
|
|
975
|
+
if (victims.length === 0) {
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
for (const v of victims) {
|
|
979
|
+
if (total <= this.maxBytes) {
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
const idleMs = now - v.last_used;
|
|
983
|
+
this._bury(v, 'evicted', now, { idleMs, maxBytes: this.maxBytes });
|
|
984
|
+
total -= v.bytes;
|
|
985
|
+
this.log.info(`cache-id '${v.cache_id}': evicted (cache over its ${formatBytes(this.maxBytes)} cap; idle ${formatDuration(idleMs)})`);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
clear(cacheId) {
|
|
991
|
+
this._write('end', () => {
|
|
992
|
+
const s = this.q.session.get(this.scope, cacheId);
|
|
993
|
+
if (s) {
|
|
994
|
+
this._bury(s, 'closed', Date.now());
|
|
995
|
+
if (this.logCacheOps) {
|
|
996
|
+
this.log.info(`cache-id '${cacheId}': closed by client after ${formatDuration(Date.now() - s.created_at)} (held ${s.resources} resource(s))`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
clearAll() {
|
|
1003
|
+
this._write('clear all', () => {
|
|
1004
|
+
const now = Date.now();
|
|
1005
|
+
for (const s of this.q.allSessions.all(this.scope)) {
|
|
1006
|
+
this._bury(s, 'cleared', now);
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* The idle sweep. One transaction: every session idle for longer than maxAge is
|
|
1013
|
+
* tombstoned and deleted. Idempotent, so it doesn't matter which process runs
|
|
1014
|
+
* it, or how many do.
|
|
1015
|
+
*/
|
|
1016
|
+
prune(maxAge = 3600000) {
|
|
1017
|
+
if (this.stats) {
|
|
1018
|
+
this.stats.task('Client Cache', 'Pruning');
|
|
1019
|
+
}
|
|
1020
|
+
let n = 0;
|
|
1021
|
+
try {
|
|
1022
|
+
this.db.transaction(() => {
|
|
1023
|
+
const now = Date.now();
|
|
1024
|
+
for (const s of this.q.expired.all(this.scope, now - maxAge)) {
|
|
1025
|
+
const idleMs = now - s.last_used;
|
|
1026
|
+
this._bury(s, 'expired', now, { idleMs, maxAgeMs: maxAge });
|
|
1027
|
+
n++;
|
|
1028
|
+
if (this.logCacheOps) {
|
|
1029
|
+
this.log.info(`cache-id '${s.cache_id}': expired after ${formatDuration(idleMs)} idle (timeout ${formatDuration(maxAge)})`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}).immediate();
|
|
1033
|
+
} catch (e) {
|
|
1034
|
+
// the next sweep will pick them up
|
|
1035
|
+
this.log.error(`resource cache: idle sweep failed: ${e.message}`);
|
|
1036
|
+
}
|
|
1037
|
+
if (this.stats) {
|
|
1038
|
+
this.stats.taskDone('Client Cache', `Pruned ${n} of ${n + this.size()} entries`);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
close() {
|
|
1043
|
+
if (!this.closed) {
|
|
1044
|
+
this.closed = true;
|
|
1045
|
+
releaseDatabase(this.path);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
module.exports = { SqliteResourceCache, contentHash, formatBytes };
|