rubedo 0.1.0__py3-none-any.whl
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.
- rubedo/__init__.py +44 -0
- rubedo/cli.py +143 -0
- rubedo/db.py +76 -0
- rubedo/execution.py +397 -0
- rubedo/hashing.py +49 -0
- rubedo/invalidation.py +96 -0
- rubedo/ledger.py +670 -0
- rubedo/models.py +406 -0
- rubedo/planning.py +838 -0
- rubedo/progress.py +44 -0
- rubedo/py.typed +0 -0
- rubedo/queries.py +90 -0
- rubedo/runner.py +401 -0
- rubedo/schemas.py +150 -0
- rubedo/selection.py +156 -0
- rubedo/server.py +569 -0
- rubedo/sources.py +322 -0
- rubedo/spec.py +570 -0
- rubedo/store.py +145 -0
- rubedo/util.py +33 -0
- rubedo-0.1.0.dist-info/METADATA +254 -0
- rubedo-0.1.0.dist-info/RECORD +26 -0
- rubedo-0.1.0.dist-info/WHEEL +5 -0
- rubedo-0.1.0.dist-info/entry_points.txt +2 -0
- rubedo-0.1.0.dist-info/licenses/LICENSE +21 -0
- rubedo-0.1.0.dist-info/top_level.txt +1 -0
rubedo/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rubedo: A local-first batch processing engine.
|
|
3
|
+
|
|
4
|
+
This package provides a framework for defining DAG pipelines over collections of
|
|
5
|
+
coordinates with content-addressed caching, durable run history, and surgical invalidation.
|
|
6
|
+
"""
|
|
7
|
+
from .spec import (
|
|
8
|
+
step,
|
|
9
|
+
source,
|
|
10
|
+
pipeline,
|
|
11
|
+
describe,
|
|
12
|
+
PipelineSpec,
|
|
13
|
+
StepSpec,
|
|
14
|
+
PipelineBuilder,
|
|
15
|
+
)
|
|
16
|
+
from .models import Filtered, ProcessResult, RunSummary
|
|
17
|
+
from .selection import Selection
|
|
18
|
+
from .sources import Source, SourceItem, FolderSource, CsvSource
|
|
19
|
+
from .invalidation import invalidate
|
|
20
|
+
from .runner import plan, run, RunPlan
|
|
21
|
+
from .progress import TerminalProgress
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"invalidate",
|
|
25
|
+
"Filtered",
|
|
26
|
+
"ProcessResult",
|
|
27
|
+
"RunSummary",
|
|
28
|
+
"Selection",
|
|
29
|
+
"Source",
|
|
30
|
+
"SourceItem",
|
|
31
|
+
"FolderSource",
|
|
32
|
+
"CsvSource",
|
|
33
|
+
"step",
|
|
34
|
+
"source",
|
|
35
|
+
"pipeline",
|
|
36
|
+
"describe",
|
|
37
|
+
"PipelineSpec",
|
|
38
|
+
"StepSpec",
|
|
39
|
+
"PipelineBuilder",
|
|
40
|
+
"run",
|
|
41
|
+
"plan",
|
|
42
|
+
"RunPlan",
|
|
43
|
+
"TerminalProgress",
|
|
44
|
+
]
|
rubedo/cli.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from .db import get_session
|
|
8
|
+
from .queries import get_recent_runs, get_run_summary, get_run_failures
|
|
9
|
+
from .invalidation import invalidate
|
|
10
|
+
from .selection import Selection
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
def cmd_ls(args):
|
|
15
|
+
with get_session() as session:
|
|
16
|
+
runs = get_recent_runs(session, limit=args.limit)
|
|
17
|
+
|
|
18
|
+
if not runs:
|
|
19
|
+
console.print("No runs found.")
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
table = Table(show_header=True, header_style="bold magenta")
|
|
23
|
+
table.add_column("ID", style="dim")
|
|
24
|
+
table.add_column("Pipeline")
|
|
25
|
+
table.add_column("Status")
|
|
26
|
+
table.add_column("Created / Reused")
|
|
27
|
+
table.add_column("Failed")
|
|
28
|
+
table.add_column("Started At")
|
|
29
|
+
|
|
30
|
+
for r in runs:
|
|
31
|
+
pipeline = r.pipeline_id or "(none)"
|
|
32
|
+
stats = f"{r.created_count} / {r.reused_count}"
|
|
33
|
+
table.add_row(
|
|
34
|
+
r.id,
|
|
35
|
+
pipeline,
|
|
36
|
+
r.status,
|
|
37
|
+
stats,
|
|
38
|
+
str(r.failed_count),
|
|
39
|
+
r.started_at
|
|
40
|
+
)
|
|
41
|
+
console.print(table)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cmd_show(args):
|
|
45
|
+
with get_session() as session:
|
|
46
|
+
run = get_run_summary(session, args.run_id)
|
|
47
|
+
if not run:
|
|
48
|
+
console.print(f"[red]Run {args.run_id} not found.[/red]")
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
if args.failed:
|
|
52
|
+
failures = get_run_failures(session, args.run_id)
|
|
53
|
+
if args.json:
|
|
54
|
+
print(json.dumps(failures, indent=2))
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
if not failures:
|
|
58
|
+
console.print("No failures recorded for this run.")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
table = Table(title=f"Failures for Run {args.run_id}", show_header=True)
|
|
62
|
+
table.add_column("Step")
|
|
63
|
+
table.add_column("Coordinate")
|
|
64
|
+
table.add_column("Error Type")
|
|
65
|
+
table.add_column("Message")
|
|
66
|
+
|
|
67
|
+
for f in failures:
|
|
68
|
+
table.add_row(
|
|
69
|
+
str(f["step_name"]),
|
|
70
|
+
str(f["coordinate"]),
|
|
71
|
+
str(f["error_type"]),
|
|
72
|
+
str(f["error_message"])
|
|
73
|
+
)
|
|
74
|
+
console.print(table)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
if args.json:
|
|
78
|
+
print(run.model_dump_json(indent=2))
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
console.print(f"[bold]Run ID:[/bold] {run.id}")
|
|
82
|
+
console.print(f"[bold]Pipeline:[/bold] {run.pipeline_id or '(none)'}")
|
|
83
|
+
console.print(f"[bold]Status:[/bold] {run.status}")
|
|
84
|
+
console.print(f"[bold]Started At:[/bold] {run.started_at}")
|
|
85
|
+
console.print(f"[bold]Finished At:[/bold] {run.finished_at or 'N/A'}")
|
|
86
|
+
console.print(f"[bold]Summary:[/bold] Created: {run.created_count}, Reused: {run.reused_count}, Failed: {run.failed_count}, Blocked: {run.blocked_count}, Filtered: {run.filtered_count}")
|
|
87
|
+
|
|
88
|
+
if run.by_step:
|
|
89
|
+
table = Table(title="Step Outcomes", show_header=True)
|
|
90
|
+
table.add_column("Step")
|
|
91
|
+
table.add_column("Created")
|
|
92
|
+
table.add_column("Reused")
|
|
93
|
+
table.add_column("Failed")
|
|
94
|
+
table.add_column("Blocked")
|
|
95
|
+
table.add_column("Filtered")
|
|
96
|
+
for step_name, counts in run.by_step.items():
|
|
97
|
+
table.add_row(
|
|
98
|
+
step_name,
|
|
99
|
+
str(counts.get("created", 0)),
|
|
100
|
+
str(counts.get("reused", 0)),
|
|
101
|
+
str(counts.get("failed", 0)),
|
|
102
|
+
str(counts.get("blocked", 0)),
|
|
103
|
+
str(counts.get("filtered", 0))
|
|
104
|
+
)
|
|
105
|
+
console.print(table)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_invalidate(args):
|
|
109
|
+
try:
|
|
110
|
+
selection = Selection.parse(args.selection)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
console.print(f"[red]Error parsing selection:[/red] {e}")
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
result = invalidate(selection, args.reason)
|
|
116
|
+
console.print(f"Invalidated [bold green]{result['invalidated_count']}[/bold green] materializations.")
|
|
117
|
+
console.print(f"New Run ID recorded for invalidation: [cyan]{result['run_id']}[/cyan]")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main():
|
|
121
|
+
parser = argparse.ArgumentParser(description="Rubedo Read-Only Ops CLI")
|
|
122
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
123
|
+
|
|
124
|
+
parser_ls = subparsers.add_parser("ls", help="List recent runs")
|
|
125
|
+
parser_ls.add_argument("--limit", type=int, default=50, help="Number of runs to show")
|
|
126
|
+
parser_ls.set_defaults(func=cmd_ls)
|
|
127
|
+
|
|
128
|
+
parser_show = subparsers.add_parser("show", help="Show details for a specific run")
|
|
129
|
+
parser_show.add_argument("run_id", help="The Run ID to show")
|
|
130
|
+
parser_show.add_argument("--json", action="store_true", help="Output as JSON")
|
|
131
|
+
parser_show.add_argument("--failed", action="store_true", help="Show failure details")
|
|
132
|
+
parser_show.set_defaults(func=cmd_show)
|
|
133
|
+
|
|
134
|
+
parser_inv = subparsers.add_parser("invalidate", help="Invalidate materializations by selection query")
|
|
135
|
+
parser_inv.add_argument("selection", help="Selection query (e.g., 'pipeline:my-pipe step:extract')")
|
|
136
|
+
parser_inv.add_argument("--reason", required=True, help="Reason for invalidation")
|
|
137
|
+
parser_inv.set_defaults(func=cmd_invalidate)
|
|
138
|
+
|
|
139
|
+
args = parser.parse_args()
|
|
140
|
+
args.func(args)
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
main()
|
rubedo/db.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database initialization and session management.
|
|
3
|
+
|
|
4
|
+
Provides utilities for setting up the SQLite database, ensuring the directory
|
|
5
|
+
is gitignored, configuring WAL mode for concurrency, and providing sessions.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
from .util import _ensure_gitignore
|
|
9
|
+
from sqlalchemy import create_engine
|
|
10
|
+
from sqlalchemy.orm import sessionmaker, Session
|
|
11
|
+
from typing import Optional, Any
|
|
12
|
+
from .models import Base
|
|
13
|
+
|
|
14
|
+
engine: Any = None
|
|
15
|
+
SessionLocal: Any = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def init_db(db_path: Optional[str] = None):
|
|
19
|
+
"""
|
|
20
|
+
Initialize the database engine and create tables.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
db_path (str, optional): The database URL or file path. If None, uses
|
|
24
|
+
RUBEDO_DB_PATH, or RUBEDO_HOME/rubedo.sqlite, or the default
|
|
25
|
+
'.rubedo/rubedo.sqlite' — in that precedence order.
|
|
26
|
+
"""
|
|
27
|
+
global engine, SessionLocal
|
|
28
|
+
if engine is not None:
|
|
29
|
+
try:
|
|
30
|
+
engine.dispose()
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
if db_path is None:
|
|
35
|
+
db_path = os.environ.get("RUBEDO_DB_PATH") or os.path.join(
|
|
36
|
+
os.environ.get("RUBEDO_HOME", ".rubedo"), "rubedo.sqlite"
|
|
37
|
+
)
|
|
38
|
+
dir_path = (
|
|
39
|
+
db_path.replace("sqlite:///", "")
|
|
40
|
+
if db_path.startswith("sqlite:///")
|
|
41
|
+
else db_path
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
db_dir = os.path.dirname(dir_path)
|
|
45
|
+
if db_dir:
|
|
46
|
+
os.makedirs(db_dir, exist_ok=True)
|
|
47
|
+
_ensure_gitignore(db_dir)
|
|
48
|
+
if db_path.startswith("sqlite:///"):
|
|
49
|
+
engine_url = db_path
|
|
50
|
+
else:
|
|
51
|
+
engine_url = f"sqlite:///{db_path}"
|
|
52
|
+
engine = create_engine(engine_url)
|
|
53
|
+
|
|
54
|
+
if engine_url.startswith("sqlite"):
|
|
55
|
+
from sqlalchemy import event
|
|
56
|
+
@event.listens_for(engine, "connect")
|
|
57
|
+
def set_sqlite_pragma(dbapi_connection, connection_record):
|
|
58
|
+
cursor = dbapi_connection.cursor()
|
|
59
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
60
|
+
cursor.execute("PRAGMA busy_timeout=5000")
|
|
61
|
+
cursor.close()
|
|
62
|
+
|
|
63
|
+
Base.metadata.create_all(bind=engine)
|
|
64
|
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_session() -> Session:
|
|
68
|
+
"""
|
|
69
|
+
Get a new SQLAlchemy session, initializing the DB if necessary.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Session: A new database session.
|
|
73
|
+
"""
|
|
74
|
+
if SessionLocal is None:
|
|
75
|
+
init_db()
|
|
76
|
+
return SessionLocal()
|
rubedo/execution.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Execute phase: running step functions.
|
|
2
|
+
|
|
3
|
+
No database access — inputs come in as refs, results go out as
|
|
4
|
+
ExecutionOutcome values for the ledger to persist.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import concurrent.futures
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import traceback
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
import loky
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
from .hashing import hash_json, hash_bytes
|
|
18
|
+
from .models import Filtered, ProcessResult
|
|
19
|
+
from .planning import (
|
|
20
|
+
EphemeralRef,
|
|
21
|
+
MatRef,
|
|
22
|
+
StepDecision,
|
|
23
|
+
_build_step_params,
|
|
24
|
+
_step_accepts_params,
|
|
25
|
+
expand_anchor_address,
|
|
26
|
+
expand_child_coord,
|
|
27
|
+
expand_child_identity,
|
|
28
|
+
)
|
|
29
|
+
from .spec import StepSpec
|
|
30
|
+
from .sources import Source
|
|
31
|
+
from .store import read_materialization_output
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _RateLimiter:
|
|
35
|
+
"""Paces calls evenly across a step's worker threads."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, count: int, period_seconds: float):
|
|
38
|
+
"""
|
|
39
|
+
Initialize the rate limiter.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
count (int): Number of allowed calls per period.
|
|
43
|
+
period_seconds (float): Time period in seconds.
|
|
44
|
+
"""
|
|
45
|
+
self.min_interval = period_seconds / count
|
|
46
|
+
self._lock = threading.Lock()
|
|
47
|
+
self._next_free = 0.0
|
|
48
|
+
|
|
49
|
+
def acquire(self):
|
|
50
|
+
"""Wait until it is safe to proceed according to the rate limit."""
|
|
51
|
+
with self._lock:
|
|
52
|
+
now = time.monotonic()
|
|
53
|
+
wait = self._next_free - now
|
|
54
|
+
self._next_free = max(now, self._next_free) + self.min_interval
|
|
55
|
+
if wait > 0:
|
|
56
|
+
time.sleep(wait)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _RunMemo:
|
|
60
|
+
"""Per-run memo so an ephemeral step runs at most once per coordinate.
|
|
61
|
+
|
|
62
|
+
Reentrant lock: chained skip_cache steps resolve recursively on the
|
|
63
|
+
same worker thread. Exceptions are memoized too, so every consumer of
|
|
64
|
+
a failed util sees the same failure.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self):
|
|
68
|
+
"""Initialize the run memoizer with a per-key locking scheme."""
|
|
69
|
+
self._lock = threading.Lock()
|
|
70
|
+
self._values: Dict[Tuple[str, str], Tuple[str, Any]] = {}
|
|
71
|
+
|
|
72
|
+
def compute(self, key: Tuple[str, str], producer: Callable[[], Any]) -> Any:
|
|
73
|
+
"""
|
|
74
|
+
Compute or retrieve a memoized value for the given key.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
key: (step_name, coordinate) — see _compute_ephemeral's call site.
|
|
78
|
+
producer: A zero-argument function that produces the value.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Any: The produced or cached value.
|
|
82
|
+
"""
|
|
83
|
+
with self._lock:
|
|
84
|
+
state = self._values.get(key)
|
|
85
|
+
if state is None:
|
|
86
|
+
event = threading.Event()
|
|
87
|
+
self._values[key] = ("computing", event)
|
|
88
|
+
elif state[0] == "done":
|
|
89
|
+
kind, value = state[1]
|
|
90
|
+
if kind == "err":
|
|
91
|
+
raise value
|
|
92
|
+
return value
|
|
93
|
+
else:
|
|
94
|
+
event = state[1]
|
|
95
|
+
|
|
96
|
+
if state is not None:
|
|
97
|
+
event.wait()
|
|
98
|
+
kind, value = self._values[key][1]
|
|
99
|
+
if kind == "err":
|
|
100
|
+
raise value
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
val = producer()
|
|
105
|
+
res = ("ok", val)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
res = ("err", e)
|
|
108
|
+
|
|
109
|
+
with self._lock:
|
|
110
|
+
self._values[key] = ("done", res)
|
|
111
|
+
event.set()
|
|
112
|
+
|
|
113
|
+
kind, value = res
|
|
114
|
+
if kind == "err":
|
|
115
|
+
raise value
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class ExecutionOutcome:
|
|
121
|
+
"""Represents the final result of attempting to execute a step for a coordinate."""
|
|
122
|
+
decision: StepDecision
|
|
123
|
+
success: bool
|
|
124
|
+
result: Any = None
|
|
125
|
+
error_trace: Optional[str] = None
|
|
126
|
+
attempts: int = 1
|
|
127
|
+
attempt_errors: List[str] = field(default_factory=list)
|
|
128
|
+
# An expand step's cache anchor (the full yielded list, addressed by the
|
|
129
|
+
# parent): stored so a re-run can skip the fn, but it is not a lane — no
|
|
130
|
+
# status, count, edge, or coord_step_mats entry.
|
|
131
|
+
is_anchor: bool = False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _resolve_parent_value(
|
|
135
|
+
ref, step_sources: Dict[str, Source], params: Optional[dict], memo: _RunMemo
|
|
136
|
+
):
|
|
137
|
+
"""
|
|
138
|
+
Resolve the output value of a parent step, computing it lazily if ephemeral.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
ref: The reference to the parent output (MatRef or EphemeralRef).
|
|
142
|
+
step_sources: step name -> the Source it reads (root/skip_cache roots).
|
|
143
|
+
params (Optional[dict]): Run parameters.
|
|
144
|
+
memo (_RunMemo): The run memoizer for ephemeral steps.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Any: The resolved output value.
|
|
148
|
+
"""
|
|
149
|
+
if isinstance(ref, EphemeralRef):
|
|
150
|
+
return _compute_ephemeral(ref, step_sources, params, memo)
|
|
151
|
+
return read_materialization_output(ref)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _compute_ephemeral(
|
|
155
|
+
ref: EphemeralRef, step_sources: Dict[str, Source], params: Optional[dict],
|
|
156
|
+
memo: _RunMemo,
|
|
157
|
+
):
|
|
158
|
+
"""Lazily compute a skip_cache step's value, at most once per run."""
|
|
159
|
+
|
|
160
|
+
def produce():
|
|
161
|
+
step = ref.step
|
|
162
|
+
if not step.depends_on:
|
|
163
|
+
args = [step_sources[step.name].load(ref.item)]
|
|
164
|
+
kwargs = {}
|
|
165
|
+
else:
|
|
166
|
+
args = []
|
|
167
|
+
kwargs = {
|
|
168
|
+
dep: _resolve_parent_value(
|
|
169
|
+
ref.parent_refs[dep], step_sources, params, memo
|
|
170
|
+
)
|
|
171
|
+
for dep in step.depends_on
|
|
172
|
+
}
|
|
173
|
+
if _step_accepts_params(step):
|
|
174
|
+
kwargs["params"] = _build_step_params(step, params)
|
|
175
|
+
result = step.fn(*args, **kwargs)
|
|
176
|
+
if isinstance(result, Filtered):
|
|
177
|
+
raise RuntimeError(
|
|
178
|
+
f"skip_cache step '{step.name}' returned Filtered: filtering "
|
|
179
|
+
"is a cacheable decision, so filter steps must be materialized"
|
|
180
|
+
)
|
|
181
|
+
# Consumers of materialized steps receive the unwrapped value;
|
|
182
|
+
# keep the contract identical (minus the serialization round-trip)
|
|
183
|
+
if isinstance(result, ProcessResult):
|
|
184
|
+
result = result.value
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
return memo.compute((ref.step.name, ref.item.coordinate), produce)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _materialized_ancestors(parent_refs: Dict[str, Any]) -> Dict[int, MatRef]:
|
|
191
|
+
"""Nearest materialized ancestors, skipping through ephemeral hops."""
|
|
192
|
+
out: Dict[int, MatRef] = {}
|
|
193
|
+
for ref in parent_refs.values():
|
|
194
|
+
if isinstance(ref, EphemeralRef):
|
|
195
|
+
out.update(_materialized_ancestors(ref.parent_refs))
|
|
196
|
+
else:
|
|
197
|
+
out[ref.id] = ref
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _validate_output(step: StepSpec, value: Any) -> None:
|
|
202
|
+
"""Run data quality assertions on a step's output."""
|
|
203
|
+
if step.output_model is not None:
|
|
204
|
+
step.output_model.model_validate(value)
|
|
205
|
+
if step.assertions:
|
|
206
|
+
for assertion in step.assertions:
|
|
207
|
+
assertion(value)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _execute_step(
|
|
211
|
+
step: StepSpec,
|
|
212
|
+
decisions: List[StepDecision],
|
|
213
|
+
step_sources: Dict[str, Source],
|
|
214
|
+
params: Optional[dict],
|
|
215
|
+
accepts_params: bool,
|
|
216
|
+
workers: Optional[int],
|
|
217
|
+
memo: _RunMemo,
|
|
218
|
+
) -> Iterator[ExecutionOutcome]:
|
|
219
|
+
"""Run the step function for each execute decision; yield as completed.
|
|
220
|
+
|
|
221
|
+
Honors the step's rate limit (shared across workers, retries included)
|
|
222
|
+
and retry policy (only exceptions matching retry_on are retried).
|
|
223
|
+
`step_sources` maps a step name to the Source it reads (root/skip_cache
|
|
224
|
+
roots), so each root loads from its own source in a multi-source pipeline.
|
|
225
|
+
"""
|
|
226
|
+
limiter = _RateLimiter(*step.rate_limit) if step.rate_limit else None
|
|
227
|
+
params_hash = hash_json(params or {})
|
|
228
|
+
|
|
229
|
+
def call(decision: StepDecision, pool: Optional[Any] = None):
|
|
230
|
+
# Root steps get the source payload positionally; dependent steps
|
|
231
|
+
# get parent outputs by parameter name. Either kind may declare
|
|
232
|
+
# `params`. A *root expand* is itself a source — it reads no payload.
|
|
233
|
+
if not step.depends_on:
|
|
234
|
+
if step.shape == "expand":
|
|
235
|
+
args = []
|
|
236
|
+
else:
|
|
237
|
+
assert decision.item is not None
|
|
238
|
+
args = [step_sources[step.name].load(decision.item)]
|
|
239
|
+
kwargs = {}
|
|
240
|
+
elif step.shape == "reduce":
|
|
241
|
+
args = []
|
|
242
|
+
kwargs = {
|
|
243
|
+
dep: {
|
|
244
|
+
lane: _resolve_parent_value(
|
|
245
|
+
ref, step_sources, params, memo
|
|
246
|
+
)
|
|
247
|
+
for lane, ref in decision.parent_mats[dep].items()
|
|
248
|
+
}
|
|
249
|
+
for dep in step.depends_on
|
|
250
|
+
}
|
|
251
|
+
else:
|
|
252
|
+
args = []
|
|
253
|
+
kwargs = {
|
|
254
|
+
dep: _resolve_parent_value(
|
|
255
|
+
decision.parent_mats[dep], step_sources, params, memo
|
|
256
|
+
)
|
|
257
|
+
for dep in step.depends_on
|
|
258
|
+
}
|
|
259
|
+
if accepts_params:
|
|
260
|
+
kwargs["params"] = _build_step_params(step, params)
|
|
261
|
+
|
|
262
|
+
if pool is not None:
|
|
263
|
+
return pool.submit(step.fn, *args, **kwargs).result()
|
|
264
|
+
return step.fn(*args, **kwargs)
|
|
265
|
+
|
|
266
|
+
def _expand_outcomes(
|
|
267
|
+
decision: StepDecision, values: List[Any], attempt: int, attempt_errors: List[str]
|
|
268
|
+
) -> List[ExecutionOutcome]:
|
|
269
|
+
"""Fan one parent lane's yielded payloads into content-addressed lanes.
|
|
270
|
+
|
|
271
|
+
A dependent expand emits the cache anchor first (the child content
|
|
272
|
+
hashes, addressed by the parent so a re-run can skip the fn). A *root*
|
|
273
|
+
expand (a source) has no parent to cache against, so it writes no
|
|
274
|
+
anchor and always re-runs. Then one child per distinct payload — each a
|
|
275
|
+
content-addressed lane `row-<hash>`; identical payloads collapse.
|
|
276
|
+
"""
|
|
277
|
+
seen: set = set()
|
|
278
|
+
children: List[tuple] = [] # (child_hash, value)
|
|
279
|
+
for value in values:
|
|
280
|
+
_validate_output(step, value)
|
|
281
|
+
if isinstance(value, bytes):
|
|
282
|
+
child_hash = "b:" + hash_bytes(value)
|
|
283
|
+
else:
|
|
284
|
+
child_hash = hash_json(value)
|
|
285
|
+
if child_hash in seen:
|
|
286
|
+
continue # identical payload — one lane
|
|
287
|
+
seen.add(child_hash)
|
|
288
|
+
children.append((child_hash, value))
|
|
289
|
+
|
|
290
|
+
outcomes: List[ExecutionOutcome] = []
|
|
291
|
+
if step.depends_on:
|
|
292
|
+
# Anchor: the child hashes, addressed by the parent. Not a lane.
|
|
293
|
+
parent_hash = decision.parent_mats[step.depends_on[0]].output_content_hash
|
|
294
|
+
anchor = StepDecision(
|
|
295
|
+
coordinate=decision.coordinate,
|
|
296
|
+
action="execute",
|
|
297
|
+
input_hash=parent_hash,
|
|
298
|
+
output_address=expand_anchor_address(
|
|
299
|
+
step, parent_hash, params_hash, accepts_params
|
|
300
|
+
),
|
|
301
|
+
parent_mats=decision.parent_mats,
|
|
302
|
+
)
|
|
303
|
+
outcomes.append(
|
|
304
|
+
ExecutionOutcome(
|
|
305
|
+
anchor, True, result=[h for h, _ in children],
|
|
306
|
+
attempts=attempt, attempt_errors=attempt_errors, is_anchor=True,
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
for child_hash, value in children:
|
|
311
|
+
input_hash, child_address = expand_child_identity(
|
|
312
|
+
step, child_hash, params_hash, accepts_params
|
|
313
|
+
)
|
|
314
|
+
child = StepDecision(
|
|
315
|
+
coordinate=expand_child_coord(child_hash),
|
|
316
|
+
action="execute",
|
|
317
|
+
input_hash=input_hash,
|
|
318
|
+
output_address=child_address,
|
|
319
|
+
parent_mats=decision.parent_mats,
|
|
320
|
+
)
|
|
321
|
+
outcomes.append(
|
|
322
|
+
ExecutionOutcome(
|
|
323
|
+
child, True, result=value, attempts=attempt,
|
|
324
|
+
attempt_errors=attempt_errors,
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
return outcomes
|
|
328
|
+
|
|
329
|
+
def process( # type: ignore
|
|
330
|
+
decision: StepDecision, pool: Optional[Any] = None
|
|
331
|
+
) -> List[ExecutionOutcome]:
|
|
332
|
+
attempt_errors: List[str] = []
|
|
333
|
+
delay = step.retry_delay
|
|
334
|
+
for attempt in range(1, step.retries + 2):
|
|
335
|
+
if limiter:
|
|
336
|
+
limiter.acquire()
|
|
337
|
+
try:
|
|
338
|
+
result = call(decision, pool)
|
|
339
|
+
if step.shape == "expand":
|
|
340
|
+
# Consume the iterable inside the try so a failing
|
|
341
|
+
# generator body is caught and retried like any other.
|
|
342
|
+
return _expand_outcomes(
|
|
343
|
+
decision, list(result), attempt, attempt_errors
|
|
344
|
+
)
|
|
345
|
+
_validate_output(step, result)
|
|
346
|
+
return [
|
|
347
|
+
ExecutionOutcome(
|
|
348
|
+
decision,
|
|
349
|
+
True,
|
|
350
|
+
result=result,
|
|
351
|
+
attempts=attempt,
|
|
352
|
+
attempt_errors=attempt_errors,
|
|
353
|
+
)
|
|
354
|
+
]
|
|
355
|
+
except Exception as e:
|
|
356
|
+
trace = traceback.format_exc()
|
|
357
|
+
retryable = attempt <= step.retries and isinstance(e, step.retry_on)
|
|
358
|
+
if not retryable:
|
|
359
|
+
return [
|
|
360
|
+
ExecutionOutcome(
|
|
361
|
+
decision,
|
|
362
|
+
False,
|
|
363
|
+
error_trace=trace,
|
|
364
|
+
attempts=attempt,
|
|
365
|
+
attempt_errors=attempt_errors,
|
|
366
|
+
)
|
|
367
|
+
]
|
|
368
|
+
attempt_errors.append(trace)
|
|
369
|
+
if delay > 0:
|
|
370
|
+
time.sleep(delay)
|
|
371
|
+
delay *= step.retry_backoff
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# Never spin up more workers (or subprocesses) than there is work.
|
|
376
|
+
pool_size = min(workers or step.workers, len(decisions))
|
|
377
|
+
if pool_size < 1:
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
# Two layers, on purpose. The thread pool is the orchestrator: it drives
|
|
381
|
+
# the retry loop and the parent-shared rate limiter for every lane. A
|
|
382
|
+
# process pool, when requested, is only where the CPU-bound step body
|
|
383
|
+
# runs — retries and rate limiting must stay in the parent, so process
|
|
384
|
+
# steps still need the thread layer to feed them.
|
|
385
|
+
process_pool = (
|
|
386
|
+
loky.ProcessPoolExecutor(max_workers=pool_size)
|
|
387
|
+
if step.executor == "process"
|
|
388
|
+
else None
|
|
389
|
+
)
|
|
390
|
+
try:
|
|
391
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=pool_size) as executor:
|
|
392
|
+
futures = [executor.submit(process, d, process_pool) for d in decisions]
|
|
393
|
+
for future in concurrent.futures.as_completed(futures):
|
|
394
|
+
yield from future.result()
|
|
395
|
+
finally:
|
|
396
|
+
if process_pool is not None:
|
|
397
|
+
process_pool.shutdown()
|