use crate::address::ensure_protocol;
use crate::errors::OxiaError;
use crate::operations::{Pending, PendingDelete, PendingDeleteRange, PendingGet, PendingPut};
use crate::proto;
use crate::provider_manager::ProviderManager;
use crate::retry::retry_delay;
use crate::server_error::{DecodedStatus, LeaderHint, decode_status};
use crate::shard_manager::ShardManager;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::mpsc::{self, UnboundedReceiver};
use tokio::sync::{Notify, oneshot};
use tonic::Streaming;
use tonic::codegen::tokio_stream::StreamExt;
use tonic::codegen::tokio_stream::wrappers::UnboundedReceiverStream;
use tracing::{debug, warn};
const WRITE_STREAM_HEADER_NAMESPACE: &str = "namespace";
const WRITE_STREAM_HEADER_SHARD_ID: &str = "shard-id";
pub(crate) struct BatcherDeps {
pub(crate) namespace: String,
pub(crate) shard_manager: Arc<ShardManager>,
pub(crate) provider_manager: Arc<ProviderManager>,
pub(crate) max_batch_size: usize,
pub(crate) max_requests_per_batch: usize,
pub(crate) max_write_batches_in_flight: usize,
pub(crate) max_read_batches_in_flight: usize,
pub(crate) request_timeout: Duration,
}
pub(crate) enum WriteOp {
Put(PendingPut),
Delete(PendingDelete),
DeleteRange(PendingDeleteRange),
}
impl WriteOp {
fn size(&self) -> usize {
match self {
WriteOp::Put(p) => p.request.key.len() + p.request.value.len(),
WriteOp::Delete(d) => d.request.key.len(),
WriteOp::DeleteRange(r) => {
r.request.start_inclusive.len() + r.request.end_exclusive.len()
}
}
}
fn fail(self, err: OxiaError) {
match self {
WriteOp::Put(p) => {
let _ = p.callback.send(Err(err));
}
WriteOp::Delete(d) => {
let _ = d.callback.send(Err(err));
}
WriteOp::DeleteRange(r) => {
let _ = r.callback.send(Err(err));
}
}
}
}
struct Queues<B> {
open: Option<B>,
ready: VecDeque<B>,
closed: bool,
flush_scheduled: bool,
}
impl<B> Default for Queues<B> {
fn default() -> Self {
Queues {
open: None,
ready: VecDeque::new(),
closed: false,
flush_scheduled: false,
}
}
}
impl<B> Queues<B> {
fn take_next(&mut self) -> Option<B> {
self.ready.pop_front().or_else(|| self.open.take())
}
fn is_drained(&self) -> bool {
self.open.is_none() && self.ready.is_empty()
}
}
fn fail_all<Resp>(
callbacks: impl IntoIterator<Item = oneshot::Sender<Result<Resp, OxiaError>>>,
err: &OxiaError,
) {
for callback in callbacks {
let _ = callback.send(Err(err.clone()));
}
}
fn complete_all<Resp>(
callbacks: impl IntoIterator<Item = oneshot::Sender<Result<Resp, OxiaError>>>,
responses: impl IntoIterator<Item = Resp>,
what: &str,
) {
let mut responses = responses.into_iter();
for callback in callbacks {
match responses.next() {
Some(response) => {
let _ = callback.send(Ok(response));
}
None => {
let _ = callback.send(Err(OxiaError::Decode(format!(
"missing {what} response from server"
))));
}
}
}
if responses.next().is_some() {
warn!("server returned more {what} responses than requested");
}
}
#[derive(Default)]
struct WriteBody {
puts: Vec<PendingPut>,
deletes: Vec<PendingDelete>,
delete_ranges: Vec<PendingDeleteRange>,
count: usize,
bytes: usize,
}
impl WriteBody {
fn push(&mut self, op: WriteOp, size: usize) {
match op {
WriteOp::Put(p) => self.puts.push(p),
WriteOp::Delete(d) => self.deletes.push(d),
WriteOp::DeleteRange(r) => self.delete_ranges.push(r),
}
self.count += 1;
self.bytes += size;
}
fn is_empty(&self) -> bool {
self.count == 0
}
fn into_request(self, shard: i64) -> (proto::WriteRequest, WriteCallbacks) {
let (put_reqs, put_cbs): (Vec<_>, Vec<_>) = self
.puts
.into_iter()
.map(|p| (p.request, p.callback))
.unzip();
let (delete_reqs, delete_cbs): (Vec<_>, Vec<_>) = self
.deletes
.into_iter()
.map(|p| (p.request, p.callback))
.unzip();
let (dr_reqs, dr_cbs): (Vec<_>, Vec<_>) = self
.delete_ranges
.into_iter()
.map(|p| (p.request, p.callback))
.unzip();
(
proto::WriteRequest {
shard: Some(shard),
puts: put_reqs,
deletes: delete_reqs,
delete_ranges: dr_reqs,
},
WriteCallbacks {
puts: put_cbs,
deletes: delete_cbs,
delete_ranges: dr_cbs,
},
)
}
}
struct WriteCallbacks {
puts: Vec<oneshot::Sender<Result<proto::PutResponse, OxiaError>>>,
deletes: Vec<oneshot::Sender<Result<proto::DeleteResponse, OxiaError>>>,
delete_ranges: Vec<oneshot::Sender<Result<proto::DeleteRangeResponse, OxiaError>>>,
}
impl WriteCallbacks {
fn complete(self, response: proto::WriteResponse) {
complete_all(self.puts, response.puts, "put");
complete_all(self.deletes, response.deletes, "delete");
complete_all(self.delete_ranges, response.delete_ranges, "delete_range");
}
fn fail(self, err: &OxiaError) {
fail_all(self.puts, err);
fail_all(self.deletes, err);
fail_all(self.delete_ranges, err);
}
}
struct InFlightWrite {
request: proto::WriteRequest,
callbacks: WriteCallbacks,
first_dispatch: Instant,
}
struct WriteState {
queues: Queues<WriteBody>,
pending: VecDeque<InFlightWrite>,
retry: VecDeque<InFlightWrite>,
hint: Option<LeaderHint>,
reconnecting: bool,
failure_streak: u32,
stream_tx: Option<mpsc::UnboundedSender<proto::WriteRequest>>,
parked: Option<Streaming<proto::WriteResponse>>,
pump_running: bool,
}
fn absent_shard_error(deps: &BatcherDeps, shard: i64) -> OxiaError {
if deps.shard_manager.shard_exists(shard) {
OxiaError::LeaderNotFound { shard }
} else {
OxiaError::ShardMoved
}
}
pub(crate) struct WriteBatcher {
shard: i64,
deps: Arc<BatcherDeps>,
state: Mutex<WriteState>,
drained: Notify,
}
impl WriteBatcher {
pub(crate) fn new(shard: i64, deps: Arc<BatcherDeps>) -> Self {
WriteBatcher {
shard,
deps,
state: Mutex::new(WriteState {
queues: Queues::default(),
pending: VecDeque::new(),
retry: VecDeque::new(),
hint: None,
reconnecting: false,
failure_streak: 0,
stream_tx: None,
parked: None,
pump_running: false,
}),
drained: Notify::new(),
}
}
pub(crate) fn add(self: &Arc<Self>, op: WriteOp) {
let size = op.size();
if size > self.deps.max_batch_size {
op.fail(OxiaError::RequestTooLarge);
return;
}
let mut spawn_flusher = false;
let rejected = {
let mut st = self.state.lock().expect("write batcher poisoned");
if st.queues.closed {
Some(op)
} else {
let mut open = st.queues.open.take().unwrap_or_default();
if !open.is_empty() && open.bytes + size > self.deps.max_batch_size {
if self.can_dispatch(&st) {
self.dispatch_locked(&mut st, open);
} else {
st.queues.ready.push_back(open);
}
open = WriteBody::default();
}
open.push(op, size);
if open.count >= self.deps.max_requests_per_batch {
if self.can_dispatch(&st) {
self.dispatch_locked(&mut st, open);
} else {
st.queues.ready.push_back(open);
}
} else {
st.queues.open = Some(open);
if self.can_dispatch(&st) && !st.queues.flush_scheduled {
st.queues.flush_scheduled = true;
spawn_flusher = true;
}
}
None
}
};
if let Some(op) = rejected {
op.fail(OxiaError::Closed);
return;
}
if spawn_flusher {
let this = self.clone();
tokio::spawn(async move {
tokio::task::yield_now().await;
this.flush_open();
});
}
}
fn flush_open(self: &Arc<Self>) {
let mut st = self.state.lock().expect("write batcher poisoned");
st.queues.flush_scheduled = false;
self.fill_capacity(&mut st);
}
fn can_dispatch(&self, st: &WriteState) -> bool {
!st.reconnecting && st.pending.len() < self.deps.max_write_batches_in_flight
}
fn fill_capacity(self: &Arc<Self>, st: &mut WriteState) {
while self.can_dispatch(st) {
if let Some(inflight) = st.retry.pop_front() {
if inflight.first_dispatch.elapsed() >= self.deps.request_timeout {
inflight.callbacks.fail(&OxiaError::Timeout);
continue;
}
self.send_locked(st, inflight);
continue;
}
match st.queues.take_next() {
Some(body) => self.dispatch_locked(st, body),
None => break,
}
}
}
fn dispatch_locked(self: &Arc<Self>, st: &mut WriteState, body: WriteBody) {
let (request, callbacks) = body.into_request(self.shard);
self.send_locked(
st,
InFlightWrite {
request,
callbacks,
first_dispatch: Instant::now(),
},
);
}
fn send_locked(self: &Arc<Self>, st: &mut WriteState, inflight: InFlightWrite) {
let request = inflight.request.clone();
st.pending.push_back(inflight);
if st.stream_tx.is_none() {
debug!(shard = self.shard, "creating write stream");
let (tx, rx) = mpsc::unbounded_channel();
st.stream_tx = Some(tx);
st.parked = None;
st.pump_running = true;
let this = self.clone();
tokio::spawn(async move { this.run_pump(PumpInit::Connect(rx)).await });
} else if !st.pump_running {
let streaming = st
.parked
.take()
.expect("idle write stream must have a parked response stream");
st.pump_running = true;
debug!(shard = self.shard, "resuming write-stream pump");
let this = self.clone();
tokio::spawn(async move { this.run_pump(PumpInit::Resume(Box::new(streaming))).await });
}
if let Some(tx) = &st.stream_tx {
let _ = tx.send(request);
}
}
async fn run_pump(self: Arc<Self>, init: PumpInit) {
let mut streaming = match init {
PumpInit::Resume(streaming) => *streaming,
PumpInit::Connect(rx) => match self.connect_stream(rx).await {
Ok(streaming) => streaming,
Err(err) => {
self.pump_died(err);
return;
}
},
};
loop {
let next = tokio::time::timeout(self.deps.request_timeout, streaming.next()).await;
let response = match next {
Err(_) => return self.pump_died(OxiaError::Timeout.into()),
Ok(None) => {
return self.pump_died(
OxiaError::Disconnected("write stream closed by server".to_string()).into(),
);
}
Ok(Some(Err(status))) => return self.pump_died(decode_status(status)),
Ok(Some(Ok(response))) => response,
};
let mut st = self.state.lock().expect("write batcher poisoned");
st.failure_streak = 0;
st.hint = None;
let Some(inflight) = st.pending.pop_front() else {
warn!(shard = self.shard, "unexpected write response; ignoring");
continue;
};
let callbacks = inflight.callbacks;
self.fill_capacity(&mut st);
if st.pending.is_empty() {
st.parked = Some(streaming);
st.pump_running = false;
let drained = st.queues.closed && self.is_drained(&st);
drop(st);
callbacks.complete(response);
if drained {
self.drained.notify_waiters();
}
return;
}
drop(st);
callbacks.complete(response);
}
}
async fn connect_stream(
&self,
rx: UnboundedReceiver<proto::WriteRequest>,
) -> Result<Streaming<proto::WriteResponse>, DecodedStatus> {
let hinted = {
let st = self.state.lock().expect("write batcher poisoned");
st.hint
.as_ref()
.and_then(|hint| hint.address_for(self.shard).map(str::to_string))
};
let target = match hinted {
Some(address) => ensure_protocol(address),
None => {
self.deps
.shard_manager
.get_leader(self.shard)
.ok_or_else(|| DecodedStatus::from(absent_shard_error(&self.deps, self.shard)))?
.service_address
}
};
let mut provider = self
.deps
.provider_manager
.get_provider(target)
.await
.map_err(DecodedStatus::from)?;
let mut request = tonic::Request::new(UnboundedReceiverStream::new(rx));
let metadata = request.metadata_mut();
metadata.insert(
WRITE_STREAM_HEADER_NAMESPACE,
self.deps.namespace.parse().map_err(|_| {
DecodedStatus::from(OxiaError::InvalidArgument("namespace".to_string()))
})?,
);
metadata.insert(
WRITE_STREAM_HEADER_SHARD_ID,
self.shard
.to_string()
.parse()
.expect("shard id is valid metadata"),
);
match provider.write_stream(request).await {
Ok(response) => Ok(response.into_inner()),
Err(status) => Err(decode_status(status)),
}
}
fn pump_died(self: &Arc<Self>, decoded: DecodedStatus) {
let DecodedStatus { error, leader_hint } = decoded;
warn!(shard = self.shard, error = %error, "write stream failed");
let (expired, failed, drained) = {
let mut st = self.state.lock().expect("write batcher poisoned");
st.pump_running = false;
st.stream_tx = None;
st.parked = None;
if let Some(hint) = leader_hint {
st.hint = Some(hint);
}
let mut expired: Vec<WriteCallbacks> = Vec::new();
let mut failed: Vec<WriteCallbacks> = Vec::new();
if error.is_retryable() && !matches!(error, OxiaError::ShardMoved) && !st.queues.closed
{
st.failure_streak += 1;
let mut still: VecDeque<InFlightWrite> = VecDeque::new();
for inflight in st.pending.drain(..) {
if inflight.first_dispatch.elapsed() >= self.deps.request_timeout {
expired.push(inflight.callbacks);
} else {
still.push_back(inflight);
}
}
still.append(&mut st.retry);
st.retry = still;
st.reconnecting = true;
let delay = retry_delay(st.failure_streak - 1);
let this = self.clone();
tokio::spawn(async move {
tokio::time::sleep(delay).await;
let mut st = this.state.lock().expect("write batcher poisoned");
st.reconnecting = false;
this.fill_capacity(&mut st);
});
} else {
failed = st.pending.drain(..).map(|i| i.callbacks).collect();
self.fill_capacity(&mut st);
}
let drained = st.queues.closed && self.is_drained(&st);
(expired, failed, drained)
};
for callbacks in expired {
callbacks.fail(&OxiaError::Timeout);
}
for callbacks in failed {
callbacks.fail(&error);
}
if drained {
self.drained.notify_waiters();
}
}
fn is_drained(&self, st: &WriteState) -> bool {
st.queues.is_drained() && st.pending.is_empty() && st.retry.is_empty()
}
pub(crate) async fn close(self: &Arc<Self>) -> Result<(), OxiaError> {
{
let mut st = self.state.lock().expect("write batcher poisoned");
st.queues.closed = true;
st.reconnecting = false;
while let Some(inflight) = st.retry.pop_front() {
self.send_locked(&mut st, inflight);
}
while let Some(body) = st.queues.take_next() {
self.dispatch_locked(&mut st, body);
}
}
let deadline = tokio::time::Instant::now() + self.deps.request_timeout;
loop {
let notified = self.drained.notified();
{
let st = self.state.lock().expect("write batcher poisoned");
if self.is_drained(&st) {
break;
}
}
if tokio::time::timeout_at(deadline, notified).await.is_err() {
let leftovers: Vec<WriteCallbacks> = {
let mut st = self.state.lock().expect("write batcher poisoned");
let mut leftovers: Vec<WriteCallbacks> =
st.pending.drain(..).map(|i| i.callbacks).collect();
leftovers.extend(st.retry.drain(..).map(|i| i.callbacks));
leftovers
};
for callbacks in leftovers {
callbacks.fail(&OxiaError::Closed);
}
break;
}
}
let mut st = self.state.lock().expect("write batcher poisoned");
st.stream_tx = None;
st.parked = None;
Ok(())
}
}
enum PumpInit {
Connect(UnboundedReceiver<proto::WriteRequest>),
Resume(Box<Streaming<proto::WriteResponse>>),
}
#[derive(Default)]
struct ReadBody {
gets: Vec<PendingGet>,
}
struct ReadState {
queues: Queues<ReadBody>,
in_flight: usize,
}
pub(crate) struct ReadBatcher {
shard: i64,
deps: Arc<BatcherDeps>,
state: Mutex<ReadState>,
drained: Notify,
}
impl ReadBatcher {
pub(crate) fn new(shard: i64, deps: Arc<BatcherDeps>) -> Self {
ReadBatcher {
shard,
deps,
state: Mutex::new(ReadState {
queues: Queues::default(),
in_flight: 0,
}),
drained: Notify::new(),
}
}
pub(crate) fn add(self: &Arc<Self>, get: PendingGet) {
let mut spawn_flusher = false;
let rejected = {
let mut st = self.state.lock().expect("read batcher poisoned");
if st.queues.closed {
Some(get)
} else {
let mut open = st.queues.open.take().unwrap_or_default();
open.gets.push(get);
if open.gets.len() >= self.deps.max_requests_per_batch {
if st.in_flight < self.deps.max_read_batches_in_flight {
self.dispatch_locked(&mut st, open);
} else {
st.queues.ready.push_back(open);
}
} else {
st.queues.open = Some(open);
if st.in_flight < self.deps.max_read_batches_in_flight
&& !st.queues.flush_scheduled
{
st.queues.flush_scheduled = true;
spawn_flusher = true;
}
}
None
}
};
if let Some(get) = rejected {
let _ = get.callback.send(Err(OxiaError::Closed));
return;
}
if spawn_flusher {
let this = self.clone();
tokio::spawn(async move {
tokio::task::yield_now().await;
this.flush_open();
});
}
}
fn flush_open(self: &Arc<Self>) {
let mut st = self.state.lock().expect("read batcher poisoned");
st.queues.flush_scheduled = false;
self.fill_capacity(&mut st);
}
fn fill_capacity(self: &Arc<Self>, st: &mut ReadState) {
while st.in_flight < self.deps.max_read_batches_in_flight {
match st.queues.take_next() {
Some(body) => self.dispatch_locked(st, body),
None => break,
}
}
}
fn dispatch_locked(self: &Arc<Self>, st: &mut ReadState, body: ReadBody) {
st.in_flight += 1;
let this = self.clone();
tokio::spawn(async move {
this.send_batch(body).await;
let drained = {
let mut st = this.state.lock().expect("read batcher poisoned");
st.in_flight -= 1;
this.fill_capacity(&mut st);
st.queues.closed && st.queues.is_drained() && st.in_flight == 0
};
if drained {
this.drained.notify_waiters();
}
});
}
async fn send_batch(&self, body: ReadBody) {
let (requests, callbacks): (Vec<_>, Vec<_>) = body
.gets
.into_iter()
.map(|p| (p.request, p.callback))
.unzip();
let deadline = Instant::now() + self.deps.request_timeout;
let mut hint: Option<LeaderHint> = None;
let mut attempt: u32 = 0;
loop {
match self.attempt_read(&requests, hint.as_ref()).await {
Ok(responses) => {
complete_all(callbacks, responses, "get");
return;
}
Err(decoded) => {
if decoded.leader_hint.is_some() {
hint = decoded.leader_hint;
}
let delay = retry_delay(attempt);
attempt += 1;
if !decoded.error.is_retryable()
|| matches!(decoded.error, OxiaError::ShardMoved)
|| Instant::now() + delay >= deadline
{
fail_all(callbacks, &decoded.error);
return;
}
warn!(
shard = self.shard,
error = %decoded.error,
"read batch failed, retrying in {delay:?}"
);
tokio::time::sleep(delay).await;
}
}
}
}
async fn attempt_read(
&self,
requests: &[proto::GetRequest],
hint: Option<&LeaderHint>,
) -> Result<Vec<proto::GetResponse>, DecodedStatus> {
let target = match hint.and_then(|h| h.address_for(self.shard)) {
Some(address) => ensure_protocol(address.to_string()),
None => {
self.deps
.shard_manager
.get_leader(self.shard)
.ok_or_else(|| DecodedStatus::from(absent_shard_error(&self.deps, self.shard)))?
.service_address
}
};
let mut provider = self
.deps
.provider_manager
.get_provider(target)
.await
.map_err(DecodedStatus::from)?;
let mut request = tonic::Request::new(proto::ReadRequest {
shard: Some(self.shard),
gets: requests.to_vec(),
});
request.set_timeout(self.deps.request_timeout);
let mut streaming = match provider.read(request).await {
Ok(response) => response.into_inner(),
Err(status) => return Err(decode_status(status)),
};
let mut responses = Vec::with_capacity(requests.len());
while let Some(next) = streaming.next().await {
match next {
Ok(read_response) => responses.extend(read_response.gets),
Err(status) => return Err(decode_status(status)),
}
}
Ok(responses)
}
pub(crate) async fn close(self: &Arc<Self>) -> Result<(), OxiaError> {
{
let mut st = self.state.lock().expect("read batcher poisoned");
st.queues.closed = true;
while let Some(body) = st.queues.take_next() {
self.dispatch_locked(&mut st, body);
}
}
let deadline = tokio::time::Instant::now() + self.deps.request_timeout;
loop {
let notified = self.drained.notified();
{
let st = self.state.lock().expect("read batcher poisoned");
if st.queues.is_drained() && st.in_flight == 0 {
break;
}
}
if tokio::time::timeout_at(deadline, notified).await.is_err() {
debug!(shard = self.shard, "read batcher close timed out");
break;
}
}
Ok(())
}
}
pub(crate) fn pending_write<Req, Resp>(
request: Req,
wrap: fn(Pending<Req, Resp>) -> WriteOp,
) -> (WriteOp, oneshot::Receiver<Result<Resp, OxiaError>>) {
let (pending, rx) = Pending::new(request);
(wrap(pending), rx)
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
fn put_op(
key: &str,
value: &[u8],
) -> (
WriteOp,
oneshot::Receiver<Result<proto::PutResponse, OxiaError>>,
) {
let request = proto::PutRequest {
key: key.to_string(),
value: Bytes::copy_from_slice(value),
..Default::default()
};
pending_write(request, WriteOp::Put)
}
#[test]
fn write_op_size_estimates() {
let (op, _rx) = put_op("key", b"value");
assert_eq!(op.size(), 3 + 5);
let (delete, _rx) = pending_write(
proto::DeleteRequest {
key: "abcd".to_string(),
..Default::default()
},
WriteOp::Delete,
);
assert_eq!(delete.size(), 4);
let (dr, _rx) = pending_write(
proto::DeleteRangeRequest {
start_inclusive: "ab".to_string(),
end_exclusive: "abc".to_string(),
},
WriteOp::DeleteRange,
);
assert_eq!(dr.size(), 5);
}
#[test]
fn queues_dispatch_order_is_sealed_then_open() {
let mut queues: Queues<i32> = Queues::default();
queues.ready.push_back(1);
queues.ready.push_back(2);
queues.open = Some(3);
assert_eq!(queues.take_next(), Some(1));
assert_eq!(queues.take_next(), Some(2));
assert_eq!(queues.take_next(), Some(3));
assert_eq!(queues.take_next(), None);
assert!(queues.is_drained());
}
#[test]
fn write_body_accounting() {
let mut body = WriteBody::default();
let (op, _rx) = put_op("k", b"0123456789");
let size = op.size();
body.push(op, size);
assert_eq!(body.count, 1);
assert_eq!(body.bytes, 11);
let (request, callbacks) = body.into_request(7);
assert_eq!(request.shard, Some(7));
assert_eq!(request.puts.len(), 1);
assert_eq!(callbacks.puts.len(), 1);
}
fn test_deps(max_batch_size: usize) -> Arc<BatcherDeps> {
Arc::new(BatcherDeps {
namespace: "default".to_string(),
shard_manager: ShardManager::detached_for_tests(),
provider_manager: Arc::new(ProviderManager::new(Duration::from_secs(1))),
max_batch_size,
max_requests_per_batch: 1000,
max_write_batches_in_flight: 4,
max_read_batches_in_flight: 4,
request_timeout: Duration::from_secs(1),
})
}
#[tokio::test]
async fn oversized_write_is_rejected_before_batching() {
let batcher = Arc::new(WriteBatcher::new(0, test_deps(16)));
let (op, rx) = put_op("key", &[0u8; 64]);
batcher.add(op);
let err = rx.await.expect("callback must fire").unwrap_err();
assert!(matches!(err, OxiaError::RequestTooLarge));
let st = batcher.state.lock().unwrap();
assert!(st.queues.is_drained());
assert!(st.pending.is_empty());
}
#[tokio::test]
async fn writes_after_close_fail_with_closed() {
let batcher = Arc::new(WriteBatcher::new(0, test_deps(1024)));
batcher.close().await.unwrap();
let (op, rx) = put_op("key", b"value");
batcher.add(op);
let err = rx.await.expect("callback must fire").unwrap_err();
assert!(matches!(err, OxiaError::Closed));
}
#[tokio::test]
async fn reads_after_close_fail_with_closed() {
let batcher = Arc::new(ReadBatcher::new(0, test_deps(1024)));
batcher.close().await.unwrap();
let (pending, rx) = Pending::new(proto::GetRequest::default());
batcher.add(pending);
let err = rx.await.expect("callback must fire").unwrap_err();
assert!(matches!(err, OxiaError::Closed));
}
}