Lifecycle and Shutdown
Starting, closing and releasing EventStorage, EventStore and EventStream
This guide covers the lifecycle of the three objects your application holds — EventStorage, EventStore and EventStream — from a store that refuses to start because it cannot reach its database, to releasing the threads and connections a store owns when you are done with it.
Three Objects, Three Scales
The library hands you three things with a lifecycle, and they are not equally expensive:
| Object | What it owns | When you close it |
|---|---|---|
EventStorage | Background threads, JDBC connections, possibly the connection pools it created | When the storage is no longer used — typically at application shutdown |
EventStore | Executors that dispatch notifications to subscribers | When the store is no longer used; does not close a storage you gave it |
EventStream | Its subscriptions, and nothing else | When you are done subscribing; never needed for a query/append-only stream |
Subscription | One listener on a stream | When that one listener should stop; the stream’s other subscriptions carry on |
All three implement AutoCloseable, and close() is declared without a checked exception, so try-with-resources needs no catch.
Starting a Store
build() connects, applies the configured DatabaseInitMode, and — on the PostgreSQL backend — starts the two LISTEN/NOTIFY monitor threads and waits for them to register their channels.
1
PostgresEventStorage storage = PostgresEventStorage.newBuilder().build();
Before the monitors start, build() also checks that the store was not restored logically into a younger cluster — one index probe, under every init mode — and refuses to start, closing the storage, if it was.
The monitors’ wait is bounded, 10 seconds by default. The monitors have no failure mode of their own: on a SQLException they log, back off and retry for as long as the storage lives, so waiting on them without a deadline is waiting on something that may never happen.
Expiry Is Fatal
When the deadline passes without the notification channels being established, build() closes the storage and throws an EventStorageException. There is deliberately no mode that starts anyway.
An event-sourced application that is never told when events are appended has read models that quietly stop advancing: nothing wakes a subscriber, so projections only move when something else happens to run them. It serves stale data with nothing in its own logs to say so — worse than not starting at all. Closing (rather than merely throwing) matters too: the monitor threads would otherwise keep retrying behind a storage the caller never received.
Where Startup Legitimately Races the Database
Container orchestrators start applications and databases at the same time. Raise the deadline rather than lowering your expectations:
1
2
3
PostgresEventStorage storage = PostgresEventStorage.newBuilder()
.notificationStartupTimeout(Duration.ofSeconds(30))
.build();
Within the deadline, the monitors’ own retry loop does the waiting, so a store racing its database up succeeds rather than failing. The default is generous on purpose: a database that is up answers in milliseconds, and the cost of being too impatient with one that is merely slow — a cold pool, a simultaneous restart — is an application that refuses to boot.
A running store still repairs itself. The same retry loop brings notifications back after an outage, with nothing to restart. The fail-fast is about not starting blind, not about tearing a live store down when its connection drops.
Which Configurations Can Actually Hit This
With ENSURE or VALIDATE, the schema work runs before the monitors start and fails with a clear error; under NONE, the restored-history check is the first thing build() asks the database, and fails within the pool’s connection timeout, naming the database. So an unreachable main DataSource never reaches the wait. The path that can reach it is a reachable main DataSource with an unreachable monitoring one — the realistic case, since the two are configured separately precisely because LISTEN/NOTIFY does not survive a transaction pooler. “Pooled works, direct is firewalled” is an ordinary misconfiguration.
An interrupt during startup throws EventStorageException and closes the storage, rather than handing back a storage nobody can tell is unstarted. And close() releases a caller still waiting inside build().
Watching Notification Health
The storage reports the health of each channel (EVENT_APPENDED / BOOKMARK_PLACED) to its observer through notificationChannelChanged: down from the constructor, so an observer knows the channel from the moment the storage exists — a signal that only appears once notifications work is no use for alerting on notifications not working — up once the monitor listens, down again when a running store loses its monitoring connection (which is the same silence as never having had one), and down on close. Every transition is reported exactly once.
For a health endpoint, ask the storage for its health: each channel is a check of its own, beside the database round trip, the visibility barrier and the connection pools. health() is on EventStorage, so whatever holds the storage can ask:
1
2
3
4
StorageHealth health = storage.health(); // waits at most 2 seconds for the database
health.status(); // the worst of its checks
health.check(PostgresEventStorage.HEALTH_EVENT_APPENDED_NOTIFICATIONS)
.map(HealthCheck::status); // DEGRADED while the channel does not listen
isNotificationsAvailable(), on the PostgresEventStorage type the builder’s build() returns, answers both channels together as one boolean.
Closing a Store
A store that lives as long as the process needs no explicit close. One created per tenant, per test or per hot reload does: the PostgreSQL backend runs two monitor threads, each holding a JDBC connection, and those threads keep the whole storage reachable — so a dropped-but-unclosed storage is never reclaimed by garbage collection.
1
2
3
4
try ( EventStore eventStore = PostgresEventStorage.newBuilder().buildStore() ) {
EventStream<CustomerEvent> stream = eventStore.getEventStream(streamId, CustomerEvent.class);
// ... use the store ...
} // stops the monitors and closes the pools the builder created
The Contract Every Backend Implements
Idempotent — later calls do nothing and never throw.
Blocks, bounded — when close() returns, the background threads have finished and released their connections. On PostgreSQL the monitors poll in 100 ms slices, so they notice the stop within that, UNLISTEN, and hand their connections back to the pool healthy: closing takes about 100 ms and logs nothing. A monitor that fails to stop on its own within 2 seconds is interrupted instead (hard bound 5 seconds); that path breaks its connection under the driver, so the pool logs “connection marked as broken” — an interrupted shutdown, not a normal one.
Ownership — a DataSource you passed to .dataSource(...) is never closed; one the builder created from db.properties is.
If you supply the pool, close the storage before closing the pool. The other order leaves the monitors retrying against a dead pool, which they cannot distinguish from a database outage.
Terminal — there is no reopening. start() on a closed storage throws.
Operations throw afterwards — every read and write throws EventStorageClosedException (a subclass of EventStorageException); name() keeps working, so logging does not break, and health() answers DOWN rather than throwing. A closed storage does not keep serving reads while its notifications are dead, which would strand projections silently.
Storage Ownership: Who Closes What
Closing an EventStore does not close a storage you handed it. A storage is the expensive object, it can back several stores, and it usually outlives them — so closing it is the caller’s job, after closing the stores built on it.
1
2
3
4
5
6
7
8
9
10
EventStorage storage = PostgresEventStorage.newBuilder().build();
EventStore writeStore = EventStore.on(storage).build();
EventStore readStore = EventStore.on(storage).build();
// ... application runs ...
writeStore.close(); // readStore keeps working
readStore.close();
storage.close(); // now the monitors stop and the pools are released
The one case that owns its storage is composed rather than special-cased. buildStore() creates the storage, hands back nothing else, and therefore returns a store that closes both:
1
2
// this store owns its storage: closing it closes everything
EventStore eventStore = PostgresEventStorage.newBuilder().buildStore();
You can build the same pairing yourself with EventStore.owning(...) when you create a storage and a store together:
1
2
3
4
5
6
7
EventStorage storage = PostgresEventStorage.newBuilder().build();
EventStore eventStore = EventStore.owning(
EventStore.on(storage).observer(observer).build(),
storage
);
eventStore.close(); // closes the store, then the storage
A closed EventStore’s streams throw too, for the same reason a closed storage’s operations do: its notifications have stopped, so letting it keep reading would strand its subscribers silently.
Spring infers
closeas the destroy method for a@Bean, and CDI has@Disposes— so declaring anEventStoreorEventStoragebean gives you shutdown for free in both.
Closing a Stream
EventStream is AutoCloseable too, but at a much smaller scale: the only thing a stream owns is its subscriptions.
A Stream You Only Query and Append Through Owns Nothing
getEventStream() registers nothing with the storage. Registration happens on the first subscribe(...), because a stream with no subscribers has nothing to do with a notification anyway. Most streams are in this category, are handed out per operation, and need no lifecycle handling at all:
1
2
3
// no cleanup needed: nothing was registered
EventStream<CustomerEvent> stream = eventStore.getEventStream(streamId, CustomerEvent.class);
stream.append(Event.of(new CustomerRegistered("John"), Tags.none()));
A Stream You Subscribe To Is Held by the Storage
Once subscribed, the storage holds the stream strongly for as long as it has a live subscription. That is what makes live updates survive the caller dropping the variable:
1
2
3
// this keeps working — the storage holds the stream, so the subscription cannot be collected
eventStore.getEventStream(streamId, CustomerEvent.class)
.subscribe(reference -> { updateReadModel(); return reference; });
The cost of that guarantee is that nothing releases it on your behalf. A subscribed stream that is never closed is retained for the lifetime of the storage — deliberately a leak you can find, rather than a subscription that dies at an unpredictable garbage collection with no error and no log.
So close what you subscribe to, or close the store, which closes them all:
1
2
3
4
try ( EventStream<CustomerEvent> stream = eventStore.getEventStream(streamId, CustomerEvent.class) ) {
Projector.from(stream).into(projection).subscribe().build();
// ... application runs ...
} // subscriptions ended, registration released
Or Close One Subscription
subscribe(...) returns a Subscription. Closing it ends that one listener and leaves the stream’s other subscriptions going:
1
2
3
Subscription subscription = stream.subscribe(reference -> { updateReadModel(); return reference; });
// ...
subscription.close();
The two ways compose, because a stream is registered with the storage exactly while it has a live subscription: closing its last Subscription releases the stream just as closing the stream does, and subscribing again after that re-registers it. Closing a handle is idempotent, and a handle the stream or the store already ended reads isActive() == false and closes as a no-op. A subscribed Projector keeps no handle — it ends with its source — so a projector that must be unsubscribed on its own is built without .subscribe() and subscribed by hand: stream.subscribe(projector).
Closing a Stream Is Not Terminal
Unlike closing a store or a storage, closing a stream ends its subscriptions and clears its listeners, but leaves the handle usable for query(), append() and bookmark operations. Subscribing again re-registers it. A stream is a cheap per-operation handle, not a connection — there is nothing to protect by poisoning it. It is idempotent, and closing a never-subscribed stream is a no-op.
Streams Are Cheap Because the Expensive Part Is Shared
getEventStream() allocates a stream object and reports it to the observer — roughly 2 µs and 1 KB. The payload serializer/deserializer is not rebuilt per call: the store caches one per distinct set of event root classes and hands the same instance to every stream opened with that mapping.
That matters more than the construction cost suggests. Jackson caches its per-type serializers inside the mapper, so a serde per call would give every stream a cold type cache and re-run bean introspection on the first serialization of each record type. Measured on a 24-record sealed hierarchy, that difference was ~175 µs / 139 KB against ~36 µs / 69 KB per query. With the serde shared, obtaining a fresh stream per operation costs the same as keeping one.
Only the serde is shared — never the stream itself. A stream is stateful (subscriber lists, a subscribed flag), so sharing it would make one caller’s close() end another caller’s subscriptions.
Putting It Together
A typical application wiring, with everything released in the right order:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class Application implements AutoCloseable {
private final EventStore eventStore;
private final EventStream<CustomerEvent> subscribedStream;
public Application ( ) {
// buildStore() owns its storage, so closing this store closes everything
this.eventStore = PostgresEventStorage.newBuilder()
.validateDatabase()
.notificationStartupTimeout(Duration.ofSeconds(30))
.buildStore();
this.subscribedStream = eventStore.getEventStream(
EventStreamId.forContext("customer"), CustomerEvent.class);
Projector.from(subscribedStream)
.into(new CustomerSummary())
.subscribe()
.bookmarkAs("customer-summary")
.build();
}
/** Per-operation streams need no lifecycle handling at all. */
public void register ( String id, String name ) {
eventStore.getEventStream(EventStreamId.forContext("customer"), CustomerEvent.class)
.append(Event.of(new CustomerRegistered(id, name), Tags.of("customer", id)));
}
@Override
public void close ( ) {
subscribedStream.close(); // end the subscription
eventStore.close(); // store, then the storage it owns
}
}
If the store outlives nothing — that is, if it lives exactly as long as the JVM — you can skip all of it. The lifecycle exists for the cases where it does not.