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.
Files changed (31) hide show
  1. {quickhouse-0.2.0 → quickhouse-0.2.3}/Cargo.lock +10 -7
  2. {quickhouse-0.2.0 → quickhouse-0.2.3}/Cargo.toml +3 -2
  3. {quickhouse-0.2.0 → quickhouse-0.2.3}/PKG-INFO +8 -5
  4. {quickhouse-0.2.0 → quickhouse-0.2.3}/README.md +7 -4
  5. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/Cargo.toml +2 -1
  6. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/config.rs +28 -6
  7. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/ddl.rs +1 -0
  8. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/lib.rs +1 -0
  9. quickhouse-0.2.3/crates/quickhouse-core/src/memory.rs +146 -0
  10. quickhouse-0.2.3/crates/quickhouse-core/src/sink/clickhouse.rs +297 -0
  11. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/sync.rs +112 -56
  12. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/transform.rs +1 -0
  13. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-py/src/lib.rs +6 -2
  14. {quickhouse-0.2.0 → quickhouse-0.2.3}/pyproject.toml +1 -1
  15. {quickhouse-0.2.0 → quickhouse-0.2.3}/python/quickhouse/_quickhouse.pyi +17 -2
  16. quickhouse-0.2.0/crates/quickhouse-core/src/sink/clickhouse.rs +0 -166
  17. {quickhouse-0.2.0 → quickhouse-0.2.3}/LICENSE +0 -0
  18. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/decode.rs +0 -0
  19. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/decode_bigquery.rs +0 -0
  20. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/decode_mysql.rs +0 -0
  21. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/error.rs +0 -0
  22. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/sink/mod.rs +0 -0
  23. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/source/bigquery.rs +0 -0
  24. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/source/mod.rs +0 -0
  25. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/source/mysql.rs +0 -0
  26. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/source/postgres.rs +0 -0
  27. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-core/src/types.rs +0 -0
  28. {quickhouse-0.2.0 → quickhouse-0.2.3}/crates/quickhouse-py/Cargo.toml +0 -0
  29. {quickhouse-0.2.0 → quickhouse-0.2.3}/python/quickhouse/__init__.py +0 -0
  30. {quickhouse-0.2.0 → quickhouse-0.2.3}/python/quickhouse/progress.py +0 -0
  31. {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.0"
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.0"
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.52.4"
3374
+ version = "1.53.0"
3372
3375
  source = "registry+https://github.com/rust-lang/crates.io-index"
3373
- checksum = "317fafbbe3f02fc663dad00ea6186197de963cd4190e86a26d8d0fae095539af"
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.0"
3390
+ version = "2.7.1"
3388
3391
  source = "registry+https://github.com/rust-lang/crates.io-index"
3389
- checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
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.0"
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.0
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
- | Memory | Batches flushed every `batch_rows` rows *or* `batch_bytes` (whichever hits first) and streamed to ClickHouse RSS stays flat regardless of table size, and wide-row tables can't blow past the byte budget the way a rows-only cap would |
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, gzip on the wire |
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 per Arrow batch / insert flush (memory vs round-trips) |
174
- | `batch_bytes` | Max estimated source bytes per batch before flushing, even under `batch_rows` — default 4 MiB; caps peak memory for wide-row tables. `0` disables (row count alone decides) |
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
- | Memory | Batches flushed every `batch_rows` rows *or* `batch_bytes` (whichever hits first) and streamed to ClickHouse RSS stays flat regardless of table size, and wide-row tables can't blow past the byte budget the way a rows-only cap would |
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, gzip on the wire |
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 per Arrow batch / insert flush (memory vs round-trips) |
144
- | `batch_bytes` | Max estimated source bytes per batch before flushing, even under `batch_rows` — default 4 MiB; caps peak memory for wide-row tables. `0` disables (row count alone decides) |
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
- /// Flush a RecordBatch once its accumulated (estimated) source bytes
123
- /// reach this many, even if `batch_rows` hasn't been hit yet — caps peak
124
- /// memory for wide-row tables, where row-count alone is a poor proxy for
125
- /// actual bytes buffered. `0` disables the byte-based trigger (row count
126
- /// alone decides, as before this option existed).
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
  }
@@ -138,6 +138,7 @@ mod tests {
138
138
  parallelism: 4,
139
139
  batch_rows: 1000,
140
140
  batch_bytes: 0,
141
+ max_memory_bytes: 0,
141
142
  partition_column: None,
142
143
  type_overrides: HashMap::new(),
143
144
  rename: HashMap::new(),
@@ -15,6 +15,7 @@ pub mod decode;
15
15
  pub mod decode_bigquery;
16
16
  pub mod decode_mysql;
17
17
  pub mod error;
18
+ pub mod memory;
18
19
  pub mod sink;
19
20
  pub mod source;
20
21
  pub mod sync;
@@ -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
+ }