Skip to content

Apply occurrent.subscription.mode=manual to reactive subscriptions too - #503

Merged
johanhaleby merged 2 commits into
mainfrom
johan/subscription-mode-reactor-phase2
Aug 2, 2026
Merged

Apply occurrent.subscription.mode=manual to reactive subscriptions too#503
johanhaleby merged 2 commits into
mainfrom
johan/subscription-mode-reactor-phase2

Conversation

@johanhaleby

Copy link
Copy Markdown
Owner

The reactive starter stopped the synchronous model under occurrent.subscription.mode=manual and did nothing about the asynchronous one, so synchronous projections stopped running while asynchronous subscriptions kept going. Half-applying is worse than not applying, because the property's own documentation told you none of your subscriptions would run.

This finishes it. The durable model is outermost on this stack and already declines to subscribe while stopped, so manual needs no wrapper, unlike the blocking stack: the bean method calls stop() on it, and no reactor model implements Spring's Lifecycle, so nothing starts it again.

The eight waitUntilStarted().block() calls in the reactor registrars had to be gated in the same change rather than a later one. A paused reactive subscription's waitUntilStarted() never completes, so stopping the model without gating them means context startup never finishes.

A @Projection(source = PUSH) is fed by a bean you supply rather than by Occurrent's subscription model, so a reactor ManualStartProjections withholds those instead. start(id) and startAll() return a Mono, and the id is claimed when it is subscribed rather than when it is called, so a Mono you build and never subscribe leaves the projection withheld instead of dropping its startup work. For a domain feed the registration is withheld along with the catch-up, because registering alone starts buffering, which is why the reactive DomainEventFeed gains the per-id catchUp the blocking one got in #497.

ADR 88 records why no wrapper is needed here, and corrects ADR 86's claim that the reactive stack already had this property. It did withhold delivery, but it did not pin the start position, which was the bug fixed in #501.

One thing worth knowing, found by removing a gate and watching what happened. The wait runs while Spring builds the context, before the test method, so a JUnit @Timeout does not cover it: a dropped gate hangs the build for as long as you let it rather than reporting a failure. The blocking test has claimed the opposite since #497 and that claim is corrected here too.

Resolves #481, apart from the OpenRewrite recipe for the renamed property.

The reactive starter stopped the synchronous model under manual and did
nothing about the asynchronous one, so synchronous projections stopped
running while asynchronous subscriptions kept going.

The durable model is outermost on this stack and already declines to
subscribe while stopped, so manual needs no wrapper: the bean method
calls stop() on it. No reactor model implements Spring's Lifecycle, so
nothing starts it again.

The eight waitUntilStarted().block() calls in the reactor registrars had
to be gated in the same change. A paused reactive subscription's
waitUntilStarted() never completes, and the wait happens while Spring
builds the context, so a missed gate hangs the build rather than failing
a test.

A @Projection(source = PUSH) is fed by a bean the application supplies,
so a reactor ManualStartProjections withholds those instead. Its start
and startAll return a Mono, and the id is claimed on subscribe so an
unsubscribed Mono leaves the projection withheld rather than dropping
it. For a domain feed the registration is withheld along with the
catch-up, since registering alone starts buffering, which needs the
per-id DomainEventFeed.catchUp the blocking feed already had.

ADR 88 records why this needs no wrapper, and corrects ADR 86's claim
that the reactive stack already had the property.

Resolves #481.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

A concurrency/contract mismatch in the new reactor ManualStartProjections.startAll() return semantics should be addressed to avoid misleading callers in concurrent use.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Extends occurrent.subscription.mode=manual behavior to the reactive MongoDB Spring Boot starter so reactive subscriptions/projections are registered but do not start (or block context boot) until explicitly resumed/started, matching the property’s documented semantics across stacks.

Changes:

  • Stop the reactive durable subscription model (and synchronous model) up-front when occurrent.subscription.mode != auto, and gate registrar waitUntilStarted().block() behavior so context startup doesn’t hang in manual.
  • Add reactive support for withholding @Projection(source = PUSH) startup work via a new reactor ManualStartProjections registry, including deferring DomainEventFeed registration + catch-up together.
  • Add reactive test coverage for manual mode (basic pause/resume, push projections, domain-push projections, and restart-with-checkpoint catch-up path), plus ADR/changelog updates.
File summaries
File Description
framework/spring-boot-starter-mongodb/src/test/java/org/occurrent/springboot/mongo/blocking/SubscriptionModeManualMongoTest.java Corrects test documentation around context-build hangs vs JUnit @Timeout.
framework/spring-boot-starter-mongodb-reactive/src/test/java/org/occurrent/springboot/mongo/reactor/ReactiveSubscriptionModeManualRestartMongoTest.java New test ensuring manual mode withholds catch-up replay across restart until resumed.
framework/spring-boot-starter-mongodb-reactive/src/test/java/org/occurrent/springboot/mongo/reactor/ReactiveSubscriptionModeManualPushProjectionMongoTest.java New test verifying manual withholding/starting for @Projection(source=PUSH) using PushSubscriptionModel.
framework/spring-boot-starter-mongodb-reactive/src/test/java/org/occurrent/springboot/mongo/reactor/ReactiveSubscriptionModeManualMongoTest.java New test proving reactive subscriptions are registered-but-paused in manual, and resume delivers withheld events.
framework/spring-boot-starter-mongodb-reactive/src/test/java/org/occurrent/springboot/mongo/reactor/ReactiveSubscriptionModeManualDomainPushProjectionMongoTest.java New test verifying domain-push projections are fully withheld (registration + catch-up) until started.
framework/spring-boot-starter-mongodb-reactive/src/main/java/org/occurrent/springboot/mongo/reactor/OccurrentReactiveMongoAutoConfiguration.java Stops reactive durable/synchronous subscription models up-front when not auto to enforce manual semantics.
framework/spring-boot-autoconfigure/reactor/src/main/java/org/occurrent/springboot/reactor/SubscriptionAnnotationRegistrar.java Gates waitUntilStarted behavior on “subscriptions start automatically” to avoid hangs in manual.
framework/spring-boot-autoconfigure/reactor/src/main/java/org/occurrent/springboot/reactor/SnapshotAnnotationRegistrar.java Same gating for snapshot registration waits under manual.
framework/spring-boot-autoconfigure/reactor/src/main/java/org/occurrent/springboot/reactor/ProjectionAnnotationRegistrar.java Defers push/domain-push projection startup work into ManualStartProjections under manual (and adds per-id domain feed catch-up).
framework/spring-boot-autoconfigure/reactor/src/main/java/org/occurrent/springboot/reactor/OccurrentReactiveAnnotationConfiguration.java Auto-configures reactor ManualStartProjections bean for applications to start withheld push projections.
framework/spring-boot-autoconfigure/reactor/src/main/java/org/occurrent/springboot/reactor/ManualStartProjections.java New reactor registry for deferred push-projection startup (start by id / start all).
dsl/projection-dsl/reactor/src/main/java/org/occurrent/dsl/projection/reactor/DomainEventFeed.java Adds per-id catchUp(String) for domain feeds to avoid re-running unrelated catch-ups.
dsl/projection-dsl/reactor/src/main/java/org/occurrent/dsl/projection/reactor/CatchupProjectionFeed.java Exposes package-private id() for per-id catch-up lookup.
dsl/projection-dsl/blocking/src/main/java/org/occurrent/dsl/projection/blocking/CatchupProjectionFeed.java Moves id() earlier (keeps package-private lookup parity with reactor).
doc/architecture/decisions/0088-manual-subscription-mode-on-the-reactive-stack.md New ADR documenting reactive manual-mode design and rationale.
changelog.md Updates release notes to reflect reactive manual-mode parity and new APIs.
.context/ORCHESTRATOR.md Updates orchestrator memory to reflect reactive parity details and ADR 88 context.
Review details
  • Files reviewed: 17/17 changed files
  • Comments generated: 1
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

startAll snapshotted pendingIds and returned that list whole, so an id
another caller claimed first was still reported as started even though
start(id) had become a no-op for it. The javadoc promised the ids
started.

Both stacks had it, so both are fixed: each id is claimed and reported
by the same call, and one that was already claimed is left out.
@johanhaleby
johanhaleby merged commit ef3b60d into main Aug 2, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Subscriptions cannot be kept from starting during context boot, so a test pays for a change stream it immediately stops

2 participants