Skip to content

Commit 258dce0

Browse files
authored
[SDK] Consolidate deployment log reading into fetch_logs (#152)
Deployment log reading was spread over three stateful readers, none of which could stream one pod's logs lazily with bounded memory. Tailing a running deployment meant re-creating a reader over overlapping windows and keeping a seen dict in user code to drop the server's late-arrival re-deliveries. fetch_logs(deployment_id, revision_number, pod, start_time=None, end_time=None, chunk_size=DEFAULT_LOG_CHUNK_LINES) replaces all three. pod is required; discover names with get_deployment_pods(). start_time defaults to the current time, resolved when fetch_logs is called rather than at the first next(), so lines logged while the generator sits unstarted are not skipped. chunk_size is the number of lines requested per round trip, so it inherits the server's 1..5000 ceiling and is validated at the call. With end_time set the iterator terminates once the window is delivered or the store has no more lines to give. Without it the iterator never terminates: once caught up it yields an empty chunk each time nothing new is stored yet, and the caller decides when to sleep or break. The dedup anchor lives inside the generator for its whole lifetime, holding only ids and timestamps within the dedup window, so every stored line is delivered at most once and callers need no cross-call bookkeeping. Each server page that carries window lines becomes one chunk, ascending by timestamp and id. A page the store answers as busy is retried in place with exponential backoff and jitter: there is no server-issued cursor, so a page request is a pure function of its anchor and re-issuing it can neither duplicate nor skip lines. get_deployment_logs, get_deployment_logs_range, deployment_log_session and the DeploymentLogSession class carry typing_extensions.deprecated and keep working unchanged. typing-extensions becomes a declared dependency. The README is one paragraph plus one example each for a window read and a tail, and examples/sdk/get_deployment_logs.py shows the same two shapes over a single generator. Signed-off-by: Honglin Cao <hocao@nvidia.com>
1 parent 79a7632 commit 258dce0

5 files changed

Lines changed: 819 additions & 72 deletions

File tree

‎README.md‎

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,20 +55,45 @@ delete the deployment automatically.
5555

5656
### Deployment logs SDK example
5757

58-
Logs are read per pod. Discover pod names with `get_deployment_pods()` (terminated
59-
pods still within log retention are included), then read with a
60-
`deployment_log_session()`: `fetch_older()` pages toward the beginning of history and
61-
`fetch_newer()` returns only new lines, while the session keeps the merged, ordered
62-
log in `.events`. `get_deployment_logs_range()` fetches a specific time window
63-
(epoch-millisecond bounds, both optional) and, with `pod=None`, merges every pod's
64-
stream chronologically. The same paging is available statelessly through
65-
`get_deployment_logs(before=..., after=...)`, anchored on events you already hold
66-
or on a bare epoch-millisecond boundary:
58+
`fetch_logs()` is the one way to read deployment logs: it fetches one pod's stored
59+
log lines within a time window (`start_time`/`end_time`, epoch ms, inclusive) and
60+
yields them lazily, oldest first, as chunks of `DeploymentLogEvent` — each line at
61+
most once, holding only a short dedup window in memory however long the stream.
62+
`chunk_size` (1 to 5000) is the number of lines requested from the server per
63+
round trip and each server page that carries window lines becomes one chunk, so
64+
a bulk read of history wants a large `chunk_size` — the log read path is
65+
rate-limited upstream, and a small `chunk_size` over a large window multiplies
66+
requests. Discover pod names with `get_deployment_pods()`
67+
(terminated pods still within log retention are included). `start_time` defaults
68+
to the moment of the call; with `end_time` set the iterator terminates once the
69+
window is delivered or the store has no more lines to give, whichever comes
70+
first:
71+
72+
```python
73+
for chunk in cclient.fetch_logs(DEPLOYMENT_ID, REVISION, pod, start_time=t1_ms, end_time=t2_ms):
74+
for event in chunk:
75+
print(event.message)
76+
```
6777

68-
```bash
69-
python examples/sdk/get_deployment_logs.py
78+
Without `end_time` the same generator tails: it never terminates, and once caught
79+
up it yields an empty chunk each time nothing new is stored yet — the caller
80+
decides when to sleep or break:
81+
82+
```python
83+
import time
84+
85+
for chunk in cclient.fetch_logs(DEPLOYMENT_ID, REVISION, pod):
86+
if not chunk:
87+
time.sleep(2)
88+
continue
89+
for event in chunk:
90+
print(event.message)
7091
```
7192

93+
`python examples/sdk/get_deployment_logs.py` runs both. `get_deployment_logs()`,
94+
`get_deployment_logs_range()` and `deployment_log_session()` still work but are
95+
deprecated in favor of `fetch_logs()` and emit a `DeprecationWarning` on use.
96+
7297
### Un-installation
7398

7499
To uninstall `centml`, simply do:

‎centml/sdk/api.py‎

Lines changed: 212 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import random
2+
import time
13
from bisect import insort
24
from contextlib import contextmanager
35
from 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

69
import platform_api_python_client
710
from platform_api_python_client import (
@@ -18,6 +21,7 @@
1821
InviteUserRequest,
1922
Metric,
2023
)
24+
from typing_extensions import deprecated
2125

2226
from centml.sdk import auth
2327
from centml.sdk.config import settings
@@ -26,9 +30,39 @@
2630

2731
DEFAULT_LOG_PAGE_LINES = 100 # server-side default for max_lines
2832
MAX_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.
3139
LOG_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

3468
def _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)
4690
class 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")
346549
class 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

Comments
 (0)