1+ import random
2+ import time
13from bisect import insort
24from contextlib import contextmanager
35from dataclasses import dataclass
4- from typing import List , Optional , Union
6+ from functools import partial
7+ from typing import Callable , Iterator , List , Optional , Union
58
69import platform_api_python_client
710from platform_api_python_client import (
1821 InviteUserRequest ,
1922 Metric ,
2023)
24+ from typing_extensions import deprecated
2125
2226from centml .sdk import auth
2327from centml .sdk .config import settings
2630
2731DEFAULT_LOG_PAGE_LINES = 100 # server-side default for max_lines
2832MAX_LOG_PAGE_LINES = 5000 # server-side ceiling for max_lines
33+ # fetch_logs asks for less than the server's own default because the common call tails a
34+ # running deployment, where the store has only a handful of new lines to hand over per poll
35+ # whatever the page size is. Reading a backlog wants a far larger chunk_size.
36+ DEFAULT_LOG_CHUNK_LINES = 10
2937# The server re-delivers a ~15s look-behind window on fetch-newer requests; only the
3038# caller's events within this generous margin of the boundary can be re-delivered.
3139LOG_DEDUP_RETENTION_MS = 300_000
40+ # The log read path is rate limited upstream on a bucket shared by every caller, and the
41+ # API reports a saturated bucket the same way it reports a sick store: HTTP 503.
42+ LOG_BUSY_STATUS = 503
43+ # Doubling from half a second spends about seven seconds over these attempts — long enough
44+ # to outlast a bucket refill without looking hung.
45+ LOG_RETRY_ATTEMPTS = 5
46+ LOG_RETRY_BASE_SECONDS = 0.5
47+ # Fraction of each backoff to vary it by: the bucket is shared, so clients backing off in
48+ # lockstep would re-collide on it every round.
49+ LOG_RETRY_JITTER = 0.25
50+
51+
52+ def _with_busy_retry (fetch_page : Callable [[], list ]) -> list :
53+ """Call fetch_page, retrying while the store reports itself busy. There is no
54+ server-issued cursor, so a page request is a pure function of its anchor and
55+ re-issuing it can neither duplicate nor skip lines."""
56+ for attempt in range (LOG_RETRY_ATTEMPTS - 1 ):
57+ try :
58+ return fetch_page ()
59+ except ApiException as exc :
60+ if exc .status != LOG_BUSY_STATUS :
61+ raise
62+ backoff = LOG_RETRY_BASE_SECONDS * 2 ** attempt
63+ time .sleep (backoff * (1 + random .uniform (- LOG_RETRY_JITTER , LOG_RETRY_JITTER )))
64+ # The last attempt propagates whatever it raises.
65+ return fetch_page ()
3266
3367
3468def _recent_anchor (events : list ) -> list :
@@ -42,6 +76,16 @@ def _recent_anchor(events: list) -> list:
4276 return events [first_recent :]
4377
4478
79+ @dataclass (frozen = True )
80+ class _LogAnchor :
81+ """The two fields an after anchor uses: the id it dedupes by and the timestamp it
82+ takes its boundary and its retention cutoff from. Holding these instead of whole
83+ events keeps a long tail off the message text it has already handed out."""
84+
85+ id : str
86+ timestamp : int
87+
88+
4589@dataclass (frozen = True )
4690class DeploymentLogEvent :
4791 """One log line with its pod attached — logs_v4 events carry no pod name, so
@@ -230,6 +274,7 @@ def get_deployment_pods(self, deployment_id: int, revision_number: int) -> List[
230274 ).pods
231275
232276 # pylint: disable=R0917
277+ @deprecated ("get_deployment_logs() is deprecated; use fetch_logs() instead" )
233278 def get_deployment_logs (
234279 self ,
235280 deployment_id : int ,
@@ -239,7 +284,9 @@ def get_deployment_logs(
239284 after : Optional [Union [list , int ]] = None ,
240285 max_lines : int = DEFAULT_LOG_PAGE_LINES ,
241286 ) -> list :
242- """Fetch one page of a pod's logs, oldest-first. Use get_deployment_pods() to
287+ """Deprecated: use fetch_logs() instead.
288+
289+ Fetch one page of a pod's logs, oldest-first. Use get_deployment_pods() to
243290 discover pod names and get_deployment_revisions() for the revision number.
244291
245292 before and after anchor the page to events a previous call returned for the
@@ -257,6 +304,22 @@ def get_deployment_logs(
257304 through undeduplicated. An empty anchor list raises ValueError. Pages never
258305 split a millisecond, so a delivered boundary millisecond is always complete.
259306 """
307+ return self ._fetch_log_page (
308+ deployment_id , revision_number , pod , before = before , after = after , max_lines = max_lines
309+ )
310+
311+ # pylint: disable=R0917
312+ def _fetch_log_page (
313+ self ,
314+ deployment_id : int ,
315+ revision_number : int ,
316+ pod : str ,
317+ before : Optional [Union [list , int ]] = None ,
318+ after : Optional [Union [list , int ]] = None ,
319+ max_lines : int = DEFAULT_LOG_PAGE_LINES ,
320+ ) -> list :
321+ """The page primitive behind fetch_logs and the deprecated readers — the
322+ contract get_deployment_logs() documents, without the deprecation warning."""
260323 if before is not None and after is not None :
261324 raise ValueError ("before and after are mutually exclusive" )
262325
@@ -294,6 +357,7 @@ def get_deployment_logs(
294357 return [event for event in response .events if event .id not in held_event_ids ]
295358
296359 # pylint: disable=R0917
360+ @deprecated ("get_deployment_logs_range() is deprecated; use fetch_logs() instead" )
297361 def get_deployment_logs_range (
298362 self ,
299363 deployment_id : int ,
@@ -302,7 +366,9 @@ def get_deployment_logs_range(
302366 start_time : Optional [int ] = None ,
303367 end_time : Optional [int ] = None ,
304368 ) -> List [DeploymentLogEvent ]:
305- """Fetch every log line in [start_time, end_time] (epoch ms, inclusive; both
369+ """Deprecated: use fetch_logs() instead.
370+
371+ Fetch every log line in [start_time, end_time] (epoch ms, inclusive; both
306372 optional — an open end reads to the beginning or the present), oldest first.
307373 pod=None reads all pods of the revision and merges the streams
308374 chronologically; each returned event carries its pod name."""
@@ -317,7 +383,7 @@ def get_deployment_logs_range(
317383 # after is exclusive, so start_time - 1 admits lines at start_time itself;
318384 # start_time 0 (or None) means the whole window — scan from the head.
319385 anchor : Union [list , int ] = _recent_anchor (events ) if events else (start_time - 1 if start_time else 0 )
320- page = self .get_deployment_logs (
386+ page = self ._fetch_log_page (
321387 deployment_id , revision_number , pod_name , after = anchor , max_lines = MAX_LOG_PAGE_LINES
322388 )
323389 if not page :
@@ -334,18 +400,156 @@ def get_deployment_logs_range(
334400 merged .sort (key = lambda event : event .id )
335401 return merged
336402
403+ # pylint: disable=R0917
404+ def fetch_logs (
405+ self ,
406+ deployment_id : int ,
407+ revision_number : int ,
408+ pod : str ,
409+ start_time : Optional [int ] = None ,
410+ end_time : Optional [int ] = None ,
411+ chunk_size : int = DEFAULT_LOG_CHUNK_LINES ,
412+ ) -> Iterator [List [DeploymentLogEvent ]]:
413+ """Fetch one pod's stored log lines within [start_time, end_time] (epoch ms,
414+ inclusive), yielded lazily oldest first as chunks of DeploymentLogEvent,
415+ each stored line at most once. Discover pod names with
416+ get_deployment_pods().
417+
418+ chunk_size is the number of lines requested from the server per round
419+ trip (1 to MAX_LOG_PAGE_LINES), and each server page that carries window
420+ lines becomes one yielded chunk, so a bulk read of history wants a large
421+ chunk_size — the log read path is rate-limited upstream, and a small
422+ chunk_size over a large window multiplies requests. A chunk usually holds
423+ up to chunk_size lines but can be smaller (lines below start_time or
424+ already delivered are filtered out of the page) or larger (the server
425+ never splits one millisecond across pages, so a millisecond holding more
426+ than chunk_size lines arrives whole). A page filtered away entirely
427+ yields nothing at all rather than an empty chunk, which means only that
428+ the stream is caught up.
429+
430+ start_time defaults to the current time, resolved once when fetch_logs is
431+ called (not at the first next()), so lines logged while the generator sits
432+ unstarted are not skipped; pass an earlier start_time to read history.
433+
434+ With end_time set the iterator terminates once the window is delivered or
435+ the store has no more lines to give, whichever comes first — an end_time
436+ in the future does not keep it polling until then.
437+ Without end_time it never terminates — once caught up it yields an empty
438+ chunk each time nothing new is stored yet, and the caller decides when to
439+ sleep or break:
440+
441+ for chunk in cclient.fetch_logs(dep, rev, pod):
442+ if not chunk:
443+ time.sleep(2)
444+ continue
445+ ...
446+
447+ Nothing is fetched before the first next(), and memory is bounded by the
448+ dedup window rather than by the length of the stream: the anchor holds
449+ only the ids and timestamps of the last LOG_DEDUP_RETENTION_MS, a
450+ generous margin over the server's re-delivery span, so no line is
451+ delivered twice — across empty chunks too.
452+
453+ A line the log store received late lands in a later chunk than its
454+ timestamp position, never duplicated, as long as it lands inside that
455+ re-delivery span (~15s). This reader only ever pages forward, and the
456+ server re-delivers the span behind the boundary only; a line whose
457+ timestamp falls further than the span behind the newest line already
458+ delivered is never returned at all. Lines inside each chunk are always
459+ in ascending (timestamp, id) order.
460+
461+ A page request the store answers as busy (HTTP 503) is retried with
462+ exponential backoff and jitter; the anchor survives the retry, so an
463+ upstream rate limit costs a pause rather than the stream.
464+
465+ If a page request fails for any other reason, or the retries run out,
466+ the iterator raises and, like any generator, cannot be resumed — but
467+ every chunk already yielded is complete and none is left half-built.
468+ Resume with a new fetch_logs whose start_time is the last delivered
469+ event's timestamp: bounds are inclusive, so the only lines delivered
470+ again are the ones sharing that millisecond, which the caller already
471+ holds.
472+ """
473+ if not 1 <= chunk_size <= MAX_LOG_PAGE_LINES :
474+ raise ValueError (
475+ f"chunk_size must be between 1 and { MAX_LOG_PAGE_LINES } lines "
476+ "(chunk_size is also the per-request page size, and the server "
477+ f"caps max_lines at { MAX_LOG_PAGE_LINES } )"
478+ )
479+ if (start_time is not None and start_time < 0 ) or (end_time is not None and end_time < 0 ):
480+ raise ValueError ("start_time and end_time are epoch milliseconds and must not be negative" )
481+ if start_time is not None and end_time is not None and start_time > end_time :
482+ raise ValueError ("start_time must not exceed end_time" )
483+ start_ms = int (time .time () * 1000 ) if start_time is None else start_time
484+
485+ def chunks () -> Iterator [List [DeploymentLogEvent ]]:
486+ held : list = []
487+ # after is exclusive, so start_ms - 1 admits lines at start_ms itself.
488+ initial_boundary = max (start_ms - 1 , 0 )
489+ while True :
490+ anchor : Union [list , int ] = _recent_anchor (held ) if held else initial_boundary
491+ page = _with_busy_retry (
492+ partial (
493+ self ._fetch_log_page , deployment_id , revision_number , pod , after = anchor , max_lines = chunk_size
494+ )
495+ )
496+ past_end = False
497+ chunk : List [DeploymentLogEvent ] = []
498+ for raw in page :
499+ anchor_event = _LogAnchor (id = raw .id , timestamp = raw .timestamp )
500+ if held and raw .id <= held [- 1 ].id :
501+ # Late arrival inside the look-behind span: keep the held window
502+ # id-ordered (id order == time order) so trimming stays correct.
503+ insort (held , anchor_event , key = lambda held_event : held_event .id )
504+ else :
505+ held .append (anchor_event )
506+ # Anchoring at start_ms - 1 re-delivers the look-behind span below
507+ # start_ms; those ids must be held for dedup but never emitted.
508+ if raw .timestamp < start_ms :
509+ continue
510+ if end_time is not None and raw .timestamp > end_time :
511+ past_end = True
512+ break
513+ event = DeploymentLogEvent (id = raw .id , timestamp = raw .timestamp , message = raw .message , pod = pod )
514+ # The server orders a page by nanosecond timestamp only, never by
515+ # the id's hash suffix, so lines sharing one nanosecond can arrive
516+ # in either id order; insort keeps every chunk ascending.
517+ if chunk and (event .timestamp , event .id ) < (chunk [- 1 ].timestamp , chunk [- 1 ].id ):
518+ insort (chunk , event , key = lambda chunk_event : (chunk_event .timestamp , chunk_event .id ))
519+ else :
520+ chunk .append (event )
521+ if page :
522+ held = _recent_anchor (held )
523+ if chunk :
524+ yield chunk
525+ if past_end or (not page and end_time is not None ):
526+ break
527+ if not page :
528+ # Caught up with no end bound: signal "nothing new yet" until
529+ # new lines are stored.
530+ yield []
531+
532+ # The nested generator closes over the validated arguments, so the
533+ # ValueErrors above raise at the call rather than at the first next().
534+ return chunks ()
535+
536+ @deprecated ("deployment_log_session() is deprecated; use fetch_logs() instead" )
337537 def deployment_log_session (
338538 self , deployment_id : int , revision_number : int , pod : str , events : Optional [list ] = None
339539 ) -> "DeploymentLogSession" :
340- """Stateful reader for one pod's logs that tracks fetched pages and anchors
540+ """Deprecated: use fetch_logs() instead.
541+
542+ Stateful reader for one pod's logs that tracks fetched pages and anchors
341543 every request itself — see DeploymentLogSession. Seed events with logs a
342544 previous session (or get_deployment_logs) returned for the same pod."""
343545 return DeploymentLogSession (self , deployment_id , revision_number , pod , events )
344546
345547
548+ @deprecated ("DeploymentLogSession is deprecated; use CentMLClient.fetch_logs() instead" )
346549class DeploymentLogSession :
347- """Maintains a contiguous, ordered window of one pod's logs across fetches .
550+ """Deprecated: use CentMLClient.fetch_logs() instead .
348551
552+ Maintains a contiguous, ordered window of one pod's logs across fetches.
349553 Every fetch is anchored on the window itself, so pages can never overlap or
350554 leave gaps inside it (within log retention; an undetectable gap forms if the
351555 session idles past retention before fetching newer lines).
@@ -372,7 +576,7 @@ def fetch_older(self, max_lines: int = DEFAULT_LOG_PAGE_LINES) -> list:
372576 """Fetch the page older than the window and prepend it; on an empty session
373577 fetches the newest page (tail). Returns the page; empty list = no older
374578 lines exist (yet)."""
375- page = self ._client .get_deployment_logs (
579+ page = self ._client ._fetch_log_page (
376580 self ._deployment_id ,
377581 self ._revision_number ,
378582 self ._pod ,
@@ -390,7 +594,7 @@ def fetch_newer(self, max_lines: int = DEFAULT_LOG_PAGE_LINES) -> list:
390594 tailing. Rare late arrivals sort into the window below its newest lines."""
391595 if not self ._events :
392596 return self .fetch_older (max_lines = max_lines )
393- delta = self ._client .get_deployment_logs (
597+ delta = self ._client ._fetch_log_page (
394598 self ._deployment_id ,
395599 self ._revision_number ,
396600 self ._pod ,
0 commit comments