reactor / reactor-core

Non-Blocking Reactive Foundation for the JVM

Home Page:http://projectreactor.io

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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 !