Skip to content

Commit c0b60e0

Browse files
committed
Stop leading when onstarted_leading raises
The onstarted_leading callback runs in its own thread, so an exception raised there was swallowed by that thread and never observed by the renew loop. The candidate kept renewing its lease indefinitely while the work the lease was meant to protect was no longer running, and onstopped_leading was never called. Record the failure and check it in the renew loop, so the candidate stops renewing and run() invokes onstopped_leading as it already does when a renewal fails. A callback that returns normally is left alone; only the failure case reported in the issue changes.
1 parent 9bc5eac commit c0b60e0

2 files changed

Lines changed: 46 additions & 3 deletions

File tree

kubernetes/base/leaderelection/leaderelection.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,24 @@ def run(self):
5555
logger.info("{} successfully acquired lease".format(self.election_config.lock.identity))
5656

5757
# Start leading and call OnStartedLeading()
58-
threading.Thread(target=self.election_config.onstarted_leading, daemon=True).start()
58+
leading_failed = threading.Event()
59+
threading.Thread(target=self.run_onstarted_leading, args=(leading_failed,), daemon=True).start()
5960

60-
self.renew_loop()
61+
self.renew_loop(leading_failed)
6162

6263
# Failed to update lease, run OnStoppedLeading callback
6364
self.election_config.onstopped_leading()
6465

66+
def run_onstarted_leading(self, leading_failed):
67+
# Run the callback in this thread, recording whether it raised. Without
68+
# this the exception is swallowed by the worker thread and the lease keeps
69+
# being renewed even though the work it protects is no longer running.
70+
try:
71+
self.election_config.onstarted_leading()
72+
except Exception:
73+
logger.exception("onstarted_leading raised an exception, stopping leading")
74+
leading_failed.set()
75+
6576
def acquire(self):
6677
# Follower
6778
logger.info("{} is a follower".format(self.election_config.lock.identity))
@@ -75,14 +86,19 @@ def acquire(self):
7586

7687
time.sleep(retry_period)
7788

78-
def renew_loop(self):
89+
def renew_loop(self, leading_failed=None):
7990
# Leader
8091
logger.info("Leader has entered renew loop and will try to update lease continuously")
8192

8293
retry_period = self.election_config.retry_period
8394
renew_deadline = self.election_config.renew_deadline * 1000
8495

8596
while True:
97+
# onstarted_leading raised, so stop renewing a lease that no longer
98+
# protects anything and let run() call onstopped_leading.
99+
if leading_failed is not None and leading_failed.is_set():
100+
return
101+
86102
timeout = int(time.time() * 1000) + renew_deadline
87103
succeeded = False
88104

kubernetes/base/leaderelection/leaderelection_test.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import unittest
2121
import threading
2222
import json
23+
import sys
2324
import time
2425
import pytest
2526
from unittest.mock import patch
@@ -222,6 +223,32 @@ def record_thread(*args, **kwargs):
222223
self.assertIn("daemon", captured)
223224
self.assertTrue(captured["daemon"])
224225

226+
"""Expected behavior: if onstarted_leading raises, the lease it protects is no
227+
longer backed by any running work, so the candidate must stop renewing it and
228+
run onstopped_leading. The lock below never refuses a renewal, so the renew
229+
loop can only end because the callback failed."""
230+
def test_stops_leading_when_onstarted_leading_raises(self):
231+
stopped = threading.Event()
232+
233+
mock_lock = MockResourceLock("mock", "mock_namespace", "mock", thread_lock,
234+
lambda: None, lambda: None, lambda: None, None)
235+
mock_lock.renew_count_max = sys.maxsize
236+
237+
def on_started_leading():
238+
raise RuntimeError("onstarted_leading failed")
239+
240+
config = electionconfig.Config(lock=mock_lock, lease_duration=2,
241+
renew_deadline=1.5, retry_period=1.1,
242+
onstarted_leading=on_started_leading,
243+
onstopped_leading=stopped.set)
244+
245+
# Run in a daemon thread so a regression times out instead of hanging.
246+
threading.Thread(target=leaderelection.LeaderElection(config).run,
247+
daemon=True).start()
248+
249+
self.assertTrue(stopped.wait(10),
250+
"onstopped_leading was not called after onstarted_leading raised")
251+
225252
def assert_history(self, history, expected):
226253
self.assertIsNotNone(expected)
227254
self.assertIsNotNone(history)

0 commit comments

Comments
 (0)