@jarenjs/db 0.56.0 → 0.67.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 +412 -56
- package/README.md +600 -57
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +169 -20
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +752 -64
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +230 -47
- package/src/cli.js +165 -59
- package/src/cursor.js +417 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +268 -113
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +244 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +17 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +430 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1567 -273
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1158 -27
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +27 -4
- package/types/wasm.d.ts +14 -0
package/src/cursor.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The ONE item cursor. `next()` / `return()` / `[Symbol.asyncIterator]`
|
|
4
|
+
* over a source that is either a ROW ITERATOR pulled one row at a time
|
|
5
|
+
* or a MATERIALISED item array (a barrier). Every engine — the
|
|
6
|
+
* collection engine's `query()`, the entity engine's, the graph
|
|
7
|
+
* loader's — builds its cursor here, so the release-at-a-row-boundary
|
|
8
|
+
* rule exists in one place:
|
|
9
|
+
*
|
|
10
|
+
* - a row source is opened on the first pull and released exactly
|
|
11
|
+
* once — on `return()`, on an error raised while a row is mapped or
|
|
12
|
+
* pulled, and on abort — through the driver iterator's own
|
|
13
|
+
* `return()`; a source pulled to exhaustion has already reset its
|
|
14
|
+
* statement and is released without a second call;
|
|
15
|
+
* - a buffered source materialises on the first pull and SAYS SO:
|
|
16
|
+
* `streaming: 'buffered'` beside the `barrier` that forced it, a
|
|
17
|
+
* `{ construct, reason }` pair whose construct is a stable
|
|
18
|
+
* identifier a test can assert and `explain()` can repeat;
|
|
19
|
+
* - `signal` cancels at a row boundary: an aborted cursor releases its
|
|
20
|
+
* statement and every later pull is `JD2072`, so an abandoned
|
|
21
|
+
* request neither keeps a statement open nor pulls another row.
|
|
22
|
+
*
|
|
23
|
+
* The source never materialises on its own: this module knows neither
|
|
24
|
+
* `.all()` nor `toArray()` — a gate holds it to that — and an engine
|
|
25
|
+
* that must buffer hands the buffered items in as `materialize`, named.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { utf8ByteLength } from '@jarenjs/core/string';
|
|
29
|
+
|
|
30
|
+
import { DbRuntimeError } from './errors.js';
|
|
31
|
+
import { chain, isThenable } from './driver.js';
|
|
32
|
+
|
|
33
|
+
/** Preserve a failure, but acknowledge source cleanup before rejecting it.
|
|
34
|
+
* @param {() => any} call @param {() => any} release @param {(error:any)=>any} [wrap]
|
|
35
|
+
*/
|
|
36
|
+
function settling(call, release, wrap = (error) => error) {
|
|
37
|
+
const failed = (error) => {
|
|
38
|
+
const failure = wrap(error);
|
|
39
|
+
let cleanup;
|
|
40
|
+
try { cleanup = release(); } catch { throw failure; }
|
|
41
|
+
if (isThenable(cleanup)) return cleanup.then(() => { throw failure; }, () => { throw failure; });
|
|
42
|
+
throw failure;
|
|
43
|
+
};
|
|
44
|
+
let result;
|
|
45
|
+
try { result = call(); } catch (error) { return failed(error); }
|
|
46
|
+
return isThenable(result) ? result.then(undefined, failed) : result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The serialised size of a JSON text in UTF-8 bytes — the one measure
|
|
51
|
+
* every byte bound in this package counts (an include per root, a page,
|
|
52
|
+
* a change record): the suite's counter, re-exported under the name the
|
|
53
|
+
* engines read.
|
|
54
|
+
* @param {string} text
|
|
55
|
+
* @returns {number}
|
|
56
|
+
*/
|
|
57
|
+
export function utf8Length(text) {
|
|
58
|
+
return utf8ByteLength(text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {{ construct: string, reason: string }} CursorBarrier - what
|
|
63
|
+
* forces a cursor to buffer: the construct is the stable identifier
|
|
64
|
+
* (a planner construct such as `$orderby`, or `external`, `window`,
|
|
65
|
+
* `pushdown`), the reason the sentence for a person
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How a cursor over a source the driver iterates actually behaves on
|
|
70
|
+
* THIS connection: one row per pull where the binding has a lazy
|
|
71
|
+
* iterator, and a buffer — declared as such, with the driver named as
|
|
72
|
+
* the barrier — where the driver composed `iterate` over `all()`. The
|
|
73
|
+
* capability is probed once at open; a cursor is classified at
|
|
74
|
+
* construction, before any statement exists, so the fact has to be the
|
|
75
|
+
* connection's. The one classification every engine and the job queue
|
|
76
|
+
* read.
|
|
77
|
+
* @param {any} connection
|
|
78
|
+
* @returns {{ streaming: 'row' | 'buffered', barrier: CursorBarrier | null }}
|
|
79
|
+
*/
|
|
80
|
+
export function rowClassOf(connection) {
|
|
81
|
+
return connection.capabilities.lazyIteration === false
|
|
82
|
+
? { streaming: 'buffered', barrier: { construct: 'driver',
|
|
83
|
+
reason: 'the driver binding has no lazy iterator; the first pull materialises the whole result' } }
|
|
84
|
+
: { streaming: 'row', barrier: null };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @typedef {object} CursorSpec
|
|
89
|
+
* @property {'row' | 'buffered'} streaming - whether items arrive one
|
|
90
|
+
* database row per pull or from a buffer the first pull filled
|
|
91
|
+
* @property {CursorBarrier | null} [barrier] - what forces buffering;
|
|
92
|
+
* `null` when the cursor streams
|
|
93
|
+
* @property {AbortSignal} [signal] - cancellation, honoured at a row boundary
|
|
94
|
+
* @property {() => any} [materialize] - a buffered source: value-or-promise
|
|
95
|
+
* of the whole item array, called once on the first pull
|
|
96
|
+
* @property {() => any} [open] - a row source: value-or-promise of the
|
|
97
|
+
* driver iterator, called once on the first pull
|
|
98
|
+
* @property {(row: any) => any[]} [items] - a row source: the items one
|
|
99
|
+
* row yields (none, one, or several); a throw releases the source
|
|
100
|
+
* @property {number} [deadline] - an epoch-millisecond deadline checked at
|
|
101
|
+
* every pull: past it, the cursor releases its source and refuses
|
|
102
|
+
* `JD2075` — a row-boundary check, never a statement interrupt
|
|
103
|
+
* @property {() => number} [now] - the clock the deadline is read
|
|
104
|
+
* against — the store's runtime record's; required beside a deadline,
|
|
105
|
+
* so no cursor reads the platform clock on its own
|
|
106
|
+
* @property {(error: any) => Error} [wrap] - classifies a failure raised
|
|
107
|
+
* while the source is opened, pulled or mapped — the engine's driver
|
|
108
|
+
* wrap, so no raw driver error leaves a cursor; a coded error passes
|
|
109
|
+
* through it unchanged
|
|
110
|
+
* @property {(opened: boolean) => void} [onSettle] - called exactly once
|
|
111
|
+
* when the cursor settles — exhausted, released, or aborted — with
|
|
112
|
+
* whether a pull ever reached the source; what an engine finalises its
|
|
113
|
+
* run accounting on
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build an asynchronous item cursor over one source.
|
|
118
|
+
* @param {CursorSpec} spec
|
|
119
|
+
* @returns {any}
|
|
120
|
+
*/
|
|
121
|
+
export function createCursor(spec) {
|
|
122
|
+
return itemCursor(spec, false);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The same lifecycle over a synchronous source, answering values.
|
|
127
|
+
* @param {CursorSpec} spec
|
|
128
|
+
* @returns {any}
|
|
129
|
+
*/
|
|
130
|
+
export function createSyncCursor(spec) {
|
|
131
|
+
return itemCursor(spec, true);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @param {CursorSpec} spec @param {boolean} synchronous */
|
|
135
|
+
function itemCursor(spec, synchronous) {
|
|
136
|
+
const { streaming, signal } = spec;
|
|
137
|
+
if (spec.deadline !== undefined && spec.now === undefined)
|
|
138
|
+
throw new TypeError('a cursor with a deadline is built with the clock it is read against (now)');
|
|
139
|
+
let buffered = [];
|
|
140
|
+
let offset = 0;
|
|
141
|
+
let underlying = null;
|
|
142
|
+
let opening = null;
|
|
143
|
+
let cleanup;
|
|
144
|
+
let sourceReleased = false;
|
|
145
|
+
let done = false;
|
|
146
|
+
let opened = false;
|
|
147
|
+
let tail = synchronous ? undefined : Promise.resolve();
|
|
148
|
+
const settle = () => {
|
|
149
|
+
if (done) return;
|
|
150
|
+
done = true;
|
|
151
|
+
buffered = [];
|
|
152
|
+
signal?.removeEventListener('abort', onAbort);
|
|
153
|
+
spec.onSettle?.(opened);
|
|
154
|
+
};
|
|
155
|
+
const releaseSource = () => {
|
|
156
|
+
if (sourceReleased || underlying === null) return;
|
|
157
|
+
sourceReleased = true;
|
|
158
|
+
return underlying.return?.(undefined);
|
|
159
|
+
};
|
|
160
|
+
const release = () => {
|
|
161
|
+
settle();
|
|
162
|
+
if (cleanup === undefined) cleanup = chain(opening, releaseSource);
|
|
163
|
+
return cleanup;
|
|
164
|
+
};
|
|
165
|
+
const onAbort = () => {
|
|
166
|
+
// Event listeners cannot await remote acknowledgement. The public
|
|
167
|
+
// return still awaits it, and a rejection is observed here as well.
|
|
168
|
+
try { const pending = release(); if (isThenable(pending)) pending.catch(() => {}); }
|
|
169
|
+
catch { /* the next/return boundary reports the source failure */ }
|
|
170
|
+
};
|
|
171
|
+
const aborted = () => new DbRuntimeError('JD2072',
|
|
172
|
+
'the cursor was aborted: its statement was released at a row boundary and it pulls '
|
|
173
|
+
+ 'no further row', { cause: signal?.reason });
|
|
174
|
+
const end = () => ({ done: true, value: undefined });
|
|
175
|
+
const boundary = () => {
|
|
176
|
+
if (signal?.aborted) throw aborted();
|
|
177
|
+
if (done) return;
|
|
178
|
+
if (spec.deadline !== undefined && spec.now() > spec.deadline)
|
|
179
|
+
throw new DbRuntimeError('JD2075',
|
|
180
|
+
`the deadline passed before the next row (${new Date(spec.deadline).toISOString()}); `
|
|
181
|
+
+ 'the statement was released at a row boundary');
|
|
182
|
+
};
|
|
183
|
+
const accept = (step) => {
|
|
184
|
+
boundary();
|
|
185
|
+
if (done) return end();
|
|
186
|
+
if (step.done === true) {
|
|
187
|
+
sourceReleased = true; // exhausted native iterators already reset
|
|
188
|
+
settle();
|
|
189
|
+
return end();
|
|
190
|
+
}
|
|
191
|
+
buffered = spec.items?.(step.value) ?? [];
|
|
192
|
+
offset = 0;
|
|
193
|
+
return null;
|
|
194
|
+
};
|
|
195
|
+
const rows = () => {
|
|
196
|
+
for (;;) {
|
|
197
|
+
boundary();
|
|
198
|
+
if (done) return end();
|
|
199
|
+
if (offset < buffered.length) return { done: false, value: buffered[offset++] };
|
|
200
|
+
const next = underlying.next();
|
|
201
|
+
if (isThenable(next)) return next.then((step) => accept(step) ?? rows());
|
|
202
|
+
const result = accept(next);
|
|
203
|
+
if (result !== null) return result;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
const pull = () => {
|
|
207
|
+
boundary();
|
|
208
|
+
if (done) return end();
|
|
209
|
+
if (spec.materialize !== undefined) {
|
|
210
|
+
if (!opened) {
|
|
211
|
+
opened = true;
|
|
212
|
+
return chain(spec.materialize(), (items) => {
|
|
213
|
+
boundary();
|
|
214
|
+
if (done) return end();
|
|
215
|
+
buffered = items;
|
|
216
|
+
return pull();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (offset < buffered.length) return { done: false, value: buffered[offset++] };
|
|
220
|
+
settle();
|
|
221
|
+
return end();
|
|
222
|
+
}
|
|
223
|
+
if (!opened) {
|
|
224
|
+
opened = true;
|
|
225
|
+
opening = chain(spec.open?.(), (iterator) => { underlying = iterator; });
|
|
226
|
+
return chain(opening, () => done
|
|
227
|
+
? chain(releaseSource(), () => { boundary(); return end(); }) : rows());
|
|
228
|
+
}
|
|
229
|
+
return rows();
|
|
230
|
+
};
|
|
231
|
+
const guarded = () => settling(pull, release, spec.wrap);
|
|
232
|
+
if (signal?.aborted) settle();
|
|
233
|
+
else signal?.addEventListener('abort', onAbort, { once: true });
|
|
234
|
+
const next = synchronous ? guarded : () => {
|
|
235
|
+
const result = tail.then(guarded);
|
|
236
|
+
tail = result.then(() => undefined, () => undefined);
|
|
237
|
+
return result;
|
|
238
|
+
};
|
|
239
|
+
const finish = () => chain(release(), end);
|
|
240
|
+
const cursor = {
|
|
241
|
+
streaming,
|
|
242
|
+
barrier: spec.barrier ?? null,
|
|
243
|
+
get settled() { return done; },
|
|
244
|
+
next,
|
|
245
|
+
return: synchronous ? finish : () => {
|
|
246
|
+
try { return Promise.resolve(finish()); }
|
|
247
|
+
catch (error) { return Promise.reject(error); }
|
|
248
|
+
},
|
|
249
|
+
...(synchronous
|
|
250
|
+
? { [Symbol.iterator]: () => cursor, [Symbol.dispose]: finish }
|
|
251
|
+
: { [Symbol.asyncIterator]: () => cursor, [Symbol.asyncDispose]: async () => { await finish(); } }),
|
|
252
|
+
};
|
|
253
|
+
return Object.freeze(cursor);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* A ROOT cursor's admission: the one decorator every store-level cursor
|
|
258
|
+
* — a collection's `query()`, an entity set's `cursor()` and
|
|
259
|
+
* `loadCursor()` — is handed back through, so the rule that a root read
|
|
260
|
+
* cannot fall inside a transaction it is not part of (MODEL-FORMAT §5.1)
|
|
261
|
+
* holds for a streaming read exactly as it holds for a finite one, at the
|
|
262
|
+
* granularity a stream can afford: **per pull**. Construction holds
|
|
263
|
+
* nothing. Each `next()` borrows the store gate for all the source work
|
|
264
|
+
* one public item needs (opening the statement on the first pull, the
|
|
265
|
+
* row steps, a row that yields no item), then releases before the
|
|
266
|
+
* promise settles, so a consumer paused between pulls blocks no
|
|
267
|
+
* transaction and a pull made while one is open waits for its commit
|
|
268
|
+
* and reads committed state only. `return()` is admitted the same way;
|
|
269
|
+
* a release the gate REFUSES (a contended `transactions: 'strict'`
|
|
270
|
+
* store, a queue timeout) still runs, off-gate — a statement reset reads
|
|
271
|
+
* and writes nothing, and a statement left open until the store closes
|
|
272
|
+
* is the worse outcome — and answers `{ done: true }`. An abort likewise
|
|
273
|
+
* resets the source at once, off-gate, through the inner cursor's own
|
|
274
|
+
* listener: an abort asks for the statement to be let go, not for it to
|
|
275
|
+
* be held until a stranger's transaction commits.
|
|
276
|
+
*
|
|
277
|
+
* Refusals keep their granularity: a pull abandoned while QUEUED is the
|
|
278
|
+
* gate's `JD2064` (the source, never opened for that pull, needs no
|
|
279
|
+
* release); a pull whose signal is already aborted, or aborts at a row
|
|
280
|
+
* boundary, is the cursor's own `JD2072` and releases the source once; a
|
|
281
|
+
* passed deadline is `JD2075`. A cursor that has settled — exhausted,
|
|
282
|
+
* released, aborted — answers `{ done: true }` without borrowing the
|
|
283
|
+
* gate at all. The decorator buffers no item and holds no gate between
|
|
284
|
+
* two public pulls: `streaming`, `barrier` and the asynchronous-iterator
|
|
285
|
+
* identity are the inner cursor's own.
|
|
286
|
+
* @param {any} cursor - the engine's `QueryCursor`
|
|
287
|
+
* @param {(fn: () => any, what?: string, signal?: AbortSignal) => any} admit
|
|
288
|
+
* - the store gate: runs `fn` holding the connection, value-or-promise
|
|
289
|
+
* @param {AbortSignal | undefined} signal - the cursor's own signal, so an
|
|
290
|
+
* abort abandons a queued pull
|
|
291
|
+
* @param {string} what - what is waiting, for the gate's timeout message
|
|
292
|
+
* @returns {any} the admitted `QueryCursor`
|
|
293
|
+
*/
|
|
294
|
+
export function admitCursor(cursor, admit, signal, what) {
|
|
295
|
+
/**
|
|
296
|
+
* @param {'next' | 'return'} member
|
|
297
|
+
* @param {string} label
|
|
298
|
+
* @param {boolean} abandonable - whether an abort leaves the queue
|
|
299
|
+
*/
|
|
300
|
+
const through = (member, label, abandonable) => () => {
|
|
301
|
+
// an already-aborted cursor refuses on its own (`JD2072`, every later
|
|
302
|
+
// pull) and releases its source; the gate has nothing to admit
|
|
303
|
+
if (abandonable && signal?.aborted === true) return cursor[member]();
|
|
304
|
+
// a settled cursor holds no source: nothing to admit, nothing to wait for
|
|
305
|
+
if (cursor.settled === true) return Promise.resolve({ done: true, value: undefined });
|
|
306
|
+
let admitted;
|
|
307
|
+
try {
|
|
308
|
+
admitted = Promise.resolve(admit(() => cursor[member](), label, abandonable ? signal : undefined));
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
admitted = Promise.reject(error);
|
|
312
|
+
}
|
|
313
|
+
if (member !== 'return') return admitted;
|
|
314
|
+
// a refused release still releases: the reset lands off-gate rather
|
|
315
|
+
// than leaving the statement open
|
|
316
|
+
return admitted.catch(() => cursor.return());
|
|
317
|
+
};
|
|
318
|
+
/** @type {any} */
|
|
319
|
+
const admitted = {
|
|
320
|
+
streaming: cursor.streaming,
|
|
321
|
+
barrier: cursor.barrier,
|
|
322
|
+
next: through('next', what, true),
|
|
323
|
+
return: through('return', `${what} (release)`, false),
|
|
324
|
+
[Symbol.asyncIterator]: () => admitted,
|
|
325
|
+
};
|
|
326
|
+
return Object.freeze(admitted);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Synchronous admission per pull; cleanup is permitted even after refusal.
|
|
330
|
+
* @param {any} cursor @param {(fn: () => any) => any} admit
|
|
331
|
+
* @returns {any}
|
|
332
|
+
*/
|
|
333
|
+
export function admitSyncCursor(cursor, admit) {
|
|
334
|
+
const wrapped = {
|
|
335
|
+
streaming: cursor.streaming,
|
|
336
|
+
barrier: cursor.barrier,
|
|
337
|
+
next: () => admit(() => cursor.next()),
|
|
338
|
+
return: () => cursor.return(),
|
|
339
|
+
[Symbol.iterator]: () => wrapped,
|
|
340
|
+
[Symbol.dispose]: () => { cursor.return(); },
|
|
341
|
+
};
|
|
342
|
+
return Object.freeze(wrapped);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The page size a page takes when none is given. */
|
|
346
|
+
export const PAGE_LIMIT_DEFAULT = 100;
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Drain a cursor into ONE page: at most `limit` items, at most `maxBytes`
|
|
350
|
+
* serialised bytes (`null` for no byte bound), stopping at an item
|
|
351
|
+
* boundary and releasing the cursor. The one implementation every page
|
|
352
|
+
* in this package is — an entity page, a change page — so the
|
|
353
|
+
* `item_too_large` rule exists once: an item that alone exceeds
|
|
354
|
+
* `maxBytes` when nothing has been delivered yet is the refusal
|
|
355
|
+
* `JD2074`, raised WITHOUT advancing the continuation, so a caller that
|
|
356
|
+
* retries meets the same refusal instead of a loop or a silent breach.
|
|
357
|
+
* An item that does not fit beside earlier ones ends the page before
|
|
358
|
+
* it: `hasMore` is true and the continuation is the last delivered
|
|
359
|
+
* item's, so the next page starts at the item that did not fit.
|
|
360
|
+
* `hasMore` is otherwise decided by one peek past `limit`.
|
|
361
|
+
* @param {any} cursor - a `QueryCursor`
|
|
362
|
+
* @param {{ limit: number, maxBytes: number | null, after?: any,
|
|
363
|
+
* sizeOf: (item: any) => number, continuationOf: (item: any) => any }} options
|
|
364
|
+
* @returns {any} value-or-promise, matching the cursor
|
|
365
|
+
*/
|
|
366
|
+
export function drainPage(cursor, options) {
|
|
367
|
+
const { limit, maxBytes, sizeOf, continuationOf } = options;
|
|
368
|
+
const after = options.after ?? null;
|
|
369
|
+
const items = [];
|
|
370
|
+
let bytes = 0;
|
|
371
|
+
let last = after;
|
|
372
|
+
let hasMore = false;
|
|
373
|
+
const finish = () => chain(cursor.return(), () => ({
|
|
374
|
+
items, continuation: items.length > 0 ? last : (hasMore ? after : null), hasMore,
|
|
375
|
+
}));
|
|
376
|
+
const consume = (pulled) => {
|
|
377
|
+
if (items.length >= limit) {
|
|
378
|
+
hasMore = pulled.done !== true;
|
|
379
|
+
return finish();
|
|
380
|
+
}
|
|
381
|
+
if (pulled.done === true) return finish();
|
|
382
|
+
const item = pulled.value;
|
|
383
|
+
const size = maxBytes === null ? 0 : sizeOf(item);
|
|
384
|
+
if (maxBytes !== null && bytes + size > maxBytes) {
|
|
385
|
+
if (items.length === 0) {
|
|
386
|
+
return chain(cursor.return(), () => {
|
|
387
|
+
assertItemBytes(size, maxBytes, continuationOf(item));
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
hasMore = true;
|
|
391
|
+
return finish();
|
|
392
|
+
}
|
|
393
|
+
items.push(item);
|
|
394
|
+
bytes += size;
|
|
395
|
+
last = continuationOf(item);
|
|
396
|
+
return null;
|
|
397
|
+
};
|
|
398
|
+
const step = () => {
|
|
399
|
+
for (;;) {
|
|
400
|
+
const pulled = cursor.next();
|
|
401
|
+
if (isThenable(pulled)) return pulled.then((value) => consume(value) ?? step());
|
|
402
|
+
const result = consume(pulled);
|
|
403
|
+
if (result !== null) return result;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
return settling(step, () => cursor.return());
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The shared refusal for an indivisible item, including a replicated transaction.
|
|
410
|
+
* @param {number} size @param {number} maxBytes @param {any} [at] */
|
|
411
|
+
export function assertItemBytes(size, maxBytes, at) {
|
|
412
|
+
if (size > maxBytes) throw new DbRuntimeError('JD2074',
|
|
413
|
+
`the next item is ${size} serialised bytes, more than the page's maxBytes bound of `
|
|
414
|
+
+ `${maxBytes}; the continuation was not advanced — raise the bound, or bound the `
|
|
415
|
+
+ "item itself (an include's maxBytes, a narrower document)",
|
|
416
|
+
{ errors: [{ bytes: size, maxBytes, at }] });
|
|
417
|
+
}
|
package/src/dag-job.js
CHANGED
|
@@ -8,13 +8,41 @@
|
|
|
8
8
|
* graph name flow nowhere.
|
|
9
9
|
*
|
|
10
10
|
* Each kind's document compiles ONCE against a delegating checkpoint
|
|
11
|
-
* store; per
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* store; the delegate resolves per ATTEMPT, never per job, because two
|
|
12
|
+
* attempts of one job are two different runs of the same workflow and
|
|
13
|
+
* the older one may not write on the younger one's behalf. The engine's
|
|
14
|
+
* store carries the fence: `save` is refused once the lease is lost, and
|
|
15
|
+
* `complete` records the DAG result, marks the job done and prunes the
|
|
16
|
+
* checkpoint rows in ONE transaction, so a failure leaves neither and a
|
|
17
|
+
* crash resumes instead of restarting.
|
|
18
|
+
*
|
|
19
|
+
* A resumed run also has to be the SAME run. The workflow document's
|
|
20
|
+
* revision and a hash of the input are persisted with the checkpoints,
|
|
21
|
+
* and a resume that disagrees with either is refused by name — reusing
|
|
22
|
+
* checkpoints written by a different workflow is not a resume, it is a
|
|
23
|
+
* silently wrong answer.
|
|
16
24
|
*/
|
|
17
25
|
|
|
26
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
27
|
+
import { hashContent } from '@jarenjs/core/string';
|
|
28
|
+
import { setObjectMember } from '@jarenjs/core/object';
|
|
29
|
+
|
|
30
|
+
import { DbRuntimeError } from './errors.js';
|
|
31
|
+
|
|
32
|
+
/** The checkpoint row a run's identity lives in. The leading unit
|
|
33
|
+
* separator keeps it out of any node-id namespace a document could
|
|
34
|
+
* declare, and the DAG's seeding skips it because no node is called
|
|
35
|
+
* that. It is pruned with the run it belongs to. */
|
|
36
|
+
export const RUN_IDENTITY_NODE = '\u001Fidentity';
|
|
37
|
+
|
|
38
|
+
/** What joins a job id to an attempt's token in the run key the DAG
|
|
39
|
+
* sees. A unit separator, so no job id can carry one by accident. */
|
|
40
|
+
const RUN_KEY_SEPARATOR = '\u001F';
|
|
41
|
+
|
|
42
|
+
/** A stable fingerprint of any JSON value: the suite's one content hash
|
|
43
|
+
* over the suite's one canonical form. */
|
|
44
|
+
const fingerprint = (value) => hashContent(canonicalizeJson(value ?? null));
|
|
45
|
+
|
|
18
46
|
/**
|
|
19
47
|
* Build a worker whose handlers run checkpointed DAG documents.
|
|
20
48
|
* @param {any} store - an open store with `{ jobs: true }`
|
|
@@ -22,8 +50,10 @@
|
|
|
22
50
|
* documents: Record<string, any>,
|
|
23
51
|
* tasks?: Record<string, Function>,
|
|
24
52
|
* concurrency?: number, pollInterval?: number, leaseMs?: number,
|
|
25
|
-
* owner?: string,
|
|
26
|
-
*
|
|
53
|
+
* owner?: string, renew?: boolean, onOutcome?: (event: any) => void,
|
|
54
|
+
* backoffBase?: number, backoffCap?: number,
|
|
55
|
+
* stopGraceMs?: number }} options
|
|
56
|
+
* @returns {{ start: () => any, stop: (options?: any) => Promise<any>, stats: () => any }}
|
|
27
57
|
*/
|
|
28
58
|
export function createDagJobRunner(store, options) {
|
|
29
59
|
if (store?.jobs === undefined) {
|
|
@@ -42,20 +72,109 @@ export function createDagJobRunner(store, options) {
|
|
|
42
72
|
'createDagJobRunner: "documents" must map job kinds to dag documents');
|
|
43
73
|
}
|
|
44
74
|
|
|
45
|
-
/**
|
|
46
|
-
*
|
|
75
|
+
/**
|
|
76
|
+
* The attempts running right now, keyed by the run key the DAG was
|
|
77
|
+
* given — the job id and this attempt's fence token. Never by owner,
|
|
78
|
+
* and never by job id alone: a re-claim of the same job is a different
|
|
79
|
+
* attempt, and a delegate that could not tell them apart would let the
|
|
80
|
+
* older one write through the younger one's store.
|
|
81
|
+
* @type {Map<string, any>}
|
|
82
|
+
*/
|
|
47
83
|
const active = new Map();
|
|
48
|
-
|
|
49
|
-
|
|
84
|
+
|
|
85
|
+
/** The run key the DAG sees, and the job id the STORE sees, are not
|
|
86
|
+
* the same string: the store keys rows by job, the delegate keys
|
|
87
|
+
* bindings by attempt. */
|
|
88
|
+
const runKeyOf = (jobId, token) => `${jobId}${RUN_KEY_SEPARATOR}${token}`;
|
|
89
|
+
const jobIdOf = (runKey) => runKey.slice(0, runKey.indexOf(RUN_KEY_SEPARATOR));
|
|
90
|
+
|
|
91
|
+
const bound = (runKey) => {
|
|
92
|
+
const context = active.get(runKey);
|
|
50
93
|
if (context === undefined) {
|
|
51
|
-
throw new
|
|
94
|
+
throw new DbRuntimeError('JD2069',
|
|
95
|
+
`no attempt holds run '${jobIdOf(runKey)}' — its lease was lost, or the run `
|
|
96
|
+
+ 'outlived the handler that started it',
|
|
97
|
+
{ docPath: '/jobs' });
|
|
52
98
|
}
|
|
53
|
-
return context
|
|
99
|
+
return context;
|
|
54
100
|
};
|
|
55
101
|
const checkpoint = {
|
|
56
|
-
load: (
|
|
57
|
-
save: (
|
|
58
|
-
|
|
102
|
+
load: (runKey) => bound(runKey).checkpoints.load(jobIdOf(runKey)),
|
|
103
|
+
save: (runKey, nodeId, value) =>
|
|
104
|
+
bound(runKey).checkpoints.save(jobIdOf(runKey), nodeId, value),
|
|
105
|
+
complete: (runKey, result) =>
|
|
106
|
+
bound(runKey).checkpoints.complete(jobIdOf(runKey), result),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Which declared task identities moved between two version maps. */
|
|
110
|
+
const describeVersionDrift = (before, after) => {
|
|
111
|
+
const names = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])].sort();
|
|
112
|
+
const moved = [];
|
|
113
|
+
for (const name of names) {
|
|
114
|
+
const was = before?.[name];
|
|
115
|
+
const now = after?.[name];
|
|
116
|
+
if (was === now) continue;
|
|
117
|
+
if (was === undefined) moved.push(`'${name}' is new at version ${now}`);
|
|
118
|
+
else if (now === undefined) moved.push(`'${name}' is gone (was version ${was})`);
|
|
119
|
+
else moved.push(`'${name}' moved from version ${was} to ${now}`);
|
|
120
|
+
}
|
|
121
|
+
return moved;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The identity a resumed run must agree with: the workflow document,
|
|
126
|
+
* the input, and the DECLARED versions of the task implementations
|
|
127
|
+
* (FLOW-FORMAT §7.8). The third closes the gap the first two cannot
|
|
128
|
+
* see — a handler reimplemented while its document stayed byte-equal
|
|
129
|
+
* produces checkpoints that describe a computation nobody asked for
|
|
130
|
+
* just as surely as an edited document does.
|
|
131
|
+
*/
|
|
132
|
+
const requireSameRun = async (context, jobId, revision, inputHash, taskVersions) => {
|
|
133
|
+
const loaded = await context.checkpoints.load(jobId);
|
|
134
|
+
const stored = loaded?.values?.[RUN_IDENTITY_NODE];
|
|
135
|
+
const taskVersionsHash = fingerprint(taskVersions);
|
|
136
|
+
const identity = { revision, inputHash, taskVersionsHash, taskVersions };
|
|
137
|
+
if (stored === undefined) {
|
|
138
|
+
await context.checkpoints.save(jobId, RUN_IDENTITY_NODE, identity);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const differs = [];
|
|
142
|
+
if (stored.revision !== revision) {
|
|
143
|
+
differs.push(`the workflow (checkpointed under revision ${stored.revision}, `
|
|
144
|
+
+ `this runner compiles revision ${revision})`);
|
|
145
|
+
}
|
|
146
|
+
if (stored.inputHash !== inputHash) {
|
|
147
|
+
differs.push(`the input (checkpointed under ${stored.inputHash}, `
|
|
148
|
+
+ `this attempt was given ${inputHash})`);
|
|
149
|
+
}
|
|
150
|
+
if (stored.taskVersionsHash === undefined) {
|
|
151
|
+
// A row written before task identity was recorded. Unknown is not
|
|
152
|
+
// equal: the upgrade is allowed only where nothing can be replayed
|
|
153
|
+
// wrongly — when no node value has been recorded yet, so the run has
|
|
154
|
+
// nothing to inherit from an implementation nobody can name.
|
|
155
|
+
const recorded = Object.keys(loaded?.values ?? {})
|
|
156
|
+
.filter((nodeId) => nodeId !== RUN_IDENTITY_NODE);
|
|
157
|
+
if (recorded.length === 0) {
|
|
158
|
+
await context.checkpoints.save(jobId, RUN_IDENTITY_NODE, identity);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
differs.push(`the task versions (this run recorded ${recorded.length} node value(s) `
|
|
162
|
+
+ 'before task identity was persisted, so the implementation that produced them '
|
|
163
|
+
+ 'cannot be confirmed)');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else if (stored.taskVersionsHash !== taskVersionsHash) {
|
|
167
|
+
const moved = describeVersionDrift(stored.taskVersions, taskVersions);
|
|
168
|
+
differs.push(`the task versions (${moved.length > 0 ? moved.join(', ')
|
|
169
|
+
: `checkpointed under ${stored.taskVersionsHash}, this runner compiles `
|
|
170
|
+
+ `${taskVersionsHash}`})`);
|
|
171
|
+
}
|
|
172
|
+
if (differs.length === 0) return;
|
|
173
|
+
throw new DbRuntimeError('JD2069',
|
|
174
|
+
`run '${jobId}' cannot resume: ${differs.join(' and ')} changed since its `
|
|
175
|
+
+ 'checkpoints were written. Enqueue it under a new id, or drop the run — '
|
|
176
|
+
+ 'reusing them would answer for a computation nobody asked for',
|
|
177
|
+
{ docPath: '/jobs', collection: jobId });
|
|
59
178
|
};
|
|
60
179
|
|
|
61
180
|
/** @type {Record<string, Function>} */
|
|
@@ -63,15 +182,24 @@ export function createDagJobRunner(store, options) {
|
|
|
63
182
|
for (const kind of Object.keys(documents)) {
|
|
64
183
|
const compiled = compileDag(documents[kind],
|
|
65
184
|
{ tasks: options.tasks ?? {}, checkpoint });
|
|
66
|
-
|
|
67
|
-
|
|
185
|
+
// one revision per compiled document, so every attempt of every run
|
|
186
|
+
// of this kind compares against the same number
|
|
187
|
+
const revision = fingerprint(documents[kind]);
|
|
188
|
+
setObjectMember(handlers, kind, async (payload, context) => {
|
|
189
|
+
const input = payload?.input ?? null;
|
|
190
|
+
const runKey = runKeyOf(context.job.id, context.job.lease.token);
|
|
191
|
+
active.set(runKey, context);
|
|
68
192
|
try {
|
|
69
|
-
|
|
193
|
+
await requireSameRun(context, context.job.id, revision, fingerprint(input),
|
|
194
|
+
compiled.taskVersions);
|
|
195
|
+
// the handler's signal reaches every task: a worker winding down
|
|
196
|
+
// inside its grace period, or a lease this attempt has lost
|
|
197
|
+
return await compiled.run(input, { runId: runKey, signal: context.signal });
|
|
70
198
|
}
|
|
71
199
|
finally {
|
|
72
|
-
active.delete(
|
|
200
|
+
active.delete(runKey);
|
|
73
201
|
}
|
|
74
|
-
};
|
|
202
|
+
});
|
|
75
203
|
}
|
|
76
204
|
|
|
77
205
|
return store.jobs.createWorker({
|
|
@@ -80,7 +208,12 @@ export function createDagJobRunner(store, options) {
|
|
|
80
208
|
pollInterval: options.pollInterval,
|
|
81
209
|
leaseMs: options.leaseMs,
|
|
82
210
|
owner: options.owner,
|
|
211
|
+
renew: options.renew,
|
|
212
|
+
onOutcome: options.onOutcome,
|
|
83
213
|
backoffBase: options.backoffBase,
|
|
84
214
|
backoffCap: options.backoffCap,
|
|
215
|
+
// the runner's declared default for `stop()` with no override; an
|
|
216
|
+
// explicit `stop({ graceMs })` still wins inside the worker
|
|
217
|
+
stopGraceMs: options.stopGraceMs,
|
|
85
218
|
});
|
|
86
219
|
}
|