dhp-protocol 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dhp/__init__.py +143 -0
- dhp/a2a_bridge.py +189 -0
- dhp/backend.py +88 -0
- dhp/bridge_worker.py +71 -0
- dhp/chaos.py +205 -0
- dhp/config.py +78 -0
- dhp/conform.py +244 -0
- dhp/demo.py +153 -0
- dhp/envelope.py +245 -0
- dhp/errors.py +74 -0
- dhp/log.py +34 -0
- dhp/mcp_server.py +325 -0
- dhp/net.py +312 -0
- dhp/prime_chunk.py +19 -0
- dhp/runner.py +308 -0
- dhp/runner3.py +446 -0
- dhp/scan.py +29 -0
- dhp/setup_chaos.py +48 -0
- dhp/setup_wave2.py +45 -0
- dhp/setup_wave3.py +45 -0
- dhp/store.py +656 -0
- dhp/supervisor.py +113 -0
- dhp/supervisor3.py +264 -0
- dhp/supervisor3_watch.py +34 -0
- dhp/supervisor_watch.py +30 -0
- dhp/transport.py +164 -0
- dhp/transport_server.py +49 -0
- dhp/validate.py +66 -0
- dhp/verify_wave.py +67 -0
- dhp/work_driver.py +94 -0
- dhp/work_driver3.py +82 -0
- dhp/work_driver4.py +89 -0
- dhp/worker_cli.py +84 -0
- dhp_protocol-1.3.0.dist-info/METADATA +449 -0
- dhp_protocol-1.3.0.dist-info/RECORD +39 -0
- dhp_protocol-1.3.0.dist-info/WHEEL +5 -0
- dhp_protocol-1.3.0.dist-info/entry_points.txt +6 -0
- dhp_protocol-1.3.0.dist-info/licenses/LICENSE +21 -0
- dhp_protocol-1.3.0.dist-info/top_level.txt +1 -0
dhp/chaos.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""DHP fault-injection harness — randomized, not scripted.
|
|
2
|
+
|
|
3
|
+
Spawns workers and supervisors as real OS processes, then a fault
|
|
4
|
+
injector kills random victims at random times (SIGKILL — no warning):
|
|
5
|
+
workers, the leader supervisor, sometimes both. A provisioner loop
|
|
6
|
+
detects orphans and spawns recovery workers automatically.
|
|
7
|
+
|
|
8
|
+
This is the "difficult, surprising" test: nobody decides who dies when.
|
|
9
|
+
Invariants asserted at the end:
|
|
10
|
+
- every handoff completed exactly once, result correct
|
|
11
|
+
- zero checkpoint rework (sequence continuous per handoff)
|
|
12
|
+
- exactly one owner at every point (no split-brain)
|
|
13
|
+
|
|
14
|
+
Usage: python3 -m dhp.chaos [--rounds N] [--seed S]
|
|
15
|
+
"""
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import random
|
|
20
|
+
import shutil
|
|
21
|
+
import signal
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
27
|
+
|
|
28
|
+
from dhp import envelope as E
|
|
29
|
+
from dhp.runner3 import Runner3
|
|
30
|
+
from dhp.prime_chunk import chunk
|
|
31
|
+
|
|
32
|
+
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
33
|
+
DRIVER = os.path.join(HERE, "dhp", "work_driver4.py")
|
|
34
|
+
SUPWATCH = os.path.join(HERE, "dhp", "supervisor3_watch.py")
|
|
35
|
+
SCHEMA = {"type": "object",
|
|
36
|
+
"properties": {"lo": {"type": "integer"}, "hi": {"type": "integer"},
|
|
37
|
+
"prime_count": {"type": "integer"},
|
|
38
|
+
"prime_sum": {"type": "integer"},
|
|
39
|
+
"chunks_done": {"type": "integer"}},
|
|
40
|
+
"required": ["lo", "hi", "prime_count", "prime_sum", "chunks_done"]}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def setup(root, n_tasks=3, chunks=400, chunk=100, span=40000):
|
|
44
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
45
|
+
os.makedirs(root, exist_ok=True)
|
|
46
|
+
r = Runner3(root, runner_id="chaos")
|
|
47
|
+
hids = []
|
|
48
|
+
base = 5000000
|
|
49
|
+
for i in range(n_tasks):
|
|
50
|
+
lo = base + i * span
|
|
51
|
+
env = E.build(
|
|
52
|
+
task_kind="prime.census",
|
|
53
|
+
inputs={"inline": {"lo": lo, "hi": lo + chunks * chunk,
|
|
54
|
+
"chunk": chunk}},
|
|
55
|
+
output_schema=SCHEMA,
|
|
56
|
+
budget={"max_steps": chunks + 100, "max_spend_usd": 500.0,
|
|
57
|
+
"max_wallclock_s": 5400.0, "max_attempts": 25},
|
|
58
|
+
created_by="chaos-harness")
|
|
59
|
+
hids.append(r.dispatch(env))
|
|
60
|
+
r.store.close()
|
|
61
|
+
return hids
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main():
|
|
65
|
+
ap = argparse.ArgumentParser()
|
|
66
|
+
ap.add_argument("--rounds", type=int, default=1)
|
|
67
|
+
ap.add_argument("--seed", type=int, default=None)
|
|
68
|
+
a = ap.parse_args()
|
|
69
|
+
rng = random.Random(a.seed)
|
|
70
|
+
|
|
71
|
+
for rnd in range(a.rounds):
|
|
72
|
+
root = f"/tmp/dhp-chaos-{os.getpid()}-{rnd}"
|
|
73
|
+
tdir = os.path.join(root, "transport")
|
|
74
|
+
os.makedirs(tdir, exist_ok=True)
|
|
75
|
+
hids = setup(root, n_tasks=3, chunks=300, chunk=100, span=30000)
|
|
76
|
+
print(f"[round {rnd}] {len(hids)} handoffs dispatched")
|
|
77
|
+
|
|
78
|
+
# supervisors (2, leader election decides)
|
|
79
|
+
sups = {}
|
|
80
|
+
for sid in ("supA", "supB"):
|
|
81
|
+
p = subprocess.Popen(
|
|
82
|
+
[sys.executable, SUPWATCH, root, sid, "300"],
|
|
83
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
84
|
+
sups[sid] = p
|
|
85
|
+
|
|
86
|
+
# workers
|
|
87
|
+
workers = {} # hid -> Popen
|
|
88
|
+
for i, hid in enumerate(hids):
|
|
89
|
+
log = os.path.join(tdir, hid + ".log")
|
|
90
|
+
p = subprocess.Popen(
|
|
91
|
+
[sys.executable, DRIVER, "--root", root,
|
|
92
|
+
"--runner", f"w{i}", "--chunk", "100", "--pace", "0.05",
|
|
93
|
+
"--ship", log, "claim", hid],
|
|
94
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
95
|
+
workers[hid] = p
|
|
96
|
+
|
|
97
|
+
kills = {"workers": 0, "supervisors": 0}
|
|
98
|
+
deadline = time.time() + 240
|
|
99
|
+
r = Runner3(root, runner_id="chaos-mon")
|
|
100
|
+
|
|
101
|
+
def live_workers():
|
|
102
|
+
return {h: p for h, p in workers.items() if p.poll() is None}
|
|
103
|
+
|
|
104
|
+
def recover_orphans():
|
|
105
|
+
for hid in r.store.all_hids():
|
|
106
|
+
env = r._load(hid)
|
|
107
|
+
if env["execution"]["status"] == "orphaned" and hid not in live_workers():
|
|
108
|
+
import uuid
|
|
109
|
+
rid = f"rw{uuid.uuid4().hex[:8]}"
|
|
110
|
+
log = os.path.join(tdir, hid + ".log")
|
|
111
|
+
p = subprocess.Popen(
|
|
112
|
+
[sys.executable, DRIVER, "--root", root,
|
|
113
|
+
"--runner", rid,
|
|
114
|
+
"--chunk", "100", "--pace", "0.05",
|
|
115
|
+
"--ship", log, "recover", hid],
|
|
116
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
117
|
+
workers[hid] = p
|
|
118
|
+
print(f"[round {rnd}] provisioned recovery for {hid[:12]}")
|
|
119
|
+
|
|
120
|
+
while time.time() < deadline:
|
|
121
|
+
# random fault injection
|
|
122
|
+
time.sleep(rng.uniform(1.0, 3.0))
|
|
123
|
+
roll = rng.random()
|
|
124
|
+
lw = live_workers()
|
|
125
|
+
if roll < 0.55 and lw:
|
|
126
|
+
hid = rng.choice(list(lw))
|
|
127
|
+
lw[hid].send_signal(signal.SIGKILL)
|
|
128
|
+
kills["workers"] += 1
|
|
129
|
+
print(f"[round {rnd}] FAULT: SIGKILL worker on {hid[:12]}")
|
|
130
|
+
elif roll < 0.75:
|
|
131
|
+
# kill a random supervisor (maybe the leader, maybe not —
|
|
132
|
+
# the injector doesn't know, that's the point)
|
|
133
|
+
sid = rng.choice(list(sups))
|
|
134
|
+
if sups[sid].poll() is None:
|
|
135
|
+
sups[sid].send_signal(signal.SIGKILL)
|
|
136
|
+
kills["supervisors"] += 1
|
|
137
|
+
print(f"[round {rnd}] FAULT: SIGKILL supervisor {sid}")
|
|
138
|
+
# start a replacement supervisor (standby pool)
|
|
139
|
+
p = subprocess.Popen(
|
|
140
|
+
[sys.executable, SUPWATCH, root, sid + "2", "300"],
|
|
141
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
142
|
+
sups[sid + "2"] = p
|
|
143
|
+
recover_orphans()
|
|
144
|
+
# done?
|
|
145
|
+
done = all(r._load(h)["execution"]["status"] == "completed"
|
|
146
|
+
for h in hids)
|
|
147
|
+
if done and not live_workers():
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
# stop everything
|
|
151
|
+
for p in list(workers.values()) + list(sups.values()):
|
|
152
|
+
try:
|
|
153
|
+
p.kill()
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
# verify invariants
|
|
158
|
+
# completed -> must be correct. failed (DLQ) -> must have intact
|
|
159
|
+
# checkpoints (parked for humans, not lost). Nothing may be stuck
|
|
160
|
+
# in a non-terminal state with no live owner.
|
|
161
|
+
ok = True
|
|
162
|
+
live = live_workers()
|
|
163
|
+
for hid in hids:
|
|
164
|
+
env = r._load(hid)
|
|
165
|
+
ex = env["execution"]
|
|
166
|
+
rows = r.store.cx.execute(
|
|
167
|
+
"SELECT seq FROM checkpoints WHERE hid=? ORDER BY seq",
|
|
168
|
+
(hid,)).fetchall()
|
|
169
|
+
seqs = [x[0] for x in rows]
|
|
170
|
+
if seqs != list(range(len(seqs))):
|
|
171
|
+
print(f"[round {rnd}] INVARIANT FAIL: {hid[:12]} rework/gap")
|
|
172
|
+
ok = False
|
|
173
|
+
if ex["status"] == "completed":
|
|
174
|
+
res = env["delivery"]["result"]
|
|
175
|
+
c = s = 0
|
|
176
|
+
n = res["lo"]
|
|
177
|
+
while n < res["hi"]:
|
|
178
|
+
rr = chunk(n, min(n + 5000, res["hi"]))
|
|
179
|
+
c += rr["count"]
|
|
180
|
+
s += rr["sum"]
|
|
181
|
+
n += 5000
|
|
182
|
+
if c != res["prime_count"] or s != res["prime_sum"]:
|
|
183
|
+
print(f"[round {rnd}] INVARIANT FAIL: {hid[:12]} wrong result")
|
|
184
|
+
ok = False
|
|
185
|
+
r.ack(hid)
|
|
186
|
+
elif ex["status"] == "failed":
|
|
187
|
+
# DLQ: parked, intact, awaiting human replay — not lost.
|
|
188
|
+
print(f"[round {rnd}] note: {hid[:12]} in DLQ "
|
|
189
|
+
f"({len(seqs)} checkpoints intact)")
|
|
190
|
+
else:
|
|
191
|
+
own = r.store.get_ownership(hid)
|
|
192
|
+
if hid not in live and (not own or own["holder"] == "__orphaned__"
|
|
193
|
+
or own["lease_expires_at"] < time.time()):
|
|
194
|
+
print(f"[round {rnd}] INVARIANT FAIL: {hid[:12]} "
|
|
195
|
+
f"stuck in {ex['status']} with no live owner")
|
|
196
|
+
ok = False
|
|
197
|
+
r.store.close()
|
|
198
|
+
print(f"[round {rnd}] kills={kills} invariants={'OK' if ok else 'FAILED'}")
|
|
199
|
+
if not ok:
|
|
200
|
+
sys.exit(1)
|
|
201
|
+
print("chaos harness: ALL ROUNDS GREEN")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
main()
|
dhp/config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""DHP configuration. Every operational knob via env vars (DHP_* prefix).
|
|
2
|
+
|
|
3
|
+
Production rule: no magic numbers in the hot path. All defaults match
|
|
4
|
+
the chaos-proven v0.4 values.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _env_float(name: str, default: float) -> float:
|
|
13
|
+
try:
|
|
14
|
+
return float(os.environ.get(name, default))
|
|
15
|
+
except ValueError:
|
|
16
|
+
return default
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _env_int(name: str, default: int) -> int:
|
|
20
|
+
try:
|
|
21
|
+
return int(os.environ.get(name, default))
|
|
22
|
+
except ValueError:
|
|
23
|
+
return default
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class DHPConfig:
|
|
28
|
+
# leases / supervision
|
|
29
|
+
lease_interval_s: float = field(
|
|
30
|
+
default_factory=lambda: _env_float("DHP_LEASE_INTERVAL_S", 2.0))
|
|
31
|
+
lease_max_missed: int = field(
|
|
32
|
+
default_factory=lambda: _env_int("DHP_LEASE_MAX_MISSED", 3))
|
|
33
|
+
supervisor_scan_s: float = field(
|
|
34
|
+
default_factory=lambda: _env_float("DHP_SUPERVISOR_SCAN_S", 1.0))
|
|
35
|
+
leader_ttl_s: float = field(
|
|
36
|
+
default_factory=lambda: _env_float("DHP_LEADER_TTL_S", 5.0))
|
|
37
|
+
# stall detection: alive but making no progress
|
|
38
|
+
stall_timeout_s: float = field(
|
|
39
|
+
default_factory=lambda: _env_float("DHP_STALL_TIMEOUT_S", 120.0))
|
|
40
|
+
stall_max_repeats: int = field(
|
|
41
|
+
default_factory=lambda: _env_int("DHP_STALL_MAX_REPEATS", 10))
|
|
42
|
+
# safety bounds
|
|
43
|
+
max_checkpoint_bytes: int = field(
|
|
44
|
+
default_factory=lambda: _env_int("DHP_MAX_CHECKPOINT_BYTES",
|
|
45
|
+
1_000_000)) # 1 MB
|
|
46
|
+
max_envelope_bytes: int = field(
|
|
47
|
+
default_factory=lambda: _env_int("DHP_MAX_ENVELOPE_BYTES",
|
|
48
|
+
10_000_000)) # 10 MB
|
|
49
|
+
max_result_bytes: int = field(
|
|
50
|
+
default_factory=lambda: _env_int("DHP_MAX_RESULT_BYTES",
|
|
51
|
+
10_000_000)) # 10 MB
|
|
52
|
+
max_checkpoints_per_handoff: int = field(
|
|
53
|
+
default_factory=lambda: _env_int("DHP_MAX_CHECKPOINTS", 1_000_000))
|
|
54
|
+
# transport
|
|
55
|
+
transport_max_line_bytes: int = field(
|
|
56
|
+
default_factory=lambda: _env_int("DHP_TRANSPORT_MAX_LINE_BYTES",
|
|
57
|
+
2_000_000)) # 2 MB
|
|
58
|
+
# observability
|
|
59
|
+
log_level: str = field(
|
|
60
|
+
default_factory=lambda: os.environ.get("DHP_LOG_LEVEL", "INFO"))
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def lease_ttl_s(self) -> float:
|
|
64
|
+
return self.lease_interval_s * self.lease_max_missed
|
|
65
|
+
|
|
66
|
+
def validate(self) -> None:
|
|
67
|
+
assert self.lease_interval_s > 0, "lease_interval_s must be > 0"
|
|
68
|
+
assert self.lease_max_missed >= 2, "lease_max_missed must be >= 2"
|
|
69
|
+
assert self.leader_ttl_s > self.supervisor_scan_s, \
|
|
70
|
+
"leader_ttl_s must exceed supervisor_scan_s"
|
|
71
|
+
for f in ("max_checkpoint_bytes", "max_envelope_bytes",
|
|
72
|
+
"max_result_bytes", "transport_max_line_bytes",
|
|
73
|
+
"max_checkpoints_per_handoff"):
|
|
74
|
+
assert getattr(self, f) > 0, f"{f} must be > 0"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
DEFAULT = DHPConfig()
|
|
78
|
+
DEFAULT.validate()
|
dhp/conform.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""DHP conformance suite — the protocol as runnable assertions.
|
|
2
|
+
|
|
3
|
+
Any implementation claiming DHP conformance must pass all of these.
|
|
4
|
+
They encode the guarantees v0.1–v0.4, each tied to a real failure mode
|
|
5
|
+
found in chaos testing.
|
|
6
|
+
|
|
7
|
+
Run: python3 -m dhp.conform
|
|
8
|
+
"""
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from . import envelope as E
|
|
16
|
+
from .runner3 import Runner3, RunnerError, BudgetExceeded, FencingError
|
|
17
|
+
from .supervisor3 import Supervisor3
|
|
18
|
+
from .store import OwnershipLost
|
|
19
|
+
from . import transport as T
|
|
20
|
+
from . import errors as DHE
|
|
21
|
+
|
|
22
|
+
SCHEMA = {"type": "object",
|
|
23
|
+
"properties": {"x": {"type": "integer"}},
|
|
24
|
+
"required": ["x"]}
|
|
25
|
+
PASS = []
|
|
26
|
+
FAIL = []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check(name, fn):
|
|
30
|
+
root = tempfile.mkdtemp(prefix="dhp-conform-")
|
|
31
|
+
try:
|
|
32
|
+
fn(root)
|
|
33
|
+
PASS.append(name)
|
|
34
|
+
print(f" PASS {name}")
|
|
35
|
+
except Exception as e:
|
|
36
|
+
FAIL.append((name, e))
|
|
37
|
+
print(f" FAIL {name}: {type(e).__name__}: {e}")
|
|
38
|
+
finally:
|
|
39
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def env(n=1, **kw):
|
|
43
|
+
return E.build(task_kind="t", inputs={"inline": {"n": n}},
|
|
44
|
+
output_schema=SCHEMA, **kw)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# 1. No lost results: kill mid-run -> recover -> result present & correct.
|
|
48
|
+
def t_no_lost_results(root):
|
|
49
|
+
r = Runner3(root, runner_id="w")
|
|
50
|
+
hid = r.dispatch(env())
|
|
51
|
+
ctx = r.claim(hid)
|
|
52
|
+
ctx.checkpoint({"s": 1})
|
|
53
|
+
# simulate death: expire lease, supervisor orphans
|
|
54
|
+
r.store.cx.execute("UPDATE ownership SET lease_expires_at=? WHERE hid=?",
|
|
55
|
+
(time.time() - 1, hid))
|
|
56
|
+
sup = Supervisor3(root, supervisor_id="s")
|
|
57
|
+
sup.ensure_leadership()
|
|
58
|
+
assert sup.scan()[0]["action"] == "orphaned"
|
|
59
|
+
r2 = Runner3(root, runner_id="w2")
|
|
60
|
+
ctx2 = r2.recover(hid)
|
|
61
|
+
assert r2.last_checkpoint(hid) == {"s": 1}
|
|
62
|
+
ctx2.complete({"x": 7})
|
|
63
|
+
assert r2._load(hid)["execution"]["status"] == "completed"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# 2. No crossed inputs: tampered inputs rejected at dispatch.
|
|
67
|
+
def t_no_crossed_inputs(root):
|
|
68
|
+
r = Runner3(root, runner_id="w")
|
|
69
|
+
e = env()
|
|
70
|
+
e["task"]["inputs"]["inline"]["n"] = 999 # tamper after id computed
|
|
71
|
+
try:
|
|
72
|
+
r.dispatch(e)
|
|
73
|
+
except (RunnerError, DHE.TamperError):
|
|
74
|
+
return
|
|
75
|
+
raise AssertionError("tampered inputs accepted")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# 3. No untyped results: schema violation rejected at completion.
|
|
79
|
+
def t_no_untyped_results(root):
|
|
80
|
+
r = Runner3(root, runner_id="w")
|
|
81
|
+
hid = r.dispatch(env())
|
|
82
|
+
ctx = r.claim(hid)
|
|
83
|
+
try:
|
|
84
|
+
ctx.complete({"wrong": "shape"})
|
|
85
|
+
except Exception:
|
|
86
|
+
return
|
|
87
|
+
raise AssertionError("schema-violating result accepted")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# 4. No silent budget overrun: runtime raises, worker cannot hide it.
|
|
91
|
+
def t_no_silent_overrun(root):
|
|
92
|
+
r = Runner3(root, runner_id="w")
|
|
93
|
+
e = E.build(task_kind="t", inputs={"inline": {"n": 1}},
|
|
94
|
+
output_schema=SCHEMA,
|
|
95
|
+
budget={"max_steps": 2, "max_spend_usd": 1.0,
|
|
96
|
+
"max_wallclock_s": 60.0, "max_attempts": 3})
|
|
97
|
+
hid = r.dispatch(e)
|
|
98
|
+
ctx = r.claim(hid)
|
|
99
|
+
ctx.note_step(2)
|
|
100
|
+
try:
|
|
101
|
+
ctx.note_step(1)
|
|
102
|
+
except BudgetExceeded:
|
|
103
|
+
return
|
|
104
|
+
raise AssertionError("budget overrun not raised")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# 5. No runner lock-in: suspend here, resume there, complete there.
|
|
108
|
+
def t_no_runner_lockin(root):
|
|
109
|
+
r1 = Runner3(root + "/a", runner_id="w1")
|
|
110
|
+
hid = r1.dispatch(env())
|
|
111
|
+
ctx = r1.claim(hid)
|
|
112
|
+
ctx.checkpoint({"s": 1})
|
|
113
|
+
bundle = r1.suspend(hid)
|
|
114
|
+
r2 = Runner3(root + "/b", runner_id="w2")
|
|
115
|
+
hid2 = r2.resume(bundle)
|
|
116
|
+
assert hid2 == hid and r2.last_checkpoint(hid2) == {"s": 1}
|
|
117
|
+
r2.claim(hid2).complete({"x": 1})
|
|
118
|
+
assert r2._load(hid2)["execution"]["status"] == "completed"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# 6. Exactly-one-wins: N simultaneous recoverers, one winner.
|
|
122
|
+
def t_exactly_one_wins(root):
|
|
123
|
+
r = Runner3(root, runner_id="w")
|
|
124
|
+
hid = r.dispatch(env())
|
|
125
|
+
r.claim(hid)
|
|
126
|
+
r.store.cx.execute("UPDATE ownership SET lease_expires_at=? WHERE hid=?",
|
|
127
|
+
(time.time() - 1, hid))
|
|
128
|
+
sup = Supervisor3(root, supervisor_id="s")
|
|
129
|
+
sup.ensure_leadership()
|
|
130
|
+
sup.scan()
|
|
131
|
+
winners = []
|
|
132
|
+
|
|
133
|
+
def race(i):
|
|
134
|
+
try:
|
|
135
|
+
Runner3(root, runner_id=f"r{i}").recover(hid)
|
|
136
|
+
winners.append(i)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
ts = [threading.Thread(target=race, args=(i,)) for i in range(8)]
|
|
141
|
+
[t.start() for t in ts]
|
|
142
|
+
[t.join() for t in ts]
|
|
143
|
+
assert len(winners) == 1, f"{len(winners)} winners"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# 7. Fencing: stale token cannot commit after orphan+recover.
|
|
147
|
+
def t_fencing(root):
|
|
148
|
+
r = Runner3(root, runner_id="w")
|
|
149
|
+
hid = r.dispatch(env())
|
|
150
|
+
ctx = r.claim(hid)
|
|
151
|
+
ctx.checkpoint({"s": 1})
|
|
152
|
+
r.store.cx.execute("UPDATE ownership SET lease_expires_at=? WHERE hid=?",
|
|
153
|
+
(time.time() - 1, hid))
|
|
154
|
+
sup = Supervisor3(root, supervisor_id="s")
|
|
155
|
+
sup.ensure_leadership()
|
|
156
|
+
sup.scan()
|
|
157
|
+
Runner3(root, runner_id="w2").recover(hid) # new owner, fence bumped
|
|
158
|
+
try:
|
|
159
|
+
ctx.checkpoint({"s": 2}) # stale token
|
|
160
|
+
except FencingError:
|
|
161
|
+
return
|
|
162
|
+
raise AssertionError("stale fence accepted")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# 8. Leader election: exactly one leader; death -> promotion.
|
|
166
|
+
def t_leader_election(root):
|
|
167
|
+
Runner3(root, runner_id="x") # init db
|
|
168
|
+
s1 = Supervisor3(root, supervisor_id="s1")
|
|
169
|
+
s2 = Supervisor3(root, supervisor_id="s2")
|
|
170
|
+
s1.ensure_leadership()
|
|
171
|
+
s2.ensure_leadership()
|
|
172
|
+
assert sum(s.is_leader for s in (s1, s2)) == 1
|
|
173
|
+
dead = "s1" if s1.is_leader else "s2"
|
|
174
|
+
cx = s1.store.cx
|
|
175
|
+
cx.execute("UPDATE leadership SET expires_at=? WHERE holder=?",
|
|
176
|
+
(time.time() - 1, dead))
|
|
177
|
+
(s2 if dead == "s1" else s1).ensure_leadership()
|
|
178
|
+
info = s1.store.get_leadership()
|
|
179
|
+
assert info["holder"] != dead and info["expires_at"] > time.time()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# 9. Transport: ingest reconstructs exact state; idempotent.
|
|
183
|
+
def t_transport(root):
|
|
184
|
+
r = Runner3(root + "/a", runner_id="w")
|
|
185
|
+
hid = r.dispatch(env())
|
|
186
|
+
ctx = r.claim(hid)
|
|
187
|
+
log = root + "/t.log"
|
|
188
|
+
T.ship_envelope(log, r._load(hid))
|
|
189
|
+
for i in range(3):
|
|
190
|
+
seq = ctx.checkpoint({"s": i})
|
|
191
|
+
T.ship_checkpoint(log, seq, {"s": i})
|
|
192
|
+
r2 = Runner3(root + "/b", runner_id="w2")
|
|
193
|
+
out = T.ingest_log(r2, log)
|
|
194
|
+
assert out["handoff_id"] == hid, out
|
|
195
|
+
assert r2.last_checkpoint(hid) == {"s": 2}
|
|
196
|
+
T.ingest_log(r2, log) # re-ingest: no dupes
|
|
197
|
+
n = r2.store.cx.execute(
|
|
198
|
+
"SELECT COUNT(*) FROM checkpoints WHERE hid=?", (hid,)).fetchone()[0]
|
|
199
|
+
assert n == 3, n
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# 10. DLQ: max_attempts exhausted -> failed, not silently dropped.
|
|
203
|
+
def t_dlq(root):
|
|
204
|
+
r = Runner3(root, runner_id="w")
|
|
205
|
+
e = E.build(task_kind="t", inputs={"inline": {"n": 1}},
|
|
206
|
+
output_schema=SCHEMA,
|
|
207
|
+
budget={"max_steps": 10, "max_spend_usd": 1.0,
|
|
208
|
+
"max_wallclock_s": 60.0, "max_attempts": 1})
|
|
209
|
+
hid = r.dispatch(e)
|
|
210
|
+
r.claim(hid)
|
|
211
|
+
r.store.cx.execute("UPDATE ownership SET lease_expires_at=? WHERE hid=?",
|
|
212
|
+
(time.time() - 1, hid))
|
|
213
|
+
sup = Supervisor3(root, supervisor_id="s")
|
|
214
|
+
sup.ensure_leadership()
|
|
215
|
+
assert sup.scan()[0]["action"] == "orphaned"
|
|
216
|
+
Runner3(root, runner_id="w2").recover(hid) # attempt -> 1
|
|
217
|
+
r.store.cx.execute("UPDATE ownership SET lease_expires_at=? WHERE hid=?",
|
|
218
|
+
(time.time() - 1, hid))
|
|
219
|
+
acts = sup.scan()
|
|
220
|
+
assert acts and acts[0]["action"] == "dlq", acts
|
|
221
|
+
assert r._load(hid)["execution"]["status"] == "failed"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def main():
|
|
225
|
+
print("DHP conformance suite (v0.4):")
|
|
226
|
+
for name, fn in [
|
|
227
|
+
("no-lost-results", t_no_lost_results),
|
|
228
|
+
("no-crossed-inputs", t_no_crossed_inputs),
|
|
229
|
+
("no-untyped-results", t_no_untyped_results),
|
|
230
|
+
("no-silent-budget-overrun", t_no_silent_overrun),
|
|
231
|
+
("no-runner-lock-in", t_no_runner_lockin),
|
|
232
|
+
("exactly-one-wins", t_exactly_one_wins),
|
|
233
|
+
("fencing", t_fencing),
|
|
234
|
+
("leader-election", t_leader_election),
|
|
235
|
+
("transport", t_transport),
|
|
236
|
+
("dead-letter-queue", t_dlq),
|
|
237
|
+
]:
|
|
238
|
+
check(name, fn)
|
|
239
|
+
print(f"\n{len(PASS)} passed, {len(FAIL)} failed")
|
|
240
|
+
sys.exit(1 if FAIL else 0)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
main()
|
dhp/demo.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""DHP v0.1 dogfood: fan out, kill a worker mid-run, resume elsewhere.
|
|
2
|
+
|
|
3
|
+
Proves the five guarantees against a real (simulated) worker death:
|
|
4
|
+
1. no lost results 4. no silent budget overrun
|
|
5
|
+
2. no crossed inputs 5. no runner lock-in (portable suspend/resume)
|
|
6
|
+
3. no untyped results
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
13
|
+
|
|
14
|
+
from dhp import envelope as E
|
|
15
|
+
from dhp.runner import LocalRunner, RunnerError, BudgetExceeded
|
|
16
|
+
|
|
17
|
+
ROOT = "/tmp/dhp-demo"
|
|
18
|
+
ROOT2 = "/tmp/dhp-demo-2"
|
|
19
|
+
SCHEMA = {"type": "object",
|
|
20
|
+
"properties": {"total": {"type": "integer"},
|
|
21
|
+
"chunks_done": {"type": "integer"}},
|
|
22
|
+
"required": ["total", "chunks_done"]}
|
|
23
|
+
BUDGET = {"max_steps": 1000, "max_spend_usd": 50.0, "max_wallclock_s": 600.0}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def sum_job(lo, hi):
|
|
27
|
+
return E.build(task_kind="sum.range",
|
|
28
|
+
inputs={"inline": {"lo": lo, "hi": hi}},
|
|
29
|
+
output_schema=SCHEMA, budget=BUDGET)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def work_range(ctx, lo, hi, die_after_checkpoints=None):
|
|
33
|
+
"""Checkpoint every 25 numbers. Optionally die (simulated kill)."""
|
|
34
|
+
state = ctx.last_checkpoint() or {"next_i": lo, "acc": 0}
|
|
35
|
+
i, acc, done_chunks = state["next_i"], state["acc"], 0
|
|
36
|
+
cps = 0
|
|
37
|
+
while i < hi:
|
|
38
|
+
for _ in range(25):
|
|
39
|
+
if i >= hi:
|
|
40
|
+
break
|
|
41
|
+
acc += i
|
|
42
|
+
i += 1
|
|
43
|
+
ctx.note_step()
|
|
44
|
+
ctx.checkpoint({"next_i": i, "acc": acc})
|
|
45
|
+
done_chunks += 1
|
|
46
|
+
cps += 1
|
|
47
|
+
if die_after_checkpoints and cps >= die_after_checkpoints:
|
|
48
|
+
raise RuntimeError("worker killed mid-run (simulated)")
|
|
49
|
+
return {"total": acc, "chunks_done": done_chunks}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main():
|
|
53
|
+
for d in (ROOT, ROOT2):
|
|
54
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
55
|
+
|
|
56
|
+
r1 = LocalRunner(ROOT, runner_id="runner-A")
|
|
57
|
+
h1 = r1.dispatch(sum_job(0, 100))
|
|
58
|
+
h2 = r1.dispatch(sum_job(100, 200))
|
|
59
|
+
h3 = r1.dispatch(sum_job(200, 300))
|
|
60
|
+
|
|
61
|
+
# worker A: h1 fully
|
|
62
|
+
ctx = r1.claim(h1)
|
|
63
|
+
r1.complete(h1, work_range(ctx, 0, 100))
|
|
64
|
+
r1.ack(h1)
|
|
65
|
+
|
|
66
|
+
# worker B: h2, DIES after 2 checkpoints (50 of 100 numbers done)
|
|
67
|
+
ctx = r1.claim(h2)
|
|
68
|
+
try:
|
|
69
|
+
work_range(ctx, 100, 200, die_after_checkpoints=2)
|
|
70
|
+
raise AssertionError("worker should have died")
|
|
71
|
+
except RuntimeError as e:
|
|
72
|
+
print(f" [supervisor] {e} — handoff {h2[:12]} orphaned mid-run")
|
|
73
|
+
|
|
74
|
+
# supervisor: fresh runner picks up the same handoff, resumes
|
|
75
|
+
r2 = LocalRunner(ROOT, runner_id="runner-B") # same root, new runner
|
|
76
|
+
ctx2 = r2.recover(h2)
|
|
77
|
+
resumed_from = ctx2.last_checkpoint()
|
|
78
|
+
assert resumed_from["next_i"] == 150, resumed_from
|
|
79
|
+
assert resumed_from["acc"] == sum(range(100, 150)), resumed_from
|
|
80
|
+
r2.complete(h2, work_range(ctx2, 100, 200))
|
|
81
|
+
r2.ack(h2)
|
|
82
|
+
print(f" [supervisor] resumed from checkpoint i={resumed_from['next_i']}, "
|
|
83
|
+
f"zero numbers recomputed before it")
|
|
84
|
+
|
|
85
|
+
# worker C: h3 fully
|
|
86
|
+
ctx = r1.claim(h3)
|
|
87
|
+
r1.complete(h3, work_range(ctx, 200, 300))
|
|
88
|
+
r1.ack(h3)
|
|
89
|
+
|
|
90
|
+
# cross-root portability: suspend a live handoff, resume on another root
|
|
91
|
+
h4 = r1.dispatch(sum_job(300, 400))
|
|
92
|
+
ctx = r1.claim(h4)
|
|
93
|
+
# do one checkpoint manually, then suspend mid-run
|
|
94
|
+
st = {"next_i": 325, "acc": sum(range(300, 325))}
|
|
95
|
+
ctx.checkpoint(st)
|
|
96
|
+
bundle = r1.suspend(h4)
|
|
97
|
+
r3 = LocalRunner(ROOT2, runner_id="runner-C")
|
|
98
|
+
h4b = r3.resume(bundle)
|
|
99
|
+
assert h4b == h4
|
|
100
|
+
# resume() leaves status=running, so hand a context directly to continue
|
|
101
|
+
from dhp.runner import TaskContext
|
|
102
|
+
c4 = TaskContext(r3, h4b)
|
|
103
|
+
assert c4.last_checkpoint() == st, "checkpoint blobs must travel in the bundle"
|
|
104
|
+
r3.complete(h4b, work_range(c4, 300, 400))
|
|
105
|
+
r3.ack(h4b)
|
|
106
|
+
print(" [portability] suspend→bytes→resume on a different root: checkpoints intact")
|
|
107
|
+
|
|
108
|
+
# ---- results ----
|
|
109
|
+
assert r1.result(h1)["total"] == sum(range(0, 100)) == 4950
|
|
110
|
+
assert r2.result(h2)["total"] == sum(range(100, 200)) == 14950
|
|
111
|
+
assert r1.result(h3)["total"] == sum(range(200, 300)) == 24950
|
|
112
|
+
assert r3.result(h4)["total"] == sum(range(300, 400)) == 34950
|
|
113
|
+
for r, h in ((r1, h1), (r2, h2), (r1, h3), (r3, h4)):
|
|
114
|
+
env = r._load(h)
|
|
115
|
+
assert env["delivery"]["outbox"]["ack"] is True
|
|
116
|
+
print(" [results] all four totals correct, all outboxes acked")
|
|
117
|
+
|
|
118
|
+
# ---- negative tests ----
|
|
119
|
+
bad = sum_job(0, 10)
|
|
120
|
+
bad["task"]["inputs"]["inline"]["lo"] = 999 # tamper after build
|
|
121
|
+
try:
|
|
122
|
+
r1.dispatch(bad)
|
|
123
|
+
raise AssertionError("tampered dispatch should be rejected")
|
|
124
|
+
except RunnerError:
|
|
125
|
+
print(" [negative] tampered inputs rejected at dispatch (no crossed wires)")
|
|
126
|
+
|
|
127
|
+
h5 = r1.dispatch(sum_job(0, 10))
|
|
128
|
+
ctx = r1.claim(h5)
|
|
129
|
+
try:
|
|
130
|
+
r1.complete(h5, {"wrong_shape": True})
|
|
131
|
+
raise AssertionError("invalid result should be rejected")
|
|
132
|
+
except E.EnvelopeError:
|
|
133
|
+
print(" [negative] schema-violating result rejected at completion")
|
|
134
|
+
|
|
135
|
+
tiny = E.build(task_kind="sum.range", inputs={"inline": {"lo": 0, "hi": 5}},
|
|
136
|
+
output_schema=SCHEMA,
|
|
137
|
+
budget={"max_steps": 3, "max_spend_usd": 50.0,
|
|
138
|
+
"max_wallclock_s": 600.0})
|
|
139
|
+
h6 = r1.dispatch(tiny)
|
|
140
|
+
ctx = r1.claim(h6)
|
|
141
|
+
try:
|
|
142
|
+
for _ in range(5):
|
|
143
|
+
ctx.note_step()
|
|
144
|
+
raise AssertionError("budget should have blown")
|
|
145
|
+
except BudgetExceeded:
|
|
146
|
+
print(" [negative] budget overrun raised by the runtime, not the prompt")
|
|
147
|
+
|
|
148
|
+
print("\nDHP v0.1 dogfood: ALL GREEN — worker death lost zero work, "
|
|
149
|
+
"every result typed, every handoff acked.")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
main()
|