Skip to content

Commit d12d981

Browse files
Merge pull request #72 from ArcInstitute/fix/threadwriter-backpressure
fix(dump): enforce projected buffer cap in ThreadWriter::ingest
2 parents dd7785d + 7b89dea commit d12d981

1 file changed

Lines changed: 240 additions & 24 deletions

File tree

src/dump/output.rs

Lines changed: 240 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
use std::io::Write;
22
use std::sync::Arc;
33
use std::thread;
4-
use std::time::Duration;
54

6-
use anyhow::Result;
5+
use anyhow::{bail, Result};
76
use parking_lot::Condvar;
87
use parking_lot::Mutex;
98

@@ -19,9 +18,6 @@ const DEFAULT_BUFFER_SIZE: usize = 1024 * 1024;
1918
/// Set the maximum overflow buffer size to 128MB
2019
const MAXIMUM_BUFFER_SIZE: usize = 128 * 1024 * 1024;
2120

22-
/// Sets the default sleep time (in milliseconds)
23-
const DEFAULT_SLEEP_MS: u64 = 100;
24-
2521
/// A shorthand for the type of output handles we expect to write
2622
pub type BoxedWriter = Box<dyn Write + Send>;
2723

@@ -104,6 +100,10 @@ struct WriterState {
104100
/// Set to `true` once the owning [`ThreadWriter`] is dropped, signalling that
105101
/// no further data will be ingested.
106102
closed: bool,
103+
/// Set if the worker thread terminates due to an I/O error. Producers parked
104+
/// in [`ThreadWriter::ingest`] observe this and return an error instead of
105+
/// blocking forever on a worker that will never notify again.
106+
error: Option<String>,
107107
}
108108

109109
/// A thead-local writer that owns a subprocess handling the actual writing
@@ -120,6 +120,7 @@ impl ThreadWriter {
120120
Mutex::new(WriterState {
121121
buffer: Vec::new(),
122122
closed: false,
123+
error: None,
123124
}),
124125
Condvar::new(),
125126
));
@@ -145,11 +146,21 @@ impl ThreadWriter {
145146

146147
// We have data to process
147148
let data = std::mem::take(&mut guard.buffer);
149+
// The buffer is now empty; wake any producer parked in `ingest`
150+
// waiting for backpressure to ease before releasing the lock.
151+
cvar.notify_all();
148152
drop(guard); // Release lock before I/O
149153

150-
// Perform actual write (potentially blocking I/O)
151-
handle.write_all(&data)?;
152-
handle.flush()?;
154+
// Perform actual write (potentially blocking I/O). On failure,
155+
// record the error and wake any parked producer so it observes
156+
// the failure instead of waiting forever for a notification that
157+
// will never come.
158+
if let Err(e) = handle.write_all(&data).and_then(|()| handle.flush()) {
159+
let mut guard = state.lock();
160+
guard.error = Some(e.to_string());
161+
cvar.notify_all();
162+
return Err(e.into());
163+
}
153164
}
154165
});
155166

@@ -159,22 +170,36 @@ impl ThreadWriter {
159170
}
160171
}
161172

162-
fn ingest(&self, data: &[u8]) {
173+
fn ingest(&self, data: &[u8]) -> Result<()> {
163174
let (state, cvar) = &*self.state_pair;
164-
loop {
165-
let mut guard = state.lock();
166-
if guard.buffer.len() <= MAXIMUM_BUFFER_SIZE {
167-
guard.buffer.extend_from_slice(data);
168-
cvar.notify_one();
169-
break;
170-
} else {
171-
// Release the lock before sleeping so the worker thread can
172-
// acquire it and drain the buffer while we wait for backpressure
173-
// to ease.
174-
drop(guard);
175-
thread::sleep(Duration::from_millis(DEFAULT_SLEEP_MS));
175+
let mut guard = state.lock();
176+
177+
// Apply backpressure based on the *projected* buffer size, not the
178+
// current size: appending `data` must not push the buffer past the cap.
179+
// We wait (releasing the lock) until the worker has drained enough for
180+
// the new data to fit. An empty buffer always accepts the data so that a
181+
// single write larger than the cap still makes forward progress instead
182+
// of deadlocking. The condition variable replaces the previous polling
183+
// sleep, so the producer resumes the instant the worker drains.
184+
while !guard.buffer.is_empty() && guard.buffer.len() + data.len() > MAXIMUM_BUFFER_SIZE {
185+
// If the worker died while we were waiting for it to drain, stop
186+
// waiting: no further notifications will arrive.
187+
if let Some(err) = &guard.error {
188+
bail!("writer thread failed: {err}");
176189
}
190+
cvar.wait(&mut guard);
191+
}
192+
193+
// The worker may have failed before we ever parked above (e.g. the
194+
// buffer had room). Surface that rather than silently buffering data
195+
// that will never be written.
196+
if let Some(err) = &guard.error {
197+
bail!("writer thread failed: {err}");
177198
}
199+
200+
guard.buffer.extend_from_slice(data);
201+
cvar.notify_all();
202+
Ok(())
178203
}
179204
}
180205

@@ -239,7 +264,7 @@ impl BufferedWriter {
239264
.zip(self.segment_buffers.iter_mut())
240265
{
241266
if !buf.is_empty() {
242-
writer.ingest(buf.drain(..).as_slice());
267+
writer.ingest(buf.drain(..).as_slice())?;
243268
}
244269
}
245270
Ok(())
@@ -325,6 +350,7 @@ mod tests {
325350
use super::*;
326351
use std::io::{self, Write};
327352
use std::sync::{Arc, Mutex};
353+
use std::time::Duration;
328354

329355
// Simple in-memory writer that lets us inspect written data
330356
struct TestWriter {
@@ -387,14 +413,169 @@ mod tests {
387413
let payload = b"ACGTACGTACGT";
388414
{
389415
let writer = ThreadWriter::new(handle);
390-
writer.ingest(payload);
416+
writer.ingest(payload).unwrap();
391417
// `writer` is dropped here; all ingested bytes must be flushed first.
392418
}
393419

394420
let written = data.lock().unwrap().clone();
395421
assert_eq!(written, payload);
396422
}
397423

424+
// Backpressure tests (finding #3: ingest must enforce a *projected* cap and
425+
// must not poll-sleep while holding the buffer mutex).
426+
427+
/// A writer that signals when it has entered `write` and then blocks there
428+
/// until the test releases it. This lets us park the worker thread so the
429+
/// shared buffer accumulates and backpressure is forced to engage.
430+
struct GateState {
431+
entered: bool,
432+
released: bool,
433+
}
434+
struct GatedWriter {
435+
gate: Arc<(Mutex<GateState>, std::sync::Condvar)>,
436+
}
437+
impl Write for GatedWriter {
438+
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
439+
let (lock, cv) = &*self.gate;
440+
let mut g = lock.lock().unwrap();
441+
g.entered = true;
442+
cv.notify_all();
443+
while !g.released {
444+
g = cv.wait(g).unwrap();
445+
}
446+
Ok(buf.len())
447+
}
448+
fn flush(&mut self) -> io::Result<()> {
449+
Ok(())
450+
}
451+
}
452+
453+
/// Reproduces finding #3: with the worker parked mid-write, a small chunk
454+
/// makes the buffer non-empty and a subsequent large chunk must NOT be
455+
/// appended, because the *projected* size would exceed the cap. The buggy
456+
/// `len() <= MAX` check appends it anyway, overshooting the cap by a full
457+
/// large chunk.
458+
#[test]
459+
fn ingest_enforces_projected_buffer_cap() {
460+
let gate = Arc::new((
461+
Mutex::new(GateState {
462+
entered: false,
463+
released: false,
464+
}),
465+
std::sync::Condvar::new(),
466+
));
467+
let writer: BoxedWriter = Box::new(GatedWriter { gate: gate.clone() });
468+
let tw = ThreadWriter::new(writer);
469+
470+
// Make the worker pick up data and park inside the writer; the shared
471+
// buffer is empty again once it does.
472+
tw.ingest(b"kick").unwrap();
473+
{
474+
let (lock, cv) = &*gate;
475+
let mut g = lock.lock().unwrap();
476+
while !g.entered {
477+
g = cv.wait(g).unwrap();
478+
}
479+
}
480+
481+
thread::scope(|s| {
482+
// Producer: a small chunk (buffer now non-empty), then a large chunk.
483+
// A correct implementation blocks on the large chunk instead of
484+
// overshooting the cap.
485+
s.spawn(|| {
486+
tw.ingest(&[1u8; 4096]).unwrap();
487+
tw.ingest(&vec![2u8; MAXIMUM_BUFFER_SIZE]).unwrap();
488+
});
489+
490+
// Let the producer attempt both ingests.
491+
thread::sleep(Duration::from_millis(250));
492+
493+
let buffered = {
494+
let (state, _) = &*tw.state_pair;
495+
state.lock().buffer.len()
496+
};
497+
498+
// Release the worker so the producer can finish and the scope joins
499+
// cleanly regardless of the assertion outcome.
500+
{
501+
let (lock, cv) = &*gate;
502+
lock.lock().unwrap().released = true;
503+
cv.notify_all();
504+
}
505+
506+
assert!(
507+
buffered <= MAXIMUM_BUFFER_SIZE,
508+
"ingest overshot the cap: buffered {buffered} bytes, cap {MAXIMUM_BUFFER_SIZE}"
509+
);
510+
});
511+
512+
drop(tw);
513+
}
514+
515+
/// Reproduces the latency half of finding #3: once the buffer drains, a
516+
/// blocked `ingest` must resume promptly via notification rather than after
517+
/// a fixed polling interval. We assert a blocked producer wakes well within
518+
/// the old 100ms poll period after space frees up.
519+
#[test]
520+
fn ingest_wakes_promptly_when_space_frees() {
521+
let gate = Arc::new((
522+
Mutex::new(GateState {
523+
entered: false,
524+
released: false,
525+
}),
526+
std::sync::Condvar::new(),
527+
));
528+
let writer: BoxedWriter = Box::new(GatedWriter { gate: gate.clone() });
529+
let tw = ThreadWriter::new(writer);
530+
531+
// Park the worker mid-write so the buffer accumulates.
532+
tw.ingest(b"kick").unwrap();
533+
{
534+
let (lock, cv) = &*gate;
535+
let mut g = lock.lock().unwrap();
536+
while !g.entered {
537+
g = cv.wait(g).unwrap();
538+
}
539+
}
540+
541+
thread::scope(|s| {
542+
// Fill to the cap, then block on one more byte.
543+
s.spawn(|| {
544+
tw.ingest(&vec![3u8; MAXIMUM_BUFFER_SIZE]).unwrap();
545+
tw.ingest(b"z").unwrap();
546+
});
547+
548+
// Ensure the producer is blocked on the second ingest.
549+
thread::sleep(Duration::from_millis(100));
550+
551+
// Release the worker; it drains the cap-sized chunk, which should
552+
// immediately notify the blocked producer.
553+
{
554+
let (lock, cv) = &*gate;
555+
lock.lock().unwrap().released = true;
556+
cv.notify_all();
557+
}
558+
559+
// The producer's final byte should be buffered/drained quickly. Poll
560+
// until the worker has been handed the trailing byte (buffer empties
561+
// again) or a generous deadline elapses.
562+
let mut woke = false;
563+
for _ in 0..50 {
564+
thread::sleep(Duration::from_millis(2));
565+
let (state, _) = &*tw.state_pair;
566+
// Once the producer appended "z" and the worker drained it, the
567+
// buffer is empty again.
568+
if state.lock().buffer.is_empty() {
569+
woke = true;
570+
break;
571+
}
572+
}
573+
assert!(woke, "blocked ingest did not resume promptly after drain");
574+
});
575+
576+
drop(tw);
577+
}
578+
398579
#[test]
399580
fn thread_writer_drains_pending_data_under_shutdown_race() {
400581
// The data-loss bug was race-sensitive: ingest immediately before drop,
@@ -406,11 +587,46 @@ mod tests {
406587

407588
{
408589
let writer = ThreadWriter::new(handle);
409-
writer.ingest(payload);
590+
writer.ingest(payload).unwrap();
410591
}
411592

412593
let written = data.lock().unwrap().clone();
413594
assert_eq!(written, payload);
414595
}
415596
}
597+
598+
/// Reproduces the Gemini review concern: if the worker thread dies on an I/O
599+
/// error, a later `ingest` must surface that error instead of blocking
600+
/// forever waiting for a notification that will never arrive.
601+
#[test]
602+
fn ingest_reports_worker_io_error_instead_of_hanging() {
603+
struct FailingWriter;
604+
impl Write for FailingWriter {
605+
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
606+
Err(io::Error::new(io::ErrorKind::BrokenPipe, "downstream gone"))
607+
}
608+
fn flush(&mut self) -> io::Result<()> {
609+
Ok(())
610+
}
611+
}
612+
613+
let tw = ThreadWriter::new(Box::new(FailingWriter));
614+
615+
// The first ingest may win the race and buffer before the worker fails;
616+
// a bounded retry guarantees the worker has died, after which ingest must
617+
// report the error rather than hang.
618+
let mut reported = false;
619+
for _ in 0..1000 {
620+
if tw.ingest(b"data").is_err() {
621+
reported = true;
622+
break;
623+
}
624+
thread::sleep(Duration::from_millis(1));
625+
}
626+
assert!(reported, "ingest never surfaced the worker I/O error");
627+
628+
// The worker already exited with an error; `Drop` would join and panic on
629+
// that result (tracked separately as report finding #16), so skip it here.
630+
std::mem::forget(tw);
631+
}
416632
}

0 commit comments

Comments
 (0)