Skip to content

Commit 338a773

Browse files
authored
Merge pull request #288 from finos/feat/k8s-perf-and-parity
feat(k8s): status-poll perf, orphan-GC parallelism, contract-mock fix
2 parents 968ca7f + 15657e5 commit 338a773

10 files changed

Lines changed: 1600 additions & 107 deletions

File tree

src/orb/providers/k8s/configuration/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,22 @@ class K8sProviderConfig(BaseSettings, BaseProviderConfig): # type: ignore[misc]
299299
),
300300
)
301301

302+
# Controller-status cache
303+
controller_status_cache_ttl_seconds: float = Field(
304+
5.0,
305+
description=(
306+
"How long (in seconds) to serve a cached ``read_namespaced_*`` controller "
307+
"response before re-issuing the GET to the API server. Applied to "
308+
"Deployment, StatefulSet and Job status polls. At the default of 5 s, "
309+
"1 000 concurrent requests polling every 5 s produce at most ~200 "
310+
"controller GETs/s instead of the unthrottled ~200 GETs/s per workload. "
311+
"Set to 0 (or any value <= 0) to disable the cache entirely — every poll "
312+
"will issue a direct GET. Disabling prevents unbounded in-memory growth "
313+
"in environments where the handler is polled at very high frequency; the "
314+
"cache dict is not populated at all when TTL <= 0."
315+
),
316+
)
317+
302318
# Prometheus metrics
303319
metrics_enabled: bool = Field(
304320
True,

src/orb/providers/k8s/infrastructure/handlers/deployment_status.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
from __future__ import annotations
1717

18+
import threading
19+
import time
1820
from typing import TYPE_CHECKING, Any, Optional
1921

2022
from orb.domain.base.provider_fulfilment import CheckHostsStatusResult, ProviderFulfilment
@@ -29,6 +31,11 @@ class DeploymentStatusResolver:
2931

3032
def __init__(self, handler: K8sDeploymentHandler) -> None:
3133
self._handler = handler
34+
# Per-workload TTL cache:
35+
# (namespace, deployment_name) -> (view, fetch_monotonic_ts, stored_requested_count)
36+
# Guarded by _cache_lock so concurrent check_hosts_status calls are safe.
37+
self._controller_cache: dict[tuple[str, str], tuple[dict[str, Any], float, int]] = {}
38+
self._cache_lock = threading.Lock()
3239

3340
def check_hosts_status(self, request: Request) -> CheckHostsStatusResult:
3441
"""Return per-pod details + the Deployment-driven fulfilment verdict.
@@ -56,7 +63,9 @@ def check_hosts_status(self, request: Request) -> CheckHostsStatusResult:
5663
# fulfilment verdict on the Deployment status because the
5764
# controller's view is authoritative for selective scale.
5865
cached_instances = handler.apply_pod_timeouts(list(cached.instances))
59-
controller_view = self.read_deployment_status(namespace, deployment_name)
66+
controller_view = self._get_cached_deployment_status(
67+
namespace, deployment_name, requested_count=request.requested_count
68+
)
6069
fulfilment = self.compute_fulfilment(
6170
cached_instances,
6271
request.requested_count,
@@ -67,10 +76,14 @@ def check_hosts_status(self, request: Request) -> CheckHostsStatusResult:
6776
selector = handler.build_label_selector(request)
6877

6978
try:
79+
# resource_version='0' serves from the apiserver reflector cache
80+
# (sub-ms, ~500 ms staleness) instead of reading from etcd.
81+
# Acceptable for status polls — not used on create/update paths.
7082
response = handler.with_retry(
7183
handler.client.core_v1.list_namespaced_pod,
7284
namespace=namespace,
7385
label_selector=selector,
86+
resource_version="0",
7487
operation_name="list_namespaced_pod",
7588
)
7689
except Exception as exc:
@@ -97,14 +110,74 @@ def check_hosts_status(self, request: Request) -> CheckHostsStatusResult:
97110
handler._instance_dict_for_pod(pod, namespace=namespace) for pod in pods
98111
]
99112
instances = handler.apply_pod_timeouts(instances)
100-
controller_view = self.read_deployment_status(namespace, deployment_name)
113+
controller_view = self._get_cached_deployment_status(
114+
namespace, deployment_name, requested_count=request.requested_count
115+
)
101116
fulfilment = self.compute_fulfilment(
102117
instances,
103118
request.requested_count,
104119
controller_view=controller_view,
105120
)
106121
return CheckHostsStatusResult(instances=instances, fulfilment=fulfilment)
107122

123+
def _get_cached_deployment_status(
124+
self, namespace: str, deployment_name: str, *, requested_count: int
125+
) -> dict[str, Any]:
126+
"""Return the controller view, serving from the TTL cache when fresh.
127+
128+
Cache semantics:
129+
130+
* **TTL disabled** — when ``controller_status_cache_ttl_seconds <= 0``
131+
the cache is bypassed entirely (no store, no read) to avoid
132+
unbounded dict growth during high-frequency polls.
133+
* **Scale-down guard** — a cached entry is only served when the
134+
*stored* ``requested_count`` equals the *current* ``requested_count``
135+
AND the stored view shows the workload fully ready. If the replica
136+
target has changed since the entry was stored (e.g. scale 5 → 3),
137+
the entry is treated as a miss and a fresh GET is issued.
138+
* **TOCTOU guard** — the fetch timestamp is captured *before* the
139+
blocking GET. On store, an existing entry is only overwritten when
140+
its timestamp is older than the new one, so a slow thread cannot
141+
clobber a fresher entry with a stale view.
142+
143+
The TTL is controlled by
144+
``K8sProviderConfig.controller_status_cache_ttl_seconds`` (default 5 s).
145+
"""
146+
ttl = self._handler.config.controller_status_cache_ttl_seconds
147+
148+
# TTL <= 0 means disabled — bypass cache entirely (Finding 5).
149+
if ttl <= 0:
150+
return self.read_deployment_status(namespace, deployment_name)
151+
152+
cache_key = (namespace, deployment_name)
153+
now = time.monotonic()
154+
155+
with self._cache_lock:
156+
entry = self._controller_cache.get(cache_key)
157+
if entry is not None:
158+
view, ts, stored_requested = entry # type: ignore[misc]
159+
ready = view.get("ready_replicas")
160+
# Finding 1: only serve when stored requested_count matches current
161+
# AND workload was fully ready when stored.
162+
fully_ready = isinstance(ready, int) and ready >= stored_requested
163+
if stored_requested == requested_count and fully_ready and now - ts < ttl:
164+
return view
165+
166+
# Cache miss, expired, workload not fully ready, or requested_count changed —
167+
# fetch outside the lock to avoid blocking concurrent callers during the API
168+
# GET. Capture the timestamp BEFORE the GET (Finding 2 / TOCTOU guard).
169+
fetch_ts = time.monotonic()
170+
view = self.read_deployment_status(namespace, deployment_name)
171+
172+
with self._cache_lock:
173+
existing = self._controller_cache.get(cache_key)
174+
# Only overwrite if there is no existing entry or the existing entry
175+
# is older than this fetch (Finding 2: don't clobber a newer entry).
176+
if existing is None or existing[1] < fetch_ts: # type: ignore[misc]
177+
self._controller_cache[cache_key] = (view, fetch_ts, requested_count) # type: ignore[assignment]
178+
179+
return view
180+
108181
def read_deployment_status(self, namespace: str, deployment_name: str) -> dict[str, Any]:
109182
"""Read ``availableReplicas``/``readyReplicas``/``conditions`` from the controller.
110183

src/orb/providers/k8s/infrastructure/handlers/job_status.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
from __future__ import annotations
1717

18+
import threading
19+
import time
1820
from typing import TYPE_CHECKING, Any, Optional
1921

2022
from orb.domain.base.provider_fulfilment import CheckHostsStatusResult, ProviderFulfilment
@@ -29,6 +31,11 @@ class JobStatusResolver:
2931

3032
def __init__(self, handler: K8sJobHandler) -> None:
3133
self._handler = handler
34+
# Per-workload TTL cache:
35+
# (namespace, job_name) -> (view, fetch_monotonic_ts)
36+
# Guarded by _cache_lock so concurrent check_hosts_status calls are safe.
37+
self._controller_cache: dict[tuple[str, str], tuple[dict[str, Any], float]] = {}
38+
self._cache_lock = threading.Lock()
3239

3340
def check_hosts_status(self, request: Request) -> CheckHostsStatusResult:
3441
"""Return per-pod details + the Job-driven fulfilment verdict.
@@ -56,7 +63,7 @@ def check_hosts_status(self, request: Request) -> CheckHostsStatusResult:
5663
# controller's view is authoritative for run-to-completion
5764
# semantics.
5865
cached_instances = handler.apply_pod_timeouts(list(cached.instances))
59-
controller_view = self.read_job_status(namespace, job_name)
66+
controller_view = self._get_cached_job_status(namespace, job_name)
6067
fulfilment = self.compute_fulfilment(
6168
cached_instances,
6269
request.requested_count,
@@ -67,10 +74,14 @@ def check_hosts_status(self, request: Request) -> CheckHostsStatusResult:
6774
selector = handler.build_label_selector(request)
6875

6976
try:
77+
# resource_version='0' serves from the apiserver reflector cache
78+
# (sub-ms, ~500 ms staleness) instead of reading from etcd.
79+
# Acceptable for status polls — not used on create/update paths.
7080
response = handler.with_retry(
7181
handler.client.core_v1.list_namespaced_pod,
7282
namespace=namespace,
7383
label_selector=selector,
84+
resource_version="0",
7485
operation_name="list_namespaced_pod",
7586
)
7687
except Exception as exc:
@@ -97,14 +108,84 @@ def check_hosts_status(self, request: Request) -> CheckHostsStatusResult:
97108
handler._instance_dict_for_pod(pod, namespace=namespace) for pod in pods
98109
]
99110
instances = handler.apply_pod_timeouts(instances)
100-
controller_view = self.read_job_status(namespace, job_name)
111+
controller_view = self._get_cached_job_status(namespace, job_name)
101112
fulfilment = self.compute_fulfilment(
102113
instances,
103114
request.requested_count,
104115
controller_view=controller_view,
105116
)
106117
return CheckHostsStatusResult(instances=instances, fulfilment=fulfilment)
107118

119+
@staticmethod
120+
def _is_terminal(view: dict[str, Any]) -> bool:
121+
"""Return True when the Job has reached a terminal condition.
122+
123+
A Job is terminal when it has a ``Complete`` or ``Failed`` condition
124+
with ``status=True``. Both states are equally steady — neither
125+
transitions further — so both are safe to cache.
126+
127+
Caching ``Failed`` prevents re-fetching the Job on every poll after it
128+
fails (Finding 3: re-fetch loop / log flood fix).
129+
"""
130+
return any(
131+
(c.get("type") in ("Complete", "Failed") and c.get("status") == "True")
132+
for c in (view.get("conditions") or [])
133+
if isinstance(c, dict)
134+
)
135+
136+
def _get_cached_job_status(self, namespace: str, job_name: str) -> dict[str, Any]:
137+
"""Return the controller view, serving from the TTL cache when fresh.
138+
139+
Cache semantics:
140+
141+
* **TTL disabled** — when ``controller_status_cache_ttl_seconds <= 0``
142+
the cache is bypassed entirely (no store, no read) to avoid
143+
unbounded dict growth during high-frequency polls (Finding 5).
144+
* **Terminal-state gate** — the cached view is only served when the
145+
Job has reached a terminal condition (``Complete`` OR ``Failed``).
146+
While the job is still running, every poll issues a fresh GET so
147+
the active→complete/failed transition is observed promptly.
148+
Caching ``Failed`` avoids the re-fetch loop that floods logs when
149+
the Job never recovers (Finding 3).
150+
* **TOCTOU guard** — the fetch timestamp is captured *before* the
151+
blocking GET. On store, an existing entry is only overwritten when
152+
its timestamp is older than the new one, so a slow thread cannot
153+
clobber a fresher entry with a stale view (Finding 2).
154+
155+
The TTL is controlled by
156+
``K8sProviderConfig.controller_status_cache_ttl_seconds`` (default 5 s).
157+
"""
158+
ttl = self._handler.config.controller_status_cache_ttl_seconds
159+
160+
# TTL <= 0 means disabled — bypass cache entirely (Finding 5).
161+
if ttl <= 0:
162+
return self.read_job_status(namespace, job_name)
163+
164+
cache_key = (namespace, job_name)
165+
now = time.monotonic()
166+
167+
with self._cache_lock:
168+
entry = self._controller_cache.get(cache_key)
169+
if entry is not None:
170+
view, ts = entry
171+
if self._is_terminal(view) and now - ts < ttl:
172+
return view
173+
174+
# Cache miss, expired, or job not yet terminal — fetch outside the lock
175+
# to avoid blocking concurrent callers during the API GET. Capture
176+
# the timestamp BEFORE the GET (Finding 2 / TOCTOU guard).
177+
fetch_ts = time.monotonic()
178+
view = self.read_job_status(namespace, job_name)
179+
180+
with self._cache_lock:
181+
existing = self._controller_cache.get(cache_key)
182+
# Only overwrite if there is no existing entry or the existing entry
183+
# is older than this fetch (Finding 2: don't clobber a newer entry).
184+
if existing is None or existing[1] < fetch_ts:
185+
self._controller_cache[cache_key] = (view, fetch_ts)
186+
187+
return view
188+
108189
def read_job_status(self, namespace: str, job_name: str) -> dict[str, Any]:
109190
"""Read controller view from ``batch/v1 Job.status``.
110191
@@ -129,6 +210,9 @@ def read_job_status(self, namespace: str, job_name: str) -> dict[str, Any]:
129210
)
130211
except Exception as exc:
131212
if handler.is_not_found(exc):
213+
# Job not found is normal after release or before creation.
214+
# Log at debug to avoid flooding on the terminal-state poll
215+
# path where the Job has already been deleted (Finding 3).
132216
handler._logger.debug(
133217
"Job %s in %s not found — assuming pre-create or post-release",
134218
job_name,

src/orb/providers/k8s/infrastructure/handlers/pod_status.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ class PodStatusResolver:
2929
def __init__(self, handler: K8sPodHandler) -> None:
3030
self._handler = handler
3131

32-
def check_hosts_status(self, request: Request) -> CheckHostsStatusResult:
32+
def check_hosts_status(
33+
self,
34+
request: Request,
35+
*,
36+
consistent_read: bool = False,
37+
) -> CheckHostsStatusResult:
3338
"""Return per-pod details + the fulfilment verdict for ``request``.
3439
3540
Cache-first read path: when a :class:`PodStateCache` has been
@@ -38,6 +43,18 @@ def check_hosts_status(self, request: Request) -> CheckHostsStatusResult:
3843
A cache miss (no entry) or a stale cache (any entry older than
3944
:attr:`K8sProviderConfig.stale_cache_timeout_seconds`)
4045
falls back to a single ``list_namespaced_pod`` call.
46+
47+
Parameters
48+
----------
49+
request:
50+
The request whose pods are being queried.
51+
consistent_read:
52+
When ``True``, omit ``resource_version='0'`` from the fallback
53+
list call, forcing a consistent read from etcd instead of the
54+
apiserver reflector cache. Use on any path that must confirm
55+
release completion — the ~500 ms reflector lag can otherwise
56+
cause just-deleted pods to appear alive (Finding 4). Defaults
57+
to ``False`` (reflector-cached read) for normal status polls.
4158
"""
4259
handler = self._handler
4360
cached = handler._read_from_cache(request)
@@ -49,12 +66,22 @@ def check_hosts_status(self, request: Request) -> CheckHostsStatusResult:
4966
namespace = handler._resolve_request_namespace(request)
5067
selector = handler.build_label_selector(request)
5168

69+
# resource_version='0' serves from the apiserver reflector cache
70+
# (sub-ms, ~500 ms staleness) instead of reading from etcd.
71+
# Acceptable for status polls — omitted when consistent_read=True
72+
# (e.g. release-confirmation paths that need a strong read).
73+
list_kwargs: dict[str, Any] = {
74+
"namespace": namespace,
75+
"label_selector": selector,
76+
"operation_name": "list_namespaced_pod",
77+
}
78+
if not consistent_read:
79+
list_kwargs["resource_version"] = "0"
80+
5281
try:
5382
response = handler.with_retry(
5483
handler.client.core_v1.list_namespaced_pod,
55-
namespace=namespace,
56-
label_selector=selector,
57-
operation_name="list_namespaced_pod",
84+
**list_kwargs,
5885
)
5986
except Exception as exc:
6087
handler._logger.error(

0 commit comments

Comments
 (0)