expr_tracker 0.4.0__tar.gz → 0.4.1__tar.gz
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-0.4.0 → expr_tracker-0.4.1}/PKG-INFO +3 -2
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/README.md +2 -1
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/architecture.md +16 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/reference/configuration.md +17 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/reader.py +45 -3
- expr_tracker-0.4.1/src/expr_tracker/history/rotation.py +29 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/store.py +11 -3
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/writer.py +90 -26
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/trace.py +2 -1
- expr_tracker-0.4.1/tests/test_rotation.py +217 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/.github/workflows/ci.yaml +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/.github/workflows/docs.yaml +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/.github/workflows/release.yaml +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/.gitignore +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/LICENSE +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/design.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/examples.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/getting-started.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/alerts.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/artifacts.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/backends.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/cli.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/distributed.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/history.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/logging.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/spans.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/streams.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/index.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/reference/api.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/reference/expressions.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/examples/README.md +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/examples/alert_rules.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/examples/checkpoints.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/examples/early_stopping.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/examples/multiprocess_pipeline.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/examples/profile_step.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/examples/quickstart.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/mkdocs.yml +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/pyproject.toml +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/__init__.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/_compat.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/_process.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/__init__.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/backends/__init__.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/backends/base.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/backends/cards.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/dispatch.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/engine.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/expr/__init__.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/expr/eval.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/expr/functions.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/expr/lexer.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/expr/nodes.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/expr/parser.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/expr/rule.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/models.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/artifacts.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/cli.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/encoders.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/__init__.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/codec.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/frame.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/naming.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/series.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/plugins.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/py.typed +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/run.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/spans.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/summary.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/tracker.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/types.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/conftest.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_alert_backends.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_alert_delivery.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_alert_dispatch.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_alert_engine.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_alert_models.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_alert_routing.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_artifacts.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_benchmark.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_cache.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_callback_isolation.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_cli.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_commit_timestamps.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_correctness.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_distributed.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_e2e.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_examples.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_expr_builder.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_expr_eval.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_expr_functions.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_expr_parser.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_expr_properties.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_failure_modes.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_features.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_fork_safety.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_frame_codec_summary.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_history.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_history_admission.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_hot_paths.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_integration.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_lark.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_nonfinite_conditions.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_perf.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_process_compat.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_public_surfaces.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_reader_edge_cases.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_review_regressions.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_rule_lifecycle.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_rule_overrides.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_rule_quoted_names.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_run_backends.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_run_dir.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_scenarios.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_serialization_edge_cases.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_series_edge_cases.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_span_lifecycle.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_span_plugins.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_span_value_encoding.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_spans.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_step_commit.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_step_types.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_streams.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_stress.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_trace.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_trace_perf.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_trackio.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_unicode_records.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_value_encoding.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_wandb.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_writer_async.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_writer_buffer.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_writer_bytes.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_writer_diagnostics.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_writer_durability.py +0 -0
- {expr_tracker-0.4.0 → expr_tracker-0.4.1}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: expr_tracker
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Local-first experiment tracking with queryable history and expression-based alerts on your training metrics
|
|
5
5
|
Project-URL: Homepage, https://hspk.github.io/expr_tracker/
|
|
6
6
|
Project-URL: Documentation, https://hspk.github.io/expr_tracker/
|
|
@@ -76,7 +76,8 @@ et.history(-1, output_type="pd") # everything, as a DataFrame
|
|
|
76
76
|
## Why
|
|
77
77
|
|
|
78
78
|
- **The file is the source of truth.** One JSON object per step, appended to
|
|
79
|
-
`metrics.jsonl
|
|
79
|
+
`metrics.jsonl`, automatically split into retained segments at 100 MiB.
|
|
80
|
+
History queries read all segments transparently. No server, no database, no vendor.
|
|
80
81
|
- **History is queryable during the run.** `et.history(n)` answers from an in-memory
|
|
81
82
|
cache and touches the file only for what it evicted — 227 µs for
|
|
82
83
|
`history(50)` whether the run has 1,000 steps or 100,000.
|
|
@@ -26,7 +26,8 @@ et.history(-1, output_type="pd") # everything, as a DataFrame
|
|
|
26
26
|
## Why
|
|
27
27
|
|
|
28
28
|
- **The file is the source of truth.** One JSON object per step, appended to
|
|
29
|
-
`metrics.jsonl
|
|
29
|
+
`metrics.jsonl`, automatically split into retained segments at 100 MiB.
|
|
30
|
+
History queries read all segments transparently. No server, no database, no vendor.
|
|
30
31
|
- **History is queryable during the run.** `et.history(n)` answers from an in-memory
|
|
31
32
|
cache and touches the file only for what it evicted — 227 µs for
|
|
32
33
|
`history(50)` whether the run has 1,000 steps or 100,000.
|
|
@@ -123,6 +123,22 @@ plain functions over a sequence, so they hold no lock and are tested directly.
|
|
|
123
123
|
`et.history(run=...)` bypasses the store entirely and reads through `JsonlReader`
|
|
124
124
|
(`read_history`), which is what makes offline analysis and `et rules test` possible.
|
|
125
125
|
|
|
126
|
+
### Rotation segments
|
|
127
|
+
|
|
128
|
+
Metric and span writers default to `max_file_bytes=100 << 20`; `None` disables
|
|
129
|
+
rotation. The original file is never renamed. Additional, permanently retained
|
|
130
|
+
segments live in `<filename>.parts/00000001.jsonl`, incrementing numerically.
|
|
131
|
+
Writes split batches only at record boundaries, and oversized records stay intact.
|
|
132
|
+
On failure, rollback truncates the logical byte stream to its pre-batch size and
|
|
133
|
+
removes newly written suffix segments before retrying.
|
|
134
|
+
|
|
135
|
+
The sparse index and metadata use cumulative byte offsets and row counts across
|
|
136
|
+
all segments. Readers capture the segment list and each file's byte boundary per
|
|
137
|
+
query, then scan or reverse-tail them as one logical file. This preserves cache
|
|
138
|
+
boundaries, cross-segment patch merging, and snapshots while new segments appear.
|
|
139
|
+
Resume repairs the active segment's torn tail and restores the whole run's state.
|
|
140
|
+
Span trace export uses the same segmented raw reader.
|
|
141
|
+
|
|
126
142
|
### Rows versus steps
|
|
127
143
|
|
|
128
144
|
A step normally occupies one physical row, but an explicit commit followed by
|
|
@@ -27,6 +27,7 @@ list of valid options rather than being silently ignored.
|
|
|
27
27
|
| `max_pending_records` | 100,000 | cap on queued plus in-flight records |
|
|
28
28
|
| `max_pending_bytes` | 64 MiB | cap on queued plus in-flight encoded bytes; `None` disables |
|
|
29
29
|
| `max_record_bytes` | 8 MiB | maximum encoded size of one record; `None` disables |
|
|
30
|
+
| `max_file_bytes` | 100 MiB | rotate metric and span files at record boundaries; `None` disables |
|
|
30
31
|
|
|
31
32
|
```python
|
|
32
33
|
et.init(project="p", cache_bytes=256 << 20, step_policy="allow")
|
|
@@ -68,6 +69,22 @@ wait for accepted records to be flushed in either mode.
|
|
|
68
69
|
`et.info()["history"]` reports metric/span `pending_bytes`, `span_pending_bytes`,
|
|
69
70
|
`dropped_records` and `dropped_spans` for diagnosing overload and failed writes.
|
|
70
71
|
|
|
72
|
+
### Automatic rotation
|
|
73
|
+
|
|
74
|
+
Metrics and spans rotate by default before the next record would make a file
|
|
75
|
+
exceed 100 MiB. Change the threshold with `et.init(max_file_bytes=20 << 20)`, or
|
|
76
|
+
disable rotation with `et.init(max_file_bytes=None)`. The limit must be a positive
|
|
77
|
+
integer or `None`. A record larger than the threshold stays intact in its own file
|
|
78
|
+
(subject to the independent `max_record_bytes` limit).
|
|
79
|
+
|
|
80
|
+
The original filename remains in place. Subsequent segments live in its `.parts`
|
|
81
|
+
directory, for example `metrics.jsonl.parts/00000001.jsonl`. Stream and rank files
|
|
82
|
+
rotate independently. All segments are retained: rotation limits individual file
|
|
83
|
+
size, not total disk usage. Keep the `.parts` directory alongside the original file
|
|
84
|
+
when copying a run. Live and offline history queries, resume, and span trace export
|
|
85
|
+
read the complete logical history across segments, even when rotation is later
|
|
86
|
+
disabled. Segment files are implementation details, not separate streams.
|
|
87
|
+
|
|
71
88
|
## Alert configuration
|
|
72
89
|
|
|
73
90
|
Resolved in this order:
|
|
@@ -16,12 +16,13 @@ from loguru import logger
|
|
|
16
16
|
|
|
17
17
|
from .frame import project, to_output
|
|
18
18
|
from .naming import RANK_PATTERN, metrics_filename
|
|
19
|
+
from .rotation import segments
|
|
19
20
|
|
|
20
21
|
CHUNK_SIZE = 256 * 1024
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
class JsonlReader:
|
|
24
|
-
"""Read-only access to
|
|
25
|
+
"""Read-only access to a JSONL file and all its rotation segments.
|
|
25
26
|
|
|
26
27
|
``index`` is a sparse list of ``(step, line, offset)`` anchors, loaded from the
|
|
27
28
|
meta sidecar or rebuilt by scanning. With ``merge=True`` results are merged by
|
|
@@ -51,6 +52,7 @@ class JsonlReader:
|
|
|
51
52
|
self._snapshot_limit: int | None = None
|
|
52
53
|
self._snapshot_file_size: int | None = None
|
|
53
54
|
self._cached_snapshot: tuple[int, JsonlReader] | None = None
|
|
55
|
+
self._segments: list[tuple[Path, int]] | None = None
|
|
54
56
|
|
|
55
57
|
def _snapshot(self, end: int | None = None) -> JsonlReader:
|
|
56
58
|
"""A query-local reader whose metadata and data share one byte boundary."""
|
|
@@ -58,13 +60,25 @@ class JsonlReader:
|
|
|
58
60
|
end is None or end >= self._snapshot_limit
|
|
59
61
|
):
|
|
60
62
|
return self
|
|
61
|
-
|
|
63
|
+
captured = self._segments
|
|
64
|
+
if captured is None:
|
|
65
|
+
captured = [
|
|
66
|
+
(path, path.stat().st_size)
|
|
67
|
+
for path in segments(self.path)
|
|
68
|
+
if path.exists()
|
|
69
|
+
]
|
|
70
|
+
size = (
|
|
71
|
+
self.size
|
|
72
|
+
if self._snapshot_limit is not None
|
|
73
|
+
else sum(length for _, length in captured)
|
|
74
|
+
)
|
|
62
75
|
limit = size if end is None else max(0, min(end, size))
|
|
63
76
|
source = self
|
|
64
77
|
cached = self._cached_snapshot
|
|
65
78
|
if cached is not None and cached[0] >= limit:
|
|
66
79
|
source = cached[1]
|
|
67
80
|
reader = JsonlReader(self.path)
|
|
81
|
+
reader._segments = captured
|
|
68
82
|
reader._snapshot_limit = limit
|
|
69
83
|
reader._snapshot_file_size = (
|
|
70
84
|
size if self._snapshot_file_size is None else self._snapshot_file_size
|
|
@@ -83,7 +97,7 @@ class JsonlReader:
|
|
|
83
97
|
def size(self) -> int:
|
|
84
98
|
if self._snapshot_limit is not None:
|
|
85
99
|
return self._snapshot_limit
|
|
86
|
-
return
|
|
100
|
+
return sum(path.stat().st_size for path in segments(self.path) if path.exists())
|
|
87
101
|
|
|
88
102
|
@property
|
|
89
103
|
def merge(self) -> bool:
|
|
@@ -170,6 +184,23 @@ class JsonlReader:
|
|
|
170
184
|
|
|
171
185
|
def _scan(self, start: int = 0, end: int | None = None):
|
|
172
186
|
"""Yield ``(byte offset, raw line)`` for every line, blanks included."""
|
|
187
|
+
if self._segments is not None:
|
|
188
|
+
limit = self.size if end is None else min(end, self.size)
|
|
189
|
+
base = 0
|
|
190
|
+
for path, length in self._segments:
|
|
191
|
+
if base >= limit:
|
|
192
|
+
break
|
|
193
|
+
if base + length > start:
|
|
194
|
+
reader = JsonlReader(path)
|
|
195
|
+
reader._snapshot_limit = min(length, limit - base)
|
|
196
|
+
reader._snapshot_file_size = length
|
|
197
|
+
for offset, raw in reader._scan_file(max(0, start - base)):
|
|
198
|
+
yield base + offset, raw
|
|
199
|
+
base += length
|
|
200
|
+
return
|
|
201
|
+
yield from self._scan_file(start, end)
|
|
202
|
+
|
|
203
|
+
def _scan_file(self, start: int = 0, end: int | None = None):
|
|
173
204
|
if not self.path.exists():
|
|
174
205
|
return
|
|
175
206
|
limit = self.size if end is None else end
|
|
@@ -207,6 +238,17 @@ class JsonlReader:
|
|
|
207
238
|
|
|
208
239
|
def _reverse_raw(self, end: int | None = None) -> Iterator[bytes]:
|
|
209
240
|
"""Yield nonblank lines backwards, joining long lines only once."""
|
|
241
|
+
if self._segments is not None:
|
|
242
|
+
limit = self.size if end is None else min(end, self.size)
|
|
243
|
+
base = sum(length for _, length in self._segments)
|
|
244
|
+
for path, length in reversed(self._segments):
|
|
245
|
+
base -= length
|
|
246
|
+
if base < limit:
|
|
247
|
+
reader = JsonlReader(path)
|
|
248
|
+
reader._snapshot_limit = min(length, limit - base)
|
|
249
|
+
reader._snapshot_file_size = length
|
|
250
|
+
yield from reader._reverse_raw()
|
|
251
|
+
return
|
|
210
252
|
if not self.path.exists():
|
|
211
253
|
return
|
|
212
254
|
size = self.size
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Stable JSONL segments, addressed as one append-only byte stream."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
DEFAULT_MAX_FILE_BYTES = 100 * 1024 * 1024
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_file_limit(max_file_bytes: int | None) -> None:
|
|
9
|
+
if max_file_bytes is not None and (
|
|
10
|
+
type(max_file_bytes) is not int or max_file_bytes <= 0
|
|
11
|
+
):
|
|
12
|
+
raise ValueError("max_file_bytes must be a positive integer or None")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def segment_path(path: Path, number: int) -> Path:
|
|
16
|
+
return path.with_name(path.name + ".parts") / f"{number:08d}.jsonl"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def segments(path: Path) -> list[Path]:
|
|
20
|
+
"""The original file stays in place; later segments never rename older data."""
|
|
21
|
+
parts = path.with_name(path.name + ".parts")
|
|
22
|
+
numbered = []
|
|
23
|
+
if parts.exists():
|
|
24
|
+
numbered.extend(
|
|
25
|
+
part
|
|
26
|
+
for part in parts.glob("*.jsonl")
|
|
27
|
+
if part.stem.isascii() and part.stem.isdecimal()
|
|
28
|
+
)
|
|
29
|
+
return [path, *sorted(numbered, key=lambda part: int(part.stem))]
|
|
@@ -38,6 +38,7 @@ from .naming import (
|
|
|
38
38
|
validate_stream,
|
|
39
39
|
)
|
|
40
40
|
from .reader import JsonlReader, merge_steps, recent_steps
|
|
41
|
+
from .rotation import DEFAULT_MAX_FILE_BYTES, validate_file_limit
|
|
41
42
|
from .series import DEFAULT_WINDOW, MetricSeries
|
|
42
43
|
from .writer import (
|
|
43
44
|
DEFAULT_BUFFER_INTERVAL,
|
|
@@ -115,6 +116,7 @@ class HistoryOptions:
|
|
|
115
116
|
max_pending_records: int = DEFAULT_MAX_PENDING_RECORDS
|
|
116
117
|
max_pending_bytes: int | None = DEFAULT_MAX_PENDING_BYTES
|
|
117
118
|
max_record_bytes: int | None = DEFAULT_MAX_RECORD_BYTES
|
|
119
|
+
max_file_bytes: int | None = DEFAULT_MAX_FILE_BYTES
|
|
118
120
|
# in-memory cache
|
|
119
121
|
cache_bytes: int = DEFAULT_CACHE_BYTES
|
|
120
122
|
cache_rows: int = DEFAULT_CACHE_ROWS
|
|
@@ -138,6 +140,7 @@ class HistoryOptions:
|
|
|
138
140
|
|
|
139
141
|
def __post_init__(self):
|
|
140
142
|
validate_byte_limits(self.max_pending_bytes, self.max_record_bytes)
|
|
143
|
+
validate_file_limit(self.max_file_bytes)
|
|
141
144
|
if self.step_policy not in STEP_POLICIES:
|
|
142
145
|
raise ValueError(
|
|
143
146
|
f"Unknown step_policy {self.step_policy!r}; "
|
|
@@ -304,6 +307,7 @@ class HistoryStore:
|
|
|
304
307
|
max_pending_records=opts.max_pending_records,
|
|
305
308
|
max_pending_bytes=opts.max_pending_bytes,
|
|
306
309
|
max_record_bytes=opts.max_record_bytes,
|
|
310
|
+
max_file_bytes=opts.max_file_bytes,
|
|
307
311
|
async_write=opts.span_async_write,
|
|
308
312
|
)
|
|
309
313
|
if self.spans_fp is not None
|
|
@@ -336,6 +340,7 @@ class HistoryStore:
|
|
|
336
340
|
max_pending_records=options.max_pending_records,
|
|
337
341
|
max_pending_bytes=options.max_pending_bytes,
|
|
338
342
|
max_record_bytes=options.max_record_bytes,
|
|
343
|
+
max_file_bytes=options.max_file_bytes,
|
|
339
344
|
meta_extra={
|
|
340
345
|
"project": self.project,
|
|
341
346
|
"name": self.name,
|
|
@@ -1095,9 +1100,12 @@ class HistoryStore:
|
|
|
1095
1100
|
records = view.records()
|
|
1096
1101
|
if view.complete:
|
|
1097
1102
|
return records
|
|
1098
|
-
return
|
|
1099
|
-
|
|
1100
|
-
|
|
1103
|
+
return (
|
|
1104
|
+
self._older(
|
|
1105
|
+
lambda: self._older_tail(view, steps, records + opened, merge=merge)
|
|
1106
|
+
)
|
|
1107
|
+
+ records
|
|
1108
|
+
)
|
|
1101
1109
|
|
|
1102
1110
|
def _older(self, fetch: Callable[[], list[dict]]) -> list[dict]:
|
|
1103
1111
|
"""Rows from before the cache; an unreadable file degrades to cache-only.
|
|
@@ -18,6 +18,12 @@ from loguru import logger
|
|
|
18
18
|
|
|
19
19
|
from .._process import check_process, process_id, register_fork_reset
|
|
20
20
|
from .reader import JsonlReader
|
|
21
|
+
from .rotation import (
|
|
22
|
+
DEFAULT_MAX_FILE_BYTES,
|
|
23
|
+
segment_path,
|
|
24
|
+
segments,
|
|
25
|
+
validate_file_limit,
|
|
26
|
+
)
|
|
21
27
|
|
|
22
28
|
DEFAULT_BUFFER_SIZE = 50
|
|
23
29
|
DEFAULT_BUFFER_INTERVAL = 1.0
|
|
@@ -140,6 +146,9 @@ class JsonlWriter:
|
|
|
140
146
|
``max_pending_bytes``. Both byte limits must be positive integers or ``None``.
|
|
141
147
|
- ``async_write``: enqueue without producer disk I/O; ``flush`` and ``close``
|
|
142
148
|
still wait for accepted writes. The default preserves synchronous flushing.
|
|
149
|
+
- ``max_file_bytes``: rotate before a record would exceed this file size.
|
|
150
|
+
All segments are retained; a larger record occupies a segment by itself.
|
|
151
|
+
``None`` disables rotation.
|
|
143
152
|
"""
|
|
144
153
|
|
|
145
154
|
def __init__(
|
|
@@ -152,13 +161,19 @@ class JsonlWriter:
|
|
|
152
161
|
max_pending_records: int = DEFAULT_MAX_PENDING_RECORDS,
|
|
153
162
|
max_pending_bytes: int | None = DEFAULT_MAX_PENDING_BYTES,
|
|
154
163
|
max_record_bytes: int | None = DEFAULT_MAX_RECORD_BYTES,
|
|
164
|
+
max_file_bytes: int | None = DEFAULT_MAX_FILE_BYTES,
|
|
155
165
|
index_every: int = DEFAULT_INDEX_EVERY,
|
|
156
166
|
meta_extra: dict | None = None,
|
|
157
167
|
async_write: bool = False,
|
|
158
168
|
):
|
|
159
169
|
self._owner_pid = process_id()
|
|
160
170
|
validate_byte_limits(max_pending_bytes, max_record_bytes)
|
|
171
|
+
validate_file_limit(max_file_bytes)
|
|
161
172
|
self.path = Path(path)
|
|
173
|
+
self.max_file_bytes = max_file_bytes
|
|
174
|
+
active = segments(self.path)[-1]
|
|
175
|
+
self._active_number = 0 if active == self.path else int(active.stem)
|
|
176
|
+
self._active_size = 0
|
|
162
177
|
self.meta_path = self.path.with_name(self.path.stem + ".meta.json")
|
|
163
178
|
self.buffer_size = max(1, int(buffer_size))
|
|
164
179
|
self.buffer_interval = (
|
|
@@ -213,12 +228,21 @@ class JsonlWriter:
|
|
|
213
228
|
|
|
214
229
|
# ------------------------------------------------------------------ open/resume
|
|
215
230
|
|
|
231
|
+
@property
|
|
232
|
+
def _active_path(self) -> Path:
|
|
233
|
+
return (
|
|
234
|
+
self.path
|
|
235
|
+
if self._active_number == 0
|
|
236
|
+
else segment_path(self.path, self._active_number)
|
|
237
|
+
)
|
|
238
|
+
|
|
216
239
|
def _load(self):
|
|
217
240
|
"""Restore state from an existing file, repairing a torn tail and stale meta."""
|
|
218
241
|
if not self.path.exists():
|
|
219
242
|
return
|
|
220
243
|
self._repair_torn_tail()
|
|
221
|
-
|
|
244
|
+
self._active_size = self._active_path.stat().st_size
|
|
245
|
+
size = JsonlReader(self.path).size
|
|
222
246
|
if not self._restore_from_meta(size):
|
|
223
247
|
self._rebuild_from_disk(size)
|
|
224
248
|
self.size = size
|
|
@@ -228,10 +252,10 @@ class JsonlWriter:
|
|
|
228
252
|
|
|
229
253
|
def _repair_torn_tail(self):
|
|
230
254
|
"""Drop a partially written final line so appends cannot glue onto it."""
|
|
231
|
-
size = self.
|
|
255
|
+
size = self._active_path.stat().st_size
|
|
232
256
|
if size == 0:
|
|
233
257
|
return
|
|
234
|
-
with open(self.
|
|
258
|
+
with open(self._active_path, "rb") as f:
|
|
235
259
|
f.seek(size - 1)
|
|
236
260
|
if f.read(1) == b"\n":
|
|
237
261
|
return
|
|
@@ -245,7 +269,7 @@ class JsonlWriter:
|
|
|
245
269
|
cut = pos + index + 1
|
|
246
270
|
break
|
|
247
271
|
try:
|
|
248
|
-
os.truncate(self.
|
|
272
|
+
os.truncate(self._active_path, cut)
|
|
249
273
|
except Exception as e:
|
|
250
274
|
self._diagnostics.add(
|
|
251
275
|
"error", f"Could not truncate torn tail of {self.path}: {e}"
|
|
@@ -253,7 +277,7 @@ class JsonlWriter:
|
|
|
253
277
|
return
|
|
254
278
|
self._diagnostics.add(
|
|
255
279
|
"warning",
|
|
256
|
-
f"Dropped {size - cut} bytes of a torn final line in {self.path}."
|
|
280
|
+
f"Dropped {size - cut} bytes of a torn final line in {self.path}.",
|
|
257
281
|
)
|
|
258
282
|
|
|
259
283
|
def _restore_from_meta(self, size: int) -> bool:
|
|
@@ -293,12 +317,9 @@ class JsonlWriter:
|
|
|
293
317
|
def _rescan(self, offset: int, line_no: int):
|
|
294
318
|
"""Fold every line from ``offset`` onwards back into the counters and index."""
|
|
295
319
|
self.lines = self._next_index_line = line_no
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if raw.strip():
|
|
300
|
-
self._track_line(_peek_step(raw), offset)
|
|
301
|
-
offset += len(raw)
|
|
320
|
+
for position, raw in JsonlReader(self.path)._snapshot()._scan(offset):
|
|
321
|
+
if raw.strip():
|
|
322
|
+
self._track_line(_peek_step(raw), position)
|
|
302
323
|
|
|
303
324
|
def _track_line(self, step: int | None, offset: int):
|
|
304
325
|
"""Account for one line that is already on disk."""
|
|
@@ -449,7 +470,8 @@ class JsonlWriter:
|
|
|
449
470
|
if self._first_buffered_time is None:
|
|
450
471
|
self._first_buffered_time = now
|
|
451
472
|
interval = (
|
|
452
|
-
None
|
|
473
|
+
None
|
|
474
|
+
if self._last_append_time is None
|
|
453
475
|
else now - self._last_append_time
|
|
454
476
|
)
|
|
455
477
|
self._last_append_time = now
|
|
@@ -592,13 +614,42 @@ class JsonlWriter:
|
|
|
592
614
|
self._save_meta()
|
|
593
615
|
|
|
594
616
|
def _write(self, payload: bytes):
|
|
617
|
+
"""Split a batch at record boundaries, without renaming pinned segments."""
|
|
618
|
+
if (
|
|
619
|
+
self.max_file_bytes is None
|
|
620
|
+
or self._active_size + len(payload) <= self.max_file_bytes
|
|
621
|
+
):
|
|
622
|
+
self._append(payload)
|
|
623
|
+
self._active_size += len(payload)
|
|
624
|
+
return
|
|
625
|
+
start = 0
|
|
626
|
+
while start < len(payload):
|
|
627
|
+
end = start
|
|
628
|
+
while end < len(payload):
|
|
629
|
+
next_end = payload.find(b"\n", end) + 1
|
|
630
|
+
if next_end == 0:
|
|
631
|
+
next_end = len(payload)
|
|
632
|
+
if self._active_size + next_end - start > self.max_file_bytes and (
|
|
633
|
+
self._active_size or end > start
|
|
634
|
+
):
|
|
635
|
+
break
|
|
636
|
+
end = next_end
|
|
637
|
+
if end == start:
|
|
638
|
+
self._active_number += 1
|
|
639
|
+
self._active_size = 0
|
|
640
|
+
continue
|
|
641
|
+
self._append(payload[start:end])
|
|
642
|
+
self._active_size += end - start
|
|
643
|
+
start = end
|
|
644
|
+
|
|
645
|
+
def _append(self, payload: bytes):
|
|
595
646
|
"""Append, recreating the parent directory once if it disappeared."""
|
|
596
647
|
try:
|
|
597
|
-
with open(self.
|
|
648
|
+
with open(self._active_path, "ab") as f:
|
|
598
649
|
f.write(payload)
|
|
599
650
|
except FileNotFoundError:
|
|
600
|
-
self.
|
|
601
|
-
with open(self.
|
|
651
|
+
self._active_path.parent.mkdir(parents=True, exist_ok=True)
|
|
652
|
+
with open(self._active_path, "ab") as f:
|
|
602
653
|
f.write(payload)
|
|
603
654
|
|
|
604
655
|
def _track_flushed(self, records: deque[tuple[int, int, bytes]], offset: int):
|
|
@@ -625,9 +676,23 @@ class JsonlWriter:
|
|
|
625
676
|
def _truncate_partial_write(self, size_before: int) -> bool:
|
|
626
677
|
"""Roll back a partial write so retries cannot duplicate lines."""
|
|
627
678
|
try:
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
679
|
+
remaining = size_before
|
|
680
|
+
for path in segments(self.path):
|
|
681
|
+
if not path.exists():
|
|
682
|
+
continue
|
|
683
|
+
length = path.stat().st_size
|
|
684
|
+
if remaining >= length:
|
|
685
|
+
remaining -= length
|
|
686
|
+
elif remaining or path == self.path:
|
|
687
|
+
os.truncate(path, remaining)
|
|
688
|
+
remaining = 0
|
|
689
|
+
else:
|
|
690
|
+
path.unlink()
|
|
691
|
+
active = segments(self.path)[-1]
|
|
692
|
+
self._active_number = 0 if active == self.path else int(active.stem)
|
|
693
|
+
self._active_size = (
|
|
694
|
+
self._active_path.stat().st_size if self._active_path.exists() else 0
|
|
695
|
+
)
|
|
631
696
|
return True
|
|
632
697
|
except Exception as e:
|
|
633
698
|
self._diagnostics.add(
|
|
@@ -645,13 +710,12 @@ class JsonlWriter:
|
|
|
645
710
|
self._first_buffered_time = time.monotonic()
|
|
646
711
|
|
|
647
712
|
def _has_capacity(self, incoming_bytes: int) -> bool:
|
|
648
|
-
return (
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
)
|
|
713
|
+
return len(
|
|
714
|
+
self._buffer
|
|
715
|
+
) + self._inflight_records < self.max_pending_records and (
|
|
716
|
+
self.max_pending_bytes is None
|
|
717
|
+
or self._queued_bytes + self._inflight_bytes + incoming_bytes
|
|
718
|
+
<= self.max_pending_bytes
|
|
655
719
|
)
|
|
656
720
|
|
|
657
721
|
def _cap_buffer(self, incoming_bytes: int) -> tuple[bool, str]:
|
|
@@ -751,7 +815,7 @@ class JsonlWriter:
|
|
|
751
815
|
"error",
|
|
752
816
|
f"Closing {self.path} with {len(self._buffer)} "
|
|
753
817
|
"unwritten records after a failed final flush "
|
|
754
|
-
f"({self._queued_bytes} bytes)."
|
|
818
|
+
f"({self._queued_bytes} bytes).",
|
|
755
819
|
)
|
|
756
820
|
finally:
|
|
757
821
|
with self._condition:
|
|
@@ -18,6 +18,7 @@ from tempfile import NamedTemporaryFile, TemporaryDirectory
|
|
|
18
18
|
from typing import Any
|
|
19
19
|
|
|
20
20
|
from .history.naming import parse_stream, spans_filename
|
|
21
|
+
from .history.reader import JsonlReader
|
|
21
22
|
|
|
22
23
|
MICROSECONDS = 1_000_000.0
|
|
23
24
|
|
|
@@ -90,7 +91,7 @@ def _iter_spans(
|
|
|
90
91
|
if not path.is_file():
|
|
91
92
|
return
|
|
92
93
|
# Binary lines isolate invalid UTF-8 to one record, like malformed JSON.
|
|
93
|
-
with path.
|
|
94
|
+
with closing(JsonlReader(path).iter_raw()) as source:
|
|
94
95
|
for line in source:
|
|
95
96
|
try:
|
|
96
97
|
record = json.loads(line)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
import expr_tracker as et
|
|
6
|
+
from expr_tracker.history import HistoryStore, JsonlReader, JsonlWriter, read_history
|
|
7
|
+
from expr_tracker.history.rotation import DEFAULT_MAX_FILE_BYTES, segments
|
|
8
|
+
from expr_tracker.trace import build_trace, read_spans
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def line(_step, **fields):
|
|
12
|
+
return json.dumps({"_step": _step, **fields}).encode() + b"\n"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def write(writer, records):
|
|
16
|
+
start = writer.lines
|
|
17
|
+
for row, record in enumerate(records, start):
|
|
18
|
+
writer.enqueue(record["_step"], row, line(**record))
|
|
19
|
+
writer.flush()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_default_and_validation(tmp_path):
|
|
23
|
+
writer = JsonlWriter(tmp_path / "metrics.jsonl")
|
|
24
|
+
assert writer.max_file_bytes == DEFAULT_MAX_FILE_BYTES == 100 << 20
|
|
25
|
+
writer.close()
|
|
26
|
+
for invalid in (0, -1, True, 1.5, "100"):
|
|
27
|
+
with pytest.raises(ValueError, match="max_file_bytes"):
|
|
28
|
+
JsonlWriter(tmp_path / "metrics.jsonl", max_file_bytes=invalid)
|
|
29
|
+
with pytest.raises(ValueError, match="max_file_bytes"):
|
|
30
|
+
HistoryStore().init("p", "n", dir=str(tmp_path), max_file_bytes=invalid)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.parametrize("async_write", [False, True])
|
|
34
|
+
def test_batch_rotation_and_resume(tmp_path, async_write):
|
|
35
|
+
path = tmp_path / "metrics.jsonl"
|
|
36
|
+
records = [{"_step": step, "v": step} for step in range(20)]
|
|
37
|
+
writer = JsonlWriter(
|
|
38
|
+
path, max_file_bytes=80, max_buffer_seconds=None, async_write=async_write
|
|
39
|
+
)
|
|
40
|
+
write(writer, records)
|
|
41
|
+
writer.close()
|
|
42
|
+
assert len(segments(path)) > 2
|
|
43
|
+
assert all(part.stat().st_size <= 80 for part in segments(path))
|
|
44
|
+
reader = JsonlReader(path)
|
|
45
|
+
assert reader.read_all() == records
|
|
46
|
+
assert reader.tail(3) == records[-3:]
|
|
47
|
+
assert reader.read_steps(3, 8) == records[3:8]
|
|
48
|
+
assert reader.count_lines() == 20
|
|
49
|
+
for row in range(21):
|
|
50
|
+
boundary = reader.offset_of_line(row)
|
|
51
|
+
assert reader.tail_rows(3, end=boundary) == records[max(0, row - 3) : row]
|
|
52
|
+
assert list(reader.iter_raw(end=boundary)) == [
|
|
53
|
+
line(**record) for record in records[:row]
|
|
54
|
+
]
|
|
55
|
+
if boundary:
|
|
56
|
+
assert list(reader.iter_raw(end=boundary - 1)) == [
|
|
57
|
+
line(**record) for record in records[: row - 1]
|
|
58
|
+
]
|
|
59
|
+
assert list(reader.iter_raw(reader.offset_of_line(7))) == [
|
|
60
|
+
line(**record) for record in records[7:]
|
|
61
|
+
]
|
|
62
|
+
resumed = JsonlWriter(path, max_file_bytes=None)
|
|
63
|
+
assert resumed.lines == 20
|
|
64
|
+
assert resumed.max_step == 19
|
|
65
|
+
write(resumed, [{"_step": 20, "v": 20}])
|
|
66
|
+
resumed.close()
|
|
67
|
+
assert JsonlReader(path).read_all() == [*records, {"_step": 20, "v": 20}]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_boundary_oversize_and_disabled(tmp_path):
|
|
71
|
+
raw = line(0, v="x")
|
|
72
|
+
path = tmp_path / "metrics.jsonl"
|
|
73
|
+
writer = JsonlWriter(path, max_file_bytes=len(raw))
|
|
74
|
+
write(
|
|
75
|
+
writer,
|
|
76
|
+
[
|
|
77
|
+
{"_step": 0, "v": "x"},
|
|
78
|
+
{"_step": 1, "v": "x" * 100},
|
|
79
|
+
{"_step": 2, "v": "x"},
|
|
80
|
+
],
|
|
81
|
+
)
|
|
82
|
+
writer.close()
|
|
83
|
+
assert [part.stat().st_size for part in segments(path)] == [
|
|
84
|
+
len(raw),
|
|
85
|
+
len(line(1, v="x" * 100)),
|
|
86
|
+
len(raw),
|
|
87
|
+
]
|
|
88
|
+
disabled = JsonlWriter(tmp_path / "disabled.jsonl", max_file_bytes=None)
|
|
89
|
+
write(disabled, [{"_step": i} for i in range(10)])
|
|
90
|
+
disabled.close()
|
|
91
|
+
assert len(segments(disabled.path)) == 1
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_snapshot_and_patches_across_segments(tmp_path):
|
|
95
|
+
path = tmp_path / "metrics.jsonl"
|
|
96
|
+
writer = JsonlWriter(path, max_file_bytes=30)
|
|
97
|
+
write(writer, [{"_step": 0, "a": 1}, {"_step": 1, "a": 2}])
|
|
98
|
+
snapshot = writer.reader()._snapshot()
|
|
99
|
+
size = snapshot.size
|
|
100
|
+
write(writer, [{"_step": 0, "b": 3}, {"_step": 2, "a": 4}])
|
|
101
|
+
assert snapshot.size == size
|
|
102
|
+
assert snapshot.read_all() == [{"_step": 0, "a": 1}, {"_step": 1, "a": 2}]
|
|
103
|
+
assert writer.reader().tail(2) == [
|
|
104
|
+
{"_step": 0, "a": 1, "b": 3},
|
|
105
|
+
{"_step": 2, "a": 4},
|
|
106
|
+
]
|
|
107
|
+
writer.close()
|
|
108
|
+
path.with_name("metrics.meta.json").unlink()
|
|
109
|
+
resumed = JsonlWriter(path, max_file_bytes=30)
|
|
110
|
+
assert resumed.lines == 4
|
|
111
|
+
assert resumed.max_step == 2
|
|
112
|
+
assert not resumed.sorted
|
|
113
|
+
resumed.close()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_partial_write_rollback_across_segments(tmp_path, monkeypatch):
|
|
117
|
+
writer = JsonlWriter(
|
|
118
|
+
tmp_path / "metrics.jsonl", max_file_bytes=30, max_buffer_seconds=None
|
|
119
|
+
)
|
|
120
|
+
write(writer, [{"_step": 0}])
|
|
121
|
+
original = writer._append
|
|
122
|
+
calls = 0
|
|
123
|
+
|
|
124
|
+
def fail(payload):
|
|
125
|
+
nonlocal calls
|
|
126
|
+
calls += 1
|
|
127
|
+
if calls == 2:
|
|
128
|
+
original(payload[:5])
|
|
129
|
+
raise OSError("disk unavailable")
|
|
130
|
+
original(payload)
|
|
131
|
+
|
|
132
|
+
monkeypatch.setattr(writer, "_append", fail)
|
|
133
|
+
write(writer, [{"_step": i} for i in range(1, 5)])
|
|
134
|
+
assert writer.reader().read_all() == [{"_step": 0}]
|
|
135
|
+
monkeypatch.setattr(writer, "_append", original)
|
|
136
|
+
writer.flush()
|
|
137
|
+
writer.close()
|
|
138
|
+
assert JsonlReader(writer.path).read_all() == [{"_step": i} for i in range(5)]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_live_offline_and_torn_tail(tmp_path):
|
|
142
|
+
store = HistoryStore().init(
|
|
143
|
+
"p",
|
|
144
|
+
"n",
|
|
145
|
+
dir=str(tmp_path),
|
|
146
|
+
max_file_bytes=100,
|
|
147
|
+
cache_rows=1,
|
|
148
|
+
cache_bytes=100,
|
|
149
|
+
max_buffer_seconds=None,
|
|
150
|
+
buffer_size=1,
|
|
151
|
+
)
|
|
152
|
+
for step in range(12):
|
|
153
|
+
store.log({"v": step}, step=step, commit=True)
|
|
154
|
+
store.flush()
|
|
155
|
+
assert [row["v"] for row in store.get(-1, output_type="records")] == list(range(12))
|
|
156
|
+
path = store.log_fp
|
|
157
|
+
store.finish()
|
|
158
|
+
assert [row["v"] for row in read_history(path, -1, output_type="records")] == list(
|
|
159
|
+
range(12)
|
|
160
|
+
)
|
|
161
|
+
with segments(path)[-1].open("ab") as source:
|
|
162
|
+
source.write(b'{"_step":')
|
|
163
|
+
resumed = JsonlWriter(path, max_file_bytes=100)
|
|
164
|
+
assert resumed.lines == 12
|
|
165
|
+
assert resumed.max_step == 11
|
|
166
|
+
resumed.close()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_spans_include_rotated_records(tmp_path):
|
|
170
|
+
path = tmp_path / "spans.jsonl"
|
|
171
|
+
writer = JsonlWriter(path, max_file_bytes=100)
|
|
172
|
+
records = [
|
|
173
|
+
{"_step": i, "name": f"span{i}", "start": i, "dur_ms": 1} for i in range(5)
|
|
174
|
+
]
|
|
175
|
+
write(writer, records)
|
|
176
|
+
writer.close()
|
|
177
|
+
assert [span["name"] for span in read_spans(path)] == [
|
|
178
|
+
record["name"] for record in records
|
|
179
|
+
]
|
|
180
|
+
assert [
|
|
181
|
+
event["name"]
|
|
182
|
+
for event in build_trace(tmp_path)["traceEvents"]
|
|
183
|
+
if event["ph"] == "X"
|
|
184
|
+
] == [record["name"] for record in records]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@pytest.mark.parametrize("rank", [0, 2])
|
|
188
|
+
def test_public_history_stream_rank_spans_and_resume(run, monkeypatch, rank):
|
|
189
|
+
monkeypatch.setenv("RANK", str(rank))
|
|
190
|
+
instance = run(
|
|
191
|
+
stream="data",
|
|
192
|
+
max_file_bytes=100,
|
|
193
|
+
cache_rows=1,
|
|
194
|
+
buffer_size=1,
|
|
195
|
+
max_buffer_seconds=None,
|
|
196
|
+
)
|
|
197
|
+
for step in range(8):
|
|
198
|
+
with et.span("work"):
|
|
199
|
+
pass
|
|
200
|
+
et.log({"v": step})
|
|
201
|
+
instance.history.flush()
|
|
202
|
+
assert instance.history.writer.max_file_bytes == 100
|
|
203
|
+
assert instance.history.span_writer.max_file_bytes == 100
|
|
204
|
+
path = instance.history.log_fp
|
|
205
|
+
span_path = instance.history.spans_fp
|
|
206
|
+
assert len(segments(path)) > 1
|
|
207
|
+
assert [row["v"] for row in et.history(-1, output_type="records")] == list(range(8))
|
|
208
|
+
et.finish()
|
|
209
|
+
assert len(read_spans(span_path)) == 8
|
|
210
|
+
assert [
|
|
211
|
+
row["v"]
|
|
212
|
+
for row in et.history(-1, run=path.parent, stream="data", output_type="records")
|
|
213
|
+
] == list(range(8))
|
|
214
|
+
resumed = run(stream="data", max_file_bytes=100, cache_rows=1, buffer_size=1)
|
|
215
|
+
assert resumed.history.current_step == 8
|
|
216
|
+
et.log({"v": 8})
|
|
217
|
+
assert [row["v"] for row in et.history(-1, output_type="records")] == list(range(9))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|