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/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)