shackleton 2.0.1__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,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
1
  Metadata-Version: 2.5
2
2
  Name: shackleton
3
- Version: 2.0.1
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>
@@ -131,7 +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 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.
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`.
135
135
 
136
136
  ```python
137
137
  repo = TableRepo(Path("/tmp/prices"), id_col="ticker")
@@ -202,8 +202,7 @@ with repo.get_replacing_record_writer(batch_size=10_000) as writer:
202
202
 
203
203
  ## Schema evolution
204
204
 
205
- Columns added in later writes fill missing values with `null` in earlier files.
206
- 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.
207
206
 
208
207
  ```python
209
208
  repo = TableRepo(Path("/tmp/evolving"))
@@ -334,22 +333,4 @@ result = pl.DataFrame(repo.map_partitions(summarise, workers=0))
334
333
 
335
334
  ## Performance
336
335
 
337
- Benchmarked on a single core, NVMe SSD, 1 million rows (4 columns: i64, f64, str, str).
338
- Run `uv run bench.py` to reproduce.
339
-
340
- | Scenario | Throughput |
341
- |---|---|
342
- | extend — single file | ~33M rows/s |
343
- | extend — sorted merge (`id_col`) | ~49M rows/s |
344
- | extend — 26 column partitions | ~11M rows/s |
345
- | extend — 64 hash partitions | ~7M rows/s |
346
- | extend — `max_records=10k` | ~7M rows/s |
347
- | extend — IPC format | ~23M rows/s |
348
- | read full (collect) | ~131M rows/s |
349
- | read full (lazy + filter) | ~170M rows/s |
350
- | read single partition (1 of 26) | ~18M rows/s |
351
- | read full — IPC format | ~102M rows/s |
352
- | replace_records (10k upserts) | ~107k rows/s |
353
-
354
- `replace_records` does a full read-merge-rewrite, so throughput scales with
355
- 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.
@@ -113,7 +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 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.
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`.
117
117
 
118
118
  ```python
119
119
  repo = TableRepo(Path("/tmp/prices"), id_col="ticker")
@@ -184,8 +184,7 @@ with repo.get_replacing_record_writer(batch_size=10_000) as writer:
184
184
 
185
185
  ## Schema evolution
186
186
 
187
- Columns added in later writes fill missing values with `null` in earlier files.
188
- 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.
189
188
 
190
189
  ```python
191
190
  repo = TableRepo(Path("/tmp/evolving"))
@@ -316,22 +315,4 @@ result = pl.DataFrame(repo.map_partitions(summarise, workers=0))
316
315
 
317
316
  ## Performance
318
317
 
319
- Benchmarked on a single core, NVMe SSD, 1 million rows (4 columns: i64, f64, str, str).
320
- Run `uv run bench.py` to reproduce.
321
-
322
- | Scenario | Throughput |
323
- |---|---|
324
- | extend — single file | ~33M rows/s |
325
- | extend — sorted merge (`id_col`) | ~49M rows/s |
326
- | extend — 26 column partitions | ~11M rows/s |
327
- | extend — 64 hash partitions | ~7M rows/s |
328
- | extend — `max_records=10k` | ~7M rows/s |
329
- | extend — IPC format | ~23M rows/s |
330
- | read full (collect) | ~131M rows/s |
331
- | read full (lazy + filter) | ~170M rows/s |
332
- | read single partition (1 of 26) | ~18M rows/s |
333
- | read full — IPC format | ~102M rows/s |
334
- | replace_records (10k upserts) | ~107k rows/s |
335
-
336
- `replace_records` does a full read-merge-rewrite, so throughput scales with
337
- 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
@@ -1,3 +1,11 @@
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
+
1
9
  ## v2.0.1
2
10
 
3
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.
@@ -4,10 +4,11 @@ from .core import ( # noqa: F401
4
4
  DfBatchWriter,
5
5
  HashDriftError,
6
6
  HashPartitioner,
7
+ NullIdError,
7
8
  RecordWriter,
8
9
  TableRepo,
9
10
  )
10
11
 
11
12
  TableShack = TableRepo # backward-compat alias
12
13
 
13
- __version__ = "2.0.1"
14
+ __version__ = "2.1.0"
@@ -22,6 +22,10 @@ class HashDriftError(Exception):
22
22
  """Stored rows sit in buckets the installed polars no longer hashes them to."""
23
23
 
24
24
 
25
+ class NullIdError(ValueError):
26
+ """A written batch has nulls in an ``id_col`` column."""
27
+
28
+
25
29
  _PATH_LOCKS: dict[str, threading.Lock] = {}
26
30
  _LOCKS_LOCK = threading.Lock()
27
31
 
@@ -93,6 +97,7 @@ class TableRepo:
93
97
  ``max_records == 0`` (no effect on the per-file append path). On
94
98
  equal keys, stored rows precede incoming ones and a batch keeps
95
99
  its own order: ``replace_records`` keeps the batch's last row.
100
+ Must not be null: writes raise ``NullIdError``.
96
101
  dedup_cols: Columns forming a composite dedup key. When set, ``extend``
97
102
  drops duplicates after merging with existing data, keeping the
98
103
  stored row when ``id_col`` is set. Only effective when
@@ -351,11 +356,20 @@ class TableRepo:
351
356
  def _sort(self, lf: pl.LazyFrame) -> pl.LazyFrame:
352
357
  return lf.sort(self.id_cols, maintain_order=True)
353
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
+ )
369
+
354
370
  def _merge_extend(self, new: pl.LazyFrame, old: pl.LazyFrame) -> pl.LazyFrame:
355
371
  if self.id_cols:
356
- out = old.merge_sorted(
357
- self._sort(new), key=self.id_cols, maintain_order=True
358
- )
372
+ out = self._merge_sorted(old, new)
359
373
  else:
360
374
  out = pl.concat([old, new], how="diagonal")
361
375
  if self.dedup_cols:
@@ -371,13 +385,7 @@ class TableRepo:
371
385
  def _merge_replace(
372
386
  self, new: pl.LazyFrame, old: pl.LazyFrame | None
373
387
  ) -> pl.LazyFrame:
374
- merged = (
375
- self._sort(new)
376
- if old is None
377
- else old.merge_sorted(
378
- self._sort(new), key=self.id_cols, maintain_order=True
379
- )
380
- )
388
+ merged = self._sort(new) if old is None else self._merge_sorted(old, new)
381
389
  # sorted, so a key's rows are adjacent: keep the last row of each run
382
390
  last_of_run = pl.any_horizontal(
383
391
  pl.col(c).ne_missing(pl.col(c).shift(-1)) for c in self.id_cols
@@ -385,6 +393,9 @@ class TableRepo:
385
393
  return merged.filter(last_of_run)
386
394
 
387
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}")
388
399
  keys = self._part_keys
389
400
  if not keys:
390
401
  handler(df, self.root_path)
@@ -395,8 +406,11 @@ class TableRepo:
395
406
  self._verify_buckets(hasher)
396
407
  df = hasher.add_to_df(df)
397
408
 
398
- for group_vals, group_df in df.group_by(keys):
399
- 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:
400
414
  hive_parts = [f"{k}={v}" for k, v in zip(keys, group_vals)]
401
415
  part_dir = Path(self.root_path, *hive_parts)
402
416
  if hasher is not None:
@@ -5,7 +5,7 @@ from base64 import b16encode
5
5
  import polars as pl
6
6
  import pytest
7
7
 
8
- from shackleton import HashDriftError, 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:
@@ -503,6 +503,25 @@ def test_schema_mismatch_extend(tmp_path):
503
503
  assert "C" in full.columns
504
504
 
505
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
+
506
525
  # ---------------------------------------------------------------------------
507
526
  # Edge cases
508
527
  # ---------------------------------------------------------------------------
shackleton-2.0.1/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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes