Skip to main content

oxia/
client.rs

1//! The Oxia client: construction, per-operation execution, and shutdown.
2
3use crate::batcher::{BatcherDeps, ReadBatcher, WriteBatcher, WriteOp, pending_write};
4use crate::client_builder::OxiaClientBuilder;
5use crate::client_options::OxiaClientOptions;
6use crate::errors::OxiaError;
7use crate::notification_manager::NotificationManager;
8use crate::operations::Pending;
9use crate::proto;
10use crate::provider_manager::ProviderManager;
11use crate::requests::{
12    DeleteBuilder, DeleteRangeBuilder, GetBuilder, ListBuilder, NotificationsBuilder, PutBuilder,
13    RangeScanBuilder, SequenceUpdatesBuilder,
14};
15use crate::retry::{retry_delay, retry_until};
16use crate::sequence_updates_manager::SequenceUpdatesManager;
17use crate::server_error::{DecodedStatus, LeaderHint, decode_status};
18use crate::session_manager::SessionManager;
19use crate::shard_manager::{ShardManager, ShardManagerOptions};
20use crate::streams::{
21    ListStream, Notifications, RangeScanStream, SequenceUpdates, ShardStream, merged,
22};
23use crate::types::{ComparisonType, GetResult};
24use bytes::Bytes;
25use dashmap::DashMap;
26use futures::StreamExt;
27use futures::future::try_join_all;
28use std::fmt;
29use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
30use std::sync::{Arc, Weak};
31use std::time::Duration;
32use tokio::sync::{mpsc, oneshot};
33use tokio::task::JoinSet;
34use tracing::warn;
35
36pub(crate) struct Inner {
37    pub(crate) options: OxiaClientOptions,
38    pub(crate) provider_manager: Arc<ProviderManager>,
39    pub(crate) shard_manager: Arc<ShardManager>,
40    pub(crate) session_manager: Arc<SessionManager>,
41    batcher_deps: Arc<BatcherDeps>,
42    notification_managers: DashMap<u64, NotificationManager>,
43    sequence_managers: DashMap<u64, SequenceUpdatesManager>,
44    next_subscription_id: AtomicU64,
45    write_batchers: DashMap<i64, Arc<WriteBatcher>>,
46    read_batchers: DashMap<i64, Arc<ReadBatcher>>,
47    closed: AtomicBool,
48}
49
50/// Which registry a [`SubscriptionGuard`] cleans up.
51enum SubscriptionKind {
52    Notification,
53    Sequence,
54}
55
56/// Held by a [`Notifications`]/[`SequenceUpdates`] handle; on drop it removes
57/// the backing manager from the client, stopping its listener task(s). A [`Weak`]
58/// reference means a dropped handle after the client is gone is a no-op.
59pub(crate) struct SubscriptionGuard {
60    inner: Weak<Inner>,
61    id: u64,
62    kind: SubscriptionKind,
63}
64
65impl Drop for SubscriptionGuard {
66    fn drop(&mut self) {
67        let Some(inner) = self.inner.upgrade() else {
68            return;
69        };
70        match self.kind {
71            SubscriptionKind::Notification => {
72                inner.notification_managers.remove(&self.id);
73            }
74            SubscriptionKind::Sequence => {
75                inner.sequence_managers.remove(&self.id);
76            }
77        }
78    }
79}
80
81/// The Oxia client.
82///
83/// Cheap to clone (all clones share one set of connections, batchers and
84/// sessions) and safe to share across tasks. Obtain one with
85/// [`OxiaClient::connect`] or [`OxiaClient::builder`]; every data operation
86/// returns a request builder that is executed by `.await`ing it.
87///
88/// Dropping the last clone tears down all background work abruptly; call
89/// [`close`](OxiaClient::close) for a graceful shutdown that flushes pending
90/// batches and closes sessions.
91///
92/// # Cancellation
93///
94/// Dropping an operation's future stops *waiting* but does not recall the
95/// operation: once submitted to a batch it may still execute on the server.
96/// Use conditional operations (e.g.
97/// [`expected_version_id`](crate::PutBuilder::expected_version_id)) when you
98/// need certainty about what was applied.
99#[derive(Clone)]
100pub struct OxiaClient {
101    inner: Arc<Inner>,
102}
103
104impl fmt::Debug for OxiaClient {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.debug_struct("OxiaClient")
107            .field("namespace", &self.inner.options.namespace)
108            .field("service_address", &self.inner.options.service_address)
109            .finish_non_exhaustive()
110    }
111}
112
113impl OxiaClient {
114    /// Returns a builder for configuring and creating a client.
115    pub fn builder() -> OxiaClientBuilder {
116        OxiaClientBuilder::new()
117    }
118
119    /// Connects to the given service address (`host:port`) with default options.
120    ///
121    /// Use [`OxiaClient::builder`] to customize the namespace, timeouts, or
122    /// batching before connecting.
123    ///
124    /// # Errors
125    ///
126    /// Fails fast — within the request timeout — when the cluster is
127    /// unreachable ([`OxiaError::Disconnected`] / [`OxiaError::Timeout`]), or
128    /// with [`OxiaError::Grpc`] when the namespace does not exist
129    /// (`NotFound`) or the credentials are rejected (`Unauthenticated`).
130    ///
131    /// # Example
132    ///
133    /// ```no_run
134    /// # async fn example() -> Result<(), oxia::OxiaError> {
135    /// let client = oxia::OxiaClient::connect("localhost:6648").await?;
136    /// # Ok(()) }
137    /// ```
138    pub async fn connect(service_address: impl Into<String>) -> Result<OxiaClient, OxiaError> {
139        OxiaClientBuilder::new()
140            .service_address(service_address)
141            .build()
142            .await
143    }
144
145    pub(crate) async fn new(options: OxiaClientOptions) -> Result<OxiaClient, OxiaError> {
146        let provider_manager = Arc::new(ProviderManager::new(options.request_timeout));
147        let shard_manager = Arc::new(
148            ShardManager::new(ShardManagerOptions {
149                address: options.service_address.clone(),
150                namespace: options.namespace.clone(),
151                provider_manager: provider_manager.clone(),
152                request_timeout: options.request_timeout,
153            })
154            .await?,
155        );
156        let batcher_deps = Arc::new(BatcherDeps {
157            namespace: options.namespace.clone(),
158            shard_manager: shard_manager.clone(),
159            provider_manager: provider_manager.clone(),
160            max_batch_size: options.batch_max_size as usize,
161            max_requests_per_batch: options.max_requests_per_batch as usize,
162            max_write_batches_in_flight: options.max_write_batches_in_flight as usize,
163            max_read_batches_in_flight: options.max_read_batches_in_flight as usize,
164            request_timeout: options.request_timeout,
165        });
166        let session_manager = Arc::new(SessionManager::new(
167            options.identity.clone(),
168            options.session_timeout,
169            options.session_keep_alive,
170            options.request_timeout,
171            shard_manager.clone(),
172            provider_manager.clone(),
173        ));
174        Ok(OxiaClient {
175            inner: Arc::new(Inner {
176                options,
177                provider_manager,
178                shard_manager,
179                session_manager,
180                batcher_deps,
181                notification_managers: DashMap::new(),
182                sequence_managers: DashMap::new(),
183                next_subscription_id: AtomicU64::new(0),
184                write_batchers: DashMap::new(),
185                read_batchers: DashMap::new(),
186                closed: AtomicBool::new(false),
187            }),
188        })
189    }
190
191    /// Stores `value` under `key`, creating or overwriting the record.
192    ///
193    /// Returns a [`PutBuilder`]; chain options
194    /// ([`ephemeral`](PutBuilder::ephemeral),
195    /// [`expected_version_id`](PutBuilder::expected_version_id),
196    /// [`partition_key`](PutBuilder::partition_key),
197    /// [`sequence_key_deltas`](PutBuilder::sequence_key_deltas),
198    /// [`secondary_index`](PutBuilder::secondary_index), …) and `.await` it.
199    /// The value is `Bytes`-backed and is not copied on its way to the wire.
200    ///
201    /// # Errors
202    ///
203    /// Awaiting the builder returns:
204    /// - [`OxiaError::InvalidArgument`] when
205    ///   [`sequence_key_deltas`](PutBuilder::sequence_key_deltas) is used
206    ///   without a partition key, with a zero delta, or together with an
207    ///   expected version;
208    /// - [`OxiaError::UnexpectedVersionId`] when a
209    ///   [`expected_version_id`](PutBuilder::expected_version_id) /
210    ///   [`expected_record_not_exists`](PutBuilder::expected_record_not_exists)
211    ///   condition does not hold;
212    /// - [`OxiaError::SessionExpired`] when an
213    ///   [`ephemeral`](PutBuilder::ephemeral) put races a session expiry
214    ///   (retrying creates a fresh session);
215    /// - [`OxiaError::RequestTooLarge`] when key + value exceed the maximum
216    ///   batch size ([`batch_max_size`](crate::OxiaClientBuilder::batch_max_size));
217    /// - [`OxiaError::Timeout`] when the request timeout elapses;
218    /// - [`OxiaError::Closed`] after [`close`](OxiaClient::close);
219    /// - transient routing/transport errors
220    ///   ([`OxiaError::is_retryable`](crate::OxiaError::is_retryable)).
221    ///
222    /// # Example
223    ///
224    /// ```no_run
225    /// # async fn example(client: &oxia::OxiaClient) -> Result<(), oxia::OxiaError> {
226    /// let res = client.put("config/mode", "primary").await?;
227    /// // Compare-and-swap on the returned version id:
228    /// client
229    ///     .put("config/mode", "standby")
230    ///     .expected_version_id(res.version.version_id)
231    ///     .await?;
232    /// # Ok(()) }
233    /// ```
234    pub fn put(&self, key: impl Into<String>, value: impl Into<Bytes>) -> PutBuilder {
235        PutBuilder::new(self.clone(), key.into(), value.into())
236    }
237
238    /// Reads the record associated with `key`.
239    ///
240    /// Returns a [`GetBuilder`]; chain options
241    /// ([`comparison`](GetBuilder::comparison),
242    /// [`partition_key`](GetBuilder::partition_key),
243    /// [`include_value`](GetBuilder::include_value),
244    /// [`use_index`](GetBuilder::use_index), …) and `.await` it. With a
245    /// non-[`Equal`](crate::ComparisonType::Equal) comparison the returned
246    /// record's key is the *found* key, which may differ from the requested
247    /// one. Records written with a partition key must be read with the same
248    /// partition key.
249    ///
250    /// An exact-match get is routed to the single shard that owns the key (or
251    /// the partition key, when given); an inequality comparison or a
252    /// secondary-index lookup instead fans out across all shards.
253    ///
254    /// # Errors
255    ///
256    /// Awaiting the builder returns:
257    /// - [`OxiaError::KeyNotFound`] when no record matches;
258    /// - [`OxiaError::Timeout`] when the request timeout elapses;
259    /// - [`OxiaError::Closed`] after [`close`](OxiaClient::close);
260    /// - transient routing/transport errors
261    ///   ([`OxiaError::is_retryable`](crate::OxiaError::is_retryable)).
262    ///
263    /// # Example
264    ///
265    /// ```no_run
266    /// # async fn example(client: &oxia::OxiaClient) -> Result<(), oxia::OxiaError> {
267    /// use oxia::ComparisonType;
268    ///
269    /// let exact = client.get("config/mode").await?;
270    /// let floor = client
271    ///     .get("config/zz")
272    ///     .comparison(ComparisonType::Floor)
273    ///     .await?;
274    /// # Ok(()) }
275    /// ```
276    pub fn get(&self, key: impl Into<String>) -> GetBuilder {
277        GetBuilder::new(self.clone(), key.into())
278    }
279
280    /// Deletes the record associated with `key`.
281    ///
282    /// Returns a [`DeleteBuilder`]; optionally chain
283    /// [`expected_version_id`](DeleteBuilder::expected_version_id) for a
284    /// conditional delete or
285    /// [`partition_key`](DeleteBuilder::partition_key) for records written
286    /// with one, and `.await` it.
287    ///
288    /// # Errors
289    ///
290    /// Awaiting the builder returns:
291    /// - [`OxiaError::KeyNotFound`] when the record does not exist;
292    /// - [`OxiaError::UnexpectedVersionId`] when an
293    ///   [`expected_version_id`](DeleteBuilder::expected_version_id)
294    ///   condition does not hold;
295    /// - [`OxiaError::Timeout`] / [`OxiaError::Closed`] / transient errors as
296    ///   for the other operations.
297    ///
298    /// # Example
299    ///
300    /// ```no_run
301    /// # async fn example(client: &oxia::OxiaClient) -> Result<(), oxia::OxiaError> {
302    /// client.delete("config/mode").await?;
303    /// # Ok(()) }
304    /// ```
305    pub fn delete(&self, key: impl Into<String>) -> DeleteBuilder {
306        DeleteBuilder::new(self.clone(), key.into())
307    }
308
309    /// Deletes every record with `min_key_inclusive <= key < max_key_exclusive`
310    /// (in Oxia's slash-aware key order).
311    ///
312    /// Without a [`partition_key`](DeleteRangeBuilder::partition_key) the
313    /// deletion is applied on every shard; it is not atomic across shards.
314    ///
315    /// # Errors
316    ///
317    /// Awaiting the builder returns [`OxiaError::Timeout`],
318    /// [`OxiaError::Closed`], or transient routing/transport errors. Deleting
319    /// an empty range is not an error.
320    ///
321    /// # Example
322    ///
323    /// ```no_run
324    /// # async fn example(client: &oxia::OxiaClient) -> Result<(), oxia::OxiaError> {
325    /// // Everything under the "config/" prefix:
326    /// client.delete_range("config/", "config/~").await?;
327    /// # Ok(()) }
328    /// ```
329    pub fn delete_range(
330        &self,
331        min_key_inclusive: impl Into<String>,
332        max_key_exclusive: impl Into<String>,
333    ) -> DeleteRangeBuilder {
334        DeleteRangeBuilder::new(
335            self.clone(),
336            min_key_inclusive.into(),
337            max_key_exclusive.into(),
338        )
339    }
340
341    /// Lists the keys with `min_key_inclusive <= key < max_key_exclusive`
342    /// (in Oxia's slash-aware key order).
343    ///
344    /// `.await` the builder for all keys at once (bounded by the request
345    /// timeout), or call [`stream()`](ListBuilder::stream) for an
346    /// incremental [`ListStream`](crate::ListStream) with no overall
347    /// deadline. Both deliver keys in the server's global key order, merged
348    /// across shards.
349    ///
350    /// # Errors
351    ///
352    /// Awaiting the builder returns [`OxiaError::Timeout`] when collecting
353    /// all keys exceeds the request timeout, [`OxiaError::Closed`] after
354    /// [`close`](OxiaClient::close), or transient routing/transport errors.
355    /// An empty range yields an empty `Vec`, not an error.
356    ///
357    /// # Example
358    ///
359    /// ```no_run
360    /// # async fn example(client: &oxia::OxiaClient) -> Result<(), oxia::OxiaError> {
361    /// let keys = client.list("config/", "config/~").await?;
362    /// # Ok(()) }
363    /// ```
364    pub fn list(
365        &self,
366        min_key_inclusive: impl Into<String>,
367        max_key_exclusive: impl Into<String>,
368    ) -> ListBuilder {
369        ListBuilder::new(
370            self.clone(),
371            min_key_inclusive.into(),
372            max_key_exclusive.into(),
373        )
374    }
375
376    /// Reads every record with `min_key_inclusive <= key < max_key_exclusive`
377    /// (in Oxia's slash-aware key order).
378    ///
379    /// `.await` the builder for all records at once (bounded by the request
380    /// timeout), or call [`stream()`](RangeScanBuilder::stream) for an
381    /// incremental [`RangeScanStream`](crate::RangeScanStream) that holds at
382    /// most one buffered record per shard — prefer it for large ranges. Both
383    /// deliver records in the server's global key order, merged across
384    /// shards.
385    ///
386    /// # Errors
387    ///
388    /// Awaiting the builder returns [`OxiaError::Timeout`] when collecting
389    /// all records exceeds the request timeout, [`OxiaError::Closed`] after
390    /// [`close`](OxiaClient::close), or transient routing/transport errors.
391    /// An empty range yields an empty `Vec`, not an error.
392    ///
393    /// # Example
394    ///
395    /// ```no_run
396    /// # async fn example(client: &oxia::OxiaClient) -> Result<(), oxia::OxiaError> {
397    /// use futures::TryStreamExt;
398    ///
399    /// let mut scan = client.range_scan("logs/", "logs/~").stream().await?;
400    /// while let Some(record) = scan.try_next().await? {
401    ///     println!("{} => {:?}", record.key, record.value);
402    /// }
403    /// # Ok(()) }
404    /// ```
405    pub fn range_scan(
406        &self,
407        min_key_inclusive: impl Into<String>,
408        max_key_exclusive: impl Into<String>,
409    ) -> RangeScanBuilder {
410        RangeScanBuilder::new(
411            self.clone(),
412            min_key_inclusive.into(),
413            max_key_exclusive.into(),
414        )
415    }
416
417    /// Subscribes to all changes applied to the database, streamed as
418    /// [`Notification`](crate::Notification)s.
419    ///
420    /// Awaiting the builder yields a [`Notifications`](crate::Notifications)
421    /// handle. Per-shard subscriptions are established in the background and
422    /// re-established automatically after connection loss, resuming from the
423    /// last delivered offset so no notification in between is lost. Events
424    /// from the same shard arrive in order; interleaving across shards is
425    /// unspecified. Changes made before the subscription is fully established
426    /// may not be delivered.
427    ///
428    /// Dropping the handle ends the subscription. When the channel buffer
429    /// ([`buffer_size`](NotificationsBuilder::buffer_size)) is full, delivery
430    /// applies backpressure to the per-shard listeners.
431    ///
432    /// # Errors
433    ///
434    /// Awaiting the builder returns [`OxiaError::Closed`] after
435    /// [`close`](OxiaClient::close).
436    ///
437    /// # Example
438    ///
439    /// ```no_run
440    /// # async fn example(client: &oxia::OxiaClient) -> Result<(), oxia::OxiaError> {
441    /// let mut notifications = client.notifications().await?;
442    /// while let Some(event) = notifications.recv().await {
443    ///     println!("{event}");
444    /// }
445    /// # Ok(()) }
446    /// ```
447    pub fn notifications(&self) -> NotificationsBuilder {
448        NotificationsBuilder::new(self.clone())
449    }
450
451    /// Subscribes to updates of the sequence rooted at `key`, delivering the
452    /// highest assigned sequence key as it advances. `partition_key` must be
453    /// the partition key the sequential records are written with
454    /// (see [`PutBuilder::sequence_key_deltas`]).
455    ///
456    /// Awaiting the builder yields a
457    /// [`SequenceUpdates`](crate::SequenceUpdates) handle. Consecutive
458    /// advances may be coalesced: each delivery carries the *highest*
459    /// sequence key at that moment. The subscription reconnects automatically
460    /// after connection loss; dropping the handle ends it.
461    ///
462    /// # Errors
463    ///
464    /// Awaiting the builder returns [`OxiaError::Closed`] after
465    /// [`close`](OxiaClient::close).
466    ///
467    /// # Example
468    ///
469    /// ```no_run
470    /// # async fn example(client: &oxia::OxiaClient) -> Result<(), oxia::OxiaError> {
471    /// let mut updates = client.sequence_updates("seq/events", "pk-0").await?;
472    /// client
473    ///     .put("seq/events", "data")
474    ///     .partition_key("pk-0")
475    ///     .sequence_key_deltas([1])
476    ///     .await?;
477    /// let highest = updates.recv().await;
478    /// # Ok(()) }
479    /// ```
480    pub fn sequence_updates(
481        &self,
482        key: impl Into<String>,
483        partition_key: impl Into<String>,
484    ) -> SequenceUpdatesBuilder {
485        SequenceUpdatesBuilder::new(self.clone(), key.into(), partition_key.into())
486    }
487
488    /// Closes the client gracefully: pending batches are flushed, subscriptions
489    /// stop, sessions are closed server-side (immediately removing this
490    /// client's ephemeral records), and connections are released.
491    ///
492    /// Idempotent: subsequent calls (from any clone) return `Ok(())`.
493    /// Operations submitted after `close` fail with [`OxiaError::Closed`].
494    /// Merely dropping every clone instead tears the client down abruptly:
495    /// queued operations are not flushed and ephemeral records linger until
496    /// their session times out.
497    ///
498    /// # Errors
499    ///
500    /// Returns the first error encountered while shutting down (shutdown
501    /// still proceeds through every component). Only the first call can
502    /// return an error.
503    ///
504    /// # Example
505    ///
506    /// ```no_run
507    /// # async fn example(client: oxia::OxiaClient) -> Result<(), oxia::OxiaError> {
508    /// client.close().await?;
509    /// # Ok(()) }
510    /// ```
511    pub async fn close(&self) -> Result<(), OxiaError> {
512        if self.inner.closed.swap(true, Ordering::SeqCst) {
513            return Ok(());
514        }
515        let mut first_err: Option<OxiaError> = None;
516
517        // Flush and stop the batchers (writes first, so queued mutations land).
518        let write_shards: Vec<i64> = self.inner.write_batchers.iter().map(|e| *e.key()).collect();
519        for shard in write_shards {
520            if let Some((_, batcher)) = self.inner.write_batchers.remove(&shard) {
521                track(&mut first_err, batcher.close().await, "write batcher");
522            }
523        }
524        let read_shards: Vec<i64> = self.inner.read_batchers.iter().map(|e| *e.key()).collect();
525        for shard in read_shards {
526            if let Some((_, batcher)) = self.inner.read_batchers.remove(&shard) {
527                track(&mut first_err, batcher.close().await, "read batcher");
528            }
529        }
530        let notification_ids: Vec<u64> = self
531            .inner
532            .notification_managers
533            .iter()
534            .map(|e| *e.key())
535            .collect();
536        for id in notification_ids {
537            if let Some((_, manager)) = self.inner.notification_managers.remove(&id) {
538                track(&mut first_err, manager.shutdown().await, "notifications");
539            }
540        }
541        let sequence_ids: Vec<u64> = self
542            .inner
543            .sequence_managers
544            .iter()
545            .map(|e| *e.key())
546            .collect();
547        for id in sequence_ids {
548            if let Some((_, manager)) = self.inner.sequence_managers.remove(&id) {
549                track(&mut first_err, manager.shutdown().await, "sequence updates");
550            }
551        }
552        track(
553            &mut first_err,
554            self.inner.session_manager.close().await,
555            "sessions",
556        );
557        track(
558            &mut first_err,
559            self.inner.shard_manager.close().await,
560            "shard manager",
561        );
562        self.inner.provider_manager.clear();
563        match first_err {
564            None => Ok(()),
565            Some(err) => Err(err),
566        }
567    }
568
569    // ---- internals shared by the request builders -------------------------
570
571    pub(crate) fn ensure_open(&self) -> Result<(), OxiaError> {
572        if self.inner.closed.load(Ordering::Acquire) {
573            return Err(OxiaError::Closed);
574        }
575        Ok(())
576    }
577
578    pub(crate) fn request_timeout(&self) -> Duration {
579        self.inner.options.request_timeout
580    }
581
582    pub(crate) fn identity(&self) -> &str {
583        &self.inner.options.identity
584    }
585
586    /// Resolves the shard that owns `routing_key`.
587    pub(crate) fn shard_for(&self, routing_key: &str) -> Result<i64, OxiaError> {
588        self.inner
589            .shard_manager
590            .get_shard(routing_key)
591            .ok_or_else(|| OxiaError::NoShardForKey {
592                key: routing_key.to_string(),
593            })
594    }
595
596    pub(crate) async fn session_id_for(&self, shard: i64) -> Result<i64, OxiaError> {
597        self.inner.session_manager.get_session_id(shard).await
598    }
599
600    /// Runs `attempt` against the shard that owns `routing_key`, re-resolving
601    /// the shard and retrying (backoff, bounded by the request timeout) while
602    /// routing is unavailable — `NoShardForKey` (assignments not loaded yet) or
603    /// `ShardMoved` (the shard just split or merged). This is how an operation
604    /// re-routes to the new shard after a split/merge.
605    pub(crate) async fn with_shard_retry<T, F, Fut>(
606        &self,
607        routing_key: &str,
608        attempt: F,
609    ) -> Result<T, OxiaError>
610    where
611        F: Fn(i64) -> Fut,
612        Fut: std::future::Future<Output = Result<T, OxiaError>>,
613    {
614        retry_until(
615            self.request_timeout(),
616            |err| matches!(err, OxiaError::ShardMoved | OxiaError::NoShardForKey { .. }),
617            || async {
618                let shard = self.shard_for(routing_key)?;
619                attempt(shard).await
620            },
621        )
622        .await
623    }
624
625    /// Retries `op` while it fails with `ShardMoved` — for whole-operation
626    /// re-routing (broadcasts) where a single failing shard means the shard set
627    /// changed and the operation must be re-issued against the new set.
628    pub(crate) async fn with_reroute_retry<T, F, Fut>(&self, op: F) -> Result<T, OxiaError>
629    where
630        F: FnMut() -> Fut,
631        Fut: std::future::Future<Output = Result<T, OxiaError>>,
632    {
633        retry_until(
634            self.request_timeout(),
635            |err| matches!(err, OxiaError::ShardMoved),
636            op,
637        )
638        .await
639    }
640
641    /// Returns (creating on first use) the write batcher for a shard.
642    fn write_batcher(&self, shard_id: i64) -> Arc<WriteBatcher> {
643        self.inner
644            .write_batchers
645            .entry(shard_id)
646            .or_insert_with(|| {
647                Arc::new(WriteBatcher::new(shard_id, self.inner.batcher_deps.clone()))
648            })
649            .clone()
650    }
651
652    /// Returns (creating on first use) the read batcher for a shard.
653    fn read_batcher(&self, shard_id: i64) -> Arc<ReadBatcher> {
654        self.inner
655            .read_batchers
656            .entry(shard_id)
657            .or_insert_with(|| {
658                Arc::new(ReadBatcher::new(shard_id, self.inner.batcher_deps.clone()))
659            })
660            .clone()
661    }
662
663    /// Submits a write to a shard's batcher and awaits its response, bounded
664    /// by the request timeout.
665    pub(crate) async fn submit_write<Req, Resp>(
666        &self,
667        shard_id: i64,
668        request: Req,
669        wrap: fn(Pending<Req, Resp>) -> WriteOp,
670    ) -> Result<Resp, OxiaError> {
671        let (op, rx) = pending_write(request, wrap);
672        self.write_batcher(shard_id).add(op);
673        self.await_response(rx).await
674    }
675
676    /// Submits a get to a shard's batcher and awaits its response, bounded by
677    /// the request timeout.
678    pub(crate) async fn submit_get(
679        &self,
680        shard_id: i64,
681        request: proto::GetRequest,
682    ) -> Result<proto::GetResponse, OxiaError> {
683        let (pending, rx) = Pending::new(request);
684        self.read_batcher(shard_id).add(pending);
685        self.await_response(rx).await
686    }
687
688    pub(crate) async fn await_response<Resp>(
689        &self,
690        rx: oneshot::Receiver<Result<Resp, OxiaError>>,
691    ) -> Result<Resp, OxiaError> {
692        match tokio::time::timeout(self.request_timeout(), rx).await {
693            Ok(Ok(result)) => result,
694            Ok(Err(_)) => {
695                if self.inner.closed.load(Ordering::Acquire) {
696                    Err(OxiaError::Closed)
697                } else {
698                    Err(OxiaError::Disconnected(
699                        "operation was dropped without a response".to_string(),
700                    ))
701                }
702            }
703            Err(_) => Err(OxiaError::Timeout),
704        }
705    }
706
707    /// Runs a get against every shard and reduces the per-shard winners
708    /// according to the comparison type. Used only when the query is not pinned
709    /// to a single shard — i.e. an inequality comparison or a secondary-index
710    /// lookup with no partition key; an exact-match primary-key get is routed
711    /// directly to its owning shard instead.
712    pub(crate) async fn broadcast_get(
713        &self,
714        request: proto::GetRequest,
715        comparison: ComparisonType,
716    ) -> Result<GetResult, OxiaError> {
717        // Re-issue against the current shard set if a shard splits mid-flight.
718        self.with_reroute_retry(|| {
719            let request = request.clone();
720            async move {
721                let mut join_set = JoinSet::new();
722                for shard in self.inner.shard_manager.get_shard_ids() {
723                    let client = self.clone();
724                    let request = request.clone();
725                    join_set.spawn(async move {
726                        let requested_key = request.key.clone();
727                        let response = client.submit_get(shard, request).await?;
728                        check_status(response.status)?;
729                        GetResult::from_proto(response, Some(requested_key))
730                    });
731                }
732                let mut best: Option<GetResult> = None;
733                let mut join_error: Option<OxiaError> = None;
734                while let Some(joined) = join_set.join_next().await {
735                    let result = match joined {
736                        Ok(result) => result,
737                        Err(err) => {
738                            join_error = Some(OxiaError::Disconnected(err.to_string()));
739                            continue;
740                        }
741                    };
742                    match result {
743                        Ok(candidate) => {
744                            best = Some(match best.take() {
745                                None => candidate,
746                                Some(current) => pick_get_winner(current, candidate, comparison),
747                            })
748                        }
749                        // Not every shard has a matching record.
750                        Err(OxiaError::KeyNotFound) => {}
751                        Err(err) => return Err(err),
752                    }
753                }
754                if let Some(err) = join_error {
755                    return Err(err);
756                }
757                best.ok_or(OxiaError::KeyNotFound)
758            }
759        })
760        .await
761    }
762
763    /// Resolves the target shards for a list/scan: either the single shard
764    /// owning `partition_key`, or all shards.
765    fn target_shards(&self, partition_key: Option<&str>) -> Result<Vec<i64>, OxiaError> {
766        match partition_key {
767            Some(pk) => Ok(vec![self.shard_for(pk)?]),
768            None => Ok(self.inner.shard_manager.get_shard_ids()),
769        }
770    }
771
772    pub(crate) async fn open_list_stream(
773        &self,
774        request: proto::ListRequest,
775        partition_key: Option<&str>,
776    ) -> Result<ListStream, OxiaError> {
777        self.ensure_open()?;
778        let opens = self.target_shards(partition_key)?.into_iter().map(|shard| {
779            let client = self.clone();
780            let request = request.clone();
781            async move {
782                let (first, streaming) = client
783                    .open_streaming_with_retry(shard, move |mut provider| {
784                        let mut request = request.clone();
785                        request.shard = Some(shard);
786                        async move { provider.list(request).await }
787                    })
788                    .await?;
789                let head = first
790                    .map(|response: proto::ListResponse| {
791                        response.keys.into_iter().map(Ok).collect::<Vec<_>>()
792                    })
793                    .unwrap_or_default();
794                let stream: ShardStream<String> = Box::pin(
795                    futures::stream::iter(head).chain(
796                        streaming
797                            .map(|chunk| match chunk {
798                                Ok(response) => response.keys.into_iter().map(Ok).collect(),
799                                Err(status) => vec![Err(OxiaError::from(status))],
800                            })
801                            .flat_map(futures::stream::iter),
802                    ),
803                );
804                Ok::<_, OxiaError>(stream)
805            }
806        });
807        Ok(ListStream::new(merged(try_join_all(opens).await?)))
808    }
809
810    pub(crate) async fn open_range_scan_stream(
811        &self,
812        request: proto::RangeScanRequest,
813        partition_key: Option<&str>,
814    ) -> Result<RangeScanStream, OxiaError> {
815        self.ensure_open()?;
816        let opens = self.target_shards(partition_key)?.into_iter().map(|shard| {
817            let client = self.clone();
818            let request = request.clone();
819            async move {
820                let (first, streaming) = client
821                    .open_streaming_with_retry(shard, move |mut provider| {
822                        let mut request = request.clone();
823                        request.shard = Some(shard);
824                        async move { provider.range_scan(request).await }
825                    })
826                    .await?;
827                let to_items = |response: proto::RangeScanResponse| {
828                    response
829                        .records
830                        .into_iter()
831                        .map(|record| GetResult::from_proto(record, None))
832                        .collect::<Vec<_>>()
833                };
834                let head = first.map(to_items).unwrap_or_default();
835                let stream: ShardStream<GetResult> = Box::pin(
836                    futures::stream::iter(head).chain(
837                        streaming
838                            .map(move |chunk| match chunk {
839                                Ok(response) => to_items(response),
840                                Err(status) => vec![Err(OxiaError::from(status))],
841                            })
842                            .flat_map(futures::stream::iter),
843                    ),
844                );
845                Ok::<_, OxiaError>(stream)
846            }
847        });
848        Ok(RangeScanStream::new(merged(try_join_all(opens).await?)))
849    }
850
851    /// Opens a per-shard server stream, retrying — with backoff and leader
852    /// hints, bounded by the request timeout — until the first response (or a
853    /// clean end of stream) has been received. Past that point the caller
854    /// consumes the stream without retries, so results are never duplicated
855    /// (the reference client's "verified" pattern).
856    async fn open_streaming_with_retry<Resp, Fut>(
857        &self,
858        shard: i64,
859        call: impl Fn(proto::oxia_client_client::OxiaClientClient<tonic::transport::Channel>) -> Fut,
860    ) -> Result<(Option<Resp>, tonic::Streaming<Resp>), OxiaError>
861    where
862        Fut: Future<Output = Result<tonic::Response<tonic::Streaming<Resp>>, tonic::Status>>,
863    {
864        let deadline = tokio::time::Instant::now() + self.request_timeout();
865        let mut hint: Option<LeaderHint> = None;
866        let mut attempt: u32 = 0;
867        loop {
868            let outcome: Result<_, DecodedStatus> = async {
869                let target = match hint.as_ref().and_then(|h| h.address_for(shard)) {
870                    Some(address) => crate::address::ensure_protocol(address.to_string()),
871                    None => {
872                        self.inner
873                            .shard_manager
874                            .get_leader(shard)
875                            .ok_or_else(|| {
876                                DecodedStatus::from(OxiaError::LeaderNotFound { shard })
877                            })?
878                            .service_address
879                    }
880                };
881                let provider = self
882                    .inner
883                    .provider_manager
884                    .get_provider(target)
885                    .await
886                    .map_err(DecodedStatus::from)?;
887                let mut streaming = call(provider).await.map_err(decode_status)?.into_inner();
888                match streaming.next().await {
889                    Some(Ok(first)) => Ok((Some(first), streaming)),
890                    None => Ok((None, streaming)),
891                    Some(Err(status)) => Err(decode_status(status)),
892                }
893            }
894            .await;
895            match outcome {
896                Ok(opened) => return Ok(opened),
897                Err(decoded) => {
898                    if decoded.leader_hint.is_some() {
899                        hint = decoded.leader_hint;
900                    }
901                    let delay = retry_delay(attempt);
902                    attempt += 1;
903                    if !decoded.error.is_retryable()
904                        || tokio::time::Instant::now() + delay >= deadline
905                    {
906                        return Err(decoded.error);
907                    }
908                    tokio::time::sleep(delay).await;
909                }
910            }
911        }
912    }
913
914    pub(crate) async fn open_notifications(
915        &self,
916        buffer_size: usize,
917    ) -> Result<Notifications, OxiaError> {
918        self.ensure_open()?;
919        let shards = self.inner.shard_manager.get_shard_ids();
920        let shard_count = shards.len();
921        let (tx, rx) = mpsc::channel(buffer_size);
922        let (init_tx, mut init_rx) = mpsc::channel(shard_count.max(1));
923        let manager = NotificationManager::new(
924            shards,
925            self.inner.shard_manager.clone(),
926            self.inner.provider_manager.clone(),
927            tx,
928            init_tx,
929        );
930        // Make the subscription live before returning, so no change in the
931        // window between here and the first stream is missed. Returning early
932        // (on error/timeout) drops `manager`, cancelling every listener.
933        let deadline = tokio::time::Instant::now() + self.request_timeout();
934        let mut ready = 0;
935        while ready < shard_count {
936            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
937            match tokio::time::timeout(remaining, init_rx.recv()).await {
938                Ok(Some(Ok(()))) => ready += 1,
939                Ok(Some(Err(err))) => return Err(err),
940                Ok(None) => break,
941                Err(_) => return Err(OxiaError::Timeout),
942            }
943        }
944        let id = self.register_notification_manager(manager);
945        Ok(Notifications::new(
946            rx,
947            self.subscription_guard(id, SubscriptionKind::Notification),
948        ))
949    }
950
951    pub(crate) async fn open_sequence_updates(
952        &self,
953        key: String,
954        partition_key: String,
955        buffer_size: usize,
956    ) -> Result<SequenceUpdates, OxiaError> {
957        self.ensure_open()?;
958        let (tx, rx) = mpsc::channel(buffer_size);
959        let manager = SequenceUpdatesManager::new(
960            key,
961            partition_key,
962            self.inner.shard_manager.clone(),
963            self.inner.provider_manager.clone(),
964            tx,
965        );
966        let id = self
967            .inner
968            .next_subscription_id
969            .fetch_add(1, Ordering::Relaxed);
970        self.inner.sequence_managers.insert(id, manager);
971        Ok(SequenceUpdates::new(
972            rx,
973            self.subscription_guard(id, SubscriptionKind::Sequence),
974        ))
975    }
976
977    /// Registers a live notification manager so [`close`](Self::close) can shut
978    /// it down, returning the id used to remove it when its handle is dropped.
979    fn register_notification_manager(&self, manager: NotificationManager) -> u64 {
980        let id = self
981            .inner
982            .next_subscription_id
983            .fetch_add(1, Ordering::Relaxed);
984        self.inner.notification_managers.insert(id, manager);
985        id
986    }
987
988    /// Builds the guard a subscription handle holds; dropping the handle removes
989    /// (and thereby stops) the registered manager.
990    fn subscription_guard(&self, id: u64, kind: SubscriptionKind) -> SubscriptionGuard {
991        SubscriptionGuard {
992            inner: Arc::downgrade(&self.inner),
993            id,
994            kind,
995        }
996    }
997
998    /// Broadcasts a delete-range to every shard, re-issuing against the current
999    /// shard set if one splits or merges mid-flight (idempotent).
1000    pub(crate) async fn broadcast_delete_range(
1001        &self,
1002        request: proto::DeleteRangeRequest,
1003    ) -> Result<(), OxiaError> {
1004        self.with_reroute_retry(|| {
1005            let request = request.clone();
1006            async move {
1007                let mut join_set = JoinSet::new();
1008                for shard in self.inner.shard_manager.get_shard_ids() {
1009                    let client = self.clone();
1010                    let request = request.clone();
1011                    join_set.spawn(async move {
1012                        let response = client
1013                            .submit_write(shard, request, WriteOp::DeleteRange)
1014                            .await?;
1015                        check_status(response.status)
1016                    });
1017                }
1018                while let Some(joined) = join_set.join_next().await {
1019                    joined.map_err(|err| OxiaError::Disconnected(err.to_string()))??;
1020                }
1021                Ok(())
1022            }
1023        })
1024        .await
1025    }
1026}
1027
1028fn track(first_err: &mut Option<OxiaError>, result: Result<(), OxiaError>, what: &str) {
1029    if let Err(err) = result {
1030        warn!("error while closing {what}: {err}");
1031        first_err.get_or_insert(err);
1032    }
1033}
1034
1035/// Reduces two per-shard get results to the winner for the comparison type.
1036fn pick_get_winner(a: GetResult, b: GetResult, comparison: ComparisonType) -> GetResult {
1037    let a_wins = match comparison {
1038        // The smallest matching key wins.
1039        ComparisonType::Equal | ComparisonType::Ceiling | ComparisonType::Higher => {
1040            a.compare_keys(&b).is_le()
1041        }
1042        // The largest matching key wins.
1043        ComparisonType::Floor | ComparisonType::Lower => a.compare_keys(&b).is_ge(),
1044    };
1045    if a_wins { a } else { b }
1046}
1047
1048/// Maps a wire-level status to the domain error.
1049pub(crate) fn check_status(status: i32) -> Result<(), OxiaError> {
1050    match proto::Status::try_from(status) {
1051        Ok(proto::Status::Ok) => Ok(()),
1052        Ok(proto::Status::KeyNotFound) => Err(OxiaError::KeyNotFound),
1053        Ok(proto::Status::UnexpectedVersionId) => Err(OxiaError::UnexpectedVersionId),
1054        Ok(proto::Status::SessionDoesNotExist) => Err(OxiaError::SessionExpired),
1055        Err(err) => Err(OxiaError::Decode(format!("invalid status code: {err}"))),
1056    }
1057}