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
|
@@ -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
|
+
)
|