Skip to main content

chorus_client/
transport.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use bytes::Bytes;
6use serde::{Deserialize, Serialize};
7
8/// Metadata key selecting the durable segment and record encoding.
9pub const META_FORMAT: &str = "chorus.format";
10/// Current durable object format written by this crate.
11pub const FORMAT_VERSION: &str = "1";
12
13#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
14/// Provider-neutral object metadata returned during segment discovery.
15pub struct ListedObject {
16    /// Replica zone assigned by the factory.
17    pub zone: usize,
18    /// Object name relative to the bucket resource.
19    pub name: String,
20    /// Immutable provider generation used for guarded deletion.
21    pub generation: i64,
22    /// Whether the provider reports the object as finalized.
23    pub finalized: bool,
24    /// Custom metadata containing the object format marker.
25    pub metadata: HashMap<String, String>,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
29/// Full object read used by recovery and replay validation.
30pub struct ReplicaSnapshot {
31    /// Replica zone.
32    pub zone: usize,
33    /// Provider object generation.
34    pub generation: i64,
35    /// Provider metadata generation.
36    pub metageneration: i64,
37    /// Authoritative durable tail for the open stream, derived from write
38    /// responses or bytes read, never from `GetObject.size` while open.
39    pub persisted_size: i64,
40    /// Whether the provider finalized the object.
41    pub finalized: bool,
42    /// Provider-computed CRC32C of the complete object, when supplied.
43    pub crc32c: Option<u32>,
44    /// Segment-format metadata.
45    pub metadata: HashMap<String, String>,
46    /// Bytes visible through the object read path.
47    pub bytes: Vec<u8>,
48}
49
50#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
51/// State for one live zonal append stream, generation-bound once resumed.
52///
53/// This is not a distributed ownership record. Logical writer identity comes
54/// from the manifest's epoch and owner; conditional segment creation provides
55/// data-plane exclusivity for the manifest-selected object. This value carries
56/// only the physical preconditions and persisted offset needed by one lane.
57pub struct AppendToken {
58    /// Replica zone.
59    pub zone: usize,
60    /// Object generation bound to this append stream. A retained conditional-
61    /// create stream learns this lazily only if it must be resumed.
62    pub generation: Option<i64>,
63    /// Metadata generation that guarded the fresh append open. This is also
64    /// initially unknown for a retained create stream.
65    pub metageneration: Option<i64>,
66    /// Next append offset learned from `persisted_size` responses.
67    pub persisted_size: i64,
68    /// Session handle returned by the fresh append open. Presenting it on
69    /// later opens resumes the same server-side session instead of issuing
70    /// another takeover: handle-free opens are object *mutations* and are
71    /// rate limited per object by the live service.
72    pub write_handle: Option<Bytes>,
73}
74
75#[derive(Clone, Debug)]
76pub(crate) struct PackedAppendMessage {
77    pub(crate) relative_offset: i64,
78    pub(crate) content: Bytes,
79    pub(crate) crc32c: u32,
80}
81
82/// An append group whose immutable wire messages were packed once before
83/// replica dispatch, shared across lanes. Public because it appears in the
84/// [`Replica::lane_send_packed`] signature exposed to the simulation harness.
85#[derive(Clone, Debug)]
86pub struct PackedAppend {
87    chunks: Box<[Bytes]>,
88    messages: Box<[PackedAppendMessage]>,
89    len: usize,
90}
91
92impl PackedAppend {
93    pub(crate) fn new(chunks: Vec<Bytes>, messages: Vec<PackedAppendMessage>, len: usize) -> Self {
94        Self {
95            chunks: chunks.into_boxed_slice(),
96            messages: messages.into_boxed_slice(),
97            len,
98        }
99    }
100
101    pub(crate) fn chunks(&self) -> &[Bytes] {
102        &self.chunks
103    }
104
105    pub(crate) fn messages(&self) -> &[PackedAppendMessage] {
106        &self.messages
107    }
108
109    pub(crate) fn len(&self) -> usize {
110        self.len
111    }
112
113    pub(crate) fn is_empty(&self) -> bool {
114        self.chunks.is_empty()
115    }
116}
117
118#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
119/// Stable transport classification used by retry and fencing logic.
120pub enum TransportCode {
121    /// Object was not found.
122    NotFound,
123    /// Conditional create found an existing object.
124    AlreadyExists,
125    /// The request itself was invalid for the provider API surface.
126    InvalidArgument,
127    /// A precondition failed; on append/open this is a terminal writer fence,
128    /// including both takeover revocation and finalized-object rejection.
129    FailedPrecondition,
130    /// GCS redirected or aborted an operation. Rich zonal write redirects are
131    /// consumed by the gRPC transport before reaching protocol code.
132    Aborted,
133    /// Requested offset lies outside the provider's accepted range.
134    OutOfRange,
135    /// GCS throttled the request with HTTP 429 / `RESOURCE_EXHAUSTED` because
136    /// a per-object mutation-rate or per-project quota was exhausted. The
137    /// request remains valid and retry-with-backoff can succeed once quota
138    /// state resets; this is not a permanent rejection.
139    ResourceExhausted,
140    /// The provider does not implement the required operation.
141    Unimplemented,
142    /// Stored bytes or checksums are corrupt.
143    DataLoss,
144    /// The provider response cannot distinguish absence from a selector or
145    /// routing failure. Recovery must fail closed rather than retrying away or
146    /// interpreting this observation as a missing object.
147    Ambiguous,
148    /// No valid credential was supplied.
149    Unauthenticated,
150    /// The credential lacks bucket permission.
151    PermissionDenied,
152    /// Temporary service or zone outage.
153    Unavailable,
154    /// Operation exceeded its deadline.
155    DeadlineExceeded,
156    /// Unclassified internal transport failure.
157    Internal,
158}
159
160impl TransportCode {
161    /// Whether retrying the same operation can be safe.
162    pub fn transient(self) -> bool {
163        matches!(
164            self,
165            Self::ResourceExhausted | Self::Unavailable | Self::DeadlineExceeded | Self::Internal
166        )
167    }
168
169    /// Whether an append path must stop the current writer incarnation.
170    pub fn fences_writer(self) -> bool {
171        matches!(self, Self::FailedPrecondition | Self::Aborted)
172    }
173}
174
175#[derive(Clone, Debug, thiserror::Error)]
176#[error("zone {zone}: {code:?}: {message}")]
177/// Transport failure annotated with replica zone and retry classification.
178pub struct TransportError {
179    /// Replica zone where the operation failed.
180    pub zone: usize,
181    /// Stable protocol-facing classification.
182    pub code: TransportCode,
183    /// Provider diagnostic text. Correctness logic must not match this string.
184    pub message: String,
185}
186
187/// Outcome of awaiting durable progress on a lane: the new durable byte offset,
188/// and any error observed in the same step (a response and a stream error can be
189/// observed together, so both are reported).
190#[derive(Clone, Debug)]
191pub struct LaneDurableChange {
192    /// Durable byte offset now reported by the session.
193    pub persisted_size: i64,
194    /// Error observed alongside the durable progress, if any.
195    pub error: Option<TransportError>,
196}
197
198#[async_trait]
199/// Creates object-specific replicas and lists one zonal bucket.
200///
201/// Implement this trait to plug in a storage backend with GCS-equivalent
202/// generation, finalization, listing, and append-takeover semantics.
203pub trait ReplicaFactory: Send + Sync {
204    /// Globally unique bucket name, without project or location qualifiers.
205    fn bucket_name(&self) -> &str;
206
207    /// Bind the factory's bucket and channel to one object name.
208    fn replica(&self, object: &str) -> Arc<dyn Replica>;
209
210    /// Strongly consistently list objects under `prefix`.
211    async fn list(&self, prefix: &str) -> Result<Vec<ListedObject>, TransportError>;
212}
213
214#[async_trait]
215/// Storage operations required by the quorum protocol for one zonal object.
216pub trait Replica: Send + Sync {
217    /// Read object bytes and metadata. For open objects, implementations must
218    /// not derive `persisted_size` from provider metadata that hides appends.
219    async fn snapshot(&self) -> Result<ReplicaSnapshot, TransportError>;
220
221    /// Read object metadata only, without the content read. Metadata reads are
222    /// content-blind: they succeed even when the stored bytes are rotted, so
223    /// repair can learn the generation of a copy whose `snapshot` fails with
224    /// `DATA_LOSS`. The returned snapshot carries empty bytes and must never
225    /// be used to infer a durable tail.
226    async fn stat(&self) -> Result<ReplicaSnapshot, TransportError>;
227
228    /// Conditionally create a new appendable object.
229    async fn create_appendable(
230        &self,
231        metadata: HashMap<String, String>,
232    ) -> Result<ReplicaSnapshot, TransportError>;
233
234    /// Conditionally create a new appendable object and retain that create
235    /// RPC as its live append session.
236    async fn create_append_session(
237        &self,
238        metadata: HashMap<String, String>,
239    ) -> Result<AppendToken, TransportError>;
240
241    /// Conditionally create a finalized, non-appendable control object with
242    /// an empty body and the supplied metadata. The manifest register lives
243    /// in a regional bucket, where appendable objects do not exist; it is
244    /// created once with this call and afterwards mutated only through
245    /// [`Replica::update_register`].
246    async fn create_register(
247        &self,
248        metadata: HashMap<String, String>,
249    ) -> Result<ReplicaSnapshot, TransportError>;
250
251    /// Conditionally replace the register's custom metadata, guarded by its
252    /// metageneration alone. The register is created exactly once and never
253    /// deleted or recreated, so its generation is constant and the
254    /// metageneration by itself names one register state.
255    async fn update_register(
256        &self,
257        metageneration: i64,
258        metadata: HashMap<String, String>,
259    ) -> Result<ReplicaSnapshot, TransportError>;
260
261    /// Re-learn the durable tail after a lane disturbance by resuming the
262    /// append session (with its handle when available, else a guarded fresh
263    /// open). Never reads object bytes: the live service hides unfinalized
264    /// appendable content from reads, and `state_lookup` on the session is
265    /// the authoritative tail.
266    async fn resume_tail(&self, token: &mut AppendToken) -> Result<i64, TransportError>;
267
268    /// Open a fresh, handle-free append stream guarded by the observed object.
269    /// The open
270    /// is the server-enforced fence: it revokes any previously open writer and
271    /// returns the authoritative durable tail from `persisted_size`.
272    async fn takeover(&self, observed: &ReplicaSnapshot) -> Result<AppendToken, TransportError>;
273
274    /// Fence the current live generation and return its authoritative durable
275    /// tail in the same RPC.
276    ///
277    /// The latest-generation lookup confirms identity or unambiguous absence;
278    /// its tail-blind size is never used for recovery. A `NotFound` from the
279    /// subsequent exact-generation takeover is ambiguous and must not be
280    /// reclassified as object absence.
281    async fn takeover_current(&self) -> Result<AppendToken, TransportError> {
282        let observed = self.stat().await?;
283        match self.takeover(&observed).await {
284            Err(error) if error.code == TransportCode::NotFound => Err(TransportError {
285                zone: error.zone,
286                code: TransportCode::Ambiguous,
287                message: format!(
288                    "exact-generation takeover returned NOT_FOUND after current object lookup: {}",
289                    error.message
290                ),
291            }),
292            result => result,
293        }
294    }
295
296    /// Replace the current appendable generation with an exact byte prefix.
297    /// Recovery uses this conditional overwrite after takeover to install the
298    /// canonical prefix; the returned token names the replacement generation.
299    async fn replace_appendable(
300        &self,
301        observed: &ReplicaSnapshot,
302        data: Bytes,
303        metadata: HashMap<String, String>,
304    ) -> Result<AppendToken, TransportError>;
305
306    /// Append one checksummed chunk at an authoritative persisted offset.
307    async fn append(
308        &self,
309        token: &AppendToken,
310        write_offset: i64,
311        data: Vec<u8>,
312    ) -> Result<i64, TransportError>;
313
314    /// Queue a non-empty ordered group of checksummed chunks on the live
315    /// append session without waiting for acknowledgments. The final wire
316    /// message always flushes everything queued through the end of the group.
317    /// Returns an error (without blocking) when no session is live — the lane
318    /// then resumes via [`Replica::resume_tail`] and resends its unacknowledged
319    /// suffix as another flushed group.
320    async fn lane_send(&self, write_offset: i64, chunks: &[Bytes]) -> Result<(), TransportError>;
321
322    /// Queue a group whose immutable wire messages were packed before replica
323    /// dispatch. Backends that do not consume the shared representation can
324    /// retain the chunk-oriented implementation.
325    async fn lane_send_packed(
326        &self,
327        write_offset: i64,
328        packed: &PackedAppend,
329    ) -> Result<(), TransportError> {
330        self.lane_send(write_offset, packed.chunks()).await
331    }
332
333    /// Wait until the session's durable tail exceeds `seen` or the session
334    /// fails. A response and stream error may be observed together; in that
335    /// case the durable offset and error are returned in one observation so the
336    /// protocol can publish the physical progress before classifying the error.
337    async fn lane_durable_change(&self, seen: i64) -> Result<LaneDurableChange, TransportError>;
338
339    /// Delete exactly the supplied object generation; missing is handled by the
340    /// caller as idempotent success.
341    async fn delete(&self, generation: i64) -> Result<(), TransportError>;
342
343    /// Finalize the appendable object at exactly `write_offset`.
344    ///
345    /// Implementations must make retry idempotent when the same generation is
346    /// already finalized at the requested length.
347    async fn finalize(
348        &self,
349        token: &mut AppendToken,
350        write_offset: i64,
351    ) -> Result<ReplicaSnapshot, TransportError>;
352
353    /// Stop and join backend-owned background work for this object.
354    ///
355    /// Backends without object-local tasks may keep the default no-op.
356    async fn shutdown(&self) {}
357}
358
359#[cfg(test)]
360mod tests {
361    use super::TransportCode;
362
363    #[test]
364    fn failed_precondition_is_a_terminal_writer_fence() {
365        assert!(!TransportCode::FailedPrecondition.transient());
366        assert!(TransportCode::FailedPrecondition.fences_writer());
367    }
368
369    #[test]
370    fn terminal_request_rejections_are_not_retried() {
371        assert!(!TransportCode::InvalidArgument.transient());
372        assert!(!TransportCode::Unimplemented.transient());
373    }
374
375    #[test]
376    fn resource_exhaustion_is_retried() {
377        assert!(TransportCode::ResourceExhausted.transient());
378    }
379}