use connectrpc::client::{ClientConfig, ClientTransport};
use polyc_proto::proto::polychrome::state::v1 as pb;
use polyc_state::{
error::StateError,
id::{CommandId, PartitionId},
journal::{
self, CommitJournalBatch, CreateJournalDirectorySnapshot, DestroyPartition,
ExcisePartitionRecords, GetJournalHead, GetJournalProof, GetJournalRoot, GetJournalSource,
GetPartitionLastModified, JournalAttestation, JournalDirectoryPage,
JournalDirectorySnapshot, JournalProof, JournalRange, JournalSourceHead,
ListJournalDirectorySnapshot, LogicalJournalHead, PartitionLastModified, QuarantinedRecord,
ReadJournalRange, ReleaseJournalDirectorySnapshot, RepairOutcome, RepairPartition,
},
receipt::Receipt,
};
use crate::{
MAX_JOURNAL_WIRE_MESSAGE_BYTES,
error::{TransportFallback, from_connect_error},
journal::verify::{PartitionVerification, VerifyPartitionReplay},
trace::{bounded_traced_options, bounded_traced_options_from_headers},
wire::{DeclaredCall, Kernel},
};
pub struct JournalClient<T> {
inner: pb::StateJournalServiceClient<T>,
}
impl<T> JournalClient<T>
where
T: ClientTransport,
<T::ResponseBody as connectrpc::http_body::Body>::Error: std::fmt::Display,
{
pub fn new(transport: T, config: ClientConfig) -> Self {
Self {
inner: pb::StateJournalServiceClient::new(
transport,
config.with_default_max_message_size(MAX_JOURNAL_WIRE_MESSAGE_BYTES),
),
}
}
fn fallback(attempted_bytes: usize) -> TransportFallback {
TransportFallback::new(
journal::family(),
MAX_JOURNAL_WIRE_MESSAGE_BYTES as u64,
attempted_bytes as u64,
)
}
pub async fn commit(
&self,
declared: &DeclaredCall,
batch: &CommitJournalBatch,
) -> Result<Receipt, StateError> {
let (request, attempted) = Self::commit_request(declared, batch);
let reply = self
.inner
.commit_journal_batch_with_options(request, bounded_traced_options(declared))
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?
.into_owned();
receipt_of(reply.receipt)
}
pub async fn commit_with_trace_headers(
&self,
declared: &DeclaredCall,
batch: &CommitJournalBatch,
headers: http::HeaderMap,
) -> Result<Receipt, StateError> {
let (request, attempted) = Self::commit_request(declared, batch);
let reply = self
.inner
.commit_journal_batch_with_options(
request,
bounded_traced_options_from_headers(declared, headers),
)
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?
.into_owned();
receipt_of(reply.receipt)
}
fn commit_request(
declared: &DeclaredCall,
batch: &CommitJournalBatch,
) -> (pb::CommitJournalBatchRequest, usize) {
let request = pb::CommitJournalBatchRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
batch: buffa::MessageField::some(Kernel(batch).into()),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
(request, attempted)
}
pub async fn receipt(
&self,
declared: &DeclaredCall,
partition: &PartitionId,
command_id: &CommandId,
) -> Result<Option<Receipt>, StateError> {
let request = pb::GetJournalReceiptRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
command_id: command_id.as_str().to_owned(),
partition: partition.as_str().to_owned(),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
let reply = self
.inner
.get_journal_receipt_with_options(request, bounded_traced_options(declared))
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?
.into_owned();
reply
.receipt
.into_option()
.map(|receipt| Kernel::<Receipt>::try_from(receipt).map(Kernel::into_inner))
.transpose()
}
pub async fn read_range(
&self,
declared: &DeclaredCall,
range: &ReadJournalRange,
) -> Result<JournalRange, StateError> {
let request = pb::ReadJournalRangeRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
range: buffa::MessageField::some(Kernel(range).into()),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
let reply = self
.inner
.read_journal_range_with_options(request, bounded_traced_options(declared))
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?
.into_owned();
let decoded = Kernel::<JournalRange>::try_from(
reply
.range
.into_option()
.ok_or_else(|| missing("range", "a successful read carries its range"))?,
)?
.into_inner();
if !decoded.answers(range) {
return Err(StateError::Malformed {
field: "range".to_owned(),
reason: "the reply does not answer the bounded read that was sent".to_owned(),
});
}
Ok(decoded)
}
pub async fn head(
&self,
declared: &DeclaredCall,
request: &GetJournalHead,
) -> Result<LogicalJournalHead, StateError> {
let request = pb::GetJournalHeadRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
partition: String::from(Kernel(request)),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
let reply = self
.inner
.get_journal_head_with_options(request, bounded_traced_options(declared))
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?
.into_owned();
Ok(Kernel::<LogicalJournalHead>::try_from(
reply
.head
.into_option()
.ok_or_else(|| missing("head", "a successful read carries its head"))?,
)?
.into_inner())
}
pub async fn source_head(
&self,
declared: &DeclaredCall,
request: &GetJournalSource,
) -> Result<Option<JournalSourceHead>, StateError> {
let expected = request.partition().clone();
let request = pb::GetJournalSourceRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
partition: String::from(Kernel(request)),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
let reply = self
.inner
.get_journal_source_with_options(request, bounded_traced_options(declared))
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?
.into_owned();
let head = reply
.head
.into_option()
.map(|head| Kernel::<JournalSourceHead>::try_from(head).map(Kernel::into_inner))
.transpose()?;
if head
.as_ref()
.is_some_and(|head| head.source().partition() != &expected)
{
return Err(missing(
"source_head",
"a successful source response binds the requested partition",
));
}
Ok(head)
}
pub async fn create_directory_snapshot(
&self,
declared: &DeclaredCall,
_request: &CreateJournalDirectorySnapshot,
) -> Result<JournalDirectorySnapshot, StateError> {
let request = pb::CreateJournalDirectorySnapshotRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
let reply = self
.inner
.create_journal_directory_snapshot_with_options(
request,
bounded_traced_options(declared),
)
.await
.map_err(|error| from_connect_error(&error, &Self::fallback(attempted)))?
.into_owned();
Kernel::<JournalDirectorySnapshot>::try_from(
reply
.snapshot
.into_option()
.ok_or_else(|| missing("snapshot", "a successful creation carries its snapshot"))?,
)
.map(Kernel::into_inner)
}
pub async fn directory_page(
&self,
declared: &DeclaredCall,
request: &ListJournalDirectorySnapshot,
) -> Result<JournalDirectoryPage, StateError> {
let wire_request = pb::ListJournalDirectorySnapshotRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
snapshot: request.snapshot().as_str().to_owned(),
start_after: request
.start_after()
.map(|partition| partition.as_str().to_owned()),
limit: request.limit(),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&wire_request) as usize;
let reply = self
.inner
.list_journal_directory_snapshot_with_options(
wire_request,
bounded_traced_options(declared),
)
.await
.map_err(|error| from_connect_error(&error, &Self::fallback(attempted)))?
.into_owned();
let page = Kernel::<JournalDirectoryPage>::try_from(
reply
.page
.into_option()
.ok_or_else(|| missing("page", "a successful listing carries its page"))?,
)?
.into_inner();
page.validate(request)?;
Ok(page)
}
pub async fn release_directory_snapshot(
&self,
declared: &DeclaredCall,
request: &ReleaseJournalDirectorySnapshot,
) -> Result<(), StateError> {
let request = pb::ReleaseJournalDirectorySnapshotRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
snapshot: request.snapshot().as_str().to_owned(),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
self.inner
.release_journal_directory_snapshot_with_options(
request,
bounded_traced_options(declared),
)
.await
.map_err(|error| from_connect_error(&error, &Self::fallback(attempted)))?;
Ok(())
}
pub async fn verify_replay(
&self,
declared: &DeclaredCall,
request: &VerifyPartitionReplay,
) -> Result<PartitionVerification, StateError> {
let request = pb::VerifyPartitionReplayRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
partition: request.partition().as_str().to_owned(),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
let reply = self
.inner
.verify_partition_replay_with_options(request, bounded_traced_options(declared))
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?
.into_owned();
Ok(Kernel::<PartitionVerification>::try_from(reply)?.into_inner())
}
pub async fn last_modified(
&self,
declared: &DeclaredCall,
request: &GetPartitionLastModified,
) -> Result<PartitionLastModified, StateError> {
let request = pb::GetPartitionLastModifiedRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
partition: request.partition().as_str().to_owned(),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
let reply = self
.inner
.get_partition_last_modified_with_options(request, bounded_traced_options(declared))
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?
.into_owned();
Ok(PartitionLastModified::new(reply.at_ms))
}
pub async fn root(
&self,
declared: &DeclaredCall,
request: &GetJournalRoot,
) -> Result<JournalAttestation, StateError> {
let request = pb::GetJournalRootRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
partition: String::from(Kernel(request)),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
let reply = self
.inner
.get_journal_root_with_options(request, bounded_traced_options(declared))
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?
.into_owned();
Ok(Kernel::<JournalAttestation>::try_from(
reply.attestation.into_option().ok_or_else(|| {
missing("attestation", "a successful read carries its attestation")
})?,
)?
.into_inner())
}
pub async fn proof(
&self,
declared: &DeclaredCall,
request: &GetJournalProof,
) -> Result<JournalProof, StateError> {
let request = pb::GetJournalProofRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
partition: request.partition().as_str().to_owned(),
position: request.position().get(),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
let reply = self
.inner
.get_journal_proof_with_options(request, bounded_traced_options(declared))
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?
.into_owned();
Ok(Kernel::<JournalProof>::try_from(
reply
.proof
.into_option()
.ok_or_else(|| missing("proof", "a successful read carries its proof"))?,
)?
.into_inner())
}
pub async fn destroy(
&self,
declared: &DeclaredCall,
command: &DestroyPartition,
) -> Result<Receipt, StateError> {
let request = pb::DestroyJournalPartitionRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
command: buffa::MessageField::some(Kernel(command).into()),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
let reply = self
.inner
.destroy_journal_partition_with_options(request, bounded_traced_options(declared))
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?
.into_owned();
receipt_of(reply.receipt)
}
pub async fn stage_destroy(
&self,
declared: &DeclaredCall,
command: &DestroyPartition,
) -> Result<(), StateError> {
let request = pb::StageJournalDestructionRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
command: buffa::MessageField::some(Kernel(command).into()),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
self.inner
.stage_journal_destruction_with_options(request, bounded_traced_options(declared))
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?;
Ok(())
}
pub async fn complete_destroy(
&self,
declared: &DeclaredCall,
command: &DestroyPartition,
) -> Result<Receipt, StateError> {
let request = pb::CompleteJournalDestructionRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
command: buffa::MessageField::some(Kernel(command).into()),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
let reply = self
.inner
.complete_journal_destruction_with_options(request, bounded_traced_options(declared))
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?
.into_owned();
receipt_of(reply.receipt)
}
pub async fn excise(
&self,
declared: &DeclaredCall,
command: &ExcisePartitionRecords,
) -> Result<Receipt, StateError> {
let request = pb::ExciseJournalRecordsRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
command: buffa::MessageField::some(Kernel(command).into()),
decisions: command
.decisions()
.iter()
.map(|decision| pb::RecordDecision::from(Kernel(decision)))
.collect(),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
let reply = self
.inner
.excise_journal_records_with_options(request, bounded_traced_options(declared))
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?
.into_owned();
receipt_of(reply.receipt)
}
pub async fn repair(
&self,
declared: &DeclaredCall,
command: &RepairPartition,
) -> Result<RepairOutcome, StateError> {
let request = pb::RepairJournalPartitionRequest {
context: buffa::MessageField::some(Kernel(declared).into()),
command: buffa::MessageField::some(Kernel(command).into()),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let attempted = buffa::Message::encoded_len(&request) as usize;
let reply = self
.inner
.repair_journal_partition_with_options(request, bounded_traced_options(declared))
.await
.map_err(|e| from_connect_error(&e, &Self::fallback(attempted)))?
.into_owned();
let quarantined = reply
.quarantined
.into_iter()
.map(|record| Kernel::<QuarantinedRecord>::from(record).into_inner())
.collect();
Ok(RepairOutcome::new(receipt_of(reply.receipt)?, quarantined))
}
}
fn receipt_of(receipt: impl Into<Option<pb::Receipt>>) -> Result<Receipt, StateError> {
Ok(Kernel::<Receipt>::try_from(
receipt
.into()
.ok_or_else(|| missing("receipt", "a committed command carries its receipt"))?,
)?
.into_inner())
}
fn missing(field: &str, reason: &str) -> StateError {
StateError::Malformed {
field: field.to_owned(),
reason: reason.to_owned(),
}
}
#[cfg(test)]
mod response_binding_tests {
#![allow(clippy::unwrap_used, missing_docs)]
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
use bytes::Bytes;
use connectrpc::client::{BoxFuture, ClientBody};
use connectrpc::http_body::{Body, Frame};
use polyc_state::revision::{JournalPosition, JournalSource, PartitionIncarnation};
use super::*;
use crate::state_audience;
struct CannedBody(std::vec::IntoIter<Bytes>);
impl Body for CannedBody {
type Data = Bytes;
type Error = std::io::Error;
fn poll_frame(
mut self: Pin<&mut Self>,
_context: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Bytes>, Self::Error>>> {
Poll::Ready(self.0.next().map(|bytes| Ok(Frame::data(bytes))))
}
}
#[derive(Clone)]
struct CannedTransport {
frames: Arc<Vec<Bytes>>,
}
impl ClientTransport for CannedTransport {
type ResponseBody = CannedBody;
type Error = std::io::Error;
fn send(
&self,
_request: http::Request<ClientBody>,
) -> BoxFuture<'static, Result<http::Response<Self::ResponseBody>, Self::Error>> {
let frames = self.frames.as_ref().clone();
Box::pin(async move {
Ok(http::Response::builder()
.status(http::StatusCode::OK)
.header(http::header::CONTENT_TYPE, "application/proto")
.body(CannedBody(frames.into_iter()))
.unwrap())
})
}
}
#[tokio::test]
async fn source_head_refuses_a_crossed_partition_reply() {
let crossed = JournalSourceHead::new(
JournalSource::new(
PartitionId::new("conv-b"),
PartitionIncarnation::from_bytes([7; PartitionIncarnation::LEN]),
),
JournalPosition::new(9),
);
let reply = pb::GetJournalSourceReply {
head: buffa::MessageField::some(Kernel(&crossed).into()),
__buffa_unknown_fields: buffa::UnknownFields::default(),
};
let frames = vec![Bytes::from(buffa::Message::encode_to_vec(&reply))];
let client = JournalClient::new(
CannedTransport {
frames: Arc::new(frames),
},
ClientConfig::new("http://journal.invalid".parse().unwrap()),
);
let error = client
.source_head(
&DeclaredCall::live(state_audience(), Duration::MAX),
&GetJournalSource::new(PartitionId::new("conv-a")),
)
.await
.expect_err("the successful reply crossed the requested partition");
assert!(
matches!(error, StateError::Malformed { ref field, .. } if field == "source_head"),
"got {error:?}"
);
}
}