mundane-sdk 0.0.2__tar.gz
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.
- mundane_sdk-0.0.2/LICENSE +21 -0
- mundane_sdk-0.0.2/PKG-INFO +35 -0
- mundane_sdk-0.0.2/README.md +22 -0
- mundane_sdk-0.0.2/mundane/__init__.py +26 -0
- mundane_sdk-0.0.2/mundane/_duration.py +38 -0
- mundane_sdk-0.0.2/mundane/_lock.py +48 -0
- mundane_sdk-0.0.2/mundane/_schema.py +44 -0
- mundane_sdk-0.0.2/mundane/core.py +437 -0
- mundane_sdk-0.0.2/mundane_sdk.egg-info/PKG-INFO +35 -0
- mundane_sdk-0.0.2/mundane_sdk.egg-info/SOURCES.txt +13 -0
- mundane_sdk-0.0.2/mundane_sdk.egg-info/dependency_links.txt +1 -0
- mundane_sdk-0.0.2/mundane_sdk.egg-info/top_level.txt +1 -0
- mundane_sdk-0.0.2/pyproject.toml +21 -0
- mundane_sdk-0.0.2/setup.cfg +4 -0
- mundane_sdk-0.0.2/tests/test_basic.py +303 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Bellamy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mundane-sdk
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Tiny durable-execution: one workflow run is one SQLite file.
|
|
5
|
+
Author: Paul Bellamy
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/paulbellamy/mundane
|
|
8
|
+
Project-URL: Repository, https://github.com/paulbellamy/mundane
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# mundane (Python)
|
|
15
|
+
|
|
16
|
+
See [`../SPEC.md`](../SPEC.md) for the cross-runtime contract.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import mundane
|
|
20
|
+
|
|
21
|
+
def workflow(ctx):
|
|
22
|
+
user = ctx.step("fetch", lambda: {"name": "alice"})
|
|
23
|
+
ctx.sleep("cool-off", "100ms")
|
|
24
|
+
ctx.step("notify", lambda: f"hi {user['name']}")
|
|
25
|
+
|
|
26
|
+
mundane.run("task.db", workflow)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
An async variant is available: `await mundane.arun(path, async_workflow)`
|
|
30
|
+
with `await ctx.astep(...)` / `await ctx.asleep(...)`.
|
|
31
|
+
|
|
32
|
+
## Implementation notes
|
|
33
|
+
|
|
34
|
+
- **Stdlib only.** No third-party dependencies — `sqlite3`, `fcntl`
|
|
35
|
+
(for `flock`), `json`, and `uuid` from the standard library.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# mundane (Python)
|
|
2
|
+
|
|
3
|
+
See [`../SPEC.md`](../SPEC.md) for the cross-runtime contract.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import mundane
|
|
7
|
+
|
|
8
|
+
def workflow(ctx):
|
|
9
|
+
user = ctx.step("fetch", lambda: {"name": "alice"})
|
|
10
|
+
ctx.sleep("cool-off", "100ms")
|
|
11
|
+
ctx.step("notify", lambda: f"hi {user['name']}")
|
|
12
|
+
|
|
13
|
+
mundane.run("task.db", workflow)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
An async variant is available: `await mundane.arun(path, async_workflow)`
|
|
17
|
+
with `await ctx.astep(...)` / `await ctx.asleep(...)`.
|
|
18
|
+
|
|
19
|
+
## Implementation notes
|
|
20
|
+
|
|
21
|
+
- **Stdlib only.** No third-party dependencies — `sqlite3`, `fcntl`
|
|
22
|
+
(for `flock`), `json`, and `uuid` from the standard library.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""mundane — tiny durable-execution library.
|
|
2
|
+
|
|
3
|
+
One workflow run is one SQLite file. Crash, re-invoke, resume.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .core import (
|
|
7
|
+
DuplicateStepError,
|
|
8
|
+
LockedError,
|
|
9
|
+
SchemaError,
|
|
10
|
+
SerializationError,
|
|
11
|
+
StepFailedError,
|
|
12
|
+
arun,
|
|
13
|
+
run,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"run",
|
|
18
|
+
"arun",
|
|
19
|
+
"LockedError",
|
|
20
|
+
"SerializationError",
|
|
21
|
+
"SchemaError",
|
|
22
|
+
"StepFailedError",
|
|
23
|
+
"DuplicateStepError",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
__version__ = "0.0.2"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Parse human-friendly duration strings.
|
|
2
|
+
|
|
3
|
+
Accepts strings like "30s", "5m", "2h", "1d", "500ms", or ints/floats in
|
|
4
|
+
seconds (matching neither TS nor JS conventions strictly — the TS API
|
|
5
|
+
documents number-of-milliseconds, the Python API documents number-of-seconds).
|
|
6
|
+
For consistency with the TS interface, we accept both: strings and numbers
|
|
7
|
+
where numbers are treated as milliseconds when called from the public API
|
|
8
|
+
(see core.py).
|
|
9
|
+
|
|
10
|
+
This module only parses strings and returns milliseconds.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
_UNIT_MS = {
|
|
16
|
+
"ms": 1,
|
|
17
|
+
"s": 1000,
|
|
18
|
+
"m": 60 * 1000,
|
|
19
|
+
"h": 60 * 60 * 1000,
|
|
20
|
+
"d": 24 * 60 * 60 * 1000,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Matches: "5m", "1h", "2.5s", "500ms". Allow integer or decimal magnitudes.
|
|
24
|
+
_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)(ms|s|m|h|d)\s*$", re.IGNORECASE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_duration_ms(s: str) -> int:
|
|
28
|
+
"""Parse a duration string into integer milliseconds."""
|
|
29
|
+
if not isinstance(s, str):
|
|
30
|
+
raise TypeError(f"duration must be a string, got {type(s).__name__}")
|
|
31
|
+
m = _RE.match(s)
|
|
32
|
+
if not m:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"invalid duration {s!r}: expected e.g. '500ms', '30s', '5m', '2h', '1d'"
|
|
35
|
+
)
|
|
36
|
+
magnitude = float(m.group(1))
|
|
37
|
+
unit = m.group(2).lower()
|
|
38
|
+
return int(round(magnitude * _UNIT_MS[unit]))
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Exclusive file locking via flock(2).
|
|
2
|
+
|
|
3
|
+
Per spec section 3: the runtime acquires flock(LOCK_EX | LOCK_NB) on the
|
|
4
|
+
SQLite file's fd. On failure, fail-fast with exit code 75 / LockedError.
|
|
5
|
+
|
|
6
|
+
Note: flock(2) and POSIX record locks (used internally by SQLite) live in
|
|
7
|
+
separate lock-spaces on Linux, so an flock on the DB file does not interfere
|
|
8
|
+
with SQLite's own locking.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import fcntl
|
|
13
|
+
import os
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FileLock:
|
|
18
|
+
"""An exclusive non-blocking flock held for the lifetime of the object."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, path: str):
|
|
21
|
+
self.path = path
|
|
22
|
+
self._fd: Optional[int] = None
|
|
23
|
+
|
|
24
|
+
def acquire(self) -> None:
|
|
25
|
+
# Open for read+write, creating if missing. SQLite will also open it
|
|
26
|
+
# later; that's fine.
|
|
27
|
+
fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
28
|
+
try:
|
|
29
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
30
|
+
except BlockingIOError:
|
|
31
|
+
os.close(fd)
|
|
32
|
+
raise
|
|
33
|
+
self._fd = fd
|
|
34
|
+
|
|
35
|
+
def release(self) -> None:
|
|
36
|
+
if self._fd is not None:
|
|
37
|
+
with contextlib.suppress(OSError):
|
|
38
|
+
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
|
39
|
+
with contextlib.suppress(OSError):
|
|
40
|
+
os.close(self._fd)
|
|
41
|
+
self._fd = None
|
|
42
|
+
|
|
43
|
+
def __enter__(self) -> "FileLock":
|
|
44
|
+
self.acquire()
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
48
|
+
self.release()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Schema definition shared across mundane runtimes.
|
|
2
|
+
|
|
3
|
+
The schema is pinned to v1. Opening a file whose meta.schema_version is
|
|
4
|
+
not '1' is a hard error.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
SCHEMA_VERSION = "1"
|
|
10
|
+
|
|
11
|
+
# Name regex per spec section 5: ^[A-Za-z0-9][A-Za-z0-9._-]*$
|
|
12
|
+
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]*$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
BOOTSTRAP_SQL = """
|
|
16
|
+
PRAGMA journal_mode = DELETE;
|
|
17
|
+
|
|
18
|
+
CREATE TABLE IF NOT EXISTS mundane_meta (
|
|
19
|
+
key TEXT PRIMARY KEY,
|
|
20
|
+
value TEXT NOT NULL
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS mundane_steps (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
name TEXT NOT NULL,
|
|
26
|
+
kind TEXT NOT NULL,
|
|
27
|
+
encoding TEXT NOT NULL,
|
|
28
|
+
result BLOB,
|
|
29
|
+
status TEXT NOT NULL,
|
|
30
|
+
error TEXT,
|
|
31
|
+
started_at TEXT NOT NULL,
|
|
32
|
+
finished_at TEXT,
|
|
33
|
+
UNIQUE(name)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE INDEX IF NOT EXISTS mundane_steps_status ON mundane_steps(status);
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_name(name: str) -> None:
|
|
41
|
+
if not isinstance(name, str) or not NAME_RE.match(name):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"invalid step name {name!r}: must match {NAME_RE.pattern}"
|
|
44
|
+
)
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Core implementation: run, ctx.step, ctx.sleep."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sqlite3
|
|
8
|
+
import time
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, Awaitable, Callable, Dict, Optional, Set, Union
|
|
14
|
+
|
|
15
|
+
from ._duration import parse_duration_ms
|
|
16
|
+
from ._lock import FileLock
|
|
17
|
+
from ._schema import BOOTSTRAP_SQL, SCHEMA_VERSION, validate_name
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LockedError(Exception):
|
|
21
|
+
"""Raised when the SQLite file is locked by another live process."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SerializationError(Exception):
|
|
25
|
+
"""Raised when a step's return value doesn't survive a JSON round-trip."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SchemaError(Exception):
|
|
29
|
+
"""Raised when meta.schema_version doesn't equal 1."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DuplicateStepError(Exception):
|
|
33
|
+
"""Raised when the same step name is used twice in one task body."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, name: str):
|
|
36
|
+
super().__init__(f"duplicate step name: {name}")
|
|
37
|
+
self.name = name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StepFailedError(Exception):
|
|
41
|
+
"""Raised when a step function raises. Wraps the underlying error."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, name: str, original: BaseException):
|
|
44
|
+
super().__init__(f"step {name!r} failed: {original!r}")
|
|
45
|
+
self.name = name
|
|
46
|
+
self.original = original
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _iso_now() -> str:
|
|
50
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _now_ms() -> int:
|
|
54
|
+
return int(time.time() * 1000)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _duration_to_ms(duration: Union[str, int, float]) -> int:
|
|
58
|
+
if isinstance(duration, str):
|
|
59
|
+
return parse_duration_ms(duration)
|
|
60
|
+
if isinstance(duration, (int, float)):
|
|
61
|
+
return int(duration)
|
|
62
|
+
raise TypeError(f"duration must be str or number, got {type(duration).__name__}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _check_json_roundtrip(value: Any) -> str:
|
|
66
|
+
"""Validate that value survives JSON.dumps -> JSON.loads -> deep equal.
|
|
67
|
+
|
|
68
|
+
Per spec section 7: catches Date, undefined, BigInt, Map, Set, functions,
|
|
69
|
+
and circular refs. Returns the JSON text on success.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
text = json.dumps(value, allow_nan=False)
|
|
73
|
+
except (TypeError, ValueError) as e:
|
|
74
|
+
raise SerializationError(str(e)) from None
|
|
75
|
+
|
|
76
|
+
decoded = json.loads(text)
|
|
77
|
+
if not _deep_equal(value, decoded):
|
|
78
|
+
raise SerializationError(
|
|
79
|
+
"value does not round-trip through JSON "
|
|
80
|
+
"(possibly contains tuples, sets, dates, or other non-JSON types)"
|
|
81
|
+
)
|
|
82
|
+
return text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _deep_equal(a: Any, b: Any) -> bool:
|
|
86
|
+
"""Strict structural equality across JSON-shaped values."""
|
|
87
|
+
if type(a) is not type(b):
|
|
88
|
+
# Allow int/float boundary if numerically equal? No — strict, since
|
|
89
|
+
# JSON.dumps preserves int vs float in Python (json emits 1 vs 1.0).
|
|
90
|
+
return False
|
|
91
|
+
if isinstance(a, dict):
|
|
92
|
+
if a.keys() != b.keys():
|
|
93
|
+
return False
|
|
94
|
+
return all(_deep_equal(a[k], b[k]) for k in a)
|
|
95
|
+
if isinstance(a, list):
|
|
96
|
+
if len(a) != len(b):
|
|
97
|
+
return False
|
|
98
|
+
return all(_deep_equal(x, y) for x, y in zip(a, b))
|
|
99
|
+
return a == b
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class _StepRow:
|
|
104
|
+
id: int
|
|
105
|
+
name: str
|
|
106
|
+
kind: str
|
|
107
|
+
encoding: str
|
|
108
|
+
result: Optional[bytes]
|
|
109
|
+
status: str
|
|
110
|
+
error: Optional[str]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _decode_result(row: _StepRow) -> Any:
|
|
114
|
+
if row.status != "done":
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
f"internal: step {row.name!r} not done (status={row.status})"
|
|
117
|
+
)
|
|
118
|
+
enc = row.encoding
|
|
119
|
+
raw = row.result
|
|
120
|
+
if raw is None:
|
|
121
|
+
return None
|
|
122
|
+
# v1.1: only json (structured + sleep wake-times) and bytes (raw payloads).
|
|
123
|
+
if enc == "json":
|
|
124
|
+
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
|
125
|
+
return json.loads(text)
|
|
126
|
+
if enc == "bytes":
|
|
127
|
+
return raw if isinstance(raw, bytes) else raw.encode("utf-8")
|
|
128
|
+
raise RuntimeError(f"unknown encoding: {enc!r}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _Task:
|
|
132
|
+
"""Per-invocation task state: open connection, cache, seen-name set."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, conn: sqlite3.Connection):
|
|
135
|
+
self.conn = conn
|
|
136
|
+
# cache: name -> _StepRow
|
|
137
|
+
self.cache: Dict[str, _StepRow] = {}
|
|
138
|
+
# Per-run set of step names already used; duplicates raise.
|
|
139
|
+
self.seen: Set[str] = set()
|
|
140
|
+
self._load_cache()
|
|
141
|
+
|
|
142
|
+
def _load_cache(self) -> None:
|
|
143
|
+
cur = self.conn.execute(
|
|
144
|
+
"SELECT id, name, kind, encoding, result, status, error "
|
|
145
|
+
"FROM mundane_steps ORDER BY id"
|
|
146
|
+
)
|
|
147
|
+
for row in cur:
|
|
148
|
+
sr = _StepRow(
|
|
149
|
+
id=row[0],
|
|
150
|
+
name=row[1],
|
|
151
|
+
kind=row[2],
|
|
152
|
+
encoding=row[3],
|
|
153
|
+
result=row[4],
|
|
154
|
+
status=row[5],
|
|
155
|
+
error=row[6],
|
|
156
|
+
)
|
|
157
|
+
self.cache[sr.name] = sr
|
|
158
|
+
|
|
159
|
+
def _check_seen(self, name: str) -> None:
|
|
160
|
+
if name in self.seen:
|
|
161
|
+
raise DuplicateStepError(name)
|
|
162
|
+
self.seen.add(name)
|
|
163
|
+
|
|
164
|
+
def _ensure_pending_row(self, name: str, kind: str, encoding: str) -> _StepRow:
|
|
165
|
+
"""Insert a pending row if not present; return the (possibly fresh) row.
|
|
166
|
+
|
|
167
|
+
A leftover pending/failed row (never 'done' on this path) is reset to
|
|
168
|
+
pending so the on-disk state reflects the retry, not a stale failure.
|
|
169
|
+
"""
|
|
170
|
+
existing = self.cache.get(name)
|
|
171
|
+
if existing is not None:
|
|
172
|
+
self.conn.execute(
|
|
173
|
+
"UPDATE mundane_steps "
|
|
174
|
+
"SET kind=?, encoding=?, status='pending', result=NULL, error=NULL, finished_at=NULL "
|
|
175
|
+
"WHERE name=?",
|
|
176
|
+
(kind, encoding, name),
|
|
177
|
+
)
|
|
178
|
+
self.conn.commit()
|
|
179
|
+
existing.kind = kind
|
|
180
|
+
existing.encoding = encoding
|
|
181
|
+
existing.status = "pending"
|
|
182
|
+
existing.result = None
|
|
183
|
+
existing.error = None
|
|
184
|
+
return existing
|
|
185
|
+
now = _iso_now()
|
|
186
|
+
self.conn.execute(
|
|
187
|
+
"INSERT INTO mundane_steps "
|
|
188
|
+
"(name, kind, encoding, result, status, started_at) "
|
|
189
|
+
"VALUES (?, ?, ?, NULL, 'pending', ?)",
|
|
190
|
+
(name, kind, encoding, now),
|
|
191
|
+
)
|
|
192
|
+
self.conn.commit()
|
|
193
|
+
# Re-load row to get id
|
|
194
|
+
cur = self.conn.execute(
|
|
195
|
+
"SELECT id, name, kind, encoding, result, status, error "
|
|
196
|
+
"FROM mundane_steps WHERE name = ?",
|
|
197
|
+
(name,),
|
|
198
|
+
)
|
|
199
|
+
row = cur.fetchone()
|
|
200
|
+
sr = _StepRow(
|
|
201
|
+
id=row[0], name=row[1], kind=row[2], encoding=row[3],
|
|
202
|
+
result=row[4], status=row[5], error=row[6],
|
|
203
|
+
)
|
|
204
|
+
self.cache[name] = sr
|
|
205
|
+
return sr
|
|
206
|
+
|
|
207
|
+
def _commit_done(self, name: str, encoding: str, result: Any) -> None:
|
|
208
|
+
"""Mark a step done with the given (already-encoded) result bytes/text."""
|
|
209
|
+
finished = _iso_now()
|
|
210
|
+
self.conn.execute(
|
|
211
|
+
"UPDATE mundane_steps "
|
|
212
|
+
"SET status = 'done', encoding = ?, result = ?, finished_at = ?, error = NULL "
|
|
213
|
+
"WHERE name = ?",
|
|
214
|
+
(encoding, result, finished, name),
|
|
215
|
+
)
|
|
216
|
+
self.conn.commit()
|
|
217
|
+
# Refresh cache
|
|
218
|
+
row = self.cache[name]
|
|
219
|
+
row.status = "done"
|
|
220
|
+
row.encoding = encoding
|
|
221
|
+
row.result = result if isinstance(result, (bytes, bytearray)) else (
|
|
222
|
+
result.encode("utf-8") if isinstance(result, str) else result
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _commit_failed(self, name: str, error: str) -> None:
|
|
226
|
+
finished = _iso_now()
|
|
227
|
+
self.conn.execute(
|
|
228
|
+
"UPDATE mundane_steps "
|
|
229
|
+
"SET status = 'failed', error = ?, finished_at = ? "
|
|
230
|
+
"WHERE name = ?",
|
|
231
|
+
(error, finished, name),
|
|
232
|
+
)
|
|
233
|
+
self.conn.commit()
|
|
234
|
+
row = self.cache.get(name)
|
|
235
|
+
if row is not None:
|
|
236
|
+
row.status = "failed"
|
|
237
|
+
row.error = error
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class Context:
|
|
241
|
+
"""The object passed to a workflow body. Provides step() and sleep()."""
|
|
242
|
+
|
|
243
|
+
def __init__(self, task: _Task):
|
|
244
|
+
self._task = task
|
|
245
|
+
|
|
246
|
+
def step(self, name: str, fn: Callable[[], Any]) -> Any:
|
|
247
|
+
validate_name(name)
|
|
248
|
+
self._task._check_seen(name)
|
|
249
|
+
resolved = name
|
|
250
|
+
row = self._task.cache.get(resolved)
|
|
251
|
+
if row is not None and row.status == "done":
|
|
252
|
+
return _decode_result(row)
|
|
253
|
+
# pending or absent: ensure row, run fn, commit
|
|
254
|
+
self._task._ensure_pending_row(resolved, "step", "json")
|
|
255
|
+
try:
|
|
256
|
+
value = fn()
|
|
257
|
+
except Exception as e:
|
|
258
|
+
self._task._commit_failed(resolved, repr(e))
|
|
259
|
+
raise StepFailedError(resolved, e) from e
|
|
260
|
+
text = _check_json_roundtrip(value)
|
|
261
|
+
self._task._commit_done(resolved, "json", text)
|
|
262
|
+
# Return the round-tripped value so first run and resume agree exactly.
|
|
263
|
+
return json.loads(text)
|
|
264
|
+
|
|
265
|
+
def sleep(self, name: str, duration: Union[str, int, float]) -> None:
|
|
266
|
+
validate_name(name)
|
|
267
|
+
self._task._check_seen(name)
|
|
268
|
+
resolved = name
|
|
269
|
+
row = self._task.cache.get(resolved)
|
|
270
|
+
if row is not None and row.status == "done":
|
|
271
|
+
# Resume: duration arg is ignored (SPEC §6); don't parse it.
|
|
272
|
+
wake_at = _decode_result(row)
|
|
273
|
+
self._sleep_remaining(int(wake_at))
|
|
274
|
+
return
|
|
275
|
+
# absent / pending: compute wake_at, write json-number row, sleep.
|
|
276
|
+
wake_at = _now_ms() + _duration_to_ms(duration)
|
|
277
|
+
self._task._ensure_pending_row(resolved, "sleep", "json")
|
|
278
|
+
self._task._commit_done(resolved, "json", str(wake_at))
|
|
279
|
+
self._sleep_remaining(wake_at)
|
|
280
|
+
|
|
281
|
+
def _sleep_remaining(self, wake_at_ms: int) -> None:
|
|
282
|
+
now = _now_ms()
|
|
283
|
+
remaining = wake_at_ms - now
|
|
284
|
+
if remaining > 0:
|
|
285
|
+
time.sleep(remaining / 1000.0)
|
|
286
|
+
|
|
287
|
+
# ---- async variants ----
|
|
288
|
+
|
|
289
|
+
async def astep(self, name: str, fn: Callable[[], Awaitable[Any]]) -> Any:
|
|
290
|
+
validate_name(name)
|
|
291
|
+
self._task._check_seen(name)
|
|
292
|
+
resolved = name
|
|
293
|
+
row = self._task.cache.get(resolved)
|
|
294
|
+
if row is not None and row.status == "done":
|
|
295
|
+
return _decode_result(row)
|
|
296
|
+
self._task._ensure_pending_row(resolved, "step", "json")
|
|
297
|
+
try:
|
|
298
|
+
value = await fn()
|
|
299
|
+
except Exception as e:
|
|
300
|
+
self._task._commit_failed(resolved, repr(e))
|
|
301
|
+
raise StepFailedError(resolved, e) from e
|
|
302
|
+
text = _check_json_roundtrip(value)
|
|
303
|
+
self._task._commit_done(resolved, "json", text)
|
|
304
|
+
# Return the round-tripped value so first run and resume agree exactly.
|
|
305
|
+
return json.loads(text)
|
|
306
|
+
|
|
307
|
+
async def asleep(self, name: str, duration: Union[str, int, float]) -> None:
|
|
308
|
+
validate_name(name)
|
|
309
|
+
self._task._check_seen(name)
|
|
310
|
+
resolved = name
|
|
311
|
+
row = self._task.cache.get(resolved)
|
|
312
|
+
if row is not None and row.status == "done":
|
|
313
|
+
# Resume: duration arg is ignored (SPEC §6); don't parse it.
|
|
314
|
+
wake_at = int(_decode_result(row))
|
|
315
|
+
else:
|
|
316
|
+
wake_at = _now_ms() + _duration_to_ms(duration)
|
|
317
|
+
self._task._ensure_pending_row(resolved, "sleep", "json")
|
|
318
|
+
self._task._commit_done(resolved, "json", str(wake_at))
|
|
319
|
+
remaining = wake_at - _now_ms()
|
|
320
|
+
if remaining > 0:
|
|
321
|
+
await asyncio.sleep(remaining / 1000.0)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _open_task(path: str) -> tuple[FileLock, sqlite3.Connection, _Task]:
|
|
325
|
+
"""Acquire lock, open DB, bootstrap schema, build cache. Caller owns cleanup."""
|
|
326
|
+
lock = FileLock(path)
|
|
327
|
+
try:
|
|
328
|
+
lock.acquire()
|
|
329
|
+
except BlockingIOError:
|
|
330
|
+
raise LockedError(
|
|
331
|
+
f"{path}: locked by another process"
|
|
332
|
+
) from None
|
|
333
|
+
|
|
334
|
+
conn: Optional[sqlite3.Connection] = None
|
|
335
|
+
try:
|
|
336
|
+
# vfs=unix-none disables SQLite's own file locking. Mundane already
|
|
337
|
+
# holds an exclusive flock(2) on the file for the whole run, so
|
|
338
|
+
# SQLite's internal locking is redundant — and on macOS, where
|
|
339
|
+
# SQLite's POSIX locks share state with our flock on the same vnode,
|
|
340
|
+
# leaving it on would deadlock against ourselves ("database is
|
|
341
|
+
# locked"). The flock above is the sole writer-lock authority.
|
|
342
|
+
# quote() with safe="/" preserves path separators but escapes ? & %
|
|
343
|
+
# so paths with those characters don't break URI parsing.
|
|
344
|
+
encoded = urllib.parse.quote(path, safe="/")
|
|
345
|
+
conn = sqlite3.connect(
|
|
346
|
+
f"file:{encoded}?vfs=unix-none", uri=True, isolation_level=None
|
|
347
|
+
)
|
|
348
|
+
conn.execute("PRAGMA journal_mode = DELETE")
|
|
349
|
+
|
|
350
|
+
# Pre-check: if mundane_meta already exists with a non-1 schema_version,
|
|
351
|
+
# bail before running CREATE INDEX (which references columns we don't
|
|
352
|
+
# promise on other schema versions).
|
|
353
|
+
existing = conn.execute(
|
|
354
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='mundane_meta'"
|
|
355
|
+
).fetchone()
|
|
356
|
+
if existing:
|
|
357
|
+
row = conn.execute(
|
|
358
|
+
"SELECT value FROM mundane_meta WHERE key='schema_version'"
|
|
359
|
+
).fetchone()
|
|
360
|
+
if row and row[0] != SCHEMA_VERSION:
|
|
361
|
+
raise SchemaError(
|
|
362
|
+
f"{path}: schema_version is {row[0]!r}, expected {SCHEMA_VERSION!r}"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Bootstrap inside an IMMEDIATE transaction (idempotent).
|
|
366
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
367
|
+
try:
|
|
368
|
+
statements = [s.strip() for s in BOOTSTRAP_SQL.split(";") if s.strip()]
|
|
369
|
+
for stmt in statements:
|
|
370
|
+
conn.execute(stmt)
|
|
371
|
+
now_iso = _iso_now()
|
|
372
|
+
new_uuid = str(uuid.uuid4())
|
|
373
|
+
conn.execute(
|
|
374
|
+
"INSERT OR IGNORE INTO mundane_meta (key, value) VALUES ('schema_version', ?)",
|
|
375
|
+
(SCHEMA_VERSION,),
|
|
376
|
+
)
|
|
377
|
+
conn.execute(
|
|
378
|
+
"INSERT OR IGNORE INTO mundane_meta (key, value) VALUES ('task_id', ?)",
|
|
379
|
+
(new_uuid,),
|
|
380
|
+
)
|
|
381
|
+
conn.execute(
|
|
382
|
+
"INSERT OR IGNORE INTO mundane_meta (key, value) VALUES ('created_at', ?)",
|
|
383
|
+
(now_iso,),
|
|
384
|
+
)
|
|
385
|
+
conn.execute("COMMIT")
|
|
386
|
+
except Exception:
|
|
387
|
+
conn.execute("ROLLBACK")
|
|
388
|
+
raise
|
|
389
|
+
|
|
390
|
+
# Final schema-version check (covers the fresh-bootstrap path).
|
|
391
|
+
row = conn.execute(
|
|
392
|
+
"SELECT value FROM mundane_meta WHERE key = 'schema_version'"
|
|
393
|
+
).fetchone()
|
|
394
|
+
if not row or row[0] != SCHEMA_VERSION:
|
|
395
|
+
raise SchemaError(
|
|
396
|
+
f"{path}: schema_version is {row[0] if row else None!r}, expected {SCHEMA_VERSION!r}"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
task = _Task(conn)
|
|
400
|
+
return lock, conn, task
|
|
401
|
+
except Exception:
|
|
402
|
+
if conn is not None:
|
|
403
|
+
conn.close()
|
|
404
|
+
lock.release()
|
|
405
|
+
raise
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def run(path: str, fn: Callable[[Context], Any]) -> Any:
|
|
409
|
+
"""Run a workflow body against the SQLite file at `path`.
|
|
410
|
+
|
|
411
|
+
On cache miss, fn-passed step closures execute; on cache hit they return
|
|
412
|
+
the cached value without re-executing. Returns fn's return value.
|
|
413
|
+
|
|
414
|
+
Raises LockedError if another process holds the file lock.
|
|
415
|
+
"""
|
|
416
|
+
lock, conn, task = _open_task(path)
|
|
417
|
+
try:
|
|
418
|
+
ctx = Context(task)
|
|
419
|
+
return fn(ctx)
|
|
420
|
+
finally:
|
|
421
|
+
try:
|
|
422
|
+
conn.close()
|
|
423
|
+
finally:
|
|
424
|
+
lock.release()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
async def arun(path: str, fn: Callable[[Context], Awaitable[Any]]) -> Any:
|
|
428
|
+
"""Async variant of run(). The body can use ctx.astep / ctx.asleep."""
|
|
429
|
+
lock, conn, task = _open_task(path)
|
|
430
|
+
try:
|
|
431
|
+
ctx = Context(task)
|
|
432
|
+
return await fn(ctx)
|
|
433
|
+
finally:
|
|
434
|
+
try:
|
|
435
|
+
conn.close()
|
|
436
|
+
finally:
|
|
437
|
+
lock.release()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mundane-sdk
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Tiny durable-execution: one workflow run is one SQLite file.
|
|
5
|
+
Author: Paul Bellamy
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/paulbellamy/mundane
|
|
8
|
+
Project-URL: Repository, https://github.com/paulbellamy/mundane
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# mundane (Python)
|
|
15
|
+
|
|
16
|
+
See [`../SPEC.md`](../SPEC.md) for the cross-runtime contract.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import mundane
|
|
20
|
+
|
|
21
|
+
def workflow(ctx):
|
|
22
|
+
user = ctx.step("fetch", lambda: {"name": "alice"})
|
|
23
|
+
ctx.sleep("cool-off", "100ms")
|
|
24
|
+
ctx.step("notify", lambda: f"hi {user['name']}")
|
|
25
|
+
|
|
26
|
+
mundane.run("task.db", workflow)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
An async variant is available: `await mundane.arun(path, async_workflow)`
|
|
30
|
+
with `await ctx.astep(...)` / `await ctx.asleep(...)`.
|
|
31
|
+
|
|
32
|
+
## Implementation notes
|
|
33
|
+
|
|
34
|
+
- **Stdlib only.** No third-party dependencies — `sqlite3`, `fcntl`
|
|
35
|
+
(for `flock`), `json`, and `uuid` from the standard library.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
mundane/__init__.py
|
|
5
|
+
mundane/_duration.py
|
|
6
|
+
mundane/_lock.py
|
|
7
|
+
mundane/_schema.py
|
|
8
|
+
mundane/core.py
|
|
9
|
+
mundane_sdk.egg-info/PKG-INFO
|
|
10
|
+
mundane_sdk.egg-info/SOURCES.txt
|
|
11
|
+
mundane_sdk.egg-info/dependency_links.txt
|
|
12
|
+
mundane_sdk.egg-info/top_level.txt
|
|
13
|
+
tests/test_basic.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mundane
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mundane-sdk"
|
|
7
|
+
version = "0.0.2"
|
|
8
|
+
description = "Tiny durable-execution: one workflow run is one SQLite file."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Paul Bellamy" }]
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/paulbellamy/mundane"
|
|
18
|
+
Repository = "https://github.com/paulbellamy/mundane"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
include = ["mundane*"]
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Basic tests for mundane (Python)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import multiprocessing as mp
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
import unittest
|
|
13
|
+
|
|
14
|
+
# Make the package importable without installation.
|
|
15
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
16
|
+
|
|
17
|
+
import mundane
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _hold_lock_child(path, barrier, done):
|
|
21
|
+
# Module-scope helper for test_second_process_gets_locked_error.
|
|
22
|
+
# Must be picklable for multiprocessing's spawn start method (default on
|
|
23
|
+
# macOS), so it can't be a local closure inside the test method.
|
|
24
|
+
def wf(ctx):
|
|
25
|
+
ctx.step("a", lambda: 1)
|
|
26
|
+
barrier.set()
|
|
27
|
+
done.wait(timeout=5)
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
mundane.run(path, wf)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _names(path):
|
|
34
|
+
"""Step names in id order, read straight from SQLite (no inspect API)."""
|
|
35
|
+
conn = sqlite3.connect(path)
|
|
36
|
+
try:
|
|
37
|
+
return [r[0] for r in conn.execute(
|
|
38
|
+
"SELECT name FROM mundane_steps ORDER BY id"
|
|
39
|
+
)]
|
|
40
|
+
finally:
|
|
41
|
+
conn.close()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TempDB:
|
|
45
|
+
def __enter__(self):
|
|
46
|
+
self.fd, self.path = tempfile.mkstemp(suffix=".db")
|
|
47
|
+
os.close(self.fd)
|
|
48
|
+
os.remove(self.path) # mundane will recreate
|
|
49
|
+
return self.path
|
|
50
|
+
|
|
51
|
+
def __exit__(self, *_):
|
|
52
|
+
for ext in ("", "-wal", "-shm", ".lock"):
|
|
53
|
+
with contextlib.suppress(FileNotFoundError):
|
|
54
|
+
os.remove(self.path + ext)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HappyPath(unittest.TestCase):
|
|
58
|
+
def test_three_steps_run_once(self):
|
|
59
|
+
calls = []
|
|
60
|
+
|
|
61
|
+
def wf(ctx):
|
|
62
|
+
a = ctx.step("a", lambda: (calls.append("a"), 1)[1])
|
|
63
|
+
b = ctx.step("b", lambda: (calls.append("b"), {"v": a + 1})[1])
|
|
64
|
+
return b
|
|
65
|
+
|
|
66
|
+
with TempDB() as path:
|
|
67
|
+
r = mundane.run(path, wf)
|
|
68
|
+
self.assertEqual(r, {"v": 2})
|
|
69
|
+
self.assertEqual(calls, ["a", "b"])
|
|
70
|
+
|
|
71
|
+
# Re-run: should return same value without re-calling
|
|
72
|
+
r2 = mundane.run(path, wf)
|
|
73
|
+
self.assertEqual(r2, {"v": 2})
|
|
74
|
+
self.assertEqual(calls, ["a", "b"]) # not re-called
|
|
75
|
+
|
|
76
|
+
def test_resume_after_crash(self):
|
|
77
|
+
"""Simulate crash by raising after step 1; verify step 1 is cached."""
|
|
78
|
+
with TempDB() as path:
|
|
79
|
+
# First run: succeed step a, raise before step b commits
|
|
80
|
+
def wf1(ctx):
|
|
81
|
+
ctx.step("a", lambda: 42)
|
|
82
|
+
raise RuntimeError("simulated crash")
|
|
83
|
+
|
|
84
|
+
with self.assertRaises(RuntimeError):
|
|
85
|
+
mundane.run(path, wf1)
|
|
86
|
+
|
|
87
|
+
# Second run: step a returns 42 from cache; step b runs and finishes.
|
|
88
|
+
calls = []
|
|
89
|
+
|
|
90
|
+
def wf2(ctx):
|
|
91
|
+
v = ctx.step("a", lambda: (calls.append("a"), 999)[1])
|
|
92
|
+
w = ctx.step("b", lambda: (calls.append("b"), v + 1)[1])
|
|
93
|
+
return w
|
|
94
|
+
|
|
95
|
+
r = mundane.run(path, wf2)
|
|
96
|
+
self.assertEqual(r, 43)
|
|
97
|
+
self.assertEqual(calls, ["b"]) # only b was called; a came from cache
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Naming(unittest.TestCase):
|
|
101
|
+
def test_invalid_name_rejected(self):
|
|
102
|
+
with TempDB() as path:
|
|
103
|
+
def wf(ctx):
|
|
104
|
+
ctx.step("bad name with space", lambda: 1)
|
|
105
|
+
with self.assertRaises(ValueError):
|
|
106
|
+
mundane.run(path, wf)
|
|
107
|
+
|
|
108
|
+
def test_duplicate_name_raises(self):
|
|
109
|
+
with TempDB() as path:
|
|
110
|
+
def wf(ctx):
|
|
111
|
+
ctx.step("x", lambda: 1)
|
|
112
|
+
ctx.step("x", lambda: 2) # duplicate -> raises
|
|
113
|
+
with self.assertRaises(mundane.DuplicateStepError):
|
|
114
|
+
mundane.run(path, wf)
|
|
115
|
+
# First step still committed before the duplicate was raised.
|
|
116
|
+
self.assertEqual(_names(path), ["x"])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Locking(unittest.TestCase):
|
|
120
|
+
def test_second_process_gets_locked_error(self):
|
|
121
|
+
with TempDB() as path:
|
|
122
|
+
barrier = mp.Event()
|
|
123
|
+
done = mp.Event()
|
|
124
|
+
|
|
125
|
+
proc = mp.Process(target=_hold_lock_child, args=(path, barrier, done))
|
|
126
|
+
proc.start()
|
|
127
|
+
try:
|
|
128
|
+
barrier.wait(timeout=5)
|
|
129
|
+
# Try to open while the other process holds the lock.
|
|
130
|
+
with self.assertRaises(mundane.LockedError):
|
|
131
|
+
mundane.run(path, lambda ctx: None)
|
|
132
|
+
finally:
|
|
133
|
+
done.set()
|
|
134
|
+
proc.join(timeout=5)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Sleep(unittest.TestCase):
|
|
138
|
+
def test_sleep_persists_wake_at_and_resumes(self):
|
|
139
|
+
with TempDB() as path:
|
|
140
|
+
# First "run": write the sleep row but interrupt before sleeping
|
|
141
|
+
# by using a 0ms duration.
|
|
142
|
+
t0 = time.time()
|
|
143
|
+
mundane.run(path, lambda ctx: ctx.sleep("nap", "10ms"))
|
|
144
|
+
elapsed1 = time.time() - t0
|
|
145
|
+
self.assertLess(elapsed1, 0.2)
|
|
146
|
+
|
|
147
|
+
# Second run: nap row already done; should return immediately.
|
|
148
|
+
t0 = time.time()
|
|
149
|
+
mundane.run(path, lambda ctx: ctx.sleep("nap", "10s"))
|
|
150
|
+
elapsed2 = time.time() - t0
|
|
151
|
+
self.assertLess(elapsed2, 0.2)
|
|
152
|
+
|
|
153
|
+
def test_resume_ignores_invalid_duration(self):
|
|
154
|
+
with TempDB() as path:
|
|
155
|
+
mundane.run(path, lambda ctx: ctx.sleep("n", "1ms"))
|
|
156
|
+
# Resume ignores the duration arg, so an invalid string is a no-op.
|
|
157
|
+
mundane.run(path, lambda ctx: ctx.sleep("n", "not-a-duration"))
|
|
158
|
+
|
|
159
|
+
def test_sleep_remaining_on_resume(self):
|
|
160
|
+
"""If we crash mid-sleep, next run should sleep only the remaining time."""
|
|
161
|
+
with TempDB() as path:
|
|
162
|
+
# First run: writes wake_at = now + 200ms then sleeps the full 200ms.
|
|
163
|
+
# We use 50ms so the test is quick but observable.
|
|
164
|
+
t0 = time.time()
|
|
165
|
+
mundane.run(path, lambda ctx: ctx.sleep("n", "50ms"))
|
|
166
|
+
self.assertGreaterEqual(time.time() - t0, 0.04)
|
|
167
|
+
|
|
168
|
+
def test_resume_sleeps_only_the_remainder(self):
|
|
169
|
+
"""A wake_at still in the future makes resume sleep the remainder."""
|
|
170
|
+
with TempDB() as path:
|
|
171
|
+
# Establish the sleep row with a short duration.
|
|
172
|
+
mundane.run(path, lambda ctx: ctx.sleep("n", "10ms"))
|
|
173
|
+
# Rewrite wake_at ~300ms into the future to simulate a long nap whose
|
|
174
|
+
# process was restarted before it elapsed.
|
|
175
|
+
future = int(time.time() * 1000) + 300
|
|
176
|
+
conn = sqlite3.connect(path)
|
|
177
|
+
conn.execute("UPDATE mundane_steps SET result=? WHERE name='n'", (str(future),))
|
|
178
|
+
conn.commit()
|
|
179
|
+
conn.close()
|
|
180
|
+
# Resume must block for the remaining ~300ms (duration arg ignored).
|
|
181
|
+
t0 = time.time()
|
|
182
|
+
mundane.run(path, lambda ctx: ctx.sleep("n", "10ms"))
|
|
183
|
+
self.assertGreaterEqual(time.time() - t0, 0.2)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class FailedStep(unittest.TestCase):
|
|
187
|
+
def test_failed_step_reruns(self):
|
|
188
|
+
with TempDB() as path:
|
|
189
|
+
def boom():
|
|
190
|
+
raise RuntimeError("boom")
|
|
191
|
+
|
|
192
|
+
with self.assertRaises(mundane.StepFailedError):
|
|
193
|
+
mundane.run(path, lambda ctx: ctx.step("s", boom))
|
|
194
|
+
|
|
195
|
+
# A failed step is not cached; it must re-run.
|
|
196
|
+
calls = []
|
|
197
|
+
r = mundane.run(path, lambda ctx: ctx.step("s", lambda: (calls.append("s"), 7)[1]))
|
|
198
|
+
self.assertEqual(r, 7)
|
|
199
|
+
self.assertEqual(calls, ["s"])
|
|
200
|
+
|
|
201
|
+
def test_failed_row_reset_to_pending_during_rerun(self):
|
|
202
|
+
with TempDB() as path:
|
|
203
|
+
def boom():
|
|
204
|
+
raise RuntimeError("boom")
|
|
205
|
+
|
|
206
|
+
with self.assertRaises(mundane.StepFailedError):
|
|
207
|
+
mundane.run(path, lambda ctx: ctx.step("s", boom))
|
|
208
|
+
|
|
209
|
+
# While the re-run body executes, the row must read 'pending' with
|
|
210
|
+
# the stale error cleared (the reset committed before fn runs).
|
|
211
|
+
seen = {}
|
|
212
|
+
|
|
213
|
+
def observe():
|
|
214
|
+
# observe() runs inside mundane.run, while the writer holds
|
|
215
|
+
# an exclusive flock on the file. On macOS, opening with the
|
|
216
|
+
# default unix VFS would deadlock against that flock — open
|
|
217
|
+
# with unix-none (no SQLite-level lock) instead.
|
|
218
|
+
conn = sqlite3.connect(f"file:{path}?vfs=unix-none", uri=True)
|
|
219
|
+
seen["status"], seen["error"] = conn.execute(
|
|
220
|
+
"SELECT status, error FROM mundane_steps WHERE name='s'"
|
|
221
|
+
).fetchone()
|
|
222
|
+
conn.close()
|
|
223
|
+
return 7
|
|
224
|
+
|
|
225
|
+
mundane.run(path, lambda ctx: ctx.step("s", observe))
|
|
226
|
+
self.assertEqual(seen["status"], "pending")
|
|
227
|
+
self.assertIsNone(seen["error"])
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class Async(unittest.TestCase):
|
|
231
|
+
def test_arun_astep_asleep(self):
|
|
232
|
+
async def aval(calls, tag, v):
|
|
233
|
+
calls.append(tag)
|
|
234
|
+
return v
|
|
235
|
+
|
|
236
|
+
with TempDB() as path:
|
|
237
|
+
calls = []
|
|
238
|
+
|
|
239
|
+
async def wf(ctx):
|
|
240
|
+
a = await ctx.astep("a", lambda: aval(calls, "a", 1))
|
|
241
|
+
await ctx.asleep("nap", "10ms")
|
|
242
|
+
return await ctx.astep("b", lambda: aval(calls, "b", a + 1))
|
|
243
|
+
|
|
244
|
+
r = asyncio.run(mundane.arun(path, wf))
|
|
245
|
+
self.assertEqual(r, 2)
|
|
246
|
+
self.assertEqual(calls, ["a", "b"])
|
|
247
|
+
|
|
248
|
+
# Resume: both steps cache-hit, neither fn runs.
|
|
249
|
+
calls.clear()
|
|
250
|
+
r2 = asyncio.run(mundane.arun(path, wf))
|
|
251
|
+
self.assertEqual(r2, 2)
|
|
252
|
+
self.assertEqual(calls, [])
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class Inspect(unittest.TestCase):
|
|
256
|
+
def test_steps_committed(self):
|
|
257
|
+
with TempDB() as path:
|
|
258
|
+
def wf(ctx):
|
|
259
|
+
ctx.step("a", lambda: {"x": 1})
|
|
260
|
+
ctx.step("b", lambda: "hello")
|
|
261
|
+
|
|
262
|
+
mundane.run(path, wf)
|
|
263
|
+
# Inspection lives in the CLI now; verify on-disk state directly.
|
|
264
|
+
conn = sqlite3.connect(path)
|
|
265
|
+
done = conn.execute(
|
|
266
|
+
"SELECT COUNT(*) FROM mundane_steps WHERE status='done'"
|
|
267
|
+
).fetchone()[0]
|
|
268
|
+
self.assertEqual(done, 2)
|
|
269
|
+
a = conn.execute(
|
|
270
|
+
"SELECT result, encoding FROM mundane_steps WHERE name='a'"
|
|
271
|
+
).fetchone()
|
|
272
|
+
self.assertEqual(a[1], "json")
|
|
273
|
+
self.assertEqual(json.loads(a[0]), {"x": 1})
|
|
274
|
+
conn.close()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class Serialization(unittest.TestCase):
|
|
278
|
+
def test_non_jsonable_raises(self):
|
|
279
|
+
with TempDB() as path:
|
|
280
|
+
def wf(ctx):
|
|
281
|
+
# Tuples become lists in JSON; that's a roundtrip mismatch.
|
|
282
|
+
ctx.step("a", lambda: (1, 2, 3))
|
|
283
|
+
with self.assertRaises(mundane.SerializationError):
|
|
284
|
+
mundane.run(path, wf)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class Schema(unittest.TestCase):
|
|
288
|
+
def test_wrong_schema_version_rejected(self):
|
|
289
|
+
with TempDB() as path:
|
|
290
|
+
# Create a file with wrong schema version manually.
|
|
291
|
+
conn = sqlite3.connect(path)
|
|
292
|
+
conn.execute("CREATE TABLE mundane_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
|
293
|
+
conn.execute("CREATE TABLE mundane_steps (id INTEGER PRIMARY KEY)")
|
|
294
|
+
conn.execute("INSERT INTO mundane_meta VALUES ('schema_version', '99')")
|
|
295
|
+
conn.commit()
|
|
296
|
+
conn.close()
|
|
297
|
+
|
|
298
|
+
with self.assertRaises(mundane.SchemaError):
|
|
299
|
+
mundane.run(path, lambda ctx: None)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
if __name__ == "__main__":
|
|
303
|
+
unittest.main()
|