@panagos/jojoscript 0.7.1 → 0.9.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 +809 -418
- package/bin/jojo.js +430 -170
- package/package.json +50 -59
- package/src/compiler.js +227 -186
- package/src/effect-parser.js +223 -0
- package/src/index.js +14 -0
- package/src/pipeline-hash.js +27 -0
- package/src/pipeline-ir.js +157 -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 +467 -327
- package/src/runtime/effect-log.js +90 -0
- package/src/runtime/effects.js +263 -0
- package/src/runtime/errors.js +29 -0
- package/src/runtime/execution-context.js +86 -0
- package/src/runtime/index.js +70 -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,90 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Storage interface for recorded effect calls, mirroring
|
|
6
|
+
* `checkpoint-store.js`'s `CheckpointStore`: the record/replay handler
|
|
7
|
+
* wrappers in `effects.js` only ever talk to this interface, never to a
|
|
8
|
+
* filesystem/database directly, so alternative backends can be added later
|
|
9
|
+
* without touching the effect runtime.
|
|
10
|
+
*/
|
|
11
|
+
export class EffectLog {
|
|
12
|
+
async record(_call) {
|
|
13
|
+
throw new Error('EffectLog.record() is not implemented')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async replayNext(_name) {
|
|
17
|
+
throw new Error('EffectLog.replayNext() is not implemented')
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* In-memory log. `replayNext(name)` returns recorded calls for that effect
|
|
23
|
+
* name in the order they were recorded, one per call — the same effect
|
|
24
|
+
* invoked three times during recording replays its three results in order
|
|
25
|
+
* during replay, regardless of what other effects were interleaved.
|
|
26
|
+
*/
|
|
27
|
+
export class MemoryEffectLog extends EffectLog {
|
|
28
|
+
constructor(entries = []) {
|
|
29
|
+
super()
|
|
30
|
+
this.entries = [...entries]
|
|
31
|
+
this.cursor = new Map()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async record(call) {
|
|
35
|
+
this.entries.push(call)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async replayNext(name) {
|
|
39
|
+
const index = this.cursor.get(name) ?? 0
|
|
40
|
+
const matches = this.entries.filter(entry => entry.name === name)
|
|
41
|
+
|
|
42
|
+
if (index >= matches.length) {
|
|
43
|
+
throw new Error(`No recorded call left to replay for effect "${name}"`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.cursor.set(name, index + 1)
|
|
47
|
+
return matches[index]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
toJSON() {
|
|
51
|
+
return this.entries
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
* JSON-on-disk log, one file for the whole recording. Like
|
|
57
|
+
* `FileCheckpointStore`, writes go to a temp file first and are then
|
|
58
|
+
* renamed into place so a crash mid-write never corrupts the log.
|
|
59
|
+
*/
|
|
60
|
+
export class FileEffectLog extends EffectLog {
|
|
61
|
+
constructor(file) {
|
|
62
|
+
super()
|
|
63
|
+
this.file = file
|
|
64
|
+
this.entries = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : []
|
|
65
|
+
this.cursor = new Map()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async record(call) {
|
|
69
|
+
this.entries.push(call)
|
|
70
|
+
|
|
71
|
+
const dir = path.dirname(this.file)
|
|
72
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
73
|
+
|
|
74
|
+
const tmp = `${this.file}.tmp-${process.pid}-${Date.now()}`
|
|
75
|
+
fs.writeFileSync(tmp, JSON.stringify(this.entries, null, 2))
|
|
76
|
+
fs.renameSync(tmp, this.file)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async replayNext(name) {
|
|
80
|
+
const index = this.cursor.get(name) ?? 0
|
|
81
|
+
const matches = this.entries.filter(entry => entry.name === name)
|
|
82
|
+
|
|
83
|
+
if (index >= matches.length) {
|
|
84
|
+
throw new Error(`No recorded call left to replay for effect "${name}"`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.cursor.set(name, index + 1)
|
|
88
|
+
return matches[index]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Algebraic effects / effect handlers runtime.
|
|
5
|
+
*
|
|
6
|
+
* The core idea: `defineEffect` turns a name into a callable that *describes
|
|
7
|
+
* an intent* ("perform fetchUser(42)") without deciding how it is carried
|
|
8
|
+
* out. `withHandlers` installs a handler map for the dynamic extent of a
|
|
9
|
+
* callback (an async-local, stack-shaped scope, exactly like
|
|
10
|
+
* `execution-context.js` already does for checkpoint/metrics state), and
|
|
11
|
+
* `performEffect` resolves an effect call against the innermost matching
|
|
12
|
+
* handler at the moment it is invoked.
|
|
13
|
+
*
|
|
14
|
+
* This is deliberately a *simplified* effect-handler model, not full
|
|
15
|
+
* algebraic effects: there is no delimited-continuation `resume`, handlers
|
|
16
|
+
* cannot resume a suspended computation more than once (or zero times) and
|
|
17
|
+
* inspect the result, and effects cannot be "re-thrown" to an outer handler
|
|
18
|
+
* after partial handling. What it *does* provide: named effects, dynamic
|
|
19
|
+
* handler dispatch, handler nesting/overriding, deterministic-vs-real
|
|
20
|
+
* handler swapping, and a clean async/JS interop story (a handler is just
|
|
21
|
+
* a normal — sync or async — JavaScript function).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const handlerStorage = new AsyncLocalStorage()
|
|
25
|
+
const invocationStorage = new AsyncLocalStorage()
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* name -> { name, params, sideEffecting, replayable, builtin? }.
|
|
29
|
+
* Populated by every `defineEffect` call (i.e. every `effect name(...)`
|
|
30
|
+
* declaration compiles into one), plus the built-in `PARALLEL` capability.
|
|
31
|
+
* Purely descriptive metadata for inspection — never consulted by
|
|
32
|
+
* dispatch itself.
|
|
33
|
+
*/
|
|
34
|
+
const effectRegistry = new Map()
|
|
35
|
+
|
|
36
|
+
export class UnhandledEffectError extends Error {
|
|
37
|
+
constructor(name) {
|
|
38
|
+
super(`Unhandled effect: ${name}\nNo handler registered for effect "${name}"`)
|
|
39
|
+
this.name = 'UnhandledEffectError'
|
|
40
|
+
this.effectName = name
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class EffectHandlerError extends Error {
|
|
45
|
+
constructor(name, cause) {
|
|
46
|
+
super(`Effect handler for "${name}" threw: ${cause?.message ?? cause}`, { cause })
|
|
47
|
+
this.name = 'EffectHandlerError'
|
|
48
|
+
this.effectName = name
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class RecursiveEffectError extends Error {
|
|
53
|
+
constructor(name) {
|
|
54
|
+
super(`Effect handler for "${name}" recursively invoked the same effect ("${name}")`)
|
|
55
|
+
this.name = 'RecursiveEffectError'
|
|
56
|
+
this.effectName = name
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
* Declares a named effect and returns the callable used to *perform* it.
|
|
62
|
+
* `options.sideEffecting` (default true) and `options.replayable` (default
|
|
63
|
+
* false) are pure metadata consumed by inspection (`describeEffect`,
|
|
64
|
+
* `describeHandlers`) and by callers deciding whether it's safe to treat a
|
|
65
|
+
* result as reusable across a checkpoint/replay boundary — the runtime
|
|
66
|
+
* itself never enforces them.
|
|
67
|
+
*/
|
|
68
|
+
export function defineEffect(name, params = [], options = {}) {
|
|
69
|
+
effectRegistry.set(name, {
|
|
70
|
+
name,
|
|
71
|
+
params: [...params],
|
|
72
|
+
sideEffecting: options.sideEffecting ?? true,
|
|
73
|
+
replayable: options.replayable ?? false
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
function effectFn(...args) {
|
|
77
|
+
return performEffect(name, args)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
effectFn.effectName = name
|
|
81
|
+
effectFn.isEffect = true
|
|
82
|
+
return effectFn
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function describeEffect(name) {
|
|
86
|
+
return effectRegistry.get(name) ?? null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function listEffects() {
|
|
90
|
+
return [...effectRegistry.values()]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeHandlerEntry(name, value) {
|
|
94
|
+
if (typeof value === 'function') {
|
|
95
|
+
return { handle: value, deterministic: undefined }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (value && typeof value === 'object' && typeof value.handle === 'function') {
|
|
99
|
+
return { handle: value.handle, deterministic: value.deterministic ?? undefined }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
throw new TypeError(
|
|
103
|
+
`Invalid handler for effect "${name}": expected a function, e.g. \`id => fakeUser(id)\`, ` +
|
|
104
|
+
'or a `{ handle, deterministic }` descriptor'
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function currentFrames() {
|
|
109
|
+
return handlerStorage.getStore() ?? []
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/*
|
|
113
|
+
* Installs `handlers` (an object of `name -> handlerFn | { handle,
|
|
114
|
+
* deterministic }`) for the dynamic extent of `fn`, then restores whatever
|
|
115
|
+
* was active before. Nesting `withHandlers` calls (source: nested `handle`
|
|
116
|
+
* blocks) pushes a new innermost frame in front of the existing ones, so an
|
|
117
|
+
* inner handler for the same effect name shadows — overrides — an outer
|
|
118
|
+
* one, while effects the inner handler map does *not* mention still fall
|
|
119
|
+
* through to the outer handler.
|
|
120
|
+
*
|
|
121
|
+
* Caveat (shared with `execution-context.js`'s checkpoint/metrics context):
|
|
122
|
+
* because pipeline stages like `map`/`filter` are lazy generators, the
|
|
123
|
+
* handler frame is only active while `fn` is *synchronously/async-chain*
|
|
124
|
+
* executing. If `fn` merely constructs a lazy pipeline and returns it
|
|
125
|
+
* without consuming it (no `toArray`/`toArrayAsync`/`reduce`/...), items
|
|
126
|
+
* pulled later outside this call will see whatever handlers (if any) are
|
|
127
|
+
* active at that later point, not these ones. Always consume (or `await`)
|
|
128
|
+
* effectful pipelines inside the `handle { ... } { ... }` block itself.
|
|
129
|
+
*/
|
|
130
|
+
export function withHandlers(handlers, fn) {
|
|
131
|
+
if (!handlers || typeof handlers !== 'object') {
|
|
132
|
+
throw new TypeError('withHandlers requires a handler map object, e.g. { effectName: handlerFn }')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const normalized = {}
|
|
136
|
+
for (const [name, value] of Object.entries(handlers)) {
|
|
137
|
+
normalized[name] = normalizeHandlerEntry(name, value)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const frames = [{ handlers: normalized }, ...currentFrames()]
|
|
141
|
+
return handlerStorage.run(frames, fn)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/*
|
|
145
|
+
* Resolves and invokes the effect named `name` against the innermost
|
|
146
|
+
* handler that declares it. Always returns a Promise (even for a
|
|
147
|
+
* synchronous handler) so calling code never needs to know whether the
|
|
148
|
+
* active handler is sync or async — production (often async: HTTP/DB) and
|
|
149
|
+
* test (often sync: fake data) handlers are interchangeable.
|
|
150
|
+
*/
|
|
151
|
+
export async function performEffect(name, args) {
|
|
152
|
+
const inFlight = invocationStorage.getStore() ?? new Set()
|
|
153
|
+
if (inFlight.has(name)) {
|
|
154
|
+
throw new RecursiveEffectError(name)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const frame = currentFrames().find(f => name in f.handlers)
|
|
158
|
+
if (!frame) {
|
|
159
|
+
throw new UnhandledEffectError(name)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const entry = frame.handlers[name]
|
|
163
|
+
const nextInFlight = new Set(inFlight)
|
|
164
|
+
nextInFlight.add(name)
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
return await invocationStorage.run(nextInFlight, () => entry.handle(...args))
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof RecursiveEffectError || error instanceof UnhandledEffectError) throw error
|
|
170
|
+
throw new EffectHandlerError(name, error)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/*
|
|
175
|
+
* Introspection: which effects have an active handler *right now* (i.e.
|
|
176
|
+
* called from inside a `handle { ... } { ... }` block), and what is known
|
|
177
|
+
* about each — handled, whether the installed handler declared itself
|
|
178
|
+
* deterministic, and the declaring effect's `sideEffecting`/`replayable`
|
|
179
|
+
* metadata. Returns `[]` outside any `withHandlers` scope.
|
|
180
|
+
*/
|
|
181
|
+
export function describeHandlers() {
|
|
182
|
+
const seen = new Map()
|
|
183
|
+
|
|
184
|
+
for (const frame of currentFrames()) {
|
|
185
|
+
for (const [name, entry] of Object.entries(frame.handlers)) {
|
|
186
|
+
if (seen.has(name)) continue
|
|
187
|
+
|
|
188
|
+
const registryEntry = effectRegistry.get(name)
|
|
189
|
+
seen.set(name, {
|
|
190
|
+
name,
|
|
191
|
+
handled: true,
|
|
192
|
+
deterministic: entry.deterministic ?? null,
|
|
193
|
+
sideEffecting: registryEntry?.sideEffecting ?? null,
|
|
194
|
+
replayable: registryEntry?.replayable ?? null
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return [...seen.values()]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/*
|
|
203
|
+
* `parallel(n)` as an effect-backed capability (see collections.js' `map`):
|
|
204
|
+
* if a `PARALLEL` handler is currently installed, it decides how
|
|
205
|
+
* `items |> parallel(n) |> map(fn)` actually runs (real concurrency,
|
|
206
|
+
* deterministic sequential execution, recorded scheduling, ...). With no
|
|
207
|
+
* handler installed, `map` falls back to the original bounded-concurrency
|
|
208
|
+
* implementation — existing programs that never touch `handle {}` are
|
|
209
|
+
* completely unaffected.
|
|
210
|
+
*/
|
|
211
|
+
effectRegistry.set('PARALLEL', {
|
|
212
|
+
name: 'PARALLEL',
|
|
213
|
+
params: ['concurrency'],
|
|
214
|
+
sideEffecting: true,
|
|
215
|
+
replayable: false,
|
|
216
|
+
builtin: true
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
export function getParallelHandler() {
|
|
220
|
+
const frame = currentFrames().find(f => 'PARALLEL' in f.handlers)
|
|
221
|
+
return frame ? frame.handlers.PARALLEL.handle : null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/*
|
|
225
|
+
* Record/replay extension point (see effect-log.js for the storage side).
|
|
226
|
+
* `recordHandlers` wraps a normal (typically production) handler map so
|
|
227
|
+
* every call's arguments and result are appended to `log`; `replayHandlers`
|
|
228
|
+
* builds a handler map that never runs real code at all, just replays
|
|
229
|
+
* recorded results for the given effect names, in call order, per effect.
|
|
230
|
+
*/
|
|
231
|
+
export function recordHandlers(handlers, log) {
|
|
232
|
+
const wrapped = {}
|
|
233
|
+
|
|
234
|
+
for (const [name, value] of Object.entries(handlers)) {
|
|
235
|
+
const entry = normalizeHandlerEntry(name, value)
|
|
236
|
+
wrapped[name] = {
|
|
237
|
+
deterministic: entry.deterministic,
|
|
238
|
+
handle: async (...args) => {
|
|
239
|
+
const result = await entry.handle(...args)
|
|
240
|
+
await log.record({ name, args, result, recordedAt: new Date().toISOString() })
|
|
241
|
+
return result
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return wrapped
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function replayHandlers(effectNames, log) {
|
|
250
|
+
const wrapped = {}
|
|
251
|
+
|
|
252
|
+
for (const name of effectNames) {
|
|
253
|
+
wrapped[name] = {
|
|
254
|
+
deterministic: true,
|
|
255
|
+
handle: async () => {
|
|
256
|
+
const call = await log.replayNext(name)
|
|
257
|
+
return call.result
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return wrapped
|
|
263
|
+
}
|
|
@@ -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,70 @@
|
|
|
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
|
+
export {
|
|
52
|
+
defineEffect,
|
|
53
|
+
withHandlers,
|
|
54
|
+
performEffect,
|
|
55
|
+
describeEffect,
|
|
56
|
+
listEffects,
|
|
57
|
+
describeHandlers,
|
|
58
|
+
getParallelHandler,
|
|
59
|
+
recordHandlers,
|
|
60
|
+
replayHandlers,
|
|
61
|
+
UnhandledEffectError,
|
|
62
|
+
EffectHandlerError,
|
|
63
|
+
RecursiveEffectError
|
|
64
|
+
} from './effects.js'
|
|
65
|
+
export {
|
|
66
|
+
EffectLog,
|
|
67
|
+
MemoryEffectLog,
|
|
68
|
+
FileEffectLog
|
|
69
|
+
} from './effect-log.js'
|
|
70
|
+
|
|
@@ -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
|
+
}
|