diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AnalyticsSearchBackendPlugin.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AnalyticsSearchBackendPlugin.java index 59f9b1f899f92..42d5fbe386ed5 100644 --- a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AnalyticsSearchBackendPlugin.java +++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AnalyticsSearchBackendPlugin.java @@ -8,7 +8,9 @@ package org.opensearch.analytics.spi; +import java.util.Collections; import java.util.List; +import java.util.Map; /** * SPI extension point for backend query engine plugins. @@ -114,6 +116,24 @@ default void configureFilterDelegation(FilterDelegationHandle handle, BackendExe throw new UnsupportedOperationException("configureFilterDelegation not implemented for [" + name() + "]"); } + /** + * Returns a snapshot of this backend's currently-tracked queries, keyed by {@code contextId}. + * + *

The map is a point-in-time view — entries can register or drain concurrently on the + * backend side. Implementations MUST return a non-null map (empty when nothing is tracked) + * and SHOULD make it unmodifiable so callers cannot mutate backend state. + * + *

Implementations MAY cap the result to a top-N subset by current memory usage to bound + * the FFI cost (the DataFusion backend caps at the heaviest 10 live queries). Callers that + * need a complete enumeration should not rely on this method. + * + *

Default implementation returns an empty map so backends that do not track per-query + * metrics don't have to opt in. + */ + default Map getTopQueriesByMemory() { + return Collections.emptyMap(); + } + /** * Install a thread tracker for attribution of delegation callbacks executing on foreign threads. * Called after {@link #configureFilterDelegation}. Pass {@code null} to clear. diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/QueryExecutionMetrics.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/QueryExecutionMetrics.java new file mode 100644 index 0000000000000..0e03287d3ead7 --- /dev/null +++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/QueryExecutionMetrics.java @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.analytics.spi; + +/** + * Snapshot of per-query execution metrics reported by an analytics backend. + * + *

The context id (the map key in {@link AnalyticsSearchBackendPlugin#getTopQueriesByMemory()}) + * is not repeated here — this record holds only the memory accounting fields. + * + * @param currentBytes bytes currently reserved by the query's memory pool + * + * @opensearch.internal + */ +public record QueryExecutionMetrics(long currentBytes) {} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/ffm.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/ffm.rs index 140f3e8bbc15a..b8fc8ce406b0e 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/ffm.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/ffm.rs @@ -206,6 +206,47 @@ pub extern "C" fn df_cancel_query(context_id: i64) { api::cancel_query(context_id); } +// --------------------------------------------------------------------------- +// Per-query registry top-N snapshot +// +// One FFM call: Java allocates a buffer sized for `N` entries, Rust selects +// the heaviest live queries by `current_bytes` (bounded min-heap of size N) +// and writes them back-to-back. See `query_tracker::WireQueryMetric` for the +// wire layout. +// --------------------------------------------------------------------------- + +/// Copies up to `cap_entries` of the heaviest live queries (by +/// `current_bytes` desc) as `WireQueryMetric`s into the caller-provided buffer. +/// Returns the number of entries actually written. +/// +/// Order of entries within the buffer is unspecified. Completed and zero-byte +/// trackers are filtered out. +/// +/// Safety: `out_ptr` must be non-null, 8-byte aligned, and point to storage +/// for at least `cap_entries * size_of::()` bytes. +#[ffm_safe] +#[no_mangle] +pub unsafe extern "C" fn df_query_registry_top_n_by_current( + out_ptr: *mut u8, + cap_entries: i64, +) -> i64 { + use crate::query_tracker::{snapshot_top_n_by_current, WireQueryMetric}; + + if cap_entries < 0 { + return Err(format!("negative capacity: {cap_entries}")); + } + if cap_entries == 0 { + return Ok(0); + } + if out_ptr.is_null() { + return Err("null snapshot buffer".to_string()); + } + let out: &mut [WireQueryMetric] = + slice::from_raw_parts_mut(out_ptr as *mut WireQueryMetric, cap_entries as usize); + let written = snapshot_top_n_by_current(out); + Ok(written as i64) +} + #[ffm_safe] #[no_mangle] pub unsafe extern "C" fn df_sql_to_substrait( diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs index fb522332de9a4..066aa8214ac82 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs @@ -35,4 +35,4 @@ pub mod session_context; pub mod statistics_cache; pub mod udf; pub mod stats; -pub mod task_monitors; \ No newline at end of file +pub mod task_monitors; diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/query_tracker.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/query_tracker.rs index 90a59a64f0aa8..84d29376b6a1f 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/query_tracker.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/query_tracker.rs @@ -150,6 +150,138 @@ impl QueryTracker { static QUERY_REGISTRY: Lazy>> = Lazy::new(DashMap::new); +// --------------------------------------------------------------------------- +// Registry snapshot — top-N FFM export +// --------------------------------------------------------------------------- + +/// Wire representation of a single query's tracker, used by the top-N +/// snapshot API. Fields are `i64` so the struct crosses the FFM boundary with +/// a stable, alignment-safe layout (8-byte aligned on every target we support). +/// +/// | Field | Meaning | +/// |---------------|-----------------------------------------------------------| +/// | context_id | `QueryTracker::context_id` | +/// | current_bytes | `QueryMemoryPool::current_bytes`, clamped to `i64::MAX` | +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct WireQueryMetric { + pub context_id: i64, + pub current_bytes: i64, +} + +const _: () = assert!(std::mem::size_of::() == 2 * 8); + +fn usize_to_i64_saturating(value: usize) -> i64 { + if value > i64::MAX as usize { + i64::MAX + } else { + value as i64 + } +} + +/// Top-N snapshot: copy at most `out.len()` of the heaviest live queries +/// (ranked by `current_bytes` descending) into `out`. Returns the number of +/// entries actually written. +/// +/// Selection is done with a bounded min-heap of capacity `out.len()`, so a +/// single pass over the registry runs in `O(M log N)` where `M` is the +/// registry size and `N = out.len()`. The heap holds the *top N candidates* +/// seen so far; the smallest of those sits at the root, and any new entry +/// heavier than that root replaces it. +/// +/// Order of entries written into `out` is unspecified — heap order, not +/// sorted. Callers that need a sorted view sort the prefix client-side. +/// +/// Filters applied on the Rust side, in this order: +/// - completed trackers are skipped (a completed query's `current_bytes` +/// drops to zero on `Drop` anyway, so this is mostly a fast path) +/// - `current_bytes == 0` is skipped (registered but un-allocated trackers +/// are not "heavy" candidates) +/// +/// `out` may be a raw slice pointing at a Java-owned buffer; it must be valid +/// for writes of `out.len() * size_of::()` bytes and properly +/// aligned for `WireQueryMetric` (8-byte aligned). +pub fn snapshot_top_n_by_current(out: &mut [WireQueryMetric]) -> usize { + use std::cmp::{Ordering, Reverse}; + use std::collections::BinaryHeap; + + /// Heap entry — POD copy of the tracker fields sampled at iteration + /// time. We deliberately avoid carrying an `Arc` here: + /// re-reading the live tracker at materialization time would let the + /// reported `current_bytes` drift away from the value the entry was + /// ranked on (e.g. a query finishes between rank and report and ships + /// out as zero), and would also defeat the `current_bytes == 0` filter + /// applied during iteration. Sampling once keeps rank and report + /// consistent and removes the per-survivor `Arc::clone` from the hot + /// path. + #[derive(Clone, Copy)] + struct HeapEntry { + context_id: i64, + current_bytes: i64, + } + + impl Eq for HeapEntry {} + impl PartialEq for HeapEntry { + fn eq(&self, other: &Self) -> bool { + self.current_bytes == other.current_bytes + } + } + impl Ord for HeapEntry { + fn cmp(&self, other: &Self) -> Ordering { + self.current_bytes.cmp(&other.current_bytes) + } + } + impl PartialOrd for HeapEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } + } + + let n = out.len(); + if n == 0 { + return 0; + } + + // BinaryHeap is a max-heap; Reverse flips it so the smallest current_bytes + // sits at the root. That root is the weakest member of the current top-N + // set — the one a heavier candidate displaces. + let mut heap: BinaryHeap> = BinaryHeap::with_capacity(n); + + for entry in QUERY_REGISTRY.iter() { + let tracker = entry.value(); + if tracker.is_completed() { + continue; + } + let current_raw = tracker.memory_pool.current_bytes(); + let current_bytes = usize_to_i64_saturating(current_raw); + if current_bytes == 0 { + continue; + } + let candidate = HeapEntry { + context_id: tracker.context_id, + current_bytes, + }; + if heap.len() < n { + heap.push(Reverse(candidate)); + } else if let Some(Reverse(min)) = heap.peek() { + if candidate.current_bytes > min.current_bytes { + heap.pop(); + heap.push(Reverse(candidate)); + } + } + } + + let mut written = 0usize; + for Reverse(entry) in heap.into_iter() { + out[written] = WireQueryMetric { + context_id: entry.context_id, + current_bytes: entry.current_bytes, + }; + written += 1; + } + written +} + /// Fire the cancellation token for the given context_id. /// No-op for unknown or already-completed queries. pub fn cancel_query(context_id: i64) { @@ -220,8 +352,8 @@ impl Drop for QueryTrackingContext { fn drop(&mut self) { if let Some(tracker) = &self.tracker { tracker.mark_completed(); - debug!( - "Query completed ctx={}: wall={:.3}s, mem_current={}B, mem_peak={}B", + debug!( + "Query completed ctx={}: wall={:.3}s, mem_current={}B, mem_peak={}B", tracker.context_id, tracker.wall_secs(), tracker.memory_pool.current_bytes(), @@ -467,4 +599,178 @@ mod tests { assert!(!QUERY_REGISTRY.contains_key(&ctx_id)); } + + // ----------------------------------------------------------------------- + // Top-N snapshot tests + // ----------------------------------------------------------------------- + + /// Helper: drain all live tracker contexts so each top-N test starts from + /// a clean registry. Tests in this module are tagged with unique id ranges + /// but the registry is process-wide, so we only assert on contexts we own. + fn fresh_buf(n: usize) -> Vec { + vec![ + WireQueryMetric { + context_id: 0, + current_bytes: 0, + }; + n + ] + } + + #[test] + fn test_top_n_zero_capacity_returns_zero() { + let mut buf: Vec = Vec::new(); + let written = snapshot_top_n_by_current(&mut buf); + assert_eq!(written, 0); + } + + #[test] + fn test_top_n_picks_highest_current_bytes() { + let global = make_global_pool(10_000_000); + let id_lo = 70_000; + let id_md = 70_001; + let id_hi = 70_002; + + let ctx_lo = QueryTrackingContext::new(id_lo, Arc::clone(&global)); + let ctx_md = QueryTrackingContext::new(id_md, Arc::clone(&global)); + let ctx_hi = QueryTrackingContext::new(id_hi, Arc::clone(&global)); + + let pool_lo: Arc = ctx_lo.memory_pool().unwrap(); + let pool_md: Arc = ctx_md.memory_pool().unwrap(); + let pool_hi: Arc = ctx_hi.memory_pool().unwrap(); + + let mut r_lo = make_reservation(&pool_lo, "lo"); + let mut r_md = make_reservation(&pool_md, "md"); + let mut r_hi = make_reservation(&pool_hi, "hi"); + r_lo.try_grow(1_000).unwrap(); + r_md.try_grow(5_000).unwrap(); + r_hi.try_grow(9_000).unwrap(); + + // Top-2 must contain ids hi and md but not lo. Other parallel tests + // may push entries into the registry, so we filter to our id range. + let mut buf = fresh_buf(2); + let written = snapshot_top_n_by_current(&mut buf); + assert!(written >= 1 && written <= 2); + + let our: Vec<&WireQueryMetric> = buf[..written] + .iter() + .filter(|m| (id_lo..=id_hi).contains(&m.context_id)) + .collect(); + // At minimum, id_hi (the heaviest) must be in our slice if any of + // ours made it in. + if !our.is_empty() { + assert!(our.iter().any(|m| m.context_id == id_hi)); + assert!(!our.iter().any(|m| m.context_id == id_lo)); + } + + drop(r_lo); + drop(r_md); + drop(r_hi); + drop(ctx_lo); + drop(ctx_md); + drop(ctx_hi); + QUERY_REGISTRY.remove(&id_lo); + QUERY_REGISTRY.remove(&id_md); + QUERY_REGISTRY.remove(&id_hi); + } + + #[test] + fn test_top_n_skips_completed_and_zero_byte_trackers() { + let global = make_global_pool(1_000_000); + let live_id = 70_100; + let zero_id = 70_101; + let done_id = 70_102; + + let live_ctx = QueryTrackingContext::new(live_id, Arc::clone(&global)); + let live_pool: Arc = live_ctx.memory_pool().unwrap(); + let mut r_live = make_reservation(&live_pool, "live"); + r_live.try_grow(4_096).unwrap(); + + // Registered but never reserved — current_bytes stays 0. + let _zero_ctx = QueryTrackingContext::new(zero_id, Arc::clone(&global)); + + // Completed before snapshot. Drop reservation first so QueryMemoryPool + // is settled, then drop the context to flip the completed flag. + let done_ctx = QueryTrackingContext::new(done_id, Arc::clone(&global)); + let done_pool: Arc = done_ctx.memory_pool().unwrap(); + let mut r_done = make_reservation(&done_pool, "done"); + r_done.try_grow(8_192).unwrap(); + drop(r_done); + drop(done_ctx); + + let mut buf = fresh_buf(8); + let written = snapshot_top_n_by_current(&mut buf); + + let our: Vec<&WireQueryMetric> = buf[..written] + .iter() + .filter(|m| [live_id, zero_id, done_id].contains(&m.context_id)) + .collect(); + assert!(our.iter().any(|m| m.context_id == live_id)); + assert!(!our.iter().any(|m| m.context_id == zero_id)); + assert!(!our.iter().any(|m| m.context_id == done_id)); + + drop(r_live); + drop(live_ctx); + QUERY_REGISTRY.remove(&live_id); + QUERY_REGISTRY.remove(&zero_id); + QUERY_REGISTRY.remove(&done_id); + } + + #[test] + fn test_top_n_with_buffer_larger_than_live_set() { + let global = make_global_pool(1_000_000); + let id = 70_200; + let ctx = QueryTrackingContext::new(id, global); + let pool: Arc = ctx.memory_pool().unwrap(); + let mut r = make_reservation(&pool, "only"); + r.try_grow(2_048).unwrap(); + + let sentinel = WireQueryMetric { + context_id: -1, + current_bytes: -1, + }; + let mut buf = vec![sentinel; 16]; + let written = snapshot_top_n_by_current(&mut buf); + assert!(written >= 1); + assert!(written < buf.len()); + // Tail past `written` keeps the sentinel — proves the snapshot did not + // touch it. + for slot in &buf[written..] { + assert_eq!(slot.context_id, -1); + } + + drop(r); + drop(ctx); + QUERY_REGISTRY.remove(&id); + } + + #[test] + fn test_top_n_caps_at_buffer_capacity() { + let global = make_global_pool(10_000_000); + let ids: Vec = (70_300..70_310).collect(); + let mut contexts = Vec::with_capacity(ids.len()); + let mut reservations = Vec::with_capacity(ids.len()); + for (i, id) in ids.iter().enumerate() { + let ctx = QueryTrackingContext::new(*id, Arc::clone(&global)); + let pool: Arc = ctx.memory_pool().unwrap(); + let mut r = make_reservation(&pool, "cap"); + r.try_grow((i as usize + 1) * 1_000).unwrap(); + reservations.push(r); + contexts.push(ctx); + } + + let mut buf = fresh_buf(3); + let written = snapshot_top_n_by_current(&mut buf); + assert!(written <= 3, "must not exceed buffer capacity"); + + for r in reservations.drain(..) { + drop(r); + } + for ctx in contexts.drain(..) { + drop(ctx); + } + for id in &ids { + QUERY_REGISTRY.remove(id); + } + } } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java index d677195e8c2aa..7112c8246dac8 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java @@ -30,6 +30,7 @@ import org.opensearch.analytics.spi.JoinCapability; import org.opensearch.analytics.spi.NumericToDoubleAdapter; import org.opensearch.analytics.spi.ProjectCapability; +import org.opensearch.analytics.spi.QueryExecutionMetrics; import org.opensearch.analytics.spi.ScalarFunction; import org.opensearch.analytics.spi.ScalarFunctionAdapter; import org.opensearch.analytics.spi.ScanCapability; @@ -704,6 +705,14 @@ public void configureFilterDelegation(FilterDelegationHandle handle, BackendExec FilterTreeCallbacks.setHandle(handle); } + @Override + public Map getTopQueriesByMemory() { + // Delegate to the plugin that owns the DataFusionService and native runtime. + // Keeping the real implementation on DataFusionPlugin lets operators call it + // directly (e.g., from a REST action) without going through the SPI. + return plugin.getTopQueriesByMemory(); + } + @Override public void setDelegationThreadTracker(org.opensearch.analytics.spi.DelegationThreadTracker tracker) { FilterTreeCallbacks.setThreadTracker(tracker); diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionPlugin.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionPlugin.java index bad7879a96322..cf06ec8aff2b9 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionPlugin.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionPlugin.java @@ -11,7 +11,9 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.opensearch.analytics.spi.AnalyticsSearchBackendPlugin; +import org.opensearch.analytics.spi.QueryExecutionMetrics; import org.opensearch.be.datafusion.action.DataFusionStatsAction; +import org.opensearch.be.datafusion.nativelib.NativeBridge; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; import org.opensearch.cluster.node.DiscoveryNodes; import org.opensearch.cluster.service.ClusterService; @@ -35,6 +37,7 @@ import org.opensearch.rest.RestController; import org.opensearch.rest.RestHandler; import org.opensearch.script.ScriptService; +import org.opensearch.search.backpressure.trackers.NativeMemoryUsageTracker; import org.opensearch.threadpool.ThreadPool; import org.opensearch.transport.client.Client; import org.opensearch.watcher.ResourceWatcherService; @@ -42,7 +45,9 @@ import java.io.IOException; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.function.Supplier; import io.substrait.extension.DefaultExtensionCatalog; @@ -105,6 +110,14 @@ public class DataFusionPlugin extends Plugin implements SearchBackEndPlugin createComponents( this.datafusionSettings = new DatafusionSettings(clusterService); + // Expose per-task native-memory usage to search backpressure. The tracker calls + // this supplier once per refresh (invoked by the backpressure service at the top of + // doRun() and nodeStats()), snapshotting all live queries in one FFM call. Per-task + // evaluation then reads from the tracker's cached map — no FFM call per task. + // + // The OpenSearch task id is used as the DataFusion context_id at query launch + // (see ShardScanInstructionHandler / DatafusionSearchExecEngine), so the map is + // already keyed by Task#getId on the consumer side. + NativeMemoryUsageTracker.setSnapshotSupplier(this::currentBytesByTaskId); + NativeMemoryUsageTracker.setNativeMemoryBudgetSupplier(() -> DATAFUSION_MEMORY_POOL_LIMIT.get(clusterService.getSettings())); + this.substraitExtensions = loadSubstraitExtensions(); return Collections.singletonList(dataFusionService); } + /** + * Project the active-query metrics map down to {@code taskId -> currentBytes} for the + * backpressure native-memory tracker. One FFM snapshot per call, capped at + * {@link #ACTIVE_QUERY_METRICS_TOP_N} entries (the heaviest live queries). + * Returns an empty map when the service isn't running, so startup/shutdown races + * don't surface bad data. + */ + private Map currentBytesByTaskId() { + if (dataFusionService == null) { + return Collections.emptyMap(); + } + Map metrics = getTopQueriesByMemory(); + if (metrics.isEmpty()) { + return Collections.emptyMap(); + } + Map out = new HashMap<>(metrics.size()); + for (Map.Entry e : metrics.entrySet()) { + out.put(e.getKey(), e.getValue().currentBytes()); + } + if (logger.isDebugEnabled()) { + logger.debug("native memory snapshot: {} active queries", out.size()); + } + return out; + } + /** * Loads the Substrait default extension catalog with the plugin's classloader as the * thread context classloader. Jackson polymorphic deserialization (used by Substrait @@ -284,4 +333,27 @@ public void close() throws IOException { dataFusionService.close(); } } + + /** + * Snapshot the native DataFusion per-query registry and return a {@code contextId -> metrics} + * map. Returns an empty map when the service is not yet running (startup) or has been stopped + * (shutdown), so callers never see a half-initialized view. + * + *

Each entry mirrors one {@code QueryTracker} on the Rust side — current and peak memory + * reservation, wall time, and whether the query has completed but not yet been drained. + * The map contains at most {@link #ACTIVE_QUERY_METRICS_TOP_N} entries — the heaviest live + * queries by {@code current_bytes}, selected on the Rust side. Iteration order matches the + * order Rust drained the bounded min-heap (unspecified but stable per snapshot). + */ + @Override + public Map getTopQueriesByMemory() { + if (dataFusionService == null) { + return Collections.emptyMap(); + } + Map result = NativeBridge.getTopNQueriesByMemory(ACTIVE_QUERY_METRICS_TOP_N); + if (logger.isDebugEnabled()) { + logger.debug("getTopQueriesByMemory: {} entries from native registry", result.size()); + } + return result; + } } diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/NativeBridge.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/NativeBridge.java index 45440e3c6f3d5..d261cb9514deb 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/NativeBridge.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/NativeBridge.java @@ -9,6 +9,7 @@ package org.opensearch.be.datafusion.nativelib; import org.opensearch.analytics.backend.jni.NativeHandle; +import org.opensearch.analytics.spi.QueryExecutionMetrics; import org.opensearch.be.datafusion.stats.DataFusionStats; import org.opensearch.be.datafusion.stats.NativeExecutorsStats; import org.opensearch.be.datafusion.stats.TaskMonitorStats; @@ -22,8 +23,11 @@ import java.lang.foreign.SymbolLookup; import java.lang.foreign.ValueLayout; import java.lang.invoke.MethodHandle; +import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * FFM bridge to native DataFusion library. @@ -84,6 +88,7 @@ public final class NativeBridge { private static final MethodHandle EXECUTE_WITH_CONTEXT; private static final MethodHandle CANCEL_QUERY; private static final MethodHandle STATS; + private static final MethodHandle QUERY_REGISTRY_TOP_N_BY_CURRENT; private static final MethodHandle PREPARE_PARTIAL_PLAN; private static final MethodHandle PREPARE_FINAL_PLAN; private static final MethodHandle EXECUTE_LOCAL_PREPARED_PLAN; @@ -407,6 +412,12 @@ public final class NativeBridge { FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS, ValueLayout.JAVA_LONG) ); + // i64 df_query_registry_top_n_by_current(out_ptr, cap_entries) + QUERY_REGISTRY_TOP_N_BY_CURRENT = linker.downcallHandle( + lib.find("df_query_registry_top_n_by_current").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS, ValueLayout.JAVA_LONG) + ); + // ── Distributed aggregate: prepare partial/final plans ── // i64 df_prepare_partial_plan(handle_ptr, bytes_ptr, bytes_len) PREPARE_PARTIAL_PLAN = linker.downcallHandle( @@ -709,6 +720,55 @@ public static DataFusionStats stats() { } } + // ---- Per-query registry top-N snapshot ---- + + /** + * Snapshot the {@code n} heaviest live queries by {@code current_bytes}. + * + *

One FFM call: Java allocates an {@code n × ENTRY_BYTES} segment, Rust + * runs a bounded min-heap of size {@code n} over its registry and writes + * the survivors as {@link QueryRegistryLayout} entries. Completed and + * zero-byte trackers are filtered on the Rust side. Order within the + * buffer is unspecified. + * + *

Mirrors the {@link #stats()} pattern: the layout class decodes + * directly into the caller's final type ({@link QueryExecutionMetrics}) + * with no transport-only intermediate. + * + * @param n maximum entries to return; must be non-negative. Zero short-circuits + * without crossing the FFM boundary. + * @return a non-null map of {@code contextId → metrics}, possibly empty, + * with at most {@code n} entries. + * @throws IllegalArgumentException if {@code n} is negative or implies a + * buffer larger than {@link Integer#MAX_VALUE} bytes + */ + public static Map getTopNQueriesByMemory(int n) { + if (n < 0) { + throw new IllegalArgumentException("n must be non-negative: " + n); + } + if (n == 0) { + return Collections.emptyMap(); + } + long bytes = (long) n * QueryRegistryLayout.ENTRY_BYTES; + if (bytes > Integer.MAX_VALUE) { + throw new IllegalArgumentException("n too large for a single snapshot: " + n); + } + try (var call = new NativeCall()) { + var seg = call.buf((int) bytes); + long written = call.invoke(QUERY_REGISTRY_TOP_N_BY_CURRENT, seg, (long) n); + if (written == 0L) { + return Collections.emptyMap(); + } + int rows = (int) Math.min(written, (long) n); + Map out = new HashMap<>(rows); + for (int i = 0; i < rows; i++) { + long ctxId = QueryRegistryLayout.readContextId(seg, i); + out.put(ctxId, QueryRegistryLayout.readMetrics(seg, i)); + } + return Collections.unmodifiableMap(out); + } + } + // ---- Stubs ---- public static byte[] sqlToSubstrait(long readerPtr, String tableName, String sql, long runtimePtr) { diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/QueryRegistryLayout.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/QueryRegistryLayout.java new file mode 100644 index 0000000000000..568d57dc4851f --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/nativelib/QueryRegistryLayout.java @@ -0,0 +1,84 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.datafusion.nativelib; + +import org.opensearch.analytics.spi.QueryExecutionMetrics; + +import java.lang.foreign.MemoryLayout; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.StructLayout; +import java.lang.foreign.ValueLayout; + +/** + * Mirrors the Rust {@code query_tracker::WireQueryMetric} {@code #[repr(C)]} + * struct (2 × i64 = 16 bytes) and decodes a strided buffer of those structs + * directly into the SPI types consumed by + * {@link org.opensearch.analytics.spi.AnalyticsSearchBackendPlugin#getTopQueriesByMemory}. + * + *

Decoder reads each row by slicing the segment to that row's 16-byte + * window. We deliberately avoid {@code SequenceLayout}-derived + * {@code VarHandle}s because their bounds check enforces the segment span + * the entire sequence layout, not just up to the requested row. With a + * generic {@code Long.MAX_VALUE / ENTRY_BYTES} sequence layout the read + * always fails an {@link IndexOutOfBoundsException}. + * + *

Buffer shape (populated by {@code df_query_registry_top_n_by_current}): + *

+ *   [ entry 0 ][ entry 1 ] ... [ entry N-1 ]
+ *   each entry = { context_id, current_bytes } (2 × i64)
+ * 
+ */ +public final class QueryRegistryLayout { + + /** Layout of a single wire entry. */ + public static final StructLayout ENTRY_LAYOUT = MemoryLayout.structLayout( + ValueLayout.JAVA_LONG.withName("context_id"), + ValueLayout.JAVA_LONG.withName("current_bytes") + ); + + /** Byte size of one wire entry. Matches {@code size_of::()} on the Rust side. */ + public static final long ENTRY_BYTES = ENTRY_LAYOUT.byteSize(); + + // Byte offsets within a single entry. Field order must match + // ENTRY_LAYOUT and the Rust #[repr(C)] WireQueryMetric struct. + private static final long OFF_CONTEXT_ID = 0L; + private static final long OFF_CURRENT_BYTES = 8L; + + static { + long expected = 2L * Long.BYTES; + if (ENTRY_BYTES != expected) { + throw new AssertionError("QueryRegistryLayout entry size mismatch: expected " + expected + " but got " + ENTRY_BYTES); + } + } + + private QueryRegistryLayout() {} + + /** + * Read the {@code context_id} of the entry at zero-based index {@code i}. + * + * @param seg buffer populated by {@code df_query_registry_top_n_by_current} + * @param i row index, must be {@code < written} returned by the FFI call + */ + public static long readContextId(MemorySegment seg, int i) { + long rowOffset = (long) i * ENTRY_BYTES; + return seg.get(ValueLayout.JAVA_LONG, rowOffset + OFF_CONTEXT_ID); + } + + /** + * Read the metrics fields of the entry at zero-based index {@code i} into + * a fresh {@link QueryExecutionMetrics} record. + * + * @param seg buffer populated by {@code df_query_registry_top_n_by_current} + * @param i row index, must be {@code < written} returned by the FFI call + */ + public static QueryExecutionMetrics readMetrics(MemorySegment seg, int i) { + long rowOffset = (long) i * ENTRY_BYTES; + return new QueryExecutionMetrics(seg.get(ValueLayout.JAVA_LONG, rowOffset + OFF_CURRENT_BYTES)); + } +} diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/task/AnalyticsQueryTask.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/task/AnalyticsQueryTask.java index 8fcb1b911048f..bb9ac23642f78 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/task/AnalyticsQueryTask.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/task/AnalyticsQueryTask.java @@ -15,11 +15,10 @@ import org.opensearch.common.Nullable; import org.opensearch.common.unit.TimeValue; import org.opensearch.core.tasks.TaskId; -import org.opensearch.tasks.CancellableTask; -import org.opensearch.tasks.SearchBackpressureTask; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; /** * Coordinator-level cancellable task representing a running analytics query. @@ -28,7 +27,7 @@ * * @opensearch.internal */ -public class AnalyticsQueryTask extends CancellableTask implements SearchBackpressureTask { +public class AnalyticsQueryTask extends SearchTask { private static final Logger logger = LogManager.getLogger(AnalyticsQueryTask.class); @@ -49,7 +48,7 @@ public AnalyticsQueryTask( id, type, action, - "queryId[" + queryId + "]", + (Supplier) () -> "queryId[" + queryId + "]", parentTaskId, headers, cancelAfterTimeInterval != null ? cancelAfterTimeInterval : TimeValue.MINUS_ONE @@ -58,15 +57,15 @@ public AnalyticsQueryTask( this.cancelAfterTimeInterval = cancelAfterTimeInterval; } - public AnalyticsQueryTask(long id, String type, String action, String queryId, TaskId parentTaskId, Map headers) { - this(id, type, action, queryId, parentTaskId, headers, null); - } - @Override public boolean shouldCancelChildrenOnCancellation() { return true; } + public AnalyticsQueryTask(long id, String type, String action, String queryId, TaskId parentTaskId, Map headers) { + this(id, type, action, queryId, parentTaskId, headers, null); + } + public String getQueryId() { return queryId; } diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/task/AnalyticsShardTask.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/task/AnalyticsShardTask.java index 8bed052216aa2..8155091958a94 100644 --- a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/task/AnalyticsShardTask.java +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/task/AnalyticsShardTask.java @@ -8,19 +8,26 @@ package org.opensearch.analytics.exec.task; +import org.opensearch.action.search.SearchShardTask; import org.opensearch.core.tasks.TaskId; -import org.opensearch.tasks.CancellableTask; import java.util.Map; /** * Data-node shard task representing a single shard fragment execution. - * Analogous to {@link org.opensearch.action.search.SearchShardTask}. - * Cancelling this task does not cascade to children. + *

Extends {@link SearchShardTask} so that search backpressure and + * {@code TaskResourceTrackingService} observe and track analytics shard + * tasks alongside regular search shard tasks. The native-memory tracker + * in {@code SearchBackpressureService} filters by {@code SearchShardTask}, + * so inheriting from it is the integration point that exposes per-query + * DataFusion memory to cancellation. + * + *

Cancelling this task does not cascade to children (inherited behaviour + * from {@link SearchShardTask}). * * @opensearch.internal */ -public class AnalyticsShardTask extends CancellableTask { +public class AnalyticsShardTask extends SearchShardTask { public AnalyticsShardTask(long id, String type, String action, String description, TaskId parentTaskId, Map headers) { super(id, type, action, description, parentTaskId, headers); diff --git a/server/src/internalClusterTest/java/org/opensearch/search/backpressure/NativeMemorySearchBackpressureIT.java b/server/src/internalClusterTest/java/org/opensearch/search/backpressure/NativeMemorySearchBackpressureIT.java new file mode 100644 index 0000000000000..f04a19eaf1dd9 --- /dev/null +++ b/server/src/internalClusterTest/java/org/opensearch/search/backpressure/NativeMemorySearchBackpressureIT.java @@ -0,0 +1,405 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.search.backpressure; + +import org.apache.lucene.util.Constants; +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.action.ActionType; +import org.opensearch.action.search.SearchShardTask; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.SuppressForbidden; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.unit.TimeValue; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.tasks.TaskCancelledException; +import org.opensearch.core.tasks.TaskId; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.env.Environment; +import org.opensearch.env.NodeEnvironment; +import org.opensearch.plugins.ActionPlugin; +import org.opensearch.plugins.Plugin; +import org.opensearch.repositories.RepositoriesService; +import org.opensearch.script.ScriptService; +import org.opensearch.search.backpressure.settings.NodeDuressSettings; +import org.opensearch.search.backpressure.settings.SearchBackpressureSettings; +import org.opensearch.search.backpressure.settings.SearchShardTaskSettings; +import org.opensearch.search.backpressure.trackers.NativeMemoryUsageTracker; +import org.opensearch.tasks.CancellableTask; +import org.opensearch.tasks.Task; +import org.opensearch.test.OpenSearchIntegTestCase; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.TransportService; +import org.opensearch.transport.client.Client; +import org.opensearch.watcher.ResourceWatcherService; +import org.opensearch.wlm.WorkloadGroupTask; +import org.hamcrest.MatcherAssert; +import org.junit.After; +import org.junit.Before; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Supplier; + +import static org.opensearch.test.hamcrest.OpenSearchAssertions.assertAcked; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.Assume.assumeTrue; + +/** + * Integration tests for the native-memory path of {@link SearchBackpressureService}. + * + *

Native-memory tracking depends on three things being true at SBP-construction time: + *

    + *
  1. The host is Linux (so {@code OsProbe.getProcessNativeMemoryBytes()} is non-negative).
  2. + *
  3. A backend has installed a snapshot supplier on + * {@link org.opensearch.search.backpressure.NativeMemoryUsageService} before SBP is built.
  4. + *
  5. {@code OsProbe.getInstance().getTotalPhysicalMemorySize() > 0}.
  6. + *
+ * + *

{@link NativeMemoryTestPlugin} satisfies the second condition by installing fake snapshot + * and budget suppliers from {@code createComponents}, mirroring the production wiring path + * {@code DataFusionPlugin} uses. The first and third are checked via {@code assumeTrue} so the + * suite is skipped on macOS/Windows CI rather than failing. + */ +@OpenSearchIntegTestCase.ClusterScope(scope = OpenSearchIntegTestCase.Scope.TEST, numDataNodes = 1) +public class NativeMemorySearchBackpressureIT extends OpenSearchIntegTestCase { + + private static final TimeValue TIMEOUT = new TimeValue(10, TimeUnit.SECONDS); + private static final long DEFAULT_BUDGET_BYTES = 1024L * 1024L * 1024L; // 1 GiB + + /** + * Per-task-id native bytes published into {@link NativeMemoryUsageTracker}'s snapshot + * supplier. Tests mutate this map between requests; the supplier installed by + * {@link NativeMemoryTestPlugin} reads the live reference on every refresh. + */ + private static final Map SNAPSHOT = new ConcurrentHashMap<>(); + private static final AtomicLong BUDGET_BYTES = new AtomicLong(DEFAULT_BUDGET_BYTES); + + @Override + protected Collection> nodePlugins() { + final List> plugins = new ArrayList<>(super.nodePlugins()); + plugins.add(NativeMemoryTestPlugin.class); + return plugins; + } + + @Before + public final void setupNodeSettings() { + // Native-memory duress + tracking only engages on Linux; skip otherwise. + assumeTrue("native-memory backpressure path is Linux-only (OsProbe.getProcessNativeMemoryBytes)", Constants.LINUX); + + // Force native-memory duress to trip after a single observation by setting the threshold + // to 0.0 — any usedFraction above zero counts as a breach. Combined with a tiny + // numSuccessiveBreaches the duress flips on the next tick deterministically. + Settings request = Settings.builder() + .put(NodeDuressSettings.SETTING_CPU_THRESHOLD.getKey(), 1.0) // disable CPU duress + .put(NodeDuressSettings.SETTING_HEAP_THRESHOLD.getKey(), 1.0) // disable heap duress + .put(NodeDuressSettings.SETTING_NATIVE_MEMORY_THRESHOLD.getKey(), 0.0) + .put(NodeDuressSettings.SETTING_NUM_SUCCESSIVE_BREACHES.getKey(), 1) + // search_backpressure.node_duress.native_memory_limit must be > 0 or the duress + // lambda short-circuits ("totalNative is zero"). 1 byte is enough — the probe + // reports a non-zero RssAnon. + .put(NodeDuressSettings.NODE_NATIVE_MEMORY_LIMIT_SETTING.getKey(), "1b") + .build(); + assertAcked(client().admin().cluster().prepareUpdateSettings().setPersistentSettings(request).get()); + + SNAPSHOT.clear(); + BUDGET_BYTES.set(DEFAULT_BUDGET_BYTES); + } + + @After + public final void cleanupNodeSettings() { + SNAPSHOT.clear(); + BUDGET_BYTES.set(DEFAULT_BUDGET_BYTES); + assertAcked( + client().admin() + .cluster() + .prepareUpdateSettings() + .setPersistentSettings(Settings.builder().putNull("*")) + .setTransientSettings(Settings.builder().putNull("*")) + ); + } + + /** + * The shard task's reported native bytes exceed {@code budget * fraction}; SBP must cancel + * with a "native memory usage exceeded" reason when running in {@code enforced} mode. + */ + public void testSearchShardTaskCancellationWithHighNativeMemory() throws InterruptedException { + Settings request = Settings.builder() + .put(SearchBackpressureSettings.SETTING_MODE.getKey(), "enforced") + // Threshold = 0.5 of 1 GiB = 512 MiB. Snapshot publishes 768 MiB for the task — + // strictly above, so the evaluator must produce a cancellation reason. + .put(SearchShardTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.getKey(), 0.5) + .build(); + assertAcked(client().admin().cluster().prepareUpdateSettings().setPersistentSettings(request).get()); + + ExceptionCatchingListener listener = new ExceptionCatchingListener(); + client().execute(TestTransportAction.ACTION, new TestRequest(taskId -> SNAPSHOT.put(taskId, 768L * 1024L * 1024L)), listener); + assertTrue("task should have been cancelled within " + TIMEOUT, listener.latch.await(TIMEOUT.getSeconds() * 2, TimeUnit.SECONDS)); + + Exception caughtException = listener.getException(); + assertNotNull("SearchShardTask should have been cancelled with TaskCancelledException", caughtException); + MatcherAssert.assertThat(caughtException, instanceOf(TaskCancelledException.class)); + MatcherAssert.assertThat(caughtException.getMessage(), containsString("native memory usage exceeded")); + } + + /** + * Per-task threshold is set, but the published bytes are below the evaluator's cutoff. SBP + * must NOT cancel even though native-memory duress is active. This pins the contract that + * duress alone doesn't kill tasks — the evaluator decides per task. + */ + public void testSearchShardTaskNotCancelledBelowThreshold() throws InterruptedException { + Settings request = Settings.builder() + .put(SearchBackpressureSettings.SETTING_MODE.getKey(), "enforced") + // Threshold = 0.9 of 1 GiB ≈ 922 MiB. Snapshot publishes 100 MiB — well under. + .put(SearchShardTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.getKey(), 0.9) + .build(); + assertAcked(client().admin().cluster().prepareUpdateSettings().setPersistentSettings(request).get()); + + ExceptionCatchingListener listener = new ExceptionCatchingListener(); + client().execute(TestTransportAction.ACTION, new TestRequest(taskId -> SNAPSHOT.put(taskId, 100L * 1024L * 1024L)), listener); + // Task self-completes after TIMEOUT; allow extra room. + assertTrue( + "task should have completed within " + (TIMEOUT.getSeconds() * 3) + "s", + listener.latch.await(TIMEOUT.getSeconds() * 3, TimeUnit.SECONDS) + ); + assertNull("SearchShardTask under threshold must not be cancelled by the native-memory tracker", listener.getException()); + } + + /** + * Native-memory threshold setting explicitly set to {@code 0.0} (the rollout-gate inert state). + * Even with heavy reported bytes the tracker must stay silent, because {@code fraction == 0} + * disables evaluation. Pins the spec's "operator can opt out" contract. + */ + public void testTrackerInertWhenPerTaskFractionIsZero() throws InterruptedException { + Settings request = Settings.builder() + .put(SearchBackpressureSettings.SETTING_MODE.getKey(), "enforced") + // Explicitly set to 0.0 to override the production default and force the inert path. + .put(SearchShardTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.getKey(), 0.0) + .build(); + assertAcked(client().admin().cluster().prepareUpdateSettings().setPersistentSettings(request).get()); + + ExceptionCatchingListener listener = new ExceptionCatchingListener(); + client().execute( + TestTransportAction.ACTION, + new TestRequest(taskId -> SNAPSHOT.put(taskId, 4L * 1024L * 1024L * 1024L)), // 4 GiB + listener + ); + assertTrue("task should have completed without cancellation", listener.latch.await(TIMEOUT.getSeconds() * 3, TimeUnit.SECONDS)); + assertNull("Per-task fraction explicitly set to 0 — the tracker must not cancel", listener.getException()); + } + + /** + * In {@code monitor_only} mode, SBP must log breaches but never invoke + * {@code TaskManager.cancelTaskAndDescendants}. The task therefore self-completes. + */ + public void testMonitorOnlyModeDoesNotCancel() throws InterruptedException { + Settings request = Settings.builder() + .put(SearchBackpressureSettings.SETTING_MODE.getKey(), "monitor_only") + .put(SearchShardTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.getKey(), 0.5) + .build(); + assertAcked(client().admin().cluster().prepareUpdateSettings().setPersistentSettings(request).get()); + + ExceptionCatchingListener listener = new ExceptionCatchingListener(); + client().execute(TestTransportAction.ACTION, new TestRequest(taskId -> SNAPSHOT.put(taskId, 768L * 1024L * 1024L)), listener); + assertTrue( + "task should have completed without cancellation in monitor_only", + listener.latch.await(TIMEOUT.getSeconds() * 3, TimeUnit.SECONDS) + ); + assertNull("monitor_only must not cancel", listener.getException()); + } + + /** + * {@code search_backpressure.mode = disabled} short-circuits the entire {@code doRun} loop + * before any tracker is consulted. The task must complete normally even with a heavy + * snapshot reading. + */ + public void testDisabledModeShortCircuits() throws InterruptedException { + Settings request = Settings.builder() + .put(SearchBackpressureSettings.SETTING_MODE.getKey(), "disabled") + .put(SearchShardTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.getKey(), 0.5) + .build(); + assertAcked(client().admin().cluster().prepareUpdateSettings().setPersistentSettings(request).get()); + + ExceptionCatchingListener listener = new ExceptionCatchingListener(); + client().execute(TestTransportAction.ACTION, new TestRequest(taskId -> SNAPSHOT.put(taskId, 999L * 1024L * 1024L)), listener); + assertTrue( + "task should have completed without cancellation in disabled mode", + listener.latch.await(TIMEOUT.getSeconds() * 3, TimeUnit.SECONDS) + ); + assertNull("disabled mode must not cancel", listener.getException()); + } + + // ----------------------------------------------------------------------- + // Test plugin + transport action + // ----------------------------------------------------------------------- + + /** + * Installs the snapshot + budget suppliers on {@link NativeMemoryUsageTracker} during + * {@code createComponents} — same boot-time hook the production DataFusion plugin uses. + * SBP is constructed in {@code Node} after {@code createComponents} runs, so the + * suppliers are visible when {@code isNativeTrackingSupported()} is evaluated. + */ + public static class NativeMemoryTestPlugin extends Plugin implements ActionPlugin { + @Override + public Collection createComponents( + Client client, + ClusterService clusterService, + ThreadPool threadPool, + ResourceWatcherService resourceWatcherService, + ScriptService scriptService, + NamedXContentRegistry xContentRegistry, + Environment environment, + NodeEnvironment nodeEnvironment, + org.opensearch.core.common.io.stream.NamedWriteableRegistry namedWriteableRegistry, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier repositoriesServiceSupplier + ) { + // Live-reading the static maps so test methods can mutate them between requests. + NativeMemoryUsageTracker.setSnapshotSupplier(() -> new HashMap<>(SNAPSHOT)); + NativeMemoryUsageTracker.setNativeMemoryBudgetSupplier(BUDGET_BYTES::get); + return Collections.emptyList(); + } + + @Override + public List> getActions() { + return Collections.singletonList(new ActionHandler<>(TestTransportAction.ACTION, TestTransportAction.class)); + } + + @Override + public List> getClientActions() { + return Collections.singletonList(TestTransportAction.ACTION); + } + } + + /** + * A request whose handler-side hook lets each test publish a per-task snapshot entry + * before busy-waiting for cancellation. The hook receives the task id assigned by + * {@code TaskManager.register}, which is the same id SBP looks up in the snapshot. + */ + public static class TestRequest extends ActionRequest { + private final transient SnapshotPublisher publisher; + + public TestRequest(SnapshotPublisher publisher) { + this.publisher = publisher; + } + + public TestRequest(StreamInput in) throws IOException { + super(in); + this.publisher = id -> {}; + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + @Override + public Task createTask(long id, String type, String action, TaskId parentTaskId, Map headers) { + // Use SearchShardTask so SBP picks it up via getTaskByType(SearchShardTask.class). + SearchShardTask task = new SearchShardTask(id, type, action, "native-memory-it", parentTaskId, headers); + publisher.publish(id); + return task; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + } + } + + /** Side-channel for tests to inject the per-task snapshot reading at task-creation time. */ + @FunctionalInterface + public interface SnapshotPublisher { + void publish(long taskId); + } + + public static class TestResponse extends ActionResponse { + public TestResponse() {} + + public TestResponse(StreamInput in) {} + + @Override + public void writeTo(StreamOutput out) throws IOException {} + } + + public static class TestTransportAction extends HandledTransportAction { + public static final ActionType ACTION = new ActionType<>("internal::native_memory_bp_test_action", TestResponse::new); + private final ThreadPool threadPool; + + @Inject + public TestTransportAction(TransportService transportService, ThreadPool threadPool, ActionFilters actionFilters) { + super(ACTION.name(), transportService, actionFilters, TestRequest::new); + this.threadPool = threadPool; + } + + @Override + @SuppressForbidden(reason = "Simulating a busy task by sleeping") + protected void doExecute(Task task, TestRequest request, ActionListener listener) { + threadPool.executor(ThreadPool.Names.SEARCH).execute(() -> { + try { + CancellableTask cancellableTask = (CancellableTask) task; + ((WorkloadGroupTask) task).setWorkloadGroupId(threadPool.getThreadContext()); + long startTime = System.nanoTime(); + // Busy-wait for cancellation or timeout. SBP runs every interval_millis + // (default 1s); the loop must outlive at least two ticks for the duress + // streak to trip and the cancel decision to fire. + do { + Thread.sleep(50); + } while (cancellableTask.isCancelled() == false && (System.nanoTime() - startTime) < TIMEOUT.getNanos()); + + if (cancellableTask.isCancelled()) { + throw new TaskCancelledException(cancellableTask.getReasonCancelled()); + } else { + listener.onResponse(new TestResponse()); + } + } catch (Exception e) { + listener.onFailure(e); + } + }); + } + } + + /** Reused in-suite instead of importing from {@link SearchBackpressureIT}. */ + public static class ExceptionCatchingListener implements ActionListener { + private final CountDownLatch latch = new CountDownLatch(1); + private volatile Exception exception = null; + + @Override + public void onResponse(TestResponse r) { + latch.countDown(); + } + + @Override + public void onFailure(Exception e) { + this.exception = e; + latch.countDown(); + } + + public Exception getException() { + return exception; + } + } +} diff --git a/server/src/main/java/org/opensearch/common/settings/ClusterSettings.java b/server/src/main/java/org/opensearch/common/settings/ClusterSettings.java index 01fbf335a3ca0..37eb9cac44d6a 100644 --- a/server/src/main/java/org/opensearch/common/settings/ClusterSettings.java +++ b/server/src/main/java/org/opensearch/common/settings/ClusterSettings.java @@ -725,6 +725,7 @@ public void apply(Settings value, Settings current, Settings previous) { NodeDuressSettings.SETTING_NUM_SUCCESSIVE_BREACHES, NodeDuressSettings.SETTING_CPU_THRESHOLD, NodeDuressSettings.SETTING_HEAP_THRESHOLD, + NodeDuressSettings.SETTING_NATIVE_MEMORY_THRESHOLD, SearchTaskSettings.SETTING_CANCELLATION_RATIO, SearchTaskSettings.SETTING_CANCELLATION_RATE, SearchTaskSettings.SETTING_CANCELLATION_BURST, @@ -734,6 +735,7 @@ public void apply(Settings value, Settings current, Settings previous) { SearchTaskSettings.SETTING_CPU_TIME_MILLIS_THRESHOLD, SearchTaskSettings.SETTING_ELAPSED_TIME_MILLIS_THRESHOLD, SearchTaskSettings.SETTING_TOTAL_HEAP_PERCENT_THRESHOLD, + SearchTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD, SearchShardTaskSettings.SETTING_CANCELLATION_RATIO, SearchShardTaskSettings.SETTING_CANCELLATION_RATE, SearchShardTaskSettings.SETTING_CANCELLATION_BURST, @@ -743,6 +745,7 @@ public void apply(Settings value, Settings current, Settings previous) { SearchShardTaskSettings.SETTING_CPU_TIME_MILLIS_THRESHOLD, SearchShardTaskSettings.SETTING_ELAPSED_TIME_MILLIS_THRESHOLD, SearchShardTaskSettings.SETTING_TOTAL_HEAP_PERCENT_THRESHOLD, + SearchShardTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD, SearchBackpressureSettings.SETTING_CANCELLATION_RATIO, // deprecated SearchBackpressureSettings.SETTING_CANCELLATION_RATE, // deprecated SearchBackpressureSettings.SETTING_CANCELLATION_BURST, // deprecated diff --git a/server/src/main/java/org/opensearch/monitor/os/OsProbe.java b/server/src/main/java/org/opensearch/monitor/os/OsProbe.java index 39a2026af0ad6..fb74bff50921b 100644 --- a/server/src/main/java/org/opensearch/monitor/os/OsProbe.java +++ b/server/src/main/java/org/opensearch/monitor/os/OsProbe.java @@ -38,6 +38,7 @@ import org.opensearch.common.SuppressForbidden; import org.opensearch.common.io.PathUtils; import org.opensearch.monitor.Probes; +import org.opensearch.monitor.jvm.JvmStats; import java.io.IOException; import java.lang.management.ManagementFactory; @@ -236,6 +237,18 @@ String readProcLoadavg() throws IOException { return readSingleLine(PathUtils.get("/proc/loadavg")); } + /** + * Reads the contents of {@code /proc/self/status} as a list of lines, one per key. + * Package-private so tests can override with a synthetic file layout. + * + * @return the lines from {@code /proc/self/status} + * @throws IOException if an I/O exception occurs reading the file + */ + @SuppressForbidden(reason = "access /proc/self/status") + List readProcSelfStatus() throws IOException { + return Files.readAllLines(PathUtils.get("/proc/self/status")); + } + /** * Reads the {@code RssAnon} field (anonymous resident memory) of the current process from * {@code /proc/self/status} and returns it in bytes. Returns {@code -1L} when the host is @@ -257,18 +270,6 @@ public long getProcessRssAnon() { } } - /** - * Reads the lines of {@code /proc/self/status}. Package-private so tests can override it - * with canned file contents. - * - * @return the lines of {@code /proc/self/status} - * @throws IOException if the file cannot be opened or read - */ - @SuppressForbidden(reason = "access /proc/self/status") - List readProcSelfStatus() throws IOException { - return Files.readAllLines(PathUtils.get("/proc/self/status")); - } - /** * Reads the {@code RssAnon} field from {@code /proc/self/status}. * @@ -301,6 +302,15 @@ long readRssAnonFromProcSelfStatus() throws IOException { return -1L; } + public long getProcessNativeMemoryBytes() { + long rssAnon = getProcessRssAnon(); + if (rssAnon < 0L) { + return -1L; + } + long jvmHeapMax = JvmStats.jvmStats().getMem().getHeapMax().getBytes(); + return Math.max(0L, rssAnon - jvmHeapMax); + } + public short getSystemCpuPercent() { return Probes.getLoadAndScaleToPercent(getSystemCpuLoad, osMxBean); } diff --git a/server/src/main/java/org/opensearch/search/backpressure/NativeMemoryUsageService.java b/server/src/main/java/org/opensearch/search/backpressure/NativeMemoryUsageService.java new file mode 100644 index 0000000000000..07f9475d5f821 --- /dev/null +++ b/server/src/main/java/org/opensearch/search/backpressure/NativeMemoryUsageService.java @@ -0,0 +1,188 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.search.backpressure; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Collections; +import java.util.Map; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +/** + * Owns the cross-tick snapshot of per-query native (off-heap) memory usage that + * {@link org.opensearch.search.backpressure.trackers.NativeMemoryUsageTracker} + * consults. Keeps a single source of truth so all per-task-class trackers share one + * snapshot rather than each holding its own copy. + * + *

Why a separate service

+ *

The tracker is per task class (today: {@code SearchTask} and + * {@code SearchShardTask}), so without a shared service there are two parallel + * snapshot maps that {@code SearchBackpressureService.doRun()} refreshes + * independently — two FFM calls for the same data. Moving the state into a single + * service collapses that to one refresh per tick and gives the deferred + * production-readiness work (state machine, observability counters, cancel-to-release + * propagation) a natural home. + * + *

Lifecycle

+ *

The service is a process-wide singleton accessed via {@link #getInstance()}. + * That mirrors the previous static-supplier wiring on {@code NativeMemoryUsageTracker} + * and lets a backend plugin call {@link #setSnapshotSupplier} from + * {@code createComponents} without threading a service reference through plugin SPI. + * A future spec can promote this to an injected component once this service + * graduates to a proper injected component. + * + *

Thread safety

+ *
    + *
  • Suppliers are {@code volatile} — single-writer (plugin {@code createComponents}), + * multi-reader (tracker refresh path, stats path).
  • + *
  • The snapshot reference is {@code volatile} — published once per + * {@link #refresh()} call.
  • + *
  • Per-task lookups via {@link #currentBytes(long)} read a stable + * map reference; concurrent {@link #refresh()} swaps the reference, never + * mutates the in-flight map.
  • + *
+ * + * @opensearch.internal + */ +public final class NativeMemoryUsageService { + + private static final Logger logger = LogManager.getLogger(NativeMemoryUsageService.class); + + private static final Supplier> EMPTY_SNAPSHOT_SUPPLIER = Collections::emptyMap; + private static final LongSupplier ZERO_BUDGET_SUPPLIER = () -> 0L; + + private static final NativeMemoryUsageService INSTANCE = new NativeMemoryUsageService(); + + /** + * Source of the per-query native-memory snapshot. Installed by a backend plugin + * (e.g. DataFusion) at boot; defaults to an empty-map supplier so the tracker + * stays inert until a backend opts in. + */ + private volatile Supplier> snapshotSupplier = EMPTY_SNAPSHOT_SUPPLIER; + + /** + * Source of the total native-memory budget in bytes (e.g. DataFusion's configured + * pool limit). Defaults to {@code 0} — the tracker treats {@code budget == 0} as + * "feature disabled" and skips evaluation. + */ + private volatile LongSupplier budgetSupplier = ZERO_BUDGET_SUPPLIER; + + /** + * Last snapshot loaded by {@link #refresh()}. {@code volatile} so the publish from + * the SBP scheduler thread is visible to the per-task lookup path. Read paths must + * not mutate this map; producers always swap in a new immutable map. + */ + private volatile Map bytesByTaskId = Collections.emptyMap(); + + private NativeMemoryUsageService() {} + + /** Process-wide singleton. */ + public static NativeMemoryUsageService getInstance() { + return INSTANCE; + } + + /** + * Install the snapshot supplier. Called once from a backend plugin's + * {@code createComponents}. Last writer wins. Passing {@code null} is a no-op so + * a reload-style call site can't accidentally clear a working installation. + */ + public void setSnapshotSupplier(Supplier> supplier) { + if (supplier == null) { + return; + } + this.snapshotSupplier = supplier; + logger.info("native memory snapshot supplier installed [{}]", supplier.getClass().getName()); + } + + /** + * Install the native-memory budget supplier. Last writer wins. Passing {@code null} + * is a no-op (same rationale as {@link #setSnapshotSupplier}). + */ + public void setBudgetSupplier(LongSupplier supplier) { + if (supplier == null) { + return; + } + this.budgetSupplier = supplier; + logger.info("native memory budget supplier installed"); + } + + /** {@code true} when a backend has installed a non-default snapshot supplier. */ + public boolean hasSnapshotProvider() { + return snapshotSupplier != EMPTY_SNAPSHOT_SUPPLIER; + } + + /** + * Current native-memory budget in bytes. Clamped to {@code >= 0} so a misbehaving + * supplier returning a negative value can't flip the threshold math. + */ + public long getBudgetBytes() { + return Math.max(0L, budgetSupplier.getAsLong()); + } + + /** + * Pull a fresh snapshot from the installed supplier and publish it. Exactly one + * supplier call per invocation — for the DataFusion backend that maps to one FFM + * round-trip. Callers (SBP service) invoke this once per tick before any per-task + * lookup. + * + *

A {@code null} return from the supplier is treated as "empty snapshot" and + * logged at warn level — the tracker must never NPE because of a misbehaving + * backend. + */ + public void refresh() { + Map snapshot; + try { + snapshot = snapshotSupplier.get(); + } catch (RuntimeException e) { + logger.warn("native memory snapshot supplier threw, using empty snapshot", e); + snapshot = null; + } + if (snapshot == null) { + logger.warn("native memory snapshot supplier returned null, using empty snapshot"); + snapshot = Collections.emptyMap(); + } + this.bytesByTaskId = snapshot; + if (logger.isDebugEnabled()) { + logger.debug("native memory snapshot refreshed: size={} budget={}B", snapshot.size(), getBudgetBytes()); + } + } + + /** + * Look up a single task's current native-memory reservation. Returns {@code 0} + * when the task is not in the snapshot (either it's not an analytics query, or + * the registry hasn't yet recorded any reservations for it). + */ + public long currentBytes(long taskId) { + Long bytes = bytesByTaskId.get(taskId); + return bytes == null ? 0L : bytes; + } + + /** + * Visible-for-testing snapshot view. Returns the live reference; callers MUST NOT + * mutate. Provided so tests can assert on what {@link #refresh()} loaded without + * needing to reach into the tracker. + */ + Map snapshotView() { + return bytesByTaskId; + } + + /** + * Visible-for-testing reset. Restores defaults so a test that installs a supplier + * doesn't leak state into the next test. Production code never calls this. Public + * so tests in sibling packages (e.g. {@code .trackers}) can reach it without + * reflection. + */ + public void resetForTesting() { + this.snapshotSupplier = EMPTY_SNAPSHOT_SUPPLIER; + this.budgetSupplier = ZERO_BUDGET_SUPPLIER; + this.bytesByTaskId = Collections.emptyMap(); + } +} diff --git a/server/src/main/java/org/opensearch/search/backpressure/SearchBackpressureService.java b/server/src/main/java/org/opensearch/search/backpressure/SearchBackpressureService.java index 6c12857477768..f5da8c4c2a9ee 100644 --- a/server/src/main/java/org/opensearch/search/backpressure/SearchBackpressureService.java +++ b/server/src/main/java/org/opensearch/search/backpressure/SearchBackpressureService.java @@ -17,6 +17,7 @@ import org.opensearch.common.settings.ClusterSettings; import org.opensearch.common.settings.Setting; import org.opensearch.monitor.jvm.JvmStats; +import org.opensearch.monitor.os.OsProbe; import org.opensearch.monitor.process.ProcessProbe; import org.opensearch.search.backpressure.settings.SearchBackpressureMode; import org.opensearch.search.backpressure.settings.SearchBackpressureSettings; @@ -28,6 +29,7 @@ import org.opensearch.search.backpressure.trackers.CpuUsageTracker; import org.opensearch.search.backpressure.trackers.ElapsedTimeTracker; import org.opensearch.search.backpressure.trackers.HeapUsageTracker; +import org.opensearch.search.backpressure.trackers.NativeMemoryUsageTracker; import org.opensearch.search.backpressure.trackers.NodeDuressTrackers; import org.opensearch.search.backpressure.trackers.NodeDuressTrackers.NodeDuressTracker; import org.opensearch.search.backpressure.trackers.TaskResourceUsageTrackerType; @@ -68,19 +70,35 @@ */ public class SearchBackpressureService extends AbstractLifecycleComponent implements TaskCompletionListener { private static final Logger logger = LogManager.getLogger(SearchBackpressureService.class); + // Tracker-apply rules (each tracker decides independently via this map): + // - CPU tracker fires when CPU is in duress. + // - Heap tracker fires when heap is in duress (and heap tracking is supported). + // - Elapsed-time tracker always fires. + // - Native-memory tracker fires when native memory is in duress (and tracking is supported). + // + // When native-memory duress is active, doRun() bypasses the heap-dominance gate so all + // in-flight tasks become cancellation candidates (off-heap pressure is invisible to heap + // metrics). If CPU or heap are ALSO in duress simultaneously, their trackers will fire + // too — that is intentional: multiple resource pressures compound, and each tracker + // independently contributes cancellation reasons that are merged before execution. private static final Map> trackerApplyConditions = Map.of( TaskResourceUsageTrackerType.CPU_USAGE_TRACKER, (nodeDuressTrackers) -> nodeDuressTrackers.isResourceInDuress(ResourceType.CPU), TaskResourceUsageTrackerType.HEAP_USAGE_TRACKER, (nodeDuressTrackers) -> isHeapTrackingSupported() && nodeDuressTrackers.isResourceInDuress(ResourceType.MEMORY), TaskResourceUsageTrackerType.ELAPSED_TIME_TRACKER, - (nodeDuressTrackers) -> true + (nodeDuressTrackers) -> true, + TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER, + (nodeDuressTrackers) -> NativeMemoryUsageService.getInstance().hasSnapshotProvider() + && nodeDuressTrackers.isResourceInDuress(ResourceType.NATIVE_MEMORY) ); + private volatile Scheduler.Cancellable scheduledFuture; private final SearchBackpressureSettings settings; private final TaskResourceTrackingService taskResourceTrackingService; private final ThreadPool threadPool; + private final NativeMemoryUsageService nativeMemoryUsageService = NativeMemoryUsageService.getInstance(); private final NodeDuressTrackers nodeDuressTrackers; private final Map, TaskResourceUsageTrackers> taskTrackers; @@ -114,6 +132,32 @@ public SearchBackpressureService( () -> settings.getNodeDuressSettings().getNumSuccessiveBreaches() ) ); + put(ResourceType.NATIVE_MEMORY, new NodeDuressTracker(() -> { + NativeMemoryUsageService svc = NativeMemoryUsageService.getInstance(); + long budget = svc.getBudgetBytes(); + if (budget <= 0L) { + return false; + } + double used = OsProbe.getInstance().getProcessNativeMemoryBytes(); + if (used < 0.0d) { + if (logger.isDebugEnabled()) { + logger.debug("native memory duress probe: signal unavailable (usedBytes={})", used); + } + return false; + } + double usedFraction = used / budget; + double threshold = settings.getNodeDuressSettings().getNativeMemoryThreshold(); + boolean breached = usedFraction >= threshold; + if (logger.isDebugEnabled()) { + logger.debug( + "native memory duress probe: usedFraction={}, threshold={}, breached={}", + String.format(java.util.Locale.ROOT, "%.4f", usedFraction), + String.format(java.util.Locale.ROOT, "%.4f", threshold), + breached + ); + } + return breached; + }, () -> settings.getNodeDuressSettings().getNumSuccessiveBreaches())); } }), getTrackers( @@ -122,6 +166,7 @@ public SearchBackpressureService( settings.getSearchTaskSettings()::getHeapPercentThreshold, settings.getSearchTaskSettings().getHeapMovingAverageWindowSize(), settings.getSearchTaskSettings()::getElapsedTimeNanosThreshold, + settings.getSearchTaskSettings()::getNativeMemoryPercentThreshold, settings.getClusterSettings(), SearchTaskSettings.SETTING_HEAP_MOVING_AVERAGE_WINDOW_SIZE ), @@ -131,6 +176,7 @@ public SearchBackpressureService( settings.getSearchShardTaskSettings()::getHeapPercentThreshold, settings.getSearchShardTaskSettings().getHeapMovingAverageWindowSize(), settings.getSearchShardTaskSettings()::getElapsedTimeNanosThreshold, + settings.getSearchShardTaskSettings()::getNativeMemoryPercentThreshold, settings.getClusterSettings(), SearchShardTaskSettings.SETTING_HEAP_MOVING_AVERAGE_WINDOW_SIZE ), @@ -195,27 +241,34 @@ void doRun() { List searchTasks = getTaskByType(SearchTask.class); List searchShardTasks = getTaskByType(SearchShardTask.class); - boolean isHeapUsageDominatedBySearchTasks = isHeapUsageDominatedBySearch( - searchTasks, - getSettings().getSearchTaskSettings().getTotalHeapPercentThreshold() - ); - boolean isHeapUsageDominatedBySearchShardTasks = isHeapUsageDominatedBySearch( - searchShardTasks, - getSettings().getSearchShardTaskSettings().getTotalHeapPercentThreshold() - ); - final Map, List> cancellableTasks = Map.of( - SearchTask.class, - isHeapUsageDominatedBySearchTasks ? searchTasks : Collections.emptyList(), - SearchShardTask.class, - isHeapUsageDominatedBySearchShardTasks ? searchShardTasks : Collections.emptyList() - ); + final boolean isNativeMemoryInDuress = nodeDuressTrackers.isNativeMemoryInDuress(); + + final Map, List> cancellableTasks; + if (isNativeMemoryInDuress) { + cancellableTasks = Map.of(SearchTask.class, searchTasks, SearchShardTask.class, searchShardTasks); + nativeMemoryUsageService.refresh(); + } else { + boolean searchHeapDominated = isHeapUsageDominatedBySearch( + searchTasks, + getSettings().getSearchTaskSettings().getTotalHeapPercentThreshold() + ); + boolean searchShardHeapDominated = isHeapUsageDominatedBySearch( + searchShardTasks, + getSettings().getSearchShardTaskSettings().getTotalHeapPercentThreshold() + ); + cancellableTasks = Map.of( + SearchTask.class, + searchHeapDominated ? searchTasks : Collections.emptyList(), + SearchShardTask.class, + searchShardHeapDominated ? searchShardTasks : Collections.emptyList() + ); + } // Force-refresh usage stats of these tasks before making a cancellation decision. taskResourceTrackingService.refreshResourceStats(searchTasks.toArray(new Task[0])); taskResourceTrackingService.refreshResourceStats(searchShardTasks.toArray(new Task[0])); List taskCancellations = new ArrayList<>(); - for (TaskResourceUsageTrackerType trackerType : TaskResourceUsageTrackerType.values()) { if (shouldApply(trackerType)) { addResourceTrackerBasedCancellations(trackerType, taskCancellations, cancellableTasks); @@ -339,10 +392,6 @@ boolean isNodeInDuress() { return nodeDuressTrackers.isNodeInDuress(); } - /* - Returns true if the increase in heap usage is due to search requests. - */ - /** * Filters and returns the list of currently running tasks of specified type. */ @@ -373,6 +422,7 @@ public static TaskResourceUsageTrackers getTrackers( DoubleSupplier heapPercentThresholdSupplier, int heapMovingAverageWindowSize, LongSupplier ElapsedTimeNanosSupplier, + DoubleSupplier nativeMemoryPercentThresholdSupplier, ClusterSettings clusterSettings, Setting windowSizeSetting ) { @@ -396,6 +446,14 @@ public static TaskResourceUsageTrackers getTrackers( new ElapsedTimeTracker(ElapsedTimeNanosSupplier, System::nanoTime), TaskResourceUsageTrackerType.ELAPSED_TIME_TRACKER ); + if (NativeMemoryUsageService.getInstance().hasSnapshotProvider()) { + trackers.addTracker( + new NativeMemoryUsageTracker(nativeMemoryPercentThresholdSupplier), + TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER + ); + } else { + logger.warn("native memory tracking not supported on this platform"); + } return trackers; } diff --git a/server/src/main/java/org/opensearch/search/backpressure/settings/NodeDuressSettings.java b/server/src/main/java/org/opensearch/search/backpressure/settings/NodeDuressSettings.java index 09c1e4fcef46c..a2c77ac167732 100644 --- a/server/src/main/java/org/opensearch/search/backpressure/settings/NodeDuressSettings.java +++ b/server/src/main/java/org/opensearch/search/backpressure/settings/NodeDuressSettings.java @@ -22,6 +22,10 @@ private static class Defaults { private static final int NUM_SUCCESSIVE_BREACHES = 3; private static final double CPU_THRESHOLD = 0.9; private static final double HEAP_THRESHOLD = 0.7; + // Trip native-memory duress when processNativeBytes / nodeNativeMemoryLimit + // exceeds this fraction. Default 0.85 — tighter than HEAP_THRESHOLD because + // native allocations bypass GC and hit the OS scheduler directly. + private static final double NATIVE_MEMORY_THRESHOLD = 0.85; } /** @@ -62,6 +66,28 @@ private static class Defaults { Setting.Property.NodeScope ); + /** + * Defines the native-memory usage threshold (as a fraction) above which a node is considered + * "in duress" for native memory. The duress probe computes + * {@code usedFraction = OsProbe.getProcessNativeMemoryBytes() / nodeNativeMemoryLimit} and + * compares against this threshold; when the comparison holds for + * {@link #numSuccessiveBreaches} consecutive observations the node is marked in duress. + * + *

This gate is independent of heap duress: backends that manage memory outside the JVM + * heap (e.g. DataFusion's memory pool) can cancel search tasks when native memory is tight + * even if the heap itself is nowhere near its threshold. + */ + private volatile double nativeMemoryThreshold; + public static final Setting SETTING_NATIVE_MEMORY_THRESHOLD = Setting.doubleSetting( + "search_backpressure.node_duress.native_memory_threshold", + Defaults.NATIVE_MEMORY_THRESHOLD, + 0.0, + 1.0, + Setting.Property.Dynamic, + Setting.Property.NodeScope + ); + + public NodeDuressSettings(Settings settings, ClusterSettings clusterSettings) { numSuccessiveBreaches = SETTING_NUM_SUCCESSIVE_BREACHES.get(settings); clusterSettings.addSettingsUpdateConsumer(SETTING_NUM_SUCCESSIVE_BREACHES, this::setNumSuccessiveBreaches); @@ -71,6 +97,10 @@ public NodeDuressSettings(Settings settings, ClusterSettings clusterSettings) { heapThreshold = SETTING_HEAP_THRESHOLD.get(settings); clusterSettings.addSettingsUpdateConsumer(SETTING_HEAP_THRESHOLD, this::setHeapThreshold); + + nativeMemoryThreshold = SETTING_NATIVE_MEMORY_THRESHOLD.get(settings); + clusterSettings.addSettingsUpdateConsumer(SETTING_NATIVE_MEMORY_THRESHOLD, this::setNativeMemoryThreshold); + } public int getNumSuccessiveBreaches() { @@ -96,4 +126,14 @@ public double getHeapThreshold() { private void setHeapThreshold(double heapThreshold) { this.heapThreshold = heapThreshold; } + + public double getNativeMemoryThreshold() { + return nativeMemoryThreshold; + } + + private void setNativeMemoryThreshold(double nativeMemoryThreshold) { + this.nativeMemoryThreshold = nativeMemoryThreshold; + } + + } diff --git a/server/src/main/java/org/opensearch/search/backpressure/settings/SearchShardTaskSettings.java b/server/src/main/java/org/opensearch/search/backpressure/settings/SearchShardTaskSettings.java index 38213506c55b7..7e4b8b4740662 100644 --- a/server/src/main/java/org/opensearch/search/backpressure/settings/SearchShardTaskSettings.java +++ b/server/src/main/java/org/opensearch/search/backpressure/settings/SearchShardTaskSettings.java @@ -34,6 +34,11 @@ private static class Defaults { private static final double HEAP_PERCENT_THRESHOLD = 0.005; private static final double HEAP_VARIANCE_THRESHOLD = 2.0; private static final int HEAP_MOVING_AVERAGE_WINDOW_SIZE = 100; + // See SearchTaskSettings.Defaults for rationale: 0 / 0.0 keep the native-memory tracker + // inert until an operator or backend plugin opts in by setting a non-zero threshold. + // The per-task threshold is a fraction of the backend-installed native memory budget, + // mirroring HEAP_PERCENT_THRESHOLD. + private static final double NATIVE_MEMORY_PERCENT_THRESHOLD = 0.05; } /** @@ -161,6 +166,24 @@ private static class Defaults { Setting.Property.NodeScope ); + /** + * Defines the native-memory threshold (as a fraction of the backend-installed native-memory + * budget, in {@code [0.0, 1.0]}) for an individual search shard task before it is considered + * for cancellation. The effective per-task byte threshold is {@code budget * fraction}, where + * {@code budget} is the value installed via + * {@link org.opensearch.search.backpressure.NativeMemoryUsageService#setBudgetSupplier}. + * {@code 0.0} disables the check. + */ + private volatile double nativeMemoryPercentThreshold; + public static final Setting SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD = Setting.doubleSetting( + "search_backpressure.search_shard_task.native_memory_percent_threshold", + Defaults.NATIVE_MEMORY_PERCENT_THRESHOLD, + 0.0, + 1.0, + Setting.Property.Dynamic, + Setting.Property.NodeScope + ); + public SearchShardTaskSettings(Settings settings, ClusterSettings clusterSettings) { totalHeapPercentThreshold = SETTING_TOTAL_HEAP_PERCENT_THRESHOLD.get(settings); this.cpuTimeMillisThreshold = SETTING_CPU_TIME_MILLIS_THRESHOLD.get(settings); @@ -171,6 +194,7 @@ public SearchShardTaskSettings(Settings settings, ClusterSettings clusterSetting this.cancellationRatio = SETTING_CANCELLATION_RATIO.get(settings); this.cancellationRate = SETTING_CANCELLATION_RATE.get(settings); this.cancellationBurst = SETTING_CANCELLATION_BURST.get(settings); + this.nativeMemoryPercentThreshold = SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.get(settings); clusterSettings.addSettingsUpdateConsumer(SETTING_TOTAL_HEAP_PERCENT_THRESHOLD, this::setTotalHeapPercentThreshold); clusterSettings.addSettingsUpdateConsumer(SETTING_CPU_TIME_MILLIS_THRESHOLD, this::setCpuTimeMillisThreshold); @@ -181,6 +205,7 @@ public SearchShardTaskSettings(Settings settings, ClusterSettings clusterSetting clusterSettings.addSettingsUpdateConsumer(SETTING_CANCELLATION_RATIO, this::setCancellationRatio); clusterSettings.addSettingsUpdateConsumer(SETTING_CANCELLATION_RATE, this::setCancellationRate); clusterSettings.addSettingsUpdateConsumer(SETTING_CANCELLATION_BURST, this::setCancellationBurst); + clusterSettings.addSettingsUpdateConsumer(SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD, this::setNativeMemoryPercentThreshold); } public double getTotalHeapPercentThreshold() { @@ -231,6 +256,14 @@ public void setHeapMovingAverageWindowSize(int heapMovingAverageWindowSize) { this.heapMovingAverageWindowSize = heapMovingAverageWindowSize; } + public double getNativeMemoryPercentThreshold() { + return nativeMemoryPercentThreshold; + } + + private void setNativeMemoryPercentThreshold(double nativeMemoryPercentThreshold) { + this.nativeMemoryPercentThreshold = nativeMemoryPercentThreshold; + } + public double getCancellationRatio() { return cancellationRatio; } diff --git a/server/src/main/java/org/opensearch/search/backpressure/settings/SearchTaskSettings.java b/server/src/main/java/org/opensearch/search/backpressure/settings/SearchTaskSettings.java index f9af7f9b59fdb..72f137a974c8a 100644 --- a/server/src/main/java/org/opensearch/search/backpressure/settings/SearchTaskSettings.java +++ b/server/src/main/java/org/opensearch/search/backpressure/settings/SearchTaskSettings.java @@ -38,6 +38,12 @@ private static class Defaults { private static final double HEAP_PERCENT_THRESHOLD = 0.02; private static final double HEAP_VARIANCE_THRESHOLD = 2.0; private static final int HEAP_MOVING_AVERAGE_WINDOW_SIZE = 100; + // Native-memory tracking is opt-in. Zero keeps the tracker inert (see + // NativeMemoryUsageTracker); raise either threshold to engage cancellation for + // native-memory-heavy tasks while the node is in native-memory duress. The + // per-task threshold is expressed as a fraction of the backend-installed native + // memory budget (e.g. DataFusion's pool limit), mirroring HEAP_PERCENT_THRESHOLD. + private static final double NATIVE_MEMORY_PERCENT_THRESHOLD = 0.05; } /** @@ -165,6 +171,24 @@ private static class Defaults { Setting.Property.NodeScope ); + /** + * Defines the native-memory threshold (as a fraction of the backend-installed native-memory + * budget, in {@code [0.0, 1.0]}) for an individual search task before it is considered for + * cancellation. The effective per-task byte threshold is {@code budget * fraction}, where + * {@code budget} is the value installed via + * {@link org.opensearch.search.backpressure.NativeMemoryUsageService#setBudgetSupplier}. + * {@code 0.0} disables the check. + */ + private volatile double nativeMemoryPercentThreshold; + public static final Setting SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD = Setting.doubleSetting( + "search_backpressure.search_task.native_memory_percent_threshold", + Defaults.NATIVE_MEMORY_PERCENT_THRESHOLD, + 0.0, + 1.0, + Setting.Property.Dynamic, + Setting.Property.NodeScope + ); + public SearchTaskSettings(Settings settings, ClusterSettings clusterSettings) { this.totalHeapPercentThreshold = SETTING_TOTAL_HEAP_PERCENT_THRESHOLD.get(settings); this.cpuTimeMillisThreshold = SETTING_CPU_TIME_MILLIS_THRESHOLD.get(settings); @@ -175,6 +199,7 @@ public SearchTaskSettings(Settings settings, ClusterSettings clusterSettings) { this.cancellationRatio = SETTING_CANCELLATION_RATIO.get(settings); this.cancellationRate = SETTING_CANCELLATION_RATE.get(settings); this.cancellationBurst = SETTING_CANCELLATION_BURST.get(settings); + this.nativeMemoryPercentThreshold = SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.get(settings); clusterSettings.addSettingsUpdateConsumer(SETTING_TOTAL_HEAP_PERCENT_THRESHOLD, this::setTotalHeapPercentThreshold); clusterSettings.addSettingsUpdateConsumer(SETTING_CPU_TIME_MILLIS_THRESHOLD, this::setCpuTimeMillisThreshold); @@ -185,6 +210,7 @@ public SearchTaskSettings(Settings settings, ClusterSettings clusterSettings) { clusterSettings.addSettingsUpdateConsumer(SETTING_CANCELLATION_RATIO, this::setCancellationRatio); clusterSettings.addSettingsUpdateConsumer(SETTING_CANCELLATION_RATE, this::setCancellationRate); clusterSettings.addSettingsUpdateConsumer(SETTING_CANCELLATION_BURST, this::setCancellationBurst); + clusterSettings.addSettingsUpdateConsumer(SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD, this::setNativeMemoryPercentThreshold); } public double getTotalHeapPercentThreshold() { @@ -235,6 +261,14 @@ public void setHeapMovingAverageWindowSize(int heapMovingAverageWindowSize) { this.heapMovingAverageWindowSize = heapMovingAverageWindowSize; } + public double getNativeMemoryPercentThreshold() { + return nativeMemoryPercentThreshold; + } + + private void setNativeMemoryPercentThreshold(double nativeMemoryPercentThreshold) { + this.nativeMemoryPercentThreshold = nativeMemoryPercentThreshold; + } + public double getCancellationRatio() { return cancellationRatio; } diff --git a/server/src/main/java/org/opensearch/search/backpressure/stats/SearchShardTaskStats.java b/server/src/main/java/org/opensearch/search/backpressure/stats/SearchShardTaskStats.java index be714271c8919..64d60f65db60d 100644 --- a/server/src/main/java/org/opensearch/search/backpressure/stats/SearchShardTaskStats.java +++ b/server/src/main/java/org/opensearch/search/backpressure/stats/SearchShardTaskStats.java @@ -18,6 +18,7 @@ import org.opensearch.search.backpressure.trackers.CpuUsageTracker; import org.opensearch.search.backpressure.trackers.ElapsedTimeTracker; import org.opensearch.search.backpressure.trackers.HeapUsageTracker; +import org.opensearch.search.backpressure.trackers.NativeMemoryUsageTracker; import org.opensearch.search.backpressure.trackers.TaskResourceUsageTrackerType; import org.opensearch.search.backpressure.trackers.TaskResourceUsageTrackers.TaskResourceUsageTracker; @@ -59,6 +60,12 @@ public SearchShardTaskStats(StreamInput in) throws IOException { builder.put(TaskResourceUsageTrackerType.CPU_USAGE_TRACKER, in.readOptionalWriteable(CpuUsageTracker.Stats::new)); builder.put(TaskResourceUsageTrackerType.HEAP_USAGE_TRACKER, in.readOptionalWriteable(HeapUsageTracker.Stats::new)); builder.put(TaskResourceUsageTrackerType.ELAPSED_TIME_TRACKER, in.readOptionalWriteable(ElapsedTimeTracker.Stats::new)); + if (in.getVersion().onOrAfter(Version.V_3_7_0)) { + builder.put( + TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER, + in.readOptionalWriteable(NativeMemoryUsageTracker.Stats::new) + ); + } this.resourceUsageTrackerStats = builder.immutableMap(); } @@ -94,6 +101,9 @@ public void writeTo(StreamOutput out) throws IOException { out.writeOptionalWriteable(resourceUsageTrackerStats.get(TaskResourceUsageTrackerType.CPU_USAGE_TRACKER)); out.writeOptionalWriteable(resourceUsageTrackerStats.get(TaskResourceUsageTrackerType.HEAP_USAGE_TRACKER)); out.writeOptionalWriteable(resourceUsageTrackerStats.get(TaskResourceUsageTrackerType.ELAPSED_TIME_TRACKER)); + if (out.getVersion().onOrAfter(Version.V_3_7_0)) { + out.writeOptionalWriteable(resourceUsageTrackerStats.get(TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER)); + } } @Override diff --git a/server/src/main/java/org/opensearch/search/backpressure/stats/SearchTaskStats.java b/server/src/main/java/org/opensearch/search/backpressure/stats/SearchTaskStats.java index 0f5f409b15def..bf912eb0a7517 100644 --- a/server/src/main/java/org/opensearch/search/backpressure/stats/SearchTaskStats.java +++ b/server/src/main/java/org/opensearch/search/backpressure/stats/SearchTaskStats.java @@ -18,6 +18,7 @@ import org.opensearch.search.backpressure.trackers.CpuUsageTracker; import org.opensearch.search.backpressure.trackers.ElapsedTimeTracker; import org.opensearch.search.backpressure.trackers.HeapUsageTracker; +import org.opensearch.search.backpressure.trackers.NativeMemoryUsageTracker; import org.opensearch.search.backpressure.trackers.TaskResourceUsageTrackerType; import org.opensearch.search.backpressure.trackers.TaskResourceUsageTrackers.TaskResourceUsageTracker; @@ -60,6 +61,12 @@ public SearchTaskStats(StreamInput in) throws IOException { builder.put(TaskResourceUsageTrackerType.CPU_USAGE_TRACKER, in.readOptionalWriteable(CpuUsageTracker.Stats::new)); builder.put(TaskResourceUsageTrackerType.HEAP_USAGE_TRACKER, in.readOptionalWriteable(HeapUsageTracker.Stats::new)); builder.put(TaskResourceUsageTrackerType.ELAPSED_TIME_TRACKER, in.readOptionalWriteable(ElapsedTimeTracker.Stats::new)); + if (in.getVersion().onOrAfter(Version.V_3_7_0)) { + builder.put( + TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER, + in.readOptionalWriteable(NativeMemoryUsageTracker.Stats::new) + ); + } this.resourceUsageTrackerStats = builder.immutableMap(); } @@ -95,6 +102,9 @@ public void writeTo(StreamOutput out) throws IOException { out.writeOptionalWriteable(resourceUsageTrackerStats.get(TaskResourceUsageTrackerType.CPU_USAGE_TRACKER)); out.writeOptionalWriteable(resourceUsageTrackerStats.get(TaskResourceUsageTrackerType.HEAP_USAGE_TRACKER)); out.writeOptionalWriteable(resourceUsageTrackerStats.get(TaskResourceUsageTrackerType.ELAPSED_TIME_TRACKER)); + if (out.getVersion().onOrAfter(Version.V_3_7_0)) { + out.writeOptionalWriteable(resourceUsageTrackerStats.get(TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER)); + } } @Override diff --git a/server/src/main/java/org/opensearch/search/backpressure/trackers/NativeMemoryUsageTracker.java b/server/src/main/java/org/opensearch/search/backpressure/trackers/NativeMemoryUsageTracker.java new file mode 100644 index 0000000000000..fac1bcc56656e --- /dev/null +++ b/server/src/main/java/org/opensearch/search/backpressure/trackers/NativeMemoryUsageTracker.java @@ -0,0 +1,272 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.search.backpressure.trackers; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.lucene.util.Constants; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.monitor.os.OsProbe; +import org.opensearch.search.backpressure.NativeMemoryUsageService; +import org.opensearch.search.backpressure.trackers.TaskResourceUsageTrackers.TaskResourceUsageTracker; +import org.opensearch.tasks.CancellableTask; +import org.opensearch.tasks.Task; +import org.opensearch.tasks.TaskCancellation; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.DoubleSupplier; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +import static org.opensearch.search.backpressure.trackers.TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER; + +/** + * NativeMemoryUsageTracker cancels in-flight tasks that are holding more native (off-heap) + * memory than allowed. Unlike {@link HeapUsageTracker}, this tracker does not maintain a + * rolling average — native memory is a reservation that rises and falls over the query's + * lifetime, so a fraction-of-budget threshold is the right shape once the node is already + * in native-memory duress (the + * {@link org.opensearch.search.backpressure.SearchBackpressureService} tracker-apply map + * gates on that). + * + *

The per-task threshold is expressed as a fraction of the backend-installed native-memory + * budget, mirroring the heap tracker's {@code heap_percent_threshold}. Effective per-task + * byte threshold is {@code budget * fraction}. + * + *

Snapshot-per-tick model

+ *

Native-memory values come from a backend (e.g. DataFusion) over an FFI boundary. Per-task + * FFI reads don't scale: the backpressure service iterates every candidate task inside + * {@code doRun()}, and stats() iterates every live task twice (max + avg). The shared + * {@link NativeMemoryUsageService} holds a single {@code Map} that the SBP + * service refreshes once per tick. Per-task lookups are then O(1) hash probes into a stable + * map reference. + * + *

This tracker is a thin consumer of the service: {@code bytesForTask(Task)} delegates to + * {@code NativeMemoryUsageService#currentBytes(long)}. The service's {@code refresh()} is + * called by {@code SearchBackpressureService.doRun()} once per tick before per-task evaluation + * begins — this tracker does not own the refresh lifecycle. The static setters retained on + * this class are kept as delegators so existing call sites (backend plugin + * {@code createComponents}, tracker tests) don't have to migrate at the same time as the + * service extraction. + * + * @opensearch.internal + */ +public class NativeMemoryUsageTracker extends TaskResourceUsageTracker { + private static final Logger logger = LogManager.getLogger(NativeMemoryUsageTracker.class); + + /** + * Per-task threshold expressed as a fraction of the installed native-memory budget, + * mirroring {@code heap_percent_threshold} on {@link HeapUsageTracker}. Range is + * {@code [0.0, 1.0]} — the {@link org.opensearch.common.settings.Setting} validator + * already enforces those bounds; this class clamps defensively. + * + *

Effective per-task byte threshold = {@code budget * fraction}. + */ + private final DoubleSupplier nativeMemoryPercentThresholdSupplier; + + /** + * Singleton service that owns the snapshot map and the budget supplier. Held as a + * field rather than read via {@link NativeMemoryUsageService#getInstance()} on every + * call so tests can substitute (via reflection or future package-private constructor). + */ + private final NativeMemoryUsageService service; + + public NativeMemoryUsageTracker(DoubleSupplier nativeMemoryPercentThresholdSupplier) { + this(nativeMemoryPercentThresholdSupplier, NativeMemoryUsageService.getInstance()); + } + + /** Package-private for tests that want to inject a service instance. */ + NativeMemoryUsageTracker(DoubleSupplier nativeMemoryPercentThresholdSupplier, NativeMemoryUsageService service) { + this.nativeMemoryPercentThresholdSupplier = nativeMemoryPercentThresholdSupplier; + this.service = service; + setDefaultResourceUsageBreachEvaluator(); + } + + // --------------------------------------------------------------------- + // Static delegators — preserved so existing callers (backend plugin + // wiring, NativeMemoryUsageTrackerTests) keep compiling. New code should + // prefer NativeMemoryUsageService directly. + // --------------------------------------------------------------------- + + /** @see NativeMemoryUsageService#setSnapshotSupplier(Supplier) */ + public static void setSnapshotSupplier(Supplier> supplier) { + NativeMemoryUsageService.getInstance().setSnapshotSupplier(supplier); + } + + /** @see NativeMemoryUsageService#setBudgetSupplier(LongSupplier) */ + public static void setNativeMemoryBudgetSupplier(LongSupplier supplier) { + NativeMemoryUsageService.getInstance().setBudgetSupplier(supplier); + } + + /** @see NativeMemoryUsageService#getBudgetBytes() */ + public static long getNativeMemoryBudgetBytes() { + return NativeMemoryUsageService.getInstance().getBudgetBytes(); + } + + /** @see NativeMemoryUsageService#hasSnapshotProvider() */ + public static boolean hasSnapshotProvider() { + return NativeMemoryUsageService.getInstance().hasSnapshotProvider(); + } + + /** + * Cancellation rule: task's current native-memory reservation is at or above + * {@code budget * fraction}, where {@code budget} is the installed native-memory + * budget (e.g. DataFusion's pool limit) and {@code fraction} comes from the + * per-task threshold setting (range {@code [0.0, 1.0]}). + */ + private void setDefaultResourceUsageBreachEvaluator() { + this.resourceUsageBreachEvaluator = (task) -> { + if (task instanceof CancellableTask == false) { + return Optional.empty(); + } + double fraction = nativeMemoryPercentThresholdSupplier.getAsDouble(); + long budget = service.getBudgetBytes(); + if (fraction <= 0.0d || budget <= 0L) { + // Feature disabled — either the operator hasn't set a threshold, or no + // backend has installed a budget supplier. Leave the tracker inert. + return Optional.empty(); + } + // Defensive clamp; the Setting validator already enforces [0.0, 1.0]. + double boundedFraction = Math.min(1.0d, fraction); + long bytesThreshold = (long) (budget * boundedFraction); + if (bytesThreshold <= 0L) { + return Optional.empty(); + } + long currentUsage = bytesForTask(task); + if (currentUsage < bytesThreshold) { + return Optional.empty(); + } + int score = (int) Math.max(1L, currentUsage / Math.max(1L, bytesThreshold)); + if (logger.isDebugEnabled()) { + logger.debug( + "native memory threshold exceeded for task [{}]: currentBytes={}B, threshold={}B ({}% of budget={}B)", + task.getId(), + currentUsage, + bytesThreshold, + (int) (boundedFraction * 100), + budget + ); + } + return Optional.of( + new TaskCancellation.Reason( + "native memory usage exceeded [" + new ByteSizeValue(currentUsage) + " >= " + new ByteSizeValue(bytesThreshold) + "]", + score + ) + ); + }; + } + + @Override + public String name() { + return NATIVE_MEMORY_USAGE_TRACKER.getName(); + } + + /** No-op: completion events carry no information we persist. */ + @Override + public void update(Task task) { + // intentionally empty + } + + /** Package-private for tests: look up a single task's bytes from the current snapshot. */ + long bytesForTask(Task task) { + if (task == null) { + return 0L; + } + return service.currentBytes(task.getId()); + } + + /** + * Returns {@code true} when the node exposes the physical-memory signal this tracker + * relies on for duress. Symmetric with {@link HeapUsageTracker#isHeapTrackingSupported()}. + */ + public static boolean isNativeTrackingSupported() { + if (Constants.LINUX == false) { + return false; + } + if (hasSnapshotProvider() == false) { + return false; + } + return OsProbe.getInstance().getTotalPhysicalMemorySize() > 0L; + } + + @Override + public TaskResourceUsageTracker.Stats stats(List activeTasks) { + long currentMax = activeTasks.stream().mapToLong(this::bytesForTask).max().orElse(0); + long currentAvg = (long) activeTasks.stream().mapToLong(this::bytesForTask).average().orElse(0); + return new Stats(getCancellations(), currentMax, currentAvg); + } + + /** + * Stats for {@link NativeMemoryUsageTracker}. Serialization shape deliberately omits the + * rolling-average field that {@link HeapUsageTracker.Stats} carries: we don't maintain a + * rolling baseline for native memory, so exposing a zeroed field would be misleading. + */ + public static class Stats implements TaskResourceUsageTracker.Stats { + private final long cancellationCount; + private final long currentMax; + private final long currentAvg; + + public Stats(long cancellationCount, long currentMax, long currentAvg) { + this.cancellationCount = cancellationCount; + this.currentMax = currentMax; + this.currentAvg = currentAvg; + } + + public Stats(StreamInput in) throws IOException { + this(in.readVLong(), in.readVLong(), in.readVLong()); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.startObject() + .field("cancellation_count", cancellationCount) + .humanReadableField("current_max_bytes", "current_max", new ByteSizeValue(currentMax)) + .humanReadableField("current_avg_bytes", "current_avg", new ByteSizeValue(currentAvg)) + .endObject(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeVLong(cancellationCount); + out.writeVLong(currentMax); + out.writeVLong(currentAvg); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Stats stats = (Stats) o; + return cancellationCount == stats.cancellationCount && currentMax == stats.currentMax && currentAvg == stats.currentAvg; + } + + @Override + public int hashCode() { + return Objects.hash(cancellationCount, currentMax, currentAvg); + } + + @Override + public String toString() { + return "NativeMemoryUsageTracker.Stats{cancellationCount=" + + cancellationCount + + ", currentMax=" + + currentMax + + ", currentAvg=" + + currentAvg + + "}"; + } + } +} diff --git a/server/src/main/java/org/opensearch/search/backpressure/trackers/NodeDuressTrackers.java b/server/src/main/java/org/opensearch/search/backpressure/trackers/NodeDuressTrackers.java index 2cf5f63144e9a..c3812fbf352cf 100644 --- a/server/src/main/java/org/opensearch/search/backpressure/trackers/NodeDuressTrackers.java +++ b/server/src/main/java/org/opensearch/search/backpressure/trackers/NodeDuressTrackers.java @@ -48,6 +48,16 @@ public boolean isResourceInDuress(ResourceType resourceType) { return resourceDuressCache.get(resourceType); } + /** + * Convenience accessor for native-memory duress. Equivalent to + * {@code isResourceInDuress(ResourceType.NATIVE_MEMORY)}. Used by + * {@code SearchBackpressureService.doRun()} to decide whether to bypass the + * heap-dominance gate and include all tasks as cancellation candidates. + */ + public boolean isNativeMemoryInDuress() { + return isResourceInDuress(ResourceType.NATIVE_MEMORY); + } + /** * Method to evaluate whether the node is in duress or not * @return true if node is in duress because of either system resource @@ -63,8 +73,10 @@ public boolean isNodeInDuress() { private void updateCache() { if (nodeDuressCacheExpiryChecker.getAsBoolean()) { - for (ResourceType resourceType : ResourceType.values()) - resourceDuressCache.put(resourceType, duressTrackers.get(resourceType).test()); + for (ResourceType resourceType : ResourceType.values()) { + NodeDuressTracker tracker = duressTrackers.get(resourceType); + resourceDuressCache.put(resourceType, tracker != null && tracker.test()); + } } } diff --git a/server/src/main/java/org/opensearch/search/backpressure/trackers/TaskResourceUsageTrackerType.java b/server/src/main/java/org/opensearch/search/backpressure/trackers/TaskResourceUsageTrackerType.java index 2211d28ad30c0..a598bc6eebb84 100644 --- a/server/src/main/java/org/opensearch/search/backpressure/trackers/TaskResourceUsageTrackerType.java +++ b/server/src/main/java/org/opensearch/search/backpressure/trackers/TaskResourceUsageTrackerType.java @@ -14,7 +14,8 @@ public enum TaskResourceUsageTrackerType { CPU_USAGE_TRACKER("cpu_usage_tracker"), HEAP_USAGE_TRACKER("heap_usage_tracker"), - ELAPSED_TIME_TRACKER("elapsed_time_tracker"); + ELAPSED_TIME_TRACKER("elapsed_time_tracker"), + NATIVE_MEMORY_USAGE_TRACKER("native_memory_usage_tracker"); private final String name; @@ -34,6 +35,8 @@ public static TaskResourceUsageTrackerType fromName(String name) { return HEAP_USAGE_TRACKER; case "elapsed_time_tracker": return ELAPSED_TIME_TRACKER; + case "native_memory_usage_tracker": + return NATIVE_MEMORY_USAGE_TRACKER; } throw new IllegalArgumentException("Invalid TaskResourceUsageTrackerType: " + name); diff --git a/server/src/main/java/org/opensearch/wlm/ResourceType.java b/server/src/main/java/org/opensearch/wlm/ResourceType.java index a560268a66853..d73586cb3a750 100644 --- a/server/src/main/java/org/opensearch/wlm/ResourceType.java +++ b/server/src/main/java/org/opensearch/wlm/ResourceType.java @@ -12,6 +12,7 @@ import org.opensearch.core.common.io.stream.StreamOutput; import org.opensearch.wlm.tracker.CpuUsageCalculator; import org.opensearch.wlm.tracker.MemoryUsageCalculator; +import org.opensearch.wlm.tracker.NativeMemoryUsageCalculator; import org.opensearch.wlm.tracker.ResourceUsageCalculator; import java.io.IOException; @@ -26,12 +27,20 @@ @PublicApi(since = "2.17.0") public enum ResourceType { CPU("cpu", true, CpuUsageCalculator.INSTANCE, WorkloadManagementSettings::getNodeLevelCpuCancellationThreshold), - MEMORY("memory", true, MemoryUsageCalculator.INSTANCE, WorkloadManagementSettings::getNodeLevelMemoryCancellationThreshold); + MEMORY("memory", true, MemoryUsageCalculator.INSTANCE, WorkloadManagementSettings::getNodeLevelMemoryCancellationThreshold), + // NATIVE_MEMORY is off-heap memory reported by native backends (e.g. DataFusion). It is + // NOT tracked per workload group — {@code statsEnabled=false} keeps WorkloadGroupState + // from allocating state slots for it, and its node-level WLM threshold is pinned at 1.0 + // so WLM cancellation never fires on this resource. The real consumer is search + // backpressure, which pulls the duress signal via NodeDuressTrackers#isResourceInDuress. + NATIVE_MEMORY("native_memory", false, NativeMemoryUsageCalculator.INSTANCE, settings -> 1.0); private final String name; private final boolean statsEnabled; private final ResourceUsageCalculator resourceUsageCalculator; private final Function nodeLevelThresholdSupplier; + // sortedValues intentionally excludes NATIVE_MEMORY — WLM XContent output and related + // consumers stayed at "CPU, MEMORY" before this enum grew, and we keep that stable. private static List sortedValues = List.of(CPU, MEMORY); ResourceType( diff --git a/server/src/main/java/org/opensearch/wlm/tracker/NativeMemoryUsageCalculator.java b/server/src/main/java/org/opensearch/wlm/tracker/NativeMemoryUsageCalculator.java new file mode 100644 index 0000000000000..09fd2cba95d9b --- /dev/null +++ b/server/src/main/java/org/opensearch/wlm/tracker/NativeMemoryUsageCalculator.java @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.wlm.tracker; + +import org.opensearch.wlm.WorkloadGroupTask; + +import java.util.List; + +/** + * Placeholder {@link ResourceUsageCalculator} for {@link org.opensearch.wlm.ResourceType#NATIVE_MEMORY}. + * + *

Workload Management does not account native (off-heap) memory per workload group — that + * live usage is reported by native backends (e.g. DataFusion) via the search backpressure + * {@code NativeMemoryUsageTracker}. The enum entry exists so {@link + * org.opensearch.search.backpressure.trackers.NodeDuressTrackers} can route native-memory + * duress through the same {@code ResourceType} keyed map as CPU and heap; this calculator + * simply returns zero for any task or task list so WLM surfaces that accidentally iterate it + * behave as "no usage". + */ +public final class NativeMemoryUsageCalculator extends ResourceUsageCalculator { + public static final NativeMemoryUsageCalculator INSTANCE = new NativeMemoryUsageCalculator(); + + private NativeMemoryUsageCalculator() {} + + @Override + public double calculateResourceUsage(List tasks) { + return 0.0; + } + + @Override + public double calculateTaskResourceUsage(WorkloadGroupTask task) { + return 0.0; + } +} diff --git a/server/src/main/java/org/opensearch/wlm/tracker/WorkloadGroupResourceUsageTrackerService.java b/server/src/main/java/org/opensearch/wlm/tracker/WorkloadGroupResourceUsageTrackerService.java index a0d39e28f77f6..5f6bcbdb01949 100644 --- a/server/src/main/java/org/opensearch/wlm/tracker/WorkloadGroupResourceUsageTrackerService.java +++ b/server/src/main/java/org/opensearch/wlm/tracker/WorkloadGroupResourceUsageTrackerService.java @@ -24,7 +24,13 @@ * This class tracks resource usage per WorkloadGroup */ public class WorkloadGroupResourceUsageTrackerService { - public static final EnumSet TRACKED_RESOURCES = EnumSet.allOf(ResourceType.class); + // CPU + MEMORY only. NATIVE_MEMORY exists in {@link ResourceType} to flow duress + // signals through NodeDuressTrackers for search backpressure, but WLM has no + // per-workload-group native-memory accounting yet — adding it here would just run + // the no-op calculator and report zero usage. + // TODO: when WLM gains per-group native-memory accounting (real usage calculator + // and per-task hook), add ResourceType.NATIVE_MEMORY to this set. + public static final EnumSet TRACKED_RESOURCES = EnumSet.of(ResourceType.CPU, ResourceType.MEMORY); private final TaskResourceTrackingService taskResourceTrackingService; /** diff --git a/server/src/test/java/org/opensearch/monitor/os/OsProbeTests.java b/server/src/test/java/org/opensearch/monitor/os/OsProbeTests.java index df4b263eb7984..05fb0ba84f6e9 100644 --- a/server/src/test/java/org/opensearch/monitor/os/OsProbeTests.java +++ b/server/src/test/java/org/opensearch/monitor/os/OsProbeTests.java @@ -511,4 +511,92 @@ boolean areCgroupStatsAvailable() { }; } + // ---- /proc/self/status RssAnon parsing ---- + + public void testGetProcessRssAnon_presentField() { + assumeThat("requires Linux to exercise the /proc/self/status path", Constants.LINUX, is(true)); + OsProbe probe = new OsProbe() { + @Override + List readProcSelfStatus() { + return Arrays.asList( + "Name:\topensearch", + "State:\tS (sleeping)", + "VmPeak:\t 12345 kB", + "VmRSS:\t 65536 kB", + "RssAnon:\t 32768 kB", + "RssFile:\t 20000 kB", + "RssShmem:\t 0 kB" + ); + } + }; + assertEquals(32768L * 1024L, probe.getProcessRssAnon()); + } + + public void testGetProcessRssAnon_missingFieldReturnsNegative() { + assumeThat("requires Linux to exercise the /proc/self/status path", Constants.LINUX, is(true)); + OsProbe probe = new OsProbe() { + @Override + List readProcSelfStatus() { + // RssAnon was added in kernel 4.5; pre-4.5 kernels expose only VmRSS. + return Arrays.asList("Name:\topensearch", "State:\tS (sleeping)", "VmRSS:\t 65536 kB"); + } + }; + assertEquals(-1L, probe.getProcessRssAnon()); + } + + public void testGetProcessRssAnon_returnsNegativeOnIoError() { + assumeThat("requires Linux to exercise the /proc/self/status path", Constants.LINUX, is(true)); + OsProbe probe = new OsProbe() { + @Override + List readProcSelfStatus() throws IOException { + throw new IOException("synthetic"); + } + }; + assertEquals(-1L, probe.getProcessRssAnon()); + } + + public void testGetProcessRssAnon_negativeOnNonLinux() { + assumeThat("only meaningful on non-Linux platforms", Constants.LINUX, is(false)); + assertEquals(-1L, OsProbe.getInstance().getProcessRssAnon()); + } + + // ---- getProcessNativeMemoryBytes (RssAnon - heapMax, clamped) ---- + + public void testGetProcessNativeMemoryBytes_returnsNegativeWhenRssAnonUnavailable() { + // Override getProcessRssAnon to return -1 (the "not supported" sentinel). The + // method must propagate that signal upward without subtracting from -1. + OsProbe probe = new OsProbe() { + @Override + public long getProcessRssAnon() { + return -1L; + } + }; + assertEquals(-1L, probe.getProcessNativeMemoryBytes()); + } + + public void testGetProcessNativeMemoryBytes_subtractsHeapMaxAndClampsAtZero() { + // RssAnon below heapMax (early process lifetime, before heap pages are committed). + // The method must clamp at 0 instead of returning a negative. + OsProbe probe = new OsProbe() { + @Override + public long getProcessRssAnon() { + return 1L; // way below any real -Xmx setting + } + }; + assertEquals(0L, probe.getProcessNativeMemoryBytes()); + } + + public void testGetProcessNativeMemoryBytes_returnsDifferenceWhenRssAnonExceedsHeap() { + // RssAnon clearly larger than the JVM heap max — the difference is reported back. + long heapMax = org.opensearch.monitor.jvm.JvmStats.jvmStats().getMem().getHeapMax().getBytes(); + long rssAnon = heapMax + 64L * 1024L * 1024L; + OsProbe probe = new OsProbe() { + @Override + public long getProcessRssAnon() { + return rssAnon; + } + }; + assertEquals(64L * 1024L * 1024L, probe.getProcessNativeMemoryBytes()); + } + } diff --git a/server/src/test/java/org/opensearch/search/backpressure/NativeMemoryUsageServiceTests.java b/server/src/test/java/org/opensearch/search/backpressure/NativeMemoryUsageServiceTests.java new file mode 100644 index 0000000000000..0cd96d21b04d0 --- /dev/null +++ b/server/src/test/java/org/opensearch/search/backpressure/NativeMemoryUsageServiceTests.java @@ -0,0 +1,263 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.search.backpressure; + +import org.opensearch.test.OpenSearchTestCase; +import org.junit.After; +import org.junit.Before; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +/** + * Unit tests for {@link NativeMemoryUsageService}. + * + *

The service is a process-wide singleton. Each test must reset state in + * {@link #setUp()}/{@link #tearDown()} so suppliers installed by a prior test + * don't leak into the next. + */ +public class NativeMemoryUsageServiceTests extends OpenSearchTestCase { + + private NativeMemoryUsageService service; + + @Before + public void resetServiceBefore() { + service = NativeMemoryUsageService.getInstance(); + service.resetForTesting(); + } + + @After + public void resetServiceAfter() { + service.resetForTesting(); + } + + public void testGetInstanceReturnsSameSingleton() { + // Singleton contract: every call returns the same instance. + assertSame(NativeMemoryUsageService.getInstance(), NativeMemoryUsageService.getInstance()); + } + + public void testDefaultBudgetIsZero() { + assertEquals(0L, service.getBudgetBytes()); + } + + public void testDefaultHasSnapshotProviderIsFalse() { + // Until a backend opts in, the predicate must report false. + assertFalse(service.hasSnapshotProvider()); + } + + public void testDefaultSnapshotIsEmpty() { + // currentBytes for any id with the default supplier must be 0. + assertEquals(0L, service.currentBytes(1L)); + assertEquals(0L, service.currentBytes(randomNonNegativeLong())); + } + + public void testSetSnapshotSupplierFlipsHasSnapshotProvider() { + Supplier> supplier = Collections::emptyMap; + service.setSnapshotSupplier(supplier); + assertTrue(service.hasSnapshotProvider()); + } + + public void testSetSnapshotSupplierWithNullIsNoOp() { + Supplier> supplier = Collections::emptyMap; + service.setSnapshotSupplier(supplier); + assertTrue(service.hasSnapshotProvider()); + // null must NOT erase a working installation. + service.setSnapshotSupplier(null); + assertTrue(service.hasSnapshotProvider()); + } + + public void testSetBudgetSupplierWithNullIsNoOp() { + service.setBudgetSupplier(() -> 1024L); + assertEquals(1024L, service.getBudgetBytes()); + service.setBudgetSupplier(null); + // Previous supplier must still be active. + assertEquals(1024L, service.getBudgetBytes()); + } + + public void testGetBudgetBytesClampsNegative() { + service.setBudgetSupplier(() -> -42L); + // Negative values from a misbehaving supplier must NOT flip threshold math; clamp to 0. + assertEquals(0L, service.getBudgetBytes()); + } + + public void testGetBudgetBytesPropagatesPositive() { + long pool = 4L * 1024L * 1024L * 1024L; + service.setBudgetSupplier(() -> pool); + assertEquals(pool, service.getBudgetBytes()); + } + + public void testRefreshLoadsSnapshot() { + Map snapshot = new HashMap<>(); + snapshot.put(10L, 100L); + snapshot.put(20L, 200L); + service.setSnapshotSupplier(() -> snapshot); + + service.refresh(); + assertEquals(100L, service.currentBytes(10L)); + assertEquals(200L, service.currentBytes(20L)); + assertEquals(0L, service.currentBytes(30L)); + assertEquals(2, service.snapshotView().size()); + } + + public void testRefreshNullSnapshotFallsBackToEmpty() { + // A misbehaving backend that returns null must NOT propagate NPE through the read path. + service.setSnapshotSupplier(() -> null); + service.refresh(); + assertEquals(0L, service.currentBytes(1L)); + assertTrue(service.snapshotView().isEmpty()); + } + + public void testRefreshSwallowsRuntimeExceptionFromSupplier() { + // A throwing supplier (e.g. transient FFM error) must not break the SBP scheduler tick. + service.setSnapshotSupplier(() -> { throw new RuntimeException("boom"); }); + service.refresh(); + assertEquals(0L, service.currentBytes(1L)); + assertTrue(service.snapshotView().isEmpty()); + } + + public void testRefreshReplacesPreviousSnapshot() { + // First refresh loads {1: 100}; second refresh loads {2: 200}. The map reference must + // swap atomically — entry 1 must no longer be present after the second refresh. + Map first = Map.of(1L, 100L); + Map second = Map.of(2L, 200L); + AtomicInteger calls = new AtomicInteger(); + service.setSnapshotSupplier(() -> calls.getAndIncrement() == 0 ? first : second); + + service.refresh(); + assertEquals(100L, service.currentBytes(1L)); + assertEquals(0L, service.currentBytes(2L)); + + service.refresh(); + assertEquals(0L, service.currentBytes(1L)); + assertEquals(200L, service.currentBytes(2L)); + } + + public void testRefreshCallsSupplierExactlyOnce() { + // The SBP service refreshes once per tick. This must translate to exactly one supplier + // invocation per refresh — for the DataFusion backend that maps to one FFM round-trip. + AtomicInteger calls = new AtomicInteger(); + service.setSnapshotSupplier(() -> { + calls.incrementAndGet(); + return Collections.emptyMap(); + }); + service.refresh(); + assertEquals(1, calls.get()); + + // A subsequent currentBytes call must NOT trigger another supplier invocation. + service.currentBytes(1L); + service.currentBytes(2L); + assertEquals(1, calls.get()); + + service.refresh(); + assertEquals(2, calls.get()); + } + + public void testCurrentBytesReturnsZeroForUnknownTaskId() { + service.setSnapshotSupplier(() -> Map.of(1L, 100L)); + service.refresh(); + assertEquals(100L, service.currentBytes(1L)); + assertEquals(0L, service.currentBytes(99L)); + } + + public void testResetForTestingClearsState() { + service.setSnapshotSupplier(() -> Map.of(1L, 100L)); + service.setBudgetSupplier(() -> 1024L); + service.refresh(); + assertTrue(service.hasSnapshotProvider()); + assertEquals(1024L, service.getBudgetBytes()); + assertEquals(100L, service.currentBytes(1L)); + + service.resetForTesting(); + + assertFalse(service.hasSnapshotProvider()); + assertEquals(0L, service.getBudgetBytes()); + assertEquals(0L, service.currentBytes(1L)); + assertTrue(service.snapshotView().isEmpty()); + } + + public void testConcurrentRefreshAndRead() throws Exception { + // Reader threads must always see a consistent snapshot — never an in-flight mutation. + // We alternate between two non-overlapping snapshots and assert that every read either + // finds the value from snapshot A OR from snapshot B, never something that would imply + // a partially-published map (e.g. a NullPointerException from an internal HashMap). + Map snapA = Map.of(1L, 100L); + Map snapB = Map.of(1L, 200L); + AtomicInteger toggle = new AtomicInteger(); + service.setSnapshotSupplier(() -> toggle.getAndIncrement() % 2 == 0 ? snapA : snapB); + + int readerCount = 4; + int iterationsPerReader = 1_000; + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(readerCount + 1); + + Thread refresher = new Thread(() -> { + try { + start.await(); + for (int i = 0; i < iterationsPerReader; i++) { + service.refresh(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }, "nm-service-refresher"); + refresher.start(); + + Thread[] readers = new Thread[readerCount]; + for (int r = 0; r < readerCount; r++) { + readers[r] = new Thread(() -> { + try { + start.await(); + for (int i = 0; i < iterationsPerReader; i++) { + long bytes = service.currentBytes(1L); + // Every observation must be 0 (between refreshes, transient), 100 (snapA), + // or 200 (snapB). Anything else implies inconsistent publication. + if (bytes != 0L && bytes != 100L && bytes != 200L) { + throw new AssertionError("inconsistent snapshot value: " + bytes); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }, "nm-service-reader-" + r); + readers[r].start(); + } + + start.countDown(); + assertTrue("threads did not finish in time", done.await(30, TimeUnit.SECONDS)); + for (Thread reader : readers) { + reader.join(); + } + refresher.join(); + } + + public void testBudgetSupplierInvokedEachCall() { + // The service does NOT cache the budget — every getBudgetBytes call routes through the + // supplier. This matters because the operator may flip the backend pool size at runtime. + AtomicInteger calls = new AtomicInteger(); + LongSupplier supplier = () -> { + calls.incrementAndGet(); + return 64L; + }; + service.setBudgetSupplier(supplier); + service.getBudgetBytes(); + service.getBudgetBytes(); + service.getBudgetBytes(); + assertEquals(3, calls.get()); + } +} diff --git a/server/src/test/java/org/opensearch/search/backpressure/SearchBackpressureServiceTests.java b/server/src/test/java/org/opensearch/search/backpressure/SearchBackpressureServiceTests.java index 8e604824b73a6..7531c9527e29f 100644 --- a/server/src/test/java/org/opensearch/search/backpressure/SearchBackpressureServiceTests.java +++ b/server/src/test/java/org/opensearch/search/backpressure/SearchBackpressureServiceTests.java @@ -607,6 +607,106 @@ private SearchBackpressureSettings getBackpressureSettings(String mode, double r ); } + public void testNativeMemoryDuressRunsOnlyNativeMemoryAndElapsedTrackers() { + // The native-memory tracker-apply condition is gated on isNativeTrackingSupported(), + // which today requires Linux + a readable /proc/meminfo. Non-Linux test hosts cannot + // exercise this short-circuit regardless of how the fixtures are wired. + assumeTrue("native memory tracking is only supported on Linux", org.apache.lucene.util.Constants.LINUX); + // isNativeTrackingSupported() also gates on whether a backend has installed a + // snapshot provider on NativeMemoryUsageService. The mock tracker in this test + // doesn't go through that path, so we install a sentinel non-empty supplier here + // and reset it in finally so other tests don't see leftover state. + org.opensearch.search.backpressure.NativeMemoryUsageService.getInstance().setSnapshotSupplier(java.util.Collections::emptyMap); + try { + // When native-memory duress is active, heap/CPU trackers MUST NOT run. We install + // "always cancel" mocks for CPU and heap, and confirm nothing trips from them; the + // native-memory tracker is the only one that should contribute cancellations. + TaskManager mockTaskManager = spy(taskManager); + TaskResourceTrackingService mockTaskResourceTrackingService = mock(TaskResourceTrackingService.class); + AtomicLong mockTime = new AtomicLong(0); + LongSupplier mockTimeNanosSupplier = mockTime::get; + + // CPU/heap ResourceType duress both OFF — only the native-memory probe is firing. + EnumMap duressTrackers = new EnumMap<>(ResourceType.class) { + { + put(MEMORY, new NodeDuressTracker(() -> false, () -> 3)); + put(CPU, new NodeDuressTracker(() -> false, () -> 3)); + put(ResourceType.NATIVE_MEMORY, new NodeDuressTracker(() -> true, () -> 3)); + } + }; + NodeDuressTrackers nodeDuressTrackers = new NodeDuressTrackers(duressTrackers, resourceCacheExpiryChecker); + + // Unconditionally-cancelling CPU + heap trackers. These should never be consulted. + TaskResourceUsageTracker cpuUsageTracker = getMockedTaskResourceUsageTracker( + TaskResourceUsageTrackerType.CPU_USAGE_TRACKER, + (task) -> Optional.of(new TaskCancellation.Reason("cpu (should not fire)", 10)) + ); + TaskResourceUsageTracker heapUsageTracker = getMockedTaskResourceUsageTracker( + TaskResourceUsageTrackerType.HEAP_USAGE_TRACKER, + (task) -> Optional.of(new TaskCancellation.Reason("heap (should not fire)", 10)) + ); + // Native-memory tracker: cancels tasks whose memory bytes exceed a modest threshold. + TaskResourceUsageTracker nativeMemoryTracker = getMockedTaskResourceUsageTracker( + TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER, + (task) -> { + if (task.getTotalResourceStats().getMemoryInBytes() < 500) { + return Optional.empty(); + } + return Optional.of(new TaskCancellation.Reason("native memory exceeded", 5)); + } + ); + // Elapsed-time tracker left silent so the cancellation count is unambiguously from + // the native-memory tracker. + TaskResourceUsageTracker elapsedTimeTracker = getMockedTaskResourceUsageTracker( + TaskResourceUsageTrackerType.ELAPSED_TIME_TRACKER, + (task) -> Optional.empty() + ); + + TaskResourceUsageTrackers taskResourceUsageTrackers = new TaskResourceUsageTrackers(); + taskResourceUsageTrackers.addTracker(cpuUsageTracker, TaskResourceUsageTrackerType.CPU_USAGE_TRACKER); + taskResourceUsageTrackers.addTracker(heapUsageTracker, TaskResourceUsageTrackerType.HEAP_USAGE_TRACKER); + taskResourceUsageTrackers.addTracker(nativeMemoryTracker, TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER); + taskResourceUsageTrackers.addTracker(elapsedTimeTracker, TaskResourceUsageTrackerType.ELAPSED_TIME_TRACKER); + + SearchBackpressureSettings settings = getBackpressureSettings("enforced", 0.1, 0.003, 10.0); + SearchBackpressureService service = new SearchBackpressureService( + settings, + mockTaskResourceTrackingService, + threadPool, + mockTimeNanosSupplier, + nodeDuressTrackers, + taskResourceUsageTrackers, + new TaskResourceUsageTrackers(), + mockTaskManager, + workloadGroupService + ); + + when(workloadGroupService.shouldSBPHandle(any())).thenReturn(true); + + // Prime the native-memory duress streak (consecutive-breach window is 3). + service.doRun(); + service.doRun(); + + // Load 20 tasks; 4 are heavy (mem >= 500) so exactly 4 are native-memory cancellation + // candidates. Heap/CPU would want to cancel all 20 — verifying this test's whole point. + Map activeSearchTasks = new HashMap<>(); + for (long i = 0; i < 20; i++) { + long memoryBytes = (i % 5 == 0) ? 800 : 100; + activeSearchTasks.put(i, createMockTaskWithResourceStats(SearchTask.class, 100, memoryBytes, i)); + } + activeSearchTasks.values().forEach(task -> task.setWorkloadGroupId(threadPool.getThreadContext())); + doReturn(activeSearchTasks).when(mockTaskResourceTrackingService).getResourceAwareTasks(); + + service.doRun(); + // Exactly the heavy tasks were cancelled — no extras from the "always cancel" CPU/heap mocks. + verify(mockTaskManager, times(4)).cancelTaskAndDescendants(any(), anyString(), anyBoolean(), any()); + assertEquals(4, service.getSearchBackpressureState(SearchTask.class).getCancellationCount()); + } finally { + // Don't leak the sentinel supplier into other tests in this suite. + org.opensearch.search.backpressure.NativeMemoryUsageService.getInstance().resetForTesting(); + } + } + private TaskResourceUsageTracker getMockedTaskResourceUsageTracker( TaskResourceUsageTrackerType type, TaskResourceUsageTracker.ResourceUsageBreachEvaluator evaluator diff --git a/server/src/test/java/org/opensearch/search/backpressure/settings/NodeDuressSettingsTests.java b/server/src/test/java/org/opensearch/search/backpressure/settings/NodeDuressSettingsTests.java new file mode 100644 index 0000000000000..8850096547ca9 --- /dev/null +++ b/server/src/test/java/org/opensearch/search/backpressure/settings/NodeDuressSettingsTests.java @@ -0,0 +1,105 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.search.backpressure.settings; + +import org.opensearch.common.settings.ClusterSettings; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.test.OpenSearchTestCase; + +/** + * Unit tests covering the native-memory threshold and node native-memory limit + * settings on {@link NodeDuressSettings}. + */ +public class NodeDuressSettingsTests extends OpenSearchTestCase { + + public void testDefaultNativeMemoryThreshold() { + NodeDuressSettings settings = new NodeDuressSettings( + Settings.EMPTY, + new ClusterSettings(Settings.EMPTY, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS) + ); + // Default per NodeDuressSettings.Defaults.NATIVE_MEMORY_THRESHOLD. + assertEquals(0.85d, settings.getNativeMemoryThreshold(), 0.0d); + } + + public void testInitialNativeMemoryThresholdRespectsSetting() { + Settings raw = Settings.builder().put(NodeDuressSettings.SETTING_NATIVE_MEMORY_THRESHOLD.getKey(), 0.42d).build(); + NodeDuressSettings settings = new NodeDuressSettings(raw, new ClusterSettings(raw, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)); + assertEquals(0.42d, settings.getNativeMemoryThreshold(), 0.0d); + } + + public void testNativeMemoryThresholdIsDynamic() { + ClusterSettings clusterSettings = new ClusterSettings(Settings.EMPTY, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + NodeDuressSettings settings = new NodeDuressSettings(Settings.EMPTY, clusterSettings); + assertEquals(0.85d, settings.getNativeMemoryThreshold(), 0.0d); + + // Apply a runtime update — the consumer should propagate to the field. + clusterSettings.applySettings(Settings.builder().put(NodeDuressSettings.SETTING_NATIVE_MEMORY_THRESHOLD.getKey(), 0.55d).build()); + assertEquals(0.55d, settings.getNativeMemoryThreshold(), 0.0d); + } + + public void testNativeMemoryThresholdRejectsOutOfRange() { + // Range [0.0, 1.0] enforced by Setting.doubleSetting(min, max). + Settings raw = Settings.builder().put(NodeDuressSettings.SETTING_NATIVE_MEMORY_THRESHOLD.getKey(), 1.5d).build(); + expectThrows( + IllegalArgumentException.class, + () -> new NodeDuressSettings(raw, new ClusterSettings(raw, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)) + ); + + Settings raw2 = Settings.builder().put(NodeDuressSettings.SETTING_NATIVE_MEMORY_THRESHOLD.getKey(), -0.1d).build(); + expectThrows( + IllegalArgumentException.class, + () -> new NodeDuressSettings(raw2, new ClusterSettings(raw2, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)) + ); + } + + public void testDefaultNodeNativeMemoryLimitIsZero() { + // Default ByteSizeValue.ZERO — operator hasn't configured a native pool, so the + // duress probe must self-report "unconfigured" and the SBP path treats it as 0. + NodeDuressSettings settings = new NodeDuressSettings( + Settings.EMPTY, + new ClusterSettings(Settings.EMPTY, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS) + ); + assertEquals(0L, settings.getNodeNativeMemory()); + } + + public void testInitialNodeNativeMemoryLimitRespectsSetting() { + // ByteSizeValue parses unit-suffixed strings; verify the configured value reaches the field. + Settings raw = Settings.builder().put(NodeDuressSettings.NODE_NATIVE_MEMORY_LIMIT_SETTING.getKey(), "2gb").build(); + NodeDuressSettings settings = new NodeDuressSettings(raw, new ClusterSettings(raw, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)); + assertEquals(2L * 1024 * 1024 * 1024, settings.getNodeNativeMemory()); + } + + public void testNodeNativeMemoryLimitIsDynamic() { + // Operator must be able to flip the limit at runtime without a node restart. + ClusterSettings clusterSettings = new ClusterSettings(Settings.EMPTY, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + NodeDuressSettings settings = new NodeDuressSettings(Settings.EMPTY, clusterSettings); + assertEquals(0L, settings.getNodeNativeMemory()); + + clusterSettings.applySettings( + Settings.builder().put(NodeDuressSettings.NODE_NATIVE_MEMORY_LIMIT_SETTING.getKey(), "512mb").build() + ); + assertEquals(512L * 1024 * 1024, settings.getNodeNativeMemory()); + + clusterSettings.applySettings(Settings.builder().put(NodeDuressSettings.NODE_NATIVE_MEMORY_LIMIT_SETTING.getKey(), "1gb").build()); + assertEquals(1024L * 1024 * 1024, settings.getNodeNativeMemory()); + } + + public void testNodeNativeMemoryLimitSetterAcceptsByteSizeValue() { + // Direct setter contract — the cluster-settings consumer routes through this setter, + // so assert the contract directly to catch regressions in the consumer wiring here + // rather than only in an integration test. + NodeDuressSettings settings = new NodeDuressSettings( + Settings.EMPTY, + new ClusterSettings(Settings.EMPTY, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS) + ); + settings.setNodeNativeMemory(new ByteSizeValue(4096L)); + assertEquals(4096L, settings.getNodeNativeMemory()); + } +} diff --git a/server/src/test/java/org/opensearch/search/backpressure/settings/SearchShardTaskSettingsTests.java b/server/src/test/java/org/opensearch/search/backpressure/settings/SearchShardTaskSettingsTests.java new file mode 100644 index 0000000000000..285398d7323eb --- /dev/null +++ b/server/src/test/java/org/opensearch/search/backpressure/settings/SearchShardTaskSettingsTests.java @@ -0,0 +1,66 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.search.backpressure.settings; + +import org.opensearch.common.settings.ClusterSettings; +import org.opensearch.common.settings.Settings; +import org.opensearch.test.OpenSearchTestCase; + +/** + * Unit tests covering the native-memory threshold settings added to + * {@link SearchShardTaskSettings}. + */ +public class SearchShardTaskSettingsTests extends OpenSearchTestCase { + + public void testDefaultNativeMemoryPercentThresholdMatchesDefaults() { + // Production default: a small non-zero fraction so the tracker engages once a backend + // installs a non-zero budget. Test pinned to the same value as Defaults.NATIVE_MEMORY_PERCENT_THRESHOLD + // so a default change here trips this assertion deliberately. + SearchShardTaskSettings settings = new SearchShardTaskSettings( + Settings.EMPTY, + new ClusterSettings(Settings.EMPTY, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS) + ); + assertEquals(0.05d, settings.getNativeMemoryPercentThreshold(), 0.0d); + } + + public void testInitialNativeMemoryPercentThresholdRespectsSetting() { + double fractionThreshold = 0.6d; + Settings raw = Settings.builder() + .put(SearchShardTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.getKey(), fractionThreshold) + .build(); + SearchShardTaskSettings settings = new SearchShardTaskSettings( + raw, + new ClusterSettings(raw, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS) + ); + assertEquals(fractionThreshold, settings.getNativeMemoryPercentThreshold(), 0.0d); + } + + public void testNativeMemoryPercentThresholdIsDynamic() { + ClusterSettings clusterSettings = new ClusterSettings(Settings.EMPTY, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + SearchShardTaskSettings settings = new SearchShardTaskSettings(Settings.EMPTY, clusterSettings); + clusterSettings.applySettings( + Settings.builder().put(SearchShardTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.getKey(), 0.25d).build() + ); + assertEquals(0.25d, settings.getNativeMemoryPercentThreshold(), 0.0d); + } + + public void testNativeMemoryPercentThresholdRejectsOutOfRange() { + Settings raw = Settings.builder().put(SearchShardTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.getKey(), 2.0d).build(); + expectThrows( + IllegalArgumentException.class, + () -> new SearchShardTaskSettings(raw, new ClusterSettings(raw, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)) + ); + + Settings raw2 = Settings.builder().put(SearchShardTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.getKey(), -0.5d).build(); + expectThrows( + IllegalArgumentException.class, + () -> new SearchShardTaskSettings(raw2, new ClusterSettings(raw2, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)) + ); + } +} diff --git a/server/src/test/java/org/opensearch/search/backpressure/settings/SearchTaskSettingsTests.java b/server/src/test/java/org/opensearch/search/backpressure/settings/SearchTaskSettingsTests.java new file mode 100644 index 0000000000000..1f0b78bd90f32 --- /dev/null +++ b/server/src/test/java/org/opensearch/search/backpressure/settings/SearchTaskSettingsTests.java @@ -0,0 +1,63 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.search.backpressure.settings; + +import org.opensearch.common.settings.ClusterSettings; +import org.opensearch.common.settings.Settings; +import org.opensearch.test.OpenSearchTestCase; + +/** + * Unit tests covering the native-memory threshold settings added to {@link SearchTaskSettings}. + */ +public class SearchTaskSettingsTests extends OpenSearchTestCase { + + private SearchTaskSettings buildDefault() { + return new SearchTaskSettings(Settings.EMPTY, new ClusterSettings(Settings.EMPTY, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)); + } + + public void testDefaultNativeMemoryPercentThresholdMatchesDefaults() { + // Production default: a small non-zero fraction so the tracker engages once a backend + // installs a non-zero budget. Test pinned to the same value as Defaults.NATIVE_MEMORY_PERCENT_THRESHOLD + // so a default change here trips this assertion deliberately. + SearchTaskSettings settings = buildDefault(); + assertEquals(0.05d, settings.getNativeMemoryPercentThreshold(), 0.0d); + } + + public void testInitialNativeMemoryPercentThresholdRespectsSetting() { + double fractionThreshold = 0.75d; + Settings raw = Settings.builder() + .put(SearchTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.getKey(), fractionThreshold) + .build(); + SearchTaskSettings settings = new SearchTaskSettings(raw, new ClusterSettings(raw, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)); + assertEquals(fractionThreshold, settings.getNativeMemoryPercentThreshold(), 0.0d); + } + + public void testNativeMemoryPercentThresholdIsDynamic() { + ClusterSettings clusterSettings = new ClusterSettings(Settings.EMPTY, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + SearchTaskSettings settings = new SearchTaskSettings(Settings.EMPTY, clusterSettings); + clusterSettings.applySettings( + Settings.builder().put(SearchTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.getKey(), 0.33d).build() + ); + assertEquals(0.33d, settings.getNativeMemoryPercentThreshold(), 0.0d); + } + + public void testNativeMemoryPercentThresholdRejectsOutOfRange() { + Settings raw = Settings.builder().put(SearchTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.getKey(), 1.5d).build(); + expectThrows( + IllegalArgumentException.class, + () -> new SearchTaskSettings(raw, new ClusterSettings(raw, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)) + ); + + Settings raw2 = Settings.builder().put(SearchTaskSettings.SETTING_NATIVE_MEMORY_PERCENT_THRESHOLD.getKey(), -0.1d).build(); + expectThrows( + IllegalArgumentException.class, + () -> new SearchTaskSettings(raw2, new ClusterSettings(raw2, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS)) + ); + } +} diff --git a/server/src/test/java/org/opensearch/search/backpressure/stats/SearchShardTaskStatsTests.java b/server/src/test/java/org/opensearch/search/backpressure/stats/SearchShardTaskStatsTests.java index 45a44136d41f7..fb8870d127b2a 100644 --- a/server/src/test/java/org/opensearch/search/backpressure/stats/SearchShardTaskStatsTests.java +++ b/server/src/test/java/org/opensearch/search/backpressure/stats/SearchShardTaskStatsTests.java @@ -8,14 +8,19 @@ package org.opensearch.search.backpressure.stats; +import org.opensearch.Version; +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.core.common.io.stream.Writeable; import org.opensearch.search.backpressure.trackers.CpuUsageTracker; import org.opensearch.search.backpressure.trackers.ElapsedTimeTracker; import org.opensearch.search.backpressure.trackers.HeapUsageTracker; +import org.opensearch.search.backpressure.trackers.NativeMemoryUsageTracker; import org.opensearch.search.backpressure.trackers.TaskResourceUsageTrackerType; import org.opensearch.search.backpressure.trackers.TaskResourceUsageTrackers.TaskResourceUsageTracker; import org.opensearch.test.AbstractWireSerializingTestCase; +import java.util.HashMap; import java.util.Map; public class SearchShardTaskStatsTests extends AbstractWireSerializingTestCase { @@ -36,7 +41,9 @@ public static SearchShardTaskStats randomInstance() { TaskResourceUsageTrackerType.HEAP_USAGE_TRACKER, new HeapUsageTracker.Stats(randomNonNegativeLong(), randomNonNegativeLong(), randomNonNegativeLong(), randomNonNegativeLong()), TaskResourceUsageTrackerType.ELAPSED_TIME_TRACKER, - new ElapsedTimeTracker.Stats(randomNonNegativeLong(), randomNonNegativeLong(), randomNonNegativeLong()) + new ElapsedTimeTracker.Stats(randomNonNegativeLong(), randomNonNegativeLong(), randomNonNegativeLong()), + TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER, + new NativeMemoryUsageTracker.Stats(randomNonNegativeLong(), randomNonNegativeLong(), randomNonNegativeLong()) ); return new SearchShardTaskStats( @@ -46,4 +53,62 @@ public static SearchShardTaskStats randomInstance() { resourceUsageTrackerStats ); } + + /** + * Pre-V_3_7_0 nodes don't recognize the {@code NATIVE_MEMORY_USAGE_TRACKER} entry. A round + * trip via the older wire version MUST drop that entry rather than corrupting the stream; + * the remaining trackers MUST round-trip unchanged. + * + *

To assert this without reaching into private fields, we construct a deterministic + * input plus a parallel "expected" instance that omits the native-memory entry. Equality + * via {@link SearchShardTaskStats#equals} then exercises the same invariants. + */ + public void testNativeMemoryEntryDroppedOnPreV37Wire() throws Exception { + long cancellations = 7L; + long limitReached = 3L; + long completions = 11L; + + TaskResourceUsageTracker.Stats cpu = new CpuUsageTracker.Stats(1L, 2L, 3L); + TaskResourceUsageTracker.Stats heap = new HeapUsageTracker.Stats(4L, 5L, 6L, 7L); + TaskResourceUsageTracker.Stats elapsed = new ElapsedTimeTracker.Stats(8L, 9L, 10L); + TaskResourceUsageTracker.Stats nativeMem = new NativeMemoryUsageTracker.Stats(11L, 12L, 13L); + + Map withNative = new HashMap<>(); + withNative.put(TaskResourceUsageTrackerType.CPU_USAGE_TRACKER, cpu); + withNative.put(TaskResourceUsageTrackerType.HEAP_USAGE_TRACKER, heap); + withNative.put(TaskResourceUsageTrackerType.ELAPSED_TIME_TRACKER, elapsed); + withNative.put(TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER, nativeMem); + + Map withoutNative = new HashMap<>(withNative); + withoutNative.remove(TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER); + + SearchShardTaskStats original = new SearchShardTaskStats(cancellations, limitReached, completions, withNative); + SearchShardTaskStats expected = new SearchShardTaskStats(cancellations, limitReached, completions, withoutNative); + + try (BytesStreamOutput out = new BytesStreamOutput()) { + out.setVersion(Version.V_3_6_0); + original.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + in.setVersion(Version.V_3_6_0); + SearchShardTaskStats deserialized = new SearchShardTaskStats(in); + assertEquals("pre-V_3_7_0 wire must drop the native-memory tracker entry but preserve the others", expected, deserialized); + } + } + } + + /** + * V_3_7_0 round-trip must preserve the native-memory entry verbatim. + */ + public void testNativeMemoryEntryRoundTripsOnV37Wire() throws Exception { + SearchShardTaskStats original = randomInstance(); + try (BytesStreamOutput out = new BytesStreamOutput()) { + out.setVersion(Version.V_3_7_0); + original.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + in.setVersion(Version.V_3_7_0); + SearchShardTaskStats deserialized = new SearchShardTaskStats(in); + assertEquals(original, deserialized); + } + } + } } diff --git a/server/src/test/java/org/opensearch/search/backpressure/stats/SearchTaskStatsTests.java b/server/src/test/java/org/opensearch/search/backpressure/stats/SearchTaskStatsTests.java index 3ac5cfd658fc3..aeb5c69b84d6c 100644 --- a/server/src/test/java/org/opensearch/search/backpressure/stats/SearchTaskStatsTests.java +++ b/server/src/test/java/org/opensearch/search/backpressure/stats/SearchTaskStatsTests.java @@ -8,14 +8,19 @@ package org.opensearch.search.backpressure.stats; +import org.opensearch.Version; +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.core.common.io.stream.Writeable; import org.opensearch.search.backpressure.trackers.CpuUsageTracker; import org.opensearch.search.backpressure.trackers.ElapsedTimeTracker; import org.opensearch.search.backpressure.trackers.HeapUsageTracker; +import org.opensearch.search.backpressure.trackers.NativeMemoryUsageTracker; import org.opensearch.search.backpressure.trackers.TaskResourceUsageTrackerType; import org.opensearch.search.backpressure.trackers.TaskResourceUsageTrackers.TaskResourceUsageTracker; import org.opensearch.test.AbstractWireSerializingTestCase; +import java.util.HashMap; import java.util.Map; public class SearchTaskStatsTests extends AbstractWireSerializingTestCase { @@ -37,9 +42,69 @@ public static SearchTaskStats randomInstance() { TaskResourceUsageTrackerType.HEAP_USAGE_TRACKER, new HeapUsageTracker.Stats(randomNonNegativeLong(), randomNonNegativeLong(), randomNonNegativeLong(), randomNonNegativeLong()), TaskResourceUsageTrackerType.ELAPSED_TIME_TRACKER, - new ElapsedTimeTracker.Stats(randomNonNegativeLong(), randomNonNegativeLong(), randomNonNegativeLong()) + new ElapsedTimeTracker.Stats(randomNonNegativeLong(), randomNonNegativeLong(), randomNonNegativeLong()), + TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER, + new NativeMemoryUsageTracker.Stats(randomNonNegativeLong(), randomNonNegativeLong(), randomNonNegativeLong()) ); return new SearchTaskStats(randomNonNegativeLong(), randomNonNegativeLong(), randomNonNegativeLong(), resourceUsageTrackerStats); } + + /** + * Pre-V_3_7_0 nodes don't recognize the {@code NATIVE_MEMORY_USAGE_TRACKER} entry. A round + * trip via the older wire version MUST drop that entry rather than corrupting the stream; + * the remaining trackers MUST round-trip unchanged. + * + *

To assert this without reaching into private fields, we construct a deterministic + * input plus a parallel "expected" instance that omits the native-memory entry. Equality + * via {@link SearchTaskStats#equals} then exercises the same invariants. + */ + public void testNativeMemoryEntryDroppedOnPreV37Wire() throws Exception { + long cancellations = 7L; + long limitReached = 3L; + long completions = 11L; + + TaskResourceUsageTracker.Stats cpu = new CpuUsageTracker.Stats(1L, 2L, 3L); + TaskResourceUsageTracker.Stats heap = new HeapUsageTracker.Stats(4L, 5L, 6L, 7L); + TaskResourceUsageTracker.Stats elapsed = new ElapsedTimeTracker.Stats(8L, 9L, 10L); + TaskResourceUsageTracker.Stats nativeMem = new NativeMemoryUsageTracker.Stats(11L, 12L, 13L); + + Map withNative = new HashMap<>(); + withNative.put(TaskResourceUsageTrackerType.CPU_USAGE_TRACKER, cpu); + withNative.put(TaskResourceUsageTrackerType.HEAP_USAGE_TRACKER, heap); + withNative.put(TaskResourceUsageTrackerType.ELAPSED_TIME_TRACKER, elapsed); + withNative.put(TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER, nativeMem); + + Map withoutNative = new HashMap<>(withNative); + withoutNative.remove(TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER); + + SearchTaskStats original = new SearchTaskStats(cancellations, limitReached, completions, withNative); + SearchTaskStats expected = new SearchTaskStats(cancellations, limitReached, completions, withoutNative); + + try (BytesStreamOutput out = new BytesStreamOutput()) { + out.setVersion(Version.V_3_6_0); + original.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + in.setVersion(Version.V_3_6_0); + SearchTaskStats deserialized = new SearchTaskStats(in); + assertEquals("pre-V_3_7_0 wire must drop the native-memory tracker entry but preserve the others", expected, deserialized); + } + } + } + + /** + * V_3_7_0 round-trip must preserve the native-memory entry verbatim. + */ + public void testNativeMemoryEntryRoundTripsOnV37Wire() throws Exception { + SearchTaskStats original = randomInstance(); + try (BytesStreamOutput out = new BytesStreamOutput()) { + out.setVersion(Version.V_3_7_0); + original.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + in.setVersion(Version.V_3_7_0); + SearchTaskStats deserialized = new SearchTaskStats(in); + assertEquals(original, deserialized); + } + } + } } diff --git a/server/src/test/java/org/opensearch/search/backpressure/trackers/NativeMemoryUsageTrackerTests.java b/server/src/test/java/org/opensearch/search/backpressure/trackers/NativeMemoryUsageTrackerTests.java new file mode 100644 index 0000000000000..f670203eac2c4 --- /dev/null +++ b/server/src/test/java/org/opensearch/search/backpressure/trackers/NativeMemoryUsageTrackerTests.java @@ -0,0 +1,321 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.search.backpressure.trackers; + +import org.opensearch.action.search.SearchShardTask; +import org.opensearch.action.search.SearchTask; +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.tasks.TaskId; +import org.opensearch.search.backpressure.NativeMemoryUsageService; +import org.opensearch.tasks.CancellableTask; +import org.opensearch.tasks.Task; +import org.opensearch.tasks.TaskCancellation; +import org.opensearch.test.OpenSearchTestCase; +import org.junit.After; +import org.junit.Before; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Unit tests for {@link NativeMemoryUsageTracker}. + * + *

The tracker is a thin wrapper over {@link NativeMemoryUsageService}, which is a + * process-wide singleton. The service holds the snapshot supplier and budget supplier + * that backend plugins install at boot. Each test must reset the singleton in + * {@link #setUp()}/{@link #tearDown()} so leftover suppliers from a prior test (or from + * production code paths exercised by another suite) don't leak. + */ +public class NativeMemoryUsageTrackerTests extends OpenSearchTestCase { + + @Before + public void resetServiceBefore() { + NativeMemoryUsageService.getInstance().resetForTesting(); + } + + @After + public void resetServiceAfter() { + NativeMemoryUsageService.getInstance().resetForTesting(); + } + + public void testNameMatchesEnum() { + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> 0.5); + assertEquals(TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER.getName(), tracker.name()); + } + + public void testBytesForTaskWithEmptySnapshot() { + // No supplier installed — the service's default empty snapshot must yield 0 for any task id. + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> 0.5); + Task task = createMockTask(SearchTask.class, randomNonNegativeLong()); + assertEquals(0L, tracker.bytesForTask(task)); + } + + public void testBytesForTaskAfterServiceRefresh() { + long taskId = 12345L; + long bytes = 8L * 1024 * 1024; + Map snapshot = new HashMap<>(); + snapshot.put(taskId, bytes); + NativeMemoryUsageTracker.setSnapshotSupplier(() -> snapshot); + + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> 0.5); + // Refresh is now owned by the service; the tracker reads what the service publishes. + NativeMemoryUsageService.getInstance().refresh(); + + Task task = createMockTask(SearchTask.class, taskId); + assertEquals(bytes, tracker.bytesForTask(task)); + } + + public void testBytesForTaskNullTaskReturnsZero() { + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> 0.5); + assertEquals(0L, tracker.bytesForTask(null)); + } + + public void testHasSnapshotProviderReflectsInstallation() { + // Before any backend installs a supplier, the predicate is false. + assertFalse(NativeMemoryUsageTracker.hasSnapshotProvider()); + + // Once installed, hasSnapshotProvider must return true even when the supplier itself + // returns an empty map. Contract is "is something wired up?", not "does it have data?". + NativeMemoryUsageTracker.setSnapshotSupplier(Collections::emptyMap); + assertTrue(NativeMemoryUsageTracker.hasSnapshotProvider()); + } + + public void testNullSnapshotSupplierIgnored() { + Supplier> good = Collections::emptyMap; + NativeMemoryUsageTracker.setSnapshotSupplier(good); + assertTrue(NativeMemoryUsageTracker.hasSnapshotProvider()); + // Passing null must not erase a working installation. + NativeMemoryUsageTracker.setSnapshotSupplier(null); + assertTrue(NativeMemoryUsageTracker.hasSnapshotProvider()); + } + + public void testGetNativeMemoryBudgetBytesClampsNegative() { + NativeMemoryUsageTracker.setNativeMemoryBudgetSupplier(() -> -1L); + assertEquals(0L, NativeMemoryUsageTracker.getNativeMemoryBudgetBytes()); + } + + public void testGetNativeMemoryBudgetBytesPropagatesValue() { + NativeMemoryUsageTracker.setNativeMemoryBudgetSupplier(() -> 1024L * 1024L * 1024L); + assertEquals(1024L * 1024L * 1024L, NativeMemoryUsageTracker.getNativeMemoryBudgetBytes()); + } + + public void testNullBudgetSupplierIgnored() { + NativeMemoryUsageTracker.setNativeMemoryBudgetSupplier(() -> 99L); + NativeMemoryUsageTracker.setNativeMemoryBudgetSupplier(null); + assertEquals(99L, NativeMemoryUsageTracker.getNativeMemoryBudgetBytes()); + } + + public void testEvaluateInertWhenFractionZero() { + // Operator hasn't opted in (fraction=0): tracker MUST NOT cancel even with budget + heavy task. + NativeMemoryUsageTracker.setNativeMemoryBudgetSupplier(() -> 1_000L); + long taskId = 1L; + NativeMemoryUsageTracker.setSnapshotSupplier(() -> Map.of(taskId, 999L)); + NativeMemoryUsageService.getInstance().refresh(); + + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> 0.0); + Task task = createMockTask(SearchTask.class, taskId); + Optional reason = tracker.checkAndMaybeGetCancellationReason(task); + assertFalse(reason.isPresent()); + } + + public void testEvaluateInertWhenBudgetZero() { + // No backend installed budget: tracker MUST NOT cancel even with a fraction set. + long taskId = 1L; + NativeMemoryUsageTracker.setSnapshotSupplier(() -> Map.of(taskId, 999L)); + NativeMemoryUsageService.getInstance().refresh(); + + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> 0.5); + Task task = createMockTask(SearchTask.class, taskId); + Optional reason = tracker.checkAndMaybeGetCancellationReason(task); + assertFalse(reason.isPresent()); + } + + public void testEvaluateNonCancellableTaskSkipped() { + NativeMemoryUsageTracker.setNativeMemoryBudgetSupplier(() -> 1_000L); + NativeMemoryUsageTracker.setSnapshotSupplier(() -> Map.of(1L, 999L)); + NativeMemoryUsageService.getInstance().refresh(); + + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> 0.5); + // Plain Task (not a CancellableTask) must be ignored by the evaluator. + Task task = new Task(1L, "type", "action", "description", TaskId.EMPTY_TASK_ID, Collections.emptyMap()); + Optional reason = tracker.checkAndMaybeGetCancellationReason(task); + assertFalse(reason.isPresent()); + } + + public void testEvaluateBelowThresholdNoCancellation() { + long budget = 1_000L; + double fraction = 0.5; + long taskId = 7L; + // 400 < 500 (budget * fraction) + NativeMemoryUsageTracker.setNativeMemoryBudgetSupplier(() -> budget); + NativeMemoryUsageTracker.setSnapshotSupplier(() -> Map.of(taskId, 400L)); + NativeMemoryUsageService.getInstance().refresh(); + + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> fraction); + Task task = createMockTask(SearchTask.class, taskId); + assertFalse(tracker.checkAndMaybeGetCancellationReason(task).isPresent()); + } + + public void testEvaluateAtAndAboveThresholdReturnsReason() { + long budget = 1_000L; + double fraction = 0.5; + long thresholdBytes = (long) (budget * fraction); + long taskId = 7L; + long usage = thresholdBytes + 1L; + NativeMemoryUsageTracker.setNativeMemoryBudgetSupplier(() -> budget); + NativeMemoryUsageTracker.setSnapshotSupplier(() -> Map.of(taskId, usage)); + NativeMemoryUsageService.getInstance().refresh(); + + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> fraction); + Task task = createMockTask(SearchShardTask.class, taskId); + Optional reason = tracker.checkAndMaybeGetCancellationReason(task); + assertTrue("usage above threshold must yield a cancellation reason", reason.isPresent()); + assertTrue("score must be at least 1", reason.get().getCancellationScore() >= 1); + assertTrue( + "reason message should mention native memory", + reason.get().getMessage().toLowerCase(java.util.Locale.ROOT).contains("native memory") + ); + } + + public void testEvaluateScoreScalesWithUsage() { + // Score is floor(usage / threshold) per the tracker's evaluator. + long budget = 1_000L; + double fraction = 0.1; // threshold = 100 + long taskId = 9L; + NativeMemoryUsageTracker.setNativeMemoryBudgetSupplier(() -> budget); + NativeMemoryUsageTracker.setSnapshotSupplier(() -> Map.of(taskId, 350L)); // 3x threshold + NativeMemoryUsageService.getInstance().refresh(); + + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> fraction); + Task task = createMockTask(SearchTask.class, taskId); + Optional reason = tracker.checkAndMaybeGetCancellationReason(task); + assertTrue(reason.isPresent()); + assertEquals(3, reason.get().getCancellationScore()); + } + + public void testEvaluateFractionClampedToOne() { + // Fraction > 1.0 must be clamped to 1.0 — only tasks at or above the full budget cancel. + long budget = 1_000L; + long taskId = 1L; + NativeMemoryUsageTracker.setNativeMemoryBudgetSupplier(() -> budget); + NativeMemoryUsageTracker.setSnapshotSupplier(() -> Map.of(taskId, 999L)); + NativeMemoryUsageService.getInstance().refresh(); + + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> 5.0); + Task task = createMockTask(SearchTask.class, taskId); + // 999 < 1000 (clamped threshold) — must NOT cancel. + assertFalse(tracker.checkAndMaybeGetCancellationReason(task).isPresent()); + + NativeMemoryUsageTracker.setSnapshotSupplier(() -> Map.of(taskId, 1_000L)); + NativeMemoryUsageService.getInstance().refresh(); + // 1000 >= 1000 — must cancel. + assertTrue(tracker.checkAndMaybeGetCancellationReason(task).isPresent()); + } + + public void testStatsMaxAndAvgOverActiveTasks() { + Map snapshot = new HashMap<>(); + snapshot.put(1L, 100L); + snapshot.put(2L, 200L); + snapshot.put(3L, 300L); + NativeMemoryUsageTracker.setSnapshotSupplier(() -> snapshot); + NativeMemoryUsageService.getInstance().refresh(); + + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> 0.5); + List tasks = List.of( + createMockTask(SearchTask.class, 1L), + createMockTask(SearchTask.class, 2L), + createMockTask(SearchTask.class, 3L) + ); + NativeMemoryUsageTracker.Stats stats = (NativeMemoryUsageTracker.Stats) tracker.stats(tasks); + // No checkAndMaybeGetCancellationReason invocations — cancellation count stays at zero. + NativeMemoryUsageTracker.Stats expected = new NativeMemoryUsageTracker.Stats(0L, 300L, 200L); + assertEquals(expected, stats); + } + + public void testStatsEmptyTaskList() { + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> 0.5); + NativeMemoryUsageTracker.Stats stats = (NativeMemoryUsageTracker.Stats) tracker.stats(List.of()); + assertEquals(new NativeMemoryUsageTracker.Stats(0L, 0L, 0L), stats); + } + + public void testStatsWireSerializationRoundTrip() throws Exception { + NativeMemoryUsageTracker.Stats original = new NativeMemoryUsageTracker.Stats(7L, 1024L, 512L); + try (BytesStreamOutput out = new BytesStreamOutput()) { + original.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + NativeMemoryUsageTracker.Stats deserialized = new NativeMemoryUsageTracker.Stats(in); + assertEquals(original, deserialized); + assertEquals(original.hashCode(), deserialized.hashCode()); + } + } + } + + public void testStatsEqualsAndHashCode() { + NativeMemoryUsageTracker.Stats a = new NativeMemoryUsageTracker.Stats(1L, 2L, 3L); + NativeMemoryUsageTracker.Stats b = new NativeMemoryUsageTracker.Stats(1L, 2L, 3L); + NativeMemoryUsageTracker.Stats c = new NativeMemoryUsageTracker.Stats(1L, 2L, 4L); + assertEquals(a, a); + assertEquals(a, b); + assertNotEquals(a, c); + assertNotEquals(a, null); + assertNotEquals(a, "not a stats object"); + assertEquals(a.hashCode(), b.hashCode()); + } + + public void testIsNativeTrackingSupportedNonLinuxAlwaysFalse() { + // Negative direction always holds: non-Linux MUST report unsupported regardless of state. + if (org.apache.lucene.util.Constants.LINUX == false) { + assertFalse(NativeMemoryUsageTracker.isNativeTrackingSupported()); + } + } + + public void testIsNativeTrackingSupportedRequiresProvider() { + // Without a snapshot provider the predicate must be false even on Linux. + // (resetForTesting() in @Before clears any previously installed supplier.) + assertFalse(NativeMemoryUsageTracker.isNativeTrackingSupported()); + } + + public void testUpdateIsNoOp() { + // update() is documented as a no-op; calling it must not throw or change snapshot state. + NativeMemoryUsageTracker.setSnapshotSupplier(() -> Map.of(1L, 100L)); + NativeMemoryUsageService.getInstance().refresh(); + + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> 0.5); + Task task = createMockTask(SearchTask.class, 1L); + tracker.update(task); + assertEquals(100L, tracker.bytesForTask(task)); + } + + public void testTrackerAcceptsInjectedService() { + // Package-private constructor lets a test isolate the service from the singleton. + NativeMemoryUsageService isolated = NativeMemoryUsageService.getInstance(); + NativeMemoryUsageTracker tracker = new NativeMemoryUsageTracker(() -> 0.5, isolated); + assertNotNull(tracker); + assertEquals(TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER.getName(), tracker.name()); + } + + /** + * Build a {@link CancellableTask} mock whose {@code getId()} returns the given id. The + * {@link org.opensearch.search.backpressure.SearchBackpressureTestHelpers#createMockTaskWithResourceStats} + * helper randomizes the id, which doesn't fit a snapshot-keyed test where evaluator + * lookups must hit a specific entry. + */ + private T createMockTask(Class type, long id) { + T task = org.mockito.Mockito.mock(type); + org.mockito.Mockito.when(task.getId()).thenReturn(id); + org.mockito.Mockito.when(task.getTotalResourceStats()) + .thenReturn(new org.opensearch.core.tasks.resourcetracker.TaskResourceUsage(0L, 0L)); + return task; + } +} diff --git a/server/src/test/java/org/opensearch/search/backpressure/trackers/NodeDuressTrackersTests.java b/server/src/test/java/org/opensearch/search/backpressure/trackers/NodeDuressTrackersTests.java index f46d84e1034a2..2610d4f468ce6 100644 --- a/server/src/test/java/org/opensearch/search/backpressure/trackers/NodeDuressTrackersTests.java +++ b/server/src/test/java/org/opensearch/search/backpressure/trackers/NodeDuressTrackersTests.java @@ -84,4 +84,57 @@ public void testNodeInDuressWhenCPUAndHeapInDuress() { // for the third time it should be in duress assertTrue(nodeDuressTrackers.isNodeInDuress()); } + + public void testNodeInDuressWhenNativeMemoryInDuress() { + // Only NATIVE_MEMORY tracker registered. With breach threshold 9, isNodeInDuress + // crosses the threshold on the third call (each isNodeInDuress invocation triggers + // updateCache once per ResourceType value, so each call increments the streak by + // ResourceType.values().length). + EnumMap map = new EnumMap<>(ResourceType.class) { + { + put(ResourceType.NATIVE_MEMORY, new NodeDuressTracker(() -> true, () -> 9)); + } + }; + + NodeDuressTrackers nodeDuressTrackers = new NodeDuressTrackers(map, resourceCacheExpiryChecker); + + assertFalse(nodeDuressTrackers.isNodeInDuress()); + assertFalse(nodeDuressTrackers.isNodeInDuress()); + assertTrue(nodeDuressTrackers.isNodeInDuress()); + assertTrue(nodeDuressTrackers.isNativeMemoryInDuress()); + } + + public void testIsNativeMemoryInDuressFalseWhenUnregistered() { + // CPU + MEMORY only — NATIVE_MEMORY missing entirely. Per + // NodeDuressTrackers#updateCache, missing trackers must be treated as "not in duress" + // rather than throwing on the absent enum value. + EnumMap map = new EnumMap<>(ResourceType.class) { + { + put(ResourceType.CPU, new NodeDuressTracker(() -> true, () -> 1)); + put(ResourceType.MEMORY, new NodeDuressTracker(() -> false, () -> 1)); + } + }; + + NodeDuressTrackers nodeDuressTrackers = new NodeDuressTrackers(map, resourceCacheExpiryChecker); + // Drive isResourceInDuress to populate the cache for all enum values. + assertTrue(nodeDuressTrackers.isResourceInDuress(ResourceType.CPU)); + assertFalse(nodeDuressTrackers.isResourceInDuress(ResourceType.MEMORY)); + // NATIVE_MEMORY was not registered — must be reported as not in duress. + assertFalse(nodeDuressTrackers.isResourceInDuress(ResourceType.NATIVE_MEMORY)); + assertFalse(nodeDuressTrackers.isNativeMemoryInDuress()); + } + + public void testIsNativeMemoryInDuressFalseWhenStreakNotMet() { + // Tracker is registered but breach threshold not reached. With threshold 100 the + // streak never crosses regardless of how many calls we make in this test loop. + EnumMap map = new EnumMap<>(ResourceType.class) { + { + put(ResourceType.NATIVE_MEMORY, new NodeDuressTracker(() -> true, () -> 100)); + } + }; + NodeDuressTrackers nodeDuressTrackers = new NodeDuressTrackers(map, resourceCacheExpiryChecker); + for (int i = 0; i < 10; i++) { + assertFalse(nodeDuressTrackers.isNativeMemoryInDuress()); + } + } } diff --git a/server/src/test/java/org/opensearch/search/backpressure/trackers/TaskResourceUsageTrackerTypeTests.java b/server/src/test/java/org/opensearch/search/backpressure/trackers/TaskResourceUsageTrackerTypeTests.java new file mode 100644 index 0000000000000..c7dac71c970c4 --- /dev/null +++ b/server/src/test/java/org/opensearch/search/backpressure/trackers/TaskResourceUsageTrackerTypeTests.java @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.search.backpressure.trackers; + +import org.opensearch.test.OpenSearchTestCase; + +/** + * Unit tests for {@link TaskResourceUsageTrackerType} ensuring the new + * {@code NATIVE_MEMORY_USAGE_TRACKER} entry round-trips through {@code fromName}. + */ +public class TaskResourceUsageTrackerTypeTests extends OpenSearchTestCase { + + public void testNativeMemoryUsageTrackerName() { + assertEquals("native_memory_usage_tracker", TaskResourceUsageTrackerType.NATIVE_MEMORY_USAGE_TRACKER.getName()); + } + + public void testFromNameRoundTrip() { + for (TaskResourceUsageTrackerType type : TaskResourceUsageTrackerType.values()) { + assertSame(type, TaskResourceUsageTrackerType.fromName(type.getName())); + } + } + + public void testFromNameUnknownThrows() { + IllegalArgumentException ex = expectThrows( + IllegalArgumentException.class, + () -> TaskResourceUsageTrackerType.fromName("not_a_real_tracker") + ); + assertTrue(ex.getMessage().contains("not_a_real_tracker")); + } + + public void testFromNameIsCaseSensitive() { + // Mirrors the strictness of ResourceType.fromName — uppercase is not accepted. + expectThrows(IllegalArgumentException.class, () -> TaskResourceUsageTrackerType.fromName("NATIVE_MEMORY_USAGE_TRACKER")); + } +} diff --git a/server/src/test/java/org/opensearch/wlm/ResourceTypeTests.java b/server/src/test/java/org/opensearch/wlm/ResourceTypeTests.java index 16bd8b7e66266..ade5bb3d6f861 100644 --- a/server/src/test/java/org/opensearch/wlm/ResourceTypeTests.java +++ b/server/src/test/java/org/opensearch/wlm/ResourceTypeTests.java @@ -23,10 +23,32 @@ public void testFromName() { assertThrows(IllegalArgumentException.class, () -> { ResourceType.fromName("JVM"); }); assertThrows(IllegalArgumentException.class, () -> { ResourceType.fromName("Heap"); }); assertThrows(IllegalArgumentException.class, () -> { ResourceType.fromName("Disk"); }); + + assertSame(ResourceType.NATIVE_MEMORY, ResourceType.fromName("native_memory")); + assertThrows(IllegalArgumentException.class, () -> { ResourceType.fromName("NATIVE_MEMORY"); }); + assertThrows(IllegalArgumentException.class, () -> { ResourceType.fromName("Native_Memory"); }); } public void testGetName() { assertEquals("cpu", ResourceType.CPU.getName()); assertEquals("memory", ResourceType.MEMORY.getName()); + assertEquals("native_memory", ResourceType.NATIVE_MEMORY.getName()); + } + + public void testNativeMemoryStatsDisabled() { + // statsEnabled=false ensures WorkloadGroupState skips allocating slots for NATIVE_MEMORY. + assertFalse(ResourceType.NATIVE_MEMORY.hasStatsEnabled()); + // CPU and MEMORY remain stats-enabled; this guards against accidental refactors. + assertTrue(ResourceType.CPU.hasStatsEnabled()); + assertTrue(ResourceType.MEMORY.hasStatsEnabled()); + } + + public void testNativeMemoryUsesPlaceholderCalculator() { + // Calculator is a no-op (returns 0.0) so accidental iteration over NATIVE_MEMORY by + // a WLM stats path does not produce spurious numbers. + assertSame( + org.opensearch.wlm.tracker.NativeMemoryUsageCalculator.INSTANCE, + ResourceType.NATIVE_MEMORY.getResourceUsageCalculator() + ); } } diff --git a/server/src/test/java/org/opensearch/wlm/tracker/NativeMemoryUsageCalculatorTests.java b/server/src/test/java/org/opensearch/wlm/tracker/NativeMemoryUsageCalculatorTests.java new file mode 100644 index 0000000000000..621b699042af3 --- /dev/null +++ b/server/src/test/java/org/opensearch/wlm/tracker/NativeMemoryUsageCalculatorTests.java @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.wlm.tracker; + +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.wlm.WorkloadGroupTask; + +import java.util.List; + +import static org.mockito.Mockito.mock; + +/** + * Unit tests for {@link NativeMemoryUsageCalculator}, the placeholder calculator wired to + * {@link org.opensearch.wlm.ResourceType#NATIVE_MEMORY}. WLM does not account off-heap + * memory per workload group, so this calculator MUST return zero unconditionally. + */ +public class NativeMemoryUsageCalculatorTests extends OpenSearchTestCase { + + public void testCalculatorIsSingleton() { + assertSame(NativeMemoryUsageCalculator.INSTANCE, NativeMemoryUsageCalculator.INSTANCE); + } + + public void testCalculateResourceUsageReturnsZero() { + // Multi-task input with mocks that have no behavior — calculator must not consult them. + List tasks = List.of(mock(WorkloadGroupTask.class), mock(WorkloadGroupTask.class)); + assertEquals(0.0d, NativeMemoryUsageCalculator.INSTANCE.calculateResourceUsage(tasks), 0.0d); + } + + public void testCalculateResourceUsageEmptyList() { + assertEquals(0.0d, NativeMemoryUsageCalculator.INSTANCE.calculateResourceUsage(List.of()), 0.0d); + } + + public void testCalculateTaskResourceUsageReturnsZero() { + WorkloadGroupTask task = mock(WorkloadGroupTask.class); + assertEquals(0.0d, NativeMemoryUsageCalculator.INSTANCE.calculateTaskResourceUsage(task), 0.0d); + } +} diff --git a/server/src/test/java/org/opensearch/wlm/tracker/WorkloadGroupResourceUsageTrackerServiceTests.java b/server/src/test/java/org/opensearch/wlm/tracker/WorkloadGroupResourceUsageTrackerServiceTests.java new file mode 100644 index 0000000000000..954e4cb8f011f --- /dev/null +++ b/server/src/test/java/org/opensearch/wlm/tracker/WorkloadGroupResourceUsageTrackerServiceTests.java @@ -0,0 +1,36 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.wlm.tracker; + +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.wlm.ResourceType; + +/** + * Unit tests for {@link WorkloadGroupResourceUsageTrackerService}'s {@code TRACKED_RESOURCES} + * set. After NATIVE_MEMORY was added to {@link ResourceType} for search-backpressure duress + * routing, this set MUST stay restricted to CPU + MEMORY so per-group accounting is unchanged. + */ +public class WorkloadGroupResourceUsageTrackerServiceTests extends OpenSearchTestCase { + + public void testTrackedResourcesContainsCpuAndMemory() { + assertTrue(WorkloadGroupResourceUsageTrackerService.TRACKED_RESOURCES.contains(ResourceType.CPU)); + assertTrue(WorkloadGroupResourceUsageTrackerService.TRACKED_RESOURCES.contains(ResourceType.MEMORY)); + } + + public void testTrackedResourcesExcludesNativeMemory() { + // Per the explicit comment on TRACKED_RESOURCES — WLM intentionally does not iterate + // NATIVE_MEMORY because the calculator is a no-op and the duress signal is consumed + // by SearchBackpressureService instead. + assertFalse(WorkloadGroupResourceUsageTrackerService.TRACKED_RESOURCES.contains(ResourceType.NATIVE_MEMORY)); + } + + public void testTrackedResourcesHasExactlyTwoEntries() { + assertEquals(2, WorkloadGroupResourceUsageTrackerService.TRACKED_RESOURCES.size()); + } +}