Skip to content

Commit 642cd46

Browse files
committed
Bound queuing thread with hard limit instead of soft
1 parent e375410 commit 642cd46

5 files changed

Lines changed: 466 additions & 39 deletions

File tree

src/bin/stream.rs

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,6 @@ struct Args {
200200
/// `restore_command` reads from here
201201
#[arg(long)]
202202
out_dir: PathBuf,
203-
/// Optional permanent physical slot name on source PG
204-
#[arg(long)]
205-
slot: Option<String>,
206203
/// Start LSN in `X/Y` hex form. Defaults to source's current
207204
/// `pg_current_wal_lsn` (per `IDENTIFY_SYSTEM`), aligned down to a
208205
/// segment boundary.
@@ -518,6 +515,8 @@ async fn run(args: Args) -> Result<()> {
518515
} else {
519516
None
520517
};
518+
// Physical replication slot from config (`[source] slot`); None = slotless.
519+
let source_slot: Option<String> = ch_config.as_ref().and_then(|c| c.source_slot.clone());
521520
let bootstrap_end_lsn: Option<u64> = if matches!(args.bootstrap_mode, BootstrapMode::Off) {
522521
None
523522
} else {
@@ -689,6 +688,14 @@ async fn run(args: Args) -> Result<()> {
689688
.await
690689
.set_invalidation_epoch(invalidation_epoch.clone());
691690

691+
// Create the configured slot before preflight, which requires it to exist.
692+
if let Some(slot) = source_slot.as_deref() {
693+
feed.ensure_physical_slot(slot)
694+
.await
695+
.with_context(|| format!("ensure physical replication slot {slot}"))?;
696+
tracing::info!(target: "walshadow", slot, "physical replication slot ready");
697+
}
698+
692699
// Pre-flight validators run after both source + shadow SQL clients
693700
// are up so every check has its connection.
694701
if !args.skip_preflight {
@@ -708,7 +715,7 @@ async fn run(args: Args) -> Result<()> {
708715
source_version_num,
709716
source_sql,
710717
shadow_sql: &shadow_sql,
711-
slot: args.slot.as_deref(),
718+
slot: source_slot.as_deref(),
712719
ch_config: ch_config.as_ref(),
713720
})
714721
.await
@@ -766,7 +773,7 @@ async fn run(args: Args) -> Result<()> {
766773
}
767774
};
768775

769-
feed.start_physical_replication(args.slot.as_deref(), aligned, ident.timeline)
776+
feed.start_physical_replication(source_slot.as_deref(), aligned, ident.timeline)
770777
.await
771778
.context("START_REPLICATION")?;
772779
// Spill dir wiped every startup: cursor file commits drains
@@ -1194,9 +1201,12 @@ async fn run(args: Args) -> Result<()> {
11941201
.context("write resume cursor")?;
11951202
last_cursor_write = Some(Instant::now());
11961203
}
1204+
// flush drives a physical slot's restart_lsn (what the source retains),
1205+
// so cap it at apply_ceiling: the source keeps WAL until CH has durably
1206+
// applied it (and shadow replayed), never recycling un-consumed WAL.
11971207
let status = StandbyStatus {
11981208
write_lsn: received,
1199-
flush_lsn: durable,
1209+
flush_lsn: durable.min(apply_ceiling),
12001210
apply_lsn: apply_ceiling,
12011211
};
12021212
let dispatched_before = stream.dispatched_lsn();
@@ -1210,10 +1220,34 @@ async fn run(args: Args) -> Result<()> {
12101220
// Idle tick so metrics/cursor keep tracking the draining pipeline
12111221
// when no new WAL arrives.
12121222
_ = tokio::time::sleep(metrics_tick) => None,
1213-
res = feed.next_chunk(status, &mut chunk_buf) => Some(match res? {
1214-
Some(c) => c,
1215-
None => break "CopyDone",
1216-
}),
1223+
res = feed.next_chunk(status, &mut chunk_buf) => match res {
1224+
Ok(Some(c)) => Some(c),
1225+
Ok(None) => break "CopyDone",
1226+
Err(e) => {
1227+
let resume = stream.next_lsn();
1228+
tracing::warn!(
1229+
target: "walshadow",
1230+
error = %e,
1231+
resume_lsn = format_pg_lsn(resume).to_string(),
1232+
"source stream error — recovering",
1233+
);
1234+
feed = reconnect_or_fatal(
1235+
e,
1236+
&cfg,
1237+
source_slot.as_deref(),
1238+
resume,
1239+
ident.timeline,
1240+
Duration::from_secs(args.status_interval),
1241+
)
1242+
.await?;
1243+
tracing::info!(
1244+
target: "walshadow",
1245+
resume_lsn = format_pg_lsn(resume).to_string(),
1246+
"source reconnected — resuming replication",
1247+
);
1248+
None
1249+
}
1250+
},
12171251
};
12181252
let server_end = chunk.as_ref().map(|c| c.server_wal_end).unwrap_or(received);
12191253
if let Some(chunk) = chunk {
@@ -1963,6 +1997,35 @@ async fn populate_metrics(
19631997
registry.set(snap).await;
19641998
}
19651999

2000+
/// Recover from a source stream error. A transient drop reconnects and resumes
2001+
/// at `resume_lsn`; a recycled segment (58P01) is fatal — the resume point is
2002+
/// gone, so the daemon exits and recovery is a re-seed via config
2003+
/// `initial_load` on restart, not an in-place reconnect.
2004+
async fn reconnect_or_fatal(
2005+
e: anyhow::Error,
2006+
cfg: &PgConfig,
2007+
slot: Option<&str>,
2008+
resume_lsn: u64,
2009+
timeline: u32,
2010+
status_interval: Duration,
2011+
) -> Result<SourceFeed> {
2012+
if walshadow::source_feed::is_wal_segment_removed(&e) {
2013+
return Err(e).context(
2014+
"source WAL segment recycled past the resume point; \
2015+
re-seed the affected tables via config initial_load \
2016+
(base_backup/object_store), then restart",
2017+
);
2018+
}
2019+
match SourceFeed::reconnect(cfg, slot, resume_lsn, timeline, status_interval).await {
2020+
Ok(feed) => Ok(feed),
2021+
Err(e2) if walshadow::source_feed::is_wal_segment_removed(&e2) => Err(e2).context(
2022+
"source WAL segment recycled (seen on reconnect); \
2023+
re-seed via config initial_load, then restart",
2024+
),
2025+
Err(e2) => Err(e2),
2026+
}
2027+
}
2028+
19662029
/// Orchestrate BASE_BACKUP into a fresh shadow data dir; returns the
19672030
/// backup's `end_lsn` so the caller rebinds the WAL pump past it.
19682031
///

src/ch_emitter.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ pub struct EmitterConfig {
161161
/// overlay tables. `None` (field empty or omitted) disables the whole
162162
/// overlay subsystem — no boot seed, no config_decoder, pure TOML+CLI.
163163
pub runtime_config_schema: Option<String>,
164+
/// `[source] slot`: physical replication slot to create + stream from on
165+
/// the source. `Some` reserves WAL so a stalled/disconnected consumer
166+
/// resumes without recycling; `None` runs slotless. Boot-only.
167+
pub source_slot: Option<String>,
164168
}
165169

166170
pub const DEFAULT_INSERT_TIMEOUT_SECS: u64 = 30;
@@ -211,6 +215,7 @@ impl Default for EmitterConfig {
211215
toast: crate::toast::ToastConfig::default(),
212216
decode_chunk_rows: crate::pipeline::decode::DECODE_CHUNK_ROWS,
213217
runtime_config_schema: None,
218+
source_slot: None,
214219
}
215220
}
216221
}
@@ -448,6 +453,13 @@ impl EmitterConfig {
448453
// Empty string == omitted == overlay disabled.
449454
out.runtime_config_schema = Some(schema.into());
450455
}
456+
if let Some(src) = root.get("source").and_then(Value::as_table)
457+
&& let Some(slot) = src.get("slot").and_then(Value::as_str)
458+
&& !slot.is_empty()
459+
{
460+
// Empty string == omitted == slotless.
461+
out.source_slot = Some(slot.into());
462+
}
451463
if let Some(nss) = root.get("namespace").and_then(Value::as_table) {
452464
for (k, v) in nss {
453465
let t = v.as_table().ok_or_else(|| {

src/queueing_record_sink.rs

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@ use crate::xact_buffer::TxnSpanRegistry;
4040
/// over the clone-into-owned baseline.
4141
pub const DEFAULT_QUEUEING_BATCH_SIZE: usize = 64;
4242

43-
/// Soft in-flight cap (channel batches + pump buffer). Past it the
44-
/// pump yields so the worker drains. No hard cap: a permanently
45-
/// stalled worker surfaces via the `wait_for_replay` timeout on the
46-
/// shared err slot.
43+
/// Hard in-flight cap (channel batches + pump buffer). The channel is bounded
44+
/// at `max_records / batch_size`, so past it the pump's `send` blocks — real
45+
/// backpressure to the source instead of unbounded RAM growth. Deadlock-safe:
46+
/// shadow is fed by an independent walsender task (+keepalive), so a parked
47+
/// pump can't starve `wait_for_replay`.
4748
pub const DEFAULT_QUEUEING_RECORD_SINK_CAPACITY: usize = 16_384;
4849

4950
/// Worker `on_idle` cadence. Lets CH emitter's hold-INSERT-open
@@ -54,7 +55,7 @@ pub const DEFAULT_QUEUEING_IDLE_INTERVAL: Duration = Duration::from_millis(50);
5455
/// Construct via [`QueueingRecordSink::spawn`].
5556
pub struct QueueingRecordSink {
5657
/// Each batch carries its ship `Instant` for the worker's `queued_ms`.
57-
tx: Option<mpsc::UnboundedSender<(Instant, Vec<Record<'static>>)>>,
58+
tx: Option<mpsc::Sender<(Instant, Vec<Record<'static>>)>>,
5859
/// Pump-side accumulator; shipped as one message at `batch_size` (or `close`).
5960
buf: Vec<Record<'static>>,
6061
batch_size: usize,
@@ -63,7 +64,6 @@ pub struct QueueingRecordSink {
6364
/// Records the worker has dispatched; with `in_flight`, tells a draining
6465
/// queue from a stalled one.
6566
processed: Arc<AtomicU64>,
66-
soft_cap: u64,
6767
worker: Option<JoinHandle<()>>,
6868
/// Per-txn span map; `Some` only with OTLP on. `flush_buf` stamps each
6969
/// shipped record's ship instant (`note_shipped`).
@@ -75,7 +75,7 @@ impl QueueingRecordSink {
7575
pub fn spawn<S>(
7676
inner: S,
7777
batch_size: usize,
78-
soft_cap: usize,
78+
max_records: usize,
7979
span_registry: Option<TxnSpanRegistry>,
8080
) -> Self
8181
where
@@ -84,43 +84,46 @@ impl QueueingRecordSink {
8484
Self::spawn_with_idle(
8585
inner,
8686
batch_size,
87-
soft_cap,
87+
max_records,
8888
DEFAULT_QUEUEING_IDLE_INTERVAL,
8989
span_registry,
9090
)
9191
}
9292

9393
/// Worker owns `inner`, drains batches, dispatches each record.
94-
/// `soft_cap` triggers `yield_now` once in-flight (channel + pump
95-
/// buffer) exceeds it. `idle_interval` paces `inner.on_idle()` on a
96-
/// quiescent channel so time-based observer work (CH emitter's
97-
/// hold-INSERT-open deadline) fires without fresh records.
94+
/// `max_records` bounds records in flight (channel + pump buffer):
95+
/// the channel holds `max_records / batch_size` batches, so a slow
96+
/// worker blocks the pump's send instead of growing without limit.
97+
/// `idle_interval` paces `inner.on_idle()` on a quiescent channel so
98+
/// time-based observer work (CH emitter's hold-INSERT-open deadline)
99+
/// fires without fresh records.
98100
pub fn spawn_with_idle<S>(
99101
mut inner: S,
100102
batch_size: usize,
101-
soft_cap: usize,
103+
max_records: usize,
102104
idle_interval: Duration,
103105
span_registry: Option<TxnSpanRegistry>,
104106
) -> Self
105107
where
106108
S: RecordSink + Send + 'static,
107109
{
108-
let (tx, mut rx) = mpsc::unbounded_channel::<(Instant, Vec<Record<'static>>)>();
110+
let batch_size = batch_size.max(1);
111+
let channel_cap = (max_records / batch_size).max(1);
112+
let (tx, mut rx) = mpsc::channel::<(Instant, Vec<Record<'static>>)>(channel_cap);
109113
let err = Arc::new(StdMutex::new(None));
110114
let in_flight = Arc::new(AtomicU64::new(0));
111115
let processed = Arc::new(AtomicU64::new(0));
112116
let err_w = err.clone();
113117
let in_flight_w = in_flight.clone();
114118
let processed_w = processed.clone();
115119
let reg_w = span_registry.clone();
116-
let batch_size = batch_size.max(1);
117120
let idle_interval = idle_interval.max(Duration::from_millis(1));
118121
let worker = tokio::spawn(async move {
119122
// Park error, drop in-flight (`n`, or 0 on idle path),
120123
// close+drain so `in_flight` settles. Caller breaks after.
121124
let park_err_and_drain = async |e: SinkError,
122125
n: u64,
123-
rx: &mut mpsc::UnboundedReceiver<(
126+
rx: &mut mpsc::Receiver<(
124127
Instant,
125128
Vec<Record<'static>>,
126129
)>| {
@@ -216,7 +219,6 @@ impl QueueingRecordSink {
216219
err,
217220
in_flight,
218221
processed,
219-
soft_cap: soft_cap.max(1) as u64,
220222
worker: Some(worker),
221223
span_registry,
222224
}
@@ -239,10 +241,10 @@ impl QueueingRecordSink {
239241
if let Some(e) = self.take_pending_error() {
240242
return Err(e);
241243
}
242-
self.flush_buf()
244+
self.flush_buf().await
243245
}
244246

245-
fn flush_buf(&mut self) -> Result<(), SinkError> {
247+
async fn flush_buf(&mut self) -> Result<(), SinkError> {
246248
if self.buf.is_empty() {
247249
return Ok(());
248250
}
@@ -260,7 +262,9 @@ impl QueueingRecordSink {
260262
.tx
261263
.as_ref()
262264
.ok_or_else(|| SinkError::Other("queueing record sink already closed".into()))?;
263-
if tx.send((Instant::now(), batch)).is_err() {
265+
// Blocking the pump here is deadlock-safe: shadow is fed by an independent
266+
// walsender task (+keepalive), so a parked pump can't starve wait_for_replay.
267+
if tx.send((Instant::now(), batch)).await.is_err() {
264268
self.in_flight.fetch_sub(n, Ordering::Relaxed);
265269
if let Some(e) = self.take_pending_error() {
266270
return Err(e);
@@ -276,7 +280,7 @@ impl QueueingRecordSink {
276280
/// Call after the pump stops feeding records.
277281
pub async fn close(mut self) -> Result<(), SinkError> {
278282
// Flush tail before dropping sender so worker sees final batch.
279-
self.flush_buf()?;
283+
self.flush_buf().await?;
280284
self.tx.take();
281285
if let Some(handle) = self.worker.take() {
282286
// Treat a worker panic as a sink error so daemon shutdown
@@ -336,12 +340,7 @@ impl RecordSink for QueueingRecordSink {
336340
catalog_signal: record.catalog_signal,
337341
});
338342
if self.buf.len() >= self.batch_size {
339-
self.flush_buf()?;
340-
// Soft backpressure: yield only when actually behind,
341-
// checked at flush time to keep per-record cost low.
342-
if self.in_flight.load(Ordering::Relaxed) > self.soft_cap {
343-
tokio::task::yield_now().await;
344-
}
343+
self.flush_buf().await?;
345344
}
346345
Ok(())
347346
})

0 commit comments

Comments
 (0)