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}