Skip to content

Commit 280d9ec

Browse files
committed
Tuck maybe_bootstrap_checkpoint() back into replication service. (#366)
Use strong Url type. Signed-off-by: Jason Volk <jason@zemos.net>
1 parent 08210c6 commit 280d9ec

7 files changed

Lines changed: 134 additions & 118 deletions

File tree

src/api/client/replication.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ use serde_json::json;
3030
use tokio::time::sleep;
3131
use tuwunel_core::{Err, Result, err, utils::time::now};
3232
use tuwunel_database::{WalFrame, is_wal_gap_error};
33+
use url::Url;
3334

3435
/// Query parameters for `GET /_tuwunel/replication/wal`.
3536
#[derive(Debug, Deserialize)]
@@ -57,7 +58,7 @@ pub(crate) async fn replication_status(
5758
.server
5859
.config
5960
.rocksdb_primary_url
60-
.as_deref()
61+
.as_ref()
6162
.filter(|_| !services.replication.is_promoted())
6263
.and(Some("secondary"))
6364
.unwrap_or("primary");
@@ -272,7 +273,7 @@ pub(crate) async fn replication_promote(
272273
#[derive(Debug, Deserialize, Serialize)]
273274
pub(crate) struct DemoteBody {
274275
/// URL of the new primary to replicate from (e.g. `http://host:8008`).
275-
pub primary_url: String,
276+
pub primary_url: Url,
276277
}
277278

278279
/// `POST /_tuwunel/replication/demote`
@@ -295,7 +296,7 @@ pub(crate) async fn replication_demote(
295296
State(services): State<crate::State>,
296297
Json(body): Json<DemoteBody>,
297298
) -> impl IntoResponse {
298-
if body.primary_url.is_empty() {
299+
if body.primary_url.as_str().is_empty() {
299300
return Err!(HttpJson(BAD_REQUEST, {"error": "primary_url is required"}));
300301
}
301302

src/core/config/mod.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,19 +1350,16 @@ pub struct Config {
13501350
/// Path for the secondary instance's own RocksDB log files. Required when
13511351
/// `rocksdb_secondary` is true and the primary DB is not on a shared
13521352
/// filesystem. Must be a writable directory local to this host.
1353-
#[serde(default)]
13541353
pub rocksdb_secondary_path: Option<PathBuf>,
13551354

13561355
/// URL of the primary instance for WAL-streaming replication.
13571356
/// Example: `https://primary.example.com`
13581357
/// Required on secondary instances that use WAL streaming.
1359-
#[serde(default)]
1360-
pub rocksdb_primary_url: Option<String>,
1358+
pub rocksdb_primary_url: Option<Url>,
13611359

13621360
/// Shared secret token for replication endpoint authentication.
13631361
/// Both primary and secondary must have the same value.
13641362
/// Leave unset to disable the replication HTTP endpoints entirely.
1365-
#[serde(default)]
13661363
pub rocksdb_replication_token: Option<String>,
13671364

13681365
/// How long (in seconds) the primary retains WAL segments beyond what

src/router/run.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use futures::{FutureExt, future::join, pin_mut};
77
use tuwunel_core::{
88
Error, Result, Server, debug, debug_error, debug_info, error, info, utils::BoolExt,
99
};
10-
use tuwunel_service::Services;
10+
use tuwunel_service::{Services, replication::maybe_bootstrap_checkpoint};
1111

1212
use crate::{handle::ServerHandle, serve};
1313

@@ -79,6 +79,7 @@ pub(crate) async fn run(services: Arc<Services>) -> Result {
7979
pub(crate) async fn start(server: Arc<Server>) -> Result<Arc<Services>> {
8080
debug!("Starting...");
8181

82+
maybe_bootstrap_checkpoint(&server).await?;
8283
let services = Services::build(server).await?.start().await?;
8384

8485
#[cfg(all(feature = "systemd", target_os = "linux"))]
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
use std::{fs, io::Cursor, sync::Arc, time::Duration};
2+
3+
use bytes::Bytes;
4+
use reqwest::Client;
5+
use tar::Archive;
6+
use tuwunel_core::{Err, Result, Server, err, info};
7+
use url::Url;
8+
9+
pub async fn maybe_bootstrap_checkpoint(server: &Arc<Server>) -> Result {
10+
let Some(primary_url) = server.config.rocksdb_primary_url.as_ref() else {
11+
return Ok(());
12+
};
13+
14+
bootstrap_checkpoint(server, primary_url).await
15+
}
16+
17+
#[tracing::instrument(
18+
name = "bootstrap",
19+
level = "info",
20+
ret,
21+
skip_all,
22+
fields(primary_url)
23+
)]
24+
async fn bootstrap_checkpoint(server: &Arc<Server>, primary_url: &Url) -> Result {
25+
let db_path = server.config.database_path.clone();
26+
let sidecar = db_path
27+
.parent()
28+
.unwrap_or(&db_path)
29+
.join("_replication_needs_bootstrap");
30+
31+
let current_file = db_path.join("CURRENT");
32+
let needs_bootstrap = sidecar.exists()
33+
|| !current_file.exists()
34+
|| fs::metadata(&current_file)
35+
.as_ref()
36+
.map(fs::Metadata::len)
37+
.unwrap_or(0)
38+
.eq(&0);
39+
40+
if !needs_bootstrap {
41+
return Ok(());
42+
}
43+
44+
let token = server
45+
.config
46+
.rocksdb_replication_token
47+
.as_deref()
48+
.unwrap_or_default();
49+
50+
info!("Pre-open bootstrap: downloading checkpoint from {primary_url}");
51+
52+
let client = Client::builder()
53+
.connect_timeout(Duration::from_secs(10))
54+
.build()
55+
.map_err(|e| err!(Database("Failed to build HTTP client: {e}")))?;
56+
57+
let resp = client
58+
.get(format!("{primary_url}/_tuwunel/replication/checkpoint"))
59+
.header("x-tuwunel-replication-token", token)
60+
.send()
61+
.await
62+
.map_err(|e| err!(Database("Checkpoint request failed: {e}")))?;
63+
64+
if !resp.status().is_success() {
65+
return Err!(Database("Primary returned {} for checkpoint", resp.status()));
66+
}
67+
68+
let seq: u64 = resp
69+
.headers()
70+
.get("x-tuwunel-checkpoint-sequence")
71+
.and_then(|v| v.to_str().ok())
72+
.and_then(|s| s.parse().ok())
73+
.unwrap_or(0);
74+
75+
let tar_bytes: Bytes = resp
76+
.bytes()
77+
.await
78+
.map_err(|e| err!(Database("Reading checkpoint body: {e}")))?;
79+
80+
let parent = db_path.parent().unwrap_or(&db_path);
81+
let staging = parent.join("_replication_staging");
82+
let backup = parent.join("_replication_backup");
83+
84+
if staging.exists() {
85+
fs::remove_dir_all(&staging).map_err(|e| err!(Database("Removing staging dir: {e}")))?;
86+
}
87+
88+
fs::create_dir_all(&staging).map_err(|e| err!(Database("Creating staging dir: {e}")))?;
89+
90+
let cursor = Cursor::new(&*tar_bytes);
91+
let mut archive = Archive::new(cursor);
92+
archive
93+
.unpack(&staging)
94+
.map_err(|e| err!(Database("Unpacking checkpoint: {e}")))?;
95+
96+
let checkpoint_src = staging.join("checkpoint");
97+
98+
if backup.exists() {
99+
fs::remove_dir_all(&backup).map_err(|e| err!(Database("Removing backup: {e}")))?;
100+
}
101+
102+
if db_path.exists() {
103+
fs::rename(&db_path, &backup).map_err(|e| err!(Database("Moving db to backup: {e}")))?;
104+
}
105+
106+
fs::rename(&checkpoint_src, &db_path)
107+
.map_err(|e| err!(Database("Moving checkpoint to db_path: {e}")))?;
108+
109+
fs::remove_dir_all(&staging).ok();
110+
111+
fs::write(&sidecar, seq.to_string())
112+
.map_err(|e| err!(Database("Writing bootstrap sidecar: {e}")))?;
113+
114+
info!("Pre-open bootstrap complete; resume_seq = {seq}. RocksDB will open clean checkpoint.");
115+
116+
Ok(())
117+
}

src/service/replication/mod.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
//! -> on demote(url): exit standby, bootstrap from new primary, resume stream
2323
//! ```
2424
25+
mod bootstrap;
26+
2527
use std::{
2628
fs,
2729
sync::{
@@ -39,7 +41,9 @@ use tokio::{
3941
};
4042
use tuwunel_core::{Err, Result, Server, err, error, expected, implement, info, warn};
4143
use tuwunel_database::{Database, WalFrame, is_wal_gap_error};
44+
use url::Url;
4245

46+
pub use self::bootstrap::maybe_bootstrap_checkpoint;
4347
use crate::{
4448
service::{Args, make_name},
4549
services::OnceServices,
@@ -62,7 +66,7 @@ pub struct Service {
6266

6367
/// Runtime-overridden primary URL set by `demote()`. Takes precedence over
6468
/// `config.rocksdb_primary_url` when set.
65-
dynamic_primary_url: RwLock<Option<String>>,
69+
dynamic_primary_url: RwLock<Option<Url>>,
6670

6771
/// Wakes the standby loop immediately when `demote()` is called.
6872
demote_notify: Notify,
@@ -282,7 +286,7 @@ pub fn is_promoted(&self) -> bool { self.promoted.load(Ordering::Acquire) }
282286
///
283287
/// Returns `Err` if the instance is not currently promoted.
284288
#[implement(Service)]
285-
pub async fn demote(&self, new_primary_url: String) -> Result {
289+
pub async fn demote(&self, new_primary_url: Url) -> Result {
286290
if !self.promoted.load(Ordering::Acquire) {
287291
return Err!(Database("This instance is not currently promoted; cannot demote."));
288292
}
@@ -304,7 +308,7 @@ pub async fn demote(&self, new_primary_url: String) -> Result {
304308
/// Stream WAL frames from the primary until disconnect, promotion, or
305309
/// error.
306310
#[implement(Service)]
307-
async fn run_stream(&self, primary_url: &str) -> Result {
311+
async fn run_stream(&self, primary_url: &Url) -> Result {
308312
let resume_seq = self.get_replication_resume_seq()?;
309313
let url = format!("{primary_url}/_tuwunel/replication/wal?since={resume_seq}");
310314
let resp = self

src/service/services.rs

Lines changed: 0 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ pub struct Services {
7373

7474
#[implement(Services)]
7575
pub async fn build(server: Arc<Server>) -> Result<Arc<Self>> {
76-
Self::maybe_bootstrap_checkpoint(&server).await?;
7776
let db = Database::open(&server).await?;
7877
let services = Arc::new(OnceServices::default());
7978
let args = Args {
@@ -275,106 +274,3 @@ pub async fn memory_usage(&self) -> Result<String> {
275274
})
276275
.await
277276
}
278-
279-
#[implement(Services)]
280-
async fn maybe_bootstrap_checkpoint(server: &Arc<Server>) -> Result {
281-
use bytes::Bytes;
282-
283-
let primary_url = match server.config.rocksdb_primary_url.as_ref() {
284-
| Some(url) => url.clone(),
285-
| None => return Ok(()),
286-
};
287-
288-
let db_path = server.config.database_path.clone();
289-
let sidecar = db_path
290-
.parent()
291-
.unwrap_or(&db_path)
292-
.join("_replication_needs_bootstrap");
293-
294-
let current_file = db_path.join("CURRENT");
295-
let needs_bootstrap = sidecar.exists() || !current_file.exists() || {
296-
std::fs::metadata(&current_file)
297-
.map(|m| m.len() == 0)
298-
.unwrap_or(true)
299-
};
300-
301-
if !needs_bootstrap {
302-
return Ok(());
303-
}
304-
305-
let token = server
306-
.config
307-
.rocksdb_replication_token
308-
.as_deref()
309-
.unwrap_or("");
310-
311-
info!("Pre-open bootstrap: downloading checkpoint from {primary_url}");
312-
313-
let client = reqwest::Client::builder()
314-
.connect_timeout(std::time::Duration::from_secs(10))
315-
.build()
316-
.map_err(|e| tuwunel_core::err!(Database("Failed to build HTTP client: {e}")))?;
317-
318-
let resp = client
319-
.get(format!("{primary_url}/_tuwunel/replication/checkpoint"))
320-
.header("x-tuwunel-replication-token", token)
321-
.send()
322-
.await
323-
.map_err(|e| tuwunel_core::err!(Database("Checkpoint request failed: {e}")))?;
324-
325-
if !resp.status().is_success() {
326-
return Err(tuwunel_core::err!(Database(
327-
"Primary returned {} for checkpoint",
328-
resp.status()
329-
)));
330-
}
331-
332-
let seq: u64 = resp
333-
.headers()
334-
.get("x-tuwunel-checkpoint-sequence")
335-
.and_then(|v| v.to_str().ok())
336-
.and_then(|s| s.parse().ok())
337-
.unwrap_or(0);
338-
339-
let tar_bytes: Bytes = resp
340-
.bytes()
341-
.await
342-
.map_err(|e| tuwunel_core::err!(Database("Reading checkpoint body: {e}")))?;
343-
344-
let parent = db_path.parent().unwrap_or(&db_path);
345-
let staging = parent.join("_replication_staging");
346-
let backup = parent.join("_replication_backup");
347-
348-
if staging.exists() {
349-
std::fs::remove_dir_all(&staging)
350-
.map_err(|e| tuwunel_core::err!(Database("Removing staging dir: {e}")))?;
351-
}
352-
std::fs::create_dir_all(&staging)
353-
.map_err(|e| tuwunel_core::err!(Database("Creating staging dir: {e}")))?;
354-
355-
let cursor = std::io::Cursor::new(&*tar_bytes);
356-
let mut archive = tar::Archive::new(cursor);
357-
archive
358-
.unpack(&staging)
359-
.map_err(|e| tuwunel_core::err!(Database("Unpacking checkpoint: {e}")))?;
360-
361-
let checkpoint_src = staging.join("checkpoint");
362-
363-
if backup.exists() {
364-
std::fs::remove_dir_all(&backup)
365-
.map_err(|e| tuwunel_core::err!(Database("Removing backup: {e}")))?;
366-
}
367-
if db_path.exists() {
368-
std::fs::rename(&db_path, &backup)
369-
.map_err(|e| tuwunel_core::err!(Database("Moving db to backup: {e}")))?;
370-
}
371-
std::fs::rename(&checkpoint_src, &db_path)
372-
.map_err(|e| tuwunel_core::err!(Database("Moving checkpoint to db_path: {e}")))?;
373-
374-
let _: std::io::Result<()> = std::fs::remove_dir_all(&staging);
375-
std::fs::write(&sidecar, seq.to_string())
376-
.map_err(|e| tuwunel_core::err!(Database("Writing bootstrap sidecar: {e}")))?;
377-
378-
info!("Pre-open bootstrap complete; resume_seq = {seq}. RocksDB will open clean checkpoint.");
379-
Ok(())
380-
}

tuwunel-example.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,19 +1130,19 @@
11301130
# `rocksdb_secondary` is true and the primary DB is not on a shared
11311131
# filesystem. Must be a writable directory local to this host.
11321132
#
1133-
#rocksdb_secondary_path = false
1133+
#rocksdb_secondary_path =
11341134

11351135
# URL of the primary instance for WAL-streaming replication.
11361136
# Example: `https://primary.example.com`
11371137
# Required on secondary instances that use WAL streaming.
11381138
#
1139-
#rocksdb_primary_url = false
1139+
#rocksdb_primary_url =
11401140

11411141
# Shared secret token for replication endpoint authentication.
11421142
# Both primary and secondary must have the same value.
11431143
# Leave unset to disable the replication HTTP endpoints entirely.
11441144
#
1145-
#rocksdb_replication_token = false
1145+
#rocksdb_replication_token =
11461146

11471147
# How long (in seconds) the primary retains WAL segments beyond what
11481148
# local recovery requires. Gives the secondary a window to reconnect

0 commit comments

Comments
 (0)