Skip to content

Commit 45212d1

Browse files
committed
feat(sdk): migrate deployment logs to logs_v4 and add pod listing
Adds get_deployment_pods, a page-based get_deployment_logs anchored on previously fetched events or an epoch-ms boundary (before/after), a get_deployment_logs_range time-window read that can merge all pods of a revision, and a stateful DeploymentLogSession for backfill, tailing, and cross-process resume. Signed-off-by: Honglin Cao <hocao@nvidia.com>
1 parent 17c38b8 commit 45212d1

4 files changed

Lines changed: 594 additions & 88 deletions

File tree

‎README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,22 @@ Use `python examples/sdk/get_clusters.py` and
5353
Creating the example reserves GPU capacity and may incur usage charges. It does not
5454
delete the deployment automatically.
5555

56+
### Deployment logs SDK example
57+
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:
67+
68+
```bash
69+
python examples/sdk/get_deployment_logs.py
70+
```
71+
5672
### Un-installation
5773

5874
To uninstall `centml`, simply do:

‎centml/sdk/api.py‎

Lines changed: 179 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
from bisect import insort
12
from contextlib import contextmanager
3+
from dataclasses import dataclass
4+
from typing import List, Optional, Union
25

36
import platform_api_python_client
47
from platform_api_python_client import (
@@ -21,6 +24,23 @@
2124

2225
STATUS_V3_DEPLOYMENT_TYPES = {DeploymentType.INFERENCE_V3, DeploymentType.CSERVE_V3}
2326

27+
DEFAULT_LOG_PAGE_LINES = 100 # server-side default for max_lines
28+
MAX_LOG_PAGE_LINES = 5000 # server-side ceiling for max_lines
29+
# The server re-delivers a ~15s look-behind window on fetch-newer requests; only the
30+
# caller's events within this generous margin of the boundary can be re-delivered.
31+
LOG_DEDUP_RETENTION_MS = 300_000
32+
33+
34+
@dataclass(frozen=True)
35+
class DeploymentLogEvent:
36+
"""One log line with its pod attached — logs_v4 events carry no pod name, so
37+
merged multi-pod views need the SDK to attribute each line itself."""
38+
39+
id: str
40+
timestamp: int
41+
message: str
42+
pod: str
43+
2444

2545
class CentMLClient:
2646
def __init__(self, api):
@@ -191,46 +211,175 @@ def get_deployment_revisions(self, deployment_id: int):
191211
deployment_id=deployment_id
192212
).results
193213

214+
def get_deployment_pods(self, deployment_id: int, revision_number: int) -> List[str]:
215+
"""List pods that have logged for a deployment revision, including terminated
216+
pods still within log retention. A fresh deployment may return an empty list."""
217+
return self._api.get_deployment_pods_deployments_pods_deployment_id_revision_number_get(
218+
deployment_id=deployment_id, revision_number=revision_number
219+
).pods
220+
221+
# pylint: disable=R0917
194222
def get_deployment_logs(
195223
self,
196224
deployment_id: int,
197225
revision_number: int,
198-
start_time: int,
199-
end_time: int,
200-
line_count: int = 100,
201-
start_from_head: bool = True,
202-
stream: bool = False,
203-
):
204-
"""Fetch logs for a deployment within a time window, handling pagination automatically.
205-
206-
start_time and end_time are Unix timestamps in milliseconds.
207-
Use get_deployment_revisions() to find the current revision number.
208-
209-
If stream=True, returns a generator that yields events as each page is fetched.
210-
If stream=False (default), returns a flat list of all events.
226+
pod: str,
227+
before: Optional[Union[list, int]] = None,
228+
after: Optional[Union[list, int]] = None,
229+
max_lines: int = DEFAULT_LOG_PAGE_LINES,
230+
) -> list:
231+
"""Fetch one page of a pod's logs, oldest-first. Use get_deployment_pods() to
232+
discover pod names and get_deployment_revisions() for the revision number.
233+
234+
before and after anchor the page to events a previous call returned for the
235+
same pod (pass your accumulated list; only the relevant boundary is used):
236+
- neither: the newest page (tail).
237+
- before=<events>: the page strictly older than the oldest of them;
238+
an empty result means the beginning of history is reached.
239+
- after=<events>: lines strictly newer than the newest of them; an empty
240+
result means nothing new yet — call again later to keep tailing. Late
241+
lines still landing near that boundary are included on top of max_lines
242+
and may sort below events you already hold (order by id if that matters).
243+
Either anchor also accepts a bare epoch-millisecond int as the (exclusive)
244+
boundary itself; an int after anchor holds no event ids, so the re-delivered
245+
span at the boundary comes through undeduplicated.
211246
"""
247+
if before is not None and after is not None:
248+
raise ValueError("before and after are mutually exclusive")
249+
250+
fetch_newer = after is not None
251+
anchor = after if fetch_newer else before
252+
anchor_events = None
253+
boundary_timestamp = None
254+
if isinstance(anchor, int):
255+
boundary_timestamp = anchor
256+
elif anchor:
257+
anchor_events = anchor
258+
timestamps = [event.timestamp for event in anchor_events]
259+
boundary_timestamp = max(timestamps) if fetch_newer else min(timestamps)
260+
261+
response = self._api.get_deployment_logs_v4_logs_deployment_id_revision_number_get(
262+
deployment_id=deployment_id,
263+
revision_number=revision_number,
264+
pod=pod,
265+
fetch_newer=fetch_newer,
266+
timestamp=boundary_timestamp,
267+
max_lines=max_lines,
268+
)
269+
if not fetch_newer or not anchor_events:
270+
return response.events
271+
272+
# fetch_newer re-delivers a look-behind window at and before the boundary
273+
# (late-arrival protection); drop the lines the caller already holds by id.
274+
cutoff = max(event.timestamp for event in anchor_events) - LOG_DEDUP_RETENTION_MS
275+
held_event_ids = {event.id for event in anchor_events if event.timestamp >= cutoff}
276+
return [event for event in response.events if event.id not in held_event_ids]
212277

213-
def _iter_events():
214-
next_page_token = None
278+
# pylint: disable=R0917
279+
def get_deployment_logs_range(
280+
self,
281+
deployment_id: int,
282+
revision_number: int,
283+
pod: Optional[str] = None,
284+
start_time: Optional[int] = None,
285+
end_time: Optional[int] = None,
286+
) -> List[DeploymentLogEvent]:
287+
"""Fetch every log line in [start_time, end_time] (epoch ms, inclusive; both
288+
optional — an open end reads to the beginning or the present), oldest first.
289+
pod=None reads all pods of the revision and merges the streams
290+
chronologically; each returned event carries its pod name."""
291+
if start_time is not None and end_time is not None and start_time > end_time:
292+
raise ValueError("start_time must not exceed end_time")
293+
294+
pods = [pod] if pod is not None else self.get_deployment_pods(deployment_id, revision_number)
295+
merged: List[DeploymentLogEvent] = []
296+
for pod_name in pods:
297+
events: list = []
215298
while True:
216-
response = self._api.get_deployment_logs_v3_deployments_logs_v3_deployment_id_revision_number_get(
217-
deployment_id=deployment_id,
218-
revision_number=revision_number,
219-
start_time=start_time,
220-
end_time=end_time,
221-
next_page_token=next_page_token,
222-
start_from_head=start_from_head,
223-
line_count=line_count,
299+
# after is exclusive, so start_time - 1 admits lines at start_time itself;
300+
# start_time 0 (or None) means the whole window — scan from the head.
301+
anchor: Union[list, int] = events if events else (start_time - 1 if start_time else [])
302+
page = self.get_deployment_logs(
303+
deployment_id, revision_number, pod_name, after=anchor, max_lines=MAX_LOG_PAGE_LINES
224304
)
225-
yield from response.events
226-
next_page_token = response.next_page_token
227-
if not next_page_token:
305+
if not page:
228306
break
307+
events += page
308+
if end_time is not None and page[-1].timestamp > end_time:
309+
break
310+
merged += [
311+
DeploymentLogEvent(id=event.id, timestamp=event.timestamp, message=event.message, pod=pod_name)
312+
for event in events
313+
if (start_time is None or event.timestamp >= start_time)
314+
and (end_time is None or event.timestamp <= end_time)
315+
]
316+
merged.sort(key=lambda event: event.id)
317+
return merged
318+
319+
def deployment_log_session(
320+
self, deployment_id: int, revision_number: int, pod: str, events: Optional[list] = None
321+
) -> "DeploymentLogSession":
322+
"""Stateful reader for one pod's logs that tracks fetched pages and anchors
323+
every request itself — see DeploymentLogSession. Seed events with logs a
324+
previous session (or get_deployment_logs) returned for the same pod."""
325+
return DeploymentLogSession(self, deployment_id, revision_number, pod, events)
326+
327+
328+
class DeploymentLogSession:
329+
"""Maintains a contiguous, ordered window of one pod's logs across fetches.
330+
331+
Every fetch is anchored on the window itself, so pages can never overlap or
332+
leave gaps inside it (within log retention; an undetectable gap forms if the
333+
session idles past retention before fetching newer lines).
334+
"""
229335

230-
if stream:
231-
return _iter_events()
232-
233-
return list(_iter_events())
336+
# pylint: disable=R0917
337+
def __init__(self, client: CentMLClient, deployment_id: int, revision_number: int, pod: str, events=None):
338+
self._client = client
339+
self._deployment_id = deployment_id
340+
self._revision_number = revision_number
341+
self._pod = pod
342+
# Seeded events come from outside the session: canonicalize to unique ids in
343+
# chronological order (id order == time order at nanosecond precision).
344+
unique_events = {event.id: event for event in events or []}
345+
self._events = [unique_events[event_id] for event_id in sorted(unique_events)]
346+
347+
@property
348+
def events(self) -> list:
349+
"""Copy of the window fetched so far, oldest first. Complete from the beginning
350+
of history only once fetch_older() has returned an empty list."""
351+
return list(self._events)
352+
353+
def fetch_older(self, max_lines: int = DEFAULT_LOG_PAGE_LINES) -> list:
354+
"""Fetch the page older than the window and prepend it; on an empty session
355+
fetches the newest page (tail). Returns the page; empty list = no older
356+
lines exist (yet)."""
357+
page = self._client.get_deployment_logs(
358+
self._deployment_id, self._revision_number, self._pod, before=self._events, max_lines=max_lines
359+
)
360+
self._events[:0] = page
361+
return page
362+
363+
def fetch_newer(self, max_lines: int = DEFAULT_LOG_PAGE_LINES) -> list:
364+
"""Fetch lines newer than the window and merge them in; on an empty session
365+
fetches the newest page (tail) — to read from the beginning of history
366+
instead, loop fetch_older() until it returns an empty list. Returns only
367+
the new lines; empty list = nothing new yet, call again later to keep
368+
tailing. Rare late arrivals sort into the window below its newest lines."""
369+
if not self._events:
370+
return self.fetch_older(max_lines=max_lines)
371+
delta = self._client.get_deployment_logs(
372+
self._deployment_id, self._revision_number, self._pod, after=self._events, max_lines=max_lines
373+
)
374+
for event in delta:
375+
if event.id > self._events[-1].id:
376+
self._events.append(event)
377+
else:
378+
# A late arrival may even precede the window's oldest line (tail page
379+
# cut inside the look-behind span); the server delivers that span
380+
# completely on top of max_lines, so the window stays contiguous.
381+
insort(self._events, event, key=lambda held: held.id)
382+
return delta
234383

235384

236385
@contextmanager

‎examples/sdk/get_deployment_logs.py‎

Lines changed: 42 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,58 @@
1-
from datetime import datetime, timezone, timedelta
1+
import time
2+
from datetime import datetime, timezone
23

34
from centml.sdk.api import get_centml_client
45

56
# --- Configuration ---
67
DEPLOYMENT_ID = 1234 # Replace with your deployment ID
78
REVISION_NUMBER = 10
8-
HOURS_BACK = 1 # Fetch logs from the last N hours
9+
TAIL_SECONDS = 30 # How long to keep polling for new lines after reading history
910

1011

11-
def format_event(event: dict) -> str:
12-
timestamp_ms = (
13-
event.get("timestamp")
14-
or event.get("time")
15-
or event.get("ts")
16-
or ""
17-
)
18-
message = (
19-
event.get("message")
20-
or event.get("msg")
21-
or event.get("log")
22-
or str(event)
23-
)
24-
if timestamp_ms:
25-
ts = datetime.fromtimestamp(int(timestamp_ms) / 1000, tz=timezone.utc).isoformat()
26-
return f"[{ts}] {message}"
27-
return message
12+
def format_event(event) -> str:
13+
ts = datetime.fromtimestamp(event.timestamp / 1000, tz=timezone.utc).isoformat()
14+
return f"[{ts}] {event.message}"
2815

2916

3017
def main():
31-
stream = True
32-
end_time = int(datetime.now(timezone.utc).timestamp() * 1000)
33-
start_time = end_time - int(timedelta(hours=HOURS_BACK).total_seconds() * 1000)
34-
35-
print(f"Fetching logs for deployment {DEPLOYMENT_ID}")
36-
print(
37-
f"Time window: "
38-
f"{datetime.fromtimestamp(start_time / 1000, tz=timezone.utc).isoformat()} → "
39-
f"{datetime.fromtimestamp(end_time / 1000, tz=timezone.utc).isoformat()}"
40-
)
41-
print()
42-
4318
with get_centml_client() as cclient:
44-
if stream:
45-
# Streaming: print events as each page arrives
46-
for event in cclient.get_deployment_logs(
47-
deployment_id=DEPLOYMENT_ID,
48-
revision_number=REVISION_NUMBER,
49-
start_time=start_time,
50-
end_time=end_time,
51-
start_from_head=False,
52-
stream=stream,
53-
):
54-
print(format_event(event))
55-
else:
56-
# Batch: collect all events then process
57-
events = cclient.get_deployment_logs(
58-
deployment_id=DEPLOYMENT_ID,
59-
revision_number=REVISION_NUMBER,
60-
start_time=start_time,
61-
end_time=end_time,
62-
start_from_head=False,
63-
)
64-
65-
if not events:
66-
print("No logs found in the given time window.")
67-
return
68-
69-
print(f"Found {len(events)} log entries:\n")
70-
for event in events:
19+
# Logs are read per pod: discover the pods that have logged for this revision
20+
# (terminated pods within log retention are included).
21+
pods = cclient.get_deployment_pods(DEPLOYMENT_ID, REVISION_NUMBER)
22+
if not pods:
23+
print("No pods have logged for this revision yet.")
24+
return
25+
26+
pod = pods[0]
27+
print(f"Reading logs for deployment {DEPLOYMENT_ID} revision {REVISION_NUMBER}, pod {pod}\n")
28+
29+
# The session tracks what it has fetched and anchors every request itself.
30+
session = cclient.deployment_log_session(DEPLOYMENT_ID, REVISION_NUMBER, pod)
31+
32+
# Read the full history: newest page first, then page back to the beginning.
33+
while session.fetch_older():
34+
pass
35+
print(f"Found {len(session.events)} log entries:\n")
36+
for event in session.events:
37+
print(format_event(event))
38+
39+
# Keep tailing: each call returns only the lines the session does not hold yet.
40+
print(f"\nPolling for new lines for {TAIL_SECONDS}s...")
41+
deadline = time.monotonic() + TAIL_SECONDS
42+
while time.monotonic() < deadline:
43+
for event in session.fetch_newer():
7144
print(format_event(event))
45+
time.sleep(2)
46+
47+
# The same paging is available statelessly via get_deployment_logs, anchored
48+
# on events you already hold — useful when you manage storage yourself:
49+
# page = cclient.get_deployment_logs(DEPLOYMENT_ID, REVISION_NUMBER, pod=pod) # tail
50+
# older = cclient.get_deployment_logs(..., pod=pod, before=page) # [] = beginning
51+
# newer = cclient.get_deployment_logs(..., pod=pod, after=page) # [] = nothing new
52+
# A specific time window (all pods merged, oldest first, pod on each event):
53+
# window = cclient.get_deployment_logs_range(
54+
# DEPLOYMENT_ID, REVISION_NUMBER, start_time=t1_ms, end_time=t2_ms
55+
# )
7256

7357

7458
if __name__ == "__main__":

0 commit comments

Comments
 (0)