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
976 Self {
977 lane,
978 decision: QueryAdmissionDecision::Admitted,
979 plan_shape: plan_shape(plan),
980 selected_access: access.kind,
981 selected_index: access.selected_index,
982 limit,
983 offset: Some(offset),
984 scan_bound: access.exact_scan_bound,
985 scan_bound_kind,
986 returned_row_bound,
987 returned_row_bound_kind,
988 response_byte_bound: None,
989 response_byte_bound_kind: QueryBoundKind::Unavailable,
990 residual_filter: admission_residual_filter(plan.residual_filter_shape()),
991 ordering: admission_ordering(plan),
992 grouped,
993 materialization: QueryMaterializationSummary::none(),
994 rejection: None,
995 }
996 }
997
998 const fn admit(mut self) -> Self {
999 self.decision = QueryAdmissionDecision::Admitted;
1000 self.rejection = None;
1001 self
1002 }
1003
1004 const fn reject(mut self, rejection: QueryAdmissionRejection) -> Self {
1005 self.decision = QueryAdmissionDecision::Rejected;
1006 self.rejection = Some(rejection);
1007 self
1008 }
1009
1010 #[must_use]
1012 pub const fn lane(&self) -> QueryAdmissionLane {
1013 self.lane
1014 }
1015
1016 #[must_use]
1018 pub const fn decision(&self) -> QueryAdmissionDecision {
1019 self.decision
1020 }
1021
1022 #[must_use]
1024 pub const fn plan_shape(&self) -> QueryAdmissionPlanShape {
1025 self.plan_shape
1026 }
1027
1028 #[must_use]
1030 pub const fn selected_access(&self) -> QueryAdmissionAccessKind {
1031 self.selected_access
1032 }
1033
1034 #[must_use]
1036 pub fn selected_index(&self) -> Option<&str> {
1037 self.selected_index.as_deref()
1038 }
1039
1040 #[must_use]
1042 pub const fn limit(&self) -> Option<u32> {
1043 self.limit
1044 }
1045
1046 #[must_use]
1048 pub const fn offset(&self) -> Option<u32> {
1049 self.offset
1050 }
1051
1052 #[must_use]
1054 pub const fn scan_bound(&self) -> Option<u64> {
1055 self.scan_bound
1056 }
1057
1058 #[must_use]
1060 pub const fn scan_bound_kind(&self) -> QueryBoundKind {
1061 self.scan_bound_kind
1062 }
1063
1064 #[must_use]
1066 pub const fn returned_row_bound(&self) -> Option<u32> {
1067 self.returned_row_bound
1068 }
1069
1070 #[must_use]
1072 pub const fn returned_row_bound_kind(&self) -> QueryBoundKind {
1073 self.returned_row_bound_kind
1074 }
1075
1076 #[must_use]
1078 pub const fn response_byte_bound(&self) -> Option<u32> {
1079 self.response_byte_bound
1080 }
1081
1082 #[must_use]
1084 pub const fn response_byte_bound_kind(&self) -> QueryBoundKind {
1085 self.response_byte_bound_kind
1086 }
1087
1088 #[must_use]
1090 pub const fn residual_filter(&self) -> QueryAdmissionResidualFilter {
1091 self.residual_filter
1092 }
1093
1094 #[must_use]
1096 pub const fn ordering(&self) -> QueryAdmissionOrdering {
1097 self.ordering
1098 }
1099
1100 #[must_use]
1102 pub const fn grouped(&self) -> Option<QueryAdmissionGroupedSummary> {
1103 self.grouped
1104 }
1105
1106 #[must_use]
1108 pub const fn materialization(&self) -> QueryMaterializationSummary {
1109 self.materialization
1110 }
1111
1112 #[must_use]
1114 pub const fn rejection(&self) -> Option<QueryAdmissionRejection> {
1115 self.rejection
1116 }
1117
1118 #[must_use]
1120 pub(in crate::db) fn render_text_block(&self) -> String {
1121 let mut out = String::from("admission:");
1122 push_text_field(&mut out, "lane", self.lane().as_str());
1123 push_text_field(&mut out, "decision", self.decision().as_str());
1124 push_text_field(
1125 &mut out,
1126 "reason",
1127 self.rejection()
1128 .map_or("none", QueryAdmissionRejection::as_str),
1129 );
1130 push_text_field(&mut out, "plan_shape", self.plan_shape().as_str());
1131 push_text_field(&mut out, "selected_access", self.selected_access().as_str());
1132 push_text_field(
1133 &mut out,
1134 "selected_index",
1135 self.selected_index().unwrap_or("none"),
1136 );
1137 push_text_option_u32(&mut out, "limit", self.limit());
1138 push_text_option_u32(&mut out, "offset", self.offset());
1139 push_text_option_u64(&mut out, "scan_bound", self.scan_bound());
1140 push_text_field(&mut out, "scan_bound_kind", self.scan_bound_kind().as_str());
1141 push_text_option_u32(&mut out, "returned_row_bound", self.returned_row_bound());
1142 push_text_field(
1143 &mut out,
1144 "returned_row_bound_kind",
1145 self.returned_row_bound_kind().as_str(),
1146 );
1147 push_text_option_u32(&mut out, "response_byte_bound", self.response_byte_bound());
1148 push_text_field(
1149 &mut out,
1150 "response_byte_bound_kind",
1151 self.response_byte_bound_kind().as_str(),
1152 );
1153 push_text_field(&mut out, "residual_filter", self.residual_filter().as_str());
1154 push_text_field(&mut out, "ordering", self.ordering().as_str());
1155 push_text_bool(
1156 &mut out,
1157 "materialized_sort",
1158 self.materialization().materialized_sort(),
1159 );
1160 push_text_option_u32(
1161 &mut out,
1162 "materialized_rows",
1163 self.materialization().materialized_rows(),
1164 );
1165 push_text_field(
1166 &mut out,
1167 "materialized_row_bound_kind",
1168 self.materialization().row_bound_kind().as_str(),
1169 );
1170
1171 if let Some(grouped) = self.grouped() {
1172 push_text_bool(&mut out, "grouped", true);
1173 push_text_u64(
1174 &mut out,
1175 "group_field_count",
1176 u64::from(grouped.group_field_count()),
1177 );
1178 push_text_u64(
1179 &mut out,
1180 "aggregate_count",
1181 u64::from(grouped.aggregate_count()),
1182 );
1183 push_text_u64(&mut out, "max_groups", grouped.max_groups());
1184 push_text_u64(&mut out, "max_group_bytes", grouped.max_group_bytes());
1185 push_text_bool(&mut out, "having_filter", grouped.has_having_filter());
1186 } else {
1187 push_text_bool(&mut out, "grouped", false);
1188 }
1189
1190 out
1191 }
1192}
1193
1194fn push_text_field(out: &mut String, key: &str, value: &str) {
1195 out.push('\n');
1196 out.push_str(" ");
1197 out.push_str(key);
1198 out.push('=');
1199 out.push_str(value);
1200}
1201
1202fn push_text_bool(out: &mut String, key: &str, value: bool) {
1203 push_text_field(out, key, if value { "true" } else { "false" });
1204}
1205
1206fn push_text_u64(out: &mut String, key: &str, value: u64) {
1207 out.push('\n');
1208 out.push_str(" ");
1209 out.push_str(key);
1210 out.push('=');
1211 let _ = write!(out, "{value}");
1212}
1213
1214fn push_text_option_u32(out: &mut String, key: &str, value: Option<u32>) {
1215 match value {
1216 Some(value) => push_text_u64(out, key, u64::from(value)),
1217 None => push_text_field(out, key, "none"),
1218 }
1219}
1220
1221fn push_text_option_u64(out: &mut String, key: &str, value: Option<u64>) {
1222 match value {
1223 Some(value) => push_text_u64(out, key, value),
1224 None => push_text_field(out, key, "none"),
1225 }
1226}
1227
1228const _: fn(QueryAdmissionLane, &AccessPlannedQuery) -> QueryAdmissionSummary =
1230 QueryAdmissionSummary::from_plan;
1231
1232const fn access_satisfies_index_requirement(
1233 kind: QueryAdmissionAccessKind,
1234 scan_bound: Option<u64>,
1235) -> bool {
1236 kind.is_secondary_index()
1237 || matches!(
1238 (kind, scan_bound),
1239 (
1240 QueryAdmissionAccessKind::ByKey | QueryAdmissionAccessKind::ByKeys,
1241 Some(_)
1242 )
1243 )
1244}
1245
1246struct AdmissionAccessProjection;
1247
1248#[derive(Clone, Debug, Eq, PartialEq)]
1249struct AdmissionAccessSummary {
1250 kind: QueryAdmissionAccessKind,
1251 selected_index: Option<String>,
1252 exact_scan_bound: Option<u64>,
1253}
1254
1255impl AdmissionAccessSummary {
1256 const fn non_index(kind: QueryAdmissionAccessKind, exact_scan_bound: Option<u64>) -> Self {
1257 Self {
1258 kind,
1259 selected_index: None,
1260 exact_scan_bound,
1261 }
1262 }
1263
1264 fn secondary_index(kind: QueryAdmissionAccessKind, index_name: &str) -> Self {
1265 Self {
1266 kind,
1267 selected_index: Some(index_name.to_string()),
1268 exact_scan_bound: None,
1269 }
1270 }
1271
1272 const fn composite(kind: QueryAdmissionAccessKind) -> Self {
1273 Self {
1274 kind,
1275 selected_index: None,
1276 exact_scan_bound: None,
1277 }
1278 }
1279
1280 const fn scan_bound_kind(&self) -> QueryBoundKind {
1281 if self.exact_scan_bound.is_some() {
1282 QueryBoundKind::Exact
1283 } else {
1284 QueryBoundKind::Unavailable
1285 }
1286 }
1287}
1288
1289impl AccessPlanProjection<Value> for AdmissionAccessProjection {
1290 type Output = AdmissionAccessSummary;
1291
1292 fn by_key(&mut self, _key: &Value) -> Self::Output {
1293 AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::ByKey, Some(1))
1294 }
1295
1296 fn by_keys(&mut self, keys: &[Value]) -> Self::Output {
1297 AdmissionAccessSummary::non_index(
1298 QueryAdmissionAccessKind::ByKeys,
1299 Some(u64::try_from(keys.len()).unwrap_or(u64::MAX)),
1300 )
1301 }
1302
1303 fn key_range(&mut self, _start: &Value, _end: &Value) -> Self::Output {
1304 AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::KeyRange, None)
1305 }
1306
1307 fn index_prefix(
1308 &mut self,
1309 index_name: &str,
1310 _index_fields: &[String],
1311 _prefix_len: usize,
1312 _values: &[Value],
1313 ) -> Self::Output {
1314 AdmissionAccessSummary::secondary_index(QueryAdmissionAccessKind::IndexPrefix, index_name)
1315 }
1316
1317 fn index_multi_lookup(
1318 &mut self,
1319 index_name: &str,
1320 _index_fields: &[String],
1321 _values: &[Value],
1322 ) -> Self::Output {
1323 AdmissionAccessSummary::secondary_index(
1324 QueryAdmissionAccessKind::IndexMultiLookup,
1325 index_name,
1326 )
1327 }
1328
1329 fn index_branch_set(
1330 &mut self,
1331 index_name: &str,
1332 _index_fields: &[String],
1333 _fixed_values: &[Value],
1334 _branch_values: &[Value],
1335 _ordered_suffix: IndexBranchSetOrderedSuffix,
1336 ) -> Self::Output {
1337 AdmissionAccessSummary::secondary_index(
1338 QueryAdmissionAccessKind::IndexBranchSet,
1339 index_name,
1340 )
1341 }
1342
1343 fn index_range(
1344 &mut self,
1345 index_name: &str,
1346 _index_fields: &[String],
1347 _prefix_len: usize,
1348 _prefix: &[Value],
1349 _lower: &Bound<Value>,
1350 _upper: &Bound<Value>,
1351 ) -> Self::Output {
1352 AdmissionAccessSummary::secondary_index(QueryAdmissionAccessKind::IndexRange, index_name)
1353 }
1354
1355 fn full_scan(&mut self) -> Self::Output {
1356 AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::FullScan, None)
1357 }
1358
1359 fn union(&mut self, _children: Vec<Self::Output>) -> Self::Output {
1360 AdmissionAccessSummary::composite(QueryAdmissionAccessKind::Union)
1361 }
1362
1363 fn intersection(&mut self, _children: Vec<Self::Output>) -> Self::Output {
1364 AdmissionAccessSummary::composite(QueryAdmissionAccessKind::Intersection)
1365 }
1366}
1367
1368fn summarize_access_plan(plan: &AccessPlannedQuery) -> AdmissionAccessSummary {
1369 project_access_plan(&plan.access, &mut AdmissionAccessProjection)
1370}
1371
1372fn summarize_grouped_plan(plan: &GroupPlan) -> QueryAdmissionGroupedSummary {
1373 QueryAdmissionGroupedSummary::new(
1374 u32::try_from(plan.group.group_fields.len()).unwrap_or(u32::MAX),
1375 u32::try_from(plan.group.aggregates.len()).unwrap_or(u32::MAX),
1376 plan.group.execution.max_groups(),
1377 plan.group.execution.max_group_bytes(),
1378 plan.having_expr.is_some(),
1379 )
1380}
1381
1382const fn scalar_limit_and_offset(plan: &ScalarPlan) -> (Option<u32>, u32) {
1383 match plan.mode {
1384 QueryMode::Load(load) => match &plan.page {
1385 Some(page) => (page.limit, page.offset),
1386 None => (load.limit(), load.offset()),
1387 },
1388 QueryMode::Delete(delete) => match plan.delete_limit {
1389 Some(delete_limit) => (delete_limit.limit, delete_limit.offset),
1390 None => (delete.limit(), delete.offset()),
1391 },
1392 }
1393}
1394
1395fn returned_row_bound_from_plan(
1396 limit: Option<u32>,
1397 grouped: Option<QueryAdmissionGroupedSummary>,
1398) -> (Option<u32>, QueryBoundKind) {
1399 if let Some(limit) = limit {
1400 return (Some(limit), QueryBoundKind::EnforcedRuntimeCap);
1401 }
1402
1403 let Some(grouped) = grouped else {
1404 return (None, QueryBoundKind::Unavailable);
1405 };
1406 if grouped.max_groups() == u64::MAX {
1407 return (None, QueryBoundKind::Unavailable);
1408 }
1409
1410 (
1411 Some(u32::try_from(grouped.max_groups()).unwrap_or(u32::MAX)),
1412 QueryBoundKind::ConservativeUpperBound,
1413 )
1414}
1415
1416const fn admission_residual_filter(shape: ResidualFilterShape) -> QueryAdmissionResidualFilter {
1417 match shape {
1418 ResidualFilterShape::Absent => QueryAdmissionResidualFilter::Absent,
1419 ResidualFilterShape::Predicate => QueryAdmissionResidualFilter::Predicate,
1420 ResidualFilterShape::Expression => QueryAdmissionResidualFilter::Expression,
1421 ResidualFilterShape::ExpressionAndPredicate => {
1422 QueryAdmissionResidualFilter::ExpressionAndPredicate
1423 }
1424 }
1425}
1426
1427fn admission_ordering(plan: &AccessPlannedQuery) -> QueryAdmissionOrdering {
1428 if plan.scalar_plan().order.is_none() {
1429 return QueryAdmissionOrdering::None;
1430 }
1431
1432 if plan.resolved_order().is_some() {
1433 QueryAdmissionOrdering::Resolved
1434 } else {
1435 QueryAdmissionOrdering::Requested
1436 }
1437}
1438
1439const fn plan_shape(plan: &AccessPlannedQuery) -> QueryAdmissionPlanShape {
1440 if plan.grouped_plan().is_some() {
1441 return QueryAdmissionPlanShape::GroupedAggregate;
1442 }
1443
1444 match plan.scalar_plan().mode {
1445 QueryMode::Load(_) => QueryAdmissionPlanShape::ScalarRead,
1446 QueryMode::Delete(_) => QueryAdmissionPlanShape::Delete,
1447 }
1448}
1449
1450#[cfg(test)]
1451mod tests {
1452 use std::num::{NonZeroU32, NonZeroU64};
1453
1454 use crate::{
1455 db::{
1456 access::{AccessPath, SemanticIndexAccessContract},
1457 predicate::{MissingRowPolicy, Predicate},
1458 query::plan::{
1459 AccessPlannedQuery, AggregateKind, DeleteLimitSpec, DeleteSpec, FieldSlot,
1460 GroupAggregateSpec, GroupSpec, GroupedExecutionConfig, OrderDirection, OrderSpec,
1461 OrderTerm, PageSpec, QueryMode,
1462 expr::{Expr, FieldId},
1463 },
1464 },
1465 model::index::IndexModel,
1466 value::Value,
1467 };
1468
1469 use super::{
1470 GroupedAdmissionPolicy, QueryAdmissionAccessKind, QueryAdmissionDecision,
1471 QueryAdmissionLane, QueryAdmissionOrdering, QueryAdmissionPlanShape, QueryAdmissionPolicy,
1472 QueryAdmissionRejection, QueryAdmissionResidualFilter, QueryAdmissionSummary,
1473 QueryBoundKind,
1474 };
1475
1476 const ADMISSION_INDEX_FIELDS: [&str; 1] = ["tag"];
1477 const ADMISSION_INDEX: IndexModel = IndexModel::generated(
1478 "admission::tag",
1479 "admission::tag_store",
1480 &ADMISSION_INDEX_FIELDS,
1481 false,
1482 );
1483
1484 #[test]
1485 fn public_read_policy_has_safe_finite_defaults() {
1486 let max_rows = NonZeroU32::new(50).expect("test max rows is non-zero");
1487 let max_bytes = NonZeroU32::new(32_768).expect("test max bytes is non-zero");
1488 let policy = QueryAdmissionPolicy::public_read(max_rows, max_bytes);
1489
1490 assert_eq!(policy.lane(), QueryAdmissionLane::PublicRead);
1491 assert!(policy.require_limit());
1492 assert!(policy.require_index());
1493 assert!(policy.reject_non_zero_offset());
1494 assert!(!policy.allow_full_scan());
1495 assert!(!policy.allow_materialized_sort());
1496 assert_eq!(policy.max_returned_rows(), Some(max_rows));
1497 assert_eq!(policy.max_response_bytes(), Some(max_bytes));
1498 assert!(policy.public_caps_are_finite());
1499 assert!(!policy.grouped().has_hard_limits());
1500 }
1501
1502 #[test]
1503 fn admin_policy_is_broader_but_still_budgeted() {
1504 let max_rows = NonZeroU32::new(100).expect("test max rows is non-zero");
1505 let max_scanned = NonZeroU64::new(1_000).expect("test scan cap is non-zero");
1506 let max_bytes = NonZeroU32::new(65_536).expect("test max bytes is non-zero");
1507 let policy = QueryAdmissionPolicy::admin_ad_hoc(max_rows, max_scanned, max_bytes);
1508
1509 assert_eq!(policy.lane(), QueryAdmissionLane::AdminAdHoc);
1510 assert!(!policy.require_limit());
1511 assert!(!policy.require_index());
1512 assert!(policy.allow_full_scan());
1513 assert!(policy.allow_materialized_sort());
1514 assert_eq!(policy.max_scanned_rows(), Some(max_scanned));
1515 assert_eq!(policy.max_materialized_rows(), Some(max_rows));
1516 }
1517
1518 #[test]
1519 fn diagnostic_explain_lane_does_not_execute_rows() {
1520 let policy = QueryAdmissionPolicy::diagnostic_explain();
1521
1522 assert_eq!(policy.lane().as_str(), "diagnostic_explain");
1523 assert!(!policy.lane().executes_rows());
1524 }
1525
1526 #[test]
1527 fn grouped_policy_requires_group_and_memory_budgets() {
1528 let max_groups = NonZeroU32::new(8).expect("test group cap is non-zero");
1529 let max_bytes = NonZeroU32::new(4096).expect("test byte cap is non-zero");
1530 let policy = GroupedAdmissionPolicy::bounded(max_groups, max_bytes, None);
1531
1532 assert!(policy.has_hard_limits());
1533 assert_eq!(policy.max_groups(), Some(max_groups));
1534 assert_eq!(policy.max_group_bytes(), Some(max_bytes));
1535 }
1536
1537 #[test]
1538 fn only_proven_or_enforced_bounds_admit_public_reads() {
1539 assert!(QueryBoundKind::Exact.admits_public_read());
1540 assert!(QueryBoundKind::ConservativeUpperBound.admits_public_read());
1541 assert!(QueryBoundKind::EnforcedRuntimeCap.admits_public_read());
1542 assert!(!QueryBoundKind::EstimateOnly.admits_public_read());
1543 assert!(!QueryBoundKind::Unavailable.admits_public_read());
1544 }
1545
1546 #[test]
1547 fn access_kind_classifies_secondary_indexes_and_full_scans() {
1548 assert!(QueryAdmissionAccessKind::IndexPrefix.is_secondary_index());
1549 assert!(QueryAdmissionAccessKind::FullScan.is_full_scan());
1550 assert!(!QueryAdmissionAccessKind::ByKey.is_secondary_index());
1551 }
1552
1553 #[test]
1554 fn rejection_maps_to_stable_diagnostic() {
1555 let rejection = QueryAdmissionRejection::PublicQueryRequiresLimit;
1556 let diagnostic = rejection.diagnostic();
1557
1558 assert_eq!(
1559 rejection.error_code(),
1560 icydb_diagnostic_code::ErrorCode::QUERY_READ_PUBLIC_REQUIRES_LIMIT
1561 );
1562 assert_eq!(
1563 diagnostic.code(),
1564 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission
1565 );
1566 }
1567
1568 #[test]
1569 fn summaries_keep_decision_and_rejection_aligned() {
1570 let admitted = QueryAdmissionSummary::admitted(
1571 QueryAdmissionLane::PublicRead,
1572 QueryAdmissionAccessKind::ByKey,
1573 );
1574 let rejected = QueryAdmissionSummary::rejected(
1575 QueryAdmissionLane::PublicRead,
1576 QueryAdmissionAccessKind::FullScan,
1577 QueryAdmissionRejection::UnboundedFullScanRejected,
1578 );
1579
1580 assert_eq!(admitted.decision(), QueryAdmissionDecision::Admitted);
1581 assert_eq!(admitted.rejection(), None);
1582 assert_eq!(rejected.decision(), QueryAdmissionDecision::Rejected);
1583 assert_eq!(
1584 rejected.rejection(),
1585 Some(QueryAdmissionRejection::UnboundedFullScanRejected)
1586 );
1587 }
1588
1589 #[test]
1590 fn admission_summary_renders_stable_verbose_explain_block() {
1591 let summary = QueryAdmissionSummary::rejected(
1592 QueryAdmissionLane::PublicRead,
1593 QueryAdmissionAccessKind::FullScan,
1594 QueryAdmissionRejection::UnboundedFullScanRejected,
1595 );
1596
1597 let rendered = summary.render_text_block();
1598
1599 assert!(
1600 rendered.starts_with("admission:\n lane=public_read\n decision=rejected"),
1601 "admission block should start with stable lane and decision fields: {rendered}",
1602 );
1603 assert!(
1604 rendered.contains("\n reason=unbounded_full_scan_rejected"),
1605 "admission block should include a stable rejection reason: {rendered}",
1606 );
1607 assert!(
1608 rendered.contains("\n selected_access=full_scan"),
1609 "admission block should include the selected access class: {rendered}",
1610 );
1611 assert!(
1612 rendered.contains("\n grouped=false"),
1613 "admission block should include grouped classification: {rendered}",
1614 );
1615 }
1616
1617 #[test]
1618 fn plan_summary_classifies_full_scan_without_overclaiming_bounds() {
1619 let plan = AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1620
1621 let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1622
1623 assert_eq!(summary.plan_shape(), QueryAdmissionPlanShape::ScalarRead);
1624 assert_eq!(
1625 summary.selected_access(),
1626 QueryAdmissionAccessKind::FullScan
1627 );
1628 assert_eq!(summary.selected_index(), None);
1629 assert_eq!(summary.limit(), None);
1630 assert_eq!(summary.offset(), Some(0));
1631 assert_eq!(summary.scan_bound(), None);
1632 assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Unavailable);
1633 assert_eq!(summary.returned_row_bound(), None);
1634 assert_eq!(
1635 summary.returned_row_bound_kind(),
1636 QueryBoundKind::Unavailable
1637 );
1638 assert_eq!(
1639 summary.residual_filter(),
1640 QueryAdmissionResidualFilter::Absent
1641 );
1642 assert_eq!(summary.ordering(), QueryAdmissionOrdering::None);
1643 }
1644
1645 #[test]
1646 fn plan_summary_uses_point_lookup_and_limit_as_proven_bounds() {
1647 let mut plan =
1648 AccessPlannedQuery::new(AccessPath::ByKey(Value::Nat64(7)), MissingRowPolicy::Ignore);
1649 plan.scalar_plan_mut().page = Some(PageSpec {
1650 limit: Some(5),
1651 offset: 2,
1652 });
1653
1654 let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1655
1656 assert_eq!(summary.selected_access(), QueryAdmissionAccessKind::ByKey);
1657 assert_eq!(summary.limit(), Some(5));
1658 assert_eq!(summary.offset(), Some(2));
1659 assert_eq!(summary.scan_bound(), Some(1));
1660 assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Exact);
1661 assert_eq!(summary.returned_row_bound(), Some(5));
1662 assert_eq!(
1663 summary.returned_row_bound_kind(),
1664 QueryBoundKind::EnforcedRuntimeCap
1665 );
1666 }
1667
1668 #[test]
1669 fn plan_summary_preserves_selected_index_identity() {
1670 let plan = AccessPlannedQuery::new(
1671 AccessPath::IndexPrefix {
1672 index: SemanticIndexAccessContract::model_only_from_generated_index(
1673 ADMISSION_INDEX,
1674 ),
1675 values: vec![Value::Text("alpha".to_string())],
1676 },
1677 MissingRowPolicy::Ignore,
1678 );
1679
1680 let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1681
1682 assert_eq!(
1683 summary.selected_access(),
1684 QueryAdmissionAccessKind::IndexPrefix
1685 );
1686 assert_eq!(summary.selected_index(), Some("admission::tag"));
1687 assert_eq!(summary.scan_bound(), None);
1688 assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Unavailable);
1689 }
1690
1691 #[test]
1692 fn plan_summary_classifies_residual_and_requested_ordering() {
1693 let mut plan =
1694 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1695 plan.scalar_plan_mut().predicate = Some(Predicate::eq(
1696 "tag".to_string(),
1697 Value::Text("alpha".to_string()),
1698 ));
1699 plan.scalar_plan_mut().order = Some(OrderSpec {
1700 fields: vec![OrderTerm::field("tag", OrderDirection::Asc)],
1701 });
1702
1703 let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::AdminAdHoc, &plan);
1704
1705 assert_eq!(
1706 summary.residual_filter(),
1707 QueryAdmissionResidualFilter::Predicate
1708 );
1709 assert_eq!(summary.ordering(), QueryAdmissionOrdering::Requested);
1710 }
1711
1712 #[test]
1713 fn plan_summary_carries_grouped_execution_budgets() {
1714 let grouped =
1715 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
1716 .into_grouped_with_having_expr(
1717 GroupSpec {
1718 group_fields: vec![FieldSlot::from_test_slot(0, "tag")],
1719 aggregates: vec![GroupAggregateSpec {
1720 kind: AggregateKind::Count,
1721 input_expr: None,
1722 filter_expr: None,
1723 distinct: false,
1724 }],
1725 execution: GroupedExecutionConfig::with_hard_limits(12, 4096),
1726 },
1727 Some(Expr::Field(FieldId::new("tag"))),
1728 );
1729
1730 let summary =
1731 QueryAdmissionSummary::from_plan(QueryAdmissionLane::DiagnosticExplain, &grouped);
1732 let grouped = summary
1733 .grouped()
1734 .expect("summary should include grouped facts");
1735
1736 assert_eq!(
1737 summary.plan_shape(),
1738 QueryAdmissionPlanShape::GroupedAggregate
1739 );
1740 assert_eq!(grouped.group_field_count(), 1);
1741 assert_eq!(grouped.aggregate_count(), 1);
1742 assert_eq!(grouped.max_groups(), 12);
1743 assert_eq!(grouped.max_group_bytes(), 4096);
1744 assert!(grouped.has_having_filter());
1745 assert_eq!(summary.returned_row_bound(), Some(12));
1746 assert_eq!(
1747 summary.returned_row_bound_kind(),
1748 QueryBoundKind::ConservativeUpperBound
1749 );
1750 }
1751
1752 #[test]
1753 fn plan_summary_reads_delete_window_without_executing_it() {
1754 let mut plan =
1755 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1756 plan.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
1757 plan.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec {
1758 limit: Some(3),
1759 offset: 1,
1760 });
1761
1762 let summary =
1763 QueryAdmissionSummary::from_plan(QueryAdmissionLane::DiagnosticExplain, &plan);
1764
1765 assert_eq!(summary.plan_shape(), QueryAdmissionPlanShape::Delete);
1766 assert_eq!(summary.limit(), Some(3));
1767 assert_eq!(summary.offset(), Some(1));
1768 assert_eq!(summary.returned_row_bound(), Some(3));
1769 }
1770
1771 #[test]
1772 fn public_read_evaluation_rejects_missing_limit_before_access_shape() {
1773 let policy = public_read_policy();
1774 let summary = summary_for_index_prefix(None, 0);
1775
1776 let evaluated = policy.evaluate(summary);
1777
1778 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1779 assert_eq!(
1780 evaluated.rejection(),
1781 Some(QueryAdmissionRejection::PublicQueryRequiresLimit)
1782 );
1783 }
1784
1785 #[test]
1786 fn public_read_evaluation_rejects_full_scan_even_with_limit() {
1787 let policy = public_read_policy();
1788 let summary = summary_for_path(AccessPath::<Value>::FullScan, Some(5), 0);
1789
1790 let evaluated = policy.evaluate(summary);
1791
1792 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1793 assert_eq!(
1794 evaluated.rejection(),
1795 Some(QueryAdmissionRejection::UnboundedFullScanRejected)
1796 );
1797 }
1798
1799 #[test]
1800 fn public_read_evaluation_admits_indexed_bounded_scalar_read() {
1801 let policy = public_read_policy();
1802 let summary = summary_for_index_prefix(Some(5), 0);
1803
1804 let evaluated = policy.evaluate(summary);
1805
1806 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Admitted);
1807 assert_eq!(evaluated.rejection(), None);
1808 }
1809
1810 #[test]
1811 fn public_read_evaluation_admits_exact_primary_key_read() {
1812 let policy = public_read_policy();
1813 let summary = summary_for_path(
1814 AccessPath::ByKey(Value::Text("primary".to_string())),
1815 Some(1),
1816 0,
1817 );
1818
1819 let evaluated = policy.evaluate(summary);
1820
1821 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Admitted);
1822 assert_eq!(evaluated.scan_bound(), Some(1));
1823 }
1824
1825 #[test]
1826 fn public_read_evaluation_rejects_non_zero_offset() {
1827 let policy = public_read_policy();
1828 let summary = summary_for_index_prefix(Some(5), 1);
1829
1830 let evaluated = policy.evaluate(summary);
1831
1832 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1833 assert_eq!(
1834 evaluated.rejection(),
1835 Some(QueryAdmissionRejection::PublicQueryOffsetRejected)
1836 );
1837 }
1838
1839 #[test]
1840 fn public_read_evaluation_rejects_returned_row_cap_overflow() {
1841 let policy = public_read_policy();
1842 let summary = summary_for_index_prefix(Some(51), 0);
1843
1844 let evaluated = policy.evaluate(summary);
1845
1846 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1847 assert_eq!(
1848 evaluated.rejection(),
1849 Some(QueryAdmissionRejection::ReturnedRowBoundExceedsPolicy)
1850 );
1851 }
1852
1853 #[test]
1854 fn diagnostic_explain_policy_rejects_row_execution() {
1855 let policy = QueryAdmissionPolicy::diagnostic_explain();
1856 let summary = summary_for_index_prefix(Some(5), 0);
1857
1858 let evaluated = policy.evaluate(summary);
1859
1860 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1861 assert_eq!(
1862 evaluated.rejection(),
1863 Some(QueryAdmissionRejection::DiagnosticLaneDoesNotExecute)
1864 );
1865 }
1866
1867 fn public_read_policy() -> QueryAdmissionPolicy {
1868 QueryAdmissionPolicy::public_read(
1869 NonZeroU32::new(50).expect("test public row cap is non-zero"),
1870 NonZeroU32::new(32_768).expect("test public byte cap is non-zero"),
1871 )
1872 }
1873
1874 fn summary_for_index_prefix(limit: Option<u32>, offset: u32) -> QueryAdmissionSummary {
1875 summary_for_path(
1876 AccessPath::IndexPrefix {
1877 index: SemanticIndexAccessContract::model_only_from_generated_index(
1878 ADMISSION_INDEX,
1879 ),
1880 values: vec![Value::Text("alpha".to_string())],
1881 },
1882 limit,
1883 offset,
1884 )
1885 }
1886
1887 fn summary_for_path(
1888 path: AccessPath<Value>,
1889 limit: Option<u32>,
1890 offset: u32,
1891 ) -> QueryAdmissionSummary {
1892 let mut plan = AccessPlannedQuery::new(path, MissingRowPolicy::Ignore);
1893 plan.scalar_plan_mut().page = Some(PageSpec { limit, offset });
1894
1895 QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan)
1896 }
1897}