diff --git a/Cargo.lock b/Cargo.lock
index 47aa178a57f..9b895626f1b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -14412,6 +14412,7 @@ dependencies = [
"common-time",
"common-wal",
"datafusion-common",
+ "datafusion-execution",
"datafusion-expr",
"datafusion-physical-plan",
"datatypes",
diff --git a/Cargo.toml b/Cargo.toml
index e2f91a54336..3cb4e77fb77 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -133,6 +133,7 @@ dashmap = "6.1"
datafusion = "=55.1.0"
datafusion-common = "=55.1.0"
datafusion-datasource = "=55.1.0"
+datafusion-execution = "=55.1.0"
datafusion-expr = "=55.1.0"
datafusion-expr-common = "=55.1.0"
datafusion-functions = "=55.1.0"
@@ -368,6 +369,7 @@ substrait = { git = "https://github.com/GreptimeTeam/substrait-rs.git", rev = "9
datafusion = { git = "https://github.com/GreptimeTeam/datafusion.git", rev = "e3221b8f74b15c7192621576e5d1e354a6cf097a" }
datafusion-common = { git = "https://github.com/GreptimeTeam/datafusion.git", rev = "e3221b8f74b15c7192621576e5d1e354a6cf097a" }
datafusion-datasource = { git = "https://github.com/GreptimeTeam/datafusion.git", rev = "e3221b8f74b15c7192621576e5d1e354a6cf097a" }
+datafusion-execution = { git = "https://github.com/GreptimeTeam/datafusion.git", rev = "e3221b8f74b15c7192621576e5d1e354a6cf097a" }
datafusion-expr = { git = "https://github.com/GreptimeTeam/datafusion.git", rev = "e3221b8f74b15c7192621576e5d1e354a6cf097a" }
datafusion-expr-common = { git = "https://github.com/GreptimeTeam/datafusion.git", rev = "e3221b8f74b15c7192621576e5d1e354a6cf097a" }
datafusion-functions = { git = "https://github.com/GreptimeTeam/datafusion.git", rev = "e3221b8f74b15c7192621576e5d1e354a6cf097a" }
diff --git a/config/config.md b/config/config.md
index 82e98fe96ec..8663ff1cc28 100644
--- a/config/config.md
+++ b/config/config.md
@@ -142,7 +142,7 @@
| `procedure.max_running_procedures` | Integer | `128` | Max running procedures. The maximum number of procedures that can be running at the same time. If the number of running procedures exceeds this limit, the procedure will be rejected. |
| `query` | -- | -- | The query engine options. |
| `query.parallelism` | Integer | `0` | Parallelism of the query engine. Default to 0, which means the number of CPU cores. |
-| `query.memory_pool_size` | String | 0 | Memory pool size for query execution operators (aggregation, sorting, join). Supports absolute size (e.g., "2GB", "4GB") or percentage of system memory (e.g., "20%"). Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`. When this limit is reached, queries will fail with ResourceExhausted error. NOTE: This does NOT limit memory used by table scans. |
+| `query.memory_pool_size` | String | 0 | Memory pool size for query execution operators (aggregation, sorting, join, and internal series merges during table scans). Supports absolute size (e.g., "2GB", "4GB") or percentage of system memory (e.g., "20%"). Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`. When this limit is reached, queries will fail with ResourceExhausted error. Projected Parquet row groups and streamed scan batches use scan_memory_limit separately. |
| `query.experimental_memory_pool_policy` | String | `greedy` | Experimental memory pool allocation policy: - "greedy" (default): first-come-first-served allocation; preserves current behavior. - "fair": divides available memory among spillable operators and may spill earlier. Only effective when `memory_pool_size` is bounded (>0). |
| `query.experimental_spill_mode` | String | `default` | Spill mode: - "default": preserve DataFusion built-in OS temp directory (default). - "custom": explicitly configure spill path, quota, and compression. - "disabled": explicitly disable disk spilling. Set this to "custom" before using the path/quota/compression keys below. |
| `query.experimental_spill_path` | String | Unset | Spill directory path. Ignored unless mode is "custom". |
@@ -210,10 +210,11 @@
| `region_engine.mito.enable_refill_cache_on_read` | Bool | `true` | Enable refilling cache on read operations (default: true). When disabled, cache refilling on read won't happen. |
| `region_engine.mito.manifest_cache_size` | String | `256MB` | Capacity for manifest cache (default: 256MB). |
| `region_engine.mito.sst_write_buffer_size` | String | `8MB` | Buffer size for SST writing. |
-| `region_engine.mito.max_concurrent_scan_files` | Integer | `384` | Maximum number of SST files to scan concurrently. |
+| `region_engine.mito.max_concurrent_scan_files` | Integer | `384` | Maximum number of SST files to scan concurrently when the memory limit is disabled or a scan includes extension sources not covered by row-group accounting. |
| `region_engine.mito.allow_stale_entries` | Bool | `false` | Whether to allow stale WAL entries read during replay. |
-| `region_engine.mito.scan_memory_limit` | String | `unlimited` | Memory limit for table scans across all queries. Supports absolute size (e.g., "2GB") or percentage of system memory (e.g., "20%"). Setting it to 0 or "unlimited" disables the limit. |
-| `region_engine.mito.scan_memory_on_exhausted` | String | `fail` | Controls what happens when a scan cannot get memory immediately. "fail" (default) fails fast and is the recommended option for most users. "wait" / "wait()" waits for memory to become available. This is mainly for advanced tuning in bursty workloads where temporary contention is common and higher latency is acceptable. "wait" means "wait(10s)", not unlimited waiting. |
+| `region_engine.mito.scan_memory_limit` | String | `25%` | Memory limit for table scans across all queries and regions. Defaults to 25% of cgroup memory, or host memory when no cgroup limit is available. Supports absolute size (e.g., "2GB") or percentage of system memory (e.g., "20%"). Setting it to 0 or "unlimited" disables the limit and restores file-count checks. |
+| `region_engine.mito.scan_memory_mode` | String | `basic` | Memory accounting mode: "basic" (default) counts projected Parquet columns' uncompressed row-group sizes while decoding, including files from older versions. "extended" also counts cumulative streamed batches until stream drop. Limited reservations round up to KB; unlimited accounting is byte-exact. Internal series merges use query.memory_pool_size in both modes, sharing capacity with other query operators on the executing node. Neither budget measures total process memory. |
+| `region_engine.mito.scan_memory_on_exhausted` | String | `fail` | Scan memory exhaustion always fails immediately. Legacy "wait" / "wait()" settings are deprecated. They are accepted for upgrade compatibility, but startup logs a warning and treats them as "fail". Change existing wait settings to "fail"; scans no longer wait for quota to free up. This policy applies only to scan reservations. Series merges use the query pool and fail immediately when that pool is exhausted. |
| `region_engine.mito.min_compaction_interval` | String | `0m` | Minimum time interval between two compactions. To align with the old behavior, the default value is 0 (no restrictions). |
| `region_engine.mito.schedule_compaction_after_edit` | Bool | `true` | Whether to allow to schedule a compaction after a successful region edit.
Setting this to "true" is a necessary but not sufficient condition for scheduling compaction after a region edit. Other constraints, such as "min_compaction_interval", may still prevent compaction from being scheduled. Setting this to "false", however, guarantees that compaction will not be scheduled after a region edit. |
| `region_engine.mito.default_flat_format` | Bool | `true` | Whether to enable flat format as the default SST format. |
@@ -387,7 +388,7 @@
| `query` | -- | -- | The query engine options. |
| `query.parallelism` | Integer | `0` | Parallelism of the query engine. Default to 0, which means the number of CPU cores. |
| `query.allow_query_fallback` | Bool | `false` | Whether to allow query fallback when push down optimize fails. Default to false, meaning when push down optimize failed, return error msg |
-| `query.memory_pool_size` | String | 0 | Memory pool size for query execution operators (aggregation, sorting, join). Supports absolute size (e.g., "4GB", "8GB") or percentage of system memory (e.g., "30%"). Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`. When this limit is reached, queries will fail with ResourceExhausted error. NOTE: This does NOT limit memory used by table scans (only applies to datanodes). |
+| `query.memory_pool_size` | String | 0 | Memory pool size for query execution operators (aggregation, sorting, join). Supports absolute size (e.g., "4GB", "8GB") or percentage of system memory (e.g., "30%"). Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`. When this limit is reached, queries will fail with ResourceExhausted error. This pool applies to operators on this frontend. Datanodes use their own query pools for local operators and internal series merges during table scans. |
| `query.experimental_memory_pool_policy` | String | `greedy` | Experimental memory pool allocation policy: - "greedy" (default): first-come-first-served allocation; preserves current behavior. - "fair": divides available memory among spillable operators and may spill earlier. Only effective when `memory_pool_size` is bounded (>0). |
| `query.experimental_spill_mode` | String | `default` | Spill mode: - "default": preserve DataFusion built-in OS temp directory (default). - "custom": explicitly configure spill path, quota, and compression. - "disabled": explicitly disable disk spilling. Set this to "custom" before using the path/quota/compression keys below. |
| `query.experimental_spill_path` | String | Unset | Spill directory path. Ignored unless mode is "custom". |
@@ -618,7 +619,7 @@
| `wal.accept_missing_history` | Bool | `false` | Whether a region opens although the WAL objects under the prefix do not hold its history. An existing region fails to open when the prefix held no object when the WAL opened, or when the flushed entry id of the region names an object above every object of the prefix that holds entries. Both happen after `storage_provider`, or the bucket or root of the provider it names, changed without the WAL objects being copied, or after the objects were deleted. To move the WAL, stop the instance, copy every object under the prefix to the new location and then change the configuration; the regions open with all their writes. When the old WAL is lost, set this to `true` for one start: the regions open without their writes that were not flushed, which are lost, a warning names each region, and `greptime_logstore_object_store_wal_missing_history_accepted_total` counts them. Turn it off again once the instance has written to the WAL after that start; until then a later start refuses a region whose flushed entry id names an object above every object of the prefix that holds entries. **It's only used when the provider is `experimental_object_store`**. |
| `query` | -- | -- | The query engine options. |
| `query.parallelism` | Integer | `0` | Parallelism of the query engine. Default to 0, which means the number of CPU cores. |
-| `query.memory_pool_size` | String | 0 | Memory pool size for query execution operators (aggregation, sorting, join). Supports absolute size (e.g., "2GB", "4GB") or percentage of system memory (e.g., "20%"). Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`. When this limit is reached, queries will fail with ResourceExhausted error. NOTE: This does NOT limit memory used by table scans. |
+| `query.memory_pool_size` | String | 0 | Memory pool size for query execution operators (aggregation, sorting, join, and internal series merges during table scans). Supports absolute size (e.g., "2GB", "4GB") or percentage of system memory (e.g., "20%"). Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`. When this limit is reached, queries will fail with ResourceExhausted error. Projected Parquet row groups and streamed scan batches use scan_memory_limit separately. |
| `query.experimental_memory_pool_policy` | String | `greedy` | Experimental memory pool allocation policy: - "greedy" (default): first-come-first-served allocation; preserves current behavior. - "fair": divides available memory among spillable operators and may spill earlier. Only effective when `memory_pool_size` is bounded (>0). |
| `query.experimental_spill_mode` | String | `default` | Spill mode: - "default": preserve DataFusion built-in OS temp directory (default). - "custom": explicitly configure spill path, quota, and compression. - "disabled": explicitly disable disk spilling. Set this to "custom" before using the path/quota/compression keys below. |
| `query.experimental_spill_path` | String | Unset | Spill directory path. Ignored unless mode is "custom". |
@@ -687,10 +688,11 @@
| `region_engine.mito.enable_refill_cache_on_read` | Bool | `true` | Enable refilling cache on read operations (default: true). When disabled, cache refilling on read won't happen. |
| `region_engine.mito.manifest_cache_size` | String | `256MB` | Capacity for manifest cache (default: 256MB). |
| `region_engine.mito.sst_write_buffer_size` | String | `8MB` | Buffer size for SST writing. |
-| `region_engine.mito.max_concurrent_scan_files` | Integer | `384` | Maximum number of SST files to scan concurrently. |
+| `region_engine.mito.max_concurrent_scan_files` | Integer | `384` | Maximum number of SST files to scan concurrently when the memory limit is disabled or a scan includes extension sources not covered by row-group accounting. |
| `region_engine.mito.allow_stale_entries` | Bool | `false` | Whether to allow stale WAL entries read during replay. |
-| `region_engine.mito.scan_memory_limit` | String | `unlimited` | Memory limit for table scans across all queries. Supports absolute size (e.g., "2GB") or percentage of system memory (e.g., "20%"). Setting it to 0 or "unlimited" disables the limit. |
-| `region_engine.mito.scan_memory_on_exhausted` | String | `fail` | Controls what happens when a scan cannot get memory immediately. "fail" (default) fails fast and is the recommended option for most users. "wait" / "wait()" waits for memory to become available. This is mainly for advanced tuning in bursty workloads where temporary contention is common and higher latency is acceptable. "wait" means "wait(10s)", not unlimited waiting. |
+| `region_engine.mito.scan_memory_limit` | String | `25%` | Memory limit for table scans across all queries and regions. Defaults to 25% of cgroup memory, or host memory when no cgroup limit is available. Supports absolute size (e.g., "2GB") or percentage of system memory (e.g., "20%"). Setting it to 0 or "unlimited" disables the limit and restores file-count checks. |
+| `region_engine.mito.scan_memory_mode` | String | `basic` | Memory accounting mode: "basic" (default) counts projected Parquet columns' uncompressed row-group sizes while decoding, including files from older versions. "extended" also counts cumulative streamed batches until stream drop. Limited reservations round up to KB; unlimited accounting is byte-exact. Internal series merges use query.memory_pool_size in both modes, sharing capacity with other query operators on the executing node. Neither budget measures total process memory. |
+| `region_engine.mito.scan_memory_on_exhausted` | String | `fail` | Scan memory exhaustion always fails immediately. Legacy "wait" / "wait()" settings are deprecated. They are accepted for upgrade compatibility, but startup logs a warning and treats them as "fail". Change existing wait settings to "fail"; scans no longer wait for quota to free up. This policy applies only to scan reservations. Series merges use the query pool and fail immediately when that pool is exhausted. |
| `region_engine.mito.min_compaction_interval` | String | `0m` | Minimum time interval between two compactions. To align with the old behavior, the default value is 0 (no restrictions). |
| `region_engine.mito.schedule_compaction_after_edit` | Bool | `true` | Whether to allow to schedule a compaction after a successful region edit.
Setting this to "true" is a necessary but not sufficient condition for scheduling compaction after a region edit. Other constraints, such as "min_compaction_interval", may still prevent compaction from being scheduled. Setting this to "false", however, guarantees that compaction will not be scheduled after a region edit. |
| `region_engine.mito.default_flat_format` | Bool | `true` | Whether to enable flat format as the default SST format. |
diff --git a/config/datanode.example.toml b/config/datanode.example.toml
index d5f8e1e9be0..4b8eb41242e 100644
--- a/config/datanode.example.toml
+++ b/config/datanode.example.toml
@@ -337,11 +337,12 @@ overwrite_entry_start_id = false
## Default to 0, which means the number of CPU cores.
parallelism = 0
-## Memory pool size for query execution operators (aggregation, sorting, join).
+## Memory pool size for query execution operators (aggregation, sorting, join,
+## and internal series merges during table scans).
## Supports absolute size (e.g., "2GB", "4GB") or percentage of system memory (e.g., "20%").
## Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`.
## When this limit is reached, queries will fail with ResourceExhausted error.
-## NOTE: This does NOT limit memory used by table scans.
+## Projected Parquet row groups and streamed scan batches use scan_memory_limit separately.
## @toml2docs:none-default="0"
memory_pool_size = "50%"
@@ -665,22 +666,33 @@ manifest_cache_size = "256MB"
## Buffer size for SST writing.
sst_write_buffer_size = "8MB"
-## Maximum number of SST files to scan concurrently.
+## Maximum number of SST files to scan concurrently when the memory limit is disabled
+## or a scan includes extension sources not covered by row-group accounting.
max_concurrent_scan_files = 384
## Whether to allow stale WAL entries read during replay.
allow_stale_entries = false
-## Memory limit for table scans across all queries.
+## Memory limit for table scans across all queries and regions.
+## Defaults to 25% of cgroup memory, or host memory when no cgroup limit is available.
## Supports absolute size (e.g., "2GB") or percentage of system memory (e.g., "20%").
-## Setting it to 0 or "unlimited" disables the limit.
-scan_memory_limit = "unlimited"
-## Controls what happens when a scan cannot get memory immediately.
-## "fail" (default) fails fast and is the recommended option for most users.
-## "wait" / "wait()" waits for memory to become available. This is mainly
-## for advanced tuning in bursty workloads where temporary contention is common and
-## higher latency is acceptable.
-## "wait" means "wait(10s)", not unlimited waiting.
+## Setting it to 0 or "unlimited" disables the limit and restores file-count checks.
+scan_memory_limit = "25%"
+
+## Memory accounting mode: "basic" (default) counts projected Parquet columns'
+## uncompressed row-group sizes while decoding, including files from older versions.
+## "extended" also counts cumulative streamed batches until stream drop.
+## Limited reservations round up to KB; unlimited accounting is byte-exact.
+## Internal series merges use query.memory_pool_size in both modes, sharing capacity
+## with other query operators on the executing node. Neither budget measures total process memory.
+scan_memory_mode = "basic"
+
+## Scan memory exhaustion always fails immediately.
+## Legacy "wait" / "wait()" settings are deprecated. They are accepted
+## for upgrade compatibility, but startup logs a warning and treats them as "fail".
+## Change existing wait settings to "fail"; scans no longer wait for quota to free up.
+## This policy applies only to scan reservations. Series merges use the query pool
+## and fail immediately when that pool is exhausted.
scan_memory_on_exhausted = "fail"
## Minimum time interval between two compactions.
diff --git a/config/frontend.example.toml b/config/frontend.example.toml
index 24aef8bd796..97a939c9829 100644
--- a/config/frontend.example.toml
+++ b/config/frontend.example.toml
@@ -370,7 +370,8 @@ allow_query_fallback = false
## Supports absolute size (e.g., "4GB", "8GB") or percentage of system memory (e.g., "30%").
## Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`.
## When this limit is reached, queries will fail with ResourceExhausted error.
-## NOTE: This does NOT limit memory used by table scans (only applies to datanodes).
+## This pool applies to operators on this frontend. Datanodes use their own query
+## pools for local operators and internal series merges during table scans.
## @toml2docs:none-default="0"
memory_pool_size = "50%"
diff --git a/config/standalone.example.toml b/config/standalone.example.toml
index 6fba1d91398..e16210d3644 100644
--- a/config/standalone.example.toml
+++ b/config/standalone.example.toml
@@ -548,11 +548,12 @@ max_running_procedures = 128
## Default to 0, which means the number of CPU cores.
parallelism = 0
-## Memory pool size for query execution operators (aggregation, sorting, join).
+## Memory pool size for query execution operators (aggregation, sorting, join,
+## and internal series merges during table scans).
## Supports absolute size (e.g., "2GB", "4GB") or percentage of system memory (e.g., "20%").
## Setting it to 0 disables the limit. Defaults to 0 (unlimited); this example sets it to `50%`.
## When this limit is reached, queries will fail with ResourceExhausted error.
-## NOTE: This does NOT limit memory used by table scans.
+## Projected Parquet row groups and streamed scan batches use scan_memory_limit separately.
## @toml2docs:none-default="0"
memory_pool_size = "50%"
@@ -872,22 +873,33 @@ manifest_cache_size = "256MB"
## Buffer size for SST writing.
sst_write_buffer_size = "8MB"
-## Maximum number of SST files to scan concurrently.
+## Maximum number of SST files to scan concurrently when the memory limit is disabled
+## or a scan includes extension sources not covered by row-group accounting.
max_concurrent_scan_files = 384
## Whether to allow stale WAL entries read during replay.
allow_stale_entries = false
-## Memory limit for table scans across all queries.
+## Memory limit for table scans across all queries and regions.
+## Defaults to 25% of cgroup memory, or host memory when no cgroup limit is available.
## Supports absolute size (e.g., "2GB") or percentage of system memory (e.g., "20%").
-## Setting it to 0 or "unlimited" disables the limit.
-scan_memory_limit = "unlimited"
-## Controls what happens when a scan cannot get memory immediately.
-## "fail" (default) fails fast and is the recommended option for most users.
-## "wait" / "wait()" waits for memory to become available. This is mainly
-## for advanced tuning in bursty workloads where temporary contention is common and
-## higher latency is acceptable.
-## "wait" means "wait(10s)", not unlimited waiting.
+## Setting it to 0 or "unlimited" disables the limit and restores file-count checks.
+scan_memory_limit = "25%"
+
+## Memory accounting mode: "basic" (default) counts projected Parquet columns'
+## uncompressed row-group sizes while decoding, including files from older versions.
+## "extended" also counts cumulative streamed batches until stream drop.
+## Limited reservations round up to KB; unlimited accounting is byte-exact.
+## Internal series merges use query.memory_pool_size in both modes, sharing capacity
+## with other query operators on the executing node. Neither budget measures total process memory.
+scan_memory_mode = "basic"
+
+## Scan memory exhaustion always fails immediately.
+## Legacy "wait" / "wait()" settings are deprecated. They are accepted
+## for upgrade compatibility, but startup logs a warning and treats them as "fail".
+## Change existing wait settings to "fail"; scans no longer wait for quota to free up.
+## This policy applies only to scan reservations. Series merges use the query pool
+## and fail immediately when that pool is exhausted.
scan_memory_on_exhausted = "fail"
## Minimum time interval between two compactions.
diff --git a/src/cmd/src/datanode/scanbench.rs b/src/cmd/src/datanode/scanbench.rs
index 9fbde16fb7b..667a5f54be1 100644
--- a/src/cmd/src/datanode/scanbench.rs
+++ b/src/cmd/src/datanode/scanbench.rs
@@ -1017,6 +1017,7 @@ impl ScanbenchCommand {
let num_partitions = scanner.properties().partitions.len();
let ctx = QueryScanContext {
explain_verbose: collect_scanner_explain,
+ ..Default::default()
};
let metrics_set = ExecutionPlanMetricsSet::new();
diff --git a/src/cmd/tests/load_config_test.rs b/src/cmd/tests/load_config_test.rs
index 00cdafd9b8a..2781183c0c8 100644
--- a/src/cmd/tests/load_config_test.rs
+++ b/src/cmd/tests/load_config_test.rs
@@ -201,7 +201,8 @@ fn test_load_datanode_example_config() {
auto_flush_interval: Duration::from_secs(10 * 60),
default_region_write_buffer_size: ReadableSize::mb(0),
write_cache_ttl: Some(Duration::from_secs(60 * 60 * 8)),
- scan_memory_limit: MemoryLimit::Unlimited,
+ scan_memory_limit: MemoryLimit::Percentage(25),
+ scan_memory_mode: mito2::config::ScanMemoryMode::Basic,
..Default::default()
}),
RegionEngineConfig::File(FileEngineConfig {}),
@@ -440,7 +441,8 @@ fn test_load_standalone_example_config() {
auto_flush_interval: Duration::from_secs(10 * 60),
default_region_write_buffer_size: ReadableSize::mb(0),
write_cache_ttl: Some(Duration::from_secs(60 * 60 * 8)),
- scan_memory_limit: MemoryLimit::Unlimited,
+ scan_memory_limit: MemoryLimit::Percentage(25),
+ scan_memory_mode: mito2::config::ScanMemoryMode::Basic,
..Default::default()
}),
RegionEngineConfig::File(FileEngineConfig {}),
diff --git a/src/common/recordbatch/src/lib.rs b/src/common/recordbatch/src/lib.rs
index 0ccb8a4c28e..a3455b366dd 100644
--- a/src/common/recordbatch/src/lib.rs
+++ b/src/common/recordbatch/src/lib.rs
@@ -17,21 +17,18 @@ pub mod cursor;
pub mod error;
pub mod ext;
pub mod filter;
+mod memory;
pub mod recordbatch;
pub mod util;
use std::fmt::{self, Write};
-use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use adapter::RecordBatchMetrics;
use arc_swap::ArcSwapOption;
use common_base::readable_size::ReadableSize;
-use common_error::ext::BoxedError;
-use common_memory_manager::{
- MemoryGuard, MemoryManager, MemoryMetrics, OnExhaustedPolicy, PermitGranularity,
-};
+use common_memory_manager::{MemoryManager, MemoryMetrics, PermitGranularity};
use common_telemetry::tracing::Span;
pub use datafusion::physical_plan::SendableRecordBatchStream as DfSendableRecordBatchStream;
use datatypes::arrow::array::{Array, ArrayRef, AsArray, StringBuilder};
@@ -53,9 +50,10 @@ use error::Result;
use futures::task::{Context, Poll};
use futures::{Stream, TryStreamExt};
pub use recordbatch::RecordBatch;
-use snafu::{IntoError, ResultExt, ensure};
+use snafu::{ResultExt, ensure};
use crate::error::{ArrowComputeSnafu, NewDfRecordBatchSnafu};
+pub use crate::memory::QueryMemoryReservation;
pub trait RecordBatchStream: Stream> {
fn name(&self) -> &str {
@@ -595,14 +593,14 @@ impl> + Unpin> Stream for RecordBatchStream
}
}
-/// Memory tracker for RecordBatch streams. Clone to share the same limit across queries.
-///
-/// Each stream acquires quota independently from this tracker.
+/// Memory tracker for RecordBatch streams and reader reservations. Clone to share
+/// the same limit across queries. Limited accounting rounds up to KB; unlimited
+/// accounting is byte-exact. Exhaustion fails immediately. DataFusion operators use
+/// the separate query runtime pool.
#[derive(Clone)]
pub struct QueryMemoryTracker {
manager: MemoryManager,
metrics: CallbackMemoryMetrics,
- on_exhausted_policy: OnExhaustedPolicy,
}
impl fmt::Debug for QueryMemoryTracker {
@@ -610,7 +608,6 @@ impl fmt::Debug for QueryMemoryTracker {
f.debug_struct("QueryMemoryTracker")
.field("current", &self.current())
.field("limit", &self.limit())
- .field("on_exhausted_policy", &self.on_exhausted_policy)
.field("on_update", &self.metrics.has_on_update())
.field("on_exhausted", &self.metrics.has_on_exhausted())
.field("on_rejected", &self.metrics.has_on_rejected())
@@ -620,13 +617,9 @@ impl fmt::Debug for QueryMemoryTracker {
impl QueryMemoryTracker {
/// Create a builder for a query memory tracker.
- pub fn builder(
- limit: usize,
- on_exhausted_policy: OnExhaustedPolicy,
- ) -> QueryMemoryTrackerBuilder {
+ pub fn builder(limit: usize) -> QueryMemoryTrackerBuilder {
QueryMemoryTrackerBuilder {
limit,
- on_exhausted_policy,
on_update: None,
on_exhausted: None,
on_reject: None,
@@ -636,7 +629,10 @@ impl QueryMemoryTracker {
fn new_stream_tracker(&self) -> StreamMemoryTracker {
StreamMemoryTracker {
tracker: self.clone(),
- guard: self.manager.try_acquire(0).unwrap(),
+ guard: QueryMemoryReservation {
+ // A zero-byte acquisition always succeeds; this manager is never closed.
+ inner: self.manager.try_acquire(0).unwrap(),
+ },
tracked_bytes: 0,
}
}
@@ -645,10 +641,22 @@ impl QueryMemoryTracker {
self.manager.used_bytes() as usize
}
- fn limit(&self) -> usize {
+ /// Returns the memory limit in bytes, or zero when unlimited.
+ pub fn limit(&self) -> usize {
self.manager.limit_bytes() as usize
}
+ /// Reserves bytes until the returned guard is dropped, failing immediately if
+ /// the shared budget cannot accommodate the reservation.
+ pub fn reserve(&self, bytes: usize) -> Result {
+ let mut tracker = self.new_stream_tracker();
+ if let Err(error) = tracker.try_track(bytes) {
+ tracker.inc_rejected();
+ return Err(error);
+ }
+ Ok(tracker.guard)
+ }
+
fn reject_error(
&self,
current: usize,
@@ -660,7 +668,7 @@ impl QueryMemoryTracker {
"{} requested, {} used globally ({}%), {} used by this stream, hard limit: {}",
ReadableSize(additional as u64),
ReadableSize(current as u64),
- (current * 100).checked_div(limit).unwrap_or(0),
+ current.saturating_mul(100).checked_div(limit).unwrap_or(0),
ReadableSize(stream_tracked as u64),
ReadableSize(limit as u64)
);
@@ -675,7 +683,6 @@ impl QueryMemoryTracker {
/// Builder for constructing a [`QueryMemoryTracker`] with optional callbacks.
pub struct QueryMemoryTrackerBuilder {
limit: usize,
- on_exhausted_policy: OnExhaustedPolicy,
on_update: Option,
on_exhausted: Option,
on_reject: Option,
@@ -687,7 +694,7 @@ impl QueryMemoryTrackerBuilder {
///
/// # Note
/// The callback is called after both successful `track()` and stream drop.
- /// Usage is exact in unlimited mode and 1KB-aligned in limited mode.
+ /// Usage is byte-exact in unlimited mode; otherwise it is 1KB-aligned.
pub fn on_update(mut self, on_update: F) -> Self
where
F: Fn(usize) + Send + Sync + 'static,
@@ -700,7 +707,6 @@ impl QueryMemoryTrackerBuilder {
///
/// # Note
/// This is called when the non-blocking allocation fast path fails.
- /// Requests using `OnExhaustedPolicy::Wait` may still succeed after waiting.
/// It is never called when `limit == 0` (unlimited mode).
pub fn on_exhausted(mut self, on_exhausted: F) -> Self
where
@@ -728,22 +734,16 @@ impl QueryMemoryTrackerBuilder {
metrics.clone(),
);
- QueryMemoryTracker {
- manager,
- metrics,
- on_exhausted_policy: self.on_exhausted_policy,
- }
+ QueryMemoryTracker { manager, metrics }
}
}
struct StreamMemoryTracker {
tracker: QueryMemoryTracker,
- guard: MemoryGuard,
+ guard: QueryMemoryReservation,
tracked_bytes: usize,
}
-type MemoryAcquireResult = std::result::Result<(), common_memory_manager::Error>;
-
impl StreamMemoryTracker {
fn inc_rejected(&self) {
self.tracker.inc_rejected();
@@ -758,51 +758,13 @@ impl StreamMemoryTracker {
}
}
- async fn track_with_policy(mut self, additional: usize) -> (Self, MemoryAcquireResult) {
- let result = self
- .guard
- .acquire_additional_with_policy(additional as u64, self.tracker.on_exhausted_policy)
- .await;
- if result.is_ok() {
- self.tracked_bytes = self.tracked_bytes.saturating_add(additional);
- }
- (self, result)
- }
-
fn reject_error(&self, additional: usize) -> error::Error {
let current = self.tracker.current();
self.tracker
.reject_error(current, additional, self.tracked_bytes)
}
-
- fn wait_error(&self, additional: usize, source: common_memory_manager::Error) -> error::Error {
- match source {
- common_memory_manager::Error::MemoryLimitExceeded { .. } => {
- self.reject_error(additional)
- }
- common_memory_manager::Error::MemoryAcquireTimeout { waited, .. } => {
- let current = self.tracker.current();
- let limit = self.tracker.limit();
- let msg = format!(
- "timed out waiting {:?} for {}, {} used globally ({}%), {} used by this stream, hard limit: {}",
- waited,
- ReadableSize(additional as u64),
- ReadableSize(current as u64),
- (current * 100).checked_div(limit).unwrap_or(0),
- ReadableSize(self.tracked_bytes as u64),
- ReadableSize(limit as u64)
- );
- error::ExceedMemoryLimitSnafu { msg }.build()
- }
- error => error::ExternalSnafu.into_error(BoxedError::new(error)),
- }
- }
}
-type PendingTrackFuture = Pin<
- Box + Send>,
->;
-
#[derive(Clone)]
struct CallbackMemoryMetrics {
inner: Arc,
@@ -871,99 +833,24 @@ impl MemoryMetrics for CallbackMemoryMetrics {
/// A wrapper stream that tracks memory usage of RecordBatches.
pub struct MemoryTrackedStream {
inner: SendableRecordBatchStream,
- tracker: Option,
- // Waiting stores a batch that has already been pulled from the inner stream but has not yet
- // acquired additional quota. This keeps `poll_next()` non-blocking and allows bounded waits,
- // at the cost of temporarily holding one untracked batch per blocked stream in memory.
- waiting: Option,
+ tracker: StreamMemoryTracker,
}
impl MemoryTrackedStream {
pub fn new(inner: SendableRecordBatchStream, tracker: QueryMemoryTracker) -> Self {
Self {
inner,
- tracker: Some(tracker.new_stream_tracker()),
- waiting: None,
+ tracker: tracker.new_stream_tracker(),
}
}
- fn ready_tracker_mut(&mut self) -> &mut StreamMemoryTracker {
- debug_assert!(
- self.waiting.is_none(),
- "a ready tracker must not coexist with a waiting future"
- );
- self.tracker.as_mut().unwrap()
- }
-
- fn enter_waiting(&mut self, batch: RecordBatch, additional: usize) {
- debug_assert!(
- self.waiting.is_none(),
- "enter_waiting should only be called from the ready state"
- );
- debug_assert!(
- self.tracker.is_some(),
- "enter_waiting requires a tracker in the ready state"
- );
- let tracker = self.tracker.take().unwrap();
- self.waiting = Some(Self::start_waiting(tracker, batch, additional));
- }
-
- fn start_waiting(
- tracker: StreamMemoryTracker,
- batch: RecordBatch,
- additional: usize,
- ) -> PendingTrackFuture {
- Box::pin(async move {
- let (tracker, result) = tracker.track_with_policy(additional).await;
- (tracker, batch, additional, result)
- })
- }
-
- fn poll_waiting(&mut self, cx: &mut Context<'_>) -> Poll