1use crate::destination::{BackendResourceId, FileFormat, TableFormat};
2use crate::error::InvalidError;
3use crate::query::{Consistency, SqlDialect};
4use crate::schema::LogicalTypeKind;
5use crate::topology::WireTopology;
6use crate::validate::Validate;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeSet;
9
10pub mod feature {
17 pub const KV_CAS: u64 = 1 << 0;
19 pub const READ_YOUR_WRITES: u64 = 1 << 1;
21 pub const STRONG_CONSISTENCY: u64 = 1 << 2;
23 pub const KV_CAS_FENCED: u64 = 1 << 3;
25 pub const AGENT_WORKFLOW: u64 = 1 << 4;
27 pub const KEYWORD_SEARCH: u64 = 1 << 5;
29 pub const WATCH: u64 = 1 << 6;
32 pub const AUTHZ: u64 = 1 << 7;
34 pub const DESTINATIONS: u64 = 1 << 8;
36 pub const KV_FENCED_LEASES: u64 = 1 << 9;
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[non_exhaustive]
51pub struct OpVersions {
52 pub query: u32,
53 pub control: u32,
54 pub kv: u32,
55 pub fork: u32,
56 #[serde(default, skip_serializing_if = "is_zero")]
60 pub agent: u32,
61 #[serde(default, skip_serializing_if = "is_zero")]
66 pub graph: u32,
67 #[serde(default, skip_serializing_if = "is_zero")]
69 pub checkpoint: u32,
70 #[serde(default, skip_serializing_if = "is_zero_u64")]
76 pub features: u64,
77}
78
79fn is_zero(value: &u32) -> bool {
80 *value == 0
81}
82
83fn is_zero_u64(value: &u64) -> bool {
84 *value == 0
85}
86
87impl OpVersions {
88 pub fn new(query: u32, control: u32, kv: u32, fork: u32) -> Self {
91 Self {
92 query,
93 control,
94 kv,
95 fork,
96 agent: 0,
97 graph: 0,
98 checkpoint: 0,
99 features: 0,
100 }
101 }
102
103 #[must_use]
105 pub fn with_agent(mut self, agent: u32) -> Self {
106 self.agent = agent;
107 self
108 }
109
110 #[must_use]
112 pub fn with_graph(mut self, graph: u32) -> Self {
113 self.graph = graph;
114 self
115 }
116
117 #[must_use]
119 pub fn with_checkpoint(mut self, checkpoint: u32) -> Self {
120 self.checkpoint = checkpoint;
121 self
122 }
123
124 #[must_use]
127 pub fn with_features(mut self, features: u64) -> Self {
128 self.features = features;
129 self
130 }
131
132 pub const fn has_feature(&self, bit: u64) -> bool {
134 self.features & bit == bit
135 }
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
144#[non_exhaustive]
145pub struct HelloReply {
146 pub versions: OpVersions,
147}
148
149impl HelloReply {
150 pub fn new(versions: OpVersions) -> Self {
152 Self { versions }
153 }
154}
155
156pub const BACKEND_DESCRIPTOR_VERSION: u32 = 1;
157
158#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
160#[non_exhaustive]
161pub struct BackendDescriptor {
162 pub descriptor_version: u32,
163 pub resource_id: BackendResourceId,
164 pub mode: BackendMode,
165 pub label: String,
166 pub implementation: BackendImplementation,
167 pub observed_backend_generation: u64,
168 pub runtime_configuration_revision: u64,
169 pub desired_state: BackendDesiredState,
170 pub observed_state: BackendObservedState,
171 pub readiness: BackendReadiness,
172 pub materialization: Vec<MaterializationCapability>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub query: Option<QueryCapabilities>,
175 pub schema: SchemaCapabilities,
176 pub maintenance: MaintenanceCapabilities,
177 pub limits: BackendLimits,
178}
179
180impl BackendDescriptor {
181 pub fn new(
182 resource_id: BackendResourceId,
183 mode: BackendMode,
184 label: impl Into<String>,
185 implementation: BackendImplementation,
186 observed_backend_generation: u64,
187 runtime_configuration_revision: u64,
188 ) -> Self {
189 Self {
190 descriptor_version: BACKEND_DESCRIPTOR_VERSION,
191 resource_id,
192 mode,
193 label: label.into(),
194 implementation,
195 observed_backend_generation,
196 runtime_configuration_revision,
197 desired_state: BackendDesiredState::Disabled,
198 observed_state: BackendObservedState::Disabled,
199 readiness: BackendReadiness::not_ready(BackendReadinessCode::Disabled),
200 materialization: Vec::new(),
201 query: None,
202 schema: SchemaCapabilities::default(),
203 maintenance: MaintenanceCapabilities::default(),
204 limits: BackendLimits::default(),
205 }
206 }
207
208 #[must_use]
209 pub fn with_state(
210 mut self,
211 desired_state: BackendDesiredState,
212 observed_state: BackendObservedState,
213 readiness: BackendReadiness,
214 ) -> Self {
215 self.desired_state = desired_state;
216 self.observed_state = observed_state;
217 self.readiness = readiness;
218 self
219 }
220
221 #[must_use]
222 pub fn with_materialization(mut self, capabilities: Vec<MaterializationCapability>) -> Self {
223 self.materialization = capabilities;
224 self
225 }
226
227 #[must_use]
228 pub fn with_query(mut self, capabilities: QueryCapabilities) -> Self {
229 self.query = Some(capabilities);
230 self
231 }
232
233 #[must_use]
234 pub fn with_schema(mut self, capabilities: SchemaCapabilities) -> Self {
235 self.schema = capabilities;
236 self
237 }
238
239 #[must_use]
240 pub fn with_maintenance(mut self, capabilities: MaintenanceCapabilities) -> Self {
241 self.maintenance = capabilities;
242 self
243 }
244
245 #[must_use]
246 pub fn with_limits(mut self, limits: BackendLimits) -> Self {
247 self.limits = limits;
248 self
249 }
250}
251
252impl Validate for BackendDescriptor {
253 fn validate(&self) -> Result<(), InvalidError> {
254 if self.descriptor_version != BACKEND_DESCRIPTOR_VERSION {
255 return Err(InvalidError::new(format!(
256 "backend descriptor version must be {BACKEND_DESCRIPTOR_VERSION}"
257 )));
258 }
259 if self.resource_id.as_u128() == 0
260 || self.observed_backend_generation == 0
261 || self.runtime_configuration_revision == 0
262 {
263 return Err(InvalidError::new(
264 "backend identity and observed revisions must be nonzero",
265 ));
266 }
267 validate_descriptor_text("backend label", &self.label)?;
268 validate_descriptor_text("backend implementation kind", &self.implementation.kind)?;
269 validate_descriptor_text(
270 "backend implementation version",
271 &self.implementation.version,
272 )?;
273 self.readiness.validate()?;
274 if self.readiness.ready && self.observed_state != BackendObservedState::Ready {
275 return Err(InvalidError::new(
276 "ready backend must report the ready observed state",
277 ));
278 }
279 let mut materialization = BTreeSet::new();
280 for capability in &self.materialization {
281 let key = (capability.file_format as u8, capability.table_format as u8);
282 if !materialization.insert(key) {
283 return Err(InvalidError::new(
284 "backend repeats a materialization capability",
285 ));
286 }
287 }
288 if let Some(query) = &self.query {
289 query.validate()?;
290 }
291 if self.readiness.ready {
292 if self.schema.logical_schema && self.limits.max_schema_fields == 0 {
293 return Err(InvalidError::new(
294 "ready logical-schema backend must advertise a schema-field limit",
295 ));
296 }
297 if !self.materialization.is_empty() && self.limits.max_materialization_file_bytes == 0 {
298 return Err(InvalidError::new(
299 "ready materialization backend must advertise a file-size limit",
300 ));
301 }
302 if self.query.is_some()
303 && (self.limits.max_query_rows == 0
304 || self.limits.max_query_bytes == 0
305 || self.limits.max_scan_bytes == 0
306 || self.limits.max_query_micros == 0
307 || self.limits.max_concurrent_queries == 0)
308 {
309 return Err(InvalidError::new(
310 "ready query backend must advertise nonzero query limits",
311 ));
312 }
313 }
314 Ok(())
315 }
316}
317
318#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(rename_all = "snake_case")]
320#[non_exhaustive]
321pub enum BackendMode {
322 Operational,
323 Lakehouse,
324}
325
326#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
327pub struct BackendImplementation {
328 pub kind: String,
329 pub version: String,
330}
331
332#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
333#[serde(rename_all = "snake_case")]
334#[non_exhaustive]
335pub enum BackendDesiredState {
336 Disabled,
337 Enabled,
338}
339
340#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
341#[serde(rename_all = "snake_case")]
342#[non_exhaustive]
343pub enum BackendObservedState {
344 Disabled,
345 Starting,
346 Ready,
347 Degraded,
348 Unavailable,
349}
350
351#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
352pub struct BackendReadiness {
353 pub ready: bool,
354 pub reasons: Vec<BackendReadinessReason>,
355 pub observed_at_micros: u64,
356}
357
358impl BackendReadiness {
359 pub fn not_ready(code: BackendReadinessCode) -> Self {
360 Self {
361 ready: false,
362 reasons: vec![BackendReadinessReason { code, detail: None }],
363 observed_at_micros: 0,
364 }
365 }
366
367 pub fn ready(observed_at_micros: u64) -> Self {
368 Self {
369 ready: true,
370 reasons: Vec::new(),
371 observed_at_micros,
372 }
373 }
374}
375
376impl Validate for BackendReadiness {
377 fn validate(&self) -> Result<(), InvalidError> {
378 if self.ready {
379 if !self.reasons.is_empty() || self.observed_at_micros == 0 {
380 return Err(InvalidError::new(
381 "ready backend requires an observation time and no failure reasons",
382 ));
383 }
384 return Ok(());
385 }
386 if self.reasons.is_empty() {
387 return Err(InvalidError::new(
388 "not-ready backend requires at least one stable reason",
389 ));
390 }
391 if self.reasons.len() > 32 {
392 return Err(InvalidError::new(
393 "backend readiness reason count exceeds cap 32",
394 ));
395 }
396 for reason in &self.reasons {
397 if let Some(detail) = &reason.detail {
398 validate_descriptor_text("backend readiness detail", detail)?;
399 }
400 }
401 Ok(())
402 }
403}
404
405#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
406pub struct BackendReadinessReason {
407 pub code: BackendReadinessCode,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub detail: Option<String>,
410}
411
412#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
413#[serde(rename_all = "snake_case")]
414#[non_exhaustive]
415pub enum BackendReadinessCode {
416 Disabled,
417 ConfigurationPending,
418 ConfigurationRejected,
419 CredentialUnavailable,
420 ObjectStoreUnavailable,
421 CatalogUnavailable,
422 QueryRuntimeUnavailable,
423 GenerationMismatch,
424 ProbeFailed,
425}
426
427#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
428pub struct MaterializationCapability {
429 pub file_format: FileFormat,
430 pub table_format: TableFormat,
431 pub create_table: bool,
432 pub append: bool,
433}
434
435#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
436pub struct QueryCapabilities {
437 pub dialects: Vec<SqlDialect>,
438 pub time_travel: Vec<TimeTravelCapability>,
439 pub consistency: Vec<Consistency>,
440 pub logical_types: Vec<LogicalTypeKind>,
441 pub paging: Vec<QueryPagingCapability>,
442 pub cancellation: bool,
443 pub execution_status: bool,
444 pub raw_sql: bool,
445}
446
447impl Validate for QueryCapabilities {
448 fn validate(&self) -> Result<(), InvalidError> {
449 if self.dialects.is_empty()
450 || self.consistency.is_empty()
451 || self.logical_types.is_empty()
452 || self.paging.is_empty()
453 {
454 return Err(InvalidError::new(
455 "query capabilities require dialect, consistency, and logical-type coverage",
456 ));
457 }
458 ensure_unique(
459 "query dialect",
460 self.dialects.iter().map(|value| *value as u8),
461 )?;
462 ensure_unique(
463 "query time-travel capability",
464 self.time_travel.iter().map(|value| *value as u8),
465 )?;
466 ensure_unique(
467 "query consistency",
468 self.consistency.iter().map(|value| *value as u8),
469 )?;
470 ensure_unique(
471 "query logical type",
472 self.logical_types.iter().map(|value| *value as u8),
473 )?;
474 ensure_unique(
475 "query paging capability",
476 self.paging.iter().map(|value| *value as u8),
477 )?;
478 if self.raw_sql && self.dialects.is_empty() {
479 return Err(InvalidError::new(
480 "raw SQL capability requires an explicit dialect",
481 ));
482 }
483 Ok(())
484 }
485}
486
487fn ensure_unique(label: &str, values: impl IntoIterator<Item = u8>) -> Result<(), InvalidError> {
488 let mut seen = BTreeSet::new();
489 if values.into_iter().all(|value| seen.insert(value)) {
490 Ok(())
491 } else {
492 Err(InvalidError::new(format!("backend repeats a {label}")))
493 }
494}
495
496fn validate_descriptor_text(label: &str, value: &str) -> Result<(), InvalidError> {
497 if value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) {
498 return Err(InvalidError::new(format!(
499 "{label} must contain 1..=4096 bytes without control characters"
500 )));
501 }
502 Ok(())
503}
504
505#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
506#[serde(rename_all = "snake_case")]
507#[non_exhaustive]
508pub enum TimeTravelCapability {
509 SnapshotId,
510 TimestampMicros,
511}
512
513#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(rename_all = "snake_case")]
515#[non_exhaustive]
516pub enum QueryPagingCapability {
517 Offset,
518 Cursor,
519}
520
521#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
522pub struct SchemaCapabilities {
523 pub logical_schema: bool,
524 pub arrow_ipc_stream: bool,
525 pub schema_evolution: bool,
526}
527
528#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
529pub struct MaintenanceCapabilities {
530 pub expire_snapshots: bool,
531 pub remove_orphan_files: bool,
532 pub compact_data_files: bool,
533}
534
535#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
536pub struct BackendLimits {
537 pub max_query_rows: u64,
538 pub max_query_bytes: u64,
539 pub max_scan_bytes: u64,
540 pub max_query_micros: u64,
541 pub max_concurrent_queries: u32,
542 pub max_schema_fields: u32,
543 pub max_materialization_file_bytes: u64,
544}
545
546const fn backend_ready_by_default() -> bool {
556 true
557}
558
559const fn backend_is_ready(ready: &bool) -> bool {
560 *ready
561}
562
563#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
564#[non_exhaustive]
565pub struct BackendAnnounce {
566 pub versions: OpVersions,
567 #[serde(
568 default = "backend_ready_by_default",
569 skip_serializing_if = "backend_is_ready"
570 )]
571 pub ready: bool,
572 #[serde(default, skip_serializing_if = "Vec::is_empty")]
577 pub backends: Vec<BackendDescriptor>,
578 #[serde(default, skip_serializing_if = "Option::is_none")]
583 pub topology: Option<WireTopology>,
584}
585
586impl BackendAnnounce {
587 pub fn new(versions: OpVersions) -> Self {
589 Self {
590 versions,
591 ready: true,
592 backends: Vec::new(),
593 topology: None,
594 }
595 }
596
597 #[must_use]
598 pub const fn unavailable(mut self) -> Self {
599 self.ready = false;
600 self
601 }
602
603 #[must_use]
605 pub fn with_backends(mut self, backends: Vec<BackendDescriptor>) -> Self {
606 self.backends = backends;
607 self
608 }
609
610 #[must_use]
612 pub fn with_topology(mut self, topology: WireTopology) -> Self {
613 self.topology = Some(topology);
614 self
615 }
616}
617
618impl Validate for BackendAnnounce {
619 fn validate(&self) -> Result<(), InvalidError> {
620 let mut resources = BTreeSet::new();
621 for backend in &self.backends {
622 backend.validate()?;
623 if !resources.insert(backend.resource_id) {
624 return Err(InvalidError::new(
625 "backend announcement repeats a resource id",
626 ));
627 }
628 }
629 if self.ready
630 && !self.backends.is_empty()
631 && !self.backends.iter().any(|backend| backend.readiness.ready)
632 {
633 return Err(InvalidError::new(
634 "ready backend announcement must contain a ready backend",
635 ));
636 }
637 Ok(())
638 }
639}
640
641#[cfg(all(test, feature = "cbor"))]
642mod tests {
643 use super::*;
644 use crate::codes::{CONTROL_OP_VERSION, FORK_OP_VERSION, KV_OP_VERSION, QUERY_OP_VERSION};
645 use crate::framing::{decode_named, encode_named};
646
647 fn backend(resource_id: u128, mode: BackendMode, label: &str, kind: &str) -> BackendDescriptor {
648 BackendDescriptor::new(
649 BackendResourceId::from_u128(resource_id),
650 mode,
651 label,
652 BackendImplementation {
653 kind: kind.to_owned(),
654 version: "1.0.0".to_owned(),
655 },
656 1,
657 1,
658 )
659 }
660
661 #[test]
662 fn given_a_hello_reply_when_round_tripped_then_should_preserve_versions() {
663 let reply = HelloReply::new(OpVersions::new(
666 QUERY_OP_VERSION,
667 CONTROL_OP_VERSION,
668 KV_OP_VERSION,
669 FORK_OP_VERSION,
670 ));
671 let bytes = encode_named(&reply).expect("hello reply serializes");
672 let back: HelloReply = decode_named(&bytes).expect("hello reply deserializes");
673 assert_eq!(back, reply);
674 }
675
676 #[test]
677 fn given_a_backend_announce_when_round_tripped_then_should_preserve_features() {
678 let announce = BackendAnnounce::new(
679 OpVersions::new(
680 QUERY_OP_VERSION,
681 CONTROL_OP_VERSION,
682 KV_OP_VERSION,
683 FORK_OP_VERSION,
684 )
685 .with_features(feature::KV_CAS | feature::READ_YOUR_WRITES),
686 );
687 let bytes = encode_named(&announce).expect("serializes");
688 let back: BackendAnnounce = decode_named(&bytes).expect("deserializes");
689 assert_eq!(back, announce);
690 assert!(back.versions.has_feature(feature::KV_CAS));
691 }
692
693 #[test]
694 fn given_an_unavailable_backend_announce_when_round_tripped_then_should_stay_unavailable() {
695 let announce = BackendAnnounce::new(OpVersions::new(
696 QUERY_OP_VERSION,
697 CONTROL_OP_VERSION,
698 KV_OP_VERSION,
699 FORK_OP_VERSION,
700 ))
701 .unavailable();
702 let bytes = encode_named(&announce).expect("serializes");
703 let back: BackendAnnounce = decode_named(&bytes).expect("deserializes");
704 assert!(!back.ready);
705 }
706
707 #[test]
708 fn given_an_empty_hello_body_when_decoded_then_should_yield_no_versions() {
709 assert!(decode_named::<HelloReply>(&[]).is_err());
712 }
713
714 #[test]
715 fn given_advertised_backends_when_round_tripped_then_should_preserve_them_and_skip_empty() {
716 let announce = BackendAnnounce::new(OpVersions::new(
717 QUERY_OP_VERSION,
718 CONTROL_OP_VERSION,
719 KV_OP_VERSION,
720 FORK_OP_VERSION,
721 ))
722 .with_backends(vec![
723 backend(1, BackendMode::Operational, "Embedded", "embedded"),
724 backend(2, BackendMode::Lakehouse, "Analytics warehouse", "columnar"),
725 ]);
726 let bytes = encode_named(&announce).expect("encodes");
727 let back: BackendAnnounce = decode_named(&bytes).expect("decodes");
728 assert_eq!(back, announce);
729 assert_eq!(back.backends.len(), 2);
730 assert_eq!(
731 back.backends[1].resource_id,
732 BackendResourceId::from_u128(2)
733 );
734 assert_eq!(back.backends[1].implementation.kind, "columnar");
735 assert_eq!(back.backends[1].label, "Analytics warehouse");
736
737 let plain = BackendAnnounce::new(OpVersions::new(1, 1, 1, 1));
740 let json = serde_json::to_string(&plain).expect("json");
741 assert!(!json.contains("backends"), "empty backends omitted: {json}");
742 }
743
744 #[test]
745 fn given_announce_without_topology_when_decoded_then_should_default_none() {
746 let announce = BackendAnnounce::new(OpVersions::new(
747 QUERY_OP_VERSION,
748 CONTROL_OP_VERSION,
749 KV_OP_VERSION,
750 FORK_OP_VERSION,
751 ));
752 let bytes = encode_named(&announce).expect("encodes");
753 let back: BackendAnnounce = decode_named(&bytes).expect("decodes");
754 assert_eq!(back.topology, None);
755 let json = serde_json::to_string(&announce).expect("json");
756 assert!(
757 !json.contains("topology"),
758 "absent topology omitted: {json}"
759 );
760 }
761
762 #[test]
763 fn given_announce_with_topology_when_round_tripped_then_should_preserve_it() {
764 let custom = WireTopology {
765 ops_stream: "custom-ops".to_owned(),
766 ..WireTopology::default()
767 };
768 let announce = BackendAnnounce::new(OpVersions::new(
769 QUERY_OP_VERSION,
770 CONTROL_OP_VERSION,
771 KV_OP_VERSION,
772 FORK_OP_VERSION,
773 ))
774 .with_topology(custom.clone());
775 let bytes = encode_named(&announce).expect("encodes");
776 let back: BackendAnnounce = decode_named(&bytes).expect("decodes");
777 assert_eq!(back.topology, Some(custom));
778 }
779
780 #[test]
781 fn given_advertised_features_when_round_tripped_then_should_preserve_bits_and_skip_zero() {
782 let versions = OpVersions::new(
783 QUERY_OP_VERSION,
784 CONTROL_OP_VERSION,
785 KV_OP_VERSION,
786 FORK_OP_VERSION,
787 )
788 .with_features(feature::KV_CAS | feature::READ_YOUR_WRITES);
789 assert!(versions.has_feature(feature::KV_CAS));
790 assert!(versions.has_feature(feature::READ_YOUR_WRITES));
791 assert!(!versions.has_feature(feature::STRONG_CONSISTENCY));
792 assert!(versions.has_feature(feature::KV_CAS | feature::READ_YOUR_WRITES));
794 assert!(!versions.has_feature(feature::KV_CAS | feature::STRONG_CONSISTENCY));
795 let reply = HelloReply::new(versions);
796 let bytes = encode_named(&reply).expect("encodes");
797 let back: HelloReply = decode_named(&bytes).expect("decodes");
798 assert_eq!(back, reply);
799 assert!(back.versions.has_feature(feature::READ_YOUR_WRITES));
800 let plain = HelloReply::new(OpVersions::new(1, 1, 1, 1));
803 let json = serde_json::to_string(&plain).expect("json");
804 assert!(!json.contains("features"), "zero features omitted: {json}");
805 }
806}