@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,122 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Storage interface for checkpoints. Deliberately storage-agnostic: the
|
|
6
|
+
* `checkpoint()` runtime operator only ever talks to this interface, never
|
|
7
|
+
* to a filesystem or database directly, so new backends (S3, a database,
|
|
8
|
+
* ...) can be added later without touching pipeline code.
|
|
9
|
+
*/
|
|
10
|
+
export class CheckpointStore {
|
|
11
|
+
async save(_checkpoint) {
|
|
12
|
+
throw new Error('CheckpointStore.save() is not implemented')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async load(_pipelineId, _checkpointId) {
|
|
16
|
+
throw new Error('CheckpointStore.load() is not implemented')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async exists(_pipelineId, _checkpointId) {
|
|
20
|
+
throw new Error('CheckpointStore.exists() is not implemented')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async delete(_pipelineId, _checkpointId) {
|
|
24
|
+
throw new Error('CheckpointStore.delete() is not implemented')
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function key(pipelineId, checkpointId) {
|
|
29
|
+
return `${pipelineId}::${checkpointId}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* In-memory store. Useful for tests and for short-lived processes where
|
|
34
|
+
* durability across restarts is not required.
|
|
35
|
+
*/
|
|
36
|
+
export class MemoryCheckpointStore extends CheckpointStore {
|
|
37
|
+
constructor() {
|
|
38
|
+
super()
|
|
39
|
+
this.data = new Map()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async save(checkpoint) {
|
|
43
|
+
this.data.set(key(checkpoint.pipelineId, checkpoint.checkpointId), checkpoint)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async load(pipelineId, checkpointId) {
|
|
47
|
+
const found = this.data.get(key(pipelineId, checkpointId))
|
|
48
|
+
if (!found) throw new Error(`Checkpoint not found: ${checkpointId}`)
|
|
49
|
+
return found
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async exists(pipelineId, checkpointId) {
|
|
53
|
+
return this.data.has(key(pipelineId, checkpointId))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async delete(pipelineId, checkpointId) {
|
|
57
|
+
this.data.delete(key(pipelineId, checkpointId))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
list(pipelineId) {
|
|
61
|
+
return [...this.data.values()]
|
|
62
|
+
.filter(checkpoint => checkpoint.pipelineId === pipelineId)
|
|
63
|
+
.map(checkpoint => checkpoint.checkpointId)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/*
|
|
68
|
+
* JSON-on-disk store, one file per (pipelineId, checkpointId). Writes go to
|
|
69
|
+
* a temp file first and are then renamed into place, so a crash mid-write
|
|
70
|
+
* can never leave a corrupted checkpoint file behind that would silently
|
|
71
|
+
* poison a later resume.
|
|
72
|
+
*/
|
|
73
|
+
export class FileCheckpointStore extends CheckpointStore {
|
|
74
|
+
constructor(baseDir = '.jojo-checkpoints') {
|
|
75
|
+
super()
|
|
76
|
+
this.baseDir = baseDir
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
filePath(pipelineId, checkpointId) {
|
|
80
|
+
return path.join(this.baseDir, pipelineId, `${checkpointId}.json`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async save(checkpoint) {
|
|
84
|
+
const file = this.filePath(checkpoint.pipelineId, checkpoint.checkpointId)
|
|
85
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
86
|
+
|
|
87
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
88
|
+
fs.writeFileSync(tmp, JSON.stringify(checkpoint), 'utf8')
|
|
89
|
+
fs.renameSync(tmp, file)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async load(pipelineId, checkpointId) {
|
|
93
|
+
const file = this.filePath(pipelineId, checkpointId)
|
|
94
|
+
if (!fs.existsSync(file)) throw new Error(`Checkpoint not found: ${checkpointId}`)
|
|
95
|
+
|
|
96
|
+
let raw
|
|
97
|
+
try {
|
|
98
|
+
raw = fs.readFileSync(file, 'utf8')
|
|
99
|
+
return JSON.parse(raw)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
throw new Error(`Corrupted checkpoint "${checkpointId}": ${error.message}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async exists(pipelineId, checkpointId) {
|
|
106
|
+
return fs.existsSync(this.filePath(pipelineId, checkpointId))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async delete(pipelineId, checkpointId) {
|
|
110
|
+
const file = this.filePath(pipelineId, checkpointId)
|
|
111
|
+
if (fs.existsSync(file)) fs.unlinkSync(file)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
list(pipelineId) {
|
|
115
|
+
const dir = path.join(this.baseDir, pipelineId)
|
|
116
|
+
if (!fs.existsSync(dir)) return []
|
|
117
|
+
|
|
118
|
+
return fs.readdirSync(dir)
|
|
119
|
+
.filter(name => name.endsWith('.json'))
|
|
120
|
+
.map(name => name.slice(0, -'.json'.length))
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { getCurrentContext, getDefaultContext } from './execution-context.js'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* A pipeline checkpoint. `items` flow through unchanged; the operator's job
|
|
5
|
+
* is purely persistence:
|
|
6
|
+
*
|
|
7
|
+
* - if a completed checkpoint already exists for this (pipelineId, name),
|
|
8
|
+
* the upstream `items` iterable is never touched at all (it may be an
|
|
9
|
+
* expensive, not-yet-started generator) — the saved items are yielded
|
|
10
|
+
* instead. This is the whole resume mechanism: re-running the same
|
|
11
|
+
* program after a crash just naturally skips completed work.
|
|
12
|
+
* - otherwise, items are streamed through as usual (never buffered ahead
|
|
13
|
+
* of the consumer) and also collected so they can be durably saved once
|
|
14
|
+
* the full checkpoint has completed. A checkpoint is only ever written
|
|
15
|
+
* after every item has passed through, so a crash mid-checkpoint never
|
|
16
|
+
* leaves a partial/misleading checkpoint on disk.
|
|
17
|
+
*
|
|
18
|
+
* `meta.checkpointOrder`/`meta.checkpointIndex` are injected by the
|
|
19
|
+
* compiler: the full, statically-known list of checkpoint names in this
|
|
20
|
+
* pipeline, and this call's position within it. They let `replay(--from)`
|
|
21
|
+
* decide whether a *later* checkpoint must be invalidated/recomputed
|
|
22
|
+
* without depending on the runtime order lazy generators happen to be
|
|
23
|
+
* pulled in (nested generators lazily run their upstream checks only once
|
|
24
|
+
* pulled, which can't be relied on for that decision).
|
|
25
|
+
*/
|
|
26
|
+
export async function* checkpoint(items, name, meta = {}) {
|
|
27
|
+
const ctx = getCurrentContext() ?? getDefaultContext()
|
|
28
|
+
const pipelineId = ctx.pipelineId ?? meta.pipelineId ?? 'default'
|
|
29
|
+
const store = ctx.checkpointStore
|
|
30
|
+
|
|
31
|
+
const replayTargetIndex = ctx.replayFrom && meta.checkpointOrder
|
|
32
|
+
? meta.checkpointOrder.indexOf(ctx.replayFrom)
|
|
33
|
+
: -1
|
|
34
|
+
|
|
35
|
+
const isReplayTarget = ctx.replayFrom === name
|
|
36
|
+
const isAfterReplayTarget =
|
|
37
|
+
replayTargetIndex !== -1 &&
|
|
38
|
+
typeof meta.checkpointIndex === 'number' &&
|
|
39
|
+
meta.checkpointIndex > replayTargetIndex
|
|
40
|
+
|
|
41
|
+
if (isReplayTarget) {
|
|
42
|
+
if (!(await store.exists(pipelineId, name))) {
|
|
43
|
+
throw new Error(`Cannot replay from checkpoint "${name}": no such checkpoint exists`)
|
|
44
|
+
}
|
|
45
|
+
const saved = await store.load(pipelineId, name)
|
|
46
|
+
for (const item of saved.items) yield item
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!isAfterReplayTarget && await store.exists(pipelineId, name)) {
|
|
51
|
+
const saved = await store.load(pipelineId, name)
|
|
52
|
+
for (const item of saved.items) yield item
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const buffer = []
|
|
57
|
+
for await (const item of items) {
|
|
58
|
+
buffer.push(item)
|
|
59
|
+
yield item
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await store.save({
|
|
63
|
+
pipelineId,
|
|
64
|
+
checkpointId: name,
|
|
65
|
+
items: buffer,
|
|
66
|
+
itemCount: buffer.length,
|
|
67
|
+
createdAt: new Date().toISOString()
|
|
68
|
+
})
|
|
69
|
+
}
|