diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3152575 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,46 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: ["3.11", "3.12", "3.13"] + # Commits are published with a hard link, so run on each OS's + # native filesystem: ext4 on Linux, NTFS on Windows, APFS on macOS. + include: + - os: windows-latest + python-version: "3.13" + - os: macos-latest + python-version: "3.13" + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install + run: python -m pip install -e ".[dev]" + + - name: Run tests + run: python -m pytest -q -rs + + - name: Round-trip a table through the command line + shell: bash + run: | + python -m tinydelta create ./ci-orders id:int region:str year:int + python -m tinydelta append ./ci-orders --file examples/orders.jsonl + python -m tinydelta overwrite ./ci-orders --file examples/orders.jsonl + python -m tinydelta read ./ci-orders --version 1 + python -m tinydelta history ./ci-orders diff --git a/README.md b/README.md index eb74b1b..ae2ce78 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # tinydelta +[![tests](https://github.com/SpookyJumpyBeans/tinydelta/actions/workflows/tests.yml/badge.svg)](https://github.com/SpookyJumpyBeans/tinydelta/actions/workflows/tests.yml) + Single-node table format. A directory of data files plus a JSON commit log. Readers only see files that a commit published. This is the same shape as Delta Lake (log as source of truth, atomic commit, time travel), on one machine, with JSONL instead of Parquet. @@ -13,12 +15,13 @@ Sister of [tinyquery](https://github.com/SpookyJumpyBeans/tinyquery): that repo - `read` the latest snapshot, or a past version - `describe` schema, file count, and row count for a version - Optimistic concurrency: two writers that both read version *n* cannot both commit *n+1* +- Atomic visibility: a reader sees a commit completely or not at all, and a version never changes once visible - `vacuum` deletes data files the log never published (crash leftovers, lost writer files) - `restore` makes the latest snapshot match an older version, as a new commit ## What it does not do -No Parquet, no checkpoints, no MERGE, no cloud object store. There is no retry loop; a conflict is an error. `vacuum` does not delete files that were `remove`d by overwrite — those still belong to older versions. +No Parquet, no checkpoints, no MERGE, no cloud object store. There is no retry loop; a conflict is an error. Commits are published with a hard link, so the table must live on a filesystem that supports them (NTFS, ext4, APFS do; FAT32 does not). `vacuum` does not delete files that were `remove`d by overwrite — those still belong to older versions. ## Run @@ -43,12 +46,19 @@ pytest ```text rows - → write part-.jsonl (not readable yet) - → create _delta_log/.json with O_CREAT|O_EXCL - → fsync that file + → write part-.jsonl (not readable yet) + → write the commit to _delta_log/...tmp, fsync it + → link the temp file to _delta_log/.json (the publish point) + → fsync the log directory, delete the temp name ``` -The commit file is the publish point. `os.open(..., O_CREAT|O_EXCL)` fails if that version already exists, so the second writer loses instead of overwriting the first. Data files are written first so a crash during the log create does not publish a half-written JSONL. +A commit needs two properties at once. **Exclusivity**: if two writers race for the same version, exactly one wins. **Atomic visibility**: a reader that sees the version file sees all of it. + +An earlier version got only the first. It created `.json` with `O_CREAT|O_EXCL` and then wrote into it, so for a moment the version existed but was empty. A reader in that window reported it as the latest version, and once the writer finished, the same version number returned different data. The window lasted microseconds, so random stress testing never caught it: a test that freezes the writer mid-write does. + +Linking fixes both at once. `link()` is atomic and fails if the target exists, so the losing writer still gets `ConcurrentWriteError`, and the version name can only ever point at complete, durable contents. Temp names don't match the version pattern, so readers never see them, and `vacuum` removes any a crashed writer left behind. + +Data files are written before the commit, so a crash anywhere before the link publishes nothing. A reader reconstructs a version by replaying `add` / `remove` from 0 to *n*. A JSONL sitting in the directory with no `add` action is garbage, not a row. diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py new file mode 100644 index 0000000..473bc88 --- /dev/null +++ b/tests/test_concurrency.py @@ -0,0 +1,294 @@ +"""Concurrency, with real threads rather than a simulated stale read_version. + +The guarantees under test: + + * a commit is visible completely or not at all; + * two writers racing for one version produce exactly one winner; + * writers that retry after losing never lose or duplicate a row; + * a version, once visible, never changes. +""" + +from __future__ import annotations + +import os +import threading +from pathlib import Path + +import pytest + +from tinydelta.errors import ConcurrentWriteError +from tinydelta.log import list_versions, stale_temp_files +from tinydelta.schema import Schema +from tinydelta.table import DeltaTable + +SCHEMA = Schema.parse(["writer:int", "seq:int"]) + + +def new_table(path: Path) -> DeltaTable: + table = DeltaTable.create(path / "t", SCHEMA) + table.append([{"writer": -1, "seq": 0}]) # version 1 + return table + + +class _PausingFile: + """A file handle whose first write blocks until the test releases it.""" + + def __init__(self, handle, pause): + self._handle = handle + self._pause = pause + + def write(self, data): + self._pause() + return self._handle.write(data) + + def __enter__(self): + return self + + def __exit__(self, *exc): + return self._handle.__exit__(*exc) + + def __getattr__(self, name): + return getattr(self._handle, name) + + +def test_a_commit_is_not_visible_while_its_bytes_are_being_written(tmp_path, monkeypatch): + """Regression for the publish window, independent of how commits are written. + + The writer is frozen at the moment it starts writing the commit's bytes, + through either os.write or a file object. A reader at that moment must + still see the previous version. The original implementation created the + version file with O_EXCL and then wrote into it, so it fails this test: + the reader finds an empty version 2. + """ + import tinydelta.log as log + import tinydelta.table as table_module + + table = new_table(tmp_path) + in_commit = threading.Event() + writing = threading.Event() + release = threading.Event() + + def pause() -> None: + if in_commit.is_set() and not writing.is_set(): + writing.set() + assert release.wait(5), "test never released the writer" + + real_write_commit = table_module.write_commit + + def flagged_write_commit(*args, **kwargs): + in_commit.set() + return real_write_commit(*args, **kwargs) + + real_os_write = os.write + real_open = open + + def paused_os_write(fd, data): + pause() + return real_os_write(fd, data) + + def paused_open(*args, **kwargs): + return _PausingFile(real_open(*args, **kwargs), pause) + + monkeypatch.setattr(table_module, "write_commit", flagged_write_commit) + monkeypatch.setattr(os, "write", paused_os_write) + monkeypatch.setattr(log, "open", paused_open, raising=False) + + writer = threading.Thread(target=lambda: table.append([{"writer": 1, "seq": 1}])) + writer.start() + try: + assert writing.wait(5), "writer never started writing its commit" + reader = DeltaTable.open(table.path) + assert reader.describe()["version"] == 1 + assert len(reader.read()) == 1 + finally: + release.set() + writer.join(5) + + assert DeltaTable.open(table.path).describe()["version"] == 2 + + +def test_a_commit_is_invisible_until_it_is_complete(tmp_path, monkeypatch): + """Regression for the publish window. + + The old writer created the version file with O_EXCL and then wrote into + it, so a reader in between saw an empty commit as the latest version. Here + the writer is frozen after its bytes are on disk but before the version + name exists, which is the last moment it could leak. + """ + table = new_table(tmp_path) + linking = threading.Event() + release = threading.Event() + real_link = os.link + + def paused_link(src, dst, *args, **kwargs): + linking.set() + assert release.wait(5), "test never released the writer" + return real_link(src, dst, *args, **kwargs) + + monkeypatch.setattr(os, "link", paused_link) + writer = threading.Thread( + target=lambda: table.append([{"writer": 1, "seq": 1}]) + ) + writer.start() + try: + assert linking.wait(5), "writer never reached the publish step" + reader = DeltaTable.open(table.path) + assert reader.describe()["version"] == 1 + assert len(reader.read()) == 1 + finally: + release.set() + writer.join(5) + + after = DeltaTable.open(table.path) + assert after.describe()["version"] == 2 + assert len(after.read()) == 2 + + +def test_racing_writers_produce_exactly_one_winner(tmp_path): + table = new_table(tmp_path) + n = 8 + start = threading.Barrier(n) + outcomes: list[str] = [] + lock = threading.Lock() + + def write(i: int) -> None: + writer = DeltaTable.open(table.path) + start.wait() + try: + writer.append([{"writer": i, "seq": 0}], read_version=1) + result = "won" + except ConcurrentWriteError: + result = "lost" + with lock: + outcomes.append(result) + + threads = [threading.Thread(target=write, args=(i,)) for i in range(n)] + for t in threads: + t.start() + for t in threads: + t.join(10) + + assert outcomes.count("won") == 1 + assert outcomes.count("lost") == n - 1 + final = DeltaTable.open(table.path) + assert final.describe()["version"] == 2 + assert len(final.read()) == 2 # the base row plus the single winner's + + +def test_writers_that_retry_lose_and_duplicate_nothing(tmp_path): + table = new_table(tmp_path) + writers, per_writer = 4, 8 + start = threading.Barrier(writers) + errors: list[BaseException] = [] + + def write(w: int) -> None: + handle = DeltaTable.open(table.path) + start.wait() + try: + for seq in range(per_writer): + while True: + try: + handle.append([{"writer": w, "seq": seq}]) + break + except ConcurrentWriteError: + continue # someone else took that version; re-read and retry + except BaseException as exc: # surface anything unexpected to the main thread + errors.append(exc) + + threads = [threading.Thread(target=write, args=(w,)) for w in range(writers)] + for t in threads: + t.start() + for t in threads: + t.join(60) + + assert not errors, errors + rows = DeltaTable.open(table.path).read() + written = sorted((r["writer"], r["seq"]) for r in rows if r["writer"] >= 0) + expected = sorted((w, s) for w in range(writers) for s in range(per_writer)) + assert written == expected # every row exactly once + versions = list_versions(table.log_dir) + assert versions == list(range(len(versions))) # no gaps in the log + assert versions[-1] == 1 + writers * per_writer + + +def test_a_visible_version_never_changes(tmp_path): + """Hammer the log with large commits while a reader watches. + + The reader records what each version contained the first time it saw it. + Afterwards every version is read again and must match exactly. + + This is a stress check, not the regression guard for the publish window. + Commit payloads are small, so that window lasted microseconds and random + timing almost never hit it: this test passed 6 times out of 6 against the + original implementation. The deterministic test above is what catches it. + """ + table = new_table(tmp_path) + commits, rows_per_commit = 20, 500 + done = threading.Event() + seen: dict[int, list] = {} + failures: list[BaseException] = [] + + def write() -> None: + try: + for c in range(commits): + table.append( + [{"writer": 0, "seq": c * rows_per_commit + i} for i in range(rows_per_commit)] + ) + finally: + done.set() + + def read() -> None: + try: + while not done.is_set(): + reader = DeltaTable.open(table.path) + info = reader.describe() + rows = reader.read(version=info["version"]) + assert len(rows) == info["num_records"] + seen.setdefault(info["version"], rows) + except BaseException as exc: + failures.append(exc) + + w = threading.Thread(target=write) + r = threading.Thread(target=read) + r.start() + w.start() + w.join(60) + r.join(60) + + assert not failures, failures + assert len(seen) > 1, "reader never overlapped the writer; test proved nothing" + final = DeltaTable.open(table.path) + for version, rows in seen.items(): + assert final.read(version=version) == rows, f"version {version} changed" + + +def test_a_failed_publish_leaves_nothing_behind(tmp_path, monkeypatch): + table = new_table(tmp_path) + + def broken_link(src, dst, *args, **kwargs): + raise OSError("simulated failure while publishing") + + monkeypatch.setattr(os, "link", broken_link) + with pytest.raises(OSError): + table.append([{"writer": 1, "seq": 1}]) + monkeypatch.undo() + + assert list_versions(table.log_dir)[-1] == 1 # nothing published + assert stale_temp_files(table.log_dir) == [] # and nothing left lying around + table.append([{"writer": 1, "seq": 1}]) # version 2 is still free + assert DeltaTable.open(table.path).describe()["version"] == 2 + + +def test_temp_files_are_invisible_and_vacuum_removes_them(tmp_path): + """A crashed writer can leave a temp file. Readers must ignore it.""" + table = new_table(tmp_path) + debris = table.log_dir / f".{2:020d}.deadbeef.tmp" + debris.write_text('{"add":{"path":"ghost","size":1,"numRecords":1}}\n') + + assert list_versions(table.log_dir) == [0, 1] + assert DeltaTable.open(table.path).describe()["version"] == 1 + + deleted = table.vacuum(older_than_ms=0) + assert f"_delta_log/{debris.name}" in deleted + assert not debris.exists() + assert list_versions(table.log_dir) == [0, 1] diff --git a/tinydelta/log.py b/tinydelta/log.py index b0b45f5..9b4d2e3 100644 --- a/tinydelta/log.py +++ b/tinydelta/log.py @@ -4,6 +4,7 @@ import os from dataclasses import dataclass from pathlib import Path +from uuid import uuid4 from tinydelta.errors import ConcurrentWriteError, TinyDeltaError from tinydelta.schema import Schema @@ -50,28 +51,70 @@ def list_versions(log_directory: Path) -> list[int]: return sorted(versions) +TEMP_SUFFIX = ".tmp" + + def write_commit(log_directory: Path, version: int, actions: list[dict]) -> None: + """Publish a commit so it appears complete or not at all. + + Two properties have to hold together: + + * exclusivity: if two writers race for the same version, exactly one + wins and the other gets ConcurrentWriteError; + * atomic visibility: a reader that sees the version file sees all of it. + + Creating the version file directly with O_EXCL gets the first but not the + second. The file exists, empty, between creating it and writing to it, and + a reader listing the log in that window treats an empty commit as the + latest version. Once the writer finishes, the same version number then + returns different data, so a version is no longer immutable once visible. + + So the bytes go to a uniquely named temp file first, are flushed to disk, + and only then is the temp file hard-linked to the version name. link() is + atomic and fails if the target already exists, which keeps exclusivity, + and the version name can only ever appear pointing at complete contents. + Temp names do not match list_versions(), so readers never see them. + """ path = version_path(log_directory, version) payload = "".join(json.dumps(action, separators=(",", ":")) + "\n" for action in actions) - data = payload.encode("utf-8") - flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY - if hasattr(os, "O_BINARY"): - flags |= os.O_BINARY + temp = log_directory / f".{version:020d}.{uuid4().hex}{TEMP_SUFFIX}" + try: + # A buffered file object writes every byte; a bare os.write may not. + with open(temp, "xb") as handle: + handle.write(payload.encode("utf-8")) + handle.flush() + os.fsync(handle.fileno()) + try: + os.link(temp, path) + except FileExistsError as exc: + raise ConcurrentWriteError(version) from exc + _fsync_directory(log_directory) + finally: + temp.unlink(missing_ok=True) + + +def _fsync_directory(directory: Path) -> None: + """Make the new directory entry durable, where the platform allows it. + + On POSIX a crash after link() but before the directory is flushed can lose + the new name even though the file's bytes are on disk. Windows cannot open + a directory this way, and NTFS journals the entry anyway, so skip it there. + """ try: - fd = os.open(path, flags, 0o644) - except FileExistsError as exc: - raise ConcurrentWriteError(version) from exc + fd = os.open(directory, os.O_RDONLY) + except OSError: + return try: - os.write(fd, data) os.fsync(fd) - except Exception: + except OSError: + pass + finally: os.close(fd) - try: - path.unlink() - except OSError: - pass - raise - os.close(fd) + + +def stale_temp_files(log_directory: Path) -> list[Path]: + """Temp files a crashed writer left behind; never part of any version.""" + return sorted(log_directory.glob(f".*{TEMP_SUFFIX}")) def read_actions(log_directory: Path, version: int) -> list[dict]: diff --git a/tinydelta/table.py b/tinydelta/table.py index e615ab7..b37df96 100644 --- a/tinydelta/table.py +++ b/tinydelta/table.py @@ -14,6 +14,7 @@ load_history, load_snapshot, log_dir, + stale_temp_files, write_commit, ) from tinydelta.schema import Schema, coerce_row @@ -148,6 +149,15 @@ def vacuum(self, *, older_than_ms: int = 0) -> list[str]: continue path.unlink() deleted.append(path.name) + + # A writer that crashed mid-commit can leave a temp file in the log. + # It was never linked to a version name, so nothing can reference it. + for path in stale_temp_files(self.log_dir): + age_ms = now_ms - path.stat().st_mtime * 1000 + if age_ms < older_than_ms: + continue + path.unlink(missing_ok=True) + deleted.append(f"{self.log_dir.name}/{path.name}") return deleted def _base_snapshot(self, read_version: int | None) -> Snapshot: