Skip to content

Commit 353f0e2

Browse files
committed
Expose replication + object-storage primitives for building WAL-processing tools
- Open more of tls.rs and conn.rs as pub - Add PutIfAbsent method to storage interface, S3 and GCS The change allows to use wal-rus PG connection and storage code as a library
1 parent 730615c commit 353f0e2

5 files changed

Lines changed: 368 additions & 4 deletions

File tree

src/pg/replication/tls.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ pub async fn maybe_upgrade(
156156
}
157157
}
158158

159-
fn build_client_config(sslmode: SslMode, tls: &TlsParams) -> Result<ClientConfig> {
159+
pub fn build_client_config(sslmode: SslMode, tls: &TlsParams) -> Result<ClientConfig> {
160160
let provider = rustls::crypto::aws_lc_rs::default_provider();
161161
let builder = ClientConfig::builder_with_provider(Arc::new(provider))
162162
.with_safe_default_protocol_versions()

src/storage/gcs.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ use serde::Deserialize;
2222
use tokio::sync::Mutex;
2323
use tokio_util::io::ReaderStream;
2424

25-
use super::{AsyncReader, CopySource, ObjectMeta, ObjectStream, Result, Storage, StorageError};
25+
use super::{
26+
AsyncReader, CopySource, ObjectMeta, ObjectStream, PutIfAbsentOutcome, Result, Storage,
27+
StorageError,
28+
};
2629

2730
const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
2831
const STORAGE_HOST: &str = "https://storage.googleapis.com";
@@ -248,6 +251,47 @@ impl Storage for GcsStorage {
248251
Ok(())
249252
}
250253

254+
async fn put_if_absent(
255+
&self,
256+
key: &str,
257+
body: AsyncReader,
258+
_size_hint: Option<u64>,
259+
) -> Result<PutIfAbsentOutcome> {
260+
let token = self.access_token().await?;
261+
let full = self.full_key(key);
262+
// ifGenerationMatch=0 makes the upload a create-if-absent: it succeeds
263+
// only when no live object exists, and returns 412 otherwise.
264+
let url = format!(
265+
"{}/upload/storage/v1/b/{}/o?uploadType=media&ifGenerationMatch=0&name={}",
266+
self.host,
267+
self.cfg.bucket,
268+
utf8_percent_encode(&full, NON_ALPHANUMERIC),
269+
);
270+
let stream = ReaderStream::new(body);
271+
let resp = self
272+
.client
273+
.post(&url)
274+
.bearer_auth(token)
275+
.header("content-type", "application/octet-stream")
276+
.body(Body::wrap_stream(stream))
277+
.send()
278+
.await?;
279+
let st = resp.status();
280+
if st.is_success() {
281+
Ok(PutIfAbsentOutcome::Created)
282+
} else if st == reqwest::StatusCode::PRECONDITION_FAILED
283+
|| st == reqwest::StatusCode::CONFLICT
284+
{
285+
Ok(PutIfAbsentOutcome::AlreadyExists)
286+
} else {
287+
let body = resp.text().await.unwrap_or_default();
288+
Err(StorageError::Http {
289+
status: st.as_u16(),
290+
body: format!("gcs put_if_absent: {body}"),
291+
})
292+
}
293+
}
294+
251295
async fn get(&self, key: &str) -> Result<AsyncReader> {
252296
let token = self.access_token().await?;
253297
let url = format!("{}?alt=media", self.object_url(key));

src/storage/mod.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,16 @@ impl From<reqwest::Error> for StorageError {
107107

108108
pub type Result<T> = std::result::Result<T, StorageError>;
109109

110+
/// Outcome of a create-if-absent PUT ([`Storage::put_if_absent`]).
111+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112+
pub enum PutIfAbsentOutcome {
113+
/// No object existed at the key; the body was written.
114+
Created,
115+
/// An object already existed; nothing was written — a benign skip. The
116+
/// existing object is authoritative and is never overwritten.
117+
AlreadyExists,
118+
}
119+
110120
/// Object storage backend
111121
///
112122
/// Implementations stream uploads & downloads, no full-segment buffering
@@ -118,6 +128,24 @@ pub trait Storage: Send + Sync {
118128
/// Upload object. `size_hint` lets s3 backend pick single-PUT vs multipart
119129
async fn put(&self, key: &str, body: AsyncReader, size_hint: Option<u64>) -> Result<()>;
120130

131+
/// Upload `body` only if no object exists at `key`, via a backend-native
132+
/// atomic create-if-absent write (S3 `If-None-Match: *`, GCS
133+
/// `ifGenerationMatch=0`, fs `O_EXCL`). Returns [`PutIfAbsentOutcome::Created`]
134+
/// on a fresh write and [`PutIfAbsentOutcome::AlreadyExists`] when the object
135+
/// is already present (a benign skip — the existing object is never
136+
/// overwritten). Backends without conditional-write support return
137+
/// `Err(StorageError::Unimplemented(_))` so callers can fall back to a plain
138+
/// [`Storage::put`]. `size_hint`, when known, sizes the buffer.
139+
async fn put_if_absent(
140+
&self,
141+
key: &str,
142+
body: AsyncReader,
143+
size_hint: Option<u64>,
144+
) -> Result<PutIfAbsentOutcome> {
145+
let _ = (key, body, size_hint);
146+
Err(StorageError::Unimplemented("put_if_absent"))
147+
}
148+
121149
/// Download object as streaming reader
122150
async fn get(&self, key: &str) -> Result<AsyncReader>;
123151

src/storage/retrying.rs

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ use std::io::Cursor;
1010

1111
use crate::retry::{RetryPolicy, with_retry};
1212

13-
use super::{AsyncReader, CopySource, ObjectStream, Result, Storage, StorageError};
13+
use super::{
14+
AsyncReader, CopySource, ObjectStream, PutIfAbsentOutcome, Result, Storage, StorageError,
15+
};
1416

1517
/// Buffer-then-retry threshold for put bodies (matches wal-g's small-object
1618
/// path: sentinels, history files, manifest fragments)
@@ -54,6 +56,31 @@ impl<S: Storage + 'static> Storage for RetryingStorage<S> {
5456
.await
5557
}
5658

59+
async fn put_if_absent(
60+
&self,
61+
key: &str,
62+
mut body: AsyncReader,
63+
size_hint: Option<u64>,
64+
) -> Result<PutIfAbsentOutcome> {
65+
// Only retry small known-size bodies;
66+
let bufferable = matches!(size_hint, Some(s) if s <= PUT_RETRY_BUFFER_THRESHOLD);
67+
if !bufferable {
68+
return self.inner.put_if_absent(key, body, size_hint).await;
69+
}
70+
let mut buf = Vec::with_capacity(size_hint.unwrap_or(0) as usize);
71+
tokio::io::copy(&mut body, &mut buf).await?;
72+
let bytes = Bytes::from(buf);
73+
let len = bytes.len() as u64;
74+
with_retry(&self.policy, StorageError::is_transient, || {
75+
let bytes = bytes.clone();
76+
async move {
77+
let reader: AsyncReader = Box::pin(Cursor::new(bytes));
78+
self.inner.put_if_absent(key, reader, Some(len)).await
79+
}
80+
})
81+
.await
82+
}
83+
5784
async fn get(&self, key: &str) -> Result<AsyncReader> {
5885
with_retry(&self.policy, StorageError::is_transient, || async {
5986
self.inner.get(key).await
@@ -113,6 +140,8 @@ mod tests {
113140
put_bodies: Mutex<Vec<Vec<u8>>>,
114141
get_script: Mutex<Vec<StubResult>>,
115142
put_script: Mutex<Vec<StubResult>>,
143+
put_if_absent_calls: AtomicU32,
144+
put_if_absent_script: Mutex<Vec<StubIfAbsent>>,
116145
}
117146

118147
enum StubResult {
@@ -121,6 +150,12 @@ mod tests {
121150
Ok,
122151
}
123152

153+
enum StubIfAbsent {
154+
Transient,
155+
Created,
156+
AlreadyExists,
157+
}
158+
124159
impl StubStorage {
125160
fn new() -> Self {
126161
Self {
@@ -129,6 +164,8 @@ mod tests {
129164
put_bodies: Mutex::new(Vec::new()),
130165
get_script: Mutex::new(Vec::new()),
131166
put_script: Mutex::new(Vec::new()),
167+
put_if_absent_calls: AtomicU32::new(0),
168+
put_if_absent_script: Mutex::new(Vec::new()),
132169
}
133170
}
134171
}
@@ -181,6 +218,25 @@ mod tests {
181218
async fn delete(&self, _key: &str) -> Result<()> {
182219
Ok(())
183220
}
221+
async fn put_if_absent(
222+
&self,
223+
_key: &str,
224+
mut body: AsyncReader,
225+
_size_hint: Option<u64>,
226+
) -> Result<PutIfAbsentOutcome> {
227+
self.put_if_absent_calls.fetch_add(1, Ordering::SeqCst);
228+
let mut buf = Vec::new();
229+
body.read_to_end(&mut buf).await?;
230+
self.put_bodies.lock().unwrap().push(buf);
231+
match self.put_if_absent_script.lock().unwrap().remove(0) {
232+
StubIfAbsent::Transient => Err(StorageError::Http {
233+
status: 503,
234+
body: "stub down".into(),
235+
}),
236+
StubIfAbsent::Created => Ok(PutIfAbsentOutcome::Created),
237+
StubIfAbsent::AlreadyExists => Ok(PutIfAbsentOutcome::AlreadyExists),
238+
}
239+
}
184240
}
185241

186242
fn fast_policy() -> RetryPolicy {
@@ -275,4 +331,53 @@ mod tests {
275331
assert!(matches!(r, Err(StorageError::Http { status: 503, .. })));
276332
assert_eq!(retry.inner.put_calls.load(Ordering::SeqCst), 1);
277333
}
334+
335+
#[tokio::test]
336+
async fn put_if_absent_retries_transient_then_returns_already_exists() {
337+
let stub = StubStorage::new();
338+
stub.put_if_absent_script.lock().unwrap().extend([
339+
StubIfAbsent::Transient,
340+
StubIfAbsent::Transient,
341+
StubIfAbsent::AlreadyExists,
342+
]);
343+
let retry = RetryingStorage::new(stub, fast_policy());
344+
let body: AsyncReader = Box::pin(Cursor::new(b"seg".to_vec()));
345+
let outcome = retry.put_if_absent("k", body, Some(3)).await.unwrap();
346+
// AlreadyExists on a retry counts as success (object exists), not an error.
347+
assert!(matches!(outcome, PutIfAbsentOutcome::AlreadyExists));
348+
assert_eq!(retry.inner.put_if_absent_calls.load(Ordering::SeqCst), 3);
349+
// body replayed byte-identically across attempts
350+
let bodies = retry.inner.put_bodies.lock().unwrap();
351+
assert_eq!(bodies.len(), 3);
352+
assert!(bodies.iter().all(|b| b == b"seg"));
353+
}
354+
355+
#[tokio::test]
356+
async fn put_if_absent_retries_transient_then_created() {
357+
let stub = StubStorage::new();
358+
stub.put_if_absent_script
359+
.lock()
360+
.unwrap()
361+
.extend([StubIfAbsent::Transient, StubIfAbsent::Created]);
362+
let retry = RetryingStorage::new(stub, fast_policy());
363+
let body: AsyncReader = Box::pin(Cursor::new(b"seg".to_vec()));
364+
let outcome = retry.put_if_absent("k", body, Some(3)).await.unwrap();
365+
assert!(matches!(outcome, PutIfAbsentOutcome::Created));
366+
assert_eq!(retry.inner.put_if_absent_calls.load(Ordering::SeqCst), 2);
367+
}
368+
369+
#[tokio::test]
370+
async fn put_if_absent_bypasses_retry_when_size_unknown() {
371+
let stub = StubStorage::new();
372+
stub.put_if_absent_script
373+
.lock()
374+
.unwrap()
375+
.push(StubIfAbsent::Transient);
376+
let retry = RetryingStorage::new(stub, fast_policy());
377+
// size_hint = None → not bufferable → single pass-through attempt (mirrors put).
378+
let body: AsyncReader = Box::pin(Cursor::new(b"seg".to_vec()));
379+
let r = retry.put_if_absent("k", body, None).await;
380+
assert!(matches!(r, Err(StorageError::Http { status: 503, .. })));
381+
assert_eq!(retry.inner.put_if_absent_calls.load(Ordering::SeqCst), 1);
382+
}
278383
}

0 commit comments

Comments
 (0)