Skip to content

Commit 38526c5

Browse files
committed
transform: abort siblings when producer loop fails
`run_all_producers()` fanned out one producer loop per output via `ss::parallel_for_each()`, which captures the first exception but waits for every loop to finish before resolving. A producer loop only exits once its abort source fires, so when a single producer threw (e.g. a transient "not a leader for partition" during a leadership transfer) the surviving loops spun forever. parallel_for_each stayed pending, the exception was never observed, and state::errored never fired -- so the processor's reported state stayed stuck at running with no progress on the affected output until an external pause/resume. Single-output transforms were unaffected because `parallel_for_each()` over one element resolves exceptionally immediately. Drive the producer loops off a composite abort source that fires when either the processor stops (_as) or a producer fails (a local source). The first producer to fail records its exception and aborts the local source, which unwinds the sibling producer loops promptly; the captured exception is then rethrown so it is reported exactly once as state::errored, and the manager restarts the processor. Also updates the repro test from the previous commit to assert the failure is reported for any output count, and adds a regression test that fails a producer, restarts, and asserts a clean restart with no spurious second error.
1 parent 638caa4 commit 38526c5

6 files changed

Lines changed: 122 additions & 33 deletions

File tree

src/v/transform/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ redpanda_cc_library(
9595
"//src/v/model:batch_compression",
9696
"//src/v/random:time_jitter",
9797
"//src/v/rpc",
98+
"//src/v/ssx:abort_source",
9899
"//src/v/ssx:future_util",
99100
"//src/v/ssx:sformat",
100101
"//src/v/utils:backoff_policy",

src/v/transform/tests/test_fixture.cc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ void fake_sink::fail_writes() {
4343
_cond_var.broadcast();
4444
}
4545

46+
void fake_sink::resume_writes() {
47+
_fail = false;
48+
_cond_var.broadcast();
49+
}
50+
4651
class read_timed_out : public ss::condition_variable_timed_out {
4752
const char* what() const noexcept override {
4853
return "waiting for read timed out";

src/v/transform/tests/test_fixture.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ class fake_sink : public sink {
115115
*/
116116
void fail_writes();
117117

118+
/**
119+
* Stop failing writes, mimicking the transient produce failure (e.g. a
120+
* leadership transfer) resolving so the sink can make progress again.
121+
*/
122+
void resume_writes();
123+
118124
private:
119125
ss::chunked_fifo<model::record> _records;
120126
ss::condition_variable _cond_var;

src/v/transform/tests/transform_processor_test.cc

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,9 @@ class ProcessorTestFixture : public ::testing::TestWithParam<fixture_param> {
224224
void fail_sink(model::output_topic_index idx) {
225225
_sinks[idx()]->fail_writes();
226226
}
227+
void recover_sink(model::output_topic_index idx) {
228+
_sinks[idx()]->resume_writes();
229+
}
227230
bool processor_running() const { return _p->is_running(); }
228231

229232
std::vector<model::output_topic_index> output_topics() const {
@@ -367,16 +370,24 @@ TEST_P(ProcessorTestFixture, LagOverflowBug) {
367370
EXPECT_EQ(lag(), 0);
368371
}
369372

370-
// Reproduces the "stuck transform" bug. When one output's producer fails to
371-
// produce (e.g. a transient "not a leader for partition"), a SINGLE-output
372-
// transform reports the error (state::errored), so the manager would restart
373-
// it. But with MULTIPLE outputs, the surviving producer loop(s) keep
374-
// `ss::parallel_for_each` (run_all_producers) pending forever, so the exception
375-
// is never observed: state::errored never fires, the processor still reports
376-
// "running", and it makes no progress until an external stop (== an `rpk
377-
// transform pause`/`resume`).
378-
TEST_P(ProcessorTestFixture, ProduceFailureWedgesMultiOutputButErrorsSingle) {
379-
const size_t n_out = GetParam().meta.output_topics.size();
373+
// Regression test for the "stuck transform" bug. When one output's producer
374+
// fails to produce (e.g. a transient "not a leader for partition"), the failure
375+
// must be reported as state::errored so the manager restarts the processor,
376+
// regardless of how many outputs the transform has.
377+
//
378+
// The bug: with MULTIPLE outputs, `run_all_producers()` fanned out via
379+
// `ss::parallel_for_each()`, which captures the first exception but waits for
380+
// every loop to finish before resolving. Nothing aborted the shared abort
381+
// source, so the surviving producer loop(s) spin forever, pinning
382+
// parallel_for_each pending: state::errored never fired, the processor stayed
383+
// "running", and it made no progress until an external stop (== an `rpk
384+
// transform pause`/`resume`). Single-output transforms were unaffected because
385+
// parallel_for_each over one element resolves exceptionally right away.
386+
//
387+
// The fix drives the producer loops off a composite abort source, so the first
388+
// failing producer unwinds its siblings (without touching the processor's own
389+
// abort source) and propagates exactly one error.
390+
TEST_P(ProcessorTestFixture, ProduceFailureIsReportedForAnyOutputCount) {
380391
set_tee_output();
381392

382393
// Healthy baseline so the pipeline is flowing and committed.
@@ -392,26 +403,59 @@ TEST_P(ProcessorTestFixture, ProduceFailureWedgesMultiOutputButErrorsSingle) {
392403
push_batch(make_records(1));
393404
tests::drain_task_queue().get();
394405

395-
// The loops are never aborted, so the processor still reports running.
406+
// The producers all unwind instead of wedging. The processor still reports
407+
// running (its abort source is untouched); like a single-output failure, it
408+
// relies on the manager observing the error and restarting it.
396409
EXPECT_TRUE(processor_running());
397410
// Output 0's producer died, so nothing is ever written there.
398411
EXPECT_TRUE(sink_empty(model::output_topic_index(0)));
412+
// The failure is reported exactly once, no matter the output count: the
413+
// consumer/transform loops keep running on the untouched abort source, so
414+
// the manager restarts the processor without spurious duplicate errors.
415+
EXPECT_EQ(error_count(), 1u)
416+
<< "produce failure should be reported as errored exactly once";
417+
}
399418

400-
if (n_out == 1) {
401-
// parallel_for_each(1 element) resolves exceptionally, the error
402-
// propagates, and the manager would restart the processor.
403-
EXPECT_GE(error_count(), 1u)
404-
<< "single-output produce failure should be reported as errored";
405-
} else {
406-
// The surviving producer loop(s) keep parallel_for_each pending, so the
407-
// error is swallowed -- THE BUG.
408-
EXPECT_EQ(error_count(), 0u)
409-
<< "multi-output produce failure is silently swallowed (stuck "
410-
"transform bug)";
411-
// Healthy outputs keep flowing while output 0 is wedged.
412-
for (size_t i = 1; i < n_out; ++i) {
413-
EXPECT_FALSE(sink_empty(model::output_topic_index(i)));
414-
}
419+
// A produce failure must leave the processor cleanly restartable: the manager's
420+
// recovery path stops and then restarts the same processor instance, so stop()
421+
// has to tear down (and the wedge fix must not have aborted the processor's own
422+
// abort source, which would short-circuit stop() and leave the engine started
423+
// for the restart to double-start). Exercise that full cycle and assert the
424+
// restart is clean and the processor resumes producing.
425+
TEST_P(ProcessorTestFixture, RecoversFromProduceFailureViaRestart) {
426+
set_tee_output();
427+
428+
// Healthy baseline so the pipeline is flowing and committed.
429+
auto baseline = make_records(1);
430+
push_batch(baseline);
431+
for (auto o : output_topics()) {
432+
EXPECT_THAT(read_records(o, 1), SameRecords(baseline));
433+
}
434+
ASSERT_TRUE(wait_for_all_committed());
435+
436+
// A producer fails and the error is reported, exactly what the manager
437+
// observes before it restarts.
438+
fail_sink(model::output_topic_index(0));
439+
push_batch(make_records(1));
440+
tests::drain_task_queue().get();
441+
ASSERT_EQ(error_count(), 1u);
442+
443+
// Recovery: the transient failure clears and the manager stops and restarts
444+
// the same processor instance.
445+
recover_sink(model::output_topic_index(0));
446+
restart();
447+
tests::drain_task_queue().get();
448+
449+
// A clean restart produces no further errors.
450+
ASSERT_EQ(error_count(), 1u)
451+
<< "restart after a produce failure should not report a new error";
452+
EXPECT_TRUE(processor_running());
453+
454+
// The restarted processor resumes producing to every output.
455+
auto resumed = make_records(1);
456+
push_batch(resumed);
457+
for (auto o : output_topics()) {
458+
EXPECT_FALSE(read_records(o, 1).empty());
415459
}
416460
}
417461

src/v/transform/transform_processor.cc

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#include "model/timestamp.h"
1919
#include "model/transform.h"
2020
#include "random/simple_time_jitter.h"
21+
#include "ssx/abort_source.h"
2122
#include "ssx/future-util.h"
2223
#include "wasm/engine.h"
2324

@@ -342,19 +343,50 @@ ss::future<> processor::run_transform_loop() {
342343
ss::future<> processor::run_all_producers(
343344
absl::flat_hash_map<model::output_topic_index, kafka::offset>
344345
latest_committed) {
345-
return ss::parallel_for_each(
346-
_outputs, [this, committed = std::move(latest_committed)](auto& entry) {
346+
// parallel_for_each captures the first exception but waits for every loop
347+
// to finish before resolving, and a producer loop only exits when its abort
348+
// source fires. Drive the loops off a composite source that aborts when
349+
// either the processor stops (_as) or a producer fails (local_producer_as,
350+
// our "a producer failed" signal), so the first failure unwinds its
351+
// siblings promptly instead of leaving them spinning forever.
352+
ss::abort_source local_producer_as;
353+
ssx::composite_abort_source producers_as(_as, local_producer_as);
354+
355+
std::exception_ptr failure;
356+
co_await ss::parallel_for_each(
357+
_outputs,
358+
[this,
359+
&failure,
360+
&local_producer_as,
361+
&producers_as,
362+
committed = std::move(latest_committed)](auto& entry) {
347363
output& out = entry.second;
348364
return run_producer_loop(
349-
out.index, &out.queue, out.sink.get(), committed.at(out.index));
365+
out.index,
366+
&out.queue,
367+
out.sink.get(),
368+
committed.at(out.index),
369+
producers_as.as())
370+
.handle_exception([&failure,
371+
&local_producer_as](std::exception_ptr ep) {
372+
if (!local_producer_as.abort_requested()) {
373+
failure = std::move(ep);
374+
local_producer_as.request_abort_ex(
375+
std::make_exception_ptr(processor_shutdown_exception()));
376+
}
377+
});
350378
});
379+
if (failure) {
380+
std::rethrow_exception(failure);
381+
}
351382
}
352383

353384
ss::future<> processor::run_producer_loop(
354385
model::output_topic_index index,
355386
transfer_queue<transformed_output>* queue,
356387
sink* sink,
357-
kafka::offset last_committed) {
388+
kafka::offset last_committed,
389+
ss::abort_source& as) {
358390
vlog(
359391
_logger.debug,
360392
"starting producer {} - last committed: {}",
@@ -364,8 +396,8 @@ ss::future<> processor::run_producer_loop(
364396
// to suppress records until we've reached the previous offset we've
365397
// committed.
366398
bool suppress = true;
367-
while (!_as.abort_requested()) {
368-
auto popped = co_await queue->pop_all(&_as);
399+
while (!as.abort_requested()) {
400+
auto popped = co_await queue->pop_all(&as);
369401
if (popped.empty()) {
370402
continue;
371403
}

src/v/transform/transform_processor.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ class processor {
114114
model::output_topic_index,
115115
transfer_queue<transformed_output>*,
116116
sink*,
117-
kafka::offset);
117+
kafka::offset,
118+
ss::abort_source&);
118119
ss::future<> poll_sleep();
119120
ss::future<absl::flat_hash_map<model::output_topic_index, kafka::offset>>
120121
load_latest_committed();

0 commit comments

Comments
 (0)