pipeloom 0.0.1__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.
Potentially problematic release.
This version of pipeloom might be problematic. Click here for more details.
- pipeloom/__init__.py +75 -0
- pipeloom/cli.py +73 -0
- pipeloom/db.py +94 -0
- pipeloom/engine.py +175 -0
- pipeloom/messages.py +82 -0
- pipeloom/progress.py +83 -0
- pipeloom/rlog.py +64 -0
- pipeloom/writer.py +194 -0
- pipeloom-0.0.1.dist-info/METADATA +379 -0
- pipeloom-0.0.1.dist-info/RECORD +13 -0
- pipeloom-0.0.1.dist-info/WHEEL +5 -0
- pipeloom-0.0.1.dist-info/entry_points.txt +2 -0
- pipeloom-0.0.1.dist-info/top_level.txt +1 -0
pipeloom/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pipeloom
|
|
3
|
+
=======
|
|
4
|
+
|
|
5
|
+
Reusable scaffolding for ETL-style, multi-threaded pipelines with:
|
|
6
|
+
|
|
7
|
+
- One SQLite writer thread in WAL mode
|
|
8
|
+
- Workers publishing progress/results over a Queue
|
|
9
|
+
- Rich progress (sticky overall + transient per-task)
|
|
10
|
+
- Typer CLI
|
|
11
|
+
|
|
12
|
+
Top-level exports are provided for convenience so you can do:
|
|
13
|
+
|
|
14
|
+
from pipeloom import (
|
|
15
|
+
run_pipeline,
|
|
16
|
+
SQLiteWriter,
|
|
17
|
+
TaskDef,
|
|
18
|
+
MsgTaskStarted, # or alias: MsgTaskStart
|
|
19
|
+
MsgTaskProgress,
|
|
20
|
+
MsgTaskFinished,
|
|
21
|
+
SENTINEL,
|
|
22
|
+
make_overall_progress,
|
|
23
|
+
make_task_progress,
|
|
24
|
+
preregister_task_bars,
|
|
25
|
+
console,
|
|
26
|
+
logger
|
|
27
|
+
)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from .engine import run_pipeline
|
|
31
|
+
from .messages import (
|
|
32
|
+
SENTINEL,
|
|
33
|
+
MsgTaskFinished,
|
|
34
|
+
MsgTaskProgress,
|
|
35
|
+
MsgTaskStarted,
|
|
36
|
+
TaskDef,
|
|
37
|
+
)
|
|
38
|
+
from .progress import (
|
|
39
|
+
make_overall_progress,
|
|
40
|
+
make_task_progress,
|
|
41
|
+
preregister_task_bars,
|
|
42
|
+
)
|
|
43
|
+
from .rlog import (
|
|
44
|
+
console,
|
|
45
|
+
logger,
|
|
46
|
+
setup_logging,
|
|
47
|
+
) # shared Console and logger, useful for advanced usage
|
|
48
|
+
from .writer import SQLiteWriter
|
|
49
|
+
|
|
50
|
+
# Ergonomic alias to match common naming used by callers
|
|
51
|
+
MsgTaskStart = MsgTaskStarted
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
# Core orchestration
|
|
55
|
+
"run_pipeline",
|
|
56
|
+
# Writer
|
|
57
|
+
"SQLiteWriter",
|
|
58
|
+
# Messages
|
|
59
|
+
"TaskDef",
|
|
60
|
+
"MsgTaskStarted",
|
|
61
|
+
"MsgTaskStart", # alias
|
|
62
|
+
"MsgTaskProgress",
|
|
63
|
+
"MsgTaskFinished",
|
|
64
|
+
"SENTINEL",
|
|
65
|
+
# Progress helpers
|
|
66
|
+
"make_overall_progress",
|
|
67
|
+
"make_task_progress",
|
|
68
|
+
"preregister_task_bars",
|
|
69
|
+
# Advanced: shared Console
|
|
70
|
+
"console",
|
|
71
|
+
"logger",
|
|
72
|
+
"setup_logging",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
__version__ = "0.0.1"
|
pipeloom/cli.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli.py
|
|
3
|
+
======
|
|
4
|
+
|
|
5
|
+
Typer-powered CLI that exposes a simple `pipeloom demo` command and serves as
|
|
6
|
+
a blueprint for wiring your own task definitions and worker functions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from .engine import run_pipeline
|
|
18
|
+
from .messages import TaskDef
|
|
19
|
+
from .rlog import setup_logging
|
|
20
|
+
|
|
21
|
+
LOG = logging.getLogger("pipeloom")
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
help="Threaded ETL scaffolding with a single-writer SQLite (WAL).",
|
|
25
|
+
add_completion=False,
|
|
26
|
+
rich_markup_mode="rich",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command(name="demo")
|
|
31
|
+
def demo(
|
|
32
|
+
db: Path = typer.Option(Path("./wal_demo.db"), help="SQLite DB path."),
|
|
33
|
+
wal: bool = typer.Option(True, help="Use SQLite WAL mode."),
|
|
34
|
+
num_tasks: int = typer.Option(20, min=1, help="How many demo tasks to run."),
|
|
35
|
+
workers: int = typer.Option(
|
|
36
|
+
os.cpu_count() or 4, min=1, help="Max concurrent worker threads."
|
|
37
|
+
),
|
|
38
|
+
verbose: int = typer.Option(
|
|
39
|
+
1, "--verbose", "-v", count=True, help="Increase log verbosity (-v, -vv)."
|
|
40
|
+
),
|
|
41
|
+
log_file: Path | None = typer.Option(None, help="Optional log file path."),
|
|
42
|
+
store_task_status: bool = typer.Option(
|
|
43
|
+
True,
|
|
44
|
+
help="Persist task status to SQLite (disable to only use DB for domain data).",
|
|
45
|
+
),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Run a demonstration pipeline that exercises the entire stack:
|
|
49
|
+
|
|
50
|
+
- Worker threads emit progress and results to a Queue
|
|
51
|
+
- A single writer thread updates SQLite in WAL mode
|
|
52
|
+
- Rich renders per-task and overall progress cleanly
|
|
53
|
+
"""
|
|
54
|
+
setup_logging(verbose, log_file)
|
|
55
|
+
LOG.info(
|
|
56
|
+
"Starting demo (tasks=%s, workers=%s, WAL=%s) → DB: %s",
|
|
57
|
+
num_tasks,
|
|
58
|
+
workers,
|
|
59
|
+
wal,
|
|
60
|
+
db,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
tasks = [
|
|
64
|
+
TaskDef(task_id=i, name=f"task-{i}", steps=20) for i in range(1, num_tasks + 1)
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
run_pipeline(
|
|
68
|
+
db_path=db,
|
|
69
|
+
tasks=tasks,
|
|
70
|
+
workers=workers,
|
|
71
|
+
wal=wal,
|
|
72
|
+
store_task_status=store_task_status,
|
|
73
|
+
)
|
pipeloom/db.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
db.py
|
|
3
|
+
=====
|
|
4
|
+
|
|
5
|
+
Low-level, thread-bound SQLite helpers (connection + schema + checkpoint).
|
|
6
|
+
|
|
7
|
+
Design rules:
|
|
8
|
+
-------------
|
|
9
|
+
- Create **one** connection in the writer thread; never share it across threads.
|
|
10
|
+
- Prefer WAL for on-disk DBs to allow concurrent readers during writes.
|
|
11
|
+
- Keep pragmas pragmatic: good performance without wrecking durability.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sqlite3
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def connect(db_path: Path, *, wal: bool = True) -> sqlite3.Connection:
|
|
22
|
+
"""
|
|
23
|
+
Create and configure a **thread-bound** SQLite connection.
|
|
24
|
+
|
|
25
|
+
Must be called from the writer thread. The connection is not safe to pass
|
|
26
|
+
to worker threads.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
db_path
|
|
31
|
+
Path to SQLite database (may be :memory:).
|
|
32
|
+
wal
|
|
33
|
+
Enable WAL mode for file-backed databases.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
sqlite3.Connection
|
|
38
|
+
"""
|
|
39
|
+
conn = sqlite3.connect(db_path, timeout=60, check_same_thread=True)
|
|
40
|
+
|
|
41
|
+
# Journal mode:
|
|
42
|
+
if db_path == Path(":memory:"):
|
|
43
|
+
conn.execute("PRAGMA journal_mode=MEMORY")
|
|
44
|
+
else:
|
|
45
|
+
conn.execute(f"PRAGMA journal_mode={'WAL' if wal else 'DELETE'}")
|
|
46
|
+
|
|
47
|
+
# Pragmas: balanced defaults suitable for ETL-like workloads.
|
|
48
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
49
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
50
|
+
conn.execute("PRAGMA temp_store=MEMORY")
|
|
51
|
+
conn.execute("PRAGMA cache_size=-100000") # ~100MB page cache (negative means KB)
|
|
52
|
+
conn.execute("PRAGMA wal_autocheckpoint=1000") # periodic auto-checkpoints
|
|
53
|
+
|
|
54
|
+
return conn
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def init_schema(conn: sqlite3.Connection, *, store_task_status: bool) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Create optional tables used for observability (task_runs).
|
|
60
|
+
|
|
61
|
+
If you set `store_task_status=False`, this becomes a no-op and the DB is
|
|
62
|
+
entirely yours for domain tables (e.g., ETL outputs).
|
|
63
|
+
"""
|
|
64
|
+
if not store_task_status:
|
|
65
|
+
return
|
|
66
|
+
conn.executescript(
|
|
67
|
+
"""
|
|
68
|
+
CREATE TABLE IF NOT EXISTS task_runs (
|
|
69
|
+
id INTEGER PRIMARY KEY,
|
|
70
|
+
name TEXT NOT NULL,
|
|
71
|
+
status TEXT NOT NULL, -- queued|running|done|error|cancelled
|
|
72
|
+
progress REAL NOT NULL DEFAULT 0.0, -- fraction 0..1
|
|
73
|
+
message TEXT,
|
|
74
|
+
started_at TEXT,
|
|
75
|
+
finished_at TEXT,
|
|
76
|
+
result TEXT
|
|
77
|
+
);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_task_runs_status ON task_runs(status);
|
|
79
|
+
"""
|
|
80
|
+
)
|
|
81
|
+
conn.commit()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def wal_checkpoint(
|
|
85
|
+
conn: sqlite3.Connection,
|
|
86
|
+
mode: Literal["PASSIVE", "FULL", "RESTART", "TRUNCATE"] = "TRUNCATE",
|
|
87
|
+
) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Manually checkpoint the WAL into the main DB and (optionally) truncate -wal.
|
|
90
|
+
|
|
91
|
+
Useful at shutdown or between phases to keep the main file tidy.
|
|
92
|
+
"""
|
|
93
|
+
conn.commit()
|
|
94
|
+
conn.execute(f"PRAGMA wal_checkpoint({mode});").fetchone()
|
pipeloom/engine.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
engine.py
|
|
3
|
+
=========
|
|
4
|
+
|
|
5
|
+
Pipeline orchestration:
|
|
6
|
+
|
|
7
|
+
- Builds two Rich progress managers (overall sticky / per-task transient).
|
|
8
|
+
- Pre-registers per-task bars to avoid UI races.
|
|
9
|
+
- Starts the SQLite writer thread.
|
|
10
|
+
- Submits work to a ThreadPoolExecutor.
|
|
11
|
+
- Signals the writer with SENTINEL and joins while the transient progress
|
|
12
|
+
is still live, so bars vanish before the final frame is rendered.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import queue
|
|
19
|
+
import random
|
|
20
|
+
import signal
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Callable, Iterable
|
|
24
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .messages import (
|
|
29
|
+
SENTINEL,
|
|
30
|
+
MsgTaskFinished,
|
|
31
|
+
MsgTaskProgress,
|
|
32
|
+
MsgTaskStarted,
|
|
33
|
+
TaskDef,
|
|
34
|
+
)
|
|
35
|
+
from .progress import make_overall_progress, make_task_progress, preregister_task_bars
|
|
36
|
+
from .writer import SQLiteWriter
|
|
37
|
+
|
|
38
|
+
LOG = logging.getLogger("pipeloom.engine")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def default_worker(task: TaskDef, msg_q: queue.Queue[object]) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Demo worker function.
|
|
44
|
+
Replace this in your projects with real work (ETL steps, API calls, etc.).
|
|
45
|
+
"""
|
|
46
|
+
started = datetime.now(UTC).isoformat()
|
|
47
|
+
msg_q.put(MsgTaskStarted(task_id=task.task_id, name=task.name, started_at=started))
|
|
48
|
+
try:
|
|
49
|
+
for step in range(1, task.steps + 1):
|
|
50
|
+
time.sleep(0.05 + random.random() * 0.05) # simulate variable work
|
|
51
|
+
msg_q.put(
|
|
52
|
+
MsgTaskProgress(
|
|
53
|
+
task_id=task.task_id,
|
|
54
|
+
step=step,
|
|
55
|
+
total=task.steps,
|
|
56
|
+
message=f"step {step}/{task.steps}",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
finished = datetime.now(UTC).isoformat()
|
|
60
|
+
msg_q.put(
|
|
61
|
+
MsgTaskFinished(
|
|
62
|
+
task_id=task.task_id,
|
|
63
|
+
status="done",
|
|
64
|
+
finished_at=finished,
|
|
65
|
+
result=f"ok:{task.name}",
|
|
66
|
+
message="completed",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
finished = datetime.now(UTC).isoformat()
|
|
71
|
+
msg_q.put(
|
|
72
|
+
MsgTaskFinished(
|
|
73
|
+
task_id=task.task_id,
|
|
74
|
+
status="error",
|
|
75
|
+
finished_at=finished,
|
|
76
|
+
result=None,
|
|
77
|
+
message=str(e),
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_pipeline(
|
|
83
|
+
db_path: Path,
|
|
84
|
+
tasks: Iterable[TaskDef],
|
|
85
|
+
*,
|
|
86
|
+
workers: int,
|
|
87
|
+
wal: bool = True,
|
|
88
|
+
store_task_status: bool = True,
|
|
89
|
+
worker_fn: Callable[[TaskDef, queue.Queue[object]], None] = default_worker,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Execute an ETL-style workload using a single-writer SQLite backend.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
db_path
|
|
97
|
+
Target SQLite database path.
|
|
98
|
+
tasks
|
|
99
|
+
Iterable of TaskDef instances (one per unit of work).
|
|
100
|
+
workers
|
|
101
|
+
Maximum number of concurrent worker threads.
|
|
102
|
+
wal
|
|
103
|
+
Enable WAL mode for the writer (recommended for file-backed DBs).
|
|
104
|
+
store_task_status
|
|
105
|
+
If True, create and maintain the `task_runs` table for observability.
|
|
106
|
+
worker_fn
|
|
107
|
+
Callable invoked for each TaskDef (signature: (task, queue) -> None).
|
|
108
|
+
|
|
109
|
+
Notes
|
|
110
|
+
-----
|
|
111
|
+
The writer thread is shut down *before* the per-task progress context exits,
|
|
112
|
+
guaranteeing that all bars are removed and the final frame shows only the
|
|
113
|
+
“All tasks 100%” line.
|
|
114
|
+
"""
|
|
115
|
+
all_tasks = list(tasks)
|
|
116
|
+
n = len(all_tasks)
|
|
117
|
+
|
|
118
|
+
# Workers → Queue → Writer
|
|
119
|
+
msg_q: queue.Queue[object] = queue.Queue(maxsize=max(64, workers * 8))
|
|
120
|
+
|
|
121
|
+
# Gentle shutdown: we don't hard-kill threads; we let in-flight tasks finish.
|
|
122
|
+
stop_event = threading.Event()
|
|
123
|
+
|
|
124
|
+
def handle_sigint(signum, frame): # type: ignore[override]
|
|
125
|
+
LOG.warning("SIGINT received; finishing in-flight tasks, then exiting…")
|
|
126
|
+
stop_event.set()
|
|
127
|
+
|
|
128
|
+
signal.signal(signal.SIGINT, handle_sigint)
|
|
129
|
+
|
|
130
|
+
# Build progress managers that share the same Console (see rlog.py).
|
|
131
|
+
overall_p = make_overall_progress()
|
|
132
|
+
task_p = make_task_progress()
|
|
133
|
+
|
|
134
|
+
with overall_p:
|
|
135
|
+
overall = overall_p.add_task("[cyan]All tasks", total=n)
|
|
136
|
+
with task_p:
|
|
137
|
+
# Pre-register bars to avoid creation races.
|
|
138
|
+
bar_map = preregister_task_bars(task_p, n)
|
|
139
|
+
|
|
140
|
+
# Start the writer *after* bars are created, passing the mapping.
|
|
141
|
+
writer = SQLiteWriter(
|
|
142
|
+
db_path=db_path,
|
|
143
|
+
msg_q=msg_q,
|
|
144
|
+
wal=wal,
|
|
145
|
+
store_task_status=store_task_status,
|
|
146
|
+
task_progress=task_p,
|
|
147
|
+
task_bar_map=bar_map,
|
|
148
|
+
)
|
|
149
|
+
writer.start()
|
|
150
|
+
|
|
151
|
+
start = time.time()
|
|
152
|
+
|
|
153
|
+
# Submit work to a thread pool.
|
|
154
|
+
with ThreadPoolExecutor(
|
|
155
|
+
max_workers=workers, thread_name_prefix="worker"
|
|
156
|
+
) as ex:
|
|
157
|
+
futures = [ex.submit(worker_fn, t, msg_q) for t in all_tasks]
|
|
158
|
+
try:
|
|
159
|
+
for fut in as_completed(futures):
|
|
160
|
+
_ = fut.result() # surface exceptions
|
|
161
|
+
overall_p.advance(
|
|
162
|
+
overall, 1
|
|
163
|
+
) # advance only when a task finishes
|
|
164
|
+
if stop_event.is_set():
|
|
165
|
+
break
|
|
166
|
+
except KeyboardInterrupt:
|
|
167
|
+
LOG.warning("KeyboardInterrupt; letting in-flight tasks finish…")
|
|
168
|
+
|
|
169
|
+
# Shut down writer while the transient progress is still alive
|
|
170
|
+
# so the last bar removal appears before the final frame.
|
|
171
|
+
msg_q.put(SENTINEL)
|
|
172
|
+
writer.join(timeout=30)
|
|
173
|
+
task_p.refresh()
|
|
174
|
+
|
|
175
|
+
LOG.info("Elapsed: %.2fs", time.time() - start)
|
pipeloom/messages.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
messages.py
|
|
3
|
+
===========
|
|
4
|
+
|
|
5
|
+
Typed message contracts used on the in-memory **Queue** between worker threads
|
|
6
|
+
and the single SQLite writer thread.
|
|
7
|
+
|
|
8
|
+
Why messages?
|
|
9
|
+
-------------
|
|
10
|
+
Workers should **never** touch SQLite directly (SQLite connections are not
|
|
11
|
+
thread-safe across threads). Instead, workers publish *intent* via small,
|
|
12
|
+
immutable dataclasses. The writer is the only component that owns a SQLite
|
|
13
|
+
connection and it executes the requested changes in a single, serialized place.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class TaskDef:
|
|
23
|
+
"""
|
|
24
|
+
Static definition of a unit of work.
|
|
25
|
+
|
|
26
|
+
Attributes
|
|
27
|
+
----------
|
|
28
|
+
task_id:
|
|
29
|
+
Stable numeric identifier for the task. Used as the primary key in
|
|
30
|
+
SQLite and as the key for progress bar lookups.
|
|
31
|
+
name:
|
|
32
|
+
Display-friendly name shown in logs and progress UI.
|
|
33
|
+
steps:
|
|
34
|
+
Number of progress steps the *demo* worker will simulate.
|
|
35
|
+
In real code, you can ignore this and emit MsgTaskProgress at your own cadence.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
task_id: int
|
|
39
|
+
name: str
|
|
40
|
+
steps: int = 20
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class MsgTaskStarted:
|
|
45
|
+
"""
|
|
46
|
+
Signal that a task has begun.
|
|
47
|
+
Posted by a worker thread as soon as the task is admitted for work.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
task_id: int
|
|
51
|
+
name: str
|
|
52
|
+
started_at: str # ISO 8601 UTC string (avoid tz-naive datetimes over queues)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class MsgTaskProgress:
|
|
57
|
+
"""
|
|
58
|
+
Incremental progress signal.
|
|
59
|
+
The writer translates this into a fractional progress column and updates the Rich bar.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
task_id: int
|
|
63
|
+
step: int
|
|
64
|
+
total: int
|
|
65
|
+
message: str = ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class MsgTaskFinished:
|
|
70
|
+
"""
|
|
71
|
+
Final status + optional result payload for a completed or failed task.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
task_id: int
|
|
75
|
+
status: str # "done" | "error" | "cancelled"
|
|
76
|
+
finished_at: str # ISO 8601 UTC string
|
|
77
|
+
result: str | None = None
|
|
78
|
+
message: str = ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Special object placed on the queue to request the writer to shut down cleanly.
|
|
82
|
+
SENTINEL: object = object()
|
pipeloom/progress.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
progress.py
|
|
3
|
+
===========
|
|
4
|
+
|
|
5
|
+
Factory helpers for Rich progress managers.
|
|
6
|
+
|
|
7
|
+
We intentionally separate **overall** progress (sticky, remains visible at 100%)
|
|
8
|
+
from **per-task** progress (transient, disappears once all tasks complete).
|
|
9
|
+
Both share the same Console (see rlog.console).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from rich.progress import (
|
|
15
|
+
BarColumn,
|
|
16
|
+
MofNCompleteColumn,
|
|
17
|
+
Progress,
|
|
18
|
+
SpinnerColumn,
|
|
19
|
+
TaskID,
|
|
20
|
+
TaskProgressColumn,
|
|
21
|
+
TimeElapsedColumn,
|
|
22
|
+
TimeRemainingColumn,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .rlog import console
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_overall_progress() -> Progress:
|
|
29
|
+
"""
|
|
30
|
+
Create the overall persistent progress manager.
|
|
31
|
+
|
|
32
|
+
This remains visible after completion and is ideal for a single “All tasks: 100%”
|
|
33
|
+
summary line once per-task bars disappear.
|
|
34
|
+
"""
|
|
35
|
+
return Progress(
|
|
36
|
+
SpinnerColumn(),
|
|
37
|
+
"[progress.description]{task.description}",
|
|
38
|
+
BarColumn(),
|
|
39
|
+
TaskProgressColumn(),
|
|
40
|
+
MofNCompleteColumn(),
|
|
41
|
+
"•",
|
|
42
|
+
TimeElapsedColumn(),
|
|
43
|
+
"•",
|
|
44
|
+
TimeRemainingColumn(),
|
|
45
|
+
transient=False, # keep final frame visible
|
|
46
|
+
console=console,
|
|
47
|
+
refresh_per_second=8, # throttle repaints to avoid log spam jitter
|
|
48
|
+
disable=not console.is_terminal,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def make_task_progress() -> Progress:
|
|
53
|
+
"""
|
|
54
|
+
Create the transient per-task progress manager.
|
|
55
|
+
|
|
56
|
+
This hides when the context exits so your terminal remains tidy.
|
|
57
|
+
"""
|
|
58
|
+
return Progress(
|
|
59
|
+
SpinnerColumn(),
|
|
60
|
+
"[progress.description]{task.description}",
|
|
61
|
+
BarColumn(),
|
|
62
|
+
TaskProgressColumn(),
|
|
63
|
+
TimeElapsedColumn(),
|
|
64
|
+
transient=True, # auto-hide on exit
|
|
65
|
+
console=console,
|
|
66
|
+
refresh_per_second=8,
|
|
67
|
+
disable=not console.is_terminal,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def preregister_task_bars(task_progress: Progress, num_tasks: int) -> dict[int, TaskID]:
|
|
72
|
+
"""
|
|
73
|
+
Pre-create one Rich bar per task and return a mapping {task_id -> TaskID}.
|
|
74
|
+
|
|
75
|
+
Why up-front?
|
|
76
|
+
-------------
|
|
77
|
+
If a worker publishes a MsgTaskProgress before the writer has created the bar,
|
|
78
|
+
updates can be lost. Pre-registering eliminates this race completely.
|
|
79
|
+
"""
|
|
80
|
+
mapping: dict[int, TaskID] = {}
|
|
81
|
+
for i in range(1, num_tasks + 1):
|
|
82
|
+
mapping[i] = task_progress.add_task(f"[bold]task-{i}", total=100)
|
|
83
|
+
return mapping
|
pipeloom/rlog.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rlog.py
|
|
3
|
+
=======
|
|
4
|
+
|
|
5
|
+
Rich-aware logging. We create **one Console** and share it between RichHandler
|
|
6
|
+
(logging) and Rich Progress (live bars). Without a shared console, Rich would
|
|
7
|
+
render duplicate live regions and your terminal would flicker or show
|
|
8
|
+
repeated “All tasks” bars.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.logging import RichHandler
|
|
18
|
+
|
|
19
|
+
# Single shared console for logs and progress.
|
|
20
|
+
console = Console(log_path=False)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("pipeloom")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def setup_logging(verbose: int, log_file: Path | None = None) -> None:
|
|
26
|
+
"""
|
|
27
|
+
Configure logging.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
verbose
|
|
32
|
+
0 = WARNING, 1 = INFO, 2+ = DEBUG
|
|
33
|
+
log_file
|
|
34
|
+
If provided, a plain (non-Rich) file handler is added for CI/grep.
|
|
35
|
+
"""
|
|
36
|
+
level = (
|
|
37
|
+
logging.WARNING
|
|
38
|
+
if verbose <= 0
|
|
39
|
+
else logging.INFO if verbose == 1 else logging.DEBUG
|
|
40
|
+
)
|
|
41
|
+
handlers: list[logging.Handler] = [
|
|
42
|
+
RichHandler(
|
|
43
|
+
rich_tracebacks=True,
|
|
44
|
+
show_time=False,
|
|
45
|
+
show_level=True,
|
|
46
|
+
show_path=False,
|
|
47
|
+
console=console, # IMPORTANT: share with Progress
|
|
48
|
+
markup=True,
|
|
49
|
+
)
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
if log_file:
|
|
53
|
+
fh = logging.FileHandler(log_file, encoding="utf-8")
|
|
54
|
+
fh.setLevel(level)
|
|
55
|
+
fh.setFormatter(
|
|
56
|
+
logging.Formatter(
|
|
57
|
+
fmt="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
|
58
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
handlers.append(fh)
|
|
62
|
+
|
|
63
|
+
logging.basicConfig(level=level, format="%(message)s", handlers=handlers)
|
|
64
|
+
logger.setLevel(level)
|
pipeloom/writer.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
writer.py
|
|
3
|
+
=========
|
|
4
|
+
|
|
5
|
+
The **single** SQLite writer thread. It:
|
|
6
|
+
|
|
7
|
+
- Opens and owns the only SQLite connection (configured for WAL).
|
|
8
|
+
- Consumes message objects from a thread-safe Queue.
|
|
9
|
+
- Writes task status/progress to SQLite (optional).
|
|
10
|
+
- Updates/removes Rich per-task progress bars safely.
|
|
11
|
+
|
|
12
|
+
Why a single writer?
|
|
13
|
+
--------------------
|
|
14
|
+
SQLite allows multiple writers *serially*, but a single connection and a single
|
|
15
|
+
owning thread is the least surprising, highest-reliability approach. It also
|
|
16
|
+
avoids cross-thread connection misuse, which is a common source of bugs.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import queue
|
|
23
|
+
import sqlite3
|
|
24
|
+
import threading
|
|
25
|
+
|
|
26
|
+
from rich.progress import Progress, TaskID
|
|
27
|
+
|
|
28
|
+
from . import db as dbmod
|
|
29
|
+
from .messages import SENTINEL, MsgTaskFinished, MsgTaskProgress, MsgTaskStarted
|
|
30
|
+
|
|
31
|
+
LOG = logging.getLogger("pipeloom.writer")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SQLiteWriter(threading.Thread):
|
|
35
|
+
"""
|
|
36
|
+
Dedicated thread that exclusively writes to SQLite and manages per-task bars.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
db_path
|
|
41
|
+
Path to the SQLite database file (or ':memory:').
|
|
42
|
+
msg_q
|
|
43
|
+
Thread-safe queue from which this writer consumes message objects.
|
|
44
|
+
wal
|
|
45
|
+
Whether to enable WAL on file-backed databases.
|
|
46
|
+
store_task_status
|
|
47
|
+
Toggle persistence of task status into the `task_runs` table.
|
|
48
|
+
task_progress
|
|
49
|
+
Rich Progress manager used for per-task bars (transient).
|
|
50
|
+
task_bar_map
|
|
51
|
+
Pre-registered mapping: task_id -> Rich TaskID (prevents render races).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
db_path,
|
|
57
|
+
msg_q: queue.Queue[object],
|
|
58
|
+
*,
|
|
59
|
+
wal: bool = True,
|
|
60
|
+
store_task_status: bool = True,
|
|
61
|
+
task_progress: Progress | None = None,
|
|
62
|
+
task_bar_map: dict[int, TaskID] | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
super().__init__(daemon=True)
|
|
65
|
+
self.db_path = db_path
|
|
66
|
+
self.msg_q = msg_q
|
|
67
|
+
self._use_wal = wal
|
|
68
|
+
self._store = store_task_status
|
|
69
|
+
self._progress = task_progress
|
|
70
|
+
self._progress_tasks: dict[int, TaskID] = dict(task_bar_map or {})
|
|
71
|
+
self._conn: sqlite3.Connection | None = None
|
|
72
|
+
self._stop = threading.Event()
|
|
73
|
+
|
|
74
|
+
# --- message handlers --------------------------------------------------------------
|
|
75
|
+
def _on_started(self, m: MsgTaskStarted) -> None:
|
|
76
|
+
"""Persist that a task has started (if enabled)."""
|
|
77
|
+
assert self._conn is not None
|
|
78
|
+
if self._store:
|
|
79
|
+
self._conn.execute(
|
|
80
|
+
"""
|
|
81
|
+
INSERT INTO task_runs (id,name,status,progress,started_at,message)
|
|
82
|
+
VALUES (?,?, 'running', 0.0, ?, '')
|
|
83
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
84
|
+
name=excluded.name, status='running', progress=0.0, started_at=excluded.started_at, message=''
|
|
85
|
+
""",
|
|
86
|
+
(m.task_id, m.name, m.started_at),
|
|
87
|
+
)
|
|
88
|
+
self._conn.commit()
|
|
89
|
+
|
|
90
|
+
def _on_progress(self, m: MsgTaskProgress) -> None:
|
|
91
|
+
"""Update DB progress as fraction and drive the Rich bar."""
|
|
92
|
+
assert self._conn is not None
|
|
93
|
+
pct = round(100.0 * (m.step / max(1, m.total)), 2)
|
|
94
|
+
|
|
95
|
+
if self._store:
|
|
96
|
+
self._conn.execute(
|
|
97
|
+
"UPDATE task_runs SET progress=?, message=? WHERE id=?",
|
|
98
|
+
(pct / 100.0, m.message, m.task_id),
|
|
99
|
+
)
|
|
100
|
+
self._conn.commit()
|
|
101
|
+
|
|
102
|
+
# IMPORTANT: Rich TaskID can be 0; do not use `if tid:` or walrus in condition.
|
|
103
|
+
if self._progress:
|
|
104
|
+
tid = self._progress_tasks.get(m.task_id)
|
|
105
|
+
if tid is not None:
|
|
106
|
+
self._progress.update(tid, completed=int(pct))
|
|
107
|
+
|
|
108
|
+
def _on_finished(self, m: MsgTaskFinished) -> None:
|
|
109
|
+
"""Write final status and remove the per-task bar cleanly."""
|
|
110
|
+
assert self._conn is not None
|
|
111
|
+
if self._store:
|
|
112
|
+
final = 1.0 if m.status == "done" else 0.0
|
|
113
|
+
self._conn.execute(
|
|
114
|
+
"""
|
|
115
|
+
UPDATE task_runs SET status=?, finished_at=?, progress=?, result=?, message=? WHERE id=?
|
|
116
|
+
""",
|
|
117
|
+
(m.status, m.finished_at, final, m.result, m.message, m.task_id),
|
|
118
|
+
)
|
|
119
|
+
self._conn.commit()
|
|
120
|
+
|
|
121
|
+
if self._progress:
|
|
122
|
+
tid = self._progress_tasks.pop(m.task_id, None)
|
|
123
|
+
if tid is not None:
|
|
124
|
+
self._progress.update(tid, completed=100)
|
|
125
|
+
self._progress.remove_task(tid)
|
|
126
|
+
self._progress.refresh()
|
|
127
|
+
|
|
128
|
+
# --- thread run loop ---------------------------------------------------------------
|
|
129
|
+
def run(self) -> None:
|
|
130
|
+
"""
|
|
131
|
+
Lifecycle:
|
|
132
|
+
- open connection
|
|
133
|
+
- initialize schema (optional)
|
|
134
|
+
- consume messages until SENTINEL arrives
|
|
135
|
+
- checkpoint and close
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
self._conn = dbmod.connect(self.db_path, wal=self._use_wal)
|
|
139
|
+
dbmod.init_schema(self._conn, store_task_status=self._store)
|
|
140
|
+
LOG.info("DB writer started → %s (WAL=%s)", self.db_path, self._use_wal)
|
|
141
|
+
|
|
142
|
+
while not self._stop.is_set():
|
|
143
|
+
try:
|
|
144
|
+
item = self.msg_q.get(
|
|
145
|
+
timeout=0.5
|
|
146
|
+
) # small timeout to enable graceful exit
|
|
147
|
+
except queue.Empty:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
if item is SENTINEL:
|
|
151
|
+
self.msg_q.task_done()
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
if isinstance(item, MsgTaskStarted):
|
|
155
|
+
self._on_started(item)
|
|
156
|
+
elif isinstance(item, MsgTaskProgress):
|
|
157
|
+
self._on_progress(item)
|
|
158
|
+
elif isinstance(item, MsgTaskFinished):
|
|
159
|
+
self._on_finished(item)
|
|
160
|
+
else:
|
|
161
|
+
LOG.warning("Unknown message: %r", type(item))
|
|
162
|
+
|
|
163
|
+
self.msg_q.task_done()
|
|
164
|
+
|
|
165
|
+
finally:
|
|
166
|
+
# Best-effort cleanup & checkpoint
|
|
167
|
+
try:
|
|
168
|
+
if self._conn:
|
|
169
|
+
self._conn.execute("ANALYZE;")
|
|
170
|
+
except Exception:
|
|
171
|
+
LOG.debug("ANALYZE failed", exc_info=True)
|
|
172
|
+
|
|
173
|
+
if self._conn:
|
|
174
|
+
with _Suppress(sqlite3.OperationalError):
|
|
175
|
+
dbmod.wal_checkpoint(self._conn, "TRUNCATE")
|
|
176
|
+
self._conn.close()
|
|
177
|
+
self._conn = None
|
|
178
|
+
|
|
179
|
+
LOG.info("DB writer stopped")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _Suppress:
|
|
183
|
+
"""Minimal suppress helper to avoid importing contextlib."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, *exc_types):
|
|
186
|
+
self.exc_types = exc_types
|
|
187
|
+
|
|
188
|
+
def __enter__(self):
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def __exit__(self, exc_type, exc, tb):
|
|
192
|
+
return exc_type is not None and issubclass(
|
|
193
|
+
exc_type, self.exc_types or (Exception,)
|
|
194
|
+
)
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pipeloom
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Threaded ETL scaffolding with a single-writer SQLite (WAL) backend, Rich progress, and a Typer CLI.
|
|
5
|
+
Author-email: Caleb Grant <grantcaleb22@gmail.com>
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://github.com/geocoug/pipeloom
|
|
8
|
+
Project-URL: Repository, https://github.com/geocoug/pipeloom
|
|
9
|
+
Project-URL: Issues, https://github.com/geocoug/pipeloom/issues
|
|
10
|
+
Requires-Python: ==3.13.*
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: rich>=14.1.0
|
|
13
|
+
Requires-Dist: typer>=0.16.0
|
|
14
|
+
|
|
15
|
+
# pipeloom
|
|
16
|
+
|
|
17
|
+
Threaded ETL scaffolding with a **single-writer SQLite (WAL)** backend, **Rich** progress, and a **Typer** CLI.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
- **SQLite WAL**: readers don’t block while the writer appends; perfect for local ETL and small services.
|
|
22
|
+
- **Single writer**: one connection, one thread → no cross-thread SQLite bugs.
|
|
23
|
+
- **Structured messaging**: workers never touch the DB; they put typed messages on a queue.
|
|
24
|
+
- **Clean UX**: sticky overall bar, transient per-task bars, shared Console to prevent flicker.
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv run pipeloom --db ./wal_demo.db --num-tasks 10 -vv
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Use in your ETL
|
|
33
|
+
|
|
34
|
+
1. Define tasks (`TaskDef(...)`).
|
|
35
|
+
2. Provide a `worker_fn(task, msg_q)` that emits:
|
|
36
|
+
- `MsgTaskStarted`
|
|
37
|
+
- `MsgTaskProgress` (at your own cadence)
|
|
38
|
+
- `MsgTaskFinished`
|
|
39
|
+
3. Call `run_pipeline(db_path, tasks, workers=..., wal=True, store_task_status=True)`
|
|
40
|
+
|
|
41
|
+
> If you don’t want the task_runs table, pass `store_task_status=False`.
|
|
42
|
+
|
|
43
|
+
## Design Notes
|
|
44
|
+
|
|
45
|
+
- All SQLite access happens in SQLiteWriter (one thread, one connection).
|
|
46
|
+
- Two progress managers:
|
|
47
|
+
- overall: remains after completion
|
|
48
|
+
- per-task: disappears when done
|
|
49
|
+
- We pre-register per-task bars to avoid races.
|
|
50
|
+
- Rich TaskID can be 0 (falsy!) — always check tid is not None.
|
|
51
|
+
|
|
52
|
+
## Components
|
|
53
|
+
|
|
54
|
+
```mermaid
|
|
55
|
+
flowchart LR
|
|
56
|
+
subgraph App["Your App / CLI (Typer)"]
|
|
57
|
+
CLI["CLI (Typer)"]
|
|
58
|
+
Engine["Engine (orchestrator)"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
subgraph Work["Worker Threads (N)"]
|
|
62
|
+
W1["Worker #1"]
|
|
63
|
+
Wn["Worker #N"]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
subgraph Writer["SQLite Writer Thread (single owner)"]
|
|
67
|
+
Q["Queue[object]"]
|
|
68
|
+
SW["SQLiteWriter (owns one sqlite3.Connection)"]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
subgraph DB["SQLite Database"]
|
|
72
|
+
WAL["db-wal / db-shm"]
|
|
73
|
+
Main["main.db file"]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
CLI --> Engine
|
|
77
|
+
Engine -->|ThreadPoolExecutor| Work
|
|
78
|
+
Work -->|MsgTaskStarted/Progress/Finished| Q
|
|
79
|
+
Engine -->|start| SW
|
|
80
|
+
SW -->|consume| Q
|
|
81
|
+
SW -->|INSERT/UPDATE| DB
|
|
82
|
+
DB <--> WAL
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Why this shape works
|
|
86
|
+
|
|
87
|
+
- Only the writer thread touches SQLite — no cross-thread connections.
|
|
88
|
+
- WAL makes reads cheap during writes (readers don’t block while the writer appends).
|
|
89
|
+
- Workers talk to the writer via a simple Queue using typed messages.
|
|
90
|
+
|
|
91
|
+
## Lifecycle
|
|
92
|
+
|
|
93
|
+
```mermaid
|
|
94
|
+
sequenceDiagram
|
|
95
|
+
participant CLI as CLI (Typer)
|
|
96
|
+
participant ENG as Engine
|
|
97
|
+
participant TPool as ThreadPoolExecutor
|
|
98
|
+
participant W as Workers (N)
|
|
99
|
+
participant Q as Queue
|
|
100
|
+
participant WR as SQLiteWriter
|
|
101
|
+
participant DB as SQLite (WAL)
|
|
102
|
+
|
|
103
|
+
CLI->>ENG: parse args, build tasks
|
|
104
|
+
ENG->>WR: start (creates connection + schema)
|
|
105
|
+
ENG->>TPool: submit N workers
|
|
106
|
+
loop each task
|
|
107
|
+
W->>Q: MsgTaskStarted
|
|
108
|
+
W->>Q: MsgTaskProgress (many)
|
|
109
|
+
W->>Q: MsgTaskFinished
|
|
110
|
+
end
|
|
111
|
+
WR->>DB: write status/progress (optional)
|
|
112
|
+
ENG->>WR: put SENTINEL (shutdown request)
|
|
113
|
+
WR->>DB: WAL checkpoint(TRUNCATE), close
|
|
114
|
+
WR-->>ENG: join()
|
|
115
|
+
ENG-->>CLI: exit (overall bar stays at 100%)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Writer state machine
|
|
119
|
+
|
|
120
|
+
```mermaid
|
|
121
|
+
stateDiagram-v2
|
|
122
|
+
[*] --> Boot
|
|
123
|
+
Boot --> Running: open conn + init schema
|
|
124
|
+
Running --> Running: handle message
|
|
125
|
+
Running --> ShutdownRequested: receive SENTINEL
|
|
126
|
+
ShutdownRequested --> Teardown: ANALYZE + checkpoint + close
|
|
127
|
+
Teardown --> [*]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## How to extend
|
|
131
|
+
|
|
132
|
+
You’ve got two knobs:
|
|
133
|
+
|
|
134
|
+
1. What work gets done (your worker function).
|
|
135
|
+
2. What gets stored (domain tables and, optionally, task_runs).
|
|
136
|
+
|
|
137
|
+
Below are pragmatic patterns.
|
|
138
|
+
|
|
139
|
+
1. Use your own worker function
|
|
140
|
+
|
|
141
|
+
Workers never touch SQLite. They publish messages to the queue. Replace the default with your own.
|
|
142
|
+
|
|
143
|
+
```py
|
|
144
|
+
# my_pipeline.py
|
|
145
|
+
from pipeloom.messages import TaskDef, MsgTaskStarted, MsgTaskProgress, MsgTaskFinished
|
|
146
|
+
from datetime import UTC, datetime
|
|
147
|
+
import time
|
|
148
|
+
|
|
149
|
+
def my_worker(task: TaskDef, q) -> None:
|
|
150
|
+
started = datetime.now(UTC).isoformat()
|
|
151
|
+
q.put(MsgTaskStarted(task_id=task.task_id, name=task.name, started_at=started))
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
# Do real work here: extract → transform → load
|
|
155
|
+
total = 3
|
|
156
|
+
# Extract
|
|
157
|
+
data = extract(task) # your code
|
|
158
|
+
q.put(MsgTaskProgress(task.task_id, 1, total, "extracted"))
|
|
159
|
+
|
|
160
|
+
# Transform
|
|
161
|
+
records = transform(data) # your code
|
|
162
|
+
q.put(MsgTaskProgress(task.task_id, 2, total, "transformed"))
|
|
163
|
+
|
|
164
|
+
# Load (NOT via SQLite here — emit a domain message instead; see below)
|
|
165
|
+
q.put(MsgTaskProgress(task.task_id, 3, total, "loaded"))
|
|
166
|
+
|
|
167
|
+
finished = datetime.now(UTC).isoformat()
|
|
168
|
+
q.put(MsgTaskFinished(task.task_id, "done", finished, result=f"ok:{task.name}"))
|
|
169
|
+
|
|
170
|
+
except Exception as e:
|
|
171
|
+
finished = datetime.now(UTC).isoformat()
|
|
172
|
+
q.put(MsgTaskFinished(task.task_id, "error", finished, message=str(e)))
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Then run:
|
|
176
|
+
|
|
177
|
+
```py
|
|
178
|
+
from pathlib import Path
|
|
179
|
+
from pipeloom.engine import run_pipeline
|
|
180
|
+
from pipeloom.messages import TaskDef
|
|
181
|
+
|
|
182
|
+
tasks = [TaskDef(i, f"etl-{i}") for i in range(1, 11)]
|
|
183
|
+
run_pipeline(
|
|
184
|
+
db_path=Path("etl.db"),
|
|
185
|
+
tasks=tasks,
|
|
186
|
+
workers=4,
|
|
187
|
+
wal=True,
|
|
188
|
+
store_task_status=True, # keep it on for observability
|
|
189
|
+
worker_fn=my_worker, # <- your worker
|
|
190
|
+
)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
2. Store your own data (domain tables)
|
|
194
|
+
|
|
195
|
+
Right now, the writer only persists task_runs (toggle with store_task_status). To persist domain data, add new message types and handle them in the writer. Keep DB writes centralized.
|
|
196
|
+
|
|
197
|
+
Define a domain message:
|
|
198
|
+
|
|
199
|
+
```py
|
|
200
|
+
# my_messages.py
|
|
201
|
+
from dataclasses import dataclass
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True)
|
|
204
|
+
class MsgUpsertRecord:
|
|
205
|
+
table: str
|
|
206
|
+
key: str
|
|
207
|
+
payload: dict # or a typed dataclass you marshal to a tuple
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Emit from your worker:
|
|
211
|
+
|
|
212
|
+
```py
|
|
213
|
+
q.put(MsgUpsertRecord(table="users", key="abc123", payload={"name": "Caleb", "active": 1}))
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Handle in the writer:
|
|
217
|
+
|
|
218
|
+
```py
|
|
219
|
+
# in your SQLiteWriter.run loop
|
|
220
|
+
from my_messages import MsgUpsertRecord
|
|
221
|
+
|
|
222
|
+
elif isinstance(item, MsgUpsertRecord):
|
|
223
|
+
self._on_upsert(item)
|
|
224
|
+
|
|
225
|
+
# and implement:
|
|
226
|
+
def _on_upsert(self, m: MsgUpsertRecord) -> None:
|
|
227
|
+
assert self._conn is not None
|
|
228
|
+
# Example UPSERT using a prepared SQL per table; avoid dynamic SQL when possible.
|
|
229
|
+
self._conn.execute(
|
|
230
|
+
"INSERT INTO users(key, name, active) VALUES (?, ?, ?) "
|
|
231
|
+
"ON CONFLICT(key) DO UPDATE SET name=excluded.name, active=excluded.active",
|
|
232
|
+
(m.key, m.payload["name"], m.payload["active"]),
|
|
233
|
+
)
|
|
234
|
+
self._conn.commit()
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Create your domain schema in a small helper or migration step:
|
|
238
|
+
|
|
239
|
+
```sql
|
|
240
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
241
|
+
key TEXT PRIMARY KEY,
|
|
242
|
+
name TEXT NOT NULL,
|
|
243
|
+
active INTEGER NOT NULL
|
|
244
|
+
);
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
> [!NOTE]
|
|
248
|
+
> Keep schema creation in a dedicated module (like db_schema.py) and call it from the writer just after init_schema(...).
|
|
249
|
+
|
|
250
|
+
3. Idempotency & retries (don’t overthink it)
|
|
251
|
+
|
|
252
|
+
- Use natural keys + ON CONFLICT DO UPDATE to make “retries” cheap.
|
|
253
|
+
- For large loads, prefer batch messages (e.g., MsgUpsertBatch) to reduce commit overhead.
|
|
254
|
+
- If a worker fails, you’ll still get a MsgTaskFinished(..., status="error"). You can query task_runs later and re-enqueue specific tasks.
|
|
255
|
+
|
|
256
|
+
4. Backpressure & throughput
|
|
257
|
+
|
|
258
|
+
- The queue is bounded: maxsize=max(64, workers * 8). If you push heavy domain data, consider:
|
|
259
|
+
- Using batching (MsgUpsertBatch), say 1,000–10,000 rows per message.
|
|
260
|
+
- Increasing maxsize if writer keeps up, or reducing if you want memory pressure to be explicit.
|
|
261
|
+
- Keep wal_autocheckpoint=1000. For huge ingest, raise it; for tiny writes, it’s fine.
|
|
262
|
+
|
|
263
|
+
5. Tuning SQLite (safe defaults vs. speed)
|
|
264
|
+
|
|
265
|
+
Current pragmas are conservative for ETL:
|
|
266
|
+
|
|
267
|
+
- journal_mode=WAL
|
|
268
|
+
- synchronous=NORMAL (fewer fsyncs; still safe enough)
|
|
269
|
+
- temp_store=MEMORY
|
|
270
|
+
- cache_size=-100000 (~100 MB)
|
|
271
|
+
- wal_autocheckpoint=1000
|
|
272
|
+
|
|
273
|
+
Tradeoffs:
|
|
274
|
+
|
|
275
|
+
- For maximum durability, bump to synchronous=FULL.
|
|
276
|
+
- For speed on scratch DBs, you can use PRAGMA synchronous=OFF (⚠️ crash can corrupt last transaction).
|
|
277
|
+
|
|
278
|
+
6. Customize the progress UI
|
|
279
|
+
|
|
280
|
+
- Per-task bars are pre-registered to avoid races.
|
|
281
|
+
- You can change columns or add text with task_progress.update(tid, description="…").
|
|
282
|
+
- To group tasks, give names like "extract:users", "transform:users", etc.
|
|
283
|
+
|
|
284
|
+
7. Multiprocessing or asyncio (when to switch)
|
|
285
|
+
|
|
286
|
+
- Multiprocessing: if your work is CPU-bound and the GIL bites. You’ll need:
|
|
287
|
+
- multiprocessing.Queue for messages.
|
|
288
|
+
- A small shim to update Rich progress from the main process only.
|
|
289
|
+
- Asyncio: if your work is network-bound and you want extreme concurrency.
|
|
290
|
+
- Keep the same “single-writer” idea — but the writer becomes an asyncio.Task with aiosqlite.
|
|
291
|
+
|
|
292
|
+
For most local ETL, threaded workers + single writer are enough.
|
|
293
|
+
|
|
294
|
+
8. Observability (recommended: keep task_runs)
|
|
295
|
+
|
|
296
|
+
You’ll thank yourself later. Handy queries:
|
|
297
|
+
|
|
298
|
+
```sql
|
|
299
|
+
-- Stuck or failed tasks in the last 24h
|
|
300
|
+
SELECT id, name, status, progress, message, started_at, finished_at
|
|
301
|
+
FROM task_runs
|
|
302
|
+
WHERE (status <> 'done' OR progress < 1.0)
|
|
303
|
+
AND (datetime(started_at) > datetime('now','-1 day'));
|
|
304
|
+
|
|
305
|
+
-- Histogram of statuses
|
|
306
|
+
SELECT status, COUNT(*) FROM task_runs GROUP BY status;
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
If a project really doesn’t need it, set --store-task-status false (or store_task_status=False in code).
|
|
310
|
+
|
|
311
|
+
9. Example: replace the demo with a real ETL
|
|
312
|
+
|
|
313
|
+
Worker: extract from API → transform rows → send a batch to writer.
|
|
314
|
+
|
|
315
|
+
```py
|
|
316
|
+
from dataclasses import dataclass
|
|
317
|
+
from pipeloom.messages import MsgTaskStarted, MsgTaskProgress, MsgTaskFinished
|
|
318
|
+
from datetime import UTC, datetime
|
|
319
|
+
import time
|
|
320
|
+
|
|
321
|
+
@dataclass(frozen=True)
|
|
322
|
+
class MsgUpsertUsersBatch:
|
|
323
|
+
rows: list[tuple[str, str, int]] # key, name, active
|
|
324
|
+
|
|
325
|
+
def user_worker(task, q):
|
|
326
|
+
q.put(MsgTaskStarted(task.task_id, task.name, datetime.now(UTC).isoformat()))
|
|
327
|
+
try:
|
|
328
|
+
users = fetch_users_page(task.task_id) # your I/O
|
|
329
|
+
rows = [(u["id"], u["name"], int(u["active"])) for u in users]
|
|
330
|
+
q.put(MsgTaskProgress(task.task_id, 1, 2, "downloaded"))
|
|
331
|
+
q.put(MsgUpsertUsersBatch(rows))
|
|
332
|
+
q.put(MsgTaskProgress(task.task_id, 2, 2, "stored"))
|
|
333
|
+
q.put(MsgTaskFinished(task.task_id, "done", datetime.now(UTC).isoformat()))
|
|
334
|
+
except Exception as e:
|
|
335
|
+
q.put(MsgTaskFinished(task.task_id, "error", datetime.now(UTC).isoformat(), message=str(e)))
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Writer handler:
|
|
339
|
+
|
|
340
|
+
```py
|
|
341
|
+
def _on_upsert_users_batch(self, m: MsgUpsertUsersBatch) -> None:
|
|
342
|
+
assert self._conn is not None
|
|
343
|
+
self._conn.executemany(
|
|
344
|
+
"INSERT INTO users(key, name, active) VALUES (?, ?, ?) "
|
|
345
|
+
"ON CONFLICT(key) DO UPDATE SET name=excluded.name, active=excluded.active",
|
|
346
|
+
m.rows,
|
|
347
|
+
)
|
|
348
|
+
self._conn.commit()
|
|
349
|
+
|
|
350
|
+
Wire it in the writer’s dispatch:
|
|
351
|
+
|
|
352
|
+
elif isinstance(item, MsgUpsertUsersBatch):
|
|
353
|
+
self._on_upsert_users_batch(item)
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
10. Safety checklist
|
|
357
|
+
|
|
358
|
+
- ✅ Never share a SQLite connection across threads.
|
|
359
|
+
- ✅ Always pre-register per-task bars; check tid is not None (TaskID 0 is valid).
|
|
360
|
+
- ✅ Keep one Console for Logging + Progress.
|
|
361
|
+
- ✅ Use UPSERT for idempotency; batch inserts when possible.
|
|
362
|
+
- ✅ Shut down writer before transient progress exits (to remove bars).
|
|
363
|
+
- ✅ Keep task_runs on unless you have a strong reason not to.
|
|
364
|
+
|
|
365
|
+
## How to Extend
|
|
366
|
+
|
|
367
|
+
The core design is message-driven. To add new features:
|
|
368
|
+
|
|
369
|
+
1. Add a new message type
|
|
370
|
+
- Create a @dataclass in messages.py representing the event.
|
|
371
|
+
- Example: MsgTaskError to log errors per task.
|
|
372
|
+
2. Handle it in SQLiteWriter.run()
|
|
373
|
+
- Add a new elif isinstance(m, MsgTaskError): self._on_error(m).
|
|
374
|
+
3. Update the database schema
|
|
375
|
+
- Modify the SQLite initialization in SQLiteWriter._init_db() to add new columns/tables.
|
|
376
|
+
- Consider migrations if you want backward compatibility.
|
|
377
|
+
4. Update your workers to emit the new message type when appropriate.
|
|
378
|
+
|
|
379
|
+
Because the SQLiteWriter owns all database writes, you keep DB operations safe and consistent no matter how many worker threads/processes you spawn.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pipeloom/__init__.py,sha256=QWvMcKQvllkUkHYpwfEwldWvs4DGSm4ib26udHjqCE0,1591
|
|
2
|
+
pipeloom/cli.py,sha256=ab4EAHymYQd7sowkb58iKT8SeqPkelOMFokbvQZNLFA,1998
|
|
3
|
+
pipeloom/db.py,sha256=7J2TaR4JTfqLtlHeEJSMJG7bmRMkUtjbShEccPgSi4w,2801
|
|
4
|
+
pipeloom/engine.py,sha256=kQKSWl7lkhpz876twLeycep905sijaZmZtgnuj23Z28,5652
|
|
5
|
+
pipeloom/messages.py,sha256=TYDhOYBs6TMjStvIYYaNsitwXhPatCVR1LHKQJ2MjAA,2071
|
|
6
|
+
pipeloom/progress.py,sha256=4HzPLMnvLm2WDUK3oBV9F1rPi4FgwsVqPc-btYRGCsU,2302
|
|
7
|
+
pipeloom/rlog.py,sha256=bQymOyvChTA_XQX80K6fE0vEtsQw5y5zDczlR1C1qBg,1690
|
|
8
|
+
pipeloom/writer.py,sha256=toDXDsInvNho0CU2ct8joUGQCpTFGCH4uAPi-RBKmHk,6676
|
|
9
|
+
pipeloom-0.0.1.dist-info/METADATA,sha256=C7e-X_qLVwUDvHlOZKu8JzutLA33h7VKGJqY_5pW3mU,12712
|
|
10
|
+
pipeloom-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
11
|
+
pipeloom-0.0.1.dist-info/entry_points.txt,sha256=KswZgdPH8r_naw3yTUhzyL9CLY8wow4LQ6mB_R_N_0U,46
|
|
12
|
+
pipeloom-0.0.1.dist-info/top_level.txt,sha256=FpG3qrEqzP846AnHvqAhvdeLosylZrsUJsi7KjtKmaM,9
|
|
13
|
+
pipeloom-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pipeloom
|