Skip to content

Commit 3e78f92

Browse files
imnasnainaecclaude
andauthored
Add timeout and retry to combine_restore kubectl cp (#4323)
Co-Authored-By: Claude Opus 4.8 (1M context) & Fable 5 <noreply@anthropic.com>
1 parent 113d6b9 commit 3e78f92

4 files changed

Lines changed: 104 additions & 16 deletions

File tree

maintenance/scripts/combine_app.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,26 @@
44

55
from enum import Enum, unique
66
import json
7+
import logging
8+
import os
79
from pathlib import Path
810
import subprocess
911
import sys
12+
import time
1013
from typing import Any, Dict, List, Optional
1114

1215
from maint_utils import run_cmd
1316

17+
# A `kubectl cp` streams a tar over the exec channel and can stall intermittently,
18+
# which would otherwise hang forever. Bound each copy with a timeout so a stalled
19+
# stream gets killed, and retry a few times so a transient stall is recovered.
20+
# Clamp to a sane floor so a misconfigured env var can't skip the copy entirely.
21+
CP_ATTEMPTS = max(1, int(os.getenv("kubectl_cp_attempts", "3")))
22+
CP_TIMEOUT_S = max(10.0, float(os.getenv("kubectl_cp_timeout_s", "300")))
23+
# A fast-failing copy can exhaust all attempts within the same second, giving a
24+
# transient failure no time to clear; pause between attempts so the retries span time.
25+
CP_RETRY_DELAY = 3.0
26+
1427

1528
class CombineApp:
1629
"""Run commands on the Combine services."""
@@ -39,6 +52,7 @@ def exec(
3952
*,
4053
exec_opts: Optional[List[str]] = None,
4154
check_results: bool = True,
55+
timeout: Optional[float] = None,
4256
) -> subprocess.CompletedProcess[str]:
4357
"""
4458
Run a kubectl 'exec' command in a Combine Kubernetes cluster.
@@ -52,6 +66,8 @@ def exec(
5266
command, for example, to specify a working directory or a
5367
specific user to run the command.
5468
check_results: Indicate if subprocess should not check for failure.
69+
timeout: If set, kill the command and raise subprocess.TimeoutExpired
70+
when it runs longer than this many seconds.
5571
Returns a subprocess.CompletedProcess.
5672
"""
5773
exec_opts = exec_opts or []
@@ -65,11 +81,69 @@ def exec(
6581
+ [pod_id, "--"]
6682
+ cmd,
6783
check_results=check_results,
84+
timeout=timeout,
6885
)
6986

70-
def kubectl(self, cmd: List[str]) -> subprocess.CompletedProcess[str]:
71-
"""Run kubectl command adding the configuration file and namespace."""
72-
return run_cmd(["kubectl"] + self.kubectl_opts + cmd)
87+
def kubectl(
88+
self, cmd: List[str], *, check_results: bool = True, timeout: Optional[float] = None
89+
) -> subprocess.CompletedProcess[str]:
90+
"""Run kubectl command adding the configuration file and namespace.
91+
92+
Args:
93+
cmd: The kubectl subcommand and its arguments.
94+
check_results: Indicate if subprocess should not check for failure.
95+
timeout: If set, kill the command and raise subprocess.TimeoutExpired
96+
when it runs longer than this many seconds.
97+
"""
98+
return run_cmd(
99+
["kubectl"] + self.kubectl_opts + cmd,
100+
check_results=check_results,
101+
timeout=timeout,
102+
)
103+
104+
def cp_with_retry(
105+
self,
106+
cp_args: List[str],
107+
*,
108+
label: str,
109+
timeout: float = CP_TIMEOUT_S,
110+
attempts: int = CP_ATTEMPTS,
111+
) -> None:
112+
"""Run a `kubectl cp`, bounding it with a timeout and retrying transient stalls.
113+
114+
`kubectl cp` streams a tar over the exec channel and can stall intermittently
115+
with no output. Each attempt is killed after `timeout` seconds and the copy is
116+
tried up to `attempts` times, pausing briefly between attempts. If every
117+
attempt fails, the failing copy is logged and the process exits non-zero so
118+
the failure surfaces instead of hanging silently.
119+
120+
Args:
121+
cp_args: Arguments to `kubectl cp` (source and destination, plus any flags).
122+
label: Human-readable description of what is being copied, for logging.
123+
timeout: Per-attempt timeout in seconds.
124+
attempts: Total number of attempts before giving up.
125+
"""
126+
for attempt in range(1, attempts + 1):
127+
try:
128+
proc = self.kubectl(["cp"] + cp_args, check_results=False, timeout=timeout)
129+
except subprocess.TimeoutExpired:
130+
logging.warning(
131+
f"Copy of {label} timed out after {timeout:g}s "
132+
f"(attempt {attempt}/{attempts})."
133+
)
134+
else:
135+
if proc.returncode == 0:
136+
logging.debug(f"stderr:\n{proc.stderr.strip()}")
137+
logging.debug(f"stdout:\n{proc.stdout.strip()}")
138+
return
139+
logging.warning(
140+
f"Copy of {label} failed with return code {proc.returncode} "
141+
f"(attempt {attempt}/{attempts}).\n{proc.stderr.strip()}"
142+
)
143+
if attempt < attempts:
144+
time.sleep(CP_RETRY_DELAY)
145+
logging.error(f"Failed to copy {label} after {attempts} attempts; aborting.")
146+
sys.exit(1)
73147

74148
def get_pod_id(self, service: CombineApp.Component, *, instance: int = 0) -> str:
75149
"""Look up the Kubernetes pod id for the specified service."""

maintenance/scripts/combine_backup.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,23 +96,23 @@ def main() -> None:
9696

9797
with tarfile.open(backup_file, "x:gz") as tar:
9898
# cp, tar, and rm the db subdir
99-
combine.kubectl(
99+
combine.cp_with_retry(
100100
[
101-
"cp",
102101
f"{db_pod}:/{db_files_subdir}",
103102
str(Path(backup_dir) / db_files_subdir),
104-
]
103+
],
104+
label=f"database dump ({db_files_subdir})",
105105
)
106106
tar.add(db_files_subdir)
107107
rmtree(db_files_subdir)
108108

109109
# cp, tar, and rm the backend subdir
110-
combine.kubectl(
110+
combine.cp_with_retry(
111111
[
112-
"cp",
113112
f"{backend_pod}:/home/app/{backend_files_subdir}/",
114113
str(Path(backup_dir) / backend_files_subdir),
115-
]
114+
],
115+
label=f"backend files ({backend_files_subdir})",
116116
)
117117
tar.add(backend_files_subdir)
118118
rmtree(backend_files_subdir)

maintenance/scripts/combine_restore.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,9 @@ def safe_extract(
160160
sys.exit(1)
161161

162162
logging.debug(f"Copying {db_files_subdir} to {db_pod} ...")
163-
cp_proc = combine.kubectl(["cp", db_files_subdir, f"{db_pod}:/"])
164-
logging.debug(f"stderr:\n{cp_proc.stderr.strip()}")
165-
logging.debug(f"stdout:\n{cp_proc.stdout.strip()}")
163+
combine.cp_with_retry(
164+
[db_files_subdir, f"{db_pod}:/"], label=f"database dump ({db_files_subdir})"
165+
)
166166

167167
logging.debug(f"Running mongorestore on {db_pod} ...")
168168
mongorestore_proc = combine.exec(
@@ -203,7 +203,10 @@ def safe_extract(
203203
continue
204204
logging.debug(f"Copying {item} ...")
205205
local_item = os.path.join(backend_files_subdir, item)
206-
combine.kubectl(["cp", local_item, remote_subdir, "--no-preserve"])
206+
combine.cp_with_retry(
207+
[local_item, remote_subdir, "--no-preserve"],
208+
label=f"backend files for {item}",
209+
)
207210

208211

209212
if __name__ == "__main__":

maintenance/scripts/maint_utils.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import os
77
import subprocess
88
import sys
9-
from typing import List
9+
from typing import List, Optional
1010

1111

1212
def check_env_vars(var_names: List[str]) -> tuple[str, ...]:
@@ -26,15 +26,26 @@ def check_env_vars(var_names: List[str]) -> tuple[str, ...]:
2626
return tuple(value_list)
2727

2828

29-
def run_cmd(cmd: List[str], *, check_results: bool = True) -> subprocess.CompletedProcess[str]:
30-
"""Run a command with subprocess and catch any CalledProcessErrors."""
29+
def run_cmd(
30+
cmd: List[str], *, check_results: bool = True, timeout: Optional[float] = None
31+
) -> subprocess.CompletedProcess[str]:
32+
"""Run a command with subprocess and catch any CalledProcessErrors.
33+
34+
Args:
35+
cmd: the command, as an argument list, to run.
36+
check_results: if true, exit when the command returns a non-zero code.
37+
timeout: if set, the command is killed and subprocess.TimeoutExpired is
38+
raised when it runs longer than this many seconds. Callers that
39+
pass a timeout are responsible for handling TimeoutExpired.
40+
"""
3141
try:
3242
return subprocess.run(
3343
cmd,
3444
stdout=subprocess.PIPE,
3545
stderr=subprocess.PIPE,
3646
universal_newlines=True,
3747
check=check_results,
48+
timeout=timeout,
3849
)
3950
except subprocess.CalledProcessError as err:
4051
print("CalledProcessError")

0 commit comments

Comments
 (0)