Skip to content

0.3.0

Choose a tag to compare

@jwcarman jwcarman released this 12 Apr 01:23
· 124 commits to main since this release

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 old Consumer<T> + builder pattern (breaking).
  • A Spring SmartLifecycle ShutdownCoordinator that 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 sealed Notification hierarchy. Backend NotifierSpi collapsed to opaque byte[] 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 plain Subscription.
  • CallbackSubscriberBuilder<T> — replaced by SubscriberConfig<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

  • DefaultCallbackSubscription removed, replaced by CallbackPumpSubscription. The push-style subscriber is now a thin wrapper around a BlockingSubscription: a dedicated virtual thread pumps values from the blocking source and dispatches each NextResult to the appropriate Subscriber<T> method. The pump loop is a static method that takes (source, subscriber) as locals, so the Thread.ofVirtual().start(...) lambda captures locals instead of this — no this-escape from the constructor.
  • DefaultJournal / DefaultJournalFactory now take a JournalLimits record bundling (subscriptionQueueCapacity, maxInactivityTtl, maxEntryTtl, maxRetentionTtl). Dropped both constructors from 8 parameters to 5.
  • FeederSupport.start(...) takes a BiFunction<String, Consumer<Notification>, NotifierSubscription> subscribe binding instead of a NotifierSpi + type. Each primitive passes its own notifier::subscribeToAtom / subscribeToJournal / subscribeToMailbox method 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 second Cancelled marker to the handoff queue.
  • BlockingSubscription default close() now has test coverage demonstrating the try-with-resources → cancel() delegation.

Internal

  • Removed seven test-only "convenience constructors" from DefaultAtom, DefaultJournal, DefaultMailbox, their factories, and DefaultBlockingSubscription. They existed only to let tests skip passing a ShutdownCoordinator. Tests now declare a ShutdownCoordinator field 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+