quickhouse 0.2.0__tar.gz → 0.2.3__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.
- {quickhouse-0.2.0 → quickhouse-0.2.3}/Cargo.lock +10 -7
- {quickhouse-0.2.0 → quickhouse-0.2.3}/Cargo.toml +3 -2
- {quickhouse-0.2.0 → quickhouse-0.2.3}/PKG-INFO +8 -5
- {quickhouse-0.2.0 → quickhouse-0.2.3}/README.md +7 -4
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/Cargo.toml +2 -1
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/config.rs +28 -6
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/ddl.rs +1 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/lib.rs +1 -0
- quickhouse-0.2.3/crates/quickhouse-core/src/memory.rs +146 -0
- quickhouse-0.2.3/crates/quickhouse-core/src/sink/clickhouse.rs +297 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/sync.rs +112 -56
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/transform.rs +1 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-py/src/lib.rs +6 -2
- {quickhouse-0.2.0 → quickhouse-0.2.3}/pyproject.toml +1 -1
- {quickhouse-0.2.0 → quickhouse-0.2.3}/python/quickhouse/_quickhouse.pyi +17 -2
- quickhouse-0.2.0/crates/quickhouse-core/src/sink/clickhouse.rs +0 -166
- {quickhouse-0.2.0 → quickhouse-0.2.3}/LICENSE +0 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/decode.rs +0 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/decode_bigquery.rs +0 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/decode_mysql.rs +0 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/error.rs +0 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/sink/mod.rs +0 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/source/bigquery.rs +0 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/source/mod.rs +0 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/source/mysql.rs +0 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/source/postgres.rs +0 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/types.rs +0 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-py/Cargo.toml +0 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/python/quickhouse/__init__.py +0 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/python/quickhouse/progress.py +0 -0
- {quickhouse-0.2.0 → quickhouse-0.2.3}/python/quickhouse/py.typed +0 -0
|
@@ -644,6 +644,8 @@ dependencies = [
|
|
|
644
644
|
"compression-core",
|
|
645
645
|
"flate2",
|
|
646
646
|
"memchr",
|
|
647
|
+
"zstd",
|
|
648
|
+
"zstd-safe",
|
|
647
649
|
]
|
|
648
650
|
|
|
649
651
|
[[package]]
|
|
@@ -2504,15 +2506,15 @@ dependencies = [
|
|
|
2504
2506
|
|
|
2505
2507
|
[[package]]
|
|
2506
2508
|
name = "quickhouse-core"
|
|
2507
|
-
version = "0.2.
|
|
2509
|
+
version = "0.2.3"
|
|
2508
2510
|
dependencies = [
|
|
2509
2511
|
"anyhow",
|
|
2510
2512
|
"arrow",
|
|
2511
2513
|
"arrow-array",
|
|
2512
2514
|
"arrow-schema",
|
|
2515
|
+
"async-compression",
|
|
2513
2516
|
"bytes",
|
|
2514
2517
|
"chrono",
|
|
2515
|
-
"flate2",
|
|
2516
2518
|
"futures",
|
|
2517
2519
|
"google-cloud-bigquery",
|
|
2518
2520
|
"google-cloud-googleapis",
|
|
@@ -2527,13 +2529,14 @@ dependencies = [
|
|
|
2527
2529
|
"tokio",
|
|
2528
2530
|
"tokio-postgres",
|
|
2529
2531
|
"tokio-postgres-rustls",
|
|
2532
|
+
"tokio-util",
|
|
2530
2533
|
"tracing",
|
|
2531
2534
|
"webpki-roots 0.26.11",
|
|
2532
2535
|
]
|
|
2533
2536
|
|
|
2534
2537
|
[[package]]
|
|
2535
2538
|
name = "quickhouse-py"
|
|
2536
|
-
version = "0.2.
|
|
2539
|
+
version = "0.2.3"
|
|
2537
2540
|
dependencies = [
|
|
2538
2541
|
"pyo3",
|
|
2539
2542
|
"quickhouse-core",
|
|
@@ -3368,9 +3371,9 @@ dependencies = [
|
|
|
3368
3371
|
|
|
3369
3372
|
[[package]]
|
|
3370
3373
|
name = "tokio"
|
|
3371
|
-
version = "1.
|
|
3374
|
+
version = "1.53.0"
|
|
3372
3375
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
3373
|
-
checksum = "
|
|
3376
|
+
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
|
|
3374
3377
|
dependencies = [
|
|
3375
3378
|
"bytes",
|
|
3376
3379
|
"libc",
|
|
@@ -3384,9 +3387,9 @@ dependencies = [
|
|
|
3384
3387
|
|
|
3385
3388
|
[[package]]
|
|
3386
3389
|
name = "tokio-macros"
|
|
3387
|
-
version = "2.7.
|
|
3390
|
+
version = "2.7.1"
|
|
3388
3391
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
3389
|
-
checksum = "
|
|
3392
|
+
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
|
|
3390
3393
|
dependencies = [
|
|
3391
3394
|
"proc-macro2",
|
|
3392
3395
|
"quote",
|
|
@@ -3,7 +3,7 @@ resolver = "2"
|
|
|
3
3
|
members = ["crates/quickhouse-core", "crates/quickhouse-py"]
|
|
4
4
|
|
|
5
5
|
[workspace.package]
|
|
6
|
-
version = "0.2.
|
|
6
|
+
version = "0.2.3"
|
|
7
7
|
edition = "2021"
|
|
8
8
|
license = "MIT"
|
|
9
9
|
authors = ["quickhouse contributors"]
|
|
@@ -19,6 +19,8 @@ arrow-array = "53"
|
|
|
19
19
|
arrow-schema = "53"
|
|
20
20
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "gzip"] }
|
|
21
21
|
bytes = "1"
|
|
22
|
+
tokio-util = { version = "0.7", features = ["io"] }
|
|
23
|
+
async-compression = { version = "0.4", features = ["tokio", "gzip", "zstd"] }
|
|
22
24
|
thiserror = "1"
|
|
23
25
|
anyhow = "1"
|
|
24
26
|
tracing = "0.1"
|
|
@@ -27,7 +29,6 @@ serde = { version = "1", features = ["derive"] }
|
|
|
27
29
|
serde_json = "1"
|
|
28
30
|
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
|
29
31
|
uuid = { version = "1", features = ["v4"] }
|
|
30
|
-
flate2 = "1"
|
|
31
32
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
|
32
33
|
tokio-postgres-rustls = "0.13"
|
|
33
34
|
webpki-roots = "0.26"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quickhouse
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -73,9 +73,11 @@ either way.
|
|
|
73
73
|
| --- | --- |
|
|
74
74
|
| Deserialization | PostgreSQL: binary `COPY` decoded straight into Arrow in Rust. MySQL: `mysql_async`'s typed binary-protocol rows appended straight into Arrow builders. BigQuery: the Storage Read API's wire format is already Arrow. Either way, no per-row Python objects. |
|
|
75
75
|
| Parallelism | Postgres/MySQL: table split into key ranges, one connection + Tokio task per partition. BigQuery: `parallelism` is passed as a stream-count hint to BigQuery's own server-side parallel preparation (see the BigQuery note below). |
|
|
76
|
-
|
|
|
76
|
+
| Pipelining | Decoding overlaps with uploading: each finished batch's insert is spawned as a background task, so a partition keeps reading/decoding while previous batches are still being compressed and sent, instead of stalling on each HTTP round-trip. |
|
|
77
|
+
| Memory | A single hard ceiling, `max_memory_bytes`, bounds **total** in-flight batch memory (across every partition and every upload in flight), measured against each batch's real Arrow allocation. When the ceiling is reached, decoding blocks (backpressure) until uploads drain — so peak RSS stays bounded regardless of `parallelism`, row width, or partition skew. `batch_rows`/`batch_bytes` separately control how big each individual batch is. |
|
|
77
78
|
| GIL | Entire transfer runs inside `Python::allow_threads`; the GIL is only touched for `on_progress` |
|
|
78
|
-
| Insert | Arrow IPC stream ingested by ClickHouse's native `ArrowStream` format,
|
|
79
|
+
| Insert | Arrow IPC stream ingested by ClickHouse's native `ArrowStream` format, streamed to the wire with zstd (default), gzip, or no compression — the compressed body is produced incrementally rather than buffered in full. |
|
|
80
|
+
| Resilience | Each insert retries transient failures (dropped/reset connections, timeouts, HTTP 5xx/429) with exponential backoff — up to 4 attempts — so a single network blip over a long WAN transfer doesn't abort the whole run. Deterministic 4xx errors (bad SQL, auth) fail fast without retrying. Retry is at-least-once: a lost ack after a committed batch can duplicate that one batch — harmless in incremental mode (`ReplacingMergeTree` dedupes by key), possible dupes for one batch in full-refresh into a plain `MergeTree`. |
|
|
79
81
|
|
|
80
82
|
## Sync modes
|
|
81
83
|
|
|
@@ -170,8 +172,9 @@ cargo test -p quickhouse-core
|
|
|
170
172
|
| `create_if_missing` | Auto-run generated `CREATE TABLE` when the destination is absent |
|
|
171
173
|
| `engine`, `order_by`, `partition_by`, `primary_key` | DDL knobs (sensible defaults per mode) |
|
|
172
174
|
| `parallelism` | Number of concurrent partition streams |
|
|
173
|
-
| `batch_rows` | Max rows
|
|
174
|
-
| `batch_bytes` |
|
|
175
|
+
| `batch_rows` | Max rows in each Arrow batch / insert — a per-batch granularity knob (round-trips vs. per-insert overhead), **not** the memory ceiling |
|
|
176
|
+
| `batch_bytes` | Also cap each individual batch at this many estimated source bytes, even under `batch_rows` — default 4 MiB; keeps a single wide-row batch from growing large. `0` disables (row count alone sizes the batch) |
|
|
177
|
+
| `max_memory_bytes` | **The memory ceiling.** Hard cap on total in-flight Arrow batch memory across all partitions and all in-flight uploads, measured against each batch's real allocation. Decoding blocks (backpressure) when reached, so peak RSS stays bounded independent of `parallelism`/row width. Default 512 MiB; `0` = unbounded |
|
|
175
178
|
| `partition_column` | Integer column to range-split on (defaults to first `key`) |
|
|
176
179
|
| `type_overrides` | Per-column ClickHouse type (e.g. `{"qty": "Decimal(18, 3)"}`) |
|
|
177
180
|
| `rename` | Source → destination column renames |
|
|
@@ -43,9 +43,11 @@ either way.
|
|
|
43
43
|
| --- | --- |
|
|
44
44
|
| Deserialization | PostgreSQL: binary `COPY` decoded straight into Arrow in Rust. MySQL: `mysql_async`'s typed binary-protocol rows appended straight into Arrow builders. BigQuery: the Storage Read API's wire format is already Arrow. Either way, no per-row Python objects. |
|
|
45
45
|
| Parallelism | Postgres/MySQL: table split into key ranges, one connection + Tokio task per partition. BigQuery: `parallelism` is passed as a stream-count hint to BigQuery's own server-side parallel preparation (see the BigQuery note below). |
|
|
46
|
-
|
|
|
46
|
+
| Pipelining | Decoding overlaps with uploading: each finished batch's insert is spawned as a background task, so a partition keeps reading/decoding while previous batches are still being compressed and sent, instead of stalling on each HTTP round-trip. |
|
|
47
|
+
| Memory | A single hard ceiling, `max_memory_bytes`, bounds **total** in-flight batch memory (across every partition and every upload in flight), measured against each batch's real Arrow allocation. When the ceiling is reached, decoding blocks (backpressure) until uploads drain — so peak RSS stays bounded regardless of `parallelism`, row width, or partition skew. `batch_rows`/`batch_bytes` separately control how big each individual batch is. |
|
|
47
48
|
| GIL | Entire transfer runs inside `Python::allow_threads`; the GIL is only touched for `on_progress` |
|
|
48
|
-
| Insert | Arrow IPC stream ingested by ClickHouse's native `ArrowStream` format,
|
|
49
|
+
| Insert | Arrow IPC stream ingested by ClickHouse's native `ArrowStream` format, streamed to the wire with zstd (default), gzip, or no compression — the compressed body is produced incrementally rather than buffered in full. |
|
|
50
|
+
| Resilience | Each insert retries transient failures (dropped/reset connections, timeouts, HTTP 5xx/429) with exponential backoff — up to 4 attempts — so a single network blip over a long WAN transfer doesn't abort the whole run. Deterministic 4xx errors (bad SQL, auth) fail fast without retrying. Retry is at-least-once: a lost ack after a committed batch can duplicate that one batch — harmless in incremental mode (`ReplacingMergeTree` dedupes by key), possible dupes for one batch in full-refresh into a plain `MergeTree`. |
|
|
49
51
|
|
|
50
52
|
## Sync modes
|
|
51
53
|
|
|
@@ -140,8 +142,9 @@ cargo test -p quickhouse-core
|
|
|
140
142
|
| `create_if_missing` | Auto-run generated `CREATE TABLE` when the destination is absent |
|
|
141
143
|
| `engine`, `order_by`, `partition_by`, `primary_key` | DDL knobs (sensible defaults per mode) |
|
|
142
144
|
| `parallelism` | Number of concurrent partition streams |
|
|
143
|
-
| `batch_rows` | Max rows
|
|
144
|
-
| `batch_bytes` |
|
|
145
|
+
| `batch_rows` | Max rows in each Arrow batch / insert — a per-batch granularity knob (round-trips vs. per-insert overhead), **not** the memory ceiling |
|
|
146
|
+
| `batch_bytes` | Also cap each individual batch at this many estimated source bytes, even under `batch_rows` — default 4 MiB; keeps a single wide-row batch from growing large. `0` disables (row count alone sizes the batch) |
|
|
147
|
+
| `max_memory_bytes` | **The memory ceiling.** Hard cap on total in-flight Arrow batch memory across all partitions and all in-flight uploads, measured against each batch's real allocation. Decoding blocks (backpressure) when reached, so peak RSS stays bounded independent of `parallelism`/row width. Default 512 MiB; `0` = unbounded |
|
|
145
148
|
| `partition_column` | Integer column to range-split on (defaults to first `key`) |
|
|
146
149
|
| `type_overrides` | Per-column ClickHouse type (e.g. `{"qty": "Decimal(18, 3)"}`) |
|
|
147
150
|
| `rename` | Source → destination column renames |
|
|
@@ -17,12 +17,13 @@ arrow-array.workspace = true
|
|
|
17
17
|
arrow-schema.workspace = true
|
|
18
18
|
reqwest.workspace = true
|
|
19
19
|
bytes.workspace = true
|
|
20
|
+
tokio-util.workspace = true
|
|
21
|
+
async-compression.workspace = true
|
|
20
22
|
thiserror.workspace = true
|
|
21
23
|
tracing.workspace = true
|
|
22
24
|
serde.workspace = true
|
|
23
25
|
serde_json.workspace = true
|
|
24
26
|
chrono.workspace = true
|
|
25
|
-
flate2.workspace = true
|
|
26
27
|
rustls.workspace = true
|
|
27
28
|
tokio-postgres-rustls.workspace = true
|
|
28
29
|
webpki-roots.workspace = true
|
|
@@ -73,7 +73,7 @@ pub struct ClickHouseConfig {
|
|
|
73
73
|
pub database: String,
|
|
74
74
|
pub user: String,
|
|
75
75
|
pub password: String,
|
|
76
|
-
/// `"none" | "gzip"` — HTTP body compression for inserts.
|
|
76
|
+
/// `"none" | "gzip" | "zstd"` — HTTP body compression for inserts.
|
|
77
77
|
pub compression: Compression,
|
|
78
78
|
}
|
|
79
79
|
|
|
@@ -81,6 +81,7 @@ pub struct ClickHouseConfig {
|
|
|
81
81
|
pub enum Compression {
|
|
82
82
|
None,
|
|
83
83
|
Gzip,
|
|
84
|
+
Zstd,
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
/// Full-refresh reloads everything; Incremental appends rows past a watermark.
|
|
@@ -118,13 +119,26 @@ pub struct TransferConfig {
|
|
|
118
119
|
|
|
119
120
|
// ---- parallelism / batching ----
|
|
120
121
|
pub parallelism: usize,
|
|
122
|
+
/// Per-batch granularity: flush a RecordBatch once it reaches this many
|
|
123
|
+
/// rows. Controls how big each individual insert is (a throughput/overhead
|
|
124
|
+
/// knob), NOT the overall memory ceiling — that's `max_memory_bytes`.
|
|
121
125
|
pub batch_rows: usize,
|
|
122
|
-
///
|
|
123
|
-
/// reach this many, even if `batch_rows` hasn't been hit yet
|
|
124
|
-
///
|
|
125
|
-
///
|
|
126
|
-
///
|
|
126
|
+
/// Per-batch granularity: also flush once a batch's accumulated (estimated)
|
|
127
|
+
/// source bytes reach this many, even if `batch_rows` hasn't been hit yet,
|
|
128
|
+
/// so a single batch of wide rows doesn't grow unbounded. `0` disables this
|
|
129
|
+
/// per-batch byte cap (row count alone decides batch size). This bounds one
|
|
130
|
+
/// *batch*; the total in-flight memory across all partitions and in-flight
|
|
131
|
+
/// inserts is bounded separately by `max_memory_bytes`.
|
|
127
132
|
pub batch_bytes: usize,
|
|
133
|
+
/// Hard ceiling on total in-flight Arrow batch memory across the whole
|
|
134
|
+
/// transfer — every partition's decoded-but-not-yet-sent batches plus all
|
|
135
|
+
/// batches currently being uploaded. Enforced against each batch's real
|
|
136
|
+
/// `RecordBatch::get_array_memory_size()`, so it holds regardless of
|
|
137
|
+
/// `parallelism`, row width, or partition skew. When the ceiling is
|
|
138
|
+
/// reached, decoding blocks (backpressure) until in-flight inserts drain.
|
|
139
|
+
/// `0` disables the ceiling (unbounded — memory then scales with
|
|
140
|
+
/// parallelism and batch size, the pre-`max_memory_bytes` behavior).
|
|
141
|
+
pub max_memory_bytes: usize,
|
|
128
142
|
/// Column used to split the table into parallel range partitions.
|
|
129
143
|
/// Defaults to the first `key` column, else the sync falls back to a single stream.
|
|
130
144
|
pub partition_column: Option<String>,
|
|
@@ -169,6 +183,14 @@ impl TransferConfig {
|
|
|
169
183
|
if self.batch_rows == 0 {
|
|
170
184
|
return Err(EtlError::config("batch_rows must be >= 1"));
|
|
171
185
|
}
|
|
186
|
+
// A non-zero ceiling must at least admit a single batch's worth of
|
|
187
|
+
// rows-ish of memory; guard against pathologically tiny values that
|
|
188
|
+
// would stall every transfer. (0 = unbounded, always allowed.)
|
|
189
|
+
if self.max_memory_bytes != 0 && self.max_memory_bytes < 64 * 1024 {
|
|
190
|
+
return Err(EtlError::config(
|
|
191
|
+
"max_memory_bytes must be 0 (unbounded) or >= 65536",
|
|
192
|
+
));
|
|
193
|
+
}
|
|
172
194
|
Ok(())
|
|
173
195
|
}
|
|
174
196
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
//! Process-wide memory budget for in-flight Arrow batches.
|
|
2
|
+
//!
|
|
3
|
+
//! The transfer pipeline decodes batches on one side and uploads them to
|
|
4
|
+
//! ClickHouse concurrently on the other. Without a ceiling, a fast source and
|
|
5
|
+
//! a slow sink would let decoded-but-not-yet-sent batches pile up unbounded.
|
|
6
|
+
//!
|
|
7
|
+
//! [`MemoryBudget`] is a byte-denominated semaphore (1 permit = 1 byte): a
|
|
8
|
+
//! batch reserves permits equal to its real [`RecordBatch::get_array_memory_size`]
|
|
9
|
+
//! before it may be handed to a send task, and releases them when the send
|
|
10
|
+
//! completes. This makes peak in-flight memory a single honest number
|
|
11
|
+
//! (`max_memory_bytes`) that holds regardless of parallelism, row width, or
|
|
12
|
+
//! partition skew — unlike a per-batch row/byte heuristic, whose true peak is
|
|
13
|
+
//! an emergent `parallelism × batch_size`.
|
|
14
|
+
//!
|
|
15
|
+
//! [`RecordBatch::get_array_memory_size`]: arrow_array::RecordBatch::get_array_memory_size
|
|
16
|
+
|
|
17
|
+
use std::sync::Arc;
|
|
18
|
+
|
|
19
|
+
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
|
20
|
+
|
|
21
|
+
/// tokio's `Semaphore` caps total permits at `usize::MAX >> 3`, and
|
|
22
|
+
/// `acquire_many_owned` takes a `u32`. Batches are MiB-scale so a single
|
|
23
|
+
/// reservation never approaches this, but clamp defensively so an absurd
|
|
24
|
+
/// size can never panic or wrap.
|
|
25
|
+
const MAX_PERMITS: u32 = u32::MAX;
|
|
26
|
+
|
|
27
|
+
/// A shared ceiling on total in-flight Arrow batch memory.
|
|
28
|
+
///
|
|
29
|
+
/// Cloneable and cheap to share across tasks (an `Arc` inside). `None`-like
|
|
30
|
+
/// "unbounded" is represented by constructing with `max_bytes == 0`.
|
|
31
|
+
#[derive(Clone)]
|
|
32
|
+
pub struct MemoryBudget {
|
|
33
|
+
/// `None` = unbounded (no accounting at all).
|
|
34
|
+
sem: Option<Arc<Semaphore>>,
|
|
35
|
+
/// The configured ceiling, retained so a single oversized batch can be
|
|
36
|
+
/// clamped to "reserve the whole budget" rather than deadlock.
|
|
37
|
+
total: usize,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
impl MemoryBudget {
|
|
41
|
+
/// Create a budget of `max_bytes`. `0` means unbounded.
|
|
42
|
+
pub fn new(max_bytes: usize) -> Self {
|
|
43
|
+
let sem = if max_bytes == 0 {
|
|
44
|
+
None
|
|
45
|
+
} else {
|
|
46
|
+
// Semaphore permit count is a usize; cap at the tokio maximum.
|
|
47
|
+
let permits = max_bytes.min(Semaphore::MAX_PERMITS);
|
|
48
|
+
Some(Arc::new(Semaphore::new(permits)))
|
|
49
|
+
};
|
|
50
|
+
MemoryBudget {
|
|
51
|
+
sem,
|
|
52
|
+
total: max_bytes,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Whether this budget actually enforces a ceiling.
|
|
57
|
+
pub fn is_bounded(&self) -> bool {
|
|
58
|
+
self.sem.is_some()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Reserve room for a batch of `size` bytes, awaiting until enough is free.
|
|
62
|
+
///
|
|
63
|
+
/// Returns a permit whose `Drop` releases the reservation — hold it for as
|
|
64
|
+
/// long as the batch occupies memory (i.e. until its upload finishes).
|
|
65
|
+
/// A batch larger than the whole budget is clamped to the full budget so
|
|
66
|
+
/// it still runs (alone) rather than deadlocking forever.
|
|
67
|
+
pub async fn reserve(&self, size: usize) -> Reservation {
|
|
68
|
+
match &self.sem {
|
|
69
|
+
None => Reservation { _permit: None },
|
|
70
|
+
Some(sem) => {
|
|
71
|
+
let want = size.min(self.total).min(MAX_PERMITS as usize).max(1) as u32;
|
|
72
|
+
// acquire_many_owned only errors if the semaphore is closed,
|
|
73
|
+
// which we never do; unwrap is safe for the lifetime of self.
|
|
74
|
+
let permit = sem
|
|
75
|
+
.clone()
|
|
76
|
+
.acquire_many_owned(want)
|
|
77
|
+
.await
|
|
78
|
+
.expect("memory budget semaphore closed unexpectedly");
|
|
79
|
+
Reservation {
|
|
80
|
+
_permit: Some(permit),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// An outstanding memory reservation. Dropping it returns the bytes to the
|
|
88
|
+
/// budget. Opaque on purpose — callers only need to keep it alive.
|
|
89
|
+
pub struct Reservation {
|
|
90
|
+
_permit: Option<OwnedSemaphorePermit>,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[cfg(test)]
|
|
94
|
+
mod tests {
|
|
95
|
+
use super::*;
|
|
96
|
+
|
|
97
|
+
#[tokio::test]
|
|
98
|
+
async fn unbounded_never_blocks() {
|
|
99
|
+
let budget = MemoryBudget::new(0);
|
|
100
|
+
assert!(!budget.is_bounded());
|
|
101
|
+
// Many large reservations resolve immediately and independently.
|
|
102
|
+
let _a = budget.reserve(10_000_000_000).await;
|
|
103
|
+
let _b = budget.reserve(10_000_000_000).await;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#[tokio::test]
|
|
107
|
+
async fn releases_on_drop() {
|
|
108
|
+
let budget = MemoryBudget::new(1000);
|
|
109
|
+
{
|
|
110
|
+
let _r = budget.reserve(1000).await; // takes the whole budget
|
|
111
|
+
// A second full reservation would block here; instead we drop first.
|
|
112
|
+
}
|
|
113
|
+
// After drop, the full budget is available again — this must not hang.
|
|
114
|
+
let _r2 = budget.reserve(1000).await;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#[tokio::test]
|
|
118
|
+
async fn oversized_batch_still_proceeds() {
|
|
119
|
+
// A batch bigger than the entire budget must not deadlock; it is
|
|
120
|
+
// clamped to the whole budget and runs alone.
|
|
121
|
+
let budget = MemoryBudget::new(1000);
|
|
122
|
+
let _r = budget.reserve(5000).await; // clamped to 1000, resolves
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#[tokio::test]
|
|
126
|
+
async fn blocks_until_released() {
|
|
127
|
+
use std::time::Duration;
|
|
128
|
+
use tokio::time::timeout;
|
|
129
|
+
|
|
130
|
+
let budget = MemoryBudget::new(1000);
|
|
131
|
+
let first = budget.reserve(1000).await; // exhausts the budget
|
|
132
|
+
|
|
133
|
+
// A second reservation cannot complete while `first` is held.
|
|
134
|
+
let second = budget.reserve(1000);
|
|
135
|
+
assert!(
|
|
136
|
+
timeout(Duration::from_millis(50), second).await.is_err(),
|
|
137
|
+
"reservation should block while the budget is fully held"
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Releasing the first frees the budget for a waiter.
|
|
141
|
+
drop(first);
|
|
142
|
+
let _third = timeout(Duration::from_millis(500), budget.reserve(1000))
|
|
143
|
+
.await
|
|
144
|
+
.expect("reservation should proceed after the budget is released");
|
|
145
|
+
}
|
|
146
|
+
}
|