Skip to content

Commit 2c41609

Browse files
perf(persistence): move WAL v3 fsync off the shard event loop (#238)
WalWriterV3::flush_sync() ran fdatasync synchronously on the shard event-loop thread — every fsync stalled the shard (no SPSC drain, no conn I/O, no CDC fan-out until the disk acks). GCE pd baseline (tmp/WALV3-OFFLOOP-FSYNC.md §8, c2d-standard-16, 3 reps): the everysec 1s timer froze every connection on the shard for 10-16 ms once per second whenever the WAL held real bytes, and appendfsync=always paid -20% RPS / ~2x tail (p999 5.4ms -> 9.8ms) on top of the AOF cost. Design (spec §3, Option A — fsync offload with a durable-LSN watermark): the writer keeps encode/buffer/page-cache-write/rotate/recycle on the shard thread; ONLY the fsync moves. - New src/persistence/wal_v3/sync_agent.rs: per-shard WalSyncAgent std::thread receives fd-dup'd SyncRequests over flume::bounded(8) and publishes a monotonic durable-LSN watermark (AtomicU64 fetch_max) after each successful fdatasync. fd-dup shares the file description, and write_all happens-before try_clone on the shard thread, so each fsync covers all previously written bytes; rotation stays safe because rotate_segment already fsyncs the old segment inline before switching. - WalWriterV3::request_sync(): non-blocking initiation. Queue-full / dup-failure / agent-spawn-failure all fall back to inline fsync — a durability request is never dropped. Agent spawn is lazy (first call) and never retried after a failure (warn once, inline forever). - WalWriterV3::wait_durable(lsn, timeout): bounded blocking wait, used ONLY by the two checkpoint ordering invariants and shutdown: * log-before-data (flush_dirty_pages page gate, persistence_tick) * WAL-before-manifest (checkpoint Finalize now waits on the actual checkpoint record LSN before manifest.commit()) Both bounded by WAIT_DURABLE_TIMEOUT (5s); failure aborts that checkpoint step (retried next tick) so redo_lsn never advances past durability. - Failure policy: an fsync error POISONS the agent permanently (POSIX post-error fsync semantics are undefined) with tracing::error!; subsequent request_sync/wait_durable fail loudly and the checkpoint stalls rather than silently opening a data-loss window. - Call sites: timers::sync_wal_v3 (everysec) and both runtimes' appendfsync=always per-drain-batch syncs use request_sync(); shutdown paths keep inline flush_sync() (which now also publishes the watermark, as does rotation). everysec semantics become "sync initiated every 1s, durable typically ms later" — the same window the AOF everysec writers provide. Testing (red/green per tmp/WALV3-OFFLOOP-FSYNC.md §4): - 7 unit tests in sync_agent.rs with a gated/failing injectable fsync backend: watermark-advances-only-after-fsync, wait-blocks-then- returns, wait-timeout, poison-fails-loud (wakes parked waiters, never publishes, refuses new requests), backpressure-hands-request-back, drop-drains-pending-syncs, watermark-monotonic. - 2 writer-level tests in segment.rs: request_sync+wait_durable covers all appends (bytes verified on disk), wait_durable fast paths. - tests/loom_wal_sync_agent.rs: loom model of the watermark/poison monitor (no lost wakeup: publisher takes the mutex before notify, waiter re-checks under it) + std smoke variant when not under loom. Gates (this worktree): fmt clean; clippy 0 warnings both feature sets (1 pre-existing vendored-monoio warning); cargo test --lib 3864 passed (monoio) + 3143 passed (tokio); cargo test --no-run compiles all targets on both feature sets. Refs: tmp/WALV3-OFFLOOP-FSYNC.md (spec + GCE baseline), stacked on refactor/wal-v3-only (PR #236). author: Tin Dang Co-authored-by: Tin Dang <tindang.ht97@gmail.com>
1 parent 340dbdd commit 2c41609

8 files changed

Lines changed: 854 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
### Changed — WAL v3 fsync moved off the shard event loop (PR #TBD)
10+
11+
- **`src/persistence/wal_v3/sync_agent.rs` (new)**: per-shard `WalSyncAgent`
12+
thread receives fd-dup'd sync requests over a bounded flume channel and
13+
publishes a monotonic durable-LSN watermark after each `fdatasync`.
14+
`WalWriterV3` gains `request_sync()` (non-blocking initiation; queue-full
15+
falls back to inline fsync — a durability request is never dropped) and
16+
`wait_durable(lsn, timeout)` (bounded blocking wait, used only by the two
17+
checkpoint ordering invariants and shutdown). An fsync error poisons the
18+
agent permanently and fails subsequent syncs loudly; the checkpoint then
19+
refuses to advance `redo_lsn`.
20+
- **Why**: `flush_sync()` ran `fdatasync` on the shard event-loop thread.
21+
Measured on GCE pd (tmp/WALV3-OFFLOOP-FSYNC.md): the everysec 1s timer
22+
froze every connection on the shard for **10–16 ms once per second** when
23+
the WAL held real bytes, and `appendfsync always` paid −20% RPS / ~2× tail
24+
on top of the AOF cost.
25+
- **Call sites**: everysec timer (`timers::sync_wal_v3`) and the always-mode
26+
per-drain-batch sync (both runtimes) now use `request_sync()`; the
27+
checkpoint log-before-data page gate and WAL-before-manifest finalize use
28+
`wait_durable` (5s bound); shutdown paths keep the inline `flush_sync()`.
29+
everysec semantics are now "sync initiated every 1s, durable typically ms
30+
later" — the same window the AOF everysec writers provide.
31+
- Loom model for the watermark/poison state machine in
32+
`tests/loom_wal_sync_agent.rs`.
33+
934
### Changed — consolidated dependency bumps wave 2 (PR #TBD, supersedes dependabot #223–227)
1035

1136
- Patch-level bumps rolled into one `Cargo.lock` update (no `Cargo.toml`

src/persistence/wal_v3/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
pub mod record;
44
pub mod replay;
55
pub mod segment;
6+
pub(crate) mod sync_agent;
67
pub mod tail;
78

89
pub use record::{WalRecord, WalRecordType, read_wal_v3_record, write_wal_v3_record};

src/persistence/wal_v3/segment.rs

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,18 @@ pub struct WalWriterV3 {
124124
min_wal_bytes: u64,
125125
/// Maximum WAL size in bytes before aggressive recycling (design section 5.5: 256MB default).
126126
max_wal_bytes: u64,
127+
/// Off-loop fsync agent (spawned lazily on the first `request_sync`).
128+
/// See `sync_agent` module docs + tmp/WALV3-OFFLOOP-FSYNC.md.
129+
sync_agent: Option<super::sync_agent::WalSyncAgent>,
130+
/// Set when agent spawn failed once — never retried (inline fsync
131+
/// fallback, logged once).
132+
sync_agent_unavailable: bool,
127133
}
128134

135+
/// Bound on every blocking durability wait (checkpoint ordering gates,
136+
/// shutdown drain). Design-for-failure: no unbounded waits.
137+
pub const WAIT_DURABLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
138+
129139
impl WalWriterV3 {
130140
/// Create a new WAL v3 writer for the given shard.
131141
///
@@ -158,6 +168,8 @@ impl WalWriterV3 {
158168
epoch: 0,
159169
min_wal_bytes: DEFAULT_MIN_WAL_BYTES,
160170
max_wal_bytes: DEFAULT_MAX_WAL_BYTES,
171+
sync_agent: None,
172+
sync_agent_unavailable: false,
161173
};
162174

163175
writer.open_new_segment()?;
@@ -203,17 +215,111 @@ impl WalWriterV3 {
203215
Ok(())
204216
}
205217

206-
/// Flush the in-memory buffer to disk and fsync.
218+
/// Flush the in-memory buffer to disk and fsync — INLINE, on the caller's
219+
/// thread. Kept for shutdown paths and as the no-agent fallback; latency-
220+
/// sensitive callers use [`Self::request_sync`] instead.
207221
///
208222
/// After this returns, all appended records are durable on stable storage.
209223
pub fn flush_sync(&mut self) -> std::io::Result<()> {
210224
self.flush_write()?;
211225
if let Some(ref mut file) = self.current_file {
212226
file.sync_data()?;
213227
}
228+
// Keep the off-loop watermark honest: everything appended so far is
229+
// now durable (fetch_max — never regresses a higher agent publish).
230+
if let Some(agent) = &self.sync_agent {
231+
agent.shared.publish(self.next_lsn.saturating_sub(1));
232+
}
214233
Ok(())
215234
}
216235

236+
/// Spawn the sync agent on first use; on failure, log once and fall
237+
/// back to inline fsync forever (never retried per call).
238+
fn spawn_agent_if_needed(&mut self) {
239+
if self.sync_agent.is_none() && !self.sync_agent_unavailable {
240+
match super::sync_agent::WalSyncAgent::spawn(self.shard_id) {
241+
Ok(agent) => self.sync_agent = Some(agent),
242+
Err(e) => {
243+
tracing::warn!(
244+
shard_id = self.shard_id,
245+
"WAL v3 sync agent spawn failed — falling back to \
246+
inline fsync permanently: {e}"
247+
);
248+
self.sync_agent_unavailable = true;
249+
}
250+
}
251+
}
252+
}
253+
254+
/// Initiate durability for everything appended so far WITHOUT blocking
255+
/// on the fsync: write the buffer to the page cache, then hand an
256+
/// fd-dup to the off-loop sync agent. Queue-full / dup-failure fall
257+
/// back to an inline fsync — a durability request is never dropped.
258+
///
259+
/// Errors: propagates page-cache write failures and reports a poisoned
260+
/// agent (a prior off-loop fsync failed — durability can no longer be
261+
/// promised on this WAL; fail loud, never silently degrade).
262+
pub fn request_sync(&mut self) -> std::io::Result<()> {
263+
self.flush_write()?;
264+
let upto_lsn = self.next_lsn.saturating_sub(1);
265+
self.spawn_agent_if_needed();
266+
267+
if let Some(agent) = &self.sync_agent {
268+
if agent.is_poisoned() {
269+
return Err(std::io::Error::other(
270+
"WAL v3 sync agent poisoned by a prior fsync failure",
271+
));
272+
}
273+
if agent.durable_lsn() >= upto_lsn {
274+
return Ok(()); // nothing new since the last durable point
275+
}
276+
let Some(file) = &self.current_file else {
277+
return Ok(());
278+
};
279+
if let Ok(dup) = file.try_clone() {
280+
if agent
281+
.try_send(super::sync_agent::SyncRequest {
282+
file: dup,
283+
upto_lsn,
284+
})
285+
.is_ok()
286+
{
287+
return Ok(());
288+
}
289+
// Queue full (disk is the bottleneck) or agent racing
290+
// poison — fall through to the inline path.
291+
}
292+
}
293+
294+
// Inline fallback: no agent / dup failed / queue full.
295+
self.flush_sync()
296+
}
297+
298+
/// Block until everything up to `lsn` is durable (bounded by `timeout`).
299+
///
300+
/// Used ONLY by the checkpoint ordering invariants (log-before-data,
301+
/// WAL-before-manifest) and shutdown drains — hot paths use
302+
/// [`Self::request_sync`].
303+
pub fn wait_durable(&mut self, lsn: u64, timeout: std::time::Duration) -> std::io::Result<()> {
304+
if lsn == 0 {
305+
return Ok(());
306+
}
307+
if let Some(agent) = &self.sync_agent {
308+
if agent.durable_lsn() >= lsn {
309+
return Ok(());
310+
}
311+
}
312+
// Make sure a sync covering `lsn` is in flight (or completed
313+
// inline, which publishes the watermark itself).
314+
self.request_sync()?;
315+
match &self.sync_agent {
316+
Some(agent) => agent.wait_watermark(lsn, timeout),
317+
// No agent: request_sync went through the inline flush_sync
318+
// path, so durability already holds.
319+
None => Ok(()),
320+
}
321+
}
322+
217323
/// Flush if buffer exceeds a threshold — write only, no fsync.
218324
///
219325
/// Matches WAL v2 pattern: frequent writes to OS page cache,
@@ -445,6 +551,13 @@ impl WalWriterV3 {
445551
}
446552
file.sync_data()?;
447553
}
554+
// The old segment (holding every record < next_lsn) is now durable;
555+
// the off-loop watermark can reflect that. This inline fsync at
556+
// rotation is also what makes the agent's fd-dup scheme safe: a
557+
// sync request only ever needs to cover the CURRENT segment.
558+
if let Some(agent) = &self.sync_agent {
559+
agent.shared.publish(self.next_lsn.saturating_sub(1));
560+
}
448561

449562
self.current_sequence += 1;
450563
crate::admin::metrics_setup::record_wal_rotation();
@@ -726,6 +839,65 @@ mod tests {
726839
assert_eq!(meta.len(), WAL_V3_HEADER_SIZE as u64);
727840
}
728841

842+
#[test]
843+
fn test_request_sync_then_wait_durable_covers_all_appends() {
844+
let tmp = tempfile::tempdir().unwrap();
845+
let wal_dir = tmp.path().join("wal");
846+
let mut writer = WalWriterV3::new(0, &wal_dir, DEFAULT_SEGMENT_SIZE).unwrap();
847+
848+
let mut last = 0;
849+
for i in 0..10u32 {
850+
last = writer.append(WalRecordType::Command, format!("SET k{i} v").as_bytes());
851+
}
852+
// Non-blocking initiation, then a bounded wait must observe
853+
// durability for every appended record.
854+
writer.request_sync().unwrap();
855+
writer
856+
.wait_durable(last, std::time::Duration::from_secs(5))
857+
.unwrap();
858+
859+
// Bytes must actually be on disk (page-cache write happened before
860+
// the agent's fsync request).
861+
let data = fs::read(WalSegment::segment_path(&wal_dir, 1)).unwrap();
862+
let mut offset = WAL_V3_HEADER_SIZE;
863+
let mut count = 0;
864+
while offset < data.len() {
865+
let record = read_wal_v3_record(&data[offset..]).expect("record parses");
866+
// record_len already includes its own 4-byte length prefix.
867+
offset += u32::from_le_bytes([
868+
data[offset],
869+
data[offset + 1],
870+
data[offset + 2],
871+
data[offset + 3],
872+
]) as usize;
873+
count += 1;
874+
assert!(record.lsn <= last);
875+
}
876+
assert_eq!(count, 10);
877+
}
878+
879+
#[test]
880+
fn test_wait_durable_zero_and_already_durable_are_noops() {
881+
let tmp = tempfile::tempdir().unwrap();
882+
let wal_dir = tmp.path().join("wal");
883+
let mut writer = WalWriterV3::new(0, &wal_dir, DEFAULT_SEGMENT_SIZE).unwrap();
884+
// lsn 0 = "nothing to wait for" — must not spawn or block.
885+
writer
886+
.wait_durable(0, std::time::Duration::from_millis(10))
887+
.unwrap();
888+
let lsn = writer.append(WalRecordType::Command, b"SET a 1");
889+
// Inline flush_sync publishes the watermark, so a subsequent
890+
// wait_durable is a fast-path no-op even with an agent spawned.
891+
writer.request_sync().unwrap(); // spawns agent
892+
writer
893+
.wait_durable(lsn, std::time::Duration::from_secs(5))
894+
.unwrap();
895+
writer.flush_sync().unwrap();
896+
writer
897+
.wait_durable(lsn, std::time::Duration::from_millis(10))
898+
.unwrap();
899+
}
900+
729901
#[test]
730902
fn test_writer_append_and_flush() {
731903
let tmp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)