Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit 312a389

Browse files
authored
Merge pull request #760 from mangelajo/throttle-spinner-losg
wait spinner: throttle
2 parents 54ca5b2 + 0799245 commit 312a389

2 files changed

Lines changed: 122 additions & 6 deletions

File tree

packages/jumpstarter/jumpstarter/client/lease.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ async def _acquire(self):
159159
# lease ready
160160
if condition_true(result.conditions, "Ready"):
161161
logger.debug("Lease %s acquired", self.name)
162-
spinner.update_status(f"Lease {self.name} acquired successfully!")
162+
spinner.update_status(f"Lease {self.name} acquired successfully!", force=True)
163163
self.exporter_name = result.exporter
164164
break
165165

@@ -332,6 +332,8 @@ def __init__(self, lease_name: str | None = None):
332332
self.start_time = None
333333
self._should_show_spinner = self._is_terminal_available() and not self._is_non_interactive()
334334
self._current_message = None
335+
self._last_log_time = None
336+
self._log_throttle_interval = timedelta(minutes=5)
335337

336338
def _is_non_interactive(self) -> bool:
337339
"""Check if the user desires a NONINTERACTIVE environment."""
@@ -359,18 +361,32 @@ def __exit__(self, exc_type, exc_val, exc_tb):
359361
if self.spinner:
360362
self.spinner.stop()
361363

362-
def update_status(self, message: str):
363-
"""Update the spinner status message."""
364+
def update_status(self, message: str, force: bool = False):
365+
"""Update the spinner status message.
366+
367+
:param message: The status message to display
368+
:param force: If True, always log the message even when throttling (default: False)
369+
"""
364370
if self.spinner and self._should_show_spinner:
365371
self._current_message = f"[blue]{message}[/blue]"
366372
elapsed = datetime.now() - self.start_time
367373
elapsed_str = str(elapsed).split(".")[0] # Remove microseconds
368374
self.spinner.update(f"{self._current_message} [dim]({elapsed_str})[/dim]")
369375
else:
370376
# Log info message when no console is available
371-
elapsed = datetime.now() - self.start_time
372-
elapsed_str = str(elapsed).split(".")[0] # Remove microseconds
373-
logger.info(f"{message} ({elapsed_str})")
377+
# Throttle updates to at most every 5 minutes unless forced
378+
now = datetime.now()
379+
should_log = (
380+
force
381+
or self._last_log_time is None
382+
or (now - self._last_log_time) >= self._log_throttle_interval
383+
)
384+
385+
if should_log:
386+
elapsed = now - self.start_time
387+
elapsed_str = str(elapsed).split(".")[0] # Remove microseconds
388+
logger.info(f"{message} ({elapsed_str})")
389+
self._last_log_time = now
374390

375391
def tick(self):
376392
"""Update the spinner with current elapsed time without changing the message."""

packages/jumpstarter/jumpstarter/client/lease_test.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,103 @@ def test_start_time_initialization_in_context(self):
228228
with spinner:
229229
assert spinner.start_time is not None
230230
assert isinstance(spinner.start_time, datetime)
231+
232+
def test_throttling_first_update_logged(self, caplog):
233+
"""Test that the first update is always logged when console is not available."""
234+
with patch.object(LeaseAcquisitionSpinner, '_is_terminal_available', return_value=False):
235+
spinner = LeaseAcquisitionSpinner("test-lease")
236+
spinner.start_time = datetime.now()
237+
238+
with caplog.at_level(logging.INFO):
239+
spinner.update_status("First message")
240+
241+
assert "First message" in caplog.text
242+
assert spinner._last_log_time is not None
243+
244+
def test_throttling_second_update_within_interval_not_logged(self, caplog):
245+
"""Test that updates within 5 minutes are not logged."""
246+
with patch.object(LeaseAcquisitionSpinner, '_is_terminal_available', return_value=False):
247+
spinner = LeaseAcquisitionSpinner("test-lease")
248+
spinner.start_time = datetime.now()
249+
spinner._last_log_time = datetime.now() - timedelta(minutes=2) # 2 minutes ago
250+
251+
with caplog.at_level(logging.INFO):
252+
spinner.update_status("Second message")
253+
254+
# Should not log because only 2 minutes have passed
255+
assert "Second message" not in caplog.text
256+
257+
def test_throttling_update_after_interval_logged(self, caplog):
258+
"""Test that updates after 5 minutes are logged."""
259+
with patch.object(LeaseAcquisitionSpinner, '_is_terminal_available', return_value=False):
260+
spinner = LeaseAcquisitionSpinner("test-lease")
261+
spinner.start_time = datetime.now()
262+
spinner._last_log_time = datetime.now() - timedelta(minutes=6) # 6 minutes ago
263+
264+
with caplog.at_level(logging.INFO):
265+
spinner.update_status("After interval message")
266+
267+
assert "After interval message" in caplog.text
268+
assert spinner._last_log_time is not None
269+
270+
def test_throttling_forced_update_always_logged(self, caplog):
271+
"""Test that forced updates are always logged regardless of throttle interval."""
272+
with patch.object(LeaseAcquisitionSpinner, '_is_terminal_available', return_value=False):
273+
spinner = LeaseAcquisitionSpinner("test-lease")
274+
spinner.start_time = datetime.now()
275+
spinner._last_log_time = datetime.now() - timedelta(minutes=1) # 1 minute ago
276+
277+
with caplog.at_level(logging.INFO):
278+
spinner.update_status("Forced message", force=True)
279+
280+
assert "Forced message" in caplog.text
281+
assert spinner._last_log_time is not None
282+
283+
def test_throttling_multiple_updates_only_logs_when_needed(self, caplog):
284+
"""Test that multiple rapid updates only log at appropriate intervals."""
285+
with patch.object(LeaseAcquisitionSpinner, '_is_terminal_available', return_value=False):
286+
spinner = LeaseAcquisitionSpinner("test-lease")
287+
spinner.start_time = datetime.now()
288+
289+
with caplog.at_level(logging.INFO):
290+
# First update should be logged
291+
spinner.update_status("Message 1")
292+
assert "Message 1" in caplog.text
293+
294+
# Set last log time to recent
295+
spinner._last_log_time = datetime.now() - timedelta(minutes=1)
296+
297+
# Second update should not be logged (within interval)
298+
spinner.update_status("Message 2")
299+
assert "Message 2" not in caplog.text
300+
301+
# Third update should not be logged (within interval)
302+
spinner.update_status("Message 3")
303+
assert "Message 3" not in caplog.text
304+
305+
# Set last log time to past the interval
306+
spinner._last_log_time = datetime.now() - timedelta(minutes=6)
307+
308+
# Fourth update should be logged (past interval)
309+
spinner.update_status("Message 4")
310+
assert "Message 4" in caplog.text
311+
312+
def test_throttling_not_applied_when_console_available(self):
313+
"""Test that throttling is not applied when console is available."""
314+
with patch.object(LeaseAcquisitionSpinner, '_is_terminal_available', return_value=True):
315+
spinner = LeaseAcquisitionSpinner("test-lease")
316+
spinner.start_time = datetime.now()
317+
318+
mock_spinner = Mock()
319+
spinner.spinner = mock_spinner
320+
321+
# Multiple updates should all call update() regardless of throttle
322+
spinner.update_status("Message 1")
323+
spinner.update_status("Message 2")
324+
spinner.update_status("Message 3")
325+
326+
# All should be called even if we set a recent last_log_time
327+
spinner._last_log_time = datetime.now() - timedelta(minutes=1)
328+
spinner.update_status("Message 4")
329+
330+
assert mock_spinner.update.call_count == 4

0 commit comments

Comments
 (0)