Skip to content

Commit 1dedf15

Browse files
committed
feat(gateway): let an operator bound what a machine loss costs the KV log
1 parent cb51d2c commit 1dedf15

5 files changed

Lines changed: 141 additions & 18 deletions

File tree

dstack/gateway/gateway.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,21 @@ bootnode = ""
175175
data_dir = "/dstack-gateway/data"
176176
# Interval for periodic persistence of WaveKV data (e.g., "5s", "1m", "1h")
177177
persist_interval = "5m"
178+
# How long a KV write may sit in the page cache before the write-ahead log is
179+
# forced to disk.
180+
#
181+
# An fsync costs about 100us on an NVMe host and milliseconds on the virtio disk
182+
# a CVM gets, and it runs under the store lock, so paying one per write bounds
183+
# how fast this gateway can accept registrations and stalls its readers while it
184+
# does. Deferring it bounds what losing the *machine* costs — writes made in the
185+
# last window — and changes nothing about losing the process: a panic, an OOM
186+
# kill or a restart loses nothing either way, because the bytes are already with
187+
# the kernel.
188+
#
189+
# A cluster recovers the window from its peers on restart. A single-node gateway
190+
# does not, which is the case for setting this to "0s" — force every write
191+
# before it returns, as releases before WaveKV 2.1 always did.
192+
wal_sync_interval = "5s"
178193
# Enable periodic sync of instance connections to KV store
179194
sync_connections_enabled = true
180195
# Interval for syncing instance connections to KV store

dstack/gateway/src/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,10 @@ pub struct SyncConfig {
543543
/// Interval for periodic WAL persistence (default: 10s)
544544
#[serde(with = "serde_duration")]
545545
pub persist_interval: Duration,
546+
/// How long a KV write may sit in the page cache before the write-ahead log
547+
/// is forced to disk. Zero forces every write before it returns.
548+
#[serde(with = "serde_duration")]
549+
pub wal_sync_interval: Duration,
546550
/// Enable periodic sync of instance connections to KV store
547551
pub sync_connections_enabled: bool,
548552
/// Interval for syncing instance connections to KV store

dstack/gateway/src/distributed_certbot.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -807,15 +807,16 @@ mod tests {
807807

808808
fn test_certbot(data_dir: &std::path::Path) -> DistributedCertBot {
809809
let kv_store =
810-
Arc::new(KvStore::new(1, vec![], data_dir).expect("failed to create kv store"));
810+
Arc::new(KvStore::new(1, vec![], data_dir, None).expect("failed to create kv store"));
811811
DistributedCertBot::new(kv_store, Arc::new(CertResolver::new()), None)
812812
}
813813

814814
#[test]
815815
fn lock_writes_wake_the_persistent_push_path() {
816816
let data_dir = tempfile::tempdir().expect("failed to create temp dir");
817-
let kv_store =
818-
Arc::new(KvStore::new(1, vec![], data_dir.path()).expect("failed to create kv store"));
817+
let kv_store = Arc::new(
818+
KvStore::new(1, vec![], data_dir.path(), None).expect("failed to create kv store"),
819+
);
819820
let notifier = Arc::new(CountingNotifier::default());
820821
let certbot = DistributedCertBot::new(
821822
kv_store,

dstack/gateway/src/kv/mod.rs

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -688,13 +688,28 @@ impl KvStore {
688688
/// operator, which for a single-node deployment holding the only copy of
689689
/// the ACME account and DNS credentials is the difference between a restart
690690
/// and a rebuild.
691+
///
692+
/// `wal_sync_interval` is how long a write may sit in the page cache before
693+
/// the log is forced to disk; `None` forces every write before it returns.
694+
/// It applies only to the persistent store — the ephemeral one keeps no log
695+
/// and never touches the disk.
691696
pub fn new(
692697
my_node_id: NodeId,
693698
peer_ids: Vec<NodeId>,
694699
data_dir: impl AsRef<Path>,
700+
wal_sync_interval: Option<Duration>,
695701
) -> Result<Self> {
696702
let data_dir = data_dir.as_ref();
697-
let persistent = match Node::new_with_persistence(my_node_id, peer_ids.clone(), data_dir) {
703+
let node_config = wavekv::NodeConfig {
704+
wal_sync_interval,
705+
..Default::default()
706+
};
707+
let persistent = match Node::with_persistence_and_config(
708+
my_node_id,
709+
peer_ids.clone(),
710+
data_dir,
711+
node_config.clone(),
712+
) {
698713
Ok(node) => node,
699714
Err(err) if is_storage_failure(&err) => {
700715
return Err(err).with_context(|| {
@@ -721,8 +736,13 @@ impl KvStore {
721736
data_dir.display(),
722737
quarantined.display(),
723738
);
724-
Node::new_with_persistence(my_node_id, peer_ids.clone(), data_dir)
725-
.context("failed to create persistent wavekv node on a fresh data dir")?
739+
Node::with_persistence_and_config(
740+
my_node_id,
741+
peer_ids.clone(),
742+
data_dir,
743+
node_config,
744+
)
745+
.context("failed to create persistent wavekv node on a fresh data dir")?
726746
}
727747
};
728748

@@ -1033,6 +1053,15 @@ impl KvStore {
10331053
self.persistent.persist_if_dirty()
10341054
}
10351055

1056+
/// Force the write-ahead log to disk if the configured window has elapsed.
1057+
///
1058+
/// Returns whether an fsync happened. A no-op when no window is configured
1059+
/// — every write was already forced — or when nothing has been written
1060+
/// since the last one, so an idle gateway costs a lock acquisition.
1061+
pub fn sync_wal_if_due(&self) -> Result<bool> {
1062+
self.persistent.sync_wal_if_due()
1063+
}
1064+
10361065
// ==================== Peer Management ====================
10371066

10381067
pub fn add_peer(&self, peer_id: NodeId) -> Result<()> {
@@ -1625,7 +1654,7 @@ mod acme_credentials_tests {
16251654
use super::*;
16261655

16271656
fn test_kv(data_dir: &std::path::Path) -> KvStore {
1628-
KvStore::new(1, vec![], data_dir).expect("failed to create kv store")
1657+
KvStore::new(1, vec![], data_dir, None).expect("failed to create kv store")
16291658
}
16301659

16311660
#[test]
@@ -1772,7 +1801,7 @@ mod value_encoding_tests {
17721801
#[test]
17731802
fn legacy_positional_records_are_still_readable() {
17741803
let dir = tempfile::tempdir().expect("failed to create temp dir");
1775-
let kv = KvStore::new(1, vec![], dir.path()).expect("failed to create kv store");
1804+
let kv = KvStore::new(1, vec![], dir.path(), None).expect("failed to create kv store");
17761805

17771806
let legacy = rmp_serde::encode::to_vec(&CertRenewLock {
17781807
started_at: 1_700_000_000,
@@ -1836,6 +1865,51 @@ mod value_encoding_tests {
18361865
}
18371866
}
18381867

1868+
/// The configured window has to reach the store, not just the config file.
1869+
///
1870+
/// An fsync per write runs under the store lock, so it bounds how fast this
1871+
/// gateway accepts registrations; the window is what buys that back, and it
1872+
/// buys nothing if the interval stops at `SyncConfig`.
1873+
#[test]
1874+
fn a_configured_window_holds_writes_out_of_the_disk_until_it_elapses() {
1875+
let dir = tempfile::tempdir().expect("failed to create temp dir");
1876+
let window = Duration::from_millis(20);
1877+
let kv = KvStore::new(1, vec![], dir.path(), Some(window)).expect("kv store");
1878+
1879+
kv.set_node_status(1, NodeStatus::Up).expect("write");
1880+
assert_eq!(
1881+
kv.persistent().read().wal_sync_count(),
1882+
0,
1883+
"a write inside the window must not reach the disk"
1884+
);
1885+
assert!(
1886+
!kv.sync_wal_if_due().expect("sync check"),
1887+
"nothing is due before the window elapses"
1888+
);
1889+
1890+
std::thread::sleep(window * 3);
1891+
assert!(kv.sync_wal_if_due().expect("sync"), "the window elapsed");
1892+
assert_eq!(kv.persistent().read().wal_sync_count(), 1);
1893+
}
1894+
1895+
/// Without a window every write is on the disk before it returns, which is
1896+
/// what every release before this one did and what a single-node gateway
1897+
/// holding the only copy of its ACME account still wants.
1898+
#[test]
1899+
fn without_a_window_every_write_reaches_the_disk_before_it_returns() {
1900+
let dir = tempfile::tempdir().expect("failed to create temp dir");
1901+
let kv = KvStore::new(1, vec![], dir.path(), None).expect("kv store");
1902+
1903+
kv.set_node_status(1, NodeStatus::Up).expect("write");
1904+
kv.set_node_status(2, NodeStatus::Down).expect("write");
1905+
1906+
assert_eq!(kv.persistent().read().wal_sync_count(), 2);
1907+
assert!(
1908+
!kv.sync_wal_if_due().expect("sync check"),
1909+
"with no window there is never anything owing"
1910+
);
1911+
}
1912+
18391913
/// An instance record as some later release will declare it.
18401914
#[derive(Debug, Serialize, Deserialize)]
18411915
struct FutureInstanceData {
@@ -1854,7 +1928,7 @@ mod value_encoding_tests {
18541928
#[test]
18551929
fn a_record_keeps_the_fields_its_writer_does_not_know() {
18561930
let dir = tempfile::tempdir().expect("failed to create temp dir");
1857-
let kv = KvStore::new(1, vec![], dir.path()).expect("failed to create kv store");
1931+
let kv = KvStore::new(1, vec![], dir.path(), None).expect("failed to create kv store");
18581932

18591933
let written_by_a_newer_node = encode(&FutureInstanceData {
18601934
app_id: "app".to_string(),
@@ -1913,7 +1987,7 @@ mod value_encoding_tests {
19131987
#[test]
19141988
fn a_snapshot_does_not_inherit_the_previous_writes_fields() {
19151989
let dir = tempfile::tempdir().expect("failed to create temp dir");
1916-
let kv = KvStore::new(1, vec![], dir.path()).expect("failed to create kv store");
1990+
let kv = KvStore::new(1, vec![], dir.path(), None).expect("failed to create kv store");
19171991

19181992
#[derive(Debug, Serialize, Deserialize)]
19191993
struct FutureCertData {
@@ -1973,7 +2047,7 @@ mod sync_wire_tests {
19732047
use wavekv::sync::SyncEnvelope;
19742048

19752049
fn store(dir: &std::path::Path, id: NodeId, peers: Vec<NodeId>) -> KvStore {
1976-
KvStore::new(id, peers, dir).expect("failed to create kv store")
2050+
KvStore::new(id, peers, dir, None).expect("failed to create kv store")
19772051
}
19782052

19792053
#[test]
@@ -2048,7 +2122,8 @@ mod wavekv_v1_migration_tests {
20482122
.expect("write trailing v1 WAL entry");
20492123
}
20502124

2051-
let upgraded = KvStore::new(1, Vec::new(), dir.path()).expect("open v1 data after upgrade");
2125+
let upgraded =
2126+
KvStore::new(1, Vec::new(), dir.path(), None).expect("open v1 data after upgrade");
20522127
assert_eq!(
20532128
upgraded
20542129
.persistent()
@@ -2078,7 +2153,8 @@ mod wavekv_v1_migration_tests {
20782153
upgraded.persist_if_dirty().expect("persist upgraded data");
20792154
drop(upgraded);
20802155

2081-
let restarted = KvStore::new(1, Vec::new(), dir.path()).expect("restart upgraded store");
2156+
let restarted =
2157+
KvStore::new(1, Vec::new(), dir.path(), None).expect("restart upgraded store");
20822158
for (key, expected) in [(key, value), (wal_key, wal_value), (new_key, new_value)] {
20832159
assert_eq!(
20842160
restarted
@@ -2131,7 +2207,7 @@ mod corruption_tests {
21312207
use super::*;
21322208

21332209
fn test_kv(data_dir: &std::path::Path) -> KvStore {
2134-
KvStore::new(1, vec![], data_dir).expect("failed to create kv store")
2210+
KvStore::new(1, vec![], data_dir, None).expect("failed to create kv store")
21352211
}
21362212

21372213
fn put_raw(kv: &KvStore, key: &str, value: &[u8]) {
@@ -2245,7 +2321,7 @@ mod corruption_tests {
22452321
let data_dir = dir.path().join("kv");
22462322
std::fs::write(&data_dir, b"not a directory").expect("failed to create blocker");
22472323

2248-
let Err(err) = KvStore::new(1, vec![], &data_dir) else {
2324+
let Err(err) = KvStore::new(1, vec![], &data_dir, None) else {
22492325
panic!("startup must fail when the storage is unusable");
22502326
};
22512327
assert!(
@@ -2285,7 +2361,8 @@ mod corruption_tests {
22852361
// replicated, so it must not keep the gateway from booting.
22862362
std::fs::write(data_dir.join("node_1.wal"), b"garbage").expect("failed to corrupt wal");
22872363

2288-
let kv = KvStore::new(1, vec![], &data_dir).expect("startup must survive a corrupt wal");
2364+
let kv =
2365+
KvStore::new(1, vec![], &data_dir, None).expect("startup must survive a corrupt wal");
22892366
let loaded = kv.load_all_instances();
22902367
assert!(loaded.decoded.is_empty());
22912368
assert!(loaded.undecodable.is_empty());

dstack/gateway/src/main_service.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,13 @@ impl ProxyInner {
263263

264264
// Initialize WaveKV store without peers (peers will be added dynamically from bootnode)
265265
let kv_store = Arc::new(
266-
KvStore::new(config.sync.node_id, vec![], &config.sync.data_dir)
267-
.context("failed to initialize WaveKV store")?,
266+
KvStore::new(
267+
config.sync.node_id,
268+
vec![],
269+
&config.sync.data_dir,
270+
(!config.sync.wal_sync_interval.is_zero()).then_some(config.sync.wal_sync_interval),
271+
)
272+
.context("failed to initialize WaveKV store")?,
268273
);
269274
info!(
270275
"WaveKV store initialized: node_id={}, sync_enabled={}",
@@ -1035,6 +1040,27 @@ fn start_wavekv_watch_task(proxy: Proxy) -> Result<()> {
10351040
info!("WaveKV: periodic persistence enabled (interval: {persist_interval:?})");
10361041
}
10371042

1043+
// Force the write-ahead log on the window the operator configured. Nothing
1044+
// else forces it on a schedule — a snapshot does, but that is minutes apart
1045+
// — so without this the window would be a hope rather than a bound. Ticking
1046+
// at the window itself is enough to make it one: the store measures from
1047+
// its last fsync, so a write is forced at the first tick that finds the
1048+
// window elapsed, never more than one window after it landed.
1049+
let wal_sync_interval = proxy.config.sync.wal_sync_interval;
1050+
if !wal_sync_interval.is_zero() {
1051+
let kv_store_for_wal = kv_store.clone();
1052+
tokio::spawn(async move {
1053+
let mut ticker = tokio::time::interval(wal_sync_interval);
1054+
loop {
1055+
ticker.tick().await;
1056+
if let Err(err) = kv_store_for_wal.sync_wal_if_due() {
1057+
error!("WaveKV: forcing the write-ahead log failed: {err:?}");
1058+
}
1059+
}
1060+
});
1061+
info!("WaveKV: deferred WAL sync enabled (window: {wal_sync_interval:?})");
1062+
}
1063+
10381064
// Start periodic connection sync task
10391065
if proxy.config.sync.sync_connections_enabled {
10401066
let sync_interval = proxy.config.sync.sync_connections_interval;

0 commit comments

Comments
 (0)