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.
- cortex_training/__init__.py +49 -0
- cortex_training/__main__.py +20 -0
- cortex_training/_cli.py +1050 -0
- cortex_training/cli.py +103 -0
- cortex_training/client.py +2866 -0
- cortex_training/peft.py +112 -0
- cortex_training/telemetry.py +526 -0
- cortex_training/tui/__init__.py +23 -0
- cortex_training/tui/__main__.py +109 -0
- cortex_training/tui/app.py +646 -0
- cortex_training/tui/format.py +389 -0
- cortex_training/tui/log_cache.py +415 -0
- cortex_training/wire.py +414 -0
- cortex_training-0.0.3.dist-info/METADATA +143 -0
- cortex_training-0.0.3.dist-info/RECORD +19 -0
- cortex_training-0.0.3.dist-info/WHEEL +5 -0
- cortex_training-0.0.3.dist-info/entry_points.txt +3 -0
- cortex_training-0.0.3.dist-info/licenses/LICENSE +177 -0
- cortex_training-0.0.3.dist-info/top_level.txt +1 -0
cortex_training/peft.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
"""Canonical validation for LoRA configs used by Cortex Training weight sync."""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
PEFT_LORA_DEFAULT_R = 8
|
|
23
|
+
PEFT_LORA_DEFAULT_ALPHA = 8
|
|
24
|
+
PEFT_LORA_DEFAULT_DROPOUT = 0.0
|
|
25
|
+
PEFT_LORA_DEFAULT_BIAS = "none"
|
|
26
|
+
SUPPORTED_LORA_PEFT_CONFIG_KEYS = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"peft_type",
|
|
29
|
+
"task_type",
|
|
30
|
+
"r",
|
|
31
|
+
"lora_alpha",
|
|
32
|
+
"lora_dropout",
|
|
33
|
+
"bias",
|
|
34
|
+
"target_modules",
|
|
35
|
+
"target_parameters",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def normalize_lora_peft_config(
|
|
41
|
+
peft_config: dict[str, Any],
|
|
42
|
+
*,
|
|
43
|
+
location: str = "peft_config",
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""Validate and normalize the LoRA subset supported by weight sync."""
|
|
46
|
+
if not isinstance(peft_config, dict):
|
|
47
|
+
raise ValueError(f"{location} must be an object, got {peft_config!r}")
|
|
48
|
+
|
|
49
|
+
unsupported = sorted(set(peft_config) - SUPPORTED_LORA_PEFT_CONFIG_KEYS)
|
|
50
|
+
if unsupported:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"{location} contains unsupported field(s): {unsupported}. "
|
|
53
|
+
f"Supported fields: {sorted(SUPPORTED_LORA_PEFT_CONFIG_KEYS)}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
peft_type = peft_config.get("peft_type")
|
|
57
|
+
if peft_type != "Lora":
|
|
58
|
+
raise ValueError(f"{location}.peft_type must be 'Lora', got {peft_type!r}")
|
|
59
|
+
|
|
60
|
+
task_type = peft_config.get("task_type", "CAUSAL_LM")
|
|
61
|
+
if task_type != "CAUSAL_LM":
|
|
62
|
+
raise ValueError(f"{location}.task_type must be 'CAUSAL_LM', got {task_type!r}")
|
|
63
|
+
|
|
64
|
+
rank = peft_config.get("r", PEFT_LORA_DEFAULT_R)
|
|
65
|
+
if not isinstance(rank, int) or isinstance(rank, bool) or rank <= 0:
|
|
66
|
+
raise ValueError(f"{location}.r must be a positive integer, got {rank!r}")
|
|
67
|
+
|
|
68
|
+
lora_alpha = peft_config.get("lora_alpha", PEFT_LORA_DEFAULT_ALPHA)
|
|
69
|
+
if not isinstance(lora_alpha, int) or isinstance(lora_alpha, bool) or lora_alpha < 0:
|
|
70
|
+
raise ValueError(f"{location}.lora_alpha must be a non-negative integer, got {lora_alpha!r}")
|
|
71
|
+
|
|
72
|
+
lora_dropout = peft_config.get("lora_dropout", PEFT_LORA_DEFAULT_DROPOUT)
|
|
73
|
+
if not isinstance(lora_dropout, (int, float)) or isinstance(lora_dropout, bool) or not 0 <= lora_dropout <= 1:
|
|
74
|
+
raise ValueError(f"{location}.lora_dropout must be a number in [0, 1], got {lora_dropout!r}")
|
|
75
|
+
|
|
76
|
+
bias = peft_config.get("bias", PEFT_LORA_DEFAULT_BIAS)
|
|
77
|
+
if bias not in {"none", "all", "lora_only"}:
|
|
78
|
+
raise ValueError(f"{location}.bias must be one of 'none', 'all', or 'lora_only', got {bias!r}")
|
|
79
|
+
if bias != "none":
|
|
80
|
+
raise ValueError(f"{location}.bias={bias!r} is not supported by vLLM 0.26 LoRA. Use bias='none'.")
|
|
81
|
+
|
|
82
|
+
target_modules = peft_config.get("target_modules", [])
|
|
83
|
+
if isinstance(target_modules, str):
|
|
84
|
+
if not target_modules:
|
|
85
|
+
raise ValueError(f"{location}.target_modules cannot be empty")
|
|
86
|
+
elif isinstance(target_modules, list):
|
|
87
|
+
if any(not isinstance(value, str) or not value for value in target_modules):
|
|
88
|
+
raise ValueError(f"{location}.target_modules must contain only non-empty strings")
|
|
89
|
+
target_modules = list(target_modules)
|
|
90
|
+
else:
|
|
91
|
+
raise ValueError(f"{location}.target_modules must be a string or list of strings, got {target_modules!r}")
|
|
92
|
+
|
|
93
|
+
target_parameters = peft_config.get("target_parameters", [])
|
|
94
|
+
if not isinstance(target_parameters, list) or any(
|
|
95
|
+
not isinstance(value, str) or not value for value in target_parameters
|
|
96
|
+
):
|
|
97
|
+
raise ValueError(f"{location}.target_parameters must be a list of non-empty strings")
|
|
98
|
+
target_parameters = list(target_parameters)
|
|
99
|
+
|
|
100
|
+
if not target_modules and not target_parameters:
|
|
101
|
+
raise ValueError(f"{location} must specify target_modules, target_parameters, or both")
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"peft_type": "Lora",
|
|
105
|
+
"task_type": "CAUSAL_LM",
|
|
106
|
+
"r": rank,
|
|
107
|
+
"lora_alpha": lora_alpha,
|
|
108
|
+
"lora_dropout": float(lora_dropout),
|
|
109
|
+
"bias": bias,
|
|
110
|
+
"target_modules": target_modules,
|
|
111
|
+
"target_parameters": target_parameters,
|
|
112
|
+
}
|
|
@@ -0,0 +1,526 @@
|
|
|
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
|
+
"""Best-effort client telemetry over Snowflake's OTLP endpoint.
|
|
17
|
+
|
|
18
|
+
Telemetry is deliberately isolated from the Cortex Training request session
|
|
19
|
+
because the two paths use different authentication schemes:
|
|
20
|
+
|
|
21
|
+
* Cortex Training APIs accept the PAT directly.
|
|
22
|
+
* Observability APIs require a short-lived Snowflake session token.
|
|
23
|
+
|
|
24
|
+
All metric emission failures are swallowed. Telemetry must never change the
|
|
25
|
+
outcome of a caller's training or inference operation.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import atexit
|
|
31
|
+
import logging
|
|
32
|
+
import math
|
|
33
|
+
import os
|
|
34
|
+
import queue
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
import weakref
|
|
38
|
+
from collections.abc import Mapping
|
|
39
|
+
from typing import Any
|
|
40
|
+
from urllib.parse import urlparse
|
|
41
|
+
|
|
42
|
+
import requests
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
_PAT_TOKEN_TYPE = "PROGRAMMATIC_ACCESS_TOKEN"
|
|
47
|
+
_DEFAULT_SESSION_VALIDITY_SECONDS = 3600.0
|
|
48
|
+
_MIN_SESSION_VALIDITY_SECONDS = 60.0
|
|
49
|
+
_DEFAULT_TIMEOUT_SECONDS = 3.0
|
|
50
|
+
_FAILURE_COOLDOWN_SECONDS = 300.0
|
|
51
|
+
_MAX_CONSECUTIVE_FAILURES = 3
|
|
52
|
+
_SNOWFLAKE_HOST_SUFFIX = ".snowflakecomputing.com"
|
|
53
|
+
_AUTH_REJECTED_STATUSES = {401, 403}
|
|
54
|
+
_DEFAULT_QUEUE_SIZE = 1024
|
|
55
|
+
_WORKER_IDLE_SECONDS = 30.0
|
|
56
|
+
_STOP = object()
|
|
57
|
+
_EMITTERS: weakref.WeakSet["OtlpMetricEmitter"] = weakref.WeakSet()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _flush_emitters_at_exit() -> None:
|
|
61
|
+
deadline = time.monotonic() + 1.0
|
|
62
|
+
for emitter in list(_EMITTERS):
|
|
63
|
+
remaining = max(deadline - time.monotonic(), 0.0)
|
|
64
|
+
emitter.close(timeout=remaining)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
atexit.register(_flush_emitters_at_exit)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _snapshot(value: Any) -> Any:
|
|
71
|
+
"""Copy JSON-like values before a background worker observes them."""
|
|
72
|
+
if isinstance(value, Mapping):
|
|
73
|
+
return {str(key): _snapshot(item) for key, item in value.items()}
|
|
74
|
+
if isinstance(value, (list, tuple)):
|
|
75
|
+
return [_snapshot(item) for item in value]
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _otlp_value(value: Any) -> dict[str, Any]:
|
|
80
|
+
"""Convert a JSON-like Python value to an OTLP ``AnyValue``."""
|
|
81
|
+
if isinstance(value, bool):
|
|
82
|
+
return {"boolValue": value}
|
|
83
|
+
if isinstance(value, int):
|
|
84
|
+
return {"intValue": str(value)}
|
|
85
|
+
if isinstance(value, float):
|
|
86
|
+
if math.isnan(value) or math.isinf(value):
|
|
87
|
+
return {"stringValue": str(value)}
|
|
88
|
+
return {"doubleValue": value}
|
|
89
|
+
if isinstance(value, Mapping):
|
|
90
|
+
return {
|
|
91
|
+
"kvlistValue": {
|
|
92
|
+
"values": [
|
|
93
|
+
{"key": str(key), "value": _otlp_value(item)}
|
|
94
|
+
for key, item in value.items()
|
|
95
|
+
if item is not None
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if isinstance(value, (list, tuple)):
|
|
100
|
+
return {
|
|
101
|
+
"arrayValue": {
|
|
102
|
+
"values": [_otlp_value(item) for item in value if item is not None]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {"stringValue": str(value)}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _telemetry_base_url(hostname: str) -> str:
|
|
109
|
+
"""Build an OTLP base URL from the observability hostname response.
|
|
110
|
+
|
|
111
|
+
GS returns a host, optionally with a ``/system`` path. Posts go to
|
|
112
|
+
``https://{hostname}/v1/logs`` without stripping that path, so a value of
|
|
113
|
+
``telemetry.example.snowflakecomputing.com/system`` becomes
|
|
114
|
+
``https://telemetry.example.snowflakecomputing.com/system``.
|
|
115
|
+
"""
|
|
116
|
+
hostname = hostname.strip().rstrip("/")
|
|
117
|
+
if not hostname:
|
|
118
|
+
raise ValueError("observability response did not contain a hostname")
|
|
119
|
+
if not hostname.startswith(("http://", "https://")):
|
|
120
|
+
hostname = f"https://{hostname}"
|
|
121
|
+
parsed = urlparse(hostname)
|
|
122
|
+
host = parsed.hostname or ""
|
|
123
|
+
if not host.endswith(_SNOWFLAKE_HOST_SUFFIX):
|
|
124
|
+
raise ValueError("observability hostname is not a Snowflake collector")
|
|
125
|
+
return hostname.rstrip("/")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class CachedSessionTokenProvider:
|
|
129
|
+
"""Exchange a PAT for a Snowflake session token and cache it until expiry."""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
base_url: str,
|
|
134
|
+
pat: str,
|
|
135
|
+
*,
|
|
136
|
+
verify_ssl: bool = True,
|
|
137
|
+
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
|
|
138
|
+
refresh_buffer_seconds: float = 60.0,
|
|
139
|
+
):
|
|
140
|
+
self.base_url = base_url.rstrip("/")
|
|
141
|
+
self.timeout = timeout
|
|
142
|
+
self.refresh_buffer_seconds = refresh_buffer_seconds
|
|
143
|
+
self._pat = pat
|
|
144
|
+
self._verify_ssl = verify_ssl
|
|
145
|
+
self._pid = os.getpid()
|
|
146
|
+
self._session = self._new_session()
|
|
147
|
+
self._token: str | None = None
|
|
148
|
+
self._expires_at = 0.0
|
|
149
|
+
self._lock = threading.Lock()
|
|
150
|
+
|
|
151
|
+
def _new_session(self) -> requests.Session:
|
|
152
|
+
session = requests.Session()
|
|
153
|
+
session.verify = self._verify_ssl
|
|
154
|
+
session.headers.update(
|
|
155
|
+
{
|
|
156
|
+
"Authorization": f"Bearer {self._pat}",
|
|
157
|
+
"X-Snowflake-Authorization-Token-Type": _PAT_TOKEN_TYPE,
|
|
158
|
+
"Accept": "application/json",
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
return session
|
|
162
|
+
|
|
163
|
+
def _ensure_process(self) -> None:
|
|
164
|
+
if self._pid == os.getpid():
|
|
165
|
+
return
|
|
166
|
+
self._pid = os.getpid()
|
|
167
|
+
self._session = self._new_session()
|
|
168
|
+
self._token = None
|
|
169
|
+
self._expires_at = 0.0
|
|
170
|
+
self._lock = threading.Lock()
|
|
171
|
+
|
|
172
|
+
def invalidate(self) -> None:
|
|
173
|
+
"""Drop the cached token so the next call re-exchanges the PAT."""
|
|
174
|
+
self._ensure_process()
|
|
175
|
+
with self._lock:
|
|
176
|
+
self._token = None
|
|
177
|
+
self._expires_at = 0.0
|
|
178
|
+
|
|
179
|
+
def get_token(self) -> str:
|
|
180
|
+
"""Return a cached session token, refreshing it shortly before expiry."""
|
|
181
|
+
self._ensure_process()
|
|
182
|
+
now = time.monotonic()
|
|
183
|
+
if self._token is not None and now < self._expires_at:
|
|
184
|
+
return self._token
|
|
185
|
+
|
|
186
|
+
with self._lock:
|
|
187
|
+
now = time.monotonic()
|
|
188
|
+
if self._token is not None and now < self._expires_at:
|
|
189
|
+
return self._token
|
|
190
|
+
|
|
191
|
+
response = self._session.post(
|
|
192
|
+
f"{self.base_url}/api/v2/sessions",
|
|
193
|
+
json={},
|
|
194
|
+
timeout=self.timeout,
|
|
195
|
+
)
|
|
196
|
+
response.raise_for_status()
|
|
197
|
+
body = response.json()
|
|
198
|
+
token = body["token"]
|
|
199
|
+
if not isinstance(token, str) or not token:
|
|
200
|
+
raise ValueError("session response did not contain a token")
|
|
201
|
+
|
|
202
|
+
validity = body.get("validityInSeconds", _DEFAULT_SESSION_VALIDITY_SECONDS)
|
|
203
|
+
try:
|
|
204
|
+
validity_seconds = float(validity)
|
|
205
|
+
except (TypeError, ValueError):
|
|
206
|
+
validity_seconds = _DEFAULT_SESSION_VALIDITY_SECONDS
|
|
207
|
+
validity_seconds = max(validity_seconds, _MIN_SESSION_VALIDITY_SECONDS)
|
|
208
|
+
refresh_buffer = min(
|
|
209
|
+
self.refresh_buffer_seconds,
|
|
210
|
+
validity_seconds * 0.1,
|
|
211
|
+
)
|
|
212
|
+
self._token = token
|
|
213
|
+
self._expires_at = now + validity_seconds - refresh_buffer
|
|
214
|
+
return token
|
|
215
|
+
|
|
216
|
+
def close(self) -> None:
|
|
217
|
+
self._session.close()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class OtlpMetricEmitter:
|
|
221
|
+
"""Emit OTLP log records as best-effort client-side metrics."""
|
|
222
|
+
|
|
223
|
+
def __init__(
|
|
224
|
+
self,
|
|
225
|
+
base_url: str,
|
|
226
|
+
token_provider: CachedSessionTokenProvider,
|
|
227
|
+
*,
|
|
228
|
+
service_name: str = "cortex-training",
|
|
229
|
+
service_version: str | None = None,
|
|
230
|
+
verify_ssl: bool = True,
|
|
231
|
+
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
|
|
232
|
+
max_consecutive_failures: int = _MAX_CONSECUTIVE_FAILURES,
|
|
233
|
+
failure_cooldown_seconds: float = _FAILURE_COOLDOWN_SECONDS,
|
|
234
|
+
background: bool = True,
|
|
235
|
+
queue_size: int = _DEFAULT_QUEUE_SIZE,
|
|
236
|
+
):
|
|
237
|
+
self.base_url = base_url.rstrip("/")
|
|
238
|
+
self.account_host = urlparse(self.base_url).hostname
|
|
239
|
+
if not self.account_host:
|
|
240
|
+
raise ValueError("base_url must contain an account hostname")
|
|
241
|
+
self.token_provider = token_provider
|
|
242
|
+
self.service_name = service_name
|
|
243
|
+
self.service_version = service_version
|
|
244
|
+
self.timeout = timeout
|
|
245
|
+
self._max_consecutive_failures = max_consecutive_failures
|
|
246
|
+
self._failure_cooldown_seconds = failure_cooldown_seconds
|
|
247
|
+
self._verify_ssl = verify_ssl
|
|
248
|
+
self._queue_size = queue_size
|
|
249
|
+
self._pid = os.getpid()
|
|
250
|
+
self._session = self._new_session()
|
|
251
|
+
self._telemetry_base_url: str | None = None
|
|
252
|
+
self._hostname_lock = threading.Lock()
|
|
253
|
+
self._consecutive_failures = 0
|
|
254
|
+
self._disabled_until = 0.0
|
|
255
|
+
self._background = background
|
|
256
|
+
self._queue: queue.Queue[Any] = queue.Queue(maxsize=queue_size)
|
|
257
|
+
self._worker: threading.Thread | None = None
|
|
258
|
+
self._worker_lock = threading.Lock()
|
|
259
|
+
self._pending_condition = threading.Condition()
|
|
260
|
+
self._pending = 0
|
|
261
|
+
self._closed = False
|
|
262
|
+
_EMITTERS.add(self)
|
|
263
|
+
|
|
264
|
+
def _new_session(self) -> requests.Session:
|
|
265
|
+
session = requests.Session()
|
|
266
|
+
session.verify = self._verify_ssl
|
|
267
|
+
return session
|
|
268
|
+
|
|
269
|
+
def _ensure_process(self) -> None:
|
|
270
|
+
if self._pid == os.getpid():
|
|
271
|
+
return
|
|
272
|
+
self._pid = os.getpid()
|
|
273
|
+
self._session = self._new_session()
|
|
274
|
+
self._telemetry_base_url = None
|
|
275
|
+
self._hostname_lock = threading.Lock()
|
|
276
|
+
self._consecutive_failures = 0
|
|
277
|
+
self._disabled_until = 0.0
|
|
278
|
+
self._queue = queue.Queue(maxsize=self._queue_size)
|
|
279
|
+
self._worker = None
|
|
280
|
+
self._worker_lock = threading.Lock()
|
|
281
|
+
self._pending_condition = threading.Condition()
|
|
282
|
+
self._pending = 0
|
|
283
|
+
self.token_provider._ensure_process()
|
|
284
|
+
|
|
285
|
+
def _is_disabled(self) -> bool:
|
|
286
|
+
return time.monotonic() < self._disabled_until
|
|
287
|
+
|
|
288
|
+
def _note_success(self) -> None:
|
|
289
|
+
self._consecutive_failures = 0
|
|
290
|
+
|
|
291
|
+
def _note_failure(self) -> None:
|
|
292
|
+
self._consecutive_failures += 1
|
|
293
|
+
if self._consecutive_failures >= self._max_consecutive_failures:
|
|
294
|
+
self._disabled_until = time.monotonic() + self._failure_cooldown_seconds
|
|
295
|
+
|
|
296
|
+
def _invalidate_auth(self) -> None:
|
|
297
|
+
self.token_provider.invalidate()
|
|
298
|
+
with self._hostname_lock:
|
|
299
|
+
self._telemetry_base_url = None
|
|
300
|
+
|
|
301
|
+
def _get_telemetry_base_url(self, token: str) -> str:
|
|
302
|
+
if self._telemetry_base_url is not None:
|
|
303
|
+
return self._telemetry_base_url
|
|
304
|
+
|
|
305
|
+
with self._hostname_lock:
|
|
306
|
+
if self._telemetry_base_url is not None:
|
|
307
|
+
return self._telemetry_base_url
|
|
308
|
+
response = self._session.get(
|
|
309
|
+
f"{self.base_url}/observability/system/hostname",
|
|
310
|
+
headers={"Authorization": f'Snowflake Token="{token}"'},
|
|
311
|
+
timeout=self.timeout,
|
|
312
|
+
)
|
|
313
|
+
response.raise_for_status()
|
|
314
|
+
hostname = response.json()["hostname"]
|
|
315
|
+
if not isinstance(hostname, str) or not hostname:
|
|
316
|
+
raise ValueError("observability response did not contain a hostname")
|
|
317
|
+
self._telemetry_base_url = _telemetry_base_url(hostname)
|
|
318
|
+
return self._telemetry_base_url
|
|
319
|
+
|
|
320
|
+
def _ensure_worker(self) -> None:
|
|
321
|
+
worker = self._worker
|
|
322
|
+
if worker is not None and worker.is_alive():
|
|
323
|
+
return
|
|
324
|
+
with self._worker_lock:
|
|
325
|
+
worker = self._worker
|
|
326
|
+
if worker is not None and worker.is_alive():
|
|
327
|
+
return
|
|
328
|
+
self._worker = None
|
|
329
|
+
worker = threading.Thread(
|
|
330
|
+
target=self._run,
|
|
331
|
+
name="cortex-training-telemetry",
|
|
332
|
+
daemon=True,
|
|
333
|
+
)
|
|
334
|
+
worker.start()
|
|
335
|
+
self._worker = worker
|
|
336
|
+
|
|
337
|
+
def _run(self) -> None:
|
|
338
|
+
try:
|
|
339
|
+
while True:
|
|
340
|
+
try:
|
|
341
|
+
item = self._queue.get(timeout=_WORKER_IDLE_SECONDS)
|
|
342
|
+
except queue.Empty:
|
|
343
|
+
return
|
|
344
|
+
if item is _STOP:
|
|
345
|
+
self._queue.task_done()
|
|
346
|
+
return
|
|
347
|
+
operation, value, attributes, time_unix_nano = item
|
|
348
|
+
try:
|
|
349
|
+
self._emit_sync(
|
|
350
|
+
operation,
|
|
351
|
+
value,
|
|
352
|
+
attributes=attributes,
|
|
353
|
+
time_unix_nano=time_unix_nano,
|
|
354
|
+
)
|
|
355
|
+
finally:
|
|
356
|
+
self._queue.task_done()
|
|
357
|
+
with self._pending_condition:
|
|
358
|
+
self._pending -= 1
|
|
359
|
+
self._pending_condition.notify_all()
|
|
360
|
+
finally:
|
|
361
|
+
with self._worker_lock:
|
|
362
|
+
if self._worker is threading.current_thread():
|
|
363
|
+
self._worker = None
|
|
364
|
+
if not self._closed and not self._queue.empty():
|
|
365
|
+
self._ensure_worker()
|
|
366
|
+
|
|
367
|
+
def flush(self, timeout: float = 1.0) -> bool:
|
|
368
|
+
"""Wait briefly for queued metrics, primarily during interpreter exit."""
|
|
369
|
+
deadline = time.monotonic() + timeout
|
|
370
|
+
with self._pending_condition:
|
|
371
|
+
while self._pending:
|
|
372
|
+
remaining = deadline - time.monotonic()
|
|
373
|
+
if remaining <= 0:
|
|
374
|
+
return False
|
|
375
|
+
self._pending_condition.wait(remaining)
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
def close(self, timeout: float = 1.0) -> None:
|
|
379
|
+
"""Flush queued events and release this emitter's worker and sessions."""
|
|
380
|
+
self._ensure_process()
|
|
381
|
+
worker = self._worker
|
|
382
|
+
if self._closed and (worker is None or not worker.is_alive()):
|
|
383
|
+
self._session.close()
|
|
384
|
+
self.token_provider.close()
|
|
385
|
+
_EMITTERS.discard(self)
|
|
386
|
+
return
|
|
387
|
+
self._closed = True
|
|
388
|
+
deadline = time.monotonic() + timeout
|
|
389
|
+
self.flush(timeout=max(deadline - time.monotonic(), 0.0))
|
|
390
|
+
worker = self._worker
|
|
391
|
+
if worker is not None and worker.is_alive():
|
|
392
|
+
try:
|
|
393
|
+
self._queue.put_nowait(_STOP)
|
|
394
|
+
except queue.Full:
|
|
395
|
+
pass
|
|
396
|
+
worker.join(timeout=max(deadline - time.monotonic(), 0.0))
|
|
397
|
+
if worker is None or not worker.is_alive():
|
|
398
|
+
self._session.close()
|
|
399
|
+
self.token_provider.close()
|
|
400
|
+
_EMITTERS.discard(self)
|
|
401
|
+
|
|
402
|
+
def emit(
|
|
403
|
+
self,
|
|
404
|
+
operation: str,
|
|
405
|
+
value: Any = 1,
|
|
406
|
+
*,
|
|
407
|
+
attributes: Mapping[str, Any] | None = None,
|
|
408
|
+
) -> None:
|
|
409
|
+
"""Queue one operation metric without blocking or raising into the caller."""
|
|
410
|
+
try:
|
|
411
|
+
self._ensure_process()
|
|
412
|
+
if self._closed:
|
|
413
|
+
return
|
|
414
|
+
time_unix_nano = time.time_ns()
|
|
415
|
+
if not self._background:
|
|
416
|
+
self._emit_sync(
|
|
417
|
+
operation,
|
|
418
|
+
value,
|
|
419
|
+
attributes=attributes,
|
|
420
|
+
time_unix_nano=time_unix_nano,
|
|
421
|
+
)
|
|
422
|
+
return
|
|
423
|
+
item = (
|
|
424
|
+
operation,
|
|
425
|
+
_snapshot(value),
|
|
426
|
+
_snapshot(attributes) if attributes is not None else None,
|
|
427
|
+
time_unix_nano,
|
|
428
|
+
)
|
|
429
|
+
with self._pending_condition:
|
|
430
|
+
self._pending += 1
|
|
431
|
+
try:
|
|
432
|
+
self._queue.put_nowait(item)
|
|
433
|
+
except Exception:
|
|
434
|
+
with self._pending_condition:
|
|
435
|
+
self._pending -= 1
|
|
436
|
+
self._pending_condition.notify_all()
|
|
437
|
+
raise
|
|
438
|
+
self._ensure_worker()
|
|
439
|
+
except Exception:
|
|
440
|
+
logger.debug("client telemetry enqueue failed", exc_info=True)
|
|
441
|
+
|
|
442
|
+
def _emit_sync(
|
|
443
|
+
self,
|
|
444
|
+
operation: str,
|
|
445
|
+
value: Any = 1,
|
|
446
|
+
*,
|
|
447
|
+
attributes: Mapping[str, Any] | None = None,
|
|
448
|
+
time_unix_nano: int | None = None,
|
|
449
|
+
) -> None:
|
|
450
|
+
"""Emit one operation metric, swallowing setup and export failures."""
|
|
451
|
+
self._ensure_process()
|
|
452
|
+
if self._is_disabled():
|
|
453
|
+
return
|
|
454
|
+
try:
|
|
455
|
+
token = self.token_provider.get_token()
|
|
456
|
+
telemetry_base_url = self._get_telemetry_base_url(token)
|
|
457
|
+
record_attributes = {
|
|
458
|
+
**dict(attributes or {}),
|
|
459
|
+
"event.name": operation,
|
|
460
|
+
}
|
|
461
|
+
resource_attributes = [
|
|
462
|
+
{
|
|
463
|
+
"key": "service.name",
|
|
464
|
+
"value": {"stringValue": self.service_name},
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
"key": "snowflake.account_host",
|
|
468
|
+
"value": {"stringValue": self.account_host},
|
|
469
|
+
},
|
|
470
|
+
]
|
|
471
|
+
if self.service_version:
|
|
472
|
+
resource_attributes.append(
|
|
473
|
+
{
|
|
474
|
+
"key": "service.version",
|
|
475
|
+
"value": {"stringValue": self.service_version},
|
|
476
|
+
}
|
|
477
|
+
)
|
|
478
|
+
response = self._session.post(
|
|
479
|
+
f"{telemetry_base_url}/v1/logs",
|
|
480
|
+
headers={
|
|
481
|
+
"Authorization": f'Snowflake Token="{token}"',
|
|
482
|
+
"Content-Type": "application/json",
|
|
483
|
+
"Accept": "application/json",
|
|
484
|
+
},
|
|
485
|
+
json={
|
|
486
|
+
"resourceLogs": [
|
|
487
|
+
{
|
|
488
|
+
"resource": {"attributes": resource_attributes},
|
|
489
|
+
"scopeLogs": [
|
|
490
|
+
{
|
|
491
|
+
"scope": {"name": self.service_name},
|
|
492
|
+
"logRecords": [
|
|
493
|
+
{
|
|
494
|
+
"timeUnixNano": str(
|
|
495
|
+
time.time_ns()
|
|
496
|
+
if time_unix_nano is None
|
|
497
|
+
else time_unix_nano
|
|
498
|
+
),
|
|
499
|
+
"severityText": "INFO",
|
|
500
|
+
"body": _otlp_value(value),
|
|
501
|
+
"attributes": [
|
|
502
|
+
{
|
|
503
|
+
"key": str(key),
|
|
504
|
+
"value": _otlp_value(item),
|
|
505
|
+
}
|
|
506
|
+
for key, item in record_attributes.items()
|
|
507
|
+
if item is not None
|
|
508
|
+
],
|
|
509
|
+
}
|
|
510
|
+
],
|
|
511
|
+
}
|
|
512
|
+
],
|
|
513
|
+
}
|
|
514
|
+
]
|
|
515
|
+
},
|
|
516
|
+
timeout=self.timeout,
|
|
517
|
+
)
|
|
518
|
+
response.raise_for_status()
|
|
519
|
+
self._note_success()
|
|
520
|
+
except Exception as exc:
|
|
521
|
+
logger.debug("client telemetry emit failed", exc_info=True)
|
|
522
|
+
response = getattr(exc, "response", None)
|
|
523
|
+
status = getattr(response, "status_code", None)
|
|
524
|
+
if status in _AUTH_REJECTED_STATUSES:
|
|
525
|
+
self._invalidate_auth()
|
|
526
|
+
self._note_failure()
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
"""Read-only terminal UI for Cortex Training job logs and scheduling events.
|
|
17
|
+
|
|
18
|
+
The formatting helpers import cleanly without ``textual``; the app and entry
|
|
19
|
+
point use the package's standard TUI dependencies.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from cortex_training.tui.format import format_event # noqa: F401
|
|
23
|
+
from cortex_training.tui.format import format_log_entry
|