@panagos/jojoscript 0.7.0 → 0.8.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/README.md +429 -418
- package/bin/jojo.js +430 -170
- package/package.json +50 -59
- package/src/compiler.js +197 -169
- package/src/pipeline-hash.js +27 -0
- package/src/pipeline-ir.js +100 -0
- package/src/pipeline-parser.js +583 -418
- package/src/runtime/checkpoint-store.js +122 -0
- package/src/runtime/checkpoint.js +69 -0
- package/src/runtime/collections.js +438 -327
- package/src/runtime/errors.js +29 -0
- package/src/runtime/execution-context.js +86 -0
- package/src/runtime/index.js +51 -29
- package/src/runtime/inspect.js +62 -0
- package/src/runtime/metrics.js +41 -0
- package/src/runtime/retry.js +67 -0
- package/src/runtime/trace.js +66 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* An error thrown by user code (or attached with `.retryable = false`) that
|
|
3
|
+
* `retry()` must never retry, no matter how many attempts remain.
|
|
4
|
+
*/
|
|
5
|
+
export class NonRetryableError extends Error {
|
|
6
|
+
constructor(message, options) {
|
|
7
|
+
super(message, options)
|
|
8
|
+
this.name = 'NonRetryableError'
|
|
9
|
+
this.retryable = false
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* A structured pipeline failure. Carries enough context (which pipeline,
|
|
15
|
+
* which node, which item, how many retries were attempted) for the CLI to
|
|
16
|
+
* print a useful failure report instead of a raw stack trace.
|
|
17
|
+
*/
|
|
18
|
+
export class PipelineError extends Error {
|
|
19
|
+
constructor(message, details = {}) {
|
|
20
|
+
super(message, { cause: details.cause })
|
|
21
|
+
this.name = 'PipelineError'
|
|
22
|
+
this.pipelineId = details.pipelineId ?? null
|
|
23
|
+
this.nodeId = details.nodeId ?? null
|
|
24
|
+
this.operation = details.operation ?? null
|
|
25
|
+
this.itemIndex = details.itemIndex ?? null
|
|
26
|
+
this.retryCount = details.retryCount ?? 0
|
|
27
|
+
this.timestamp = details.timestamp ?? new Date().toISOString()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
3
|
+
|
|
4
|
+
import { Metrics } from './metrics.js'
|
|
5
|
+
import { FileCheckpointStore } from './checkpoint-store.js'
|
|
6
|
+
|
|
7
|
+
const storage = new AsyncLocalStorage()
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* Everything a running pipeline needs that isn't the data itself: identity,
|
|
11
|
+
* timing, metrics, where to persist checkpoints, and CLI-provided options
|
|
12
|
+
* such as which checkpoint to replay from. Threaded through explicitly via
|
|
13
|
+
* `runInContext`/`getCurrentContext` (AsyncLocalStorage) instead of bare
|
|
14
|
+
* module-level globals.
|
|
15
|
+
*/
|
|
16
|
+
export class ExecutionContext {
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.pipelineId = options.pipelineId ?? null
|
|
19
|
+
this.executionId = options.executionId ?? randomId()
|
|
20
|
+
this.startedAt = options.startedAt ?? new Date().toISOString()
|
|
21
|
+
this.currentNode = null
|
|
22
|
+
this.metrics = options.metrics ?? new Metrics()
|
|
23
|
+
this.checkpointStore = options.checkpointStore ?? new FileCheckpointStore()
|
|
24
|
+
this.options = options.options ?? {}
|
|
25
|
+
this.replayFrom = options.replayFrom ?? null
|
|
26
|
+
this.quiet = options.quiet ?? false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function randomId() {
|
|
31
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function runInContext(context, fn) {
|
|
35
|
+
return storage.run(context, fn)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getCurrentContext() {
|
|
39
|
+
return storage.getStore()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
* Compiled pipelines call `checkpoint()`/`inspect()` as ordinary function
|
|
44
|
+
* calls, with no CLI wrapping required (`node compiled.js` must work on its
|
|
45
|
+
* own for the resume story to make sense). When no context has been set up
|
|
46
|
+
* via `runInContext`, a single lazily-created default context is used
|
|
47
|
+
* instead, configured from environment variables so the CLI can steer it
|
|
48
|
+
* (`jojo replay --from`, `--out`, profiling) without needing to run the
|
|
49
|
+
* compiled module in-process.
|
|
50
|
+
*/
|
|
51
|
+
let defaultContext = null
|
|
52
|
+
|
|
53
|
+
export function getDefaultContext() {
|
|
54
|
+
const active = getCurrentContext()
|
|
55
|
+
if (active) return active
|
|
56
|
+
|
|
57
|
+
if (!defaultContext) {
|
|
58
|
+
defaultContext = new ExecutionContext({
|
|
59
|
+
checkpointStore: new FileCheckpointStore(process.env.JOJO_CHECKPOINT_DIR || '.jojo-checkpoints'),
|
|
60
|
+
replayFrom: process.env.JOJO_REPLAY_FROM || null,
|
|
61
|
+
quiet: process.env.JOJO_QUIET === '1'
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if (process.env.JOJO_PROFILE_OUT) {
|
|
65
|
+
const outFile = process.env.JOJO_PROFILE_OUT
|
|
66
|
+
process.on('exit', () => {
|
|
67
|
+
try {
|
|
68
|
+
fs.writeFileSync(outFile, JSON.stringify(defaultContext.metrics.toJSON()))
|
|
69
|
+
} catch {
|
|
70
|
+
/* best-effort; a missing metrics file is handled by the CLI */
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return defaultContext
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/*
|
|
80
|
+
* Test-only escape hatch: forces the next `getDefaultContext()` call in
|
|
81
|
+
* this process to create a fresh default context instead of reusing a
|
|
82
|
+
* memoized one.
|
|
83
|
+
*/
|
|
84
|
+
export function resetDefaultContext() {
|
|
85
|
+
defaultContext = null
|
|
86
|
+
}
|
package/src/runtime/index.js
CHANGED
|
@@ -1,29 +1,51 @@
|
|
|
1
|
-
export {
|
|
2
|
-
map,
|
|
3
|
-
filter,
|
|
4
|
-
sort,
|
|
5
|
-
unique,
|
|
6
|
-
take,
|
|
7
|
-
skip,
|
|
8
|
-
flatMap,
|
|
9
|
-
reduce,
|
|
10
|
-
toArray,
|
|
11
|
-
count,
|
|
12
|
-
first,
|
|
13
|
-
find,
|
|
14
|
-
some,
|
|
15
|
-
every,
|
|
16
|
-
groupBy,
|
|
17
|
-
partition,
|
|
18
|
-
chunk,
|
|
19
|
-
window,
|
|
20
|
-
zip,
|
|
21
|
-
zipWith,
|
|
22
|
-
scan,
|
|
23
|
-
sortBy,
|
|
24
|
-
distinctBy,
|
|
25
|
-
tap,
|
|
26
|
-
mapAsync,
|
|
27
|
-
filterAsync,
|
|
28
|
-
toArrayAsync
|
|
29
|
-
|
|
1
|
+
export {
|
|
2
|
+
map,
|
|
3
|
+
filter,
|
|
4
|
+
sort,
|
|
5
|
+
unique,
|
|
6
|
+
take,
|
|
7
|
+
skip,
|
|
8
|
+
flatMap,
|
|
9
|
+
reduce,
|
|
10
|
+
toArray,
|
|
11
|
+
count,
|
|
12
|
+
first,
|
|
13
|
+
find,
|
|
14
|
+
some,
|
|
15
|
+
every,
|
|
16
|
+
groupBy,
|
|
17
|
+
partition,
|
|
18
|
+
chunk,
|
|
19
|
+
window,
|
|
20
|
+
zip,
|
|
21
|
+
zipWith,
|
|
22
|
+
scan,
|
|
23
|
+
sortBy,
|
|
24
|
+
distinctBy,
|
|
25
|
+
tap,
|
|
26
|
+
mapAsync,
|
|
27
|
+
filterAsync,
|
|
28
|
+
toArrayAsync,
|
|
29
|
+
parallel,
|
|
30
|
+
batch
|
|
31
|
+
} from './collections.js'
|
|
32
|
+
|
|
33
|
+
export { checkpoint } from './checkpoint.js'
|
|
34
|
+
export { inspect, formatInspectReport } from './inspect.js'
|
|
35
|
+
export { retry } from './retry.js'
|
|
36
|
+
export { traceNode } from './trace.js'
|
|
37
|
+
export { PipelineError, NonRetryableError } from './errors.js'
|
|
38
|
+
export { Metrics, throughputOf } from './metrics.js'
|
|
39
|
+
export {
|
|
40
|
+
CheckpointStore,
|
|
41
|
+
MemoryCheckpointStore,
|
|
42
|
+
FileCheckpointStore
|
|
43
|
+
} from './checkpoint-store.js'
|
|
44
|
+
export {
|
|
45
|
+
ExecutionContext,
|
|
46
|
+
runInContext,
|
|
47
|
+
getCurrentContext,
|
|
48
|
+
getDefaultContext,
|
|
49
|
+
resetDefaultContext
|
|
50
|
+
} from './execution-context.js'
|
|
51
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { getCurrentContext, getDefaultContext } from './execution-context.js'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* `inspect(items, label)` is a pure passthrough: it must never change what
|
|
5
|
+
* flows through the pipeline. It records how many items passed, how long
|
|
6
|
+
* that took, and derived throughput, then prints a small report (unless
|
|
7
|
+
* the execution context is marked quiet, e.g. under `node --test`).
|
|
8
|
+
*/
|
|
9
|
+
export async function* inspect(items, label, meta = {}) {
|
|
10
|
+
const ctx = getCurrentContext() ?? getDefaultContext()
|
|
11
|
+
const start = process.hrtime.bigint()
|
|
12
|
+
const startedAt = new Date().toISOString()
|
|
13
|
+
let count = 0
|
|
14
|
+
let failed = false
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
for await (const item of items) {
|
|
18
|
+
count++
|
|
19
|
+
yield item
|
|
20
|
+
}
|
|
21
|
+
} catch (error) {
|
|
22
|
+
failed = true
|
|
23
|
+
throw error
|
|
24
|
+
} finally {
|
|
25
|
+
const durationMs = Number(process.hrtime.bigint() - start) / 1e6
|
|
26
|
+
const durationSeconds = durationMs / 1000
|
|
27
|
+
const throughput = durationSeconds > 0 ? count / durationSeconds : count
|
|
28
|
+
|
|
29
|
+
const report = {
|
|
30
|
+
label,
|
|
31
|
+
id: meta.id ?? null,
|
|
32
|
+
pipelineId: meta.pipelineId ?? null,
|
|
33
|
+
items: count,
|
|
34
|
+
succeeded: failed ? Math.max(count - 1, 0) : count,
|
|
35
|
+
failed: failed ? 1 : 0,
|
|
36
|
+
durationMs,
|
|
37
|
+
throughput,
|
|
38
|
+
startedAt,
|
|
39
|
+
endedAt: new Date().toISOString(),
|
|
40
|
+
memoryUsageBytes: typeof process.memoryUsage === 'function' ? process.memoryUsage().heapUsed : undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
ctx?.metrics?.recordInspect(label, report)
|
|
44
|
+
|
|
45
|
+
if (!ctx || !ctx.quiet) {
|
|
46
|
+
console.log(formatInspectReport(report))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatInspectReport(report) {
|
|
52
|
+
const duration = (report.durationMs / 1000).toFixed(2)
|
|
53
|
+
const throughput = Math.round(report.throughput)
|
|
54
|
+
|
|
55
|
+
return [
|
|
56
|
+
report.label,
|
|
57
|
+
'-'.repeat(Math.max(report.label.length, 20)),
|
|
58
|
+
`items: ${report.items}`,
|
|
59
|
+
`throughput: ${throughput}/s`,
|
|
60
|
+
`duration: ${duration}s`
|
|
61
|
+
].join('\n')
|
|
62
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Aggregates per-node execution metrics for a single pipeline execution:
|
|
3
|
+
* item counts, cumulative duration, and derived throughput. Used by both
|
|
4
|
+
* `inspect()` (a single named checkpoint the user asked to observe) and the
|
|
5
|
+
* `jojo profile` compiler instrumentation (every node, automatically).
|
|
6
|
+
*/
|
|
7
|
+
export class Metrics {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.nodes = new Map()
|
|
10
|
+
this.inspections = []
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
record(id, type, items, durationMs) {
|
|
14
|
+
if (!id) return
|
|
15
|
+
|
|
16
|
+
const existing = this.nodes.get(id) ?? { id, type, items: 0, durationMs: 0 }
|
|
17
|
+
existing.items += items
|
|
18
|
+
existing.durationMs += durationMs
|
|
19
|
+
existing.type = type
|
|
20
|
+
this.nodes.set(id, existing)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
recordInspect(label, report) {
|
|
24
|
+
this.inspections.push(report)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
toJSON() {
|
|
28
|
+
return {
|
|
29
|
+
nodes: [...this.nodes.values()].map(node => ({
|
|
30
|
+
...node,
|
|
31
|
+
throughput: throughputOf(node.items, node.durationMs)
|
|
32
|
+
})),
|
|
33
|
+
inspections: this.inspections
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function throughputOf(items, durationMs) {
|
|
39
|
+
const seconds = durationMs / 1000
|
|
40
|
+
return seconds > 0 ? items / seconds : items
|
|
41
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { NonRetryableError } from './errors.js'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Wraps `fn` so calling the returned function retries on failure, up to
|
|
5
|
+
* `options.attempts` total tries, with an optional fixed or exponential
|
|
6
|
+
* delay between attempts. Errors marked non-retryable (`NonRetryableError`,
|
|
7
|
+
* or any error with `error.retryable === false`) are never retried and
|
|
8
|
+
* propagate immediately.
|
|
9
|
+
*
|
|
10
|
+
* Used two ways by compiled pipelines:
|
|
11
|
+
* - fused into a preceding `map`/`mapAsync`/`filterAsync`/`tap` stage, so
|
|
12
|
+
* each *item* is retried independently: `map(items, retry(fetch, opts))`.
|
|
13
|
+
* - standalone, wrapping the whole pipeline expression built so far as a
|
|
14
|
+
* zero-argument thunk: `retry(() => (expr), opts)()`.
|
|
15
|
+
*/
|
|
16
|
+
export function retry(fn, options = {}, meta = {}) {
|
|
17
|
+
const attempts = options.attempts ?? 1
|
|
18
|
+
const backoff = options.backoff ?? 'fixed'
|
|
19
|
+
const delayMs = options.delay ?? 0
|
|
20
|
+
|
|
21
|
+
return async function retried(...args) {
|
|
22
|
+
let attempt = 1
|
|
23
|
+
|
|
24
|
+
while (true) {
|
|
25
|
+
try {
|
|
26
|
+
return await fn(...args)
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error instanceof NonRetryableError || error?.retryable === false) {
|
|
29
|
+
annotate(error, meta, attempt)
|
|
30
|
+
throw error
|
|
31
|
+
}
|
|
32
|
+
if (attempt >= attempts) {
|
|
33
|
+
annotate(error, meta, attempt)
|
|
34
|
+
throw error
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const wait = backoff === 'exponential'
|
|
38
|
+
? delayMs * 2 ** (attempt - 1)
|
|
39
|
+
: delayMs
|
|
40
|
+
|
|
41
|
+
if (wait > 0) await sleep(wait)
|
|
42
|
+
attempt++
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sleep(ms) {
|
|
49
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
* Attaches structured failure context (§16: pipelineId, nodeId, retryCount)
|
|
54
|
+
* to the error without changing its type/instanceof behavior, so the CLI
|
|
55
|
+
* (or user code) can report exactly which pipeline node exhausted retries.
|
|
56
|
+
*/
|
|
57
|
+
function annotate(error, meta, retryCount) {
|
|
58
|
+
if (error && typeof error === 'object') {
|
|
59
|
+
error.jojo = {
|
|
60
|
+
pipelineId: meta.pipelineId ?? null,
|
|
61
|
+
nodeId: meta.id ?? null,
|
|
62
|
+
operation: 'retry',
|
|
63
|
+
retryCount,
|
|
64
|
+
timestamp: new Date().toISOString()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getCurrentContext, getDefaultContext } from './execution-context.js'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Generic per-node instrumentation used only by `jojo profile` (the
|
|
5
|
+
* compiler wraps every stage's output with a call to this function when
|
|
6
|
+
* compiled with `{ profile: true }`). It must be transparent to *type*,
|
|
7
|
+
* not just to values: eager results (arrays, Maps, Sets, scalars) are
|
|
8
|
+
* counted and returned completely unchanged, while lazy
|
|
9
|
+
* generator/async-generator results are wrapped in an equivalent
|
|
10
|
+
* generator that counts items as they're pulled through — never
|
|
11
|
+
* materializing anything itself.
|
|
12
|
+
*/
|
|
13
|
+
export function traceNode(value, meta = {}) {
|
|
14
|
+
const ctx = getCurrentContext() ?? getDefaultContext()
|
|
15
|
+
const start = process.hrtime.bigint()
|
|
16
|
+
|
|
17
|
+
function finish(count) {
|
|
18
|
+
const durationMs = Number(process.hrtime.bigint() - start) / 1e6
|
|
19
|
+
ctx.metrics.record(meta.id, meta.type, count, durationMs)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const isAsyncGenerator = value != null &&
|
|
23
|
+
typeof value.next === 'function' &&
|
|
24
|
+
typeof value[Symbol.asyncIterator] === 'function'
|
|
25
|
+
|
|
26
|
+
const isSyncGenerator = !isAsyncGenerator && value != null &&
|
|
27
|
+
typeof value.next === 'function' &&
|
|
28
|
+
typeof value[Symbol.iterator] === 'function'
|
|
29
|
+
|
|
30
|
+
if (isAsyncGenerator) {
|
|
31
|
+
return (async function* () {
|
|
32
|
+
let count = 0
|
|
33
|
+
try {
|
|
34
|
+
for await (const item of value) {
|
|
35
|
+
count++
|
|
36
|
+
yield item
|
|
37
|
+
}
|
|
38
|
+
} finally {
|
|
39
|
+
finish(count)
|
|
40
|
+
}
|
|
41
|
+
})()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isSyncGenerator) {
|
|
45
|
+
return (function* () {
|
|
46
|
+
let count = 0
|
|
47
|
+
try {
|
|
48
|
+
for (const item of value) {
|
|
49
|
+
count++
|
|
50
|
+
yield item
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
finish(count)
|
|
54
|
+
}
|
|
55
|
+
})()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const count = Array.isArray(value)
|
|
59
|
+
? value.length
|
|
60
|
+
: value instanceof Map || value instanceof Set
|
|
61
|
+
? value.size
|
|
62
|
+
: 1
|
|
63
|
+
|
|
64
|
+
finish(count)
|
|
65
|
+
return value
|
|
66
|
+
}
|