1515
1616from __future__ import annotations
1717
18+ import threading
19+ import time
1820from typing import TYPE_CHECKING , Any , Optional
1921
2022from 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 ,
0 commit comments