dstrack 0.1.0__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.
- dstrack/__init__.py +8 -0
- dstrack/_benchmark/__init__.py +54 -0
- dstrack/_benchmark/_cli.py +114 -0
- dstrack/_benchmark/_environment.py +144 -0
- dstrack/_benchmark/_profiling.py +207 -0
- dstrack/_benchmark/_report.py +152 -0
- dstrack/_benchmark/_runner.py +196 -0
- dstrack/_benchmark/_synthetic.py +158 -0
- dstrack/_cli.py +105 -0
- dstrack/_log.py +268 -0
- dstrack/_log_render.py +282 -0
- dstrack/_logging.py +60 -0
- dstrack/_store_template.py +58 -0
- dstrack/_track.py +144 -0
- dstrack/cache.py +448 -0
- dstrack/console.py +47 -0
- dstrack/errors.py +22 -0
- dstrack/paths.py +88 -0
- dstrack/readers/__init__.py +69 -0
- dstrack/readers/_csv.py +361 -0
- dstrack/readers/_protocol.py +120 -0
- dstrack/readers/_registry.py +241 -0
- dstrack/readers/_resolve.py +140 -0
- dstrack/schemas/snapshot_v1.json +299 -0
- dstrack/snapshot/__init__.py +46 -0
- dstrack/snapshot/_builder.py +114 -0
- dstrack/snapshot/_metadata.py +154 -0
- dstrack/snapshot/_stats.py +487 -0
- dstrack/snapshot/_version.py +13 -0
- dstrack/store.py +765 -0
- dstrack/utils.py +6 -0
- dstrack-0.1.0.dist-info/METADATA +123 -0
- dstrack-0.1.0.dist-info/RECORD +36 -0
- dstrack-0.1.0.dist-info/WHEEL +4 -0
- dstrack-0.1.0.dist-info/entry_points.txt +4 -0
- dstrack-0.1.0.dist-info/licenses/LICENSE +21 -0
dstrack/store.py
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
"""Persistence of snapshots into the local store, and reads back out of it.
|
|
2
|
+
|
|
3
|
+
A snapshot is written under ``datasets/<dataset_id>/`` as its full JSON
|
|
4
|
+
payload, a one-line append to ``log.jsonl``, and an updated ``HEAD``. The
|
|
5
|
+
three are written in that order so a crash never leaves ``HEAD``
|
|
6
|
+
pointing at a snapshot that was not fully written. Re-tracking a source whose
|
|
7
|
+
recorded path matches an existing dataset's latest snapshot continues that
|
|
8
|
+
dataset's lineage rather than creating a new one.
|
|
9
|
+
|
|
10
|
+
The three writes are not a single atomic transaction, so two safeguards keep
|
|
11
|
+
readers consistent. A store-wide advisory lock serializes
|
|
12
|
+
the whole resolve-parent-then-write sequence, so concurrent writers cannot read
|
|
13
|
+
the same ``HEAD`` as their parent nor mint two datasets for one path. And
|
|
14
|
+
``HEAD`` -- written last -- is the single source of truth for what is committed:
|
|
15
|
+
recovery and path matching read the log entry that ``HEAD`` names rather than
|
|
16
|
+
trusting the final line, so a ``log.jsonl`` left one entry ahead by a crash
|
|
17
|
+
between the append and the ``HEAD`` write is ignored.
|
|
18
|
+
|
|
19
|
+
Reading history back is therefore *reachability-based*, not positional.
|
|
20
|
+
[read_log_entries][dstrack.store.read_log_entries] returns every line a dataset's log holds, including
|
|
21
|
+
entries no ``HEAD`` ever named; [walk_lineage][dstrack.store.walk_lineage] then follows
|
|
22
|
+
``parent_snapshot_id`` links back from ``HEAD`` to select the ones that are
|
|
23
|
+
actually committed. Neither "read every line" nor "read every line up to
|
|
24
|
+
``HEAD``" is correct: a crash between the append and the ``HEAD`` write leaves
|
|
25
|
+
an uncommitted entry behind, and the *next* successful write appends after it,
|
|
26
|
+
stranding it in the middle of the file rather than at the end. Only
|
|
27
|
+
reachability from ``HEAD`` tells the two apart.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import contextlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
import tempfile
|
|
35
|
+
import uuid
|
|
36
|
+
from collections.abc import Iterator, Mapping
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Final
|
|
40
|
+
|
|
41
|
+
from dstrack.errors import DatasetNotFoundError, StoreCorruptionError
|
|
42
|
+
|
|
43
|
+
# Lightweight identity fields copied from a snapshot into its `log.jsonl` line.
|
|
44
|
+
# A subset of ADR-0001's fields: only those the current builders produce.
|
|
45
|
+
_LOG_FIELDS: Final[tuple[str, ...]] = (
|
|
46
|
+
"snapshot_id",
|
|
47
|
+
"parent_snapshot_id",
|
|
48
|
+
"created_at",
|
|
49
|
+
"created_by",
|
|
50
|
+
"dataset_name",
|
|
51
|
+
"dataset_path",
|
|
52
|
+
"num_rows",
|
|
53
|
+
"num_columns",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_DATASETS_DIRNAME: Final = "datasets"
|
|
57
|
+
_LOG_FILENAME: Final = "log.jsonl"
|
|
58
|
+
_HEAD_FILENAME: Final = "HEAD"
|
|
59
|
+
_SNAPSHOTS_DIRNAME: Final = "snapshots"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class LogEntry:
|
|
64
|
+
"""One snapshot as recorded in a dataset's ``log.jsonl``.
|
|
65
|
+
|
|
66
|
+
The lightweight per-snapshot record, mirroring
|
|
67
|
+
log fields rather than the full
|
|
68
|
+
``snapshots/<snapshot_id>.json`` payload.
|
|
69
|
+
|
|
70
|
+
Every field except ``snapshot_id`` is optional. Log lines are written with
|
|
71
|
+
``payload.get(key)`` (see [write_snapshot][dstrack.store.write_snapshot]),
|
|
72
|
+
so a snapshot built without a field records it as JSON null, and a line
|
|
73
|
+
written by a different version of dstrack may omit it entirely.
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
snapshot_id: Identifier of the snapshot. Always present: an entry is
|
|
77
|
+
only ever reached via ``HEAD`` or a ``parent_snapshot_id`` link,
|
|
78
|
+
both of which name it.
|
|
79
|
+
parent_snapshot_id: Snapshot this one succeeds, or ``None`` for the
|
|
80
|
+
dataset's first snapshot.
|
|
81
|
+
created_at: ISO-8601 UTC timestamp, kept as the raw recorded string.
|
|
82
|
+
Deliberately not parsed here: an unparsable value is a display
|
|
83
|
+
concern, not a reason to fail a read.
|
|
84
|
+
created_by: Recorded author, or ``None`` if not recorded.
|
|
85
|
+
dataset_name: Human-readable name at snapshot time, or ``None``.
|
|
86
|
+
dataset_path: POSIX path relative to the path root in force at
|
|
87
|
+
snapshot time, or ``None``.
|
|
88
|
+
num_rows: Row count, or ``None`` if not recorded.
|
|
89
|
+
num_columns: Column count, or ``None`` if not recorded.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
snapshot_id: str
|
|
93
|
+
parent_snapshot_id: str | None
|
|
94
|
+
created_at: str | None
|
|
95
|
+
created_by: str | None
|
|
96
|
+
dataset_name: str | None
|
|
97
|
+
dataset_path: str | None
|
|
98
|
+
num_rows: int | None
|
|
99
|
+
num_columns: int | None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class DatasetSummary:
|
|
104
|
+
"""A dataset in the store, described by its latest committed snapshot.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
dataset_id: The dataset's directory name under ``datasets/``.
|
|
108
|
+
head: The log entry the dataset's ``HEAD`` names, or ``None`` if the
|
|
109
|
+
dataset has no committed snapshot yet.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
dataset_id: str
|
|
113
|
+
head: LogEntry | None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class SnapshotWriteResult:
|
|
118
|
+
"""Outcome of writing one snapshot into the store.
|
|
119
|
+
|
|
120
|
+
Attributes:
|
|
121
|
+
dataset_id: Dataset whose lineage the snapshot joined, whether it was
|
|
122
|
+
passed in, matched by path, or minted by this write.
|
|
123
|
+
snapshot_id: Identifier of the snapshot that was written, copied from
|
|
124
|
+
the snapshot payload.
|
|
125
|
+
snapshot_path: Path of the written ``snapshots/<snapshot_id>.json``.
|
|
126
|
+
parent_snapshot_id: Snapshot this one succeeds, i.e. the dataset's
|
|
127
|
+
``HEAD`` before this write, or ``None`` for a dataset's first
|
|
128
|
+
snapshot.
|
|
129
|
+
is_new_dataset: Whether this write minted a new ``dataset_id``, as
|
|
130
|
+
opposed to continuing an existing dataset's lineage.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
dataset_id: str
|
|
134
|
+
snapshot_id: str
|
|
135
|
+
snapshot_path: Path
|
|
136
|
+
parent_snapshot_id: str | None
|
|
137
|
+
is_new_dataset: bool
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def write_snapshot(
|
|
141
|
+
snapshot: dict[str, Any],
|
|
142
|
+
*,
|
|
143
|
+
store_root: Path,
|
|
144
|
+
dataset_id: str | None = None,
|
|
145
|
+
) -> SnapshotWriteResult:
|
|
146
|
+
"""Write ``snapshot`` into the store and update the dataset's history.
|
|
147
|
+
|
|
148
|
+
Resolves which dataset the snapshot belongs to, then writes the full
|
|
149
|
+
payload, appends the lightweight ``log.jsonl`` line, and moves ``HEAD``
|
|
150
|
+
onto the new snapshot.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
snapshot: A snapshot dict as produced by
|
|
154
|
+
[SnapshotBuilder][dstrack.snapshot._builder.SnapshotBuilder]. Must contain
|
|
155
|
+
``snapshot_id`` and ``dataset_path``; ``parent_snapshot_id`` is
|
|
156
|
+
filled in here and overwritten if already present.
|
|
157
|
+
store_root: Path to the ``.dstrack/`` directory, e.g. from
|
|
158
|
+
[resolve_store_root][dstrack.paths.resolve_store_root].
|
|
159
|
+
dataset_id: Continue this dataset's lineage explicitly. The dataset must
|
|
160
|
+
already exist. When ``None``, the dataset is matched by
|
|
161
|
+
``dataset_path`` against each existing dataset's latest snapshot; a
|
|
162
|
+
new ``dataset_id`` is minted if none matches.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
A [SnapshotWriteResult][dstrack.store.SnapshotWriteResult] describing
|
|
166
|
+
where the snapshot landed and which lineage it joined.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
KeyError: If ``snapshot`` lacks ``snapshot_id`` or ``dataset_path``.
|
|
170
|
+
ValueError: If ``snapshot_id`` or an explicit ``dataset_id`` resolves to
|
|
171
|
+
a path outside the store.
|
|
172
|
+
DatasetNotFoundError: If ``dataset_id`` is given but names no dataset in
|
|
173
|
+
the store.
|
|
174
|
+
StoreCorruptionError: If an existing dataset's ``log.jsonl`` ends in a
|
|
175
|
+
line that is not valid JSON.
|
|
176
|
+
OSError: If the store cannot be written to.
|
|
177
|
+
"""
|
|
178
|
+
datasets_dir = _datasets_dir(store_root)
|
|
179
|
+
dataset_path = snapshot["dataset_path"]
|
|
180
|
+
snapshot_id = snapshot["snapshot_id"]
|
|
181
|
+
|
|
182
|
+
# Lock the store to avoid cross-process contamination
|
|
183
|
+
with _store_lock(store_root):
|
|
184
|
+
if dataset_id is not None:
|
|
185
|
+
# An explicit id continues a lineage; it must name a dataset
|
|
186
|
+
_ensure_direct_child(
|
|
187
|
+
base=datasets_dir,
|
|
188
|
+
candidate=datasets_dir / dataset_id,
|
|
189
|
+
kind="dataset_id",
|
|
190
|
+
value=dataset_id,
|
|
191
|
+
)
|
|
192
|
+
_check_dataset_exists(datasets_dir, dataset_id)
|
|
193
|
+
is_new_dataset = False
|
|
194
|
+
else:
|
|
195
|
+
dataset_id = _match_dataset_by_path(datasets_dir, dataset_path)
|
|
196
|
+
is_new_dataset = dataset_id is None
|
|
197
|
+
if dataset_id is None:
|
|
198
|
+
dataset_id = str(uuid.uuid4())
|
|
199
|
+
|
|
200
|
+
dataset_dir = datasets_dir / dataset_id
|
|
201
|
+
snapshots_dir = dataset_dir / _SNAPSHOTS_DIRNAME
|
|
202
|
+
snapshot_path = snapshots_dir / f"{snapshot_id}.json"
|
|
203
|
+
# snapshot_id arrives in the payload from outside, so confirm the file
|
|
204
|
+
# lands inside the dataset's snapshots/ before creating any directory.
|
|
205
|
+
_ensure_direct_child(
|
|
206
|
+
base=snapshots_dir,
|
|
207
|
+
candidate=snapshot_path,
|
|
208
|
+
kind="snapshot_id",
|
|
209
|
+
value=snapshot_id,
|
|
210
|
+
)
|
|
211
|
+
snapshots_dir.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
|
|
213
|
+
# HEAD is the dataset's latest snapshot, so it is this snapshot's parent
|
|
214
|
+
parent_snapshot_id = _read_head(dataset_dir)
|
|
215
|
+
payload = {**snapshot, "parent_snapshot_id": parent_snapshot_id}
|
|
216
|
+
|
|
217
|
+
_atomic_write(snapshot_path, json.dumps(payload, indent=2) + "\n")
|
|
218
|
+
|
|
219
|
+
log_line = {key: payload.get(key) for key in _LOG_FIELDS}
|
|
220
|
+
with (dataset_dir / _LOG_FILENAME).open("a", encoding="utf-8") as fh:
|
|
221
|
+
fh.write(json.dumps(log_line) + "\n")
|
|
222
|
+
|
|
223
|
+
_atomic_write(dataset_dir / _HEAD_FILENAME, snapshot_id + "\n")
|
|
224
|
+
|
|
225
|
+
return SnapshotWriteResult(
|
|
226
|
+
dataset_id=dataset_id,
|
|
227
|
+
snapshot_id=snapshot_id,
|
|
228
|
+
snapshot_path=snapshot_path,
|
|
229
|
+
parent_snapshot_id=parent_snapshot_id,
|
|
230
|
+
is_new_dataset=is_new_dataset,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def list_dataset_ids(*, store_root: Path) -> list[str]:
|
|
235
|
+
"""Return the id of every dataset in the store, sorted.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
store_root: Path to the ``.dstrack/`` directory, e.g. from
|
|
239
|
+
[resolve_store_root][dstrack.paths.resolve_store_root].
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Each dataset's id, i.e. its directory name under ``datasets/``. Empty
|
|
243
|
+
if the store holds no datasets yet.
|
|
244
|
+
"""
|
|
245
|
+
return [d.name for d in _iter_dataset_dirs(_datasets_dir(store_root))]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def dataset_exists(dataset_id: str, *, store_root: Path) -> bool:
|
|
249
|
+
"""Return whether the store holds a dataset with this id.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
dataset_id: The dataset id, which may come from the user.
|
|
253
|
+
store_root: Path to the ``.dstrack/`` directory.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
``True`` if the dataset has a directory in the store, whether or not
|
|
257
|
+
it has been snapshotted yet.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
ValueError: If ``dataset_id`` resolves to a path outside the store.
|
|
261
|
+
"""
|
|
262
|
+
return _resolve_dataset_dir(dataset_id, store_root).is_dir()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def read_head(dataset_id: str, *, store_root: Path) -> str | None:
|
|
266
|
+
"""Return the id of the snapshot a dataset's ``HEAD`` names.
|
|
267
|
+
|
|
268
|
+
``HEAD`` is the single source of truth for what a dataset has committed,
|
|
269
|
+
so callers that derive history from it should read it *before* reading
|
|
270
|
+
``log.jsonl``: a snapshot's log line is appended before the ``HEAD`` write
|
|
271
|
+
that names it, so any ``HEAD`` observed at a given moment is guaranteed to
|
|
272
|
+
have its whole ancestry already on disk. Reading them the other way round
|
|
273
|
+
can observe a ``HEAD`` naming a snapshot whose line was appended after the
|
|
274
|
+
log was read, which looks indistinguishable from corruption.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
dataset_id: Dataset whose ``HEAD`` to read.
|
|
278
|
+
store_root: Path to the ``.dstrack/`` directory.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
The latest committed snapshot's id, or ``None`` if the dataset does
|
|
282
|
+
not exist or has never been snapshotted.
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
ValueError: If ``dataset_id`` resolves to a path outside the store.
|
|
286
|
+
"""
|
|
287
|
+
return _read_head(_resolve_dataset_dir(dataset_id, store_root))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def dataset_log_path(dataset_id: str, *, store_root: Path) -> Path:
|
|
291
|
+
"""Return the path of a dataset's ``log.jsonl``. Need not exist.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
dataset_id: Dataset whose log to locate.
|
|
295
|
+
store_root: Path to the ``.dstrack/`` directory.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
The path of the dataset's append-only log.
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
ValueError: If ``dataset_id`` resolves to a path outside the store.
|
|
302
|
+
"""
|
|
303
|
+
return _resolve_dataset_dir(dataset_id, store_root) / _LOG_FILENAME
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def read_log_entries(dataset_id: str, *, store_root: Path) -> list[LogEntry]:
|
|
307
|
+
"""Return every entry in a dataset's ``log.jsonl``, in file order.
|
|
308
|
+
|
|
309
|
+
Includes entries that no ``HEAD`` ever named, so the result is what the
|
|
310
|
+
log *says*, not what the dataset has *committed*. Pass the result through
|
|
311
|
+
[walk_lineage][dstrack.store.walk_lineage] to select the committed
|
|
312
|
+
lineage.
|
|
313
|
+
|
|
314
|
+
A trailing line that is not valid JSON and is not newline-terminated is
|
|
315
|
+
treated as an interrupted append and dropped, not reported as corruption:
|
|
316
|
+
the log is appended to before ``HEAD`` moves, so a torn final line is a
|
|
317
|
+
snapshot that was never committed and is expected after a crash. A
|
|
318
|
+
malformed line anywhere else was written completely and is corruption.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
dataset_id: Dataset whose log to read.
|
|
322
|
+
store_root: Path to the ``.dstrack/`` directory.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
One entry per log line, oldest first. Empty if the dataset does not
|
|
326
|
+
exist or has no log yet.
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
ValueError: If ``dataset_id`` resolves to a path outside the store.
|
|
330
|
+
StoreCorruptionError: If a complete log line is not a valid JSON
|
|
331
|
+
object, or records a ``snapshot_id`` or ``parent_snapshot_id``
|
|
332
|
+
that is not a string.
|
|
333
|
+
OSError: If the log cannot be read.
|
|
334
|
+
"""
|
|
335
|
+
path = dataset_log_path(dataset_id, store_root=store_root)
|
|
336
|
+
if not path.is_file():
|
|
337
|
+
return []
|
|
338
|
+
|
|
339
|
+
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
340
|
+
entries: list[LogEntry] = []
|
|
341
|
+
for index, line in enumerate(lines):
|
|
342
|
+
if not line.strip():
|
|
343
|
+
continue
|
|
344
|
+
try:
|
|
345
|
+
data = json.loads(line)
|
|
346
|
+
except json.JSONDecodeError as e:
|
|
347
|
+
is_torn_append = index == len(lines) - 1 and not line.endswith("\n")
|
|
348
|
+
if is_torn_append:
|
|
349
|
+
break
|
|
350
|
+
raise StoreCorruptionError(
|
|
351
|
+
f"An entry of `{path}` is not valid JSON. The file may have "
|
|
352
|
+
"been truncated by an interrupted write; restore it from git "
|
|
353
|
+
f"or delete the offending line. Error found in line loc {index}."
|
|
354
|
+
) from e
|
|
355
|
+
if not isinstance(data, dict):
|
|
356
|
+
raise StoreCorruptionError(
|
|
357
|
+
f"An entry of `{path}` is not a JSON object. Restore the file "
|
|
358
|
+
"from git or delete the offending line."
|
|
359
|
+
)
|
|
360
|
+
entries.append(_log_entry_from_line(data, path=path))
|
|
361
|
+
return entries
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def walk_lineage(index: Mapping[str, LogEntry], head: str) -> list[LogEntry]:
|
|
365
|
+
"""Select the snapshots reachable from ``head``, newest first.
|
|
366
|
+
|
|
367
|
+
Follows ``parent_snapshot_id`` links back from ``head``, which is what
|
|
368
|
+
makes a history *committed* rather than merely *recorded*. Entries the
|
|
369
|
+
walk does not reach are ignored: a crash between a log append and the
|
|
370
|
+
``HEAD`` write (see [write_snapshot][dstrack.store.write_snapshot]) leaves
|
|
371
|
+
an entry the store never committed, and a later successful write appends
|
|
372
|
+
after it, stranding it mid-file rather than at the end. Reachability is
|
|
373
|
+
the only thing that tells such an entry apart from a real snapshot.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
index: Every entry the dataset's log holds, keyed by ``snapshot_id``,
|
|
377
|
+
e.g. built from [read_log_entries][dstrack.store.read_log_entries].
|
|
378
|
+
head: Id of the snapshot to walk back from, i.e. the dataset's
|
|
379
|
+
``HEAD``.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
The lineage ``head`` names, ordered newest first and ending at the
|
|
383
|
+
snapshot with no parent.
|
|
384
|
+
|
|
385
|
+
Raises:
|
|
386
|
+
StoreCorruptionError: If the lineage names a snapshot ``index`` has no
|
|
387
|
+
entry for, or if the ``parent_snapshot_id`` links form a cycle.
|
|
388
|
+
"""
|
|
389
|
+
lineage: list[LogEntry] = []
|
|
390
|
+
seen: set[str] = set()
|
|
391
|
+
current: str | None = head
|
|
392
|
+
child: str | None = None
|
|
393
|
+
|
|
394
|
+
while current is not None:
|
|
395
|
+
if current in seen:
|
|
396
|
+
raise StoreCorruptionError(
|
|
397
|
+
f"The history of snapshot {head!r} loops back on itself at "
|
|
398
|
+
f"{current!r}. A snapshot cannot be its own ancestor; the log "
|
|
399
|
+
"has been edited into a cycle. Restore it from git."
|
|
400
|
+
)
|
|
401
|
+
seen.add(current)
|
|
402
|
+
|
|
403
|
+
entry = index.get(current)
|
|
404
|
+
if entry is None:
|
|
405
|
+
missing = (
|
|
406
|
+
f"The log has no entry for snapshot {current!r}"
|
|
407
|
+
if child is None
|
|
408
|
+
else f"The log has no entry for snapshot {current!r}, named as "
|
|
409
|
+
f"the parent of {child!r}"
|
|
410
|
+
)
|
|
411
|
+
raise StoreCorruptionError(
|
|
412
|
+
f"{missing}. Part of the dataset's lineage is missing; restore "
|
|
413
|
+
"the log from git."
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
lineage.append(entry)
|
|
417
|
+
child = current
|
|
418
|
+
current = entry.parent_snapshot_id
|
|
419
|
+
|
|
420
|
+
return lineage
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _log_entry_from_line(line: dict[str, Any], *, path: Path) -> LogEntry:
|
|
424
|
+
"""Build a [LogEntry][dstrack.store.LogEntry] from a parsed log line.
|
|
425
|
+
|
|
426
|
+
Reads each field by name and ignores any it does not know, rather than
|
|
427
|
+
unpacking the line. A store written by a newer dstrack would make ``LogEntry(**line)`` raise, and
|
|
428
|
+
``.dstrack/`` is committed to git, so such a store legitimately reaches an
|
|
429
|
+
older client by way of a clone or a pull.
|
|
430
|
+
|
|
431
|
+
Fields are validated by the job they do. ``snapshot_id`` and
|
|
432
|
+
``parent_snapshot_id`` are structural: they drive
|
|
433
|
+
[walk_lineage][dstrack.store.walk_lineage], so a wrong type there is
|
|
434
|
+
corruption. The rest are cosmetic, and a wrong type is coerced to ``None``
|
|
435
|
+
for the caller to render as missing: the store's own corruption messages
|
|
436
|
+
tell users to hand-edit the log, so hand-edited damage is expected, and a
|
|
437
|
+
display command must not die on a field it only prints.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
line: A parsed ``log.jsonl`` line.
|
|
441
|
+
path: The log the line came from, used in error messages.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
The line as a [LogEntry][dstrack.store.LogEntry].
|
|
445
|
+
|
|
446
|
+
Raises:
|
|
447
|
+
StoreCorruptionError: If ``snapshot_id`` is missing, empty, or not a
|
|
448
|
+
string, or if ``parent_snapshot_id`` is present but not a string.
|
|
449
|
+
"""
|
|
450
|
+
snapshot_id = line.get("snapshot_id")
|
|
451
|
+
if not isinstance(snapshot_id, str) or not snapshot_id:
|
|
452
|
+
raise StoreCorruptionError(
|
|
453
|
+
f"An entry of `{path}` has no usable `snapshot_id`. Every entry "
|
|
454
|
+
"must identify its snapshot; restore the file from git or delete "
|
|
455
|
+
"the offending line."
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
parent_snapshot_id = line.get("parent_snapshot_id")
|
|
459
|
+
if parent_snapshot_id is not None and not isinstance(parent_snapshot_id, str):
|
|
460
|
+
raise StoreCorruptionError(
|
|
461
|
+
f"Entry {snapshot_id!r} of `{path}` records a "
|
|
462
|
+
"`parent_snapshot_id` that is not a snapshot id. Restore the file "
|
|
463
|
+
"from git or delete the offending line."
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
return LogEntry(
|
|
467
|
+
snapshot_id=snapshot_id,
|
|
468
|
+
parent_snapshot_id=parent_snapshot_id,
|
|
469
|
+
created_at=_optional_str(line.get("created_at")),
|
|
470
|
+
created_by=_optional_str(line.get("created_by")),
|
|
471
|
+
dataset_name=_optional_str(line.get("dataset_name")),
|
|
472
|
+
dataset_path=_optional_str(line.get("dataset_path")),
|
|
473
|
+
num_rows=_optional_int(line.get("num_rows")),
|
|
474
|
+
num_columns=_optional_int(line.get("num_columns")),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _optional_str(value: Any) -> str | None:
|
|
479
|
+
"""Return `value` if it is a string, else ``None``."""
|
|
480
|
+
return value if isinstance(value, str) else None
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _optional_int(value: Any) -> int | None:
|
|
484
|
+
"""Return `value` if it is an integer, else ``None``.
|
|
485
|
+
|
|
486
|
+
``bool`` is a subclass of ``int``, so it is rejected explicitly: a count
|
|
487
|
+
recorded as ``true`` is damage, not the number one.
|
|
488
|
+
"""
|
|
489
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _datasets_dir(store_root: Path) -> Path:
|
|
493
|
+
"""Return the store's ``datasets/`` directory. Need not exist.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
store_root: Path to the ``.dstrack/`` directory.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
The directory every dataset lives directly inside.
|
|
500
|
+
"""
|
|
501
|
+
return store_root / _DATASETS_DIRNAME
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _iter_dataset_dirs(datasets_dir: Path) -> Iterator[Path]:
|
|
505
|
+
"""Yield each dataset's directory, in a stable order.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
datasets_dir: The store's ``datasets/`` directory. Need not exist.
|
|
509
|
+
|
|
510
|
+
Yields:
|
|
511
|
+
Every directory directly inside ``datasets_dir``, sorted by name.
|
|
512
|
+
Non-directory entries are skipped, so a stray file in the store does
|
|
513
|
+
not read as a dataset.
|
|
514
|
+
"""
|
|
515
|
+
if not datasets_dir.is_dir():
|
|
516
|
+
return
|
|
517
|
+
for dataset_dir in sorted(datasets_dir.iterdir()):
|
|
518
|
+
if dataset_dir.is_dir():
|
|
519
|
+
yield dataset_dir
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _resolve_dataset_dir(dataset_id: str, store_root: Path) -> Path:
|
|
523
|
+
"""Return a dataset's directory, rejecting an id that escapes the store.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
dataset_id: The dataset id, which may come from the user.
|
|
527
|
+
store_root: Path to the ``.dstrack/`` directory.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
The ``datasets/<dataset_id>/`` directory. Need not exist.
|
|
531
|
+
|
|
532
|
+
Raises:
|
|
533
|
+
ValueError: If ``dataset_id`` does not name a direct child of
|
|
534
|
+
``datasets/``.
|
|
535
|
+
"""
|
|
536
|
+
datasets_dir = _datasets_dir(store_root)
|
|
537
|
+
dataset_dir = datasets_dir / dataset_id
|
|
538
|
+
_ensure_direct_child(
|
|
539
|
+
base=datasets_dir,
|
|
540
|
+
candidate=dataset_dir,
|
|
541
|
+
kind="dataset_id",
|
|
542
|
+
value=dataset_id,
|
|
543
|
+
)
|
|
544
|
+
return dataset_dir
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _ensure_direct_child(base: Path, candidate: Path, kind: str, value: str) -> None:
|
|
548
|
+
"""Raise unless ``candidate`` resolves to a direct child of ``base``.
|
|
549
|
+
|
|
550
|
+
``dataset_id`` and ``snapshot_id`` are joined onto the store to form a single
|
|
551
|
+
directory or file name directly under ``base``. A value holding a ``..``
|
|
552
|
+
segment, a path separator, or an absolute path can make ``candidate`` land
|
|
553
|
+
outside ``base`` (and clobber files elsewhere) or nest under a subdirectory
|
|
554
|
+
that does not exist. Requiring the resolved parent to equal ``base`` rejects
|
|
555
|
+
both, regardless of how the escape is spelled, before any file is written.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
base: Directory the candidate must sit directly under, e.g. the store's
|
|
559
|
+
``datasets/`` directory.
|
|
560
|
+
candidate: The joined path derived from ``value``.
|
|
561
|
+
kind: Name of the identifier, used in the error message.
|
|
562
|
+
value: The identifier value, used in the error message.
|
|
563
|
+
|
|
564
|
+
Raises:
|
|
565
|
+
ValueError: If ``candidate`` does not resolve to a direct child of
|
|
566
|
+
``base``.
|
|
567
|
+
"""
|
|
568
|
+
if candidate.resolve().parent != base.resolve():
|
|
569
|
+
raise ValueError(
|
|
570
|
+
f"Invalid {kind} {value!r}: it must name a single entry directly "
|
|
571
|
+
"inside the store, not a nested or outside path."
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _check_dataset_exists(datasets_dir: Path, dataset_id: str) -> None:
|
|
576
|
+
"""Raise unless ``dataset_id`` names a dataset already in the store.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
datasets_dir: The store's ``datasets/`` directory. Need not exist.
|
|
580
|
+
dataset_id: The dataset id the caller asked to continue.
|
|
581
|
+
|
|
582
|
+
Raises:
|
|
583
|
+
DatasetNotFoundError: If the store holds no such dataset.
|
|
584
|
+
"""
|
|
585
|
+
if (datasets_dir / dataset_id).is_dir():
|
|
586
|
+
return
|
|
587
|
+
known: list[str] = []
|
|
588
|
+
if datasets_dir.is_dir():
|
|
589
|
+
known = sorted(d.name for d in datasets_dir.iterdir() if d.is_dir())
|
|
590
|
+
listing = "\n".join(f" {d}" for d in known) or " (none)"
|
|
591
|
+
raise DatasetNotFoundError(
|
|
592
|
+
f"No dataset {dataset_id!r} in the store. Omit --dataset-id to match "
|
|
593
|
+
f"the dataset by path, or start a new lineage.\nKnown datasets:\n{listing}"
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _match_dataset_by_path(datasets_dir: Path, dataset_path: str) -> str | None:
|
|
598
|
+
"""Return the id of the dataset whose latest snapshot has this path.
|
|
599
|
+
|
|
600
|
+
Compares ``dataset_path`` against the last ``log.jsonl`` entry of every
|
|
601
|
+
existing dataset, the cheap lightweight record rather than the full
|
|
602
|
+
snapshot JSON.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
datasets_dir: The store's ``datasets/`` directory. Need not exist.
|
|
606
|
+
dataset_path: Relative POSIX path recorded for the snapshot being
|
|
607
|
+
written, as computed against the path root.
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
The ``dataset_id`` of the first dataset whose latest snapshot recorded
|
|
611
|
+
the same path, or ``None`` if the store holds no dataset that matches.
|
|
612
|
+
|
|
613
|
+
Raises:
|
|
614
|
+
StoreCorruptionError: If a dataset's ``log.jsonl`` ends in a line that
|
|
615
|
+
is not valid JSON.
|
|
616
|
+
"""
|
|
617
|
+
for dataset_dir in _iter_dataset_dirs(datasets_dir):
|
|
618
|
+
entry = _read_committed_log_entry(dataset_dir)
|
|
619
|
+
if entry is not None and entry.get("dataset_path") == dataset_path:
|
|
620
|
+
return dataset_dir.name
|
|
621
|
+
return None
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _read_committed_log_entry(dataset_dir: Path) -> dict[str, Any] | None:
|
|
625
|
+
"""Return the ``log.jsonl`` entry for the snapshot ``HEAD`` names.
|
|
626
|
+
|
|
627
|
+
``HEAD`` is written last, so it is the single source of truth for what is
|
|
628
|
+
committed. The final log line is not: a crash between the ``log.jsonl``
|
|
629
|
+
append and the ``HEAD`` write can leave the log one entry ahead of ``HEAD``.
|
|
630
|
+
So the committed entry is the one whose ``snapshot_id`` equals ``HEAD``,
|
|
631
|
+
which is guaranteed to be present because its append precedes the ``HEAD``
|
|
632
|
+
write that names it.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
dataset_dir: A ``datasets/<dataset_id>/`` directory.
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
The parsed log entry the dataset's ``HEAD`` points at, or ``None`` if
|
|
639
|
+
the dataset has no ``HEAD`` or no ``log.jsonl`` yet.
|
|
640
|
+
|
|
641
|
+
Raises:
|
|
642
|
+
StoreCorruptionError: If a log line is not valid JSON, or no entry
|
|
643
|
+
matches ``HEAD`` (the log lost the committed snapshot's line).
|
|
644
|
+
"""
|
|
645
|
+
head = _read_head(dataset_dir)
|
|
646
|
+
if head is None:
|
|
647
|
+
return None
|
|
648
|
+
log_path = dataset_dir / _LOG_FILENAME
|
|
649
|
+
if not log_path.is_file():
|
|
650
|
+
return None
|
|
651
|
+
with log_path.open(encoding="utf-8") as fh:
|
|
652
|
+
for line in fh:
|
|
653
|
+
if not line.strip():
|
|
654
|
+
continue
|
|
655
|
+
try:
|
|
656
|
+
entry: dict[str, Any] = json.loads(line)
|
|
657
|
+
except json.JSONDecodeError as e:
|
|
658
|
+
raise StoreCorruptionError(
|
|
659
|
+
f"An entry of `{log_path}` is not valid JSON. The file may "
|
|
660
|
+
"have been truncated by an interrupted write; restore it "
|
|
661
|
+
"from git or delete the offending line."
|
|
662
|
+
) from e
|
|
663
|
+
if entry.get("snapshot_id") == head:
|
|
664
|
+
return entry
|
|
665
|
+
raise StoreCorruptionError(
|
|
666
|
+
f"`{log_path}` has no entry for HEAD {head!r}. The log is missing the "
|
|
667
|
+
"committed snapshot's line; restore it from git."
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _read_head(dataset_dir: Path) -> str | None:
|
|
672
|
+
"""Return the snapshot_id recorded in a dataset's ``HEAD``.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
dataset_dir: A ``datasets/<dataset_id>/`` directory.
|
|
676
|
+
|
|
677
|
+
Returns:
|
|
678
|
+
The latest snapshot's id, or ``None`` if the dataset has no ``HEAD``
|
|
679
|
+
yet, i.e. it has never been snapshotted.
|
|
680
|
+
"""
|
|
681
|
+
head_path = dataset_dir / _HEAD_FILENAME
|
|
682
|
+
if not head_path.is_file():
|
|
683
|
+
return None
|
|
684
|
+
head = head_path.read_text(encoding="utf-8").strip()
|
|
685
|
+
return head or None
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
689
|
+
"""Write ``text`` to ``path`` atomically via a temp file and ``os.replace``.
|
|
690
|
+
|
|
691
|
+
The temp file is created in the destination directory so the final rename
|
|
692
|
+
stays on the same filesystem and is therefore atomic. Readers only ever see
|
|
693
|
+
the old content or the new one, never a partial write.
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
path: File to create or replace. Its parent directory must exist.
|
|
697
|
+
text: Full content to write, encoded as UTF-8.
|
|
698
|
+
|
|
699
|
+
Raises:
|
|
700
|
+
OSError: If the temp file cannot be written or renamed into place. The
|
|
701
|
+
temp file is removed and ``path`` is left untouched.
|
|
702
|
+
"""
|
|
703
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
704
|
+
dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
|
|
705
|
+
)
|
|
706
|
+
try:
|
|
707
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
708
|
+
fh.write(text)
|
|
709
|
+
os.replace(tmp_name, path)
|
|
710
|
+
except BaseException:
|
|
711
|
+
Path(tmp_name).unlink(missing_ok=True)
|
|
712
|
+
raise
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
@contextlib.contextmanager
|
|
716
|
+
def _store_lock(store_root: Path) -> Iterator[None]:
|
|
717
|
+
"""Hold an exclusive, cross-process lock on the store for the block's body.
|
|
718
|
+
|
|
719
|
+
Serializes writers across processes so the resolve-parent-then-write
|
|
720
|
+
sequence in [write_snapshot][dstrack.store.write_snapshot] commits as a
|
|
721
|
+
unit: no two writers observe the same ``HEAD`` as a parent, and no two
|
|
722
|
+
path-matched writers mint separate datasets for one path. The lock is a
|
|
723
|
+
single ``.lock`` file at the store root, held for every dataset rather than
|
|
724
|
+
per-dataset, which also covers the cross-dataset scan that path matching
|
|
725
|
+
performs. It is an OS advisory lock, so it is released automatically if the
|
|
726
|
+
holding process dies, leaving no stale lock to clear by hand.
|
|
727
|
+
|
|
728
|
+
Args:
|
|
729
|
+
store_root: Path to the ``.dstrack/`` directory. Created if absent so
|
|
730
|
+
the lock file has somewhere to live.
|
|
731
|
+
|
|
732
|
+
Yields:
|
|
733
|
+
Nothing; the caller runs its critical section inside the ``with``.
|
|
734
|
+
"""
|
|
735
|
+
store_root.mkdir(parents=True, exist_ok=True)
|
|
736
|
+
# Open without truncating: the file is a lock handle, its contents unused.
|
|
737
|
+
fh = (store_root / ".lock").open("a", encoding="utf-8")
|
|
738
|
+
try:
|
|
739
|
+
_acquire(fh)
|
|
740
|
+
try:
|
|
741
|
+
yield
|
|
742
|
+
finally:
|
|
743
|
+
_release(fh)
|
|
744
|
+
finally:
|
|
745
|
+
fh.close()
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
if sys.platform == "win32": # pragma: no cover - platform specific
|
|
749
|
+
import msvcrt
|
|
750
|
+
|
|
751
|
+
def _acquire(fh: Any) -> None:
|
|
752
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1)
|
|
753
|
+
|
|
754
|
+
def _release(fh: Any) -> None:
|
|
755
|
+
fh.seek(0)
|
|
756
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
|
|
757
|
+
|
|
758
|
+
else:
|
|
759
|
+
import fcntl
|
|
760
|
+
|
|
761
|
+
def _acquire(fh: Any) -> None:
|
|
762
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
|
763
|
+
|
|
764
|
+
def _release(fh: Any) -> None:
|
|
765
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|