Skip to main content

exoware_sdk/
lib.rs

1//! Store Rust SDK Client.
2//!
3//! Provides typed access to the store put/get/query APIs plus
4//! HTTP health/readiness probes.
5//!
6//! ## Errors
7//!
8//! RPC failures surface as [`ClientError::Rpc`] carrying a native [`ConnectError`]. Use
9//! [`ClientError::decoded_rpc_error`] or [`StoreClient::decode_error_details`] to unpack
10//! protobuf `google.rpc` details (and `store.query.v1.Detail` on query RPC errors), not string parsing.
11//! Idempotent reads honor `google.rpc.RetryInfo` when deciding backoff (see `retry_delay_for_error`).
12
13mod credential;
14pub mod keys;
15pub mod kv_codec;
16pub mod proto;
17pub mod prune_policy;
18pub mod retention;
19pub mod selector;
20pub mod stream_filter;
21pub use credential::API_KEY_ENV;
22pub use keys::{Key, KeyMut, KeyValidationError, Prefix, PrefixError, Value, MAX_KEY_LEN};
23pub use proto::*;
24extern crate self as exoware_proto;
25
26use bytes::Bytes;
27use connectrpc::client::{ClientConfig, ServerStream as ConnectServerStream};
28pub use connectrpc::{ConnectError, ErrorCode};
29use credential::{client_error_from_connect, ApiKey, Credential, UnusableEnvKey};
30use exoware_proto::ingest::ServiceClient as IngestServiceClient;
31use exoware_proto::log::ingest::v1::PutRequest as ProtoPutRequest;
32use exoware_proto::prune::ServiceClient as PruneServiceClient;
33use exoware_proto::query as proto_query;
34use exoware_proto::query::ServiceClient as QueryServiceClient;
35use exoware_proto::store::prune::v1::PruneRequest as ProtoPruneRequest;
36use exoware_proto::store::query::v1::{
37    GetManyRequest as ProtoGetManyRequest, GetRequest as ProtoGetRequest,
38    RangeRequest as ProtoRangeRequest, ReduceRequest as ProtoWireReduceRequest,
39};
40use exoware_proto::RangeReduceRequest as DomainRangeReduceRequest;
41use exoware_proto::{
42    connect_compression_registry as proto_connect_compression_registry,
43    decode_connect_error as proto_decode_connect_error,
44    to_domain_reduce_response as proto_to_domain_reduce_response,
45    to_proto_reduce_params as proto_to_proto_reduce_params,
46    PreferZstdHttpClient as ProtoPreferZstdHttpClient,
47};
48use futures::future::BoxFuture;
49use futures::{stream::BoxStream, StreamExt};
50use keys::is_valid_key_size;
51use kv_codec::{KvExpr, KvFieldRef, KvReducedValue};
52use rustls_platform_verifier::ConfigVerifierExt;
53use std::collections::HashMap;
54use std::sync::{
55    atomic::{AtomicU64, Ordering},
56    Arc,
57};
58use std::time::Duration;
59
60use crate::proto::transport::{
61    BalancedHttp2Config as ProtoBalancedHttp2Config,
62    BalancedHttp2Transport as ProtoBalancedHttp2Transport,
63    ErasedClientTransport as ProtoErasedClientTransport,
64    ErasedResponseBody as ProtoErasedResponseBody,
65};
66
67const DEFAULT_RETRY_MAX_ATTEMPTS: usize = 3;
68const DEFAULT_RETRY_INITIAL_BACKOFF_MS: u64 = 100;
69const DEFAULT_RETRY_MAX_BACKOFF_MS: u64 = 2_000;
70
71/// Converts caller-provided write values into the byte owner stored by
72/// [`StoreWriteBatch`].
73pub trait IntoStoreWriteValue {
74    fn into_store_write_value(self) -> Bytes;
75}
76
77impl IntoStoreWriteValue for Bytes {
78    fn into_store_write_value(self) -> Bytes {
79        self
80    }
81}
82
83impl IntoStoreWriteValue for &Bytes {
84    fn into_store_write_value(self) -> Bytes {
85        self.clone()
86    }
87}
88
89impl IntoStoreWriteValue for Vec<u8> {
90    fn into_store_write_value(self) -> Bytes {
91        self.into()
92    }
93}
94
95impl IntoStoreWriteValue for &Vec<u8> {
96    fn into_store_write_value(self) -> Bytes {
97        Bytes::copy_from_slice(self)
98    }
99}
100
101impl IntoStoreWriteValue for &[u8] {
102    fn into_store_write_value(self) -> Bytes {
103        Bytes::copy_from_slice(self)
104    }
105}
106
107impl<const N: usize> IntoStoreWriteValue for &[u8; N] {
108    fn into_store_write_value(self) -> Bytes {
109        Bytes::copy_from_slice(self)
110    }
111}
112
113/// Codec used to compress **outgoing** RPC request bodies when compression applies.
114///
115/// Request compression is disabled by default because large ingest batches are
116/// often CPU-bound before they are network-bound. Use [`Zstd`](Self::Zstd) when
117/// upload bandwidth matters more than client CPU, or [`Gzip`](Self::Gzip) when
118/// talking to a peer that only accepts gzip-compressed requests. Response
119/// decompression still follows [`PreferZstdHttpClient`] and the shared
120/// [`connect_compression_registry`].
121///
122/// To drive this from configuration or environment variables, map your setting to this enum and
123/// pass it to [`StoreClientBuilder::connect_request_compression`].
124#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
125pub enum ConnectRequestCompression {
126    /// Do not compress outgoing request bodies.
127    #[default]
128    None,
129    /// `compress_requests("zstd")`.
130    Zstd,
131    /// `compress_requests("gzip")`.
132    Gzip,
133}
134
135impl ConnectRequestCompression {
136    fn wire_name(self) -> Option<&'static str> {
137        match self {
138            Self::None => None,
139            Self::Zstd => Some("zstd"),
140            Self::Gzip => Some("gzip"),
141        }
142    }
143}
144
145/// Default max decompressed RPC message size for client decode (matches the query worker).
146///
147/// The underlying client uses 4 MiB unless configured; large `Range` frames need headroom.
148/// The store simulator uses the same 256 MiB cap for large `Range` frames.
149const STORE_CLIENT_MAX_MESSAGE_BYTES: usize = 256 * 1024 * 1024;
150
151/// Store client defaults: [`connect_compression_registry`] for codecs;
152/// [`PreferZstdHttpClient`] sets `Accept-Encoding: zstd, gzip` on responses.
153///
154/// Request body compression uses [`ConnectRequestCompression`] (default none);
155/// connectrpc only supports one request encoding per config (see
156/// [`ConnectRequestCompression`]).
157fn store_connect_client_config(
158    base_uri: http::Uri,
159    request_compression: ConnectRequestCompression,
160    timeout: Option<Duration>,
161) -> ClientConfig {
162    let config = ClientConfig::new(base_uri)
163        .with_compression(proto_connect_compression_registry())
164        .with_default_max_message_size(STORE_CLIENT_MAX_MESSAGE_BYTES);
165    let config = match timeout {
166        Some(timeout) => config.with_default_timeout(timeout),
167        None => config,
168    };
169    match request_compression.wire_name() {
170        Some(name) => config.compress_requests(name),
171        None => config,
172    }
173}
174
175/// Store client error.
176#[derive(Debug, thiserror::Error)]
177pub enum ClientError {
178    #[error("HTTP error: {0}")]
179    Http(#[from] reqwest::Error),
180    #[error("RPC error ({0})")]
181    Rpc(Box<ConnectError>),
182    #[error("store key prefix error: {0}")]
183    Prefix(#[from] StoreKeyPrefixError),
184    #[error("invalid key length: expected {expected}, got {got}")]
185    InvalidKeyLength { expected: usize, got: usize },
186    #[error("wire format error: {0}")]
187    WireFormat(String),
188}
189
190impl ClientError {
191    pub fn rpc_error(&self) -> Option<&ConnectError> {
192        match self {
193            Self::Rpc(err) => Some(err.as_ref()),
194            _ => None,
195        }
196    }
197
198    pub fn rpc_code(&self) -> Option<ErrorCode> {
199        self.rpc_error().map(|err| err.code)
200    }
201
202    pub fn decoded_rpc_error(
203        &self,
204    ) -> Result<Option<exoware_proto::DecodedConnectError>, buffa::DecodeError> {
205        self.rpc_error().map(proto_decode_connect_error).transpose()
206    }
207}
208
209/// Errors returned by [`StoreKeyPrefix`] when a logical key cannot be mapped
210/// into the prefixed physical keyspace.
211#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
212pub enum StoreKeyPrefixError {
213    #[error("key does not belong to this store prefix")]
214    PrefixMismatch,
215    #[error("key offset {offset} plus store prefix shift {shift} exceeds u16")]
216    KeyOffsetOverflow { offset: u16, shift: u16 },
217    #[error("key prefix error: {0}")]
218    Prefix(#[from] PrefixError),
219}
220
221/// A client-side namespace layered over raw Store keys.
222///
223/// The prefix prepends a fixed byte string to each physical Store key and
224/// stores the caller's logical key in the remaining bytes. QMDB, SQL, and
225/// other higher-level instances continue to build their own logical keys as
226/// before; a prefixed [`StoreClient`] maps those keys on the wire and maps
227/// returned keys back before callers see them.
228#[derive(Clone, Debug, PartialEq, Eq, Hash)]
229pub struct StoreKeyPrefix {
230    inner: Prefix,
231}
232
233impl StoreKeyPrefix {
234    pub fn new(prefix: impl Into<Bytes>) -> Result<Self, StoreKeyPrefixError> {
235        Ok(Self {
236            inner: Prefix::new(prefix)?,
237        })
238    }
239
240    /// The zero-width identity prefix: maps every logical key to itself, so
241    /// encode/decode/range/match are all no-ops and keys pass through
242    /// un-namespaced.
243    pub const fn identity() -> Self {
244        Self {
245            inner: Prefix::empty(),
246        }
247    }
248
249    /// The raw prefix bytes.
250    #[inline]
251    pub fn prefix(&self) -> &Bytes {
252        self.inner.as_bytes()
253    }
254
255    /// True when `key` starts with this prefix. The identity prefix matches
256    /// every key.
257    #[inline]
258    pub fn matches(&self, key: &[u8]) -> bool {
259        self.inner.matches(key)
260    }
261
262    /// Maximum logical key bytes available under this prefix.
263    #[inline]
264    pub fn max_logical_key_len(&self) -> usize {
265        self.inner.max_payload_len()
266    }
267
268    /// Encode a logical key into the physical Store keyspace. The identity
269    /// prefix returns a refcount-only clone of `key`.
270    pub fn encode_key(&self, key: &Key) -> Result<Key, StoreKeyPrefixError> {
271        Ok(self.inner.encode_key(key)?)
272    }
273
274    /// Decode a physical Store key back into the logical keyspace as a
275    /// zero-copy slice of `key`'s backing storage.
276    pub fn decode_key(&self, key: &Key) -> Result<Key, StoreKeyPrefixError> {
277        self.inner
278            .strip(key)
279            .map_err(|_| StoreKeyPrefixError::PrefixMismatch)
280    }
281
282    /// Return the smallest valid logical key strictly greater than `key`, or
283    /// `None` when no later key remains under this prefix.
284    pub fn next_key(&self, key: &Key) -> Result<Option<Key>, StoreKeyPrefixError> {
285        let physical = self.encode_key(key)?;
286        Ok(crate::keys::next_key(&physical).and_then(|next| self.decode_key(&next).ok()))
287    }
288
289    /// Encode an inclusive logical range into the physical Store keyspace.
290    ///
291    /// Empty `end` means unbounded in the logical keyspace and is narrowed to
292    /// this prefix's physical upper bound. Long logical upper bounds are
293    /// clamped to the maximum logical key length representable under this
294    /// prefix; this keeps scans over full-width prefix bounds (whose upper
295    /// bound is intentionally `MAX_KEY_LEN` bytes) working.
296    pub fn encode_range(&self, start: &Key, end: &Key) -> Result<(Key, Key), StoreKeyPrefixError> {
297        let start = self.encode_key(start)?;
298        let end = if end.is_empty() {
299            self.inner.bounds().1
300        } else {
301            let max_len = self.max_logical_key_len();
302            if end.len() > max_len {
303                self.encode_key(&end.slice(..max_len))?
304            } else {
305                self.encode_key(end)?
306            }
307        };
308        Ok((start, end))
309    }
310
311    fn prefix_selector(
312        &self,
313        selector: &crate::selector::Selector,
314    ) -> Result<crate::selector::Selector, StoreKeyPrefixError> {
315        let prefix = self.inner.join(&Prefix::new(selector.prefix.clone())?)?;
316        Ok(crate::selector::Selector {
317            prefix: prefix.as_bytes().clone(),
318            payload_regex: selector.payload_regex.clone(),
319        })
320    }
321}
322
323/// A physical [`StoreClient`] paired with a [`StoreKeyPrefix`] that namespaces
324/// every key on the wire. The prefix can be the zero-width
325/// [`StoreKeyPrefix::identity`], representing the un-namespaced case.
326#[derive(Clone, Debug)]
327pub struct PrefixedStoreClient {
328    client: StoreClient,
329    prefix: StoreKeyPrefix,
330}
331
332impl PrefixedStoreClient {
333    /// Pair a physical client with an explicit namespace prefix.
334    pub fn new(client: StoreClient, prefix: StoreKeyPrefix) -> Self {
335        Self { client, prefix }
336    }
337
338    /// Pair a physical client with the zero-width [`StoreKeyPrefix::identity`],
339    /// so keys pass through untranslated.
340    pub fn empty(client: StoreClient) -> Self {
341        Self::new(client, StoreKeyPrefix::identity())
342    }
343
344    /// The configured key prefix.
345    pub fn key_prefix(&self) -> &StoreKeyPrefix {
346        &self.prefix
347    }
348
349    /// Borrow the underlying physical transport. Operations performed directly
350    /// on it are *not* namespaced; prefer the methods on this type.
351    pub fn client(&self) -> &StoreClient {
352        &self.client
353    }
354
355    // --- key translation -----------------------------------------------------
356
357    /// Encode a logical key as it will appear in the physical Store.
358    pub fn encode_store_key(&self, key: &Key) -> Result<Key, ClientError> {
359        Ok(self.prefix.encode_key(key)?)
360    }
361
362    /// Decode a physical Store key into this client's logical keyspace.
363    pub fn decode_store_key(&self, key: &Key) -> Result<Key, ClientError> {
364        Ok(self.prefix.decode_key(key)?)
365    }
366
367    fn encode_store_range(&self, start: &Key, end: &Key) -> Result<(Key, Key), ClientError> {
368        Ok(self.prefix.encode_range(start, end)?)
369    }
370
371    fn prefix_prune_policies(
372        &self,
373        policies: &[crate::prune_policy::PrunePolicy],
374    ) -> Result<Vec<crate::prune_policy::PrunePolicy>, ClientError> {
375        policies
376            .iter()
377            .map(|policy| {
378                use crate::prune_policy::PrunePolicy;
379                let mut scope = policy.scope.clone();
380                scope.selector = self.prefix.prefix_selector(&scope.selector)?;
381                Ok(PrunePolicy {
382                    scope,
383                    retain: policy.retain.clone(),
384                })
385            })
386            .collect::<Result<Vec<_>, StoreKeyPrefixError>>()
387            .map_err(ClientError::from)
388    }
389
390    fn prefix_stream_filter(
391        &self,
392        filter: crate::stream_filter::StreamFilter,
393    ) -> Result<crate::stream_filter::StreamFilter, ClientError> {
394        let selectors = filter
395            .selectors
396            .iter()
397            .map(|mk| self.prefix.prefix_selector(mk))
398            .collect::<Result<Vec<_>, _>>()?;
399        Ok(crate::stream_filter::StreamFilter {
400            selectors,
401            value_filters: filter.value_filters,
402        })
403    }
404
405    fn prefix_reduce_request(
406        &self,
407        request: &DomainRangeReduceRequest,
408    ) -> Result<DomainRangeReduceRequest, ClientError> {
409        let mut request = request.clone();
410        shift_reduce_request_key_offsets(self.prefix.prefix().len(), &mut request)?;
411        Ok(request)
412    }
413
414    // --- service-grouped accessors -------------------------------------------
415
416    /// Typed access to the `log.ingest.v1` service.
417    pub fn ingest(&self) -> Ingest<'_> {
418        Ingest { c: self }
419    }
420
421    /// Typed access to the `store.query.v1` service.
422    pub fn query(&self) -> Query<'_> {
423        Query { c: self }
424    }
425
426    /// Typed access to the `store.prune.v1` service.
427    pub fn prune(&self) -> Prune<'_> {
428        Prune { c: self }
429    }
430
431    /// Typed access to the `log.retention.v1` service.
432    pub fn retention(&self) -> Retention<'_> {
433        Retention { c: self }
434    }
435
436    /// Typed access to the `log.stream.v1` service.
437    pub fn stream(&self) -> Stream<'_> {
438        Stream { c: self }
439    }
440
441    /// Create a serializable read session with no initial floor over this namespace.
442    pub fn create_session(&self) -> SerializableReadSession {
443        self.create_session_with_sequence(0)
444    }
445
446    /// Create a serializable read session whose read floor starts at `sequence`.
447    pub fn create_session_with_sequence(&self, sequence: u64) -> SerializableReadSession {
448        SerializableReadSession {
449            client: self.clone(),
450            state: Arc::new(SessionState {
451                minimum_sequence: sequence,
452                sequence: Arc::new(AtomicU64::new(0)),
453                init_gate: tokio::sync::Mutex::new(()),
454            }),
455        }
456    }
457
458    // --- writes --------------------------------------------------------------
459
460    pub(crate) async fn put(&self, kvs: &[(&Key, &[u8])]) -> Result<u64, ClientError> {
461        let keys = kvs
462            .iter()
463            .map(|(key, _)| self.encode_store_key(key))
464            .collect::<Result<Vec<_>, _>>()?;
465        let prefixed: Vec<(&Key, &[u8])> = keys
466            .iter()
467            .zip(kvs.iter())
468            .map(|(key, (_, value))| (key, *value))
469            .collect();
470        self.client.put_physical(&prefixed).await
471    }
472
473    // --- reads ---------------------------------------------------------------
474
475    pub(crate) async fn send_get(
476        &self,
477        key: &Key,
478        min_sequence_number: Option<u64>,
479    ) -> Result<
480        (
481            exoware_proto::query::GetResponse,
482            Option<proto_query::Detail>,
483        ),
484        ClientError,
485    > {
486        self.client
487            .send_get(&self.encode_store_key(key)?, min_sequence_number)
488            .await
489    }
490
491    pub(crate) async fn get(&self, key: &Key) -> Result<Option<Bytes>, ClientError> {
492        self.client.get(&self.encode_store_key(key)?).await
493    }
494
495    pub(crate) async fn get_with_min_sequence_number(
496        &self,
497        key: &Key,
498        min_sequence_number: u64,
499    ) -> Result<Option<Bytes>, ClientError> {
500        self.client
501            .get_with_min_sequence_number(&self.encode_store_key(key)?, min_sequence_number)
502            .await
503    }
504
505    pub(crate) async fn get_many(
506        &self,
507        keys: &[&Key],
508        batch_size: u32,
509    ) -> Result<GetManyStream, ClientError> {
510        self.get_many_internal(keys, batch_size, None, None).await
511    }
512
513    pub(crate) async fn get_many_with_min_sequence_number(
514        &self,
515        keys: &[&Key],
516        batch_size: u32,
517        min_sequence_number: u64,
518    ) -> Result<GetManyStream, ClientError> {
519        self.get_many_internal(keys, batch_size, Some(min_sequence_number), None)
520            .await
521    }
522
523    pub(crate) async fn get_many_internal(
524        &self,
525        keys: &[&Key],
526        batch_size: u32,
527        min_sequence_number: Option<u64>,
528        observed_sequence: Option<Arc<AtomicU64>>,
529    ) -> Result<GetManyStream, ClientError> {
530        let mut proto_keys: Vec<Vec<u8>> = Vec::with_capacity(keys.len());
531        for key in keys {
532            let encoded = self.encode_store_key(key)?;
533            if !is_valid_key_size(encoded.len()) {
534                return Err(ClientError::WireFormat(format!(
535                    "key length {} is outside valid store key range ({}..={})",
536                    encoded.len(),
537                    keys::MIN_KEY_LEN,
538                    MAX_KEY_LEN
539                )));
540            }
541            proto_keys.push(encoded.to_vec());
542        }
543        let mut stream = self
544            .client
545            .get_many_internal(
546                proto_keys,
547                batch_size,
548                min_sequence_number,
549                observed_sequence,
550            )
551            .await?;
552        stream.key_prefix = Some(self.prefix.clone());
553        Ok(stream)
554    }
555
556    pub(crate) async fn range(
557        &self,
558        start: &Key,
559        end: &Key,
560        limit: usize,
561    ) -> Result<Vec<(Key, Bytes)>, ClientError> {
562        self.range_internal(start, end, limit, RangeMode::Forward, None)
563            .await
564    }
565
566    pub(crate) async fn range_with_mode(
567        &self,
568        start: &Key,
569        end: &Key,
570        limit: usize,
571        mode: RangeMode,
572    ) -> Result<Vec<(Key, Bytes)>, ClientError> {
573        self.range_internal(start, end, limit, mode, None).await
574    }
575
576    pub(crate) async fn range_with_min_sequence_number(
577        &self,
578        start: &Key,
579        end: &Key,
580        limit: usize,
581        min_sequence_number: u64,
582    ) -> Result<Vec<(Key, Bytes)>, ClientError> {
583        self.range_internal(
584            start,
585            end,
586            limit,
587            RangeMode::Forward,
588            Some(min_sequence_number),
589        )
590        .await
591    }
592
593    pub(crate) async fn range_with_mode_and_min_sequence_number(
594        &self,
595        start: &Key,
596        end: &Key,
597        limit: usize,
598        mode: RangeMode,
599        min_sequence_number: u64,
600    ) -> Result<Vec<(Key, Bytes)>, ClientError> {
601        self.range_internal(start, end, limit, mode, Some(min_sequence_number))
602            .await
603    }
604
605    async fn range_internal(
606        &self,
607        start: &Key,
608        end: &Key,
609        limit: usize,
610        mode: RangeMode,
611        min_sequence_number: Option<u64>,
612    ) -> Result<Vec<(Key, Bytes)>, ClientError> {
613        self.range_stream_internal(
614            start,
615            end,
616            limit,
617            limit.max(1),
618            mode,
619            QueryStreamReadOptions {
620                min_sequence_number,
621                observed_sequence: None,
622            },
623        )
624        .await?
625        .collect()
626        .await
627    }
628
629    pub(crate) async fn range_stream(
630        &self,
631        start: &Key,
632        end: &Key,
633        limit: usize,
634        batch_size: usize,
635    ) -> Result<RangeStream, ClientError> {
636        self.range_stream_internal(
637            start,
638            end,
639            limit,
640            batch_size,
641            RangeMode::Forward,
642            QueryStreamReadOptions::default(),
643        )
644        .await
645    }
646
647    pub(crate) async fn range_stream_with_mode(
648        &self,
649        start: &Key,
650        end: &Key,
651        limit: usize,
652        batch_size: usize,
653        mode: RangeMode,
654    ) -> Result<RangeStream, ClientError> {
655        self.range_stream_internal(start, end, limit, batch_size, mode, Default::default())
656            .await
657    }
658
659    pub(crate) async fn range_stream_with_min_sequence_number(
660        &self,
661        start: &Key,
662        end: &Key,
663        limit: usize,
664        batch_size: usize,
665        min_sequence_number: u64,
666    ) -> Result<RangeStream, ClientError> {
667        self.range_stream_internal(
668            start,
669            end,
670            limit,
671            batch_size,
672            RangeMode::Forward,
673            QueryStreamReadOptions {
674                min_sequence_number: Some(min_sequence_number),
675                observed_sequence: None,
676            },
677        )
678        .await
679    }
680
681    pub(crate) async fn range_stream_with_mode_and_min_sequence_number(
682        &self,
683        start: &Key,
684        end: &Key,
685        limit: usize,
686        batch_size: usize,
687        mode: RangeMode,
688        min_sequence_number: u64,
689    ) -> Result<RangeStream, ClientError> {
690        self.range_stream_internal(
691            start,
692            end,
693            limit,
694            batch_size,
695            mode,
696            QueryStreamReadOptions {
697                min_sequence_number: Some(min_sequence_number),
698                observed_sequence: None,
699            },
700        )
701        .await
702    }
703
704    pub(crate) async fn range_stream_internal(
705        &self,
706        start: &Key,
707        end: &Key,
708        limit: usize,
709        batch_size: usize,
710        mode: RangeMode,
711        options: QueryStreamReadOptions,
712    ) -> Result<RangeStream, ClientError> {
713        let (start, end) = self.encode_store_range(start, end)?;
714        let mut stream = self
715            .client
716            .range_stream_internal(&start, &end, limit, batch_size, mode, options)
717            .await?;
718        stream.key_prefix = Some(self.prefix.clone());
719        Ok(stream)
720    }
721
722    pub(crate) async fn range_reduce_stream_internal(
723        &self,
724        start: &Key,
725        end: &Key,
726        request: &DomainRangeReduceRequest,
727        options: QueryStreamReadOptions,
728    ) -> Result<ReduceStream, ClientError> {
729        let (start, end) = self.encode_store_range(start, end)?;
730        let request = self.prefix_reduce_request(request)?;
731        self.client
732            .range_reduce_stream_internal(&start, &end, &request, options)
733            .await
734    }
735
736    pub(crate) async fn apply_prune_policies(
737        &self,
738        policies: &[crate::prune_policy::PrunePolicy],
739    ) -> Result<(), ClientError> {
740        let policies = self.prefix_prune_policies(policies)?;
741        self.client.prune(&policies).await
742    }
743
744    pub(crate) async fn subscribe(
745        &self,
746        filter: crate::stream_filter::StreamFilter,
747        since_sequence_number: Option<u64>,
748    ) -> Result<StreamSubscription, ClientError> {
749        // Byte-aligned payloads let the server apply the caller's payload_regex
750        // exactly against the post-prefix payload, so there is no client-side
751        // re-filter: the prefix only namespaces keys on the wire.
752        let filter = self.prefix_stream_filter(filter)?;
753        let mut sub = self
754            .client
755            .subscribe_physical(filter, since_sequence_number)
756            .await?;
757        sub.key_prefix = Some(self.prefix.clone());
758        Ok(sub)
759    }
760
761    pub(crate) async fn stream_get(
762        &self,
763        sequence_number: u64,
764    ) -> Result<Option<Vec<(Key, Bytes)>>, ClientError> {
765        let Some(owned) = self.client.stream_get_physical(sequence_number).await? else {
766            return Ok(None);
767        };
768        let mut out = Vec::with_capacity(owned.entries.len());
769        for entry in owned.entries {
770            let key = Bytes::from(entry.key);
771            if !self.prefix.matches(&key) {
772                continue;
773            }
774            out.push((self.decode_store_key(&key)?, entry.value));
775        }
776        Ok(Some(out))
777    }
778
779    pub(crate) async fn set_retention(
780        &self,
781        policy: Option<crate::retention::RetentionPolicy>,
782    ) -> Result<Option<u64>, ClientError> {
783        // Retention operates on sequence numbers, not keys, so there is no
784        // per-namespace prefixing to apply here.
785        self.client.set_retention(policy).await
786    }
787}
788
789/// Returns the sole scalar frame after validating its shape and consuming the final status.
790pub async fn scalar_reduce_response(
791    mut stream: ReduceStream,
792    request: &DomainRangeReduceRequest,
793) -> Result<connectrpc::StreamMessage<proto_query::ReduceResponse>, ClientError> {
794    if !request.group_by.is_empty() {
795        return Err(ClientError::WireFormat(
796            "grouped reductions require range_reduce_stream".to_string(),
797        ));
798    }
799    let response = stream.next().await.transpose()?.ok_or_else(|| {
800        ClientError::WireFormat("scalar reduction stream returned no results".to_string())
801    })?;
802    let view = response.view();
803    if !view.groups.is_empty() || view.results.len() != request.reducers.len() {
804        return Err(ClientError::WireFormat(
805            "scalar reduction returned an invalid result shape".to_string(),
806        ));
807    }
808    if stream.next().await.transpose()?.is_some() {
809        return Err(ClientError::WireFormat(
810            "scalar reduction returned more than one frame".to_string(),
811        ));
812    }
813    Ok(response)
814}
815
816async fn scalar_reduce_results(
817    stream: ReduceStream,
818    request: &DomainRangeReduceRequest,
819) -> Result<Vec<Option<KvReducedValue>>, ClientError> {
820    let response = scalar_reduce_response(stream, request).await?;
821    let decoded =
822        proto_to_domain_reduce_response(response.view()).map_err(ClientError::WireFormat)?;
823    Ok(decoded
824        .results
825        .into_iter()
826        .map(|result| result.value)
827        .collect())
828}
829
830/// A physical Store write batch assembled from one or more logical clients.
831///
832/// Use [`Self::push`] with the specific prefixed client that produced each
833/// logical key, then [`Self::commit`] once to submit all rows in one atomic
834/// Store `Put`.
835#[derive(Clone, Debug, Default)]
836pub struct StoreWriteBatch {
837    entries: Vec<(Key, Bytes)>,
838}
839
840impl StoreWriteBatch {
841    pub fn new() -> Self {
842        Self::default()
843    }
844
845    pub fn len(&self) -> usize {
846        self.entries.len()
847    }
848
849    pub fn is_empty(&self) -> bool {
850        self.entries.is_empty()
851    }
852
853    pub fn clear(&mut self) {
854        self.entries.clear();
855    }
856
857    pub fn reserve(&mut self, additional: usize) {
858        self.entries.reserve(additional);
859    }
860
861    /// Stage a logical row under `client`'s namespace.
862    ///
863    /// The key is encoded as it is staged, so rows from several namespaces
864    /// can share one batch; [`Self::commit`] writes the encoded rows
865    /// verbatim through the physical client.
866    pub fn push(
867        &mut self,
868        client: &PrefixedStoreClient,
869        key: &Key,
870        value: impl IntoStoreWriteValue,
871    ) -> Result<&mut Self, ClientError> {
872        self.entries.push((
873            client.encode_store_key(key)?,
874            value.into_store_write_value(),
875        ));
876        Ok(self)
877    }
878
879    /// The staged physical entries, in staging order.
880    pub fn entries(&self) -> &[(Key, Bytes)] {
881        &self.entries
882    }
883
884    pub async fn commit(&self, client: &StoreClient) -> Result<u64, ClientError> {
885        client.put_prepared_physical(&self.entries).await
886    }
887}
888
889/// A writer that can stage an already-prepared upload into a shared Store
890/// write batch and then be notified of the batch outcome.
891///
892/// Implementations should keep `prepare_*` methods as inherent APIs because
893/// each writer's input shape differs. Once a caller has a prepared handle,
894/// this trait provides the common lifecycle:
895///
896/// 1. stage rows into a [`StoreWriteBatch`], consuming staged payloads if useful
897/// 2. commit that batch
898/// 3. mark the prepared handle persisted with the returned Store sequence
899///    number, or failed if staging/commit does not complete
900pub trait StoreBatchUpload {
901    type Prepared: Send;
902    type Receipt: Send;
903    type Error: std::fmt::Display + Send;
904
905    fn store_client(&self) -> &PrefixedStoreClient;
906
907    fn stage_upload(
908        &self,
909        prepared: &mut Self::Prepared,
910        batch: &mut StoreWriteBatch,
911    ) -> Result<(), Self::Error>;
912
913    fn commit_error(&self, error: ClientError) -> Self::Error;
914
915    fn mark_upload_persisted<'a>(
916        &'a self,
917        prepared: Self::Prepared,
918        sequence_number: u64,
919    ) -> BoxFuture<'a, Self::Receipt>
920    where
921        Self: Sync + 'a,
922        Self::Prepared: 'a;
923
924    fn mark_upload_failed<'a>(
925        &'a self,
926        prepared: Self::Prepared,
927        error: String,
928    ) -> BoxFuture<'a, ()>
929    where
930        Self: Sync + 'a,
931        Self::Prepared: 'a;
932
933    fn commit_upload<'a>(
934        &'a self,
935        prepared: Self::Prepared,
936    ) -> BoxFuture<'a, Result<Self::Receipt, Self::Error>>
937    where
938        Self: Sync + Sized + 'a,
939        Self::Prepared: 'a,
940        Self::Receipt: 'a,
941        Self::Error: 'a,
942    {
943        Box::pin(async move {
944            let mut prepared = prepared;
945            let mut batch = StoreWriteBatch::new();
946            if let Err(err) = self.stage_upload(&mut prepared, &mut batch) {
947                let message = err.to_string();
948                self.mark_upload_failed(prepared, message).await;
949                return Err(err);
950            }
951            match batch.commit(self.store_client().client()).await {
952                Ok(sequence_number) => {
953                    Ok(self.mark_upload_persisted(prepared, sequence_number).await)
954                }
955                Err(err) => {
956                    let message = err.to_string();
957                    self.mark_upload_failed(prepared, message).await;
958                    Err(self.commit_error(err))
959                }
960            }
961        })
962    }
963}
964
965/// Traversal mode for range queries.
966#[derive(Clone, Copy, Debug, Eq, PartialEq)]
967pub enum RangeMode {
968    Forward,
969    Reverse,
970}
971
972#[derive(Clone, Debug)]
973pub struct RangeChunk {
974    /// Rows returned in this stream frame.
975    pub rows: Vec<(Key, Bytes)>,
976    /// Query detail reported after reading this chunk.
977    pub detail: Option<proto_query::Detail>,
978}
979
980#[derive(Clone, Debug)]
981pub struct GetManyChunk {
982    /// Lookup entries returned in this stream frame.
983    pub entries: Vec<(Key, Option<Bytes>)>,
984    /// Query detail reported after reading this chunk.
985    pub detail: Option<proto_query::Detail>,
986}
987
988/// Iterator-like async range stream.
989pub struct RangeStream {
990    stream:
991        ConnectServerStream<ProtoErasedResponseBody, exoware_proto::query::RangeFrameView<'static>>,
992    pending_frame: Option<exoware_proto::query::RangeFrame>,
993    rows_seen: usize,
994    final_count: Option<usize>,
995    finished: bool,
996    observed_sequence: Option<Arc<AtomicU64>>,
997    key_prefix: Option<StoreKeyPrefix>,
998    credential: Credential,
999}
1000
1001impl RangeStream {
1002    fn from_connect_stream(
1003        stream: ConnectServerStream<
1004            ProtoErasedResponseBody,
1005            exoware_proto::query::RangeFrameView<'static>,
1006        >,
1007        observed_sequence: Option<Arc<AtomicU64>>,
1008        key_prefix: Option<StoreKeyPrefix>,
1009        credential: Credential,
1010    ) -> Self {
1011        Self {
1012            stream,
1013            pending_frame: None,
1014            rows_seen: 0,
1015            final_count: None,
1016            finished: false,
1017            observed_sequence,
1018            key_prefix,
1019            credential,
1020        }
1021    }
1022
1023    pub fn final_count(&self) -> Option<usize> {
1024        self.final_count
1025    }
1026
1027    async fn prefetch_first_frame(&mut self) -> Result<(), ConnectError> {
1028        if self.pending_frame.is_some() || self.finished {
1029            return Ok(());
1030        }
1031        match self.stream.message().await? {
1032            Some(frame) => {
1033                let frame = frame.to_owned_message();
1034                if let (Some(sequence_store), Some(detail)) =
1035                    (&self.observed_sequence, frame.detail.as_option())
1036                {
1037                    sequence_store.fetch_max(detail.sequence_number, Ordering::SeqCst);
1038                }
1039                self.pending_frame = Some(frame);
1040                Ok(())
1041            }
1042            None => {
1043                self.finished = true;
1044                if let Some(err) = self.stream.error() {
1045                    Err(err.clone())
1046                } else {
1047                    self.final_count = Some(self.rows_seen);
1048                    Ok(())
1049                }
1050            }
1051        }
1052    }
1053
1054    pub async fn next_chunk(&mut self) -> Result<Option<RangeChunk>, ClientError> {
1055        loop {
1056            if self.finished {
1057                return Ok(None);
1058            }
1059
1060            let frame = if let Some(frame) = self.pending_frame.take() {
1061                frame
1062            } else {
1063                let Some(frame) = self
1064                    .stream
1065                    .message()
1066                    .await
1067                    .map_err(|err| client_error_from_connect(err, self.credential))?
1068                else {
1069                    self.finished = true;
1070                    if let Some(err) = self.stream.error() {
1071                        return Err(client_error_from_connect(err.clone(), self.credential));
1072                    }
1073                    self.final_count = Some(self.rows_seen);
1074                    return Ok(None);
1075                };
1076                let frame = frame.to_owned_message();
1077                if let (Some(sequence_store), Some(detail)) =
1078                    (&self.observed_sequence, frame.detail.as_option())
1079                {
1080                    sequence_store.fetch_max(detail.sequence_number, Ordering::SeqCst);
1081                }
1082                frame
1083            };
1084
1085            let detail = frame.detail.as_option().cloned();
1086            let n = frame.results.len();
1087
1088            // Hide default/empty wire frames from the SDK's semantic chunk stream.
1089            if n == 0 && detail.is_none() {
1090                continue;
1091            }
1092
1093            let mut out = Vec::with_capacity(n);
1094            for entry in frame.results {
1095                let key = Bytes::from(entry.key);
1096                let key = match &self.key_prefix {
1097                    Some(prefix) => prefix.decode_key(&key)?,
1098                    None => key,
1099                };
1100                out.push((key, entry.value));
1101            }
1102            self.rows_seen += n;
1103            return Ok(Some(RangeChunk { rows: out, detail }));
1104        }
1105    }
1106
1107    pub async fn collect(mut self) -> Result<Vec<(Key, Bytes)>, ClientError> {
1108        let mut entries = Vec::new();
1109        while let Some(chunk) = self.next_chunk().await? {
1110            entries.extend(chunk.rows);
1111        }
1112        Ok(entries)
1113    }
1114}
1115
1116/// Final reduction results, delivered in bounded frames without collecting all groups.
1117///
1118/// Each item retains its wire buffer and exposes a borrowed response through `view()`.
1119/// Dropping the stream cancels the request. Only opening the stream and receiving
1120/// its first frame may be retried. Errors after that frame are returned to the caller.
1121pub type ReduceStream =
1122    BoxStream<'static, Result<connectrpc::StreamMessage<proto_query::ReduceResponse>, ClientError>>;
1123
1124pub struct GetManyStream {
1125    stream: ConnectServerStream<
1126        ProtoErasedResponseBody,
1127        exoware_proto::query::GetManyFrameView<'static>,
1128    >,
1129    pending_frame: Option<exoware_proto::query::GetManyFrame>,
1130    finished: bool,
1131    observed_sequence: Option<Arc<AtomicU64>>,
1132    key_prefix: Option<StoreKeyPrefix>,
1133    credential: Credential,
1134}
1135
1136impl GetManyStream {
1137    fn from_connect_stream(
1138        stream: ConnectServerStream<
1139            ProtoErasedResponseBody,
1140            exoware_proto::query::GetManyFrameView<'static>,
1141        >,
1142        observed_sequence: Option<Arc<AtomicU64>>,
1143        key_prefix: Option<StoreKeyPrefix>,
1144        credential: Credential,
1145    ) -> Self {
1146        Self {
1147            stream,
1148            pending_frame: None,
1149            finished: false,
1150            observed_sequence,
1151            key_prefix,
1152            credential,
1153        }
1154    }
1155
1156    async fn prefetch_first_frame(&mut self) -> Result<(), ConnectError> {
1157        if self.pending_frame.is_some() || self.finished {
1158            return Ok(());
1159        }
1160        match self.stream.message().await? {
1161            Some(frame) => {
1162                let frame = frame.to_owned_message();
1163                if let (Some(sequence_store), Some(detail)) =
1164                    (&self.observed_sequence, frame.detail.as_option())
1165                {
1166                    sequence_store.fetch_max(detail.sequence_number, Ordering::SeqCst);
1167                }
1168                self.pending_frame = Some(frame);
1169                Ok(())
1170            }
1171            None => {
1172                self.finished = true;
1173                if let Some(err) = self.stream.error() {
1174                    Err(err.clone())
1175                } else {
1176                    Ok(())
1177                }
1178            }
1179        }
1180    }
1181
1182    pub async fn next_chunk(&mut self) -> Result<Option<GetManyChunk>, ClientError> {
1183        loop {
1184            if self.finished {
1185                return Ok(None);
1186            }
1187            let frame = if let Some(frame) = self.pending_frame.take() {
1188                frame
1189            } else {
1190                let Some(frame) = self
1191                    .stream
1192                    .message()
1193                    .await
1194                    .map_err(|err| client_error_from_connect(err, self.credential))?
1195                else {
1196                    self.finished = true;
1197                    if let Some(err) = self.stream.error() {
1198                        return Err(client_error_from_connect(err.clone(), self.credential));
1199                    }
1200                    return Ok(None);
1201                };
1202                let frame = frame.to_owned_message();
1203                if let (Some(sequence_store), Some(detail)) =
1204                    (&self.observed_sequence, frame.detail.as_option())
1205                {
1206                    sequence_store.fetch_max(detail.sequence_number, Ordering::SeqCst);
1207                }
1208                frame
1209            };
1210
1211            let detail = frame.detail.as_option().cloned();
1212            let n = frame.results.len();
1213
1214            // Hide default/empty wire frames from the SDK's semantic chunk stream.
1215            if n == 0 && detail.is_none() {
1216                continue;
1217            }
1218
1219            let mut out = Vec::with_capacity(n);
1220            for entry in frame.results {
1221                let key = Bytes::from(entry.key);
1222                let key = match &self.key_prefix {
1223                    Some(prefix) => prefix.decode_key(&key)?,
1224                    None => key,
1225                };
1226                out.push((key, entry.value));
1227            }
1228            return Ok(Some(GetManyChunk {
1229                entries: out,
1230                detail,
1231            }));
1232        }
1233    }
1234
1235    pub async fn collect(mut self) -> Result<HashMap<Key, Bytes>, ClientError> {
1236        let mut map = HashMap::new();
1237        while let Some(chunk) = self.next_chunk().await? {
1238            for (key, value) in chunk.entries {
1239                if let Some(v) = value {
1240                    map.insert(key, v);
1241                }
1242            }
1243        }
1244        Ok(map)
1245    }
1246}
1247
1248impl RangeMode {
1249    fn to_proto(self) -> proto_query::TraversalMode {
1250        match self {
1251            Self::Forward => proto_query::TraversalMode::TRAVERSAL_MODE_FORWARD,
1252            Self::Reverse => proto_query::TraversalMode::TRAVERSAL_MODE_REVERSE,
1253        }
1254    }
1255}
1256
1257/// One delivered (key, value) row from a stream subscription. The client
1258/// reapplies its own filter if it needs to know which selector matched —
1259/// the wire frame doesn't carry the index.
1260#[derive(Clone, Debug)]
1261pub struct StreamSubscriptionEntry {
1262    pub key: Key,
1263    pub value: Bytes,
1264}
1265
1266/// One atomic Put batch delivered to a subscriber.
1267#[derive(Clone, Debug)]
1268pub struct StreamSubscriptionFrame {
1269    pub sequence_number: u64,
1270    pub entries: Vec<StreamSubscriptionEntry>,
1271}
1272
1273/// Async stream of `StreamSubscriptionFrame`. Backed by the generated
1274/// connectrpc server stream.
1275pub struct StreamSubscription {
1276    stream: ConnectServerStream<
1277        ProtoErasedResponseBody,
1278        exoware_proto::log::stream::v1::SubscribeResponseView<'static>,
1279    >,
1280    key_prefix: Option<StoreKeyPrefix>,
1281    credential: Credential,
1282}
1283
1284impl std::fmt::Debug for StreamSubscription {
1285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1286        f.debug_struct("StreamSubscription").finish_non_exhaustive()
1287    }
1288}
1289
1290impl StreamSubscription {
1291    /// Pull the next frame. `Ok(None)` = server closed the stream cleanly.
1292    pub async fn next(&mut self) -> Result<Option<StreamSubscriptionFrame>, ClientError> {
1293        loop {
1294            match self
1295                .stream
1296                .message()
1297                .await
1298                .map_err(|err| client_error_from_connect(err, self.credential))?
1299            {
1300                Some(view) => {
1301                    let owned = view.to_owned_message();
1302                    let mut entries = Vec::with_capacity(owned.entries.len());
1303                    for entry in owned.entries {
1304                        let key = Bytes::from(entry.key);
1305                        let key = match &self.key_prefix {
1306                            Some(prefix) => prefix.decode_key(&key)?,
1307                            None => key,
1308                        };
1309                        entries.push(StreamSubscriptionEntry {
1310                            key,
1311                            value: entry.value,
1312                        });
1313                    }
1314                    if entries.is_empty() {
1315                        continue;
1316                    }
1317                    let frame = StreamSubscriptionFrame {
1318                        sequence_number: owned.sequence_number,
1319                        entries,
1320                    };
1321                    return Ok(Some(frame));
1322                }
1323                None => {
1324                    if let Some(err) = self.stream.error() {
1325                        return Err(client_error_from_connect(err.clone(), self.credential));
1326                    } else {
1327                        return Ok(None);
1328                    }
1329                }
1330            }
1331        }
1332    }
1333}
1334
1335/// Inspect a Connect error for `log.stream.BATCH_EVICTED` / `BATCH_NOT_FOUND`
1336/// `ErrorInfo` details. Used by `get_batch` to collapse both into `Ok(None)`.
1337fn is_batch_missing_error(err: &ConnectError) -> bool {
1338    match proto_decode_connect_error(err) {
1339        Ok(decoded) => decoded.error_info.is_some_and(|info| {
1340            info.domain == "log.stream"
1341                && matches!(info.reason.as_str(), "BATCH_EVICTED" | "BATCH_NOT_FOUND")
1342        }),
1343        Err(_) => false,
1344    }
1345}
1346
1347/// Retry policy for idempotent read operations.
1348#[derive(Clone, Copy, Debug)]
1349pub struct RetryConfig {
1350    max_attempts: usize,
1351    initial_backoff: Duration,
1352    max_backoff: Duration,
1353}
1354
1355impl RetryConfig {
1356    pub fn standard() -> Self {
1357        Self {
1358            max_attempts: DEFAULT_RETRY_MAX_ATTEMPTS,
1359            initial_backoff: Duration::from_millis(DEFAULT_RETRY_INITIAL_BACKOFF_MS),
1360            max_backoff: Duration::from_millis(DEFAULT_RETRY_MAX_BACKOFF_MS),
1361        }
1362    }
1363
1364    pub fn disabled() -> Self {
1365        Self::standard().with_max_attempts(1)
1366    }
1367
1368    pub fn with_max_attempts(mut self, max_attempts: usize) -> Self {
1369        self.max_attempts = max_attempts.max(1);
1370        self
1371    }
1372
1373    pub fn with_initial_backoff(mut self, initial_backoff: Duration) -> Self {
1374        self.initial_backoff = initial_backoff;
1375        self
1376    }
1377
1378    pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
1379        self.max_backoff = max_backoff;
1380        self
1381    }
1382
1383    pub(crate) fn sanitized(self) -> Self {
1384        let max_attempts = self.max_attempts.max(1);
1385        let max_backoff = self.max_backoff.max(self.initial_backoff);
1386        Self {
1387            max_attempts,
1388            initial_backoff: self.initial_backoff,
1389            max_backoff,
1390        }
1391    }
1392}
1393
1394impl Default for RetryConfig {
1395    fn default() -> Self {
1396        Self::standard()
1397    }
1398}
1399
1400fn trim_connect_base(url: &str) -> String {
1401    url.trim_end_matches('/').to_string()
1402}
1403
1404fn parse_connect_uri(url: &str) -> Result<http::Uri, ClientBuildError> {
1405    let uri: http::Uri = url.parse().map_err(|source| ClientBuildError::InvalidUrl {
1406        url: url.to_string(),
1407        source,
1408    })?;
1409    if uri.authority().is_none() || !matches!(uri.scheme_str(), Some("http" | "https")) {
1410        return Err(ClientBuildError::InvalidEndpointUrl {
1411            url: url.to_string(),
1412        });
1413    }
1414    Ok(uri)
1415}
1416
1417fn new_health_client() -> reqwest::Client {
1418    reqwest::Client::builder()
1419        .pool_max_idle_per_host(32)
1420        .timeout(Duration::from_secs(30))
1421        .build()
1422        .expect("failed to build HTTP client")
1423}
1424
1425/// Error returned while building a [`StoreClient`].
1426#[derive(Debug, thiserror::Error)]
1427pub enum ClientBuildError {
1428    #[error("StoreClientBuilder: missing health URL (set health_url or url)")]
1429    MissingHealthUrl,
1430    #[error("StoreClientBuilder: missing ingest URL (set ingest_url or url)")]
1431    MissingIngestUrl,
1432    #[error("StoreClientBuilder: missing query URL (set query_url or url)")]
1433    MissingQueryUrl,
1434    #[error("StoreClientBuilder: missing prune URL (set prune_url or url)")]
1435    MissingPruneUrl,
1436    #[error("StoreClientBuilder: missing retention URL (set retention_url or url)")]
1437    MissingRetentionUrl,
1438    #[error("StoreClientBuilder: missing stream URL (set stream_url or url)")]
1439    MissingStreamUrl,
1440    #[error("StoreClientBuilder: invalid URL \"{url}\": {source}")]
1441    InvalidUrl {
1442        url: String,
1443        source: http::uri::InvalidUri,
1444    },
1445    #[error(
1446        "StoreClientBuilder: invalid endpoint URL \"{url}\" (expected absolute HTTP or HTTPS URL)"
1447    )]
1448    InvalidEndpointUrl { url: String },
1449    #[error("StoreClientBuilder: failed to configure platform TLS verifier: {0}")]
1450    TlsConfig(#[source] connectrpc::rustls::Error),
1451    #[error(
1452        "StoreClientBuilder: API key is not valid in an HTTP header (check for control or non-ASCII characters)"
1453    )]
1454    InvalidApiKey,
1455    #[error(
1456        "{API_KEY_ENV} is set to a value that cannot be an HTTP header. Remove any control or non-ASCII characters from it"
1457    )]
1458    InvalidApiKeyEnv,
1459}
1460
1461#[derive(Debug)]
1462enum RpcTransportChoice {
1463    Custom(ProtoErasedClientTransport),
1464    BalancedHttp2(ProtoBalancedHttp2Config),
1465}
1466
1467/// Configures a [`StoreClient`] with explicit bases for health probes and store services.
1468///
1469/// Use [`StoreClient::builder()`] to construct. Call [`Self::url`] to point every
1470/// service at the same origin, or set each base separately. Finish with [`Self::build`].
1471/// HTTP and HTTPS service URLs may be mixed. HTTPS uses the platform certificate verifier.
1472#[derive(Debug, Default)]
1473pub struct StoreClientBuilder {
1474    health_url: Option<String>,
1475    ingest_url: Option<String>,
1476    query_url: Option<String>,
1477    prune_url: Option<String>,
1478    retention_url: Option<String>,
1479    stream_url: Option<String>,
1480    retry_config: RetryConfig,
1481    connect_request_compression: ConnectRequestCompression,
1482    api_key: Option<ApiKey>,
1483    rpc_transport: Option<RpcTransportChoice>,
1484}
1485
1486impl StoreClientBuilder {
1487    /// Sets the same base URL for all services (health, ingest, query, prune,
1488    /// retention, stream).
1489    pub fn url(mut self, url: &str) -> Self {
1490        let u = trim_connect_base(url);
1491        self.health_url = Some(u.clone());
1492        self.ingest_url = Some(u.clone());
1493        self.query_url = Some(u.clone());
1494        self.prune_url = Some(u.clone());
1495        self.retention_url = Some(u.clone());
1496        self.stream_url = Some(u);
1497        self
1498    }
1499
1500    /// Base URL for `GET /health` and `GET /ready` (often the query worker).
1501    pub fn health_url(mut self, url: &str) -> Self {
1502        self.health_url = Some(trim_connect_base(url));
1503        self
1504    }
1505
1506    /// Base URL for the ingest service (`log.ingest.v1.Service`).
1507    pub fn ingest_url(mut self, url: &str) -> Self {
1508        self.ingest_url = Some(trim_connect_base(url));
1509        self
1510    }
1511
1512    /// Base URL for the query service (`store.query.v1.Service`).
1513    pub fn query_url(mut self, url: &str) -> Self {
1514        self.query_url = Some(trim_connect_base(url));
1515        self
1516    }
1517
1518    /// Base URL for the prune service (`store.prune.v1.Service`).
1519    pub fn prune_url(mut self, url: &str) -> Self {
1520        self.prune_url = Some(trim_connect_base(url));
1521        self
1522    }
1523
1524    /// Base URL for the retention service (`log.retention.v1.Service`).
1525    pub fn retention_url(mut self, url: &str) -> Self {
1526        self.retention_url = Some(trim_connect_base(url));
1527        self
1528    }
1529
1530    /// Base URL for the stream service (`log.stream.v1.Service`).
1531    pub fn stream_url(mut self, url: &str) -> Self {
1532        self.stream_url = Some(trim_connect_base(url));
1533        self
1534    }
1535
1536    /// API key sent as `Authorization: Bearer <key>` on every RPC.
1537    ///
1538    /// Leaving this unset falls back to the `EXOWARE_API_KEY` environment variable, if present.
1539    ///
1540    /// Ignored for [`StoreClient::health`] and [`StoreClient::ready`].
1541    pub fn api_key(mut self, key: &str) -> Self {
1542        self.api_key = Some(ApiKey(key.to_string()));
1543        self
1544    }
1545
1546    /// Retry policy for idempotent read operations (get / range / reduce).
1547    pub fn retry_config(mut self, retry: RetryConfig) -> Self {
1548        self.retry_config = retry.sanitized();
1549        self
1550    }
1551
1552    /// Codec for compressing **outgoing** RPC request bodies (default [`ConnectRequestCompression::None`]).
1553    pub fn connect_request_compression(mut self, compression: ConnectRequestCompression) -> Self {
1554        self.connect_request_compression = compression;
1555        self
1556    }
1557
1558    /// Uses a consumer-supplied raw connectrpc transport for every Store RPC.
1559    ///
1560    /// The transport must route absolute request URIs to the configured service origins. The SDK
1561    /// applies authentication, cookies, compression preferences, and request compression.
1562    /// Health and readiness probes are unaffected. [`crate::transport`] re-exports the exact trait
1563    /// and body types. Tower middleware that exposes `tower::BoxError` must map it into a concrete
1564    /// [`std::error::Error`] before constructing [`crate::transport::ServiceTransport`].
1565    /// connectrpc reports transport failures as `unavailable`.
1566    pub fn client_transport<T>(mut self, transport: T) -> Self
1567    where
1568        T: connectrpc::client::ClientTransport,
1569        <T::ResponseBody as http_body::Body>::Error: std::fmt::Display,
1570    {
1571        self.rpc_transport = Some(RpcTransportChoice::Custom(ProtoErasedClientTransport::new(
1572            transport,
1573        )));
1574        self
1575    }
1576
1577    /// Uses independent HTTP/2 connection pools for each distinct Store RPC origin.
1578    ///
1579    /// HTTP origins must accept prior-knowledge h2c. HTTPS origins require HTTP/2 through ALPN.
1580    /// The configured request timeout bounds complete unary calls. It also bounds query streams
1581    /// through their first frame and subscriptions through their response headers. It does not stop
1582    /// a streaming response after the call returns.
1583    pub fn balanced_http2_transport(mut self, config: ProtoBalancedHttp2Config) -> Self {
1584        self.rpc_transport = Some(RpcTransportChoice::BalancedHttp2(config));
1585        self
1586    }
1587
1588    /// Build the client, or return an error if any required URL was not set.
1589    /// Takes the API key from [`API_KEY_ENV`] unless [`Self::api_key`] set one, and fails if
1590    /// either cannot be an HTTP header.
1591    pub fn build(self) -> Result<StoreClient, ClientBuildError> {
1592        self.build_with(std::env::var(API_KEY_ENV).ok(), UnusableEnvKey::Reject)
1593    }
1594
1595    /// Takes the environment lookup as an argument, so a test can cover every credential case
1596    /// without mutating a variable that the rest of the process is reading.
1597    fn build_with(
1598        self,
1599        env_api_key: Option<String>,
1600        unusable_env_key: UnusableEnvKey,
1601    ) -> Result<StoreClient, ClientBuildError> {
1602        let health_url = self.health_url.ok_or(ClientBuildError::MissingHealthUrl)?;
1603        let ingest_url = self.ingest_url.ok_or(ClientBuildError::MissingIngestUrl)?;
1604        let query_url = self.query_url.ok_or(ClientBuildError::MissingQueryUrl)?;
1605        let prune_url = self.prune_url.ok_or(ClientBuildError::MissingPruneUrl)?;
1606        let retention_url = self
1607            .retention_url
1608            .ok_or(ClientBuildError::MissingRetentionUrl)?;
1609        let stream_url = self.stream_url.ok_or(ClientBuildError::MissingStreamUrl)?;
1610        let ingest_uri = parse_connect_uri(&ingest_url)?;
1611        let query_uri = parse_connect_uri(&query_url)?;
1612        let prune_uri = parse_connect_uri(&prune_url)?;
1613        let retention_uri = parse_connect_uri(&retention_url)?;
1614        let stream_uri = parse_connect_uri(&stream_url)?;
1615        let rpc_uris = [
1616            &ingest_uri,
1617            &query_uri,
1618            &prune_uri,
1619            &retention_uri,
1620            &stream_uri,
1621        ];
1622        let uses_tls = rpc_uris
1623            .into_iter()
1624            .any(|uri| uri.scheme_str() == Some("https"));
1625
1626        let resolved =
1627            credential::resolve(self.api_key.map(|key| key.0), env_api_key, unusable_env_key)?;
1628        let credential = resolved.credential;
1629        let (connect_http, rpc_timeout) = match self.rpc_transport {
1630            None => {
1631                let connect_http = if uses_tls {
1632                    let tls_config = connectrpc::rustls::ClientConfig::with_platform_verifier()
1633                        .map_err(ClientBuildError::TlsConfig)?;
1634                    ProtoPreferZstdHttpClient::with_tls(Arc::new(tls_config))
1635                } else {
1636                    ProtoPreferZstdHttpClient::plaintext()
1637                };
1638                let connect_http = match resolved.header {
1639                    Some(value) => connect_http.with_authorization(value),
1640                    None => connect_http,
1641                };
1642                (ProtoErasedClientTransport::new(connect_http), None)
1643            }
1644            Some(RpcTransportChoice::Custom(transport)) => {
1645                (transport.with_metadata(resolved.header), None)
1646            }
1647            Some(RpcTransportChoice::BalancedHttp2(mut config)) => {
1648                let rpc_timeout = config.request_timeout;
1649                if uses_tls && config.tls_config.is_none() {
1650                    config = config.with_tls_config(Arc::new(
1651                        connectrpc::rustls::ClientConfig::with_platform_verifier()
1652                            .map_err(ClientBuildError::TlsConfig)?,
1653                    ));
1654                }
1655                let transport =
1656                    ProtoBalancedHttp2Transport::new(rpc_uris.into_iter().cloned(), config);
1657                (
1658                    ProtoErasedClientTransport::new(transport).with_metadata(resolved.header),
1659                    Some(rpc_timeout),
1660                )
1661            }
1662        };
1663        Ok(StoreClient {
1664            health_url,
1665            ingest_uri,
1666            query_uri,
1667            prune_uri,
1668            retention_uri,
1669            stream_uri,
1670            health_http: new_health_client(),
1671            connect_http,
1672            rpc_timeout,
1673            retry_config: self.retry_config,
1674            connect_request_compression: self.connect_request_compression,
1675            credential,
1676        })
1677    }
1678}
1679
1680/// Typed Rust client for Store.
1681#[derive(Clone, Debug)]
1682pub struct StoreClient {
1683    /// Base URL for `health()` / `ready()` (typically the query worker).
1684    pub(crate) health_url: String,
1685    ingest_uri: http::Uri,
1686    query_uri: http::Uri,
1687    prune_uri: http::Uri,
1688    retention_uri: http::Uri,
1689    stream_uri: http::Uri,
1690    health_http: reqwest::Client,
1691    connect_http: ProtoErasedClientTransport,
1692    rpc_timeout: Option<Duration>,
1693    retry_config: RetryConfig,
1694    connect_request_compression: ConnectRequestCompression,
1695    credential: Credential,
1696}
1697
1698/// A session whose read floor advances to the highest Store sequence observed.
1699///
1700/// An explicit initial floor constrains reads without claiming that any read has
1701/// evaluated at that sequence. Reads started after a response is observed use
1702/// at least its sequence. Clones share the floor, including updates from streams.
1703///
1704/// Streamed reads record response sequences as frames arrive, including the
1705/// first frame fetched before the stream is returned to the caller.
1706#[derive(Clone, Debug)]
1707pub struct SerializableReadSession {
1708    client: PrefixedStoreClient,
1709    state: Arc<SessionState>,
1710}
1711
1712#[derive(Debug)]
1713struct SessionState {
1714    minimum_sequence: u64,
1715    sequence: Arc<AtomicU64>,
1716    init_gate: tokio::sync::Mutex<()>,
1717}
1718
1719impl SessionState {
1720    fn fixed_sequence(&self) -> Option<u64> {
1721        let sequence = self.sequence.load(Ordering::Acquire);
1722        let floor = self.minimum_sequence.max(sequence);
1723        (floor > 0).then_some(floor)
1724    }
1725
1726    fn evaluated_sequence(&self) -> Option<u64> {
1727        let sequence = self.sequence.load(Ordering::Acquire);
1728        (sequence > 0).then_some(sequence)
1729    }
1730}
1731
1732#[derive(Default)]
1733struct QueryStreamReadOptions {
1734    min_sequence_number: Option<u64>,
1735    observed_sequence: Option<Arc<AtomicU64>>,
1736}
1737
1738impl StoreClient {
1739    /// Start building a client with per-service base URLs.
1740    pub fn builder() -> StoreClientBuilder {
1741        StoreClientBuilder::default()
1742    }
1743
1744    pub fn new(url: &str) -> Self {
1745        Self::with_retry_config(url, RetryConfig::standard())
1746    }
1747
1748    /// Panics if `url` is not a valid endpoint. A malformed [`API_KEY_ENV`] is ignored rather
1749    /// than panicking, so use [`Self::builder`] to have it rejected.
1750    pub fn with_retry_config(url: &str, retry_config: RetryConfig) -> Self {
1751        Self::builder()
1752            .url(url)
1753            .retry_config(retry_config)
1754            .build_with(std::env::var(API_KEY_ENV).ok(), UnusableEnvKey::Tolerate)
1755            .expect("failed to configure Store client")
1756    }
1757
1758    /// Pair this physical client with `prefix`, yielding the logical
1759    /// [`PrefixedStoreClient`] that namespaces every operation.
1760    pub fn prefixed(&self, prefix: StoreKeyPrefix) -> PrefixedStoreClient {
1761        PrefixedStoreClient::new(self.clone(), prefix)
1762    }
1763
1764    /// Outgoing Connect request body compression (see [`ConnectRequestCompression`]).
1765    pub fn connect_request_compression(&self) -> ConnectRequestCompression {
1766        self.connect_request_compression
1767    }
1768
1769    fn unary_client_config(&self, base_uri: http::Uri) -> ClientConfig {
1770        store_connect_client_config(base_uri, self.connect_request_compression, self.rpc_timeout)
1771    }
1772
1773    fn streaming_client_config(&self, base_uri: http::Uri) -> ClientConfig {
1774        store_connect_client_config(base_uri, self.connect_request_compression, None)
1775    }
1776
1777    async fn with_streaming_timeout<T>(
1778        &self,
1779        future: impl std::future::Future<Output = Result<T, ConnectError>>,
1780    ) -> Result<T, ConnectError> {
1781        match self.rpc_timeout {
1782            Some(timeout) => tokio::time::timeout(timeout, future)
1783                .await
1784                .map_err(|_| ConnectError::deadline_exceeded("client-side deadline exceeded"))?,
1785            None => future.await,
1786        }
1787    }
1788
1789    pub fn decode_error_details(
1790        err: &ConnectError,
1791    ) -> Result<exoware_proto::DecodedConnectError, buffa::DecodeError> {
1792        proto_decode_connect_error(err)
1793    }
1794
1795    /// Submit a KV batch via Connect `Put`.
1796    ///
1797    /// On success returns the **store sequence number** from the response. Use it for immediate
1798    /// `get_with_min_sequence_number` / range calls or as the initial floor for
1799    /// [`PrefixedStoreClient::create_session_with_sequence`].
1800    /// If the request succeeds, the server accepts the full batch (count is `kvs.len()`).
1801    pub(crate) async fn put_physical(&self, kvs: &[(&Key, &[u8])]) -> Result<u64, ClientError> {
1802        let mut proto_kvs = Vec::with_capacity(kvs.len());
1803        for (key, value) in kvs {
1804            if !is_valid_key_size(key.len()) {
1805                return Err(ClientError::WireFormat(format!(
1806                    "key length {} is outside valid store key range ({}..={})",
1807                    key.len(),
1808                    keys::MIN_KEY_LEN,
1809                    MAX_KEY_LEN
1810                )));
1811            }
1812            proto_kvs.push(exoware_proto::common::Entry {
1813                key: key.to_vec(),
1814                value: Bytes::copy_from_slice(value),
1815                ..Default::default()
1816            });
1817        }
1818        self.send_put(proto_kvs).await
1819    }
1820
1821    async fn put_prepared_physical(&self, kvs: &[(Key, Bytes)]) -> Result<u64, ClientError> {
1822        let mut proto_kvs = Vec::with_capacity(kvs.len());
1823        for (key, value) in kvs {
1824            if !is_valid_key_size(key.len()) {
1825                return Err(ClientError::WireFormat(format!(
1826                    "key length {} is outside valid store key range ({}..={})",
1827                    key.len(),
1828                    keys::MIN_KEY_LEN,
1829                    MAX_KEY_LEN
1830                )));
1831            }
1832            proto_kvs.push(exoware_proto::common::Entry {
1833                key: key.to_vec(),
1834                value: value.clone(),
1835                ..Default::default()
1836            });
1837        }
1838        self.send_put(proto_kvs).await
1839    }
1840
1841    async fn send_put(&self, kvs: Vec<exoware_proto::common::Entry>) -> Result<u64, ClientError> {
1842        let config = self.unary_client_config(self.ingest_uri.clone());
1843        let client = IngestServiceClient::new(self.connect_http.clone(), config);
1844        let response = client
1845            .put(ProtoPutRequest {
1846                kvs,
1847                ..Default::default()
1848            })
1849            .await
1850            .map_err(|err| client_error_from_connect(err, self.credential))?;
1851        Ok(response.into_owned().sequence_number)
1852    }
1853
1854    pub(crate) async fn get(&self, key: &Key) -> Result<Option<Bytes>, ClientError> {
1855        self.get_internal(key, None).await
1856    }
1857
1858    pub(crate) async fn get_with_min_sequence_number(
1859        &self,
1860        key: &Key,
1861        min_sequence_number: u64,
1862    ) -> Result<Option<Bytes>, ClientError> {
1863        self.get_internal(key, Some(min_sequence_number)).await
1864    }
1865
1866    async fn get_internal(
1867        &self,
1868        key: &Key,
1869        min_sequence_number: Option<u64>,
1870    ) -> Result<Option<Bytes>, ClientError> {
1871        let (response, _detail) = self
1872            .send_get(key, self.normalize_min_sequence_number(min_sequence_number))
1873            .await?;
1874        Ok(response.value)
1875    }
1876
1877    /// Physical `get_many` over already-encoded wire keys. The
1878    /// [`PrefixedStoreClient`] wrapper encodes + validates and attaches the
1879    /// prefix to the returned stream for decoding.
1880    pub(crate) async fn get_many_internal(
1881        &self,
1882        proto_keys: Vec<Vec<u8>>,
1883        batch_size: u32,
1884        min_sequence_number: Option<u64>,
1885        observed_sequence: Option<Arc<AtomicU64>>,
1886    ) -> Result<GetManyStream, ClientError> {
1887        let config = self.streaming_client_config(self.query_uri.clone());
1888        let client = QueryServiceClient::new(self.connect_http.clone(), config);
1889        let effective_min = self.normalize_min_sequence_number(min_sequence_number);
1890        let max_attempts = self.retry_config.max_attempts.max(1);
1891        let mut attempt = 1usize;
1892        loop {
1893            let result = self
1894                .with_streaming_timeout(async {
1895                    let response = client
1896                        .get_many(ProtoGetManyRequest {
1897                            keys: proto_keys.clone(),
1898                            min_sequence_number: effective_min,
1899                            batch_size,
1900                            ..Default::default()
1901                        })
1902                        .await?;
1903                    let mut stream = GetManyStream::from_connect_stream(
1904                        response,
1905                        observed_sequence.clone(),
1906                        None,
1907                        self.credential,
1908                    );
1909                    stream.prefetch_first_frame().await?;
1910                    Ok(stream)
1911                })
1912                .await;
1913            match result {
1914                Ok(stream) => return Ok(stream),
1915                Err(err) => {
1916                    if attempt < max_attempts && is_retryable_error(&err) {
1917                        let delay = retry_delay_for_error(&err, attempt, self.retry_config);
1918                        tokio::time::sleep(delay).await;
1919                        attempt += 1;
1920                        continue;
1921                    }
1922                    return Err(client_error_from_connect(err, self.credential));
1923                }
1924            }
1925        }
1926    }
1927
1928    pub(crate) async fn prune(
1929        &self,
1930        policies: &[crate::prune_policy::PrunePolicy],
1931    ) -> Result<(), ClientError> {
1932        let config = self.unary_client_config(self.prune_uri.clone());
1933        let client = PruneServiceClient::new(self.connect_http.clone(), config);
1934        client
1935            .prune(ProtoPruneRequest {
1936                policies: exoware_proto::prune_policies_to_proto(policies),
1937                ..Default::default()
1938            })
1939            .await
1940            .map_err(|err| client_error_from_connect(err, self.credential))?;
1941        Ok(())
1942    }
1943
1944    /// Open a physical subscription over an already-encoded `filter`. The
1945    /// returned [`StreamSubscription`] carries no prefix; the logical
1946    /// [`PrefixedStoreClient`] wrapper attaches one for decoding.
1947    async fn subscribe_physical(
1948        &self,
1949        filter: crate::stream_filter::StreamFilter,
1950        since_sequence_number: Option<u64>,
1951    ) -> Result<StreamSubscription, ClientError> {
1952        crate::stream_filter::validate_filter(&filter)
1953            .map_err(|e| ClientError::WireFormat(e.to_string()))?;
1954        let selectors = filter
1955            .selectors
1956            .into_iter()
1957            .map(|mk| exoware_proto::common::kv::v1::Selector {
1958                prefix: mk.prefix,
1959                payload_regex: mk.payload_regex.0,
1960                ..Default::default()
1961            })
1962            .collect();
1963        let value_filters = filter
1964            .value_filters
1965            .into_iter()
1966            .map(|vf| {
1967                use crate::stream_filter::Filter;
1968                use exoware_proto::common::kv::v1::filter::Kind as ProtoKind;
1969                let kind = match vf {
1970                    Filter::Exact(bytes) => ProtoKind::Exact(bytes),
1971                    Filter::Prefix(bytes) => ProtoKind::Prefix(bytes),
1972                    Filter::Regex(pattern) => ProtoKind::Regex(pattern),
1973                };
1974                exoware_proto::common::kv::v1::Filter {
1975                    kind: Some(kind),
1976                    ..Default::default()
1977                }
1978            })
1979            .collect();
1980        let request = exoware_proto::log::stream::v1::SubscribeRequest {
1981            selectors,
1982            value_filters,
1983            since_sequence_number,
1984            ..Default::default()
1985        };
1986        let config = self.streaming_client_config(self.stream_uri.clone());
1987        let client =
1988            exoware_proto::log::stream::v1::ServiceClient::new(self.connect_http.clone(), config);
1989        let stream = self
1990            .with_streaming_timeout(client.subscribe(request))
1991            .await
1992            .map_err(|err| client_error_from_connect(err, self.credential))?;
1993        Ok(StreamSubscription {
1994            stream,
1995            key_prefix: None,
1996            credential: self.credential,
1997        })
1998    }
1999
2000    /// Fetch a batch by sequence number, returning the raw owned response
2001    /// (physical keys, no decode). `None` collapses the server's
2002    /// `BATCH_EVICTED` / `BATCH_NOT_FOUND` errors. The [`PrefixedStoreClient`]
2003    /// wrapper consumes the entries in one pass to filter + decode.
2004    async fn stream_get_physical(
2005        &self,
2006        sequence_number: u64,
2007    ) -> Result<Option<exoware_proto::log::stream::v1::GetResponse>, ClientError> {
2008        let config = self.unary_client_config(self.stream_uri.clone());
2009        let client =
2010            exoware_proto::log::stream::v1::ServiceClient::new(self.connect_http.clone(), config);
2011        match client
2012            .get(exoware_proto::log::stream::v1::GetRequest {
2013                sequence_number,
2014                ..Default::default()
2015            })
2016            .await
2017        {
2018            Ok(resp) => Ok(Some(resp.into_owned())),
2019            Err(err) => {
2020                if is_batch_missing_error(&err) {
2021                    Ok(None)
2022                } else {
2023                    Err(client_error_from_connect(err, self.credential))
2024                }
2025            }
2026        }
2027    }
2028
2029    /// Install (`Some`) or clear (`None`) the sequence-log retention rule via
2030    /// `log.retention.v1.SetRetention`. Returns the lowest retained sequence
2031    /// after one synchronous enforcement of the new rule (`None` when the log is
2032    /// empty / no floor exists yet).
2033    async fn set_retention(
2034        &self,
2035        policy: Option<crate::retention::RetentionPolicy>,
2036    ) -> Result<Option<u64>, ClientError> {
2037        let request = exoware_proto::log::retention::v1::SetRetentionRequest {
2038            policy: policy
2039                .as_ref()
2040                .map(exoware_proto::retention_policy_to_proto)
2041                .into(),
2042            ..Default::default()
2043        };
2044        let config = self.unary_client_config(self.retention_uri.clone());
2045        let client = exoware_proto::log::retention::v1::ServiceClient::new(
2046            self.connect_http.clone(),
2047            config,
2048        );
2049        let response = client
2050            .set_retention(request)
2051            .await
2052            .map_err(|err| client_error_from_connect(err, self.credential))?;
2053        Ok(response.into_owned().oldest_retained_sequence)
2054    }
2055
2056    pub async fn health(&self) -> Result<bool, ClientError> {
2057        let resp = self
2058            .health_http
2059            .get(format!("{}/health", self.health_url))
2060            .send()
2061            .await?;
2062        Ok(resp.status().is_success())
2063    }
2064
2065    pub async fn ready(&self) -> Result<bool, ClientError> {
2066        let resp = self
2067            .health_http
2068            .get(format!("{}/ready", self.health_url))
2069            .send()
2070            .await?;
2071        Ok(resp.status().is_success())
2072    }
2073
2074    fn normalize_min_sequence_number(&self, requested_sequence: Option<u64>) -> Option<u64> {
2075        requested_sequence.filter(|sequence| *sequence > 0)
2076    }
2077
2078    async fn send_get(
2079        &self,
2080        key: &Key,
2081        min_sequence_number: Option<u64>,
2082    ) -> Result<
2083        (
2084            exoware_proto::query::GetResponse,
2085            Option<proto_query::Detail>,
2086        ),
2087        ClientError,
2088    > {
2089        if !is_valid_key_size(key.len()) {
2090            return Err(ClientError::WireFormat(format!(
2091                "key length {} is outside valid store key range ({}..={})",
2092                key.len(),
2093                keys::MIN_KEY_LEN,
2094                MAX_KEY_LEN
2095            )));
2096        }
2097
2098        let config = self.unary_client_config(self.query_uri.clone());
2099        let client = QueryServiceClient::new(self.connect_http.clone(), config);
2100        let response = self
2101            .send_with_retry(|| async {
2102                client
2103                    .get(ProtoGetRequest {
2104                        key: key.clone().into(),
2105                        min_sequence_number,
2106                        ..Default::default()
2107                    })
2108                    .await
2109            })
2110            .await?;
2111        let owned = response.into_owned();
2112        let detail = owned.detail.as_option().cloned();
2113        Ok((owned, detail))
2114    }
2115
2116    #[cfg(test)]
2117    pub async fn send_get_for_tests(
2118        &self,
2119        key: &Key,
2120        min_sequence_number: Option<u64>,
2121    ) -> Result<
2122        (
2123            exoware_proto::query::GetResponse,
2124            Option<proto_query::Detail>,
2125        ),
2126        ClientError,
2127    > {
2128        self.send_get(key, min_sequence_number).await
2129    }
2130
2131    async fn range_stream_internal(
2132        &self,
2133        start: &Key,
2134        end: &Key,
2135        limit: usize,
2136        batch_size: usize,
2137        mode: RangeMode,
2138        options: QueryStreamReadOptions,
2139    ) -> Result<RangeStream, ClientError> {
2140        if !is_valid_key_size(start.len()) || !is_valid_key_size(end.len()) {
2141            return Err(ClientError::WireFormat(
2142                "range start/end key length is outside valid store key range".to_string(),
2143            ));
2144        }
2145        if batch_size == 0 {
2146            return Err(ClientError::WireFormat(
2147                "batch_size must be positive".to_string(),
2148            ));
2149        }
2150
2151        let config = self.streaming_client_config(self.query_uri.clone());
2152        let client = QueryServiceClient::new(self.connect_http.clone(), config);
2153        let min_sequence_number = self.normalize_min_sequence_number(options.min_sequence_number);
2154        let max_attempts = self.retry_config.max_attempts.max(1);
2155        let mut attempt = 1usize;
2156        loop {
2157            // Server-streaming RPCs cannot transparently recover mid-stream failures,
2158            // but retrying a transient error while opening the stream or before the
2159            // first frame arrives is still safe. Treat both phases as a single
2160            // attempt budget so range opens do not multiply retries quadratically.
2161            let result = self
2162                .with_streaming_timeout(async {
2163                    let response = client
2164                        .range(ProtoRangeRequest {
2165                            start: start.clone().into(),
2166                            end: end.clone().into(),
2167                            limit: (limit != usize::MAX)
2168                                .then(|| u32::try_from(limit).unwrap_or(u32::MAX)),
2169                            batch_size: u32::try_from(batch_size).unwrap_or(u32::MAX),
2170                            mode: mode.to_proto().into(),
2171                            min_sequence_number,
2172                            ..Default::default()
2173                        })
2174                        .await?;
2175                    let mut stream = RangeStream::from_connect_stream(
2176                        response,
2177                        options.observed_sequence.clone(),
2178                        None,
2179                        self.credential,
2180                    );
2181                    stream.prefetch_first_frame().await?;
2182                    Ok(stream)
2183                })
2184                .await;
2185            match result {
2186                Ok(stream) => return Ok(stream),
2187                Err(err) => {
2188                    if attempt < max_attempts && is_retryable_error(&err) {
2189                        let delay = retry_delay_for_error(&err, attempt, self.retry_config);
2190                        tracing::debug!(
2191                            attempt,
2192                            max_attempts,
2193                            code = err.code.as_str(),
2194                            delay_ms = delay.as_millis() as u64,
2195                            "store client retrying transient stream-open error",
2196                        );
2197                        tokio::time::sleep(delay).await;
2198                        attempt += 1;
2199                        continue;
2200                    }
2201                    return Err(client_error_from_connect(err, self.credential));
2202                }
2203            }
2204        }
2205    }
2206
2207    async fn range_reduce_stream_internal(
2208        &self,
2209        start: &Key,
2210        end: &Key,
2211        request: &DomainRangeReduceRequest,
2212        options: QueryStreamReadOptions,
2213    ) -> Result<ReduceStream, ClientError> {
2214        let config = self.streaming_client_config(self.query_uri.clone());
2215        let client = QueryServiceClient::new(self.connect_http.clone(), config);
2216        let proto_params = proto_to_proto_reduce_params(request.clone());
2217        let min_sequence_number = self.normalize_min_sequence_number(options.min_sequence_number);
2218        let (stream, first) = self
2219            .send_with_retry(|| {
2220                self.with_streaming_timeout(async {
2221                    let mut stream = client
2222                        .reduce(ProtoWireReduceRequest {
2223                            start: start.clone().into(),
2224                            end: end.clone().into(),
2225                            params: Some(proto_params.clone()).into(),
2226                            min_sequence_number,
2227                            ..Default::default()
2228                        })
2229                        .await?;
2230                    let first = stream
2231                        .message::<proto_query::ReduceResponse>()
2232                        .await?
2233                        .ok_or_else(|| {
2234                            ConnectError::new(
2235                                ErrorCode::Internal,
2236                                "reduction stream returned no frames",
2237                            )
2238                        })?;
2239                    Ok((stream, first))
2240                })
2241            })
2242            .await?;
2243        let observed_sequence = options.observed_sequence;
2244        if let (Some(sequence), Some(detail)) =
2245            (&observed_sequence, first.view().detail.as_option())
2246        {
2247            sequence.fetch_max(detail.sequence_number, Ordering::SeqCst);
2248        }
2249        let credential = self.credential;
2250        Ok(
2251            futures::stream::try_unfold((stream, Some(first)), move |(mut stream, first)| {
2252                let observed_sequence = observed_sequence.clone();
2253                async move {
2254                    let frame = if let Some(frame) = first {
2255                        frame
2256                    } else {
2257                        match stream
2258                            .message()
2259                            .await
2260                            .map_err(|err| client_error_from_connect(err, credential))?
2261                        {
2262                            Some(frame) => {
2263                                if let (Some(sequence), Some(detail)) =
2264                                    (&observed_sequence, frame.view().detail.as_option())
2265                                {
2266                                    sequence.fetch_max(detail.sequence_number, Ordering::SeqCst);
2267                                }
2268                                frame
2269                            }
2270                            None => return Ok(None),
2271                        }
2272                    };
2273                    Ok(Some((frame, (stream, None))))
2274                }
2275            })
2276            .boxed(),
2277        )
2278    }
2279
2280    async fn send_with_retry<F, Fut, T>(&self, mut make_request: F) -> Result<T, ClientError>
2281    where
2282        F: FnMut() -> Fut,
2283        Fut: std::future::Future<Output = Result<T, ConnectError>>,
2284    {
2285        let max_attempts = self.retry_config.max_attempts.max(1);
2286        let mut attempt = 1usize;
2287        loop {
2288            match make_request().await {
2289                Ok(response) => return Ok(response),
2290                Err(err) => {
2291                    if attempt < max_attempts && is_retryable_error(&err) {
2292                        let delay = retry_delay_for_error(&err, attempt, self.retry_config);
2293                        tracing::debug!(
2294                            attempt,
2295                            max_attempts,
2296                            code = err.code.as_str(),
2297                            delay_ms = delay.as_millis() as u64,
2298                            "store client retrying transient RPC error",
2299                        );
2300                        tokio::time::sleep(delay).await;
2301                        attempt += 1;
2302                        continue;
2303                    }
2304                    return Err(client_error_from_connect(err, self.credential));
2305                }
2306            }
2307        }
2308    }
2309}
2310
2311fn shift_reduce_request_key_offsets(
2312    prefix_len: usize,
2313    request: &mut DomainRangeReduceRequest,
2314) -> Result<(), StoreKeyPrefixError> {
2315    // A byte-aligned store prefix shifts every key field past its bytes.
2316    // `KeyField` offsets are byte-granular, so they shift by the whole prefix
2317    // length in bytes; `ZOrderKey` offsets remain bit-granular, so they shift
2318    // by `prefix_len * 8` bits. `prefix_len` comes from a validated
2319    // `StoreKeyPrefix`, so both shifts fit u16 (`MAX_KEY_LEN * 8` = 2032).
2320    debug_assert!(prefix_len <= MAX_KEY_LEN);
2321    let shift_bytes = prefix_len as u16;
2322    let shift_bits = shift_bytes * 8;
2323    for reducer in &mut request.reducers {
2324        if let Some(expr) = &mut reducer.expr {
2325            shift_expr_key_offsets(shift_bytes, shift_bits, expr)?;
2326        }
2327    }
2328    for expr in &mut request.group_by {
2329        shift_expr_key_offsets(shift_bytes, shift_bits, expr)?;
2330    }
2331    for filter in request.filter.iter_mut().chain(
2332        request
2333            .reducers
2334            .iter_mut()
2335            .filter_map(|reducer| reducer.filter.as_mut()),
2336    ) {
2337        for check in &mut filter.checks {
2338            shift_field_ref_key_offset(shift_bytes, shift_bits, &mut check.field)?;
2339        }
2340    }
2341    Ok(())
2342}
2343
2344fn shift_expr_key_offsets(
2345    shift_bytes: u16,
2346    shift_bits: u16,
2347    expr: &mut KvExpr,
2348) -> Result<(), StoreKeyPrefixError> {
2349    match expr {
2350        KvExpr::Field(field) => shift_field_ref_key_offset(shift_bytes, shift_bits, field),
2351        KvExpr::Literal(_) => Ok(()),
2352        KvExpr::Add(left, right)
2353        | KvExpr::Sub(left, right)
2354        | KvExpr::Mul(left, right)
2355        | KvExpr::Div(left, right) => {
2356            shift_expr_key_offsets(shift_bytes, shift_bits, left)?;
2357            shift_expr_key_offsets(shift_bytes, shift_bits, right)
2358        }
2359        KvExpr::Lower(inner) | KvExpr::DateTruncDay(inner) | KvExpr::CastFloat64(inner) => {
2360            shift_expr_key_offsets(shift_bytes, shift_bits, inner)
2361        }
2362    }
2363}
2364
2365fn shift_field_ref_key_offset(
2366    shift_bytes: u16,
2367    shift_bits: u16,
2368    field: &mut KvFieldRef,
2369) -> Result<(), StoreKeyPrefixError> {
2370    match field {
2371        KvFieldRef::Key { byte_offset, .. } => {
2372            *byte_offset = byte_offset.checked_add(shift_bytes).ok_or(
2373                StoreKeyPrefixError::KeyOffsetOverflow {
2374                    offset: *byte_offset,
2375                    shift: shift_bytes,
2376                },
2377            )?;
2378            Ok(())
2379        }
2380        KvFieldRef::ZOrderKey { bit_offset, .. } => {
2381            *bit_offset = bit_offset.checked_add(shift_bits).ok_or(
2382                StoreKeyPrefixError::KeyOffsetOverflow {
2383                    offset: *bit_offset,
2384                    shift: shift_bits,
2385                },
2386            )?;
2387            Ok(())
2388        }
2389        KvFieldRef::Value { .. } => Ok(()),
2390    }
2391}
2392
2393// --- Service-grouped accessors ---------------------------------------------
2394
2395#[derive(Clone, Copy, Debug)]
2396pub struct Ingest<'a> {
2397    c: &'a PrefixedStoreClient,
2398}
2399
2400#[derive(Clone, Copy, Debug)]
2401pub struct Query<'a> {
2402    c: &'a PrefixedStoreClient,
2403}
2404
2405#[derive(Clone, Copy, Debug)]
2406pub struct Prune<'a> {
2407    c: &'a PrefixedStoreClient,
2408}
2409
2410#[derive(Clone, Copy, Debug)]
2411pub struct Retention<'a> {
2412    c: &'a PrefixedStoreClient,
2413}
2414
2415#[derive(Clone, Copy, Debug)]
2416pub struct Stream<'a> {
2417    c: &'a PrefixedStoreClient,
2418}
2419
2420impl<'a> Ingest<'a> {
2421    pub async fn put(&self, kvs: &[(&Key, &[u8])]) -> Result<u64, ClientError> {
2422        self.c.put(kvs).await
2423    }
2424
2425    /// Submit a [`StoreWriteBatch`] that has already been encoded into the
2426    /// physical Store keyspace.
2427    pub async fn put_prepared(&self, batch: &StoreWriteBatch) -> Result<u64, ClientError> {
2428        batch.commit(self.c.client()).await
2429    }
2430}
2431
2432impl<'a> Query<'a> {
2433    pub async fn get(&self, key: &Key) -> Result<Option<Bytes>, ClientError> {
2434        self.c.get(key).await
2435    }
2436
2437    pub async fn get_with_min_sequence_number(
2438        &self,
2439        key: &Key,
2440        min_sequence_number: u64,
2441    ) -> Result<Option<Bytes>, ClientError> {
2442        self.c
2443            .get_with_min_sequence_number(key, min_sequence_number)
2444            .await
2445    }
2446
2447    pub async fn get_many(
2448        &self,
2449        keys: &[&Key],
2450        batch_size: u32,
2451    ) -> Result<GetManyStream, ClientError> {
2452        self.c.get_many(keys, batch_size).await
2453    }
2454
2455    pub async fn get_many_with_min_sequence_number(
2456        &self,
2457        keys: &[&Key],
2458        batch_size: u32,
2459        min_sequence_number: u64,
2460    ) -> Result<GetManyStream, ClientError> {
2461        self.c
2462            .get_many_with_min_sequence_number(keys, batch_size, min_sequence_number)
2463            .await
2464    }
2465
2466    /// Collect a `Range` into a `Vec`. Use `range_stream` for large scans.
2467    pub async fn range(
2468        &self,
2469        start: &Key,
2470        end: &Key,
2471        limit: usize,
2472    ) -> Result<Vec<(Key, Bytes)>, ClientError> {
2473        self.c.range(start, end, limit).await
2474    }
2475
2476    pub async fn range_with_mode(
2477        &self,
2478        start: &Key,
2479        end: &Key,
2480        limit: usize,
2481        mode: RangeMode,
2482    ) -> Result<Vec<(Key, Bytes)>, ClientError> {
2483        self.c.range_with_mode(start, end, limit, mode).await
2484    }
2485
2486    pub async fn range_with_min_sequence_number(
2487        &self,
2488        start: &Key,
2489        end: &Key,
2490        limit: usize,
2491        min_sequence_number: u64,
2492    ) -> Result<Vec<(Key, Bytes)>, ClientError> {
2493        self.c
2494            .range_with_min_sequence_number(start, end, limit, min_sequence_number)
2495            .await
2496    }
2497
2498    pub async fn range_with_mode_and_min_sequence_number(
2499        &self,
2500        start: &Key,
2501        end: &Key,
2502        limit: usize,
2503        mode: RangeMode,
2504        min_sequence_number: u64,
2505    ) -> Result<Vec<(Key, Bytes)>, ClientError> {
2506        self.c
2507            .range_with_mode_and_min_sequence_number(start, end, limit, mode, min_sequence_number)
2508            .await
2509    }
2510
2511    pub async fn range_stream(
2512        &self,
2513        start: &Key,
2514        end: &Key,
2515        limit: usize,
2516        batch_size: usize,
2517    ) -> Result<RangeStream, ClientError> {
2518        self.c.range_stream(start, end, limit, batch_size).await
2519    }
2520
2521    pub async fn range_stream_with_mode(
2522        &self,
2523        start: &Key,
2524        end: &Key,
2525        limit: usize,
2526        batch_size: usize,
2527        mode: RangeMode,
2528    ) -> Result<RangeStream, ClientError> {
2529        self.c
2530            .range_stream_with_mode(start, end, limit, batch_size, mode)
2531            .await
2532    }
2533
2534    pub async fn range_stream_with_min_sequence_number(
2535        &self,
2536        start: &Key,
2537        end: &Key,
2538        limit: usize,
2539        batch_size: usize,
2540        min_sequence_number: u64,
2541    ) -> Result<RangeStream, ClientError> {
2542        self.c
2543            .range_stream_with_min_sequence_number(
2544                start,
2545                end,
2546                limit,
2547                batch_size,
2548                min_sequence_number,
2549            )
2550            .await
2551    }
2552
2553    pub async fn range_stream_with_mode_and_min_sequence_number(
2554        &self,
2555        start: &Key,
2556        end: &Key,
2557        limit: usize,
2558        batch_size: usize,
2559        mode: RangeMode,
2560        min_sequence_number: u64,
2561    ) -> Result<RangeStream, ClientError> {
2562        self.c
2563            .range_stream_with_mode_and_min_sequence_number(
2564                start,
2565                end,
2566                limit,
2567                batch_size,
2568                mode,
2569                min_sequence_number,
2570            )
2571            .await
2572    }
2573
2574    pub async fn range_reduce(
2575        &self,
2576        start: &Key,
2577        end: &Key,
2578        request: &DomainRangeReduceRequest,
2579    ) -> Result<Vec<Option<KvReducedValue>>, ClientError> {
2580        scalar_reduce_results(
2581            self.range_reduce_stream(start, end, request).await?,
2582            request,
2583        )
2584        .await
2585    }
2586
2587    pub async fn range_reduce_with_min_sequence_number(
2588        &self,
2589        start: &Key,
2590        end: &Key,
2591        request: &DomainRangeReduceRequest,
2592        min_sequence_number: u64,
2593    ) -> Result<Vec<Option<KvReducedValue>>, ClientError> {
2594        scalar_reduce_results(
2595            self.range_reduce_stream_with_min_sequence_number(
2596                start,
2597                end,
2598                request,
2599                min_sequence_number,
2600            )
2601            .await?,
2602            request,
2603        )
2604        .await
2605    }
2606
2607    pub async fn range_reduce_stream(
2608        &self,
2609        start: &Key,
2610        end: &Key,
2611        request: &DomainRangeReduceRequest,
2612    ) -> Result<ReduceStream, ClientError> {
2613        self.c
2614            .range_reduce_stream_internal(start, end, request, QueryStreamReadOptions::default())
2615            .await
2616    }
2617
2618    pub async fn range_reduce_stream_with_min_sequence_number(
2619        &self,
2620        start: &Key,
2621        end: &Key,
2622        request: &DomainRangeReduceRequest,
2623        min_sequence_number: u64,
2624    ) -> Result<ReduceStream, ClientError> {
2625        self.c
2626            .range_reduce_stream_internal(
2627                start,
2628                end,
2629                request,
2630                QueryStreamReadOptions {
2631                    min_sequence_number: Some(min_sequence_number),
2632                    observed_sequence: None,
2633                },
2634            )
2635            .await
2636    }
2637}
2638
2639impl<'a> Prune<'a> {
2640    /// `store.prune.v1.Service.Prune` — apply each policy in turn.
2641    pub async fn prune(
2642        &self,
2643        policies: &[crate::prune_policy::PrunePolicy],
2644    ) -> Result<(), ClientError> {
2645        self.c.apply_prune_policies(policies).await
2646    }
2647}
2648
2649impl<'a> Stream<'a> {
2650    /// `log.stream.v1.Service.Subscribe` — see `StreamSubscription::next`
2651    /// for consuming delivered frames. `since_sequence_number = None` starts
2652    /// live from the next Put; `Some(N)` replays retained batches before
2653    /// transitioning to live. An evicted `since` returns a
2654    /// `ConnectError::out_of_range` carrying `ErrorInfo.reason = "BATCH_EVICTED"`.
2655    pub async fn subscribe(
2656        &self,
2657        filter: crate::stream_filter::StreamFilter,
2658        since_sequence_number: Option<u64>,
2659    ) -> Result<StreamSubscription, ClientError> {
2660        self.c.subscribe(filter, since_sequence_number).await
2661    }
2662
2663    /// `log.stream.v1.Service.Get` — `Ok(None)` collapses the server's
2664    /// `BATCH_EVICTED` / `BATCH_NOT_FOUND` error details.
2665    pub async fn get(
2666        &self,
2667        sequence_number: u64,
2668    ) -> Result<Option<Vec<(Key, Bytes)>>, ClientError> {
2669        self.c.stream_get(sequence_number).await
2670    }
2671}
2672
2673impl<'a> Retention<'a> {
2674    /// `log.retention.v1.Service.SetRetention` — install (`Some`) or clear
2675    /// (`None`) the sequence-log retention rule. The rule is persistent and
2676    /// continuously enforced as the log grows; eviction surfaces to
2677    /// `log.stream.v1` subscribers as `BATCH_EVICTED`. Returns the lowest
2678    /// retained sequence after one synchronous enforcement (`None` when the log
2679    /// is empty / no floor exists yet).
2680    pub async fn set_retention(
2681        &self,
2682        policy: Option<crate::retention::RetentionPolicy>,
2683    ) -> Result<Option<u64>, ClientError> {
2684        self.c.set_retention(policy).await
2685    }
2686}
2687
2688impl SerializableReadSession {
2689    /// Minimum Store sequence requested by subsequent reads.
2690    ///
2691    /// Before any response, this is the explicitly configured floor, if present.
2692    /// It advances as reads observe newer Store sequences.
2693    pub fn fixed_sequence(&self) -> Option<u64> {
2694        self.state.fixed_sequence()
2695    }
2696
2697    /// Highest positive Store sequence reported by a read in this session.
2698    ///
2699    /// Returns `None` until a positive sequence is observed, including after reads that report zero.
2700    pub fn evaluated_sequence(&self) -> Option<u64> {
2701        self.state.evaluated_sequence()
2702    }
2703
2704    /// Share this session's freshness state with another client for the same Store.
2705    pub fn with_client(&self, client: PrefixedStoreClient) -> Self {
2706        Self {
2707            client,
2708            state: self.state.clone(),
2709        }
2710    }
2711
2712    pub async fn get(&self, key: &Key) -> Result<Option<Bytes>, ClientError> {
2713        self.run_read(|sequence, observed_sequence| async move {
2714            let (response, detail) = self.client.send_get(key, sequence).await?;
2715            if let Some(detail) = detail {
2716                observed_sequence.fetch_max(detail.sequence_number, Ordering::SeqCst);
2717            }
2718            Ok(response.value)
2719        })
2720        .await
2721    }
2722
2723    pub async fn get_many(
2724        &self,
2725        keys: &[&Key],
2726        batch_size: u32,
2727    ) -> Result<GetManyStream, ClientError> {
2728        self.run_read(|sequence, observed_sequence| {
2729            self.client
2730                .get_many_internal(keys, batch_size, sequence, Some(observed_sequence))
2731        })
2732        .await
2733    }
2734
2735    pub async fn range(
2736        &self,
2737        start: &Key,
2738        end: &Key,
2739        limit: usize,
2740    ) -> Result<Vec<(Key, Bytes)>, ClientError> {
2741        self.range_with_mode(start, end, limit, RangeMode::Forward)
2742            .await
2743    }
2744
2745    pub async fn range_with_mode(
2746        &self,
2747        start: &Key,
2748        end: &Key,
2749        limit: usize,
2750        mode: RangeMode,
2751    ) -> Result<Vec<(Key, Bytes)>, ClientError> {
2752        self.run_read(|sequence, observed_sequence| async move {
2753            self.client
2754                .range_stream_internal(
2755                    start,
2756                    end,
2757                    limit,
2758                    limit.max(1),
2759                    mode,
2760                    QueryStreamReadOptions {
2761                        min_sequence_number: sequence,
2762                        observed_sequence: Some(observed_sequence),
2763                    },
2764                )
2765                .await?
2766                .collect()
2767                .await
2768        })
2769        .await
2770    }
2771
2772    pub async fn range_stream(
2773        &self,
2774        start: &Key,
2775        end: &Key,
2776        limit: usize,
2777        batch_size: usize,
2778    ) -> Result<RangeStream, ClientError> {
2779        self.range_stream_with_mode(start, end, limit, batch_size, RangeMode::Forward)
2780            .await
2781    }
2782
2783    pub async fn range_stream_with_mode(
2784        &self,
2785        start: &Key,
2786        end: &Key,
2787        limit: usize,
2788        batch_size: usize,
2789        mode: RangeMode,
2790    ) -> Result<RangeStream, ClientError> {
2791        self.run_read(|sequence, observed_sequence| {
2792            self.client.range_stream_internal(
2793                start,
2794                end,
2795                limit,
2796                batch_size,
2797                mode,
2798                QueryStreamReadOptions {
2799                    min_sequence_number: sequence,
2800                    observed_sequence: Some(observed_sequence),
2801                },
2802            )
2803        })
2804        .await
2805    }
2806
2807    pub async fn range_reduce(
2808        &self,
2809        start: &Key,
2810        end: &Key,
2811        request: &DomainRangeReduceRequest,
2812    ) -> Result<Vec<Option<KvReducedValue>>, ClientError> {
2813        scalar_reduce_results(
2814            self.range_reduce_stream(start, end, request).await?,
2815            request,
2816        )
2817        .await
2818    }
2819
2820    pub async fn range_reduce_stream(
2821        &self,
2822        start: &Key,
2823        end: &Key,
2824        request: &DomainRangeReduceRequest,
2825    ) -> Result<ReduceStream, ClientError> {
2826        self.run_read(|sequence, observed_sequence| {
2827            self.client.range_reduce_stream_internal(
2828                start,
2829                end,
2830                request,
2831                QueryStreamReadOptions {
2832                    min_sequence_number: sequence,
2833                    observed_sequence: Some(observed_sequence),
2834                },
2835            )
2836        })
2837        .await
2838    }
2839
2840    async fn run_read<T, Call, Fut>(&self, call: Call) -> Result<T, ClientError>
2841    where
2842        Call: FnOnce(Option<u64>, Arc<AtomicU64>) -> Fut,
2843        Fut: std::future::Future<Output = Result<T, ClientError>>,
2844    {
2845        if let Some(sequence) = self.fixed_sequence() {
2846            return call(Some(sequence), self.state.sequence.clone()).await;
2847        }
2848
2849        let gate = self.state.init_gate.lock().await;
2850
2851        if let Some(sequence) = self.fixed_sequence() {
2852            drop(gate);
2853            return call(Some(sequence), self.state.sequence.clone()).await;
2854        }
2855
2856        let result = call(None, self.state.sequence.clone()).await;
2857        drop(gate);
2858        result
2859    }
2860}
2861
2862fn is_retryable_error(err: &ConnectError) -> bool {
2863    matches!(
2864        err.code,
2865        ErrorCode::Aborted
2866            | ErrorCode::ResourceExhausted
2867            | ErrorCode::Unavailable
2868            | ErrorCode::Unknown
2869            // Retrying `internal` is a trade-off: proxies and load balancers sometimes surface
2870            // transient faults this way; idempotent reads use a small attempt budget so we do not
2871            // spin forever. Prefer interpreting `google.rpc.RetryInfo` when present (see
2872            // `retry_delay_for_error`).
2873            | ErrorCode::Internal
2874    )
2875}
2876
2877fn retry_delay_for_error(
2878    err: &ConnectError,
2879    attempt: usize,
2880    retry_config: RetryConfig,
2881) -> Duration {
2882    if let Ok(decoded) = proto_decode_connect_error(err) {
2883        if let Some(retry_info) = decoded.retry_info {
2884            if let Some(delay) = retry_info.retry_delay.as_option() {
2885                let secs = u64::try_from(delay.seconds).unwrap_or(0);
2886                let nanos = u32::try_from(delay.nanos.max(0)).unwrap_or(0);
2887                let hinted = Duration::new(secs, nanos);
2888                if !hinted.is_zero() {
2889                    return hinted.min(retry_config.max_backoff);
2890                }
2891            }
2892        }
2893    }
2894    retry_backoff_delay(attempt, retry_config)
2895}
2896
2897fn retry_backoff_delay(attempt: usize, retry_config: RetryConfig) -> Duration {
2898    let exponent = (attempt.saturating_sub(1)).min(20) as u32;
2899    let factor = 1u128 << exponent;
2900    let base_ms = retry_config.initial_backoff.as_millis();
2901    let capped_ms = base_ms
2902        .saturating_mul(factor)
2903        .min(retry_config.max_backoff.as_millis());
2904    Duration::from_millis(capped_ms.min(u64::MAX as u128) as u64)
2905}
2906
2907#[cfg(test)]
2908mod tests {
2909    use super::*;
2910    use crate::kv_codec::{KvFieldKind, KvPredicate, KvPredicateCheck, KvPredicateConstraint};
2911    use buffa::Message as _;
2912    use exoware_proto::query::TraversalMode as ProtoTraversalMode;
2913    use http::header::{ACCEPT_ENCODING, AUTHORIZATION};
2914
2915    #[derive(Clone, Debug, Default)]
2916    struct RecordingTransport {
2917        requests: Arc<std::sync::Mutex<Vec<(http::Uri, http::HeaderMap)>>>,
2918    }
2919
2920    impl RecordingTransport {
2921        fn requests(&self) -> Vec<(http::Uri, http::HeaderMap)> {
2922            self.requests.lock().unwrap().clone()
2923        }
2924    }
2925
2926    impl connectrpc::client::ClientTransport for RecordingTransport {
2927        type ResponseBody =
2928            http_body_util::combinators::UnsyncBoxBody<Bytes, std::convert::Infallible>;
2929        type Error = ConnectError;
2930
2931        fn send(
2932            &self,
2933            request: http::Request<connectrpc::client::ClientBody>,
2934        ) -> connectrpc::client::BoxFuture<
2935            'static,
2936            Result<http::Response<Self::ResponseBody>, Self::Error>,
2937        > {
2938            self.requests
2939                .lock()
2940                .unwrap()
2941                .push((request.uri().clone(), request.headers().clone()));
2942            Box::pin(async { Err(ConnectError::unavailable("recorded test request")) })
2943        }
2944    }
2945
2946    #[derive(Clone, Copy, Debug)]
2947    struct StalledStreamTransport;
2948
2949    impl connectrpc::client::ClientTransport for StalledStreamTransport {
2950        type ResponseBody =
2951            http_body_util::combinators::UnsyncBoxBody<Bytes, std::convert::Infallible>;
2952        type Error = ConnectError;
2953
2954        fn send(
2955            &self,
2956            _request: http::Request<connectrpc::client::ClientBody>,
2957        ) -> connectrpc::client::BoxFuture<
2958            'static,
2959            Result<http::Response<Self::ResponseBody>, Self::Error>,
2960        > {
2961            Box::pin(async {
2962                let frames = futures::stream::pending::<
2963                    Result<http_body::Frame<Bytes>, std::convert::Infallible>,
2964                >();
2965                let body =
2966                    http_body_util::BodyExt::boxed_unsync(http_body_util::StreamBody::new(frames));
2967                Ok(http::Response::new(body))
2968            })
2969        }
2970    }
2971
2972    #[derive(Clone)]
2973    struct ReduceTransport {
2974        bodies: Arc<std::sync::Mutex<std::collections::VecDeque<Bytes>>>,
2975        requests: Arc<std::sync::Mutex<Vec<proto_query::ReduceRequest>>>,
2976    }
2977
2978    impl ReduceTransport {
2979        fn new(bodies: impl IntoIterator<Item = Bytes>) -> Self {
2980            Self {
2981                bodies: Arc::new(std::sync::Mutex::new(bodies.into_iter().collect())),
2982                requests: Arc::default(),
2983            }
2984        }
2985    }
2986
2987    impl connectrpc::client::ClientTransport for ReduceTransport {
2988        type ResponseBody = http_body_util::Full<Bytes>;
2989        type Error = ConnectError;
2990
2991        fn send(
2992            &self,
2993            request: http::Request<connectrpc::client::ClientBody>,
2994        ) -> connectrpc::client::BoxFuture<
2995            'static,
2996            Result<http::Response<Self::ResponseBody>, Self::Error>,
2997        > {
2998            use buffa::Message;
2999            use http_body_util::BodyExt;
3000
3001            let transport = self.clone();
3002            Box::pin(async move {
3003                assert_eq!(request.uri().path(), "/store.query.v1.Service/Reduce");
3004                let body = request.into_body().collect().await.unwrap().to_bytes();
3005                let mut body = bytes::BytesMut::from(body.as_ref());
3006                let envelope = connectrpc::envelope::Envelope::decode(&mut body)
3007                    .unwrap()
3008                    .unwrap();
3009                assert!(!envelope.is_compressed());
3010                assert!(body.is_empty());
3011                let request =
3012                    proto_query::ReduceRequest::decode_from_slice(&envelope.data).unwrap();
3013                transport.requests.lock().unwrap().push(request);
3014                let body = transport
3015                    .bodies
3016                    .lock()
3017                    .unwrap()
3018                    .pop_front()
3019                    .expect("unexpected retry");
3020                Ok(http::Response::builder()
3021                    .header("content-type", "application/connect+proto")
3022                    .body(http_body_util::Full::new(body))
3023                    .unwrap())
3024            })
3025        }
3026    }
3027
3028    type ReduceBodyReceiver = futures::channel::mpsc::UnboundedReceiver<
3029        Result<http_body::Frame<Bytes>, std::convert::Infallible>,
3030    >;
3031
3032    #[derive(Clone)]
3033    struct PendingReduceTransport {
3034        receiver: Arc<std::sync::Mutex<Option<ReduceBodyReceiver>>>,
3035    }
3036
3037    impl connectrpc::client::ClientTransport for PendingReduceTransport {
3038        type ResponseBody =
3039            http_body_util::combinators::UnsyncBoxBody<Bytes, std::convert::Infallible>;
3040        type Error = ConnectError;
3041
3042        fn send(
3043            &self,
3044            _request: http::Request<connectrpc::client::ClientBody>,
3045        ) -> connectrpc::client::BoxFuture<
3046            'static,
3047            Result<http::Response<Self::ResponseBody>, Self::Error>,
3048        > {
3049            use http_body_util::BodyExt;
3050
3051            let receiver = self.receiver.lock().unwrap().take().unwrap();
3052            Box::pin(async move {
3053                Ok(http::Response::builder()
3054                    .header("content-type", "application/connect+proto")
3055                    .body(http_body_util::StreamBody::new(receiver).boxed_unsync())
3056                    .unwrap())
3057            })
3058        }
3059    }
3060
3061    #[tokio::test]
3062    async fn reduce_delivers_a_frame_before_eof_and_drop_cancels_the_body() {
3063        let (sender, receiver) = futures::channel::mpsc::unbounded();
3064        let transport = PendingReduceTransport {
3065            receiver: Arc::new(std::sync::Mutex::new(Some(receiver))),
3066        };
3067        let client = StoreClient::builder()
3068            .url("http://query.internal")
3069            .retry_config(RetryConfig::disabled())
3070            .client_transport(transport)
3071            .build()
3072            .unwrap()
3073            .prefixed(StoreKeyPrefix::new("tenant/").unwrap());
3074        use buffa::Message;
3075        let first =
3076            connectrpc::envelope::Envelope::data(count_frame(None).encode_to_bytes()).encode();
3077        sender
3078            .unbounded_send(Ok(http_body::Frame::data(first)))
3079            .unwrap();
3080        let mut stream = client
3081            .query()
3082            .range_reduce_stream(
3083                &Bytes::from_static(b"a"),
3084                &Bytes::from_static(b"z"),
3085                &count_request(),
3086            )
3087            .await
3088            .unwrap();
3089        assert_eq!(
3090            stream.next().await.unwrap().unwrap().view().results.len(),
3091            1
3092        );
3093        assert!(!sender.is_closed());
3094        drop(stream);
3095        assert!(sender.is_closed());
3096    }
3097
3098    fn reduce_body(frames: &[proto_query::ReduceResponse], final_status: &[u8]) -> Bytes {
3099        use buffa::Message;
3100
3101        let mut body = Vec::new();
3102        for frame in frames {
3103            body.extend_from_slice(
3104                &connectrpc::envelope::Envelope::data(frame.encode_to_bytes()).encode(),
3105            );
3106        }
3107        body.extend_from_slice(
3108            &connectrpc::envelope::Envelope::end_stream(Bytes::copy_from_slice(final_status))
3109                .encode(),
3110        );
3111        body.into()
3112    }
3113
3114    fn count_request() -> DomainRangeReduceRequest {
3115        DomainRangeReduceRequest {
3116            reducers: vec![RangeReducerSpec {
3117                filter: None,
3118                op: RangeReduceOp::CountAll,
3119                expr: None,
3120            }],
3121            group_by: Vec::new(),
3122            filter: None,
3123        }
3124    }
3125
3126    fn count_frame(sequence: Option<u64>) -> proto_query::ReduceResponse {
3127        proto_query::ReduceResponse {
3128            results: vec![proto_query::RangeReduceResult {
3129                value: Some(to_proto_reduced_value(KvReducedValue::UInt64(3))).into(),
3130                ..Default::default()
3131            }],
3132            detail: sequence
3133                .map(|sequence_number| proto_query::Detail {
3134                    sequence_number,
3135                    ..Default::default()
3136                })
3137                .into(),
3138            ..Default::default()
3139        }
3140    }
3141
3142    fn reduce_test_client(transport: ReduceTransport) -> PrefixedStoreClient {
3143        StoreClient::builder()
3144            .url("http://query.internal")
3145            .retry_config(RetryConfig {
3146                max_attempts: 2,
3147                initial_backoff: Duration::ZERO,
3148                max_backoff: Duration::ZERO,
3149            })
3150            .client_transport(transport)
3151            .build()
3152            .unwrap()
3153            .prefixed(StoreKeyPrefix::new("tenant/").unwrap())
3154    }
3155
3156    #[tokio::test]
3157    async fn reduce_retries_before_first_frame_and_observes_response_details() {
3158        let transport = ReduceTransport::new([
3159            reduce_body(
3160                &[],
3161                br#"{"error":{"code":"unavailable","message":"retry open"}}"#,
3162            ),
3163            reduce_body(&[count_frame(None), count_frame(Some(47))], b"{}"),
3164            reduce_body(&[count_frame(Some(50))], b"{}"),
3165        ]);
3166        let session = reduce_test_client(transport.clone()).create_session();
3167        let start = Bytes::from_static(b"a");
3168        let end = Bytes::from_static(b"z");
3169        let request = count_request();
3170        let mut stream = session
3171            .range_reduce_stream(&start, &end, &request)
3172            .await
3173            .unwrap();
3174        assert_eq!(transport.requests.lock().unwrap().len(), 2);
3175        assert_eq!(session.fixed_sequence(), None);
3176        assert!(stream
3177            .next()
3178            .await
3179            .unwrap()
3180            .unwrap()
3181            .view()
3182            .detail
3183            .as_option()
3184            .is_none());
3185        assert_eq!(session.fixed_sequence(), None);
3186        assert_eq!(
3187            stream
3188                .next()
3189                .await
3190                .unwrap()
3191                .unwrap()
3192                .view()
3193                .detail
3194                .sequence_number,
3195            47
3196        );
3197        assert_eq!(session.fixed_sequence(), Some(47));
3198        assert!(stream.next().await.is_none());
3199        assert_eq!(
3200            session.range_reduce(&start, &end, &request).await.unwrap(),
3201            vec![Some(KvReducedValue::UInt64(3))]
3202        );
3203        assert_eq!(session.fixed_sequence(), Some(50));
3204        let requests = transport.requests.lock().unwrap();
3205        assert_eq!(requests.len(), 3);
3206        assert_eq!(requests[0].min_sequence_number, None);
3207        assert_eq!(requests[1].min_sequence_number, None);
3208        assert_eq!(requests[2].min_sequence_number, Some(47));
3209        assert_eq!(requests[2].start.as_slice(), b"tenant/a");
3210        assert_eq!(requests[2].end.as_slice(), b"tenant/z");
3211    }
3212
3213    #[tokio::test]
3214    async fn reduce_never_retries_after_a_result_frame() {
3215        let transport = ReduceTransport::new([reduce_body(
3216            &[count_frame(None)],
3217            br#"{"error":{"code":"unavailable","message":"failed after results"}}"#,
3218        )]);
3219        let client = reduce_test_client(transport.clone());
3220        let mut stream = client
3221            .query()
3222            .range_reduce_stream(
3223                &Bytes::from_static(b"a"),
3224                &Bytes::from_static(b"z"),
3225                &count_request(),
3226            )
3227            .await
3228            .unwrap();
3229        assert_eq!(
3230            stream.next().await.unwrap().unwrap().view().results.len(),
3231            1
3232        );
3233        assert_eq!(
3234            stream.next().await.unwrap().unwrap_err().rpc_code(),
3235            Some(ErrorCode::Unavailable)
3236        );
3237        assert!(stream.next().await.is_none());
3238        assert_eq!(transport.requests.lock().unwrap().len(), 1);
3239    }
3240
3241    #[tokio::test]
3242    async fn scalar_reduce_validates_frame_count_shape_and_final_status() {
3243        let request = count_request();
3244        for frames in [
3245            vec![],
3246            vec![proto_query::ReduceResponse::default()],
3247            vec![count_frame(Some(1)), count_frame(Some(1))],
3248            vec![proto_query::ReduceResponse {
3249                groups: vec![proto_query::RangeReduceGroup::default()],
3250                ..count_frame(Some(1))
3251            }],
3252        ] {
3253            let stream = futures::stream::iter(
3254                frames
3255                    .into_iter()
3256                    .map(|frame| Ok(connectrpc::StreamMessage::from_message(&frame))),
3257            )
3258            .boxed();
3259            assert!(matches!(
3260                scalar_reduce_results(stream, &request).await,
3261                Err(ClientError::WireFormat(_))
3262            ));
3263        }
3264        let stream = futures::stream::iter([
3265            Ok(connectrpc::StreamMessage::from_message(&count_frame(Some(
3266                1,
3267            )))),
3268            Err(ClientError::Rpc(Box::new(ConnectError::unavailable(
3269                "final status",
3270            )))),
3271        ])
3272        .boxed();
3273        assert_eq!(
3274            scalar_reduce_results(stream, &request)
3275                .await
3276                .unwrap_err()
3277                .rpc_code(),
3278            Some(ErrorCode::Unavailable)
3279        );
3280    }
3281
3282    #[derive(Clone, Debug, Default)]
3283    struct SessionSequenceTransport {
3284        requested_floors: Arc<std::sync::Mutex<Vec<Option<u64>>>>,
3285    }
3286
3287    impl connectrpc::client::ClientTransport for SessionSequenceTransport {
3288        type ResponseBody =
3289            http_body_util::combinators::UnsyncBoxBody<Bytes, std::convert::Infallible>;
3290        type Error = ConnectError;
3291
3292        fn send(
3293            &self,
3294            request: http::Request<connectrpc::client::ClientBody>,
3295        ) -> connectrpc::client::BoxFuture<
3296            'static,
3297            Result<http::Response<Self::ResponseBody>, Self::Error>,
3298        > {
3299            let requested_floors = self.requested_floors.clone();
3300            Box::pin(async move {
3301                let (parts, body) = request.into_parts();
3302                let body = http_body_util::BodyExt::collect(body)
3303                    .await
3304                    .unwrap()
3305                    .to_bytes();
3306                let path = parts.uri.path();
3307                let streaming = path.ends_with("/GetMany")
3308                    || path.ends_with("/Range")
3309                    || path.ends_with("/Reduce");
3310                let payload = if streaming { &body[5..] } else { &body[..] };
3311                let requested_floor = match path {
3312                    "/store.query.v1.Service/Get" => {
3313                        proto_query::GetRequest::decode_from_slice(payload)
3314                            .unwrap()
3315                            .min_sequence_number
3316                    }
3317                    "/store.query.v1.Service/GetMany" => {
3318                        proto_query::GetManyRequest::decode_from_slice(payload)
3319                            .unwrap()
3320                            .min_sequence_number
3321                    }
3322                    "/store.query.v1.Service/Range" => {
3323                        proto_query::RangeRequest::decode_from_slice(payload)
3324                            .unwrap()
3325                            .min_sequence_number
3326                    }
3327                    "/store.query.v1.Service/Reduce" => {
3328                        proto_query::ReduceRequest::decode_from_slice(payload)
3329                            .unwrap()
3330                            .min_sequence_number
3331                    }
3332                    path => {
3333                        return Err(ConnectError::unimplemented(format!(
3334                            "unexpected test request path {path}"
3335                        )));
3336                    }
3337                };
3338                let sequence_number = {
3339                    let mut floors = requested_floors.lock().unwrap();
3340                    floors.push(requested_floor);
3341                    40 + u64::try_from(floors.len()).unwrap()
3342                };
3343                let detail = proto_query::Detail {
3344                    sequence_number,
3345                    ..Default::default()
3346                };
3347                let payload = match path {
3348                    "/store.query.v1.Service/Get" => proto_query::GetResponse {
3349                        detail: detail.into(),
3350                        ..Default::default()
3351                    }
3352                    .encode_to_bytes(),
3353                    "/store.query.v1.Service/GetMany" => proto_query::GetManyFrame {
3354                        detail: detail.into(),
3355                        ..Default::default()
3356                    }
3357                    .encode_to_bytes(),
3358                    "/store.query.v1.Service/Range" => proto_query::RangeFrame {
3359                        detail: detail.into(),
3360                        ..Default::default()
3361                    }
3362                    .encode_to_bytes(),
3363                    "/store.query.v1.Service/Reduce" => {
3364                        count_frame(Some(sequence_number)).encode_to_bytes()
3365                    }
3366                    _ => unreachable!(),
3367                };
3368                let (body, content_type) = if streaming {
3369                    let mut envelope = Vec::with_capacity(payload.len() + 12);
3370                    envelope.push(0);
3371                    envelope
3372                        .extend_from_slice(&u32::try_from(payload.len()).unwrap().to_be_bytes());
3373                    envelope.extend_from_slice(&payload);
3374                    envelope.extend_from_slice(&[2, 0, 0, 0, 2, b'{', b'}']);
3375                    (Bytes::from(envelope), "application/connect+proto")
3376                } else {
3377                    (payload, "application/proto")
3378                };
3379                let body = http_body_util::BodyExt::boxed_unsync(http_body_util::Full::new(body));
3380                Ok(http::Response::builder()
3381                    .header(http::header::CONTENT_TYPE, content_type)
3382                    .body(body)
3383                    .unwrap())
3384            })
3385        }
3386    }
3387
3388    async fn assert_streaming_deadline<T>(
3389        future: impl std::future::Future<Output = Result<T, ClientError>>,
3390    ) {
3391        let result = tokio::time::timeout(Duration::from_secs(1), future)
3392            .await
3393            .expect("stream open did not honor the request timeout");
3394        assert_eq!(
3395            result.err().and_then(|err| err.rpc_code()),
3396            Some(ErrorCode::DeadlineExceeded)
3397        );
3398    }
3399
3400    #[test]
3401    fn hex_round_trip() {
3402        let data = vec![0x00, 0x42, 0xFF, 0xAB];
3403        let encoded = hex_encode(&data);
3404        assert_eq!(encoded, "0042ffab");
3405        let decoded = hex_decode(&encoded).unwrap();
3406        assert_eq!(decoded, data);
3407    }
3408
3409    #[test]
3410    fn client_creation() {
3411        let client = StoreClient::new("http://localhost:10000");
3412        assert_eq!(client.health_url, "http://localhost:10000");
3413        assert_eq!(client.ingest_uri.to_string(), "http://localhost:10000/");
3414        assert_eq!(client.query_uri.to_string(), "http://localhost:10000/");
3415        assert_eq!(client.stream_uri.to_string(), "http://localhost:10000/");
3416    }
3417
3418    #[test]
3419    fn an_explicit_key_records_that_one_will_be_sent() {
3420        assert_eq!(built_with_api_key("token").credential, Credential::Sent);
3421    }
3422
3423    #[test]
3424    fn client_creation_enables_tls_for_https() {
3425        let client = StoreClient::new("https://store.example.com");
3426        assert_eq!(client.health_url, "https://store.example.com");
3427        assert_eq!(client.ingest_uri.to_string(), "https://store.example.com/");
3428        assert_eq!(client.query_uri.to_string(), "https://store.example.com/");
3429        assert_eq!(client.stream_uri.to_string(), "https://store.example.com/");
3430    }
3431
3432    #[test]
3433    fn builder_supports_mixed_http_and_https_services() {
3434        let client = StoreClient::builder()
3435            .health_url("https://health.example.com")
3436            .ingest_url("http://ingest.internal")
3437            .query_url("https://query.example.com")
3438            .prune_url("http://prune.internal")
3439            .retention_url("http://retention.internal")
3440            .stream_url("https://stream.example.com")
3441            .build()
3442            .unwrap();
3443
3444        assert_eq!(client.ingest_uri.scheme_str(), Some("http"));
3445        assert_eq!(client.query_uri.scheme_str(), Some("https"));
3446        assert_eq!(client.prune_uri.scheme_str(), Some("http"));
3447        assert_eq!(client.retention_uri.scheme_str(), Some("http"));
3448        assert_eq!(client.stream_uri.scheme_str(), Some("https"));
3449    }
3450
3451    #[test]
3452    fn builder_rejects_non_http_connect_urls() {
3453        for url in ["store.example.com", "ftp://store.example.com", "/relative"] {
3454            assert!(matches!(
3455                StoreClient::builder().url(url).build(),
3456                Err(ClientBuildError::InvalidEndpointUrl { .. })
3457            ));
3458        }
3459    }
3460
3461    #[tokio::test]
3462    async fn custom_transport_keeps_high_level_features_and_split_origins() {
3463        let transport = RecordingTransport::default();
3464        let client = StoreClient::builder()
3465            .health_url("http://health.internal")
3466            .ingest_url("http://ingest.internal/base")
3467            .query_url("http://query.internal/base")
3468            .prune_url("http://prune.internal/base")
3469            .retention_url("http://retention.internal/base")
3470            .stream_url("http://stream.internal/base")
3471            .api_key("token-abc")
3472            .retry_config(RetryConfig::disabled())
3473            .client_transport(transport.clone())
3474            .build()
3475            .unwrap();
3476        let prefixed = client.prefixed(StoreKeyPrefix::new("tenant/").unwrap());
3477
3478        let _ = prefixed.put(&[]).await;
3479        let _ = prefixed
3480            .create_session()
3481            .get(&Bytes::from_static(b"key"))
3482            .await;
3483        let _ = prefixed.client().prune(&[]).await;
3484        let _ = prefixed.client().set_retention(None).await;
3485        let _ = prefixed.client().stream_get_physical(1).await;
3486
3487        let requests = transport.requests();
3488        let origins = requests
3489            .iter()
3490            .map(|(uri, _)| uri.authority().unwrap().as_str())
3491            .collect::<Vec<_>>();
3492        assert_eq!(
3493            origins,
3494            [
3495                "ingest.internal",
3496                "query.internal",
3497                "prune.internal",
3498                "retention.internal",
3499                "stream.internal",
3500            ]
3501        );
3502        for (_, headers) in requests {
3503            assert_eq!(headers.get(AUTHORIZATION).unwrap(), "Bearer token-abc");
3504            assert_eq!(headers.get(ACCEPT_ENCODING).unwrap(), "zstd, gzip");
3505        }
3506    }
3507
3508    #[tokio::test]
3509    async fn generated_reduce_stream_prefers_zstd() {
3510        let transport = RecordingTransport::default();
3511        let client = StoreClient::builder()
3512            .url("http://query.internal")
3513            .retry_config(RetryConfig::disabled())
3514            .client_transport(transport.clone())
3515            .build()
3516            .unwrap()
3517            .prefixed(StoreKeyPrefix::identity());
3518
3519        let _ = client
3520            .query()
3521            .range_reduce_stream(
3522                &Bytes::from_static(b"a"),
3523                &Bytes::from_static(b"z"),
3524                &count_request(),
3525            )
3526            .await;
3527
3528        let requests = transport.requests();
3529        assert_eq!(requests.len(), 1);
3530        let (uri, headers) = &requests[0];
3531        assert_eq!(uri.path(), "/store.query.v1.Service/Reduce");
3532        assert_eq!(
3533            headers.get("content-type").unwrap(),
3534            "application/connect+proto"
3535        );
3536        assert_eq!(headers.get(ACCEPT_ENCODING).unwrap(), "zstd, gzip");
3537        let stream_encoding = connectrpc::Protocol::Connect.accept_encoding_header();
3538        assert_eq!(headers.get(stream_encoding).unwrap(), "zstd, gzip");
3539        assert_eq!(headers.get_all(stream_encoding).iter().count(), 1);
3540    }
3541
3542    #[tokio::test]
3543    async fn streaming_timeout_includes_first_frame_prefetch() {
3544        let mut client = StoreClient::builder()
3545            .url("http://query.internal")
3546            .retry_config(RetryConfig::disabled())
3547            .client_transport(StalledStreamTransport)
3548            .build()
3549            .unwrap();
3550        client.rpc_timeout = Some(Duration::from_millis(25));
3551        let client = client.prefixed(StoreKeyPrefix::new("timeout/").unwrap());
3552        let key = Key::from(b"key".to_vec());
3553        let start = Key::from(b"a".to_vec());
3554        let end = Key::from(b"z".to_vec());
3555
3556        assert_streaming_deadline(client.get_many(&[&key], 1)).await;
3557        assert_streaming_deadline(client.range_stream(&start, &end, 1, 1)).await;
3558        assert_streaming_deadline(client.query().range_reduce_stream(
3559            &start,
3560            &end,
3561            &count_request(),
3562        ))
3563        .await;
3564    }
3565
3566    #[tokio::test]
3567    async fn session_stream_prefetch_records_sequence_before_returning() {
3568        let client = StoreClient::builder()
3569            .url("http://query.internal")
3570            .retry_config(RetryConfig::disabled())
3571            .client_transport(SessionSequenceTransport::default())
3572            .build()
3573            .unwrap()
3574            .prefixed(StoreKeyPrefix::new("prefetch/").unwrap());
3575        let key = Key::from(b"key".to_vec());
3576        let start = Key::from(b"a".to_vec());
3577        let end = Key::from(b"z".to_vec());
3578
3579        let get_many_session = client.create_session();
3580        let _stream = get_many_session.get_many(&[&key], 1).await.unwrap();
3581        assert_eq!(get_many_session.fixed_sequence(), Some(41));
3582        assert_eq!(get_many_session.evaluated_sequence(), Some(41));
3583
3584        let range_session = client.create_session();
3585        let _stream = range_session
3586            .range_stream(&start, &end, 1, 1)
3587            .await
3588            .unwrap();
3589        assert_eq!(range_session.fixed_sequence(), Some(42));
3590        assert_eq!(range_session.evaluated_sequence(), Some(42));
3591
3592        let reduce_session = client.create_session();
3593        let _stream = reduce_session
3594            .range_reduce_stream(&start, &end, &count_request())
3595            .await
3596            .unwrap();
3597        assert_eq!(reduce_session.fixed_sequence(), Some(43));
3598        assert_eq!(reduce_session.evaluated_sequence(), Some(43));
3599    }
3600
3601    #[tokio::test]
3602    async fn session_reads_advance_the_floor_across_all_read_methods() {
3603        for initial_sequence in [0, 27] {
3604            let transport = SessionSequenceTransport::default();
3605            let client = StoreClient::builder()
3606                .url("http://query.internal")
3607                .retry_config(RetryConfig::disabled())
3608                .client_transport(transport.clone())
3609                .build()
3610                .unwrap()
3611                .prefixed(StoreKeyPrefix::new("session/").unwrap());
3612            let session = client.create_session_with_sequence(initial_sequence);
3613            let shared_session = session.clone();
3614            let key = Key::from(b"key".to_vec());
3615            let start = Key::from(b"a".to_vec());
3616            let end = Key::from(b"z".to_vec());
3617            let reduction = count_request();
3618
3619            assert_eq!(session.evaluated_sequence(), None);
3620            assert_eq!(session.get(&key).await.unwrap(), None);
3621            assert_eq!(session.evaluated_sequence(), Some(41));
3622            shared_session.get_many(&[&key], 1).await.unwrap();
3623            assert_eq!(session.evaluated_sequence(), Some(42));
3624            session.range_stream(&start, &end, 1, 1).await.unwrap();
3625            assert_eq!(session.evaluated_sequence(), Some(43));
3626            session.range(&start, &end, 1).await.unwrap();
3627            assert_eq!(session.evaluated_sequence(), Some(44));
3628            session
3629                .range_reduce(&start, &end, &reduction)
3630                .await
3631                .unwrap();
3632            assert_eq!(session.evaluated_sequence(), Some(45));
3633            let _stream = session
3634                .range_reduce_stream(&start, &end, &reduction)
3635                .await
3636                .unwrap();
3637
3638            assert_eq!(session.fixed_sequence(), Some(46));
3639            assert_eq!(shared_session.evaluated_sequence(), Some(46));
3640            assert_eq!(
3641                *transport.requested_floors.lock().unwrap(),
3642                vec![
3643                    (initial_sequence > 0).then_some(initial_sequence),
3644                    Some(41),
3645                    Some(42),
3646                    Some(43),
3647                    Some(44),
3648                    Some(45),
3649                ]
3650            );
3651        }
3652    }
3653
3654    #[test]
3655    fn public_streams_remain_send_and_sync() {
3656        fn assert_send_sync<T: Send + Sync>() {}
3657
3658        assert_send_sync::<RangeStream>();
3659        assert_send_sync::<GetManyStream>();
3660        assert_send_sync::<StreamSubscription>();
3661    }
3662
3663    #[test]
3664    fn balanced_http2_builder_supports_mixed_service_schemes() {
3665        let timeout = Duration::from_millis(123);
3666        let client = StoreClient::builder()
3667            .health_url("https://health.example.com")
3668            .ingest_url("http://ingest.internal")
3669            .query_url("https://query.example.com")
3670            .prune_url("http://prune.internal")
3671            .retention_url("http://retention.internal")
3672            .stream_url("https://stream.example.com")
3673            .balanced_http2_transport(
3674                ProtoBalancedHttp2Config::default().with_request_timeout(timeout),
3675            )
3676            .build()
3677            .unwrap();
3678
3679        assert_eq!(client.ingest_uri.scheme_str(), Some("http"));
3680        assert_eq!(client.query_uri.scheme_str(), Some("https"));
3681        assert_eq!(client.rpc_timeout, Some(timeout));
3682    }
3683
3684    #[test]
3685    fn builder_fails_until_all_urls_set() {
3686        assert!(matches!(
3687            StoreClient::builder().health_url("http://h").build(),
3688            Err(ClientBuildError::MissingIngestUrl)
3689        ));
3690        assert!(matches!(
3691            StoreClient::builder()
3692                .health_url("http://h")
3693                .ingest_url("http://i")
3694                .build(),
3695            Err(ClientBuildError::MissingQueryUrl)
3696        ));
3697        assert!(matches!(
3698            StoreClient::builder()
3699                .health_url("http://h")
3700                .ingest_url("http://i")
3701                .query_url("http://q")
3702                .build(),
3703            Err(ClientBuildError::MissingPruneUrl)
3704        ));
3705        assert!(matches!(
3706            StoreClient::builder()
3707                .health_url("http://h")
3708                .ingest_url("http://i")
3709                .query_url("http://q")
3710                .prune_url("http://p")
3711                .build(),
3712            Err(ClientBuildError::MissingRetentionUrl)
3713        ));
3714        assert!(matches!(
3715            StoreClient::builder()
3716                .health_url("http://h")
3717                .ingest_url("http://i")
3718                .query_url("http://q")
3719                .prune_url("http://p")
3720                .retention_url("http://r")
3721                .build(),
3722            Err(ClientBuildError::MissingStreamUrl)
3723        ));
3724    }
3725
3726    fn built_with_api_key(key: &str) -> StoreClient {
3727        StoreClient::builder()
3728            .url("https://example.test")
3729            .api_key(key)
3730            .build()
3731            .expect("builder should accept this key")
3732    }
3733
3734    #[test]
3735    fn api_key_becomes_a_bearer_header() {
3736        let client = built_with_api_key("token-abc");
3737        assert_eq!(client.credential, Credential::Sent);
3738    }
3739
3740    #[test]
3741    fn api_key_is_redacted_in_debug_output() {
3742        let rendered = format!(
3743            "{:?}",
3744            StoreClient::builder()
3745                .url("https://example.test")
3746                .api_key("token-abc")
3747        );
3748
3749        assert!(!rendered.contains("token-abc"), "builder leaked the key");
3750        assert!(rendered.contains("<redacted>"));
3751    }
3752
3753    #[test]
3754    fn a_bearer_header_never_renders_the_key() {
3755        let rendered = format!("{client:?}", client = built_with_api_key("token-abc"));
3756
3757        assert!(!rendered.contains("token-abc"));
3758    }
3759
3760    /// Builds with `key` standing in for the environment lookup, so nothing here depends on a
3761    /// variable another test could be changing. `credential.rs` covers which key wins.
3762    fn built_with_env_key(key: Option<&str>, unusable_env_key: UnusableEnvKey) -> StoreClient {
3763        StoreClient::builder()
3764            .url("https://example.test")
3765            .build_with(key.map(str::to_string), unusable_env_key)
3766            .expect("builder should accept this environment key")
3767    }
3768
3769    #[test]
3770    fn no_api_key_sends_no_header() {
3771        // A deployment with no load balancer in front of it authenticates nothing, so local and
3772        // in-VPC clients must keep working with no credential configured.
3773        let client = built_with_env_key(None, UnusableEnvKey::Reject);
3774
3775        assert_eq!(client.credential, Credential::Absent);
3776    }
3777
3778    #[test]
3779    fn an_environment_key_reaches_the_transport() {
3780        let client = built_with_env_key(Some("from-env"), UnusableEnvKey::Reject);
3781
3782        assert_eq!(client.credential, Credential::Sent);
3783    }
3784
3785    #[test]
3786    fn a_key_that_cannot_be_a_header_fails_the_build() {
3787        assert!(matches!(
3788            StoreClient::builder()
3789                .url("https://example.test")
3790                .api_key("has\nnewline")
3791                .build(),
3792            Err(ClientBuildError::InvalidApiKey)
3793        ));
3794    }
3795
3796    /// The infallible constructors would have to panic, so they build a client that carries no
3797    /// credential and says why when a request is rejected.
3798    #[test]
3799    fn a_tolerated_unusable_key_yields_a_client_that_explains_itself() {
3800        let client = built_with_env_key(Some("has\nnewline"), UnusableEnvKey::Tolerate);
3801        assert_eq!(client.credential, Credential::Unusable);
3802
3803        let rendered = client_error_from_connect(
3804            ConnectError::new(ErrorCode::Unauthenticated, "HTTP error 401"),
3805            client.credential,
3806        )
3807        .to_string();
3808        assert!(rendered.contains(API_KEY_ENV), "{rendered}");
3809        assert!(rendered.contains("cannot be an HTTP header"), "{rendered}");
3810    }
3811
3812    #[test]
3813    fn client_trims_trailing_slash() {
3814        let client = StoreClient::new("http://localhost:10000/");
3815        assert_eq!(client.health_url, "http://localhost:10000");
3816    }
3817
3818    #[test]
3819    fn create_session_starts_unseeded() {
3820        let client = PrefixedStoreClient::empty(StoreClient::new("http://localhost:10000/"));
3821        let session = client.create_session();
3822        assert_eq!(session.fixed_sequence(), None);
3823        assert_eq!(session.evaluated_sequence(), None);
3824    }
3825
3826    #[test]
3827    fn range_mode_maps_to_proto_traversal() {
3828        assert_eq!(
3829            RangeMode::Forward.to_proto(),
3830            ProtoTraversalMode::TRAVERSAL_MODE_FORWARD
3831        );
3832        assert_eq!(
3833            RangeMode::Reverse.to_proto(),
3834            ProtoTraversalMode::TRAVERSAL_MODE_REVERSE
3835        );
3836    }
3837
3838    #[test]
3839    fn retry_config_standard_defaults_match_expected() {
3840        let config = RetryConfig::standard();
3841        assert_eq!(config.max_attempts, 3);
3842        assert_eq!(config.initial_backoff, Duration::from_millis(100));
3843        assert_eq!(config.max_backoff, Duration::from_millis(2_000));
3844    }
3845
3846    #[test]
3847    fn retry_config_clamps_attempts_and_backoff_bounds() {
3848        let config = RetryConfig::standard()
3849            .with_max_attempts(0)
3850            .with_initial_backoff(Duration::from_millis(250))
3851            .with_max_backoff(Duration::from_millis(50))
3852            .sanitized();
3853        assert_eq!(config.max_attempts, 1);
3854        assert_eq!(config.initial_backoff, Duration::from_millis(250));
3855        assert_eq!(config.max_backoff, Duration::from_millis(250));
3856    }
3857
3858    #[test]
3859    fn retryable_codes_include_connect_transients() {
3860        assert!(is_retryable_error(&ConnectError::aborted("retry")));
3861        assert!(is_retryable_error(&ConnectError::resource_exhausted(
3862            "retry"
3863        )));
3864        assert!(is_retryable_error(&ConnectError::unavailable("retry")));
3865        assert!(is_retryable_error(&ConnectError::internal("retry")));
3866        assert!(!is_retryable_error(&ConnectError::invalid_argument(
3867            "no retry"
3868        )));
3869    }
3870
3871    #[test]
3872    fn retry_backoff_delay_is_exponential_and_capped() {
3873        let config = RetryConfig::standard()
3874            .with_initial_backoff(Duration::from_millis(100))
3875            .with_max_backoff(Duration::from_millis(250));
3876        assert_eq!(retry_backoff_delay(1, config), Duration::from_millis(100));
3877        assert_eq!(retry_backoff_delay(2, config), Duration::from_millis(200));
3878        assert_eq!(retry_backoff_delay(3, config), Duration::from_millis(250));
3879        assert_eq!(retry_backoff_delay(4, config), Duration::from_millis(250));
3880    }
3881
3882    #[test]
3883    fn create_session_with_sequence_pins_explicit_floor() {
3884        let client = PrefixedStoreClient::empty(StoreClient::new("http://localhost:10000/"));
3885        let session = client.create_session_with_sequence(27);
3886        assert_eq!(session.fixed_sequence(), Some(27));
3887        assert_eq!(session.evaluated_sequence(), None);
3888    }
3889
3890    #[test]
3891    fn store_key_prefix_round_trips_logical_keys() {
3892        let prefix = StoreKeyPrefix::new(vec![0x0A]).unwrap();
3893        let logical = Bytes::from_static(b"hello");
3894        let physical = prefix.encode_key(&logical).unwrap();
3895        assert!(prefix.matches(&physical));
3896        assert_eq!(prefix.decode_key(&physical).unwrap(), logical);
3897    }
3898
3899    #[test]
3900    fn store_key_prefix_next_key_respects_namespace_capacity() {
3901        for prefix in [
3902            StoreKeyPrefix::identity(),
3903            StoreKeyPrefix::new(Bytes::from_static(b"tenant/")).unwrap(),
3904        ] {
3905            assert_eq!(
3906                prefix.next_key(&Bytes::from_static(b"row")).unwrap(),
3907                Some(Bytes::from_static(b"row\0")),
3908            );
3909            let capacity = prefix.max_logical_key_len();
3910            let mut key = vec![0; capacity];
3911            key[capacity - 2] = 0x12;
3912            key[capacity - 1] = 0xFF;
3913            let next = prefix.next_key(&Bytes::from(key)).unwrap().unwrap();
3914            assert_eq!(next.len(), capacity - 1);
3915            assert_eq!(next[capacity - 2], 0x13);
3916            assert!(prefix.encode_key(&next).is_ok());
3917            assert_eq!(
3918                prefix.next_key(&Bytes::from(vec![0xFF; capacity])).unwrap(),
3919                None,
3920            );
3921            assert!(prefix
3922                .next_key(&Bytes::from(vec![0; capacity + 1]))
3923                .is_err());
3924        }
3925        let prefix = StoreKeyPrefix::new(vec![0; MAX_KEY_LEN]).unwrap();
3926        assert_eq!(prefix.next_key(&Bytes::new()).unwrap(), None);
3927    }
3928
3929    #[test]
3930    fn identity_prefix_encode_decode_are_zero_copy() {
3931        let prefix = StoreKeyPrefix::identity();
3932        let logical = Bytes::from_static(b"passthrough-key");
3933        let physical = prefix.encode_key(&logical).unwrap();
3934        // The identity prefix copies no key bytes: encode/decode share backing.
3935        assert_eq!(physical.as_ptr(), logical.as_ptr());
3936        let decoded = prefix.decode_key(&physical).unwrap();
3937        assert_eq!(decoded.as_ptr(), logical.as_ptr());
3938        assert_eq!(decoded, logical);
3939    }
3940
3941    #[test]
3942    fn uniform_width_prefixes_are_pairwise_disjoint() {
3943        // Six leaf prefixes sharing one uniform (1-byte) width.
3944        let all = [
3945            StoreKeyPrefix::new(vec![0]).unwrap(),
3946            StoreKeyPrefix::new(vec![1]).unwrap(),
3947            StoreKeyPrefix::new(vec![2]).unwrap(),
3948            StoreKeyPrefix::new(vec![3]).unwrap(),
3949            StoreKeyPrefix::new(vec![4]).unwrap(),
3950            StoreKeyPrefix::new(vec![5]).unwrap(),
3951        ];
3952        // A key encoded under one prefix never matches another's prefix, so a
3953        // prefix-bounded range scan in one can't observe another's keys (this is
3954        // the bug being fixed). Same-length prefixes are pairwise disjoint.
3955        let logical = Bytes::from_static(b"\x00\x10whatever-block-meta-or-op-log-key");
3956        for (i, pa) in all.iter().enumerate() {
3957            let physical = pa.encode_key(&logical).unwrap();
3958            assert!(pa.matches(&physical));
3959            assert_eq!(pa.decode_key(&physical).unwrap(), logical);
3960            for (j, pb) in all.iter().enumerate() {
3961                if i == j {
3962                    continue;
3963                }
3964                assert!(
3965                    !pb.matches(&physical),
3966                    "prefix {j} matched a key encoded under prefix {i}",
3967                );
3968            }
3969        }
3970    }
3971
3972    #[test]
3973    fn prefixed_store_client_always_carries_its_prefix() {
3974        let base = StoreClient::new("http://localhost:8090");
3975        let prefix = StoreKeyPrefix::new(vec![0]).unwrap();
3976        let client = base.prefixed(prefix.clone());
3977        assert_eq!(client.key_prefix(), &prefix);
3978        // The logical client encodes through its prefix.
3979        let logical = Bytes::from_static(b"row");
3980        assert_eq!(
3981            client.encode_store_key(&logical).unwrap(),
3982            prefix.encode_key(&logical).unwrap(),
3983        );
3984    }
3985
3986    #[test]
3987    fn store_key_prefix_clamps_long_logical_range_upper_bound() {
3988        let prefix = StoreKeyPrefix::new(vec![0x02]).unwrap();
3989        // A full-width logical upper bound (MAX_KEY_LEN bytes of 0xFF), as an
3990        // un-prefixed scan's inclusive end would be.
3991        let logical_start = Bytes::new();
3992        let logical_end = Bytes::from(vec![0xFFu8; MAX_KEY_LEN]);
3993        assert_eq!(logical_end.len(), MAX_KEY_LEN);
3994
3995        let (physical_start, physical_end) =
3996            prefix.encode_range(&logical_start, &logical_end).unwrap();
3997        assert!(prefix.matches(&physical_start));
3998        assert!(prefix.matches(&physical_end));
3999        // The logical end clamps to max_logical_key_len (253) before prefixing,
4000        // so the physical end is exactly MAX_KEY_LEN bytes.
4001        assert_eq!(prefix.max_logical_key_len(), MAX_KEY_LEN - 1);
4002        assert_eq!(physical_end.len(), MAX_KEY_LEN);
4003        assert_eq!(prefix.decode_key(&physical_start).unwrap(), logical_start);
4004    }
4005
4006    #[test]
4007    fn store_key_prefix_rewrites_selector_family() {
4008        let prefix = StoreKeyPrefix::new(vec![0x05]).unwrap();
4009        let logical = crate::selector::Selector {
4010            prefix: Bytes::copy_from_slice(&[0x06]),
4011            payload_regex: crate::kv_codec::Utf8::from("(?s).*"),
4012        };
4013        let physical = prefix.prefix_selector(&logical).unwrap();
4014        assert_eq!(physical.prefix.as_ref(), &[0x05, 0x06]);
4015        assert_eq!(physical.payload_regex, logical.payload_regex);
4016    }
4017
4018    #[test]
4019    fn store_key_prefix_composed_selector_reconstructs_and_strips_cleanly() {
4020        // Reproduces the selector-merge -> reconstruct-codec path that the old
4021        // bit-padding KeyCodec corrupted: a store namespace prefix is composed
4022        // onto a logical selector's prefix, and then a codec is rebuilt FROM the
4023        // merged selector prefix and used to strip keys. Under the old bit-packed
4024        // codec, composing two sub-byte prefixes and reconstructing a codec left a
4025        // spurious trailing 0x00 in the stripped payload; the byte-prefix design
4026        // must strip cleanly.
4027
4028        // Multi-byte store namespace so the payload offset shift (= prefix length)
4029        // is genuinely exercised.
4030        let prefix = StoreKeyPrefix::new(vec![0x05, 0x06]).unwrap();
4031        let logical = crate::selector::Selector {
4032            prefix: Bytes::copy_from_slice(&[0x07]),
4033            payload_regex: crate::kv_codec::Utf8::from("(?s).*"),
4034        };
4035
4036        // Compose via the real SDK merge path (byte-concatenates the store prefix
4037        // onto the selector prefix).
4038        let merged = prefix.prefix_selector(&logical).unwrap();
4039        assert_eq!(merged.prefix.as_ref(), &[0x05, 0x06, 0x07]);
4040
4041        // Reconstruct the codec EXACTLY as apply_key_prune_policy does.
4042        let codec = Prefix::new(merged.prefix.clone()).unwrap();
4043
4044        // Round-trip a real key through the reconstructed codec.
4045        let payload = [0xAA, 0xBB, 0xCC];
4046        let key = codec.encode(&payload).unwrap();
4047        assert_eq!(key.as_ref(), &[0x05, 0x06, 0x07, 0xAA, 0xBB, 0xCC]);
4048        assert!(codec.matches(&key));
4049        // The assertion the old codec would fail: the stripped payload is exactly
4050        // the original bytes, with no trailing spurious 0x00 from 8-bit padding.
4051        assert_eq!(
4052            codec.strip(&key).unwrap(),
4053            Bytes::copy_from_slice(&[0xAA, 0xBB, 0xCC])
4054        );
4055
4056        // The scan bounds bracket the key. bounds() are inclusive on both ends:
4057        // start is the bare prefix, end is the prefix padded with 0xFF.
4058        let (start, end) = codec.bounds();
4059        assert!(start <= key && key <= end);
4060
4061        // A key under a DIFFERENT merged prefix must not match this codec.
4062        let sibling = Prefix::new(vec![0x05, 0x06, 0x08])
4063            .unwrap()
4064            .encode(&[0x11])
4065            .unwrap();
4066        assert!(!codec.matches(&sibling));
4067    }
4068
4069    #[test]
4070    fn store_key_prefix_stream_filter_passes_payload_regex_through() {
4071        // The stream path no longer broadens the payload regex: byte-aligned
4072        // payloads let the server apply the caller's real regex to the
4073        // post-prefix payload, so it must pass through unchanged.
4074        let client = StoreClient::builder()
4075            .url("http://localhost:10000")
4076            .build()
4077            .unwrap()
4078            .prefixed(StoreKeyPrefix::new(vec![0x05]).unwrap());
4079        let filter = crate::stream_filter::StreamFilter {
4080            selectors: vec![crate::selector::Selector {
4081                prefix: Bytes::copy_from_slice(&[0x06]),
4082                payload_regex: crate::kv_codec::Utf8::from("(?s)foo.*"),
4083            }],
4084            value_filters: vec![],
4085        };
4086        let physical = client.prefix_stream_filter(filter.clone()).unwrap();
4087        assert_eq!(physical.selectors[0].prefix.as_ref(), &[0x05, 0x06]);
4088        assert_eq!(
4089            physical.selectors[0].payload_regex,
4090            filter.selectors[0].payload_regex
4091        );
4092    }
4093
4094    #[test]
4095    fn prefixed_reduce_request_shifts_nested_cast_fields() {
4096        let client = StoreClient::builder()
4097            .url("http://localhost:10000")
4098            .build()
4099            .unwrap()
4100            .prefixed(StoreKeyPrefix::new(vec![1, 2, 3]).unwrap());
4101        let request_at = |byte_offset, bit_offset| DomainRangeReduceRequest {
4102            reducers: vec![crate::RangeReducerSpec {
4103                op: crate::RangeReduceOp::SumField,
4104                expr: Some(KvExpr::CastFloat64(Box::new(KvExpr::Add(
4105                    Box::new(KvExpr::Field(KvFieldRef::Key {
4106                        byte_offset,
4107                        kind: KvFieldKind::Int64,
4108                    })),
4109                    Box::new(KvExpr::CastFloat64(Box::new(KvExpr::Field(
4110                        KvFieldRef::Value {
4111                            index: 2,
4112                            kind: KvFieldKind::UInt64,
4113                            nullable: true,
4114                        },
4115                    )))),
4116                )))),
4117                filter: None,
4118            }],
4119            group_by: vec![KvExpr::CastFloat64(Box::new(KvExpr::Field(
4120                KvFieldRef::ZOrderKey {
4121                    bit_offset,
4122                    field_position: 0,
4123                    field_widths: vec![8],
4124                    kind: KvFieldKind::UInt64,
4125                },
4126            )))],
4127            filter: None,
4128        };
4129        let request = request_at(9, 12);
4130        assert_eq!(
4131            client.prefix_reduce_request(&request).unwrap(),
4132            request_at(12, 36)
4133        );
4134        assert_eq!(request, request_at(9, 12));
4135    }
4136
4137    #[test]
4138    fn prefixed_reduce_request_shifts_key_field_offsets() {
4139        let client = StoreClient::builder()
4140            .url("http://localhost:10000")
4141            .build()
4142            .unwrap()
4143            .prefixed(StoreKeyPrefix::new(vec![0x01, 0x02, 0x03]).unwrap());
4144        let request = DomainRangeReduceRequest {
4145            reducers: vec![crate::RangeReducerSpec {
4146                filter: Some(KvPredicate {
4147                    checks: vec![
4148                        KvPredicateCheck {
4149                            field: KvFieldRef::Key {
4150                                byte_offset: 9,
4151                                kind: KvFieldKind::UInt64,
4152                            },
4153                            constraint: KvPredicateConstraint::IsNotNull,
4154                        },
4155                        KvPredicateCheck {
4156                            field: KvFieldRef::ZOrderKey {
4157                                bit_offset: 12,
4158                                field_position: 0,
4159                                field_widths: vec![8],
4160                                kind: KvFieldKind::UInt64,
4161                            },
4162                            constraint: KvPredicateConstraint::IsNotNull,
4163                        },
4164                        KvPredicateCheck {
4165                            field: KvFieldRef::Value {
4166                                index: 2,
4167                                kind: KvFieldKind::UInt64,
4168                                nullable: true,
4169                            },
4170                            constraint: KvPredicateConstraint::IsNotNull,
4171                        },
4172                    ],
4173                    contradiction: false,
4174                }),
4175                op: crate::RangeReduceOp::SumField,
4176                expr: Some(KvExpr::Field(KvFieldRef::Key {
4177                    byte_offset: 9,
4178                    kind: KvFieldKind::UInt64,
4179                })),
4180            }],
4181            group_by: vec![KvExpr::Field(KvFieldRef::ZOrderKey {
4182                bit_offset: 12,
4183                field_position: 0,
4184                field_widths: vec![8],
4185                kind: KvFieldKind::UInt64,
4186            })],
4187            filter: Some(KvPredicate {
4188                checks: vec![KvPredicateCheck {
4189                    field: KvFieldRef::Value {
4190                        index: 0,
4191                        kind: KvFieldKind::UInt64,
4192                        nullable: false,
4193                    },
4194                    constraint: KvPredicateConstraint::UInt64Range {
4195                        min: Some(1),
4196                        max: Some(9),
4197                    },
4198                }],
4199                contradiction: false,
4200            }),
4201        };
4202
4203        let shifted = client.prefix_reduce_request(&request).unwrap();
4204        let Some(KvExpr::Field(KvFieldRef::Key { byte_offset, .. })) =
4205            shifted.reducers[0].expr.as_ref()
4206        else {
4207            panic!("expected key field reducer");
4208        };
4209        // KeyField offsets are byte-granular: a 3-byte prefix shifts by 3 bytes,
4210        // so 9 -> 12.
4211        assert_eq!(*byte_offset, 12);
4212        let KvExpr::Field(KvFieldRef::ZOrderKey { bit_offset, .. }) = &shifted.group_by[0] else {
4213            panic!("expected z-order group field");
4214        };
4215        // Z-order offsets stay bit-granular: a 3-byte prefix shifts by 3*8 = 24
4216        // bits, so 12 -> 36.
4217        assert_eq!(*bit_offset, 36);
4218        let checks = &shifted.reducers[0].filter.as_ref().unwrap().checks;
4219        assert_eq!(
4220            checks[0].field,
4221            KvFieldRef::Key {
4222                byte_offset: 12,
4223                kind: KvFieldKind::UInt64,
4224            }
4225        );
4226        assert_eq!(
4227            checks[1].field,
4228            KvFieldRef::ZOrderKey {
4229                bit_offset: 36,
4230                field_position: 0,
4231                field_widths: vec![8],
4232                kind: KvFieldKind::UInt64,
4233            }
4234        );
4235        let original_checks = &request.reducers[0].filter.as_ref().unwrap().checks;
4236        assert_eq!(checks[2], original_checks[2]);
4237        assert_eq!(shifted.filter, request.filter);
4238        assert_eq!(
4239            original_checks[0].field,
4240            KvFieldRef::Key {
4241                byte_offset: 9,
4242                kind: KvFieldKind::UInt64,
4243            }
4244        );
4245    }
4246
4247    #[test]
4248    fn store_write_batch_uses_each_clients_prefix() {
4249        let base = StoreClient::new("http://localhost:10000");
4250        let a = base.prefixed(StoreKeyPrefix::new(vec![1]).unwrap());
4251        let b = base.prefixed(StoreKeyPrefix::new(vec![2]).unwrap());
4252        let key_a = Bytes::from_static(b"a");
4253        let key_b = Bytes::from_static(b"b");
4254
4255        let mut batch = StoreWriteBatch::new();
4256        batch.push(&a, &key_a, b"va").unwrap();
4257        batch.push(&b, &key_b, b"vb").unwrap();
4258
4259        assert_eq!(
4260            batch.entries[0].0,
4261            a.key_prefix().encode_key(&key_a).unwrap()
4262        );
4263        assert_eq!(
4264            batch.entries[1].0,
4265            b.key_prefix().encode_key(&key_b).unwrap()
4266        );
4267    }
4268
4269    #[test]
4270    fn pushed_rows_round_trip_only_through_their_own_client() {
4271        let base = StoreClient::new("http://localhost:10000");
4272        let a = base.prefixed(StoreKeyPrefix::new(vec![1]).unwrap());
4273        let b = base.prefixed(StoreKeyPrefix::new(vec![2]).unwrap());
4274        let key = Bytes::from_static(b"shared-logical-key");
4275
4276        let mut batch = StoreWriteBatch::new();
4277        batch.push(&a, &key, b"va").unwrap();
4278        batch.push(&b, &key, b"vb").unwrap();
4279
4280        // The same logical key staged through two namespaces produces two
4281        // distinct physical rows, in staging order.
4282        let entries = batch.entries();
4283        assert_eq!(entries.len(), 2);
4284        assert_ne!(entries[0].0, entries[1].0);
4285
4286        // Each physical key decodes back through its own namespace and is
4287        // rejected by the other.
4288        assert_eq!(a.decode_store_key(&entries[0].0).unwrap(), key);
4289        assert_eq!(b.decode_store_key(&entries[1].0).unwrap(), key);
4290        assert!(a.decode_store_key(&entries[1].0).is_err());
4291        assert!(b.decode_store_key(&entries[0].0).is_err());
4292    }
4293
4294    #[test]
4295    fn identity_prefix_stages_keys_verbatim() {
4296        let base = StoreClient::new("http://localhost:10000");
4297        let plain = PrefixedStoreClient::empty(base);
4298        let key = Bytes::from_static(b"raw-key");
4299
4300        let mut batch = StoreWriteBatch::new();
4301        batch.push(&plain, &key, b"v").unwrap();
4302
4303        assert_eq!(batch.entries()[0].0, key);
4304    }
4305
4306    #[test]
4307    fn push_rejects_keys_exceeding_prefixed_capacity() {
4308        let base = StoreClient::new("http://localhost:10000");
4309        let a = base.prefixed(StoreKeyPrefix::new(vec![1]).unwrap());
4310        let max = a.key_prefix().max_logical_key_len();
4311
4312        let mut batch = StoreWriteBatch::new();
4313        let at_capacity = Key::from(vec![7u8; max]);
4314        batch.push(&a, &at_capacity, b"v").unwrap();
4315
4316        let oversize = Key::from(vec![7u8; max + 1]);
4317        assert!(batch.push(&a, &oversize, b"v").is_err());
4318        assert_eq!(batch.len(), 1);
4319    }
4320
4321    fn hex_encode(data: &[u8]) -> String {
4322        hex::encode(data)
4323    }
4324
4325    fn hex_decode(s: &str) -> Option<Vec<u8>> {
4326        hex::decode(s).ok()
4327    }
4328}