shackleton 0.0.2__tar.gz → 1.2.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-1.2.0/.github/workflows/test.yml +21 -0
- shackleton-1.2.0/.github/workflows/twine_release.yml +15 -0
- {shackleton-0.0.2 → shackleton-1.2.0}/.gitignore +2 -1
- shackleton-1.2.0/PERF.md +37 -0
- shackleton-1.2.0/PKG-INFO +357 -0
- shackleton-1.2.0/README.md +339 -0
- shackleton-1.2.0/bench.py +154 -0
- shackleton-1.2.0/coverage/report.md +6 -0
- shackleton-1.2.0/docs/api.md +201 -0
- shackleton-1.2.0/docs/release_notes.md +9 -0
- shackleton-1.2.0/plans.md +149 -0
- shackleton-1.2.0/pyproject.toml +30 -0
- shackleton-1.2.0/shackleton/__init__.py +7 -0
- shackleton-1.2.0/shackleton/core.py +517 -0
- shackleton-1.2.0/shackleton/tests/test_core.py +618 -0
- shackleton-1.2.0/uv.lock +110 -0
- shackleton-0.0.2/.github/workflows/test.yml +0 -28
- shackleton-0.0.2/.github/workflows/twine_release.yml +0 -30
- shackleton-0.0.2/PKG-INFO +0 -23
- shackleton-0.0.2/README.md +0 -7
- shackleton-0.0.2/pyproject.toml +0 -22
- shackleton-0.0.2/shackleton/__init__.py +0 -5
- shackleton-0.0.2/shackleton/core.py +0 -120
- shackleton-0.0.2/shackleton/tests/test_core.py +0 -56
- {shackleton-0.0.2 → shackleton-1.2.0}/LICENSE +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Python Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
push:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: astral-sh/setup-uv@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: '3.12'
|
|
19
|
+
enable-cache: true
|
|
20
|
+
- run: uv tool install branthebuilder
|
|
21
|
+
- run: branb test
|
|
@@ -0,0 +1,15 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shackleton
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Polars-based persistent tabular store backed by Parquet or Arrow IPC files
|
|
5
|
+
Project-URL: Homepage, https://github.com/endremborza/shackleton
|
|
6
|
+
Author-email: Endre Márk Borza <endremborza@gmail.com>
|
|
7
|
+
License: Copyright 2023 Endre Márk Borza
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
12
|
+
|
|
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
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: polars
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# shackleton
|
|
20
|
+
|
|
21
|
+
[](https://pypi.org/project/shackleton/)
|
|
22
|
+
|
|
23
|
+
Persistent tabular store backed by Parquet or Arrow IPC, built on [polars](https://pola.rs).
|
|
24
|
+
|
|
25
|
+
- **Partitioned writes** — by column value or xxhash bucket
|
|
26
|
+
- **Sorted merge** — maintain sorted order on extend via `id_col`
|
|
27
|
+
- **Lazy reads** — returns `pl.LazyFrame` so polars can push down predicates
|
|
28
|
+
- **Schema evolution** — diagonal concat handles mismatched columns transparently
|
|
29
|
+
- **Thread-safe** — per-directory stdlib locks; polars I/O releases the GIL
|
|
30
|
+
|
|
31
|
+
Single dependency: `polars`.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
pip install shackleton
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
import polars as pl
|
|
48
|
+
from shackleton import TableRepo
|
|
49
|
+
|
|
50
|
+
repo = TableRepo(Path("/tmp/mydata"))
|
|
51
|
+
|
|
52
|
+
# Write
|
|
53
|
+
repo.extend(pl.DataFrame({"id": [1, 2, 3], "value": [10.0, 20.0, 30.0]}))
|
|
54
|
+
repo.extend(pl.DataFrame({"id": [4, 5], "value": [40.0, 50.0]}))
|
|
55
|
+
|
|
56
|
+
# Read
|
|
57
|
+
df = repo.get_full_df() # pl.DataFrame — all rows
|
|
58
|
+
lf = repo.get_full_lf() # pl.LazyFrame — push down filters yourself
|
|
59
|
+
|
|
60
|
+
# Overwrite everything
|
|
61
|
+
repo.replace_all(pl.DataFrame({"id": [99], "value": [0.0]}))
|
|
62
|
+
|
|
63
|
+
# Delete all files
|
|
64
|
+
repo.purge()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Column partitioning
|
|
70
|
+
|
|
71
|
+
Data is split into hive-style directories (`col=val/data.parquet`).
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
repo = TableRepo(Path("/tmp/events"), partition_cols=["region", "env"])
|
|
75
|
+
|
|
76
|
+
repo.extend(pl.DataFrame({
|
|
77
|
+
"region": ["us", "us", "eu"],
|
|
78
|
+
"env": ["prod", "dev", "prod"],
|
|
79
|
+
"count": [100, 50, 80],
|
|
80
|
+
}))
|
|
81
|
+
|
|
82
|
+
# Read only us/prod — no full scan
|
|
83
|
+
df = repo.get_partition_df({"region": "us", "env": "prod"})
|
|
84
|
+
|
|
85
|
+
# Wildcard: all eu partitions
|
|
86
|
+
df_eu = repo.get_partition_df({"region": "eu"})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Hash partitioning
|
|
92
|
+
|
|
93
|
+
When partition values have unbounded cardinality, hash into fixed buckets instead.
|
|
94
|
+
Uses polars' native xxhash — vectorised, GIL-free.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from shackleton import HashPartitioner
|
|
98
|
+
|
|
99
|
+
hp = HashPartitioner(col="user_id", num_groups=128)
|
|
100
|
+
repo = TableRepo(Path("/tmp/users"), partition_cols=hp)
|
|
101
|
+
|
|
102
|
+
repo.extend(pl.DataFrame({
|
|
103
|
+
"user_id": ["alice", "bob", "carol", "dave"],
|
|
104
|
+
"score": [0.9, 0.7, 0.8, 0.6],
|
|
105
|
+
}))
|
|
106
|
+
|
|
107
|
+
df = repo.get_full_df()
|
|
108
|
+
# The hash key column is stripped — only original columns are stored
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Deduplication with `dedup_cols`
|
|
114
|
+
|
|
115
|
+
When `dedup_cols` is set, each `extend` drops duplicate rows on those columns
|
|
116
|
+
after merging with existing data — only the first occurrence is kept. Combine
|
|
117
|
+
with `id_col` for sorted-merge dedup; use alone for unordered dedup.
|
|
118
|
+
Not supported with `max_records > 0` (raises at construction time).
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
repo = TableRepo(Path("/tmp/events"), dedup_cols=["event_id"])
|
|
122
|
+
|
|
123
|
+
repo.extend(pl.DataFrame({"event_id": [1, 2, 3], "val": [10, 20, 30]}))
|
|
124
|
+
# Re-ingest with overlapping IDs — existing rows are kept, new ones appended
|
|
125
|
+
repo.extend(pl.DataFrame({"event_id": [2, 4], "val": [99, 40]}))
|
|
126
|
+
|
|
127
|
+
repo.get_full_df().shape # (4, 2) — event_id 2 kept once
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Sorted merge with `id_col`
|
|
133
|
+
|
|
134
|
+
When `id_col` is set, each extend keeps the file sorted by that column
|
|
135
|
+
(when `max_records == 0`) and `replace_records` does an upsert: matching IDs
|
|
136
|
+
are updated, new IDs appended.
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
repo = TableRepo(Path("/tmp/prices"), id_col="ticker")
|
|
140
|
+
|
|
141
|
+
repo.extend(pl.DataFrame({
|
|
142
|
+
"ticker": ["AAPL", "GOOG", "MSFT"],
|
|
143
|
+
"price": [180.0, 140.0, 380.0],
|
|
144
|
+
}))
|
|
145
|
+
|
|
146
|
+
# Update GOOG, add TSLA
|
|
147
|
+
repo.replace_records(pl.DataFrame({
|
|
148
|
+
"ticker": ["GOOG", "TSLA"],
|
|
149
|
+
"price": [145.0, 220.0],
|
|
150
|
+
}))
|
|
151
|
+
|
|
152
|
+
repo.get_full_df().sort("ticker")
|
|
153
|
+
# ┌────────┬───────┐
|
|
154
|
+
# │ ticker ┆ price │
|
|
155
|
+
# ╞════════╪═══════╡
|
|
156
|
+
# │ AAPL ┆ 180.0 │
|
|
157
|
+
# │ GOOG ┆ 145.0 │
|
|
158
|
+
# │ MSFT ┆ 380.0 │
|
|
159
|
+
# │ TSLA ┆ 220.0 │
|
|
160
|
+
# └────────┴───────┘
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## File size limits with `max_records`
|
|
166
|
+
|
|
167
|
+
Cap each file at N rows. Useful when downstream tools (Spark, DuckDB) benefit
|
|
168
|
+
from many smaller files, or when partial reads matter.
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
repo = TableRepo(Path("/tmp/logs"), max_records=100_000)
|
|
172
|
+
|
|
173
|
+
for batch in my_log_stream():
|
|
174
|
+
repo.extend(batch)
|
|
175
|
+
# Creates file-00000000000000000000.parquet, file-00000000000000000001.parquet, …
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Streaming ingestion with batch writers
|
|
181
|
+
|
|
182
|
+
For row-at-a-time or small-DataFrame pipelines: accumulate in memory, flush in
|
|
183
|
+
batches.
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
# Dict record writer — for APIs / scrapers
|
|
187
|
+
with repo.get_extending_record_writer(batch_size=50_000) as writer:
|
|
188
|
+
for event in event_stream():
|
|
189
|
+
writer.add_to_batch({"ts": event.ts, "value": event.value})
|
|
190
|
+
# Final partial batch is flushed on context exit
|
|
191
|
+
|
|
192
|
+
# DataFrame writer — for chunked ETL
|
|
193
|
+
with repo.get_extending_df_writer(batch_size=500_000) as writer:
|
|
194
|
+
for chunk_df in chunked_source():
|
|
195
|
+
writer.add_to_batch(chunk_df)
|
|
196
|
+
|
|
197
|
+
# Replacing variants — upsert on each flush
|
|
198
|
+
with repo.get_replacing_record_writer(batch_size=10_000) as writer:
|
|
199
|
+
for record in updates():
|
|
200
|
+
writer.add_to_batch(record)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Schema evolution
|
|
206
|
+
|
|
207
|
+
Columns added in later writes fill missing values with `null` in earlier files.
|
|
208
|
+
No migration step needed.
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
repo = TableRepo(Path("/tmp/evolving"))
|
|
212
|
+
repo.extend(pl.DataFrame({"a": [1, 2], "b": [3, 4]}))
|
|
213
|
+
repo.extend(pl.DataFrame({"a": [5], "c": [6]})) # new column c
|
|
214
|
+
|
|
215
|
+
df = repo.get_full_df()
|
|
216
|
+
# shape: (3, 3) — columns a, b, c — nulls where data didn't exist
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Partition-level overwrite
|
|
222
|
+
|
|
223
|
+
`replace_partition` atomically clears and rewrites only the partitions covered
|
|
224
|
+
by the DataFrame. `purge_partition` deletes matching files without rewriting.
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
repo = TableRepo(Path("/tmp/events"), partition_cols=["date"])
|
|
228
|
+
repo.extend(pl.DataFrame({
|
|
229
|
+
"date": ["2024-01-01", "2024-01-01", "2024-01-02"],
|
|
230
|
+
"val": [1, 2, 3],
|
|
231
|
+
}))
|
|
232
|
+
|
|
233
|
+
# Replace just the 2024-01-01 partition with corrected data
|
|
234
|
+
repo.replace_partition(pl.DataFrame({
|
|
235
|
+
"date": ["2024-01-01"],
|
|
236
|
+
"val": [99],
|
|
237
|
+
}))
|
|
238
|
+
|
|
239
|
+
# Or wipe it without rewriting
|
|
240
|
+
repo.purge_partition({"date": "2024-01-01"})
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## Compression
|
|
246
|
+
|
|
247
|
+
Control the codec and, for Parquet, the compression level.
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
repo = TableRepo(Path("/tmp/data"), compression="zstd", compression_level=3)
|
|
251
|
+
repo_ipc = TableRepo(Path("/tmp/fast"), ipc=True, compression="lz4")
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Arrow IPC format
|
|
257
|
+
|
|
258
|
+
Slightly faster reads for local workloads that don't need Parquet compatibility.
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
repo = TableRepo(Path("/tmp/fast"), ipc=True) # writes .arrow files
|
|
262
|
+
repo.extend(df)
|
|
263
|
+
df = repo.get_full_df()
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Lazy reads and predicate pushdown
|
|
269
|
+
|
|
270
|
+
`get_full_lf()` and `get_partition_lf()` return `pl.LazyFrame`. Polars will
|
|
271
|
+
push predicates and column selections into the scan.
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
result = (
|
|
275
|
+
repo.get_full_lf()
|
|
276
|
+
.filter(pl.col("region") == "us")
|
|
277
|
+
.select(["ticker", "price"])
|
|
278
|
+
.sort("price", descending=True)
|
|
279
|
+
.limit(10)
|
|
280
|
+
.collect()
|
|
281
|
+
)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Parallel partition processing with `map_partitions`
|
|
287
|
+
|
|
288
|
+
Apply a function to each partition's collected DataFrame in parallel. All files
|
|
289
|
+
within a partition are combined before your function is called, so it always
|
|
290
|
+
receives one complete DataFrame per partition.
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
repo = TableRepo(Path("/tmp/events"), partition_cols=["region"])
|
|
294
|
+
repo.extend(pl.DataFrame({
|
|
295
|
+
"region": ["us", "us", "eu", "eu", "ap"],
|
|
296
|
+
"revenue": [100.0, 200.0, 150.0, 50.0, 300.0],
|
|
297
|
+
}))
|
|
298
|
+
|
|
299
|
+
# Sequential (workers=None, default)
|
|
300
|
+
totals = repo.map_partitions(lambda df: df["revenue"].sum())
|
|
301
|
+
# e.g. [300.0, 200.0, 300.0] — one value per partition
|
|
302
|
+
|
|
303
|
+
# Parallel with auto-detected thread count
|
|
304
|
+
totals = repo.map_partitions(lambda df: df["revenue"].sum(), workers=0)
|
|
305
|
+
|
|
306
|
+
# Parallel with fixed thread pool
|
|
307
|
+
totals = repo.map_partitions(lambda df: df["revenue"].sum(), workers=4)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The `workers` parameter controls parallelism:
|
|
311
|
+
|
|
312
|
+
| `workers` | Behaviour |
|
|
313
|
+
|---|---|
|
|
314
|
+
| `None` | Sequential — no thread pool |
|
|
315
|
+
| `0` | Thread pool sized to `os.cpu_count()` |
|
|
316
|
+
| `> 0` | Thread pool of exactly that size |
|
|
317
|
+
|
|
318
|
+
Because polars I/O releases the GIL, threads overlap on I/O with no contention.
|
|
319
|
+
|
|
320
|
+
The function can return any type; results preserve partition order.
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
# Compute per-partition summary stats
|
|
324
|
+
def summarise(df: pl.DataFrame) -> dict:
|
|
325
|
+
return {"n": len(df), "mean": df["revenue"].mean()}
|
|
326
|
+
|
|
327
|
+
stats = repo.map_partitions(summarise, workers=0)
|
|
328
|
+
# [{"n": 2, "mean": 150.0}, {"n": 2, "mean": 100.0}, {"n": 1, "mean": 300.0}]
|
|
329
|
+
|
|
330
|
+
# Collect into a single DataFrame
|
|
331
|
+
import polars as pl
|
|
332
|
+
result = pl.DataFrame(repo.map_partitions(summarise, workers=0))
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## Performance
|
|
338
|
+
|
|
339
|
+
Benchmarked on a single core, NVMe SSD, 1 million rows (4 columns: i64, f64, str, str).
|
|
340
|
+
Run `uv run bench.py` to reproduce.
|
|
341
|
+
|
|
342
|
+
| Scenario | Throughput |
|
|
343
|
+
|---|---|
|
|
344
|
+
| extend — single file | ~33M rows/s |
|
|
345
|
+
| extend — sorted merge (`id_col`) | ~49M rows/s |
|
|
346
|
+
| extend — 26 column partitions | ~11M rows/s |
|
|
347
|
+
| extend — 64 hash partitions | ~7M rows/s |
|
|
348
|
+
| extend — `max_records=10k` | ~7M rows/s |
|
|
349
|
+
| extend — IPC format | ~23M rows/s |
|
|
350
|
+
| read full (collect) | ~131M rows/s |
|
|
351
|
+
| read full (lazy + filter) | ~170M rows/s |
|
|
352
|
+
| read single partition (1 of 26) | ~18M rows/s |
|
|
353
|
+
| read full — IPC format | ~102M rows/s |
|
|
354
|
+
| replace_records (10k upserts) | ~107k rows/s |
|
|
355
|
+
|
|
356
|
+
`replace_records` does a full read-merge-rewrite, so throughput scales with
|
|
357
|
+
existing file size, not batch size. Use partitioned repos to bound the merge scope.
|