Skip to content

Commit 2203c8d

Browse files
authored
[Prism] Fix DEADLINE_EXCEEDED errors caused by worker failures (#38523)
* Set b.BundleErr correctly when dynamic split happens. * Fix deadlock on worker failures by introducing a bundle-level Done signal The cancellation signal `b.Done` is to abort bundle data streaming immediately if the worker fails or completes the bundle early. Without this signal, a deadlock scenario can occur. - Runner sends elements through data channel - SDK worker reads and processes the first element, raises an exception and sends InstructionResponse through control channel. However, at the same time, Runner keeps sending data and is blocked until SDK worker reads more data. - The existed two signals are not sufficient to do an immediate break - ctx.Done() is closed when the pipeline is done or timeout. Given the bundle failure, timeout is the only option but it is too late. - wk.StoppedChan() is closed when the worker is stopped by the worker pool, which is also not happening when the runner is waiting to send data. * Fix a problem of prism log not relaying correctly. * Trigger more python tests. * Add a mutex and safe getter/setter methods for bundleErr * Rename b.Done to b.DataAbort. * Set bundleErr before closing DataAbort to ensure error will be propagated correctly. * Refactor Respond to prevent race condition on BundleErr setting * Remove debug msg * Formatting. * Address reviewer comments.
1 parent 1c0c024 commit 2203c8d

9 files changed

Lines changed: 213 additions & 12 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"comment": "Modify this file in a trivial way to cause this test suite to run",
3-
"revision": 1
3+
"revision": 2
44
}

sdks/go/pkg/beam/runners/prism/internal/execute_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,27 @@ func TestFailureHang(t *testing.T) {
541541
}
542542
}
543543

544+
func TestFailure_SplitError(t *testing.T) {
545+
initRunner(t)
546+
547+
p, s := beam.NewPipelineWithRoot()
548+
configs := beam.Create(s, SourceConfig{NumElements: 100, InitialSplits: 1})
549+
col := beam.ParDo(s, &slowFailSDF{}, configs)
550+
beam.ParDo(s, &int64Check{Name: "sdf_fail", Want: []int{}}, col)
551+
552+
// Set a short timeout so the test fails quickly if the pipeline hangs due to split error handling bug
553+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
554+
defer cancel()
555+
556+
_, err := executeWithT(ctx, t, p)
557+
if err == nil {
558+
t.Fatalf("expected pipeline failure, but got a success")
559+
}
560+
if want := "intentional split error from tracker"; !strings.Contains(err.Error(), want) {
561+
t.Fatalf("expected pipeline failure with %q, but was %v", want, err)
562+
}
563+
}
564+
544565
func TestRunner_Passert(t *testing.T) {
545566
initRunner(t)
546567
tests := []struct {

sdks/go/pkg/beam/runners/prism/internal/stage.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,9 @@ progress:
207207
return context.Cause(ctx)
208208
case resp = <-b.Resp:
209209
bundleFinished = true
210-
if b.BundleErr != nil {
211-
return b.BundleErr
210+
if err := b.GetErr(); err != nil {
211+
slog.Error("stage.Execute aborting due to bundle error", "stage", s.ID, "bundle", rb.BundleID)
212+
return err
212213
}
213214
if dataFinished && bundleFinished {
214215
break progress // exit progress loop on close.
@@ -240,10 +241,16 @@ progress:
240241
sr, err := b.Split(ctx, wk, 0.5 /* fraction of remainder */, nil /* allowed splits */)
241242
if err != nil {
242243
slog.Warn("SDK Error from split, aborting splits and failing bundle", "bundle", rb, "error", err.Error())
243-
if b.BundleErr != nil {
244-
b.BundleErr = err
244+
// Safely set the split error if no primary bundle error has been set yet.
245+
// Both SetErr and GetErr are synchronized under the same mutex, guaranteeing
246+
// no memory races occur. The write-read inconsistency is explicitly guarded
247+
// by the nil/null check inside SetErr(): if a concurrent primary error (e.g.,
248+
// worker crash) was set first, it will not be overwritten and b.GetErr() will
249+
// correctly preserve and return it instead of the secondary split error.
250+
if !b.SetErr(err) {
251+
slog.Debug("Error for bundle already set, logging dropped split error", "bundle", rb, "error", err)
245252
}
246-
return b.BundleErr
253+
return b.GetErr()
247254
}
248255
if sr.GetChannelSplits() == nil {
249256
slog.Debug("SDK returned no splits", "bundle", rb)

sdks/go/pkg/beam/runners/prism/internal/testdofns_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ func init() {
6464
register.Function2x1(combineIntSum)
6565

6666
register.DoFn3x1[*sdf.LockRTracker, SourceConfig, func(int64), error]((*intRangeFn)(nil))
67+
register.DoFn4x1[context.Context, *sdf.LockRTracker, SourceConfig, func(int64), error]((*slowFailSDF)(nil))
6768
register.Emitter1[int64]()
6869
register.Emitter2[int64, int64]()
6970
}
@@ -404,3 +405,36 @@ func (fn *selfCheckpointingDoFn) ProcessElement(rt *sdf.LockRTracker, _ []byte,
404405
}
405406
}
406407
}
408+
409+
type errorSplitTracker struct {
410+
*offsetrange.Tracker
411+
}
412+
413+
func (t *errorSplitTracker) TrySplit(fraction float64) (any, any, error) {
414+
return nil, nil, fmt.Errorf("intentional split error from tracker")
415+
}
416+
417+
type slowFailSDF struct{}
418+
419+
func (fn *slowFailSDF) CreateInitialRestriction(config SourceConfig) offsetrange.Restriction {
420+
return offsetrange.Restriction{Start: 0, End: config.NumElements}
421+
}
422+
423+
func (fn *slowFailSDF) SplitRestriction(config SourceConfig, rest offsetrange.Restriction) []offsetrange.Restriction {
424+
return rest.EvenSplits(config.InitialSplits)
425+
}
426+
427+
func (fn *slowFailSDF) RestrictionSize(_ SourceConfig, rest offsetrange.Restriction) float64 {
428+
return rest.Size()
429+
}
430+
431+
func (fn *slowFailSDF) CreateTracker(rest offsetrange.Restriction) *sdf.LockRTracker {
432+
return sdf.NewLockRTracker(&errorSplitTracker{Tracker: offsetrange.NewTracker(rest)})
433+
}
434+
435+
func (fn *slowFailSDF) ProcessElement(ctx context.Context, rt *sdf.LockRTracker, config SourceConfig, emit func(int64)) error {
436+
for i := rt.GetRestriction().(offsetrange.Restriction).Start; rt.TryClaim(i); i++ {
437+
<-ctx.Done()
438+
}
439+
return nil
440+
}

sdks/go/pkg/beam/runners/prism/internal/worker/bundle.go

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"fmt"
2222
"log/slog"
23+
"sync"
2324
"sync/atomic"
2425

2526
"github.com/apache/beam/sdks/v2/go/pkg/beam/core/typex"
@@ -61,8 +62,19 @@ type B struct {
6162
dataSema atomic.Int32
6263
OutputData engine.TentativeData
6364

64-
Resp chan *fnpb.ProcessBundleResponse
65-
BundleErr error
65+
Resp chan *fnpb.ProcessBundleResponse
66+
// DataAbort is closed when the worker responds to the bundle instruction
67+
// (with success or failure), signaling ProcessOn to stop streaming data.
68+
//
69+
// This prevents a deadlock where a worker fails mid-bundle and stops reading
70+
// from the data channel while the runner blocks indefinitely attempting to
71+
// write remaining elements. Other signals are insufficient to abort immediately:
72+
// - ctx.Done() only triggers on global timeouts/cancellations, which is too late.
73+
// - wk.StoppedChan is only closed when tearing down the worker pool, which does
74+
// not happen while the runner is waiting on the current bundle to finish.
75+
DataAbort chan struct{}
76+
mu sync.Mutex
77+
bundleErr error
6678
responded bool
6779

6880
SinkToPCollection map[string]string
@@ -80,6 +92,7 @@ func (b *B) Init() {
8092
close(b.DataWait) // Can happen if there are no outputs for the bundle.
8193
}
8294
b.Resp = make(chan *fnpb.ProcessBundleResponse, 1)
95+
b.DataAbort = make(chan struct{})
8396
}
8497

8598
// DataOrTimerDone indicates a final element has been received from a Data or Timer output.
@@ -96,14 +109,40 @@ func (b *B) LogValue() slog.Value {
96109
slog.String("stage", b.PBDID))
97110
}
98111

112+
// SetErr sets the bundle error if it is not already set, returning true if it was set, and false otherwise.
113+
func (b *B) SetErr(err error) bool {
114+
b.mu.Lock()
115+
defer b.mu.Unlock()
116+
if b.bundleErr == nil {
117+
b.bundleErr = err
118+
return true
119+
}
120+
return false
121+
}
122+
123+
// GetErr gets the current bundle error.
124+
func (b *B) GetErr() error {
125+
b.mu.Lock()
126+
defer b.mu.Unlock()
127+
return b.bundleErr
128+
}
129+
99130
func (b *B) Respond(resp *fnpb.InstructionResponse) {
100131
if b.responded {
101132
slog.Warn("additional bundle response", "bundle", b, "resp", resp)
102133
return
103134
}
104135
b.responded = true
136+
if b.DataAbort != nil {
137+
// Defer closing DataAbort to guarantee that the abort signal is sent
138+
// when this function returns. This ensures it is always executed after
139+
// any error has been safely written and synchronized via b.SetErr() or,
140+
// in the happy path, after the successful response is sent to b.Resp.
141+
defer close(b.DataAbort)
142+
}
105143
if resp.GetError() != "" {
106-
b.BundleErr = fmt.Errorf("bundle %v %v failed:%v", resp.GetInstructionId(), b.PBDID, resp.GetError())
144+
slog.Error("Prism received bundle error from worker response", "bundle", resp.GetInstructionId())
145+
b.SetErr(fmt.Errorf("bundle %v %v failed:%v", resp.GetInstructionId(), b.PBDID, resp.GetError()))
107146
close(b.Resp)
108147
return
109148
}
@@ -143,6 +182,13 @@ func (b *B) ProcessOn(ctx context.Context, wk *W) <-chan struct{} {
143182
b.DataOrTimerDone()
144183
}
145184
return b.DataWait
185+
case <-b.DataAbort:
186+
// The bundle completed/failed before req was sent.
187+
outCap := b.OutputCount + len(b.HasTimers)
188+
for i := 0; i < outCap; i++ {
189+
b.DataOrTimerDone()
190+
}
191+
return b.DataWait
146192
case wk.InstReqs <- req:
147193
// desired outcome
148194
}
@@ -181,6 +227,9 @@ func (b *B) ProcessOn(ctx context.Context, wk *W) <-chan struct{} {
181227
case <-ctx.Done():
182228
b.DataOrTimerDone()
183229
return b.DataWait
230+
case <-b.DataAbort:
231+
b.DataOrTimerDone()
232+
return b.DataWait
184233
case wk.DataReqs <- elms:
185234
}
186235
}
@@ -202,6 +251,9 @@ func (b *B) ProcessOn(ctx context.Context, wk *W) <-chan struct{} {
202251
case <-ctx.Done():
203252
b.DataOrTimerDone()
204253
return b.DataWait
254+
case <-b.DataAbort:
255+
b.DataOrTimerDone()
256+
return b.DataWait
205257
case wk.DataReqs <- &fnpb.Elements{
206258
Timers: timers,
207259
Data: []*fnpb.Elements_Data{

sdks/go/pkg/beam/runners/prism/internal/worker/worker_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,73 @@ func TestWorker_State_MultimapSideInput(t *testing.T) {
522522
}
523523
}
524524

525+
// TestBundle_ProcessOn_WorkerFailure verifies that the runner does not deadlock when
526+
// a worker fails mid-bundle and stops reading elements from the Data plane stream.
527+
func TestBundle_ProcessOn_WorkerFailure(t *testing.T) {
528+
ctx, wk, clientConn := serveTestWorker(t)
529+
530+
dataCli := fnpb.NewBeamFnDataClient(clientConn)
531+
dataStream, err := dataCli.Data(ctx)
532+
if err != nil {
533+
t.Fatal("couldn't create data client:", err)
534+
}
535+
536+
instID := wk.NextInst()
537+
538+
// Create 15 large input blocks (512 KB each) to saturate the 10-slot channel buffer
539+
// and the gRPC flow control window, forcing the Data sender inside worker.go to block.
540+
largeBytes := make([]byte, 512*1024)
541+
var inputBlocks []*engine.Block
542+
for i := 0; i < 15; i++ {
543+
inputBlocks = append(inputBlocks, &engine.Block{
544+
Kind: engine.BlockData,
545+
Bytes: [][]byte{largeBytes},
546+
})
547+
}
548+
549+
b := &B{
550+
InstID: instID,
551+
PBDID: "teststageID",
552+
Input: inputBlocks,
553+
OutputCount: 1,
554+
}
555+
b.Init()
556+
wk.activeInstructions[instID] = b
557+
558+
processOnDone := make(chan struct{})
559+
go func() {
560+
b.ProcessOn(ctx, wk)
561+
close(processOnDone)
562+
}()
563+
564+
// Send the initial process bundle request trigger.
565+
wk.InstReqs <- &fnpb.InstructionRequest{
566+
InstructionId: instID,
567+
}
568+
569+
// Read only the first block to simulate worker processing start.
570+
_, err = dataStream.Recv()
571+
if err != nil {
572+
t.Fatal("couldn't receive first data element:", err)
573+
}
574+
575+
// Simulate worker failure by responding with an error on the Control channel.
576+
// Without the fix, ProcessOn's background goroutine deadlocks at `wk.DataReqs <- elms`
577+
// because the client stopped reading and the buffer/flow-control is saturated.
578+
wk.activeInstructions[instID].Respond(&fnpb.InstructionResponse{
579+
InstructionId: instID,
580+
Error: "Intentional worker failure",
581+
})
582+
583+
// Verify that ProcessOn exits cleanly and does not deadlock/hang.
584+
select {
585+
case <-processOnDone:
586+
// Test passed: ProcessOn exited successfully!
587+
case <-time.After(10 * time.Second):
588+
t.Fatal("ProcessOn deadlocked / hung after worker failure!")
589+
}
590+
}
591+
525592
func newWorker() *W {
526593
mw := &MultiplexW{
527594
pool: map[string]*W{},

sdks/python/apache_beam/runners/portability/job_server.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ def start(self):
121121
logger = logging.getLogger(f"{self.__class__.__name__}")
122122
if self._log_filter is not None:
123123
logger.addFilter(self._log_filter)
124+
# Explicitly set logger level to INFO so logger.info(...) calls in
125+
# SubprocessServer._really_start_process's log_stdout pass the initial
126+
# isEnabledFor check, allowing the filter to dynamically elevate log
127+
# levels to WARNING or ERROR.
128+
logger.setLevel(logging.INFO)
124129
self._server = subprocess_server.SubprocessServer(
125130
beam_job_api_pb2_grpc.JobServiceStub, cmd, port=port, logger=logger)
126131
return self._server.start()

sdks/python/apache_beam/runners/portability/prism_runner.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,13 @@ def filter(self, record):
154154
record.msg = json_record["sdk"]["msg"]
155155
else:
156156
record.name = "PrismRunner"
157-
record.msg = (
158-
f"{json_record['msg']} "
159-
f"({', '.join(f'{k}={v!r}' for k, v in extras.items())})")
157+
formatted_extras = []
158+
for k, v in extras.items():
159+
if isinstance(v, str) and '\n' in v:
160+
formatted_extras.append(f"\n{k}:\n{v}")
161+
else:
162+
formatted_extras.append(f"{k}={v!r}")
163+
record.msg = f"{json_record['msg']} ({', '.join(formatted_extras)})"
160164
except (json.JSONDecodeError,
161165
KeyError,
162166
ValueError,

sdks/python/apache_beam/runners/portability/prism_runner_test.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,17 @@ def test_after_count_trigger_streaming(self):
300300
('B-3', {10, 15, 16}),
301301
])))
302302

303+
def test_failing_dofn(self):
304+
# Prism interprets all bundle failures as RuntimeError
305+
with self.assertRaisesRegex(
306+
RuntimeError, "Intentional DoFn failure for prism logging test"):
307+
with self.create_pipeline() as p:
308+
309+
def failing_fn(x):
310+
raise ValueError("Intentional DoFn failure for prism logging test")
311+
312+
_ = p | beam.Create([1, 2, 3]) | beam.Map(failing_fn)
313+
303314

304315
class PrismJobServerTest(unittest.TestCase):
305316
def setUp(self) -> None:

0 commit comments

Comments
 (0)