Repository navigation
0.3.0
Highlights
This release is a substantial refactor pass around subscriptions, shutdown, and the internal notifier protocol. The headline items:
- A new
Subscriber<T>/SubscriberConfig<T>callback API replacing the oldConsumer<T>+ builder pattern (breaking). - A Spring
SmartLifecycleShutdownCoordinatorthat fixes a 30-second hang at graceful context shutdown when the app holds SSE-style long-lived subscriptions. - A new typed notifier routing layer (
DefaultNotifier) that replaces the stringly-typed"__DELETED__"/"__COMPLETED__"sentinels with a sealedNotificationhierarchy. BackendNotifierSpicollapsed to opaquebyte[]broadcast. - A new
NextResult.Cancelled<T>sealed variant (breaking for exhaustive switches).
Full details below. substrate remains in 0.x — breaking changes are allowed between minor versions.
Breaking changes
Subscriber-based callback API
The callback subscribe API has been rewritten around Subscriber<T> and SubscriberConfig<T>, replacing the previous Consumer<T> onNext + Consumer<CallbackSubscriberBuilder<T>> customizer pattern.
Removed types:
CallbackSubscription(marker interface) — callback subscribe methods now return plainSubscription.CallbackSubscriberBuilder<T>— replaced bySubscriberConfig<T>.
Changed subscribe signatures on Atom, Journal, and Mailbox:
// Before
CallbackSubscription subscribe(Consumer<T> onNext);
CallbackSubscription subscribe(Consumer<T> onNext, Consumer<CallbackSubscriberBuilder<T>> customizer);
// After
Subscription subscribe(Subscriber<T> subscriber);
Subscription subscribe(Consumer<SubscriberConfig<T>> customizer);Migrate by either passing a Subscriber<T> lambda directly or using the SubscriberConfig<T> customizer:
// Lambda (simplest — Subscriber is a @FunctionalInterface)
atom.subscribe((Snapshot<Session> snap) -> process(snap));
// SubscriberConfig customizer (for lifecycle handlers)
atom.subscribe(cfg -> cfg
.onNext(snap -> process(snap))
.onExpired(() -> reconnect())
.onError(err -> log.error("boom", err)));NextResult.Cancelled<T> sealed variant
A new variant on NextResult<T> distinct from Completed / Expired / Deleted. Fires when the local subscription was torn down via cancel() or the shutdown coordinator — the underlying primitive is unaffected and other subscribers on other nodes keep working. Clients that observe Cancelled should typically treat it as "reconnect and resume" rather than "the data source is done."
This is a breaking change for exhaustive switch expressions. Add a case NextResult.Cancelled<T> _ -> ... branch.
NotifierSpi is now byte-oriented (internal SPI)
The notifier backend SPI collapsed from (String key, String payload) to opaque byte[] broadcast. This is an internal SPI change — the public Atom / Journal / Mailbox subscribe API is unchanged. Only custom NotifierSpi implementations are affected.
// Before
public interface NotifierSpi {
void notify(String key, String payload);
NotifierSubscription subscribe(NotificationHandler handler);
}
// After
public interface NotifierSpi {
void notify(byte[] payload);
NotifierSubscription subscribe(Consumer<byte[]> handler);
}Backends become dumb opaque-bytes transports with a single channel/topic/subject per node. All the routing and type discrimination moved up into the new DefaultNotifier layer in substrate-core.
Two backend-config renames came with this:
| Before | After |
|---|---|
substrate.redis.channelPrefix |
substrate.redis.channel |
substrate.nats.subjectPrefix |
substrate.nats.subject |
Both now hold a single channel/subject name instead of a prefix.
Added
Graceful shutdown coordinator
New ShutdownCoordinator bean registered as a Spring SmartLifecycle at phase Integer.MAX_VALUE. It runs before the web server's graceful shutdown phase and cancels every active substrate subscription, releasing the feeder threads that back them.
This fixes a 30-second hang at ApplicationContext close when an SSE-style application holds long-lived substrate subscriptions — Tomcat's graceful shutdown was waiting for in-flight async requests whose SseEmitters were parked inside BlockingSubscription.next(...).
Subscriptions auto-register on construction and unregister on cancel or terminal state. Late registration after stop() cancels immediately. Fan-out cancellation runs one virtual thread per subscription joined with a bounded 5-second deadline.
Typed notifier routing layer
A new typed Notifier interface and DefaultNotifier implementation in substrate-core replace direct NotifierSpi usage everywhere inside core. Notifications are now delivered as a sealed Notification hierarchy (Changed / Completed / Deleted), and DefaultNotifier owns a per-(PrimitiveType, key) routing map that only delivers events to the handlers that asked for them. No more client-side key filtering in FeederSupport, no more magic "__DELETED__" / "__COMPLETED__" sentinel strings on the wire.
The Notifier interface has seven typed producer methods and three typed consumer methods:
void notifyAtomChanged(String key);
void notifyAtomDeleted(String key);
void notifyJournalChanged(String key);
void notifyJournalCompleted(String key);
void notifyJournalDeleted(String key);
void notifyMailboxChanged(String key);
void notifyMailboxDeleted(String key);
NotifierSubscription subscribeToAtom(String key, Consumer<Notification> handler);
NotifierSubscription subscribeToJournal(String key, Consumer<Notification> handler);
NotifierSubscription subscribeToMailbox(String key, Consumer<Notification> handler);PrimitiveType and EventType enums are package-private to the notifier package — they're serialization implementation details that never appear in public signatures.
Changed
DefaultCallbackSubscriptionremoved, replaced byCallbackPumpSubscription. The push-style subscriber is now a thin wrapper around aBlockingSubscription: a dedicated virtual thread pumps values from the blocking source and dispatches eachNextResultto the appropriateSubscriber<T>method. The pump loop is astaticmethod that takes(source, subscriber)as locals, so theThread.ofVirtual().start(...)lambda captures locals instead ofthis— nothis-escape from the constructor.DefaultJournal/DefaultJournalFactorynow take aJournalLimitsrecord bundling(subscriptionQueueCapacity, maxInactivityTtl, maxEntryTtl, maxRetentionTtl). Dropped both constructors from 8 parameters to 5.FeederSupport.start(...)takes aBiFunction<String, Consumer<Notification>, NotifierSubscription>subscribe binding instead of aNotifierSpi+ type. Each primitive passes its ownnotifier::subscribeToAtom/subscribeToJournal/subscribeToMailboxmethod reference.
Fixed
- Spring context shutdown no longer hangs when the application holds active substrate subscriptions (see ShutdownCoordinator above).
BlockingBoundedHandoff.markCancelled()idempotency — the second call no longer tries to add a secondCancelledmarker to the handoff queue.BlockingSubscriptiondefaultclose()now has test coverage demonstrating thetry-with-resources→cancel()delegation.
Internal
- Removed seven test-only "convenience constructors" from
DefaultAtom,DefaultJournal,DefaultMailbox, their factories, andDefaultBlockingSubscription. They existed only to let tests skip passing aShutdownCoordinator. Tests now declare aShutdownCoordinatorfield and pass it explicitly. - Full Sonar pass: 1 bug and 29 code smells cleared, three new-code coverage gaps closed. Quality gate back to green.
Requirements
- Java 25+
- Spring Boot 4.x
- Codec 0.1.0+