cortex-training 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ # Copyright 2025 Snowflake Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Client-side persistent cache for the Cortex Training log TUI.
17
+
18
+ Reopening a job should not re-retrieve logs already fetched — that is wasted
19
+ load on the (deliberately stateless) Zone Manager. The transport is already
20
+ cursor-based (``tail_logs(cursor) -> {entries, next_cursor, eof}``), so we cache,
21
+ per ``(job_id, source_id)`` on the *client*:
22
+
23
+ * the opaque ``next_cursor`` — lets the next open resume with a delta-only fetch
24
+ * the retrieved entries (append-only JSONL) — lets the next open repaint instantly
25
+
26
+ On reopen the TUI replays the local cache, then resumes ``tail_logs`` from the
27
+ saved cursor. Nothing is added to the server.
28
+
29
+ Pure stdlib, NO ``textual`` import, so it is unit-testable like ``format.py``.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import tempfile
37
+ import time
38
+ from collections import deque
39
+ from datetime import datetime
40
+ from datetime import timezone
41
+ from pathlib import Path
42
+ from typing import Any
43
+ from typing import Callable
44
+ from typing import Iterable
45
+ from typing import Iterator
46
+ from typing import Optional
47
+ from typing import Tuple
48
+
49
+ # Cache dir resolution mirrors cortex_training._cli._login_state_path: an explicit
50
+ # override wins, else XDG_CACHE_HOME, else ~/.cache, namespaced under the app.
51
+ _OVERRIDE_ENV = "CORTEX_TRAINING_TUI_CACHE_DIR"
52
+ _XDG_CACHE_ENV = "XDG_CACHE_HOME"
53
+ _NAMESPACE = "cortex-training"
54
+
55
+ # Backoff defaults, matching CortexTrainingClient.poll_backoff_multiplier /
56
+ # poll_max_interval. Kept module-level so a test/mock client need not carry them.
57
+ _POLL_BACKOFF_MULTIPLIER = 1.25
58
+ _POLL_MAX_INTERVAL = 6.0
59
+
60
+ _ERROR_LOG = os.path.expanduser("~/.cortex-training-errors.log")
61
+ _persist_warned = False
62
+
63
+ # Filename-safe byte set; everything else (incl. ':' '/' '%') is %XX-escaped so
64
+ # a source_id collapses to a single, reversible path component.
65
+ _SAFE = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.")
66
+ _MAX_NAME = 200
67
+
68
+
69
+ def cache_root() -> Path:
70
+ """Base cache directory (not created here; writers mkdir lazily)."""
71
+ override = os.environ.get(_OVERRIDE_ENV)
72
+ if override:
73
+ return Path(override).expanduser()
74
+ xdg = os.environ.get(_XDG_CACHE_ENV)
75
+ base = Path(xdg).expanduser() if xdg else Path.home() / ".cache"
76
+ return base / _NAMESPACE
77
+
78
+
79
+ def safe_source_name(source_id: str) -> str:
80
+ """Encode an arbitrary source_id into one filesystem-safe path component.
81
+
82
+ Reversible via :func:`unsafe_source_name` for ids under ``_MAX_NAME`` bytes;
83
+ longer ids get a sha1 suffix and are not reversible (correctness comes from
84
+ the cursors.json id->cursor map, not the filename).
85
+ """
86
+ out = []
87
+ for b in source_id.encode("utf-8"):
88
+ c = chr(b)
89
+ out.append(c if c in _SAFE else "%%%02X" % b)
90
+ name = "".join(out)
91
+ if name in (".", ".."):
92
+ name = name.replace(".", "%2E")
93
+ if len(name) > _MAX_NAME:
94
+ import hashlib
95
+
96
+ digest = hashlib.sha1(source_id.encode("utf-8")).hexdigest()[:12]
97
+ name = name[: _MAX_NAME - 13] + "-" + digest
98
+ return name
99
+
100
+
101
+ def unsafe_source_name(name: str) -> str:
102
+ """Inverse of :func:`safe_source_name` (for tests / introspection)."""
103
+ raw = bytearray()
104
+ i = 0
105
+ while i < len(name):
106
+ ch = name[i]
107
+ if ch == "%" and i + 2 < len(name) + 1 and i + 3 <= len(name):
108
+ raw.append(int(name[i + 1 : i + 3], 16))
109
+ i += 3
110
+ else:
111
+ raw.append(ord(ch))
112
+ i += 1
113
+ return raw.decode("utf-8")
114
+
115
+
116
+ def _atomic_write_text(path: Path, text: str) -> None:
117
+ """Write text via same-dir temp + fsync + os.replace (atomic on one fs)."""
118
+ path.parent.mkdir(parents=True, exist_ok=True)
119
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".tmp-", suffix=".swp")
120
+ try:
121
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
122
+ f.write(text)
123
+ f.flush()
124
+ os.fsync(f.fileno())
125
+ os.replace(tmp, str(path))
126
+ except BaseException:
127
+ try:
128
+ os.unlink(tmp)
129
+ except OSError:
130
+ pass
131
+ raise
132
+
133
+
134
+ class LogCache:
135
+ """Per-job on-disk cache of log entries + resume cursors.
136
+
137
+ Layout::
138
+
139
+ <cache_root>/jobs/<safe(job_id)>/cursors.json
140
+ <cache_root>/jobs/<safe(job_id)>/logs/<safe(source_id)>.jsonl
141
+ """
142
+
143
+ DEFAULT_MAX_BYTES = 50 * 1024 * 1024
144
+ DEFAULT_MAX_LINES = 100_000
145
+
146
+ def __init__(
147
+ self,
148
+ job_id: str,
149
+ *,
150
+ root: Optional[Path] = None,
151
+ max_bytes: int = DEFAULT_MAX_BYTES,
152
+ max_lines: int = DEFAULT_MAX_LINES,
153
+ ) -> None:
154
+ base = root if root is not None else cache_root()
155
+ self._dir = base / "jobs" / safe_source_name(job_id)
156
+ self._max_bytes = max_bytes
157
+ self._max_lines = max_lines
158
+
159
+ # ---- paths ----
160
+ def _logs_dir(self) -> Path:
161
+ return self._dir / "logs"
162
+
163
+ def _jsonl_path(self, source_id: str) -> Path:
164
+ return self._logs_dir() / (safe_source_name(source_id) + ".jsonl")
165
+
166
+ def _cursors_path(self) -> Path:
167
+ return self._dir / "cursors.json"
168
+
169
+ # ---- cursors ----
170
+ def _read_cursors(self) -> dict:
171
+ try:
172
+ doc = json.loads(self._cursors_path().read_text(encoding="utf-8"))
173
+ if isinstance(doc, dict):
174
+ return doc
175
+ except (OSError, ValueError):
176
+ pass
177
+ return {"version": 1, "sources": {}}
178
+
179
+ def get_cursor(self, source_id: str) -> Optional[str]:
180
+ entry = self._read_cursors().get("sources", {}).get(source_id)
181
+ if isinstance(entry, dict):
182
+ cur = entry.get("cursor")
183
+ return cur if isinstance(cur, str) else None
184
+ return None
185
+
186
+ def set_cursor(self, source_id: str, cursor: Optional[str]) -> None:
187
+ doc = self._read_cursors()
188
+ sources = doc.setdefault("sources", {})
189
+ sources[source_id] = {
190
+ "cursor": cursor,
191
+ "updated": datetime.now(timezone.utc).isoformat(),
192
+ }
193
+ doc["version"] = 1
194
+ _atomic_write_text(self._cursors_path(), json.dumps(doc, sort_keys=True) + "\n")
195
+
196
+ # ---- entries ----
197
+ def append_entries(self, source_id: str, entries: Iterable[Any]) -> int:
198
+ path = self._jsonl_path(source_id)
199
+ path.parent.mkdir(parents=True, exist_ok=True)
200
+ n = 0
201
+ with open(path, "a", encoding="utf-8") as f:
202
+ for e in entries:
203
+ try:
204
+ line = json.dumps(e, separators=(",", ":"))
205
+ except (TypeError, ValueError):
206
+ line = json.dumps({"_raw": str(e)}, separators=(",", ":"))
207
+ f.write(line + "\n")
208
+ n += 1
209
+ f.flush()
210
+ if n:
211
+ self._rotate_if_needed(source_id)
212
+ return n
213
+
214
+ def load_entries(self, source_id: str, *, limit: Optional[int] = None) -> list:
215
+ path = self._jsonl_path(source_id)
216
+ if not path.exists():
217
+ return []
218
+ sink: Any = deque(maxlen=limit) if limit else []
219
+ try:
220
+ with open(path, "r", encoding="utf-8") as f:
221
+ for line in f:
222
+ line = line.strip()
223
+ if not line:
224
+ continue
225
+ try:
226
+ sink.append(json.loads(line))
227
+ except ValueError:
228
+ continue # torn last line / corruption: skip
229
+ except OSError:
230
+ return []
231
+ return list(sink)
232
+
233
+ def load(self, source_id: str, *, limit: Optional[int] = None) -> Tuple[list, Optional[str]]:
234
+ return self.load_entries(source_id, limit=limit), self.get_cursor(source_id)
235
+
236
+ def cached_sources(self) -> list:
237
+ """Source ids that have cached log data on disk for this job. Lets the
238
+ TUI show previously-viewed sources (and replay them) even when a live
239
+ ``list_log_sources`` call fails."""
240
+ d = self._logs_dir()
241
+ if not d.exists():
242
+ return []
243
+ out = []
244
+ for p in sorted(d.glob("*.jsonl")):
245
+ try:
246
+ out.append(unsafe_source_name(p.stem))
247
+ except Exception: # noqa: BLE001 - skip an undecodable (hashed) name
248
+ continue
249
+ return out
250
+
251
+ # ---- maintenance ----
252
+ def _rotate_if_needed(self, source_id: str) -> None:
253
+ path = self._jsonl_path(source_id)
254
+ try:
255
+ if path.stat().st_size <= self._max_bytes:
256
+ return
257
+ except OSError:
258
+ return
259
+ try:
260
+ with open(path, "r", encoding="utf-8") as f:
261
+ lines = f.readlines()
262
+ except OSError:
263
+ return
264
+ # Keep the newest max_lines, then drop from the front until within bytes.
265
+ kept = deque(lines, maxlen=self._max_lines)
266
+ kept = list(kept)
267
+ budget = self._max_bytes
268
+ total = sum(len(s.encode("utf-8")) for s in kept)
269
+ while kept and total > budget:
270
+ total -= len(kept[0].encode("utf-8"))
271
+ kept.pop(0)
272
+ # The cursor is intentionally NOT touched: history is bounded but the
273
+ # resume position is preserved, so reopen still delta-fetches correctly.
274
+ _atomic_write_text(path, "".join(kept))
275
+
276
+
277
+ def _note_persist_error(exc: BaseException) -> None:
278
+ global _persist_warned
279
+ if _persist_warned:
280
+ return
281
+ _persist_warned = True
282
+ try:
283
+ with open(_ERROR_LOG, "a", encoding="utf-8") as f:
284
+ f.write("log cache persistence disabled (%s: %s)\n" % (type(exc).__name__, exc))
285
+ except OSError:
286
+ pass
287
+
288
+
289
+ def _safe_append(cache: LogCache, source_id: str, entries: list) -> None:
290
+ try:
291
+ cache.append_entries(source_id, entries)
292
+ except OSError as exc: # disk full / unwritable: degrade to live-only
293
+ _note_persist_error(exc)
294
+
295
+
296
+ def _safe_set_cursor(cache: LogCache, source_id: str, cursor: Optional[str]) -> None:
297
+ try:
298
+ cache.set_cursor(source_id, cursor)
299
+ except OSError as exc:
300
+ _note_persist_error(exc)
301
+
302
+
303
+ def cached_pages(
304
+ cache: LogCache,
305
+ source_id: str,
306
+ fetch: Callable[[Optional[str]], dict],
307
+ *,
308
+ entries_key: str,
309
+ follow: bool,
310
+ poll_interval: float,
311
+ live: bool = True,
312
+ sleep: Callable[[float], None] = time.sleep,
313
+ replay_limit: Optional[int] = None,
314
+ is_cancelled: Optional[Callable[[], bool]] = None,
315
+ backoff_multiplier: float = _POLL_BACKOFF_MULTIPLIER,
316
+ max_interval: float = _POLL_MAX_INTERVAL,
317
+ ) -> Iterator[Tuple[str, list]]:
318
+ """Replay the cached entries, then (when ``live``) resume ``fetch`` from the
319
+ saved cursor.
320
+
321
+ Yields ``(kind, entries)`` where ``kind`` is ``"cache"`` for the replayed
322
+ batch and ``"live"`` for each newly fetched page. Entries are yielded a
323
+ whole page (or the whole cached batch) at a time so the consumer can render
324
+ them in one shot — essential for high-velocity logs, where per-line
325
+ rendering would saturate the UI thread. New entries are appended to disk and
326
+ the advancing cursor persisted; on a crash between the two we re-fetch a few
327
+ lines next time rather than skip any.
328
+
329
+ ``live=False`` replays the cache and stops without ever calling ``fetch`` —
330
+ used for terminal jobs whose zone is gone, so the operation API (which would
331
+ 500) is never hit.
332
+ """
333
+ cancelled = is_cancelled or (lambda: False)
334
+
335
+ cached, cursor = cache.load(source_id, limit=replay_limit)
336
+ if cached and not cancelled():
337
+ yield ("cache", cached)
338
+
339
+ if not live:
340
+ return # cache-only (terminal job): never touch the operation API
341
+
342
+ delay = poll_interval
343
+ while True:
344
+ if cancelled():
345
+ return
346
+ page = fetch(cursor)
347
+ new_cursor = page.get("next_cursor", cursor)
348
+ entries = page.get(entries_key) or []
349
+ if entries:
350
+ _safe_append(cache, source_id, entries) # entries first...
351
+ _safe_set_cursor(cache, source_id, new_cursor) # ...then cursor
352
+ cursor = new_cursor
353
+ if cancelled():
354
+ return
355
+ yield ("live", entries) # whole page in one batch
356
+ # Throttle floor: even when the source is busy, wait at least
357
+ # poll_interval before the next request. Biases toward server
358
+ # reliability over log freshness — caps the rate at ~1 req/s per
359
+ # source per viewer regardless of how chatty the source is.
360
+ delay = poll_interval
361
+ sleep(delay)
362
+ continue
363
+ if new_cursor != cursor:
364
+ _safe_set_cursor(cache, source_id, new_cursor)
365
+ cursor = new_cursor
366
+ if not follow:
367
+ # An empty page means we've drained everything available right now.
368
+ # Stop rather than poll forever — independent of the server's eof
369
+ # flag (the pod-logs backend never sets eof=True). This is the
370
+ # "terminal job: replay + drain once, then stop" path.
371
+ return
372
+ sleep(delay)
373
+ delay = min(delay * backoff_multiplier, max_interval)
374
+
375
+
376
+ def cached_log_pages(
377
+ cache: LogCache,
378
+ client: Any,
379
+ job_id: str,
380
+ sub_job_id: str,
381
+ *,
382
+ follow: bool,
383
+ poll_interval: float,
384
+ live: bool = True,
385
+ replay_limit: Optional[int] = 2000,
386
+ tail_lines: Optional[int] = 2000,
387
+ is_cancelled: Optional[Callable[[], bool]] = None,
388
+ ) -> Iterator[Tuple[str, list]]:
389
+ """``cached_pages`` wired to ``client.tail_logs`` for a sub-job's logs. The
390
+ cache is keyed by ``sub_job_id``.
391
+
392
+ First open (no cached cursor) tails the last ``tail_lines`` lines via
393
+ ``max_lines`` instead of streaming the whole pod log from the beginning — a
394
+ long-running job can have hours of backlog, and reading it all is a slow,
395
+ heavy catch-up storm on the server. Reopens resume from the saved
396
+ cursor (delta-only), so ``max_lines`` is sent only on that first request.
397
+ """
398
+
399
+ def fetch(cursor: Optional[str]) -> dict:
400
+ kw: dict = {"cursor": cursor, "sub_job_id": sub_job_id}
401
+ if cursor is None and tail_lines:
402
+ kw["max_lines"] = tail_lines # first fetch: tail recent, not from start
403
+ return client.tail_logs(job_id, **kw)
404
+
405
+ return cached_pages(
406
+ cache,
407
+ sub_job_id,
408
+ fetch,
409
+ entries_key="entries",
410
+ follow=follow,
411
+ poll_interval=poll_interval,
412
+ live=live,
413
+ replay_limit=replay_limit,
414
+ is_cancelled=is_cancelled,
415
+ )