Skip to main content

snix_castore/chunkstore/
grpc.rs

1//! A gRPC client implementation.
2
3use futures::TryStreamExt;
4use std::sync::Arc;
5use tonic::{Code, async_trait};
6use tracing::instrument;
7
8use crate::{
9    B3Digest,
10    chunkstore::{Chunk, ChunkStore},
11    composition::{CompositionContext, ServiceBuilder},
12    proto::{self, GetChunkRequest, GetChunkResponse, PutChunkRequest, PutChunkResponse},
13};
14
15/// 2 MiB, well below the size limit of gRPC messages (4 MiB)
16const GRPC_CHUNK_SIZE: usize = 2 * 1024 * 1024;
17
18/// Connects to a (remote) snix-castore ChunkStoreService over gRPC.
19#[derive(Clone)]
20pub struct GrpcChunkStore<T> {
21    instance_name: String,
22    /// The internal reference to a gRPC client.
23    /// Cloning it is cheap, and it internally handles concurrent requests.
24    client: proto::chunk_store_service_client::ChunkStoreServiceClient<T>,
25}
26
27impl<T> GrpcChunkStore<T> {
28    /// construct a [GrpcChunkStore] from a [proto::chunk_store_service_client::ChunkStoreServiceClient].
29    pub fn from_client(
30        instance_name: String,
31        client: proto::chunk_store_service_client::ChunkStoreServiceClient<T>,
32    ) -> Self {
33        Self {
34            instance_name,
35            client,
36        }
37    }
38}
39
40#[async_trait]
41impl<T> ChunkStore for GrpcChunkStore<T>
42where
43    T: tonic::client::GrpcService<tonic::body::Body> + Send + Sync + Clone + 'static,
44    T::ResponseBody: tonic::codegen::Body<Data = tonic::codegen::Bytes> + Send + 'static,
45    <T::ResponseBody as tonic::codegen::Body>::Error: Into<tonic::codegen::StdError> + Send,
46    T::Future: Send,
47{
48    #[instrument(skip_all, err, fields(chunk.digest=%digest, instance_name=%self.instance_name))]
49    async fn get(&self, digest: &B3Digest) -> Result<Option<Chunk>, super::Error> {
50        // Easy to reply statically.
51        if digest == &B3Digest::EMPTY {
52            return Ok(Some(Chunk::from_static(&[])));
53        }
54
55        let resp = match self
56            .client
57            .clone()
58            .get_chunk(GetChunkRequest::with_digest(*digest))
59            .await
60        {
61            Ok(resp) => resp,
62            Err(err) if err.code() == Code::NotFound => return Ok(None),
63            Err(err) => Err(Error::Tonic(err))?,
64        };
65
66        // collect messages from the stream
67        let mut stream = resp.into_inner();
68        let mut chunk_builder = Chunk::builder();
69        while let Some(GetChunkResponse { data }) = stream.try_next().await.map_err(Error::Tonic)? {
70            chunk_builder.append_bytes(data);
71        }
72        let chunk = chunk_builder.build();
73
74        // verify digest
75        let actual_digest = chunk.digest();
76        if &chunk.digest() != digest {
77            Err(Error::GetIncorrectDigest {
78                expected: digest.to_owned(),
79                actual: actual_digest,
80            })?;
81        }
82
83        Ok(Some(chunk))
84    }
85
86    #[instrument(skip_all, err, fields(chunk.digest=%digest, instance_name=%self.instance_name))]
87    async fn has(&self, digest: &B3Digest) -> Result<bool, super::Error> {
88        // Easy to reply statically.
89        if digest == &B3Digest::EMPTY {
90            return Ok(true);
91        }
92
93        match self
94            .client
95            .clone()
96            .stat_chunk(proto::StatChunkRequest::with_digest(*digest))
97            .await
98        {
99            Ok(_blob_meta) => Ok(true),
100            Err(e) if e.code() == Code::NotFound => Ok(false),
101            Err(err) => Err(Error::Tonic(err))?,
102        }
103    }
104
105    #[instrument(skip_all, err, fields(instance_name=%self.instance_name))]
106    async fn put(&self, chunk: Chunk) -> Result<B3Digest, super::Error> {
107        let chunk_digest: B3Digest = chunk.digest();
108        let chunk_len = chunk.len();
109        let chunk_bytes: bytes::Bytes = chunk.into();
110
111        let resp = self
112            .client
113            .clone()
114            .put_chunk(futures::stream::iter(
115                (0..chunk_len)
116                    .step_by(GRPC_CHUNK_SIZE)
117                    .map(move |i| PutChunkRequest {
118                        data: chunk_bytes.slice(i..(i + GRPC_CHUNK_SIZE).min(chunk_len)),
119                    }),
120            ))
121            .await
122            .map_err(Error::Tonic)?;
123
124        let PutChunkResponse { digest } = resp.into_inner();
125        let resp_digest: B3Digest =
126            B3Digest::try_from(digest).map_err(|_| Error::PutInvalidDigest)?;
127
128        if chunk_digest != resp_digest {
129            Err(Error::PutIncorrectDigest {
130                expected: chunk_digest,
131                actual: resp_digest,
132            })?
133        }
134
135        Ok(chunk_digest)
136    }
137}
138
139/// Configuration for a [GrpcChunkStore].
140#[derive(serde::Deserialize, Debug)]
141#[serde(deny_unknown_fields)]
142pub struct GRPCChunkStoreConfig {
143    url: url::Url,
144}
145
146impl TryFrom<url::Url> for GRPCChunkStoreConfig {
147    type Error = Box<dyn std::error::Error + Send + Sync>;
148    fn try_from(url: url::Url) -> Result<Self, Self::Error> {
149        Ok(GRPCChunkStoreConfig { url })
150    }
151}
152
153#[async_trait]
154impl ServiceBuilder for GRPCChunkStoreConfig {
155    type Output = dyn ChunkStore;
156    async fn build<'a>(
157        &'a self,
158        instance_name: &str,
159        _context: &CompositionContext,
160    ) -> Result<Arc<Self::Output>, Box<dyn std::error::Error + Send + Sync>> {
161        let client = proto::chunk_store_service_client::ChunkStoreServiceClient::with_interceptor(
162            crate::tonic::channel_from_url(&self.url).await?,
163            snix_tracing::propagate::tonic::send_trace,
164        );
165        Ok(Arc::new(GrpcChunkStore::from_client(
166            instance_name.to_string(),
167            client,
168        )))
169    }
170}
171
172#[derive(thiserror::Error, Debug)]
173enum Error {
174    #[error(transparent)]
175    Tonic(tonic::Status),
176    #[error("chunk with unexpected digest received, expected: {expected}, got: {actual}")]
177    GetIncorrectDigest {
178        expected: B3Digest,
179        actual: B3Digest,
180    },
181    #[error("got invalid B3Digest in PutChunkResponse")]
182    PutInvalidDigest,
183    #[error("got unexpected digest {actual} after uploading chunk with digest {expected}")]
184    PutIncorrectDigest {
185        expected: B3Digest,
186        actual: B3Digest,
187    },
188}
189
190impl From<Error> for super::Error {
191    fn from(value: Error) -> Self {
192        super::Error(Box::new(value))
193    }
194}