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}