This is an automated email from the ASF dual-hosted git repository.

He-Pin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko.git


The following commit(s) were added to refs/heads/main by this push:
     new 26d8b60f19 feat(stream): add alsoTo overload with configurable 
cancellation propagation (#3127)
26d8b60f19 is described below

commit 26d8b60f19ff31afdbf0ee920a400038d2ef1cd8
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Mon Jun 29 17:27:31 2026 +0800

    feat(stream): add alsoTo overload with configurable cancellation 
propagation (#3127)
    
    * feat(stream): add alsoTo overload with configurable cancellation 
propagation
    
    Motivation:
    alsoTo uses Broadcast[Out](2, eagerCancel = true) internally. When the
    side sink fails or cancels, the entire stream is terminated. Users cannot
    isolate the side sink — for example, a fire-and-forget logging sink
    should not kill the main business stream when the logging destination is
    temporarily unavailable.
    
    Modification:
    Add a new alsoTo(sink, propagateCancellation: Boolean) overload. When
    propagateCancellation is false, a new ResilientAlsoTo GraphStage is used
    instead of Broadcast. ResilientAlsoTo backpressures when either output
    backpressures (same contract as alsoTo), but when the side sink cancels
    or fails, elements continue flowing to the main downstream only. A
    warning is logged on side sink failure. Also add alsoToMat overloads for
    both Scala and Java DSLs, and update FlowWithContext/SourceWithContext.
    
    Result:
    Users can now use alsoTo(sink, propagateCancellation = false) to
    fire-and-forget to a side sink without risking main stream termination.
    Default behavior (propagateCancellation = true) is unchanged.
    
    Tests:
    - sbt "stream-tests / Test / testOnly 
org.apache.pekko.stream.scaladsl.FlowAlsoToSpec" — 11/11 passed
    - sbt "stream-tests / Test / testOnly 
org.apache.pekko.stream.DslConsistencySpec" — 12/12 passed
    - sbt "stream-tests / Test / testOnly 
org.apache.pekko.stream.scaladsl.FlowAlsoToAllSpec" — 2/2 passed
    
    References:
    Fixes #3104
    
    * fix(stream): correct @since version, add Java tests, clean up 
DefaultAttributes
    
    Motivation:
    Review feedback: @since should be 2.0.0, Java tests were missing, and
    DefaultAttributes.resilientAlsoTo was unnecessary.
    
    Modification:
    - Fix @since 1.2.0 to @since 2.0.0 across all new alsoTo/alsoToMat
      overloads
    - Add Java DSL compilation tests for alsoTo(propagateCancellation)
      and alsoToMat(propagateCancellation) in FlowTest and SourceTest
    - Remove DefaultAttributes.resilientAlsoTo; use inline
      Attributes.name("resilientAlsoTo") instead
    
    Result:
    Correct version annotation, Java API coverage, cleaner DefaultAttributes.
    
    Tests:
    - sbt "stream-tests / Test / testOnly 
org.apache.pekko.stream.scaladsl.FlowAlsoToSpec 
org.apache.pekko.stream.DslConsistencySpec 
org.apache.pekko.stream.javadsl.FlowTest 
org.apache.pekko.stream.javadsl.SourceTest" — 222/222 passed
    
    References:
    Refs #3104
    
    * refactor(stream): implement alsoTo(propagateCancellation=false) via 
Broadcast
    
    Motivation:
    ResilientAlsoTo was a separate GraphStage duplicating Broadcast's
    fan-out logic. WireTap was also modified with an unrelated backpressure
    parameter.
    
    Modification:
    Add nonEagerCancelOutputs: Set[Int] parameter to Broadcast. Outputs in
    this set do not trigger stage cancellation when they cancel — elements
    continue flowing to remaining outputs. alsoTo(propagateCancellation=false)
    now uses Broadcast(2, eagerCancel=true, nonEagerCancelOutputs=Set(1)),
    eliminating ResilientAlsoTo entirely. WireTap is reverted to its
    original form. Also remove DefaultAttributes.resilientAlsoTo and fix
    @since to 2.0.0 across all new methods. Add Java DSL compilation tests.
    
    Result:
    Cleaner implementation built on existing Broadcast. No new GraphStage.
    Same 224 tests pass.
    
    Tests:
    - 224/224 passed (FlowAlsoToSpec, DslConsistencySpec, FlowTest, SourceTest, 
FlowAlsoToAllSpec)
    
    References:
    Refs #3104
    
    * style: apply javafmt to Java test files
    
    * fix(stream): preserve Broadcast binary compatibility and tighten require
    
    Motivation:
    The previous commit restructured Broadcast to take a 3-arg primary
    constructor (Int, Boolean, Set[Int]), replacing the historical 2-arg
    (Int, Boolean) primary. That changes the JVM `<init>` descriptor, which
    is a binary-compatibility break for any external caller compiled against
    prior Pekko versions invoking `new Broadcast(n, eager)`. Also, the
    require condition allowed a non-empty nonEagerCancelOutputs with
    eagerCancel=false, where the parameter is silently meaningless.
    
    Modification:
    - Restore the 2-arg `(Int, Boolean)` as the primary constructor,
      preserving the `<init>(IZ)V` JVM descriptor for existing callers.
      Add the 3-arg `(Int, Boolean, Set[Int])` as an auxiliary
      constructor. Store nonEagerCancelOutputs in a private mutable
      backing field assigned once from the auxiliary constructor; expose
      it via a public `def nonEagerCancelOutputs` accessor (source- and
      binary-compatible since it is a new, unreleased API).
    - Strengthen require: nonEagerCancelOutputs must imply eagerCancel=true
      (rejected with a descriptive message otherwise), and must be a
      subset of valid output indices.
    - Add two regression tests in GraphBroadcastSpec:
      * "continue to remaining outputs when a non-eager output cancels"
        directly exercises the new Broadcast overload.
      * "reject nonEagerCancelOutputs when eagerCancel is false" verifies
        the require guard.
    
    Result:
    Existing callers compiled against the old Broadcast(Int, Boolean)
    constructor keep working (MiMa clean). New 3-arg constructor is
    source-available. Invalid Broadcast configurations are rejected at
    construction time with a clear message.
    
    Tests:
    - sbt 'stream-tests / Test / testOnly 
org.apache.pekko.stream.scaladsl.GraphBroadcastSpec' -> 13/13 passed
    - sbt 'stream-tests / Test / testOnly 
org.apache.pekko.stream.scaladsl.FlowAlsoToSpec' -> 11/11 passed
    - sbt 'stream-tests / Test / testOnly 
org.apache.pekko.stream.DslConsistencySpec' -> 12/12 passed
    - sbt '+stream / mimaReportBinaryIssues' -> clean
    
    References:
    Refs #3104
    
    * style: apply scalafmt to GraphBroadcastSpec
    
    References:
    Refs #3104
    
    * fix(stream): add Java Broadcast non-eager cancellation factory
    
    Motivation:
    PR #3127 added a Scala Broadcast factory overload used by 
alsoTo(propagateCancellation = false), but the Java DSL did not expose the 
matching factory. The Scala 3.3 DSL factory consistency check therefore failed 
in CI.
    
    Modification:
    Add the Java Broadcast.create overload accepting nonEagerCancelOutputs, 
cover it with a Java GraphDSL behavior test, and clarify alsoTo cancellation 
propagation docs.
    
    Result:
    Java and Scala DSL factory coverage is back in parity and alsoTo 
non-propagating cancellation behavior is documented.
    
    Tests:
    - rtk sbt "++ 3.3.8" "stream-tests / Test / testOnly 
org.apache.pekko.stream.DslFactoriesConsistencySpec 
org.apache.pekko.stream.javadsl.GraphDslTest 
org.apache.pekko.stream.scaladsl.GraphBroadcastSpec 
org.apache.pekko.stream.scaladsl.FlowAlsoToSpec" - passed
    - rtk sbt docs/paradox - passed
    - rtk sbt "+stream / mimaReportBinaryIssues" - passed
    - rtk scalafmt --mode diff-ref=origin/main - passed
    - rtk scalafmt --list --mode diff-ref=origin/main - passed
    - rtk sbt javafmtAll with JDK 17 - passed
    - rtk sbt javafmtCheckAll checkCodeStyle with JDK 17 - passed
    - rtk git diff --check - passed
    
    References:
    Refs #3127
---
 .../stream/operators/Source-or-Flow/alsoTo.md      |  11 +-
 project/StreamOperatorsIndexGenerator.scala        |   1 +
 .../org/apache/pekko/stream/javadsl/FlowTest.java  |  10 +
 .../apache/pekko/stream/javadsl/GraphDslTest.java  |  22 ++
 .../apache/pekko/stream/javadsl/SourceTest.java    |  10 +
 .../apache/pekko/stream/DslConsistencySpec.scala   |   1 +
 .../pekko/stream/scaladsl/FlowAlsoToSpec.scala     | 288 +++++++++++++++++++++
 .../pekko/stream/scaladsl/GraphBroadcastSpec.scala |  45 ++++
 .../org/apache/pekko/stream/javadsl/Flow.scala     |  41 +++
 .../org/apache/pekko/stream/javadsl/Graph.scala    |  21 ++
 .../org/apache/pekko/stream/javadsl/Source.scala   |  41 +++
 .../org/apache/pekko/stream/javadsl/SubFlow.scala  |  24 ++
 .../apache/pekko/stream/javadsl/SubSource.scala    |  24 ++
 .../org/apache/pekko/stream/scaladsl/Flow.scala    |  54 ++++
 .../pekko/stream/scaladsl/FlowWithContext.scala    |   4 +
 .../pekko/stream/scaladsl/FlowWithContextOps.scala |   8 +
 .../org/apache/pekko/stream/scaladsl/Graph.scala   |  61 ++++-
 .../pekko/stream/scaladsl/SourceWithContext.scala  |   4 +
 18 files changed, 666 insertions(+), 4 deletions(-)

diff --git a/docs/src/main/paradox/stream/operators/Source-or-Flow/alsoTo.md 
b/docs/src/main/paradox/stream/operators/Source-or-Flow/alsoTo.md
index 452e012235..430b4fedb2 100644
--- a/docs/src/main/paradox/stream/operators/Source-or-Flow/alsoTo.md
+++ b/docs/src/main/paradox/stream/operators/Source-or-Flow/alsoTo.md
@@ -14,6 +14,12 @@ Attaches the given `Sink` to this `Flow`, meaning that 
elements that pass throug
 
 Attaches the given `Sink` to this `Flow`, meaning that elements that pass 
through this `Flow` will also be sent to the `Sink`.
 
+By default, cancellation or failure of the attached `Sink` cancels the main 
stream. The `alsoTo` overload with
+`propagateCancellation = false` can be used when the attached `Sink` is a 
best-effort side sink, such as logging or
+metrics, and its cancellation or failure should not terminate the main stream. 
In that mode, the operator still
+backpressures when the side `Sink` is active and backpressuring, but once the 
side `Sink` cancels or fails, elements
+continue to the main downstream only.
+
 ## Reactive Streams semantics
 
 @@@div { .callout }
@@ -24,8 +30,9 @@ Attaches the given `Sink` to this `Flow`, meaning that 
elements that pass throug
 
 **completes** when upstream completes
 
-**cancels** when downstream or `Sink` cancels
+**cancels** when downstream cancels. With the default cancellation 
propagation, the operator also cancels when the
+attached `Sink` cancels or fails. With `propagateCancellation = false`, 
cancellation or failure of the attached `Sink`
+does not cancel the main stream.
 
 @@@
 
-
diff --git a/project/StreamOperatorsIndexGenerator.scala 
b/project/StreamOperatorsIndexGenerator.scala
index 1dcce00698..daf88cc4e4 100644
--- a/project/StreamOperatorsIndexGenerator.scala
+++ b/project/StreamOperatorsIndexGenerator.scala
@@ -81,6 +81,7 @@ object StreamOperatorsIndexGenerator extends AutoPlugin {
     "mergeGraph",
     "wireTapGraph",
     "alsoToGraph",
+    "resilientAlsoToGraph",
     "orElseGraph",
     "divertToGraph",
     "zipWithGraph",
diff --git 
a/stream-tests/src/test/java/org/apache/pekko/stream/javadsl/FlowTest.java 
b/stream-tests/src/test/java/org/apache/pekko/stream/javadsl/FlowTest.java
index 6ee8f1c1e6..8737c6d27d 100644
--- a/stream-tests/src/test/java/org/apache/pekko/stream/javadsl/FlowTest.java
+++ b/stream-tests/src/test/java/org/apache/pekko/stream/javadsl/FlowTest.java
@@ -1879,6 +1879,16 @@ public class FlowTest extends StreamTestJupiter {
         Flow.of(Integer.class).alsoToMat(Sink.ignore(), (i, n) -> "foo");
   }
 
+  @Test
+  public void mustBeAbleToUseAlsoToWithPropagateCancellation() {
+    final Flow<Integer, Integer, NotUsed> f = 
Flow.of(Integer.class).alsoTo(Sink.ignore(), false);
+    final Flow<Integer, Integer, NotUsed> f2 = 
Flow.of(Integer.class).alsoTo(Sink.ignore(), true);
+    final Flow<Integer, Integer, String> f3 =
+        Flow.of(Integer.class).alsoToMat(Sink.ignore(), false, (i, n) -> 
"foo");
+    final Flow<Integer, Integer, String> f4 =
+        Flow.of(Integer.class).alsoToMat(Sink.ignore(), true, (i, n) -> "foo");
+  }
+
   @Test
   public void mustBeAbleToUseAlsoToAll() {
     final Flow<Integer, Integer, NotUsed> f =
diff --git 
a/stream-tests/src/test/java/org/apache/pekko/stream/javadsl/GraphDslTest.java 
b/stream-tests/src/test/java/org/apache/pekko/stream/javadsl/GraphDslTest.java
index 869ac16898..135d67b0b2 100644
--- 
a/stream-tests/src/test/java/org/apache/pekko/stream/javadsl/GraphDslTest.java
+++ 
b/stream-tests/src/test/java/org/apache/pekko/stream/javadsl/GraphDslTest.java
@@ -17,6 +17,7 @@ import static org.junit.jupiter.api.Assertions.*;
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.CompletionStage;
 import java.util.concurrent.TimeUnit;
@@ -80,6 +81,27 @@ public class GraphDslTest extends StreamTestJupiter {
         new String[] {"31", "32", "33", "34", "35", "41", "42", "43", "44", 
"45"}, res);
   }
 
+  @Test
+  public void mustKeepFlowingWhenNonEagerBroadcastOutputCancels() throws 
Exception {
+    final Source<Integer, NotUsed> source = Source.from(Arrays.asList(1, 2, 
3));
+    final Sink<Integer, CompletionStage<List<Integer>>> sink = Sink.seq();
+
+    final RunnableGraph<CompletionStage<List<Integer>>> graph =
+        RunnableGraph.fromGraph(
+            GraphDSL.create(
+                sink,
+                (builder, out) -> {
+                  final UniformFanOutShape<Integer, Integer> broadcast =
+                      builder.add(Broadcast.create(2, true, 
Collections.singleton(1)));
+                  
builder.from(builder.add(source)).viaFanOut(broadcast).to(out);
+                  
builder.from(broadcast).to(builder.add(Sink.<Integer>cancelled()));
+                  return ClosedShape.getInstance();
+                }));
+
+    assertEquals(
+        Arrays.asList(1, 2, 3), graph.run(system).toCompletableFuture().get(3, 
TimeUnit.SECONDS));
+  }
+
   @Test
   @SuppressWarnings("unused")
   public void demonstrateConnectErrors() {
diff --git 
a/stream-tests/src/test/java/org/apache/pekko/stream/javadsl/SourceTest.java 
b/stream-tests/src/test/java/org/apache/pekko/stream/javadsl/SourceTest.java
index 519a2eeec7..465d3d350b 100644
--- a/stream-tests/src/test/java/org/apache/pekko/stream/javadsl/SourceTest.java
+++ b/stream-tests/src/test/java/org/apache/pekko/stream/javadsl/SourceTest.java
@@ -1476,6 +1476,16 @@ public class SourceTest extends StreamTestJupiter {
         Source.<Integer>empty().alsoToMat(Sink.ignore(), (i, n) -> "foo");
   }
 
+  @Test
+  public void mustBeAbleToUseAlsoToWithPropagateCancellation() {
+    final Source<Integer, NotUsed> f = 
Source.<Integer>empty().alsoTo(Sink.ignore(), false);
+    final Source<Integer, NotUsed> f2 = 
Source.<Integer>empty().alsoTo(Sink.ignore(), true);
+    final Source<Integer, String> f3 =
+        Source.<Integer>empty().alsoToMat(Sink.ignore(), false, (i, n) -> 
"foo");
+    final Source<Integer, String> f4 =
+        Source.<Integer>empty().alsoToMat(Sink.ignore(), true, (i, n) -> 
"foo");
+  }
+
   @Test
   public void mustBeAbleToUseAlsoToAll() {
     final Source<Integer, NotUsed> f =
diff --git 
a/stream-tests/src/test/scala/org/apache/pekko/stream/DslConsistencySpec.scala 
b/stream-tests/src/test/scala/org/apache/pekko/stream/DslConsistencySpec.scala
index 1befcaefb7..2aa2c3ec01 100755
--- 
a/stream-tests/src/test/scala/org/apache/pekko/stream/DslConsistencySpec.scala
+++ 
b/stream-tests/src/test/scala/org/apache/pekko/stream/DslConsistencySpec.scala
@@ -90,6 +90,7 @@ class DslConsistencySpec extends AnyWordSpec with Matchers {
     "concatGraph",
     "prependGraph",
     "alsoToGraph",
+    "resilientAlsoToGraph",
     "wireTapGraph",
     "orElseGraph",
     "divertToGraph",
diff --git 
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowAlsoToSpec.scala
 
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowAlsoToSpec.scala
new file mode 100644
index 0000000000..c78b25ebb8
--- /dev/null
+++ 
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowAlsoToSpec.scala
@@ -0,0 +1,288 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.stream.scaladsl
+
+import org.apache.pekko
+import pekko.stream.testkit._
+import pekko.stream.testkit.scaladsl.TestSink
+import pekko.stream.testkit.scaladsl.TestSource
+
+import scala.concurrent.duration._
+
+class FlowAlsoToSpec extends StreamSpec("""
+    pekko.stream.materializer.initial-input-buffer-size = 2
+  """) {
+
+  "alsoTo with propagateCancellation=true (default)" must {
+
+    "cancel the stream when side sink cancels" in {
+      val (mainProbe, mainSink) = TestSink[Int]().preMaterialize()
+      val (sideProbe, sideSink) = TestSink[Int]().preMaterialize()
+      val (pub, src) = TestSource[Int]().preMaterialize()
+
+      src.alsoTo(sideSink).runWith(mainSink)
+
+      mainProbe.request(2)
+      sideProbe.request(2)
+
+      pub.sendNext(1)
+      mainProbe.expectNext(1)
+      sideProbe.expectNext(1)
+
+      sideProbe.cancel()
+      pub.expectCancellation()
+    }
+
+    "forward elements to both downstreams" in {
+      val (mainProbe, mainSink) = TestSink[Int]().preMaterialize()
+      val (sideProbe, sideSink) = TestSink[Int]().preMaterialize()
+      val (pub, src) = TestSource[Int]().preMaterialize()
+
+      src.alsoTo(sideSink).runWith(mainSink)
+
+      mainProbe.request(3)
+      sideProbe.request(3)
+
+      pub.sendNext(1)
+      pub.sendNext(2)
+      pub.sendNext(3)
+
+      mainProbe.expectNext(1, 2, 3)
+      sideProbe.expectNext(1, 2, 3)
+
+      pub.sendComplete()
+      mainProbe.expectComplete()
+      sideProbe.expectComplete()
+    }
+  }
+
+  "alsoTo with propagateCancellation=false" must {
+
+    "continue main stream when side sink cancels" in {
+      val (mainProbe, mainSink) = TestSink[Int]().preMaterialize()
+      val (sideProbe, sideSink) = TestSink[Int]().preMaterialize()
+      val (pub, src) = TestSource[Int]().preMaterialize()
+
+      src.alsoTo(sideSink, propagateCancellation = false).runWith(mainSink)
+
+      mainProbe.request(4)
+      sideProbe.request(2)
+
+      pub.sendNext(1)
+      mainProbe.expectNext(1)
+      sideProbe.expectNext(1)
+
+      pub.sendNext(2)
+      mainProbe.expectNext(2)
+      sideProbe.expectNext(2)
+
+      sideProbe.cancel()
+
+      pub.sendNext(3)
+      mainProbe.expectNext(3)
+
+      pub.sendNext(4)
+      mainProbe.expectNext(4)
+
+      pub.sendComplete()
+      mainProbe.expectComplete()
+    }
+
+    "continue main stream when side sink fails" in {
+      val (mainProbe, mainSink) = TestSink[Int]().preMaterialize()
+      val (sideProbe, sideSink) = TestSink[Int]().preMaterialize()
+      val (pub, src) = TestSource[Int]().preMaterialize()
+
+      val failingSideSink = Flow[Int].map { elem =>
+        if (elem == 1) throw new RuntimeException("side sink failure")
+        elem
+      }.to(sideSink)
+
+      src.alsoTo(failingSideSink, propagateCancellation = 
false).runWith(mainSink)
+
+      mainProbe.request(3)
+      sideProbe.request(3)
+
+      pub.sendNext(1)
+      mainProbe.expectNext(1)
+
+      pub.sendNext(2)
+      mainProbe.expectNext(2)
+
+      pub.sendNext(3)
+      mainProbe.expectNext(3)
+
+      pub.sendComplete()
+      mainProbe.expectComplete()
+    }
+
+    "cancel side sink when main downstream cancels" in {
+      val (mainProbe, mainSink) = TestSink[Int]().preMaterialize()
+      val (sideProbe, sideSink) = TestSink[Int]().preMaterialize()
+      val (pub, src) = TestSource[Int]().preMaterialize()
+
+      src.alsoTo(sideSink, propagateCancellation = false).runWith(mainSink)
+
+      mainProbe.request(1)
+      sideProbe.request(1)
+
+      pub.sendNext(1)
+      mainProbe.expectNext(1)
+      sideProbe.expectNext(1)
+
+      mainProbe.cancel()
+      pub.expectCancellation()
+    }
+
+    "forward elements to both downstreams before side cancels" in {
+      val (mainProbe, mainSink) = TestSink[Int]().preMaterialize()
+      val (sideProbe, sideSink) = TestSink[Int]().preMaterialize()
+      val (pub, src) = TestSource[Int]().preMaterialize()
+
+      src.alsoTo(sideSink, propagateCancellation = false).runWith(mainSink)
+
+      mainProbe.request(3)
+      sideProbe.request(3)
+
+      pub.sendNext(1)
+      pub.sendNext(2)
+      pub.sendNext(3)
+
+      mainProbe.expectNext(1, 2, 3)
+      sideProbe.expectNext(1, 2, 3)
+
+      pub.sendComplete()
+      mainProbe.expectComplete()
+      sideProbe.expectComplete()
+    }
+
+    "complete normally when upstream completes" in {
+      val (mainProbe, mainSink) = TestSink[Int]().preMaterialize()
+      val (sideProbe, sideSink) = TestSink[Int]().preMaterialize()
+      val (pub, src) = TestSource[Int]().preMaterialize()
+
+      src.alsoTo(sideSink, propagateCancellation = false).runWith(mainSink)
+
+      mainProbe.request(2)
+      sideProbe.request(2)
+
+      pub.sendNext(1)
+      pub.sendNext(2)
+      pub.sendComplete()
+
+      mainProbe.expectNext(1, 2).expectComplete()
+      sideProbe.expectNext(1, 2).expectComplete()
+    }
+
+    "handle side sink cancelling before any element is emitted" in {
+      val (mainProbe, mainSink) = TestSink[Int]().preMaterialize()
+      val (sideProbe, sideSink) = TestSink[Int]().preMaterialize()
+      val (pub, src) = TestSource[Int]().preMaterialize()
+
+      src.alsoTo(sideSink, propagateCancellation = false).runWith(mainSink)
+
+      mainProbe.request(2)
+      sideProbe.request(1)
+
+      sideProbe.cancel()
+
+      pub.sendNext(1)
+      mainProbe.expectNext(1)
+
+      pub.sendNext(2)
+      mainProbe.expectNext(2)
+
+      pub.sendComplete()
+      mainProbe.expectComplete()
+    }
+
+    "propagate upstream failure to both downstreams" in {
+      val (mainProbe, mainSink) = TestSink[Int]().preMaterialize()
+      val (sideProbe, sideSink) = TestSink[Int]().preMaterialize()
+      val (pub, src) = TestSource[Int]().preMaterialize()
+
+      src.alsoTo(sideSink, propagateCancellation = false).runWith(mainSink)
+
+      mainProbe.request(1)
+      sideProbe.request(1)
+
+      val ex = new RuntimeException("upstream boom")
+      pub.sendError(ex)
+
+      mainProbe.expectError(ex)
+      sideProbe.expectError(ex)
+    }
+
+    "backpressure when side sink is slow" in {
+      val (mainProbe, mainSink) = TestSink[Int]().preMaterialize()
+      val (sideProbe, sideSink) = TestSink[Int]().preMaterialize()
+      val (pub, src) = TestSource[Int]().preMaterialize()
+
+      src.alsoTo(sideSink, propagateCancellation = false).runWith(mainSink)
+
+      mainProbe.request(3)
+      sideProbe.request(1)
+
+      pub.sendNext(1)
+      mainProbe.expectNext(1)
+      sideProbe.expectNext(1)
+
+      sideProbe.request(1)
+
+      pub.sendNext(2)
+      mainProbe.expectNext(2)
+      sideProbe.expectNext(2)
+
+      sideProbe.request(1)
+
+      pub.sendNext(3)
+      mainProbe.expectNext(3)
+      sideProbe.expectNext(3)
+
+      pub.sendComplete()
+      mainProbe.expectComplete()
+      sideProbe.expectComplete()
+    }
+
+    "handle side sink cancelling while pending element exists" in {
+      val (mainProbe, mainSink) = TestSink[Int]().preMaterialize()
+      val (sideProbe, sideSink) = TestSink[Int]().preMaterialize()
+      val (pub, src) = TestSource[Int]().preMaterialize()
+
+      src.alsoTo(sideSink, propagateCancellation = false).runWith(mainSink)
+
+      mainProbe.request(3)
+      sideProbe.request(1)
+
+      pub.sendNext(1)
+      mainProbe.expectNext(1)
+      sideProbe.expectNext(1)
+
+      pub.sendNext(2)
+
+      sideProbe.cancel()
+      mainProbe.expectNext(2)
+
+      pub.sendNext(3)
+      mainProbe.expectNext(3)
+
+      pub.sendComplete()
+      mainProbe.expectComplete()
+    }
+  }
+}
diff --git 
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/GraphBroadcastSpec.scala
 
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/GraphBroadcastSpec.scala
index 5817f7a633..0b9b3c3884 100644
--- 
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/GraphBroadcastSpec.scala
+++ 
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/GraphBroadcastSpec.scala
@@ -301,6 +301,51 @@ class GraphBroadcastSpec extends StreamSpec("""
       pMain.cancel()
       pIn.expectCancellation()
     }
+
+    "continue to remaining outputs when a non-eager output cancels" in {
+      val (pub, src) = TestSource[Int]().preMaterialize()
+      val (mainProbe, mainSink) = TestSink[Int]().preMaterialize()
+      val (sideProbe, sideSink) = TestSink[Int]().preMaterialize()
+
+      RunnableGraph
+        .fromGraph(GraphDSL.create() { implicit b =>
+          import GraphDSL.Implicits._
+          // eagerCancel=true but side output (index 1) is non-eager: its 
cancellation
+          // must not tear down the stage. Main output (index 0) keeps flowing.
+          val bcast = b.add(Broadcast[Int](2, eagerCancel = true, 
nonEagerCancelOutputs = Set(1)))
+          src          ~> bcast.in
+          bcast.out(0) ~> mainSink
+          bcast.out(1) ~> sideSink
+          ClosedShape
+        })
+        .run()
+
+      mainProbe.request(3)
+      sideProbe.request(2)
+
+      pub.sendNext(1)
+      mainProbe.expectNext(1)
+      sideProbe.expectNext(1)
+
+      pub.sendNext(2)
+      mainProbe.expectNext(2)
+      sideProbe.expectNext(2)
+
+      sideProbe.cancel()
+
+      pub.sendNext(3)
+      mainProbe.expectNext(3)
+
+      pub.sendComplete()
+      mainProbe.expectComplete()
+    }
+
+    "reject nonEagerCancelOutputs when eagerCancel is false" in {
+      val ex = intercept[IllegalArgumentException] {
+        Broadcast[Int](2, eagerCancel = false, nonEagerCancelOutputs = Set(1))
+      }
+      ex.getMessage should include("requires eagerCancel=true")
+    }
   }
 
 }
diff --git a/stream/src/main/scala/org/apache/pekko/stream/javadsl/Flow.scala 
b/stream/src/main/scala/org/apache/pekko/stream/javadsl/Flow.scala
index 50c8e2a0e2..525a0e25a7 100755
--- a/stream/src/main/scala/org/apache/pekko/stream/javadsl/Flow.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/javadsl/Flow.scala
@@ -3322,6 +3322,30 @@ final class Flow[In, Out, Mat](delegate: 
scaladsl.Flow[In, Out, Mat]) extends Gr
   def alsoTo(that: Graph[SinkShape[Out], ?]): javadsl.Flow[In, Out, Mat] =
     new Flow(delegate.alsoTo(that))
 
+  /**
+   * Attaches the given [[Sink]] to this [[Flow]], meaning that elements that 
pass
+   * through will also be sent to the [[Sink]].
+   *
+   * When `propagateCancellation` is `false`, cancellation or failure of the 
side [[Sink]]
+   * will not cancel the main stream. Elements will continue to flow to the 
main downstream only.
+   *
+   * When `propagateCancellation` is `true` (the default), this behaves 
identically to [[#alsoTo]].
+   *
+   * '''Emits when''' element is available and demand exists both from the 
Sink and the downstream.
+   *
+   * '''Backpressures when''' downstream or Sink backpressures
+   *
+   * '''Completes when''' upstream completes
+   *
+   * '''Cancels when''' downstream cancels (the side [[Sink]] is also 
cancelled).
+   *                        When `propagateCancellation` is `true`, 
cancellation or failure of
+   *                        the side [[Sink]] also cancels the downstream.
+   *
+   * @since 2.0.0
+   */
+  def alsoTo(that: Graph[SinkShape[Out], ?], propagateCancellation: Boolean): 
javadsl.Flow[In, Out, Mat] =
+    new Flow(delegate.alsoTo(that, propagateCancellation))
+
   /**
    * Attaches the given [[Sink]]s to this [[Flow]], meaning that elements that 
passes
    * through will also be sent to all those [[Sink]]s.
@@ -3357,6 +3381,23 @@ final class Flow[In, Out, Mat](delegate: 
scaladsl.Flow[In, Out, Mat]) extends Gr
       matF: function.Function2[Mat, M2, M3]): javadsl.Flow[In, Out, M3] =
     new Flow(delegate.alsoToMat(that)(combinerToScala(matF)))
 
+  /**
+   * Attaches the given [[Sink]] to this [[Flow]], meaning that elements that 
pass
+   * through will also be sent to the [[Sink]].
+   *
+   * @see [[#alsoTo]]
+   *
+   * It is recommended to use the internally optimized `Keep.left` and 
`Keep.right` combiners
+   * where appropriate instead of manually writing functions that pass through 
one of the values.
+   *
+   * @since 2.0.0
+   */
+  def alsoToMat[M2, M3](
+      that: Graph[SinkShape[Out], M2],
+      propagateCancellation: Boolean,
+      matF: function.Function2[Mat, M2, M3]): javadsl.Flow[In, Out, M3] =
+    new Flow(delegate.alsoToMat(that, 
propagateCancellation)(combinerToScala(matF)))
+
   /**
    * Attaches the given [[Sink]] to this [[Flow]], meaning that elements will 
be sent to the [[Sink]]
    * instead of being passed through if the predicate `when` returns `true`.
diff --git a/stream/src/main/scala/org/apache/pekko/stream/javadsl/Graph.scala 
b/stream/src/main/scala/org/apache/pekko/stream/javadsl/Graph.scala
index 4af7893d3f..f897bde130 100644
--- a/stream/src/main/scala/org/apache/pekko/stream/javadsl/Graph.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/javadsl/Graph.scala
@@ -209,6 +209,27 @@ object Broadcast {
   def create[T](outputCount: Int, eagerCancel: Boolean): 
Graph[UniformFanOutShape[T, T], NotUsed] =
     scaladsl.Broadcast(outputCount, eagerCancel = eagerCancel)
 
+  /**
+   * Create a new `Broadcast` operator with the specified input type and 
per-output cancellation behavior.
+   *
+   * @param outputCount number of output ports
+   * @param eagerCancel if true, broadcast cancels upstream if any of its 
downstreams cancel
+   *                    (except those listed in `nonEagerCancelOutputs`).
+   * @param nonEagerCancelOutputs set of output port indices whose 
cancellation should not
+   *                              trigger upstream cancellation. These outputs 
are silently
+   *                              removed from the broadcast when they cancel, 
and elements
+   *                              continue flowing to the remaining outputs.
+   * @since 2.0.0
+   */
+  def create[T](
+      outputCount: Int,
+      eagerCancel: Boolean,
+      nonEagerCancelOutputs: util.Set[java.lang.Integer]): 
Graph[UniformFanOutShape[T, T], NotUsed] =
+    scaladsl.Broadcast(
+      outputCount,
+      eagerCancel = eagerCancel,
+      nonEagerCancelOutputs = 
nonEagerCancelOutputs.asScala.iterator.map(_.intValue()).toSet)
+
   /**
    * Create a new `Broadcast` operator with the specified input type.
    *
diff --git a/stream/src/main/scala/org/apache/pekko/stream/javadsl/Source.scala 
b/stream/src/main/scala/org/apache/pekko/stream/javadsl/Source.scala
index 4c8923a6fe..9893ffdcd9 100755
--- a/stream/src/main/scala/org/apache/pekko/stream/javadsl/Source.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/javadsl/Source.scala
@@ -1412,6 +1412,30 @@ final class Source[Out, Mat](delegate: 
scaladsl.Source[Out, Mat]) extends Graph[
   def alsoTo(that: Graph[SinkShape[Out], ?]): javadsl.Source[Out, Mat] =
     new Source(delegate.alsoTo(that))
 
+  /**
+   * Attaches the given [[Sink]] to this [[Source]], meaning that elements 
that pass
+   * through will also be sent to the [[Sink]].
+   *
+   * When `propagateCancellation` is `false`, cancellation or failure of the 
side [[Sink]]
+   * will not cancel the main stream. Elements will continue to flow to the 
main downstream only.
+   *
+   * When `propagateCancellation` is `true` (the default), this behaves 
identically to [[#alsoTo]].
+   *
+   * '''Emits when''' element is available and demand exists both from the 
Sink and the downstream.
+   *
+   * '''Backpressures when''' downstream or Sink backpressures
+   *
+   * '''Completes when''' upstream completes
+   *
+   * '''Cancels when''' downstream cancels (the side [[Sink]] is also 
cancelled).
+   *                        When `propagateCancellation` is `true`, 
cancellation or failure of
+   *                        the side [[Sink]] also cancels the downstream.
+   *
+   * @since 2.0.0
+   */
+  def alsoTo(that: Graph[SinkShape[Out], ?], propagateCancellation: Boolean): 
javadsl.Source[Out, Mat] =
+    new Source(delegate.alsoTo(that, propagateCancellation))
+
   /**
    * Attaches the given [[Sink]]s to this [[Source]], meaning that elements 
that passes
    * through will also be sent to all those [[Sink]]s.
@@ -1447,6 +1471,23 @@ final class Source[Out, Mat](delegate: 
scaladsl.Source[Out, Mat]) extends Graph[
       matF: function.Function2[Mat, M2, M3]): javadsl.Source[Out, M3] =
     new Source(delegate.alsoToMat(that)(combinerToScala(matF)))
 
+  /**
+   * Attaches the given [[Sink]] to this [[Source]], meaning that elements 
that pass
+   * through will also be sent to the [[Sink]].
+   *
+   * @see [[#alsoTo]]
+   *
+   * It is recommended to use the internally optimized `Keep.left` and 
`Keep.right` combiners
+   * where appropriate instead of manually writing functions that pass through 
one of the values.
+   *
+   * @since 2.0.0
+   */
+  def alsoToMat[M2, M3](
+      that: Graph[SinkShape[Out], M2],
+      propagateCancellation: Boolean,
+      matF: function.Function2[Mat, M2, M3]): javadsl.Source[Out, M3] =
+    new Source(delegate.alsoToMat(that, 
propagateCancellation)(combinerToScala(matF)))
+
   /**
    * Attaches the given [[Sink]] to this [[Flow]], meaning that elements will 
be sent to the [[Sink]]
    * instead of being passed through if the predicate `when` returns `true`.
diff --git 
a/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubFlow.scala 
b/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubFlow.scala
index 189ad343a7..f06f63d10c 100755
--- a/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubFlow.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubFlow.scala
@@ -2329,6 +2329,30 @@ final class SubFlow[In, Out, Mat](
   def alsoTo(that: Graph[SinkShape[Out], ?]): SubFlow[In, Out, Mat] =
     new SubFlow(delegate.alsoTo(that))
 
+  /**
+   * Attaches the given [[Sink]] to this [[Flow]], meaning that elements that 
pass
+   * through will also be sent to the [[Sink]].
+   *
+   * When `propagateCancellation` is `false`, cancellation or failure of the 
side [[Sink]]
+   * will not cancel the main stream. Elements will continue to flow to the 
main downstream only.
+   *
+   * When `propagateCancellation` is `true` (the default), this behaves 
identically to [[#alsoTo]].
+   *
+   * '''Emits when''' element is available and demand exists both from the 
Sink and the downstream.
+   *
+   * '''Backpressures when''' downstream or Sink backpressures
+   *
+   * '''Completes when''' upstream completes
+   *
+   * '''Cancels when''' downstream cancels (the side [[Sink]] is also 
cancelled).
+   *                        When `propagateCancellation` is `true`, 
cancellation or failure of
+   *                        the side [[Sink]] also cancels the downstream.
+   *
+   * @since 2.0.0
+   */
+  def alsoTo(that: Graph[SinkShape[Out], ?], propagateCancellation: Boolean): 
SubFlow[In, Out, Mat] =
+    new SubFlow(delegate.alsoTo(that, propagateCancellation))
+
   /**
    * Attaches the given [[Sink]]s to this [[Flow]], meaning that elements that 
passes
    * through will also be sent to all those [[Sink]]s.
diff --git 
a/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubSource.scala 
b/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubSource.scala
index ba572b0a86..da703a4562 100755
--- a/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubSource.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubSource.scala
@@ -2295,6 +2295,30 @@ final class SubSource[Out, Mat](
   def alsoTo(that: Graph[SinkShape[Out], ?]): SubSource[Out, Mat] =
     new SubSource(delegate.alsoTo(that))
 
+  /**
+   * Attaches the given [[Sink]] to this [[Source]], meaning that elements 
that pass
+   * through will also be sent to the [[Sink]].
+   *
+   * When `propagateCancellation` is `false`, cancellation or failure of the 
side [[Sink]]
+   * will not cancel the main stream. Elements will continue to flow to the 
main downstream only.
+   *
+   * When `propagateCancellation` is `true` (the default), this behaves 
identically to [[#alsoTo]].
+   *
+   * '''Emits when''' element is available and demand exists both from the 
Sink and the downstream.
+   *
+   * '''Backpressures when''' downstream or Sink backpressures
+   *
+   * '''Completes when''' upstream completes
+   *
+   * '''Cancels when''' downstream cancels (the side [[Sink]] is also 
cancelled).
+   *                        When `propagateCancellation` is `true`, 
cancellation or failure of
+   *                        the side [[Sink]] also cancels the downstream.
+   *
+   * @since 2.0.0
+   */
+  def alsoTo(that: Graph[SinkShape[Out], ?], propagateCancellation: Boolean): 
SubSource[Out, Mat] =
+    new SubSource(delegate.alsoTo(that, propagateCancellation))
+
   /**
    * Attaches the given [[Sink]]s to this [[Source]], meaning that elements 
that passes
    * through will also be sent to all those [[Sink]]s.
diff --git a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Flow.scala 
b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Flow.scala
index 322108cd63..f9addecd4a 100755
--- a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Flow.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Flow.scala
@@ -4067,6 +4067,44 @@ trait FlowOps[+Out, +Mat] {
       FlowShape(bcast.in, bcast.out(0))
     }
 
+  /**
+   * Attaches the given [[Sink]] to this [[Flow]], meaning that elements that 
pass
+   * through will also be sent to the [[Sink]].
+   *
+   * When `propagateCancellation` is `false`, cancellation or failure of the 
side [[Sink]]
+   * will not cancel the main stream. Elements will continue to flow to the 
main downstream
+   * only. This is useful for fire-and-forget side sinks (e.g. logging) where 
the side sink's
+   * availability should not affect the main business stream.
+   *
+   * When `propagateCancellation` is `true` (the default), this behaves 
identically to [[#alsoTo]].
+   *
+   * It is similar to [[#wireTap]] but will backpressure instead of dropping 
elements when the given [[Sink]] is not ready.
+   *
+   * '''Emits when''' element is available and demand exists both from the 
Sink and the downstream.
+   *
+   * '''Backpressures when''' downstream or Sink backpressures
+   *
+   * '''Completes when''' upstream completes
+   *
+   * '''Cancels when''' downstream cancels (the side [[Sink]] is also 
cancelled).
+   *                        When `propagateCancellation` is `true`, 
cancellation or failure of
+   *                        the side [[Sink]] also cancels the downstream.
+   *
+   * @since 2.0.0
+   */
+  def alsoTo(that: Graph[SinkShape[Out], ?], propagateCancellation: Boolean): 
Repr[Out] =
+    if (propagateCancellation) alsoTo(that)
+    else via(resilientAlsoToGraph(that))
+
+  protected def resilientAlsoToGraph[M](
+      that: Graph[SinkShape[Out], M]): Graph[FlowShape[Out @uncheckedVariance, 
Out], M] =
+    GraphDSL.createGraph(that) { implicit b => r =>
+      import GraphDSL.Implicits._
+      val bcast = b.add(Broadcast[Out](2, eagerCancel = true, 
nonEagerCancelOutputs = Set(1)))
+      bcast.out(1) ~> r
+      FlowShape(bcast.in, bcast.out(0))
+    }
+
   /**
    * Attaches the given [[Sink]]s to this [[Source]], meaning that elements 
that pass
    * through will also be sent to the [[Sink]].
@@ -4591,6 +4629,22 @@ trait FlowOpsMat[+Out, +Mat] extends FlowOps[Out, Mat] {
   def alsoToMat[Mat2, Mat3](that: Graph[SinkShape[Out], Mat2])(matF: (Mat, 
Mat2) => Mat3): ReprMat[Out, Mat3] =
     viaMat(alsoToGraph(that))(matF)
 
+  /**
+   * Attaches the given [[Sink]] to this [[Flow]], meaning that elements that 
pass
+   * through will also be sent to the [[Sink]].
+   *
+   * @see [[#alsoTo]]
+   *
+   * It is recommended to use the internally optimized `Keep.left` and 
`Keep.right` combiners
+   * where appropriate instead of manually writing functions that pass through 
one of the values.
+   *
+   * @since 2.0.0
+   */
+  def alsoToMat[Mat2, Mat3](that: Graph[SinkShape[Out], Mat2], 
propagateCancellation: Boolean)(
+      matF: (Mat, Mat2) => Mat3): ReprMat[Out, Mat3] =
+    if (propagateCancellation) viaMat(alsoToGraph(that))(matF)
+    else viaMat(resilientAlsoToGraph(that))(matF)
+
   /**
    * Attaches the given [[Sink]] to this [[Flow]], meaning that elements will 
be sent to the [[Sink]]
    * instead of being passed through if the predicate `when` returns `true`.
diff --git 
a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/FlowWithContext.scala 
b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/FlowWithContext.scala
index fd7c1eafb6..40cf9ce25f 100644
--- 
a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/FlowWithContext.scala
+++ 
b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/FlowWithContext.scala
@@ -149,6 +149,10 @@ final class FlowWithContext[-In, -CtxIn, +Out, +CtxOut, 
+Mat](delegate: Flow[(In
   override def alsoTo(that: Graph[SinkShape[Out], ?]): Repr[Out, CtxOut] =
     FlowWithContext.fromTuples(delegate.alsoTo(Sink.contramapImpl(that, (in: 
(Out, CtxOut)) => in._1)))
 
+  override def alsoTo(that: Graph[SinkShape[Out], ?], propagateCancellation: 
Boolean): Repr[Out, CtxOut] =
+    FlowWithContext.fromTuples(
+      delegate.alsoTo(Sink.contramapImpl(that, (in: (Out, CtxOut)) => in._1), 
propagateCancellation))
+
   override def alsoToContext(that: Graph[SinkShape[CtxOut], ?]): Repr[Out, 
CtxOut] =
     FlowWithContext.fromTuples(delegate.alsoTo(Sink.contramapImpl(that, (in: 
(Out, CtxOut)) => in._2)))
 
diff --git 
a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/FlowWithContextOps.scala
 
b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/FlowWithContextOps.scala
index 3a1193cf86..ac034481fc 100644
--- 
a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/FlowWithContextOps.scala
+++ 
b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/FlowWithContextOps.scala
@@ -95,6 +95,14 @@ trait FlowWithContextOps[+Out, +Ctx, +Mat] {
    */
   def alsoTo(that: Graph[SinkShape[Out], ?]): Repr[Out, Ctx]
 
+  /**
+   * Data variant of [[pekko.stream.scaladsl.FlowOps.alsoTo]] with 
configurable cancellation propagation.
+   *
+   * @see [[pekko.stream.scaladsl.FlowOps.alsoTo]]
+   * @since 2.0.0
+   */
+  def alsoTo(that: Graph[SinkShape[Out], ?], propagateCancellation: Boolean): 
Repr[Out, Ctx]
+
   /**
    * Context variant of [[pekko.stream.scaladsl.FlowOps.alsoTo]]
    *
diff --git a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Graph.scala 
b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Graph.scala
index 415af7ea12..3136297a0d 100755
--- a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Graph.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Graph.scala
@@ -609,6 +609,25 @@ object Broadcast {
    */
   def apply[T](outputPorts: Int, eagerCancel: Boolean = false): Broadcast[T] =
     new Broadcast(outputPorts, eagerCancel)
+
+  /**
+   * Create a new `Broadcast` with the specified number of output ports and 
per-output
+   * cancellation behavior.
+   *
+   * @param outputPorts number of output ports
+   * @param eagerCancel if true, broadcast cancels upstream if any of its 
downstreams cancel
+   *                    (except those listed in `nonEagerCancelOutputs`).
+   * @param nonEagerCancelOutputs set of output port indices whose 
cancellation should not
+   *                              trigger upstream cancellation. These outputs 
are silently
+   *                              removed from the broadcast when they cancel, 
and elements
+   *                              continue flowing to the remaining outputs.
+   * @since 2.0.0
+   */
+  def apply[T](
+      outputPorts: Int,
+      eagerCancel: Boolean,
+      nonEagerCancelOutputs: Set[Int]): Broadcast[T] =
+    new Broadcast(outputPorts, eagerCancel, nonEagerCancelOutputs)
 }
 
 /**
@@ -627,8 +646,46 @@ object Broadcast {
 final class Broadcast[T](val outputPorts: Int, val eagerCancel: Boolean)
     extends GraphStage[UniformFanOutShape[T, T]]
     with pekko.stream.TypePreservingFanOut {
-  // one output might seem counter intuitive but saves us from special 
handling in other places
   require(outputPorts >= 1, "A Broadcast must have one or more output ports")
+
+  // Backing field for the 3-arg auxiliary constructor; default is empty (all 
outputs eager).
+  // The auxiliary constructor assigns this exactly once during construction, 
so the value is
+  // effectively immutable after init. Kept `var` because Scala auxiliary 
constructors cannot
+  // assign `val` fields.
+  private var _nonEagerCancelOutputs: Set[Int] = Set.empty
+
+  /**
+   * Set of output port indices whose cancellation should not trigger upstream 
cancellation.
+   * Only meaningful when `eagerCancel` is `true`; these outputs are silently 
removed from the
+   * broadcast when they cancel, and elements continue flowing to the 
remaining outputs.
+   *
+   * @since 2.0.0
+   */
+  def nonEagerCancelOutputs: Set[Int] = _nonEagerCancelOutputs
+
+  /**
+   * Create a new `Broadcast` with per-output cancellation behavior.
+   *
+   * @param nonEagerCancelOutputs set of output port indices whose 
cancellation should not
+   *                              trigger upstream cancellation when 
`eagerCancel` is `true`.
+   *                              These outputs are silently removed from the 
broadcast when
+   *                              they cancel, and elements continue flowing 
to the remaining
+   *                              outputs. Must be empty when `eagerCancel` is 
`false` (where
+   *                              the parameter would be meaningless).
+   * @since 2.0.0
+   */
+  def this(outputPorts: Int, eagerCancel: Boolean, nonEagerCancelOutputs: 
Set[Int]) = {
+    this(outputPorts, eagerCancel)
+    require(
+      eagerCancel || nonEagerCancelOutputs.isEmpty,
+      s"nonEagerCancelOutputs [$nonEagerCancelOutputs] requires 
eagerCancel=true; " +
+      s"with eagerCancel=false all outputs already behave non-eagerly")
+    require(
+      nonEagerCancelOutputs.subsetOf((0 until outputPorts).toSet),
+      s"nonEagerCancelOutputs [$nonEagerCancelOutputs] must be a subset of 
[${0 until outputPorts}]")
+    _nonEagerCancelOutputs = nonEagerCancelOutputs
+  }
+
   val in: Inlet[T] = Inlet[T]("Broadcast.in")
   val out: immutable.IndexedSeq[Outlet[T]] = Vector.tabulate(outputPorts)(i => 
Outlet[T]("Broadcast.out" + i))
   override def initialAttributes = DefaultAttributes.broadcast
@@ -677,7 +734,7 @@ final class Broadcast[T](val outputPorts: Int, val 
eagerCancel: Boolean)
               }
 
               override def onDownstreamFinish(cause: Throwable) = {
-                if (eagerCancel) cancelStage(cause)
+                if (eagerCancel && !nonEagerCancelOutputs.contains(i)) 
cancelStage(cause)
                 else {
                   downstreamsRunning -= 1
                   if (downstreamsRunning == 0) cancelStage(cause)
diff --git 
a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/SourceWithContext.scala
 
b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/SourceWithContext.scala
index 4890b40efe..e97b206696 100644
--- 
a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/SourceWithContext.scala
+++ 
b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/SourceWithContext.scala
@@ -166,6 +166,10 @@ final class SourceWithContext[+Out, +Ctx, +Mat] 
private[stream] (delegate: Sourc
   override def alsoTo(that: Graph[SinkShape[Out], ?]): Repr[Out, Ctx] =
     SourceWithContext.fromTuples(delegate.alsoTo(Sink.contramapImpl(that, (in: 
(Out, Ctx)) => in._1)))
 
+  override def alsoTo(that: Graph[SinkShape[Out], ?], propagateCancellation: 
Boolean): Repr[Out, Ctx] =
+    SourceWithContext.fromTuples(
+      delegate.alsoTo(Sink.contramapImpl(that, (in: (Out, Ctx)) => in._1), 
propagateCancellation))
+
   override def alsoToContext(that: Graph[SinkShape[Ctx], ?]): Repr[Out, Ctx] =
     SourceWithContext.fromTuples(delegate.alsoTo(Sink.contramapImpl(that, (in: 
(Out, Ctx)) => in._2)))
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to