shackleton 1.2.0__tar.gz → 2.1.0__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.
@@ -0,0 +1,27 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ gate:
10
+ # compatibility_test.yml here also requires the OS matrix to pass
11
+ uses: ./.github/workflows/test.yml
12
+ release:
13
+ needs: gate
14
+ runs-on: ubuntu-latest
15
+ permissions:
16
+ contents: write
17
+ steps:
18
+ - uses: actions/checkout@v7
19
+ with:
20
+ fetch-depth: 0
21
+ - uses: astral-sh/setup-uv@v10.3.0
22
+ - run: uv build && uv publish
23
+ env:
24
+ UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
25
+ - run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --notes "$(git tag -l --format='%(contents)' "$GITHUB_REF_NAME")"
26
+ env:
27
+ GH_TOKEN: ${{ github.token }}
@@ -7,13 +7,14 @@ on:
7
7
  push:
8
8
  branches:
9
9
  - main
10
+ workflow_call:
10
11
 
11
12
  jobs:
12
13
  test:
13
14
  runs-on: ubuntu-latest
14
15
  steps:
15
- - uses: actions/checkout@v4
16
- - uses: astral-sh/setup-uv@v5
16
+ - uses: actions/checkout@v7
17
+ - uses: astral-sh/setup-uv@v10.3.0
17
18
  with:
18
19
  python-version: '3.12'
19
20
  enable-cache: true
@@ -68,19 +68,12 @@ instance/
68
68
  # Scrapy stuff:
69
69
  .scrapy
70
70
 
71
- # Sphinx documentation
72
- docs/_build/
71
+ # Generated coverage HTML (report.md is committed, HTML is not)
72
+ coverage/html/
73
73
 
74
74
  # PyBuilder
75
75
  target/
76
76
 
77
- # Jupyter Notebook
78
- .ipynb_checkpoints
79
-
80
- # IPython
81
- profile_default/
82
- ipython_config.py
83
-
84
77
  # pyenv
85
78
  .python-version
86
79
 
@@ -113,9 +106,6 @@ venv.bak/
113
106
  # Rope project settings
114
107
  .ropeproject
115
108
 
116
- # mkdocs documentation
117
- /site
118
-
119
109
  # mypy
120
110
  .mypy_cache/
121
111
  .dmypy.json
@@ -0,0 +1,41 @@
1
+ # Performance
2
+
3
+ ## Running Benchmarks
4
+
5
+ ```bash
6
+ uv run bench.py
7
+ uv run bench.py --rows <n>
8
+ ```
9
+
10
+ Rows default to `DEFAULT_ROWS` in `bench.py`; columns are an int id, a float and two strings.
11
+
12
+ ## Metrics
13
+
14
+ All measured in rows/second.
15
+
16
+ | Operation | Baseline (`DEFAULT_ROWS`, polars 2.0) |
17
+ | --- | --: |
18
+ | Single-file extend | ~50M rows/s |
19
+ | Extend into stored rows (id_col merge) | ~17M rows/s |
20
+ | Partitioned write (`N_CATEGORIES` partitions) | ~9.4M rows/s |
21
+ | Hash-partitioned write (`N_BUCKETS` buckets) | ~5.5M rows/s |
22
+ | Extend with `max_records=MAX_RECORDS` | ~11M rows/s |
23
+ | IPC extend | ~30M rows/s |
24
+ | Full read (collect) | ~127M rows/s |
25
+ | Lazy read + filter | ~158M rows/s |
26
+ | Single partition read | ~19M rows/s |
27
+ | IPC full read | ~122M rows/s |
28
+ | Replace records (`N_UPSERTS` upserts) | ~248k rows/s |
29
+
30
+ ## Known Bottlenecks
31
+
32
+ - `extend` into a stored file and `replace_records` read-merge-rewrite the partition, so their cost scales with the stored partition, not the batch: an append-heavy store partitions so that each partition stays small or is written once
33
+ - With `max_records` unset, a merge with stored data streams through polars' sink; with `max_records > 0`, `replace_records` and `compact` collect the whole partition in memory
34
+ - Hash-partitioned writes scale sub-linearly with bucket count due to per-partition file overhead
35
+ - Partition reads are limited by parquet file open latency when partitions are small
36
+
37
+ ## What to Watch
38
+
39
+ - Polars version upgrades can significantly affect parquet I/O throughput
40
+ - Row group size interacts with partition count — too many small files hurts read performance
41
+ - The GIL-releasing path through polars I/O makes thread-pool scaling important for parallel reads
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: shackleton
3
- Version: 1.2.0
3
+ Version: 2.1.0
4
4
  Summary: Polars-based persistent tabular store backed by Parquet or Arrow IPC files
5
5
  Project-URL: Homepage, https://github.com/endremborza/shackleton
6
6
  Author-email: Endre Márk Borza <endremborza@gmail.com>
@@ -13,7 +13,7 @@ License: Copyright 2023 Endre Márk Borza
13
13
  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
14
14
  License-File: LICENSE
15
15
  Requires-Python: >=3.12
16
- Requires-Dist: polars
16
+ Requires-Dist: polars>=2
17
17
  Description-Content-Type: text/markdown
18
18
 
19
19
  # shackleton
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
22
22
 
23
23
  Persistent tabular store backed by Parquet or Arrow IPC, built on [polars](https://pola.rs).
24
24
 
25
- - **Partitioned writes** — by column value or xxhash bucket
25
+ - **Partitioned writes** — by column value or hash bucket
26
26
  - **Sorted merge** — maintain sorted order on extend via `id_col`
27
27
  - **Lazy reads** — returns `pl.LazyFrame` so polars can push down predicates
28
28
  - **Schema evolution** — diagonal concat handles mismatched columns transparently
@@ -91,7 +91,7 @@ df_eu = repo.get_partition_df({"region": "eu"})
91
91
  ## Hash partitioning
92
92
 
93
93
  When partition values have unbounded cardinality, hash into fixed buckets instead.
94
- Uses polars' native xxhash — vectorised, GIL-free.
94
+ Uses polars' `Series.hash` — vectorised, GIL-free. That hash is not stable across polars versions while bucket assignment lives on disk, so before its first bucket-addressed operation (a write, `get_partition_*`, `purge_partition`) a repo samples stored rows and raises `HashDriftError` if they no longer hash to their bucket. `repo.rehash()` moves every row into its current bucket.
95
95
 
96
96
  ```python
97
97
  from shackleton import HashPartitioner
@@ -113,15 +113,15 @@ df = repo.get_full_df()
113
113
  ## Deduplication with `dedup_cols`
114
114
 
115
115
  When `dedup_cols` is set, each `extend` drops duplicate rows on those columns
116
- after merging with existing data — only the first occurrence is kept. Combine
117
- with `id_col` for sorted-merge dedup; use alone for unordered dedup.
116
+ after merging with existing data. With `id_col` the merge is sorted and the
117
+ existing (first) row wins; without `id_col` the surviving occurrence is arbitrary.
118
118
  Not supported with `max_records > 0` (raises at construction time).
119
119
 
120
120
  ```python
121
121
  repo = TableRepo(Path("/tmp/events"), dedup_cols=["event_id"])
122
122
 
123
123
  repo.extend(pl.DataFrame({"event_id": [1, 2, 3], "val": [10, 20, 30]}))
124
- # Re-ingest with overlapping IDs — existing rows are kept, new ones appended
124
+ # Re-ingest with overlapping IDs — duplicates collapse to one row per event_id
125
125
  repo.extend(pl.DataFrame({"event_id": [2, 4], "val": [99, 40]}))
126
126
 
127
127
  repo.get_full_df().shape # (4, 2) — event_id 2 kept once
@@ -131,9 +131,7 @@ repo.get_full_df().shape # (4, 2) — event_id 2 kept once
131
131
 
132
132
  ## Sorted merge with `id_col`
133
133
 
134
- When `id_col` is set, each extend keeps the file sorted by that column
135
- (when `max_records == 0`) and `replace_records` does an upsert: matching IDs
136
- are updated, new IDs appended.
134
+ When `id_col` is set, each extend keeps the file sorted by it (when `max_records == 0`) and `replace_records` does an upsert: matching IDs are updated, new IDs appended. `id_col` is a column or a list of columns, sorted in that order. On equal keys stored rows come before incoming ones, and within one `replace_records` batch the last row for a key wins. Id columns must not be null: a write with a null id raises `NullIdError`.
137
135
 
138
136
  ```python
139
137
  repo = TableRepo(Path("/tmp/prices"), id_col="ticker")
@@ -204,8 +202,7 @@ with repo.get_replacing_record_writer(batch_size=10_000) as writer:
204
202
 
205
203
  ## Schema evolution
206
204
 
207
- Columns added in later writes fill missing values with `null` in earlier files.
208
- No migration step needed.
205
+ Columns added in later writes fill missing values with `null` in earlier files. No migration step needed. The `id_col` merges match columns by name, so a batch may also carry the stored columns in another order.
209
206
 
210
207
  ```python
211
208
  repo = TableRepo(Path("/tmp/evolving"))
@@ -336,22 +333,4 @@ result = pl.DataFrame(repo.map_partitions(summarise, workers=0))
336
333
 
337
334
  ## Performance
338
335
 
339
- Benchmarked on a single core, NVMe SSD, 1 million rows (4 columns: i64, f64, str, str).
340
- Run `uv run bench.py` to reproduce.
341
-
342
- | Scenario | Throughput |
343
- |---|---|
344
- | extend — single file | ~33M rows/s |
345
- | extend — sorted merge (`id_col`) | ~49M rows/s |
346
- | extend — 26 column partitions | ~11M rows/s |
347
- | extend — 64 hash partitions | ~7M rows/s |
348
- | extend — `max_records=10k` | ~7M rows/s |
349
- | extend — IPC format | ~23M rows/s |
350
- | read full (collect) | ~131M rows/s |
351
- | read full (lazy + filter) | ~170M rows/s |
352
- | read single partition (1 of 26) | ~18M rows/s |
353
- | read full — IPC format | ~102M rows/s |
354
- | replace_records (10k upserts) | ~107k rows/s |
355
-
356
- `replace_records` does a full read-merge-rewrite, so throughput scales with
357
- existing file size, not batch size. Use partitioned repos to bound the merge scope.
336
+ Throughput per scenario and the known bottlenecks are in [PERF.md](PERF.md); `uv run bench.py` reproduces it.
@@ -4,7 +4,7 @@
4
4
 
5
5
  Persistent tabular store backed by Parquet or Arrow IPC, built on [polars](https://pola.rs).
6
6
 
7
- - **Partitioned writes** — by column value or xxhash bucket
7
+ - **Partitioned writes** — by column value or hash bucket
8
8
  - **Sorted merge** — maintain sorted order on extend via `id_col`
9
9
  - **Lazy reads** — returns `pl.LazyFrame` so polars can push down predicates
10
10
  - **Schema evolution** — diagonal concat handles mismatched columns transparently
@@ -73,7 +73,7 @@ df_eu = repo.get_partition_df({"region": "eu"})
73
73
  ## Hash partitioning
74
74
 
75
75
  When partition values have unbounded cardinality, hash into fixed buckets instead.
76
- Uses polars' native xxhash — vectorised, GIL-free.
76
+ Uses polars' `Series.hash` — vectorised, GIL-free. That hash is not stable across polars versions while bucket assignment lives on disk, so before its first bucket-addressed operation (a write, `get_partition_*`, `purge_partition`) a repo samples stored rows and raises `HashDriftError` if they no longer hash to their bucket. `repo.rehash()` moves every row into its current bucket.
77
77
 
78
78
  ```python
79
79
  from shackleton import HashPartitioner
@@ -95,15 +95,15 @@ df = repo.get_full_df()
95
95
  ## Deduplication with `dedup_cols`
96
96
 
97
97
  When `dedup_cols` is set, each `extend` drops duplicate rows on those columns
98
- after merging with existing data — only the first occurrence is kept. Combine
99
- with `id_col` for sorted-merge dedup; use alone for unordered dedup.
98
+ after merging with existing data. With `id_col` the merge is sorted and the
99
+ existing (first) row wins; without `id_col` the surviving occurrence is arbitrary.
100
100
  Not supported with `max_records > 0` (raises at construction time).
101
101
 
102
102
  ```python
103
103
  repo = TableRepo(Path("/tmp/events"), dedup_cols=["event_id"])
104
104
 
105
105
  repo.extend(pl.DataFrame({"event_id": [1, 2, 3], "val": [10, 20, 30]}))
106
- # Re-ingest with overlapping IDs — existing rows are kept, new ones appended
106
+ # Re-ingest with overlapping IDs — duplicates collapse to one row per event_id
107
107
  repo.extend(pl.DataFrame({"event_id": [2, 4], "val": [99, 40]}))
108
108
 
109
109
  repo.get_full_df().shape # (4, 2) — event_id 2 kept once
@@ -113,9 +113,7 @@ repo.get_full_df().shape # (4, 2) — event_id 2 kept once
113
113
 
114
114
  ## Sorted merge with `id_col`
115
115
 
116
- When `id_col` is set, each extend keeps the file sorted by that column
117
- (when `max_records == 0`) and `replace_records` does an upsert: matching IDs
118
- are updated, new IDs appended.
116
+ When `id_col` is set, each extend keeps the file sorted by it (when `max_records == 0`) and `replace_records` does an upsert: matching IDs are updated, new IDs appended. `id_col` is a column or a list of columns, sorted in that order. On equal keys stored rows come before incoming ones, and within one `replace_records` batch the last row for a key wins. Id columns must not be null: a write with a null id raises `NullIdError`.
119
117
 
120
118
  ```python
121
119
  repo = TableRepo(Path("/tmp/prices"), id_col="ticker")
@@ -186,8 +184,7 @@ with repo.get_replacing_record_writer(batch_size=10_000) as writer:
186
184
 
187
185
  ## Schema evolution
188
186
 
189
- Columns added in later writes fill missing values with `null` in earlier files.
190
- No migration step needed.
187
+ Columns added in later writes fill missing values with `null` in earlier files. No migration step needed. The `id_col` merges match columns by name, so a batch may also carry the stored columns in another order.
191
188
 
192
189
  ```python
193
190
  repo = TableRepo(Path("/tmp/evolving"))
@@ -318,22 +315,4 @@ result = pl.DataFrame(repo.map_partitions(summarise, workers=0))
318
315
 
319
316
  ## Performance
320
317
 
321
- Benchmarked on a single core, NVMe SSD, 1 million rows (4 columns: i64, f64, str, str).
322
- Run `uv run bench.py` to reproduce.
323
-
324
- | Scenario | Throughput |
325
- |---|---|
326
- | extend — single file | ~33M rows/s |
327
- | extend — sorted merge (`id_col`) | ~49M rows/s |
328
- | extend — 26 column partitions | ~11M rows/s |
329
- | extend — 64 hash partitions | ~7M rows/s |
330
- | extend — `max_records=10k` | ~7M rows/s |
331
- | extend — IPC format | ~23M rows/s |
332
- | read full (collect) | ~131M rows/s |
333
- | read full (lazy + filter) | ~170M rows/s |
334
- | read single partition (1 of 26) | ~18M rows/s |
335
- | read full — IPC format | ~102M rows/s |
336
- | replace_records (10k upserts) | ~107k rows/s |
337
-
338
- `replace_records` does a full read-merge-rewrite, so throughput scales with
339
- existing file size, not batch size. Use partitioned repos to bound the merge scope.
318
+ Throughput per scenario and the known bottlenecks are in [PERF.md](PERF.md); `uv run bench.py` reproduces it.
@@ -14,6 +14,12 @@ import polars as pl
14
14
 
15
15
  from shackleton import HashPartitioner, TableRepo
16
16
 
17
+ DEFAULT_ROWS = 1_000_000
18
+ N_CATEGORIES = 26
19
+ N_BUCKETS = 64
20
+ MAX_RECORDS = 10_000
21
+ N_UPSERTS = 10_000
22
+
17
23
  # ---------------------------------------------------------------------------
18
24
  # Helpers
19
25
  # ---------------------------------------------------------------------------
@@ -23,7 +29,7 @@ def make_df(n: int) -> pl.DataFrame:
23
29
  {
24
30
  "id": pl.arange(0, n, eager=True),
25
31
  "value": pl.Series([float(i) * 1.1 for i in range(n)]),
26
- "category": pl.Series([chr(65 + i % 26) for i in range(n)]),
32
+ "category": pl.Series([chr(65 + i % N_CATEGORIES) for i in range(n)]),
27
33
  "tag": pl.Series([f"tag-{i % 100}" for i in range(n)]),
28
34
  }
29
35
  )
@@ -49,22 +55,42 @@ def bench_extend_single(df: pl.DataFrame, root: Path) -> None:
49
55
 
50
56
  def bench_extend_with_id(df: pl.DataFrame, root: Path) -> None:
51
57
  repo = TableRepo(root / "extend_id", id_col="id")
52
- bench("extend + sorted merge (id_col)", lambda: repo.extend(df), scale=len(df))
58
+ repo.extend(df.with_columns(pl.col("id") * 2))
59
+ interleaved = df.with_columns(pl.col("id") * 2 + 1)
60
+ bench(
61
+ "extend into stored rows (id_col merge)",
62
+ lambda: repo.extend(interleaved),
63
+ scale=len(df),
64
+ )
53
65
 
54
66
 
55
67
  def bench_extend_partitioned(df: pl.DataFrame, root: Path) -> None:
56
68
  repo = TableRepo(root / "extend_part", partition_cols=["category"])
57
- bench("extend (26 column partitions)", lambda: repo.extend(df), scale=len(df))
69
+ bench(
70
+ f"extend ({N_CATEGORIES} column partitions)",
71
+ lambda: repo.extend(df),
72
+ scale=len(df),
73
+ )
58
74
 
59
75
 
60
76
  def bench_extend_hash(df: pl.DataFrame, root: Path) -> None:
61
- repo = TableRepo(root / "extend_hash", partition_cols=HashPartitioner("tag", 64))
62
- bench("extend (64 hash partitions)", lambda: repo.extend(df), scale=len(df))
77
+ repo = TableRepo(
78
+ root / "extend_hash", partition_cols=HashPartitioner("tag", N_BUCKETS)
79
+ )
80
+ bench(
81
+ f"extend ({N_BUCKETS} hash partitions)",
82
+ lambda: repo.extend(df),
83
+ scale=len(df),
84
+ )
63
85
 
64
86
 
65
87
  def bench_extend_max_records(df: pl.DataFrame, root: Path) -> None:
66
- repo = TableRepo(root / "extend_max", max_records=10_000)
67
- bench("extend (max_records=10k files)", lambda: repo.extend(df), scale=len(df))
88
+ repo = TableRepo(root / "extend_max", max_records=MAX_RECORDS)
89
+ bench(
90
+ f"extend (max_records={MAX_RECORDS:,})",
91
+ lambda: repo.extend(df),
92
+ scale=len(df),
93
+ )
68
94
 
69
95
 
70
96
  def bench_read_full(df: pl.DataFrame, root: Path) -> None:
@@ -87,19 +113,23 @@ def bench_read_partition(df: pl.DataFrame, root: Path) -> None:
87
113
  repo = TableRepo(root / "read_part", partition_cols=["category"])
88
114
  repo.extend(df)
89
115
  bench(
90
- "get_partition_df (1 of 26 partitions)",
116
+ f"get_partition_df (1 of {N_CATEGORIES} partitions)",
91
117
  lambda: repo.get_partition_df({"category": "A"}),
92
- scale=len(df) // 26,
118
+ scale=len(df) // N_CATEGORIES,
93
119
  )
94
120
 
95
121
 
96
122
  def bench_replace_records(df: pl.DataFrame, root: Path) -> None:
97
123
  repo = TableRepo(root / "replace_rec", id_col="id")
98
124
  repo.extend(df)
99
- updates = df.sample(n=min(10_000, len(df) // 10), seed=42).with_columns(
125
+ updates = df.sample(n=min(N_UPSERTS, len(df) // 10), seed=42).with_columns(
100
126
  pl.col("value") * 2
101
127
  )
102
- bench("replace_records (10k updates)", lambda: repo.replace_records(updates), scale=len(updates))
128
+ bench(
129
+ f"replace_records ({len(updates):,} updates)",
130
+ lambda: repo.replace_records(updates),
131
+ scale=len(updates),
132
+ )
103
133
 
104
134
 
105
135
  def bench_ipc_extend(df: pl.DataFrame, root: Path) -> None:
@@ -119,7 +149,7 @@ def bench_ipc_read(df: pl.DataFrame, root: Path) -> None:
119
149
 
120
150
  def main() -> None:
121
151
  parser = argparse.ArgumentParser()
122
- parser.add_argument("--rows", type=int, default=1_000_000)
152
+ parser.add_argument("--rows", type=int, default=DEFAULT_ROWS)
123
153
  args = parser.parse_args()
124
154
 
125
155
  n = args.rows
@@ -0,0 +1,37 @@
1
+ ## v2.1.0
2
+
3
+ - `id_col` merges now match columns by name instead of position, so a batch whose columns arrive reordered, or that adds or drops columns, merges correctly instead of merging wrong or failing.
4
+ - Writing a null value into an `id_col` column now raises the new exported `NullIdError` instead of silently misordering the merge.
5
+ - A batch that lands entirely in one partition is no longer re-sorted on write, fixing a case where it lost its already-sorted order.
6
+
7
+ ---
8
+
9
+ ## v2.0.1
10
+
11
+ - Fixed composite-key merges to be order-exact: stored rows now reliably come before incoming ones on equal keys, where polars' streaming engine could previously reorder ties.
12
+ - Fixed `replace_records` to work against a partition with no stored data yet.
13
+ - Writes are now atomic: data is staged to a temp file and moved into place with `os.replace`, so a crash mid-merge can no longer leave a partially-written file.
14
+ - Fixed partitions to be flagged sorted on read only when they are a single file, since appended parts are unsorted and were previously mis-flagged.
15
+
16
+ ---
17
+
18
+ ## v2.0.0
19
+
20
+ - **Breaking:** requires polars ≥2; existing `HashPartitioner` stores built under older polars versions will raise `HashDriftError` on their next bucket-addressed operation until migrated.
21
+ - Detects hash bucket drift before writes, `get_partition_*` and `purge_partition`, and adds `TableRepo.rehash()` to rebuild a store under the installed polars' hashing; full reads are unaffected and skip the check.
22
+ - `id_col` now accepts a list of columns for composite-key merges and dedup.
23
+ - Merges are now order-exact: on equal keys stored rows precede incoming ones, so `extend` with `dedup_cols` keeps the stored row and `replace_records` keeps the batch's last row instead of an arbitrary one.
24
+ - Writes are now staged to a temp file and atomically renamed into place, and merges with existing data stream instead of collecting fully in memory.
25
+ - `paths` are now returned sorted, so reads are deterministic.
26
+
27
+ ---
28
+
29
+ ## v1.2.0
30
+
31
+ deduped
32
+
33
+ ---
34
+
35
+ ## v1.1.0
36
+
37
+ realign, simplify
@@ -6,7 +6,7 @@ authors = [{name = "Endre Márk Borza", email = "endremborza@gmail.com"}]
6
6
  license = {file = "LICENSE"}
7
7
  readme = "README.md"
8
8
  requires-python = ">=3.12"
9
- dependencies = ["polars"]
9
+ dependencies = ["polars>=2"]
10
10
 
11
11
  [project.urls]
12
12
  Homepage = "https://github.com/endremborza/shackleton"
@@ -0,0 +1,14 @@
1
+ """Polars-based persistent tabular store backed by Parquet or Arrow IPC files."""
2
+
3
+ from .core import ( # noqa: F401
4
+ DfBatchWriter,
5
+ HashDriftError,
6
+ HashPartitioner,
7
+ NullIdError,
8
+ RecordWriter,
9
+ TableRepo,
10
+ )
11
+
12
+ TableShack = TableRepo # backward-compat alias
13
+
14
+ __version__ = "2.1.0"
@@ -1,8 +1,10 @@
1
1
  import contextlib
2
2
  import fcntl
3
+ import os
4
+ import shutil
3
5
  import threading
4
6
  from concurrent.futures import ThreadPoolExecutor
5
- from dataclasses import dataclass, field
7
+ from dataclasses import dataclass, field, replace
6
8
  from functools import cached_property
7
9
  from pathlib import Path
8
10
  from typing import Any, Callable, Iterable, TypeVar
@@ -12,6 +14,17 @@ import polars as pl
12
14
  T = TypeVar("T")
13
15
 
14
16
  FILE_NAME = "data"
17
+ HASH_CHECK_DIRS = 8
18
+ HASH_CHECK_ROWS = 64
19
+
20
+
21
+ class HashDriftError(Exception):
22
+ """Stored rows sit in buckets the installed polars no longer hashes them to."""
23
+
24
+
25
+ class NullIdError(ValueError):
26
+ """A written batch has nulls in an ``id_col`` column."""
27
+
15
28
 
16
29
  _PATH_LOCKS: dict[str, threading.Lock] = {}
17
30
  _LOCKS_LOCK = threading.Lock()
@@ -44,7 +57,11 @@ def _dir_lock(path_str: str):
44
57
  class HashPartitioner:
45
58
  """Partition rows by hashing a column into `num_groups` zero-padded buckets.
46
59
 
47
- Uses polars' native xxhash for vectorised, GIL-free partitioning.
60
+ Uses polars' ``Series.hash`` for vectorised, GIL-free partitioning. That hash
61
+ is not stable across polars versions while bucket assignment is persisted on
62
+ disk, so ``TableRepo`` samples stored rows before its first bucket-addressed
63
+ operation and raises ``HashDriftError`` if they no longer hash to their bucket;
64
+ ``TableRepo.rehash`` rewrites the layout.
48
65
  The computed column (``key``) is stripped before writing to files.
49
66
  """
50
67
 
@@ -75,13 +92,16 @@ class TableRepo:
75
92
 
76
93
  Args:
77
94
  root_path: Root directory for all data files.
78
- id_col: Column used as a unique row identifier. Required for
79
- ``replace_records``; enables sorted merge on ``extend`` when
80
- ``max_records == 0`` (no effect on the per-file append path).
95
+ id_col: Column, or columns in sort order, identifying a row. Required
96
+ for ``replace_records``; enables sorted merge on ``extend`` when
97
+ ``max_records == 0`` (no effect on the per-file append path). On
98
+ equal keys, stored rows precede incoming ones and a batch keeps
99
+ its own order: ``replace_records`` keeps the batch's last row.
100
+ Must not be null: writes raise ``NullIdError``.
81
101
  dedup_cols: Columns forming a composite dedup key. When set, ``extend``
82
- drops duplicates after merging with existing data. Only effective
83
- when ``max_records == 0``; raises ``ValueError`` otherwise.
84
- ``replace_records`` still requires a scalar ``id_col``.
102
+ drops duplicates after merging with existing data, keeping the
103
+ stored row when ``id_col`` is set. Only effective when
104
+ ``max_records == 0``; raises ``ValueError`` otherwise.
85
105
  partition_cols: Column name(s) to partition by, or a
86
106
  ``HashPartitioner`` for hash-based bucketing. Partition values
87
107
  are encoded as hive-style directories (``col=val/``).
@@ -96,13 +116,14 @@ class TableRepo:
96
116
  """
97
117
 
98
118
  root_path: Path
99
- id_col: str | None = None
119
+ id_col: str | list[str] | None = None
100
120
  dedup_cols: list[str] | None = None
101
121
  partition_cols: list[str] | HashPartitioner = field(default_factory=list)
102
122
  max_records: int = 0
103
123
  ipc: bool = False
104
124
  compression: str | None = None
105
125
  compression_level: int | None = None
126
+ _buckets_verified: bool = field(default=False, init=False, repr=False)
106
127
 
107
128
  def __post_init__(self) -> None:
108
129
  if self.dedup_cols and self.max_records:
@@ -112,6 +133,13 @@ class TableRepo:
112
133
  def extension(self) -> str:
113
134
  return ".arrow" if self.ipc else ".parquet"
114
135
 
136
+ @cached_property
137
+ def id_cols(self) -> list[str]:
138
+ """``id_col`` as a list; empty when unset."""
139
+ if self.id_col is None:
140
+ return []
141
+ return [self.id_col] if isinstance(self.id_col, str) else list(self.id_col)
142
+
115
143
  # --- write ---
116
144
 
117
145
  def extend(self, df: pl.DataFrame) -> None:
@@ -124,7 +152,7 @@ class TableRepo:
124
152
  Requires ``id_col`` to be set. For multi-file partitions
125
153
  (``max_records > 0``) performs a full read-merge-rewrite.
126
154
  """
127
- assert self.id_col is not None, "replace_records requires id_col"
155
+ assert self.id_cols, "replace_records requires id_col"
128
156
  self._for_each_partition(df, self._replace_dir)
129
157
 
130
158
  def replace_all(self, df: pl.DataFrame) -> None:
@@ -151,14 +179,31 @@ class TableRepo:
151
179
  for p in self.paths:
152
180
  p.unlink()
153
181
 
182
+ def rehash(self) -> None:
183
+ """Move every row of a ``HashPartitioner`` store into the bucket the
184
+ installed polars hashes it to — the fix for ``HashDriftError``.
185
+
186
+ Builds the new layout in a sibling ``<root>.rehash`` directory and swaps
187
+ it in. Holds the whole store in memory; assumes no concurrent writers.
188
+ """
189
+ if self._hasher is None:
190
+ raise ValueError("rehash requires a HashPartitioner")
191
+ staging = self.root_path.with_name(self.root_path.name + ".rehash")
192
+ if staging.exists():
193
+ raise FileExistsError(f"{staging} left by an interrupted rehash")
194
+ df = self.get_full_df()
195
+ if df.is_empty():
196
+ return
197
+ replace(self, root_path=staging).extend(df)
198
+ shutil.rmtree(self.root_path)
199
+ staging.rename(self.root_path)
200
+ self._buckets_verified = True
201
+
154
202
  # --- read ---
155
203
 
156
204
  def get_full_lf(self) -> pl.LazyFrame:
157
205
  """Lazy frame over all data in the store."""
158
- parts = list(self.paths)
159
- if not parts:
160
- return pl.LazyFrame()
161
- return pl.concat(map(self._lazy_read, parts), how="diagonal")
206
+ return self._concat_lf(self.paths)
162
207
 
163
208
  def get_full_df(self) -> pl.DataFrame:
164
209
  """Collect all data into a single DataFrame."""
@@ -171,10 +216,7 @@ class TableRepo:
171
216
  For ``HashPartitioner`` repos, use ``repo.partition_cols.key``
172
217
  as the dict key with the zero-padded bucket string as the value.
173
218
  """
174
- paths = list(self._partition_paths(partitions))
175
- if not paths:
176
- return pl.LazyFrame()
177
- return pl.concat(map(self._lazy_read, paths), how="diagonal")
219
+ return self._concat_lf(self._partition_paths(partitions))
178
220
 
179
221
  def get_partition_df(self, partitions: dict[str, Any]) -> pl.DataFrame:
180
222
  """Collect partitions matching ``partitions`` dict into a DataFrame."""
@@ -183,9 +225,9 @@ class TableRepo:
183
225
  # --- iteration ---
184
226
 
185
227
  @property
186
- def paths(self) -> Iterable[Path]:
187
- """All data file paths in the store."""
188
- return self.root_path.glob("**/*" + self.extension)
228
+ def paths(self) -> list[Path]:
229
+ """All data file paths in the store, sorted so reads are deterministic."""
230
+ return sorted(self.root_path.glob("**/*" + self.extension))
189
231
 
190
232
  @property
191
233
  def n_files(self) -> int:
@@ -248,8 +290,7 @@ class TableRepo:
248
290
 
249
291
  def _apply(d: Path) -> T:
250
292
  files = sorted(d.glob("*" + self.extension))
251
- lf = pl.concat([self._lazy_read(f) for f in files], how="diagonal")
252
- return fn(lf.collect())
293
+ return fn(self._concat_lf(files).collect())
253
294
 
254
295
  if workers is None:
255
296
  return [_apply(d) for d in dirs]
@@ -269,9 +310,7 @@ class TableRepo:
269
310
  files = sorted(d.glob("*" + self.extension))
270
311
  if len(files) <= 1:
271
312
  continue
272
- df = pl.concat(
273
- [self._lazy_read(f) for f in files], how="diagonal"
274
- ).collect()
313
+ df = self._concat_lf(files).collect()
275
314
  for f in files:
276
315
  f.unlink()
277
316
  for idx, offset in enumerate(range(0, max(len(df), 1), target_rows)):
@@ -282,66 +321,120 @@ class TableRepo:
282
321
 
283
322
  # --- internals ---
284
323
 
324
+ def _concat_lf(self, paths: Iterable[Path]) -> pl.LazyFrame:
325
+ paths = list(paths)
326
+ if not paths:
327
+ return pl.LazyFrame()
328
+ return pl.concat(map(self._lazy_read, paths), how="diagonal")
329
+
285
330
  def _lazy_read(self, path: Path) -> pl.LazyFrame:
286
331
  lf = (
287
332
  pl.scan_ipc(path)
288
333
  if self.ipc
289
334
  else pl.scan_parquet(path, hive_partitioning=False)
290
335
  )
291
- return lf.set_sorted(self.id_col) if self.id_col else lf
336
+ # only single-file partitions stay sorted; the append path does not sort
337
+ return (
338
+ lf.set_sorted(self.id_cols) if self.id_cols and not self.max_records else lf
339
+ )
292
340
 
293
- def _write(self, df: pl.DataFrame, path: Path) -> None:
341
+ def _write(self, data: pl.DataFrame | pl.LazyFrame, path: Path) -> None:
342
+ """Write atomically; a LazyFrame (a merge with stored data) is streamed."""
294
343
  path.parent.mkdir(parents=True, exist_ok=True)
344
+ staged = path.with_name(path.name + ".tmp")
295
345
  kw: dict = {}
296
346
  if self.compression:
297
347
  kw["compression"] = self.compression
298
- if self.ipc:
299
- df.write_ipc(path, **kw)
348
+ if not self.ipc and self.compression_level is not None:
349
+ kw["compression_level"] = self.compression_level
350
+ if isinstance(data, pl.LazyFrame):
351
+ (data.sink_ipc if self.ipc else data.sink_parquet)(staged, **kw)
300
352
  else:
301
- if self.compression_level is not None:
302
- kw["compression_level"] = self.compression_level
303
- df.write_parquet(path, **kw)
353
+ (data.write_ipc if self.ipc else data.write_parquet)(staged, **kw)
354
+ os.replace(staged, path)
355
+
356
+ def _sort(self, lf: pl.LazyFrame) -> pl.LazyFrame:
357
+ return lf.sort(self.id_cols, maintain_order=True)
358
+
359
+ def _merge_sorted(self, old: pl.LazyFrame, new: pl.LazyFrame) -> pl.LazyFrame:
360
+ """``old`` and the sorted ``new`` merged on ``id_cols``. ``merge_sorted``
361
+ matches columns by position, so each side first takes the diagonal union's
362
+ schema, the one the diagonal concat path writes."""
363
+ new = self._sort(new)
364
+ return pl.concat([old, new.clear()], how="diagonal").merge_sorted(
365
+ pl.concat([old.clear(), new], how="diagonal"),
366
+ key=self.id_cols,
367
+ maintain_order=True,
368
+ )
304
369
 
305
370
  def _merge_extend(self, new: pl.LazyFrame, old: pl.LazyFrame) -> pl.LazyFrame:
306
- if self.id_col:
307
- out = old.merge_sorted(new.sort(self.id_col), key=self.id_col)
371
+ if self.id_cols:
372
+ out = self._merge_sorted(old, new)
308
373
  else:
309
374
  out = pl.concat([old, new], how="diagonal")
310
375
  if self.dedup_cols:
311
- # when id_col is set, merge_sorted makes dupes adjacent; keep="first"
312
- # with maintain_order=True then runs in O(n) and preserves sort
376
+ # an ordered merge puts stored rows before incoming ones on equal keys,
377
+ # so keep="first" keeps the stored row and maintain_order the sort
313
378
  out = out.unique(
314
379
  subset=self.dedup_cols,
315
- keep="first" if self.id_col else "any",
316
- maintain_order=bool(self.id_col),
380
+ keep="first" if self.id_cols else "any",
381
+ maintain_order=bool(self.id_cols),
317
382
  )
318
383
  return out
319
384
 
320
- def _merge_replace(self, new: pl.LazyFrame, old: pl.LazyFrame) -> pl.LazyFrame:
321
- return (
322
- old.merge_sorted(new.sort(self.id_col), key=self.id_col)
323
- .unique(subset=self.id_col, keep="last")
324
- .sort(self.id_col)
385
+ def _merge_replace(
386
+ self, new: pl.LazyFrame, old: pl.LazyFrame | None
387
+ ) -> pl.LazyFrame:
388
+ merged = self._sort(new) if old is None else self._merge_sorted(old, new)
389
+ # sorted, so a key's rows are adjacent: keep the last row of each run
390
+ last_of_run = pl.any_horizontal(
391
+ pl.col(c).ne_missing(pl.col(c).shift(-1)) for c in self.id_cols
325
392
  )
393
+ return merged.filter(last_of_run)
326
394
 
327
395
  def _for_each_partition(self, df: pl.DataFrame, handler: Callable) -> None:
396
+ # polars' streaming merge_sorted misorders null keys on a single key column
397
+ if null_ids := [c for c in self.id_cols if df[c].null_count()]:
398
+ raise NullIdError(f"{self.root_path}: nulls in id column(s) {null_ids}")
328
399
  keys = self._part_keys
329
400
  if not keys:
330
401
  handler(df, self.root_path)
331
402
  return
332
403
 
333
- is_hash = isinstance(self.partition_cols, HashPartitioner)
334
- if is_hash:
335
- df = self.partition_cols.add_to_df(df)
404
+ hasher = self._hasher
405
+ if hasher is not None:
406
+ self._verify_buckets(hasher)
407
+ df = hasher.add_to_df(df)
336
408
 
337
- for group_vals, group_df in df.group_by(keys):
338
- group_vals = group_vals if isinstance(group_vals, tuple) else (group_vals,)
409
+ # a frame of one partition goes through whole: group_by would gather a copy
410
+ # and drop its sorted flags, so the write would sort it again
411
+ single = df.select(pl.struct(keys).n_unique()).item() == 1
412
+ groups = [(df.select(keys).row(0), df)] if single else df.group_by(keys)
413
+ for group_vals, group_df in groups:
339
414
  hive_parts = [f"{k}={v}" for k, v in zip(keys, group_vals)]
340
415
  part_dir = Path(self.root_path, *hive_parts)
341
- if is_hash:
416
+ if hasher is not None:
342
417
  group_df = group_df.drop(keys)
343
418
  handler(group_df, part_dir)
344
419
 
420
+ def _verify_buckets(self, hasher: HashPartitioner) -> None:
421
+ if self._buckets_verified:
422
+ return
423
+ for d in self._partition_dirs[:HASH_CHECK_DIRS]:
424
+ bucket = d.name.split("=", 1)[1]
425
+ sample = (
426
+ self._lazy_read(next(d.glob("*" + self.extension)))
427
+ .select(hasher.col)
428
+ .head(HASH_CHECK_ROWS)
429
+ .collect()
430
+ )
431
+ if (hasher.add_to_df(sample)[hasher.key] != bucket).any():
432
+ raise HashDriftError(
433
+ f"{self.root_path}: rows of bucket {bucket} hash elsewhere under "
434
+ f"polars {pl.__version__}; rewrite with TableRepo.rehash()"
435
+ )
436
+ self._buckets_verified = True
437
+
345
438
  def _extend_dir(self, df: pl.DataFrame, root: Path) -> None:
346
439
  with _dir_lock(str(root)):
347
440
  if self.max_records > 0:
@@ -353,11 +446,13 @@ class TableRepo:
353
446
  out = df
354
447
  if self.dedup_cols:
355
448
  out = out.unique(subset=self.dedup_cols, maintain_order=False)
356
- if self.id_col:
357
- out = out.sort(self.id_col)
449
+ if self.id_cols:
450
+ out = out.sort(self.id_cols, maintain_order=True)
451
+ self._write(out, path)
358
452
  else:
359
- out = self._merge_extend(df.lazy(), self._lazy_read(path)).collect()
360
- self._write(out, path)
453
+ self._write(
454
+ self._merge_extend(df.lazy(), self._lazy_read(path)), path
455
+ )
361
456
 
362
457
  def _purge_dir(self, root: Path) -> None:
363
458
  with _dir_lock(str(root)):
@@ -371,21 +466,19 @@ class TableRepo:
371
466
  def _replace_dir(self, df: pl.DataFrame, root: Path) -> None:
372
467
  with _dir_lock(str(root)):
373
468
  existing = sorted(root.glob("*" + self.extension))
374
- if not existing:
375
- self._write(df.sort(self.id_col), self._single_path(root))
376
- return
469
+ old = None
470
+ if existing:
471
+ old = self._concat_lf(existing)
472
+ if self.max_records > 0:
473
+ old = self._sort(old)
474
+ out = self._merge_replace(df.lazy(), old)
377
475
  if self.max_records > 0:
378
- old = pl.concat(
379
- [self._lazy_read(p) for p in existing], how="diagonal"
380
- ).collect()
381
- out = self._merge_replace(df.lazy(), old.lazy()).collect()
476
+ out = out.collect()
382
477
  for p in existing:
383
478
  p.unlink()
384
479
  self._append_parts(out, root, [])
385
480
  else:
386
- path = existing[0]
387
- out = self._merge_replace(df.lazy(), self._lazy_read(path)).collect()
388
- self._write(out, path)
481
+ self._write(out, self._single_path(root))
389
482
 
390
483
  def _append_parts(self, df: pl.DataFrame, root: Path, existing: list[Path]) -> None:
391
484
  if existing:
@@ -406,6 +499,8 @@ class TableRepo:
406
499
  keys = self._part_keys
407
500
  if not keys:
408
501
  return []
502
+ if self._hasher is not None:
503
+ self._verify_buckets(self._hasher)
409
504
  g = "/".join(f"{c}={partitions.get(c, '*')}" for c in keys)
410
505
  return self.root_path.glob(f"{g}/*{self.extension}")
411
506
 
@@ -423,12 +518,17 @@ class TableRepo:
423
518
  result.append(d)
424
519
  return result
425
520
 
521
+ @cached_property
522
+ def _hasher(self) -> HashPartitioner | None:
523
+ pc = self.partition_cols
524
+ return pc if isinstance(pc, HashPartitioner) else None
525
+
426
526
  @cached_property
427
527
  def _part_keys(self) -> list[str]:
428
528
  if not self.partition_cols:
429
529
  return []
430
- if isinstance(self.partition_cols, HashPartitioner):
431
- return [self.partition_cols.key]
530
+ if self._hasher is not None:
531
+ return [self._hasher.key]
432
532
  return list(self.partition_cols)
433
533
 
434
534
 
@@ -5,7 +5,7 @@ from base64 import b16encode
5
5
  import polars as pl
6
6
  import pytest
7
7
 
8
- from shackleton import HashPartitioner, TableRepo
8
+ from shackleton import HashDriftError, HashPartitioner, NullIdError, TableRepo
9
9
 
10
10
 
11
11
  def make_df(n: int, seed: int = 0) -> pl.DataFrame:
@@ -173,7 +173,16 @@ def test_replace_records_dedup(tmp_path):
173
173
  repo.replace_records(pl.DataFrame({"id": [2, 2], "val": [20, 21]}))
174
174
  result = repo.get_full_df().sort("id")
175
175
  assert result.shape[0] == 3
176
- assert result.filter(pl.col("id") == 2)["val"][0] in (20, 21)
176
+ assert result.filter(pl.col("id") == 2)["val"][0] == 21 # batch's last row wins
177
+
178
+
179
+ def test_replace_records_over_duplicate_ids(tmp_path):
180
+ repo = TableRepo(tmp_path / "data", id_col="id")
181
+ ids = list(range(10))
182
+ repo.extend(pl.DataFrame({"id": ids, "val": 1}))
183
+ repo.extend(pl.DataFrame({"id": ids, "val": 2}))
184
+ repo.replace_records(pl.DataFrame({"id": ids, "val": 3}))
185
+ assert repo.get_full_df()["val"].to_list() == [3] * 10
177
186
 
178
187
 
179
188
  def test_replace_records_with_max_records(tmp_path):
@@ -187,12 +196,44 @@ def test_replace_records_with_max_records(tmp_path):
187
196
  assert repo.n_files == 2 # 4 rows at max_records=2
188
197
 
189
198
 
199
+ def test_replace_records_max_records_unsorted_parts(tmp_path):
200
+ repo = TableRepo(tmp_path / "data", id_col="id", max_records=2)
201
+ repo.extend(pl.DataFrame({"id": [3, 1, 2], "val": [30, 10, 20]}))
202
+ repo.replace_records(pl.DataFrame({"id": [1], "val": [100]}))
203
+ result = repo.get_full_df()
204
+ assert result["id"].to_list() == [1, 2, 3]
205
+ assert result["val"].to_list() == [100, 20, 30]
206
+
207
+
208
+ def test_composite_id_col(tmp_path):
209
+ repo = TableRepo(tmp_path / "data", id_col=["a", "b"])
210
+ repo.extend(pl.DataFrame({"a": [2, 1, 1], "b": [1, 2, 1], "val": [21, 12, 11]}))
211
+ repo.extend(pl.DataFrame({"a": [1, 2], "b": [3, 0], "val": [13, 20]}))
212
+ assert repo.get_full_df()["val"].to_list() == [11, 12, 13, 20, 21]
213
+ repo.replace_records(pl.DataFrame({"a": [1, 3], "b": [2, 0], "val": [-12, 30]}))
214
+ assert repo.get_full_df()["val"].to_list() == [11, -12, 13, 20, 21, 30]
215
+
216
+
190
217
  def test_replace_records_empty_repo(tmp_path):
191
218
  repo = TableRepo(tmp_path / "data", id_col="id")
192
219
  repo.replace_records(pl.DataFrame({"id": [1, 2], "val": [1, 2]}))
193
220
  assert repo.get_full_df().shape[0] == 2
194
221
 
195
222
 
223
+ def test_replace_records_empty_repo_max_records(tmp_path):
224
+ repo = TableRepo(tmp_path / "data", id_col="id", max_records=2)
225
+ repo.replace_records(
226
+ pl.DataFrame({"id": [1, 2, 3, 4, 5], "val": [10, 20, 30, 40, 50]})
227
+ )
228
+ assert repo.n_files == 3 # 5 rows at max_records=2 → (2, 2, 1)
229
+ assert all(p.name.startswith("file-") for p in repo.paths)
230
+ repo.extend(pl.DataFrame({"id": [6, 7], "val": [60, 70]}))
231
+ result = repo.get_full_df().sort("id")
232
+ assert result["id"].to_list() == [1, 2, 3, 4, 5, 6, 7]
233
+ assert all(p.name.startswith("file-") for p in repo.paths)
234
+ assert repo.n_files == 4 # 7 rows at max_records=2 → (2, 2, 2, 1)
235
+
236
+
196
237
  def test_id_col_sorted_merge_on_extend(tmp_path):
197
238
  repo = TableRepo(tmp_path / "data", id_col="id")
198
239
  repo.extend(pl.DataFrame({"id": [1, 3], "val": [10, 30]}))
@@ -243,6 +284,15 @@ def test_dedup_cols_with_id_col_old_wins(tmp_path):
243
284
  assert result.filter(pl.col("id") == 3)["val"][0] == 30 # new row inserted
244
285
 
245
286
 
287
+ def test_dedup_cols_old_wins_within_id_col_runs(tmp_path):
288
+ repo = TableRepo(tmp_path / "data", id_col="g", dedup_cols=["g", "id"])
289
+ ids = list(range(10))
290
+ df = pl.DataFrame({"g": [i // 4 for i in ids], "id": ids})
291
+ repo.extend(df.with_columns(val=pl.lit("stored")))
292
+ repo.extend(df.with_columns(val=pl.lit("incoming")))
293
+ assert repo.get_full_df()["val"].to_list() == ["stored"] * 10
294
+
295
+
246
296
  # ---------------------------------------------------------------------------
247
297
  # IPC / Parquet format
248
298
  # ---------------------------------------------------------------------------
@@ -323,6 +373,26 @@ def test_hash_partitioner_extend_twice(tmp_path):
323
373
  assert repo.get_full_df().shape[0] == 60
324
374
 
325
375
 
376
+ def test_hash_drift_raises_until_rehash(tmp_path, monkeypatch):
377
+ hp = HashPartitioner("id", num_groups=4)
378
+ df = pl.DataFrame({"id": range(100), "val": 0})
379
+ TableRepo(tmp_path / "data", id_col="id", partition_cols=hp).extend(df)
380
+
381
+ upgraded = pl.Expr.hash
382
+ monkeypatch.setattr(pl.Expr, "hash", lambda e, seed=0: upgraded(e, seed + 1))
383
+ repo = TableRepo(tmp_path / "data", id_col="id", partition_cols=hp)
384
+ assert repo.get_full_df().shape[0] == 100 # full reads are layout-agnostic
385
+ with pytest.raises(HashDriftError):
386
+ repo.replace_records(df.with_columns(val=pl.lit(1)))
387
+ with pytest.raises(HashDriftError):
388
+ repo.get_partition_df({hp.key: "0"})
389
+
390
+ repo.rehash()
391
+ fresh = TableRepo(tmp_path / "data", id_col="id", partition_cols=hp)
392
+ fresh.replace_records(df.with_columns(val=pl.lit(1)))
393
+ assert fresh.get_full_df().sort("id")["val"].to_list() == [1] * 100
394
+
395
+
326
396
  # ---------------------------------------------------------------------------
327
397
  # RecordWriter
328
398
  # ---------------------------------------------------------------------------
@@ -433,6 +503,25 @@ def test_schema_mismatch_extend(tmp_path):
433
503
  assert "C" in full.columns
434
504
 
435
505
 
506
+ def test_id_col_merges_match_columns_by_name(tmp_path):
507
+ repo = TableRepo(tmp_path / "data", id_col="id")
508
+ repo.extend(pl.DataFrame({"id": [1, 3], "a": ["x", "y"], "b": [1.0, 2.0]}))
509
+ repo.extend(pl.DataFrame({"id": [2], "b": [9.0], "a": ["z"], "c": [7]}))
510
+ repo.replace_records(pl.DataFrame({"a": ["w"], "id": [3], "b": [None]}))
511
+ assert repo.get_full_df().to_dicts() == [
512
+ {"id": 1, "a": "x", "b": 1.0, "c": None},
513
+ {"id": 2, "a": "z", "b": 9.0, "c": 7},
514
+ {"id": 3, "a": "w", "b": None, "c": None},
515
+ ]
516
+
517
+
518
+ @pytest.mark.parametrize("method", ["extend", "replace_records"])
519
+ def test_null_ids_raise(tmp_path, method):
520
+ repo = TableRepo(tmp_path / "data", id_col=["k", "id"])
521
+ with pytest.raises(NullIdError):
522
+ getattr(repo, method)(pl.DataFrame({"k": [1, 1], "id": [2, None]}))
523
+
524
+
436
525
  # ---------------------------------------------------------------------------
437
526
  # Edge cases
438
527
  # ---------------------------------------------------------------------------
@@ -40,30 +40,30 @@ wheels = [
40
40
 
41
41
  [[package]]
42
42
  name = "polars"
43
- version = "1.39.0"
43
+ version = "2.0.0"
44
44
  source = { registry = "https://pypi.org/simple" }
45
45
  dependencies = [
46
46
  { name = "polars-runtime-32" },
47
47
  ]
48
- sdist = { url = "https://files.pythonhosted.org/packages/ab/b8/3a6a5b85e34af7936620f331f04f8bed235625439f5bd80832f968648618/polars-1.39.0.tar.gz", hash = "sha256:e63a25fb7682ae660e36067915a7c71a653b17f82308a8eb67a190a80daf0710", size = 728783, upload-time = "2026-03-12T14:24:47.876Z" }
48
+ sdist = { url = "https://files.pythonhosted.org/packages/8e/e9/001f371ec6a1bb54893f599ceebd56e6144fed4091f09f09fec0021a9276/polars-2.0.0.tar.gz", hash = "sha256:62da109e27a19a9d36657ee25dc035c9d3f87e7bd610526fe467dc37ea7dc115", size = 778215, upload-time = "2026-10-06T11:51:29.679Z" }
49
49
  wheels = [
50
- { url = "https://files.pythonhosted.org/packages/a7/f8/fad8470d9701c1b208cc24919a661efdf565373e77e7d06400642a759285/polars-1.39.0-py3-none-any.whl", hash = "sha256:4d1198b41bc47561673d9f54d0f595125202a3f53e3502821802958d3e60efe9", size = 823938, upload-time = "2026-03-12T14:22:37.78Z" },
50
+ { url = "https://files.pythonhosted.org/packages/ac/09/cc33bbd5463749c116b62c204d88bed6c02a6cb901eac7adab0d38651b07/polars-2.0.0-py3-none-any.whl", hash = "sha256:35d62f3541b7a6d4c360a2e2f07fccc0c2bcbd33b0ea51c83a25417a47a3f3ad", size = 876611, upload-time = "2026-10-06T11:44:04.327Z" },
51
51
  ]
52
52
 
53
53
  [[package]]
54
54
  name = "polars-runtime-32"
55
- version = "1.39.0"
55
+ version = "2.0.0"
56
56
  source = { registry = "https://pypi.org/simple" }
57
- sdist = { url = "https://files.pythonhosted.org/packages/8b/1e/fce83ad77bfed1bf4a83f74dde19e2572c32fc040e93bd98d161e3950eaf/polars_runtime_32-1.39.0.tar.gz", hash = "sha256:f5aabed8c7318fcad5173e83bee385445f54b5f8c83b1ec9eab78bdffa293141", size = 2870686, upload-time = "2026-03-12T14:24:49.41Z" }
57
+ sdist = { url = "https://files.pythonhosted.org/packages/34/ad/dbb6f6d7070867951532bcfe5e6a648d8777b416b18cddabc07030404e8c/polars_runtime_32-2.0.0.tar.gz", hash = "sha256:b5f9afcc742b4a67eabd2c680ff0f12eb02ede9b4bf807bffabd6dbb9a58d5c7", size = 3591339, upload-time = "2026-10-06T11:51:31.076Z" }
58
58
  wheels = [
59
- { url = "https://files.pythonhosted.org/packages/2b/6d/143b552baa9e859ae266f087f3ec0aeb29e5acc39e1f49c1a64023cee469/polars_runtime_32-1.39.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4a4bc06ca97238d963979e3f888fbb500ee607f03cefe43a9062381e259503e2", size = 45299222, upload-time = "2026-03-12T14:22:40.821Z" },
60
- { url = "https://files.pythonhosted.org/packages/97/ec/eb4e57eedfb97019f951b298fa4cd232a50db65aa6702c735b6f272a0fa0/polars_runtime_32-1.39.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9914b9e168634bc21d07ee03b8fa92d0aaa8ac7b2bb1c9e2f1f78622aa1b8f4", size = 40863978, upload-time = "2026-03-12T14:22:45.16Z" },
61
- { url = "https://files.pythonhosted.org/packages/5f/b7/28fa0345586f7c449dd27d687c32a10dcea470ebc5a978d7fc47e463b298/polars_runtime_32-1.39.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ded58f1c28e17ecbff8625cb1ad93016761260348acb79b1a4cd077970e89e5", size = 43231627, upload-time = "2026-03-12T14:22:49.464Z" },
62
- { url = "https://files.pythonhosted.org/packages/cf/60/c0d0b6720437685223457242a79f6bba443485ca85928645786479ebed86/polars_runtime_32-1.39.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b82c872b25ef6628462f90f1b6b3950779aee36889e83b3693d0a69684d3d86a", size = 46899324, upload-time = "2026-03-12T14:22:54.364Z" },
63
- { url = "https://files.pythonhosted.org/packages/73/98/53ad9c8a6f151e098e4f65c5146f9e538f1ba148feb5289fd2a4c5e2d764/polars_runtime_32-1.39.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4a0e9d6b56362f3ba1a33d0538ae14c9b9a8e0fb835f86abfc82fa7b2c7d89c9", size = 43389283, upload-time = "2026-03-12T14:22:59.767Z" },
64
- { url = "https://files.pythonhosted.org/packages/74/a2/21f77d6e588ee7c8e7f6232d135538690411de2ea6415d8bbe9b8d684f37/polars_runtime_32-1.39.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0daea3919661ba672b00bd01b5547cd29bb6414732457abb72cbc75103cf3c90", size = 46509946, upload-time = "2026-03-12T14:23:05.215Z" },
65
- { url = "https://files.pythonhosted.org/packages/24/a3/37a56ad2d931c857b892b22760b9bf9a53f681d9ccf27741cf6dd8489320/polars_runtime_32-1.39.0-cp310-abi3-win_amd64.whl", hash = "sha256:d6e9d1cf264aacfe5bf03241c04ef435d0f9cfec3fbe079acc3a7328a737961a", size = 47012669, upload-time = "2026-03-12T14:23:11.134Z" },
66
- { url = "https://files.pythonhosted.org/packages/b3/eb/936f5eeae196e8c8aaabe5f7d98891be8a5bbc741d50ce5c60f55575ad29/polars_runtime_32-1.39.0-cp310-abi3-win_arm64.whl", hash = "sha256:d69abde5f148566860bbe910010847bd7791e72f7c8063a4d2c462246a33a72a", size = 41885761, upload-time = "2026-03-12T14:23:16.773Z" },
59
+ { url = "https://files.pythonhosted.org/packages/82/88/d35dec6c8928dfbaa1cccf9b626a1067da906e792c92d9f994ca825ab2b5/polars_runtime_32-2.0.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ffb7ac6cf4e8c4a652df1951e3c3840c7c23a033603d5a9efd422fa8dd699d82", size = 52494314, upload-time = "2026-10-06T11:44:07.768Z" },
60
+ { url = "https://files.pythonhosted.org/packages/5f/fd/2237bf53ffaff47cdf1edc6c10587a7a6444d4951150eeb08d84f3493ff8/polars_runtime_32-2.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:7012d8a0201bd95638545ce8f256c0efe2c5cab0f806eb043021dddde5a9498b", size = 47930083, upload-time = "2026-10-06T11:44:11.592Z" },
61
+ { url = "https://files.pythonhosted.org/packages/0d/0d/85e3ed90417996fc09770be91b39979074fe2978fc15b431bf8a9459760d/polars_runtime_32-2.0.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b85bb42e6009acc9629afcc70a83473fd468694d6a30ffb0ab376c8dd1a0a17", size = 50417889, upload-time = "2026-10-06T11:50:20.774Z" },
62
+ { url = "https://files.pythonhosted.org/packages/83/88/e9fecfd49159da92f54ff2445883577a0f1bc195da53ecc9535c458d55dd/polars_runtime_32-2.0.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d6ac584ea2b38913784db943879412380d92e28ab9cb88e20a77ba71ba3f911", size = 54475036, upload-time = "2026-10-06T11:50:24.411Z" },
63
+ { url = "https://files.pythonhosted.org/packages/48/ad/b2abf732697b21467aaaeaac0f3bf7eee0d89c59ce8125f1ed41b28a2d97/polars_runtime_32-2.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a6bf5e260e0a6f00d0f9181438fe9e45776df8c66cee9cba16e3675cc3888488", size = 50579474, upload-time = "2026-10-06T11:50:28.377Z" },
64
+ { url = "https://files.pythonhosted.org/packages/7f/05/304deee59a95865e1b5e9ec7b066069b49093b81b768f473d9d3b165c686/polars_runtime_32-2.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55c26eef325b6840584d91aac232e9cf3ac19e1b904594b9b54131be1edeab4d", size = 54413293, upload-time = "2026-10-06T11:50:31.828Z" },
65
+ { url = "https://files.pythonhosted.org/packages/61/59/8c9fd7199f7c4eb1b64e640306a946a2e4a46337b3bbb33b840972c7d84b/polars_runtime_32-2.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:7da1caf3c7b4f397fb213c984013a0c755557619a2d511899a1ff74392484078", size = 54229989, upload-time = "2026-10-06T11:50:35.206Z" },
66
+ { url = "https://files.pythonhosted.org/packages/e2/93/43608026f38aa6ed4d22da8597706a61682ee403caef0021ce8e6dc73227/polars_runtime_32-2.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:c30ba698c8904048df4a9bc3d6c5033cc2d0a7cbb0e13f4fd2de5a1947b61994", size = 48730655, upload-time = "2026-10-06T11:50:38.756Z" },
67
67
  ]
68
68
 
69
69
  [[package]]
@@ -104,7 +104,7 @@ dev = [
104
104
  ]
105
105
 
106
106
  [package.metadata]
107
- requires-dist = [{ name = "polars" }]
107
+ requires-dist = [{ name = "polars", specifier = ">=2" }]
108
108
 
109
109
  [package.metadata.requires-dev]
110
110
  dev = [{ name = "pytest", specifier = ">=9.0.2" }]
@@ -1,15 +0,0 @@
1
- name: Python Package Release
2
-
3
- on:
4
- release:
5
- types: [published]
6
-
7
- jobs:
8
- release:
9
- runs-on: ubuntu-latest
10
- steps:
11
- - uses: actions/checkout@v4
12
- - uses: astral-sh/setup-uv@v5
13
- - run: uv build && uv publish
14
- env:
15
- UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
shackleton-1.2.0/PERF.md DELETED
@@ -1,37 +0,0 @@
1
- # Performance
2
-
3
- ## Running Benchmarks
4
-
5
- ```bash
6
- uv run bench.py
7
- uv run bench.py --rows 5000000
8
- ```
9
-
10
- Default: 1M rows, 5 columns (int, float, str, date, bool).
11
-
12
- ## Metrics
13
-
14
- All measured in rows/second.
15
-
16
- | Operation | Baseline (1M rows) |
17
- |-----------|-------------------|
18
- | Single-file extend | ~33M rows/s |
19
- | Extend with sorted merge (id_col) | ~49M rows/s |
20
- | Partitioned write (26 partitions) | ~11M rows/s |
21
- | Hash-partitioned write (64 buckets) | ~7M rows/s |
22
- | Full read (collect) | ~131M rows/s |
23
- | Lazy read + filter | ~170M rows/s |
24
- | Single partition read | ~18M rows/s |
25
- | Replace records (10k upserts) | ~107k rows/s |
26
-
27
- ## Known Bottlenecks
28
-
29
- - `replace_records` requires a full merge — O(n) in total rows, not O(k) in replacement size
30
- - Hash-partitioned writes scale sub-linearly with bucket count due to per-partition file overhead
31
- - Partition reads are limited by parquet file open latency when partitions are small
32
-
33
- ## What to Watch
34
-
35
- - Polars version upgrades can significantly affect parquet I/O throughput
36
- - Row group size interacts with partition count — too many small files hurts read performance
37
- - The GIL-releasing path through polars I/O makes thread-pool scaling important for parallel reads
@@ -1,9 +0,0 @@
1
- ## v1.2.0
2
-
3
- deduped
4
-
5
- ---
6
-
7
- ## v1.1.0
8
-
9
- realign, simplify
@@ -1,7 +0,0 @@
1
- """Polars-based persistent tabular store backed by Parquet or Arrow IPC files."""
2
-
3
- from .core import DfBatchWriter, HashPartitioner, RecordWriter, TableRepo # noqa: F401
4
-
5
- TableShack = TableRepo # backward-compat alias
6
-
7
- __version__ = "1.2.0"
File without changes
File without changes
File without changes