Skip to content

Commit f3a3bde

Browse files
authored
Add START_TIMEOUT (#688)
Co-authored-by: James Tufarelli <minituff@users.noreply.github.com>
1 parent f139b8d commit f3a3bde

6 files changed

Lines changed: 120 additions & 7 deletions

File tree

app/backup.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -471,24 +471,30 @@ def _stop_container(self, c: Container, attempt=1) -> bool:
471471
self._stop_container(c, attempt=attempt + 1)
472472
return False
473473

474-
def _start_container(self, c: Container, attempt=1) -> bool:
474+
def _start_container(self, c: Container, attempt=1, max_attempts: Optional[int] = None) -> bool:
475+
if max_attempts is None:
476+
start_timeout = int(self.get_label(c, "start-timeout", str(self.env.START_TIMEOUT)))
477+
max_attempts = max(1, start_timeout // 2)
478+
475479
c.reload() # Refresh the status for this container
476480
status = c.status # Read once to avoid consuming multiple mock cycles
477481

478482
if status == "running":
479483
if attempt == 1:
480484
self.log_this(f"Container {c.name} was not stopped. No need to start.", "DEBUG")
485+
else:
486+
self.log_this(f"Container {c.name} is now running.", "INFO")
481487
return True
482488

483489
if status != "exited":
484490
# Transitional state: Docker statuses are created/restarting/running/paused/exited/dead
485-
if attempt <= 3:
491+
if attempt <= max_attempts:
486492
self.log_this(
487-
f"Container {c.name} is in '{status}' state, waiting for it to stabilize (Attempt {attempt}/3)",
493+
f"Container {c.name} is in '{status}' state, waiting for it to stabilize (Attempt {attempt}/{max_attempts})",
488494
"WARN",
489495
)
490496
time.sleep(2)
491-
return self._start_container(c, attempt=attempt + 1)
497+
return self._start_container(c, attempt=attempt + 1, max_attempts=max_attempts)
492498
return False
493499

494500
try:
@@ -505,10 +511,14 @@ def _start_container(self, c: Container, attempt=1) -> bool:
505511
status = c.status # Read once to avoid consuming multiple mock cycles
506512

507513
if status == "running":
514+
if attempt > 1:
515+
self.log_this(f"Container {c.name} is now running.", "INFO")
508516
return True
509-
elif attempt <= 3:
510-
self.log_this(f"Container {c.name} was not in running state. Trying again (Attempt {attempt}/3)", "ERROR")
511-
return self._start_container(c, attempt=attempt + 1)
517+
elif attempt <= max_attempts:
518+
self.log_this(
519+
f"Container {c.name} was not in running state. Trying again (Attempt {attempt}/{max_attempts})", "WARN"
520+
)
521+
return self._start_container(c, attempt=attempt + 1, max_attempts=max_attempts)
512522
return False
513523

514524
def _get_src_dir(self, c: Container, log=False) -> Tuple[Path, str]:

app/defaults.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ LABEL_PREFIX=nautical-backup
5151
# How long to wait for a container to stop before killing it
5252
STOP_TIMEOUT=10
5353

54+
# How long to wait for a container to reach running state after startup
55+
START_TIMEOUT=10
56+
5457
# Set the default log level to INFO
5558
LOG_LEVEL=INFO
5659

app/nautical_env.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def __init__(self) -> None:
6969
self.REPORT_FILE = False
7070

7171
self.STOP_TIMEOUT = int(os.environ.get("STOP_TIMEOUT", 10))
72+
self.START_TIMEOUT = int(os.environ.get("START_TIMEOUT", 10))
7273

7374
_keep = os.environ.get("NUMBER_OF_BACKUPS_TO_KEEP", "0")
7475
self.NUMBER_OF_BACKUPS_TO_KEEP = int(_keep) if _keep.isdigit() else 0

docs/arguments.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,17 @@ STOP_TIMEOUT=10
569569

570570
<small>🔄 This is the same action as the [Stop Timeout](./labels.md#stop-timeout) label, but applied globally.</small>
571571

572+
## Start Timeout
573+
Nautical will check every 2 seconds whether a container has reached a running state after startup, giving up after *x* total seconds.
574+
575+
> **Default**: 10 <small>(seconds)</small>
576+
577+
```properties
578+
START_TIMEOUT=10
579+
```
580+
581+
<small>🔄 This is the same action as the [Start Timeout](./labels.md#start-timeout) label, but applied globally.</small>
582+
572583
## Backup on Start
573584
Nautical will immediately perform a backup when the container is started in addition to the CRON scheduled backup.
574585

docs/labels.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,17 @@ nautical-backup.stop-timeout=10
155155

156156
<small>🔄 This is a similar action to the [Stop Timeout](./arguments.md#stop-timeout) variable, but applied only to this container.</small>
157157

158+
## Start Timeout
159+
Nautical will check every 2 seconds whether a container has reached a running state after startup, giving up after *x* total seconds.
160+
161+
> **Default If Missing**: [Start Timeout](./arguments.md#start-timeout) Environment value<small> (Defaults to 10 seconds)</small>
162+
163+
```properties
164+
nautical-backup.start-timeout=10
165+
```
166+
167+
<small>🔄 This is a similar action to the [Start Timeout](./arguments.md#start-timeout) variable, but applied only to this container.</small>
168+
158169
## Groups
159170
Use this label to have multiple containers stopped, backed up, and restarted at the same time.
160171

pytest/test_backup.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2948,6 +2948,83 @@ def test_start_container_transitional_state_eventually_running(
29482948
assert "container1" in nb.containers_completed
29492949
assert "container1" not in nb.containers_failed
29502950

2951+
@mock.patch("subprocess.run")
2952+
@pytest.mark.parametrize(
2953+
"mock_container1",
2954+
[
2955+
{
2956+
"name": "container1",
2957+
"id": "123456789",
2958+
# Reads: stop-check, after-stop, pre-rsync, start-attempt-1,
2959+
# after-c.start (restarting), retry-2 (restarting), retry-3 (restarting), retry-4 (running)
2960+
# Mirrors the exact sequence from issue #683 (2026-05-25 comment)
2961+
"status_side_effect": [
2962+
"running",
2963+
"exited",
2964+
"exited",
2965+
"exited",
2966+
"restarting",
2967+
"restarting",
2968+
"restarting",
2969+
"running",
2970+
],
2971+
}
2972+
],
2973+
indirect=True,
2974+
)
2975+
def test_start_container_three_restarting_states_eventually_running(
2976+
self,
2977+
mock_subprocess_run: MagicMock,
2978+
mock_docker_client: MagicMock,
2979+
mock_container1: MagicMock,
2980+
):
2981+
"""Container that stays in 'restarting' for 3 cycles before becoming running should still
2982+
complete successfully. Reproduces the scenario from issue #683 (2026-05-25)."""
2983+
mock_subprocess_run.return_value.returncode = 0
2984+
2985+
mock_docker_client.containers.list.return_value = [mock_container1]
2986+
nb = NauticalBackup(mock_docker_client)
2987+
nb.backup()
2988+
2989+
mock_container1.stop.assert_called()
2990+
mock_container1.start.assert_called()
2991+
assert "container1" in nb.containers_completed
2992+
assert "container1" not in nb.containers_failed
2993+
2994+
@mock.patch("subprocess.run")
2995+
@pytest.mark.parametrize(
2996+
"mock_container1",
2997+
[
2998+
{
2999+
"name": "container1",
3000+
"id": "123456789",
3001+
"labels": {"nautical-backup.start-timeout": "10"},
3002+
# Needs 2 retries after c.start() to reach running.
3003+
# With START_TIMEOUT=2 (max_attempts=1) this would fail;
3004+
# the label overrides to 10s (max_attempts=5) so it succeeds.
3005+
"status_side_effect": ["running", "exited", "exited", "exited", "restarting", "restarting", "running"],
3006+
}
3007+
],
3008+
indirect=True,
3009+
)
3010+
def test_START_TIMEOUT_label_supersedes_env(
3011+
self,
3012+
mock_subprocess_run: MagicMock,
3013+
mock_docker_client: MagicMock,
3014+
mock_container1: MagicMock,
3015+
monkeypatch: pytest.MonkeyPatch,
3016+
):
3017+
"""Per-container start-timeout label should override the global START_TIMEOUT env var."""
3018+
mock_subprocess_run.return_value.returncode = 0
3019+
monkeypatch.setenv("START_TIMEOUT", "2") # max_attempts=1 — would fail without the label
3020+
3021+
mock_docker_client.containers.list.return_value = [mock_container1]
3022+
nb = NauticalBackup(mock_docker_client)
3023+
nb.backup()
3024+
3025+
assert "container1" in nb.containers_completed
3026+
assert "container1" not in nb.containers_failed
3027+
29513028
@mock.patch("subprocess.run")
29523029
@pytest.mark.parametrize(
29533030
"mock_container1",

0 commit comments

Comments
 (0)