@jarenjs/db 0.85.0 → 0.86.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/ARCHITECTURE.md +7 -0
- package/README.md +24 -14
- package/docs/HOSTS.md +131 -4
- package/docs/MODEL-FORMAT.md +1 -0
- package/package.json +8 -4
- package/src/driver.js +3 -0
- package/src/drivers/node-process-endpoint.js +13 -0
- package/src/drivers/node-process.js +177 -0
- package/src/drivers/node-worker-endpoint.js +3 -103
- package/src/drivers/node-worker.js +6 -178
- package/src/drivers/node.js +1 -0
- package/src/drivers/sqlite-endpoint.js +113 -0
- package/src/drivers/worker-client.js +185 -0
- package/src/drivers/worker-protocol.js +15 -0
- package/src/errors.js +1 -0
- package/types/index.d.ts +2 -0
- package/types/node-process.d.ts +33 -0
package/ARCHITECTURE.md
CHANGED
|
@@ -1196,3 +1196,10 @@ SQLite-semantic and never enter the JSON residual evaluator. See
|
|
|
1196
1196
|
does not import runtime owners. `/query`, `/model` and `/entity` expose those
|
|
1197
1197
|
mechanisms without the root store import. `compileEntityModel` performs one model
|
|
1198
1198
|
normalization for both entities and mapping; openStore reuses that result.
|
|
1199
|
+
|
|
1200
|
+
`drivers/worker-client.js` and `drivers/sqlite-endpoint.js` own the shared bounded
|
|
1201
|
+
RPC contract. Thread and process entries supply their transports. The process
|
|
1202
|
+
driver retains owner credits until OS exit, fences lost generations, and combines
|
|
1203
|
+
observed native transaction state with pending statement metadata to distinguish
|
|
1204
|
+
rollback from an unknown commit. Supervision never serializes callbacks or replays
|
|
1205
|
+
transactions; Store ownership remains in the existing driver/Store gates.
|
package/README.md
CHANGED
|
@@ -888,7 +888,8 @@ oracle run on both. What differs is declared rather than discovered:
|
|
|
888
888
|
| `ALTER TABLE` | additive only | full, so a rebuild is never needed |
|
|
889
889
|
|
|
890
890
|
Neither backend has a **statement timeout** (`capabilities.statementTimeout`
|
|
891
|
-
is `false` on both) or **row estimates**.
|
|
891
|
+
is `false` on both) or **row estimates**. Portable replication uses SQLite
|
|
892
|
+
change capture; PostgreSQL capture and replication remain unqualified.
|
|
892
893
|
Cross-process concurrency on SQLite is its own story — WAL plus a busy
|
|
893
894
|
timeout, both set and visible on `store.capabilities`; on PostgreSQL it
|
|
894
895
|
is the server's, and a serialization failure or a deadlock arrives as
|
|
@@ -1211,16 +1212,18 @@ same report rows — with the one difference the PostgreSQL mapping
|
|
|
1211
1212
|
states: `numeric` carries both JSON number types, so an `integer`
|
|
1212
1213
|
member reads back as `number`.
|
|
1213
1214
|
|
|
1214
|
-
##
|
|
1215
|
+
## Replication and external-write boundaries
|
|
1215
1216
|
|
|
1216
|
-
The
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1217
|
+
The portable replication engine supplies replica identities, causal frontiers,
|
|
1218
|
+
bounded logical envelopes, durable replay receipts, conflict evidence and snapshot
|
|
1219
|
+
resets. Data and acknowledgements commit together; hosts supply transport and any
|
|
1220
|
+
conflict resolver. The default preserves both contenders and rejects the write.
|
|
1221
|
+
See [REPLICATION-FORMAT](docs/REPLICATION-FORMAT.md) for the current contract.
|
|
1222
|
+
|
|
1223
|
+
Capture observes participating Store writes on supported SQLite hosts. Arbitrary
|
|
1224
|
+
writes from another connection are not fine-grained capture events; `dataVersion()`
|
|
1225
|
+
provides coarse invalidation. Adopted triggers, unsupported layouts and PostgreSQL
|
|
1226
|
+
retain the explicit capture and replication qualification limits.
|
|
1224
1227
|
|
|
1225
1228
|
## Operating a store — configuration, maintenance, backup, cancellation, the queue
|
|
1226
1229
|
|
|
@@ -1311,10 +1314,11 @@ its side-effect-free status read are in
|
|
|
1311
1314
|
|
|
1312
1315
|
## What this is not — every non-claim in one place
|
|
1313
1316
|
|
|
1314
|
-
- **SQLite
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1317
|
+
- **SQLite and PostgreSQL have different capabilities.** Both backends ship;
|
|
1318
|
+
PostgreSQL does not provide the SQLite capture, replication, job queue or pragma
|
|
1319
|
+
maintenance capabilities. See the dialect table and normative host contracts.
|
|
1320
|
+
- **Replication transport is supplied by the host.** Portable logical replication
|
|
1321
|
+
ships; arbitrary external writes are not captured as local row events.
|
|
1318
1322
|
- **No statement timeout** on SQLite (the drivers expose no interrupt;
|
|
1319
1323
|
the capability slot is honestly `false`), no row estimates.
|
|
1320
1324
|
- **Not safe for mutually hostile tenants** without the profile's
|
|
@@ -1359,6 +1363,7 @@ Every subpath a consumer can import, derived from the manifest by
|
|
|
1359
1363
|
<!--fact:exports.db-->
|
|
1360
1364
|
| Import | Kind | Declarations |
|
|
1361
1365
|
|---|---|---|
|
|
1366
|
+
| `@jarenjs/db/node-process` | JavaScript | declared |
|
|
1362
1367
|
| `@jarenjs/db/search` | JavaScript | declared |
|
|
1363
1368
|
| `@jarenjs/db` | JavaScript | declared |
|
|
1364
1369
|
| `@jarenjs/db/node` | JavaScript | declared |
|
|
@@ -1442,3 +1447,8 @@ partial conflicts, raw-text JSON queries and byte-valued operations use
|
|
|
1442
1447
|
`snapshotDatabase(connection, newPath)` for a disk-backed committed-WAL copy.
|
|
1443
1448
|
Existing-connection consumers can import `/query`, `/model` and `/entity`;
|
|
1444
1449
|
`compileEntityModel` shares one normalization between queries and mapping.
|
|
1450
|
+
|
|
1451
|
+
`@jarenjs/db/node-process` adds supervised native execution with finite owner
|
|
1452
|
+
admission, caller deadlines, generation fencing and separate process-exit and
|
|
1453
|
+
transaction-fate observations. See [execution hosts](docs/HOSTS.md#supervised-node-processes)
|
|
1454
|
+
for Store composition, receipt reconciliation and operating-system limits.
|
package/docs/HOSTS.md
CHANGED
|
@@ -77,6 +77,132 @@ run on the caller. Synchronous Store methods and live queries are unavailable on
|
|
|
77
77
|
these asynchronous connections. Bun can import both subpaths; opening a Node
|
|
78
78
|
SQLite worker there reports the named unavailable-binding failure (`JD0003`).
|
|
79
79
|
|
|
80
|
+
## Supervised Node processes
|
|
81
|
+
|
|
82
|
+
`nodeProcessDriver` from `@jarenjs/db/node-process` implements the same asynchronous
|
|
83
|
+
Driver and Connection contracts using one Node child process per connection. It
|
|
84
|
+
shares the worker protocol, cursor credits, transaction scopes and error handling
|
|
85
|
+
with the thread driver. SQLite and its native calls execute in the child; callbacks
|
|
86
|
+
and decoded query residuals still execute in the parent. No function is serialized.
|
|
87
|
+
Node.js 24 or newer is required. Bun can import the entry but `open` refuses with
|
|
88
|
+
`JD0003`. The native-call and process-lifecycle fixtures qualify Node 24.20.0 on
|
|
89
|
+
Linux; other supported operating systems require their own timing qualification.
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
import { nodeProcessDriver } from '@jarenjs/db/node-process';
|
|
93
|
+
|
|
94
|
+
const driver = nodeProcessDriver({ maxOwners: 2, timeoutMs: 250 });
|
|
95
|
+
const owner = await driver.open('application.sqlite', { timeout: 50, queueTimeout: 100 });
|
|
96
|
+
try {
|
|
97
|
+
const result = await owner.supervise(async (connection) => {
|
|
98
|
+
return connection.transaction(async (scope) => {
|
|
99
|
+
const statement = await scope.prepare('SELECT value FROM settings WHERE key = ?');
|
|
100
|
+
return statement.get(['theme']);
|
|
101
|
+
});
|
|
102
|
+
}, { signal: requestSignal, timeoutMs: 250 });
|
|
103
|
+
useResult(result);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error.code === 'JD2097' || error.code === 'JD2090') {
|
|
106
|
+
const observation = owner.settlement();
|
|
107
|
+
// This snapshot can still say quarantined / safeToReplace:false.
|
|
108
|
+
recordOwnerObservation(observation);
|
|
109
|
+
const exited = await owner.settled();
|
|
110
|
+
// Before repeating an uncertain write, inspect its durable receipt.
|
|
111
|
+
reconcileBeforeRetry(exited);
|
|
112
|
+
} else throw error;
|
|
113
|
+
} finally {
|
|
114
|
+
// Preserve the original operation outcome if closing a fenced generation fails.
|
|
115
|
+
await owner.close().catch(recordCleanupFailure);
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`supervise(body,{signal,timeoutMs})` admits one supervised callback per connection.
|
|
120
|
+
An overlapping callback refuses with `JD2091`; an already-aborted call runs no
|
|
121
|
+
callback and leaves the owner healthy. A deadline or abort rejects with `JD2097`,
|
|
122
|
+
fences all pending calls and signals process termination. Later operations on the
|
|
123
|
+
old generation fail with `JD2090`. A normal callback failure propagates unchanged
|
|
124
|
+
and does not terminate a healthy owner. `cancel(reason)` explicitly fences the
|
|
125
|
+
whole connection and returns its cancellation error. It affects every call sharing
|
|
126
|
+
that owner, so independent request lifetimes should use separate owners.
|
|
127
|
+
|
|
128
|
+
This is response cancellation and owner termination, not a SQLite statement
|
|
129
|
+
interrupt. `capabilities.process` and `ownerTermination` are true, while
|
|
130
|
+
`capabilities.cancellation.midStatement` remains false. The supervision timer cannot
|
|
131
|
+
preempt synchronous parent JavaScript or bound operating-system scheduling. Keep
|
|
132
|
+
callbacks/residuals bounded and await all admitted work. Native fixtures use a
|
|
133
|
+
short deadline and independently enforce a one-second caller-response ceiling;
|
|
134
|
+
that tested ceiling is not a universal scheduling guarantee.
|
|
135
|
+
|
|
136
|
+
`settlement()` reports the generation, PID, health, transaction fate and
|
|
137
|
+
`safeToReplace`. `settled()` resolves only after the child's OS exit event. An
|
|
138
|
+
owner awaiting termination is quarantined and still consumes its credit. If the
|
|
139
|
+
OS cannot settle a process promptly, the response can finish while the owner
|
|
140
|
+
remains quarantined; neither capacity nor writer ownership is recycled. `restart()`
|
|
141
|
+
waits for confirmed exit and returns a new generation; old Store handles remain
|
|
142
|
+
invalid. No statement, transaction or callback is automatically replayed.
|
|
143
|
+
|
|
144
|
+
The transaction observation is conservative:
|
|
145
|
+
|
|
146
|
+
- A live, acknowledged native transaction is `active`.
|
|
147
|
+
- An acknowledged single-statement completion is `committed`, or `rolled-back`
|
|
148
|
+
after rollback. A multi-statement program with no open transaction remains
|
|
149
|
+
`unknown`: an acknowledgement alone cannot distinguish its internal boundaries.
|
|
150
|
+
- Confirmed process death rolls back an observed open transaction only when no
|
|
151
|
+
pending operation could have committed it.
|
|
152
|
+
- A lost commit reply, multi-statement program or uncertain autocommit mutation
|
|
153
|
+
is `unknown`. Native transaction-state support is observed, never inferred from
|
|
154
|
+
a close promise. Reopen, verify integrity and reconcile a durable receipt before
|
|
155
|
+
retrying a write with an unknown outcome.
|
|
156
|
+
|
|
157
|
+
Autocommit mutation cursors remain `unknown` until their final native frame is
|
|
158
|
+
acknowledged. Creating an iterator or receiving an intermediate `RETURNING` row
|
|
159
|
+
does not acknowledge completion; early cursor return still requires reconciliation.
|
|
160
|
+
|
|
161
|
+
Driver `metrics()` reports capacity, owners, healthy owners and quarantine. A
|
|
162
|
+
file path already owned by the same driver cannot be opened again until exit;
|
|
163
|
+
this reservation uses its resolved path spelling, not filesystem inode identity.
|
|
164
|
+
Different driver instances, aliases and external processes remain the host's
|
|
165
|
+
coordination responsibility. Use one supervisor per ownership domain and retain
|
|
166
|
+
finite SQLite busy and transaction-queue timeouts. The host's service manager must
|
|
167
|
+
also own its process group: an uncatchable parent-process death is not a promise
|
|
168
|
+
that JavaScript can reap every descendant. Child crashes and restart are covered
|
|
169
|
+
by the file/receipt fixtures; power loss and service-manager policy are separate.
|
|
170
|
+
|
|
171
|
+
The ordinary Store composition uses `openStore(model,{driver,path})`. To supervise
|
|
172
|
+
Store calls, retain the connection in a small driver wrapper's `open` method, then
|
|
173
|
+
call `owner.supervise(() => store.transaction(body), options)`. This uses the same
|
|
174
|
+
Store and transaction APIs. After owner loss, close the invalid Store, await owner
|
|
175
|
+
exit and explicitly reopen/reconcile. A failed-generation `close()` may reject;
|
|
176
|
+
its rejection never substitutes for the separate exit observation.
|
|
177
|
+
|
|
178
|
+
All options below are positive safe integers. Row/byte/statement/cursor limits
|
|
179
|
+
retain the thread worker meanings. `maxOwners` includes startup and quarantine;
|
|
180
|
+
`timeoutMs` is the default supervised response deadline. `maxRequestBytes` bounds
|
|
181
|
+
each outgoing request before IPC; parameters must be SQLite scalars or byte arrays.
|
|
182
|
+
The process close deadline
|
|
183
|
+
and startup deadline can reject while termination is still pending. The reserved
|
|
184
|
+
owner credit remains until exit. Direct calls without `supervise`
|
|
185
|
+
retain the ordinary row, queue and step cancellation boundaries.
|
|
186
|
+
|
|
187
|
+
<!--fact:db.execution-options-->
|
|
188
|
+
|
|
189
|
+
| Option | Thread worker | Process owner |
|
|
190
|
+
|---|---:|---:|
|
|
191
|
+
| `windowRows` | 64 | 64 |
|
|
192
|
+
| `windowBytes` | 1048576 | 1048576 |
|
|
193
|
+
| `maxPending` | 64 | 64 |
|
|
194
|
+
| `maxStatements` | 1024 | 1024 |
|
|
195
|
+
| `maxCursors` | 64 | 64 |
|
|
196
|
+
| `allMaxRows` | 100000 | 100000 |
|
|
197
|
+
| `allMaxBytes` | 16777216 | 16777216 |
|
|
198
|
+
| `closeTimeoutMs` | 5000 | 1000 |
|
|
199
|
+
| `startupTimeoutMs` | 10000 | 10000 |
|
|
200
|
+
| `maxOwners` | — | 4 |
|
|
201
|
+
| `timeoutMs` | — | 250 |
|
|
202
|
+
| `maxRequestBytes` | — | 1048576 |
|
|
203
|
+
|
|
204
|
+
<!--/fact-->
|
|
205
|
+
|
|
80
206
|
## WAL pool policy
|
|
81
207
|
|
|
82
208
|
A file pool opens exactly one writer and `readers` read-only workers, verifies WAL,
|
|
@@ -283,9 +409,10 @@ sync twin. Physical adoption on PostgreSQL refuses pending a separate mapping
|
|
|
283
409
|
and codec qualification. Unknown application-trigger effects do not qualify
|
|
284
410
|
capture or replication; those combinations refuse before an adoption claim.
|
|
285
411
|
|
|
286
|
-
Node backups use the built-in online snapshot. Bun uses `
|
|
287
|
-
under the store gate,
|
|
288
|
-
|
|
289
|
-
image
|
|
412
|
+
Node backups use the built-in online snapshot. Bun uses disk-backed `VACUUM INTO`
|
|
413
|
+
under the store gate, flushes a sibling temporary, then uses the shared atomic
|
|
414
|
+
publisher. Both include committed WAL. Bun allocates no whole-database JavaScript
|
|
415
|
+
image; native SQLite caches and temporary storage govern working memory, and
|
|
416
|
+
cancellation takes effect between phases. Process-kill tests
|
|
290
417
|
cover rebuild copy, table drop, commit and backup publication on both hosts;
|
|
291
418
|
these tests do not establish power-loss durability or native executable packaging.
|
package/docs/MODEL-FORMAT.md
CHANGED
|
@@ -1101,6 +1101,7 @@ error.
|
|
|
1101
1101
|
| `JD2094` | invalid or uncommitted durable snapshot; reopen the last committed version |
|
|
1102
1102
|
| `JD2095` | trusted SQL or synchronous callback authority refused |
|
|
1103
1103
|
| `JD2096` | persistence invariant rejected the mutation; constraint class |
|
|
1104
|
+
| `JD2097` | supervised native operation cancelled or past its response deadline; owner exit and transaction fate are reported separately |
|
|
1104
1105
|
| `JD0060` | a replication envelope or snapshot is invalid |
|
|
1105
1106
|
| `JD2100` | a replica sequence or causal dependency has a gap |
|
|
1106
1107
|
| `JD2101` | an envelope identity names different content or an unknown local origin |
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarenjs/db",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.86.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./types/index.d.ts",
|
|
8
8
|
"sideEffects": false,
|
|
9
9
|
"exports": {
|
|
10
|
+
"./node-process": {
|
|
11
|
+
"types": "./types/node-process.d.ts",
|
|
12
|
+
"default": "./src/drivers/node-process.js"
|
|
13
|
+
},
|
|
10
14
|
"./search": {
|
|
11
15
|
"types": "./types/search.d.ts",
|
|
12
16
|
"default": "./src/search.js"
|
|
@@ -104,9 +108,9 @@
|
|
|
104
108
|
"prepack": "npm run build:types"
|
|
105
109
|
},
|
|
106
110
|
"dependencies": {
|
|
107
|
-
"@jarenjs/core": "^0.
|
|
108
|
-
"@jarenjs/json": "^0.
|
|
109
|
-
"@jarenjs/validate": "^0.
|
|
111
|
+
"@jarenjs/core": "^0.86.0",
|
|
112
|
+
"@jarenjs/json": "^0.86.0",
|
|
113
|
+
"@jarenjs/validate": "^0.86.0"
|
|
110
114
|
},
|
|
111
115
|
"bin": {
|
|
112
116
|
"jaren-db": "./src/cli.js"
|
package/src/driver.js
CHANGED
|
@@ -292,6 +292,8 @@ export function baseCapabilities() {
|
|
|
292
292
|
sessions: false,
|
|
293
293
|
sessionReason: null,
|
|
294
294
|
worker: false,
|
|
295
|
+
process: false,
|
|
296
|
+
ownerTermination: false,
|
|
295
297
|
pooling: false,
|
|
296
298
|
poolReaders: 0,
|
|
297
299
|
poolWriters: 0,
|
|
@@ -809,6 +811,7 @@ export function finishConnection(raw, dialect, synchronous, capabilities, queueT
|
|
|
809
811
|
if (pending.some(isThenable)) return closeResult = Promise.allSettled(pending).then(() => raw.close());
|
|
810
812
|
return closeResult = raw.close();
|
|
811
813
|
},
|
|
814
|
+
transactionState: () => raw.transactionState?.() ?? null,
|
|
812
815
|
registerFunction: typeof raw.registerFunction === 'function'
|
|
813
816
|
? (name, functionOptions, fn) => { requireOpen(); return raw.registerFunction(name, functionOptions, fn); }
|
|
814
817
|
: null,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** The child owns SQLite; only bounded protocol frames cross IPC. */
|
|
3
|
+
import { serveSqliteEndpoint } from './sqlite-endpoint.js';
|
|
4
|
+
|
|
5
|
+
process.once('message', (configuration) => {
|
|
6
|
+
const port = {
|
|
7
|
+
postMessage: (message) => { if (process.connected) process.send(message); },
|
|
8
|
+
on: (name, listener) => process.on(name, listener),
|
|
9
|
+
close: () => { if (process.connected) process.disconnect(); },
|
|
10
|
+
};
|
|
11
|
+
serveSqliteEndpoint(port, configuration);
|
|
12
|
+
});
|
|
13
|
+
process.once('disconnect', () => process.exit(0));
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Supervised process ownership; response cancellation never implies SQL rollback. */
|
|
3
|
+
import { sqliteDialect } from '../dialects/sqlite.js';
|
|
4
|
+
import { sqlTokens } from '../dialects/check-read.js';
|
|
5
|
+
import { DbCompileError, DbRuntimeError } from '../errors.js';
|
|
6
|
+
import { createWorkerConnection } from './worker-client.js';
|
|
7
|
+
import { positiveOption, queueFailure, rowBytes, workerSettings, PROCESS_DEFAULTS } from './worker-protocol.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Finite owned SQLite processes behind the ordinary Driver contract.
|
|
11
|
+
* A quarantined process retains its admission credit until its exit event.
|
|
12
|
+
* @param {import('../../types/node-process.js').NodeProcessOptions} [configuration]
|
|
13
|
+
* @returns {import('../../types/node-process.js').NodeProcessDriver}
|
|
14
|
+
*/
|
|
15
|
+
export function nodeProcessDriver(configuration = {}) {
|
|
16
|
+
const maxOwners = positiveOption('maxOwners', configuration.maxOwners, PROCESS_DEFAULTS.maxOwners);
|
|
17
|
+
const timeoutMs = positiveOption('timeoutMs', configuration.timeoutMs, PROCESS_DEFAULTS.timeoutMs);
|
|
18
|
+
const maxRequestBytes = positiveOption('maxRequestBytes', configuration.maxRequestBytes, PROCESS_DEFAULTS.maxRequestBytes);
|
|
19
|
+
const settings = workerSettings(configuration, PROCESS_DEFAULTS);
|
|
20
|
+
const { limits } = settings;
|
|
21
|
+
const owners = new Set();
|
|
22
|
+
let generation = 0;
|
|
23
|
+
const driver = {
|
|
24
|
+
name: 'node-process-sqlite', dialect: sqliteDialect,
|
|
25
|
+
metrics: () => Object.freeze({ capacity: maxOwners, owners: owners.size,
|
|
26
|
+
quarantined: [...owners].filter((owner) => owner.status === 'quarantined').length,
|
|
27
|
+
healthy: [...owners].filter((owner) => owner.status === 'healthy').length }),
|
|
28
|
+
async open(path = ':memory:', options = {}) {
|
|
29
|
+
if (globalThis.process?.versions?.bun || globalThis.process?.release?.name !== 'node'
|
|
30
|
+
|| Number(process.versions.node.split('.')[0]) < 24)
|
|
31
|
+
throw new DbCompileError('JD0003', 'supervised SQLite processes require Node.js 24 or newer');
|
|
32
|
+
const [{ fork }, { resolve }] = await Promise.all([import('node:child_process'), import('node:path')]);
|
|
33
|
+
const identity = path === ':memory:' ? null : resolve(path);
|
|
34
|
+
if (owners.size >= maxOwners || identity && [...owners].some((owner) => owner.path === identity))
|
|
35
|
+
throw queueFailure('process owner capacity or database ownership is reserved', owners.size);
|
|
36
|
+
const epoch = ++generation;
|
|
37
|
+
const state = { path: identity, status: 'starting', transaction: 'none', generation: epoch, pid: null,
|
|
38
|
+
safeToReplace: false, exitCode: null, exitSignal: null };
|
|
39
|
+
owners.add(state);
|
|
40
|
+
let child;
|
|
41
|
+
try {
|
|
42
|
+
child = fork(new URL('./node-process-endpoint.js', import.meta.url), [], {
|
|
43
|
+
serialization: 'advanced', stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
44
|
+
execArgv: ['--no-warnings=ExperimentalWarning'],
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (error) { owners.delete(state); throw error; }
|
|
48
|
+
state.pid = child.pid ?? null;
|
|
49
|
+
let resolveExit, control, killPromise, active = false, closeAcknowledged = false;
|
|
50
|
+
let transactionOpen = false, ambiguous = false;
|
|
51
|
+
const prepared = new Map();
|
|
52
|
+
const cursors = new Map();
|
|
53
|
+
const report = () => Object.freeze({ ...state });
|
|
54
|
+
const exited = new Promise((resolve) => { resolveExit = resolve; });
|
|
55
|
+
child.once('exit', (code, signal) => {
|
|
56
|
+
state.status = 'exited'; state.safeToReplace = true;
|
|
57
|
+
state.exitCode = code; state.exitSignal = signal;
|
|
58
|
+
queueMicrotask(() => {
|
|
59
|
+
if (ambiguous) state.transaction = 'unknown';
|
|
60
|
+
else if (transactionOpen) state.transaction = 'rolled-back';
|
|
61
|
+
owners.delete(state); resolveExit(report());
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
// A failed spawn has no owner to quarantine and emits no exit event.
|
|
65
|
+
child.once('error', () => {
|
|
66
|
+
if (child.pid === undefined) {
|
|
67
|
+
state.status = 'exited'; state.safeToReplace = true;
|
|
68
|
+
owners.delete(state); resolveExit(report());
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
const terminate = () => {
|
|
72
|
+
if (!killPromise) {
|
|
73
|
+
if (state.status !== 'exited' && !closeAcknowledged) { state.status = 'quarantined'; child.kill('SIGKILL'); }
|
|
74
|
+
killPromise = exited;
|
|
75
|
+
}
|
|
76
|
+
return killPromise;
|
|
77
|
+
};
|
|
78
|
+
const transport = {
|
|
79
|
+
on: (name, fn) => child.on(name, fn),
|
|
80
|
+
postMessage: (message) => child.send(message, (error) => { if (error) control?.lose(error); }),
|
|
81
|
+
terminate, unref: () => {},
|
|
82
|
+
};
|
|
83
|
+
const info = (sql) => {
|
|
84
|
+
const tokens = sqlTokens(sql), words = tokens.filter((token) => token.kind === 'word').map((token) => token.value.toUpperCase());
|
|
85
|
+
const multiple = tokens.some((token, i) => token.kind === 'symbol' && token.value === ';' && i < tokens.length - 1);
|
|
86
|
+
return { multiple, end: multiple || ['COMMIT', 'END', 'RELEASE'].includes(words[0]),
|
|
87
|
+
rollback: words[0] === 'ROLLBACK' && !words.includes('TO'),
|
|
88
|
+
writes: multiple || !['SELECT', 'WITH'].includes(words[0]) || words.some((word) => ['INSERT', 'UPDATE', 'DELETE', 'REPLACE'].includes(word)) };
|
|
89
|
+
};
|
|
90
|
+
const unknownStatement = { multiple: true, writes: true, end: true };
|
|
91
|
+
const execution = (op, data) => op === 'exec' ? info(data.sql)
|
|
92
|
+
: ['next', 'return'].includes(op) ? cursors.get(data.cursor) ?? unknownStatement
|
|
93
|
+
: ['run', 'get', 'iterate'].includes(op) ? prepared.get(data.statement) ?? unknownStatement : null;
|
|
94
|
+
const observe = (op, data, open, failed = false, statement = execution(op, data)) => {
|
|
95
|
+
if (typeof open !== 'boolean') return;
|
|
96
|
+
const wasOpen = transactionOpen;
|
|
97
|
+
transactionOpen = open;
|
|
98
|
+
if (open) { ambiguous = false; state.transaction = 'active'; }
|
|
99
|
+
else if (statement?.multiple || [...cursors.values()].some((cursor) => cursor.writes)
|
|
100
|
+
|| op === 'return' && statement?.writes) { ambiguous = true; state.transaction = 'unknown'; }
|
|
101
|
+
else if (failed && wasOpen || statement?.rollback) {
|
|
102
|
+
ambiguous = false; state.transaction = 'rolled-back';
|
|
103
|
+
}
|
|
104
|
+
else if (!failed && statement && (wasOpen || ambiguous && statement.writes)) { ambiguous = false; state.transaction = 'committed'; }
|
|
105
|
+
};
|
|
106
|
+
const hooks = {
|
|
107
|
+
control: (value) => { control = value; },
|
|
108
|
+
lost: (error, pending) => {
|
|
109
|
+
const live = pending.map(({ op, data }) => execution(op, data)).filter(Boolean);
|
|
110
|
+
ambiguous = live.some((statement) => statement.end || statement.writes && !transactionOpen)
|
|
111
|
+
|| !transactionOpen && [...cursors.values()].some((statement) => statement.writes);
|
|
112
|
+
if (ambiguous) state.transaction = 'unknown';
|
|
113
|
+
if (ambiguous || transactionOpen) error.retryable = false;
|
|
114
|
+
if (state.status !== 'exited') { state.status = 'quarantined'; terminate(); }
|
|
115
|
+
},
|
|
116
|
+
request: (op, data) => {
|
|
117
|
+
if (data.params?.some((param) => param !== null && !['number', 'bigint', 'string'].includes(typeof param) && !(param instanceof Uint8Array)))
|
|
118
|
+
throw new DbRuntimeError('JD2093', 'process parameters must be SQLite scalars or byte arrays');
|
|
119
|
+
if (rowBytes(data) > maxRequestBytes) throw new DbRuntimeError('JD2092', 'process request exceeds its byte credit');
|
|
120
|
+
const statement = execution(op, data);
|
|
121
|
+
if (statement && (statement.end || statement.writes && !transactionOpen)) {
|
|
122
|
+
ambiguous = true; state.transaction = 'unknown';
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
failure: (op, data, open) => observe(op, data, open, true),
|
|
126
|
+
result: (op, data, value, open) => {
|
|
127
|
+
const statement = execution(op, data);
|
|
128
|
+
if (op === 'close') closeAcknowledged = true;
|
|
129
|
+
if (op === 'prepare') {
|
|
130
|
+
if (prepared.size >= limits.statements) prepared.delete(prepared.keys().next().value);
|
|
131
|
+
prepared.set(value, info(data.sql));
|
|
132
|
+
}
|
|
133
|
+
if (op === 'iterate') cursors.set(value, statement);
|
|
134
|
+
if (op === 'return' || op === 'next' && value.done) cursors.delete(data.cursor);
|
|
135
|
+
observe(op, data, open, false, statement);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
const opening = createWorkerConnection(transport, { ...settings, epoch, options, hooks, awaitStartupExit: false,
|
|
139
|
+
reopen: () => driver.open(path, options) });
|
|
140
|
+
child.send({ generation: epoch, path, options: { timeout: options.timeout, readOnly: options.readOnly }, limits });
|
|
141
|
+
const connection = await opening;
|
|
142
|
+
state.status = 'healthy';
|
|
143
|
+
const cancel = (reason = 'caller cancelled') => {
|
|
144
|
+
const error = Object.assign(new DbRuntimeError('JD2097', String(reason)), {
|
|
145
|
+
generation: epoch, retryable: false, class: 'cancelled',
|
|
146
|
+
});
|
|
147
|
+
control.lose(error); terminate(); return error;
|
|
148
|
+
};
|
|
149
|
+
const result = Object.freeze({ ...connection,
|
|
150
|
+
get mustQueue() { return connection.mustQueue; },
|
|
151
|
+
capabilities: Object.freeze({ ...connection.capabilities, worker: false, process: true,
|
|
152
|
+
ownerTermination: true, cancellation: Object.freeze({ ...connection.capabilities.cancellation, midStatement: false }) }),
|
|
153
|
+
settlement: report, settled: () => exited, cancel,
|
|
154
|
+
metrics: () => Object.freeze({ ...connection.metrics(), owner: report(), supervised: active ? 1 : 0 }),
|
|
155
|
+
async supervise(body, request = {}) {
|
|
156
|
+
if (active) throw queueFailure('one supervised operation already owns this process', 1);
|
|
157
|
+
const bound = positiveOption('timeoutMs', request.timeoutMs, timeoutMs);
|
|
158
|
+
if (request.signal?.aborted) throw Object.assign(new DbRuntimeError('JD2097', 'cancelled before admission'), { generation: epoch, retryable: true, class: 'cancelled' });
|
|
159
|
+
if (state.status !== 'healthy') throw new DbRuntimeError('JD2090', 'process owner is not healthy');
|
|
160
|
+
active = true;
|
|
161
|
+
let timer, abort;
|
|
162
|
+
try {
|
|
163
|
+
return await new Promise((resolve, reject) => {
|
|
164
|
+
abort = () => { const error = cancel('supervised operation was cancelled'); reject(error); };
|
|
165
|
+
request.signal?.addEventListener('abort', abort, { once: true });
|
|
166
|
+
timer = setTimeout(() => { const error = cancel('supervised operation exceeded its response deadline'); reject(error); }, bound);
|
|
167
|
+
Promise.resolve().then(() => body(result)).then(resolve, reject);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
finally { active = false; clearTimeout(timer); request.signal?.removeEventListener('abort', abort); }
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
return result;
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
return Object.freeze(driver);
|
|
177
|
+
}
|
|
@@ -1,105 +1,5 @@
|
|
|
1
1
|
//@ts-check
|
|
2
|
-
/**
|
|
2
|
+
/** A thread owns one SQLite endpoint and its bounded protocol frames. */
|
|
3
3
|
import { parentPort, workerData } from 'node:worker_threads';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
import { validRequest, generationFailure, rowBytes } from './worker-protocol.js';
|
|
7
|
-
|
|
8
|
-
const { generation, path, options, limits } = workerData;
|
|
9
|
-
let connection;
|
|
10
|
-
let sequence = 0;
|
|
11
|
-
const statements = new Map();
|
|
12
|
-
const cursors = new Map();
|
|
13
|
-
const frame = (kind, id, value) => ({ v: 1, generation, kind, id, ...value });
|
|
14
|
-
const bound = (reason) => new DbRuntimeError('JD2092', reason);
|
|
15
|
-
const release = (id) => {
|
|
16
|
-
const cursor = cursors.get(id);
|
|
17
|
-
if (cursor === undefined) return;
|
|
18
|
-
cursors.delete(id);
|
|
19
|
-
cursor.iterator.return?.();
|
|
20
|
-
if (cursor.ephemeral) statements.delete(cursor.statement);
|
|
21
|
-
};
|
|
22
|
-
const statementOf = (id) => {
|
|
23
|
-
const statement = statements.get(id);
|
|
24
|
-
if (statement === undefined) throw generationFailure(generation);
|
|
25
|
-
return statement;
|
|
26
|
-
};
|
|
27
|
-
const dispatch = (request) => {
|
|
28
|
-
switch (request.op) {
|
|
29
|
-
case 'exec': return connection.exec(request.sql);
|
|
30
|
-
case 'prepare': {
|
|
31
|
-
if (statements.size >= limits.statements) throw bound(`worker statement capacity ${limits.statements} exceeded`);
|
|
32
|
-
const id = ++sequence;
|
|
33
|
-
statements.set(id, { statement: connection.prepare(request.sql), ephemeral: request.ephemeral === true });
|
|
34
|
-
return id;
|
|
35
|
-
}
|
|
36
|
-
case 'get': {
|
|
37
|
-
const row = statementOf(request.statement).statement.get(request.params);
|
|
38
|
-
if (rowBytes(row) > limits.bytes) throw bound(`one worker row exceeds ${limits.bytes} bytes`);
|
|
39
|
-
return row;
|
|
40
|
-
}
|
|
41
|
-
case 'run': return statementOf(request.statement).statement.run(request.params);
|
|
42
|
-
case 'iterate': {
|
|
43
|
-
if (cursors.size >= limits.cursors) throw bound(`worker cursor capacity ${limits.cursors} exceeded`);
|
|
44
|
-
const record = statementOf(request.statement);
|
|
45
|
-
const id = ++sequence;
|
|
46
|
-
cursors.set(id, { iterator: record.statement.iterate(request.params),
|
|
47
|
-
statement: request.statement, ephemeral: record.ephemeral, buffered: null });
|
|
48
|
-
return id;
|
|
49
|
-
}
|
|
50
|
-
case 'next': {
|
|
51
|
-
const cursor = cursors.get(request.cursor);
|
|
52
|
-
if (cursor === undefined) throw generationFailure(generation);
|
|
53
|
-
if (request.rows > limits.rows || request.bytes > limits.bytes) throw bound('worker cursor credit exceeds the negotiated window');
|
|
54
|
-
const rows = [];
|
|
55
|
-
let bytes = 0;
|
|
56
|
-
try {
|
|
57
|
-
while (rows.length < request.rows) {
|
|
58
|
-
const step = cursor.buffered ?? cursor.iterator.next();
|
|
59
|
-
cursor.buffered = null;
|
|
60
|
-
if (step.done) { release(request.cursor); return { rows, bytes, done: true }; }
|
|
61
|
-
const size = rowBytes(step.value);
|
|
62
|
-
if (size > request.bytes) throw bound(`one worker row of ${size} bytes exceeds the ${request.bytes} byte window`);
|
|
63
|
-
if (bytes + size > request.bytes) { cursor.buffered = step; break; }
|
|
64
|
-
rows.push(step.value);
|
|
65
|
-
bytes += size;
|
|
66
|
-
}
|
|
67
|
-
return { rows, bytes, done: false };
|
|
68
|
-
}
|
|
69
|
-
catch (error) { release(request.cursor); throw error; }
|
|
70
|
-
}
|
|
71
|
-
case 'return': release(request.cursor); return undefined;
|
|
72
|
-
case 'finalize': {
|
|
73
|
-
for (const [id, cursor] of cursors) if (cursor.statement === request.statement) release(id);
|
|
74
|
-
statements.delete(request.statement);
|
|
75
|
-
return undefined;
|
|
76
|
-
}
|
|
77
|
-
case 'close': {
|
|
78
|
-
for (const id of cursors.keys()) release(id);
|
|
79
|
-
statements.clear();
|
|
80
|
-
return connection.close();
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
try {
|
|
85
|
-
connection = await nodeDriver().open(path, options);
|
|
86
|
-
// Functions cannot cross structured clone. Capture uses the existing
|
|
87
|
-
// journal path; a live query requires a synchronous connection.
|
|
88
|
-
parentPort.postMessage(frame('ready', 0, { capabilities: { ...connection.capabilities,
|
|
89
|
-
sessions: false, userFunctions: false, deterministicIndexableFunctions: false,
|
|
90
|
-
aggregateFunctions: false, backup: false, worker: true, pooling: false } }));
|
|
91
|
-
parentPort.on('message', (request) => {
|
|
92
|
-
try {
|
|
93
|
-
if (!validRequest(request)) throw new DbRuntimeError('JD2093', 'invalid worker protocol request');
|
|
94
|
-
if (request.generation !== generation) throw generationFailure(request.generation);
|
|
95
|
-
const value = dispatch(request);
|
|
96
|
-
parentPort.postMessage(frame('result', request.id, { value }));
|
|
97
|
-
if (request.op === 'close') parentPort.close();
|
|
98
|
-
}
|
|
99
|
-
catch (error) { parentPort.postMessage(frame('failure', request?.id ?? 0, { error: cloneDriverError(error) })); }
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
catch (error) {
|
|
103
|
-
parentPort.postMessage(frame('failure', 0, { error: cloneDriverError(error) }));
|
|
104
|
-
parentPort.close();
|
|
105
|
-
}
|
|
4
|
+
import { serveSqliteEndpoint } from './sqlite-endpoint.js';
|
|
5
|
+
await serveSqliteEndpoint(parentPort, workerData);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
//@ts-check
|
|
2
2
|
/** A worker transport behind the ordinary Connection contract. */
|
|
3
|
-
import {
|
|
3
|
+
import { lazyOpen } from '../driver.js';
|
|
4
4
|
import { sqliteDialect } from '../dialects/sqlite.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { createWorkerConnection } from './worker-client.js';
|
|
6
|
+
import { workerSettings } from './worker-protocol.js';
|
|
7
7
|
|
|
8
8
|
/** Dedicated SQLite worker per connection. No function serialization or write replay.
|
|
9
9
|
* @param {{ windowRows?: number, windowBytes?: number, maxPending?: number,
|
|
@@ -12,17 +12,7 @@ import { generationFailure, positiveOption, queueFailure, rowBytes, validRespons
|
|
|
12
12
|
* @returns {any} a Driver
|
|
13
13
|
*/
|
|
14
14
|
export function nodeWorkerDriver(configuration = {}) {
|
|
15
|
-
const limits =
|
|
16
|
-
rows: positiveOption('windowRows', configuration.windowRows, 64),
|
|
17
|
-
bytes: positiveOption('windowBytes', configuration.windowBytes, 1024 * 1024),
|
|
18
|
-
statements: positiveOption('maxStatements', configuration.maxStatements, 1024),
|
|
19
|
-
cursors: positiveOption('maxCursors', configuration.maxCursors, 64),
|
|
20
|
-
};
|
|
21
|
-
const maxPending = positiveOption('maxPending', configuration.maxPending, 64);
|
|
22
|
-
const allRows = positiveOption('allMaxRows', configuration.allMaxRows, 100000);
|
|
23
|
-
const allBytes = positiveOption('allMaxBytes', configuration.allMaxBytes, 16 * 1024 * 1024);
|
|
24
|
-
const closeMs = positiveOption('closeTimeoutMs', configuration.closeTimeoutMs, 5000);
|
|
25
|
-
const startupMs = positiveOption('startupTimeoutMs', configuration.startupTimeoutMs, 10000);
|
|
15
|
+
const { limits, maxPending, allRows, allBytes, closeMs, startupMs } = workerSettings(configuration);
|
|
26
16
|
let generation = 0;
|
|
27
17
|
const driver = {
|
|
28
18
|
name: 'node-worker-sqlite', dialect: sqliteDialect,
|
|
@@ -34,170 +24,8 @@ export function nodeWorkerDriver(configuration = {}) {
|
|
|
34
24
|
// Parent --test/--input-type/preloads do not describe the endpoint.
|
|
35
25
|
execArgv: ['--no-warnings=ExperimentalWarning'],
|
|
36
26
|
});
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
let failed = null;
|
|
40
|
-
let closing = false;
|
|
41
|
-
let closed = false;
|
|
42
|
-
let transactionDepth = 0;
|
|
43
|
-
let closePromise;
|
|
44
|
-
const metrics = { frames: 0, rows: 0, maxFrameRows: 0, maxFrameBytes: 0, maxPending: 0 };
|
|
45
|
-
let readyResolve;
|
|
46
|
-
let readyReject;
|
|
47
|
-
const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
|
|
48
|
-
const lose = (cause) => {
|
|
49
|
-
if (failed !== null || closed) return;
|
|
50
|
-
failed = generationFailure(epoch, transactionDepth > 0, cause);
|
|
51
|
-
readyReject(failed);
|
|
52
|
-
for (const request of pending.values()) request.reject(failed);
|
|
53
|
-
pending.clear();
|
|
54
|
-
};
|
|
55
|
-
worker.on('error', lose);
|
|
56
|
-
worker.on('exit', (code) => { if (!closed) lose(new Error(`worker exited (${code})`)); });
|
|
57
|
-
worker.on('message', (message) => {
|
|
58
|
-
if (!validResponse(message, epoch)) { lose(new Error('invalid worker response')); return; }
|
|
59
|
-
if (message.kind === 'ready') { readyResolve(message.capabilities); return; }
|
|
60
|
-
if (message.kind === 'failure' && message.id === 0) {
|
|
61
|
-
readyReject(Object.assign(new Error(message.error.message), message.error)); return;
|
|
62
|
-
}
|
|
63
|
-
const request = pending.get(message.id);
|
|
64
|
-
if (request === undefined) return;
|
|
65
|
-
pending.delete(message.id);
|
|
66
|
-
if (message.kind === 'failure') {
|
|
67
|
-
const error = message.error;
|
|
68
|
-
request.reject(Object.assign(error.code?.startsWith('JD')
|
|
69
|
-
? new DbRuntimeError(error.code, error.message) : new Error(error.message), error));
|
|
70
|
-
}
|
|
71
|
-
else if (message.kind === 'result' && validResult(request.op, message.value, limits)) request.resolve(message.value);
|
|
72
|
-
else { request.reject(generationFailure(epoch, transactionDepth > 0)); lose(new Error('invalid worker response')); }
|
|
73
|
-
});
|
|
74
|
-
const timer = setTimeout(() => { lose(new Error('worker startup timed out')); worker.terminate(); }, startupMs);
|
|
75
|
-
let capabilities;
|
|
76
|
-
try { capabilities = await ready; }
|
|
77
|
-
catch (error) { await worker.terminate(); throw error; }
|
|
78
|
-
finally { clearTimeout(timer); }
|
|
79
|
-
const request = (op, data = {}, cleanup = false) => {
|
|
80
|
-
if (failed !== null) return Promise.reject(failed);
|
|
81
|
-
if (closed || (closing && !cleanup)) return Promise.reject(new DbRuntimeError('JD2063', 'the worker connection is closing or closed'));
|
|
82
|
-
if (!cleanup && pending.size >= maxPending)
|
|
83
|
-
return Promise.reject(queueFailure(`worker request capacity ${maxPending} exceeded`, pending.size));
|
|
84
|
-
// One return per live cursor and one close have reserved capacity.
|
|
85
|
-
if (cleanup && pending.size >= maxPending + limits.cursors + 1)
|
|
86
|
-
return Promise.reject(queueFailure('worker cleanup capacity exceeded', pending.size));
|
|
87
|
-
const id = ++sequence;
|
|
88
|
-
return new Promise((resolve, reject) => {
|
|
89
|
-
pending.set(id, { resolve, reject, op });
|
|
90
|
-
metrics.maxPending = Math.max(metrics.maxPending, pending.size);
|
|
91
|
-
try { worker.postMessage({ v: 1, generation: epoch, kind: 'request', id, op, ...data }); }
|
|
92
|
-
catch (error) { pending.delete(id); reject(error); }
|
|
93
|
-
});
|
|
94
|
-
};
|
|
95
|
-
const raw = {
|
|
96
|
-
closeDrainsIterators: true,
|
|
97
|
-
exec: (sql) => {
|
|
98
|
-
if (/^(?:SAVEPOINT|BEGIN)\b/.test(sql)) transactionDepth++;
|
|
99
|
-
return request('exec', { sql }).then((value) => {
|
|
100
|
-
if (/^RELEASE\b/.test(sql)) transactionDepth = Math.max(0, transactionDepth - 1);
|
|
101
|
-
if (/^(?:COMMIT|ROLLBACK(?! TO))\b/.test(sql)) transactionDepth = 0;
|
|
102
|
-
return value;
|
|
103
|
-
});
|
|
104
|
-
},
|
|
105
|
-
prepare: async (sql, metadata = {}) => {
|
|
106
|
-
const id = await request('prepare', { sql, ephemeral: metadata.ephemeral === true });
|
|
107
|
-
const iterate = async (params = []) => {
|
|
108
|
-
const cursor = await request('iterate', { statement: id, params });
|
|
109
|
-
let rows = [];
|
|
110
|
-
let at = 0;
|
|
111
|
-
let done = false;
|
|
112
|
-
let returned = false;
|
|
113
|
-
let pulling = Promise.resolve();
|
|
114
|
-
const next = async () => {
|
|
115
|
-
if (failed !== null) throw failed;
|
|
116
|
-
if (returned) return { done: true, value: undefined };
|
|
117
|
-
if (at < rows.length) return { done: false, value: rows[at++] };
|
|
118
|
-
if (done) return { done: true, value: undefined };
|
|
119
|
-
const batch = await request('next', { cursor, rows: limits.rows, bytes: limits.bytes });
|
|
120
|
-
metrics.frames++;
|
|
121
|
-
metrics.rows += batch.rows.length;
|
|
122
|
-
metrics.maxFrameRows = Math.max(metrics.maxFrameRows, batch.rows.length);
|
|
123
|
-
metrics.maxFrameBytes = Math.max(metrics.maxFrameBytes, batch.bytes);
|
|
124
|
-
if (returned) return { done: true, value: undefined };
|
|
125
|
-
rows = batch.rows;
|
|
126
|
-
at = 0;
|
|
127
|
-
done = batch.done;
|
|
128
|
-
return at < rows.length ? { done: false, value: rows[at++] } : { done: true, value: undefined };
|
|
129
|
-
};
|
|
130
|
-
return {
|
|
131
|
-
next: () => {
|
|
132
|
-
const result = pulling.then(next);
|
|
133
|
-
pulling = result.then(() => undefined, () => undefined);
|
|
134
|
-
return result;
|
|
135
|
-
},
|
|
136
|
-
return: async () => {
|
|
137
|
-
if (returned) return { done: true, value: undefined };
|
|
138
|
-
returned = true;
|
|
139
|
-
rows = [];
|
|
140
|
-
if (!done) await request('return', { cursor }, true);
|
|
141
|
-
return { done: true, value: undefined };
|
|
142
|
-
},
|
|
143
|
-
};
|
|
144
|
-
};
|
|
145
|
-
return {
|
|
146
|
-
run: (params = []) => request('run', { statement: id, params }),
|
|
147
|
-
get: (params = []) => request('get', { statement: id, params }),
|
|
148
|
-
iterate,
|
|
149
|
-
all: async (params = []) => {
|
|
150
|
-
const iterator = await iterate(params);
|
|
151
|
-
const rows = [];
|
|
152
|
-
let bytes = 0;
|
|
153
|
-
try {
|
|
154
|
-
for (;;) {
|
|
155
|
-
const step = await iterator.next();
|
|
156
|
-
if (step.done) return rows;
|
|
157
|
-
bytes += rowBytes(step.value);
|
|
158
|
-
if (rows.length >= allRows || bytes > allBytes)
|
|
159
|
-
throw new DbRuntimeError('JD2092', `worker all() exceeds its ${allRows} row / ${allBytes} byte bound; use a cursor`);
|
|
160
|
-
rows.push(step.value);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
finally { await iterator.return(); }
|
|
164
|
-
},
|
|
165
|
-
};
|
|
166
|
-
},
|
|
167
|
-
close: () => {
|
|
168
|
-
if (closePromise !== undefined) return closePromise;
|
|
169
|
-
closing = true;
|
|
170
|
-
closePromise = new Promise((resolve, reject) => {
|
|
171
|
-
const timeout = setTimeout(() => {
|
|
172
|
-
lose(new Error('worker close timed out'));
|
|
173
|
-
// V8 termination cannot preempt a synchronous native SQLite
|
|
174
|
-
// call. Fence and detach now; never hold the caller's deadline
|
|
175
|
-
// hostage to that native call or claim it was rolled back.
|
|
176
|
-
worker.unref();
|
|
177
|
-
worker.terminate().catch(() => {});
|
|
178
|
-
reject(failed);
|
|
179
|
-
}, closeMs);
|
|
180
|
-
request('close', {}, true).then(() => {
|
|
181
|
-
closed = true;
|
|
182
|
-
clearTimeout(timeout);
|
|
183
|
-
worker.terminate().then(() => resolve(undefined), reject);
|
|
184
|
-
}, (error) => {
|
|
185
|
-
clearTimeout(timeout);
|
|
186
|
-
worker.unref();
|
|
187
|
-
worker.terminate().catch(() => {});
|
|
188
|
-
reject(error);
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
return closePromise;
|
|
192
|
-
},
|
|
193
|
-
};
|
|
194
|
-
const connection = finishConnection(raw, sqliteDialect, false, Object.freeze(capabilities), options.queueTimeout);
|
|
195
|
-
return Object.freeze({ ...connection,
|
|
196
|
-
get mustQueue() { return connection.mustQueue; },
|
|
197
|
-
generation: epoch,
|
|
198
|
-
metrics: () => Object.freeze({ ...metrics, pending: pending.size, generation: epoch, healthy: failed === null && !closed }),
|
|
199
|
-
restart: async () => { lose(new Error('worker restarted')); await worker.terminate(); return driver.open(path, options); },
|
|
200
|
-
});
|
|
27
|
+
return createWorkerConnection(worker, { epoch, options, limits, maxPending, allRows, allBytes,
|
|
28
|
+
closeMs, startupMs, reopen: () => driver.open(path, options) });
|
|
201
29
|
}, []),
|
|
202
30
|
};
|
|
203
31
|
return Object.freeze(driver);
|
package/src/drivers/node.js
CHANGED
|
@@ -38,6 +38,7 @@ export function adaptNodeDatabase(db, options) {
|
|
|
38
38
|
};
|
|
39
39
|
},
|
|
40
40
|
close: () => db.close(),
|
|
41
|
+
transactionState: () => typeof db.isTransaction === 'boolean' ? db.isTransaction : null,
|
|
41
42
|
// each optional primitive is exposed only when the HANDLE has it, so
|
|
42
43
|
// a substitute that carries less than `node:sqlite` reports less —
|
|
43
44
|
// a declared capability the handle cannot honour is a TypeError at
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** One worker owns one SQLite handle. Only bounded credit frames carry rows. */
|
|
3
|
+
import { nodeDriver } from './node.js';
|
|
4
|
+
import { DbRuntimeError, cloneDriverError } from '../errors.js';
|
|
5
|
+
import { validRequest, generationFailure, rowBytes } from './worker-protocol.js';
|
|
6
|
+
|
|
7
|
+
/** @param {any} parentPort @param {any} configuration */
|
|
8
|
+
export async function serveSqliteEndpoint(parentPort, configuration) {
|
|
9
|
+
const { generation, path, options, limits } = configuration;
|
|
10
|
+
let connection;
|
|
11
|
+
let sequence = 0;
|
|
12
|
+
const statements = new Map();
|
|
13
|
+
const cursors = new Map();
|
|
14
|
+
const frame = (kind, id, value) => ({ v: 1, generation, kind, id, ...value });
|
|
15
|
+
const transaction = () => {
|
|
16
|
+
try { return connection.transactionState(); }
|
|
17
|
+
catch { return null; }
|
|
18
|
+
};
|
|
19
|
+
const bound = (reason) => new DbRuntimeError('JD2092', reason);
|
|
20
|
+
const release = (id) => {
|
|
21
|
+
const cursor = cursors.get(id);
|
|
22
|
+
if (cursor === undefined) return;
|
|
23
|
+
cursors.delete(id);
|
|
24
|
+
cursor.iterator.return?.();
|
|
25
|
+
if (cursor.ephemeral) statements.delete(cursor.statement);
|
|
26
|
+
};
|
|
27
|
+
const statementOf = (id) => {
|
|
28
|
+
const statement = statements.get(id);
|
|
29
|
+
if (statement === undefined) throw generationFailure(generation);
|
|
30
|
+
return statement;
|
|
31
|
+
};
|
|
32
|
+
const dispatch = (request) => {
|
|
33
|
+
switch (request.op) {
|
|
34
|
+
case 'exec': return connection.exec(request.sql);
|
|
35
|
+
case 'prepare': {
|
|
36
|
+
if (statements.size >= limits.statements) throw bound(`worker statement capacity ${limits.statements} exceeded`);
|
|
37
|
+
const id = ++sequence;
|
|
38
|
+
statements.set(id, { statement: connection.prepare(request.sql), ephemeral: request.ephemeral === true });
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
case 'get': {
|
|
42
|
+
const row = statementOf(request.statement).statement.get(request.params);
|
|
43
|
+
if (rowBytes(row) > limits.bytes) throw bound(`one worker row exceeds ${limits.bytes} bytes`);
|
|
44
|
+
return row;
|
|
45
|
+
}
|
|
46
|
+
case 'run': return statementOf(request.statement).statement.run(request.params);
|
|
47
|
+
case 'iterate': {
|
|
48
|
+
if (cursors.size >= limits.cursors) throw bound(`worker cursor capacity ${limits.cursors} exceeded`);
|
|
49
|
+
const record = statementOf(request.statement);
|
|
50
|
+
const id = ++sequence;
|
|
51
|
+
cursors.set(id, { iterator: record.statement.iterate(request.params),
|
|
52
|
+
statement: request.statement, ephemeral: record.ephemeral, buffered: null });
|
|
53
|
+
return id;
|
|
54
|
+
}
|
|
55
|
+
case 'next': {
|
|
56
|
+
const cursor = cursors.get(request.cursor);
|
|
57
|
+
if (cursor === undefined) throw generationFailure(generation);
|
|
58
|
+
if (request.rows > limits.rows || request.bytes > limits.bytes) throw bound('worker cursor credit exceeds the negotiated window');
|
|
59
|
+
const rows = [];
|
|
60
|
+
let bytes = 0;
|
|
61
|
+
try {
|
|
62
|
+
while (rows.length < request.rows) {
|
|
63
|
+
const step = cursor.buffered ?? cursor.iterator.next();
|
|
64
|
+
cursor.buffered = null;
|
|
65
|
+
if (step.done) { release(request.cursor); return { rows, bytes, done: true }; }
|
|
66
|
+
const size = rowBytes(step.value);
|
|
67
|
+
if (size > request.bytes) throw bound(`one worker row of ${size} bytes exceeds the ${request.bytes} byte window`);
|
|
68
|
+
if (bytes + size > request.bytes) { cursor.buffered = step; break; }
|
|
69
|
+
rows.push(step.value);
|
|
70
|
+
bytes += size;
|
|
71
|
+
}
|
|
72
|
+
return { rows, bytes, done: false };
|
|
73
|
+
}
|
|
74
|
+
catch (error) { release(request.cursor); throw error; }
|
|
75
|
+
}
|
|
76
|
+
case 'return': release(request.cursor); return undefined;
|
|
77
|
+
case 'finalize': {
|
|
78
|
+
for (const [id, cursor] of cursors) if (cursor.statement === request.statement) release(id);
|
|
79
|
+
statements.delete(request.statement);
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
case 'close': {
|
|
83
|
+
for (const id of cursors.keys()) release(id);
|
|
84
|
+
statements.clear();
|
|
85
|
+
return connection.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
try {
|
|
90
|
+
connection = await nodeDriver().open(path, options);
|
|
91
|
+
// Functions cannot cross structured clone. Capture uses the existing
|
|
92
|
+
// journal path; a live query requires a synchronous connection.
|
|
93
|
+
parentPort.postMessage(frame('ready', 0, { capabilities: { ...connection.capabilities,
|
|
94
|
+
sessions: false, userFunctions: false, deterministicIndexableFunctions: false,
|
|
95
|
+
aggregateFunctions: false, backup: false, worker: true, pooling: false } }));
|
|
96
|
+
parentPort.on('message', (request) => {
|
|
97
|
+
try {
|
|
98
|
+
if (!validRequest(request)) throw new DbRuntimeError('JD2093', 'invalid worker protocol request');
|
|
99
|
+
if (request.generation !== generation) throw generationFailure(request.generation);
|
|
100
|
+
const value = dispatch(request);
|
|
101
|
+
parentPort.postMessage(frame('result', request.id, { value,
|
|
102
|
+
transaction: request.op === 'close' ? null : transaction() }));
|
|
103
|
+
if (request.op === 'close') parentPort.close();
|
|
104
|
+
}
|
|
105
|
+
catch (error) { parentPort.postMessage(frame('failure', request?.id ?? 0, {
|
|
106
|
+
error: cloneDriverError(error), transaction: transaction() })); }
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
parentPort.postMessage(frame('failure', 0, { error: cloneDriverError(error) }));
|
|
111
|
+
parentPort.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Shared bounded RPC Connection client for owned SQLite execution hosts. */
|
|
3
|
+
import { finishConnection } from '../driver.js';
|
|
4
|
+
import { sqliteDialect } from '../dialects/sqlite.js';
|
|
5
|
+
import { DbRuntimeError } from '../errors.js';
|
|
6
|
+
import { generationFailure, queueFailure, rowBytes, validResponse, validResult } from './worker-protocol.js';
|
|
7
|
+
|
|
8
|
+
/** @param {any} worker @param {any} settings @returns {Promise<any>} */
|
|
9
|
+
export async function createWorkerConnection(worker, settings) {
|
|
10
|
+
const { epoch, options, limits, maxPending, allRows, allBytes, closeMs, startupMs, reopen, hooks = {} } = settings;
|
|
11
|
+
const pending = new Map();
|
|
12
|
+
let sequence = 0;
|
|
13
|
+
let failed = null;
|
|
14
|
+
let closing = false;
|
|
15
|
+
let closed = false;
|
|
16
|
+
let transactionDepth = 0;
|
|
17
|
+
let closePromise;
|
|
18
|
+
const metrics = { frames: 0, rows: 0, maxFrameRows: 0, maxFrameBytes: 0, maxPending: 0 };
|
|
19
|
+
let readyResolve;
|
|
20
|
+
let readyReject;
|
|
21
|
+
const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
|
|
22
|
+
const lose = (cause) => {
|
|
23
|
+
if (failed !== null || closed) return;
|
|
24
|
+
failed = generationFailure(epoch, transactionDepth > 0, cause);
|
|
25
|
+
hooks.lost?.(failed, [...pending.values()]);
|
|
26
|
+
readyReject(failed);
|
|
27
|
+
for (const request of pending.values()) request.reject(failed);
|
|
28
|
+
pending.clear();
|
|
29
|
+
};
|
|
30
|
+
hooks.control?.({ lose });
|
|
31
|
+
worker.on('error', lose);
|
|
32
|
+
worker.on('exit', (code) => { if (!closed) lose(new Error(`worker exited (${code})`)); });
|
|
33
|
+
worker.on('message', (message) => {
|
|
34
|
+
if (!validResponse(message, epoch)) { lose(new Error('invalid worker response')); return; }
|
|
35
|
+
if (message.kind === 'ready') { readyResolve(message.capabilities); return; }
|
|
36
|
+
if (message.kind === 'failure' && message.id === 0) {
|
|
37
|
+
readyReject(Object.assign(new Error(message.error.message), message.error)); return;
|
|
38
|
+
}
|
|
39
|
+
const request = pending.get(message.id);
|
|
40
|
+
if (request === undefined) return;
|
|
41
|
+
pending.delete(message.id);
|
|
42
|
+
if (message.kind === 'failure') {
|
|
43
|
+
hooks.failure?.(request.op, request.data, message.transaction);
|
|
44
|
+
const error = message.error;
|
|
45
|
+
request.reject(Object.assign(error.code?.startsWith('JD')
|
|
46
|
+
? new DbRuntimeError(error.code, error.message) : new Error(error.message), error));
|
|
47
|
+
}
|
|
48
|
+
else if (message.kind === 'result' && validResult(request.op, message.value, limits)) {
|
|
49
|
+
hooks.result?.(request.op, request.data, message.value, message.transaction); request.resolve(message.value);
|
|
50
|
+
}
|
|
51
|
+
else { request.reject(generationFailure(epoch, transactionDepth > 0)); lose(new Error('invalid worker response')); }
|
|
52
|
+
});
|
|
53
|
+
const timer = setTimeout(() => { lose(new Error('worker startup timed out')); worker.terminate(); }, startupMs);
|
|
54
|
+
let capabilities;
|
|
55
|
+
try { capabilities = await ready; }
|
|
56
|
+
catch (error) {
|
|
57
|
+
const termination = worker.terminate();
|
|
58
|
+
if (settings.awaitStartupExit !== false) await termination;
|
|
59
|
+
else termination.catch(() => {});
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
finally { clearTimeout(timer); }
|
|
63
|
+
const request = (op, data = {}, cleanup = false) => {
|
|
64
|
+
if (failed !== null) return Promise.reject(failed);
|
|
65
|
+
if (closed || (closing && !cleanup)) return Promise.reject(new DbRuntimeError('JD2063', 'the worker connection is closing or closed'));
|
|
66
|
+
if (!cleanup && pending.size >= maxPending)
|
|
67
|
+
return Promise.reject(queueFailure(`worker request capacity ${maxPending} exceeded`, pending.size));
|
|
68
|
+
// One return per live cursor and one close have reserved capacity.
|
|
69
|
+
if (cleanup && pending.size >= maxPending + limits.cursors + 1)
|
|
70
|
+
return Promise.reject(queueFailure('worker cleanup capacity exceeded', pending.size));
|
|
71
|
+
const id = ++sequence;
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
pending.set(id, { resolve, reject, op, data });
|
|
74
|
+
metrics.maxPending = Math.max(metrics.maxPending, pending.size);
|
|
75
|
+
try { hooks.request?.(op, data); worker.postMessage({ v: 1, generation: epoch, kind: 'request', id, op, ...data }); }
|
|
76
|
+
catch (error) { pending.delete(id); reject(error); }
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
const raw = {
|
|
80
|
+
closeDrainsIterators: true,
|
|
81
|
+
exec: (sql) => {
|
|
82
|
+
if (/^(?:SAVEPOINT|BEGIN)\b/.test(sql)) transactionDepth++;
|
|
83
|
+
return request('exec', { sql }).then((value) => {
|
|
84
|
+
if (/^RELEASE\b/.test(sql)) transactionDepth = Math.max(0, transactionDepth - 1);
|
|
85
|
+
if (/^(?:COMMIT|ROLLBACK(?! TO))\b/.test(sql)) transactionDepth = 0;
|
|
86
|
+
return value;
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
prepare: async (sql, metadata = {}) => {
|
|
90
|
+
const id = await request('prepare', { sql, ephemeral: metadata.ephemeral === true });
|
|
91
|
+
const iterate = async (params = []) => {
|
|
92
|
+
const cursor = await request('iterate', { statement: id, params });
|
|
93
|
+
let rows = [];
|
|
94
|
+
let at = 0;
|
|
95
|
+
let done = false;
|
|
96
|
+
let returned = false;
|
|
97
|
+
let pulling = Promise.resolve();
|
|
98
|
+
const next = async () => {
|
|
99
|
+
if (failed !== null) throw failed;
|
|
100
|
+
if (returned) return { done: true, value: undefined };
|
|
101
|
+
if (at < rows.length) return { done: false, value: rows[at++] };
|
|
102
|
+
if (done) return { done: true, value: undefined };
|
|
103
|
+
const batch = await request('next', { cursor, rows: limits.rows, bytes: limits.bytes });
|
|
104
|
+
metrics.frames++;
|
|
105
|
+
metrics.rows += batch.rows.length;
|
|
106
|
+
metrics.maxFrameRows = Math.max(metrics.maxFrameRows, batch.rows.length);
|
|
107
|
+
metrics.maxFrameBytes = Math.max(metrics.maxFrameBytes, batch.bytes);
|
|
108
|
+
if (returned) return { done: true, value: undefined };
|
|
109
|
+
rows = batch.rows;
|
|
110
|
+
at = 0;
|
|
111
|
+
done = batch.done;
|
|
112
|
+
return at < rows.length ? { done: false, value: rows[at++] } : { done: true, value: undefined };
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
next: () => {
|
|
116
|
+
const result = pulling.then(next);
|
|
117
|
+
pulling = result.then(() => undefined, () => undefined);
|
|
118
|
+
return result;
|
|
119
|
+
},
|
|
120
|
+
return: async () => {
|
|
121
|
+
if (returned) return { done: true, value: undefined };
|
|
122
|
+
returned = true;
|
|
123
|
+
rows = [];
|
|
124
|
+
if (!done) await request('return', { cursor }, true);
|
|
125
|
+
return { done: true, value: undefined };
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
run: (params = []) => request('run', { statement: id, params }),
|
|
131
|
+
get: (params = []) => request('get', { statement: id, params }),
|
|
132
|
+
iterate,
|
|
133
|
+
all: async (params = []) => {
|
|
134
|
+
const iterator = await iterate(params);
|
|
135
|
+
const rows = [];
|
|
136
|
+
let bytes = 0;
|
|
137
|
+
try {
|
|
138
|
+
for (;;) {
|
|
139
|
+
const step = await iterator.next();
|
|
140
|
+
if (step.done) return rows;
|
|
141
|
+
bytes += rowBytes(step.value);
|
|
142
|
+
if (rows.length >= allRows || bytes > allBytes)
|
|
143
|
+
throw new DbRuntimeError('JD2092', `worker all() exceeds its ${allRows} row / ${allBytes} byte bound; use a cursor`);
|
|
144
|
+
rows.push(step.value);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
finally { await iterator.return(); }
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
close: () => {
|
|
152
|
+
if (closePromise !== undefined) return closePromise;
|
|
153
|
+
closing = true;
|
|
154
|
+
closePromise = new Promise((resolve, reject) => {
|
|
155
|
+
const timeout = setTimeout(() => {
|
|
156
|
+
lose(new Error('worker close timed out'));
|
|
157
|
+
// V8 termination cannot preempt a synchronous native SQLite
|
|
158
|
+
// call. Fence and detach now; never hold the caller's deadline
|
|
159
|
+
// hostage to that native call or claim it was rolled back.
|
|
160
|
+
worker.unref();
|
|
161
|
+
worker.terminate().catch(() => {});
|
|
162
|
+
reject(failed);
|
|
163
|
+
}, closeMs);
|
|
164
|
+
request('close', {}, true).then(() => {
|
|
165
|
+
closed = true;
|
|
166
|
+
clearTimeout(timeout);
|
|
167
|
+
worker.terminate().then(() => resolve(undefined), reject);
|
|
168
|
+
}, (error) => {
|
|
169
|
+
clearTimeout(timeout);
|
|
170
|
+
worker.unref();
|
|
171
|
+
worker.terminate().catch(() => {});
|
|
172
|
+
reject(error);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
return closePromise;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const connection = finishConnection(raw, sqliteDialect, false, Object.freeze(capabilities), options.queueTimeout);
|
|
179
|
+
return Object.freeze({ ...connection,
|
|
180
|
+
get mustQueue() { return connection.mustQueue; },
|
|
181
|
+
generation: epoch,
|
|
182
|
+
metrics: () => Object.freeze({ ...metrics, pending: pending.size, generation: epoch, healthy: failed === null && !closed }),
|
|
183
|
+
restart: async () => { lose(new Error('worker restarted')); await worker.terminate(); return reopen(); },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -4,6 +4,11 @@ import { DbRuntimeError } from '../errors.js';
|
|
|
4
4
|
import { jsonStringBytes } from '../json-bytes.js';
|
|
5
5
|
|
|
6
6
|
export const WORKER_PROTOCOL_VERSION = 1;
|
|
7
|
+
export const WORKER_DEFAULTS = Object.freeze({ windowRows: 64, windowBytes: 1048576,
|
|
8
|
+
maxPending: 64, maxStatements: 1024, maxCursors: 64, allMaxRows: 100000,
|
|
9
|
+
allMaxBytes: 16777216, closeTimeoutMs: 5000, startupTimeoutMs: 10000 });
|
|
10
|
+
export const PROCESS_DEFAULTS = Object.freeze({ ...WORKER_DEFAULTS, closeTimeoutMs: 1000,
|
|
11
|
+
maxOwners: 4, timeoutMs: 250, maxRequestBytes: 1048576 });
|
|
7
12
|
const operations = new Set(['exec', 'prepare', 'run', 'get', 'iterate', 'next', 'return', 'finalize', 'close']);
|
|
8
13
|
|
|
9
14
|
/** @param {number} generation @param {boolean} [transaction] @param {unknown} [cause] */
|
|
@@ -73,6 +78,16 @@ export function positiveOption(name, value, fallback) {
|
|
|
73
78
|
return value;
|
|
74
79
|
}
|
|
75
80
|
|
|
81
|
+
/** One validation and spelling of shared transport credits.
|
|
82
|
+
* @param {any} configuration @param {typeof WORKER_DEFAULTS} [defaults] */
|
|
83
|
+
export function workerSettings(configuration, defaults = WORKER_DEFAULTS) {
|
|
84
|
+
const values = Object.fromEntries(Object.keys(WORKER_DEFAULTS).map((name) =>
|
|
85
|
+
[name, positiveOption(name, configuration[name], defaults[name])]));
|
|
86
|
+
return { limits: { rows: values.windowRows, bytes: values.windowBytes, statements: values.maxStatements, cursors: values.maxCursors },
|
|
87
|
+
maxPending: values.maxPending, allRows: values.allMaxRows, allBytes: values.allMaxBytes,
|
|
88
|
+
closeMs: values.closeTimeoutMs, startupMs: values.startupTimeoutMs };
|
|
89
|
+
}
|
|
90
|
+
|
|
76
91
|
/** @param {string} reason @param {number} depth */
|
|
77
92
|
export function queueFailure(reason, depth) {
|
|
78
93
|
return Object.assign(new DbRuntimeError('JD2091', reason), { class: 'queue', retryable: true, depth });
|
package/src/errors.js
CHANGED
|
@@ -112,6 +112,7 @@ export const DB_CODES = Object.freeze({
|
|
|
112
112
|
JD2094: 'the durable snapshot failed and the connection is invalid',
|
|
113
113
|
JD2095: 'the trusted SQL or synchronous transaction authority was refused',
|
|
114
114
|
JD2096: 'a persistence invariant rejected the mutation',
|
|
115
|
+
JD2097: 'the supervised operation was cancelled or exceeded its response deadline',
|
|
115
116
|
});
|
|
116
117
|
|
|
117
118
|
/**
|
package/types/index.d.ts
CHANGED
|
@@ -514,6 +514,8 @@ export interface StoreCapabilities {
|
|
|
514
514
|
readonly sessions: boolean;
|
|
515
515
|
readonly sessionReason: string | null;
|
|
516
516
|
readonly worker: boolean;
|
|
517
|
+
readonly process: boolean;
|
|
518
|
+
readonly ownerTermination: boolean;
|
|
517
519
|
readonly pooling: boolean;
|
|
518
520
|
readonly poolReaders: number;
|
|
519
521
|
readonly poolWriters: number;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { NodeOpenOptions } from './node.js';
|
|
2
|
+
import type { CancellationCapabilities, Driver } from './index.js';
|
|
3
|
+
import type { NodeWorkerConnection, NodeWorkerOptions, WorkerMetrics } from './node-worker.js';
|
|
4
|
+
|
|
5
|
+
export interface NodeProcessOptions extends NodeWorkerOptions { maxOwners?: number; timeoutMs?: number; maxRequestBytes?: number }
|
|
6
|
+
export interface ProcessSettlement {
|
|
7
|
+
readonly path: string | null;
|
|
8
|
+
readonly generation: number;
|
|
9
|
+
readonly pid: number | null;
|
|
10
|
+
readonly status: 'starting' | 'healthy' | 'quarantined' | 'exited';
|
|
11
|
+
readonly transaction: 'none' | 'active' | 'committed' | 'rolled-back' | 'unknown';
|
|
12
|
+
readonly safeToReplace: boolean;
|
|
13
|
+
readonly exitCode: number | null;
|
|
14
|
+
readonly exitSignal: string | null;
|
|
15
|
+
}
|
|
16
|
+
export interface NodeProcessConnection extends NodeWorkerConnection {
|
|
17
|
+
readonly capabilities: Readonly<Record<string, unknown>> & {
|
|
18
|
+
readonly process: true; readonly ownerTermination: true;
|
|
19
|
+
readonly cancellation: CancellationCapabilities & {readonly midStatement: false};
|
|
20
|
+
};
|
|
21
|
+
supervise<T>(body: (connection: NodeProcessConnection) => T | Promise<T>, options?: {signal?: AbortSignal; timeoutMs?: number}): Promise<T>;
|
|
22
|
+
cancel(reason?: string): Error;
|
|
23
|
+
settlement(): ProcessSettlement;
|
|
24
|
+
/** Resolves only after the OS reports owner exit, never from a caller deadline. */
|
|
25
|
+
settled(): Promise<ProcessSettlement>;
|
|
26
|
+
metrics(): WorkerMetrics & {readonly owner: ProcessSettlement; readonly supervised: number};
|
|
27
|
+
restart(): Promise<NodeProcessConnection>;
|
|
28
|
+
}
|
|
29
|
+
export interface NodeProcessDriver extends Driver {
|
|
30
|
+
open(path?: string, options?: NodeOpenOptions): Promise<NodeProcessConnection>;
|
|
31
|
+
metrics(): Readonly<{capacity: number; owners: number; quarantined: number; healthy: number}>;
|
|
32
|
+
}
|
|
33
|
+
export declare function nodeProcessDriver(options?: NodeProcessOptions): NodeProcessDriver;
|