@factoidal/core 0.5.1 → 0.7.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/CHANGELOG.md +191 -0
- package/README.md +216 -16
- package/bin/ext.mjs +198 -0
- package/bin/factoidal.mjs +93 -9
- package/bin/pack-host.mjs +21 -2
- package/bin/pack.mjs +8 -2
- package/bin/store-worker-host.mjs +397 -0
- package/bin/store-worker.mjs +100 -0
- package/bin/store.mjs +60 -36
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.mjs +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +4 -4
- package/package.json +4 -1
- package/version.json +1 -1
package/bin/factoidal.mjs
CHANGED
|
@@ -28,6 +28,9 @@ import { loadEngine } from './engine.mjs'
|
|
|
28
28
|
import { sampleStoreFacts, sampleStorePath } from '../sample-store.mjs'
|
|
29
29
|
import { PackError, packSupported, verifyGeneration } from './pack.mjs'
|
|
30
30
|
import { denoReexec, isStackOverflow, runPack } from './pack-host.mjs'
|
|
31
|
+
import {
|
|
32
|
+
planQueryOnWorker, queryStoreOnWorker, workerRouteAvailable
|
|
33
|
+
} from './store-worker-host.mjs'
|
|
31
34
|
import {
|
|
32
35
|
STACK_REMEDY, StoreOperationError, inspectManifest, openStore, planQuery,
|
|
33
36
|
queryStore, stackLimitAdvice, turtleOfNQuads
|
|
@@ -42,7 +45,7 @@ const EXIT_NOT_WIRED = 3
|
|
|
42
45
|
// a time, so this is the window that catches one feed per report.
|
|
43
46
|
const FEED_PROGRESS = 65536
|
|
44
47
|
|
|
45
|
-
const PACK_LAYOUTS = ['ibk3', 'ibk4']
|
|
48
|
+
const PACK_LAYOUTS = ['ibk3', 'ibk4', 'ibk5']
|
|
46
49
|
const PACK_SYNTAXES = ['turtle', 'trig', 'nquads', 'ntriples']
|
|
47
50
|
const PACK_SUFFIXES = [
|
|
48
51
|
['.ttl', 'turtle'], ['.turtle', 'turtle'],
|
|
@@ -179,6 +182,21 @@ options:
|
|
|
179
182
|
--generation NAME query this generation instead of the activated one
|
|
180
183
|
--json shorthand for --format json
|
|
181
184
|
--quiet print the result only, no plan line on stderr
|
|
185
|
+
--no-worker never retry on a worker thread (see below)
|
|
186
|
+
|
|
187
|
+
Several engine paths recurse once per manifest entry or once per row, and
|
|
188
|
+
against a large collection that exceeds the runtime's default call stack.
|
|
189
|
+
A one-shot query builds no store handle, so it runs IN PROCESS and pays
|
|
190
|
+
nothing for a worker it would drop; only when the runtime runs out of
|
|
191
|
+
frames does it run again on a worker thread with a raised stack, or under
|
|
192
|
+
Deno re-execute itself once with a raised V8 stack, which needs
|
|
193
|
+
--allow-run and --allow-env (https://github.com/danbri/factoidal/issues/653).
|
|
194
|
+
--no-worker turns the retry off, and the frame budget is then reported.
|
|
195
|
+
|
|
196
|
+
A process that asks MANY questions about one store should hold a store
|
|
197
|
+
handle instead: openStoreHandleOnWorker in bin/store-worker-host.mjs
|
|
198
|
+
verifies and decodes once, on a thread with the raised stack, and answers
|
|
199
|
+
every later query from what it retained.
|
|
182
200
|
|
|
183
201
|
formats:
|
|
184
202
|
table a human display of the engine's SPARQL Query Results JSON;
|
|
@@ -212,9 +230,15 @@ INPUT is an RDF file. OUTPUT is the generation directory to create; it is
|
|
|
212
230
|
normally STORE/gen-N. Packing does not activate: run activate next.
|
|
213
231
|
|
|
214
232
|
options:
|
|
215
|
-
--layout LAYOUT ibk3 (triples, default)
|
|
233
|
+
--layout LAYOUT ibk3 (triples, default), ibk4 (quads, wire version 9)
|
|
234
|
+
or ibk5 (quads, wire version 10: RDF 1.2 terms,
|
|
235
|
+
out-of-line literals, zone maps)
|
|
216
236
|
--syntax SYNTAX turtle, trig or nquads; default from the file extension
|
|
217
237
|
--chunk-bytes N Merkle chunk size; default is the engine's
|
|
238
|
+
--batch-bytes N ibk4 only: source bytes between publication batches;
|
|
239
|
+
default is the engine's. Blocks are published during
|
|
240
|
+
the ingest pass, so this bounds the packer's memory.
|
|
241
|
+
Match l4block-shard-pack --batch-bytes for its blocks.
|
|
218
242
|
--json emit one JSON object
|
|
219
243
|
--no-worker pack in this process instead of on a worker thread
|
|
220
244
|
|
|
@@ -452,10 +476,27 @@ async function commandInspect (positional, options) {
|
|
|
452
476
|
out(`term registry ${envelope.termRegistryVersion === '' ? '(none recorded)' : envelope.termRegistryVersion}`)
|
|
453
477
|
out(`fixed-chunk Merkle commitment ${envelope.rangeCommitted ? 'yes' : 'no'}`)
|
|
454
478
|
out(`${envelope.entries.length} ${envelope.entries.length === 1 ? 'entry' : 'entries'}, ${envelope.totalBytes} bytes, ${plural(envelope.totalRows, 'row')}`)
|
|
479
|
+
// Wire version 10: the manifest blob table. Each `blob-<hex>.lit` holds the
|
|
480
|
+
// lexical form of one literal above the 65,536-byte inline ceiling, and is
|
|
481
|
+
// shared by every block whose dictionary names it.
|
|
482
|
+
const blobs = envelope.blobs ?? []
|
|
483
|
+
if (blobs.length > 0) {
|
|
484
|
+
let blobBytes = 0
|
|
485
|
+
for (const blob of blobs) blobBytes += blob.bytes
|
|
486
|
+
out(`${plural(blobs.length, 'out-of-line literal')}, ${blobBytes} bytes`)
|
|
487
|
+
}
|
|
455
488
|
out(`generation directory holds ${files.length} files, ${directoryBytes} bytes`)
|
|
456
489
|
out('')
|
|
490
|
+
// The zone maps are per entry, so the columns appear only for a manifest
|
|
491
|
+
// that carries them. A bound is the first 64 bytes of the smallest (or
|
|
492
|
+
// largest) term key of the block; the first eight are enough to see that
|
|
493
|
+
// two blocks hold different ranges.
|
|
494
|
+
const zoned = envelope.entries.some((entry) => entry.subjectZone !== undefined)
|
|
495
|
+
const zoneCell = (zone) =>
|
|
496
|
+
zone === undefined ? '-' : `${zone.min.slice(0, 16)}..${zone.max.slice(0, 16)}`
|
|
457
497
|
printTable(
|
|
458
|
-
['#', 'rows', 'bytes', 'kind', 'graphs', 'predicate']
|
|
498
|
+
['#', 'rows', 'bytes', 'kind', 'graphs', 'predicate']
|
|
499
|
+
.concat(zoned ? ['blobs', 'subject zone'] : []),
|
|
459
500
|
envelope.entries.map((entry) => [
|
|
460
501
|
String(entry.ordinal),
|
|
461
502
|
String(entry.rows),
|
|
@@ -463,7 +504,9 @@ async function commandInspect (positional, options) {
|
|
|
463
504
|
entry.blockKind,
|
|
464
505
|
entry.graphs.length === 0 ? '-' : entry.graphs.map(graphLabel).join(' '),
|
|
465
506
|
entry.predicate
|
|
466
|
-
]
|
|
507
|
+
].concat(zoned
|
|
508
|
+
? [String((entry.blobRefs ?? []).length), zoneCell(entry.subjectZone)]
|
|
509
|
+
: [])))
|
|
467
510
|
return EXIT_OK
|
|
468
511
|
}
|
|
469
512
|
|
|
@@ -532,6 +575,36 @@ function reportStoreFailure (error) {
|
|
|
532
575
|
return EXIT_FAILURE
|
|
533
576
|
}
|
|
534
577
|
|
|
578
|
+
/**
|
|
579
|
+
* Run one store call again on a bigger call stack, after the in-process
|
|
580
|
+
* attempt ran out of frames.
|
|
581
|
+
*
|
|
582
|
+
* A one-shot query builds no handle and drops what it loads, so it runs
|
|
583
|
+
* IN PROCESS first and pays nothing for a worker it would throw away.
|
|
584
|
+
* This is the retry, and it is reached only by the overflow.
|
|
585
|
+
*
|
|
586
|
+
* Node takes the worker route; Deno has no `worker_threads` route with a
|
|
587
|
+
* stack size, so it re-executes this command once with a raised V8 stack
|
|
588
|
+
* (`denoReexec`), which needs --allow-run and --allow-env.
|
|
589
|
+
*
|
|
590
|
+
* @returns the retried answer, or a number to exit with (the Deno child's
|
|
591
|
+
* code), or null when no bigger stack is available here
|
|
592
|
+
*/
|
|
593
|
+
async function onBiggerStack (options, quiet, retry) {
|
|
594
|
+
const host = { worker: options['no-worker'] !== true }
|
|
595
|
+
if (host.worker === false) return null
|
|
596
|
+
if (isDeno) {
|
|
597
|
+
const code = await denoReexec(host)
|
|
598
|
+
return typeof code === 'number' ? code : null
|
|
599
|
+
}
|
|
600
|
+
if (!workerRouteAvailable()) return null
|
|
601
|
+
if (!quiet) {
|
|
602
|
+
err('The runtime ran out of call stack; running again on a worker thread ' +
|
|
603
|
+
'with a bigger one.')
|
|
604
|
+
}
|
|
605
|
+
return await retry()
|
|
606
|
+
}
|
|
607
|
+
|
|
535
608
|
async function commandQuery (positional, options) {
|
|
536
609
|
if (positional.length < 1) throw new UsageError('query needs a STORE')
|
|
537
610
|
if (typeof options.base === 'string') {
|
|
@@ -554,8 +627,13 @@ async function commandQuery (positional, options) {
|
|
|
554
627
|
try {
|
|
555
628
|
plan = planQuery(engine, store, sparql)
|
|
556
629
|
} catch (error) {
|
|
557
|
-
if (error instanceof StoreOperationError)
|
|
558
|
-
|
|
630
|
+
if (!(error instanceof StoreOperationError)) throw error
|
|
631
|
+
if (!error.stackLimit) return reportStoreFailure(error)
|
|
632
|
+
const retried = await onBiggerStack(options, quiet,
|
|
633
|
+
() => planQueryOnWorker(root, named, sparql))
|
|
634
|
+
if (retried === null) return reportStoreFailure(error)
|
|
635
|
+
if (typeof retried === 'number') return retried
|
|
636
|
+
plan = retried
|
|
559
637
|
}
|
|
560
638
|
if (format === 'json' || options.json === true) {
|
|
561
639
|
out(JSON.stringify(plan, null, 2))
|
|
@@ -572,8 +650,13 @@ async function commandQuery (positional, options) {
|
|
|
572
650
|
try {
|
|
573
651
|
answer = queryStore(engine, store, sparql)
|
|
574
652
|
} catch (error) {
|
|
575
|
-
if (error instanceof StoreOperationError)
|
|
576
|
-
|
|
653
|
+
if (!(error instanceof StoreOperationError)) throw error
|
|
654
|
+
if (!error.stackLimit) return reportStoreFailure(error)
|
|
655
|
+
const retried = await onBiggerStack(options, quiet,
|
|
656
|
+
() => queryStoreOnWorker(root, named, sparql))
|
|
657
|
+
if (retried === null) return reportStoreFailure(error)
|
|
658
|
+
if (typeof retried === 'number') return retried
|
|
659
|
+
answer = retried
|
|
577
660
|
}
|
|
578
661
|
const { plan, result, blobBytes } = answer
|
|
579
662
|
if (!quiet) {
|
|
@@ -706,7 +789,8 @@ async function commandPack (positional, options) {
|
|
|
706
789
|
let answer
|
|
707
790
|
try {
|
|
708
791
|
answer = await runPack(
|
|
709
|
-
{ kind: 'pack', input, output, syntax, layout, base: packBase(input, options)
|
|
792
|
+
{ kind: 'pack', input, output, syntax, layout, base: packBase(input, options),
|
|
793
|
+
batchBytes: options['batch-bytes'] },
|
|
710
794
|
quiet
|
|
711
795
|
? undefined
|
|
712
796
|
: (progress) => {
|
package/bin/pack-host.mjs
CHANGED
|
@@ -46,7 +46,7 @@ import { StoreHostError, listGeneration, readWhole } from '../store-host/index.m
|
|
|
46
46
|
import { fileUrlToPath, joinPath } from '../store-host/paths.mjs'
|
|
47
47
|
import { loadEngine } from './engine.mjs'
|
|
48
48
|
import { PackError, packFile, packSupported, verifyGeneration } from './pack.mjs'
|
|
49
|
-
import { openStore } from './store.mjs'
|
|
49
|
+
import { StoreOperationError, openStore } from './store.mjs'
|
|
50
50
|
|
|
51
51
|
const isDeno = typeof globalThis.Deno !== 'undefined'
|
|
52
52
|
|
|
@@ -144,6 +144,16 @@ export function describeError (error) {
|
|
|
144
144
|
path: error.path === undefined ? null : error.path
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
|
+
if (error instanceof StoreOperationError) {
|
|
148
|
+
return {
|
|
149
|
+
type: 'StoreOperationError',
|
|
150
|
+
message: error.message,
|
|
151
|
+
capValue: error.capValue,
|
|
152
|
+
capLimit: error.capLimit,
|
|
153
|
+
digestKey: error.digestKey,
|
|
154
|
+
stackLimit: error.stackLimit
|
|
155
|
+
}
|
|
156
|
+
}
|
|
147
157
|
return {
|
|
148
158
|
type: 'Error',
|
|
149
159
|
message: error && error.message ? String(error.message) : String(error)
|
|
@@ -160,6 +170,14 @@ export function reviveError (description) {
|
|
|
160
170
|
const detail = description.path === null ? {} : { path: description.path }
|
|
161
171
|
return new StoreHostError(description.code, description.message, detail)
|
|
162
172
|
}
|
|
173
|
+
if (description.type === 'StoreOperationError') {
|
|
174
|
+
return new StoreOperationError(description.message, {
|
|
175
|
+
capValue: description.capValue === null ? undefined : description.capValue,
|
|
176
|
+
capLimit: description.capLimit === null ? undefined : description.capLimit,
|
|
177
|
+
digestKey: description.digestKey === null ? undefined : description.digestKey,
|
|
178
|
+
stackLimit: description.stackLimit
|
|
179
|
+
})
|
|
180
|
+
}
|
|
163
181
|
return new Error(description.message)
|
|
164
182
|
}
|
|
165
183
|
|
|
@@ -197,6 +215,7 @@ export async function packHere (task, onProgress) {
|
|
|
197
215
|
syntax: task.syntax,
|
|
198
216
|
layout: task.layout,
|
|
199
217
|
base: task.base,
|
|
218
|
+
batchBytes: task.batchBytes,
|
|
200
219
|
onProgress
|
|
201
220
|
})
|
|
202
221
|
return { report }
|
|
@@ -280,7 +299,7 @@ async function packInWorker (task, onProgress) {
|
|
|
280
299
|
* already re-executed (see `denoReexec`), so this runs in process.
|
|
281
300
|
*
|
|
282
301
|
* @param {object} task {kind, ...}: a pack takes
|
|
283
|
-
* {input, output, syntax, layout, base}, an activate takes
|
|
302
|
+
* {input, output, syntax, layout, base, batchBytes}, an activate takes
|
|
284
303
|
* {root, generation}
|
|
285
304
|
* @param {(progress: object) => void} [onProgress]
|
|
286
305
|
* @param {object} [options] {worker: false} forces the in-process path
|
package/bin/pack.mjs
CHANGED
|
@@ -93,16 +93,22 @@ function drain (engine, handle, output) {
|
|
|
93
93
|
* @param {string} input the RDF file to read
|
|
94
94
|
* @param {string} output the generation directory to fill; it must exist
|
|
95
95
|
* @param {object} options
|
|
96
|
-
* @param {string} options.layout `ibk3` or `
|
|
96
|
+
* @param {string} options.layout `ibk3`, `ibk4` or `ibk5`
|
|
97
97
|
* @param {string} options.syntax `turtle`, `trig` or `nquads`
|
|
98
98
|
* @param {string} [options.base] the base IRI relative IRIs resolve against;
|
|
99
99
|
* the empty string means no base
|
|
100
|
+
* @param {number|string} [options.batchBytes] source bytes per publication
|
|
101
|
+
* batch for the IBK4 streamed grammars; omitted means the engine's own
|
|
102
|
+
* default. Match the native packer's `--batch-bytes` to get its block set.
|
|
100
103
|
* @param {(progress: object) => void} [options.onProgress]
|
|
101
104
|
* @returns {object} the engine's own finish envelope, plus what was written
|
|
102
105
|
*/
|
|
103
106
|
export function packFile (engine, input, output, options) {
|
|
104
107
|
const begun = engine.call('packBegin',
|
|
105
|
-
[options.syntax, options.layout,
|
|
108
|
+
[options.syntax, options.layout,
|
|
109
|
+
typeof options.base === 'string' ? options.base : '',
|
|
110
|
+
options.batchBytes === undefined || options.batchBytes === null
|
|
111
|
+
? '' : String(options.batchBytes)])
|
|
106
112
|
const handle = begun.handle
|
|
107
113
|
if (typeof handle !== 'string') {
|
|
108
114
|
throw new PackError('packBegin answered no handle')
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// Giving the store operations a call stack big enough to finish, without
|
|
2
|
+
// asking the reader for a runtime flag.
|
|
3
|
+
// https://github.com/danbri/factoidal/issues/653
|
|
4
|
+
//
|
|
5
|
+
// THE DEFECT THIS FILE EXISTS FOR
|
|
6
|
+
// Several engine paths recurse once per manifest entry or once per row.
|
|
7
|
+
// On a large generation that exceeds the default call stack of Node and
|
|
8
|
+
// of Deno, and the failure is `Maximum call stack size exceeded` rather
|
|
9
|
+
// than an engine refusal. MEASURED 2026-09-05 against the committed
|
|
10
|
+
// module, on a 7,315,251-quad collection of 3,286 blocks in 204 graphs
|
|
11
|
+
// (`/Users/danbri/working/factoidal-skosfull`): `storeQueryPlan` alone
|
|
12
|
+
// overflows on plain `node`, before a single artifact byte is read, and
|
|
13
|
+
// `node --stack-size=60000` clears the plan, the open and every query.
|
|
14
|
+
// Earlier the same limit was narrowed to any query that materialises
|
|
15
|
+
// about 14,576 rows or more, whatever it returns.
|
|
16
|
+
//
|
|
17
|
+
// `factoidal pack` had the same defect and it was solved host-side, on a
|
|
18
|
+
// worker thread with a raised stack (`bin/pack-host.mjs`,
|
|
19
|
+
// https://github.com/danbri/factoidal/issues/649). This file carries that
|
|
20
|
+
// route to the store operations.
|
|
21
|
+
//
|
|
22
|
+
// WHY THE HANDLE ITSELF LIVES IN THE WORKER
|
|
23
|
+
// A handle is STATE INSIDE THE WASM INSTANCE -- the verified, decoded,
|
|
24
|
+
// indexed blocks that `storeOpen` retained. A WebAssembly instance does
|
|
25
|
+
// not cross a thread boundary, so a handle opened on the main thread
|
|
26
|
+
// cannot be queried from a worker, and the reverse. Only two designs are
|
|
27
|
+
// available:
|
|
28
|
+
//
|
|
29
|
+
// (a) the handle lives in the worker, and `query()` and `close()` are
|
|
30
|
+
// messages to it;
|
|
31
|
+
// (b) each query spawns a worker, which re-reads and re-decodes every
|
|
32
|
+
// artifact.
|
|
33
|
+
//
|
|
34
|
+
// (b) destroys the reason a handle exists: `storeOpen` is the expensive
|
|
35
|
+
// call (5.4 s against the collection above, where a query is 0.6 s), and
|
|
36
|
+
// paying it per query is worse than the stateless `queryStore` path. So
|
|
37
|
+
// (a) is what this file implements.
|
|
38
|
+
//
|
|
39
|
+
// THE COST OF (a), STATED
|
|
40
|
+
// - One worker thread per SESSION, not per handle. A session holds one
|
|
41
|
+
// engine and as many handles as the engine's own handle cap allows,
|
|
42
|
+
// so a caller that opens several stores pays for one thread and one
|
|
43
|
+
// copy of the module. `openStoreHandleOnWorker` uses a shared session
|
|
44
|
+
// by default; `{ownWorker: true}` gives a handle its own thread, and
|
|
45
|
+
// its own copy of the 5.6 MB module, when a caller wants failure
|
|
46
|
+
// isolation between stores.
|
|
47
|
+
// - Every call is ASYNCHRONOUS, where the in-process handle is
|
|
48
|
+
// synchronous. A message round trip is the price of the bigger stack.
|
|
49
|
+
// - An extension function registered on the MAIN thread's engine
|
|
50
|
+
// (`bin/ext.mjs`) is not visible to the worker's engine. Register it
|
|
51
|
+
// inside the worker, or use the in-process handle.
|
|
52
|
+
// - The worker keeps the process alive until `close()` or
|
|
53
|
+
// `terminate()`. `unref()` is not used: a server that dropped its
|
|
54
|
+
// last reference would otherwise lose its store silently.
|
|
55
|
+
//
|
|
56
|
+
// WHAT A ONE-SHOT QUERY DOES INSTEAD
|
|
57
|
+
// A single `factoidal query` builds no handle and drops whatever it
|
|
58
|
+
// loads, so a worker is measured overhead it cannot amortise. It runs
|
|
59
|
+
// IN PROCESS, and only when the runtime runs out of frames does it retry
|
|
60
|
+
// once on a worker (`queryStoreOnWorker`). The normal case pays nothing.
|
|
61
|
+
|
|
62
|
+
import { loadEngine } from './engine.mjs'
|
|
63
|
+
import {
|
|
64
|
+
WORKER_STACK_MB, isStackOverflow, reviveError, workerRefused, workerStackMb
|
|
65
|
+
} from './pack-host.mjs'
|
|
66
|
+
import {
|
|
67
|
+
StoreOperationError, openStore, openStoreHandle, planQuery, queryStore
|
|
68
|
+
} from './store.mjs'
|
|
69
|
+
|
|
70
|
+
const isDeno = typeof globalThis.Deno !== 'undefined'
|
|
71
|
+
|
|
72
|
+
export { WORKER_STACK_MB, isStackOverflow, workerRefused, workerStackMb }
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether this runtime has the `worker_threads` route at all.
|
|
76
|
+
*
|
|
77
|
+
* Deno has a `node:worker_threads` shim, and it accepts
|
|
78
|
+
* `resourceLimits.stackSizeMb` and does almost nothing with it. MEASURED
|
|
79
|
+
* 2026-09-05, deno 2.9.4 / V8 15.0.245.2 against Node 22.22.2, by
|
|
80
|
+
* counting frames to the overflow inside the worker:
|
|
81
|
+
*
|
|
82
|
+
* runtime default stackSizeMb 64
|
|
83
|
+
* node 41,195 696,555
|
|
84
|
+
* deno 10,835 13,837
|
|
85
|
+
*
|
|
86
|
+
* A 1.28-times raise does not carry a store open that needs sixteen
|
|
87
|
+
* times the default, so Deno takes the re-exec route in
|
|
88
|
+
* `bin/pack-host.mjs` instead: the command runs itself again once with
|
|
89
|
+
* `--v8-flags=--stack-size=...`, which needs --allow-run and --allow-env.
|
|
90
|
+
* A Deno LIBRARY caller cannot re-execute its own host, so
|
|
91
|
+
* `openStoreHandleOnWorker` gives it an in-process handle behind the same
|
|
92
|
+
* asynchronous interface, and the process must be started with
|
|
93
|
+
* deno run --allow-read --v8-flags=--stack-size=65536
|
|
94
|
+
*/
|
|
95
|
+
export function workerRouteAvailable () {
|
|
96
|
+
return !isDeno
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ------------------------------------------------------------- a session
|
|
100
|
+
//
|
|
101
|
+
// One worker thread, one engine, many handles.
|
|
102
|
+
|
|
103
|
+
let nextRequestId = 1
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A worker thread holding one engine and the handles opened through it.
|
|
107
|
+
*
|
|
108
|
+
* Hold one of these for as long as a process answers questions about a
|
|
109
|
+
* store. `terminate()` drops the thread and everything in it.
|
|
110
|
+
*/
|
|
111
|
+
export class StoreWorkerSession {
|
|
112
|
+
constructor (worker) {
|
|
113
|
+
this.worker = worker
|
|
114
|
+
this.pending = new Map()
|
|
115
|
+
this.dead = null
|
|
116
|
+
this.handles = new Set()
|
|
117
|
+
worker.on('message', (message) => {
|
|
118
|
+
const waiting = this.pending.get(message.id)
|
|
119
|
+
if (waiting === undefined) return
|
|
120
|
+
this.pending.delete(message.id)
|
|
121
|
+
if (message.kind === 'error') waiting.reject(reviveError(message.error))
|
|
122
|
+
else waiting.resolve(message.answer)
|
|
123
|
+
})
|
|
124
|
+
worker.on('error', (error) => { this.fail(error) })
|
|
125
|
+
worker.on('exit', (code) => {
|
|
126
|
+
this.fail(new StoreOperationError(
|
|
127
|
+
`the store worker exited with code ${code}`))
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Fail every outstanding request and refuse every later one. */
|
|
132
|
+
fail (error) {
|
|
133
|
+
if (this.dead === null) this.dead = error
|
|
134
|
+
for (const waiting of this.pending.values()) waiting.reject(error)
|
|
135
|
+
this.pending.clear()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Send one request and await its answer. */
|
|
139
|
+
send (request) {
|
|
140
|
+
if (this.dead !== null) return Promise.reject(this.dead)
|
|
141
|
+
const id = nextRequestId
|
|
142
|
+
nextRequestId += 1
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
this.pending.set(id, { resolve, reject })
|
|
145
|
+
this.worker.postMessage({ ...request, id })
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Open one store handle inside this session's worker.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} root the collection root that holds CURRENT
|
|
153
|
+
* @param {{generation?: string, keys?: string[], sparql?: string}} options
|
|
154
|
+
* the same options `openStoreHandle` takes, plus `generation` to open
|
|
155
|
+
* a generation that has not been activated
|
|
156
|
+
* @returns {Promise<WorkerStoreHandle>}
|
|
157
|
+
*/
|
|
158
|
+
async open (root, options = {}) {
|
|
159
|
+
const envelope = await this.send({
|
|
160
|
+
kind: 'open',
|
|
161
|
+
root,
|
|
162
|
+
generation: typeof options.generation === 'string' ? options.generation : null,
|
|
163
|
+
keys: Array.isArray(options.keys) ? options.keys : null,
|
|
164
|
+
sparql: typeof options.sparql === 'string' ? options.sparql : null
|
|
165
|
+
})
|
|
166
|
+
const handle = new WorkerStoreHandle(this, envelope)
|
|
167
|
+
this.handles.add(handle)
|
|
168
|
+
return handle
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** `storeHandleList` from inside the worker: what this session holds. */
|
|
172
|
+
async list () {
|
|
173
|
+
return (await this.send({ kind: 'list' })).list
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Stop the thread. Every handle it held is gone with it. */
|
|
177
|
+
async terminate () {
|
|
178
|
+
if (this.dead === null) this.dead = new StoreOperationError(
|
|
179
|
+
'this store worker session is terminated')
|
|
180
|
+
for (const handle of this.handles) handle.closed = true
|
|
181
|
+
this.handles.clear()
|
|
182
|
+
await this.worker.terminate()
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A store handle held inside a worker thread.
|
|
188
|
+
*
|
|
189
|
+
* The same envelope fields as the in-process `StoreHandle`, and the same
|
|
190
|
+
* two methods, except that both are asynchronous. The wasm module is
|
|
191
|
+
* single-threaded, so two `query()` calls on one session are served in
|
|
192
|
+
* the order they were sent.
|
|
193
|
+
*/
|
|
194
|
+
export class WorkerStoreHandle {
|
|
195
|
+
constructor (session, envelope) {
|
|
196
|
+
this.session = session
|
|
197
|
+
this.handle = envelope.handle
|
|
198
|
+
this.identity = envelope.identity
|
|
199
|
+
this.layout = envelope.layout
|
|
200
|
+
this.wireVersion = envelope.wireVersion
|
|
201
|
+
this.artifacts = envelope.artifacts
|
|
202
|
+
this.bytes = envelope.bytes
|
|
203
|
+
this.rows = envelope.rows
|
|
204
|
+
this.closed = false
|
|
205
|
+
// Set when this handle owns its session, so `close()` stops the thread.
|
|
206
|
+
this.ownSession = false
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** One SPARQL query against the retained blocks. The envelope is the
|
|
210
|
+
* one the in-process handle answers with. */
|
|
211
|
+
async query (sparql) {
|
|
212
|
+
if (this.closed) {
|
|
213
|
+
throw new StoreOperationError(`store handle ${this.handle} is closed`)
|
|
214
|
+
}
|
|
215
|
+
return (await this.session.send({
|
|
216
|
+
kind: 'query', handle: this.handle, sparql
|
|
217
|
+
})).result
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Drop the handle and everything it retained. Idempotent. */
|
|
221
|
+
async close () {
|
|
222
|
+
if (this.closed) return
|
|
223
|
+
this.closed = true
|
|
224
|
+
this.session.handles.delete(this)
|
|
225
|
+
if (this.ownSession) {
|
|
226
|
+
await this.session.terminate()
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
if (this.session.dead !== null) return
|
|
230
|
+
await this.session.send({ kind: 'close', handle: this.handle })
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ------------------------------------------------------- starting one
|
|
235
|
+
|
|
236
|
+
/** Start a worker thread with a stack big enough for the store paths. */
|
|
237
|
+
async function startWorker () {
|
|
238
|
+
if (!workerRouteAvailable()) {
|
|
239
|
+
throw new StoreOperationError(
|
|
240
|
+
'this runtime has no worker_threads route for the store operations; ' +
|
|
241
|
+
'raise the call stack itself instead')
|
|
242
|
+
}
|
|
243
|
+
const { Worker } = await import('node:worker_threads')
|
|
244
|
+
const entry = new URL('./store-worker.mjs', import.meta.url)
|
|
245
|
+
const worker = new Worker(entry, {
|
|
246
|
+
resourceLimits: { stackSizeMb: workerStackMb() }
|
|
247
|
+
})
|
|
248
|
+
const session = new StoreWorkerSession(worker)
|
|
249
|
+
// Load the engine now, so the first query measures the query.
|
|
250
|
+
await session.send({ kind: 'warm' })
|
|
251
|
+
return session
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Start a session of this session's own. */
|
|
255
|
+
export async function openStoreWorkerSession () {
|
|
256
|
+
return await startWorker()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let sharedSession = null
|
|
260
|
+
|
|
261
|
+
/** The session `openStoreHandleOnWorker` uses when the caller names none:
|
|
262
|
+
* one thread, one engine, shared by every handle in the process. */
|
|
263
|
+
export async function sharedStoreWorkerSession () {
|
|
264
|
+
if (sharedSession === null) sharedSession = startWorker()
|
|
265
|
+
try {
|
|
266
|
+
return await sharedSession
|
|
267
|
+
} catch (error) {
|
|
268
|
+
sharedSession = null
|
|
269
|
+
throw error
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Stop the shared session, if one was started. */
|
|
274
|
+
export async function closeSharedStoreWorkerSession () {
|
|
275
|
+
if (sharedSession === null) return
|
|
276
|
+
const session = sharedSession
|
|
277
|
+
sharedSession = null
|
|
278
|
+
await (await session).terminate()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Open one store handle on a worker thread with a raised call stack.
|
|
283
|
+
*
|
|
284
|
+
* This is the handle a long-lived process wants against a large store: an
|
|
285
|
+
* MCP server, a chat tool loop, a SPARQL endpoint. It is asynchronous
|
|
286
|
+
* where `openStoreHandle` is synchronous, and it needs no runtime flag.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} root the collection root that holds CURRENT
|
|
289
|
+
* @param {{generation?: string, keys?: string[], sparql?: string,
|
|
290
|
+
* session?: StoreWorkerSession, ownWorker?: boolean}} options
|
|
291
|
+
* @returns {Promise<WorkerStoreHandle>}
|
|
292
|
+
*/
|
|
293
|
+
export async function openStoreHandleOnWorker (root, options = {}) {
|
|
294
|
+
if (!workerRouteAvailable() || workerRefused(options)) {
|
|
295
|
+
return await openHere(root, options)
|
|
296
|
+
}
|
|
297
|
+
if (options.session instanceof StoreWorkerSession) {
|
|
298
|
+
return await options.session.open(root, options)
|
|
299
|
+
}
|
|
300
|
+
if (options.ownWorker === true) {
|
|
301
|
+
const session = await startWorker()
|
|
302
|
+
const handle = await session.open(root, options)
|
|
303
|
+
handle.ownSession = true
|
|
304
|
+
return handle
|
|
305
|
+
}
|
|
306
|
+
const session = await sharedStoreWorkerSession()
|
|
307
|
+
return await session.open(root, options)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// -------------------------------- the in-process facade, for Deno
|
|
311
|
+
//
|
|
312
|
+
// Same two methods, same envelopes, no thread. It exists so one caller
|
|
313
|
+
// works on both runtimes; on Deno the stack must come from the host
|
|
314
|
+
// (`--v8-flags=--stack-size=65536`), because Deno's worker_threads shim
|
|
315
|
+
// does not raise it (see `workerRouteAvailable`).
|
|
316
|
+
|
|
317
|
+
/** A handle held in this process, behind the asynchronous interface. */
|
|
318
|
+
export class LocalStoreHandle {
|
|
319
|
+
constructor (handle) {
|
|
320
|
+
this.local = handle
|
|
321
|
+
this.handle = handle.handle
|
|
322
|
+
this.identity = handle.identity
|
|
323
|
+
this.layout = handle.layout
|
|
324
|
+
this.wireVersion = handle.wireVersion
|
|
325
|
+
this.artifacts = handle.artifacts
|
|
326
|
+
this.bytes = handle.bytes
|
|
327
|
+
this.rows = handle.rows
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
get closed () { return this.local.closed }
|
|
331
|
+
|
|
332
|
+
async query (sparql) { return this.local.query(sparql) }
|
|
333
|
+
|
|
334
|
+
async close () { this.local.close() }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Open a handle in this process, behind the asynchronous interface. */
|
|
338
|
+
async function openHere (root, options) {
|
|
339
|
+
const engine = await loadEngine()
|
|
340
|
+
const store = openStore(root,
|
|
341
|
+
typeof options.generation === 'string' ? options.generation : null)
|
|
342
|
+
const pick = {}
|
|
343
|
+
if (Array.isArray(options.keys)) pick.keys = options.keys
|
|
344
|
+
else if (typeof options.sparql === 'string') pick.sparql = options.sparql
|
|
345
|
+
return new LocalStoreHandle(openStoreHandle(engine, store, pick))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ------------------------------------------------- the one-shot retry
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Run one stateless query on a worker thread, then stop the thread.
|
|
352
|
+
*
|
|
353
|
+
* What `factoidal query` retries with after the in-process attempt ran
|
|
354
|
+
* out of frames. It answers exactly what `queryStore` answers.
|
|
355
|
+
*
|
|
356
|
+
* @returns {Promise<{plan: object, result: object, blobBytes: number}>}
|
|
357
|
+
*/
|
|
358
|
+
export async function queryStoreOnWorker (root, generation, sparql) {
|
|
359
|
+
if (!workerRouteAvailable()) {
|
|
360
|
+
const engine = await loadEngine()
|
|
361
|
+
const store = openStore(root, generation ?? null)
|
|
362
|
+
const answer = queryStore(engine, store, sparql)
|
|
363
|
+
return { plan: answer.plan, result: answer.result, blobBytes: answer.blobBytes }
|
|
364
|
+
}
|
|
365
|
+
const session = await startWorker()
|
|
366
|
+
try {
|
|
367
|
+
return await session.send({
|
|
368
|
+
kind: 'query-once', root, generation: generation ?? null, sparql
|
|
369
|
+
})
|
|
370
|
+
} finally {
|
|
371
|
+
await session.terminate()
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Plan one query on a worker thread, then stop the thread.
|
|
377
|
+
*
|
|
378
|
+
* `storeQueryPlan` walks the manifest, and on a collection of a few
|
|
379
|
+
* thousand blocks that walk alone exceeds the default stack. What
|
|
380
|
+
* `factoidal query --explain` retries with.
|
|
381
|
+
*
|
|
382
|
+
* @returns {Promise<object>} the plan envelope
|
|
383
|
+
*/
|
|
384
|
+
export async function planQueryOnWorker (root, generation, sparql) {
|
|
385
|
+
if (!workerRouteAvailable()) {
|
|
386
|
+
const engine = await loadEngine()
|
|
387
|
+
return planQuery(engine, openStore(root, generation ?? null), sparql)
|
|
388
|
+
}
|
|
389
|
+
const session = await startWorker()
|
|
390
|
+
try {
|
|
391
|
+
return (await session.send({
|
|
392
|
+
kind: 'plan', root, generation: generation ?? null, sparql
|
|
393
|
+
})).plan
|
|
394
|
+
} finally {
|
|
395
|
+
await session.terminate()
|
|
396
|
+
}
|
|
397
|
+
}
|