Spaces:
Running
Running
Keep watchdog heartbeat independent of control requests
Browse files- src/lumi_eggcracker/supervisor.py +20 -2
- tests/test_supervisor.py +10 -0
src/lumi_eggcracker/supervisor.py
CHANGED
|
@@ -190,6 +190,7 @@ class Supervisor:
|
|
| 190 |
self.discovery_failures = 0
|
| 191 |
self.heartbeat_sequence = 0
|
| 192 |
self.last_heartbeat_sent = 0.0
|
|
|
|
| 193 |
|
| 194 |
@property
|
| 195 |
def operator_uid(self) -> int:
|
|
@@ -611,6 +612,11 @@ class Supervisor:
|
|
| 611 |
# The independent watchdog decides whether the missing heartbeat is fatal.
|
| 612 |
pass
|
| 613 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
def _cleanup(self, unit: str) -> dict[str, Any]:
|
| 615 |
result = self._run(["/usr/bin/systemctl", "stop", unit])
|
| 616 |
return {
|
|
@@ -938,7 +944,18 @@ class Supervisor:
|
|
| 938 |
continue
|
| 939 |
recorded_ids.add(record["run_id"])
|
| 940 |
if record["state"] in ACTIVE_STATES:
|
| 941 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 942 |
root = Path("/sys/fs/cgroup/system.slice")
|
| 943 |
if not root.is_dir():
|
| 944 |
return
|
|
@@ -1057,6 +1074,8 @@ class Supervisor:
|
|
| 1057 |
self._prepare()
|
| 1058 |
self.discovery_thread = threading.Thread(target=self._discovery_loop, daemon=True)
|
| 1059 |
self.discovery_thread.start()
|
|
|
|
|
|
|
| 1060 |
listeners: dict[socket.socket, Path] = {}
|
| 1061 |
try:
|
| 1062 |
for path in SOCKET_ACTIONS:
|
|
@@ -1069,7 +1088,6 @@ class Supervisor:
|
|
| 1069 |
listeners[listener] = path
|
| 1070 |
while not self.stop_event.is_set():
|
| 1071 |
ready, _, _ = select.select(listeners, [], [], 0.25)
|
| 1072 |
-
self._heartbeat()
|
| 1073 |
for listener in ready:
|
| 1074 |
try:
|
| 1075 |
connection, _ = listener.accept()
|
|
|
|
| 190 |
self.discovery_failures = 0
|
| 191 |
self.heartbeat_sequence = 0
|
| 192 |
self.last_heartbeat_sent = 0.0
|
| 193 |
+
self.heartbeat_thread: threading.Thread | None = None
|
| 194 |
|
| 195 |
@property
|
| 196 |
def operator_uid(self) -> int:
|
|
|
|
| 612 |
# The independent watchdog decides whether the missing heartbeat is fatal.
|
| 613 |
pass
|
| 614 |
|
| 615 |
+
def _heartbeat_loop(self) -> None:
|
| 616 |
+
while not self.stop_event.is_set():
|
| 617 |
+
self._heartbeat()
|
| 618 |
+
self.stop_event.wait(0.05)
|
| 619 |
+
|
| 620 |
def _cleanup(self, unit: str) -> dict[str, Any]:
|
| 621 |
result = self._run(["/usr/bin/systemctl", "stop", unit])
|
| 622 |
return {
|
|
|
|
| 944 |
continue
|
| 945 |
recorded_ids.add(record["run_id"])
|
| 946 |
if record["state"] in ACTIVE_STATES:
|
| 947 |
+
try:
|
| 948 |
+
self._contain(record, "SUPERVISOR_RESTART_FAIL_CLOSED")
|
| 949 |
+
except JsonInputError as error:
|
| 950 |
+
# The transient unit may have been collected after its
|
| 951 |
+
# cgroup was proven empty but before the restart scan.
|
| 952 |
+
# It is not a live workload and must not crash recovery.
|
| 953 |
+
if "owned cgroup is unavailable" not in str(error):
|
| 954 |
+
raise
|
| 955 |
+
_empty_ns, proof = verify_empty(identity_from_run(record))
|
| 956 |
+
if not proof.complete:
|
| 957 |
+
raise
|
| 958 |
+
self._mark_completed(record)
|
| 959 |
root = Path("/sys/fs/cgroup/system.slice")
|
| 960 |
if not root.is_dir():
|
| 961 |
return
|
|
|
|
| 1074 |
self._prepare()
|
| 1075 |
self.discovery_thread = threading.Thread(target=self._discovery_loop, daemon=True)
|
| 1076 |
self.discovery_thread.start()
|
| 1077 |
+
self.heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
|
| 1078 |
+
self.heartbeat_thread.start()
|
| 1079 |
listeners: dict[socket.socket, Path] = {}
|
| 1080 |
try:
|
| 1081 |
for path in SOCKET_ACTIONS:
|
|
|
|
| 1088 |
listeners[listener] = path
|
| 1089 |
while not self.stop_event.is_set():
|
| 1090 |
ready, _, _ = select.select(listeners, [], [], 0.25)
|
|
|
|
| 1091 |
for listener in ready:
|
| 1092 |
try:
|
| 1093 |
connection, _ = listener.accept()
|
tests/test_supervisor.py
CHANGED
|
@@ -67,6 +67,16 @@ class SupervisorTests(unittest.TestCase):
|
|
| 67 |
stored.assert_called_once()
|
| 68 |
self.assertEqual("COMPLETED_ALLOWED", item["state"])
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
def test_status_is_read_only(self) -> None:
|
| 71 |
supervisor = self._instance()
|
| 72 |
item = record()
|
|
|
|
| 67 |
stored.assert_called_once()
|
| 68 |
self.assertEqual("COMPLETED_ALLOWED", item["state"])
|
| 69 |
|
| 70 |
+
def test_recovery_accepts_only_an_exact_empty_collected_cgroup(self) -> None:
|
| 71 |
+
supervisor = self._instance()
|
| 72 |
+
item = record()
|
| 73 |
+
with tempfile.TemporaryDirectory() as raw:
|
| 74 |
+
supervisor.runs = Path(raw)
|
| 75 |
+
(supervisor.runs / ("a" * 24 + ".json")).write_text(__import__("json").dumps(item), encoding="utf-8")
|
| 76 |
+
with patch.object(supervisor, "_contain", side_effect=JsonInputError("containment failed: owned cgroup is unavailable")), patch("lumi_eggcracker.supervisor.verify_empty", return_value=(1, EmptyProof(True, 0, 0, []))), patch.object(supervisor, "_mark_completed") as completed:
|
| 77 |
+
supervisor._recover()
|
| 78 |
+
completed.assert_called_once_with(item)
|
| 79 |
+
|
| 80 |
def test_status_is_read_only(self) -> None:
|
| 81 |
supervisor = self._instance()
|
| 82 |
item = record()
|