@harperfast/harper 5.3.0-beta.1 → 5.3.0-beta.2
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/components/DESIGN.md +421 -0
- package/components/mcp/DESIGN.md +109 -0
- package/components/mcp/audit.ts +21 -17
- package/config/DESIGN.md +306 -0
- package/dataLayer/DESIGN.md +179 -0
- package/dataLayer/restoreMarker.ts +92 -25
- package/dist/components/mcp/audit.d.ts +2 -1
- package/dist/components/mcp/audit.js +21 -17
- package/dist/components/mcp/audit.js.map +1 -1
- package/dist/dataLayer/restoreMarker.d.ts +21 -8
- package/dist/dataLayer/restoreMarker.js +94 -27
- package/dist/dataLayer/restoreMarker.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/resources/RecordEncoder.js +25 -5
- package/dist/resources/RecordEncoder.js.map +1 -1
- package/dist/resources/Table.js +48 -2
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.d.ts +3 -0
- package/dist/resources/analytics/write.js +39 -13
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/crdt.d.ts +10 -0
- package/dist/resources/crdt.js +22 -0
- package/dist/resources/crdt.js.map +1 -1
- package/dist/resources/databases.js +1 -1
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +2 -2
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +43 -24
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
- package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
- package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
- package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
- package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
- package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
- package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
- package/dist/resources/recordLockCoordinator.d.ts +5 -5
- package/dist/resources/recordLockCoordinator.js +48 -16
- package/dist/resources/recordLockCoordinator.js.map +1 -1
- package/dist/resources/transactionBroadcast.js +4 -6
- package/dist/resources/transactionBroadcast.js.map +1 -1
- package/dist/security/auth.js +59 -23
- package/dist/security/auth.js.map +1 -1
- package/dist/security/deferredAuthentication.d.ts +11 -0
- package/dist/security/deferredAuthentication.js +25 -3
- package/dist/security/deferredAuthentication.js.map +1 -1
- package/dist/security/jsLoader.js +3 -0
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/server/REST.js +6 -3
- package/dist/server/REST.js.map +1 -1
- package/dist/server/mqtt.js +5 -1
- package/dist/server/mqtt.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
- package/dist/server/serverHelpers/serverUtilities.js +14 -3
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +4 -1
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
- package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
- package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
- package/dist/utility/logging/harper_logger.js +1 -0
- package/dist/utility/logging/harper_logger.js.map +1 -1
- package/index.ts +3 -0
- package/npm-shrinkwrap.json +104 -104
- package/package.json +6 -5
- package/resources/DESIGN.md +568 -3
- package/resources/RecordEncoder.ts +27 -5
- package/resources/Table.ts +49 -3
- package/resources/analytics/DESIGN.md +38 -0
- package/resources/analytics/write.ts +40 -14
- package/resources/crdt.ts +22 -0
- package/resources/databases.ts +1 -1
- package/resources/indexes/DESIGN.md +833 -0
- package/resources/indexes/HierarchicalNavigableSmallWorld.ts +44 -25
- package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
- package/resources/indexes/fullTextNativeBinding.ts +146 -0
- package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
- package/resources/record-locks.md +1407 -0
- package/resources/recordLockCoordinator.ts +61 -22
- package/resources/scheduler/DESIGN.md +40 -0
- package/resources/transactionBroadcast.ts +4 -4
- package/security/DESIGN.md +175 -0
- package/security/auth.ts +53 -24
- package/security/deferredAuthentication.ts +24 -2
- package/security/jsLoader.ts +3 -0
- package/server/DESIGN.md +264 -0
- package/server/REST.ts +6 -3
- package/server/mqtt.ts +6 -4
- package/server/serverHelpers/serverUtilities.ts +14 -3
- package/server/serverHelpers/uwsServer.ts +4 -1
- package/server/serverHelpers/webSocketCloseReason.ts +25 -0
- package/studio/web/assets/{Chat-D3j-1yY1.js → Chat-DADFFGe_.js} +1 -1
- package/studio/web/assets/{FloatingChat-BxJGYcfB.js → FloatingChat-D_mI-rZ7.js} +3 -3
- package/studio/web/assets/{apiToken-CT55oWOe.js → apiToken-c2NiSDHa.js} +1 -1
- package/studio/web/assets/{applications-D9Ct9_vm.js → applications-DktUqh7G.js} +1 -1
- package/studio/web/assets/{cssMode-DV8H7VwA.js → cssMode-Cs_75Xhw.js} +1 -1
- package/studio/web/assets/{editor-uatc0unt.js → editor-19b-Y1IN.js} +1 -1
- package/studio/web/assets/{html-Bm6D6paN.js → html-DiYEQMpB.js} +1 -1
- package/studio/web/assets/{htmlMode-CEn7tpLG.js → htmlMode-CmR0y7P_.js} +1 -1
- package/studio/web/assets/{index-BIXW6Pu4.js → index-Dm0rfkJ7.js} +5 -5
- package/studio/web/assets/{index.lazy-UI7L-Vrk.js → index.lazy-7vqt2CC3.js} +1 -1
- package/studio/web/assets/{javascript-CJ0G3AFZ.js → javascript-BWtCFuOt.js} +1 -1
- package/studio/web/assets/{jsonMode-DQADAYEa.js → jsonMode-Buzzbv9y.js} +1 -1
- package/studio/web/assets/{languageServices-CAQJXWcI.js → languageServices-SqsFWfTM.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-CCQ8P5sY.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
- package/studio/web/assets/{notifications-Cvb3P1lB.js → notifications-CAB-LZWT.js} +1 -1
- package/studio/web/assets/{notifications-BbxTU6Aw.js → notifications-DRzmSRxM.js} +1 -1
- package/studio/web/assets/{profile-Yyb7gsvL.js → profile-BNKAl79n.js} +1 -1
- package/studio/web/assets/{regions-OgjGHlU5.js → regions-CUow_Zw2.js} +1 -1
- package/studio/web/assets/{register-6qwNEOY3.js → register-Dkt3WUMp.js} +2 -2
- package/studio/web/assets/{setComponentFile-BilDMtgB.js → setComponentFile-BZRfMD0N.js} +1 -1
- package/studio/web/assets/{setup-J6qJ7OIU.js → setup-D_yiEPO2.js} +2 -2
- package/studio/web/assets/{status-0RWGcfyD.js → status-DhHh1Ge-.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-BIn-vErT.js → toggleHighContrast-D7L1PDtV.js} +1 -1
- package/studio/web/assets/{tsMode-DgUXku4d.js → tsMode-CCwLk1YS.js} +1 -1
- package/studio/web/assets/{typescript-C9orXcsM.js → typescript-BP1j1mjn.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-BEoXXbUB.js → useEntityRestURL-D7bnYxLw.js} +1 -1
- package/studio/web/assets/{workers-JVzSDmgx.js → workers-tOuCNT17.js} +1 -1
- package/studio/web/assets/{xml-Cq-S8S4X.js → xml-BSG_3mQT.js} +1 -1
- package/studio/web/assets/{yaml-sfoRdh1M.js → yaml-DxiLprBB.js} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/DESIGN.md +55 -0
- package/utility/logging/harper_logger.ts +1 -0
package/config/DESIGN.md
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# config/ — Design notes
|
|
2
|
+
|
|
3
|
+
Config composition, persistence, env layers and hot reload.
|
|
4
|
+
|
|
5
|
+
**Read this when:** touching `configUtils.ts`, `readConfigFileSync.ts`, `harperConfigEnvVars.ts` or a root config watcher.
|
|
6
|
+
|
|
7
|
+
Index of every design note: [DESIGN.md](../DESIGN.md).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## `set_configuration` replication is opt-in; `replicateOperation` is default-on (`config/configUtils.ts`)
|
|
12
|
+
|
|
13
|
+
`server.replication.replicateOperation` (installed by harper-pro's replicator) fans out whenever
|
|
14
|
+
`req.replicated \!== false` — absence of the flag means "replicate". That default-on contract is what
|
|
15
|
+
DDL ops rely on (`dropSchema`/`dropTable` call it unconditionally), so a handler that mirrors the
|
|
16
|
+
drop_schema pattern without a guard silently becomes replicate-by-default. `setConfiguration` must
|
|
17
|
+
stay **opt-in** (`if (replicated)` truthy guard) because config bodies routinely carry node-local
|
|
18
|
+
params (ports, paths, node identity) that would clobber peers. Two invariants to preserve:
|
|
19
|
+
`replicated` must remain in the handler's destructure strip-list on both origin and peers (peers
|
|
20
|
+
receive `replicated: false` in the forwarded body; anything not stripped is treated as a config
|
|
21
|
+
param), and there is deliberately **no** per-param node-local/cluster-wide guard here — per-field
|
|
22
|
+
replicability metadata is deferred to the cluster-level-config work (CORE-3018), which will own that
|
|
23
|
+
schema. Per-peer failures never reject: they come back as `{status: 'failed', reason, node}` entries
|
|
24
|
+
in `response.replicated[]`, and `message` still reads as success (same contract as drop_schema), so
|
|
25
|
+
operators must inspect the array for per-node outcomes.
|
|
26
|
+
|
|
27
|
+
## Root config watchers must read synchronously (`config/readConfigFileSync.ts`)
|
|
28
|
+
|
|
29
|
+
`atomicWriteFile()` swaps the config file in with `renameSync` and, on Windows, retries the
|
|
30
|
+
`EPERM`/`EACCES`/`EBUSY` a still-open destination handle produces — blocking the calling thread in
|
|
31
|
+
`Atomics.wait`. The handle that blocks it belongs to the _process_, not to the thread that opened
|
|
32
|
+
it: measured on `windows-latest`/Node 24 (harper#2313), a single Node **read** descriptor on the
|
|
33
|
+
destination fails the rename, while `fs.watch` and chokidar's own handles do not.
|
|
34
|
+
`set_configuration` reaches that loop from a live request thread, and every worker runs root config
|
|
35
|
+
watchers over the same file, so an **async** read in a watcher is unsatisfiable by construction:
|
|
36
|
+
libuv opens the descriptor on the threadpool but closes it from JS, which cannot run while the same
|
|
37
|
+
thread is blocked in the retry loop. The worker then deadlocks against its own
|
|
38
|
+
watcher and burns the entire budget before failing (harper#2191, reproduced by the Windows
|
|
39
|
+
integration job). Both root watchers — `RootConfigWatcher.handleChange` and an `OptionsWatcher`
|
|
40
|
+
explicitly identified as a root-config watcher — therefore go through `readConfigFileSync()`, which
|
|
41
|
+
holds no descriptor across a yield. A component's own config remains asynchronous even if the
|
|
42
|
+
component names it `harper-config.yaml` or `harperdb-config.yaml`. Do not "modernize" root-config
|
|
43
|
+
reads back to `fsPromises.readFile`.
|
|
44
|
+
|
|
45
|
+
Three constraints follow from it. The reader gates its retry to win32 (`isSharingViolation`); the
|
|
46
|
+
writer does not (`configUtils`' `isRetryableRenameError`, same three codes, any platform). That
|
|
47
|
+
asymmetry is deliberate: a misclassified read falls through to the timer ladder below and still
|
|
48
|
+
recovers, a rename has nothing to fall through to, and `process.platform` does not answer the
|
|
49
|
+
question that matters — whether this filesystem can replace an open file. A Linux worker whose
|
|
50
|
+
rootPath sits on WSL drvfs, a CIFS/SMB mount, or a Docker Desktop bind mount reports `linux` and
|
|
51
|
+
still returns these codes transiently.
|
|
52
|
+
|
|
53
|
+
The reader's 500ms budget is one deadline **per path shared by all callers on the thread**, not per
|
|
54
|
+
call — a worker holds one `OptionsWatcher` per root-declared plugin (10+ on a stock install,
|
|
55
|
+
`TRUSTED_RESOURCE_PLUGINS`) over the same file, all reacting to a single change event, so a per-call
|
|
56
|
+
budget would serialize into N x 500ms of blocked event loop whenever a writer's lock outlives it.
|
|
57
|
+
|
|
58
|
+
Both watchers parse through `parseConfigFile()` (`config/parseConfigFile.ts`) rather than calling
|
|
59
|
+
`yaml.parse` directly: yaml's `prettyErrors` frames the offending source lines into the error's
|
|
60
|
+
`message`, and the root config holds credentials, so a parse failure would otherwise ship that
|
|
61
|
+
frame to the component log (`OptionsWatcher` → `Scope`) or the config log.
|
|
62
|
+
|
|
63
|
+
And a lock that outlives even that emits no new watcher event when it clears, so both watchers hand
|
|
64
|
+
the failure to `ConfigReadRetry` (`config/configReadRetry.ts`) rather than going stale: retrying from
|
|
65
|
+
a timer holds no descriptor either, so it cannot re-enter the deadlock. A ladder rung passes
|
|
66
|
+
`waitForLock: false` — the ladder already owns the retry, and letting each rung re-enter the
|
|
67
|
+
blocking budget would multiply one lock incident into a stall per rung. The ladder is bounded by
|
|
68
|
+
wall clock and its backoff is derived from elapsed time rather than from how many times it was
|
|
69
|
+
armed, because watcher callbacks and timer callbacks share one entry point: a rename burst delivers
|
|
70
|
+
several chokidar events in milliseconds and would otherwise both spend the ladder and push the next
|
|
71
|
+
rung out to the maximum before the writer has let go.
|
|
72
|
+
|
|
73
|
+
A deletion supersedes the reads already in flight, so `OptionsWatcher.#handleUnlink` claims the
|
|
74
|
+
current read sequence rather than only cancelling the ladder: an asynchronous rung completing after
|
|
75
|
+
it would otherwise put the removed file's options back, or find ENOENT and report the same deletion
|
|
76
|
+
a second time as a `remove` asking `Scope` to restart a scope that deletion just settled. That
|
|
77
|
+
ordering cannot be staged from a real deletion — every technique that holds a `readFile` open past
|
|
78
|
+
chokidar's `unlink` (threadpool saturation, a FIFO) holds the `unlink` behind it too, because
|
|
79
|
+
chokidar's own event delivery needs the same threadpool; measured here, a saturated pool produced no
|
|
80
|
+
`unlink` for at least 3 seconds. The regression therefore delivers the deletion through
|
|
81
|
+
`_simulateUnlinkForTests`.
|
|
82
|
+
|
|
83
|
+
### An empty read is a writer mid-write, not an empty config
|
|
84
|
+
|
|
85
|
+
A non-atomic writer — an operator's editor, a shell redirect, anything that is not
|
|
86
|
+
`atomicWriteFile()`'s temp-file-and-rename — truncates the config before it writes it, and the
|
|
87
|
+
synchronous read is fast enough to land in that window where the async read never was. chokidar
|
|
88
|
+
throttles change events per path for 50ms and _drops_ the throttled ones, so the event carrying the
|
|
89
|
+
content is routinely discarded as a duplicate of the truncate's: an empty read that is discarded is
|
|
90
|
+
the last read that config gets, and the thread holds the pre-truncate value indefinitely
|
|
91
|
+
(`RootConfigWatcher`) or reports the scope as removed (`OptionsWatcher`). Both therefore hand an
|
|
92
|
+
empty read to `ConfigReadRetry`, the same ladder a lock takes and for the same reason — there is no
|
|
93
|
+
further event to re-read on. `OptionsWatcher` applies it on both read paths, not only the
|
|
94
|
+
synchronous one: the asynchronous read is far less likely to land in a truncate window, but the
|
|
95
|
+
consequence there is a spurious `remove` that tears the scope down.
|
|
96
|
+
|
|
97
|
+
A read that _parses_ to nothing is the same event and takes the same ladder: a truncated document,
|
|
98
|
+
a lone `\n` and a file of nothing but comments all yield `null` from the parser rather than
|
|
99
|
+
throwing. `OptionsWatcher` judges that on the file's own parse, **before** `overlayRootEnvConfig`,
|
|
100
|
+
which returns a non-null object whenever a config env var is set — the norm in containers — and
|
|
101
|
+
would otherwise launder a half-written file into a valid-looking env-only config and wipe the
|
|
102
|
+
file's own options.
|
|
103
|
+
|
|
104
|
+
Past the ladder the emptiness is believed, and what that costs depends on whether the scope has
|
|
105
|
+
settled: a worker still booting starts on the defaults, while one already running keeps the config
|
|
106
|
+
it has and only warns. The asymmetry is deliberate in both halves — a running worker must not let a
|
|
107
|
+
truncate window that outlived the ladder reset every scope, and a booting one must not hold
|
|
108
|
+
`Scope.ready` open waiting for a file that is genuinely empty — but it does mean an operator who
|
|
109
|
+
empties `harper-config.yaml` at runtime gets divergence between workers until the next restart.
|
|
110
|
+
|
|
111
|
+
### `ready` means the watcher is armed
|
|
112
|
+
|
|
113
|
+
`RootConfigWatcher.ready` is a startup barrier — `harper_logger`'s `updateLogSettings()` attaches
|
|
114
|
+
its `change` listener only after awaiting it — so it has to mean "watching", not merely "the first
|
|
115
|
+
read landed". The synchronous read would otherwise emit `ready` from inside chokidar's initial `add`
|
|
116
|
+
dispatch, and on darwin FSEvents has not armed its stream at that point: a write in that window is
|
|
117
|
+
dropped with no later event to recover it (the async read used to defer past it by a threadpool
|
|
118
|
+
round-trip, which is why this surfaced only when the read went synchronous). Measured on the
|
|
119
|
+
harper#2191 review head, writing that far after `ready`: 0ms is lost, 5ms and beyond is delivered.
|
|
120
|
+
|
|
121
|
+
So `ready` is gated on chokidar's own `ready` — its initial scan has established the native
|
|
122
|
+
watches by then — plus a darwin-only grace over that measurement for the kernel-side warm-up
|
|
123
|
+
chokidar cannot observe. Neither half is sufficient alone: chokidar's event still lands inside the
|
|
124
|
+
warm-up, and a bare timer could elapse before the scan has created any watch. Config read before
|
|
125
|
+
that gate opens is staged into `#config`, re-read once the gate opens — a write that landed while
|
|
126
|
+
the watch was unarmed produced no event, so nothing else would ever deliver it — and then handed to
|
|
127
|
+
`ready` itself rather than to a `change` that would precede it.
|
|
128
|
+
|
|
129
|
+
`OptionsWatcher` shares the gate (`ArmGate`, `config/watcherArming.ts`) because it has the same
|
|
130
|
+
unarmed window and, for the root config, many more of them: `componentLoader` gives every
|
|
131
|
+
`TRUSTED_RESOURCE_PLUGINS` entry its own root-config `OptionsWatcher`, and those read synchronously.
|
|
132
|
+
It shares the arming **re-read**, which is what recovers the otherwise-undeliverable write, but not
|
|
133
|
+
the barrier: its `ready` still goes out on the first read, so it means "the config has been read",
|
|
134
|
+
not "armed". The difference is only ordering, because unlike `harper_logger` its consumer (`Scope`)
|
|
135
|
+
attaches `change`/`remove`/`ready` listeners in its constructor, before any read — so a write made
|
|
136
|
+
in the unarmed window reaches the scope as a post-`ready` `change` (and, for a plugin that doesn't
|
|
137
|
+
handle its own options, a restart) rather than being lost. Holding `OptionsWatcher.ready` behind
|
|
138
|
+
arming as well would need every terminal outcome to open a second barrier, per scope, with a boot
|
|
139
|
+
hang as the failure mode; the ordering is not worth that.
|
|
140
|
+
|
|
141
|
+
Whether a scope is configured is tracked separately from its value, because neither truthiness nor
|
|
142
|
+
`!== undefined` can answer it: `myPlugin:` with no body is a configured scope whose value is `null`,
|
|
143
|
+
and a boot that found no config of its own holds `DEFAULT_CONFIG[name]` — a value the watcher gave
|
|
144
|
+
itself. Reading either as "the file supplied this" costs a restart: for the six scopes
|
|
145
|
+
`DEFAULT_CONFIG` names, the next read of an unchanged file looks like the block being deleted, and
|
|
146
|
+
filling in an empty block looks like the unconfigured → configured transition `Scope` answers by
|
|
147
|
+
restarting rather than the `change` it is.
|
|
148
|
+
|
|
149
|
+
What the arming re-read must _not_ do is report a deletion. Its job is the write no event carried;
|
|
150
|
+
a file that is gone is chokidar's `unlink` to report, and answering the re-read's `ENOENT` with
|
|
151
|
+
`remove` announces it ahead of the event that would confirm it — where there is a grace, ahead of
|
|
152
|
+
chokidar having finished tearing the watch down, so a config recreated on the strength of that
|
|
153
|
+
early `remove` lands in a window where its `add` is not observed at all and the scope keeps the
|
|
154
|
+
defaults with nothing further coming. Settling a barrier that has nothing applied yet is still the
|
|
155
|
+
arming re-read's job: an absent file at boot is the install window, not a deletion.
|
|
156
|
+
|
|
157
|
+
### Every terminal read outcome settles the barrier
|
|
158
|
+
|
|
159
|
+
Both barriers — `RootConfigWatcher.ready` and, through `Scope`, `OptionsWatcher.ready` — are
|
|
160
|
+
awaited with no timeout, so a read that ends without a config must still settle them or the worker
|
|
161
|
+
hangs at boot rather than failing. Every terminal outcome therefore boots on defaults and logs what
|
|
162
|
+
failed: a read the ladder could not complete, a file still empty when the ladder is spent, and a
|
|
163
|
+
file that will not parse. Only a config that parses is a config; the alternative, failing the boot
|
|
164
|
+
closed on an unreadable file, is a different policy than the one `OptionsWatcher` already applies to
|
|
165
|
+
its ENOENT and read-failure paths, and the two watchers must not disagree about it. A file that
|
|
166
|
+
becomes readable later still arrives, as a `change`.
|
|
167
|
+
|
|
168
|
+
A missing file is not one of those outcomes to wait on: `ENOENT` is not a sharing violation, so
|
|
169
|
+
neither watcher takes the retry ladder for it. `OptionsWatcher` has always settled it at once as
|
|
170
|
+
the install window, and `RootConfigWatcher` does the same rather than spending the whole read
|
|
171
|
+
budget inside `harper_logger.start()` on every boot that has no config file — an env-var-only
|
|
172
|
+
deployment, or a rootPath mounted empty. Neither is a deletion an outcome to wait on. `OptionsWatcher.#handleUnlink` cancels the ladder —
|
|
173
|
+
the deletion settles what a pending read was retrying — so when that read had not produced a config
|
|
174
|
+
yet, the ladder it cancels was the only thing left to settle `ready`. Before the first `ready` there
|
|
175
|
+
is also nothing to remove and nothing to hear it: `Scope` is still inside `await scope.ready`, so a
|
|
176
|
+
`remove` there asks for a restart of a component that never booted. A deletion in the boot window
|
|
177
|
+
therefore settles the barrier on the defaults, exactly as the ENOENT read path does; only a deletion
|
|
178
|
+
after `ready` reports `remove`. A watcher error is terminal for the barrier too, and
|
|
179
|
+
settling it is what removes the `error` listener `once(this, 'ready')` attached — so reporting the
|
|
180
|
+
failure afterwards has to check for a listener rather than assume one, or an unlistened `error`
|
|
181
|
+
throws out of chokidar's dispatch and takes the worker down over a fault it just decided to survive.
|
|
182
|
+
|
|
183
|
+
An env-compose failure rides that settle rather than preceding it: `#envComposeError` is reported
|
|
184
|
+
only after the barrier has settled, because an `error` emitted first rejects `once(this, 'ready')`
|
|
185
|
+
instead of settling it. It is set and reported inside one synchronous call chain, the arming path
|
|
186
|
+
included: that path defers an absence check rather than reporting a removal, and it drops the
|
|
187
|
+
failure before deferring rather than reporting it there. Reporting would duplicate — every
|
|
188
|
+
resolution of the deferral recomposes and reports the env state it finds — and holding it would
|
|
189
|
+
carry a failure that may no longer be true onto whatever event reports next. So the early returns
|
|
190
|
+
taken when the env-only overlay _succeeded_ cannot be carrying one, and hoisting the report ahead of
|
|
191
|
+
them for symmetry would put it back before the settle on the paths this ordering exists for.
|
|
192
|
+
|
|
193
|
+
What a scope does about a config that arrives late is the other half of settling early.
|
|
194
|
+
`OptionsWatcher.ready` is not once-per-watcher: it fires whenever a scope goes from having no
|
|
195
|
+
config of its own to having one, which is both the recreated-config-file path and a scope that
|
|
196
|
+
booted while the file was unreadable. Nothing downstream re-runs on it — `componentLoader` is long
|
|
197
|
+
past its `await scope.ready` — so `Scope` answers a repeat `ready` the same way it answers `remove`,
|
|
198
|
+
by requesting a restart. Without that, one worker keeps serving the defaults while every worker
|
|
199
|
+
that read the file cleanly serves the operator's config.
|
|
200
|
+
|
|
201
|
+
Arming is a terminal outcome of its own: chokidar reports a scan that found no file by emitting
|
|
202
|
+
`ready` and nothing else, so `RootConfigWatcher` always re-reads when the gate opens rather than
|
|
203
|
+
publishing what an earlier read staged — a missing config file takes the ladder and settles on the
|
|
204
|
+
defaults instead of holding the barrier open. That fallback must also discard the staged value:
|
|
205
|
+
the arming re-read is authoritative precisely because a write in the unarmed window may have
|
|
206
|
+
superseded it, including by replacing the file with an unusable or missing one. A watcher scan error
|
|
207
|
+
also settles the barrier, but preserves a successfully staged value because no read superseded it.
|
|
208
|
+
`close()` settles the barrier as well.
|
|
209
|
+
|
|
210
|
+
What settles the barrier is not the same as what the settled value may be _used_ as. A read that
|
|
211
|
+
carried no config settles it carrying nothing — not `{}`, which is a configuration that a consumer
|
|
212
|
+
cannot tell apart from one the file really held, and `updateLogger` reads an absent `rotation` as
|
|
213
|
+
rotation off and an absent `console` as console off. `updateLogSettings()` therefore keeps what
|
|
214
|
+
`initLogSettings()` established until a real config arrives, rather than silently turning logging
|
|
215
|
+
off on the very boot that could not read its configuration.
|
|
216
|
+
|
|
217
|
+
## Config is composed and memoized before any component runs (`config/configUtils.ts`)
|
|
218
|
+
|
|
219
|
+
`getConfigObj()` composes the config once per thread (module-level memo) at its first call, which
|
|
220
|
+
happens before the root component loads and long before any user component's plugins run. Anything a
|
|
221
|
+
component does at load time — like `loadEnv` writing `process.env` — therefore cannot affect the
|
|
222
|
+
composed config (#1513). By design this stays true: configuration is strictly top-down, so the three
|
|
223
|
+
config-shaping env vars (`HARPER_DEFAULT_CONFIG`/`HARPER_CONFIG`/`HARPER_SET_CONFIG`) are **never
|
|
224
|
+
honored** from a component `.env`. What #1513 fixed is the silence: `config/componentEnvPrepass.ts`
|
|
225
|
+
scans `componentsRoot` + `RUN_HDB_APP` for `loadEnv` declarations during `initConfig` and emits an
|
|
226
|
+
actionable warning per config-shaping var found, and `resources/loadEnv.ts` warns again at
|
|
227
|
+
component-load time (covering post-boot deploys) and **skips the `process.env` assignment** for the
|
|
228
|
+
trio — enforce-at-injection, so anything downstream that (re)composes from `process.env`
|
|
229
|
+
(#1618/#1726) can rely on the trio arriving only via sanctioned channels. The pre-pass deliberately
|
|
230
|
+
mirrors loader behaviors that must stay in sync if the loader changes: config filename precedence
|
|
231
|
+
(`harper-config.yaml` → `harperdb-config.yaml` → `config.yaml`) and `files` pattern validation
|
|
232
|
+
(`..` and absolute patterns rejected). Known limitation: a `componentsRoot` override that itself
|
|
233
|
+
arrives via env var cannot redirect the scan.
|
|
234
|
+
|
|
235
|
+
## Boot-path config persistence is best-effort, and its two artifacts commit as a unit (`config/configUtils.ts`, `config/harperConfigEnvVars.ts`)
|
|
236
|
+
|
|
237
|
+
Every boot with a `HARPER_*_CONFIG` env var set re-derives the merged config and, historically, wrote
|
|
238
|
+
it back unconditionally. On a full or quota-exhausted volume that write is refused and, being fatal,
|
|
239
|
+
turned a full disk into a container restart loop nothing inside the container could break — the
|
|
240
|
+
cleanup that frees space needs a started process (#847). Two rules follow.
|
|
241
|
+
|
|
242
|
+
**Derived boot writes are best-effort; user-requested ones are not.** `persistConfigDuringBoot()`
|
|
243
|
+
swallows exactly ENOSPC/EDQUOT (matching on `errno` as well as `code`, because Linux has no libuv
|
|
244
|
+
mapping for EDQUOT and reports `Unknown system error -122`) and lets the boot proceed on the
|
|
245
|
+
in-memory config. `updateConfig`/`set_configuration`, `addConfig`, `deleteConfigFromFile` and the
|
|
246
|
+
install path keep persist-or-throw: a caller who asked to persist must not get a silent success, and
|
|
247
|
+
an install has no last-known-good config to fall back on.
|
|
248
|
+
|
|
249
|
+
**The env-config state and the config file must never disagree.** The state file records the
|
|
250
|
+
_pre-env_ values, so it is the only copy of what the operator's config said before an env layer
|
|
251
|
+
overwrote it — the config file itself holds the env-derived value. Both single-file orderings lose
|
|
252
|
+
something: writing the state last means the file it would read originals from is already
|
|
253
|
+
overwritten; writing it first leaves a state ahead of the file, which the next boot's
|
|
254
|
+
`detectConfigDrift` reads as a manual user edit and _permanently_ reassigns those paths to `user`,
|
|
255
|
+
silently disabling the env layer even after space is freed. So the commit is three steps —
|
|
256
|
+
`saveState()` stages the new state in `.harper-config-state.pending.<pid>.json`, the config file is
|
|
257
|
+
written, and `confirmConfigWritten()` **renames** the sidecar over the confirmed record. A rename
|
|
258
|
+
needs no free space, which is the point: no write an exhausted volume can refuse ever stands between
|
|
259
|
+
the confirmed originals and disk. A refused staging write leaves the config file alone; a refused
|
|
260
|
+
config write unlinks the sidecar; a sidecar found at load means a commit was interrupted, so it is
|
|
261
|
+
cleared and drift detection is skipped for that boot rather than mistaking the in-flight write for
|
|
262
|
+
an edit. A boot that re-derives the same state writes nothing at all.
|
|
263
|
+
|
|
264
|
+
Two details the name and the caller carry. The sidecar is **per-process**: every CLI invocation runs
|
|
265
|
+
`initConfig`, and one shared name would let a starting server clear a running process's in-flight
|
|
266
|
+
commit — the loser would then rewrite the config file with the confirmed state still describing the
|
|
267
|
+
old values, which is the failure the protocol exists to prevent. Recovery therefore only clears a
|
|
268
|
+
sidecar whose owning pid is gone. And only the **main thread** persists or runs recovery: workers
|
|
269
|
+
derive the same merged config and would otherwise race over one pair of files for a result they
|
|
270
|
+
already agree on — and since a worker shares its process's pid, a recovery scan from one would
|
|
271
|
+
delete the main thread's in-flight sidecar as if it were the last boot's wreckage.
|
|
272
|
+
|
|
273
|
+
A sidecar owned by a _live_ foreign process is not cleared — that process is mid-commit — but its
|
|
274
|
+
presence still turns drift detection off for this boot: a pair someone else is halfway through is no
|
|
275
|
+
more comparable than one an interruption left behind. That suspension is why a sidecar also ages
|
|
276
|
+
out regardless of what its pid says: without it, a sidecar whose owner was killed and whose pid was
|
|
277
|
+
later recycled would look mid-commit forever and suspend drift detection on every boot. The age-out
|
|
278
|
+
is deliberately far longer than a commit could take — recovery from a recycled pid only has to be
|
|
279
|
+
eventual, while deleting a slow-but-live writer's sidecar is the worse error, stranding its config
|
|
280
|
+
file against an unpromoted state.
|
|
281
|
+
|
|
282
|
+
Drift detection is main-thread-only for the same reason recovery is. A worker never owns the state:
|
|
283
|
+
in the normal sequence the main thread has already classified and persisted before any worker runs,
|
|
284
|
+
and inside the main thread's commit window a file that differs from the snapshot is as likely to be
|
|
285
|
+
the write in flight as an operator edit. A worker that concluded "user edit" would drop the
|
|
286
|
+
env-supplied value for itself alone and serve different config than its siblings.
|
|
287
|
+
|
|
288
|
+
Known limit: the pair commits as a unit _within a process_. Two live processes (a server boot and a
|
|
289
|
+
CLI invocation) can still interleave their config-file writes and promotions, and nothing in the repo
|
|
290
|
+
serializes config writes across processes. Pre-existing — both artifacts were unordered before this
|
|
291
|
+
protocol — and out of scope here, but the "commits as a unit" guarantee stops at the process
|
|
292
|
+
boundary.
|
|
293
|
+
|
|
294
|
+
Related: a log write must not be fatal either. `fs.appendFileSync` in `logQueuedData` throws from
|
|
295
|
+
both inline and timer call sites, so on a full volume every log statement was a crash point. The
|
|
296
|
+
fallback goes through `nativeStdWrite`, never `console` — `installStdioGuard` routes console output
|
|
297
|
+
back into this same file logger when `logging.file` and `logging.console` are both on, so a console
|
|
298
|
+
fallback recurses until the stack blows.
|
|
299
|
+
|
|
300
|
+
## Env-config empty objects mean three different things (`config/harperConfigEnvVars.ts`)
|
|
301
|
+
|
|
302
|
+
An `{}` in the config system is context-dependent, and conflating the contexts is the root of #2067. In an **env layer** (`HARPER_SET_CONFIG` et al.), an empty object contributes no leaves — `http: {}` means "no overrides under http" (load-bearing removal semantics in `flattenObject`). In the **base config file**, a bare `componentName: {}` is user content — a real empty scope declaration that composition must preserve (`restoreBaseEmptyObjects`, #1618/#1726). An `{}` that is _neither_ — the residue of removing an env-sourced entry leaf-by-leaf — is invalid config that validation may reject forever, because the file is written before validation runs and the residue then reads as user content on every later boot.
|
|
303
|
+
|
|
304
|
+
Removal therefore prunes: `deleteNestedValue` removes ancestors the deletion emptied, only when it actually deleted an existing leaf, and reports what it pruned. The overlap case — a file-declared empty scope an env layer temporarily populated — is tracked in the state file's `emptyScopeOriginals` (separate from `originalValues` so a marker can never mask or be consumed as a real leaf original at the same path; older state files lacking the field are defaulted). Restore consumes a marker only for a path the prune actually removed, so a scalar overwrite or an absent-leaf no-op can never resurrect a scope over live env-layer content. Note there are two coexisting mechanisms for "file `{}` is user content": `restoreBaseEmptyObjects` on the stateless compose path and the marker pair on the stateful removal path — if you touch one, check the other.
|
|
305
|
+
|
|
306
|
+
Two durable limitations of the marker mechanism, both with user config-file content as the blast radius: markers can only be recorded at populate time, so a scope an env layer populated _before_ `emptyScopeOriginals` existed (any pre-upgrade boot) has no marker and prunes away on its first post-upgrade vacate; and a corrupt config-state file resets to fresh state — dropping `originalValues` and `emptyScopeOriginals` for every tracked path — after which the next removal prunes those scopes for good; `saveConfigState` writes via temp+rename precisely so a torn write cannot be the trigger, leaving genuine corruption (disk faults, hand edits) as the remaining path.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# dataLayer/ — Design notes
|
|
2
|
+
|
|
3
|
+
Backup and restore, version gating, system-table bootstrap and storage migration.
|
|
4
|
+
|
|
5
|
+
**Read this when:** touching `rocksdbBackup.ts`, `restoreMarker.ts`, `blobBackup.ts`, `hdbInfoController.ts`, `bin/copyDb.ts` or `json/systemSchema.json`.
|
|
6
|
+
|
|
7
|
+
Index of every design note: [DESIGN.md](../DESIGN.md).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Version gate at startup: downgrades prompt, and only the minor direction is confirmable
|
|
12
|
+
|
|
13
|
+
`getVersionUpdateInfo()` (`dataLayer/hdbInfoController.ts`) compares the store's `data_version_num` (latest `system.hdb_info` record) against the binary's `packageJson.version` on every start. Data newer than binary by a **major** version → hard refusal. Newer by a **minor** version → `forceDowngradePrompt()` asks for confirmation; answering yes records the data version back down to the binary's version and boots (upgrade directives are deliberately additive/downgrade-compatible — see the struct-mode section above and `patchHdbSecretIsHashAttribute` in `upgrade/directives/5-2-0.ts`).
|
|
14
|
+
|
|
15
|
+
- The prompt's answer can be supplied non-interactively via `CONFIRM_DOWNGRADE` — env var or `--CONFIRM_DOWNGRADE` CLI arg; argv wins (`assignCMDENVVariables`). With no override and no TTY on stdin, the prompt throws instead of blocking on stdin forever (#2046 — services/CI hung with nothing in the log; the mismatch is also logged to hdb.log now).
|
|
16
|
+
- Upgrades never prompt (see the rationale comment in `bin/upgrade.js`); only the downgrade direction confirms. `upgradeCertsPrompt()` on the 4.x upgrade path still has the block-on-stdin hazard.
|
|
17
|
+
- Test-suite gotcha: a suite that supplies the override via `process.argv` affects every later test file in the same mocha process — save and restore `process.argv` in `before`/`after` (see `unitTests/dataLayer/hdbInfoController.test.js`).
|
|
18
|
+
|
|
19
|
+
## Opening a source LMDB DBI for migration must thread through `compression`
|
|
20
|
+
|
|
21
|
+
When `migrateOnStart` opens a source LMDB primary store to read records out for the RocksDB copy, it constructs an `OpenDBIObject` and calls `sourceRootStore.openDB(key, dbiInit)`. Critically, the per-attribute `compression` setting from the corresponding `__dbis__` entry must be assigned onto `dbiInit` before that call — `dbiInit.compression = attribute.compression`. Without it, lmdb-js doesn't install its decompression layer; every read on the DBI returns raw compressed bytes. msgpackr then misreads bytes in the `0x40–0x7F` range as shared-structure refs, calls `loadStructures` → decodes the (also compressed) structures buffer → finds more bytes in that range → recurses → stack overflow.
|
|
22
|
+
|
|
23
|
+
Harper's normal `databases.ts` path already does this (search for `dbiInit.compression = primaryKeyAttribute.compression`); the migration path in `bin/copyDb.ts` has to match.
|
|
24
|
+
|
|
25
|
+
The persisted `compression` value itself is LMDB-era and loosely shaped: `getDefaultCompression()` historically stored whatever falsy value the config resolved to (`''`, `false`, `null`) when `storage.compression` was disabled, and `{ startingOffset, threshold, dictionary? }` when enabled. lmdb-js interprets falsy as "no compression", but rocksdb-js >= 2.6 validates the option strictly (`''`/booleans throw `Unsupported compression algorithm`) and treats UNSET as "use the build default (lz4)" — the inverse default of lmdb. Every RocksDB open must therefore route through `toRocksCompression()` in `resources/databases.ts` (applied inside `openRocksDatabase`, the single chokepoint), which maps defined-falsy → `'none'` and enabled-without-an-algorithm → an explicit lz4 request when available. Don't pass persisted attribute compression to a RocksDB open directly.
|
|
26
|
+
|
|
27
|
+
`bin/copyDb.ts`'s `openRocksDb` is part of that chokepoint, not an exception to it. This is about the bytes migration writes, not about a later failure: `copyDbToRocks()` closes every target handle before the staging directory is renamed, and rocksdb-js permits an explicit codec change across a close/reopen, so the runtime would open the migrated database fine either way. But a migration that ignores the configured codec writes the entire dataset uncompressed, and those SST/blob files then keep their original codec until write traffic rewrites them — a full LMDB→RocksDB migration is the one moment the whole dataset is written at once, so it is exactly when the deployment's codec should apply.
|
|
28
|
+
|
|
29
|
+
## System table bootstrap: `systemSchema.json` + upgrade directive
|
|
30
|
+
|
|
31
|
+
Adding a new system table (e.g. `hdb_deployment` in #641 Slice A) requires three changes:
|
|
32
|
+
|
|
33
|
+
1. **`json/systemSchema.json`** — the table entry. Fresh installs auto-create it via `utility/mount_hdb.ts:createTables()`, which iterates `Object.keys(systemSchema)` on first boot.
|
|
34
|
+
2. **`utility/hdbTerms.ts`** — add the table name to `SYSTEM_TABLE_NAMES`.
|
|
35
|
+
3. **`upgrade/directives/<version>.ts`** — provisions the table on existing installs that already have a system schema. Registered in `upgrade/directives/directivesController.ts` (which is otherwise empty — its `versions` Map gets populated by these imports). The directive shape is `{ version, sync_functions, async_functions }`; copy `5-1-0.ts` for the canonical pattern (uses `bridge.createTable` to match what `mount_hdb` does on a fresh install).
|
|
36
|
+
|
|
37
|
+
**Version the directive to the first release that ships the dependent code, not a later one.** Directives only run when `current_version < directive_version <= upgrade_version` (`directivesController.getVersionsForUpgrade`). The `hdb_deployment` directive was originally mis-tagged `5.2.0` while the deployment-recorder code shipped in `5.1.0`, so on every `5.0.x -> 5.1.x` upgrade the directive was filtered out (`5.2.0 > 5.1.x`) and the table never got created — breaking replicated `deploy_component` on peer nodes for the entire existing customer base. Caveat: `utility/common_utils.ts:compareVersions` strips trailing `.0` and therefore sorts a pre-release (`5.1.0-beta.1`) _above_ its GA (`5.1.0`), so an install already on a `5.1.0-beta.x` data version will not pick up a `5.1.0` directive when upgrading to GA; those pre-release installs need the table created by other means.
|
|
38
|
+
|
|
39
|
+
System tables replicate by default. To opt out, add the name to `NON_REPLICATING_SYSTEM_TABLES` in `resources/databases.ts`. The check happens after table init and sets `table.replicate = false` per-node.
|
|
40
|
+
|
|
41
|
+
If the table needs `audit: true`, set it both in the schema (for fresh installs) **and** on the `CreateTableObject` instance in the directive (for upgrades) — otherwise the two paths diverge.
|
|
42
|
+
|
|
43
|
+
## RocksDB backup/restore: the restore lock + marker protocol (`dataLayer/restoreMarker.ts`, `dataLayer/rocksdbBackup.ts`)
|
|
44
|
+
|
|
45
|
+
The `restore_backup` operation restores a user database on a live server by closing it across all
|
|
46
|
+
worker threads, purging its directory (`backups.restore` with `purgeAllFiles`), and reloading it.
|
|
47
|
+
Three non-obvious mechanics keep that safe:
|
|
48
|
+
|
|
49
|
+
- **Two files in an isolated `` `restore` `` directory beside (never inside) the database directory**,
|
|
50
|
+
each keyed by `sha256(basename(dbPath)).slice(0,32)`: `<key>.lock`, an OS-level exclusive flock
|
|
51
|
+
(rocksdb-js `tryFileLock`, auto-released on process death), serializes restores; `<key>.restoring`,
|
|
52
|
+
a marker written+fsynced (file _and_ the metadata directory) after the lock and before any
|
|
53
|
+
destructive step, means "a restore started and has not finished" (its first line records the
|
|
54
|
+
database directory name so the scan can map a marker back without decoding the key). The metadata
|
|
55
|
+
is hashed into a sibling directory rather than suffixed onto the database name (`<db>.restoring`)
|
|
56
|
+
for two reasons: a legal database literally named `orders.restoring` would otherwise be mistaken
|
|
57
|
+
for the restore marker of `orders`, and a 250-character name (the legal max) plus a `.restore.lock`
|
|
58
|
+
suffix exceeds `NAME_MAX` (255) on most filesystems. The directory name deliberately contains a
|
|
59
|
+
backtick — `schemaRegex` (the database-name validator) forbids only `/` and a backtick among
|
|
60
|
+
filesystem-legal characters — so it can never collide with a legal database name, including a
|
|
61
|
+
database literally named `.restore` (which _is_ a legal name; a plain `.restore/` directory would
|
|
62
|
+
be exactly that database's directory). Because the startup scan opens any `CURRENT`+`MANIFEST-`
|
|
63
|
+
directory without re-applying `schemaRegex`, it also explicitly skips the reserved `` `restore` ``
|
|
64
|
+
entry so an out-of-band directory at that name is never loaded as a database. Startup/rescan
|
|
65
|
+
detection (`databasesBlockedByRestore` → `scanBlockedRestores` in `dataLayer/restoreMarker.ts`)
|
|
66
|
+
reads the metadata directory and checks the **marker first**, only probing the lock when the marker exists —
|
|
67
|
+
probes take the flock and are mutually exclusive across threads, so probing the (persistent) lock
|
|
68
|
+
file of every long-ago-restored database on every rescan would make concurrent rescans misclassify
|
|
69
|
+
healthy databases as in-progress. Marker-present + lock-held = restore in progress (don't load);
|
|
70
|
+
marker-present + lock-free = crashed mid-restore (don't load; rerun the restore to recover).
|
|
71
|
+
- **A recovery restore must not clear a pre-existing marker on a pre-destruction failure.**
|
|
72
|
+
`beginRestore` returns `preexisting: true` when a `.restoring` marker was already present (this run
|
|
73
|
+
is a recovery over a possibly half-purged directory). If such a run fails _before_ any destruction
|
|
74
|
+
(e.g. `verifyDatabaseClosed` finds a leaked handle), it must leave the marker in place — clearing
|
|
75
|
+
it and broadcasting a reload would surface the earlier attempt's partial/corrupt directory as
|
|
76
|
+
healthy. Only a _fresh_ marker on a _previously healthy_ database that failed before destruction is
|
|
77
|
+
safe to clear.
|
|
78
|
+
- **The ITC close broadcast is best-effort, so closure is verified before the purge.** The SCHEMA
|
|
79
|
+
broadcast (`signalSchemaChange`) resolves after remote handlers complete but times out at 30s
|
|
80
|
+
"best-effort", swallows errors, and never reaches job-worker threads at all (their ports are
|
|
81
|
+
excluded from broadcasts to avoid re-entrant deadlocks). A destructive purge cannot trust it:
|
|
82
|
+
`restoreBackup` polls rocksdb-js `registryStatus()` (process-global across worker threads) until
|
|
83
|
+
the database path has no open instance, and aborts with a 409 — _cleaning up the marker, since
|
|
84
|
+
nothing was destroyed_ — if handles remain.
|
|
85
|
+
- **Online restore is impossible for a database a component holds open — and that failure is
|
|
86
|
+
correct.** rocksdb-js's registry is process-global but records only a per-path refCount, with no
|
|
87
|
+
attribution to a thread or component; Harper keeps no component→database ownership map. So when a
|
|
88
|
+
loaded component (or the `system` database, which Harper itself never stops while running) holds
|
|
89
|
+
its own handle on the target database, `registryStatus()` stays non-zero, Harper can neither
|
|
90
|
+
identify nor force-close that handle, and an in-place purge would corrupt a live instance.
|
|
91
|
+
`verifyDatabaseClosed` therefore waits only a short grace period (`DATABASE_CLOSE_WAIT_MS`, for a
|
|
92
|
+
just-finished job worker's own close to drain) and then fails fast with a 409 that points at
|
|
93
|
+
running the operation offline (`harper restore_backup` with the server stopped, where no
|
|
94
|
+
components are loaded and nothing holds the database open). Offline restore is the supported path
|
|
95
|
+
for component-held and `system` databases; online restore serves databases not actively held by a
|
|
96
|
+
component. The CLI exposes each backup operation under its operation name only (`create_backup`,
|
|
97
|
+
`restore_backup`, …) — no hyphenated alias — and `bin/backup.ts` routes it to a reachable server
|
|
98
|
+
or, when the local server is stopped, to the equivalent offline function.
|
|
99
|
+
- **Job workers must release their RocksDB handles on exit, or the closure check can never pass.**
|
|
100
|
+
rocksdb-js's registry is process-global across worker threads, and a thread that exits WITHOUT
|
|
101
|
+
closing leaks its handles (the refCount never drops); the only alternative, `shutdown()`, tears
|
|
102
|
+
down rocksdb for the _entire_ process. A job worker (`server/jobs/jobProcess.ts`) opens the whole
|
|
103
|
+
database graph via `getDatabases()` and exits when the job finishes — and `create_backup` is
|
|
104
|
+
itself a job, so before any `restore_backup` there is always at least one exited job worker that
|
|
105
|
+
touched the database. Without cleanup those leaked handles keep `registryStatus()` non-zero and
|
|
106
|
+
would fail the closure check even when no component holds the database. `jobProcess` therefore
|
|
107
|
+
calls `closeLoadedDatabases()` (`resources/databases.ts`) in its `finally`, closing every loaded
|
|
108
|
+
user database on that thread (the non-enumerable `system` DB is intentionally skipped), so an
|
|
109
|
+
exited job worker leaves no residual handle to be mistaken for a live holder.
|
|
110
|
+
- **`dropDatabase` and `restore_backup` serialize on the same lock, not a check-then-act probe.**
|
|
111
|
+
A drop's `destroy()` interleaving with a restore's purge-and-copy on the same directory would gut
|
|
112
|
+
a "successful" restore (or vice versa). `dropDatabase` therefore _acquires_ the restore lock
|
|
113
|
+
(`acquireRestoreLock`, marker-less) for each RocksDB root store and holds it across the whole drop,
|
|
114
|
+
releasing in a `finally`; a restore in progress makes the acquire fail with 409, and a leftover
|
|
115
|
+
incomplete-restore marker (lock free, detected via `restoreMarkerPresent`, which — unlike
|
|
116
|
+
`checkRestoreState` — is safe while this thread holds the lock) is refused rather than dropped over.
|
|
117
|
+
`database()`'s on-demand open still uses the read-only `throwIfBlockedByRestore` (a
|
|
118
|
+
`create_table`/`create_schema` must not resurrect a half-purged directory as a fresh empty DB), but
|
|
119
|
+
the destructive drop path now uses the exclusive lock so the race is closed, not merely narrowed.
|
|
120
|
+
- **The offline restore probes RocksDB's own `LOCK` file, and fails closed.** The offline path runs
|
|
121
|
+
only when the CLI sees no server (a PID heuristic; the PID file is briefly absent mid-`harper
|
|
122
|
+
restart`), and `backups.restore`'s `purgeAllFiles` never takes RocksDB's lock — so before purging,
|
|
123
|
+
`restoreBackupOffline` opens the database to probe. It now takes the restore lock+marker _before_
|
|
124
|
+
probing (so a server that starts afterward sees the marker and refuses to load), and recognizes the
|
|
125
|
+
pinned rocksdb-js 2.5.0 lock error — a plain `Error` with no `code` and message
|
|
126
|
+
`IO error: While lock file: <db>/LOCK: Resource temporarily unavailable` (`isRocksDbLockError`) —
|
|
127
|
+
aborting with a 409 rather than purging a database another process holds open. Any _other_ open
|
|
128
|
+
failure (corrupt/half-restored) is exactly what restore recovers, so only a lock conflict aborts.
|
|
129
|
+
|
|
130
|
+
Known limitation: the flock is process-owned; if the restore job's worker _thread_ dies without
|
|
131
|
+
the process exiting, the lock stays held (restores 409) until Harper restarts. There is no typed
|
|
132
|
+
native lock signal in rocksdb-js 2.5.0, so the offline probe relies on message matching; a native
|
|
133
|
+
lock primitive is a rocksdb-js follow-on.
|
|
134
|
+
|
|
135
|
+
## RocksDB managed backups: blob snapshots (`dataLayer/blobBackup.ts`)
|
|
136
|
+
|
|
137
|
+
A database's file-backed blobs live in one or more roots _outside_ the RocksDB directory
|
|
138
|
+
(`getBlobPathsForDatabaseName` in `resources/blob.ts` — one per configured `storage.blobPaths`, else
|
|
139
|
+
`<hdb_root>/blobs/<database>`), so the engine's backup does not capture them. `create_backup`,
|
|
140
|
+
`restore_backup`, `delete_backup`, `purge_backups`, and the streaming `get_backup` therefore handle
|
|
141
|
+
blobs alongside the engine data (the `exclude_blobs` request option — default false — opts out for an
|
|
142
|
+
engine-only backup):
|
|
143
|
+
|
|
144
|
+
- **Managed backups** snapshot the blob roots to `<backupDir>/blobs/<backupId>/<rootIndex>/<relpath>`
|
|
145
|
+
— a full, non-incremental copy per backup, mirroring the binding's `transaction_logs/<id>/` layout.
|
|
146
|
+
Each enumerated entry is classified before capture: complete blobs and existing abort markers are
|
|
147
|
+
hard-linked when possible (copied across filesystems), `.repair` temporaries are omitted, and an
|
|
148
|
+
incomplete blob is replaced by a retryable PENDING (`0xfe`) marker. If a classified blob vanishes
|
|
149
|
+
before capture, a terminal ERROR (`0xff`) marker preserves its file id. A file reclaimed before its
|
|
150
|
+
parent directory is read is outside the snapshot. This keeps a snapshot inode from changing as a
|
|
151
|
+
live write finishes, while complete blobs remain safe to hard-link because published blob paths are
|
|
152
|
+
write-once. The snapshot is built in a `.tmp-<id>` sibling and atomically renamed so a failed create
|
|
153
|
+
leaves no partial snapshot. `restore_backup` purges each blob root and rewrites it from the snapshot;
|
|
154
|
+
`delete_backup` / `purge_backups` remove the corresponding snapshot directories.
|
|
155
|
+
- **`get_backup`** appends the blob files to the same tar under `blobs/<rootIndex>/<relpath>`. The
|
|
156
|
+
binding's streaming backup finalizes its tar with exactly a 1024-byte (two-block) end-of-archive
|
|
157
|
+
marker; `createBackupStream` streams the native _plain_ tar while withholding that trailer
|
|
158
|
+
(verifying it is all-zero), appends the blob entries via `tar-stream` (whose `finalize` writes the
|
|
159
|
+
one real trailer), and gzips the combined stream itself when requested — so the binding is always
|
|
160
|
+
asked for a plain tar and compression happens after the append. No scratch disk. The same blob
|
|
161
|
+
classification rule applies: complete blobs are streamed, incomplete or post-enumeration missing
|
|
162
|
+
blobs become PENDING/ERROR marker entries, and repair temporaries are omitted.
|
|
163
|
+
|
|
164
|
+
**Completion manifest (`dataLayer/backupManifest.ts`).** `create_backup` is two-phase: the engine
|
|
165
|
+
backup (`rootStore.backup()`) resolves — and is immediately visible to `list_backups`/`verify_backup`/
|
|
166
|
+
`restore_backup` — before the blob snapshot is copied. Without a completion record, a blob-snapshot
|
|
167
|
+
failure (or a crash between the phases) would leave an engine backup that lists and verifies as
|
|
168
|
+
healthy while silently missing its blobs, and a concurrent restore could pick a backup id whose
|
|
169
|
+
snapshot is still being written and treat it as intentionally engine-only. So a manifest at
|
|
170
|
+
`<backupDir>/manifests/<backupId>.json` — recording the blob-inclusion policy — is written
|
|
171
|
+
(atomically, temp + rename) only after _both_ phases are durable, and a graceful blob-snapshot
|
|
172
|
+
failure rolls back the just-created engine backup + partial snapshot. Consumers treat a backup id
|
|
173
|
+
with no manifest as incomplete: `list_backups` hides it, `verify_backup`/`restore_backup` reject it
|
|
174
|
+
(409 for a specific id, "no complete backups" for `latest`), and restore uses the manifest's `blobs`
|
|
175
|
+
flag — not the mere presence of a snapshot dir — to decide whether to restore blobs (so an engine-only
|
|
176
|
+
backup leaves live blobs untouched, and a manifest that claims blobs but has no snapshot is flagged
|
|
177
|
+
corrupt by verify). This closes the "healthy-looking but incomplete" and concurrent-restore races;
|
|
178
|
+
the remaining engine/blob point-in-time skew (a blob unlinked between the engine cut and the blob
|
|
179
|
+
walk) is the documented best-effort limitation above.
|