1use std::fmt::Write as _;
7use std::num::{NonZeroU32, NonZeroU64};
8use std::ops::Bound;
9
10use crate::{
11 db::{
12 access::IndexBranchSetOrderedSuffix,
13 query::plan::{
14 AccessPlanProjection, AccessPlannedQuery, GroupPlan, QueryMode, ResidualFilterShape,
15 ScalarPlan, project_access_plan,
16 },
17 },
18 value::Value,
19};
20use icydb_diagnostic_code::{
21 Diagnostic, DiagnosticCode, DiagnosticDetail, ErrorCode, ErrorOrigin, QueryReadAdmissionCode,
22};
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum QueryAdmissionLane {
27 PublicRead,
29 AdminAdHoc,
31 DiagnosticExplain,
33 DevTest,
35}
36
37impl QueryAdmissionLane {
38 #[must_use]
40 pub const fn as_str(self) -> &'static str {
41 match self {
42 Self::PublicRead => "public_read",
43 Self::AdminAdHoc => "admin_ad_hoc",
44 Self::DiagnosticExplain => "diagnostic_explain",
45 Self::DevTest => "dev_test",
46 }
47 }
48
49 #[must_use]
51 pub const fn executes_rows(self) -> bool {
52 !matches!(self, Self::DiagnosticExplain)
53 }
54}
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub enum QueryBoundKind {
59 Exact,
61 ConservativeUpperBound,
63 EnforcedRuntimeCap,
65 EstimateOnly,
67 Unavailable,
69}
70
71impl QueryBoundKind {
72 #[must_use]
74 pub const fn as_str(self) -> &'static str {
75 match self {
76 Self::Exact => "exact",
77 Self::ConservativeUpperBound => "conservative_upper_bound",
78 Self::EnforcedRuntimeCap => "enforced_runtime_cap",
79 Self::EstimateOnly => "estimate_only",
80 Self::Unavailable => "unavailable",
81 }
82 }
83
84 #[must_use]
86 pub const fn admits_public_read(self) -> bool {
87 matches!(
88 self,
89 Self::Exact | Self::ConservativeUpperBound | Self::EnforcedRuntimeCap
90 )
91 }
92}
93
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
96pub enum QueryAdmissionDecision {
97 Admitted,
99 Rejected,
101}
102
103impl QueryAdmissionDecision {
104 #[must_use]
106 pub const fn as_str(self) -> &'static str {
107 match self {
108 Self::Admitted => "admitted",
109 Self::Rejected => "rejected",
110 }
111 }
112
113 #[must_use]
115 pub const fn is_admitted(self) -> bool {
116 matches!(self, Self::Admitted)
117 }
118}
119
120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122pub enum QueryAdmissionAccessKind {
123 Unknown,
125 ByKey,
127 ByKeys,
129 KeyRange,
131 IndexPrefix,
133 IndexMultiLookup,
135 IndexBranchSet,
137 IndexRange,
139 FullScan,
141 Union,
143 Intersection,
145}
146
147impl QueryAdmissionAccessKind {
148 #[must_use]
150 pub const fn as_str(self) -> &'static str {
151 match self {
152 Self::Unknown => "unknown",
153 Self::ByKey => "by_key",
154 Self::ByKeys => "by_keys",
155 Self::KeyRange => "key_range",
156 Self::IndexPrefix => "index_prefix",
157 Self::IndexMultiLookup => "index_multi_lookup",
158 Self::IndexBranchSet => "index_branch_set",
159 Self::IndexRange => "index_range",
160 Self::FullScan => "full_scan",
161 Self::Union => "union",
162 Self::Intersection => "intersection",
163 }
164 }
165
166 #[must_use]
168 pub const fn is_secondary_index(self) -> bool {
169 matches!(
170 self,
171 Self::IndexPrefix | Self::IndexMultiLookup | Self::IndexBranchSet | Self::IndexRange
172 )
173 }
174
175 #[must_use]
177 pub const fn is_full_scan(self) -> bool {
178 matches!(self, Self::FullScan)
179 }
180}
181
182#[derive(Clone, Copy, Debug, Eq, PartialEq)]
184pub enum QueryAdmissionPlanShape {
185 ScalarRead,
187 GroupedAggregate,
189 Delete,
191}
192
193impl QueryAdmissionPlanShape {
194 #[must_use]
196 pub const fn as_str(self) -> &'static str {
197 match self {
198 Self::ScalarRead => "scalar_read",
199 Self::GroupedAggregate => "grouped_aggregate",
200 Self::Delete => "delete",
201 }
202 }
203}
204
205#[derive(Clone, Copy, Debug, Eq, PartialEq)]
207pub enum QueryAdmissionResidualFilter {
208 Absent,
210 Predicate,
212 Expression,
214 ExpressionAndPredicate,
216}
217
218impl QueryAdmissionResidualFilter {
219 #[must_use]
221 pub const fn as_str(self) -> &'static str {
222 match self {
223 Self::Absent => "none",
224 Self::Predicate => "predicate",
225 Self::Expression => "expression",
226 Self::ExpressionAndPredicate => "expression_and_predicate",
227 }
228 }
229
230 #[must_use]
232 pub const fn is_absent(self) -> bool {
233 matches!(self, Self::Absent)
234 }
235}
236
237#[derive(Clone, Copy, Debug, Eq, PartialEq)]
239pub enum QueryAdmissionOrdering {
240 None,
242 Requested,
244 Resolved,
246}
247
248impl QueryAdmissionOrdering {
249 #[must_use]
251 pub const fn as_str(self) -> &'static str {
252 match self {
253 Self::None => "none",
254 Self::Requested => "requested",
255 Self::Resolved => "resolved",
256 }
257 }
258}
259
260#[derive(Clone, Copy, Debug, Eq, PartialEq)]
262pub struct QueryAdmissionGroupedSummary {
263 group_field_count: u32,
264 aggregate_count: u32,
265 max_groups: u64,
266 max_group_bytes: u64,
267 having_filter: bool,
268}
269
270impl QueryAdmissionGroupedSummary {
271 #[must_use]
273 pub const fn new(
274 group_field_count: u32,
275 aggregate_count: u32,
276 max_groups: u64,
277 max_group_bytes: u64,
278 having_filter: bool,
279 ) -> Self {
280 Self {
281 group_field_count,
282 aggregate_count,
283 max_groups,
284 max_group_bytes,
285 having_filter,
286 }
287 }
288
289 #[must_use]
291 pub const fn group_field_count(self) -> u32 {
292 self.group_field_count
293 }
294
295 #[must_use]
297 pub const fn aggregate_count(self) -> u32 {
298 self.aggregate_count
299 }
300
301 #[must_use]
303 pub const fn max_groups(self) -> u64 {
304 self.max_groups
305 }
306
307 #[must_use]
309 pub const fn max_group_bytes(self) -> u64 {
310 self.max_group_bytes
311 }
312
313 #[must_use]
315 pub const fn has_having_filter(self) -> bool {
316 self.having_filter
317 }
318}
319
320#[derive(Clone, Copy, Debug, Eq, PartialEq)]
322pub struct GroupedAdmissionPolicy {
323 groups: Option<NonZeroU32>,
324 group_bytes: Option<NonZeroU32>,
325 distinct_entries: Option<NonZeroU32>,
326}
327
328impl GroupedAdmissionPolicy {
329 #[must_use]
331 pub const fn disabled() -> Self {
332 Self {
333 groups: None,
334 group_bytes: None,
335 distinct_entries: None,
336 }
337 }
338
339 #[must_use]
341 pub const fn bounded(
342 max_groups: NonZeroU32,
343 max_group_bytes: NonZeroU32,
344 max_distinct_entries: Option<NonZeroU32>,
345 ) -> Self {
346 Self {
347 groups: Some(max_groups),
348 group_bytes: Some(max_group_bytes),
349 distinct_entries: max_distinct_entries,
350 }
351 }
352
353 #[must_use]
355 pub const fn max_groups(&self) -> Option<NonZeroU32> {
356 self.groups
357 }
358
359 #[must_use]
361 pub const fn max_group_bytes(&self) -> Option<NonZeroU32> {
362 self.group_bytes
363 }
364
365 #[must_use]
367 pub const fn max_distinct_entries(&self) -> Option<NonZeroU32> {
368 self.distinct_entries
369 }
370
371 #[must_use]
373 pub const fn has_hard_limits(&self) -> bool {
374 self.groups.is_some() && self.group_bytes.is_some()
375 }
376}
377
378#[derive(Clone, Copy, Debug, Eq, PartialEq)]
379enum LimitRequirement {
380 Required,
381 Optional,
382}
383
384#[derive(Clone, Copy, Debug, Eq, PartialEq)]
385enum IndexRequirement {
386 Required,
387 Optional,
388}
389
390#[derive(Clone, Copy, Debug, Eq, PartialEq)]
391enum FullScanPolicy {
392 Allow,
393 Reject,
394}
395
396#[derive(Clone, Copy, Debug, Eq, PartialEq)]
397enum MaterializedSortPolicy {
398 Allow,
399 Reject,
400}
401
402#[derive(Clone, Copy, Debug, Eq, PartialEq)]
403enum OffsetPolicy {
404 Allow,
405 RejectNonZero,
406}
407
408#[derive(Clone, Debug, Eq, PartialEq)]
410pub struct QueryAdmissionPolicy {
411 lane: QueryAdmissionLane,
412 limit_requirement: LimitRequirement,
413 max_returned_rows: Option<NonZeroU32>,
414 max_scanned_rows: Option<NonZeroU64>,
415 max_response_bytes: Option<NonZeroU32>,
416 index_requirement: IndexRequirement,
417 offset_policy: OffsetPolicy,
418 full_scan_policy: FullScanPolicy,
419 materialized_sort_policy: MaterializedSortPolicy,
420 max_materialized_rows: Option<NonZeroU32>,
421 max_projection_columns: Option<NonZeroU32>,
422 grouped: GroupedAdmissionPolicy,
423}
424
425impl QueryAdmissionPolicy {
426 #[must_use]
428 pub const fn public_read(
429 max_returned_rows: NonZeroU32,
430 max_response_bytes: NonZeroU32,
431 ) -> Self {
432 Self {
433 lane: QueryAdmissionLane::PublicRead,
434 limit_requirement: LimitRequirement::Required,
435 max_returned_rows: Some(max_returned_rows),
436 max_scanned_rows: None,
437 max_response_bytes: Some(max_response_bytes),
438 index_requirement: IndexRequirement::Required,
439 offset_policy: OffsetPolicy::RejectNonZero,
440 full_scan_policy: FullScanPolicy::Reject,
441 materialized_sort_policy: MaterializedSortPolicy::Reject,
442 max_materialized_rows: None,
443 max_projection_columns: None,
444 grouped: GroupedAdmissionPolicy::disabled(),
445 }
446 }
447
448 #[must_use]
450 pub const fn admin_ad_hoc(
451 max_returned_rows: NonZeroU32,
452 max_scanned_rows: NonZeroU64,
453 max_response_bytes: NonZeroU32,
454 ) -> Self {
455 Self {
456 lane: QueryAdmissionLane::AdminAdHoc,
457 limit_requirement: LimitRequirement::Optional,
458 max_returned_rows: Some(max_returned_rows),
459 max_scanned_rows: Some(max_scanned_rows),
460 max_response_bytes: Some(max_response_bytes),
461 index_requirement: IndexRequirement::Optional,
462 offset_policy: OffsetPolicy::Allow,
463 full_scan_policy: FullScanPolicy::Allow,
464 materialized_sort_policy: MaterializedSortPolicy::Allow,
465 max_materialized_rows: Some(max_returned_rows),
466 max_projection_columns: None,
467 grouped: GroupedAdmissionPolicy::disabled(),
468 }
469 }
470
471 #[must_use]
473 pub const fn diagnostic_explain() -> Self {
474 Self {
475 lane: QueryAdmissionLane::DiagnosticExplain,
476 limit_requirement: LimitRequirement::Optional,
477 max_returned_rows: None,
478 max_scanned_rows: None,
479 max_response_bytes: None,
480 index_requirement: IndexRequirement::Optional,
481 offset_policy: OffsetPolicy::Allow,
482 full_scan_policy: FullScanPolicy::Allow,
483 materialized_sort_policy: MaterializedSortPolicy::Allow,
484 max_materialized_rows: None,
485 max_projection_columns: None,
486 grouped: GroupedAdmissionPolicy::disabled(),
487 }
488 }
489
490 #[must_use]
492 pub const fn dev_test_unbounded() -> Self {
493 Self {
494 lane: QueryAdmissionLane::DevTest,
495 limit_requirement: LimitRequirement::Optional,
496 max_returned_rows: None,
497 max_scanned_rows: None,
498 max_response_bytes: None,
499 index_requirement: IndexRequirement::Optional,
500 offset_policy: OffsetPolicy::Allow,
501 full_scan_policy: FullScanPolicy::Allow,
502 materialized_sort_policy: MaterializedSortPolicy::Allow,
503 max_materialized_rows: None,
504 max_projection_columns: None,
505 grouped: GroupedAdmissionPolicy::disabled(),
506 }
507 }
508
509 #[must_use]
511 pub const fn lane(&self) -> QueryAdmissionLane {
512 self.lane
513 }
514
515 #[must_use]
517 pub const fn require_limit(&self) -> bool {
518 matches!(self.limit_requirement, LimitRequirement::Required)
519 }
520
521 #[must_use]
523 pub const fn max_returned_rows(&self) -> Option<NonZeroU32> {
524 self.max_returned_rows
525 }
526
527 #[must_use]
529 pub const fn max_scanned_rows(&self) -> Option<NonZeroU64> {
530 self.max_scanned_rows
531 }
532
533 #[must_use]
535 pub const fn max_response_bytes(&self) -> Option<NonZeroU32> {
536 self.max_response_bytes
537 }
538
539 #[must_use]
541 pub const fn require_index(&self) -> bool {
542 matches!(self.index_requirement, IndexRequirement::Required)
543 }
544
545 #[must_use]
547 pub const fn reject_non_zero_offset(&self) -> bool {
548 matches!(self.offset_policy, OffsetPolicy::RejectNonZero)
549 }
550
551 #[must_use]
553 pub const fn allow_full_scan(&self) -> bool {
554 matches!(self.full_scan_policy, FullScanPolicy::Allow)
555 }
556
557 #[must_use]
559 pub const fn allow_materialized_sort(&self) -> bool {
560 matches!(self.materialized_sort_policy, MaterializedSortPolicy::Allow)
561 }
562
563 #[must_use]
565 pub const fn max_materialized_rows(&self) -> Option<NonZeroU32> {
566 self.max_materialized_rows
567 }
568
569 #[must_use]
571 pub const fn max_projection_columns(&self) -> Option<NonZeroU32> {
572 self.max_projection_columns
573 }
574
575 #[must_use]
577 pub const fn grouped(&self) -> GroupedAdmissionPolicy {
578 self.grouped
579 }
580
581 #[must_use]
583 pub const fn public_caps_are_finite(&self) -> bool {
584 !matches!(self.lane, QueryAdmissionLane::PublicRead)
585 || (self.max_returned_rows.is_some() && self.max_response_bytes.is_some())
586 }
587
588 #[must_use]
590 pub fn evaluate(&self, mut summary: QueryAdmissionSummary) -> QueryAdmissionSummary {
591 summary.lane = self.lane;
592
593 match self.rejection_for_summary(&summary) {
594 Some(rejection) => summary.reject(rejection),
595 None => summary.admit(),
596 }
597 }
598
599 fn rejection_for_summary(
600 &self,
601 summary: &QueryAdmissionSummary,
602 ) -> Option<QueryAdmissionRejection> {
603 if !self.lane.executes_rows() {
604 return Some(QueryAdmissionRejection::DiagnosticLaneDoesNotExecute);
605 }
606
607 if matches!(summary.plan_shape(), QueryAdmissionPlanShape::Delete) {
608 return Some(QueryAdmissionRejection::UnsupportedStatementForQueryLane);
609 }
610
611 if matches!(
612 summary.plan_shape(),
613 QueryAdmissionPlanShape::GroupedAggregate
614 ) && !self.grouped.has_hard_limits()
615 {
616 return Some(QueryAdmissionRejection::GroupedQueryRequiresLimits);
617 }
618
619 if self.require_limit() && summary.limit().is_none() {
620 return Some(QueryAdmissionRejection::PublicQueryRequiresLimit);
621 }
622
623 if self.reject_non_zero_offset() && summary.offset().unwrap_or_default() != 0 {
624 return Some(QueryAdmissionRejection::PublicQueryOffsetRejected);
625 }
626
627 if let Some(rejection) = self.returned_row_bound_rejection(summary) {
628 return Some(rejection);
629 }
630
631 if !self.allow_full_scan() && summary.selected_access().is_full_scan() {
632 return Some(QueryAdmissionRejection::UnboundedFullScanRejected);
633 }
634
635 if self.require_index()
636 && !access_satisfies_index_requirement(summary.selected_access(), summary.scan_bound())
637 {
638 return Some(QueryAdmissionRejection::PublicQueryRequiresIndex);
639 }
640
641 if let Some(rejection) = self.scan_bound_rejection(summary) {
642 return Some(rejection);
643 }
644
645 self.materialization_rejection(summary)
646 }
647
648 fn returned_row_bound_rejection(
649 &self,
650 summary: &QueryAdmissionSummary,
651 ) -> Option<QueryAdmissionRejection> {
652 let max_returned_rows = self.max_returned_rows?;
653
654 if matches!(
655 summary.returned_row_bound_kind(),
656 QueryBoundKind::EstimateOnly
657 ) {
658 return Some(QueryAdmissionRejection::EstimatedOnlyBoundRejected);
659 }
660
661 if !summary.returned_row_bound_kind().admits_public_read() {
662 return Some(QueryAdmissionRejection::ScanBoundUnavailable);
663 }
664
665 let Some(returned_row_bound) = summary.returned_row_bound() else {
666 return Some(QueryAdmissionRejection::ScanBoundUnavailable);
667 };
668
669 if returned_row_bound > max_returned_rows.get() {
670 return Some(QueryAdmissionRejection::ReturnedRowBoundExceedsPolicy);
671 }
672
673 None
674 }
675
676 fn scan_bound_rejection(
677 &self,
678 summary: &QueryAdmissionSummary,
679 ) -> Option<QueryAdmissionRejection> {
680 let max_scanned_rows = self.max_scanned_rows?;
681
682 if matches!(summary.scan_bound_kind(), QueryBoundKind::EstimateOnly) {
683 return Some(QueryAdmissionRejection::EstimatedOnlyBoundRejected);
684 }
685
686 if !summary.scan_bound_kind().admits_public_read() {
687 return Some(QueryAdmissionRejection::ScanBoundUnavailable);
688 }
689
690 let Some(scan_bound) = summary.scan_bound() else {
691 return Some(QueryAdmissionRejection::ScanBoundUnavailable);
692 };
693
694 if scan_bound > max_scanned_rows.get() {
695 return Some(QueryAdmissionRejection::ScanBoundExceedsPolicy);
696 }
697
698 None
699 }
700
701 fn materialization_rejection(
702 &self,
703 summary: &QueryAdmissionSummary,
704 ) -> Option<QueryAdmissionRejection> {
705 if !self.allow_materialized_sort() && summary.materialization().materialized_sort() {
706 return Some(QueryAdmissionRejection::SortRequiresMaterialization);
707 }
708
709 let max_materialized_rows = self.max_materialized_rows?;
710 let materialized_rows = summary.materialization().materialized_rows()?;
711
712 if materialized_rows > max_materialized_rows.get() {
713 Some(QueryAdmissionRejection::MaterializationExceedsBudget)
714 } else {
715 None
716 }
717 }
718}
719
720#[derive(Clone, Copy, Debug, Eq, PartialEq)]
722pub struct QueryMaterializationSummary {
723 materialized_sort: bool,
724 materialized_rows: Option<u32>,
725 row_bound_kind: QueryBoundKind,
726}
727
728impl QueryMaterializationSummary {
729 #[must_use]
731 pub const fn none() -> Self {
732 Self {
733 materialized_sort: false,
734 materialized_rows: None,
735 row_bound_kind: QueryBoundKind::Unavailable,
736 }
737 }
738
739 #[must_use]
741 pub const fn sort(materialized_rows: Option<u32>, row_bound_kind: QueryBoundKind) -> Self {
742 Self {
743 materialized_sort: true,
744 materialized_rows,
745 row_bound_kind,
746 }
747 }
748
749 #[must_use]
751 pub const fn materialized_sort(&self) -> bool {
752 self.materialized_sort
753 }
754
755 #[must_use]
757 pub const fn materialized_rows(&self) -> Option<u32> {
758 self.materialized_rows
759 }
760
761 #[must_use]
763 pub const fn row_bound_kind(&self) -> QueryBoundKind {
764 self.row_bound_kind
765 }
766}
767
768#[derive(Clone, Copy, Debug, Eq, PartialEq)]
770pub enum QueryAdmissionRejection {
771 PublicQueryRequiresLimit,
773 PublicQueryRequiresIndex,
775 UnboundedFullScanRejected,
777 ScanBoundUnavailable,
779 ScanBoundExceedsPolicy,
781 EstimatedOnlyBoundRejected,
783 SortRequiresMaterialization,
785 MaterializationExceedsBudget,
787 ProjectionResponseMayExceedLimit,
789 GroupedQueryRequiresLimits,
791 GroupedQueryExceedsBudget,
793 DiagnosticLaneDoesNotExecute,
795 IntrospectionDisabledForLane,
797 UnsupportedStatementForQueryLane,
799 PublicQueryOffsetRejected,
801 ReturnedRowBoundExceedsPolicy,
803}
804
805impl QueryAdmissionRejection {
806 #[must_use]
808 pub const fn as_str(self) -> &'static str {
809 match self {
810 Self::PublicQueryRequiresLimit => "public_query_requires_limit",
811 Self::PublicQueryRequiresIndex => "public_query_requires_index",
812 Self::UnboundedFullScanRejected => "unbounded_full_scan_rejected",
813 Self::ScanBoundUnavailable => "scan_bound_unavailable",
814 Self::ScanBoundExceedsPolicy => "scan_bound_exceeds_policy",
815 Self::EstimatedOnlyBoundRejected => "estimated_only_bound_rejected",
816 Self::SortRequiresMaterialization => "sort_requires_materialization",
817 Self::MaterializationExceedsBudget => "materialization_exceeds_budget",
818 Self::ProjectionResponseMayExceedLimit => "projection_response_may_exceed_limit",
819 Self::GroupedQueryRequiresLimits => "grouped_query_requires_limits",
820 Self::GroupedQueryExceedsBudget => "grouped_query_exceeds_budget",
821 Self::DiagnosticLaneDoesNotExecute => "diagnostic_lane_does_not_execute",
822 Self::IntrospectionDisabledForLane => "introspection_disabled_for_lane",
823 Self::UnsupportedStatementForQueryLane => "unsupported_statement_for_query_lane",
824 Self::PublicQueryOffsetRejected => "public_query_offset_rejected",
825 Self::ReturnedRowBoundExceedsPolicy => "returned_row_bound_exceeds_policy",
826 }
827 }
828
829 #[must_use]
831 pub const fn code(self) -> QueryReadAdmissionCode {
832 match self {
833 Self::PublicQueryRequiresLimit => QueryReadAdmissionCode::PublicQueryRequiresLimit,
834 Self::PublicQueryRequiresIndex => QueryReadAdmissionCode::PublicQueryRequiresIndex,
835 Self::UnboundedFullScanRejected => QueryReadAdmissionCode::UnboundedFullScanRejected,
836 Self::ScanBoundUnavailable => QueryReadAdmissionCode::ScanBoundUnavailable,
837 Self::ScanBoundExceedsPolicy => QueryReadAdmissionCode::ScanBoundExceedsPolicy,
838 Self::EstimatedOnlyBoundRejected => QueryReadAdmissionCode::EstimatedOnlyBoundRejected,
839 Self::SortRequiresMaterialization => {
840 QueryReadAdmissionCode::SortRequiresMaterialization
841 }
842 Self::MaterializationExceedsBudget => {
843 QueryReadAdmissionCode::MaterializationExceedsBudget
844 }
845 Self::ProjectionResponseMayExceedLimit => {
846 QueryReadAdmissionCode::ProjectionResponseMayExceedLimit
847 }
848 Self::GroupedQueryRequiresLimits => QueryReadAdmissionCode::GroupedQueryRequiresLimits,
849 Self::GroupedQueryExceedsBudget => QueryReadAdmissionCode::GroupedQueryExceedsBudget,
850 Self::DiagnosticLaneDoesNotExecute => {
851 QueryReadAdmissionCode::DiagnosticLaneDoesNotExecute
852 }
853 Self::IntrospectionDisabledForLane => {
854 QueryReadAdmissionCode::IntrospectionDisabledForLane
855 }
856 Self::UnsupportedStatementForQueryLane => {
857 QueryReadAdmissionCode::UnsupportedStatementForQueryLane
858 }
859 Self::PublicQueryOffsetRejected => QueryReadAdmissionCode::PublicQueryOffsetRejected,
860 Self::ReturnedRowBoundExceedsPolicy => {
861 QueryReadAdmissionCode::ReturnedRowBoundExceedsPolicy
862 }
863 }
864 }
865
866 #[must_use]
868 pub const fn diagnostic(self) -> Diagnostic {
869 Diagnostic::new(
870 DiagnosticCode::QueryReadAdmission,
871 ErrorOrigin::Query,
872 Some(DiagnosticDetail::QueryReadAdmission {
873 reason: self.code(),
874 }),
875 )
876 }
877
878 #[must_use]
880 pub const fn error_code(self) -> ErrorCode {
881 self.diagnostic().error_code()
882 }
883}
884
885#[derive(Clone, Debug, Eq, PartialEq)]
887pub struct QueryAdmissionSummary {
888 lane: QueryAdmissionLane,
889 decision: QueryAdmissionDecision,
890 plan_shape: QueryAdmissionPlanShape,
891 selected_access: QueryAdmissionAccessKind,
892 selected_index: Option<String>,
893 limit: Option<u32>,
894 offset: Option<u32>,
895 scan_bound: Option<u64>,
896 scan_bound_kind: QueryBoundKind,
897 returned_row_bound: Option<u32>,
898 returned_row_bound_kind: QueryBoundKind,
899 response_byte_bound: Option<u32>,
900 response_byte_bound_kind: QueryBoundKind,
901 residual_filter: QueryAdmissionResidualFilter,
902 ordering: QueryAdmissionOrdering,
903 grouped: Option<QueryAdmissionGroupedSummary>,
904 materialization: QueryMaterializationSummary,
905 rejection: Option<QueryAdmissionRejection>,
906}
907
908impl QueryAdmissionSummary {
909 #[must_use]
911 pub const fn admitted(
912 lane: QueryAdmissionLane,
913 selected_access: QueryAdmissionAccessKind,
914 ) -> Self {
915 Self {
916 lane,
917 decision: QueryAdmissionDecision::Admitted,
918 plan_shape: QueryAdmissionPlanShape::ScalarRead,
919 selected_access,
920 selected_index: None,
921 limit: None,
922 offset: None,
923 scan_bound: None,
924 scan_bound_kind: QueryBoundKind::Unavailable,
925 returned_row_bound: None,
926 returned_row_bound_kind: QueryBoundKind::Unavailable,
927 response_byte_bound: None,
928 response_byte_bound_kind: QueryBoundKind::Unavailable,
929 residual_filter: QueryAdmissionResidualFilter::Absent,
930 ordering: QueryAdmissionOrdering::None,
931 grouped: None,
932 materialization: QueryMaterializationSummary::none(),
933 rejection: None,
934 }
935 }
936
937 #[must_use]
939 pub const fn rejected(
940 lane: QueryAdmissionLane,
941 selected_access: QueryAdmissionAccessKind,
942 rejection: QueryAdmissionRejection,
943 ) -> Self {
944 Self {
945 lane,
946 decision: QueryAdmissionDecision::Rejected,
947 plan_shape: QueryAdmissionPlanShape::ScalarRead,
948 selected_access,
949 selected_index: None,
950 limit: None,
951 offset: None,
952 scan_bound: None,
953 scan_bound_kind: QueryBoundKind::Unavailable,
954 returned_row_bound: None,
955 returned_row_bound_kind: QueryBoundKind::Unavailable,
956 response_byte_bound: None,
957 response_byte_bound_kind: QueryBoundKind::Unavailable,
958 residual_filter: QueryAdmissionResidualFilter::Absent,
959 ordering: QueryAdmissionOrdering::None,
960 grouped: None,
961 materialization: QueryMaterializationSummary::none(),
962 rejection: Some(rejection),
963 }
964 }
965
966 #[must_use]
968 pub(in crate::db) fn from_plan(lane: QueryAdmissionLane, plan: &AccessPlannedQuery) -> Self {
969 let access = summarize_access_plan(plan);
970 let grouped = plan.grouped_plan().map(summarize_grouped_plan);
971 let (limit, offset) = scalar_limit_and_offset(plan.scalar_plan());
972 let (returned_row_bound, returned_row_bound_kind) =
973 returned_row_bound_from_plan(limit, grouped);
974 let scan_bound_kind = access.scan_bound_kind();
975 Self {
976 lane,
977 decision: QueryAdmissionDecision::Admitted,
978 plan_shape: plan_shape(plan),
979 selected_access: access.kind,
980 selected_index: access.selected_index,
981 limit,
982 offset: Some(offset),
983 scan_bound: access.exact_scan_bound,
984 scan_bound_kind,
985 returned_row_bound,
986 returned_row_bound_kind,
987 response_byte_bound: None,
988 response_byte_bound_kind: QueryBoundKind::Unavailable,
989 residual_filter: admission_residual_filter(plan.residual_filter_shape()),
990 ordering: admission_ordering(plan),
991 grouped,
992 materialization: QueryMaterializationSummary::none(),
993 rejection: None,
994 }
995 }
996
997 const fn admit(mut self) -> Self {
998 self.decision = QueryAdmissionDecision::Admitted;
999 self.rejection = None;
1000 self
1001 }
1002
1003 const fn reject(mut self, rejection: QueryAdmissionRejection) -> Self {
1004 self.decision = QueryAdmissionDecision::Rejected;
1005 self.rejection = Some(rejection);
1006 self
1007 }
1008
1009 #[must_use]
1011 pub const fn lane(&self) -> QueryAdmissionLane {
1012 self.lane
1013 }
1014
1015 #[must_use]
1017 pub const fn decision(&self) -> QueryAdmissionDecision {
1018 self.decision
1019 }
1020
1021 #[must_use]
1023 pub const fn plan_shape(&self) -> QueryAdmissionPlanShape {
1024 self.plan_shape
1025 }
1026
1027 #[must_use]
1029 pub const fn selected_access(&self) -> QueryAdmissionAccessKind {
1030 self.selected_access
1031 }
1032
1033 #[must_use]
1035 pub fn selected_index(&self) -> Option<&str> {
1036 self.selected_index.as_deref()
1037 }
1038
1039 #[must_use]
1041 pub const fn limit(&self) -> Option<u32> {
1042 self.limit
1043 }
1044
1045 #[must_use]
1047 pub const fn offset(&self) -> Option<u32> {
1048 self.offset
1049 }
1050
1051 #[must_use]
1053 pub const fn scan_bound(&self) -> Option<u64> {
1054 self.scan_bound
1055 }
1056
1057 #[must_use]
1059 pub const fn scan_bound_kind(&self) -> QueryBoundKind {
1060 self.scan_bound_kind
1061 }
1062
1063 #[must_use]
1065 pub const fn returned_row_bound(&self) -> Option<u32> {
1066 self.returned_row_bound
1067 }
1068
1069 #[must_use]
1071 pub const fn returned_row_bound_kind(&self) -> QueryBoundKind {
1072 self.returned_row_bound_kind
1073 }
1074
1075 #[must_use]
1077 pub const fn response_byte_bound(&self) -> Option<u32> {
1078 self.response_byte_bound
1079 }
1080
1081 #[must_use]
1083 pub const fn response_byte_bound_kind(&self) -> QueryBoundKind {
1084 self.response_byte_bound_kind
1085 }
1086
1087 #[must_use]
1089 pub const fn residual_filter(&self) -> QueryAdmissionResidualFilter {
1090 self.residual_filter
1091 }
1092
1093 #[must_use]
1095 pub const fn ordering(&self) -> QueryAdmissionOrdering {
1096 self.ordering
1097 }
1098
1099 #[must_use]
1101 pub const fn grouped(&self) -> Option<QueryAdmissionGroupedSummary> {
1102 self.grouped
1103 }
1104
1105 #[must_use]
1107 pub const fn materialization(&self) -> QueryMaterializationSummary {
1108 self.materialization
1109 }
1110
1111 #[must_use]
1113 #[cfg_attr(not(feature = "sql"), allow(dead_code))]
1114 pub(in crate::db) const fn with_materialization(
1115 mut self,
1116 materialization: QueryMaterializationSummary,
1117 ) -> Self {
1118 self.materialization = materialization;
1119 self
1120 }
1121
1122 #[must_use]
1124 pub const fn rejection(&self) -> Option<QueryAdmissionRejection> {
1125 self.rejection
1126 }
1127
1128 #[must_use]
1130 pub(in crate::db) fn render_text_block(&self) -> String {
1131 let mut out = String::from("admission:");
1132 push_text_field(&mut out, "lane", self.lane().as_str());
1133 push_text_field(&mut out, "decision", self.decision().as_str());
1134 push_text_field(
1135 &mut out,
1136 "reason",
1137 self.rejection()
1138 .map_or("none", QueryAdmissionRejection::as_str),
1139 );
1140 push_text_field(&mut out, "plan_shape", self.plan_shape().as_str());
1141 push_text_field(&mut out, "selected_access", self.selected_access().as_str());
1142 push_text_field(
1143 &mut out,
1144 "selected_index",
1145 self.selected_index().unwrap_or("none"),
1146 );
1147 push_text_option_u32(&mut out, "limit", self.limit());
1148 push_text_option_u32(&mut out, "offset", self.offset());
1149 push_text_option_u64(&mut out, "scan_bound", self.scan_bound());
1150 push_text_field(&mut out, "scan_bound_kind", self.scan_bound_kind().as_str());
1151 push_text_option_u32(&mut out, "returned_row_bound", self.returned_row_bound());
1152 push_text_field(
1153 &mut out,
1154 "returned_row_bound_kind",
1155 self.returned_row_bound_kind().as_str(),
1156 );
1157 push_text_option_u32(&mut out, "response_byte_bound", self.response_byte_bound());
1158 push_text_field(
1159 &mut out,
1160 "response_byte_bound_kind",
1161 self.response_byte_bound_kind().as_str(),
1162 );
1163 push_text_field(&mut out, "residual_filter", self.residual_filter().as_str());
1164 push_text_field(&mut out, "ordering", self.ordering().as_str());
1165 push_text_bool(
1166 &mut out,
1167 "materialized_sort",
1168 self.materialization().materialized_sort(),
1169 );
1170 push_text_option_u32(
1171 &mut out,
1172 "materialized_rows",
1173 self.materialization().materialized_rows(),
1174 );
1175 push_text_field(
1176 &mut out,
1177 "materialized_row_bound_kind",
1178 self.materialization().row_bound_kind().as_str(),
1179 );
1180
1181 if let Some(grouped) = self.grouped() {
1182 push_text_bool(&mut out, "grouped", true);
1183 push_text_u64(
1184 &mut out,
1185 "group_field_count",
1186 u64::from(grouped.group_field_count()),
1187 );
1188 push_text_u64(
1189 &mut out,
1190 "aggregate_count",
1191 u64::from(grouped.aggregate_count()),
1192 );
1193 push_text_u64(&mut out, "max_groups", grouped.max_groups());
1194 push_text_u64(&mut out, "max_group_bytes", grouped.max_group_bytes());
1195 push_text_bool(&mut out, "having_filter", grouped.has_having_filter());
1196 } else {
1197 push_text_bool(&mut out, "grouped", false);
1198 }
1199
1200 out
1201 }
1202}
1203
1204fn push_text_field(out: &mut String, key: &str, value: &str) {
1205 out.push('\n');
1206 out.push_str(" ");
1207 out.push_str(key);
1208 out.push('=');
1209 out.push_str(value);
1210}
1211
1212fn push_text_bool(out: &mut String, key: &str, value: bool) {
1213 push_text_field(out, key, if value { "true" } else { "false" });
1214}
1215
1216fn push_text_u64(out: &mut String, key: &str, value: u64) {
1217 out.push('\n');
1218 out.push_str(" ");
1219 out.push_str(key);
1220 out.push('=');
1221 let _ = write!(out, "{value}");
1222}
1223
1224fn push_text_option_u32(out: &mut String, key: &str, value: Option<u32>) {
1225 match value {
1226 Some(value) => push_text_u64(out, key, u64::from(value)),
1227 None => push_text_field(out, key, "none"),
1228 }
1229}
1230
1231fn push_text_option_u64(out: &mut String, key: &str, value: Option<u64>) {
1232 match value {
1233 Some(value) => push_text_u64(out, key, value),
1234 None => push_text_field(out, key, "none"),
1235 }
1236}
1237
1238const _: fn(QueryAdmissionLane, &AccessPlannedQuery) -> QueryAdmissionSummary =
1240 QueryAdmissionSummary::from_plan;
1241
1242const fn access_satisfies_index_requirement(
1243 kind: QueryAdmissionAccessKind,
1244 scan_bound: Option<u64>,
1245) -> bool {
1246 kind.is_secondary_index()
1247 || matches!(
1248 (kind, scan_bound),
1249 (
1250 QueryAdmissionAccessKind::ByKey | QueryAdmissionAccessKind::ByKeys,
1251 Some(_)
1252 )
1253 )
1254}
1255
1256struct AdmissionAccessProjection;
1257
1258#[derive(Clone, Debug, Eq, PartialEq)]
1259struct AdmissionAccessSummary {
1260 kind: QueryAdmissionAccessKind,
1261 selected_index: Option<String>,
1262 exact_scan_bound: Option<u64>,
1263}
1264
1265impl AdmissionAccessSummary {
1266 const fn non_index(kind: QueryAdmissionAccessKind, exact_scan_bound: Option<u64>) -> Self {
1267 Self {
1268 kind,
1269 selected_index: None,
1270 exact_scan_bound,
1271 }
1272 }
1273
1274 fn secondary_index(kind: QueryAdmissionAccessKind, index_name: &str) -> Self {
1275 Self {
1276 kind,
1277 selected_index: Some(index_name.to_string()),
1278 exact_scan_bound: None,
1279 }
1280 }
1281
1282 const fn composite(kind: QueryAdmissionAccessKind) -> Self {
1283 Self {
1284 kind,
1285 selected_index: None,
1286 exact_scan_bound: None,
1287 }
1288 }
1289
1290 const fn scan_bound_kind(&self) -> QueryBoundKind {
1291 if self.exact_scan_bound.is_some() {
1292 QueryBoundKind::Exact
1293 } else {
1294 QueryBoundKind::Unavailable
1295 }
1296 }
1297}
1298
1299impl AccessPlanProjection<Value> for AdmissionAccessProjection {
1300 type Output = AdmissionAccessSummary;
1301
1302 fn by_key(&mut self, _key: &Value) -> Self::Output {
1303 AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::ByKey, Some(1))
1304 }
1305
1306 fn by_keys(&mut self, keys: &[Value]) -> Self::Output {
1307 AdmissionAccessSummary::non_index(
1308 QueryAdmissionAccessKind::ByKeys,
1309 Some(u64::try_from(keys.len()).unwrap_or(u64::MAX)),
1310 )
1311 }
1312
1313 fn key_range(&mut self, _start: &Value, _end: &Value) -> Self::Output {
1314 AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::KeyRange, None)
1315 }
1316
1317 fn index_prefix(
1318 &mut self,
1319 index_name: &str,
1320 _index_fields: &[String],
1321 _prefix_len: usize,
1322 _values: &[Value],
1323 ) -> Self::Output {
1324 AdmissionAccessSummary::secondary_index(QueryAdmissionAccessKind::IndexPrefix, index_name)
1325 }
1326
1327 fn index_multi_lookup(
1328 &mut self,
1329 index_name: &str,
1330 _index_fields: &[String],
1331 _values: &[Value],
1332 ) -> Self::Output {
1333 AdmissionAccessSummary::secondary_index(
1334 QueryAdmissionAccessKind::IndexMultiLookup,
1335 index_name,
1336 )
1337 }
1338
1339 fn index_branch_set(
1340 &mut self,
1341 index_name: &str,
1342 _index_fields: &[String],
1343 _fixed_values: &[Value],
1344 _branch_values: &[Value],
1345 _ordered_suffix: IndexBranchSetOrderedSuffix,
1346 ) -> Self::Output {
1347 AdmissionAccessSummary::secondary_index(
1348 QueryAdmissionAccessKind::IndexBranchSet,
1349 index_name,
1350 )
1351 }
1352
1353 fn index_range(
1354 &mut self,
1355 index_name: &str,
1356 _index_fields: &[String],
1357 _prefix_len: usize,
1358 _prefix: &[Value],
1359 _lower: &Bound<Value>,
1360 _upper: &Bound<Value>,
1361 ) -> Self::Output {
1362 AdmissionAccessSummary::secondary_index(QueryAdmissionAccessKind::IndexRange, index_name)
1363 }
1364
1365 fn full_scan(&mut self) -> Self::Output {
1366 AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::FullScan, None)
1367 }
1368
1369 fn union(&mut self, _children: Vec<Self::Output>) -> Self::Output {
1370 AdmissionAccessSummary::composite(QueryAdmissionAccessKind::Union)
1371 }
1372
1373 fn intersection(&mut self, _children: Vec<Self::Output>) -> Self::Output {
1374 AdmissionAccessSummary::composite(QueryAdmissionAccessKind::Intersection)
1375 }
1376}
1377
1378fn summarize_access_plan(plan: &AccessPlannedQuery) -> AdmissionAccessSummary {
1379 project_access_plan(&plan.access, &mut AdmissionAccessProjection)
1380}
1381
1382fn summarize_grouped_plan(plan: &GroupPlan) -> QueryAdmissionGroupedSummary {
1383 QueryAdmissionGroupedSummary::new(
1384 u32::try_from(plan.group.group_fields.len()).unwrap_or(u32::MAX),
1385 u32::try_from(plan.group.aggregates.len()).unwrap_or(u32::MAX),
1386 plan.group.execution.max_groups(),
1387 plan.group.execution.max_group_bytes(),
1388 plan.having_expr.is_some(),
1389 )
1390}
1391
1392const fn scalar_limit_and_offset(plan: &ScalarPlan) -> (Option<u32>, u32) {
1393 match plan.mode {
1394 QueryMode::Load(load) => match &plan.page {
1395 Some(page) => (page.limit, page.offset),
1396 None => (load.limit(), load.offset()),
1397 },
1398 QueryMode::Delete(delete) => match plan.delete_limit {
1399 Some(delete_limit) => (delete_limit.limit, delete_limit.offset),
1400 None => (delete.limit(), delete.offset()),
1401 },
1402 }
1403}
1404
1405fn returned_row_bound_from_plan(
1406 limit: Option<u32>,
1407 grouped: Option<QueryAdmissionGroupedSummary>,
1408) -> (Option<u32>, QueryBoundKind) {
1409 if let Some(limit) = limit {
1410 return (Some(limit), QueryBoundKind::EnforcedRuntimeCap);
1411 }
1412
1413 let Some(grouped) = grouped else {
1414 return (None, QueryBoundKind::Unavailable);
1415 };
1416 if grouped.max_groups() == u64::MAX {
1417 return (None, QueryBoundKind::Unavailable);
1418 }
1419
1420 (
1421 Some(u32::try_from(grouped.max_groups()).unwrap_or(u32::MAX)),
1422 QueryBoundKind::ConservativeUpperBound,
1423 )
1424}
1425
1426const fn admission_residual_filter(shape: ResidualFilterShape) -> QueryAdmissionResidualFilter {
1427 match shape {
1428 ResidualFilterShape::Absent => QueryAdmissionResidualFilter::Absent,
1429 ResidualFilterShape::Predicate => QueryAdmissionResidualFilter::Predicate,
1430 ResidualFilterShape::Expression => QueryAdmissionResidualFilter::Expression,
1431 ResidualFilterShape::ExpressionAndPredicate => {
1432 QueryAdmissionResidualFilter::ExpressionAndPredicate
1433 }
1434 }
1435}
1436
1437fn admission_ordering(plan: &AccessPlannedQuery) -> QueryAdmissionOrdering {
1438 if plan.scalar_plan().order.is_none() {
1439 return QueryAdmissionOrdering::None;
1440 }
1441
1442 if plan.resolved_order().is_some() {
1443 QueryAdmissionOrdering::Resolved
1444 } else {
1445 QueryAdmissionOrdering::Requested
1446 }
1447}
1448
1449const fn plan_shape(plan: &AccessPlannedQuery) -> QueryAdmissionPlanShape {
1450 if plan.grouped_plan().is_some() {
1451 return QueryAdmissionPlanShape::GroupedAggregate;
1452 }
1453
1454 match plan.scalar_plan().mode {
1455 QueryMode::Load(_) => QueryAdmissionPlanShape::ScalarRead,
1456 QueryMode::Delete(_) => QueryAdmissionPlanShape::Delete,
1457 }
1458}
1459
1460#[cfg(test)]
1461mod tests {
1462 use std::num::{NonZeroU32, NonZeroU64};
1463
1464 use crate::{
1465 db::{
1466 access::{AccessPath, SemanticIndexAccessContract},
1467 predicate::{MissingRowPolicy, Predicate},
1468 query::plan::{
1469 AccessPlannedQuery, AggregateKind, DeleteLimitSpec, DeleteSpec, FieldSlot,
1470 GroupAggregateSpec, GroupSpec, GroupedExecutionConfig, OrderDirection, OrderSpec,
1471 OrderTerm, PageSpec, QueryMode,
1472 expr::{Expr, FieldId},
1473 },
1474 },
1475 model::index::IndexModel,
1476 value::Value,
1477 };
1478
1479 use super::{
1480 GroupedAdmissionPolicy, QueryAdmissionAccessKind, QueryAdmissionDecision,
1481 QueryAdmissionLane, QueryAdmissionOrdering, QueryAdmissionPlanShape, QueryAdmissionPolicy,
1482 QueryAdmissionRejection, QueryAdmissionResidualFilter, QueryAdmissionSummary,
1483 QueryBoundKind, QueryMaterializationSummary,
1484 };
1485
1486 const ADMISSION_INDEX_FIELDS: [&str; 1] = ["tag"];
1487 const ADMISSION_INDEX: IndexModel = IndexModel::generated(
1488 "admission::tag",
1489 "admission::tag_store",
1490 &ADMISSION_INDEX_FIELDS,
1491 false,
1492 );
1493
1494 #[test]
1495 fn public_read_policy_has_safe_finite_defaults() {
1496 let max_rows = NonZeroU32::new(50).expect("test max rows is non-zero");
1497 let max_bytes = NonZeroU32::new(32_768).expect("test max bytes is non-zero");
1498 let policy = QueryAdmissionPolicy::public_read(max_rows, max_bytes);
1499
1500 assert_eq!(policy.lane(), QueryAdmissionLane::PublicRead);
1501 assert!(policy.require_limit());
1502 assert!(policy.require_index());
1503 assert!(policy.reject_non_zero_offset());
1504 assert!(!policy.allow_full_scan());
1505 assert!(!policy.allow_materialized_sort());
1506 assert_eq!(policy.max_returned_rows(), Some(max_rows));
1507 assert_eq!(policy.max_response_bytes(), Some(max_bytes));
1508 assert!(policy.public_caps_are_finite());
1509 assert!(!policy.grouped().has_hard_limits());
1510 }
1511
1512 #[test]
1513 fn admin_policy_is_broader_but_still_budgeted() {
1514 let max_rows = NonZeroU32::new(100).expect("test max rows is non-zero");
1515 let max_scanned = NonZeroU64::new(1_000).expect("test scan cap is non-zero");
1516 let max_bytes = NonZeroU32::new(65_536).expect("test max bytes is non-zero");
1517 let policy = QueryAdmissionPolicy::admin_ad_hoc(max_rows, max_scanned, max_bytes);
1518
1519 assert_eq!(policy.lane(), QueryAdmissionLane::AdminAdHoc);
1520 assert!(!policy.require_limit());
1521 assert!(!policy.require_index());
1522 assert!(policy.allow_full_scan());
1523 assert!(policy.allow_materialized_sort());
1524 assert_eq!(policy.max_scanned_rows(), Some(max_scanned));
1525 assert_eq!(policy.max_materialized_rows(), Some(max_rows));
1526 }
1527
1528 #[test]
1529 fn diagnostic_explain_lane_does_not_execute_rows() {
1530 let policy = QueryAdmissionPolicy::diagnostic_explain();
1531
1532 assert_eq!(policy.lane().as_str(), "diagnostic_explain");
1533 assert!(!policy.lane().executes_rows());
1534 }
1535
1536 #[test]
1537 fn grouped_policy_requires_group_and_memory_budgets() {
1538 let max_groups = NonZeroU32::new(8).expect("test group cap is non-zero");
1539 let max_bytes = NonZeroU32::new(4096).expect("test byte cap is non-zero");
1540 let policy = GroupedAdmissionPolicy::bounded(max_groups, max_bytes, None);
1541
1542 assert!(policy.has_hard_limits());
1543 assert_eq!(policy.max_groups(), Some(max_groups));
1544 assert_eq!(policy.max_group_bytes(), Some(max_bytes));
1545 }
1546
1547 #[test]
1548 fn only_proven_or_enforced_bounds_admit_public_reads() {
1549 assert!(QueryBoundKind::Exact.admits_public_read());
1550 assert!(QueryBoundKind::ConservativeUpperBound.admits_public_read());
1551 assert!(QueryBoundKind::EnforcedRuntimeCap.admits_public_read());
1552 assert!(!QueryBoundKind::EstimateOnly.admits_public_read());
1553 assert!(!QueryBoundKind::Unavailable.admits_public_read());
1554 }
1555
1556 #[test]
1557 fn access_kind_classifies_secondary_indexes_and_full_scans() {
1558 assert!(QueryAdmissionAccessKind::IndexPrefix.is_secondary_index());
1559 assert!(QueryAdmissionAccessKind::FullScan.is_full_scan());
1560 assert!(!QueryAdmissionAccessKind::ByKey.is_secondary_index());
1561 }
1562
1563 #[test]
1564 fn rejection_maps_to_stable_diagnostic() {
1565 let rejection = QueryAdmissionRejection::PublicQueryRequiresLimit;
1566 let diagnostic = rejection.diagnostic();
1567
1568 assert_eq!(
1569 rejection.error_code(),
1570 icydb_diagnostic_code::ErrorCode::QUERY_READ_PUBLIC_REQUIRES_LIMIT
1571 );
1572 assert_eq!(
1573 diagnostic.code(),
1574 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission
1575 );
1576 }
1577
1578 #[test]
1579 fn summaries_keep_decision_and_rejection_aligned() {
1580 let admitted = QueryAdmissionSummary::admitted(
1581 QueryAdmissionLane::PublicRead,
1582 QueryAdmissionAccessKind::ByKey,
1583 );
1584 let rejected = QueryAdmissionSummary::rejected(
1585 QueryAdmissionLane::PublicRead,
1586 QueryAdmissionAccessKind::FullScan,
1587 QueryAdmissionRejection::UnboundedFullScanRejected,
1588 );
1589
1590 assert_eq!(admitted.decision(), QueryAdmissionDecision::Admitted);
1591 assert_eq!(admitted.rejection(), None);
1592 assert_eq!(rejected.decision(), QueryAdmissionDecision::Rejected);
1593 assert_eq!(
1594 rejected.rejection(),
1595 Some(QueryAdmissionRejection::UnboundedFullScanRejected)
1596 );
1597 }
1598
1599 #[test]
1600 fn admission_summary_renders_stable_verbose_explain_block() {
1601 let summary = QueryAdmissionSummary::rejected(
1602 QueryAdmissionLane::PublicRead,
1603 QueryAdmissionAccessKind::FullScan,
1604 QueryAdmissionRejection::UnboundedFullScanRejected,
1605 );
1606
1607 let rendered = summary.render_text_block();
1608
1609 assert!(
1610 rendered.starts_with("admission:\n lane=public_read\n decision=rejected"),
1611 "admission block should start with stable lane and decision fields: {rendered}",
1612 );
1613 assert!(
1614 rendered.contains("\n reason=unbounded_full_scan_rejected"),
1615 "admission block should include a stable rejection reason: {rendered}",
1616 );
1617 assert!(
1618 rendered.contains("\n selected_access=full_scan"),
1619 "admission block should include the selected access class: {rendered}",
1620 );
1621 assert!(
1622 rendered.contains("\n grouped=false"),
1623 "admission block should include grouped classification: {rendered}",
1624 );
1625 }
1626
1627 #[test]
1628 fn plan_summary_classifies_full_scan_without_overclaiming_bounds() {
1629 let plan = AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1630
1631 let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1632
1633 assert_eq!(summary.plan_shape(), QueryAdmissionPlanShape::ScalarRead);
1634 assert_eq!(
1635 summary.selected_access(),
1636 QueryAdmissionAccessKind::FullScan
1637 );
1638 assert_eq!(summary.selected_index(), None);
1639 assert_eq!(summary.limit(), None);
1640 assert_eq!(summary.offset(), Some(0));
1641 assert_eq!(summary.scan_bound(), None);
1642 assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Unavailable);
1643 assert_eq!(summary.returned_row_bound(), None);
1644 assert_eq!(
1645 summary.returned_row_bound_kind(),
1646 QueryBoundKind::Unavailable
1647 );
1648 assert_eq!(
1649 summary.residual_filter(),
1650 QueryAdmissionResidualFilter::Absent
1651 );
1652 assert_eq!(summary.ordering(), QueryAdmissionOrdering::None);
1653 }
1654
1655 #[test]
1656 fn plan_summary_uses_point_lookup_and_limit_as_proven_bounds() {
1657 let mut plan =
1658 AccessPlannedQuery::new(AccessPath::ByKey(Value::Nat64(7)), MissingRowPolicy::Ignore);
1659 plan.scalar_plan_mut().page = Some(PageSpec {
1660 limit: Some(5),
1661 offset: 2,
1662 });
1663
1664 let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1665
1666 assert_eq!(summary.selected_access(), QueryAdmissionAccessKind::ByKey);
1667 assert_eq!(summary.limit(), Some(5));
1668 assert_eq!(summary.offset(), Some(2));
1669 assert_eq!(summary.scan_bound(), Some(1));
1670 assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Exact);
1671 assert_eq!(summary.returned_row_bound(), Some(5));
1672 assert_eq!(
1673 summary.returned_row_bound_kind(),
1674 QueryBoundKind::EnforcedRuntimeCap
1675 );
1676 }
1677
1678 #[test]
1679 fn plan_summary_preserves_selected_index_identity() {
1680 let plan = AccessPlannedQuery::new(
1681 AccessPath::IndexPrefix {
1682 index: SemanticIndexAccessContract::model_only_from_generated_index(
1683 ADMISSION_INDEX,
1684 ),
1685 values: vec![Value::Text("alpha".to_string())],
1686 },
1687 MissingRowPolicy::Ignore,
1688 );
1689
1690 let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1691
1692 assert_eq!(
1693 summary.selected_access(),
1694 QueryAdmissionAccessKind::IndexPrefix
1695 );
1696 assert_eq!(summary.selected_index(), Some("admission::tag"));
1697 assert_eq!(summary.scan_bound(), None);
1698 assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Unavailable);
1699 }
1700
1701 #[test]
1702 fn plan_summary_classifies_residual_and_requested_ordering() {
1703 let mut plan =
1704 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1705 plan.scalar_plan_mut().predicate = Some(Predicate::eq(
1706 "tag".to_string(),
1707 Value::Text("alpha".to_string()),
1708 ));
1709 plan.scalar_plan_mut().order = Some(OrderSpec {
1710 fields: vec![OrderTerm::field("tag", OrderDirection::Asc)],
1711 });
1712
1713 let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::AdminAdHoc, &plan);
1714
1715 assert_eq!(
1716 summary.residual_filter(),
1717 QueryAdmissionResidualFilter::Predicate
1718 );
1719 assert_eq!(summary.ordering(), QueryAdmissionOrdering::Requested);
1720 assert!(!summary.materialization().materialized_sort());
1721 assert_eq!(summary.materialization().materialized_rows(), None);
1722 assert_eq!(
1723 summary.materialization().row_bound_kind(),
1724 QueryBoundKind::Unavailable
1725 );
1726 }
1727
1728 #[test]
1729 fn plan_summary_carries_grouped_execution_budgets() {
1730 let grouped =
1731 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
1732 .into_grouped_with_having_expr(
1733 GroupSpec {
1734 group_fields: vec![FieldSlot::from_test_slot(0, "tag")],
1735 aggregates: vec![GroupAggregateSpec {
1736 kind: AggregateKind::Count,
1737 input_expr: None,
1738 filter_expr: None,
1739 distinct: false,
1740 }],
1741 execution: GroupedExecutionConfig::with_hard_limits(12, 4096),
1742 },
1743 Some(Expr::Field(FieldId::new("tag"))),
1744 );
1745
1746 let summary =
1747 QueryAdmissionSummary::from_plan(QueryAdmissionLane::DiagnosticExplain, &grouped);
1748 let grouped = summary
1749 .grouped()
1750 .expect("summary should include grouped facts");
1751
1752 assert_eq!(
1753 summary.plan_shape(),
1754 QueryAdmissionPlanShape::GroupedAggregate
1755 );
1756 assert_eq!(grouped.group_field_count(), 1);
1757 assert_eq!(grouped.aggregate_count(), 1);
1758 assert_eq!(grouped.max_groups(), 12);
1759 assert_eq!(grouped.max_group_bytes(), 4096);
1760 assert!(grouped.has_having_filter());
1761 assert_eq!(summary.returned_row_bound(), Some(12));
1762 assert_eq!(
1763 summary.returned_row_bound_kind(),
1764 QueryBoundKind::ConservativeUpperBound
1765 );
1766 }
1767
1768 #[test]
1769 fn plan_summary_reads_delete_window_without_executing_it() {
1770 let mut plan =
1771 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1772 plan.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
1773 plan.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec {
1774 limit: Some(3),
1775 offset: 1,
1776 });
1777
1778 let summary =
1779 QueryAdmissionSummary::from_plan(QueryAdmissionLane::DiagnosticExplain, &plan);
1780
1781 assert_eq!(summary.plan_shape(), QueryAdmissionPlanShape::Delete);
1782 assert_eq!(summary.limit(), Some(3));
1783 assert_eq!(summary.offset(), Some(1));
1784 assert_eq!(summary.returned_row_bound(), Some(3));
1785 }
1786
1787 #[test]
1788 fn public_read_evaluation_rejects_missing_limit_before_access_shape() {
1789 let policy = public_read_policy();
1790 let summary = summary_for_index_prefix(None, 0);
1791
1792 let evaluated = policy.evaluate(summary);
1793
1794 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1795 assert_eq!(
1796 evaluated.rejection(),
1797 Some(QueryAdmissionRejection::PublicQueryRequiresLimit)
1798 );
1799 }
1800
1801 #[test]
1802 fn public_read_evaluation_rejects_full_scan_even_with_limit() {
1803 let policy = public_read_policy();
1804 let summary = summary_for_path(AccessPath::<Value>::FullScan, Some(5), 0);
1805
1806 let evaluated = policy.evaluate(summary);
1807
1808 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1809 assert_eq!(
1810 evaluated.rejection(),
1811 Some(QueryAdmissionRejection::UnboundedFullScanRejected)
1812 );
1813 }
1814
1815 #[test]
1816 fn public_read_evaluation_admits_indexed_bounded_scalar_read() {
1817 let policy = public_read_policy();
1818 let summary = summary_for_index_prefix(Some(5), 0);
1819
1820 let evaluated = policy.evaluate(summary);
1821
1822 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Admitted);
1823 assert_eq!(evaluated.rejection(), None);
1824 }
1825
1826 #[test]
1827 fn public_read_evaluation_admits_exact_primary_key_read() {
1828 let policy = public_read_policy();
1829 let summary = summary_for_path(
1830 AccessPath::ByKey(Value::Text("primary".to_string())),
1831 Some(1),
1832 0,
1833 );
1834
1835 let evaluated = policy.evaluate(summary);
1836
1837 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Admitted);
1838 assert_eq!(evaluated.scan_bound(), Some(1));
1839 }
1840
1841 #[test]
1842 fn public_read_evaluation_rejects_non_zero_offset() {
1843 let policy = public_read_policy();
1844 let summary = summary_for_index_prefix(Some(5), 1);
1845
1846 let evaluated = policy.evaluate(summary);
1847
1848 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1849 assert_eq!(
1850 evaluated.rejection(),
1851 Some(QueryAdmissionRejection::PublicQueryOffsetRejected)
1852 );
1853 }
1854
1855 #[test]
1856 fn public_read_evaluation_rejects_returned_row_cap_overflow() {
1857 let policy = public_read_policy();
1858 let summary = summary_for_index_prefix(Some(51), 0);
1859
1860 let evaluated = policy.evaluate(summary);
1861
1862 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1863 assert_eq!(
1864 evaluated.rejection(),
1865 Some(QueryAdmissionRejection::ReturnedRowBoundExceedsPolicy)
1866 );
1867 }
1868
1869 #[test]
1870 fn public_read_evaluation_rejects_unresolved_order_materialized_sort() {
1871 let policy = public_read_policy();
1872 let summary = summary_for_index_prefix(Some(5), 0);
1873 let returned_row_bound = summary.returned_row_bound();
1874 let returned_row_bound_kind = summary.returned_row_bound_kind();
1875 let summary = summary.with_materialization(QueryMaterializationSummary::sort(
1876 returned_row_bound,
1877 returned_row_bound_kind,
1878 ));
1879
1880 let evaluated = policy.evaluate(summary);
1881
1882 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1883 assert_eq!(
1884 evaluated.rejection(),
1885 Some(QueryAdmissionRejection::SortRequiresMaterialization)
1886 );
1887 }
1888
1889 #[test]
1890 fn diagnostic_explain_policy_rejects_row_execution() {
1891 let policy = QueryAdmissionPolicy::diagnostic_explain();
1892 let summary = summary_for_index_prefix(Some(5), 0);
1893
1894 let evaluated = policy.evaluate(summary);
1895
1896 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1897 assert_eq!(
1898 evaluated.rejection(),
1899 Some(QueryAdmissionRejection::DiagnosticLaneDoesNotExecute)
1900 );
1901 }
1902
1903 fn public_read_policy() -> QueryAdmissionPolicy {
1904 QueryAdmissionPolicy::public_read(
1905 NonZeroU32::new(50).expect("test public row cap is non-zero"),
1906 NonZeroU32::new(32_768).expect("test public byte cap is non-zero"),
1907 )
1908 }
1909
1910 fn summary_for_index_prefix(limit: Option<u32>, offset: u32) -> QueryAdmissionSummary {
1911 summary_for_path(
1912 AccessPath::IndexPrefix {
1913 index: SemanticIndexAccessContract::model_only_from_generated_index(
1914 ADMISSION_INDEX,
1915 ),
1916 values: vec![Value::Text("alpha".to_string())],
1917 },
1918 limit,
1919 offset,
1920 )
1921 }
1922
1923 fn summary_for_path(
1924 path: AccessPath<Value>,
1925 limit: Option<u32>,
1926 offset: u32,
1927 ) -> QueryAdmissionSummary {
1928 let mut plan = AccessPlannedQuery::new(path, MissingRowPolicy::Ignore);
1929 plan.scalar_plan_mut().page = Some(PageSpec { limit, offset });
1930
1931 QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan)
1932 }
1933}