Skip to content

Commit 0abb140

Browse files
authored
add termination grace period (#1379)
1 parent 0cfedd7 commit 0abb140

4 files changed

Lines changed: 225 additions & 27 deletions

File tree

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# /// script
22
# requires-python = "==3.12"
33
# dependencies = [
4-
# "kubernetes",
4+
# "flyte",
55
# ]
66
# ///
77
"""
@@ -22,33 +22,26 @@
2222
task. (Plain ``def`` tasks run in a worker thread, where ``signal.signal`` is not
2323
allowed -- for those, register the handler at module import time instead.)
2424
25-
2. Give the callback enough time to finish. The default grace period is 30s, which
26-
may be too short to upload a large checkpoint, so we raise
27-
``termination_grace_period_seconds`` on the pod template.
25+
2. Give the callback enough time to finish. The default grace period is 30s, which may
26+
be too short to upload a large checkpoint, so we raise it with the
27+
``flyte.PodTemplate().with_termination_grace_period(...)`` helper. It accepts an int
28+
number of seconds or a ``timedelta`` and sets ``terminationGracePeriodSeconds`` on the
29+
pod spec -- no need to import the ``kubernetes`` package.
2830
"""
2931

3032
from __future__ import annotations
3133

3234
import asyncio
3335
import signal
34-
35-
from kubernetes.client import V1Container, V1PodSpec
36+
from datetime import timedelta
3637

3738
import flyte
3839

39-
# Raise the pod termination grace period so the abort callback has time to flush its
40-
# side effects (e.g. upload a checkpoint) before Kubernetes escalates to SIGKILL.
41-
pod_template = flyte.PodTemplate(
42-
primary_container_name="primary",
43-
pod_spec=V1PodSpec(
44-
containers=[V1Container(name="primary")],
45-
termination_grace_period_seconds=600, # 10 minutes; default is 30s
46-
),
47-
)
48-
4940
env = flyte.TaskEnvironment(
5041
name="abort_callback",
51-
pod_template=pod_template,
42+
# Give the abort callback time to flush its side effects (e.g. upload a checkpoint)
43+
# before Kubernetes escalates to SIGKILL. Default is 30s.
44+
pod_template=flyte.PodTemplate().with_termination_grace_period(timedelta(minutes=10)),
5245
image=flyte.Image.from_uv_script(__file__, name="flyte"),
5346
)
5447

@@ -62,7 +55,7 @@ async def train(n_steps: int = 1000, step_seconds: float = 2.0) -> str:
6255
# Resume from the previous attempt's checkpoint, if any.
6356
prev = await checkpoint.load()
6457
start = int(prev.read_bytes().decode()) if prev is not None else 0
65-
print(f"Starting training at step {start}")
58+
flyte.logger.info(f"Starting training at step {start}")
6659

6760
aborted = asyncio.Event()
6861

@@ -71,45 +64,46 @@ def on_abort() -> None:
7164
# Keep the callback itself lightweight: flip a flag and let the loop below
7265
# perform the actual side effects. (If you'd rather do the work directly in
7366
# the callback, schedule a coroutine with `asyncio.ensure_future(...)`.)
74-
print("Abort received -- will checkpoint current progress and exit.")
67+
flyte.logger.info("Abort received -- will checkpoint current progress and exit.")
7568
aborted.set()
7669

7770
# An `async def` task runs on the main thread's event loop, so we can register the
7871
# handler from inside the task. Aborting the run deletes the pod, and Kubernetes
7972
# sends SIGTERM to this process, which triggers `on_abort`.
8073
try:
8174
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, on_abort)
75+
flyte.logger.info("Registered SIGTERM handler for abort callback.")
8276
except (NotImplementedError, RuntimeError):
8377
# e.g. running locally off the main thread; abort handling only applies in-cluster.
84-
print("Could not register a SIGTERM handler here; continuing without abort handling.")
78+
flyte.logger.info("Could not register a SIGTERM handler here; continuing without abort handling.")
8579

8680
step = start
8781
for step in range(start, n_steps):
8882
if aborted.is_set():
8983
# ---- extra code to run on abort; side effects live here ----
9084
await checkpoint.save(f"{step}".encode())
91-
print(f"Checkpoint saved at step {step}. Exiting due to abort.")
85+
flyte.logger.info(f"Checkpoint saved at step {step}. Exiting due to abort.")
9286
return f"aborted at step {step}"
9387
# ... real training work would go here ...
9488
await asyncio.sleep(step_seconds)
9589
await checkpoint.save(f"{step + 1}".encode())
96-
print(f"Completed step {step + 1}/{n_steps}")
90+
flyte.logger.info(f"Completed step {step + 1}/{n_steps}")
9791

9892
return f"completed all {n_steps} steps"
9993

10094

10195
if __name__ == "__main__":
10296
import time
10397

104-
flyte.init_from_config()
105-
run = flyte.run(train, n_steps=1000, step_seconds=2.0)
98+
flyte.init_from_config(log_level="INFO")
99+
run = flyte.run(train, n_steps=100, step_seconds=2.0)
106100
print(run.url)
107101

108102
# Wait until the task is actually running, let it take a few steps, then abort it
109103
# to trigger the on-abort checkpoint callback. (You could also just click "Abort"
110104
# in the UI instead of the code below.)
111105
run.wait(wait_for="running")
112-
time.sleep(20)
106+
time.sleep(10)
113107
print("Aborting the run to trigger the on-abort checkpoint callback...")
114108
run.abort()
115109
print("Aborted. Inspect the task logs -- it should have saved a checkpoint before exiting.")

src/flyte/_environment.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ class Environment:
4949
:param env_vars: Environment variables as `dict[str, str]`.
5050
:param secrets: Secrets to inject into the environment.
5151
:param pod_template: Kubernetes pod template as a string reference to a
52-
named template or a `PodTemplate` object.
52+
named template or a `PodTemplate` object. To set a termination grace
53+
period without depending on the `kubernetes` package, use
54+
`flyte.PodTemplate().with_termination_grace_period(...)`.
5355
:param description: Human-readable description (max 255 characters).
5456
:param interruptible: Whether the environment can be scheduled on
5557
spot/preemptible instances.

src/flyte/_pod.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,21 @@
22

33
import copy
44
from dataclasses import dataclass
5-
from typing import TYPE_CHECKING, Dict, Optional
5+
from datetime import timedelta
6+
from typing import TYPE_CHECKING, Dict, Optional, Union
67

78
if TYPE_CHECKING:
89
from flyteidl2.core.tasks_pb2 import K8sPod
910
from kubernetes.client import V1Container, V1PodSpec
1011

1112

13+
# A task's Kubernetes termination grace period, accepted as an ``int`` number of seconds or a
14+
# ``timedelta``. This is the time Kubernetes waits after sending SIGTERM (e.g. when a run is
15+
# aborted, which deletes the pod) before escalating to SIGKILL, and maps to
16+
# ``V1PodSpec.termination_grace_period_seconds``.
17+
TerminationGracePeriod = Union[int, timedelta]
18+
19+
1220
_PRIMARY_CONTAINER_NAME_FIELD = "primary_container_name"
1321
_PRIMARY_CONTAINER_DEFAULT_NAME = "primary"
1422

@@ -187,6 +195,37 @@ def allow_nested_sandboxing(self) -> PodTemplate:
187195
"""
188196
return _apply_sandboxing(self)
189197

198+
def with_termination_grace_period(self, termination_grace_period: TerminationGracePeriod) -> PodTemplate:
199+
"""
200+
Return a copy of this template with Kubernetes' ``terminationGracePeriodSeconds`` set.
201+
202+
This is the time Kubernetes waits after sending SIGTERM (e.g. when a run is aborted,
203+
which deletes the pod) before escalating to SIGKILL — raise it to give a task time to
204+
checkpoint or otherwise clean up on abort. Accepts an ``int`` number of seconds or a
205+
``timedelta``.
206+
207+
Because the primary container is synthesized when the template has no pod spec, you can
208+
set a grace period without depending on the ``kubernetes`` package::
209+
210+
env = flyte.TaskEnvironment(
211+
name="train",
212+
pod_template=flyte.PodTemplate().with_termination_grace_period(timedelta(minutes=10)),
213+
)
214+
215+
The original template is never mutated; existing containers, volumes, labels, and other
216+
pod-spec fields are preserved. Re-applying overwrites the previously set value.
217+
218+
:param termination_grace_period: Grace period as an ``int`` (seconds) or ``timedelta``.
219+
Must be non-negative.
220+
"""
221+
seconds = _normalize_termination_grace_period(termination_grace_period)
222+
if seconds is None:
223+
raise TypeError("termination_grace_period is required and must be an int (seconds) or timedelta.")
224+
pt = _clone_with_primary(self)
225+
assert pt.pod_spec is not None # _clone_with_primary guarantees a pod spec
226+
pt.pod_spec.termination_grace_period_seconds = seconds
227+
return pt
228+
190229
def to_k8s_pod(self) -> "K8sPod":
191230
from flyteidl2.core.tasks_pb2 import K8sObjectMetadata, K8sPod
192231
from kubernetes.client import ApiClient, V1PodSpec
@@ -216,6 +255,29 @@ def _clone_with_primary(pt: PodTemplate) -> PodTemplate:
216255
return pt
217256

218257

258+
def _normalize_termination_grace_period(value: Optional[TerminationGracePeriod]) -> Optional[int]:
259+
"""Normalize an ``int`` (seconds) or ``timedelta`` grace period to whole seconds.
260+
261+
Returns ``None`` when ``value`` is ``None``. Raises ``TypeError`` for other types and
262+
``ValueError`` for negative durations. A ``timedelta`` is truncated to whole seconds, since
263+
Kubernetes' ``terminationGracePeriodSeconds`` is integer-valued.
264+
"""
265+
if value is None:
266+
return None
267+
# bool is an int subclass; reject it explicitly to avoid True -> 1 surprises.
268+
if isinstance(value, bool):
269+
raise TypeError("termination_grace_period must be an int (seconds) or timedelta, not bool.")
270+
if isinstance(value, timedelta):
271+
seconds = int(value.total_seconds())
272+
elif isinstance(value, int):
273+
seconds = value
274+
else:
275+
raise TypeError(f"termination_grace_period must be an int (seconds) or timedelta, got {type(value).__name__}.")
276+
if seconds < 0:
277+
raise ValueError(f"termination_grace_period must be non-negative, got {seconds} seconds.")
278+
return seconds
279+
280+
219281
def _get_primary_container(pt: PodTemplate) -> "V1Container":
220282
"""Return the primary container of a template prepared by ``_clone_with_primary``."""
221283
assert pt.pod_spec is not None # _clone_with_primary guarantees it
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Tests for ``PodTemplate.with_termination_grace_period`` — the helper that sets
2+
``terminationGracePeriodSeconds`` on a pod template without needing the ``kubernetes`` package.
3+
"""
4+
5+
import copy
6+
import pathlib
7+
from datetime import timedelta
8+
9+
import pytest
10+
from kubernetes.client import V1Container, V1PodSpec
11+
12+
import flyte
13+
from flyte._internal.runtime.task_serde import get_proto_task
14+
from flyte._pod import PodTemplate
15+
from flyte.models import SerializationContext
16+
17+
18+
def _grace_seconds(task):
19+
"""Serialize a task and return the pod spec's terminationGracePeriodSeconds (or None)."""
20+
ctx = SerializationContext(project="p", domain="d", org="o", version="v1", root_dir=pathlib.Path.cwd())
21+
proto = get_proto_task(task, ctx)
22+
if not proto.HasField("k8s_pod"):
23+
return None
24+
pod_spec = proto.k8s_pod.pod_spec # google.protobuf.Struct: supports `in` / `[]`, not `.get`
25+
return pod_spec["terminationGracePeriodSeconds"] if "terminationGracePeriodSeconds" in pod_spec else None
26+
27+
28+
# --------------------------------------------------------------------------- #
29+
# PodTemplate.with_termination_grace_period
30+
# --------------------------------------------------------------------------- #
31+
32+
33+
class TestWithTerminationGracePeriod:
34+
def test_synthesizes_pod_spec_with_primary_no_kubernetes_needed(self):
35+
# Starting from a bare PodTemplate(), the helper builds the pod spec + primary container
36+
# internally, so the caller never touches kubernetes.client.
37+
pt = PodTemplate().with_termination_grace_period(42)
38+
assert pt.pod_spec.termination_grace_period_seconds == 42
39+
assert any(c.name == "primary" for c in pt.pod_spec.containers)
40+
41+
def test_int_seconds(self):
42+
assert PodTemplate().with_termination_grace_period(30).pod_spec.termination_grace_period_seconds == 30
43+
44+
def test_timedelta(self):
45+
pt = PodTemplate().with_termination_grace_period(timedelta(minutes=2))
46+
assert pt.pod_spec.termination_grace_period_seconds == 120
47+
48+
def test_zero_is_kept(self):
49+
# 0 is a meaningful k8s value (immediate kill), not "unset".
50+
assert PodTemplate().with_termination_grace_period(0).pod_spec.termination_grace_period_seconds == 0
51+
52+
def test_custom_primary_container_name(self):
53+
pt = PodTemplate(primary_container_name="worker").with_termination_grace_period(10)
54+
assert any(c.name == "worker" for c in pt.pod_spec.containers)
55+
assert pt.pod_spec.termination_grace_period_seconds == 10
56+
57+
def test_preserves_existing_pod_spec_fields(self):
58+
base = PodTemplate(pod_spec=V1PodSpec(containers=[V1Container(name="primary", image="img")], hostname="h"))
59+
pt = base.with_termination_grace_period(99)
60+
assert pt.pod_spec.termination_grace_period_seconds == 99
61+
assert pt.pod_spec.containers[0].image == "img"
62+
assert pt.pod_spec.hostname == "h"
63+
64+
def test_does_not_mutate_original(self):
65+
base = PodTemplate(pod_spec=V1PodSpec(containers=[V1Container(name="primary")]))
66+
snapshot = copy.deepcopy(base)
67+
base.with_termination_grace_period(99)
68+
assert base == snapshot
69+
70+
def test_reapplying_overwrites(self):
71+
pt = PodTemplate().with_termination_grace_period(30).with_termination_grace_period(60)
72+
assert pt.pod_spec.termination_grace_period_seconds == 60
73+
74+
def test_composes_with_capability_helpers(self):
75+
pt = PodTemplate().allow_fuse().with_termination_grace_period(60)
76+
primary = next(c for c in pt.pod_spec.containers if c.name == "primary")
77+
assert pt.pod_spec.termination_grace_period_seconds == 60
78+
assert primary.resources.requests["smarter-devices/fuse"] == "1" # allow_fuse survived
79+
80+
def test_bool_rejected(self):
81+
with pytest.raises(TypeError, match="not bool"):
82+
PodTemplate().with_termination_grace_period(True)
83+
84+
def test_negative_rejected(self):
85+
with pytest.raises(ValueError, match="non-negative"):
86+
PodTemplate().with_termination_grace_period(-1)
87+
88+
def test_wrong_type_rejected(self):
89+
with pytest.raises(TypeError):
90+
PodTemplate().with_termination_grace_period("30")
91+
92+
def test_none_rejected(self):
93+
with pytest.raises(TypeError):
94+
PodTemplate().with_termination_grace_period(None)
95+
96+
97+
# --------------------------------------------------------------------------- #
98+
# End-to-end: the helper output flows through task serialization
99+
# --------------------------------------------------------------------------- #
100+
101+
102+
env = flyte.TaskEnvironment(
103+
name="tgp_env",
104+
pod_template=PodTemplate().with_termination_grace_period(timedelta(minutes=10)),
105+
)
106+
107+
108+
@env.task
109+
async def env_task() -> int:
110+
return 1
111+
112+
113+
env_plain = flyte.TaskEnvironment(name="tgp_plain")
114+
115+
116+
@env_plain.task
117+
async def plain_task() -> int:
118+
return 1
119+
120+
121+
@env_plain.task(pod_template=PodTemplate().with_termination_grace_period(300))
122+
async def decorated_task() -> int:
123+
return 1
124+
125+
126+
class TestSerialization:
127+
def test_environment_pod_template(self):
128+
assert _grace_seconds(env_task) == 600
129+
130+
def test_no_pod_template_uses_container_path(self):
131+
assert _grace_seconds(plain_task) is None
132+
ctx = SerializationContext(project="p", domain="d", org="o", version="v1", root_dir=pathlib.Path.cwd())
133+
assert get_proto_task(plain_task, ctx).HasField("container")
134+
135+
def test_decorator_pod_template(self):
136+
assert _grace_seconds(decorated_task) == 300
137+
138+
def test_override_pod_template(self):
139+
overridden = plain_task.override(pod_template=PodTemplate().with_termination_grace_period(15))
140+
assert _grace_seconds(overridden) == 15

0 commit comments

Comments
 (0)