fhirsmith 0.18.1 → 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 +190 -9
- 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 +331 -33
- package/ips/jurisdictions.js +65 -0
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +131 -17
- package/ips/presenter.js +1 -1
- package/ips/profile-editor.js +283 -4
- package/ips/readme.md +170 -20
- package/ips/render.js +769 -109
- package/ips/scripts.js +46 -0
- package/ips/store.js +123 -11
- 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/api.js +179 -1
- package/registry/crawler.js +86 -108
- package/registry/documentation/api.md +68 -2
- package/registry/documentation/configuration.md +34 -0
- package/registry/documentation/internals.md +4 -1
- package/registry/documentation/readme.md +3 -2
- package/registry/model.js +8 -0
- package/registry/openapi.yaml +84 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +58 -6
- 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 +158 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +50 -16
- 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 +199 -24
- 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 +193 -67
- 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 +861 -95
- 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 +125 -61
- 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/tho-precedence.js +40 -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 +314 -67
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +81 -17
- 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 +175 -15
- 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 +44 -17
- package/tx/vs/vs-vsac.js +649 -101
- 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/cluster.js
ADDED
|
@@ -0,0 +1,1220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
//
|
|
4
|
+
// Copyright 2025, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
5
|
+
//
|
|
6
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* An optional launcher: one master and N terminology workers on one machine, behind one port
|
|
11
|
+
* (see cluster.md). It applies the routing of nginx.md's configuration, as one upstream: always
|
|
12
|
+
* run it behind nginx on the same host, with request buffering on (proxy_request_buffering,
|
|
13
|
+
* nginx's default). It is not built to face clients itself. It:
|
|
14
|
+
*
|
|
15
|
+
* - starts the master (node server.js --role=master), waits until it is ready (it says so
|
|
16
|
+
* on its IPC channel once it listens), then starts the workers (--role=tx-worker) one
|
|
17
|
+
* after another, each once the one before is ready, each with its own instance code and
|
|
18
|
+
* port; exits 1 if the master exits maxStartFailures times without ever being ready
|
|
19
|
+
* - routes: a request with X-Cache-Id to the process that issued it (by the id's prefix);
|
|
20
|
+
* $closure, the syndication feed, the source info pages and everything that isn't a tx
|
|
21
|
+
* endpoint to the master; any other tx request to the tx process with the fewest
|
|
22
|
+
* requests outstanding
|
|
23
|
+
* - sends a process at most maxOutstanding tx requests at once (those routed to a tx process,
|
|
24
|
+
* not the master's own paths or CORS preflights, which neither wait nor count); the rest
|
|
25
|
+
* wait, and one that has waited queueSeconds (ownerQueueSeconds with a cache-id) gets 503
|
|
26
|
+
* + Retry-After
|
|
27
|
+
* - lets cheap requests (by method and path: the fast lane) use fastLaneExtra more slots
|
|
28
|
+
* than maxOutstanding, ahead of the others, leaving them normalMinSlots
|
|
29
|
+
* - sends no new requests to a process that answered 503 for cooldownSeconds, while
|
|
30
|
+
* another can take them
|
|
31
|
+
* - answers 504 for a process that hasn't begun its response in upstreamTimeoutSeconds, and
|
|
32
|
+
* aborts that request; after SUSPECT_TIMEOUTS in a row, the process cools as on a 503
|
|
33
|
+
* - frees a tx request's slot when the process's response ends, however slowly the client
|
|
34
|
+
* reads it, and closes the connection of a client that takes none of a response for
|
|
35
|
+
* writeTimeoutSeconds
|
|
36
|
+
* - restarts a process that exits, waiting longer each time it fails quickly (up to a minute)
|
|
37
|
+
* - restarts a tx-worker held over the memory guard's refusal threshold for recycleSeconds
|
|
38
|
+
* with no request at work
|
|
39
|
+
* - every statusSeconds, passes the master a snapshot of every process's memory, caches and
|
|
40
|
+
* work, for its status panel (library/cluster-status.js)
|
|
41
|
+
* - on SIGTERM or SIGINT, stops taking connections, lets the requests in progress finish,
|
|
42
|
+
* then stops the processes (each shuts down as a single server does)
|
|
43
|
+
*
|
|
44
|
+
* node cluster.js [--workers=N] [--port=P] [--internal-port=P] [--master-serves-tx=false]
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const http = require('http');
|
|
48
|
+
const path = require('path');
|
|
49
|
+
const { spawn } = require('child_process');
|
|
50
|
+
const { checkInstanceCode, bindAddress, connectAddress, ROLE_TRIM_ALLOCATOR, READY } = require('./library/role');
|
|
51
|
+
const { ClusterMetrics, METRIC, SNAPSHOT } = require('./library/cluster-metrics');
|
|
52
|
+
const { PendingCounts, COUNTS, COUNTS_INTERVAL_MS } = require('./library/cluster-counts');
|
|
53
|
+
const { PendingClusterEvents, CLUSTER_EVENTS, KINDS } = require('./library/cluster-events');
|
|
54
|
+
const { readCgroupAnon, readCgroupStat, readProcessMemory } = require('./library/cgroup-memory');
|
|
55
|
+
const { buildSnapshot, STATUS, CLUSTER_STATUS, STATUS_INTERVAL_MS } = require('./library/cluster-status');
|
|
56
|
+
const { thresholds } = require('./library/memory-guard');
|
|
57
|
+
const { defaultWorkers, describeDecision } = require('./library/worker-count');
|
|
58
|
+
const { englishMessage } = require('./library/i18nsupport');
|
|
59
|
+
const { formatDuration } = require('./tx/operation-context');
|
|
60
|
+
|
|
61
|
+
const TX_ISSUE_TYPE = 'http://hl7.org/fhir/tools/CodeSystem/tx-issue-type';
|
|
62
|
+
const MESSAGE_ID_EXTENSION = 'http://hl7.org/fhir/StructureDefinition/operationoutcome-message-id';
|
|
63
|
+
|
|
64
|
+
// how long the requests in progress get on stop, and on a worker before a memory recycle
|
|
65
|
+
// (drainSeconds): half again the operation limit (operationTimeLimit, 20 s)
|
|
66
|
+
const DRAIN_MS = 30000;
|
|
67
|
+
// how often start() looks whether the master is ready (an in-memory flag)
|
|
68
|
+
const WAIT_POLL_MS = 250;
|
|
69
|
+
const MAX_BACKOFF_MS = 60000;
|
|
70
|
+
// an idle connection to a process is closed after this long: below the process's own
|
|
71
|
+
// keep-alive timeout (5 s at the least), so no request goes down one it is closing (a 502)
|
|
72
|
+
const UPSTREAM_IDLE_MS = 4000;
|
|
73
|
+
// a process that ran this long after it was ready, before exiting, is restarted at once
|
|
74
|
+
const HEALTHY_RUN_MS = 60000;
|
|
75
|
+
// a master that exits this many times in a row without ever having been ready: the launcher
|
|
76
|
+
// exits 1 (cluster.md, maxStartFailures). With the waits between (1, 2, 4, 8, 16 s), the
|
|
77
|
+
// sixth start is 31 s after the first failure, past the 30 s an old process still shutting
|
|
78
|
+
// down may hold the port for (each process's SIGKILL deadline)
|
|
79
|
+
const MAX_START_FAILURES = 6;
|
|
80
|
+
// tx requests sent to one process at once; the rest wait in the launcher (cluster.md)
|
|
81
|
+
const MAX_OUTSTANDING = 16;
|
|
82
|
+
// a waiting tx request gets 503 after this long; one with a cache-id, which only its owner
|
|
83
|
+
// can take, after OWNER_QUEUE_MS
|
|
84
|
+
const QUEUE_MS = 1000;
|
|
85
|
+
const OWNER_QUEUE_MS = 30000;
|
|
86
|
+
// a process that answered 503 gets no new requests for this long while another can take
|
|
87
|
+
// them. Short: its memory guard re-measures only when a request arrives
|
|
88
|
+
const COOLDOWN_MS = 2000;
|
|
89
|
+
// a process that hasn't begun its response this long after the request was sent is taken to be
|
|
90
|
+
// wedged: the client gets 504 and the request is aborted. The operation limit
|
|
91
|
+
// (modules.tx.operationTimeLimit, 20 s) counts an operation's own compute, not the turns it
|
|
92
|
+
// waits for others on its event loop, so a heavy one in a busy process takes longer by the
|
|
93
|
+
// clock: three times it. Time in the launcher's queue is apart
|
|
94
|
+
const UPSTREAM_TIMEOUT_MS = 60000;
|
|
95
|
+
// this many timeouts in a row, and the process cools (as on a 503) while another can take them
|
|
96
|
+
const SUSPECT_TIMEOUTS = 3;
|
|
97
|
+
// a client that takes none of the response waiting for it this long is cut off (cluster.md,
|
|
98
|
+
// writeTimeoutSeconds): one that reads takes some within milliseconds; nginx's own
|
|
99
|
+
// send_timeout is the same 60 s
|
|
100
|
+
const WRITE_TIMEOUT_MS = 60000;
|
|
101
|
+
// a tx-worker over the memory guard's refusal threshold this long, with no request at work, is
|
|
102
|
+
// restarted (cluster.md, "Memory"): past the guard's own GC and trims, short next to a retry
|
|
103
|
+
const RECYCLE_MS = 30000;
|
|
104
|
+
// how often the launcher measures for that
|
|
105
|
+
const RECYCLE_CHECK_MS = 1000;
|
|
106
|
+
// a request in flight this long is work; a refusal (503) is answered at once
|
|
107
|
+
const AT_WORK_MS = 1000;
|
|
108
|
+
// memory to leave under the refusal threshold with every process loaded and idle: about what
|
|
109
|
+
// one heavy operation (a large SNOMED $expand, a big $batch-validate-code) uses
|
|
110
|
+
const HEADROOM_GB = 2;
|
|
111
|
+
// a fast-lane request (isFastLane) may go to a process with this many more than
|
|
112
|
+
// MAX_OUTSTANDING; a POST takes the lane only with a Content-Length up to FAST_LANE_MAX_BYTES
|
|
113
|
+
const FAST_LANE_EXTRA = 4;
|
|
114
|
+
const FAST_LANE_MAX_BYTES = 1024;
|
|
115
|
+
// while other requests wait, the fast lane leaves them this many of a process's slots, so
|
|
116
|
+
// heavy work goes on during a flood of cheap requests
|
|
117
|
+
const NORMAL_MIN_SLOTS = 4;
|
|
118
|
+
const GB = (b) => (b / 2 ** 30).toFixed(1);
|
|
119
|
+
const HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade', 'te', 'trailer', 'expect'];
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The settings, from the flags, then the environment, then config.json (server.cluster).
|
|
123
|
+
* @param {string[]} argv - process.argv.slice(2)
|
|
124
|
+
* @param {Object} env - process.env
|
|
125
|
+
* @param {Object} config - config.json
|
|
126
|
+
* @param {Object} [cpu] - for tests: the CPU inputs (library/worker-count.js, cpuInputs)
|
|
127
|
+
*/
|
|
128
|
+
function loadSettings(argv, env, config, cpu = {}) {
|
|
129
|
+
const flags = {};
|
|
130
|
+
for (const a of argv) {
|
|
131
|
+
const m = /^--([a-z-]+)=(.*)$/.exec(a);
|
|
132
|
+
if (m) {
|
|
133
|
+
flags[m[1]] = m[2];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const cluster = (config.server && config.server.cluster) || {};
|
|
137
|
+
const tx = (config.modules && config.modules.tx) || {};
|
|
138
|
+
if (!tx.enabled) {
|
|
139
|
+
throw new Error('cluster.js runs terminology workers: the tx module must be enabled in config.json');
|
|
140
|
+
}
|
|
141
|
+
const port = Number(flags.port || env.PORT || (config.server && config.server.port) || 3000);
|
|
142
|
+
// not given: absent, null, or an empty FHIRSMITH_WORKERS; 0: the master alone
|
|
143
|
+
const workersGiven = flags.workers ?? (env.FHIRSMITH_WORKERS || undefined) ?? cluster.workers ?? undefined;
|
|
144
|
+
if (workersGiven !== undefined && !/^\d+$/.test(String(workersGiven))) {
|
|
145
|
+
throw new Error(`the number of workers (--workers, FHIRSMITH_WORKERS or server.cluster.workers) must be a whole number, not '${workersGiven}'`);
|
|
146
|
+
}
|
|
147
|
+
// not given: from the CPUs, by the engine's rule (library/worker-count.js; cluster.md)
|
|
148
|
+
const workerDecision = defaultWorkers({ ...cpu, cores: cluster.cores ?? cpu.cores, rule: cluster.workerRule });
|
|
149
|
+
const workers = Number(workersGiven ?? workerDecision.workers);
|
|
150
|
+
const internalPort = Number(flags['internal-port'] || cluster.internalPort || port + 1);
|
|
151
|
+
// the master is on internalPort, worker n on internalPort + n
|
|
152
|
+
if (!Number.isInteger(internalPort) || internalPort < 1 || internalPort + workers > 65535) {
|
|
153
|
+
throw new Error(`the internal ports (--internal-port or server.cluster.internalPort ${internalPort}, to + ${workers} for the workers) must be port numbers`);
|
|
154
|
+
}
|
|
155
|
+
if (port >= internalPort && port <= internalPort + workers) {
|
|
156
|
+
throw new Error(`the public port ${port} is one of the internal ports ${internalPort}-${internalPort + workers} (the master's, and one per worker): ` +
|
|
157
|
+
'choose server.cluster.internalPort (--internal-port) outside it');
|
|
158
|
+
}
|
|
159
|
+
const masterCode = checkInstanceCode(tx.instanceCode || 'm0', 'modules.tx.instanceCode');
|
|
160
|
+
const servesTx = flags['master-serves-tx'] !== undefined ? flags['master-serves-tx'] !== 'false' : cluster.masterServesTx !== false;
|
|
161
|
+
// where the processes listen (server.cluster.bindAddress: loopback by default)
|
|
162
|
+
const host = connectAddress(bindAddress(config));
|
|
163
|
+
const procs = [{ name: 'master', role: 'master', code: masterCode, host, port: internalPort, servesTx }];
|
|
164
|
+
for (let i = 1; i <= workers; i++) {
|
|
165
|
+
const code = 'tx' + i;
|
|
166
|
+
if (code === masterCode) {
|
|
167
|
+
throw new Error(`modules.tx.instanceCode '${masterCode}' is also the code of a worker: choose another`);
|
|
168
|
+
}
|
|
169
|
+
procs.push({ name: 'worker ' + code, role: 'tx-worker', code, host, port: internalPort + i, servesTx: true });
|
|
170
|
+
}
|
|
171
|
+
// with the shared cache any process can answer any cache-id (cluster.md, "Options")
|
|
172
|
+
const sharedCache = cluster.sharedCache === true;
|
|
173
|
+
const statusSeconds = Number(cluster.statusSeconds ?? STATUS_INTERVAL_MS / 1000);
|
|
174
|
+
if (!(statusSeconds > 0)) {
|
|
175
|
+
throw new Error(`server.cluster.statusSeconds must be a number of seconds above 0, not '${cluster.statusSeconds}'`);
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
port,
|
|
179
|
+
procs,
|
|
180
|
+
txPaths: (tx.endpoints || []).map(e => String(e.path).replace(/\/+$/, '')),
|
|
181
|
+
drainMs: Number(cluster.drainSeconds ?? DRAIN_MS / 1000) * 1000,
|
|
182
|
+
maxOutstanding: Number(cluster.maxOutstanding ?? MAX_OUTSTANDING),
|
|
183
|
+
queueMs: Number(cluster.queueSeconds ?? QUEUE_MS / 1000) * 1000,
|
|
184
|
+
ownerQueueMs: Number(cluster.ownerQueueSeconds ?? OWNER_QUEUE_MS / 1000) * 1000,
|
|
185
|
+
cooldownMs: Number(cluster.cooldownSeconds ?? COOLDOWN_MS / 1000) * 1000,
|
|
186
|
+
upstreamTimeoutMs: Number(cluster.upstreamTimeoutSeconds ?? UPSTREAM_TIMEOUT_MS / 1000) * 1000,
|
|
187
|
+
writeTimeoutMs: Number(cluster.writeTimeoutSeconds ?? WRITE_TIMEOUT_MS / 1000) * 1000,
|
|
188
|
+
recycleMs: Number(cluster.recycleSeconds ?? RECYCLE_MS / 1000) * 1000,
|
|
189
|
+
maxStartFailures: Number(cluster.maxStartFailures ?? MAX_START_FAILURES),
|
|
190
|
+
// the number of workers was not given: fewer if memory is short (see sizeWorkers)
|
|
191
|
+
workersDefault: workersGiven === undefined,
|
|
192
|
+
workerDecision,
|
|
193
|
+
headroomBytes: Number(cluster.memoryHeadroomGB ?? HEADROOM_GB) * 2 ** 30,
|
|
194
|
+
memory: (config.server && config.server.memory) || {},
|
|
195
|
+
fastLaneExtra: Number(cluster.fastLaneExtra ?? FAST_LANE_EXTRA),
|
|
196
|
+
fastLaneMaxBytes: Number(cluster.fastLaneMaxBytes ?? FAST_LANE_MAX_BYTES),
|
|
197
|
+
normalMinSlots: Number(cluster.normalMinSlots ?? NORMAL_MIN_SLOTS),
|
|
198
|
+
statusMs: statusSeconds * 1000,
|
|
199
|
+
routing: { cacheAffinity: !sharedCache || cluster.cacheAffinity === true, sharedClosure: cluster.sharedClosure === true, sharedCache }
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// the path after the tx endpoint ('' for the endpoint itself), or null if not a tx path
|
|
204
|
+
function txRest(url, txPaths) {
|
|
205
|
+
let p = String(url).split('?')[0];
|
|
206
|
+
try {
|
|
207
|
+
p = decodeURIComponent(p);
|
|
208
|
+
} catch {
|
|
209
|
+
// leave it as it is
|
|
210
|
+
}
|
|
211
|
+
const ep = txPaths.find(e => p === e || p.startsWith(e + '/'));
|
|
212
|
+
return ep === undefined ? null : p.substring(ep.length);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const FAST_OPS = /^(\/[A-Za-z]+(\/[A-Za-z0-9.-]{1,64})?)?\/\$(lookup|subsumes|validate-code)$/;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whether a tx request takes the fast lane (cluster.md), by method, path and declared size (the
|
|
219
|
+
* launcher doesn't read bodies): a GET or HEAD of metadata, $lookup, $subsumes or
|
|
220
|
+
* $validate-code, or a POST of those three with a Content-Length of at most maxBytes (0: none).
|
|
221
|
+
* @param {Object} req - method, url, headers
|
|
222
|
+
*/
|
|
223
|
+
function isFastLane(req, txPaths, maxBytes) {
|
|
224
|
+
const rest = txRest(req.url, txPaths);
|
|
225
|
+
if (rest === null) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
const r = rest.replace(/\/+$/, '');
|
|
229
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
230
|
+
return r === '/metadata' || FAST_OPS.test(r);
|
|
231
|
+
}
|
|
232
|
+
const len = req.headers['content-length'];
|
|
233
|
+
return req.method === 'POST' && maxBytes > 0 && FAST_OPS.test(r) && /^\d+$/.test(len || '') && Number(len) <= maxBytes;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Where a request goes, by the rules in nginx.md.
|
|
238
|
+
* @param {string} url - the request target (path and query)
|
|
239
|
+
* @param {Object} headers - the request headers (lower case names)
|
|
240
|
+
* @param {string[]} txPaths - the tx endpoint paths, e.g. ['/r4', '/r5']
|
|
241
|
+
* @param {Object} [routing] - from loadSettings: cacheAffinity (default true) sends a
|
|
242
|
+
* request with a cache-id to the process that issued it; sharedClosure sends $closure
|
|
243
|
+
* to any tx process rather than the master
|
|
244
|
+
* @returns {{to: 'master'}|{to: 'owner', code: string}|{to: 'tx'}}
|
|
245
|
+
*/
|
|
246
|
+
function classify(url, headers, txPaths, routing = {}) {
|
|
247
|
+
const rest = txRest(url, txPaths);
|
|
248
|
+
if (rest === null) {
|
|
249
|
+
return { to: 'master' };
|
|
250
|
+
}
|
|
251
|
+
// the closure tables (unless shared) and the syndication store are the master's; the
|
|
252
|
+
// source info pages carry the VSAC resync form, which writes the VSAC database
|
|
253
|
+
if (/^\/\$closure\/?$/.test(rest)) {
|
|
254
|
+
// closure never uses a cache-id, so the least loaded, whatever the header says
|
|
255
|
+
return routing.sharedClosure ? { to: 'tx' } : { to: 'master' };
|
|
256
|
+
}
|
|
257
|
+
if (/^\/syndication(\/|$)/.test(rest) || rest.startsWith('/info/')) {
|
|
258
|
+
return { to: 'master' };
|
|
259
|
+
}
|
|
260
|
+
if (routing.cacheAffinity === false) {
|
|
261
|
+
return { to: 'tx' };
|
|
262
|
+
}
|
|
263
|
+
const m = /^([A-Za-z0-9]{1,16})\./.exec(headers['x-cache-id'] || '');
|
|
264
|
+
return m ? { to: 'owner', code: m[1] } : { to: 'tx' };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The process to send a request to, or null if none is ready.
|
|
269
|
+
* @param {Object} route - from classify
|
|
270
|
+
* @param {Object[]} procs - {code, role, servesTx, ready, outstanding, coolUntil}
|
|
271
|
+
* @param {function(Object): boolean} [hasRoom] - whether a process has room for this request:
|
|
272
|
+
* the least loaded of those that do, if any
|
|
273
|
+
*/
|
|
274
|
+
let rotation = 0;
|
|
275
|
+
function pickTarget(route, procs, hasRoom = () => true) {
|
|
276
|
+
if (route.to === 'master') {
|
|
277
|
+
return procs.find(p => p.role === 'master' && p.ready) || null;
|
|
278
|
+
}
|
|
279
|
+
if (route.to === 'owner') {
|
|
280
|
+
const owner = procs.find(p => p.code === route.code && p.ready && p.servesTx);
|
|
281
|
+
if (owner) {
|
|
282
|
+
return owner;
|
|
283
|
+
}
|
|
284
|
+
// the owner is down (send answers that itself, without the shared cache) or the code is
|
|
285
|
+
// unknown: any tx process answers. With the shared cache, the cache is there; for an
|
|
286
|
+
// unknown code it is cache-id-unknown, and the client starts a new cache
|
|
287
|
+
}
|
|
288
|
+
const ready = procs.filter(p => p.servesTx && p.ready);
|
|
289
|
+
if (ready.length === 0) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
// not one that is refusing (it answered 503), unless they all are
|
|
293
|
+
const now = Date.now();
|
|
294
|
+
const warm = ready.filter(p => !(p.coolUntil > now));
|
|
295
|
+
const usable = warm.length ? warm : ready;
|
|
296
|
+
const open = usable.filter(hasRoom);
|
|
297
|
+
const candidates = open.length ? open : usable;
|
|
298
|
+
rotation = (rotation + 1) % candidates.length;
|
|
299
|
+
let best = null;
|
|
300
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
301
|
+
const c = candidates[(rotation + i) % candidates.length];
|
|
302
|
+
if (!best || c.outstanding < best.outstanding) {
|
|
303
|
+
best = c;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return best;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The launcher's view of the memory guard's refusal threshold: the same limit, fraction and
|
|
311
|
+
* cgroup (thresholds in library/memory-guard.js). sample(procs) gives each proc's RssAnon (0:
|
|
312
|
+
* not known; Linux only) and, with the cgroup measured, how far it is over (`excess`; else null).
|
|
313
|
+
* @param {Object} memory - config.server.memory
|
|
314
|
+
*/
|
|
315
|
+
function memorySampler(memory = {}) {
|
|
316
|
+
const guard = thresholds({ ...memory, group: true });
|
|
317
|
+
const { limit, group, rejectFraction, rejectAt } = guard;
|
|
318
|
+
const anonOf = (pid) => {
|
|
319
|
+
const m = readProcessMemory(pid);
|
|
320
|
+
return m ? m.anon : 0;
|
|
321
|
+
};
|
|
322
|
+
return {
|
|
323
|
+
rejectAt,
|
|
324
|
+
// for the status snapshot (library/cluster-status.js): the guard's limit and thresholds,
|
|
325
|
+
// and the cgroup's memory.stat when the guard measures the cgroup (else null)
|
|
326
|
+
guard,
|
|
327
|
+
cgroupStat: () => (group ? readCgroupStat(group) : null),
|
|
328
|
+
describe: `over ${(rejectAt / 2 ** 30).toFixed(1)} GB (${Math.round(rejectFraction * 100)}% of ${(limit / 2 ** 30).toFixed(1)} GB)` +
|
|
329
|
+
(group ? ' for the whole cgroup' : ' for the process'),
|
|
330
|
+
sample(procs) {
|
|
331
|
+
const excess = group ? readCgroupAnon(group) - rejectAt : null;
|
|
332
|
+
return { excess, own: new Map(procs.map(p => [p, p.child && p.child.pid ? anonOf(p.child.pid) : 0])) };
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Node flags for the launcher itself, not its children: a debugger port each child would fight
|
|
338
|
+
// over (--inspect...), or a watcher that restarts a child behind the launcher's back (--watch...)
|
|
339
|
+
const OWN_FLAGS = /^--(inspect(-brk|-wait|-port|-publish-uid)?|debug-port|watch(-path|-preserve-output|-kill-signal)?)(=|$)/;
|
|
340
|
+
// those of them that take their value as the next argument when written without '='
|
|
341
|
+
const OWN_FLAGS_WITH_VALUE = ['--inspect-port', '--debug-port', '--inspect-publish-uid', '--watch-path', '--watch-kill-signal'];
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The launcher's Node flags (process.execArgv) that its children get: all but OWN_FLAGS.
|
|
345
|
+
* @param {string[]} execArgv
|
|
346
|
+
*/
|
|
347
|
+
function childExecArgv(execArgv) {
|
|
348
|
+
const out = [];
|
|
349
|
+
for (let i = 0; i < execArgv.length; i++) {
|
|
350
|
+
if (OWN_FLAGS.test(execArgv[i])) {
|
|
351
|
+
if (OWN_FLAGS_WITH_VALUE.includes(execArgv[i])) {
|
|
352
|
+
i++;
|
|
353
|
+
}
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
out.push(execArgv[i]);
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function isLoopback(address) {
|
|
362
|
+
return address === '127.0.0.1' || address === '::1';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* @param {Object} settings - from loadSettings, plus optionally
|
|
367
|
+
* command(proc): [executable, args] to start a process (tests), healthyRunMs (tests), log
|
|
368
|
+
*/
|
|
369
|
+
function createLauncher(settings) {
|
|
370
|
+
const log = settings.log || ((msg) => console.log(`[cluster] ${msg}`));
|
|
371
|
+
const healthyRunMs = settings.healthyRunMs ?? HEALTHY_RUN_MS;
|
|
372
|
+
const maxStartFailures = settings.maxStartFailures ?? MAX_START_FAILURES; // 0: never give up
|
|
373
|
+
const maxOutstanding = settings.maxOutstanding ?? MAX_OUTSTANDING; // 0: no limit
|
|
374
|
+
const queueMs = settings.queueMs ?? QUEUE_MS; // 0: refuse at once
|
|
375
|
+
const ownerQueueMs = settings.ownerQueueMs ?? OWNER_QUEUE_MS;
|
|
376
|
+
const cooldownMs = settings.cooldownMs ?? COOLDOWN_MS; // 0: none
|
|
377
|
+
const upstreamTimeoutMs = settings.upstreamTimeoutMs ?? UPSTREAM_TIMEOUT_MS; // 0: none
|
|
378
|
+
const writeTimeoutMs = settings.writeTimeoutMs ?? WRITE_TIMEOUT_MS; // 0: none
|
|
379
|
+
const recycleMs = settings.recycleMs ?? RECYCLE_MS; // 0: never
|
|
380
|
+
// (tests give their own)
|
|
381
|
+
const memory = settings.memorySampler || memorySampler(settings.memory);
|
|
382
|
+
const headroom = settings.headroomBytes ?? HEADROOM_GB * 2 ** 30;
|
|
383
|
+
const fastLaneExtra = settings.fastLaneExtra ?? FAST_LANE_EXTRA; // 0: no fast lane
|
|
384
|
+
const fastLaneMaxBytes = settings.fastLaneMaxBytes ?? FAST_LANE_MAX_BYTES;
|
|
385
|
+
const normalMinSlots = Math.min(settings.normalMinSlots ?? NORMAL_MIN_SLOTS, maxOutstanding);
|
|
386
|
+
// what each process does: every one has a role (library/role.js)
|
|
387
|
+
const trimAllocator = (settings.memory && settings.memory.trimAllocator) ?? ROLE_TRIM_ALLOCATOR;
|
|
388
|
+
const command = settings.command || ((proc) => [process.execPath, [
|
|
389
|
+
...new Set(['--expose-gc', ...childExecArgv(process.execArgv)]), path.join(__dirname, 'server.js'),
|
|
390
|
+
`--role=${proc.role}`, `--instance-code=${proc.code}`, `--port=${proc.port}`]]);
|
|
391
|
+
const procs = settings.procs.map(p => ({
|
|
392
|
+
// outstanding: the tx requests it has (those that wait for a slot); active: every request
|
|
393
|
+
...p, child: null, ready: false, outstanding: 0, fastOutstanding: 0, active: 0, coolUntil: 0, timeouts: 0, failures: 0, startedAt: 0, readyAt: 0, everReady: false, exitedAt: 0, restartTimer: null,
|
|
394
|
+
// for checkMemory: when each request in flight was sent, since when it has been over with
|
|
395
|
+
// none at work, whether it is being restarted, its memory when it was ready
|
|
396
|
+
sentAt: new Set(), overSince: 0, recycling: false, readyMemory: null,
|
|
397
|
+
// the figures it last sent for the status snapshot (library/cluster-status.js)
|
|
398
|
+
statusFigures: null,
|
|
399
|
+
// since when it has been unavailable after an exit or recycle (0: it hasn't), for the daily report
|
|
400
|
+
downSince: 0,
|
|
401
|
+
agent: new http.Agent({ keepAlive: true, maxSockets: 256, timeout: settings.upstreamIdleMs || UPSTREAM_IDLE_MS })
|
|
402
|
+
}));
|
|
403
|
+
const metrics = new ClusterMetrics(procs, {
|
|
404
|
+
// the memory graph's Total and threshold lines: the cgroup when the guard measures it
|
|
405
|
+
rejectAt: memory.rejectAt, group: Boolean(memory.guard && memory.guard.group),
|
|
406
|
+
sharedCache: Boolean(settings.routing && settings.routing.sharedCache)
|
|
407
|
+
});
|
|
408
|
+
// the workers' request counts, on their way to the master's statistics
|
|
409
|
+
const pendingCounts = new PendingCounts();
|
|
410
|
+
// what the launcher saw, on its way to the master's daily report (library/cluster-events.js)
|
|
411
|
+
const pendingEvents = new PendingClusterEvents();
|
|
412
|
+
let eventsTimer = null;
|
|
413
|
+
let server = null;
|
|
414
|
+
let stopping = false;
|
|
415
|
+
// why the launcher gave up starting (see maxStartFailures), or null
|
|
416
|
+
let gaveUp = null;
|
|
417
|
+
let onIdle = null;
|
|
418
|
+
// tx requests waiting for a process with room, oldest first; the fast lane's apart
|
|
419
|
+
const waiting = [];
|
|
420
|
+
const fastWaiting = [];
|
|
421
|
+
let queueRefused = 0;
|
|
422
|
+
let lastQueueLog = 0;
|
|
423
|
+
// requests the launcher has not finished answering, for the drain on stop
|
|
424
|
+
let inFlight = 0;
|
|
425
|
+
let writeTimeouts = 0;
|
|
426
|
+
let lastWriteTimeoutLog = 0;
|
|
427
|
+
let memoryTimer = null;
|
|
428
|
+
let statusTimer = null;
|
|
429
|
+
// requests the launcher has passed on, for the status snapshot
|
|
430
|
+
let proxied = 0;
|
|
431
|
+
const launcherStartedAt = Date.now();
|
|
432
|
+
const statusMs = settings.statusMs || STATUS_INTERVAL_MS;
|
|
433
|
+
|
|
434
|
+
// Only the master renders the public home/dashboard pages. Keep at most one
|
|
435
|
+
// snapshot in flight, coalescing changes if its IPC channel is backpressured.
|
|
436
|
+
function publishMetrics() {
|
|
437
|
+
const master = procs.find(p => p.role === 'master');
|
|
438
|
+
const child = master && master.child;
|
|
439
|
+
if (!child || !child.connected) return;
|
|
440
|
+
if (master.metricsSending === child) {
|
|
441
|
+
master.metricsDirty = true;
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
master.metricsSending = child;
|
|
445
|
+
master.metricsDirty = false;
|
|
446
|
+
try {
|
|
447
|
+
child.send({ type: SNAPSHOT, ...metrics.snapshot() }, () => {
|
|
448
|
+
if (master.metricsSending !== child) return;
|
|
449
|
+
master.metricsSending = null;
|
|
450
|
+
if (master.metricsDirty && master.child === child && child.connected) publishMetrics();
|
|
451
|
+
});
|
|
452
|
+
} catch {
|
|
453
|
+
master.metricsSending = null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// The cluster's memory in all, for the graphs' Total line: the cgroup's private memory
|
|
458
|
+
// when the guard measures it, else what the processes hold privately, summed
|
|
459
|
+
function memoryTotal() {
|
|
460
|
+
const cg = memory.cgroupStat ? memory.cgroupStat() : null;
|
|
461
|
+
if (cg) {
|
|
462
|
+
return cg.anon;
|
|
463
|
+
}
|
|
464
|
+
const own = procs.map(p => (p.child && p.child.pid ? readProcessMemory(p.child.pid) : null)).filter(Boolean);
|
|
465
|
+
return own.length ? own.reduce((a, m) => a + m.anon, 0) : null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// The status snapshot (library/cluster-status.js): what each process last sent, and what the
|
|
469
|
+
// launcher reads itself
|
|
470
|
+
function statusSnapshot(now = Date.now()) {
|
|
471
|
+
const self = readProcessMemory('self');
|
|
472
|
+
const heap = process.memoryUsage();
|
|
473
|
+
return buildSnapshot({
|
|
474
|
+
now, intervalMs: statusMs,
|
|
475
|
+
procs: procs.map(p => {
|
|
476
|
+
const series = metrics.series.get(p.code) || {};
|
|
477
|
+
return {
|
|
478
|
+
code: p.code, role: p.role, status: series.status || (p.ready ? 'ready' : 'starting'), restarts: series.restarts || 0,
|
|
479
|
+
startedAt: p.child ? p.startedAt : 0,
|
|
480
|
+
figures: p.child ? p.statusFigures : null,
|
|
481
|
+
memory: p.child && p.child.pid ? readProcessMemory(p.child.pid) : null
|
|
482
|
+
};
|
|
483
|
+
}),
|
|
484
|
+
launcher: { startedAt: launcherStartedAt, requests: proxied, memory: self, heapUsed: heap.heapUsed,
|
|
485
|
+
heapLimit: require('v8').getHeapStatistics().heap_size_limit },
|
|
486
|
+
guard: memory.guard || { limit: 0, rejectAt: memory.rejectAt, abortAt: 0, rejectFraction: 0, abortFraction: 0, group: null },
|
|
487
|
+
cgroup: memory.cgroupStat ? memory.cgroupStat() : null,
|
|
488
|
+
sharedCache: Boolean(settings.routing && settings.routing.sharedCache),
|
|
489
|
+
procAvailable: self !== null
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// to the master, one at a time; one that can't go is replaced by the next
|
|
494
|
+
let statusSending = false;
|
|
495
|
+
function sendStatus() {
|
|
496
|
+
const master = procs.find(p => p.role === 'master');
|
|
497
|
+
const child = master && master.child;
|
|
498
|
+
if (statusSending || !child || !child.connected || !master.ready) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
statusSending = true;
|
|
502
|
+
try {
|
|
503
|
+
child.send({ type: CLUSTER_STATUS, snapshot: statusSnapshot() }, () => { statusSending = false; });
|
|
504
|
+
} catch {
|
|
505
|
+
statusSending = false;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Pass the workers' request counts (library/cluster-counts.js) and what the launcher saw
|
|
510
|
+
// (library/cluster-events.js) on to the master, once it is ready: it is listening by then.
|
|
511
|
+
// One lot at a time; a lot that can't be delivered is kept for the next try (more arriving,
|
|
512
|
+
// or the master becoming ready)
|
|
513
|
+
function delivery(pending, type) {
|
|
514
|
+
let inFlight = null;
|
|
515
|
+
const deliver = () => {
|
|
516
|
+
const master = procs.find(p => p.role === 'master');
|
|
517
|
+
const child = master && master.child;
|
|
518
|
+
if (inFlight || pending.size === 0 || !child || !child.connected || !master.ready) {
|
|
519
|
+
return inFlight || Promise.resolve();
|
|
520
|
+
}
|
|
521
|
+
const rows = pending.take();
|
|
522
|
+
inFlight = new Promise(resolve => {
|
|
523
|
+
const done = error => {
|
|
524
|
+
inFlight = null;
|
|
525
|
+
if (error) {
|
|
526
|
+
pending.add(rows);
|
|
527
|
+
}
|
|
528
|
+
resolve();
|
|
529
|
+
if (!error && pending.size > 0) {
|
|
530
|
+
void deliver();
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
try {
|
|
534
|
+
child.send({ type, rows }, done);
|
|
535
|
+
} catch (error) {
|
|
536
|
+
done(error);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
return inFlight;
|
|
540
|
+
};
|
|
541
|
+
return deliver;
|
|
542
|
+
}
|
|
543
|
+
const deliverCounts = delivery(pendingCounts, COUNTS);
|
|
544
|
+
const deliverEvents = delivery(pendingEvents, CLUSTER_EVENTS);
|
|
545
|
+
// an event goes at once; what is counted goes every minute (eventsTimer)
|
|
546
|
+
function clusterEvent(kind, proc, options) {
|
|
547
|
+
pendingEvents.event(kind, proc ? proc.name : null, options);
|
|
548
|
+
void deliverEvents();
|
|
549
|
+
}
|
|
550
|
+
const countEvent = (kind, proc = null) => pendingEvents.count(kind, proc ? proc.name : null);
|
|
551
|
+
|
|
552
|
+
function startProcess(proc) {
|
|
553
|
+
return new Promise((resolve) => {
|
|
554
|
+
if (stopping) {
|
|
555
|
+
return resolve(false);
|
|
556
|
+
}
|
|
557
|
+
const [cmd, args] = command(proc);
|
|
558
|
+
proc.startedAt = Date.now();
|
|
559
|
+
proc.readyMemory = null;
|
|
560
|
+
proc.statusFigures = null;
|
|
561
|
+
metrics.state(proc.code, 'starting');
|
|
562
|
+
const child = spawn(cmd, args, { stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env: process.env });
|
|
563
|
+
proc.child = child;
|
|
564
|
+
child.on('message', message => {
|
|
565
|
+
if (proc.child === child && message && message.type === METRIC && metrics.record(proc.code, message)) {
|
|
566
|
+
if (proc.role === 'master') {
|
|
567
|
+
// the cluster's total, at the master's sample times
|
|
568
|
+
metrics.recordTotal(message.point.time, memoryTotal());
|
|
569
|
+
}
|
|
570
|
+
publishMetrics();
|
|
571
|
+
} else if (proc.child === child && message && message.type === STATUS && message.figures) {
|
|
572
|
+
proc.statusFigures = message.figures;
|
|
573
|
+
} else if (message && message.type === COUNTS && proc.role !== 'master') {
|
|
574
|
+
// real requests, even from a child that is going away
|
|
575
|
+
pendingCounts.add(message.rows);
|
|
576
|
+
void deliverCounts();
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
child.on('disconnect', () => {
|
|
580
|
+
if (proc.child === child) {
|
|
581
|
+
metrics.state(proc.code, 'unavailable');
|
|
582
|
+
publishMetrics();
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
publishMetrics();
|
|
586
|
+
child.on('exit', (code, signal) => {
|
|
587
|
+
proc.ready = false;
|
|
588
|
+
proc.exitedAt = Date.now();
|
|
589
|
+
proc.child = null;
|
|
590
|
+
proc.statusFigures = null;
|
|
591
|
+
metrics.state(proc.code, stopping ? 'unavailable' : 'restarting');
|
|
592
|
+
publishMetrics();
|
|
593
|
+
resolve(false);
|
|
594
|
+
if (stopping) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
proc.downSince = proc.downSince || proc.exitedAt;
|
|
598
|
+
if (!proc.recycling) {
|
|
599
|
+
// (a recycle is its own event)
|
|
600
|
+
clusterEvent(KINDS.exited, proc, { detail: signal || code });
|
|
601
|
+
}
|
|
602
|
+
if (proc.recycling) {
|
|
603
|
+
// the launcher stopped it (see checkMemory): at once, and not a failure
|
|
604
|
+
proc.recycling = false;
|
|
605
|
+
proc.readyAt = 0;
|
|
606
|
+
log(`${proc.name} exited (${signal || code}) to be recycled; restarting`);
|
|
607
|
+
void startProcess(proc);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
// counted from when it was ready: one that fails late in a long load is still failing
|
|
611
|
+
const ran = proc.readyAt ? Date.now() - proc.readyAt : 0;
|
|
612
|
+
proc.readyAt = 0;
|
|
613
|
+
proc.failures = ran > healthyRunMs ? 1 : proc.failures + 1;
|
|
614
|
+
if (proc.role === 'master' && !proc.everReady && maxStartFailures > 0 && proc.failures >= maxStartFailures) {
|
|
615
|
+
// nothing would be served: exit, so the service manager sees the failure
|
|
616
|
+
gaveUp = `the master exited ${proc.failures} time(s) (the last: ${signal || code}) and was never ready (server.cluster.maxStartFailures ${maxStartFailures})`;
|
|
617
|
+
log(`ERROR ${gaveUp}: giving up`);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const delay = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** (proc.failures - 1));
|
|
621
|
+
log(`${proc.name} exited (${signal || code}); restarting in ${delay / 1000} s`);
|
|
622
|
+
proc.restartTimer = setTimeout(() => startProcess(proc), delay);
|
|
623
|
+
});
|
|
624
|
+
// ready: the process says so from its listen callback (server.js), on its own channel,
|
|
625
|
+
// so an old process still shutting down on the same port can't answer for it
|
|
626
|
+
child.on('message', message => {
|
|
627
|
+
if (!message || message.type !== READY || proc.child !== child || proc.ready) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
proc.ready = true;
|
|
631
|
+
proc.everReady = true;
|
|
632
|
+
proc.readyAt = Date.now();
|
|
633
|
+
sendWaiting();
|
|
634
|
+
metrics.state(proc.code, 'ready');
|
|
635
|
+
publishMetrics();
|
|
636
|
+
if (proc.downSince) {
|
|
637
|
+
clusterEvent(KINDS.ready, proc, { ms: proc.readyAt - proc.downSince });
|
|
638
|
+
proc.downSince = 0;
|
|
639
|
+
}
|
|
640
|
+
if (proc.role === 'master') {
|
|
641
|
+
void deliverCounts();
|
|
642
|
+
void deliverEvents();
|
|
643
|
+
}
|
|
644
|
+
log(`${proc.name} (${proc.code}) ready on port ${proc.port}`);
|
|
645
|
+
resolve(true);
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// A process has maxOutstanding + fastLaneExtra slots. The fast lane may use them all, but
|
|
651
|
+
// while others wait it leaves free what they need to have normalMinSlots running; the
|
|
652
|
+
// others use up to maxOutstanding, or more to reach normalMinSlots
|
|
653
|
+
const full = (p, fast) => {
|
|
654
|
+
const slots = maxOutstanding + fastLaneExtra;
|
|
655
|
+
const short = Math.max(0, normalMinSlots - (p.outstanding - p.fastOutstanding));
|
|
656
|
+
if (maxOutstanding <= 0) {
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
if (fast) {
|
|
660
|
+
return p.outstanding >= slots - (waiting.length ? short : 0);
|
|
661
|
+
}
|
|
662
|
+
return p.outstanding >= maxOutstanding && !(short > 0 && p.outstanding < slots);
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
// true if the request was sent (or refused), false if it must wait. The master's own
|
|
666
|
+
// paths and CORS preflights don't wait
|
|
667
|
+
function send(w) {
|
|
668
|
+
if (lostToRestart(w)) {
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
const target = pickTarget(w.route, procs, p => !full(p, w.fast));
|
|
672
|
+
if (!target) {
|
|
673
|
+
countEvent(KINDS.notReady);
|
|
674
|
+
fail(w.res, 503, 'No server process is ready to take this request; try again shortly');
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
if (w.route.to !== 'master' && w.req.method !== 'OPTIONS' && full(target, w.fast)) {
|
|
678
|
+
return false;
|
|
679
|
+
}
|
|
680
|
+
// only the tx requests, which wait for a slot, take one
|
|
681
|
+
forward(w.req, w.res, target, w.fast, w.route.to !== 'master' && w.req.method !== 'OPTIONS');
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// the fast lane first, then oldest first; one waiting for its owner doesn't hold up the rest
|
|
686
|
+
function sendWaiting() {
|
|
687
|
+
for (const queue of [fastWaiting, waiting]) {
|
|
688
|
+
const fast = queue === fastWaiting;
|
|
689
|
+
for (let i = 0; i < queue.length && procs.some(p => p.ready && p.servesTx && !full(p, fast));) {
|
|
690
|
+
const w = queue[i];
|
|
691
|
+
if (send(w)) {
|
|
692
|
+
clearTimeout(w.timer);
|
|
693
|
+
queue.splice(i, 1);
|
|
694
|
+
} else {
|
|
695
|
+
i++;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Without the shared cache, a cache lives only in the process that issued it: while that
|
|
702
|
+
// process is restarting, the cache is gone, so say so at once rather than have another
|
|
703
|
+
// process answer that the id is someone else's (the restarted process says the same)
|
|
704
|
+
function lostToRestart(w) {
|
|
705
|
+
const owner = w.route.to === 'owner' && !(settings.routing && settings.routing.sharedCache) &&
|
|
706
|
+
procs.find(p => p.code === w.route.code && p.servesTx);
|
|
707
|
+
if (!owner || owner.ready || w.req.method === 'OPTIONS') {
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
countEvent(KINDS.lostToRestart, owner);
|
|
711
|
+
const id = String(w.req.headers['x-cache-id']);
|
|
712
|
+
const messageId = 'CACHE_ID_LOST_TO_RESTART';
|
|
713
|
+
const text = englishMessage(messageId, [id, formatDuration(Date.now() - (owner.exitedAt || Date.now()))]);
|
|
714
|
+
w.res.writeHead(404, { 'Content-Type': 'application/fhir+json', ...(stopping ? { Connection: 'close' } : {}) });
|
|
715
|
+
w.res.end(JSON.stringify({
|
|
716
|
+
resourceType: 'OperationOutcome',
|
|
717
|
+
issue: [{
|
|
718
|
+
severity: 'error', code: 'not-found',
|
|
719
|
+
details: { text, coding: [{ system: TX_ISSUE_TYPE, code: 'cache-id-unknown' }] },
|
|
720
|
+
diagnostics: `instance '${owner.code}', which issued it, is restarting`,
|
|
721
|
+
extension: [{ url: MESSAGE_ID_EXTENSION, valueString: messageId }]
|
|
722
|
+
}]
|
|
723
|
+
}));
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function refuse(w, ms) {
|
|
728
|
+
queueRefused++;
|
|
729
|
+
countEvent(KINDS.busy);
|
|
730
|
+
const now = Date.now();
|
|
731
|
+
if (now - lastQueueLog >= 60000) {
|
|
732
|
+
lastQueueLog = now;
|
|
733
|
+
log(`over capacity: requests waited ${ms / 1000} s for a process and were refused (${queueRefused} so far)`);
|
|
734
|
+
}
|
|
735
|
+
fail(w.res, 503, `The server is busy: no process was free for ${ms / 1000} s; try again shortly`, true);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// a client that took none of a response for writeTimeoutMs: its connection is closed
|
|
739
|
+
function writeTimedOut(req) {
|
|
740
|
+
writeTimeouts++;
|
|
741
|
+
countEvent(KINDS.writeTimeout);
|
|
742
|
+
const now = Date.now();
|
|
743
|
+
if (now - lastWriteTimeoutLog >= 60000) {
|
|
744
|
+
lastWriteTimeoutLog = now;
|
|
745
|
+
log(`a client took none of the response to ${req.method} ${req.url} for ${writeTimeoutMs / 1000} s (writeTimeoutSeconds): ` +
|
|
746
|
+
`its connection was closed (${writeTimeouts} so far)`);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function proxy(req, res) {
|
|
751
|
+
proxied++;
|
|
752
|
+
const w = { req, res, route: classify(req.url, req.headers, settings.txPaths, settings.routing) };
|
|
753
|
+
w.fast = fastLaneExtra > 0 && w.route.to !== 'master' && isFastLane(req, settings.txPaths, fastLaneMaxBytes);
|
|
754
|
+
if (send(w)) {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (stopping) {
|
|
758
|
+
countEvent(KINDS.stopping);
|
|
759
|
+
return fail(res, 503, 'The server is stopping; try again shortly');
|
|
760
|
+
}
|
|
761
|
+
const ms = w.route.to === 'owner' ? ownerQueueMs : queueMs;
|
|
762
|
+
if (ms <= 0) {
|
|
763
|
+
return refuse(w, 0);
|
|
764
|
+
}
|
|
765
|
+
const queue = w.fast ? fastWaiting : waiting;
|
|
766
|
+
queue.push(w);
|
|
767
|
+
const leave = () => {
|
|
768
|
+
const i = queue.indexOf(w);
|
|
769
|
+
if (i >= 0) {
|
|
770
|
+
queue.splice(i, 1);
|
|
771
|
+
clearTimeout(w.timer);
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
res.on('close', leave);
|
|
775
|
+
w.timer = setTimeout(() => {
|
|
776
|
+
leave();
|
|
777
|
+
refuse(w, ms);
|
|
778
|
+
}, ms);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function forward(req, res, target, fast = false, counted = true) {
|
|
782
|
+
inFlight++;
|
|
783
|
+
target.active++;
|
|
784
|
+
if (counted) {
|
|
785
|
+
target.outstanding++;
|
|
786
|
+
target.fastOutstanding += fast ? 1 : 0;
|
|
787
|
+
}
|
|
788
|
+
const sent = { at: Date.now() };
|
|
789
|
+
target.sentAt.add(sent);
|
|
790
|
+
let released = false;
|
|
791
|
+
let finished = false;
|
|
792
|
+
let timedOut = false;
|
|
793
|
+
let timer = null;
|
|
794
|
+
let stall = null;
|
|
795
|
+
// the process is done with it: its response has ended (or failed), so its slot is free,
|
|
796
|
+
// though the client may still be taking the response from the launcher
|
|
797
|
+
const release = () => {
|
|
798
|
+
clearTimeout(timer);
|
|
799
|
+
if (!released) {
|
|
800
|
+
released = true;
|
|
801
|
+
target.active--;
|
|
802
|
+
if (counted) {
|
|
803
|
+
target.outstanding--;
|
|
804
|
+
target.fastOutstanding -= fast ? 1 : 0;
|
|
805
|
+
}
|
|
806
|
+
target.sentAt.delete(sent);
|
|
807
|
+
sendWaiting();
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
// the launcher is done with it: the client has the whole response, or is gone
|
|
811
|
+
const finish = () => {
|
|
812
|
+
release();
|
|
813
|
+
clearTimeout(stall);
|
|
814
|
+
if (!finished) {
|
|
815
|
+
finished = true;
|
|
816
|
+
inFlight--;
|
|
817
|
+
if (onIdle && inFlight === 0) {
|
|
818
|
+
onIdle();
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
const headers = { ...req.headers };
|
|
823
|
+
for (const h of HOP_BY_HOP) {
|
|
824
|
+
delete headers[h];
|
|
825
|
+
}
|
|
826
|
+
// a chunked body goes on chunked. Without this, Node sends a GET, HEAD, DELETE or OPTIONS
|
|
827
|
+
// body raw (no Content-Length, no chunking), and the process reads it as a second request
|
|
828
|
+
// on the kept-alive connection, past the routing (Node's server accepts only chunked as
|
|
829
|
+
// the last coding, so the body is chunked whatever the header listed)
|
|
830
|
+
if (req.headers['transfer-encoding'] !== undefined) {
|
|
831
|
+
headers['transfer-encoding'] = 'chunked';
|
|
832
|
+
}
|
|
833
|
+
const client = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
|
|
834
|
+
headers['x-forwarded-for'] = headers['x-forwarded-for'] ? `${headers['x-forwarded-for']}, ${client}` : client;
|
|
835
|
+
if (!headers['x-forwarded-proto'] || !isLoopback(client)) {
|
|
836
|
+
headers['x-forwarded-proto'] = 'http';
|
|
837
|
+
}
|
|
838
|
+
const upstream = http.request({
|
|
839
|
+
host: target.host || '127.0.0.1', port: target.port, method: req.method, path: req.url, headers, agent: target.agent
|
|
840
|
+
}, (ures) => {
|
|
841
|
+
clearTimeout(timer);
|
|
842
|
+
target.timeouts = 0;
|
|
843
|
+
if (ures.statusCode === 503 && cooldownMs > 0) {
|
|
844
|
+
target.coolUntil = Date.now() + cooldownMs;
|
|
845
|
+
setTimeout(sendWaiting, cooldownMs).unref();
|
|
846
|
+
}
|
|
847
|
+
const out = { ...ures.headers };
|
|
848
|
+
for (const h of HOP_BY_HOP) {
|
|
849
|
+
delete out[h];
|
|
850
|
+
}
|
|
851
|
+
if (stopping) {
|
|
852
|
+
out.connection = 'close';
|
|
853
|
+
}
|
|
854
|
+
res.writeHead(ures.statusCode, ures.statusMessage, out);
|
|
855
|
+
// A tx request's response is read to its end as fast as the process sends it, so its
|
|
856
|
+
// slot is free once the process is done, however slowly the client reads (the process
|
|
857
|
+
// held the whole response in memory already). Any other is passed on as the client takes
|
|
858
|
+
// it (a package download can be large). Either way, a client that takes none of what is
|
|
859
|
+
// waiting for it for writeTimeoutMs is cut off
|
|
860
|
+
const stalled = () => {
|
|
861
|
+
if (writeTimeoutMs > 0 && !stall) {
|
|
862
|
+
stall = setTimeout(() => {
|
|
863
|
+
writeTimedOut(req);
|
|
864
|
+
upstream.destroy();
|
|
865
|
+
res.destroy();
|
|
866
|
+
}, writeTimeoutMs);
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
res.on('drain', () => {
|
|
870
|
+
clearTimeout(stall);
|
|
871
|
+
stall = null;
|
|
872
|
+
ures.resume();
|
|
873
|
+
});
|
|
874
|
+
ures.on('data', (chunk) => {
|
|
875
|
+
if (!res.write(chunk)) {
|
|
876
|
+
stalled();
|
|
877
|
+
if (!counted) {
|
|
878
|
+
ures.pause();
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
ures.on('end', () => {
|
|
883
|
+
release();
|
|
884
|
+
res.end();
|
|
885
|
+
});
|
|
886
|
+
ures.on('error', () => res.destroy());
|
|
887
|
+
});
|
|
888
|
+
upstream.on('error', (err) => {
|
|
889
|
+
if (timedOut) {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (!res.headersSent) {
|
|
893
|
+
countEvent(KINDS.failed, target);
|
|
894
|
+
fail(res, 502, `The ${target.name} process did not answer (${err.code || err.message})`);
|
|
895
|
+
} else {
|
|
896
|
+
res.destroy();
|
|
897
|
+
}
|
|
898
|
+
finish();
|
|
899
|
+
});
|
|
900
|
+
res.on('close', () => {
|
|
901
|
+
if (!res.writableFinished && !released) {
|
|
902
|
+
upstream.destroy();
|
|
903
|
+
}
|
|
904
|
+
finish();
|
|
905
|
+
});
|
|
906
|
+
// until the response begins; a response already streaming is not timed
|
|
907
|
+
if (upstreamTimeoutMs > 0) {
|
|
908
|
+
timer = setTimeout(() => {
|
|
909
|
+
timedOut = true;
|
|
910
|
+
target.timeouts++;
|
|
911
|
+
const suspect = target.timeouts >= SUSPECT_TIMEOUTS && cooldownMs > 0;
|
|
912
|
+
log(`WARNING ${target.name} did not begin its response to ${req.method} ${req.url} in ${upstreamTimeoutMs / 1000} s: answered 504 and aborted ` +
|
|
913
|
+
`(${target.timeouts} in a row${suspect ? `; no new requests for ${cooldownMs / 1000} s while another can take them` : ''})`);
|
|
914
|
+
if (suspect) {
|
|
915
|
+
target.coolUntil = Date.now() + cooldownMs;
|
|
916
|
+
setTimeout(sendWaiting, cooldownMs).unref();
|
|
917
|
+
}
|
|
918
|
+
upstream.destroy();
|
|
919
|
+
countEvent(KINDS.timeout, target);
|
|
920
|
+
fail(res, 504, `The ${target.name} process did not begin its response in ${upstreamTimeoutMs / 1000} s (server.cluster.upstreamTimeoutSeconds); the request was abandoned`, false, 'timeout');
|
|
921
|
+
finish();
|
|
922
|
+
}, upstreamTimeoutMs);
|
|
923
|
+
}
|
|
924
|
+
req.pipe(upstream);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Every RECYCLE_CHECK_MS: restart a tx-worker over the refusal threshold for recycleMs with no
|
|
929
|
+
* request at work, the largest first, one at a time. Only one that has grown since it was
|
|
930
|
+
* ready by more than the excess: a restart takes it back to its ready size, so restarting
|
|
931
|
+
* one that was already too big would not reopen the gate. Never the master.
|
|
932
|
+
*/
|
|
933
|
+
function checkMemory() {
|
|
934
|
+
if (stopping || procs.some(p => p.recycling)) {
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const now = Date.now();
|
|
938
|
+
const workers = procs.filter(p => p.role === 'tx-worker');
|
|
939
|
+
const { excess, own } = memory.sample(workers);
|
|
940
|
+
const over = new Map();
|
|
941
|
+
let largest = null;
|
|
942
|
+
for (const p of workers) {
|
|
943
|
+
const mine = own.get(p) || 0;
|
|
944
|
+
if (p.ready && p.readyMemory === null) {
|
|
945
|
+
p.readyMemory = mine;
|
|
946
|
+
if (excess === null && mine > memory.rejectAt) {
|
|
947
|
+
log(`WARNING memory: ${p.name} uses ${GB(mine)} GB loaded and idle, ${memory.describe}: its memory guard refuses its requests, ` +
|
|
948
|
+
'and restarting it would not help, so it is not recycled; use a larger limit');
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const atWork = [...p.sentAt].some(s => now - s.at >= AT_WORK_MS);
|
|
952
|
+
const pExcess = excess === null ? mine - memory.rejectAt : excess;
|
|
953
|
+
over.set(p, mine > 0 && pExcess > 0 && mine - p.readyMemory > pExcess ? mine : 0);
|
|
954
|
+
if (!p.ready || !over.get(p) || atWork) {
|
|
955
|
+
p.overSince = 0;
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
p.overSince = p.overSince || now;
|
|
959
|
+
// and none in flight at all now (a refusal is answered within milliseconds)
|
|
960
|
+
if (now - p.overSince >= recycleMs && p.active === 0 && (!largest || over.get(p) > over.get(largest))) {
|
|
961
|
+
largest = p;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (largest) {
|
|
965
|
+
recycle(largest, `its memory (${(over.get(largest) / 2 ** 30).toFixed(1)} GB) has been ${memory.describe}` +
|
|
966
|
+
(excess !== null ? ` (by ${(excess / 2 ** 30).toFixed(1)} GB)` : '') + ` for ${Math.round((now - largest.overSince) / 1000)} s with no request at work`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// no new requests; those it has finish; it stops as on shutdown, and exit starts it again
|
|
971
|
+
function recycle(p, why) {
|
|
972
|
+
log(`recycling ${p.name}: ${why}`);
|
|
973
|
+
clusterEvent(KINDS.recycled, p, { detail: why });
|
|
974
|
+
p.downSince = Date.now();
|
|
975
|
+
p.recycling = true;
|
|
976
|
+
p.ready = false;
|
|
977
|
+
p.exitedAt = 0;
|
|
978
|
+
p.overSince = 0;
|
|
979
|
+
metrics.state(p.code, 'restarting');
|
|
980
|
+
publishMetrics();
|
|
981
|
+
const child = p.child;
|
|
982
|
+
const t0 = Date.now();
|
|
983
|
+
const stopWhenIdle = () => {
|
|
984
|
+
if (p.child !== child) {
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
if (p.active > 0 && Date.now() - t0 < settings.drainMs) {
|
|
988
|
+
return void setTimeout(stopWhenIdle, 100);
|
|
989
|
+
}
|
|
990
|
+
const kill = setTimeout(() => child.kill('SIGKILL'), 30000);
|
|
991
|
+
child.once('exit', () => clearTimeout(kill));
|
|
992
|
+
child.kill('SIGTERM');
|
|
993
|
+
};
|
|
994
|
+
stopWhenIdle();
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// close: the request body has not been read, and an overloaded server sheds the connection too.
|
|
998
|
+
// The launcher's own refusals (503: busy, stopping, no process ready; 504: no response in
|
|
999
|
+
// time) are tx-issue-type too-costly, as CLOSURE_TABLE_BUSY is: the code system has none for
|
|
1000
|
+
// a server that can't take the request now. A 502 (the process failed) has none that fits
|
|
1001
|
+
function fail(res, status, text, close = false, code = 'transient') {
|
|
1002
|
+
res.writeHead(status, { 'Content-Type': 'application/fhir+json', 'Retry-After': '5', ...(stopping || close ? { Connection: 'close' } : {}) });
|
|
1003
|
+
const details = status === 503 || status === 504 ? { text, coding: [{ system: TX_ISSUE_TYPE, code: 'too-costly' }] } : { text };
|
|
1004
|
+
res.end(JSON.stringify({
|
|
1005
|
+
resourceType: 'OperationOutcome',
|
|
1006
|
+
issue: [{ severity: 'error', code, details, diagnostics: text }]
|
|
1007
|
+
}));
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
async function start() {
|
|
1011
|
+
const master = procs.find(p => p.role === 'master');
|
|
1012
|
+
// the master first, and on its own: on a new data directory it builds the package
|
|
1013
|
+
// caches and creates the VSAC database, which processes starting together would corrupt.
|
|
1014
|
+
// (If it exits before it is ready, it is started again, as any process is.)
|
|
1015
|
+
clusterEvent(KINDS.launcherStarted, null);
|
|
1016
|
+
eventsTimer = setInterval(deliverEvents, settings.eventsIntervalMs || COUNTS_INTERVAL_MS);
|
|
1017
|
+
eventsTimer.unref();
|
|
1018
|
+
log(`starting the master (${master.code}) on port ${master.port}` + (maxStartFailures > 0
|
|
1019
|
+
? `; if it exits ${maxStartFailures} times without being ready, the launcher exits (maxStartFailures)` : ''));
|
|
1020
|
+
void startProcess(master);
|
|
1021
|
+
await new Promise((resolve, reject) => {
|
|
1022
|
+
const t = setInterval(() => {
|
|
1023
|
+
if (master.ready || stopping) {
|
|
1024
|
+
clearInterval(t);
|
|
1025
|
+
resolve();
|
|
1026
|
+
} else if (gaveUp) {
|
|
1027
|
+
clearInterval(t);
|
|
1028
|
+
reject(new Error(gaveUp));
|
|
1029
|
+
}
|
|
1030
|
+
}, WAIT_POLL_MS);
|
|
1031
|
+
});
|
|
1032
|
+
if (stopping) {
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
server = http.createServer(proxy);
|
|
1036
|
+
server.keepAliveTimeout = 65000;
|
|
1037
|
+
await new Promise((resolve, reject) => {
|
|
1038
|
+
server.once('error', reject);
|
|
1039
|
+
server.listen(settings.port, resolve);
|
|
1040
|
+
});
|
|
1041
|
+
sizeWorkers(master);
|
|
1042
|
+
log(`listening on port ${settings.port}; starting ${procs.length - 1} worker(s)`);
|
|
1043
|
+
log(`limits: maxOutstanding ${maxOutstanding}, queueSeconds ${queueMs / 1000}, ownerQueueSeconds ${ownerQueueMs / 1000}, cooldownSeconds ${cooldownMs / 1000}, upstreamTimeoutSeconds ${upstreamTimeoutMs / 1000}, writeTimeoutSeconds ${writeTimeoutMs / 1000}, ` +
|
|
1044
|
+
`fastLaneExtra ${fastLaneExtra}, fastLaneMaxBytes ${fastLaneMaxBytes}, normalMinSlots ${normalMinSlots}, memoryHeadroomGB ${headroom / 2 ** 30}, drainSeconds ${settings.drainMs / 1000}, ` +
|
|
1045
|
+
`trimAllocator ${trimAllocator}, statusSeconds ${statusMs / 1000}, recycleSeconds ${recycleMs / 1000}` + (recycleMs > 0 ? ` (a tx-worker ${memory.describe} that long, with no request at work, is restarted)` : ' (never)'));
|
|
1046
|
+
if (recycleMs > 0) {
|
|
1047
|
+
memoryTimer = setInterval(checkMemory, settings.recycleCheckMs || RECYCLE_CHECK_MS);
|
|
1048
|
+
memoryTimer.unref();
|
|
1049
|
+
}
|
|
1050
|
+
statusTimer = setInterval(sendStatus, statusMs);
|
|
1051
|
+
statusTimer.unref();
|
|
1052
|
+
// one after another, so their loads (and memory peaks) don't coincide; in the background
|
|
1053
|
+
void (async () => {
|
|
1054
|
+
for (const p of procs) {
|
|
1055
|
+
if (p !== master && !stopping) {
|
|
1056
|
+
await startProcess(p);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (!stopping) {
|
|
1060
|
+
checkIdleMemory();
|
|
1061
|
+
}
|
|
1062
|
+
})();
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Once the master is loaded, take its memory as each worker's. Workers not given: start only
|
|
1068
|
+
* as many as fit under the refusal threshold with `headroom` to spare (at least one); given:
|
|
1069
|
+
* warn if they don't fit.
|
|
1070
|
+
*/
|
|
1071
|
+
function sizeWorkers(master) {
|
|
1072
|
+
const workers = procs.filter(p => p.role === 'tx-worker');
|
|
1073
|
+
const decided = (fitText) => {
|
|
1074
|
+
if (settings.workerDecision) {
|
|
1075
|
+
log(describeDecision(settings.workerDecision, procs.length - 1, fitText, !settings.workersDefault));
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
const perProcess = memory.sample([master]).own.get(master) || 0;
|
|
1079
|
+
if (!perProcess) {
|
|
1080
|
+
decided('memory not measured');
|
|
1081
|
+
return; // not measurable here (not Linux)
|
|
1082
|
+
}
|
|
1083
|
+
const fits = Math.floor((memory.rejectAt - headroom - perProcess) / perProcess);
|
|
1084
|
+
const fit = Math.max(1, fits);
|
|
1085
|
+
const idle = (1 + workers.length) * perProcess;
|
|
1086
|
+
const line = `the master uses ${GB(perProcess)} GB loaded, so ${workers.length + 1} processes would use about ${GB(idle)} GB idle; ` +
|
|
1087
|
+
`the memory guard refuses requests ${memory.describe}, and memoryHeadroomGB is ${GB(headroom)}`;
|
|
1088
|
+
if (idle + headroom <= memory.rejectAt) {
|
|
1089
|
+
decided(`memory fits ${fits}`);
|
|
1090
|
+
log(`memory: ${line}`);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (settings.workersDefault) {
|
|
1094
|
+
for (const p of workers.slice(fit)) {
|
|
1095
|
+
procs.splice(procs.indexOf(p), 1);
|
|
1096
|
+
metrics.series.delete(p.code);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const starting = procs.length - 1;
|
|
1100
|
+
decided(`memory fits ${Math.max(0, fits)}`);
|
|
1101
|
+
if (fits < 1) {
|
|
1102
|
+
log(`WARNING memory: ${line}: even 1 worker leaves too little room; starting ${starting}: raise the limit, or run a single server`);
|
|
1103
|
+
} else if (starting < workers.length) {
|
|
1104
|
+
log(`memory: ${line}: starting ${fit} worker(s), not ${workers.length} (set server.cluster.workers to choose)`);
|
|
1105
|
+
} else {
|
|
1106
|
+
log(`WARNING memory: ${line}: ${workers.length} worker(s) leave too little room; at most ${fit} would, or raise the limit`);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// once every worker is ready: what they all use idle, against the threshold
|
|
1111
|
+
function checkIdleMemory() {
|
|
1112
|
+
const { excess, own } = memory.sample(procs);
|
|
1113
|
+
const total = excess !== null ? excess + memory.rejectAt : [...own.values()].reduce((a, b) => a + b, 0);
|
|
1114
|
+
if (!total) {
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
const room = memory.rejectAt - total;
|
|
1118
|
+
log(`${room < headroom ? 'WARNING ' : ''}memory: all processes loaded and idle use ${GB(total)} GB, ` +
|
|
1119
|
+
`${GB(room)} GB under the threshold (memoryHeadroomGB ${GB(headroom)})` +
|
|
1120
|
+
(room < headroom ? '; a heavy request will shut the gate for everyone: use fewer workers or a larger limit' : ''));
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Stop taking connections, wait for the requests in progress (up to drainMs), then stop
|
|
1125
|
+
* the workers, then the master (SIGTERM, then SIGKILL after 30 s), so the workers' last
|
|
1126
|
+
* request counts reach the master's statistics.
|
|
1127
|
+
*/
|
|
1128
|
+
async function stop() {
|
|
1129
|
+
if (stopping) {
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
stopping = true;
|
|
1133
|
+
clearInterval(memoryTimer);
|
|
1134
|
+
clearInterval(statusTimer);
|
|
1135
|
+
clearInterval(eventsTimer);
|
|
1136
|
+
for (const p of procs) {
|
|
1137
|
+
clearTimeout(p.restartTimer);
|
|
1138
|
+
}
|
|
1139
|
+
// those still waiting haven't started: refuse them; drain only those in progress
|
|
1140
|
+
for (const w of [...fastWaiting.splice(0), ...waiting.splice(0)]) {
|
|
1141
|
+
clearTimeout(w.timer);
|
|
1142
|
+
countEvent(KINDS.stopping);
|
|
1143
|
+
fail(w.res, 503, 'The server is stopping; try again shortly');
|
|
1144
|
+
}
|
|
1145
|
+
if (server) {
|
|
1146
|
+
server.close();
|
|
1147
|
+
server.closeIdleConnections();
|
|
1148
|
+
const idle = setInterval(() => server.closeIdleConnections(), 100);
|
|
1149
|
+
if (inFlight > 0) {
|
|
1150
|
+
log(`draining: ${inFlight} request(s) in progress`);
|
|
1151
|
+
await new Promise(resolve => {
|
|
1152
|
+
const timer = setTimeout(resolve, settings.drainMs);
|
|
1153
|
+
onIdle = () => {
|
|
1154
|
+
clearTimeout(timer);
|
|
1155
|
+
resolve();
|
|
1156
|
+
};
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
clearInterval(idle);
|
|
1160
|
+
server.closeAllConnections();
|
|
1161
|
+
}
|
|
1162
|
+
// the workers first: each sends its last request counts as it stops, and they reach
|
|
1163
|
+
// the master's statistics before the master stops
|
|
1164
|
+
const stopChild = (p) => new Promise(resolve => {
|
|
1165
|
+
const child = p.child;
|
|
1166
|
+
if (!child) {
|
|
1167
|
+
return resolve();
|
|
1168
|
+
}
|
|
1169
|
+
const kill = setTimeout(() => child.kill('SIGKILL'), 30000);
|
|
1170
|
+
// exited, and everything it sent on the channel has been read
|
|
1171
|
+
let waiting = child.connected ? 2 : 1;
|
|
1172
|
+
const one = () => {
|
|
1173
|
+
if (--waiting === 0) {
|
|
1174
|
+
clearTimeout(kill);
|
|
1175
|
+
resolve();
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
child.once('exit', one);
|
|
1179
|
+
if (child.connected) {
|
|
1180
|
+
child.once('disconnect', one);
|
|
1181
|
+
}
|
|
1182
|
+
child.kill('SIGTERM');
|
|
1183
|
+
});
|
|
1184
|
+
await Promise.all(procs.filter(p => p.role !== 'master').map(stopChild));
|
|
1185
|
+
const deliverWait = new Promise(resolve => setTimeout(resolve, 5000).unref());
|
|
1186
|
+
await Promise.race([Promise.all([deliverCounts(), deliverEvents()]), deliverWait]);
|
|
1187
|
+
await Promise.all(procs.filter(p => p.role === 'master').map(stopChild));
|
|
1188
|
+
for (const p of procs) {
|
|
1189
|
+
p.agent.destroy();
|
|
1190
|
+
}
|
|
1191
|
+
log('stopped');
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
return { start, stop, procs, metrics, statusSnapshot, get server() { return server; } };
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
async function main() {
|
|
1198
|
+
const folders = require('./library/folder-setup');
|
|
1199
|
+
let launcher;
|
|
1200
|
+
try {
|
|
1201
|
+
const config = require('./library/config-reader').readConfig(folders.filePath('config.json'));
|
|
1202
|
+
launcher = createLauncher(loadSettings(process.argv.slice(2), process.env, config));
|
|
1203
|
+
} catch (error) {
|
|
1204
|
+
console.error(`cluster.js: ${error.message}`);
|
|
1205
|
+
process.exit(1);
|
|
1206
|
+
}
|
|
1207
|
+
const shutdown = () => launcher.stop().then(() => process.exit(0));
|
|
1208
|
+
process.on('SIGTERM', shutdown);
|
|
1209
|
+
process.on('SIGINT', shutdown);
|
|
1210
|
+
await launcher.start();
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (require.main === module) {
|
|
1214
|
+
main().catch((error) => {
|
|
1215
|
+
console.error(error);
|
|
1216
|
+
process.exit(1);
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
module.exports = { loadSettings, classify, isFastLane, pickTarget, createLauncher, memorySampler, childExecArgv };
|