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.
- shackleton-2.1.0/PERF.md +41 -0
- {shackleton-2.0.1 → shackleton-2.1.0}/PKG-INFO +4 -23
- {shackleton-2.0.1 → shackleton-2.1.0}/README.md +3 -22
- {shackleton-2.0.1 → shackleton-2.1.0}/bench.py +42 -12
- {shackleton-2.0.1 → shackleton-2.1.0}/docs/release_notes.md +8 -0
- {shackleton-2.0.1 → shackleton-2.1.0}/shackleton/__init__.py +2 -1
- {shackleton-2.0.1 → shackleton-2.1.0}/shackleton/core.py +26 -12
- {shackleton-2.0.1 → shackleton-2.1.0}/shackleton/tests/test_core.py +20 -1
- shackleton-2.0.1/PERF.md +0 -37
- {shackleton-2.0.1 → shackleton-2.1.0}/.github/workflows/release.yml +0 -0
- {shackleton-2.0.1 → shackleton-2.1.0}/.github/workflows/test.yml +0 -0
- {shackleton-2.0.1 → shackleton-2.1.0}/.gitignore +0 -0
- {shackleton-2.0.1 → shackleton-2.1.0}/LICENSE +0 -0
- {shackleton-2.0.1 → shackleton-2.1.0}/coverage/report.md +0 -0
- {shackleton-2.0.1 → shackleton-2.1.0}/docs/api.md +0 -0
- {shackleton-2.0.1 → shackleton-2.1.0}/plans.md +0 -0
- {shackleton-2.0.1 → shackleton-2.1.0}/pyproject.toml +0 -0
- {shackleton-2.0.1 → shackleton-2.1.0}/uv.lock +0 -0
shackleton-2.1.0/PERF.md
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 %
|
|
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
|
-
|
|
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(
|
|
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(
|
|
62
|
-
|
|
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=
|
|
67
|
-
bench(
|
|
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
|
|
116
|
+
f"get_partition_df (1 of {N_CATEGORIES} partitions)",
|
|
91
117
|
lambda: repo.get_partition_df({"category": "A"}),
|
|
92
|
-
scale=len(df) //
|
|
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(
|
|
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(
|
|
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=
|
|
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.
|
|
@@ -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 =
|
|
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
|
-
|
|
399
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|