Skip to main content

laser_wire/
hello.rs

1use crate::destination::BackendResourceId;
2use crate::error::InvalidError;
3use crate::filter::{FILTER_EVALUATOR_VERSION, FilterCodec};
4use crate::query::{AggFunc, Consistency, SqlDialect};
5use crate::schema::LogicalTypeKind;
6use crate::topology::WireTopology;
7use crate::validate::Validate;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10
11/// Capability feature bits advertised in [`OpVersions::features`]. Each constant
12/// names one managed sub-feature a server serves beyond the base surface, so a
13/// binary client feature-detects it (before attempting the op) the way the HTTP
14/// surface reads the boolean flags on `Capabilities`. Additive and pinned
15/// cross-repo: a new bit is set by a newer server and ignored by an older
16/// client (which simply does not light up that capability).
17pub mod feature {
18    /// The key-value store serves compare-and-swap (`AGDX_KV_CAS`).
19    pub const KV_CAS: u64 = 1 << 0;
20    /// The query surface honors `Consistency::ReadYourWrites`.
21    pub const READ_YOUR_WRITES: u64 = 1 << 1;
22    /// Retired with linearizable query reads. No server sets it and the bit is
23    /// never reused.
24    pub const STRONG_CONSISTENCY: u64 = 1 << 2;
25    /// The key-value store serves fenced compare-and-swap (`AGDX_KV_CAS_FENCED`).
26    pub const KV_CAS_FENCED: u64 = 1 << 3;
27    /// Retired with the run registry. No server sets it and the bit is never
28    /// reused.
29    pub const AGENT_WORKFLOW: u64 = 1 << 4;
30    /// The query surface serves lexical relevance search (`Query.text`).
31    pub const KEYWORD_SEARCH: u64 = 1 << 5;
32    /// The deployment publishes the change feed (`ChangeRecord`s on the
33    /// changes topic) for bindings that opt into `notify`.
34    pub const WATCH: u64 = 1 << 6;
35    /// The streaming server serves the authorization control band (`AGDX_AUTHZ_*`).
36    pub const AUTHZ: u64 = 1 << 7;
37    /// Retired with lakehouse destinations. No server sets it and the bit is
38    /// never reused.
39    pub const DESTINATIONS: u64 = 1 << 8;
40    /// The key-value store serves the revocable fenced-lease contract at
41    /// `KV_LEASE_OP_VERSION`: holder-scoped acquire, renewal
42    /// (`AGDX_KV_LEASE_RENEW`), holder-and-fence-validated release, fenced
43    /// compare-and-swap requiring a live lease, and the barriered read
44    /// (`KvGet::min_position`). Subsumes [`KV_CAS_FENCED`]: a client must not
45    /// send the reshaped lease ops (or a barriered read) to a server without
46    /// this bit, which would silently decode them under the old contract.
47    pub const KV_FENCED_LEASES: u64 = 1 << 9;
48    /// The streaming server serves consumer filters: filtered reads, fenced
49    /// acknowledgments, previews, sample tests, and validation
50    /// (`AGDX_FILTERED_POLL` and the rest of the delivery band's server-native
51    /// decade). Server-native, so it holds with the managed backend disabled.
52    /// The saved-filter catalog additionally needs the backend's `filter` op
53    /// version.
54    pub const CONSUMER_FILTERS: u64 = 1 << 10;
55    /// The streaming server serves group-aware reads: `FilterRef::Group`
56    /// resolves a consumer group's own policy, an unbound group is delivered
57    /// unfiltered without payload decoding, pages and acknowledgments carry the
58    /// execution mode and policy generation, and the request may bound the
59    /// examined records and wait for a catalog position. A client must not send
60    /// the automatic selector to a server without this bit.
61    pub const GROUP_POLICY_READS: u64 = 1 << 11;
62    /// The managed backend serves session reads for one named stream.
63    pub const SESSIONS: u64 = 1 << 12;
64    /// The managed backend runs in stream tenancy: every managed name is
65    /// scoped to one stream, and the change feed and dead letters of each
66    /// stream ride their own ops topics.
67    pub const STREAM_TENANCY: u64 = 1 << 13;
68    /// The managed backend serves whole-record projections, control replies
69    /// that wait for the applied outcome, and binding status on the projection
70    /// browse.
71    pub const MATERIALIZATIONS: u64 = 1 << 14;
72}
73
74/// The wire op versions a server accepts, one per surface, plus the capability
75/// feature bits it advertises. A pinned wire shape, mirrored by the HTTP
76/// capabilities `versions` block.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[non_exhaustive]
79pub struct OpVersions {
80    pub query: u32,
81    pub control: u32,
82    pub kv: u32,
83    pub fork: u32,
84    /// The agent envelope (AGDX) version LaserData Cloud consumes for its
85    /// conversation projections. `0` means "not advertised" and is skipped on
86    /// encode, so pre-AGDX hello frames stay byte-identical and decode unchanged.
87    #[serde(default, skip_serializing_if = "is_zero")]
88    pub agent: u32,
89    /// The knowledge-graph op version served. `0` means not served, skipped on
90    /// encode so a pre-graph hello frame stays byte-identical. Mirrors the
91    /// `managed_graph` HTTP capability flag. (Agentic memory rides this plus the
92    /// query surface, so it has no op version of its own.)
93    #[serde(default, skip_serializing_if = "is_zero")]
94    pub graph: u32,
95    /// The consumer-filter catalog op version served by the managed backend. Zero
96    /// means the catalog (saved filters and group bindings) is not served.
97    #[serde(default, skip_serializing_if = "is_zero")]
98    pub filter: u32,
99    /// Capability feature bits (see [`feature`]): managed sub-features served
100    /// beyond the base surface (compare-and-swap, read-your-writes, and the
101    /// rest). `0` (the default) is skipped on encode, so a pre-feature
102    /// hello reply stays byte-identical and an old client just sees no extra
103    /// capabilities.
104    #[serde(default, skip_serializing_if = "is_zero_u64")]
105    pub features: u64,
106}
107
108fn is_zero(value: &u32) -> bool {
109    *value == 0
110}
111
112fn is_zero_u64(value: &u64) -> bool {
113    *value == 0
114}
115
116impl OpVersions {
117    /// Versions per surface. The struct is `#[non_exhaustive]` (new surfaces
118    /// land without a breaking change), so this is the constructor.
119    pub fn new(query: u32, control: u32, kv: u32, fork: u32) -> Self {
120        Self {
121            query,
122            control,
123            kv,
124            fork,
125            agent: 0,
126            graph: 0,
127            filter: 0,
128            features: 0,
129        }
130    }
131
132    /// Returns a copy advertising this agent-envelope (AGDX) version.
133    #[must_use]
134    pub fn with_agent(mut self, agent: u32) -> Self {
135        self.agent = agent;
136        self
137    }
138
139    /// Returns a copy advertising the knowledge-graph op version served.
140    #[must_use]
141    pub fn with_graph(mut self, graph: u32) -> Self {
142        self.graph = graph;
143        self
144    }
145
146    /// Returns a copy advertising the consumer-filter catalog version served.
147    #[must_use]
148    pub fn with_filter(mut self, filter: u32) -> Self {
149        self.filter = filter;
150        self
151    }
152
153    /// Returns a copy advertising the capability feature bits in `features`
154    /// (an OR of [`feature`] constants).
155    #[must_use]
156    pub fn with_features(mut self, features: u64) -> Self {
157        self.features = features;
158        self
159    }
160
161    /// Whether a [`feature`] bit (or set of bits) is advertised.
162    pub const fn has_feature(&self, bit: u64) -> bool {
163        self.features & bit == bit
164    }
165}
166
167/// Body of the `AGDX_HELLO` probe reply: the wire op versions the server (and
168/// its managed backend) accepts, mirroring the HTTP capabilities `versions`
169/// block. A pinned wire shape. Pre-versioned
170/// servers answer the probe with an empty body, which a client treats as "no
171/// versions advertised", never an error.
172#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
173#[non_exhaustive]
174pub struct HelloReply {
175    pub versions: OpVersions,
176}
177
178impl HelloReply {
179    /// Constructor for the non-exhaustive wire struct.
180    pub fn new(versions: OpVersions) -> Self {
181        Self { versions }
182    }
183}
184
185pub const BACKEND_DESCRIPTOR_VERSION: u32 = 1;
186
187/// One observed backend resource and its structured, secret-free capabilities.
188#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
189#[non_exhaustive]
190pub struct BackendDescriptor {
191    pub descriptor_version: u32,
192    pub resource_id: BackendResourceId,
193    pub mode: BackendMode,
194    pub label: String,
195    pub implementation: BackendImplementation,
196    pub observed_backend_generation: u64,
197    pub runtime_configuration_revision: u64,
198    pub desired_state: BackendDesiredState,
199    pub observed_state: BackendObservedState,
200    pub readiness: BackendReadiness,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub query: Option<QueryCapabilities>,
203    pub schema: SchemaCapabilities,
204    pub limits: BackendLimits,
205}
206
207impl BackendDescriptor {
208    pub fn new(
209        resource_id: BackendResourceId,
210        mode: BackendMode,
211        label: impl Into<String>,
212        implementation: BackendImplementation,
213        observed_backend_generation: u64,
214        runtime_configuration_revision: u64,
215    ) -> Self {
216        Self {
217            descriptor_version: BACKEND_DESCRIPTOR_VERSION,
218            resource_id,
219            mode,
220            label: label.into(),
221            implementation,
222            observed_backend_generation,
223            runtime_configuration_revision,
224            desired_state: BackendDesiredState::Disabled,
225            observed_state: BackendObservedState::Disabled,
226            readiness: BackendReadiness::not_ready(BackendReadinessCode::Disabled),
227            query: None,
228            schema: SchemaCapabilities::default(),
229            limits: BackendLimits::default(),
230        }
231    }
232
233    #[must_use]
234    pub fn with_state(
235        mut self,
236        desired_state: BackendDesiredState,
237        observed_state: BackendObservedState,
238        readiness: BackendReadiness,
239    ) -> Self {
240        self.desired_state = desired_state;
241        self.observed_state = observed_state;
242        self.readiness = readiness;
243        self
244    }
245
246    #[must_use]
247    pub fn with_query(mut self, capabilities: QueryCapabilities) -> Self {
248        self.query = Some(capabilities);
249        self
250    }
251
252    #[must_use]
253    pub fn with_schema(mut self, capabilities: SchemaCapabilities) -> Self {
254        self.schema = capabilities;
255        self
256    }
257
258    #[must_use]
259    pub fn with_limits(mut self, limits: BackendLimits) -> Self {
260        self.limits = limits;
261        self
262    }
263}
264
265impl Validate for BackendDescriptor {
266    fn validate(&self) -> Result<(), InvalidError> {
267        if self.descriptor_version != BACKEND_DESCRIPTOR_VERSION {
268            return Err(InvalidError::new(format!(
269                "backend descriptor version must be {BACKEND_DESCRIPTOR_VERSION}"
270            )));
271        }
272        if self.resource_id.as_u128() == 0
273            || self.observed_backend_generation == 0
274            || self.runtime_configuration_revision == 0
275        {
276            return Err(InvalidError::new(
277                "backend identity and observed revisions must be nonzero",
278            ));
279        }
280        validate_descriptor_text("backend label", &self.label)?;
281        validate_descriptor_text("backend implementation kind", &self.implementation.kind)?;
282        validate_descriptor_text(
283            "backend implementation version",
284            &self.implementation.version,
285        )?;
286        self.readiness.validate()?;
287        if self.readiness.ready && self.observed_state != BackendObservedState::Ready {
288            return Err(InvalidError::new(
289                "ready backend must report the ready observed state",
290            ));
291        }
292        if let Some(query) = &self.query {
293            query.validate()?;
294        }
295        if self.readiness.ready {
296            if self.schema.logical_schema && self.limits.max_schema_fields == 0 {
297                return Err(InvalidError::new(
298                    "ready logical-schema backend must advertise a schema-field limit",
299                ));
300            }
301            if self.query.is_some()
302                && (self.limits.max_query_rows == 0
303                    || self.limits.max_query_bytes == 0
304                    || self.limits.max_scan_bytes == 0
305                    || self.limits.max_query_micros == 0
306                    || self.limits.max_concurrent_queries == 0)
307            {
308                return Err(InvalidError::new(
309                    "ready query backend must advertise nonzero query limits",
310                ));
311            }
312        }
313        Ok(())
314    }
315}
316
317#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "snake_case")]
319#[non_exhaustive]
320pub enum BackendMode {
321    Operational,
322}
323
324#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
325pub struct BackendImplementation {
326    pub kind: String,
327    pub version: String,
328}
329
330#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "snake_case")]
332#[non_exhaustive]
333pub enum BackendDesiredState {
334    Disabled,
335    Enabled,
336}
337
338#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340#[non_exhaustive]
341pub enum BackendObservedState {
342    Disabled,
343    Starting,
344    Ready,
345    Degraded,
346    Unavailable,
347}
348
349#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
350pub struct BackendReadiness {
351    pub ready: bool,
352    pub reasons: Vec<BackendReadinessReason>,
353    pub observed_at_micros: u64,
354}
355
356impl BackendReadiness {
357    pub fn not_ready(code: BackendReadinessCode) -> Self {
358        Self {
359            ready: false,
360            reasons: vec![BackendReadinessReason { code, detail: None }],
361            observed_at_micros: 0,
362        }
363    }
364
365    pub fn ready(observed_at_micros: u64) -> Self {
366        Self {
367            ready: true,
368            reasons: Vec::new(),
369            observed_at_micros,
370        }
371    }
372}
373
374impl Validate for BackendReadiness {
375    fn validate(&self) -> Result<(), InvalidError> {
376        if self.ready {
377            if !self.reasons.is_empty() || self.observed_at_micros == 0 {
378                return Err(InvalidError::new(
379                    "ready backend requires an observation time and no failure reasons",
380                ));
381            }
382            return Ok(());
383        }
384        if self.reasons.is_empty() {
385            return Err(InvalidError::new(
386                "not-ready backend requires at least one stable reason",
387            ));
388        }
389        if self.reasons.len() > 32 {
390            return Err(InvalidError::new(
391                "backend readiness reason count exceeds cap 32",
392            ));
393        }
394        for reason in &self.reasons {
395            if let Some(detail) = &reason.detail {
396                validate_descriptor_text("backend readiness detail", detail)?;
397            }
398        }
399        Ok(())
400    }
401}
402
403#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
404pub struct BackendReadinessReason {
405    pub code: BackendReadinessCode,
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub detail: Option<String>,
408}
409
410#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
411#[serde(rename_all = "snake_case")]
412#[non_exhaustive]
413pub enum BackendReadinessCode {
414    Disabled,
415    ConfigurationPending,
416    ConfigurationRejected,
417    CredentialUnavailable,
418    ObjectStoreUnavailable,
419    CatalogUnavailable,
420    QueryRuntimeUnavailable,
421    GenerationMismatch,
422    ProbeFailed,
423    /// The provisioned or configured raw SQL query identity failed its privilege check, so raw SQL is refused while materialization continues.
424    QueryIdentityRejected,
425}
426
427#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
428pub struct QueryCapabilities {
429    pub dialects: Vec<SqlDialect>,
430    pub consistency: Vec<Consistency>,
431    pub logical_types: Vec<LogicalTypeKind>,
432    pub raw_sql: bool,
433    /// The aggregate functions the backend computes.
434    #[serde(default, skip_serializing_if = "Vec::is_empty")]
435    pub aggregates: Vec<AggFunc>,
436    #[serde(default)]
437    pub vector_search: bool,
438    #[serde(default)]
439    pub text_search: bool,
440}
441
442impl Validate for QueryCapabilities {
443    fn validate(&self) -> Result<(), InvalidError> {
444        if self.dialects.is_empty() || self.consistency.is_empty() || self.logical_types.is_empty()
445        {
446            return Err(InvalidError::new(
447                "query capabilities require dialect, consistency, and logical-type coverage",
448            ));
449        }
450        ensure_unique(
451            "query dialect",
452            self.dialects.iter().map(|value| *value as u8),
453        )?;
454        ensure_unique(
455            "query consistency",
456            self.consistency.iter().map(|value| *value as u8),
457        )?;
458        ensure_unique(
459            "query logical type",
460            self.logical_types.iter().map(|value| *value as u8),
461        )?;
462        ensure_unique(
463            "query aggregate",
464            self.aggregates.iter().map(|value| *value as u8),
465        )?;
466        if self.raw_sql && self.dialects.is_empty() {
467            return Err(InvalidError::new(
468                "raw SQL capability requires an explicit dialect",
469            ));
470        }
471        Ok(())
472    }
473}
474
475fn ensure_unique(label: &str, values: impl IntoIterator<Item = u8>) -> Result<(), InvalidError> {
476    let mut seen = BTreeSet::new();
477    if values.into_iter().all(|value| seen.insert(value)) {
478        Ok(())
479    } else {
480        Err(InvalidError::new(format!("backend repeats a {label}")))
481    }
482}
483
484fn validate_descriptor_text(label: &str, value: &str) -> Result<(), InvalidError> {
485    if value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) {
486        return Err(InvalidError::new(format!(
487            "{label} must contain 1..=4096 bytes without control characters"
488        )));
489    }
490    Ok(())
491}
492
493#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
494pub struct SchemaCapabilities {
495    pub logical_schema: bool,
496    pub arrow_ipc_stream: bool,
497    pub schema_evolution: bool,
498}
499
500#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
501pub struct BackendLimits {
502    pub max_query_rows: u64,
503    pub max_query_bytes: u64,
504    pub max_scan_bytes: u64,
505    pub max_query_micros: u64,
506    pub max_concurrent_queries: u32,
507    pub max_schema_fields: u32,
508    /// The largest record body a whole-record row stores.
509    #[serde(default)]
510    pub max_payload_bytes: u64,
511}
512
513/// The managed backend's capability announcement to the streaming server, sent over their
514/// private socket on connect (`AGDX_BACKEND_HELLO_CODE`). The streaming server caches the
515/// `versions` and the advertised `backends`, and relays them verbatim when it answers a
516/// client `AGDX_HELLO` / capabilities probe, so the streaming server never hardcodes feature
517/// bits or backend identities the backend may or may not serve.
518/// This makes the backend the single source of its own capability truth and
519/// keeps the binary `features` bitset and the HTTP capability flags in agreement
520/// with what is actually served. A separate type from [`HelloReply`] because the
521/// direction and sender differ (backend to streaming server, not server to client).
522const fn backend_ready_by_default() -> bool {
523    true
524}
525
526const fn backend_is_ready(ready: &bool) -> bool {
527    *ready
528}
529
530#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
531#[non_exhaustive]
532pub struct BackendAnnounce {
533    pub versions: OpVersions,
534    #[serde(
535        default = "backend_ready_by_default",
536        skip_serializing_if = "backend_is_ready"
537    )]
538    pub ready: bool,
539    /// Materialization backends the server currently exposes (the ones it has
540    /// open). A client routes only to an advertised id. Empty (the default) is
541    /// skipped on encode, so a pre-backends announce stays byte-identical and an
542    /// older reader simply sees no advertised backends.
543    #[serde(default, skip_serializing_if = "Vec::is_empty")]
544    pub backends: Vec<BackendDescriptor>,
545    /// The stream/topic names this deployment uses. Absent (the default) is
546    /// skipped on encode, so a pre-topology announce stays byte-identical and
547    /// an older reader sees no advertised topology (falls back to its
548    /// own [`WireTopology::default`]).
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub topology: Option<WireTopology>,
551    /// The consumer-filter evaluation the streaming server serves, set with
552    /// the `CONSUMER_FILTERS` bit. Absent from an older server, whose client
553    /// then assumes its own evaluator.
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub filters: Option<FilterAnnounce>,
556}
557
558/// The consumer-filter evaluation a server serves. A client refuses to run a
559/// filter under another evaluator version or with a codec the server does not
560/// list, so a version gap fails before the first page instead of filtering
561/// differently.
562#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
563pub struct FilterAnnounce {
564    pub evaluator_version: u32,
565    #[serde(default, skip_serializing_if = "Vec::is_empty")]
566    pub codecs: Vec<FilterCodec>,
567    /// Longest filtered wait the server holds. Zero means it serves no
568    /// filtered wait, so a reader paces idle rounds itself.
569    #[serde(default, skip_serializing_if = "is_zero_u32")]
570    pub max_wait_ms: u32,
571}
572
573impl FilterAnnounce {
574    /// What this build of the evaluator serves.
575    pub fn served() -> Self {
576        Self {
577            evaluator_version: FILTER_EVALUATOR_VERSION,
578            codecs: vec![
579                FilterCodec::Json,
580                FilterCodec::HeadersOnly,
581                FilterCodec::Avro,
582                FilterCodec::Protobuf,
583                FilterCodec::Cbor,
584            ],
585            max_wait_ms: 0,
586        }
587    }
588
589    /// Whether a filter with `evaluator_version` and `codec` evaluates here
590    /// exactly as it would in the client that built it.
591    pub fn evaluates(&self, evaluator_version: u32, codec: FilterCodec) -> bool {
592        self.evaluator_version == evaluator_version && self.codecs.contains(&codec)
593    }
594}
595
596impl BackendAnnounce {
597    /// Constructor for the non-exhaustive wire struct.
598    pub fn new(versions: OpVersions) -> Self {
599        Self {
600            versions,
601            ready: true,
602            backends: Vec::new(),
603            topology: None,
604            filters: None,
605        }
606    }
607
608    /// Returns a copy announcing the served consumer-filter evaluation.
609    #[must_use]
610    pub fn with_filters(mut self, filters: FilterAnnounce) -> Self {
611        self.filters = Some(filters);
612        self
613    }
614
615    #[must_use]
616    pub const fn unavailable(mut self) -> Self {
617        self.ready = false;
618        self
619    }
620
621    /// Returns a copy advertising `backends`.
622    #[must_use]
623    pub fn with_backends(mut self, backends: Vec<BackendDescriptor>) -> Self {
624        self.backends = backends;
625        self
626    }
627
628    /// Returns a copy advertising the deployment's `topology`.
629    #[must_use]
630    pub fn with_topology(mut self, topology: WireTopology) -> Self {
631        self.topology = Some(topology);
632        self
633    }
634}
635
636impl Validate for BackendAnnounce {
637    fn validate(&self) -> Result<(), InvalidError> {
638        let mut resources = BTreeSet::new();
639        for backend in &self.backends {
640            backend.validate()?;
641            if !resources.insert(backend.resource_id) {
642                return Err(InvalidError::new(
643                    "backend announcement repeats a resource id",
644                ));
645            }
646        }
647        if self.ready
648            && !self.backends.is_empty()
649            && !self.backends.iter().any(|backend| backend.readiness.ready)
650        {
651            return Err(InvalidError::new(
652                "ready backend announcement must contain a ready backend",
653            ));
654        }
655        Ok(())
656    }
657}
658
659#[cfg(all(test, feature = "cbor"))]
660mod tests {
661    use super::*;
662    use crate::codes::{CONTROL_OP_VERSION, FORK_OP_VERSION, KV_OP_VERSION, QUERY_OP_VERSION};
663    use crate::framing::{decode_named, encode_named};
664
665    fn backend(resource_id: u128, mode: BackendMode, label: &str, kind: &str) -> BackendDescriptor {
666        BackendDescriptor::new(
667            BackendResourceId::from_u128(resource_id),
668            mode,
669            label,
670            BackendImplementation {
671                kind: kind.to_owned(),
672                version: "1.0.0".to_owned(),
673            },
674            1,
675            1,
676        )
677    }
678
679    #[test]
680    fn given_a_hello_reply_when_round_tripped_then_should_preserve_versions() {
681        // The pinned `HelloReply` shape (CBOR named fields). The connect-time
682        // probe decodes exactly this shape.
683        let reply = HelloReply::new(OpVersions::new(
684            QUERY_OP_VERSION,
685            CONTROL_OP_VERSION,
686            KV_OP_VERSION,
687            FORK_OP_VERSION,
688        ));
689        let bytes = encode_named(&reply).expect("hello reply serializes");
690        let back: HelloReply = decode_named(&bytes).expect("hello reply deserializes");
691        assert_eq!(back, reply);
692    }
693
694    #[test]
695    fn given_a_backend_announce_when_round_tripped_then_should_preserve_features() {
696        let announce = BackendAnnounce::new(
697            OpVersions::new(
698                QUERY_OP_VERSION,
699                CONTROL_OP_VERSION,
700                KV_OP_VERSION,
701                FORK_OP_VERSION,
702            )
703            .with_features(feature::KV_CAS | feature::READ_YOUR_WRITES),
704        );
705        let bytes = encode_named(&announce).expect("serializes");
706        let back: BackendAnnounce = decode_named(&bytes).expect("deserializes");
707        assert_eq!(back, announce);
708        assert!(back.versions.has_feature(feature::KV_CAS));
709    }
710
711    #[test]
712    fn given_an_unavailable_backend_announce_when_round_tripped_then_should_stay_unavailable() {
713        let announce = BackendAnnounce::new(OpVersions::new(
714            QUERY_OP_VERSION,
715            CONTROL_OP_VERSION,
716            KV_OP_VERSION,
717            FORK_OP_VERSION,
718        ))
719        .unavailable();
720        let bytes = encode_named(&announce).expect("serializes");
721        let back: BackendAnnounce = decode_named(&bytes).expect("deserializes");
722        assert!(!back.ready);
723    }
724
725    #[test]
726    fn given_an_empty_hello_body_when_decoded_then_should_yield_no_versions() {
727        // Pre-versioned servers answer the probe with an empty body. The probe
728        // treats a failed decode as "no versions advertised", never an error.
729        assert!(decode_named::<HelloReply>(&[]).is_err());
730    }
731
732    #[test]
733    fn given_advertised_backends_when_round_tripped_then_should_preserve_them_and_skip_empty() {
734        let announce = BackendAnnounce::new(OpVersions::new(
735            QUERY_OP_VERSION,
736            CONTROL_OP_VERSION,
737            KV_OP_VERSION,
738            FORK_OP_VERSION,
739        ))
740        .with_backends(vec![
741            backend(1, BackendMode::Operational, "Embedded", "embedded"),
742            backend(
743                2,
744                BackendMode::Operational,
745                "Analytics warehouse",
746                "columnar",
747            ),
748        ]);
749        let bytes = encode_named(&announce).expect("encodes");
750        let back: BackendAnnounce = decode_named(&bytes).expect("decodes");
751        assert_eq!(back, announce);
752        assert_eq!(back.backends.len(), 2);
753        assert_eq!(
754            back.backends[1].resource_id,
755            BackendResourceId::from_u128(2)
756        );
757        assert_eq!(back.backends[1].implementation.kind, "columnar");
758        assert_eq!(back.backends[1].label, "Analytics warehouse");
759
760        // No advertised backends (the default) is omitted on the wire, so a
761        // pre-backends announce stays byte-identical.
762        let plain = BackendAnnounce::new(OpVersions::new(1, 1, 1, 1));
763        let json = serde_json::to_string(&plain).expect("json");
764        assert!(!json.contains("backends"), "empty backends omitted: {json}");
765    }
766
767    #[test]
768    fn given_announce_without_topology_when_decoded_then_should_default_none() {
769        let announce = BackendAnnounce::new(OpVersions::new(
770            QUERY_OP_VERSION,
771            CONTROL_OP_VERSION,
772            KV_OP_VERSION,
773            FORK_OP_VERSION,
774        ));
775        let bytes = encode_named(&announce).expect("encodes");
776        let back: BackendAnnounce = decode_named(&bytes).expect("decodes");
777        assert_eq!(back.topology, None);
778        let json = serde_json::to_string(&announce).expect("json");
779        assert!(
780            !json.contains("topology"),
781            "absent topology omitted: {json}"
782        );
783    }
784
785    #[test]
786    fn given_announce_with_topology_when_round_tripped_then_should_preserve_it() {
787        let custom = WireTopology {
788            ops_stream: "custom-ops".to_owned(),
789            ..WireTopology::default()
790        };
791        let announce = BackendAnnounce::new(OpVersions::new(
792            QUERY_OP_VERSION,
793            CONTROL_OP_VERSION,
794            KV_OP_VERSION,
795            FORK_OP_VERSION,
796        ))
797        .with_topology(custom.clone());
798        let bytes = encode_named(&announce).expect("encodes");
799        let back: BackendAnnounce = decode_named(&bytes).expect("decodes");
800        assert_eq!(back.topology, Some(custom));
801    }
802
803    #[test]
804    fn given_advertised_features_when_round_tripped_then_should_preserve_bits_and_skip_zero() {
805        let versions = OpVersions::new(
806            QUERY_OP_VERSION,
807            CONTROL_OP_VERSION,
808            KV_OP_VERSION,
809            FORK_OP_VERSION,
810        )
811        .with_features(feature::KV_CAS | feature::READ_YOUR_WRITES);
812        assert!(versions.has_feature(feature::KV_CAS));
813        assert!(versions.has_feature(feature::READ_YOUR_WRITES));
814        assert!(!versions.has_feature(feature::STRONG_CONSISTENCY));
815        // has_feature on a combined mask requires every bit present.
816        assert!(versions.has_feature(feature::KV_CAS | feature::READ_YOUR_WRITES));
817        assert!(!versions.has_feature(feature::KV_CAS | feature::STRONG_CONSISTENCY));
818        let reply = HelloReply::new(versions);
819        let bytes = encode_named(&reply).expect("encodes");
820        let back: HelloReply = decode_named(&bytes).expect("decodes");
821        assert_eq!(back, reply);
822        assert!(back.versions.has_feature(feature::READ_YOUR_WRITES));
823        // No advertised feature (0) is omitted on the wire, so a pre-feature
824        // hello reply stays byte-identical.
825        let plain = HelloReply::new(OpVersions::new(1, 1, 1, 1));
826        let json = serde_json::to_string(&plain).expect("json");
827        assert!(!json.contains("features"), "zero features omitted: {json}");
828    }
829
830    #[test]
831    fn given_announced_filter_evaluation_when_round_tripped_then_should_gate_codecs_and_versions() {
832        let announce = BackendAnnounce::new(OpVersions::new(1, 1, 1, 1))
833            .with_filters(FilterAnnounce::served());
834        let bytes = encode_named(&announce).expect("serializes");
835        let back: BackendAnnounce = decode_named(&bytes).expect("deserializes");
836        let filters = back.filters.expect("the evaluation is announced");
837        assert!(filters.evaluates(FILTER_EVALUATOR_VERSION, FilterCodec::Json));
838        assert!(!filters.evaluates(FILTER_EVALUATOR_VERSION + 1, FilterCodec::Json));
839        assert!(!filters.evaluates(FILTER_EVALUATOR_VERSION, FilterCodec::Unknown));
840        let plain =
841            encode_named(&BackendAnnounce::new(OpVersions::new(1, 1, 1, 1))).expect("serializes");
842        let older: BackendAnnounce = decode_named(&plain).expect("deserializes");
843        assert_eq!(older.filters, None);
844    }
845
846    #[test]
847    fn given_the_feature_bits_when_read_then_should_match_the_pinned_values() {
848        assert_eq!(
849            [
850                feature::KV_CAS,
851                feature::READ_YOUR_WRITES,
852                feature::STRONG_CONSISTENCY,
853                feature::KV_CAS_FENCED,
854                feature::AGENT_WORKFLOW,
855                feature::KEYWORD_SEARCH,
856                feature::WATCH,
857                feature::AUTHZ,
858                feature::DESTINATIONS,
859                feature::KV_FENCED_LEASES,
860                feature::CONSUMER_FILTERS,
861                feature::GROUP_POLICY_READS,
862                feature::SESSIONS,
863                feature::STREAM_TENANCY,
864                feature::MATERIALIZATIONS,
865            ],
866            [
867                1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384
868            ]
869        );
870    }
871
872    #[test]
873    fn given_op_versions_with_zero_surfaces_when_encoded_then_should_omit_them_and_keep_set_ones() {
874        let advertised = OpVersions::new(1, 1, 1, 1)
875            .with_agent(2)
876            .with_graph(3)
877            .with_filter(4);
878        let json = serde_json::to_value(advertised).expect("encode advertised surfaces");
879        let decoded: OpVersions = serde_json::from_value(json).expect("decode advertised surfaces");
880        assert_eq!(decoded, advertised);
881        let plain =
882            serde_json::to_value(OpVersions::new(1, 1, 1, 1)).expect("encode base surfaces");
883        assert_eq!(
884            (plain.get("agent"), plain.get("graph"), plain.get("filter")),
885            (None, None, None)
886        );
887    }
888
889    #[test]
890    fn given_a_ready_announce_with_a_filter_wait_when_encoded_then_should_keep_only_the_wait() {
891        let waiting =
892            BackendAnnounce::new(OpVersions::new(1, 1, 1, 1)).with_filters(FilterAnnounce {
893                max_wait_ms: 250,
894                ..FilterAnnounce::served()
895            });
896        let json = serde_json::to_value(&waiting).expect("encode a ready announce");
897        assert!(
898            json.get("ready").is_none(),
899            "a ready announce omits ready: {json}"
900        );
901        let decoded: BackendAnnounce =
902            serde_json::from_value(json).expect("decode a ready announce");
903        assert_eq!(decoded, waiting);
904        let served =
905            serde_json::to_value(FilterAnnounce::served()).expect("encode the served evaluation");
906        assert!(
907            served.get("max_wait_ms").is_none(),
908            "a zero wait is omitted: {served}"
909        );
910    }
911
912    #[test]
913    fn given_a_backend_descriptor_when_validated_then_should_refuse_each_broken_invariant() {
914        let ready = ready_backend(1);
915        ready.validate().expect("a ready backend with every limit");
916        backend(1, BackendMode::Operational, "Embedded", "embedded")
917            .validate()
918            .expect("a backend that is not ready");
919        BackendDescriptor {
920            label: "l".repeat(4096),
921            ..ready.clone()
922        }
923        .validate()
924        .expect("a label at the cap");
925        let refused = [
926            (
927                "another descriptor version",
928                BackendDescriptor {
929                    descriptor_version: BACKEND_DESCRIPTOR_VERSION + 1,
930                    ..ready.clone()
931                },
932            ),
933            (
934                "a zero resource id",
935                BackendDescriptor {
936                    resource_id: BackendResourceId::from_u128(0),
937                    ..ready.clone()
938                },
939            ),
940            (
941                "a zero backend generation",
942                BackendDescriptor {
943                    observed_backend_generation: 0,
944                    ..ready.clone()
945                },
946            ),
947            (
948                "a zero configuration revision",
949                BackendDescriptor {
950                    runtime_configuration_revision: 0,
951                    ..ready.clone()
952                },
953            ),
954            (
955                "an empty label",
956                BackendDescriptor {
957                    label: String::new(),
958                    ..ready.clone()
959                },
960            ),
961            (
962                "a label past the cap",
963                BackendDescriptor {
964                    label: "l".repeat(4097),
965                    ..ready.clone()
966                },
967            ),
968            (
969                "a ready backend still starting",
970                BackendDescriptor {
971                    observed_state: BackendObservedState::Starting,
972                    ..ready.clone()
973                },
974            ),
975            (
976                "a logical schema without a field limit",
977                BackendDescriptor {
978                    limits: BackendLimits {
979                        max_schema_fields: 0,
980                        ..ready.limits.clone()
981                    },
982                    ..ready.clone()
983                },
984            ),
985        ];
986        for (case, descriptor) in refused {
987            assert!(descriptor.validate().is_err(), "{case} must be refused");
988        }
989        let limits = &ready.limits;
990        let zero_query_limits = [
991            BackendLimits {
992                max_query_rows: 0,
993                ..limits.clone()
994            },
995            BackendLimits {
996                max_query_bytes: 0,
997                ..limits.clone()
998            },
999            BackendLimits {
1000                max_scan_bytes: 0,
1001                ..limits.clone()
1002            },
1003            BackendLimits {
1004                max_query_micros: 0,
1005                ..limits.clone()
1006            },
1007            BackendLimits {
1008                max_concurrent_queries: 0,
1009                ..limits.clone()
1010            },
1011        ];
1012        for limits in zero_query_limits {
1013            let descriptor = BackendDescriptor {
1014                limits,
1015                ..ready.clone()
1016            };
1017            assert!(
1018                descriptor.validate().is_err(),
1019                "a ready query backend needs every query limit"
1020            );
1021        }
1022    }
1023
1024    #[test]
1025    fn given_backend_readiness_when_validated_then_should_need_a_time_or_a_bounded_reason_list() {
1026        let not_ready = |reasons: usize| BackendReadiness {
1027            ready: false,
1028            reasons: vec![
1029                BackendReadinessReason {
1030                    code: BackendReadinessCode::ProbeFailed,
1031                    detail: None,
1032                };
1033                reasons
1034            ],
1035            observed_at_micros: 0,
1036        };
1037        BackendReadiness::ready(5)
1038            .validate()
1039            .expect("ready with an observation time");
1040        not_ready(32).validate().expect("reasons at the cap");
1041        assert!(not_ready(33).validate().is_err(), "reasons past the cap");
1042        assert!(
1043            BackendReadiness::ready(0).validate().is_err(),
1044            "ready without an observation time"
1045        );
1046    }
1047
1048    #[test]
1049    fn given_query_capabilities_when_validated_then_should_need_coverage_without_repeats() {
1050        capabilities()
1051            .validate()
1052            .expect("full coverage with raw SQL");
1053        let refused = [
1054            (
1055                "no dialect",
1056                QueryCapabilities {
1057                    dialects: Vec::new(),
1058                    raw_sql: false,
1059                    ..capabilities()
1060                },
1061            ),
1062            (
1063                "no consistency level",
1064                QueryCapabilities {
1065                    consistency: Vec::new(),
1066                    ..capabilities()
1067                },
1068            ),
1069            (
1070                "no logical type",
1071                QueryCapabilities {
1072                    logical_types: Vec::new(),
1073                    ..capabilities()
1074                },
1075            ),
1076            (
1077                "a repeated dialect",
1078                QueryCapabilities {
1079                    dialects: vec![SqlDialect::Postgres, SqlDialect::Postgres],
1080                    ..capabilities()
1081                },
1082            ),
1083        ];
1084        for (case, rejected) in refused {
1085            assert!(rejected.validate().is_err(), "{case} must be refused");
1086        }
1087    }
1088
1089    #[test]
1090    fn given_a_backend_announce_when_validated_then_should_need_unique_ids_and_a_ready_backend() {
1091        let versions = OpVersions::new(1, 1, 1, 1);
1092        let not_ready = backend(2, BackendMode::Operational, "Analytics", "columnar");
1093        BackendAnnounce::new(versions)
1094            .validate()
1095            .expect("a ready announce without backends");
1096        BackendAnnounce::new(versions)
1097            .with_backends(vec![ready_backend(1), not_ready.clone()])
1098            .validate()
1099            .expect("a ready announce with a ready backend");
1100        assert!(
1101            BackendAnnounce::new(versions)
1102                .with_backends(vec![not_ready])
1103                .validate()
1104                .is_err(),
1105            "a ready announce without a ready backend"
1106        );
1107        assert!(
1108            BackendAnnounce::new(versions)
1109                .with_backends(vec![ready_backend(1), ready_backend(1)])
1110                .validate()
1111                .is_err(),
1112            "a repeated resource id"
1113        );
1114    }
1115
1116    fn ready_backend(resource_id: u128) -> BackendDescriptor {
1117        backend(
1118            resource_id,
1119            BackendMode::Operational,
1120            "Embedded",
1121            "embedded",
1122        )
1123        .with_state(
1124            BackendDesiredState::Enabled,
1125            BackendObservedState::Ready,
1126            BackendReadiness::ready(5),
1127        )
1128        .with_query(capabilities())
1129        .with_schema(SchemaCapabilities {
1130            logical_schema: true,
1131            arrow_ipc_stream: false,
1132            schema_evolution: false,
1133        })
1134        .with_limits(BackendLimits {
1135            max_query_rows: 1000,
1136            max_query_bytes: 1_048_576,
1137            max_scan_bytes: 1_073_741_824,
1138            max_query_micros: 1_000_000,
1139            max_concurrent_queries: 8,
1140            max_schema_fields: 64,
1141            max_payload_bytes: 0,
1142        })
1143    }
1144
1145    fn capabilities() -> QueryCapabilities {
1146        QueryCapabilities {
1147            dialects: vec![SqlDialect::Postgres],
1148            consistency: vec![Consistency::Eventual],
1149            logical_types: vec![LogicalTypeKind::String],
1150            raw_sql: true,
1151            aggregates: vec![AggFunc::Count],
1152            vector_search: false,
1153            text_search: false,
1154        }
1155    }
1156}
1157const fn is_zero_u32(value: &u32) -> bool {
1158    *value == 0
1159}