expr_tracker 0.2.2__py3-none-any.whl → 0.2.4__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.
- expr_tracker/__init__.py +6 -1
- expr_tracker/cli.py +24 -0
- expr_tracker/history/__init__.py +13 -2
- expr_tracker/history/naming.py +74 -0
- expr_tracker/history/reader.py +40 -10
- expr_tracker/history/store.py +100 -28
- expr_tracker/plugins.py +269 -0
- expr_tracker/run.py +27 -6
- expr_tracker/spans.py +364 -0
- expr_tracker/trace.py +190 -0
- expr_tracker/tracker.py +27 -5
- {expr_tracker-0.2.2.dist-info → expr_tracker-0.2.4.dist-info}/METADATA +11 -2
- {expr_tracker-0.2.2.dist-info → expr_tracker-0.2.4.dist-info}/RECORD +16 -12
- {expr_tracker-0.2.2.dist-info → expr_tracker-0.2.4.dist-info}/WHEEL +1 -1
- {expr_tracker-0.2.2.dist-info → expr_tracker-0.2.4.dist-info}/entry_points.txt +0 -0
- {expr_tracker-0.2.2.dist-info → expr_tracker-0.2.4.dist-info}/licenses/LICENSE +0 -0
expr_tracker/__init__.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from importlib.metadata import PackageNotFoundError
|
|
2
2
|
from importlib.metadata import version as _version
|
|
3
3
|
|
|
4
|
-
from . import tracker
|
|
4
|
+
from . import plugins, tracker
|
|
5
5
|
from .alerts import (
|
|
6
6
|
AlertConfig,
|
|
7
7
|
AlertLevel,
|
|
@@ -19,6 +19,7 @@ from .alerts import (
|
|
|
19
19
|
)
|
|
20
20
|
from .artifacts import Artifact
|
|
21
21
|
from .run import Run
|
|
22
|
+
from .spans import Span, span, start_span
|
|
22
23
|
|
|
23
24
|
try:
|
|
24
25
|
__version__ = _version("expr_tracker")
|
|
@@ -45,6 +46,7 @@ __all__ = [
|
|
|
45
46
|
"ChannelConfig",
|
|
46
47
|
"M",
|
|
47
48
|
"Run",
|
|
49
|
+
"Span",
|
|
48
50
|
"WebhookPolicy",
|
|
49
51
|
"__version__",
|
|
50
52
|
"add_alert_rule",
|
|
@@ -59,8 +61,11 @@ __all__ = [
|
|
|
59
61
|
"list_alert_rules",
|
|
60
62
|
"log",
|
|
61
63
|
"log_artifact",
|
|
64
|
+
"plugins",
|
|
62
65
|
"register_backend",
|
|
63
66
|
"remove_alert_rule",
|
|
67
|
+
"span",
|
|
68
|
+
"start_span",
|
|
64
69
|
"summary",
|
|
65
70
|
"use_artifact",
|
|
66
71
|
]
|
expr_tracker/cli.py
CHANGED
|
@@ -13,6 +13,7 @@ from .alerts.engine import AlertEngine
|
|
|
13
13
|
from .alerts.expr import EvalContext, compile_condition, parse_rule, validate
|
|
14
14
|
from .alerts.models import AlertConfig, ChannelConfig, WebhookPolicy
|
|
15
15
|
from .history import MetricSeries, read_history
|
|
16
|
+
from .trace import write_trace
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
@click.group()
|
|
@@ -54,6 +55,29 @@ def history(run: str, n: int, metrics: str | None, step_range: str | None, fmt:
|
|
|
54
55
|
click.echo(_to_table(rows))
|
|
55
56
|
|
|
56
57
|
|
|
58
|
+
@main.command()
|
|
59
|
+
@click.argument("run", type=click.Path(exists=True))
|
|
60
|
+
@click.option("-o", "--output", default="trace.json", help="Where to write the trace")
|
|
61
|
+
@click.option(
|
|
62
|
+
"--stream",
|
|
63
|
+
default=None,
|
|
64
|
+
help="Comma separated streams; every stream by default. Use 'default' for the "
|
|
65
|
+
"unnamed one.",
|
|
66
|
+
)
|
|
67
|
+
@click.option("--step-range", default=None, help="start:end (end exclusive)")
|
|
68
|
+
def trace(run: str, output: str, stream: str | None, step_range: str | None):
|
|
69
|
+
"""Export recorded spans as a Chrome Trace.
|
|
70
|
+
|
|
71
|
+
Open the result at https://ui.perfetto.dev or chrome://tracing. It can be
|
|
72
|
+
loaded beside a torch.profiler trace.
|
|
73
|
+
"""
|
|
74
|
+
wanted: str | list[str] | None = "*"
|
|
75
|
+
if stream:
|
|
76
|
+
wanted = [None if s in ("default", "") else s for s in stream.split(",")]
|
|
77
|
+
count = write_trace(run, output, wanted, step_range=_parse_range(step_range))
|
|
78
|
+
click.echo(f"wrote {count} span(s) to {output}")
|
|
79
|
+
|
|
80
|
+
|
|
57
81
|
@main.group()
|
|
58
82
|
def rules():
|
|
59
83
|
"""Alert rule tooling."""
|
expr_tracker/history/__init__.py
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
from .codec import RecordCodec
|
|
2
2
|
from .frame import project, to_output
|
|
3
|
-
from .
|
|
3
|
+
from .naming import current_rank, metrics_filename, parse_stream, validate_stream
|
|
4
|
+
from .reader import (
|
|
5
|
+
JsonlReader,
|
|
6
|
+
list_streams,
|
|
7
|
+
merge_steps,
|
|
8
|
+
read_history,
|
|
9
|
+
resolve_run_path,
|
|
10
|
+
)
|
|
4
11
|
from .series import MetricSeries
|
|
5
|
-
from .store import HistoryStore,
|
|
12
|
+
from .store import HistoryStore, resolve_commit
|
|
6
13
|
from .writer import JsonlWriter
|
|
7
14
|
|
|
8
15
|
__all__ = [
|
|
@@ -12,10 +19,14 @@ __all__ = [
|
|
|
12
19
|
"MetricSeries",
|
|
13
20
|
"RecordCodec",
|
|
14
21
|
"current_rank",
|
|
22
|
+
"list_streams",
|
|
15
23
|
"merge_steps",
|
|
24
|
+
"metrics_filename",
|
|
25
|
+
"parse_stream",
|
|
16
26
|
"project",
|
|
17
27
|
"read_history",
|
|
18
28
|
"resolve_commit",
|
|
19
29
|
"resolve_run_path",
|
|
20
30
|
"to_output",
|
|
31
|
+
"validate_stream",
|
|
21
32
|
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Run file naming: distributed rank shards and independent producer streams.
|
|
2
|
+
|
|
3
|
+
Kept separate from the store and the reader because both need it, and the store
|
|
4
|
+
already depends on the reader.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
STREAM_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
|
13
|
+
RANK_PATTERN = re.compile(r"rank\d+")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def current_rank() -> int:
|
|
17
|
+
"""Distributed rank from the usual environment variables, 0 when unset."""
|
|
18
|
+
rank = os.getenv("RANK") or os.getenv("LOCAL_RANK") or "0"
|
|
19
|
+
try:
|
|
20
|
+
return int(rank)
|
|
21
|
+
except ValueError:
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_stream(stream: str) -> str:
|
|
26
|
+
"""Check a stream name is safe as a filename component.
|
|
27
|
+
|
|
28
|
+
The name becomes part of ``metrics.<stream>[.rankN].jsonl``, so it must not
|
|
29
|
+
contain a separator, and must not look like a rank shard.
|
|
30
|
+
"""
|
|
31
|
+
if not STREAM_PATTERN.match(stream):
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"Invalid stream name {stream!r}; use letters, digits, '_' or '-', "
|
|
34
|
+
"starting with a letter or digit."
|
|
35
|
+
)
|
|
36
|
+
if RANK_PATTERN.fullmatch(stream):
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"Stream name {stream!r} collides with the rank shard naming; "
|
|
39
|
+
"pick another name."
|
|
40
|
+
)
|
|
41
|
+
return stream
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def metrics_filename(stream: str | None, rank_aware: bool = True) -> str:
|
|
45
|
+
"""``metrics[.stream][.rankN].jsonl``.
|
|
46
|
+
|
|
47
|
+
Streams separate independent producers; non-zero ranks get their own shard so
|
|
48
|
+
concurrent appends cannot interleave. The two compose.
|
|
49
|
+
"""
|
|
50
|
+
rank = current_rank() if rank_aware else 0
|
|
51
|
+
parts = ["metrics"]
|
|
52
|
+
if stream:
|
|
53
|
+
parts.append(stream)
|
|
54
|
+
if rank > 0:
|
|
55
|
+
parts.append(f"rank{rank}")
|
|
56
|
+
return ".".join(parts) + ".jsonl"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def spans_filename(stream: str | None, rank_aware: bool = True) -> str:
|
|
60
|
+
"""``spans[.stream][.rankN].jsonl``, alongside the metrics file."""
|
|
61
|
+
return metrics_filename(stream, rank_aware).replace("metrics", "spans", 1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def sidecar_filename(base: str, stream: str | None, suffix: str) -> str:
|
|
65
|
+
"""``<base>[.stream].<suffix>``, so producers do not overwrite each other."""
|
|
66
|
+
return f"{base}.{stream}.{suffix}" if stream else f"{base}.{suffix}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_stream(filename: str) -> str | None:
|
|
70
|
+
"""The stream a metrics filename belongs to; ``None`` is the default producer."""
|
|
71
|
+
parts = filename.split(".")[1:-1] # drop "metrics" and "jsonl"
|
|
72
|
+
if parts and RANK_PATTERN.fullmatch(parts[-1]):
|
|
73
|
+
parts = parts[:-1]
|
|
74
|
+
return parts[0] if parts else None
|
expr_tracker/history/reader.py
CHANGED
|
@@ -14,6 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
from loguru import logger
|
|
15
15
|
|
|
16
16
|
from .frame import project, to_output
|
|
17
|
+
from .naming import RANK_PATTERN, metrics_filename
|
|
17
18
|
|
|
18
19
|
CHUNK_SIZE = 256 * 1024
|
|
19
20
|
|
|
@@ -317,6 +318,7 @@ def read_history(
|
|
|
317
318
|
run: str | Path,
|
|
318
319
|
n: int | None = 50,
|
|
319
320
|
*,
|
|
321
|
+
stream: str | None = None,
|
|
320
322
|
output_type: str = "dict",
|
|
321
323
|
metrics: Sequence[str] | None = None,
|
|
322
324
|
step_range: tuple[int | None, int | None] | None = None,
|
|
@@ -325,7 +327,7 @@ def read_history(
|
|
|
325
327
|
dropna: bool = False,
|
|
326
328
|
):
|
|
327
329
|
"""Read any run's history without ``init()``; ``run`` is a directory or file."""
|
|
328
|
-
reader = JsonlReader(resolve_run_path(run))
|
|
330
|
+
reader = JsonlReader(resolve_run_path(run, stream))
|
|
329
331
|
limit = None if n is None or n < 0 else max(0, n)
|
|
330
332
|
if limit == 0:
|
|
331
333
|
records: list[dict] = []
|
|
@@ -347,13 +349,41 @@ def read_history(
|
|
|
347
349
|
return to_output(rows, output_type)
|
|
348
350
|
|
|
349
351
|
|
|
350
|
-
def resolve_run_path(run: str | Path) -> Path:
|
|
352
|
+
def resolve_run_path(run: str | Path, stream: str | None = None) -> Path:
|
|
353
|
+
"""The metrics file of a run directory, or the file itself.
|
|
354
|
+
|
|
355
|
+
Names are matched exactly rather than by sort order: ``metrics.data.jsonl``
|
|
356
|
+
sorts before ``metrics.jsonl``, so picking the first match would silently
|
|
357
|
+
return a stream instead of the default producer.
|
|
358
|
+
"""
|
|
351
359
|
path = Path(run)
|
|
352
|
-
if path.is_dir():
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
+
if not path.is_dir():
|
|
361
|
+
if not path.exists():
|
|
362
|
+
raise FileNotFoundError(f"Run path does not exist: {path}")
|
|
363
|
+
return path
|
|
364
|
+
wanted = metrics_filename(stream, rank_aware=False)
|
|
365
|
+
exact = path / wanted
|
|
366
|
+
if exact.is_file():
|
|
367
|
+
return exact
|
|
368
|
+
# Fall back to this stream's rank shards, which a worker rank writes instead
|
|
369
|
+
prefix = wanted[: -len(".jsonl")]
|
|
370
|
+
shards = sorted(path.glob(f"{prefix}.rank*.jsonl"))
|
|
371
|
+
if shards:
|
|
372
|
+
return shards[0]
|
|
373
|
+
raise FileNotFoundError(f"No {wanted} found under {path}; {_available(path)}")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _available(path: Path) -> str:
|
|
377
|
+
found = sorted(p.name for p in path.glob("metrics*.jsonl"))
|
|
378
|
+
return f"available: {', '.join(found)}" if found else "the directory has none"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def list_streams(run: str | Path) -> list[str | None]:
|
|
382
|
+
"""Every stream in a run directory; ``None`` is the default producer."""
|
|
383
|
+
streams: set[str | None] = set()
|
|
384
|
+
for path in Path(run).glob("metrics*.jsonl"):
|
|
385
|
+
parts = path.name.split(".")[1:-1] # drop "metrics" and "jsonl"
|
|
386
|
+
if parts and RANK_PATTERN.fullmatch(parts[-1]):
|
|
387
|
+
parts = parts[:-1]
|
|
388
|
+
streams.add(parts[0] if parts else None)
|
|
389
|
+
return sorted(streams, key=lambda s: (s is not None, s or ""))
|
expr_tracker/history/store.py
CHANGED
|
@@ -13,18 +13,24 @@ from __future__ import annotations
|
|
|
13
13
|
import atexit
|
|
14
14
|
import json
|
|
15
15
|
import operator
|
|
16
|
-
import os
|
|
17
16
|
import threading
|
|
18
17
|
import time
|
|
19
18
|
from collections import deque
|
|
20
19
|
from collections.abc import Callable, Sequence
|
|
21
20
|
from dataclasses import dataclass, fields
|
|
22
21
|
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
23
|
|
|
24
24
|
from loguru import logger
|
|
25
25
|
|
|
26
26
|
from .codec import RESERVED_KEYS, RecordCodec, encode_line
|
|
27
27
|
from .frame import project, to_output
|
|
28
|
+
from .naming import (
|
|
29
|
+
metrics_filename,
|
|
30
|
+
sidecar_filename,
|
|
31
|
+
spans_filename,
|
|
32
|
+
validate_stream,
|
|
33
|
+
)
|
|
28
34
|
from .reader import JsonlReader, merge_steps
|
|
29
35
|
from .series import DEFAULT_WINDOW, MetricSeries
|
|
30
36
|
from .writer import (
|
|
@@ -65,6 +71,13 @@ class HistoryOptions:
|
|
|
65
71
|
max_open_seconds: float | None = DEFAULT_MAX_OPEN_SECONDS
|
|
66
72
|
step_policy: str = "monotonic"
|
|
67
73
|
rank_aware: bool = True
|
|
74
|
+
# an independent producer writing its own file with its own step cursor
|
|
75
|
+
stream: str | None = None
|
|
76
|
+
# write the full span tree to spans.jsonl beside the metrics
|
|
77
|
+
spans: bool = True
|
|
78
|
+
# run-wide defaults for et.span(); a span's own arguments win
|
|
79
|
+
span_print_fn: Callable[[str], None] | None = None
|
|
80
|
+
span_plugins: Sequence[Any] = ()
|
|
68
81
|
# alerts and output
|
|
69
82
|
alert_window: int = DEFAULT_WINDOW
|
|
70
83
|
print_to_screen: bool = False
|
|
@@ -76,6 +89,8 @@ class HistoryOptions:
|
|
|
76
89
|
f"Unknown step_policy {self.step_policy!r}; "
|
|
77
90
|
f"expected one of {', '.join(STEP_POLICIES)}."
|
|
78
91
|
)
|
|
92
|
+
if self.stream is not None:
|
|
93
|
+
object.__setattr__(self, "stream", validate_stream(self.stream))
|
|
79
94
|
clamped = {
|
|
80
95
|
"cache_bytes": max(0, int(self.cache_bytes)),
|
|
81
96
|
"cache_rows": max(1, int(self.cache_rows)),
|
|
@@ -128,6 +143,9 @@ class HistoryStore:
|
|
|
128
143
|
self.log_dir: Path | None = None
|
|
129
144
|
self.log_fp: Path | None = None
|
|
130
145
|
self.config_fp: Path | None = None
|
|
146
|
+
self.spans_fp: Path | None = None
|
|
147
|
+
self.span_writer: JsonlWriter | None = None
|
|
148
|
+
self.stream: str | None = None
|
|
131
149
|
self.project: str = ""
|
|
132
150
|
self.name: str = ""
|
|
133
151
|
self._reset(HistoryOptions(), on_commit=None)
|
|
@@ -177,12 +195,29 @@ class HistoryStore:
|
|
|
177
195
|
self.project, self.name = project, name
|
|
178
196
|
self.log_dir = Path(dir or "./tracker/jsonl") / project / name
|
|
179
197
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
180
|
-
self.
|
|
181
|
-
self.
|
|
198
|
+
self.stream = opts.stream
|
|
199
|
+
self.config_fp = self.log_dir / sidecar_filename("config", opts.stream, "json")
|
|
200
|
+
self.spans_fp = (
|
|
201
|
+
self.log_dir / spans_filename(opts.stream, opts.rank_aware)
|
|
202
|
+
if opts.spans
|
|
203
|
+
else None
|
|
204
|
+
)
|
|
205
|
+
self.log_fp = self.log_dir / metrics_filename(opts.stream, opts.rank_aware)
|
|
182
206
|
|
|
183
207
|
self._close_previous()
|
|
184
208
|
self._reset(opts, on_commit)
|
|
185
209
|
self.writer = self._open_writer(opts)
|
|
210
|
+
self.span_writer = (
|
|
211
|
+
JsonlWriter(
|
|
212
|
+
self.spans_fp,
|
|
213
|
+
buffer_size=opts.buffer_size,
|
|
214
|
+
buffer_interval=opts.buffer_interval,
|
|
215
|
+
max_buffer_seconds=opts.max_buffer_seconds,
|
|
216
|
+
max_pending_records=opts.max_pending_records,
|
|
217
|
+
)
|
|
218
|
+
if self.spans_fp is not None
|
|
219
|
+
else None
|
|
220
|
+
)
|
|
186
221
|
self._resume(opts.alert_window)
|
|
187
222
|
self._write_config(config)
|
|
188
223
|
self._register_atexit()
|
|
@@ -191,8 +226,9 @@ class HistoryStore:
|
|
|
191
226
|
def _close_previous(self):
|
|
192
227
|
"""Re-initialising must not strand the previous writer's buffer or timer."""
|
|
193
228
|
self._cancel_open_timer()
|
|
194
|
-
|
|
195
|
-
|
|
229
|
+
for writer in (self.writer, self.span_writer):
|
|
230
|
+
if writer is not None:
|
|
231
|
+
writer.close()
|
|
196
232
|
|
|
197
233
|
def _open_writer(self, options: HistoryOptions) -> JsonlWriter:
|
|
198
234
|
assert self.log_fp is not None
|
|
@@ -276,7 +312,24 @@ class HistoryStore:
|
|
|
276
312
|
``is None``, since step 0 is falsy.
|
|
277
313
|
"""
|
|
278
314
|
self._require_init()
|
|
279
|
-
|
|
315
|
+
return self.ingest(self._codec.encode(metrics), step=step, commit=commit)
|
|
316
|
+
|
|
317
|
+
def ingest(
|
|
318
|
+
self,
|
|
319
|
+
encoded: dict,
|
|
320
|
+
*,
|
|
321
|
+
step: int | None = None,
|
|
322
|
+
commit: bool | None = None,
|
|
323
|
+
accumulate: bool = False,
|
|
324
|
+
) -> int | None:
|
|
325
|
+
"""The single path metrics take into the open row.
|
|
326
|
+
|
|
327
|
+
``log()`` and span durations both come through here, so the step policy,
|
|
328
|
+
the merge and the commit rules only exist once. ``accumulate`` adds to any
|
|
329
|
+
existing value instead of replacing it, which is what repeated spans in
|
|
330
|
+
one step need.
|
|
331
|
+
"""
|
|
332
|
+
self._require_init()
|
|
280
333
|
now = time.time()
|
|
281
334
|
pending: list[dict] = []
|
|
282
335
|
with self._lock:
|
|
@@ -287,7 +340,7 @@ class HistoryStore:
|
|
|
287
340
|
return None
|
|
288
341
|
pending.append(self._switch_open_row(step, now))
|
|
289
342
|
resolved = self._open_step
|
|
290
|
-
self._update_open_row(encoded)
|
|
343
|
+
self._update_open_row(encoded, accumulate)
|
|
291
344
|
if resolve_commit(step, commit):
|
|
292
345
|
pending.append(self._close_open_row())
|
|
293
346
|
for record in filter(None, pending):
|
|
@@ -295,6 +348,28 @@ class HistoryStore:
|
|
|
295
348
|
self._schedule_open_timer()
|
|
296
349
|
return resolved
|
|
297
350
|
|
|
351
|
+
def record_span(self, metrics: dict, record: dict | None = None) -> int | None:
|
|
352
|
+
"""A finished span: durations join the open row, the tree goes to disk.
|
|
353
|
+
|
|
354
|
+
Durations never commit a step of their own -- they ride along with
|
|
355
|
+
whatever ``log()`` commits, so a span costs no extra row. The metrics are
|
|
356
|
+
built from floats and ints here, so they skip the encoder: spans are
|
|
357
|
+
recorded per sub-step and cannot afford it.
|
|
358
|
+
"""
|
|
359
|
+
step = self.ingest(metrics, commit=False, accumulate=True)
|
|
360
|
+
if record is None or self.span_writer is None or step is None:
|
|
361
|
+
return step
|
|
362
|
+
try:
|
|
363
|
+
line = encode_line({"_step": step, **record})
|
|
364
|
+
except Exception as e:
|
|
365
|
+
logger.warning(f"Dropping span that cannot be serialized: {e}")
|
|
366
|
+
return step
|
|
367
|
+
# enqueue, not append: the batch goes out when the step commits, so a
|
|
368
|
+
# span does not pay for a flush decision of its own
|
|
369
|
+
if self.span_writer.enqueue(step, self.span_writer.lines, line):
|
|
370
|
+
self.span_writer.flush()
|
|
371
|
+
return step
|
|
372
|
+
|
|
298
373
|
def _switch_open_row(self, step: int | None, now: float) -> dict | None:
|
|
299
374
|
"""Point the open row at ``step``, returning the row it displaced, if any.
|
|
300
375
|
|
|
@@ -331,12 +406,18 @@ class HistoryStore:
|
|
|
331
406
|
if timer is not None:
|
|
332
407
|
timer.cancel()
|
|
333
408
|
|
|
334
|
-
def _update_open_row(self, encoded: dict):
|
|
409
|
+
def _update_open_row(self, encoded: dict, accumulate: bool = False):
|
|
335
410
|
for key in RESERVED_KEYS & set(encoded):
|
|
336
411
|
self._codec.warn_once(
|
|
337
412
|
key, f"Metric name {key!r} is reserved and will be ignored."
|
|
338
413
|
)
|
|
339
414
|
encoded.pop(key)
|
|
415
|
+
if accumulate:
|
|
416
|
+
# Repeated spans in one step sum rather than overwrite
|
|
417
|
+
for key, value in encoded.items():
|
|
418
|
+
current = self._open_row.get(key)
|
|
419
|
+
self._open_row[key] = value + current if _addable(current) else value
|
|
420
|
+
return
|
|
340
421
|
for key in encoded:
|
|
341
422
|
if key in self._open_row:
|
|
342
423
|
self._codec.warn_once(
|
|
@@ -480,8 +561,9 @@ class HistoryStore:
|
|
|
480
561
|
record = self._close_open_row()
|
|
481
562
|
if record is not None:
|
|
482
563
|
self._emit(record)
|
|
483
|
-
|
|
484
|
-
|
|
564
|
+
for writer in (self.writer, self.span_writer):
|
|
565
|
+
if writer is not None:
|
|
566
|
+
writer.flush()
|
|
485
567
|
|
|
486
568
|
def finish(self):
|
|
487
569
|
"""Close the store. Never raises: a bad disk must not mask the real error."""
|
|
@@ -492,11 +574,12 @@ class HistoryStore:
|
|
|
492
574
|
self.flush(commit_open=True)
|
|
493
575
|
except Exception as e:
|
|
494
576
|
logger.warning(f"Failed to flush history on finish: {e}")
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
577
|
+
for writer in (self.writer, self.span_writer):
|
|
578
|
+
if writer is not None:
|
|
579
|
+
try:
|
|
580
|
+
writer.close()
|
|
581
|
+
except Exception as e:
|
|
582
|
+
logger.warning(f"Failed to close {writer.path}: {e}")
|
|
500
583
|
if self._atexit_registered:
|
|
501
584
|
atexit.unregister(self._atexit)
|
|
502
585
|
self._atexit_registered = False
|
|
@@ -758,19 +841,8 @@ class HistoryStore:
|
|
|
758
841
|
# ---------------------------------------------------------------------- helpers
|
|
759
842
|
|
|
760
843
|
|
|
761
|
-
def
|
|
762
|
-
|
|
763
|
-
rank = os.getenv("RANK") or os.getenv("LOCAL_RANK") or "0"
|
|
764
|
-
try:
|
|
765
|
-
return int(rank)
|
|
766
|
-
except ValueError:
|
|
767
|
-
return 0
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
def _metrics_filename(rank_aware: bool) -> str:
|
|
771
|
-
"""Non-zero ranks get their own shard so appends cannot interleave."""
|
|
772
|
-
rank = current_rank() if rank_aware else 0
|
|
773
|
-
return "metrics.jsonl" if rank <= 0 else f"metrics.rank{rank}.jsonl"
|
|
844
|
+
def _addable(value) -> bool:
|
|
845
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
774
846
|
|
|
775
847
|
|
|
776
848
|
def _parse_cached(entries: Sequence[tuple[int, int, bytes]]) -> list[dict]:
|