|
|
@@ -219,6 +219,9 @@ class FakeSaddle:
|
|
|
self.abort_failures = 0 # /abort requests failing (500) while > 0
|
|
|
self.abort_log = [] # task ids aborted via POST /abort
|
|
|
self.running_task = 0 # task id the daemon believes it runs
|
|
|
+ self.zombie_409 = 0 # /start 409s once per unit (then decrements)
|
|
|
+ self.conflict_running = 0 # while > 0, /start persistently 409s with this id
|
|
|
+ self.hold_state = False # freeze /status lifecycle transitions
|
|
|
|
|
|
def set_state(self, state, summary=None):
|
|
|
"""Advances the scripted lifecycle.
|
|
|
@@ -287,6 +290,21 @@ class FakeSaddle:
|
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
|
raw = self.rfile.read(length).decode() if length else "{}"
|
|
|
if self.path == "/start":
|
|
|
+ if self.daemon.conflict_running > 0 or self.daemon.zombie_409 > 0:
|
|
|
+ # Wire contract: a machine-readable conflict.
|
|
|
+ running = self.daemon.conflict_running
|
|
|
+ if running == 0:
|
|
|
+ running = self.daemon.running_task
|
|
|
+ self.daemon.zombie_409 -= 1
|
|
|
+ body = json.dumps({"ok": False, "error": "busy",
|
|
|
+ "state": "working",
|
|
|
+ "running_task_id": running}).encode()
|
|
|
+ self.send_response(409)
|
|
|
+ self.send_header("Content-Type", "application/json")
|
|
|
+ self.send_header("Content-Length", str(len(body)))
|
|
|
+ self.end_headers()
|
|
|
+ self.wfile.write(body)
|
|
|
+ return
|
|
|
try:
|
|
|
payload = json.loads(raw)
|
|
|
except ValueError:
|
|
|
@@ -343,6 +361,9 @@ class FakeSaddle:
|
|
|
if self.daemon.resumed_pending:
|
|
|
response["resumed"] = True
|
|
|
self.daemon.resumed_pending = False
|
|
|
+ if self.daemon.hold_state:
|
|
|
+ self._send(200, response)
|
|
|
+ return
|
|
|
if self.daemon.state == "failing":
|
|
|
# Fails fast (a rejected /start script).
|
|
|
if time.monotonic() - self.daemon.state_entered >= 1:
|
|
|
@@ -1973,6 +1994,97 @@ def main():
|
|
|
check(wait_task_done(auth, during_fence, timeout=60) is not None,
|
|
|
"queued work dispatches once the fence clears")
|
|
|
|
|
|
+
|
|
|
+ print("== R7 B2: 409-aware dispatch ==")
|
|
|
+ # Zombie: the daemon (per the fake) still runs an old completed task;
|
|
|
+ # one 409, then abort + retry recovers without churning sessions.
|
|
|
+ fake.state = "idle"
|
|
|
+ fake.zombie_409 = 1
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Zombie recovery",
|
|
|
+ "priority": "2",
|
|
|
+ })
|
|
|
+ zombie_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "Zombie recovery"][0])
|
|
|
+ check(wait_task_done(auth, zombie_task, timeout=90) is not None,
|
|
|
+ "zombie conflict recovers and the task completes")
|
|
|
+ detail = task_state(auth, zombie_task)
|
|
|
+ check(any("recovered zombie session" in c["body"] for c in detail.get("comments", [])),
|
|
|
+ "the zombie recovery is narrated")
|
|
|
+ conn = db()
|
|
|
+ zombie_sessions = conn.execute(
|
|
|
+ "select count(*) from sessions where task_id = ?", (zombie_task,)).fetchone()[0]
|
|
|
+ conn.close()
|
|
|
+ check(zombie_sessions == 1,
|
|
|
+ "no session churn: one row across the failed and retried push (%d)"
|
|
|
+ % zombie_sessions)
|
|
|
+
|
|
|
+ # Live conflict: the daemon claims to run a task the web still sees
|
|
|
+ # live — fence + alert, never abort live work.
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Conflict live task",
|
|
|
+ "priority": "2",
|
|
|
+ })
|
|
|
+ conflict_live = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "Conflict live task"][0])
|
|
|
+ check(wait_for(lambda: task_state(auth, conflict_live).get("status") == "working"
|
|
|
+ or False, timeout=60, what="conflict task working") is not None,
|
|
|
+ "live task reaches working")
|
|
|
+ # Hold the daemon mid-working and desynchronise the web's view:
|
|
|
+ # double-booking means the web believes the saddle free while the
|
|
|
+ # daemon still claims the live task.
|
|
|
+ fake.hold_state = True
|
|
|
+ fake.conflict_running = conflict_live
|
|
|
+ conn = db()
|
|
|
+ conn.execute("update saddles set status = 'idle', current_task_id = 0,"
|
|
|
+ " allocated_at = null where name = 'fake-saddle'")
|
|
|
+ conn.commit()
|
|
|
+ conn.close()
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Conflict newcomer",
|
|
|
+ "priority": "3",
|
|
|
+ })
|
|
|
+ conflict_new = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "Conflict newcomer"][0])
|
|
|
+ check(wait_for(lambda: saddle_card("fake-saddle")["status"] == "recovering" or False,
|
|
|
+ timeout=30, what="double-booked saddle fenced") is not None,
|
|
|
+ "double-booked saddle is fenced")
|
|
|
+ check(any("double-booked" in c["body"]
|
|
|
+ for c in task_state(auth, conflict_live).get("comments", [])),
|
|
|
+ "the live task gets the alert comment")
|
|
|
+ check(any("dispatch refused" in c["body"]
|
|
|
+ for c in task_state(auth, conflict_new).get("comments", [])),
|
|
|
+ "the refused task gets its comment")
|
|
|
+ conn = db()
|
|
|
+ new_sessions = conn.execute(
|
|
|
+ "select outcome from sessions where task_id = ?", (conflict_new,)).fetchall()
|
|
|
+ conn.close()
|
|
|
+ check(len(new_sessions) == 1 and new_sessions[0][0] == "push_failed",
|
|
|
+ "failed push marks the session push_failed (no churn)")
|
|
|
+ check(conflict_live not in fake.abort_log,
|
|
|
+ "the live task is never aborted")
|
|
|
+ fake.conflict_running = 0
|
|
|
+ fake.hold_state = False
|
|
|
+ fake.state = "idle"
|
|
|
+ act(auth, task_state(auth, conflict_live)["complete"],
|
|
|
+ {"summary": "finished before the conflict"})
|
|
|
+ check(wait_for(lambda: task_state(auth, conflict_live).get("status") == "done"
|
|
|
+ or False, timeout=30, what="live task completed") is not None,
|
|
|
+ "the live task completes without an abort")
|
|
|
+ check(wait_for(lambda: saddle_card("fake-saddle")["status"] == "idle" or False,
|
|
|
+ timeout=40, what="fence clears after conflict") is not None,
|
|
|
+ "fence clears once the daemon goes idle")
|
|
|
+ conn = db()
|
|
|
+ after_clear = conn.execute(
|
|
|
+ "select count(*) from sessions where task_id = ?", (conflict_new,)).fetchone()[0]
|
|
|
+ conn.close()
|
|
|
+ check(after_clear == 1, "still one session row for the refused task")
|
|
|
+ check(wait_task_done(auth, conflict_new, timeout=90) is not None,
|
|
|
+ "the refused task dispatches after the fence clears")
|
|
|
+
|
|
|
finally:
|
|
|
# Exit-code diagnostic: distinguishes a crashed app (negative
|
|
|
# returncode, e.g. -11 SIGSEGV) from a clean shutdown. When
|