1use laser_wire::checkpoint::CheckpointReadConsistency;
2use laser_wire::filter::FilterCodec;
3use laser_wire::query::Consistency;
4
5pub use laser_wire::destination::BackendResourceId;
6pub use laser_wire::hello::{
7 BackendDescriptor, BackendDesiredState, BackendImplementation, BackendLimits, BackendMode,
8 BackendObservedState, BackendReadiness, BackendReadinessCode, BackendReadinessReason,
9 FilterAnnounce, MaintenanceCapabilities, MaterializationCapability, OpVersions,
10 QueryCapabilities, QueryPagingCapability, SchemaCapabilities, TimeTravelCapability,
11};
12
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
28#[non_exhaustive]
29pub struct Capabilities {
30 pub managed: bool,
33 pub query: QueryCaps,
36 pub destinations: DestinationCaps,
38 pub kv: KvCaps,
41 pub graph: bool,
44 pub forks: bool,
46 pub a2a_gateway: bool,
48 pub sessions: bool,
50 pub stream_tenancy: bool,
53 pub watch: bool,
57 pub authz: bool,
60 pub filters: FilterCaps,
64 pub versions: Option<OpVersions>,
69 pub backends: Vec<BackendDescriptor>,
71 pub hello: HelloOutcome,
74}
75
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
78pub enum HelloOutcome {
79 #[default]
81 Unknown,
82 Answered,
84 Rejected,
87 Failed,
89}
90
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub struct QueryCaps {
94 pub available: bool,
96 pub consistency: Consistency,
101 pub keyword: bool,
106 pub cursor_paging: bool,
108 pub cancellation: bool,
110 pub execution_status: bool,
112}
113
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
115pub struct DestinationCaps {
116 pub available: bool,
117 pub consistency: CheckpointReadConsistency,
118}
119
120#[derive(Debug, Clone, Default, PartialEq, Eq)]
122pub struct FilterCaps {
123 pub native: bool,
127 pub catalog: bool,
130 pub group_policy_reads: bool,
135 pub evaluation: Option<FilterAnnounce>,
139}
140
141impl FilterCaps {
142 #[must_use]
145 pub fn evaluates(&self, evaluator_version: u32, codec: FilterCodec) -> bool {
146 self.evaluation
147 .as_ref()
148 .is_none_or(|evaluation| evaluation.evaluates(evaluator_version, codec))
149 }
150}
151
152#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
154pub struct KvCaps {
155 pub available: bool,
157 pub cas: bool,
161 pub cas_fenced: bool,
166 pub fenced_leases: bool,
173}
174
175impl Capabilities {
176 pub const OPEN: Self = Self {
178 managed: false,
179 query: QueryCaps {
180 available: false,
181 consistency: Consistency::Eventual,
182 keyword: false,
183 cursor_paging: false,
184 cancellation: false,
185 execution_status: false,
186 },
187 destinations: DestinationCaps {
188 available: false,
189 consistency: CheckpointReadConsistency::PotentiallyStale,
190 },
191 kv: KvCaps {
192 available: false,
193 cas: false,
194 cas_fenced: false,
195 fenced_leases: false,
196 },
197 graph: false,
198 forks: false,
199 a2a_gateway: false,
200 sessions: false,
201 stream_tenancy: false,
202 watch: false,
203 authz: false,
204 filters: FilterCaps {
205 native: false,
206 catalog: false,
207 group_policy_reads: false,
208 evaluation: None,
209 },
210 versions: None,
211 backends: Vec::new(),
212 hello: HelloOutcome::Unknown,
213 };
214
215 pub fn is_open_only(&self) -> bool {
218 let mut open = Self::OPEN;
219 open.hello = self.hello;
220 *self == open
221 }
222
223 pub fn backend(&self, resource_id: BackendResourceId) -> Option<&BackendDescriptor> {
224 self.backends
225 .iter()
226 .find(|backend| backend.resource_id == resource_id)
227 }
228
229 pub fn enabled_backends(&self) -> impl Iterator<Item = &BackendDescriptor> {
230 self.backends
231 .iter()
232 .filter(|backend| backend.desired_state == BackendDesiredState::Enabled)
233 }
234
235 pub fn unready_backends(&self) -> impl Iterator<Item = &BackendDescriptor> {
236 self.enabled_backends().filter(|backend| {
237 backend.observed_state != BackendObservedState::Ready || !backend.readiness.ready
238 })
239 }
240
241 pub fn readiness_reasons(
242 &self,
243 resource_id: BackendResourceId,
244 ) -> Option<&[BackendReadinessReason]> {
245 self.backend(resource_id)
246 .map(|backend| backend.readiness.reasons.as_slice())
247 }
248
249 pub fn is_ready(&self) -> bool {
250 if !self.managed {
251 return false;
252 }
253 let mut enabled = self.enabled_backends();
254 let Some(first) = enabled.next() else {
255 return false;
256 };
257 let ready = |backend: &BackendDescriptor| {
258 backend.observed_state == BackendObservedState::Ready && backend.readiness.ready
259 };
260 ready(first) && enabled.all(ready)
261 }
262
263 #[must_use]
268 pub fn with_managed(mut self, value: bool) -> Self {
269 self.managed = value;
270 self
271 }
272
273 #[must_use]
275 pub fn with_query(mut self, value: bool) -> Self {
276 self.query.available = value;
277 self
278 }
279
280 #[must_use]
283 pub fn with_query_consistency(mut self, level: Consistency) -> Self {
284 self.query.consistency = level;
285 self
286 }
287
288 #[must_use]
290 pub fn with_query_keyword(mut self, value: bool) -> Self {
291 self.query.keyword = value;
292 self
293 }
294
295 #[must_use]
296 pub fn with_query_execution(mut self, paging: bool, cancellation: bool, status: bool) -> Self {
297 self.query.cursor_paging = paging;
298 self.query.cancellation = cancellation;
299 self.query.execution_status = status;
300 self
301 }
302
303 #[must_use]
304 pub fn with_destinations(mut self, value: bool) -> Self {
305 self.destinations.available = value;
306 self
307 }
308
309 #[must_use]
310 pub fn with_destination_consistency(mut self, value: CheckpointReadConsistency) -> Self {
311 self.destinations.consistency = value;
312 self
313 }
314
315 #[must_use]
317 pub fn with_kv(mut self, value: bool) -> Self {
318 self.kv.available = value;
319 self
320 }
321
322 #[must_use]
324 pub fn with_kv_cas(mut self, value: bool) -> Self {
325 self.kv.cas = value;
326 self
327 }
328
329 #[must_use]
331 pub fn with_kv_fenced_leases(mut self, value: bool) -> Self {
332 self.kv.fenced_leases = value;
333 self
334 }
335
336 #[must_use]
338 pub fn with_kv_cas_fenced(mut self, value: bool) -> Self {
339 self.kv.cas_fenced = value;
340 self
341 }
342
343 #[must_use]
345 pub fn with_graph(mut self, value: bool) -> Self {
346 self.graph = value;
347 self
348 }
349
350 #[must_use]
352 pub fn with_forks(mut self, value: bool) -> Self {
353 self.forks = value;
354 self
355 }
356
357 #[must_use]
359 pub fn with_a2a_gateway(mut self, value: bool) -> Self {
360 self.a2a_gateway = value;
361 self
362 }
363
364 #[must_use]
366 pub fn with_sessions(mut self, value: bool) -> Self {
367 self.sessions = value;
368 self
369 }
370
371 #[must_use]
373 pub fn with_stream_tenancy(mut self, value: bool) -> Self {
374 self.stream_tenancy = value;
375 self
376 }
377
378 #[must_use]
381 pub fn with_filters(mut self, native: bool, catalog: bool) -> Self {
382 self.filters = FilterCaps {
383 native,
384 catalog,
385 group_policy_reads: native,
386 evaluation: None,
387 };
388 self
389 }
390
391 #[must_use]
393 pub fn with_versions(mut self, value: Option<OpVersions>) -> Self {
394 self.versions = value;
395 self
396 }
397
398 #[must_use]
400 pub fn with_backends(mut self, value: Vec<BackendDescriptor>) -> Self {
401 self.backends = value;
402 self
403 }
404
405 #[cfg(any(feature = "streaming", test))]
411 pub(crate) fn merge_features(&mut self, versions: &OpVersions) {
412 use laser_wire::hello::feature;
413 self.kv.cas |= versions.has_feature(feature::KV_CAS);
414 self.kv.cas_fenced |= versions.has_feature(feature::KV_CAS_FENCED);
415 self.kv.fenced_leases |= versions.has_feature(feature::KV_FENCED_LEASES);
416 self.kv.cas_fenced |= self.kv.fenced_leases;
417 self.sessions |= versions.has_feature(feature::SESSIONS);
418 self.stream_tenancy |= versions.has_feature(feature::STREAM_TENANCY);
419 self.query.keyword |= versions.has_feature(feature::KEYWORD_SEARCH);
420 self.watch |= versions.has_feature(feature::WATCH);
421 self.authz |= versions.has_feature(feature::AUTHZ);
422 self.filters.native |= versions.has_feature(feature::CONSUMER_FILTERS);
423 self.filters.group_policy_reads |= versions.has_feature(feature::GROUP_POLICY_READS);
424 self.destinations.available |=
425 versions.checkpoint > 0 && versions.has_feature(feature::DESTINATIONS);
426 if self.destinations.available {
427 self.destinations.consistency = CheckpointReadConsistency::Linearizable;
428 }
429 if versions.has_feature(feature::STRONG_CONSISTENCY) {
430 self.query.consistency = self.query.consistency.max(Consistency::Strong);
431 } else if versions.has_feature(feature::READ_YOUR_WRITES) {
432 self.query.consistency = self.query.consistency.max(Consistency::ReadYourWrites);
433 }
434 }
435
436 pub fn serves_consistency(&self, level: Consistency) -> bool {
441 level <= self.query.consistency
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use laser_wire::hello::feature;
449
450 fn backend(
451 id: u128,
452 desired: BackendDesiredState,
453 observed: BackendObservedState,
454 readiness: BackendReadiness,
455 ) -> BackendDescriptor {
456 BackendDescriptor::new(
457 BackendResourceId::from_u128(id),
458 BackendMode::Operational,
459 format!("backend-{id}"),
460 BackendImplementation {
461 kind: "embedded".to_owned(),
462 version: "1.0.0".to_owned(),
463 },
464 1,
465 1,
466 )
467 .with_state(desired, observed, readiness)
468 }
469
470 #[test]
471 fn given_advertised_feature_bits_when_merged_then_should_light_up_the_capabilities() {
472 let mut caps = Capabilities::OPEN;
473 let versions = OpVersions::new(1, 1, 1, 1).with_features(
474 feature::KV_CAS
475 | feature::READ_YOUR_WRITES
476 | feature::KV_CAS_FENCED
477 | feature::KV_FENCED_LEASES
478 | feature::SESSIONS,
479 );
480 caps.merge_features(&versions);
481 assert!(caps.sessions);
482 assert!(caps.kv.cas, "KV_CAS bit should set kv.cas");
483 assert!(
484 caps.kv.cas_fenced,
485 "KV_CAS_FENCED bit should set kv.cas_fenced"
486 );
487 assert!(
488 caps.kv.fenced_leases,
489 "KV_FENCED_LEASES bit should set kv.fenced_leases"
490 );
491 assert_eq!(
492 caps.query.consistency,
493 Consistency::ReadYourWrites,
494 "the read-your-writes bit should raise the level"
495 );
496 }
497
498 #[test]
499 fn given_the_consumer_filters_bit_when_merged_then_should_light_up_native_filters_only() {
500 let mut caps = Capabilities::OPEN;
501 caps.merge_features(&OpVersions::new(1, 1, 1, 1).with_features(feature::CONSUMER_FILTERS));
502 assert!(caps.filters.native);
503 assert!(
504 !caps.filters.catalog,
505 "the catalog needs a ready backend with the filter op version"
506 );
507 }
508
509 #[test]
510 fn given_the_strong_bit_when_merged_then_should_raise_the_level_to_strong() {
511 let mut caps = Capabilities::OPEN;
512 caps.merge_features(
513 &OpVersions::new(1, 1, 1, 1).with_features(feature::STRONG_CONSISTENCY),
514 );
515 assert_eq!(caps.query.consistency, Consistency::Strong);
516 assert!(caps.serves_consistency(Consistency::ReadYourWrites));
518 }
519
520 #[test]
521 fn given_only_fenced_leases_when_merged_then_should_also_advertise_fenced_cas() {
522 let mut caps = Capabilities::OPEN;
523 caps.merge_features(&OpVersions::new(1, 1, 1, 1).with_features(feature::KV_FENCED_LEASES));
524 assert!(caps.kv.fenced_leases);
525 assert!(
526 caps.kv.cas_fenced,
527 "the full fenced-lease contract subsumes fenced CAS"
528 );
529 }
530
531 #[test]
532 fn given_an_explicit_sub_feature_when_merging_unadvertised_then_should_preserve_it() {
533 let mut caps = Capabilities::OPEN.with_kv_cas(true);
534 caps.merge_features(&OpVersions::new(1, 1, 1, 1));
535 assert!(
536 caps.kv.cas,
537 "an explicit sub-feature must survive the merge"
538 );
539 }
540
541 #[test]
542 fn given_open_capabilities_when_checking_consistency_then_should_serve_only_eventual() {
543 assert!(Capabilities::OPEN.serves_consistency(Consistency::Eventual));
544 assert!(!Capabilities::OPEN.serves_consistency(Consistency::ReadYourWrites));
545 assert!(!Capabilities::OPEN.serves_consistency(Consistency::Strong));
546 }
547
548 #[test]
549 fn given_read_your_writes_only_when_checked_then_should_not_serve_strong() {
550 let ryw = Capabilities::OPEN.with_query_consistency(Consistency::ReadYourWrites);
551 assert!(ryw.serves_consistency(Consistency::ReadYourWrites));
552 assert!(
553 !ryw.serves_consistency(Consistency::Strong),
554 "read-your-writes must not imply strong"
555 );
556 }
557
558 #[test]
559 fn given_strong_when_checked_then_should_subsume_read_your_writes() {
560 let strong = Capabilities::OPEN.with_query_consistency(Consistency::Strong);
561 assert!(strong.serves_consistency(Consistency::Strong));
562 assert!(
563 strong.serves_consistency(Consistency::ReadYourWrites),
564 "strong must subsume read-your-writes"
565 );
566 }
567
568 #[test]
569 fn given_advertised_backends_when_set_then_should_expose_them_and_open_has_none() {
570 assert!(Capabilities::OPEN.backends.is_empty());
571 let caps = Capabilities::OPEN.with_backends(vec![
572 BackendDescriptor::new(
573 laser_wire::destination::BackendResourceId::from_u128(1),
574 BackendMode::Operational,
575 "Embedded",
576 BackendImplementation {
577 kind: "embedded".to_owned(),
578 version: "1.0.0".to_owned(),
579 },
580 1,
581 1,
582 ),
583 BackendDescriptor::new(
584 laser_wire::destination::BackendResourceId::from_u128(2),
585 BackendMode::Lakehouse,
586 "Warehouse",
587 BackendImplementation {
588 kind: "columnar".to_owned(),
589 version: "2.1.0".to_owned(),
590 },
591 1,
592 1,
593 ),
594 ]);
595 assert_eq!(caps.backends.len(), 2);
596 assert_eq!(caps.backends[1].label, "Warehouse");
597 assert!(!caps.is_open_only() && !caps.managed);
598 }
599
600 #[test]
601 fn given_enabled_ready_backends_when_checking_readiness_then_should_be_ready() {
602 let first_id = BackendResourceId::from_u128(1);
603 let caps = Capabilities::OPEN.with_managed(true).with_backends(vec![
604 backend(
605 1,
606 BackendDesiredState::Enabled,
607 BackendObservedState::Ready,
608 BackendReadiness::ready(1),
609 ),
610 backend(
611 2,
612 BackendDesiredState::Disabled,
613 BackendObservedState::Disabled,
614 BackendReadiness::not_ready(BackendReadinessCode::Disabled),
615 ),
616 ]);
617 assert!(caps.is_ready());
618 assert_eq!(
619 caps.backend(first_id).map(|backend| backend.label.as_str()),
620 Some("backend-1")
621 );
622 assert_eq!(caps.enabled_backends().count(), 1);
623 assert_eq!(caps.unready_backends().count(), 0);
624 assert_eq!(caps.readiness_reasons(first_id), Some([].as_slice()));
625 }
626
627 #[test]
628 fn given_starting_backend_when_checking_readiness_then_reason_should_be_exposed() {
629 let id = BackendResourceId::from_u128(3);
630 let caps = Capabilities::OPEN
631 .with_managed(true)
632 .with_backends(vec![backend(
633 3,
634 BackendDesiredState::Enabled,
635 BackendObservedState::Starting,
636 BackendReadiness::not_ready(BackendReadinessCode::ConfigurationPending),
637 )]);
638 assert!(!caps.is_ready());
639 assert_eq!(caps.unready_backends().count(), 1);
640 assert_eq!(
641 caps.readiness_reasons(id)
642 .and_then(|reasons| reasons.first())
643 .map(|reason| reason.code),
644 Some(BackendReadinessCode::ConfigurationPending)
645 );
646 }
647
648 #[test]
649 fn given_open_capabilities_when_the_probe_outcome_changes_then_should_still_be_open_only() {
650 for hello in [
651 HelloOutcome::Unknown,
652 HelloOutcome::Answered,
653 HelloOutcome::Rejected,
654 HelloOutcome::Failed,
655 ] {
656 let mut caps = Capabilities::OPEN;
657 caps.hello = hello;
658 assert!(caps.is_open_only());
659 assert!(!caps.with_managed(true).is_open_only());
660 }
661 }
662
663 #[test]
664 fn given_managed_without_enabled_backends_when_checking_readiness_then_should_not_be_ready() {
665 assert!(!Capabilities::OPEN.with_managed(true).is_ready());
666 }
667}