Weird behavior of Sinks.many().multicast() with retryWhen
larousso opened this issue · comments
Hi,
When I run this code, the sink is cancelled when an error occured and the retryWhen has no effect.
AtomicBoolean shouldFail = new AtomicBoolean(true);
CountDownLatch cdl = new CountDownLatch(1);
Sinks.Many<Integer> sink = Sinks.many().multicast().onBackpressureBuffer(100);
var d = sink.asFlux().concatMap(any -> {
if (any == 50 && shouldFail.getAndSet(false)) {
return Mono.error(new RuntimeException("Boom"));
} else {
return Mono.just(any);
}
}, 1)
.retryWhen(Retry.backoff(5, Duration.ofMillis(1)))
.subscribe(
n -> {
System.out.println("Next %s".formatted(n));
},
e -> {
e.printStackTrace();
cdl.countDown();
},
() -> {
System.out.println("Finished");
cdl.countDown();
}
);
for (int i = 0; i < 100; i++) {
Sinks.EmitResult emitResult = sink.tryEmitNext(i);
if (emitResult.isFailure()) {
System.out.println(emitResult);
}
}
d.dispose();
System.out.println(sink.scan(Scannable.Attr.CANCELLED));
try {
cdl.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
Expected Behavior
The sink should publish the buffered elements when the retry do his job.
Actual Behavior
The sink is cancelled and is no longer working.
Steps to Reproduce
CF code nippet
@Test
void reproCase() {
AtomicBoolean shouldFail = new AtomicBoolean(true);
CountDownLatch cdl = new CountDownLatch(1);
Sinks.Many<Integer> sink = Sinks.many().multicast().onBackpressureBuffer(100);
var d = sink.asFlux().concatMap(any -> {
if (any == 50 && shouldFail.getAndSet(false)) {
return Mono.error(new RuntimeException("Boom"));
} else {
return Mono.just(any);
}
}, 1)
.retryWhen(Retry.backoff(5, Duration.ofMillis(1)))
.subscribe(
n -> {
System.out.println("Next %s".formatted(n));
},
e -> {
e.printStackTrace();
cdl.countDown();
},
() -> {
System.out.println("Finished");
cdl.countDown();
}
);
for (int i = 0; i < 100; i++) {
Sinks.EmitResult emitResult = sink.tryEmitNext(i);
if (emitResult.isFailure()) {
System.out.println(emitResult);
}
}
d.dispose();
System.out.println(sink.scan(Scannable.Attr.CANCELLED));
try {
cdl.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
Hey @larousso 👋
d.dispose();
This is where the sink
is cancelled. Your for loop emits 100 items -> items 0 to 49 are delivered to the Subscriber
, item 50 is retried with a delay of 1ms, while before that time passes you dispose the Subscription
, which means item 51 and the rest are not delivered.
Now let's assume we remove d.dispose()
. What happens is that the test passes, however items 51 and the rest are not delivered still. At the same time sink.scan(Scannable.Attr.CANCELLED)
returns true
. Why is that? The sink
you used has a property called autoCancel
which is true
. It means the sink
is considered done when the last subscriber terminates and that happens at the time the retry logic tries to re-subscribe - it first cancels the current Subscription
, rendering the sink
unusable for future subscribes. A re-subscription would not process any values and immediately terminates. If we disable autoCancel
, I believe the example behaves as you'd expect. Please see the modified reproducer:
@Test
void reproCase() {
AtomicBoolean shouldFail = new AtomicBoolean(true);
CountDownLatch cdl = new CountDownLatch(1);
// Note the autoCancel = false argument.
Sinks.Many<Integer> sink = Sinks.many().multicast()
.onBackpressureBuffer(100, false);
// Note the output for "Subscribed" - it appears twice.
Disposable d = sink.asFlux().doOnSubscribe(s -> System.out.println("Subscribed"))
.concatMap(any -> {
if (any == 50 && shouldFail.getAndSet(false)) {
return Mono.error(new RuntimeException("Boom"));
}
else {
return Mono.just(any);
}
}, 1)
.retryWhen(Retry.backoff(5, Duration.ofMillis(1)))
.subscribe(n -> {
System.out.println("Next " + n);
}, e -> {
e.printStackTrace();
cdl.countDown();
}, () -> {
System.out.println("Finished");
cdl.countDown();
});
for (int i = 0; i < 100; i++) {
Sinks.EmitResult emitResult = sink.tryEmitNext(i);
if (emitResult.isFailure()) {
System.out.println(emitResult);
}
}
// Note we emit completion to countDown the latch.
sink.tryEmitComplete();
// The dispose() call is counter-productive, we remove it.
// d.dispose();
System.out.println(sink.scan(Scannable.Attr.CANCELLED));
try {
cdl.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
Having the above, I'm closing the report as in my view everything works as designed.
Hi,
The d.dispose();
was a mistake.
I didn't know about the auto cancel and it's the information I needed.
Thanks for your answer !