Skip to main content

query_runtime/runtime/query_service/handle/
mod.rs

1//! Bounded native query-service admission handle.
2
3#[cfg(any(test, feature = "test-hooks"))]
4use std::sync::{Mutex, PoisonError};
5use std::{
6    sync::Arc,
7    time::{Duration, Instant},
8};
9
10#[cfg(test)]
11use query_planning::policy::PlanningPolicy;
12use query_planning::{correlation::TraceId, error::QueryErrorCode};
13
14#[cfg(any(test, feature = "test-hooks"))]
15use super::RuntimeTerminalOutcome;
16use super::{
17    AdmissionRequestId, AdmittedAttempt, AggregateStrategyExecutionOptions, AttemptCapabilityEvidence,
18    ExecutionCancellation, ExecutionContext, GpuExecutionJobKind, GpuExecutionPool, MemoryControlPolicy, PlanScope,
19    QueryError, QueryExecutionConfig, QueryId, QueryService, QueryServiceInner, QueueBounds, Result,
20    SourceChunkPlanningMode, SourceChunkPlanningOptions, SourceSchedulerPolicy, StatisticalAggregateExecutionMode,
21    StreamBinding, memory_control_policy_for_grant, resources::QueryServiceResources,
22    validate_service_stream_pool_device,
23};
24#[cfg(any(test, feature = "test-hooks"))]
25use crate::attempt::AttemptOwner;
26use crate::{
27    observability::{AttemptAdmissionWaitFact, RuntimeStreamLeaseScope},
28    runtime::{
29        GpuExecutorLease, StreamPermitClass,
30        context::{AttemptAuxiliaryPermit, QueryServiceResourceEpoch},
31        gpu::{DeviceContext, GrantIdentity},
32        gpu_execution_pool::GpuExecutionJobClass,
33        service_stream::StreamSlot,
34    },
35};
36
37mod lifetime;
38mod release;
39mod report_commit;
40
41pub(super) use lifetime::QueryHandleLifetime;
42use lifetime::{ContextSetupClaim, RootContextSetupClaimGuard};
43
44/// An auxiliary context's attempt permit and stream slot, reserved before
45/// its context-setup job runs.
46#[derive(Debug)]
47struct AuxiliaryReservation {
48    permit: AttemptAuxiliaryPermit,
49    stream: StreamSlot,
50}
51
52/// RAII admission handle for one native query.
53#[derive(Debug)]
54pub struct QueryHandle {
55    pub(super) config: QueryExecutionConfig,
56    pub(super) queue_bounds: QueueBounds,
57    pub(super) admission_request_id: AdmissionRequestId,
58    pub(super) admission_sequence: u64,
59    pub(super) admission_wait: Duration,
60    pub(super) admission_wait_fact: Option<AttemptAdmissionWaitFact>,
61    pub(super) device_budget_bytes: u64,
62    pub(super) device_ordinal: usize,
63    pub(super) lifetime: Arc<QueryHandleLifetime>,
64    /// The attempt of a test request, owned by its handle: dropping the
65    /// handle ends the attempt, whose terminal commit releases the grant.
66    #[cfg(any(test, feature = "test-hooks"))]
67    pub(super) test_attempt: Mutex<Option<AttemptOwner>>,
68}
69
70impl QueryHandle {
71    /// Stable service-local request id assigned for this admission attempt.
72    pub fn admission_request_id(&self) -> AdmissionRequestId {
73        self.admission_request_id
74    }
75
76    /// Monotonic service-local admission sequence assigned when this handle was admitted.
77    pub fn admission_sequence(&self) -> u64 {
78        self.admission_sequence
79    }
80
81    /// Time spent waiting in the service admission queue.
82    pub fn admission_wait(&self) -> Duration {
83        self.admission_wait
84    }
85
86    /// Returns the final controller-owned queue state for this queued
87    /// admission, when the handle was admitted through the bounded queue.
88    /// Direct non-queued admission intentionally has no such fact.
89    pub fn admission_wait_fact(&self) -> Option<AttemptAdmissionWaitFact> {
90        self.admission_wait_fact
91    }
92
93    /// Device-memory budget slice assigned to this query.
94    pub fn device_budget_bytes(&self) -> u64 {
95        self.device_budget_bytes
96    }
97
98    /// CUDA device ordinal selected for this query.
99    pub fn device_ordinal(&self) -> usize {
100        self.device_ordinal
101    }
102
103    /// Hands a test attempt's owner to this newly admitted handle.
104    #[cfg(any(test, feature = "test-hooks"))]
105    pub(in crate::runtime::query_service) fn own_test_attempt(&self, owner: Option<AttemptOwner>) {
106        *self.test_attempt.lock().unwrap_or_else(PoisonError::into_inner) = owner;
107    }
108
109    /// Ends this handle's test attempt as `Dropped` while the handle stays
110    /// alive; the attempt's terminal commit then releases the grant.
111    #[cfg(any(test, feature = "test-hooks"))]
112    #[doc(hidden)]
113    pub fn end_test_attempt(&self) {
114        let owner = self.test_attempt.lock().unwrap_or_else(PoisonError::into_inner).take();
115        drop(owner);
116    }
117
118    /// Commits `outcome` for this handle's test attempt, as a frontend does
119    /// for the attempt it owns; the commit then releases the grant.
120    #[cfg(any(test, feature = "test-hooks"))]
121    #[doc(hidden)]
122    pub fn commit_test_attempt(&self, outcome: RuntimeTerminalOutcome) {
123        let owner = self.test_attempt.lock().unwrap_or_else(PoisonError::into_inner).take();
124        if let Some(owner) = owner {
125            owner.commit_terminal(outcome).expect("a test attempt commits one terminal outcome");
126        }
127    }
128
129    /// Joins a fragment planned after this attempt's claim to its grant.
130    ///
131    /// The fragment runs under the same grant when the granted device
132    /// provides every capability it requires; its plan identity is then added
133    /// to what the attempt may execute. The queue and the ledger do not
134    /// change. A device that lacks a capability, or a released handle,
135    /// returns a typed error.
136    pub fn join_fragment(&self, fragment: &AttemptCapabilityEvidence) -> Result<()> {
137        let service = QueryService { inner: self.lifetime.service()? };
138        service.inner.admission.join_admitted_fragment(
139            self.device_ordinal,
140            &self.lifetime.admitted_plan_identities,
141            fragment,
142        )
143    }
144
145    /// Immutable attempt and ledger generation committed for this admission.
146    pub fn grant_identity(&self) -> Result<GrantIdentity> {
147        self.lifetime.with_resources(|resources| Ok(resources.envelope.memory_grant().identity()))
148    }
149
150    /// Bounded memory policy this handle will use for native execution.
151    pub fn memory_control_policy(&self) -> MemoryControlPolicy {
152        self.config.memory_policy_for_admitted_query(self.device_budget_bytes)
153    }
154
155    /// Source scheduler policy copied into the admitted execution context.
156    pub fn source_scheduler_policy(&self) -> SourceSchedulerPolicy {
157        self.config.source_scheduler_policy
158    }
159
160    /// Source chunk planning mode copied into the admitted execution context.
161    pub fn source_chunk_planning_mode(&self) -> SourceChunkPlanningMode {
162        self.config.source_chunk_planning_mode
163    }
164
165    /// Floating statistical aggregate execution contract assigned to this admitted query.
166    pub fn statistical_aggregate_execution_mode(&self) -> StatisticalAggregateExecutionMode {
167        self.config.statistical_aggregate_execution_mode
168    }
169
170    /// Join and dynamic-filter thresholds assigned to this admitted query.
171    #[cfg(test)]
172    pub(crate) fn planning_policy(&self) -> PlanningPolicy {
173        self.config.planning_policy
174    }
175
176    /// Grouped aggregate strategy execution policy assigned to this admitted query.
177    pub fn aggregate_strategy_execution_options(&self) -> AggregateStrategyExecutionOptions {
178        self.config.aggregate_strategy_execution_options
179    }
180
181    /// Expert source chunk planning policy assigned to this admitted query.
182    pub fn source_chunk_planning_options(&self) -> SourceChunkPlanningOptions {
183        self.config.source_chunk_planning_options
184    }
185
186    /// Reserves an auxiliary stream slot on this query's device without
187    /// blocking the caller; the slot becomes a lease inside the context-setup
188    /// job.
189    async fn reserve_auxiliary_stream(
190        &self,
191        cancellation: &ExecutionCancellation,
192        deadline: Option<Instant>,
193    ) -> Result<StreamSlot> {
194        let operation = "query_engine.query_service.context.stream_acquire";
195        let service = self.lifetime.service()?;
196        let resources = &service.resources;
197        let pool = resources.devices.context_by_ordinal(self.device_ordinal)?.stream_pool().clone();
198        pool.reserve_async(
199            cancellation,
200            deadline,
201            operation,
202            self.lifetime.observation.clone(),
203            RuntimeStreamLeaseScope::default(),
204            StreamPermitClass::Auxiliary,
205        )
206        .await
207    }
208
209    fn context_stream_acquire_deadline(&self, cancellation: &ExecutionCancellation) -> Option<Instant> {
210        self.queue_bounds.wait_deadline(Instant::now(), cancellation.deadline())
211    }
212
213    /// Acquires an admitted execution context from the service stream pool.
214    pub fn acquire_context(&self) -> Result<AdmittedQueryContext> {
215        self.acquire_context_with_cancellation(ExecutionCancellation::new())
216    }
217
218    /// Acquires an admitted execution context without blocking the caller's async runtime.
219    #[cfg(test)]
220    pub(crate) async fn acquire_context_async(self: &Arc<Self>) -> Result<AdmittedQueryContext> {
221        self.acquire_context_with_cancellation_async(ExecutionCancellation::new()).await
222    }
223
224    /// Acquires an admitted execution context on this query's device executor.
225    pub async fn acquire_context_with_cancellation_async(
226        self: &Arc<Self>,
227        cancellation: ExecutionCancellation,
228    ) -> Result<AdmittedQueryContext> {
229        let operation = "query_engine.query_service.context";
230        let setup_claim = self.lifetime.reserve_context_setup_async(&cancellation, operation).await?;
231        let root_setup_claim = (setup_claim == ContextSetupClaim::Root)
232            .then(|| RootContextSetupClaimGuard::new(Arc::clone(&self.lifetime)));
233        let stream_acquire_deadline = self.context_stream_acquire_deadline(&cancellation);
234        // Claim the attempt-owned auxiliary share and its stream slot before
235        // submitting an execution-class job, so the GPU worker never waits
236        // for either. Otherwise excess work from one attempt can fill every
237        // global auxiliary executor permit while it waits for its own local
238        // limit.
239        let auxiliary_reservation = match setup_claim {
240            ContextSetupClaim::Auxiliary => {
241                let permit = self
242                    .lifetime
243                    .attempt_auxiliary_slots
244                    .acquire_with_cancellation_deadline_async(
245                        &cancellation,
246                        stream_acquire_deadline,
247                        "query_engine.query_service.context.attempt_auxiliary_acquire",
248                    )
249                    .await?;
250                let stream = self.reserve_auxiliary_stream(&cancellation, stream_acquire_deadline).await?;
251                Some(AuxiliaryReservation { permit, stream })
252            },
253            ContextSetupClaim::Root => None,
254            ContextSetupClaim::WaitForRoot => {
255                unreachable!("context setup reservation resolves waits before scheduling")
256            },
257        };
258        let pool = self.gpu_execution_pool()?;
259        let job_class = match setup_claim {
260            ContextSetupClaim::Root => GpuExecutionJobClass::Lifecycle,
261            ContextSetupClaim::Auxiliary => GpuExecutionJobClass::Execution,
262            ContextSetupClaim::WaitForRoot => {
263                unreachable!("context setup reservation resolves waits before scheduling")
264            },
265        };
266        let observation = self.lifetime.observation.clone();
267        let handle = Arc::clone(self);
268        pool.run_observed_scoped_in_class(
269            GpuExecutionJobKind::ContextSetup,
270            job_class,
271            observation,
272            move |stream_scope| async move {
273                let _root_setup_claim = root_setup_claim;
274                handle.acquire_context_with_cancellation_and_stream_scope(
275                    cancellation,
276                    stream_scope,
277                    setup_claim,
278                    stream_acquire_deadline,
279                    auxiliary_reservation,
280                )
281            },
282        )
283        .await
284        .and_then(|result| result)
285    }
286
287    /// Opaque lifecycle-bound executor for synchronous GPU work on this
288    /// query's selected device.
289    pub fn gpu_executor(&self) -> Result<GpuExecutorLease> {
290        self.gpu_execution_pool().map(GpuExecutorLease::from_pool)
291    }
292
293    pub(crate) fn gpu_execution_pool(&self) -> Result<GpuExecutionPool> {
294        let operation = "query_engine.query_service.gpu_execution_pool";
295        let query_anchor = self.lifetime.acquire_resource_anchor(operation)?;
296        let service = self.lifetime.service()?;
297        let resources = &service.resources;
298        let pool = resources.gpu_execution_pool_for_device(self.device_ordinal)?;
299        let resource_epoch =
300            QueryServiceResourceEpoch::acquire(&service.service_epoch, Some(&query_anchor), operation)?;
301        Ok(pool.with_resource_epoch(resource_epoch))
302    }
303
304    /// Acquires an admitted execution context that observes caller-supplied
305    /// cooperative cancellation.
306    pub fn acquire_context_with_cancellation(
307        &self,
308        cancellation: ExecutionCancellation,
309    ) -> Result<AdmittedQueryContext> {
310        let operation = "query_engine.query_service.context";
311        let setup_claim = self.lifetime.reserve_context_setup_sync(&cancellation, operation)?;
312        let _root_setup_claim = (setup_claim == ContextSetupClaim::Root)
313            .then(|| RootContextSetupClaimGuard::new(Arc::clone(&self.lifetime)));
314        let stream_acquire_deadline = self.context_stream_acquire_deadline(&cancellation);
315        self.acquire_context_with_cancellation_and_stream_scope(
316            cancellation,
317            RuntimeStreamLeaseScope::default(),
318            setup_claim,
319            stream_acquire_deadline,
320            None,
321        )
322    }
323
324    fn acquire_context_with_cancellation_and_stream_scope(
325        &self,
326        cancellation: ExecutionCancellation,
327        stream_scope: RuntimeStreamLeaseScope,
328        setup_claim: ContextSetupClaim,
329        stream_acquire_deadline: Option<Instant>,
330        auxiliary_reservation: Option<AuxiliaryReservation>,
331    ) -> Result<AdmittedQueryContext> {
332        let operation = "query_engine.query_service.context";
333        // This is the first instruction after an asynchronous device-worker
334        // queue wait. Do not create a context or consume a root binding after
335        // the attempt's absolute deadline has elapsed.
336        cancellation.check(operation)?;
337        let query_anchor = self.lifetime.acquire_resource_anchor(operation)?;
338        let service = self.lifetime.service()?;
339        let resources = &service.resources;
340        let device = resources.devices.context_by_ordinal(self.device_ordinal)?;
341        let template = self.lifetime.with_resources(|lifetime| Ok(lifetime.root_context.clone()))?;
342        if let Some(template) = template {
343            if setup_claim != ContextSetupClaim::Auxiliary {
344                return Err(QueryError::new(
345                    QueryErrorCode::Internal,
346                    operation,
347                    "root context was ready without an auxiliary context-setup reservation",
348                )
349                .with_fact("reason", "context_setup_claim_mismatch")
350                .with_fact("context_setup_claim", "root"));
351            }
352            // Concurrent fragments under one admitted query must not share the
353            // root stream, because cuDF rejects a second live query-domain budget
354            // token on the same stream. Bind each secondary acquire to a dedicated
355            // service stream while preserving the shared grant/domain.
356            let context = self.clone_auxiliary_context(
357                &cancellation,
358                device,
359                template,
360                stream_scope,
361                stream_acquire_deadline,
362                auxiliary_reservation,
363            )?;
364            let context = context.with_query_resource_anchor(query_anchor);
365            self.lifetime.query_epoch.check_open(operation).map_err(|_error| self.lifetime.released_error())?;
366            return Ok(AdmittedQueryContext { context });
367        }
368        if setup_claim != ContextSetupClaim::Root {
369            return Err(QueryError::new(
370                QueryErrorCode::Internal,
371                operation,
372                "auxiliary context setup ran before the admitted root context was ready",
373            )
374            .with_fact("reason", "context_setup_claim_mismatch")
375            .with_fact("context_setup_claim", "auxiliary"));
376        }
377        debug_assert!(auxiliary_reservation.is_none(), "root context setup cannot carry an auxiliary reservation");
378        // The admission transaction already owns the root stream, allocation
379        // domain, and root stream member. Consume that binding; no stream or
380        // domain is created after admission.
381        let context = self.bind_root_execution_context(cancellation, stream_scope, device, &service, resources)?;
382        self.lifetime.complete_root_context_setup();
383        let context = context.with_query_resource_anchor(query_anchor);
384        self.lifetime.query_epoch.check_open(operation).map_err(|_error| self.lifetime.released_error())?;
385        Ok(AdmittedQueryContext { context })
386    }
387
388    fn clone_auxiliary_context(
389        &self,
390        cancellation: &ExecutionCancellation,
391        device: &DeviceContext,
392        template: ExecutionContext,
393        stream_scope: RuntimeStreamLeaseScope,
394        stream_acquire_deadline: Option<Instant>,
395        auxiliary_reservation: Option<AuxiliaryReservation>,
396    ) -> Result<ExecutionContext> {
397        let operation = "query_engine.query_service.context.stream_acquire";
398        let (attempt_auxiliary_permit, stream_lease) = match auxiliary_reservation {
399            Some(AuxiliaryReservation { permit, stream }) => (permit, stream.into_lease(operation)?),
400            None => {
401                let permit = self.lifetime.attempt_auxiliary_slots.acquire_with_cancellation_deadline(
402                    cancellation,
403                    stream_acquire_deadline,
404                    "query_engine.query_service.context.attempt_auxiliary_acquire",
405                )?;
406                let stream_lease = device.stream_pool().acquire_observed_with_cancellation_deadline_and_class(
407                    cancellation,
408                    stream_acquire_deadline,
409                    operation,
410                    self.lifetime.observation.clone(),
411                    stream_scope,
412                    StreamPermitClass::Auxiliary,
413                )?;
414                (permit, stream_lease)
415            },
416        };
417        validate_service_stream_pool_device(device.stream_pool(), stream_lease.device())?;
418        template.with_owned_auxiliary_stream(stream_lease, attempt_auxiliary_permit)
419    }
420
421    /// Binds the attempt's root execution context. Only the root setup claim
422    /// holder calls this. The lifetime lock is held only to take the root
423    /// binding and to store the result, so a release request never waits on
424    /// the binding; the query anchor the caller holds keeps the release from
425    /// taking the resources in between.
426    fn bind_root_execution_context(
427        &self,
428        cancellation: ExecutionCancellation,
429        stream_scope: RuntimeStreamLeaseScope,
430        device: &DeviceContext,
431        service: &QueryServiceInner,
432        resources: &QueryServiceResources,
433    ) -> Result<ExecutionContext> {
434        let device_runtime = resources.device_runtime(self.device_ordinal)?;
435        let (binding, memory_grant, allocation_domain, attempt) = self.lifetime.with_resources(|lifetime| {
436            let envelope = &lifetime.envelope;
437            // The envelope's worker scope is the admission-time identity
438            // evidence: same device, same worker pool, and the lane the
439            // attempt has held since the ledger transaction committed.
440            if let Some(scope) = envelope.worker_scope() {
441                debug_assert_eq!(scope.device_ordinal(), self.device_ordinal);
442                debug_assert_eq!(scope.lane_index(), envelope.lane_index().expect("lane matches its scope"));
443                debug_assert_eq!(scope.pool_id(), device_runtime.gpu_execution_pool.snapshot().pool_id);
444            }
445            let binding = envelope.take_root_binding()?;
446            let memory_grant = envelope.memory_grant().clone();
447            let allocation_domain = envelope.domain().cloned().ok_or_else(|| self.lifetime.released_error())?;
448            let attempt = lifetime.attempt().ok_or_else(|| self.lifetime.released_error())?;
449            Ok((binding, memory_grant, allocation_domain, attempt))
450        })?;
451        #[cfg(test)]
452        self.lifetime.wait_at_root_context_binding_gate();
453        validate_service_stream_pool_device(device.stream_pool(), binding.stream_lease.device())?;
454        // The grant, its allocation domain, and the attempt identity come from
455        // one admission envelope, so plan validation does not re-check them.
456        debug_assert_eq!(allocation_domain.grant_identity(), memory_grant.identity());
457        debug_assert_eq!(memory_grant.identity().attempt_id, self.lifetime.attempt_id);
458        debug_assert_eq!(memory_grant.device_ordinal(), binding.stream_lease.device().ordinal());
459        let runtime = binding.stream_lease.shared_runtime()?;
460        let stream_provider_route = Arc::new(
461            allocation_domain
462                .create_stream_provider_route(runtime.stream(), self.lifetime.attempt_auxiliary_slots.capacity())?,
463        );
464        let query_id = self.lifetime.observation.query_id().map(|query_id| QueryId::new(query_id.as_str()));
465        let trace_id = self.lifetime.observation.trace_id().map(|trace_id| TraceId::new(trace_id.as_str()));
466        let attempt = AdmittedAttempt {
467            attempt_id: self.lifetime.attempt_id,
468            service_epoch: service.service_epoch.clone(),
469            query_epoch: self.lifetime.query_epoch.clone(),
470            cancellation,
471            attempt,
472            #[cfg(feature = "cugraph")]
473            cache_scope_allocator_correlation: self.lifetime.cache_scope_allocator_correlation(),
474            plan_identities: Arc::clone(&self.lifetime.admitted_plan_identities),
475            memory_control_policy: memory_control_policy_for_grant(self.memory_control_policy(), &memory_grant),
476            auxiliary_slots: Arc::clone(&self.lifetime.attempt_auxiliary_slots),
477            allocation_domain,
478            memory_grant,
479        };
480        // The root execution stream binds the attempt's root domain member
481        // once for the whole attempt lifetime.
482        let context = ExecutionContext::admitted_root(
483            device_runtime,
484            attempt,
485            StreamBinding::AttemptRoot {
486                runtime,
487                member: binding.member_lease,
488                route: Arc::clone(&stream_provider_route),
489                lease: binding.stream_lease,
490            },
491            PlanScope::root(query_id, trace_id, self.lifetime.observation.clone(), stream_scope),
492        );
493        self.lifetime.with_resources(|lifetime| {
494            debug_assert!(
495                lifetime.attempt_stream_provider_route.is_none(),
496                "an admitted attempt can install only one stream-provider route"
497            );
498            lifetime.attempt_stream_provider_route = Some(Arc::clone(&stream_provider_route));
499            lifetime.root_context = Some(context.clone());
500            Ok(())
501        })?;
502        Ok(context)
503    }
504}
505
506/// Execution context acquired from a [`QueryHandle`].
507#[derive(Debug)]
508pub struct AdmittedQueryContext {
509    context: ExecutionContext,
510}
511
512impl AdmittedQueryContext {
513    /// Native execution context for this admitted query.
514    pub fn context(&self) -> &ExecutionContext {
515        &self.context
516    }
517}