noqt commited on
Commit
e198ef6
·
1 Parent(s): 372f370

Keep watchdog heartbeat independent of control requests

Browse files
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
- self._contain(record, "SUPERVISOR_RESTART_FAIL_CLOSED")
 
 
 
 
 
 
 
 
 
 
 
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()