Occurrent 0.32.0 is released. Where 0.31.0 was about writing less wiring by hand, this release makes the contracts explicit. What the event store and the subscription models promise is now written down, verified by a published conformance kit, and enforced with clear refusals where the old behaviour was a silent surprise. It builds on 0.31.0 and requires Java 21.
Several of the corrections change what already-running code does, and a few types moved. Read the behavior changes and the backward-incompatible changes below before upgrading. The org.occurrent.UpgradeToOccurrent_0_32 OpenRewrite recipe rewrites the renames for you, and the upgrade guide covers everything else.
Highlights
A published TCK
If you implement an Occurrent contract yourself, an event store over your own database or a subscription model over your own feed, you can now run the same conformance suites Occurrent runs against itself. Five artifacts are published, occurrent-tck-common, occurrent-tck-eventstore-blocking, occurrent-tck-eventstore-reactor, occurrent-tck-subscription-blocking and occurrent-tck-subscription-reactor, so an implementation living outside this repository can depend on them.
You supply one fixture that hands back the implementation under test and declares the capabilities it was built with, then extend one suite per capability. Twelve suites ship, covering stream reads and writes, every WriteCondition operator with its exact failure message, cross-stream queries, deletes and updates, time precision, and the subscription life cycle on both stacks. Verifying a new event store is a few lines of test code instead of a few thousand.
What a TCK version promises is part of the contract too. A minor release may add suites and tighten assertions, so a bump that turns your build red is the kit doing its job, and the fixture interface never breaks at source level in a minor. If your subscriptions need more time than the default on slow infrastructure, override deliveryTimeout() on your fixture. See testing your own event store.
Subscriptions can filter on the event payload
Filter.data("amount", eq(42)) now works on every subscription model Occurrent ships, and on the in-memory event store, where it previously threw on the first delivered event or worked only on MongoDB. The path is the dotted path MongoDB resolves, so Filter.data("person.city", eq("Malmö")) reaches into a nested object and into each element of an array of objects, and numbers compare by value, so a stored Long matches an operand written as an int literal.
Reading a payload means parsing it, so the in-memory store asks for a reader rather than picking a JSON library for you. Add occurrent-common-inmemory-filter-matching-jackson and build the store with withDataFieldReader(new JacksonDataFieldReader()). A store built without one refuses a data filter instead of quietly matching nothing. The rules were measured against MongoDB rather than assumed, and the conformance suites hold every store to them. See filtering on payload data.
Sagas and projections fed from a broker
@Saga(source = PUSH) feeds a saga from a push subscription model, so it can react to events arriving from RabbitMQ, Kafka or an HTTP listener without you wiring a SagaRunner by hand, mirroring what @Projection(source = PUSH) already did. A saga or projection that has never run first works through the event store’s history, then hands over to the live feed. Set catchup = NONE when the events come from another application’s broker, since your local event store holds none of them and there is nothing to replay.
The replay no longer has to hold up your application either. startupMode = BACKGROUND runs it on a thread of its own, and because nobody waits for a background replay, the starters contribute a PushCatchupStatus bean you can check from a readiness probe. It answers per id whether a read model is still filling, live, failed with the cause, or not started. A saga’s timers wait for the handover, so a timeout cannot fire against state that is only half rebuilt, and closing the application stops a replay instead of letting it keep issuing commands into a store that is shutting down.
One protection to know about up front. A saga now refuses an event it cannot tell a redelivery of, rather than reacting to it again on every redelivery, and redeliveryDetection = BEST_EFFORT opts out where that is what your feed needs. Feeding a saga from a broker explains when each applies.
Manual subscription mode
In the Spring Boot starter, occurrent.subscription.mode = manual registers every subscription bean and then waits. Nothing is delivered, no change stream is opened, no history is replayed and no competing consumer lock is taken until you call start() or resumeSubscription("someId") yourself. Use it to bring subscriptions up under your own control, behind a leader election, a feature flag or a staged startup. The registered subscriptions stay observable while they wait, so an admin endpoint can list them before anything runs. The default is unchanged. Outside Spring, ManualStartSubscriptionModel.stoppedByDefault(subscriptionModel) in occurrent-subscription-api-blocking wraps any subscription model the same way, registering subscriptions withheld until you start them. See deferring subscription startup.
Published testing artifacts
occurrent-testing-junit-jupiter-blocking, and its reactive twin occurrent-testing-junit-jupiter-reactor, give you a JUnit 5 extension that keeps every subscription stopped while a test runs, so a test only runs the subscriptions it names. stoppedByDefault(subscriptionModel) stops them before and after each test, start("someId") resumes one and blocks until it is live, and clearingCheckpoints(checkpointStorage) makes sure a subscription cannot resume from where an earlier test left it. An in-memory CheckpointStorage now exists on both stacks for exactly this kind of test.
occurrent-testing-mongodb empties a MongoDB database between tests without breaking your subscriptions. OccurrentMongoFlush.everyCollectionIn(database) deletes the documents but keeps the collections and their indexes, which matters because dropping a collection invalidates a live change stream, and every subscription resumed after that receives nothing. See the testing documentation.
Behavior changes
Questions like “what does start() on an already-running model do” and “when does waitUntilStarted answer” had a different answer in each subscription model. This release picks one answer per question and corrects the implementations that disagreed. These changes alter what already-running code does, with no compiler error to point at them.
start()on a subscription model that is already started is accepted everywhere.CompetingConsumerSubscriptionModelused to throw where every other model accepted.waitUntilStartedanswers for the one start the handle was created for, and a catch-up replay failure on the blocking push model now reaches you there rather than out ofsubscribe(...), because the replay no longer runs on your thread.- A synchronous subscription still handles an event when another subscription’s handler throws. The first exception used to end the dispatch for everyone, and a synchronous subscription has no replay to make up for it.
- A
PushSubscriptionModelorDomainEventFeedfeeds exactly one projection or saga, and a second registration is refused at startup. A broker message carries one acknowledgement decision, and sharing it meant one failing consumer held up every other one.DomainEventFeed.accept(..)also refuses an event when no projection is registered, instead of letting the broker discard it as acknowledged. - With more than one delivering thread, a live push handler can now be invoked concurrently and must tolerate that. A single-threaded caller sees no change. ADR 108 has the reasoning and the measured throughput.
- A saga refuses an event it cannot recognise a redelivery of, and
@Saganow honoursstartupModewithBACKGROUNDas the default, so a replaying saga no longer blocks startup. SetstartupMode = WAIT_UNTIL_STARTEDfor the old behaviour.
Each of these has a full entry in the changelog, with the reasoning in its ADR.
Backward-incompatible changes
The renames are rewritten by the org.occurrent.UpgradeToOccurrent_0_32 recipe. Start with the upgrade guide.
| Change | What changed | How to upgrade |
|---|---|---|
The reactor SubscriptionModel renamed |
The reactor interface whose subscribe returns a Flux<CloudEvent> is now FluxSubscriptionModel. The name SubscriptionModel now means the same thing on both stacks, a named, lifecycle-managed subscription model. |
Run the recipe, or see section 5 of the upgrade guide to do it by hand. |
NativeMongoLeaseCompetingConsumerStrategy moved |
It is built on the native driver and never touched Spring, but shipped under the Spring package. It now lives in org.occurrent.subscription.mongodb.nativedriver.blocking with the other native-driver types. |
Run the recipe. It also rewrites the Builder construction and the withDefaults(..) call. |
| Refusals got their own exception types | subscribe(..) throws DuplicateSubscriptionIdException, UnsupportedSubscriptionFilterException or UnsupportedStartAtException instead of a mix of IllegalArgumentException and IllegalStateException, and a saga throws SagaRedeliveryDetectionException. |
A catch of the old general-purpose types still compiles. Narrow it when you want to act on the specific refusal. |
| Canonical time storage | With TimeRepresentation.RFC_3339_STRING the MongoDB stores now write the CloudEvent time attribute in one canonical shape, always with seconds and nine fractional digits, so Filter.time(..) comparisons work across events written by different writers. |
Nothing to do for new events. The upgrade guide explains the two limits for histories written before this. |
occurrent-command-composition dependency scope |
It no longer leaks the in-memory event store to your compile classpath, a test dependency was missing its test scope. | If you used the in-memory store through that leak, depend on occurrent-eventstore-inmemory yourself. |
Full details are in the changelog, and the documentation has been updated with the new features, each marked with a 0.32.0 badge.