Skip to content

Commit 0fb33f1

Browse files
authored
Merge pull request #91 from DataKitchen/feat/testgen-stop-grace-period
fix(installer): give TestGen time to stop before docker kills it
2 parents c711f06 + af717d6 commit 0fb33f1

5 files changed

Lines changed: 517 additions & 29 deletions

File tree

dk-installer.py

Lines changed: 131 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@
6767
TESTGEN_LOG_FILE_PATH = pathlib.Path.home() / ".testgen" / "logs" / "app.log"
6868
TESTGEN_CONFIG_ENV_PATH = pathlib.Path.home() / ".testgen" / "config.env"
6969
TESTGEN_APP_READY_TIMEOUT = 120
70+
# Seconds TestGen is given to stop. Must exceed TG_JOB_SHUTDOWN_TIMEOUT (default 60s) plus
71+
# the time the process needs to record what stopped, or the scheduler is killed mid-wait and
72+
# a running job is cut instead of stopping at a checkpoint.
73+
TESTGEN_STOP_GRACE_PERIOD = 90
7074
INSTALL_MARKER_FILE = "dk-{}-install.json"
7175
INSTALL_MODE_DOCKER = "docker"
7276
INSTALL_MODE_PIP = "pip"
@@ -2079,6 +2083,32 @@ def execute(self, args):
20792083
CONSOLE.msg("Observability Heartbeat demo stopped.")
20802084

20812085

2086+
def find_in_block(contents: str, block: str, key: str) -> typing.Optional[re.Match]:
2087+
"""Find the ``key:`` line inside the compose ``block:`` mapping, or None.
2088+
2089+
Scans by indentation rather than parsing YAML — enough for the block-style files the
2090+
installer writes, and it avoids a runtime dependency. Offsets on the returned match
2091+
are absolute, so callers can splice around it; group 1 is the key's indent.
2092+
2093+
Scoping to a block is the point: the same key can appear on several services, and
2094+
only ``engine`` runs the scheduler. Deliberately says nothing about *which* image a
2095+
service uses — ``tg install --image`` accepts any registry.
2096+
"""
2097+
headers = list(re.finditer(rf"^([ \t]*){re.escape(block)}:[ \t]*$", contents, flags=re.M))
2098+
if not headers:
2099+
return None
2100+
# Shallowest wins: a name like ``postgres`` is both a service and a nested key under
2101+
# another service's ``depends_on``, and it's the service the caller means.
2102+
header = min(headers, key=lambda match: len(match.group(1)))
2103+
# The block body ends at the first line indented no deeper than the block key itself.
2104+
end = len(contents)
2105+
for line in re.finditer(r"^([ \t]*)\S.*$", contents[header.end() :], flags=re.M):
2106+
if len(line.group(1)) <= len(header.group(1)):
2107+
end = header.end() + line.start()
2108+
break
2109+
return re.compile(rf"^([ \t]+){re.escape(key)}:.*$", flags=re.M).search(contents, header.end(), end)
2110+
2111+
20822112
class UpdateComposeFileStep(Step):
20832113
label = "Updating the Docker compose file"
20842114

@@ -2088,6 +2118,7 @@ def __init__(self):
20882118
self.update_token = False
20892119
self.update_base_url = False
20902120
self.update_api_port = False
2121+
self.update_stop_grace = False
20912122
super().__init__()
20922123

20932124
def pre_execute(self, action, args):
@@ -2149,13 +2180,25 @@ def pre_execute(self, action, args):
21492180
and not re.search(rf"- \d+:{TESTGEN_DEFAULT_API_PORT}\b", contents)
21502181
)
21512182

2183+
# Compose files written before the grace period was added stop the engine after
2184+
# docker's 10s default, cutting a running job instead of letting it checkpoint.
2185+
# Only count it as a pending change if we can actually place it, or the step
2186+
# would report success having silently rewritten the file unchanged.
2187+
engine_image = find_in_block(contents, "engine", "image")
2188+
if engine_image is None:
2189+
LOG.info("No image line in the compose 'engine' service; leaving stop_grace_period alone")
2190+
self.update_stop_grace = (
2191+
engine_image is not None and find_in_block(contents, "engine", "stop_grace_period") is None
2192+
)
2193+
21522194
if not any(
21532195
(
21542196
self.update_version,
21552197
self.update_analytics,
21562198
self.update_token,
21572199
self.update_base_url,
21582200
self.update_api_port,
2201+
self.update_stop_grace,
21592202
)
21602203
):
21612204
CONSOLE.msg("No changes will be applied.")
@@ -2169,6 +2212,7 @@ def execute(self, action, args):
21692212
self.update_token,
21702213
self.update_base_url,
21712214
self.update_api_port,
2215+
self.update_stop_grace,
21722216
)
21732217
):
21742218
raise SkipStep
@@ -2210,6 +2254,15 @@ def execute(self, action, args):
22102254
new_mapping = f"\n{match.group(1)}- {TESTGEN_DEFAULT_API_PORT}:{TESTGEN_DEFAULT_API_PORT}"
22112255
contents = contents[0 : match.end()] + new_mapping + contents[match.end() :]
22122256

2257+
if self.update_stop_grace and (image := find_in_block(contents, "engine", "image")):
2258+
indent = image.group(1)
2259+
grace = (
2260+
f"\n{indent}# Must exceed TG_JOB_SHUTDOWN_TIMEOUT (default 60s), or docker kills the scheduler"
2261+
f"\n{indent}# mid-wait and running jobs are cut instead of stopping at a checkpoint."
2262+
f"\n{indent}stop_grace_period: {TESTGEN_STOP_GRACE_PERIOD}s"
2263+
)
2264+
contents = contents[: image.end()] + grace + contents[image.end() :]
2265+
22132266
action.get_compose_file_path(args).write_text(contents)
22142267

22152268

@@ -2309,6 +2362,9 @@ def get_compose_file_contents(self, action, args):
23092362
services:
23102363
engine:
23112364
image: {args.image}
2365+
# Must exceed TG_JOB_SHUTDOWN_TIMEOUT (default 60s), or docker kills the scheduler
2366+
# mid-wait and running jobs are cut instead of stopping at a checkpoint.
2367+
stop_grace_period: {TESTGEN_STOP_GRACE_PERIOD}s
23122368
container_name: testgen
23132369
environment: *common-variables
23142370
volumes:
@@ -2524,17 +2580,25 @@ def read_testgen_config_env() -> dict[str, str]:
25242580
return config
25252581

25262582

2527-
def stop_app_tree(proc: subprocess.Popen, timeout: int = 10) -> None:
2528-
"""Terminate ``proc`` and all of its descendants.
2583+
def supports_graceful_stop() -> bool:
2584+
"""Whether we can ask the app to stop rather than kill it outright.
25292585
2530-
Plain ``proc.terminate()`` only kills the parent — pixeltable-pgserver
2531-
spawns ``postgres`` children that get orphaned otherwise. Cross-platform:
2532-
on Windows we shell out to ``taskkill /F /T``; on POSIX we send SIGTERM
2533-
to the whole process group (the parent was started with
2534-
``start_new_session=True``).
2586+
POSIX only. On Windows the app is started without ``CREATE_NEW_PROCESS_GROUP``,
2587+
so there is no catchable signal we can deliver to it and ``taskkill /F`` is the
2588+
only reliable stop — a running job is cut there whatever grace period we would
2589+
nominally allow. Callers use this to avoid promising a wait that can't happen.
2590+
"""
2591+
return platform.system() != "Windows"
2592+
2593+
2594+
def force_kill_app_tree(proc: subprocess.Popen, timeout: int = 5) -> None:
2595+
"""Kill ``proc`` and every descendant, including those outside its process group.
2596+
2597+
``testgen run-app all`` starts its ui/scheduler children in their own sessions, so
2598+
they sit outside ``proc``'s process group and outlive a ``killpg`` — killing only
2599+
the parent leaves the UI holding its port and postgres holding the data directory,
2600+
which then breaks the next ``tg start``. Hence the orphan sweep to finish the job.
25352601
"""
2536-
if proc.poll() is not None:
2537-
return
25382602
if platform.system() == "Windows":
25392603
with contextlib.suppress(Exception):
25402604
subprocess.run(
@@ -2546,16 +2610,51 @@ def stop_app_tree(proc: subprocess.Popen, timeout: int = 10) -> None:
25462610
)
25472611
else:
25482612
with contextlib.suppress(Exception):
2549-
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
2613+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
2614+
# Backstop in case the platform kill above didn't land (e.g. taskkill denied).
2615+
proc.kill()
2616+
with contextlib.suppress(subprocess.TimeoutExpired):
2617+
proc.wait(timeout=timeout)
2618+
stop_standalone_orphans()
2619+
2620+
2621+
def stop_app_tree(proc: subprocess.Popen, timeout: int = 10) -> bool:
2622+
"""Terminate ``proc`` and all of its descendants. Returns whether it stopped on its own.
2623+
2624+
Plain ``proc.terminate()`` only kills the parent — pixeltable-pgserver
2625+
spawns ``postgres`` children that get orphaned otherwise. Cross-platform:
2626+
on Windows we shell out to ``taskkill /F /T``; on POSIX we send SIGTERM
2627+
to the whole process group (the parent was started with
2628+
``start_new_session=True``) and let it forward the signal to its children.
2629+
2630+
``timeout`` is how long the tree is given to shut down cooperatively before
2631+
it is force-killed — the caller passes ``TESTGEN_STOP_GRACE_PERIOD`` when a
2632+
running job may need to reach a checkpoint first. A second Ctrl+C during
2633+
that wait is taken as "stop now" and skips straight to the force-kill.
2634+
2635+
Returns ``True`` when the tree exited within ``timeout`` (or was already
2636+
gone), ``False`` when it had to be force-killed. See ``supports_graceful_stop``
2637+
for why Windows always reports ``False`` when there was a live process.
2638+
"""
2639+
if proc.poll() is not None:
2640+
return True
2641+
2642+
if not supports_graceful_stop():
2643+
force_kill_app_tree(proc, timeout=timeout)
2644+
return False
2645+
2646+
with contextlib.suppress(Exception):
2647+
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
25502648
try:
25512649
proc.wait(timeout=timeout)
2552-
except subprocess.TimeoutExpired:
2553-
if platform.system() != "Windows":
2554-
with contextlib.suppress(Exception):
2555-
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
2556-
proc.kill()
2557-
with contextlib.suppress(subprocess.TimeoutExpired):
2558-
proc.wait(timeout=5)
2650+
except (subprocess.TimeoutExpired, KeyboardInterrupt):
2651+
# KeyboardInterrupt here is a second Ctrl+C while we were waiting. Swallow it
2652+
# rather than letting it unwind into the caller's ``finally``, which would send
2653+
# the tree another SIGTERM — TestGen reads a second signal as "hurry up" and
2654+
# force-kills the job mid-pause, losing exactly the checkpoint we were waiting for.
2655+
force_kill_app_tree(proc)
2656+
return False
2657+
return True
25592658

25602659

25612660
def stop_standalone_orphans() -> None:
@@ -2687,8 +2786,21 @@ def start_testgen_app(action, args) -> None:
26872786
# Reset the cursor to column 0 — the terminal echoed `^C` mid-line.
26882787
print("")
26892788
CONSOLE.msg("Stopping TestGen...")
2690-
stop_app_tree(proc, timeout=10)
2691-
CONSOLE.msg("TestGen stopped.")
2789+
graceful = supports_graceful_stop()
2790+
if graceful:
2791+
# A running profiling job stops at its next checkpoint rather than being cut,
2792+
# but that can take up to TG_JOB_SHUTDOWN_TIMEOUT — say so, or the wait reads
2793+
# as a hang and the user reaches for a second Ctrl+C.
2794+
CONSOLE.msg(
2795+
f"Waiting up to {TESTGEN_STOP_GRACE_PERIOD} seconds for running jobs to reach a checkpoint..."
2796+
)
2797+
stopped_cleanly = stop_app_tree(proc, timeout=TESTGEN_STOP_GRACE_PERIOD)
2798+
# Only worth flagging where we actually offered a grace period. Windows always
2799+
# force-kills, so the warning would fire on every stop and mean nothing.
2800+
if graceful and not stopped_cleanly:
2801+
CONSOLE.msg("TestGen stopped. A job that was still running will restart from the beginning.")
2802+
else:
2803+
CONSOLE.msg("TestGen stopped.")
26922804
CONSOLE.msg(f"To start it again, {command_hint(args.prod, 'start', 'Start TestGen')}.")
26932805
finally:
26942806
stop_app_tree(proc, timeout=5)

tests/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ def _no_real_process_group_signals():
3333
yield killpg_mock
3434

3535

36+
@pytest.fixture(autouse=True)
37+
def _no_real_orphan_sweep():
38+
"""``force_kill_app_tree`` finishes with ``stop_standalone_orphans``, which shells out
39+
to a real ``pkill -9 -f 'testgen.*run-app'``. Unpatched, a test exercising the
40+
force-kill path would kill the developer's own running TestGen. Tests that assert on
41+
the sweep override this inside their own ``with patch(...)``.
42+
"""
43+
with patch("tests.installer.stop_standalone_orphans") as mock:
44+
yield mock
45+
46+
3647
@pytest.fixture
3748
def stdout_mock():
3849
return Mock(return_value=[])

tests/test_tg_install.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
AbortAction,
1111
TestGenCreateDockerComposeFileStep,
1212
ComposeVerifyExistingInstallStep,
13+
TESTGEN_STOP_GRACE_PERIOD,
1314
)
1415

1516

@@ -114,6 +115,24 @@ def test_tg_compose_base_url_custom_port(tg_install_action, start_cmd_mock, stdo
114115
assert "TG_UI_BASE_URL: http://localhost:9000" in contents
115116

116117

118+
@pytest.mark.integration
119+
def test_tg_compose_sets_engine_stop_grace_period(tg_install_action, start_cmd_mock, stdout_mock, compose_path):
120+
"""Docker's 10s default kills the scheduler mid-shutdown, cutting a running job
121+
instead of letting it stop at a checkpoint."""
122+
tg_install_action.execute()
123+
compose_content = compose_path.read_text()
124+
125+
# Only the engine needs it — postgres shuts down on its own quickly.
126+
assert compose_content.count("stop_grace_period") == 1
127+
lines = compose_content.splitlines()
128+
image_idx = next(i for i, line in enumerate(lines) if "image: datakitchen/dataops-testgen" in line)
129+
grace_idx = next(i for i, line in enumerate(lines) if "stop_grace_period" in line)
130+
# Inside the engine service, right under its image: two comment lines, then the key.
131+
assert grace_idx == image_idx + 3
132+
indent = lines[image_idx][: -len(lines[image_idx].lstrip())]
133+
assert lines[grace_idx] == f"{indent}stop_grace_period: {TESTGEN_STOP_GRACE_PERIOD}s"
134+
135+
117136
@pytest.mark.integration
118137
def test_tg_compose_base_url_ssl(tg_install_action, start_cmd_mock, stdout_mock, args_mock, compose_path):
119138
args_mock.ssl_cert_file = "/path/to/cert.crt"

0 commit comments

Comments
 (0)