flexlock 0.8.2__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.
- flexlock/__init__.py +87 -0
- flexlock/api.py +1444 -0
- flexlock/backends/__init__.py +11 -0
- flexlock/backends/base.py +41 -0
- flexlock/backends/pbs.py +219 -0
- flexlock/backends/slurm.py +333 -0
- flexlock/cli.py +953 -0
- flexlock/config.py +121 -0
- flexlock/context.py +7 -0
- flexlock/data_hash.py +310 -0
- flexlock/debug.py +473 -0
- flexlock/diff.py +253 -0
- flexlock/diff_cli.py +155 -0
- flexlock/exceptions.py +50 -0
- flexlock/export.py +134 -0
- flexlock/fingerprint.py +129 -0
- flexlock/flexcli.py +163 -0
- flexlock/freeze.py +291 -0
- flexlock/git_utils.py +196 -0
- flexlock/index.py +300 -0
- flexlock/load_stage.py +60 -0
- flexlock/mlflow.py +192 -0
- flexlock/parallel.py +378 -0
- flexlock/query.py +739 -0
- flexlock/report.py +78 -0
- flexlock/report_template.html +246 -0
- flexlock/resolvers.py +248 -0
- flexlock/run_cli.py +44 -0
- flexlock/run_record.py +173 -0
- flexlock/runner.py +571 -0
- flexlock/save_dir.py +232 -0
- flexlock/skills/flexlock-new-stage/SKILL.md +57 -0
- flexlock/skills/flexlock-report/SKILL.md +50 -0
- flexlock/skills/flexlock-run-and-watch/SKILL.md +60 -0
- flexlock/skills/flexlock-survey/SKILL.md +61 -0
- flexlock/snapshot.py +245 -0
- flexlock/status_cli.py +299 -0
- flexlock/taskdb.py +685 -0
- flexlock/utils.py +710 -0
- flexlock/worker.py +228 -0
- flexlock/worker_cli.py +143 -0
- flexlock-0.8.2.dist-info/METADATA +357 -0
- flexlock-0.8.2.dist-info/RECORD +45 -0
- flexlock-0.8.2.dist-info/WHEEL +4 -0
- flexlock-0.8.2.dist-info/entry_points.txt +7 -0
flexlock/taskdb.py
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
"""SQLite-based task database for FlexLock parallel execution."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import random
|
|
5
|
+
import sqlite3
|
|
6
|
+
import time
|
|
7
|
+
from omegaconf import OmegaConf, DictConfig, ListConfig
|
|
8
|
+
import threading
|
|
9
|
+
from loguru import logger
|
|
10
|
+
import yaml
|
|
11
|
+
import hashlib
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from typing import Any, List
|
|
14
|
+
|
|
15
|
+
from . import config as _config
|
|
16
|
+
|
|
17
|
+
_thread_local_conns = threading.local()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _tag_filter(tag):
|
|
21
|
+
"""Return (sql_fragment, params) for an optional tag scope.
|
|
22
|
+
|
|
23
|
+
tag=None -> no filter (matches all rows, including legacy NULL rows).
|
|
24
|
+
str -> single tag: AND tag = ?
|
|
25
|
+
list/tuple/set -> AND tag IN (?, ?, ...) — empty collection = no filter.
|
|
26
|
+
|
|
27
|
+
The returned fragment is prefixed with 'AND ' and is '' when tag is None.
|
|
28
|
+
"""
|
|
29
|
+
if tag is None:
|
|
30
|
+
return "", []
|
|
31
|
+
if isinstance(tag, (list, tuple, set)):
|
|
32
|
+
tags = list(tag)
|
|
33
|
+
if not tags:
|
|
34
|
+
return "", []
|
|
35
|
+
placeholders = ",".join("?" for _ in tags)
|
|
36
|
+
return f"AND tag IN ({placeholders})", tags
|
|
37
|
+
return "AND tag = ?", [tag]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _hash_task(task: Any) -> str:
|
|
41
|
+
"""Generate a stable SHA1 id for a task.
|
|
42
|
+
|
|
43
|
+
The id must be invariant across the ``_to_yaml``/``_from_yaml`` roundtrip
|
|
44
|
+
and across plain-dict vs ``DictConfig`` representations. ``str(task)`` is
|
|
45
|
+
neither: YAML serialization reorders keys, so a task claimed back from the
|
|
46
|
+
DB would hash differently than when it was queued — which silently breaks
|
|
47
|
+
``finish_task`` (it can't find the row to mark done). Canonicalize to a
|
|
48
|
+
sorted-key JSON string first.
|
|
49
|
+
"""
|
|
50
|
+
import json as _json
|
|
51
|
+
|
|
52
|
+
if isinstance(task, (DictConfig, ListConfig)):
|
|
53
|
+
obj = OmegaConf.to_container(task, resolve=False)
|
|
54
|
+
else:
|
|
55
|
+
obj = task
|
|
56
|
+
try:
|
|
57
|
+
canonical = _json.dumps(obj, sort_keys=True, default=str)
|
|
58
|
+
except TypeError:
|
|
59
|
+
canonical = str(obj)
|
|
60
|
+
return hashlib.sha1(canonical.encode()).hexdigest()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _to_yaml(value: Any) -> str:
|
|
64
|
+
"""Serialize any value to a YAML string without !! Python tags.
|
|
65
|
+
|
|
66
|
+
OmegaConf containers are serialized via OmegaConf.to_yaml (handles
|
|
67
|
+
interpolation correctly). Everything else — scalars, plain dicts/lists —
|
|
68
|
+
goes through yaml.safe_dump, which raises RepresenterError on arbitrary
|
|
69
|
+
Python objects instead of emitting !!python/object tags.
|
|
70
|
+
"""
|
|
71
|
+
if isinstance(value, (DictConfig, ListConfig)):
|
|
72
|
+
return OmegaConf.to_yaml(value)
|
|
73
|
+
return yaml.safe_dump(value, default_flow_style=False)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _from_yaml(text: str) -> Any:
|
|
77
|
+
"""Deserialize a YAML string back to a task or result value.
|
|
78
|
+
|
|
79
|
+
Mappings are wrapped in OmegaConf.create so the rest of the codebase
|
|
80
|
+
can treat them as DictConfig. Scalars and lists are returned as-is.
|
|
81
|
+
"""
|
|
82
|
+
obj = yaml.safe_load(text)
|
|
83
|
+
if isinstance(obj, dict):
|
|
84
|
+
return OmegaConf.create(obj)
|
|
85
|
+
return obj
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@contextmanager
|
|
89
|
+
def _conn(db_path: Path):
|
|
90
|
+
"""
|
|
91
|
+
A thread-safe context manager for SQLite database connections.
|
|
92
|
+
|
|
93
|
+
This function maintains a cache of connections per thread. A new connection
|
|
94
|
+
is created for each unique database path and reused for subsequent calls
|
|
95
|
+
with the same path within that thread.
|
|
96
|
+
"""
|
|
97
|
+
# Use the absolute path as a reliable key for the connections dictionary.
|
|
98
|
+
db_path_str = str(db_path.resolve())
|
|
99
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
# Initialize the connections dictionary for the current thread if it doesn't exist.
|
|
102
|
+
if not hasattr(_thread_local_conns, "conns"):
|
|
103
|
+
_thread_local_conns.conns = {}
|
|
104
|
+
|
|
105
|
+
# Check if a connection for this specific db_path already exists in the thread's cache.
|
|
106
|
+
if db_path_str not in _thread_local_conns.conns:
|
|
107
|
+
# If not, create a new connection and add it to the cache.
|
|
108
|
+
try:
|
|
109
|
+
# Autocommit mode: write transactions are managed explicitly by
|
|
110
|
+
# _write_txn (BEGIN IMMEDIATE), reads run without a transaction.
|
|
111
|
+
c = sqlite3.connect(
|
|
112
|
+
db_path_str, check_same_thread=False, isolation_level=None
|
|
113
|
+
)
|
|
114
|
+
# Set PRAGMA for better performance and concurrency.
|
|
115
|
+
c.execute("PRAGMA journal_mode=DELETE")
|
|
116
|
+
c.execute("PRAGMA busy_timeout=30000")
|
|
117
|
+
c.execute(
|
|
118
|
+
"PRAGMA foreign_keys=ON"
|
|
119
|
+
) # Good practice to enforce foreign key constraints
|
|
120
|
+
c.execute(
|
|
121
|
+
"""
|
|
122
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
123
|
+
task_id TEXT PRIMARY KEY,
|
|
124
|
+
task_info TEXT,
|
|
125
|
+
result_info TEXT,
|
|
126
|
+
status TEXT DEFAULT 'pending',
|
|
127
|
+
node TEXT,
|
|
128
|
+
error TEXT,
|
|
129
|
+
ts_start DATETIME,
|
|
130
|
+
ts_end DATETIME,
|
|
131
|
+
snapshot TEXT,
|
|
132
|
+
job_id TEXT,
|
|
133
|
+
tag TEXT
|
|
134
|
+
)
|
|
135
|
+
"""
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Auto-migration: add columns introduced after the initial schema.
|
|
139
|
+
cursor = c.execute("PRAGMA table_info(tasks)")
|
|
140
|
+
columns = [row[1] for row in cursor.fetchall()]
|
|
141
|
+
for col, ddl in [
|
|
142
|
+
("snapshot", "ALTER TABLE tasks ADD COLUMN snapshot TEXT"),
|
|
143
|
+
("job_id", "ALTER TABLE tasks ADD COLUMN job_id TEXT"),
|
|
144
|
+
("tag", "ALTER TABLE tasks ADD COLUMN tag TEXT"),
|
|
145
|
+
]:
|
|
146
|
+
if col not in columns:
|
|
147
|
+
logger.debug(f"Adding {col} column to {db_path_str}")
|
|
148
|
+
c.execute(ddl)
|
|
149
|
+
c.commit()
|
|
150
|
+
|
|
151
|
+
c.execute(
|
|
152
|
+
"CREATE INDEX IF NOT EXISTS ix_tasks_tag_status "
|
|
153
|
+
"ON tasks(tag, status)"
|
|
154
|
+
)
|
|
155
|
+
c.commit()
|
|
156
|
+
|
|
157
|
+
_thread_local_conns.conns[db_path_str] = c
|
|
158
|
+
logger.debug(
|
|
159
|
+
f"Created new connection for {db_path_str} in thread {threading.get_ident()}"
|
|
160
|
+
)
|
|
161
|
+
except sqlite3.Error as e:
|
|
162
|
+
logger.error(f"Error connecting to database {db_path_str}: {e}")
|
|
163
|
+
raise
|
|
164
|
+
|
|
165
|
+
# Yield the connection from the thread's cache.
|
|
166
|
+
# A try...finally block is not strictly necessary here because @contextmanager
|
|
167
|
+
# handles resource cleanup, but it makes the intent clear.
|
|
168
|
+
try:
|
|
169
|
+
yield _thread_local_conns.conns[db_path_str]
|
|
170
|
+
finally:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _write_txn(db_path: Path, op):
|
|
175
|
+
"""Run ``op(conn)`` in a BEGIN IMMEDIATE transaction, retrying on lock.
|
|
176
|
+
|
|
177
|
+
``busy_timeout`` alone is not enough with many workers on one DB: a
|
|
178
|
+
deferred transaction upgrading SHARED->RESERVED returns SQLITE_BUSY
|
|
179
|
+
immediately (bypassing the timeout), and fcntl locks on network
|
|
180
|
+
filesystems (NFS/Lustre) can fail spuriously. BEGIN IMMEDIATE takes the
|
|
181
|
+
write lock up front, and this loop adds jittered exponential backoff on
|
|
182
|
+
top of busy_timeout. Retrying re-runs ``op`` from scratch, so ``op``
|
|
183
|
+
must be a pure function of its statements (no external side effects).
|
|
184
|
+
"""
|
|
185
|
+
delay = 0.05
|
|
186
|
+
attempts = max(1, _config.DB_RETRY_ATTEMPTS)
|
|
187
|
+
for attempt in range(attempts):
|
|
188
|
+
try:
|
|
189
|
+
with _conn(db_path) as c:
|
|
190
|
+
try:
|
|
191
|
+
c.execute("BEGIN IMMEDIATE")
|
|
192
|
+
out = op(c)
|
|
193
|
+
c.commit()
|
|
194
|
+
return out
|
|
195
|
+
except BaseException:
|
|
196
|
+
c.rollback()
|
|
197
|
+
raise
|
|
198
|
+
except sqlite3.OperationalError as e:
|
|
199
|
+
msg = str(e).lower()
|
|
200
|
+
if "locked" not in msg and "busy" not in msg:
|
|
201
|
+
raise
|
|
202
|
+
if attempt == attempts - 1:
|
|
203
|
+
raise
|
|
204
|
+
sleep = random.uniform(delay, 2 * delay)
|
|
205
|
+
logger.debug(
|
|
206
|
+
f"{db_path} locked ({e}); retry {attempt + 1}/{attempts} "
|
|
207
|
+
f"in {sleep:.2f}s"
|
|
208
|
+
)
|
|
209
|
+
time.sleep(sleep)
|
|
210
|
+
delay = min(delay * 2, _config.DB_RETRY_MAX_BACKOFF)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def queue_tasks(db_path: Path, tasks: List[Any], tag: str | None = None) -> None:
|
|
214
|
+
"""Adds a list of tasks to the database if they don't already exist.
|
|
215
|
+
|
|
216
|
+
``tag`` scopes these rows to a particular sweep so concurrent sweeps (or a
|
|
217
|
+
parent pipeline job) sharing the same DB don't interfere with each other.
|
|
218
|
+
|
|
219
|
+
Note: ``task_id`` is a content hash of the task dict. If two sweeps queue
|
|
220
|
+
an *identical* task dict they share one row; ``INSERT OR IGNORE`` keeps the
|
|
221
|
+
first tag. That only happens when the work is truly identical, which is
|
|
222
|
+
acceptable.
|
|
223
|
+
"""
|
|
224
|
+
rows = [(_hash_task(t), _to_yaml(t), tag) for t in tasks]
|
|
225
|
+
|
|
226
|
+
def op(c):
|
|
227
|
+
c.executemany(
|
|
228
|
+
"INSERT OR IGNORE INTO tasks (task_id, task_info, tag) VALUES (?, ?, ?)",
|
|
229
|
+
rows,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
_write_txn(db_path, op)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def claim_next_tasks(
|
|
236
|
+
db_path: Path,
|
|
237
|
+
node: str,
|
|
238
|
+
job_id: str | None = None,
|
|
239
|
+
tags=None,
|
|
240
|
+
n: int = 1,
|
|
241
|
+
) -> List[Any]:
|
|
242
|
+
"""Claims up to ``n`` pending tasks in one write transaction.
|
|
243
|
+
|
|
244
|
+
Batching is the main lever against DB contention at scale: with many
|
|
245
|
+
workers, one claim transaction per batch instead of per task divides
|
|
246
|
+
the write-lock traffic by ``n``. Returns the claimed tasks (possibly
|
|
247
|
+
fewer than ``n``, empty when nothing is pending).
|
|
248
|
+
|
|
249
|
+
``node`` and ``job_id`` record which worker/scheduler job owns the tasks,
|
|
250
|
+
so the controller can later reconcile orphaned tasks against the OS/
|
|
251
|
+
scheduler's view of worker liveness (no self-reported heartbeat needed).
|
|
252
|
+
|
|
253
|
+
``tags`` restricts the claim to rows whose ``tag`` matches (``None``
|
|
254
|
+
claims any row, preserving backwards-compatible behaviour for untagged DBs).
|
|
255
|
+
"""
|
|
256
|
+
tag_frag, tag_params = _tag_filter(tags)
|
|
257
|
+
|
|
258
|
+
def op(c):
|
|
259
|
+
cur = c.execute(
|
|
260
|
+
f"""
|
|
261
|
+
UPDATE tasks
|
|
262
|
+
SET status='running',
|
|
263
|
+
node=?,
|
|
264
|
+
job_id=?,
|
|
265
|
+
ts_start=CURRENT_TIMESTAMP
|
|
266
|
+
WHERE task_id IN (
|
|
267
|
+
SELECT task_id FROM tasks
|
|
268
|
+
WHERE status='pending' {tag_frag}
|
|
269
|
+
LIMIT ?
|
|
270
|
+
)
|
|
271
|
+
RETURNING task_info
|
|
272
|
+
""",
|
|
273
|
+
(node, job_id, *tag_params, n),
|
|
274
|
+
)
|
|
275
|
+
return [r[0] for r in cur.fetchall()]
|
|
276
|
+
|
|
277
|
+
return [_from_yaml(r) for r in _write_txn(db_path, op)]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def claim_next_task(
|
|
281
|
+
db_path: Path,
|
|
282
|
+
node: str,
|
|
283
|
+
job_id: str | None = None,
|
|
284
|
+
tags=None,
|
|
285
|
+
) -> Any | None:
|
|
286
|
+
"""Claims the next available pending task and marks it as running.
|
|
287
|
+
|
|
288
|
+
Single-task convenience wrapper over ``claim_next_tasks``.
|
|
289
|
+
"""
|
|
290
|
+
tasks = claim_next_tasks(db_path, node, job_id=job_id, tags=tags, n=1)
|
|
291
|
+
return tasks[0] if tasks else None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def finish_tasks(db_path: Path, entries: List[dict]) -> None:
|
|
295
|
+
"""Marks a batch of tasks finished in one write transaction.
|
|
296
|
+
|
|
297
|
+
Each entry is a dict with key ``task`` (required) and optional ``error``,
|
|
298
|
+
``result``, ``status`` — same semantics as ``finish_task``. Workers
|
|
299
|
+
buffer per-task outcomes and flush them here once per claimed batch.
|
|
300
|
+
"""
|
|
301
|
+
rows = []
|
|
302
|
+
for e in entries:
|
|
303
|
+
error = e.get("error")
|
|
304
|
+
status = e.get("status") or ("failed" if error else "done")
|
|
305
|
+
result = e.get("result")
|
|
306
|
+
rows.append(
|
|
307
|
+
(
|
|
308
|
+
status,
|
|
309
|
+
error,
|
|
310
|
+
_to_yaml(result) if result is not None else None,
|
|
311
|
+
_hash_task(e["task"]),
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
if not rows:
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
def op(c):
|
|
318
|
+
c.executemany(
|
|
319
|
+
"UPDATE tasks SET status=?, error=?, result_info=?, ts_end=CURRENT_TIMESTAMP WHERE task_id=?",
|
|
320
|
+
rows,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
_write_txn(db_path, op)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def finish_task(
|
|
327
|
+
db_path: Path,
|
|
328
|
+
task: Any,
|
|
329
|
+
error: str | None = None,
|
|
330
|
+
result: Any | None = None,
|
|
331
|
+
status: str | None = None,
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Marks a task as finished and records its result or error.
|
|
334
|
+
|
|
335
|
+
``status`` defaults to ``'done'`` on success, ``'failed'`` on error.
|
|
336
|
+
Pass ``status='interrupted'`` when the worker was killed mid-task.
|
|
337
|
+
"""
|
|
338
|
+
finish_tasks(db_path, [dict(task=task, error=error, result=result, status=status)])
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _update_running(
|
|
342
|
+
db_path: Path,
|
|
343
|
+
new_status: str,
|
|
344
|
+
job_id: str | None = None,
|
|
345
|
+
tags=None,
|
|
346
|
+
error: str | None = None,
|
|
347
|
+
) -> int:
|
|
348
|
+
"""Move ``running`` tasks to ``new_status``; optionally scope to job/tags.
|
|
349
|
+
|
|
350
|
+
Returns the number of rows updated. Shared by the controller (which marks
|
|
351
|
+
orphans 'interrupted') and the reclaim path (which resets them 'pending').
|
|
352
|
+
"""
|
|
353
|
+
tag_frag, tag_params = _tag_filter(tags)
|
|
354
|
+
clauses = [f"status='running' {tag_frag}"]
|
|
355
|
+
params: list = list(tag_params)
|
|
356
|
+
if job_id is not None:
|
|
357
|
+
clauses.append("job_id=?")
|
|
358
|
+
params.append(job_id)
|
|
359
|
+
where = " AND ".join(c for c in clauses if c)
|
|
360
|
+
|
|
361
|
+
if new_status == "pending":
|
|
362
|
+
# Clear ownership so the task is freely claimable again.
|
|
363
|
+
set_clause = "status='pending', node=NULL, job_id=NULL, ts_start=NULL"
|
|
364
|
+
set_params: list = []
|
|
365
|
+
else:
|
|
366
|
+
set_clause = "status=?, error=?, ts_end=CURRENT_TIMESTAMP"
|
|
367
|
+
set_params = [new_status, error]
|
|
368
|
+
|
|
369
|
+
def op(c):
|
|
370
|
+
cur = c.execute(
|
|
371
|
+
f"UPDATE tasks SET {set_clause} WHERE {where}",
|
|
372
|
+
(*set_params, *params),
|
|
373
|
+
)
|
|
374
|
+
return cur.rowcount
|
|
375
|
+
|
|
376
|
+
return _write_txn(db_path, op)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def mark_orphans_interrupted(
|
|
380
|
+
db_path: Path, job_id: str | None = None, tags=None
|
|
381
|
+
) -> int:
|
|
382
|
+
"""Mark still-``running`` tasks as ``interrupted``.
|
|
383
|
+
|
|
384
|
+
Called by the controller once it knows the owning worker(s) are dead
|
|
385
|
+
(local processes joined, or the HPC job left the scheduler's active set).
|
|
386
|
+
``tags`` limits the reconcile to this sweep's rows so a parent pipeline
|
|
387
|
+
row in the same DB is never touched. Returns the number of tasks marked.
|
|
388
|
+
"""
|
|
389
|
+
return _update_running(
|
|
390
|
+
db_path, "interrupted", job_id=job_id, tags=tags,
|
|
391
|
+
error="Worker died before the task finished (orphaned).",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def reclaim_running(db_path: Path, job_id: str | None = None, tags=None) -> int:
|
|
396
|
+
"""Reset ``running`` tasks back to ``pending`` so they can be re-claimed.
|
|
397
|
+
|
|
398
|
+
Used by ``flexlock-worker --reclaim`` to recover tasks stranded by a
|
|
399
|
+
previous worker that died without cleanup. ``tags`` limits the reclaim
|
|
400
|
+
to the specified sweep. Returns the number reset.
|
|
401
|
+
"""
|
|
402
|
+
return _update_running(db_path, "pending", job_id=job_id, tags=tags)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def pending_count(db_path: Path, tags=None) -> int:
|
|
406
|
+
"""Returns the number of pending tasks in the database.
|
|
407
|
+
|
|
408
|
+
``tags`` restricts the count to rows matching the given tag(s).
|
|
409
|
+
"""
|
|
410
|
+
tag_frag, tag_params = _tag_filter(tags)
|
|
411
|
+
with _conn(db_path) as c:
|
|
412
|
+
return c.execute(
|
|
413
|
+
f"SELECT COUNT(*) FROM tasks WHERE status='pending' {tag_frag}",
|
|
414
|
+
tag_params,
|
|
415
|
+
).fetchone()[0]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def dump_to_yaml(db_path: Path, yaml_path: Path, tags=None) -> None:
|
|
419
|
+
"""Dumps all terminal (done/failed/interrupted) tasks and their results to a YAML file.
|
|
420
|
+
|
|
421
|
+
``tags`` restricts the dump to the specified sweep's rows so each sweep
|
|
422
|
+
writes only its own results.
|
|
423
|
+
"""
|
|
424
|
+
tag_frag, tag_params = _tag_filter(tags)
|
|
425
|
+
with _conn(db_path) as c:
|
|
426
|
+
logger.debug(f"using {c} for {db_path}")
|
|
427
|
+
rows = c.execute(
|
|
428
|
+
f"SELECT result_info, task_info, status FROM tasks "
|
|
429
|
+
f"WHERE status IN ('done','failed','interrupted') {tag_frag} "
|
|
430
|
+
f"ORDER BY ts_end",
|
|
431
|
+
tag_params,
|
|
432
|
+
).fetchall()
|
|
433
|
+
data = [
|
|
434
|
+
dict(task=_from_yaml(r[0]), status=r[2])
|
|
435
|
+
if r[0]
|
|
436
|
+
else dict(task=_from_yaml(r[1]), status=r[2])
|
|
437
|
+
for r in rows
|
|
438
|
+
if r[0] or r[1]
|
|
439
|
+
]
|
|
440
|
+
logger.debug(f"dumping {rows} to {yaml_path}")
|
|
441
|
+
|
|
442
|
+
_atomic_write_yaml(data, yaml_path)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def update_task_snapshot(db_path: Path, task_id: str, snapshot_data: dict) -> None:
|
|
446
|
+
"""
|
|
447
|
+
Updates the snapshot column for a specific task.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
db_path: Path to SQLite database
|
|
451
|
+
task_id: Hash of the task (from _hash_task)
|
|
452
|
+
snapshot_data: Complete snapshot dictionary
|
|
453
|
+
"""
|
|
454
|
+
import json
|
|
455
|
+
|
|
456
|
+
payload = json.dumps(snapshot_data)
|
|
457
|
+
|
|
458
|
+
def op(c):
|
|
459
|
+
c.execute(
|
|
460
|
+
"UPDATE tasks SET snapshot=? WHERE task_id=?",
|
|
461
|
+
(payload, task_id),
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
_write_txn(db_path, op)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def get_task_snapshot(db_path: Path, task_id: str) -> dict | None:
|
|
468
|
+
"""
|
|
469
|
+
Retrieves the snapshot for a specific task from the database.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
db_path: Path to SQLite database
|
|
473
|
+
task_id: Hash of the task (from _hash_task)
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
dict: Snapshot data, or None if not found
|
|
477
|
+
"""
|
|
478
|
+
import json
|
|
479
|
+
|
|
480
|
+
with _conn(db_path) as c:
|
|
481
|
+
cur = c.execute("SELECT snapshot FROM tasks WHERE task_id=?", (task_id,))
|
|
482
|
+
row = cur.fetchone()
|
|
483
|
+
if row and row[0]:
|
|
484
|
+
return json.loads(row[0])
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def get_task_status(db_path: Path, task_id: str) -> dict | None:
|
|
489
|
+
"""Return a single task's status row, or ``None`` if not found.
|
|
490
|
+
|
|
491
|
+
Thin SELECT used by the query layer to classify a sweep-task dir from its
|
|
492
|
+
``.flexlock_marker`` without loading the whole DB.
|
|
493
|
+
"""
|
|
494
|
+
with _conn(db_path) as c:
|
|
495
|
+
cur = c.execute(
|
|
496
|
+
"SELECT status, error, node, ts_start, ts_end FROM tasks WHERE task_id=?",
|
|
497
|
+
(task_id,),
|
|
498
|
+
)
|
|
499
|
+
row = cur.fetchone()
|
|
500
|
+
if row is None:
|
|
501
|
+
return None
|
|
502
|
+
return {
|
|
503
|
+
"status": row[0],
|
|
504
|
+
"error": row[1],
|
|
505
|
+
"node": row[2],
|
|
506
|
+
"ts_start": row[3],
|
|
507
|
+
"ts_end": row[4],
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def list_task_snapshots(db_path: Path, status: str = None) -> List[tuple]:
|
|
512
|
+
"""
|
|
513
|
+
Lists all tasks with their snapshots.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
db_path: Path to SQLite database
|
|
517
|
+
status: Optional filter by status (pending, running, done, failed)
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
List of tuples: (task_id, snapshot_dict, status)
|
|
521
|
+
"""
|
|
522
|
+
import json
|
|
523
|
+
|
|
524
|
+
with _conn(db_path) as c:
|
|
525
|
+
if status:
|
|
526
|
+
cur = c.execute(
|
|
527
|
+
"SELECT task_id, snapshot, status FROM tasks WHERE status=? AND snapshot IS NOT NULL",
|
|
528
|
+
(status,),
|
|
529
|
+
)
|
|
530
|
+
else:
|
|
531
|
+
cur = c.execute(
|
|
532
|
+
"SELECT task_id, snapshot, status FROM tasks WHERE snapshot IS NOT NULL"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
rows = cur.fetchall()
|
|
536
|
+
return [(r[0], json.loads(r[1]) if r[1] else None, r[2]) for r in rows]
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def get_status_counts(db_path: Path, tags=None) -> dict:
|
|
540
|
+
"""Get counts of tasks by status.
|
|
541
|
+
|
|
542
|
+
Returns a dict with keys for every status that has at least one task,
|
|
543
|
+
always including 'pending', 'running', 'done', 'failed', 'interrupted'
|
|
544
|
+
(defaulting to 0 when absent).
|
|
545
|
+
|
|
546
|
+
``tags`` restricts the counts to the specified sweep's rows.
|
|
547
|
+
"""
|
|
548
|
+
tag_frag, tag_params = _tag_filter(tags)
|
|
549
|
+
with _conn(db_path) as c:
|
|
550
|
+
rows = c.execute(
|
|
551
|
+
f"SELECT status, COUNT(*) as count FROM tasks "
|
|
552
|
+
f"WHERE 1=1 {tag_frag} GROUP BY status",
|
|
553
|
+
tag_params,
|
|
554
|
+
).fetchall()
|
|
555
|
+
counts = {row[0]: row[1] for row in rows}
|
|
556
|
+
for key in ("pending", "running", "done", "failed", "interrupted"):
|
|
557
|
+
counts.setdefault(key, 0)
|
|
558
|
+
return counts
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def get_status_counts_by_tag(db_path: Path) -> dict:
|
|
562
|
+
"""Get per-tag status counts for all rows in the DB.
|
|
563
|
+
|
|
564
|
+
Returns ``{tag: {status: count, ...}}`` where ``tag`` may be ``None``
|
|
565
|
+
for legacy untagged rows. Each inner dict is zero-filled for the five
|
|
566
|
+
standard statuses.
|
|
567
|
+
"""
|
|
568
|
+
with _conn(db_path) as c:
|
|
569
|
+
rows = c.execute(
|
|
570
|
+
"SELECT tag, status, COUNT(*) FROM tasks GROUP BY tag, status"
|
|
571
|
+
).fetchall()
|
|
572
|
+
result: dict = {}
|
|
573
|
+
for tag, status, count in rows:
|
|
574
|
+
bucket = result.setdefault(tag, {})
|
|
575
|
+
bucket[status] = count
|
|
576
|
+
for bucket in result.values():
|
|
577
|
+
for key in ("pending", "running", "done", "failed", "interrupted"):
|
|
578
|
+
bucket.setdefault(key, 0)
|
|
579
|
+
return result
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def get_failed_tasks(db_path: Path, tags=None) -> list:
|
|
583
|
+
"""Get details of failed and interrupted tasks.
|
|
584
|
+
|
|
585
|
+
``tags`` restricts results to the specified sweep's rows.
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
list: List of dicts with task info, error, and timestamps
|
|
589
|
+
"""
|
|
590
|
+
tag_frag, tag_params = _tag_filter(tags)
|
|
591
|
+
with _conn(db_path) as c:
|
|
592
|
+
rows = c.execute(
|
|
593
|
+
f"""
|
|
594
|
+
SELECT task_info, error, ts_start, ts_end, node, status
|
|
595
|
+
FROM tasks WHERE status IN ('failed', 'interrupted') {tag_frag}
|
|
596
|
+
ORDER BY ts_end DESC
|
|
597
|
+
""",
|
|
598
|
+
tag_params,
|
|
599
|
+
).fetchall()
|
|
600
|
+
|
|
601
|
+
failed_tasks = []
|
|
602
|
+
for row in rows:
|
|
603
|
+
task_info = _from_yaml(row[0]) if row[0] else {}
|
|
604
|
+
failed_tasks.append(
|
|
605
|
+
{
|
|
606
|
+
"task": task_info,
|
|
607
|
+
"error": row[1],
|
|
608
|
+
"ts_start": row[2],
|
|
609
|
+
"ts_end": row[3],
|
|
610
|
+
"node": row[4],
|
|
611
|
+
"status": row[5],
|
|
612
|
+
}
|
|
613
|
+
)
|
|
614
|
+
return failed_tasks
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def get_all_tasks(db_path: Path, status: str = None, tags=None) -> list:
|
|
618
|
+
"""Get all tasks, optionally filtered by status and/or tag.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
db_path: Path to database
|
|
622
|
+
status: Optional status filter ('pending', 'running', 'done', 'failed', 'interrupted')
|
|
623
|
+
tags: Optional tag filter (str or list of str); ``None`` returns all rows.
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
list: List of dicts with task details
|
|
627
|
+
"""
|
|
628
|
+
tag_frag, tag_params = _tag_filter(tags)
|
|
629
|
+
with _conn(db_path) as c:
|
|
630
|
+
if status:
|
|
631
|
+
rows = c.execute(
|
|
632
|
+
f"""
|
|
633
|
+
SELECT task_id, task_info, result_info, status, error,
|
|
634
|
+
ts_start, ts_end, node
|
|
635
|
+
FROM tasks WHERE status=? {tag_frag}
|
|
636
|
+
ORDER BY ts_start DESC
|
|
637
|
+
""",
|
|
638
|
+
(status, *tag_params),
|
|
639
|
+
).fetchall()
|
|
640
|
+
else:
|
|
641
|
+
rows = c.execute(
|
|
642
|
+
f"""
|
|
643
|
+
SELECT task_id, task_info, result_info, status, error,
|
|
644
|
+
ts_start, ts_end, node
|
|
645
|
+
FROM tasks WHERE 1=1 {tag_frag}
|
|
646
|
+
ORDER BY ts_start DESC
|
|
647
|
+
""",
|
|
648
|
+
tag_params,
|
|
649
|
+
).fetchall()
|
|
650
|
+
|
|
651
|
+
tasks = []
|
|
652
|
+
for row in rows:
|
|
653
|
+
task_info = _from_yaml(row[1]) if row[1] else {}
|
|
654
|
+
result_info = _from_yaml(row[2]) if row[2] else {}
|
|
655
|
+
tasks.append(
|
|
656
|
+
{
|
|
657
|
+
"task_id": row[0],
|
|
658
|
+
"task": task_info,
|
|
659
|
+
"result": result_info,
|
|
660
|
+
"status": row[3],
|
|
661
|
+
"error": row[4],
|
|
662
|
+
"ts_start": row[5],
|
|
663
|
+
"ts_end": row[6],
|
|
664
|
+
"node": row[7],
|
|
665
|
+
}
|
|
666
|
+
)
|
|
667
|
+
return tasks
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _atomic_write_yaml(data: list, path: Path):
|
|
671
|
+
import tempfile, os
|
|
672
|
+
|
|
673
|
+
def _to_primitive(v):
|
|
674
|
+
if isinstance(v, (DictConfig, ListConfig)):
|
|
675
|
+
return OmegaConf.to_container(v, resolve=True)
|
|
676
|
+
if isinstance(v, dict):
|
|
677
|
+
return {k: _to_primitive(val) for k, val in v.items()}
|
|
678
|
+
if isinstance(v, list):
|
|
679
|
+
return [_to_primitive(i) for i in v]
|
|
680
|
+
return v
|
|
681
|
+
|
|
682
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.tmp-")
|
|
683
|
+
with os.fdopen(fd, "w") as f:
|
|
684
|
+
yaml.safe_dump(_to_primitive(data), f, default_flow_style=False)
|
|
685
|
+
os.rename(tmp, path)
|