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.
- shackleton-2.1.0/.github/workflows/release.yml +27 -0
- {shackleton-1.2.0 → shackleton-2.1.0}/.github/workflows/test.yml +3 -2
- {shackleton-1.2.0 → shackleton-2.1.0}/.gitignore +2 -12
- shackleton-2.1.0/PERF.md +41 -0
- {shackleton-1.2.0 → shackleton-2.1.0}/PKG-INFO +11 -32
- {shackleton-1.2.0 → shackleton-2.1.0}/README.md +8 -29
- {shackleton-1.2.0 → shackleton-2.1.0}/bench.py +42 -12
- shackleton-2.1.0/docs/release_notes.md +37 -0
- {shackleton-1.2.0 → shackleton-2.1.0}/pyproject.toml +1 -1
- shackleton-2.1.0/shackleton/__init__.py +14 -0
- {shackleton-1.2.0 → shackleton-2.1.0}/shackleton/core.py +166 -66
- {shackleton-1.2.0 → shackleton-2.1.0}/shackleton/tests/test_core.py +91 -2
- {shackleton-1.2.0 → shackleton-2.1.0}/uv.lock +14 -14
- shackleton-1.2.0/.github/workflows/twine_release.yml +0 -15
- shackleton-1.2.0/PERF.md +0 -37
- shackleton-1.2.0/docs/release_notes.md +0 -9
- shackleton-1.2.0/shackleton/__init__.py +0 -7
- {shackleton-1.2.0 → shackleton-2.1.0}/LICENSE +0 -0
- {shackleton-1.2.0 → shackleton-2.1.0}/coverage/report.md +0 -0
- {shackleton-1.2.0 → shackleton-2.1.0}/docs/api.md +0 -0
- {shackleton-1.2.0 → shackleton-2.1.0}/plans.md +0 -0
|
@@ -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@
|
|
16
|
-
- uses: astral-sh/setup-uv@
|
|
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
|
-
#
|
|
72
|
-
|
|
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
|
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
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: shackleton
|
|
3
|
-
Version: 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>
|
|
@@ -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
|
|
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'
|
|
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
|
|
117
|
-
|
|
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 —
|
|
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
|
|
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
|
-
|
|
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
|
|
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'
|
|
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
|
|
99
|
-
|
|
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 —
|
|
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
|
|
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
|
-
|
|
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 %
|
|
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
|
|
@@ -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'
|
|
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
|
|
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
|
|
83
|
-
when ``
|
|
84
|
-
``
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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) ->
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
302
|
-
|
|
303
|
-
|
|
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.
|
|
307
|
-
out =
|
|
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
|
-
#
|
|
312
|
-
#
|
|
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.
|
|
316
|
-
maintain_order=bool(self.
|
|
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(
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
334
|
-
if
|
|
335
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
|
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.
|
|
357
|
-
out = out.sort(self.
|
|
449
|
+
if self.id_cols:
|
|
450
|
+
out = out.sort(self.id_cols, maintain_order=True)
|
|
451
|
+
self._write(out, path)
|
|
358
452
|
else:
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
431
|
-
return [self.
|
|
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]
|
|
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 = "
|
|
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/
|
|
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/
|
|
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 = "
|
|
55
|
+
version = "2.0.0"
|
|
56
56
|
source = { registry = "https://pypi.org/simple" }
|
|
57
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
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/
|
|
60
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
61
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
62
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
63
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
64
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
65
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
66
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|