1use std::num::{NonZeroU32, NonZeroU64};
7use std::ops::Bound;
8
9use crate::{
10 db::{
11 access::IndexBranchSetOrderedSuffix,
12 query::plan::{
13 AccessPlanProjection, AccessPlannedQuery, GroupPlan, QueryMode, ResidualFilterShape,
14 ScalarPlan, project_access_plan,
15 },
16 },
17 value::Value,
18};
19use icydb_diagnostic_code::{
20 Diagnostic, DiagnosticCode, DiagnosticDetail, ErrorCode, ErrorOrigin, QueryReadAdmissionCode,
21};
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum QueryAdmissionLane {
26 PublicRead,
28 AdminAdHoc,
30 DiagnosticExplain,
32 DevTest,
34}
35
36impl QueryAdmissionLane {
37 #[must_use]
39 pub const fn as_str(self) -> &'static str {
40 match self {
41 Self::PublicRead => "public_read",
42 Self::AdminAdHoc => "admin_ad_hoc",
43 Self::DiagnosticExplain => "diagnostic_explain",
44 Self::DevTest => "dev_test",
45 }
46 }
47
48 #[must_use]
50 pub const fn executes_rows(self) -> bool {
51 !matches!(self, Self::DiagnosticExplain)
52 }
53}
54
55#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum QueryBoundKind {
58 Exact,
60 ConservativeUpperBound,
62 EnforcedRuntimeCap,
64 EstimateOnly,
66 Unavailable,
68}
69
70impl QueryBoundKind {
71 #[must_use]
73 pub const fn admits_public_read(self) -> bool {
74 matches!(
75 self,
76 Self::Exact | Self::ConservativeUpperBound | Self::EnforcedRuntimeCap
77 )
78 }
79}
80
81#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub enum QueryAdmissionDecision {
84 Admitted,
86 Rejected,
88}
89
90impl QueryAdmissionDecision {
91 #[must_use]
93 pub const fn is_admitted(self) -> bool {
94 matches!(self, Self::Admitted)
95 }
96}
97
98#[derive(Clone, Copy, Debug, Eq, PartialEq)]
100pub enum QueryAdmissionAccessKind {
101 Unknown,
103 ByKey,
105 ByKeys,
107 KeyRange,
109 IndexPrefix,
111 IndexMultiLookup,
113 IndexBranchSet,
115 IndexRange,
117 FullScan,
119 Union,
121 Intersection,
123}
124
125impl QueryAdmissionAccessKind {
126 #[must_use]
128 pub const fn as_str(self) -> &'static str {
129 match self {
130 Self::Unknown => "unknown",
131 Self::ByKey => "by_key",
132 Self::ByKeys => "by_keys",
133 Self::KeyRange => "key_range",
134 Self::IndexPrefix => "index_prefix",
135 Self::IndexMultiLookup => "index_multi_lookup",
136 Self::IndexBranchSet => "index_branch_set",
137 Self::IndexRange => "index_range",
138 Self::FullScan => "full_scan",
139 Self::Union => "union",
140 Self::Intersection => "intersection",
141 }
142 }
143
144 #[must_use]
146 pub const fn is_secondary_index(self) -> bool {
147 matches!(
148 self,
149 Self::IndexPrefix | Self::IndexMultiLookup | Self::IndexBranchSet | Self::IndexRange
150 )
151 }
152
153 #[must_use]
155 pub const fn is_full_scan(self) -> bool {
156 matches!(self, Self::FullScan)
157 }
158}
159
160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
162pub enum QueryAdmissionPlanShape {
163 ScalarRead,
165 GroupedAggregate,
167 Delete,
169}
170
171impl QueryAdmissionPlanShape {
172 #[must_use]
174 pub const fn as_str(self) -> &'static str {
175 match self {
176 Self::ScalarRead => "scalar_read",
177 Self::GroupedAggregate => "grouped_aggregate",
178 Self::Delete => "delete",
179 }
180 }
181}
182
183#[derive(Clone, Copy, Debug, Eq, PartialEq)]
185pub enum QueryAdmissionResidualFilter {
186 Absent,
188 Predicate,
190 Expression,
192 ExpressionAndPredicate,
194}
195
196impl QueryAdmissionResidualFilter {
197 #[must_use]
199 pub const fn as_str(self) -> &'static str {
200 match self {
201 Self::Absent => "none",
202 Self::Predicate => "predicate",
203 Self::Expression => "expression",
204 Self::ExpressionAndPredicate => "expression_and_predicate",
205 }
206 }
207
208 #[must_use]
210 pub const fn is_absent(self) -> bool {
211 matches!(self, Self::Absent)
212 }
213}
214
215#[derive(Clone, Copy, Debug, Eq, PartialEq)]
217pub enum QueryAdmissionOrdering {
218 None,
220 Requested,
222 Resolved,
224}
225
226impl QueryAdmissionOrdering {
227 #[must_use]
229 pub const fn as_str(self) -> &'static str {
230 match self {
231 Self::None => "none",
232 Self::Requested => "requested",
233 Self::Resolved => "resolved",
234 }
235 }
236}
237
238#[derive(Clone, Copy, Debug, Eq, PartialEq)]
240pub struct QueryAdmissionGroupedSummary {
241 group_field_count: u32,
242 aggregate_count: u32,
243 max_groups: u64,
244 max_group_bytes: u64,
245 having_filter: bool,
246}
247
248impl QueryAdmissionGroupedSummary {
249 #[must_use]
251 pub const fn new(
252 group_field_count: u32,
253 aggregate_count: u32,
254 max_groups: u64,
255 max_group_bytes: u64,
256 having_filter: bool,
257 ) -> Self {
258 Self {
259 group_field_count,
260 aggregate_count,
261 max_groups,
262 max_group_bytes,
263 having_filter,
264 }
265 }
266
267 #[must_use]
269 pub const fn group_field_count(self) -> u32 {
270 self.group_field_count
271 }
272
273 #[must_use]
275 pub const fn aggregate_count(self) -> u32 {
276 self.aggregate_count
277 }
278
279 #[must_use]
281 pub const fn max_groups(self) -> u64 {
282 self.max_groups
283 }
284
285 #[must_use]
287 pub const fn max_group_bytes(self) -> u64 {
288 self.max_group_bytes
289 }
290
291 #[must_use]
293 pub const fn has_having_filter(self) -> bool {
294 self.having_filter
295 }
296}
297
298#[derive(Clone, Copy, Debug, Eq, PartialEq)]
300pub struct GroupedAdmissionPolicy {
301 groups: Option<NonZeroU32>,
302 group_bytes: Option<NonZeroU32>,
303 distinct_entries: Option<NonZeroU32>,
304}
305
306impl GroupedAdmissionPolicy {
307 #[must_use]
309 pub const fn disabled() -> Self {
310 Self {
311 groups: None,
312 group_bytes: None,
313 distinct_entries: None,
314 }
315 }
316
317 #[must_use]
319 pub const fn bounded(
320 max_groups: NonZeroU32,
321 max_group_bytes: NonZeroU32,
322 max_distinct_entries: Option<NonZeroU32>,
323 ) -> Self {
324 Self {
325 groups: Some(max_groups),
326 group_bytes: Some(max_group_bytes),
327 distinct_entries: max_distinct_entries,
328 }
329 }
330
331 #[must_use]
333 pub const fn max_groups(&self) -> Option<NonZeroU32> {
334 self.groups
335 }
336
337 #[must_use]
339 pub const fn max_group_bytes(&self) -> Option<NonZeroU32> {
340 self.group_bytes
341 }
342
343 #[must_use]
345 pub const fn max_distinct_entries(&self) -> Option<NonZeroU32> {
346 self.distinct_entries
347 }
348
349 #[must_use]
351 pub const fn has_hard_limits(&self) -> bool {
352 self.groups.is_some() && self.group_bytes.is_some()
353 }
354}
355
356#[derive(Clone, Copy, Debug, Eq, PartialEq)]
357enum LimitRequirement {
358 Required,
359 Optional,
360}
361
362#[derive(Clone, Copy, Debug, Eq, PartialEq)]
363enum IndexRequirement {
364 Required,
365 Optional,
366}
367
368#[derive(Clone, Copy, Debug, Eq, PartialEq)]
369enum FullScanPolicy {
370 Allow,
371 Reject,
372}
373
374#[derive(Clone, Copy, Debug, Eq, PartialEq)]
375enum MaterializedSortPolicy {
376 Allow,
377 Reject,
378}
379
380#[derive(Clone, Copy, Debug, Eq, PartialEq)]
381enum OffsetPolicy {
382 Allow,
383 RejectNonZero,
384}
385
386#[derive(Clone, Debug, Eq, PartialEq)]
388pub struct QueryAdmissionPolicy {
389 lane: QueryAdmissionLane,
390 limit_requirement: LimitRequirement,
391 max_returned_rows: Option<NonZeroU32>,
392 max_scanned_rows: Option<NonZeroU64>,
393 max_response_bytes: Option<NonZeroU32>,
394 index_requirement: IndexRequirement,
395 offset_policy: OffsetPolicy,
396 full_scan_policy: FullScanPolicy,
397 materialized_sort_policy: MaterializedSortPolicy,
398 max_materialized_rows: Option<NonZeroU32>,
399 max_projection_columns: Option<NonZeroU32>,
400 grouped: GroupedAdmissionPolicy,
401}
402
403impl QueryAdmissionPolicy {
404 #[must_use]
406 pub const fn public_read(
407 max_returned_rows: NonZeroU32,
408 max_response_bytes: NonZeroU32,
409 ) -> Self {
410 Self {
411 lane: QueryAdmissionLane::PublicRead,
412 limit_requirement: LimitRequirement::Required,
413 max_returned_rows: Some(max_returned_rows),
414 max_scanned_rows: None,
415 max_response_bytes: Some(max_response_bytes),
416 index_requirement: IndexRequirement::Required,
417 offset_policy: OffsetPolicy::RejectNonZero,
418 full_scan_policy: FullScanPolicy::Reject,
419 materialized_sort_policy: MaterializedSortPolicy::Reject,
420 max_materialized_rows: None,
421 max_projection_columns: None,
422 grouped: GroupedAdmissionPolicy::disabled(),
423 }
424 }
425
426 #[must_use]
428 pub const fn admin_ad_hoc(
429 max_returned_rows: NonZeroU32,
430 max_scanned_rows: NonZeroU64,
431 max_response_bytes: NonZeroU32,
432 ) -> Self {
433 Self {
434 lane: QueryAdmissionLane::AdminAdHoc,
435 limit_requirement: LimitRequirement::Optional,
436 max_returned_rows: Some(max_returned_rows),
437 max_scanned_rows: Some(max_scanned_rows),
438 max_response_bytes: Some(max_response_bytes),
439 index_requirement: IndexRequirement::Optional,
440 offset_policy: OffsetPolicy::Allow,
441 full_scan_policy: FullScanPolicy::Allow,
442 materialized_sort_policy: MaterializedSortPolicy::Allow,
443 max_materialized_rows: Some(max_returned_rows),
444 max_projection_columns: None,
445 grouped: GroupedAdmissionPolicy::disabled(),
446 }
447 }
448
449 #[must_use]
451 pub const fn diagnostic_explain() -> Self {
452 Self {
453 lane: QueryAdmissionLane::DiagnosticExplain,
454 limit_requirement: LimitRequirement::Optional,
455 max_returned_rows: None,
456 max_scanned_rows: None,
457 max_response_bytes: None,
458 index_requirement: IndexRequirement::Optional,
459 offset_policy: OffsetPolicy::Allow,
460 full_scan_policy: FullScanPolicy::Allow,
461 materialized_sort_policy: MaterializedSortPolicy::Allow,
462 max_materialized_rows: None,
463 max_projection_columns: None,
464 grouped: GroupedAdmissionPolicy::disabled(),
465 }
466 }
467
468 #[must_use]
470 pub const fn dev_test_unbounded() -> Self {
471 Self {
472 lane: QueryAdmissionLane::DevTest,
473 limit_requirement: LimitRequirement::Optional,
474 max_returned_rows: None,
475 max_scanned_rows: None,
476 max_response_bytes: None,
477 index_requirement: IndexRequirement::Optional,
478 offset_policy: OffsetPolicy::Allow,
479 full_scan_policy: FullScanPolicy::Allow,
480 materialized_sort_policy: MaterializedSortPolicy::Allow,
481 max_materialized_rows: None,
482 max_projection_columns: None,
483 grouped: GroupedAdmissionPolicy::disabled(),
484 }
485 }
486
487 #[must_use]
489 pub const fn lane(&self) -> QueryAdmissionLane {
490 self.lane
491 }
492
493 #[must_use]
495 pub const fn require_limit(&self) -> bool {
496 matches!(self.limit_requirement, LimitRequirement::Required)
497 }
498
499 #[must_use]
501 pub const fn max_returned_rows(&self) -> Option<NonZeroU32> {
502 self.max_returned_rows
503 }
504
505 #[must_use]
507 pub const fn max_scanned_rows(&self) -> Option<NonZeroU64> {
508 self.max_scanned_rows
509 }
510
511 #[must_use]
513 pub const fn max_response_bytes(&self) -> Option<NonZeroU32> {
514 self.max_response_bytes
515 }
516
517 #[must_use]
519 pub const fn require_index(&self) -> bool {
520 matches!(self.index_requirement, IndexRequirement::Required)
521 }
522
523 #[must_use]
525 pub const fn reject_non_zero_offset(&self) -> bool {
526 matches!(self.offset_policy, OffsetPolicy::RejectNonZero)
527 }
528
529 #[must_use]
531 pub const fn allow_full_scan(&self) -> bool {
532 matches!(self.full_scan_policy, FullScanPolicy::Allow)
533 }
534
535 #[must_use]
537 pub const fn allow_materialized_sort(&self) -> bool {
538 matches!(self.materialized_sort_policy, MaterializedSortPolicy::Allow)
539 }
540
541 #[must_use]
543 pub const fn max_materialized_rows(&self) -> Option<NonZeroU32> {
544 self.max_materialized_rows
545 }
546
547 #[must_use]
549 pub const fn max_projection_columns(&self) -> Option<NonZeroU32> {
550 self.max_projection_columns
551 }
552
553 #[must_use]
555 pub const fn grouped(&self) -> GroupedAdmissionPolicy {
556 self.grouped
557 }
558
559 #[must_use]
561 pub const fn public_caps_are_finite(&self) -> bool {
562 !matches!(self.lane, QueryAdmissionLane::PublicRead)
563 || (self.max_returned_rows.is_some() && self.max_response_bytes.is_some())
564 }
565
566 #[must_use]
568 pub fn evaluate(&self, mut summary: QueryAdmissionSummary) -> QueryAdmissionSummary {
569 summary.lane = self.lane;
570
571 match self.rejection_for_summary(&summary) {
572 Some(rejection) => summary.reject(rejection),
573 None => summary.admit(),
574 }
575 }
576
577 fn rejection_for_summary(
578 &self,
579 summary: &QueryAdmissionSummary,
580 ) -> Option<QueryAdmissionRejection> {
581 if !self.lane.executes_rows() {
582 return Some(QueryAdmissionRejection::DiagnosticLaneDoesNotExecute);
583 }
584
585 if matches!(summary.plan_shape(), QueryAdmissionPlanShape::Delete) {
586 return Some(QueryAdmissionRejection::UnsupportedStatementForQueryLane);
587 }
588
589 if matches!(
590 summary.plan_shape(),
591 QueryAdmissionPlanShape::GroupedAggregate
592 ) && !self.grouped.has_hard_limits()
593 {
594 return Some(QueryAdmissionRejection::GroupedQueryRequiresLimits);
595 }
596
597 if self.require_limit() && summary.limit().is_none() {
598 return Some(QueryAdmissionRejection::PublicQueryRequiresLimit);
599 }
600
601 if self.reject_non_zero_offset() && summary.offset().unwrap_or_default() != 0 {
602 return Some(QueryAdmissionRejection::PublicQueryOffsetRejected);
603 }
604
605 if let Some(rejection) = self.returned_row_bound_rejection(summary) {
606 return Some(rejection);
607 }
608
609 if !self.allow_full_scan() && summary.selected_access().is_full_scan() {
610 return Some(QueryAdmissionRejection::UnboundedFullScanRejected);
611 }
612
613 if self.require_index()
614 && !access_satisfies_index_requirement(summary.selected_access(), summary.scan_bound())
615 {
616 return Some(QueryAdmissionRejection::PublicQueryRequiresIndex);
617 }
618
619 if let Some(rejection) = self.scan_bound_rejection(summary) {
620 return Some(rejection);
621 }
622
623 self.materialization_rejection(summary)
624 }
625
626 fn returned_row_bound_rejection(
627 &self,
628 summary: &QueryAdmissionSummary,
629 ) -> Option<QueryAdmissionRejection> {
630 let max_returned_rows = self.max_returned_rows?;
631
632 if matches!(
633 summary.returned_row_bound_kind(),
634 QueryBoundKind::EstimateOnly
635 ) {
636 return Some(QueryAdmissionRejection::EstimatedOnlyBoundRejected);
637 }
638
639 if !summary.returned_row_bound_kind().admits_public_read() {
640 return Some(QueryAdmissionRejection::ScanBoundUnavailable);
641 }
642
643 let Some(returned_row_bound) = summary.returned_row_bound() else {
644 return Some(QueryAdmissionRejection::ScanBoundUnavailable);
645 };
646
647 if returned_row_bound > max_returned_rows.get() {
648 return Some(QueryAdmissionRejection::ReturnedRowBoundExceedsPolicy);
649 }
650
651 None
652 }
653
654 fn scan_bound_rejection(
655 &self,
656 summary: &QueryAdmissionSummary,
657 ) -> Option<QueryAdmissionRejection> {
658 let max_scanned_rows = self.max_scanned_rows?;
659
660 if matches!(summary.scan_bound_kind(), QueryBoundKind::EstimateOnly) {
661 return Some(QueryAdmissionRejection::EstimatedOnlyBoundRejected);
662 }
663
664 if !summary.scan_bound_kind().admits_public_read() {
665 return Some(QueryAdmissionRejection::ScanBoundUnavailable);
666 }
667
668 let Some(scan_bound) = summary.scan_bound() else {
669 return Some(QueryAdmissionRejection::ScanBoundUnavailable);
670 };
671
672 if scan_bound > max_scanned_rows.get() {
673 return Some(QueryAdmissionRejection::ScanBoundExceedsPolicy);
674 }
675
676 None
677 }
678
679 fn materialization_rejection(
680 &self,
681 summary: &QueryAdmissionSummary,
682 ) -> Option<QueryAdmissionRejection> {
683 if !self.allow_materialized_sort() && summary.materialization().materialized_sort() {
684 return Some(QueryAdmissionRejection::SortRequiresMaterialization);
685 }
686
687 let max_materialized_rows = self.max_materialized_rows?;
688 let materialized_rows = summary.materialization().materialized_rows()?;
689
690 if materialized_rows > max_materialized_rows.get() {
691 Some(QueryAdmissionRejection::MaterializationExceedsBudget)
692 } else {
693 None
694 }
695 }
696}
697
698#[derive(Clone, Copy, Debug, Eq, PartialEq)]
700pub struct QueryMaterializationSummary {
701 materialized_sort: bool,
702 materialized_rows: Option<u32>,
703 row_bound_kind: QueryBoundKind,
704}
705
706impl QueryMaterializationSummary {
707 #[must_use]
709 pub const fn none() -> Self {
710 Self {
711 materialized_sort: false,
712 materialized_rows: None,
713 row_bound_kind: QueryBoundKind::Unavailable,
714 }
715 }
716
717 #[must_use]
719 pub const fn sort(materialized_rows: Option<u32>, row_bound_kind: QueryBoundKind) -> Self {
720 Self {
721 materialized_sort: true,
722 materialized_rows,
723 row_bound_kind,
724 }
725 }
726
727 #[must_use]
729 pub const fn materialized_sort(&self) -> bool {
730 self.materialized_sort
731 }
732
733 #[must_use]
735 pub const fn materialized_rows(&self) -> Option<u32> {
736 self.materialized_rows
737 }
738
739 #[must_use]
741 pub const fn row_bound_kind(&self) -> QueryBoundKind {
742 self.row_bound_kind
743 }
744}
745
746#[derive(Clone, Copy, Debug, Eq, PartialEq)]
748pub enum QueryAdmissionRejection {
749 PublicQueryRequiresLimit,
751 PublicQueryRequiresIndex,
753 UnboundedFullScanRejected,
755 ScanBoundUnavailable,
757 ScanBoundExceedsPolicy,
759 EstimatedOnlyBoundRejected,
761 SortRequiresMaterialization,
763 MaterializationExceedsBudget,
765 ProjectionResponseMayExceedLimit,
767 GroupedQueryRequiresLimits,
769 GroupedQueryExceedsBudget,
771 DiagnosticLaneDoesNotExecute,
773 IntrospectionDisabledForLane,
775 UnsupportedStatementForQueryLane,
777 PublicQueryOffsetRejected,
779 ReturnedRowBoundExceedsPolicy,
781}
782
783impl QueryAdmissionRejection {
784 #[must_use]
786 pub const fn code(self) -> QueryReadAdmissionCode {
787 match self {
788 Self::PublicQueryRequiresLimit => QueryReadAdmissionCode::PublicQueryRequiresLimit,
789 Self::PublicQueryRequiresIndex => QueryReadAdmissionCode::PublicQueryRequiresIndex,
790 Self::UnboundedFullScanRejected => QueryReadAdmissionCode::UnboundedFullScanRejected,
791 Self::ScanBoundUnavailable => QueryReadAdmissionCode::ScanBoundUnavailable,
792 Self::ScanBoundExceedsPolicy => QueryReadAdmissionCode::ScanBoundExceedsPolicy,
793 Self::EstimatedOnlyBoundRejected => QueryReadAdmissionCode::EstimatedOnlyBoundRejected,
794 Self::SortRequiresMaterialization => {
795 QueryReadAdmissionCode::SortRequiresMaterialization
796 }
797 Self::MaterializationExceedsBudget => {
798 QueryReadAdmissionCode::MaterializationExceedsBudget
799 }
800 Self::ProjectionResponseMayExceedLimit => {
801 QueryReadAdmissionCode::ProjectionResponseMayExceedLimit
802 }
803 Self::GroupedQueryRequiresLimits => QueryReadAdmissionCode::GroupedQueryRequiresLimits,
804 Self::GroupedQueryExceedsBudget => QueryReadAdmissionCode::GroupedQueryExceedsBudget,
805 Self::DiagnosticLaneDoesNotExecute => {
806 QueryReadAdmissionCode::DiagnosticLaneDoesNotExecute
807 }
808 Self::IntrospectionDisabledForLane => {
809 QueryReadAdmissionCode::IntrospectionDisabledForLane
810 }
811 Self::UnsupportedStatementForQueryLane => {
812 QueryReadAdmissionCode::UnsupportedStatementForQueryLane
813 }
814 Self::PublicQueryOffsetRejected => QueryReadAdmissionCode::PublicQueryOffsetRejected,
815 Self::ReturnedRowBoundExceedsPolicy => {
816 QueryReadAdmissionCode::ReturnedRowBoundExceedsPolicy
817 }
818 }
819 }
820
821 #[must_use]
823 pub const fn diagnostic(self) -> Diagnostic {
824 Diagnostic::new(
825 DiagnosticCode::QueryReadAdmission,
826 ErrorOrigin::Query,
827 Some(DiagnosticDetail::QueryReadAdmission {
828 reason: self.code(),
829 }),
830 )
831 }
832
833 #[must_use]
835 pub const fn error_code(self) -> ErrorCode {
836 self.diagnostic().error_code()
837 }
838}
839
840#[derive(Clone, Debug, Eq, PartialEq)]
842pub struct QueryAdmissionSummary {
843 lane: QueryAdmissionLane,
844 decision: QueryAdmissionDecision,
845 plan_shape: QueryAdmissionPlanShape,
846 selected_access: QueryAdmissionAccessKind,
847 selected_index: Option<String>,
848 limit: Option<u32>,
849 offset: Option<u32>,
850 scan_bound: Option<u64>,
851 scan_bound_kind: QueryBoundKind,
852 returned_row_bound: Option<u32>,
853 returned_row_bound_kind: QueryBoundKind,
854 response_byte_bound: Option<u32>,
855 response_byte_bound_kind: QueryBoundKind,
856 residual_filter: QueryAdmissionResidualFilter,
857 ordering: QueryAdmissionOrdering,
858 grouped: Option<QueryAdmissionGroupedSummary>,
859 materialization: QueryMaterializationSummary,
860 rejection: Option<QueryAdmissionRejection>,
861}
862
863impl QueryAdmissionSummary {
864 #[must_use]
866 pub const fn admitted(
867 lane: QueryAdmissionLane,
868 selected_access: QueryAdmissionAccessKind,
869 ) -> Self {
870 Self {
871 lane,
872 decision: QueryAdmissionDecision::Admitted,
873 plan_shape: QueryAdmissionPlanShape::ScalarRead,
874 selected_access,
875 selected_index: None,
876 limit: None,
877 offset: None,
878 scan_bound: None,
879 scan_bound_kind: QueryBoundKind::Unavailable,
880 returned_row_bound: None,
881 returned_row_bound_kind: QueryBoundKind::Unavailable,
882 response_byte_bound: None,
883 response_byte_bound_kind: QueryBoundKind::Unavailable,
884 residual_filter: QueryAdmissionResidualFilter::Absent,
885 ordering: QueryAdmissionOrdering::None,
886 grouped: None,
887 materialization: QueryMaterializationSummary::none(),
888 rejection: None,
889 }
890 }
891
892 #[must_use]
894 pub const fn rejected(
895 lane: QueryAdmissionLane,
896 selected_access: QueryAdmissionAccessKind,
897 rejection: QueryAdmissionRejection,
898 ) -> Self {
899 Self {
900 lane,
901 decision: QueryAdmissionDecision::Rejected,
902 plan_shape: QueryAdmissionPlanShape::ScalarRead,
903 selected_access,
904 selected_index: None,
905 limit: None,
906 offset: None,
907 scan_bound: None,
908 scan_bound_kind: QueryBoundKind::Unavailable,
909 returned_row_bound: None,
910 returned_row_bound_kind: QueryBoundKind::Unavailable,
911 response_byte_bound: None,
912 response_byte_bound_kind: QueryBoundKind::Unavailable,
913 residual_filter: QueryAdmissionResidualFilter::Absent,
914 ordering: QueryAdmissionOrdering::None,
915 grouped: None,
916 materialization: QueryMaterializationSummary::none(),
917 rejection: Some(rejection),
918 }
919 }
920
921 #[must_use]
923 pub(in crate::db) fn from_plan(lane: QueryAdmissionLane, plan: &AccessPlannedQuery) -> Self {
924 let access = summarize_access_plan(plan);
925 let grouped = plan.grouped_plan().map(summarize_grouped_plan);
926 let (limit, offset) = scalar_limit_and_offset(plan.scalar_plan());
927 let (returned_row_bound, returned_row_bound_kind) =
928 returned_row_bound_from_plan(limit, grouped);
929 let scan_bound_kind = access.scan_bound_kind();
930
931 Self {
932 lane,
933 decision: QueryAdmissionDecision::Admitted,
934 plan_shape: plan_shape(plan),
935 selected_access: access.kind,
936 selected_index: access.selected_index,
937 limit,
938 offset: Some(offset),
939 scan_bound: access.exact_scan_bound,
940 scan_bound_kind,
941 returned_row_bound,
942 returned_row_bound_kind,
943 response_byte_bound: None,
944 response_byte_bound_kind: QueryBoundKind::Unavailable,
945 residual_filter: admission_residual_filter(plan.residual_filter_shape()),
946 ordering: admission_ordering(plan),
947 grouped,
948 materialization: QueryMaterializationSummary::none(),
949 rejection: None,
950 }
951 }
952
953 const fn admit(mut self) -> Self {
954 self.decision = QueryAdmissionDecision::Admitted;
955 self.rejection = None;
956 self
957 }
958
959 const fn reject(mut self, rejection: QueryAdmissionRejection) -> Self {
960 self.decision = QueryAdmissionDecision::Rejected;
961 self.rejection = Some(rejection);
962 self
963 }
964
965 #[must_use]
967 pub const fn lane(&self) -> QueryAdmissionLane {
968 self.lane
969 }
970
971 #[must_use]
973 pub const fn decision(&self) -> QueryAdmissionDecision {
974 self.decision
975 }
976
977 #[must_use]
979 pub const fn plan_shape(&self) -> QueryAdmissionPlanShape {
980 self.plan_shape
981 }
982
983 #[must_use]
985 pub const fn selected_access(&self) -> QueryAdmissionAccessKind {
986 self.selected_access
987 }
988
989 #[must_use]
991 pub fn selected_index(&self) -> Option<&str> {
992 self.selected_index.as_deref()
993 }
994
995 #[must_use]
997 pub const fn limit(&self) -> Option<u32> {
998 self.limit
999 }
1000
1001 #[must_use]
1003 pub const fn offset(&self) -> Option<u32> {
1004 self.offset
1005 }
1006
1007 #[must_use]
1009 pub const fn scan_bound(&self) -> Option<u64> {
1010 self.scan_bound
1011 }
1012
1013 #[must_use]
1015 pub const fn scan_bound_kind(&self) -> QueryBoundKind {
1016 self.scan_bound_kind
1017 }
1018
1019 #[must_use]
1021 pub const fn returned_row_bound(&self) -> Option<u32> {
1022 self.returned_row_bound
1023 }
1024
1025 #[must_use]
1027 pub const fn returned_row_bound_kind(&self) -> QueryBoundKind {
1028 self.returned_row_bound_kind
1029 }
1030
1031 #[must_use]
1033 pub const fn response_byte_bound(&self) -> Option<u32> {
1034 self.response_byte_bound
1035 }
1036
1037 #[must_use]
1039 pub const fn response_byte_bound_kind(&self) -> QueryBoundKind {
1040 self.response_byte_bound_kind
1041 }
1042
1043 #[must_use]
1045 pub const fn residual_filter(&self) -> QueryAdmissionResidualFilter {
1046 self.residual_filter
1047 }
1048
1049 #[must_use]
1051 pub const fn ordering(&self) -> QueryAdmissionOrdering {
1052 self.ordering
1053 }
1054
1055 #[must_use]
1057 pub const fn grouped(&self) -> Option<QueryAdmissionGroupedSummary> {
1058 self.grouped
1059 }
1060
1061 #[must_use]
1063 pub const fn materialization(&self) -> QueryMaterializationSummary {
1064 self.materialization
1065 }
1066
1067 #[must_use]
1069 pub const fn rejection(&self) -> Option<QueryAdmissionRejection> {
1070 self.rejection
1071 }
1072}
1073
1074const _: fn(QueryAdmissionLane, &AccessPlannedQuery) -> QueryAdmissionSummary =
1076 QueryAdmissionSummary::from_plan;
1077
1078const fn access_satisfies_index_requirement(
1079 kind: QueryAdmissionAccessKind,
1080 scan_bound: Option<u64>,
1081) -> bool {
1082 kind.is_secondary_index()
1083 || matches!(
1084 (kind, scan_bound),
1085 (
1086 QueryAdmissionAccessKind::ByKey | QueryAdmissionAccessKind::ByKeys,
1087 Some(_)
1088 )
1089 )
1090}
1091
1092struct AdmissionAccessProjection;
1093
1094#[derive(Clone, Debug, Eq, PartialEq)]
1095struct AdmissionAccessSummary {
1096 kind: QueryAdmissionAccessKind,
1097 selected_index: Option<String>,
1098 exact_scan_bound: Option<u64>,
1099}
1100
1101impl AdmissionAccessSummary {
1102 const fn non_index(kind: QueryAdmissionAccessKind, exact_scan_bound: Option<u64>) -> Self {
1103 Self {
1104 kind,
1105 selected_index: None,
1106 exact_scan_bound,
1107 }
1108 }
1109
1110 fn secondary_index(kind: QueryAdmissionAccessKind, index_name: &str) -> Self {
1111 Self {
1112 kind,
1113 selected_index: Some(index_name.to_string()),
1114 exact_scan_bound: None,
1115 }
1116 }
1117
1118 const fn composite(kind: QueryAdmissionAccessKind) -> Self {
1119 Self {
1120 kind,
1121 selected_index: None,
1122 exact_scan_bound: None,
1123 }
1124 }
1125
1126 const fn scan_bound_kind(&self) -> QueryBoundKind {
1127 if self.exact_scan_bound.is_some() {
1128 QueryBoundKind::Exact
1129 } else {
1130 QueryBoundKind::Unavailable
1131 }
1132 }
1133}
1134
1135impl AccessPlanProjection<Value> for AdmissionAccessProjection {
1136 type Output = AdmissionAccessSummary;
1137
1138 fn by_key(&mut self, _key: &Value) -> Self::Output {
1139 AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::ByKey, Some(1))
1140 }
1141
1142 fn by_keys(&mut self, keys: &[Value]) -> Self::Output {
1143 AdmissionAccessSummary::non_index(
1144 QueryAdmissionAccessKind::ByKeys,
1145 Some(u64::try_from(keys.len()).unwrap_or(u64::MAX)),
1146 )
1147 }
1148
1149 fn key_range(&mut self, _start: &Value, _end: &Value) -> Self::Output {
1150 AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::KeyRange, None)
1151 }
1152
1153 fn index_prefix(
1154 &mut self,
1155 index_name: &str,
1156 _index_fields: &[String],
1157 _prefix_len: usize,
1158 _values: &[Value],
1159 ) -> Self::Output {
1160 AdmissionAccessSummary::secondary_index(QueryAdmissionAccessKind::IndexPrefix, index_name)
1161 }
1162
1163 fn index_multi_lookup(
1164 &mut self,
1165 index_name: &str,
1166 _index_fields: &[String],
1167 _values: &[Value],
1168 ) -> Self::Output {
1169 AdmissionAccessSummary::secondary_index(
1170 QueryAdmissionAccessKind::IndexMultiLookup,
1171 index_name,
1172 )
1173 }
1174
1175 fn index_branch_set(
1176 &mut self,
1177 index_name: &str,
1178 _index_fields: &[String],
1179 _fixed_values: &[Value],
1180 _branch_values: &[Value],
1181 _ordered_suffix: IndexBranchSetOrderedSuffix,
1182 ) -> Self::Output {
1183 AdmissionAccessSummary::secondary_index(
1184 QueryAdmissionAccessKind::IndexBranchSet,
1185 index_name,
1186 )
1187 }
1188
1189 fn index_range(
1190 &mut self,
1191 index_name: &str,
1192 _index_fields: &[String],
1193 _prefix_len: usize,
1194 _prefix: &[Value],
1195 _lower: &Bound<Value>,
1196 _upper: &Bound<Value>,
1197 ) -> Self::Output {
1198 AdmissionAccessSummary::secondary_index(QueryAdmissionAccessKind::IndexRange, index_name)
1199 }
1200
1201 fn full_scan(&mut self) -> Self::Output {
1202 AdmissionAccessSummary::non_index(QueryAdmissionAccessKind::FullScan, None)
1203 }
1204
1205 fn union(&mut self, _children: Vec<Self::Output>) -> Self::Output {
1206 AdmissionAccessSummary::composite(QueryAdmissionAccessKind::Union)
1207 }
1208
1209 fn intersection(&mut self, _children: Vec<Self::Output>) -> Self::Output {
1210 AdmissionAccessSummary::composite(QueryAdmissionAccessKind::Intersection)
1211 }
1212}
1213
1214fn summarize_access_plan(plan: &AccessPlannedQuery) -> AdmissionAccessSummary {
1215 project_access_plan(&plan.access, &mut AdmissionAccessProjection)
1216}
1217
1218fn summarize_grouped_plan(plan: &GroupPlan) -> QueryAdmissionGroupedSummary {
1219 QueryAdmissionGroupedSummary::new(
1220 u32::try_from(plan.group.group_fields.len()).unwrap_or(u32::MAX),
1221 u32::try_from(plan.group.aggregates.len()).unwrap_or(u32::MAX),
1222 plan.group.execution.max_groups(),
1223 plan.group.execution.max_group_bytes(),
1224 plan.having_expr.is_some(),
1225 )
1226}
1227
1228const fn scalar_limit_and_offset(plan: &ScalarPlan) -> (Option<u32>, u32) {
1229 match plan.mode {
1230 QueryMode::Load(load) => match &plan.page {
1231 Some(page) => (page.limit, page.offset),
1232 None => (load.limit(), load.offset()),
1233 },
1234 QueryMode::Delete(delete) => match plan.delete_limit {
1235 Some(delete_limit) => (delete_limit.limit, delete_limit.offset),
1236 None => (delete.limit(), delete.offset()),
1237 },
1238 }
1239}
1240
1241fn returned_row_bound_from_plan(
1242 limit: Option<u32>,
1243 grouped: Option<QueryAdmissionGroupedSummary>,
1244) -> (Option<u32>, QueryBoundKind) {
1245 if let Some(limit) = limit {
1246 return (Some(limit), QueryBoundKind::EnforcedRuntimeCap);
1247 }
1248
1249 let Some(grouped) = grouped else {
1250 return (None, QueryBoundKind::Unavailable);
1251 };
1252 if grouped.max_groups() == u64::MAX {
1253 return (None, QueryBoundKind::Unavailable);
1254 }
1255
1256 (
1257 Some(u32::try_from(grouped.max_groups()).unwrap_or(u32::MAX)),
1258 QueryBoundKind::ConservativeUpperBound,
1259 )
1260}
1261
1262const fn admission_residual_filter(shape: ResidualFilterShape) -> QueryAdmissionResidualFilter {
1263 match shape {
1264 ResidualFilterShape::Absent => QueryAdmissionResidualFilter::Absent,
1265 ResidualFilterShape::Predicate => QueryAdmissionResidualFilter::Predicate,
1266 ResidualFilterShape::Expression => QueryAdmissionResidualFilter::Expression,
1267 ResidualFilterShape::ExpressionAndPredicate => {
1268 QueryAdmissionResidualFilter::ExpressionAndPredicate
1269 }
1270 }
1271}
1272
1273fn admission_ordering(plan: &AccessPlannedQuery) -> QueryAdmissionOrdering {
1274 if plan.scalar_plan().order.is_none() {
1275 return QueryAdmissionOrdering::None;
1276 }
1277
1278 if plan.resolved_order().is_some() {
1279 QueryAdmissionOrdering::Resolved
1280 } else {
1281 QueryAdmissionOrdering::Requested
1282 }
1283}
1284
1285const fn plan_shape(plan: &AccessPlannedQuery) -> QueryAdmissionPlanShape {
1286 if plan.grouped_plan().is_some() {
1287 return QueryAdmissionPlanShape::GroupedAggregate;
1288 }
1289
1290 match plan.scalar_plan().mode {
1291 QueryMode::Load(_) => QueryAdmissionPlanShape::ScalarRead,
1292 QueryMode::Delete(_) => QueryAdmissionPlanShape::Delete,
1293 }
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298 use std::num::{NonZeroU32, NonZeroU64};
1299
1300 use crate::{
1301 db::{
1302 access::{AccessPath, SemanticIndexAccessContract},
1303 predicate::{MissingRowPolicy, Predicate},
1304 query::plan::{
1305 AccessPlannedQuery, AggregateKind, DeleteLimitSpec, DeleteSpec, FieldSlot,
1306 GroupAggregateSpec, GroupSpec, GroupedExecutionConfig, OrderDirection, OrderSpec,
1307 OrderTerm, PageSpec, QueryMode,
1308 expr::{Expr, FieldId},
1309 },
1310 },
1311 model::index::IndexModel,
1312 value::Value,
1313 };
1314
1315 use super::{
1316 GroupedAdmissionPolicy, QueryAdmissionAccessKind, QueryAdmissionDecision,
1317 QueryAdmissionLane, QueryAdmissionOrdering, QueryAdmissionPlanShape, QueryAdmissionPolicy,
1318 QueryAdmissionRejection, QueryAdmissionResidualFilter, QueryAdmissionSummary,
1319 QueryBoundKind,
1320 };
1321
1322 const ADMISSION_INDEX_FIELDS: [&str; 1] = ["tag"];
1323 const ADMISSION_INDEX: IndexModel = IndexModel::generated(
1324 "admission::tag",
1325 "admission::tag_store",
1326 &ADMISSION_INDEX_FIELDS,
1327 false,
1328 );
1329
1330 #[test]
1331 fn public_read_policy_has_safe_finite_defaults() {
1332 let max_rows = NonZeroU32::new(50).expect("test max rows is non-zero");
1333 let max_bytes = NonZeroU32::new(32_768).expect("test max bytes is non-zero");
1334 let policy = QueryAdmissionPolicy::public_read(max_rows, max_bytes);
1335
1336 assert_eq!(policy.lane(), QueryAdmissionLane::PublicRead);
1337 assert!(policy.require_limit());
1338 assert!(policy.require_index());
1339 assert!(policy.reject_non_zero_offset());
1340 assert!(!policy.allow_full_scan());
1341 assert!(!policy.allow_materialized_sort());
1342 assert_eq!(policy.max_returned_rows(), Some(max_rows));
1343 assert_eq!(policy.max_response_bytes(), Some(max_bytes));
1344 assert!(policy.public_caps_are_finite());
1345 assert!(!policy.grouped().has_hard_limits());
1346 }
1347
1348 #[test]
1349 fn admin_policy_is_broader_but_still_budgeted() {
1350 let max_rows = NonZeroU32::new(100).expect("test max rows is non-zero");
1351 let max_scanned = NonZeroU64::new(1_000).expect("test scan cap is non-zero");
1352 let max_bytes = NonZeroU32::new(65_536).expect("test max bytes is non-zero");
1353 let policy = QueryAdmissionPolicy::admin_ad_hoc(max_rows, max_scanned, max_bytes);
1354
1355 assert_eq!(policy.lane(), QueryAdmissionLane::AdminAdHoc);
1356 assert!(!policy.require_limit());
1357 assert!(!policy.require_index());
1358 assert!(policy.allow_full_scan());
1359 assert!(policy.allow_materialized_sort());
1360 assert_eq!(policy.max_scanned_rows(), Some(max_scanned));
1361 assert_eq!(policy.max_materialized_rows(), Some(max_rows));
1362 }
1363
1364 #[test]
1365 fn diagnostic_explain_lane_does_not_execute_rows() {
1366 let policy = QueryAdmissionPolicy::diagnostic_explain();
1367
1368 assert_eq!(policy.lane().as_str(), "diagnostic_explain");
1369 assert!(!policy.lane().executes_rows());
1370 }
1371
1372 #[test]
1373 fn grouped_policy_requires_group_and_memory_budgets() {
1374 let max_groups = NonZeroU32::new(8).expect("test group cap is non-zero");
1375 let max_bytes = NonZeroU32::new(4096).expect("test byte cap is non-zero");
1376 let policy = GroupedAdmissionPolicy::bounded(max_groups, max_bytes, None);
1377
1378 assert!(policy.has_hard_limits());
1379 assert_eq!(policy.max_groups(), Some(max_groups));
1380 assert_eq!(policy.max_group_bytes(), Some(max_bytes));
1381 }
1382
1383 #[test]
1384 fn only_proven_or_enforced_bounds_admit_public_reads() {
1385 assert!(QueryBoundKind::Exact.admits_public_read());
1386 assert!(QueryBoundKind::ConservativeUpperBound.admits_public_read());
1387 assert!(QueryBoundKind::EnforcedRuntimeCap.admits_public_read());
1388 assert!(!QueryBoundKind::EstimateOnly.admits_public_read());
1389 assert!(!QueryBoundKind::Unavailable.admits_public_read());
1390 }
1391
1392 #[test]
1393 fn access_kind_classifies_secondary_indexes_and_full_scans() {
1394 assert!(QueryAdmissionAccessKind::IndexPrefix.is_secondary_index());
1395 assert!(QueryAdmissionAccessKind::FullScan.is_full_scan());
1396 assert!(!QueryAdmissionAccessKind::ByKey.is_secondary_index());
1397 }
1398
1399 #[test]
1400 fn rejection_maps_to_stable_diagnostic() {
1401 let rejection = QueryAdmissionRejection::PublicQueryRequiresLimit;
1402 let diagnostic = rejection.diagnostic();
1403
1404 assert_eq!(
1405 rejection.error_code(),
1406 icydb_diagnostic_code::ErrorCode::QUERY_READ_PUBLIC_REQUIRES_LIMIT
1407 );
1408 assert_eq!(
1409 diagnostic.code(),
1410 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission
1411 );
1412 }
1413
1414 #[test]
1415 fn summaries_keep_decision_and_rejection_aligned() {
1416 let admitted = QueryAdmissionSummary::admitted(
1417 QueryAdmissionLane::PublicRead,
1418 QueryAdmissionAccessKind::ByKey,
1419 );
1420 let rejected = QueryAdmissionSummary::rejected(
1421 QueryAdmissionLane::PublicRead,
1422 QueryAdmissionAccessKind::FullScan,
1423 QueryAdmissionRejection::UnboundedFullScanRejected,
1424 );
1425
1426 assert_eq!(admitted.decision(), QueryAdmissionDecision::Admitted);
1427 assert_eq!(admitted.rejection(), None);
1428 assert_eq!(rejected.decision(), QueryAdmissionDecision::Rejected);
1429 assert_eq!(
1430 rejected.rejection(),
1431 Some(QueryAdmissionRejection::UnboundedFullScanRejected)
1432 );
1433 }
1434
1435 #[test]
1436 fn plan_summary_classifies_full_scan_without_overclaiming_bounds() {
1437 let plan = AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1438
1439 let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1440
1441 assert_eq!(summary.plan_shape(), QueryAdmissionPlanShape::ScalarRead);
1442 assert_eq!(
1443 summary.selected_access(),
1444 QueryAdmissionAccessKind::FullScan
1445 );
1446 assert_eq!(summary.selected_index(), None);
1447 assert_eq!(summary.limit(), None);
1448 assert_eq!(summary.offset(), Some(0));
1449 assert_eq!(summary.scan_bound(), None);
1450 assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Unavailable);
1451 assert_eq!(summary.returned_row_bound(), None);
1452 assert_eq!(
1453 summary.returned_row_bound_kind(),
1454 QueryBoundKind::Unavailable
1455 );
1456 assert_eq!(
1457 summary.residual_filter(),
1458 QueryAdmissionResidualFilter::Absent
1459 );
1460 assert_eq!(summary.ordering(), QueryAdmissionOrdering::None);
1461 }
1462
1463 #[test]
1464 fn plan_summary_uses_point_lookup_and_limit_as_proven_bounds() {
1465 let mut plan =
1466 AccessPlannedQuery::new(AccessPath::ByKey(Value::Nat64(7)), MissingRowPolicy::Ignore);
1467 plan.scalar_plan_mut().page = Some(PageSpec {
1468 limit: Some(5),
1469 offset: 2,
1470 });
1471
1472 let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1473
1474 assert_eq!(summary.selected_access(), QueryAdmissionAccessKind::ByKey);
1475 assert_eq!(summary.limit(), Some(5));
1476 assert_eq!(summary.offset(), Some(2));
1477 assert_eq!(summary.scan_bound(), Some(1));
1478 assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Exact);
1479 assert_eq!(summary.returned_row_bound(), Some(5));
1480 assert_eq!(
1481 summary.returned_row_bound_kind(),
1482 QueryBoundKind::EnforcedRuntimeCap
1483 );
1484 }
1485
1486 #[test]
1487 fn plan_summary_preserves_selected_index_identity() {
1488 let plan = AccessPlannedQuery::new(
1489 AccessPath::IndexPrefix {
1490 index: SemanticIndexAccessContract::model_only_from_generated_index(
1491 ADMISSION_INDEX,
1492 ),
1493 values: vec![Value::Text("alpha".to_string())],
1494 },
1495 MissingRowPolicy::Ignore,
1496 );
1497
1498 let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan);
1499
1500 assert_eq!(
1501 summary.selected_access(),
1502 QueryAdmissionAccessKind::IndexPrefix
1503 );
1504 assert_eq!(summary.selected_index(), Some("admission::tag"));
1505 assert_eq!(summary.scan_bound(), None);
1506 assert_eq!(summary.scan_bound_kind(), QueryBoundKind::Unavailable);
1507 }
1508
1509 #[test]
1510 fn plan_summary_classifies_residual_and_requested_ordering() {
1511 let mut plan =
1512 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1513 plan.scalar_plan_mut().predicate = Some(Predicate::eq(
1514 "tag".to_string(),
1515 Value::Text("alpha".to_string()),
1516 ));
1517 plan.scalar_plan_mut().order = Some(OrderSpec {
1518 fields: vec![OrderTerm::field("tag", OrderDirection::Asc)],
1519 });
1520
1521 let summary = QueryAdmissionSummary::from_plan(QueryAdmissionLane::AdminAdHoc, &plan);
1522
1523 assert_eq!(
1524 summary.residual_filter(),
1525 QueryAdmissionResidualFilter::Predicate
1526 );
1527 assert_eq!(summary.ordering(), QueryAdmissionOrdering::Requested);
1528 }
1529
1530 #[test]
1531 fn plan_summary_carries_grouped_execution_budgets() {
1532 let grouped =
1533 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
1534 .into_grouped_with_having_expr(
1535 GroupSpec {
1536 group_fields: vec![FieldSlot::from_test_slot(0, "tag")],
1537 aggregates: vec![GroupAggregateSpec {
1538 kind: AggregateKind::Count,
1539 input_expr: None,
1540 filter_expr: None,
1541 distinct: false,
1542 }],
1543 execution: GroupedExecutionConfig::with_hard_limits(12, 4096),
1544 },
1545 Some(Expr::Field(FieldId::new("tag"))),
1546 );
1547
1548 let summary =
1549 QueryAdmissionSummary::from_plan(QueryAdmissionLane::DiagnosticExplain, &grouped);
1550 let grouped = summary
1551 .grouped()
1552 .expect("summary should include grouped facts");
1553
1554 assert_eq!(
1555 summary.plan_shape(),
1556 QueryAdmissionPlanShape::GroupedAggregate
1557 );
1558 assert_eq!(grouped.group_field_count(), 1);
1559 assert_eq!(grouped.aggregate_count(), 1);
1560 assert_eq!(grouped.max_groups(), 12);
1561 assert_eq!(grouped.max_group_bytes(), 4096);
1562 assert!(grouped.has_having_filter());
1563 assert_eq!(summary.returned_row_bound(), Some(12));
1564 assert_eq!(
1565 summary.returned_row_bound_kind(),
1566 QueryBoundKind::ConservativeUpperBound
1567 );
1568 }
1569
1570 #[test]
1571 fn plan_summary_reads_delete_window_without_executing_it() {
1572 let mut plan =
1573 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore);
1574 plan.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
1575 plan.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec {
1576 limit: Some(3),
1577 offset: 1,
1578 });
1579
1580 let summary =
1581 QueryAdmissionSummary::from_plan(QueryAdmissionLane::DiagnosticExplain, &plan);
1582
1583 assert_eq!(summary.plan_shape(), QueryAdmissionPlanShape::Delete);
1584 assert_eq!(summary.limit(), Some(3));
1585 assert_eq!(summary.offset(), Some(1));
1586 assert_eq!(summary.returned_row_bound(), Some(3));
1587 }
1588
1589 #[test]
1590 fn public_read_evaluation_rejects_missing_limit_before_access_shape() {
1591 let policy = public_read_policy();
1592 let summary = summary_for_index_prefix(None, 0);
1593
1594 let evaluated = policy.evaluate(summary);
1595
1596 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1597 assert_eq!(
1598 evaluated.rejection(),
1599 Some(QueryAdmissionRejection::PublicQueryRequiresLimit)
1600 );
1601 }
1602
1603 #[test]
1604 fn public_read_evaluation_rejects_full_scan_even_with_limit() {
1605 let policy = public_read_policy();
1606 let summary = summary_for_path(AccessPath::<Value>::FullScan, Some(5), 0);
1607
1608 let evaluated = policy.evaluate(summary);
1609
1610 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1611 assert_eq!(
1612 evaluated.rejection(),
1613 Some(QueryAdmissionRejection::UnboundedFullScanRejected)
1614 );
1615 }
1616
1617 #[test]
1618 fn public_read_evaluation_admits_indexed_bounded_scalar_read() {
1619 let policy = public_read_policy();
1620 let summary = summary_for_index_prefix(Some(5), 0);
1621
1622 let evaluated = policy.evaluate(summary);
1623
1624 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Admitted);
1625 assert_eq!(evaluated.rejection(), None);
1626 }
1627
1628 #[test]
1629 fn public_read_evaluation_admits_exact_primary_key_read() {
1630 let policy = public_read_policy();
1631 let summary = summary_for_path(
1632 AccessPath::ByKey(Value::Text("primary".to_string())),
1633 Some(1),
1634 0,
1635 );
1636
1637 let evaluated = policy.evaluate(summary);
1638
1639 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Admitted);
1640 assert_eq!(evaluated.scan_bound(), Some(1));
1641 }
1642
1643 #[test]
1644 fn public_read_evaluation_rejects_non_zero_offset() {
1645 let policy = public_read_policy();
1646 let summary = summary_for_index_prefix(Some(5), 1);
1647
1648 let evaluated = policy.evaluate(summary);
1649
1650 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1651 assert_eq!(
1652 evaluated.rejection(),
1653 Some(QueryAdmissionRejection::PublicQueryOffsetRejected)
1654 );
1655 }
1656
1657 #[test]
1658 fn public_read_evaluation_rejects_returned_row_cap_overflow() {
1659 let policy = public_read_policy();
1660 let summary = summary_for_index_prefix(Some(51), 0);
1661
1662 let evaluated = policy.evaluate(summary);
1663
1664 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1665 assert_eq!(
1666 evaluated.rejection(),
1667 Some(QueryAdmissionRejection::ReturnedRowBoundExceedsPolicy)
1668 );
1669 }
1670
1671 #[test]
1672 fn diagnostic_explain_policy_rejects_row_execution() {
1673 let policy = QueryAdmissionPolicy::diagnostic_explain();
1674 let summary = summary_for_index_prefix(Some(5), 0);
1675
1676 let evaluated = policy.evaluate(summary);
1677
1678 assert_eq!(evaluated.decision(), QueryAdmissionDecision::Rejected);
1679 assert_eq!(
1680 evaluated.rejection(),
1681 Some(QueryAdmissionRejection::DiagnosticLaneDoesNotExecute)
1682 );
1683 }
1684
1685 fn public_read_policy() -> QueryAdmissionPolicy {
1686 QueryAdmissionPolicy::public_read(
1687 NonZeroU32::new(50).expect("test public row cap is non-zero"),
1688 NonZeroU32::new(32_768).expect("test public byte cap is non-zero"),
1689 )
1690 }
1691
1692 fn summary_for_index_prefix(limit: Option<u32>, offset: u32) -> QueryAdmissionSummary {
1693 summary_for_path(
1694 AccessPath::IndexPrefix {
1695 index: SemanticIndexAccessContract::model_only_from_generated_index(
1696 ADMISSION_INDEX,
1697 ),
1698 values: vec![Value::Text("alpha".to_string())],
1699 },
1700 limit,
1701 offset,
1702 )
1703 }
1704
1705 fn summary_for_path(
1706 path: AccessPath<Value>,
1707 limit: Option<u32>,
1708 offset: u32,
1709 ) -> QueryAdmissionSummary {
1710 let mut plan = AccessPlannedQuery::new(path, MissingRowPolicy::Ignore);
1711 plan.scalar_plan_mut().page = Some(PageSpec { limit, offset });
1712
1713 QueryAdmissionSummary::from_plan(QueryAdmissionLane::PublicRead, &plan)
1714 }
1715}