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/vs/vs-vsac.js
CHANGED
|
@@ -7,13 +7,116 @@ const { VersionUtilities } = require('../../library/version-utilities');
|
|
|
7
7
|
const folders = require('../../library/folder-setup');
|
|
8
8
|
const {debugLog} = require("../operation-context");
|
|
9
9
|
const { applyIdPrefix, stripIdPrefix } = require("../library/resource-ids");
|
|
10
|
+
// the server log ({vsac}): sync progress for each value set and page is at debug level
|
|
11
|
+
const vsacLog = require('../../library/logger').getInstance().child({ module: 'vsac' });
|
|
10
12
|
|
|
11
13
|
// Persisted watermark for the phase-1b _lastUpdated scan.
|
|
12
14
|
const VSAC_LAST_UPDATED_KEY = 'vsac_last_updated_date';
|
|
15
|
+
// Phase-2 work list of the sync in progress, and how far through it the sync has got.
|
|
16
|
+
// Both are cleared when a run completes; if they are still there, the last run was
|
|
17
|
+
// interrupted (server stopped) or failed, and the next run picks up what it didn't finish.
|
|
18
|
+
const VSAC_PENDING_QUEUE_KEY = 'vsac_pending_queue';
|
|
19
|
+
const VSAC_PENDING_PROGRESS_KEY = 'vsac_pending_progress';
|
|
20
|
+
|
|
21
|
+
// The longest delay a Node timer accepts; a longer one fires after 1 ms.
|
|
22
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
23
|
+
|
|
24
|
+
// A follower (cluster.md) compares its generation with the database's at most this often,
|
|
25
|
+
// when a request uses the value sets
|
|
26
|
+
const GENERATION_CHECK_MS = 1000;
|
|
13
27
|
|
|
14
28
|
// Canonical URL prefix for VSAC value sets, so operators can enter a bare OID.
|
|
15
29
|
const VSAC_VALUESET_URL_PREFIX = 'http://cts.nlm.nih.gov/fhir/ValueSet/';
|
|
16
30
|
|
|
31
|
+
// Default size of the cache of recently used value sets (see RecentValueSets)
|
|
32
|
+
const DEFAULT_VALUESET_CACHE_MB = 64;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The value sets most recently read from the database, parsed and prepared, by row id, with
|
|
36
|
+
* the least recently used dropped first once the stored JSON they were read from adds up to
|
|
37
|
+
* more than maxBytes.
|
|
38
|
+
*
|
|
39
|
+
* Reading a value set means parsing its JSON every time: 15ms for a list of 33,000 codes, and
|
|
40
|
+
* $batch-validate-code reads it once per entry. Most use is of a few value sets over and over,
|
|
41
|
+
* so the ones in use are kept. The sync drops a row from here when it writes it, so what is
|
|
42
|
+
* kept is never older than the database (each keeps the contentHash it was read with).
|
|
43
|
+
*
|
|
44
|
+
* What is kept is shared between requests (as the map was): it must not be changed.
|
|
45
|
+
*/
|
|
46
|
+
class RecentValueSets {
|
|
47
|
+
constructor(maxBytes) {
|
|
48
|
+
this.maxBytes = maxBytes;
|
|
49
|
+
this.entries = new Map(); // id -> { vs, bytes }, least recently used first
|
|
50
|
+
this.bytes = 0;
|
|
51
|
+
// changes whenever the sync drops anything (delete, deleteUrl, clear), so a read that started
|
|
52
|
+
// before is not kept (see _readValueSet). Keeping a value set or dropping one to make room
|
|
53
|
+
// doesn't change it: reads of other value sets under way at the same time are still kept.
|
|
54
|
+
this.generation = 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get size() {
|
|
58
|
+
return this.entries.size;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The value set for a row id, which is now the most recently used; undefined if it isn't kept */
|
|
62
|
+
get(id) {
|
|
63
|
+
const entry = this.entries.get(id);
|
|
64
|
+
if (!entry) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
this.entries.delete(id);
|
|
68
|
+
this.entries.set(id, entry);
|
|
69
|
+
return entry.vs;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The value set for a row id, without making it any more recently used */
|
|
73
|
+
peek(id) {
|
|
74
|
+
const entry = this.entries.get(id);
|
|
75
|
+
return entry ? entry.vs : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
set(id, vs, bytes) {
|
|
79
|
+
this._drop(id);
|
|
80
|
+
if (!(bytes >= 0) || bytes > this.maxBytes) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.entries.set(id, { vs, bytes });
|
|
84
|
+
this.bytes += bytes;
|
|
85
|
+
while (this.bytes > this.maxBytes) {
|
|
86
|
+
this._drop(this.entries.keys().next().value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
delete(id) {
|
|
91
|
+
this.generation++;
|
|
92
|
+
this._drop(id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
deleteUrl(url) {
|
|
96
|
+
this.generation++;
|
|
97
|
+
for (const [id, entry] of this.entries) {
|
|
98
|
+
if (entry.vs.url === url) {
|
|
99
|
+
this._drop(id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
clear() {
|
|
105
|
+
this.generation++;
|
|
106
|
+
this.entries.clear();
|
|
107
|
+
this.bytes = 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Removes a row without changing the generation: what the sync drops goes through delete */
|
|
111
|
+
_drop(id) {
|
|
112
|
+
const entry = this.entries.get(id);
|
|
113
|
+
if (entry) {
|
|
114
|
+
this.entries.delete(id);
|
|
115
|
+
this.bytes -= entry.bytes;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
17
120
|
/**
|
|
18
121
|
* VSAC (Value Set Authority Center) ValueSet provider
|
|
19
122
|
* Fetches and caches ValueSets from the NLM VSAC FHIR server
|
|
@@ -21,6 +124,11 @@ const VSAC_VALUESET_URL_PREFIX = 'http://cts.nlm.nih.gov/fhir/ValueSet/';
|
|
|
21
124
|
class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
22
125
|
SYNC_AT_START_UP = false;
|
|
23
126
|
|
|
127
|
+
// A value set asked for at a version VSAC doesn't have is not found, as for value sets from
|
|
128
|
+
// packages (#94). It used to be answered from the latest version: set this to true to go back
|
|
129
|
+
// to that
|
|
130
|
+
static VERSION_FALLBACK_TO_LATEST = false;
|
|
131
|
+
|
|
24
132
|
/**
|
|
25
133
|
* @param {Object} config - Configuration object
|
|
26
134
|
* @param {string} config.apiKey - API key for VSAC authentication
|
|
@@ -30,6 +138,13 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
30
138
|
* @param {number} [config.timeoutMs=120000] - HTTP request timeout in milliseconds
|
|
31
139
|
* @param {string} [config.resyncPassword] - If set, enables the operator "resync a ValueSet"
|
|
32
140
|
* form on the /info page, gated by this password. If unset, the form is not offered.
|
|
141
|
+
* @param {number} [config.valueSetCacheMB=64] - Size (of their stored JSON) of the recently used
|
|
142
|
+
* value sets kept parsed (see RecentValueSets); 0 for none. This is measured in stored JSON,
|
|
143
|
+
* not memory: parsed, they take several times this much heap, and in cluster mode each
|
|
144
|
+
* worker has its own
|
|
145
|
+
* @param {string} [config.cluster] - set by --role (library/role.js), else absent: 'leader'
|
|
146
|
+
* (the master) syncs, and bumps the database's generation after each sync; 'follower' (a
|
|
147
|
+
* tx-worker) never syncs, and catches up when the generation moves (catchUp)
|
|
33
148
|
*/
|
|
34
149
|
constructor(config, stats) {
|
|
35
150
|
super();
|
|
@@ -47,13 +162,25 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
47
162
|
// the key has been sent in the clear)
|
|
48
163
|
this.baseUrl = (config.baseUrl || 'https://cts.nlm.nih.gov/fhir').replace(/\/+$/, '');
|
|
49
164
|
if (this.baseUrl.startsWith('http://')) {
|
|
50
|
-
|
|
165
|
+
vsacLog.warn(`baseUrl ${this.baseUrl} is http, so the API key is sent unencrypted - use https`);
|
|
51
166
|
}
|
|
52
167
|
this.refreshIntervalHours = config.refreshIntervalHours || 24;
|
|
168
|
+
this.valueSetCacheMB = config.valueSetCacheMB ?? DEFAULT_VALUESET_CACHE_MB;
|
|
53
169
|
|
|
54
170
|
this.dbPath = path.join(this.cacheFolder, 'vsac-valuesets.db');
|
|
55
|
-
this.
|
|
56
|
-
|
|
171
|
+
this.cluster = config.cluster || null;
|
|
172
|
+
// a follower only reads: the master creates and migrates the file (a file not migrated
|
|
173
|
+
// yet fails to open, and is tried again on the next use)
|
|
174
|
+
this.database = new ValueSetDatabase(this.dbPath, { shared: !!this.cluster, readOnly: this.cluster === 'follower' });
|
|
175
|
+
// The value sets aren't held in memory: each one is read from the database when it is
|
|
176
|
+
// asked for (see fetchValueSet), apart from the recently used ones (see RecentValueSets).
|
|
177
|
+
// This is the count of urls as of the last recount
|
|
178
|
+
this.urlCount = 0;
|
|
179
|
+
// a follower: the generation it last caught up with (null: not yet - the master hasn't
|
|
180
|
+
// made the database), and when it last compared
|
|
181
|
+
this.loadedGeneration = null;
|
|
182
|
+
this.generationCheckedAt = 0;
|
|
183
|
+
this.catchingUp = null;
|
|
57
184
|
this.initialized = false;
|
|
58
185
|
this.refreshTimer = null;
|
|
59
186
|
this.isRefreshing = false;
|
|
@@ -76,6 +203,30 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
76
203
|
return "vsac";
|
|
77
204
|
}
|
|
78
205
|
|
|
206
|
+
// the recently used value sets were read from this database: another one starts with none
|
|
207
|
+
get database() {
|
|
208
|
+
return this._database;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
set database(database) {
|
|
212
|
+
this._database = database;
|
|
213
|
+
if (this._recent) {
|
|
214
|
+
this._recent.clear();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The recently used value sets (made when first needed)
|
|
220
|
+
* @returns {RecentValueSets}
|
|
221
|
+
* @private
|
|
222
|
+
*/
|
|
223
|
+
_recentValueSets() {
|
|
224
|
+
if (!this._recent) {
|
|
225
|
+
this._recent = new RecentValueSets((this.valueSetCacheMB ?? DEFAULT_VALUESET_CACHE_MB) * 1024 * 1024);
|
|
226
|
+
}
|
|
227
|
+
return this._recent;
|
|
228
|
+
}
|
|
229
|
+
|
|
79
230
|
sourcePackage() {
|
|
80
231
|
return "vsac";
|
|
81
232
|
}
|
|
@@ -88,50 +239,236 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
88
239
|
if (this.initialized) {
|
|
89
240
|
return;
|
|
90
241
|
}
|
|
242
|
+
if (this.cluster === 'follower') {
|
|
243
|
+
// the master creates the database and syncs; this process only reads
|
|
244
|
+
if (await this.database.exists()) {
|
|
245
|
+
try {
|
|
246
|
+
await this._followDatabase();
|
|
247
|
+
} catch (error) {
|
|
248
|
+
// not migrated yet by the master: no value sets until a catch-up finds it done
|
|
249
|
+
vsacLog.info(`${error.message}; trying again on use`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
this.initialized = true;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// initialize() is re-entered by lookups while the first call is still syncing; only
|
|
256
|
+
// the first call checks for (and resumes) an incomplete sync
|
|
257
|
+
const firstCall = !this._startupChecked;
|
|
258
|
+
this._startupChecked = true;
|
|
91
259
|
this.stats.addTask('VSAC Sync', `${this.refreshIntervalHours} hours`);
|
|
92
260
|
|
|
93
261
|
// Create database if it doesn't exist
|
|
94
262
|
if (!(await this.database.exists())) {
|
|
95
263
|
await this.database.create();
|
|
96
|
-
} else {
|
|
97
|
-
// Schema migrations are applied lazily by the database layer on first
|
|
98
|
-
// connection. Just load existing data.
|
|
99
|
-
await this._reloadMap();
|
|
100
264
|
}
|
|
101
|
-
|
|
102
|
-
|
|
265
|
+
// Schema migrations are applied lazily by the database layer on first
|
|
266
|
+
// connection. Count what is there (none, for a new database), so a
|
|
267
|
+
// database with no value sets is synced now rather than at the first timer
|
|
268
|
+
await this._recount();
|
|
269
|
+
const incomplete = firstCall ? await this._incompleteSyncReason() : null;
|
|
270
|
+
let failed = false;
|
|
271
|
+
let synced = false;
|
|
272
|
+
if (this.SYNC_AT_START_UP || this.database.vsCount == 0 || incomplete) {
|
|
273
|
+
if (incomplete) {
|
|
274
|
+
vsacLog.info(`${incomplete} - resuming the sync now`);
|
|
275
|
+
}
|
|
276
|
+
synced = true;
|
|
277
|
+
try {
|
|
278
|
+
await this.refreshValueSets();
|
|
279
|
+
} catch (error) {
|
|
280
|
+
// already recorded by refreshValueSets; the timer below retries it soon
|
|
281
|
+
vsacLog.error(`sync at startup failed: ${error.message}`);
|
|
282
|
+
failed = true;
|
|
283
|
+
this._failedSyncs = 1;
|
|
284
|
+
}
|
|
103
285
|
}
|
|
104
|
-
// Start periodic refresh
|
|
105
|
-
|
|
286
|
+
// Start periodic refresh; after a failed sync, the next try comes after a short wait,
|
|
287
|
+
// and without a sync now, the next is due an interval after the last one (not after now)
|
|
288
|
+
let next;
|
|
289
|
+
if (failed) {
|
|
290
|
+
next = { delayMs: this._retryDelayMs(1), why: 'retrying the failed sync' };
|
|
291
|
+
} else if (synced) {
|
|
292
|
+
next = { delayMs: this._refreshIntervalMs(), why: 'synced at startup' };
|
|
293
|
+
} else {
|
|
294
|
+
next = await this._firstRefreshDelay();
|
|
295
|
+
}
|
|
296
|
+
vsacLog.info(`next sync due at ${new Date(Date.now() + next.delayMs).toISOString()} (${next.why})`);
|
|
297
|
+
this._startRefreshTimer(next.delayMs);
|
|
106
298
|
this.initialized = true;
|
|
107
299
|
}
|
|
108
300
|
|
|
109
301
|
/**
|
|
110
|
-
*
|
|
302
|
+
* The wait before the first scheduled sync when initialize() didn't sync: the refresh
|
|
303
|
+
* interval after the last sync, as recorded in vsac_runs, rather than after this start (a
|
|
304
|
+
* server restarted more often than the interval would otherwise never sync). The whole
|
|
305
|
+
* interval when no sync is recorded, or the last one didn't work. Sets lastRefresh to the
|
|
306
|
+
* recorded time, so getStatistics() reports when the next sync is due
|
|
307
|
+
* @returns {Promise<{delayMs: number, why: string}>}
|
|
308
|
+
* @private
|
|
309
|
+
*/
|
|
310
|
+
async _firstRefreshDelay() {
|
|
311
|
+
const intervalMs = this._refreshIntervalMs();
|
|
312
|
+
const lastRun = await this.database.getLastRun();
|
|
313
|
+
if (!lastRun || lastRun.status !== 'ok' || !lastRun.finished_at) {
|
|
314
|
+
return { delayMs: intervalMs, why: 'no completed sync recorded' };
|
|
315
|
+
}
|
|
316
|
+
// finished_at is in seconds (strftime('%s'))
|
|
317
|
+
const finished = lastRun.finished_at * 1000;
|
|
318
|
+
this.lastRefresh = new Date(finished);
|
|
319
|
+
return {
|
|
320
|
+
delayMs: Math.max(0, finished + intervalMs - Date.now()),
|
|
321
|
+
why: `the last sync finished at ${this.lastRefresh.toISOString()}`
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Whether the last sync didn't finish: it was cut short when the server stopped (its
|
|
327
|
+
* run is still marked 'running', or its work list is still saved), or it failed.
|
|
328
|
+
* Runs left marked 'running' are closed off as interrupted.
|
|
329
|
+
* @returns {Promise<string|null>} why a sync is needed now, or null if the last one completed
|
|
330
|
+
* @private
|
|
331
|
+
*/
|
|
332
|
+
async _incompleteSyncReason() {
|
|
333
|
+
const lastRun = await this.database.getLastRun();
|
|
334
|
+
const interrupted = await this.database.markInterruptedRuns();
|
|
335
|
+
if (interrupted > 0) {
|
|
336
|
+
return 'the last sync was interrupted';
|
|
337
|
+
}
|
|
338
|
+
if ((await this.database.getSetting(VSAC_PENDING_QUEUE_KEY)) != null) {
|
|
339
|
+
return 'the last sync did not finish';
|
|
340
|
+
}
|
|
341
|
+
if (lastRun && lastRun.status === 'error') {
|
|
342
|
+
return 'the last sync failed';
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The URLs an interrupted or failed run didn't get through: the in-flight URL and
|
|
349
|
+
* everything after it, plus first-pass failures it hadn't retried yet.
|
|
350
|
+
* @returns {Promise<string[]>}
|
|
351
|
+
* @private
|
|
352
|
+
*/
|
|
353
|
+
async _loadPendingQueue() {
|
|
354
|
+
try {
|
|
355
|
+
const queue = JSON.parse(await this.database.getSetting(VSAC_PENDING_QUEUE_KEY) || '[]');
|
|
356
|
+
const progress = JSON.parse(await this.database.getSetting(VSAC_PENDING_PROGRESS_KEY) || '{}');
|
|
357
|
+
if (!Array.isArray(queue)) {
|
|
358
|
+
return [];
|
|
359
|
+
}
|
|
360
|
+
const requeue = Array.isArray(progress.requeue) ? progress.requeue : [];
|
|
361
|
+
return [...requeue.slice(progress.requeueDone || 0), ...queue.slice(progress.done || 0)];
|
|
362
|
+
} catch (error) {
|
|
363
|
+
debugLog(error);
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Save the phase-2 work list, so a restart can pick up where this run stops
|
|
370
|
+
* @private
|
|
371
|
+
*/
|
|
372
|
+
async _savePendingQueue(queue) {
|
|
373
|
+
await this.database.setSetting(VSAC_PENDING_QUEUE_KEY, JSON.stringify(queue));
|
|
374
|
+
await this._savePendingProgress({ done: 0, requeue: [], requeueDone: 0 });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @param {{done: number, requeue: string[], requeueDone: number}} progress - first-pass
|
|
379
|
+
* URLs processed, the URLs that failed and are waiting for a retry, and retries done
|
|
380
|
+
* @private
|
|
381
|
+
*/
|
|
382
|
+
async _savePendingProgress(progress) {
|
|
383
|
+
await this.database.setSetting(VSAC_PENDING_PROGRESS_KEY, JSON.stringify(progress));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* @private
|
|
388
|
+
*/
|
|
389
|
+
async _clearPendingQueue() {
|
|
390
|
+
await this.database.deleteSetting(VSAC_PENDING_QUEUE_KEY);
|
|
391
|
+
await this.database.deleteSetting(VSAC_PENDING_PROGRESS_KEY);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Waits before trying a failed sync again: 1, 5 and then every 30 minutes (never longer
|
|
396
|
+
* than the refresh interval). A failure at startup is often passing (the network is not
|
|
397
|
+
* up yet, or a connection attempt timed out while the server was busy loading), so it
|
|
398
|
+
* should not wait a whole day.
|
|
399
|
+
*/
|
|
400
|
+
static RETRY_DELAYS_MS = [60 * 1000, 5 * 60 * 1000, 30 * 60 * 1000];
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* @param {number} failures - failed syncs in a row (1 or more)
|
|
404
|
+
* @returns {number} milliseconds to wait before the next try
|
|
111
405
|
* @private
|
|
112
406
|
*/
|
|
113
|
-
|
|
407
|
+
_retryDelayMs(failures) {
|
|
408
|
+
const delays = VSACValueSetProvider.RETRY_DELAYS_MS;
|
|
409
|
+
const delay = delays[Math.min(failures, delays.length) - 1];
|
|
410
|
+
return Math.min(delay, this._refreshIntervalMs());
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Start the refresh timer. Each sync sets the timer for the next one, whether it worked
|
|
415
|
+
* or not: the refresh interval after a sync that worked, a shorter wait after one that
|
|
416
|
+
* failed (see RETRY_DELAYS_MS).
|
|
417
|
+
* @param {number} [delayMs] - wait before the next sync (default: the refresh interval)
|
|
418
|
+
* @private
|
|
419
|
+
*/
|
|
420
|
+
_startRefreshTimer(delayMs = undefined) {
|
|
421
|
+
this._stopped = false;
|
|
114
422
|
if (this.refreshTimer) {
|
|
115
|
-
|
|
423
|
+
clearTimeout(this.refreshTimer);
|
|
116
424
|
}
|
|
117
425
|
|
|
118
|
-
const intervalMs = this.
|
|
119
|
-
this.refreshTimer =
|
|
426
|
+
const intervalMs = this._refreshIntervalMs();
|
|
427
|
+
this.refreshTimer = setTimeout(async () => {
|
|
428
|
+
let next = intervalMs;
|
|
120
429
|
try {
|
|
121
430
|
await this.refreshValueSets();
|
|
431
|
+
this._failedSyncs = 0;
|
|
122
432
|
} catch (error) {
|
|
123
433
|
debugLog(error);
|
|
124
|
-
this.
|
|
434
|
+
this._failedSyncs = (this._failedSyncs || 0) + 1;
|
|
435
|
+
next = this._retryDelayMs(this._failedSyncs);
|
|
436
|
+
vsacLog.error(`scheduled sync failed (next try in ${Math.round(next / 60000)} min): ${error.message}`);
|
|
437
|
+
} finally {
|
|
438
|
+
if (!this._stopped) {
|
|
439
|
+
this._startRefreshTimer(next);
|
|
440
|
+
}
|
|
125
441
|
}
|
|
126
|
-
}, intervalMs);
|
|
442
|
+
}, delayMs ?? intervalMs);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The refresh interval in ms. Node runs a timer whose delay is above 2^31-1 ms (about 596 hours)
|
|
447
|
+
* or not a positive number after 1 ms instead, so the refresh would run back to back: such a
|
|
448
|
+
* setting is clamped (or, when it isn't a positive number, replaced by the 24 hour default).
|
|
449
|
+
* @private
|
|
450
|
+
*/
|
|
451
|
+
_refreshIntervalMs() {
|
|
452
|
+
const hours = Number(this.refreshIntervalHours);
|
|
453
|
+
if (!Number.isFinite(hours) || hours <= 0) {
|
|
454
|
+
vsacLog.warn(`refreshIntervalHours ${this.refreshIntervalHours} is not a positive number; using 24`);
|
|
455
|
+
return 24 * 60 * 60 * 1000;
|
|
456
|
+
}
|
|
457
|
+
const ms = hours * 60 * 60 * 1000;
|
|
458
|
+
if (ms > MAX_TIMER_MS) {
|
|
459
|
+
vsacLog.warn(`refreshIntervalHours ${hours} is longer than a timer can wait; using ${Math.floor(MAX_TIMER_MS / 3600000)} hours`);
|
|
460
|
+
return MAX_TIMER_MS;
|
|
461
|
+
}
|
|
462
|
+
return ms;
|
|
127
463
|
}
|
|
128
464
|
|
|
129
465
|
/**
|
|
130
466
|
* Stop the refresh timer (for cleanup)
|
|
131
467
|
*/
|
|
132
468
|
stopRefreshTimer() {
|
|
469
|
+
this._stopped = true;
|
|
133
470
|
if (this.refreshTimer) {
|
|
134
|
-
|
|
471
|
+
clearTimeout(this.refreshTimer);
|
|
135
472
|
this.refreshTimer = null;
|
|
136
473
|
}
|
|
137
474
|
}
|
|
@@ -143,7 +480,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
143
480
|
async refreshValueSets() {
|
|
144
481
|
this.stats.task('VSAC Sync', 'running');
|
|
145
482
|
if (this.isRefreshing) {
|
|
146
|
-
|
|
483
|
+
vsacLog.info('Refresh already in progress, skipping');
|
|
147
484
|
return;
|
|
148
485
|
}
|
|
149
486
|
this.queue = [];
|
|
@@ -154,17 +491,18 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
154
491
|
|
|
155
492
|
try {
|
|
156
493
|
// phase 1: list all value sets
|
|
157
|
-
|
|
494
|
+
vsacLog.info('Starting VSAC ValueSet refresh...');
|
|
158
495
|
|
|
159
496
|
// This lists all the currently valid value sets by URL, but not the older versions
|
|
160
497
|
let url = '/ValueSet?_offset=0&_count=1000&_elements=id,url,version,status';
|
|
498
|
+
const known = await this.database.listVersionKeys();
|
|
161
499
|
|
|
162
500
|
let total = undefined;
|
|
163
501
|
let count = 0;
|
|
164
502
|
let ncount = 0;
|
|
165
503
|
|
|
166
504
|
while (url) {
|
|
167
|
-
|
|
505
|
+
vsacLog.debug(`Sync: ${count} of ${total} - ${ncount} new`);
|
|
168
506
|
this.stats.task('VSAC Sync', `Sync: ${count} of ${total} - ${ncount} new`);
|
|
169
507
|
|
|
170
508
|
const bundle = await this._fetchBundle(url);
|
|
@@ -177,7 +515,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
177
515
|
if (vs) {
|
|
178
516
|
count++;
|
|
179
517
|
// if we've seen this value set before, then we've got nothing new here.
|
|
180
|
-
if (!
|
|
518
|
+
if (!known.has(vs.url+"|"+vs.version)) {
|
|
181
519
|
this.queue.push(vs.url);
|
|
182
520
|
ncount++;
|
|
183
521
|
}
|
|
@@ -188,22 +526,31 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
188
526
|
|
|
189
527
|
// Safety check against infinite loops
|
|
190
528
|
if (count > total) {
|
|
191
|
-
|
|
529
|
+
vsacLog.info(`Reached total count (${total}), stopping`);
|
|
192
530
|
break;
|
|
193
531
|
}
|
|
194
532
|
}
|
|
195
533
|
|
|
196
534
|
this.lastRefresh = new Date();
|
|
197
|
-
|
|
535
|
+
vsacLog.info(`VSAC refresh phase 1 done. Total: ${count} with ${ncount} new items`);
|
|
198
536
|
this.stats.task('VSAC Sync', `VSAC refresh phase 1 done. Total: ${count} with ${ncount} new items`);
|
|
199
537
|
|
|
200
538
|
// phase 1b: query for recently updated value sets via _lastUpdated
|
|
201
539
|
let lastUpdatedCount = await this._scanLastUpdated();
|
|
202
|
-
|
|
540
|
+
vsacLog.info(`VSAC refresh phase 1b done. ${lastUpdatedCount} additional items from _lastUpdated`);
|
|
203
541
|
this.stats.task('VSAC Sync', `Phase 1b: ${lastUpdatedCount} from _lastUpdated`);
|
|
204
542
|
|
|
205
|
-
//
|
|
206
|
-
|
|
543
|
+
// Pick up what an interrupted or failed run didn't finish. Phase 1 only queues a
|
|
544
|
+
// URL whose current version is missing, so a URL that was part way through its
|
|
545
|
+
// versions would otherwise be missed. The leftovers go first, and the deduplicated
|
|
546
|
+
// queue is saved so that a restart can carry on from here.
|
|
547
|
+
const leftover = await this._loadPendingQueue();
|
|
548
|
+
if (leftover.length > 0) {
|
|
549
|
+
vsacLog.info(`resuming ${leftover.length} value set(s) left over from the last sync`);
|
|
550
|
+
this.stats.task('VSAC Sync', `Resuming ${leftover.length} left over from the last sync`);
|
|
551
|
+
}
|
|
552
|
+
this.queue = [...new Set([...leftover, ...this.queue])];
|
|
553
|
+
await this._savePendingQueue(this.queue);
|
|
207
554
|
|
|
208
555
|
let tracking = { totalFetched: 0, totalNew: 0, totalUpdated: 0, count: 0, newCount : 0 };
|
|
209
556
|
// URLs that fail even after a requeue are permanently dropped this run; we
|
|
@@ -211,6 +558,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
211
558
|
let permanentFailures = 0;
|
|
212
559
|
// phase 2: query for history & content
|
|
213
560
|
this.requeue = [];
|
|
561
|
+
const progress = { done: 0, requeue: this.requeue, requeueDone: 0 };
|
|
214
562
|
for (let q of this.queue) {
|
|
215
563
|
this.stats.task('VSAC History for '+q, `running (${tracking.totalFetched} fetched, ${tracking.totalNew} new, ${tracking.totalUpdated} updated)`);
|
|
216
564
|
try {
|
|
@@ -221,8 +569,10 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
221
569
|
this.stats.task('VSAC Sync', error.message);
|
|
222
570
|
}
|
|
223
571
|
tracking.count++;
|
|
572
|
+
progress.done++;
|
|
573
|
+
await this._savePendingProgress(progress);
|
|
224
574
|
}
|
|
225
|
-
|
|
575
|
+
vsacLog.debug("Requeue");
|
|
226
576
|
for (let q of this.requeue) {
|
|
227
577
|
this.stats.task('VSAC History for '+q, `running (${tracking.totalFetched} fetched, ${tracking.totalNew} new, ${tracking.totalUpdated} updated)`);
|
|
228
578
|
try {
|
|
@@ -233,10 +583,16 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
233
583
|
this.stats.task('VSAC Sync', error.message);
|
|
234
584
|
}
|
|
235
585
|
tracking.count++;
|
|
586
|
+
progress.requeueDone++;
|
|
587
|
+
await this._savePendingProgress(progress);
|
|
236
588
|
}
|
|
237
589
|
|
|
238
|
-
//
|
|
239
|
-
|
|
590
|
+
// the followers catch up when they see the generation move
|
|
591
|
+
if (this.cluster) {
|
|
592
|
+
await this.database.bumpGeneration();
|
|
593
|
+
}
|
|
594
|
+
// Recount, with the fresh data
|
|
595
|
+
await this._recount();
|
|
240
596
|
|
|
241
597
|
// Commit the _lastUpdated watermark only now that phase 2 has durably
|
|
242
598
|
// written everything. Hold it back if any URL was permanently dropped, so
|
|
@@ -247,22 +603,30 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
247
603
|
await this.database.setSetting(VSAC_LAST_UPDATED_KEY, this._pendingLastUpdated);
|
|
248
604
|
} else if (permanentFailures > 0) {
|
|
249
605
|
const holdMsg = `Holding _lastUpdated watermark: ${permanentFailures} URL(s) failed after retry; will re-scan next run`;
|
|
250
|
-
|
|
606
|
+
vsacLog.info(holdMsg);
|
|
251
607
|
this.stats.task('VSAC Sync', holdMsg);
|
|
252
608
|
}
|
|
609
|
+
// the work list is done (URLs that failed twice are re-found through the held watermark)
|
|
610
|
+
await this._clearPendingQueue();
|
|
253
611
|
|
|
254
612
|
let msg = `VSAC refresh completed. Total: ${tracking.totalFetched} ValueSets, New: ${tracking.totalNew}, Updated: ${tracking.totalUpdated}`;
|
|
255
613
|
this.stats.taskDone('VSAC Sync', msg);
|
|
256
|
-
|
|
614
|
+
vsacLog.info(msg);
|
|
257
615
|
|
|
258
616
|
await this.database.finishRun(runId, tracking.totalFetched, tracking.totalNew, tracking.totalUpdated);
|
|
259
617
|
} catch (error) {
|
|
260
618
|
debugLog(error, 'Error during VSAC refresh:');
|
|
261
619
|
this.stats.taskError('VSAC Sync', `Error (${error.message})`);
|
|
262
620
|
await this.database.failRun(runId, error.message);
|
|
621
|
+
if (this.cluster) {
|
|
622
|
+
// what the run wrote before it failed is in the database
|
|
623
|
+
await this.database.bumpGeneration();
|
|
624
|
+
}
|
|
263
625
|
throw error;
|
|
264
626
|
} finally {
|
|
265
627
|
this.isRefreshing = false;
|
|
628
|
+
// each row written was dropped as it was written; start again anyway after a full sync
|
|
629
|
+
this._recentValueSets().clear();
|
|
266
630
|
}
|
|
267
631
|
}
|
|
268
632
|
|
|
@@ -295,38 +659,40 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
295
659
|
|
|
296
660
|
// Process sequentially to avoid database locking
|
|
297
661
|
for (const valueSet of valueSets) {
|
|
298
|
-
const
|
|
299
|
-
const existing = this.valueSetMap.get(key);
|
|
662
|
+
const existing = valueSet.version ? await this.database.getVersion(valueSet.url, valueSet.version) : undefined;
|
|
300
663
|
const newHash = this._hashValueSet(valueSet);
|
|
301
664
|
|
|
302
665
|
if (existing) {
|
|
303
666
|
// We've seen this url|version before. Decide whether the content
|
|
304
667
|
// has actually changed by comparing hashes.
|
|
305
668
|
//
|
|
306
|
-
// Note:
|
|
307
|
-
// from compose.include/exclude), so
|
|
308
|
-
//
|
|
309
|
-
// original unmutated JSON we stored. For rows predating this feature
|
|
669
|
+
// Note: the value sets served are altered after they are read (_prepare
|
|
670
|
+
// strips inc.version from compose.include/exclude), so the hash is
|
|
671
|
+
// the one stored with the original unmutated JSON. For rows predating this feature
|
|
310
672
|
// (content_hash NULL), we defer update detection until the next cycle:
|
|
311
673
|
// the upsert below runs only when hashes differ, so on the *next*
|
|
312
674
|
// sync after migration we'll have a proper baseline.
|
|
313
|
-
if (existing.
|
|
675
|
+
if (existing.content_hash && existing.content_hash === newHash) {
|
|
314
676
|
// No change - just touch last_seen
|
|
315
677
|
await this.database.seeValueSet(valueSet);
|
|
316
|
-
} else if (!existing.
|
|
678
|
+
} else if (!existing.content_hash) {
|
|
317
679
|
// Legacy row without a stored hash - backfill the hash silently
|
|
318
680
|
// without emitting a spurious 'updated' event. We do a lightweight
|
|
319
681
|
// touch + hash update rather than a full upsert+event.
|
|
320
682
|
await this.database.seeValueSet(valueSet);
|
|
321
683
|
await this.database.setContentHash(valueSet.id, newHash);
|
|
684
|
+
this._recentValueSets().delete(existing.id);
|
|
322
685
|
} else {
|
|
323
686
|
// Content has changed - treat as update
|
|
324
687
|
await this.database.upsertValueSet(valueSet, newHash);
|
|
688
|
+
this._recentValueSets().delete(existing.id);
|
|
689
|
+
this._recentValueSets().delete(valueSet.id);
|
|
325
690
|
await this.database.recordEvent('updated', valueSet.url, valueSet.version);
|
|
326
691
|
updatedCount++;
|
|
327
692
|
}
|
|
328
693
|
} else {
|
|
329
694
|
await this.database.upsertValueSet(valueSet, newHash);
|
|
695
|
+
this._recentValueSets().delete(valueSet.id);
|
|
330
696
|
await this.database.recordEvent('new', valueSet.url, valueSet.version);
|
|
331
697
|
newCount++;
|
|
332
698
|
}
|
|
@@ -411,43 +777,139 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
411
777
|
base = new URL(this.baseUrl);
|
|
412
778
|
next = new URL(nextLink.url, this.baseUrl + '/');
|
|
413
779
|
} catch (e) {
|
|
414
|
-
|
|
780
|
+
vsacLog.warn(`ignoring next link ${String(nextLink.url).substring(0, 200)}: ${e.message}`);
|
|
415
781
|
return null;
|
|
416
782
|
}
|
|
417
783
|
const basePath = base.pathname.replace(/\/+$/, '');
|
|
418
784
|
if (next.host !== base.host || !(next.pathname === basePath || next.pathname.startsWith(basePath + '/'))) {
|
|
419
|
-
|
|
785
|
+
vsacLog.warn(`ignoring next link ${String(nextLink.url).substring(0, 200)}: it is not on ${this.baseUrl}`);
|
|
420
786
|
return null;
|
|
421
787
|
}
|
|
422
788
|
return next.pathname.substring(basePath.length) + next.search;
|
|
423
789
|
}
|
|
424
790
|
|
|
425
791
|
/**
|
|
426
|
-
*
|
|
792
|
+
* Refresh the counts from the database (the value sets themselves are read on demand)
|
|
427
793
|
* @returns {Promise<void>}
|
|
428
794
|
* @private
|
|
429
795
|
*/
|
|
430
|
-
async
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
796
|
+
async _recount() {
|
|
797
|
+
const counts = await this.database.countValueSets();
|
|
798
|
+
this.urlCount = counts.urls;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Read a value set from the database and make it ready to serve: the compose doesn't pin
|
|
803
|
+
* code system versions, and the id is in this provider's id space
|
|
804
|
+
* @param {string} id - the value set's own id
|
|
805
|
+
* @returns {Promise<ValueSet|null>}
|
|
806
|
+
* @private
|
|
807
|
+
*/
|
|
808
|
+
async _loadValueSet(id) {
|
|
809
|
+
const vs = await this.database.loadValueSet(id, this.sourcePackage());
|
|
810
|
+
if (vs) {
|
|
811
|
+
this._prepare(vs);
|
|
812
|
+
}
|
|
813
|
+
return vs;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* A value set by row id, from the recently used ones if it is there, or else read (and kept)
|
|
818
|
+
* @param {string} id - the value set's own id
|
|
819
|
+
* @returns {Promise<ValueSet|null>} shared with other requests: not to be changed
|
|
820
|
+
* @private
|
|
821
|
+
*/
|
|
822
|
+
async _readValueSet(id) {
|
|
823
|
+
const recent = this._recentValueSets();
|
|
824
|
+
let vs = recent.get(id);
|
|
825
|
+
if (vs) {
|
|
826
|
+
return vs;
|
|
827
|
+
}
|
|
828
|
+
// if the sync drops anything while this is read, what is read may be older than the database
|
|
829
|
+
const generation = recent.generation;
|
|
830
|
+
vs = await this._loadValueSet(id);
|
|
831
|
+
if (vs && recent.generation === generation) {
|
|
832
|
+
recent.set(id, vs, vs.storedLength);
|
|
833
|
+
}
|
|
834
|
+
return vs;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
_prepare(vs) {
|
|
838
|
+
if (vs.jsonObj.compose) {
|
|
839
|
+
for (const inc of vs.jsonObj.compose.include || []) {
|
|
840
|
+
if (inc.version) {
|
|
841
|
+
delete inc.version;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
for (const inc of vs.jsonObj.compose.exclude || []) {
|
|
845
|
+
if (inc.version) {
|
|
846
|
+
delete inc.version;
|
|
442
847
|
}
|
|
443
848
|
}
|
|
444
849
|
}
|
|
445
850
|
// the value sets are served in this provider's id space
|
|
446
|
-
|
|
447
|
-
|
|
851
|
+
applyIdPrefix(vs, this.spaceId);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* A follower (cluster.md): bring what this process holds up to date with the database, and
|
|
856
|
+
* remember the generation it is now at. Only the recently used value sets are held, so they
|
|
857
|
+
* are dropped (they may be older than the database now), and the urls recounted. The
|
|
858
|
+
* generation is read first: a sync that finishes meanwhile makes the next check catch up
|
|
859
|
+
* again, never the reverse.
|
|
860
|
+
* @returns {Promise<void>}
|
|
861
|
+
* @private
|
|
862
|
+
*/
|
|
863
|
+
async _followDatabase() {
|
|
864
|
+
const generation = await this.database.getGeneration();
|
|
865
|
+
this._recentValueSets().clear();
|
|
866
|
+
await this._recount();
|
|
867
|
+
this.loadedGeneration = generation;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* A follower: pick up what the master's sync wrote. At most every GENERATION_CHECK_MS,
|
|
872
|
+
* read the database's generation; if it isn't the one this process last caught up with,
|
|
873
|
+
* catch up (_followDatabase). Runs in the background (requests meanwhile use what they
|
|
874
|
+
* have) and never rejects. Returns the promise of the check, for tests.
|
|
875
|
+
* @returns {Promise<void>}
|
|
876
|
+
*/
|
|
877
|
+
catchUp() {
|
|
878
|
+
const now = Date.now();
|
|
879
|
+
if (this.catchingUp || now - this.generationCheckedAt < GENERATION_CHECK_MS) {
|
|
880
|
+
return this.catchingUp || Promise.resolve();
|
|
448
881
|
}
|
|
449
|
-
|
|
450
|
-
this.
|
|
882
|
+
this.generationCheckedAt = now;
|
|
883
|
+
this.catchingUp = (async () => {
|
|
884
|
+
// not before the master has made it: opening it would create an empty file
|
|
885
|
+
if (this.loadedGeneration === null && !(await this.database.exists())) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const generation = await this.database.getGeneration();
|
|
889
|
+
if (generation !== this.loadedGeneration) {
|
|
890
|
+
vsacLog.info(`the database is at generation ${generation}, this process at ${this.loadedGeneration}; catching up`);
|
|
891
|
+
await this._followDatabase();
|
|
892
|
+
}
|
|
893
|
+
})().catch((error) => {
|
|
894
|
+
debugLog(error, 'VSAC: catching up with the database failed');
|
|
895
|
+
}).finally(() => {
|
|
896
|
+
this.catchingUp = null;
|
|
897
|
+
});
|
|
898
|
+
return this.catchingUp;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* A follower whose master hasn't made the database yet has no value sets; reading would
|
|
903
|
+
* open the file, and so create an empty one. Starts a catch-up check either way.
|
|
904
|
+
* @returns {boolean} true if there is nothing to read yet
|
|
905
|
+
* @private
|
|
906
|
+
*/
|
|
907
|
+
_followerHasNoDatabase() {
|
|
908
|
+
if (this.cluster !== 'follower') {
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
void this.catchUp();
|
|
912
|
+
return this.loadedGeneration === null;
|
|
451
913
|
}
|
|
452
914
|
|
|
453
915
|
/**
|
|
@@ -459,11 +921,37 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
459
921
|
async fetchValueSet(url, version) {
|
|
460
922
|
await this.initialize();
|
|
461
923
|
this._validateFetchParams(url, version);
|
|
924
|
+
if (this._followerHasNoDatabase()) {
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// which row the url (and version) refers to is worked out the way it always was, from a
|
|
929
|
+
// map keyed like the one loadAllValueSets builds - but of just this url's ids and versions
|
|
930
|
+
let rows = await this.database.listVersions(url);
|
|
931
|
+
while (rows.length > 0) {
|
|
932
|
+
const versions = new Map();
|
|
933
|
+
for (const row of rows) {
|
|
934
|
+
this.database.addToMap(versions, row.id, row.url, row.version, row);
|
|
935
|
+
}
|
|
936
|
+
const row = this._findVersion(versions, url, version);
|
|
937
|
+
if (!row) {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
const vs = await this._readValueSet(row.id);
|
|
941
|
+
if (vs) {
|
|
942
|
+
return await this.checkFullVS(vs);
|
|
943
|
+
}
|
|
944
|
+
// it was rejected (and logged): as when it couldn't be loaded into the map, it's as if it isn't there
|
|
945
|
+
rows = rows.filter(r => r !== row);
|
|
946
|
+
}
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
462
949
|
|
|
950
|
+
_findVersion(versions, url, version) {
|
|
463
951
|
// Try exact match first: url|version
|
|
464
952
|
let key = `${url}|${version}`;
|
|
465
|
-
if (
|
|
466
|
-
return
|
|
953
|
+
if (versions.has(key)) {
|
|
954
|
+
return versions.get(key);
|
|
467
955
|
}
|
|
468
956
|
|
|
469
957
|
// If version is semver, try url|major.minor
|
|
@@ -472,8 +960,8 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
472
960
|
const majorMinor = VersionUtilities.getMajMin(version);
|
|
473
961
|
if (majorMinor) {
|
|
474
962
|
key = `${url}|${majorMinor}`;
|
|
475
|
-
if (
|
|
476
|
-
return
|
|
963
|
+
if (versions.has(key)) {
|
|
964
|
+
return versions.get(key);
|
|
477
965
|
}
|
|
478
966
|
}
|
|
479
967
|
}
|
|
@@ -481,18 +969,20 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
481
969
|
// Ignore version parsing errors
|
|
482
970
|
}
|
|
483
971
|
|
|
484
|
-
// Finally try just the URL
|
|
485
|
-
if (
|
|
486
|
-
return
|
|
972
|
+
// Finally try just the URL - only when no version was asked for (see VERSION_FALLBACK_TO_LATEST)
|
|
973
|
+
if ((!version || VSACValueSetProvider.VERSION_FALLBACK_TO_LATEST) && versions.has(url)) {
|
|
974
|
+
return versions.get(url);
|
|
487
975
|
}
|
|
488
976
|
|
|
489
977
|
return null;
|
|
490
978
|
}
|
|
491
979
|
|
|
492
980
|
async fetchValueSetById(id) {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
981
|
+
if (this._followerHasNoDatabase()) {
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
984
|
+
const ownId = stripIdPrefix(id, this.spaceId);
|
|
985
|
+
const vs = ownId ? await this._readValueSet(ownId) : null;
|
|
496
986
|
return vs && vs.id === id ? await this.checkFullVS(vs) : null;
|
|
497
987
|
}
|
|
498
988
|
/**
|
|
@@ -503,12 +993,39 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
503
993
|
async searchValueSets(searchParams, elements) {
|
|
504
994
|
await this.initialize();
|
|
505
995
|
this._validateSearchParams(searchParams);
|
|
996
|
+
if (this._followerHasNoDatabase()) {
|
|
997
|
+
return [];
|
|
998
|
+
}
|
|
506
999
|
|
|
507
1000
|
if (searchParams.length === 0) {
|
|
508
1001
|
return [];
|
|
509
1002
|
}
|
|
510
1003
|
|
|
511
|
-
|
|
1004
|
+
if (elements && elements.length > 0 && elements.every(e => ValueSetDatabase.INDEXED_COLUMNS.includes(e))) {
|
|
1005
|
+
// built from the indexed columns, without reading the value sets
|
|
1006
|
+
return await this.database.search(this.spaceId, null, searchParams, elements);
|
|
1007
|
+
}
|
|
1008
|
+
// The matches carry what a search is sorted by; each value set is only read if it is on the
|
|
1009
|
+
// page that is returned (see SearchWorker.loadPage)
|
|
1010
|
+
return (await this.database.searchRows(searchParams)).map(row => this._searchMatch(row));
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
_searchMatch(row) {
|
|
1014
|
+
const match = { resourceType: 'ValueSet', id: this.spaceId ? `${this.spaceId}-${row.id}` : row.id };
|
|
1015
|
+
for (const name of ['url', 'version', 'date', 'name']) {
|
|
1016
|
+
if (row[name] != null) {
|
|
1017
|
+
match[name] = row[name];
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
// the value set as search would have returned it; null if it is rejected when it is read.
|
|
1021
|
+
// A search can match thousands, so what it reads is not kept, and doesn't push out what is
|
|
1022
|
+
Object.defineProperty(match, 'load', {
|
|
1023
|
+
value: async () => {
|
|
1024
|
+
const vs = this._recentValueSets().peek(row.id) || await this._loadValueSet(row.id);
|
|
1025
|
+
return vs ? vs.jsonObj : null;
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
return match;
|
|
512
1029
|
}
|
|
513
1030
|
|
|
514
1031
|
/**
|
|
@@ -527,24 +1044,18 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
527
1044
|
isRefreshing: this.isRefreshing,
|
|
528
1045
|
refreshIntervalHours: this.refreshIntervalHours,
|
|
529
1046
|
nextRefresh: this.refreshTimer && this.lastRefresh
|
|
530
|
-
? new Date(this.lastRefresh.getTime() +
|
|
1047
|
+
? new Date(this.lastRefresh.getTime() + this._refreshIntervalMs())
|
|
531
1048
|
: null
|
|
532
1049
|
}
|
|
533
1050
|
};
|
|
534
1051
|
}
|
|
535
1052
|
|
|
536
1053
|
/**
|
|
537
|
-
* Get the number of value sets
|
|
538
|
-
* @returns {number} Number of unique value
|
|
1054
|
+
* Get the number of value sets available (as of the last refresh)
|
|
1055
|
+
* @returns {number} Number of unique value set urls
|
|
539
1056
|
*/
|
|
540
1057
|
getMapSize() {
|
|
541
|
-
|
|
542
|
-
for (const [key, valueSet] of this.valueSetMap.entries()) {
|
|
543
|
-
if (!key.includes('|')) { // Only count base URL keys
|
|
544
|
-
uniqueUrls.add(valueSet.url);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
return uniqueUrls.size;
|
|
1058
|
+
return this.urlCount;
|
|
548
1059
|
}
|
|
549
1060
|
|
|
550
1061
|
/**
|
|
@@ -576,22 +1087,54 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
576
1087
|
}
|
|
577
1088
|
|
|
578
1089
|
async listAllValueSets() {
|
|
1090
|
+
if (this._followerHasNoDatabase()) {
|
|
1091
|
+
return [];
|
|
1092
|
+
}
|
|
579
1093
|
return await this.database.listAllValueSets();
|
|
580
1094
|
}
|
|
581
1095
|
|
|
1096
|
+
async scanAllValueSets(fn) {
|
|
1097
|
+
if (this._followerHasNoDatabase()) {
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
// the latest version of each url, once (read in batches)
|
|
1101
|
+
const versions = new Map();
|
|
1102
|
+
for (const row of await this.database.listVersions(null)) {
|
|
1103
|
+
let map = versions.get(row.url);
|
|
1104
|
+
if (!map) {
|
|
1105
|
+
map = new Map();
|
|
1106
|
+
versions.set(row.url, map);
|
|
1107
|
+
}
|
|
1108
|
+
this.database.addToMap(map, row.id, row.url, row.version, row);
|
|
1109
|
+
}
|
|
1110
|
+
const urls = [...versions.keys()].sort();
|
|
1111
|
+
for (let i = 0; i < urls.length; i += 500) {
|
|
1112
|
+
const batch = urls.slice(i, i + 500).map(url => ({ url, row: this._findVersion(versions.get(url), url) }));
|
|
1113
|
+
const valueSets = await this.database.loadValueSets(batch.map(b => b.row.id), this.sourcePackage());
|
|
1114
|
+
for (const b of batch) {
|
|
1115
|
+
let vs = valueSets.get(b.row.id);
|
|
1116
|
+
if (vs) {
|
|
1117
|
+
this._prepare(vs);
|
|
1118
|
+
} else {
|
|
1119
|
+
vs = await this.fetchValueSet(b.url); // the latest was rejected
|
|
1120
|
+
}
|
|
1121
|
+
if (vs) {
|
|
1122
|
+
await fn(vs);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
582
1128
|
async close() {
|
|
1129
|
+
this._recentValueSets().clear();
|
|
583
1130
|
await this.database.close();
|
|
584
1131
|
}
|
|
585
1132
|
|
|
586
1133
|
prefixIds() {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
// can happen before or after the spaceId is assigned - so both prefix whatever is there
|
|
592
|
-
#applyIdPrefix() {
|
|
593
|
-
for (const vs of new Set(this.valueSetMap.values())) {
|
|
594
|
-
applyIdPrefix(vs, this.spaceId);
|
|
1134
|
+
// nothing is held in memory: each value set gets its prefix as it is read (see _prepare).
|
|
1135
|
+
// Anything read before the spaceId was assigned has the wrong id, so start again
|
|
1136
|
+
if (this._recent) {
|
|
1137
|
+
this._recent.clear();
|
|
595
1138
|
}
|
|
596
1139
|
}
|
|
597
1140
|
|
|
@@ -606,10 +1149,8 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
606
1149
|
// if (vs.jsonObj?.compose) {
|
|
607
1150
|
// return vs;
|
|
608
1151
|
// }
|
|
609
|
-
// console.log('get a full copy for the ValueSet '+vs.url+'|'+vs.version);
|
|
610
1152
|
// let vsNew = await this._fetchValueSet(vs.id);
|
|
611
1153
|
// await this.database.upsertValueSet(vsNew);
|
|
612
|
-
// this.database.addToMap(this.valueSetMap, vsNew.id, vsNew.url, vsNew.version, vsNew);
|
|
613
1154
|
// return new ValueSet(vsNew);
|
|
614
1155
|
return vs;
|
|
615
1156
|
}
|
|
@@ -634,7 +1175,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
634
1175
|
}
|
|
635
1176
|
}
|
|
636
1177
|
let logMsg = `VSAC (${tracking.count} of ${length}) ${q}: ${vcount} versions (${perRun.newCount} new, ${perRun.updatedCount} updated)`;
|
|
637
|
-
|
|
1178
|
+
vsacLog.debug(logMsg);
|
|
638
1179
|
this.stats.task('VSAC Sync', logMsg);
|
|
639
1180
|
}
|
|
640
1181
|
|
|
@@ -659,7 +1200,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
659
1200
|
let serverDate = null;
|
|
660
1201
|
|
|
661
1202
|
while (url) {
|
|
662
|
-
|
|
1203
|
+
vsacLog.debug(`_lastUpdated scan: ${count} found so far`);
|
|
663
1204
|
this.stats.task('VSAC Sync', `_lastUpdated scan: ${count} found`);
|
|
664
1205
|
|
|
665
1206
|
const bundle = await this._fetchBundle(url);
|
|
@@ -703,7 +1244,8 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
703
1244
|
* Whether the on-demand resync form is enabled (a resyncPassword is configured).
|
|
704
1245
|
*/
|
|
705
1246
|
_resyncEnabled() {
|
|
706
|
-
|
|
1247
|
+
// a follower doesn't write: the info pages are routed to the master
|
|
1248
|
+
return !!this.resyncPassword && this.cluster !== 'follower';
|
|
707
1249
|
}
|
|
708
1250
|
|
|
709
1251
|
/**
|
|
@@ -724,14 +1266,19 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
724
1266
|
|
|
725
1267
|
/**
|
|
726
1268
|
* Immediately resync a single ValueSet URL (all versions): fetch from VSAC,
|
|
727
|
-
* upsert, and
|
|
1269
|
+
* upsert, and recount.
|
|
728
1270
|
* @param {string} url - the ValueSet canonical url
|
|
729
1271
|
* @returns {Promise<number>} number of versions fetched
|
|
730
1272
|
*/
|
|
731
1273
|
async resyncValueSet(url) {
|
|
732
1274
|
const tracking = { totalFetched: 0, totalNew: 0, totalUpdated: 0, count: 0, newCount: 0 };
|
|
733
1275
|
await this.processContentAndHistory(url, tracking, 1);
|
|
734
|
-
|
|
1276
|
+
if (this.cluster) {
|
|
1277
|
+
await this.database.bumpGeneration();
|
|
1278
|
+
}
|
|
1279
|
+
// the rows written were dropped as they were written; an operator asking expects none kept
|
|
1280
|
+
this._recentValueSets().deleteUrl(url);
|
|
1281
|
+
await this._recount();
|
|
735
1282
|
return tracking.totalFetched;
|
|
736
1283
|
}
|
|
737
1284
|
|
|
@@ -762,7 +1309,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
762
1309
|
}
|
|
763
1310
|
try {
|
|
764
1311
|
const n = await this.resyncValueSet(url);
|
|
765
|
-
|
|
1312
|
+
vsacLog.info(`Manual resync of ${url}: ${n} version(s)`);
|
|
766
1313
|
this.stats.task('VSAC Sync', `Manual resync of ${url}: ${n} version(s)`);
|
|
767
1314
|
return `<p style="color:#070"><strong>Resynced ${escape(url)}: ${n} version(s).</strong></p>`;
|
|
768
1315
|
} catch (error) {
|
|
@@ -899,10 +1446,10 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
899
1446
|
colour = 'green';
|
|
900
1447
|
} else if (row.status === 'error') {
|
|
901
1448
|
detail = `Failed: ${escape(row.error_message || '')} (${duration})`;
|
|
902
|
-
colour = '
|
|
1449
|
+
colour = 'maroon';
|
|
903
1450
|
} else {
|
|
904
1451
|
detail = `Running... (started ${fmtFull(row.ts)})`;
|
|
905
|
-
colour = '
|
|
1452
|
+
colour = '#a16600';
|
|
906
1453
|
}
|
|
907
1454
|
html += `<tr style="background:#f0f0f0">`;
|
|
908
1455
|
html += `<td>${escape(timeOnly(row.ts))}</td>`;
|
|
@@ -952,5 +1499,6 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
952
1499
|
|
|
953
1500
|
// Usage examples:
|
|
954
1501
|
module.exports = {
|
|
955
|
-
VSACValueSetProvider
|
|
1502
|
+
VSACValueSetProvider,
|
|
1503
|
+
vsacLog
|
|
956
1504
|
};
|