Runtime Lifecycle
WowRuntime is the common owner of all RuntimeComponent instances. It decides when the whole runtime may accept work, when admission closes, and how work drains under one deadline. It does not decide whether an individual business message should be retried.
Component contract
interface RuntimeComponent {
fun prepare(runtimeContext: RuntimeContext): Mono<Void>
fun start()
fun suspendDurableIntake() = Unit
fun quiesce() = Unit
fun stopGracefully(): Mono<Void>
fun forceStop()
}| Method | Contract |
|---|---|
prepare | Acquire subscriptions or resources and complete when new work can be retained; processing remains closed |
start | Open processing after every component has prepared |
suspendDurableIntake | Promptly, non-blockingly, and idempotently stop pulling from durable transports at the start of a graceful stop, without cancelling them (since 9.3.0) |
quiesce | Promptly, non-blockingly, and idempotently close component intake after global admission closes |
stopGracefully | Drain accepted work and asynchronously release resources |
forceStop | Be prompt, non-blocking, repeatable, and safe before prepare |
Component construction must remain inert. RuntimeComponent does not extend AutoCloseable, so a container must not infer a second cleanup owner.
Startup: all ready before processing opens
Preparation and startup follow registration order. If any preparation fails, the runtime enters startup rollback and cleans up lifecycle-entered components in reverse order. start() returns a cold Mono; cancelling its subscription aborts startup and force-stops this one-shot runtime.
The barrier proves only the readiness reported by components. A custom transport must keep prepare incomplete until new messages cannot be missed in the startup window, not merely until a client object exists.
Activity admission
RuntimeContext.tryAcquire() requests a RuntimeActivity for one complete asynchronous operation:
candidate work arrives
→ tryAcquire()
→ null: reject admission and do not acknowledge the message as handled
→ lease: run the complete asynchronous chain and close on terminationClosing a lease is idempotent. The lease must cover the full asynchronous chain, not just enqueueing into a local buffer, or quiescence may observe a false idle state. A terminal component-pipeline failure is reported with reportFailure(error); ordinary business failures remain owned by their filter, compensation, and acknowledgement policies.
Graceful shutdown
Each new runtime activity restarts the quiet period. After a continuous idle interval reaches shutdownQuietPeriod, the runtime closes global admission before component intake. Tail work can therefore acquire a lease during handoff gaps where upstream publication has completed but downstream consumption is only beginning.
Sustained traffic: durable intake stops first
Since 9.3.0 a graceful stop first suspends durable intake: every dispatcher's Kafka and Redis Streams receivers stop requesting records. Up to 9.2 they kept pulling while the runtime waited for the quiet period, so traffic arriving more often than shutdownQuietPeriod reset it until shutdownTimeout expired and the runtime force-stopped.
| After the suspension | |
|---|---|
| Records already handed over | Still processed and acknowledged, together with the records requested before the suspension (at most the decoder's prefetch of 32 per receiver) |
| Kafka | Without demand Reactor Kafka pauses the assigned partitions and keeps polling, so the consumer stays in the group and still commits acknowledged offsets. Offsets are committed only up to the first unacknowledged record, so a record that was polled but not handed over is never committed; after the consumer leaves, the group's next owner of the partition receives it |
| Redis Streams | The receiver stops reading. An entry it read but did not hand over stays pending without XACK; pending-message recovery claims it for another consumer of the group once it has been idle for min-idle-time and this consumer is inactive (RedisStreamRecoveryOptions) |
| In-process work | Unchanged from 9.2: local buses, local-first hand-offs, and commands that sagas or handlers send during the drain are admitted until global admission closes, so a chain already in flight completes within the deadline. A message that arrives after admission closes is rejected as before (a local-first hand-off falls back to its distributed copy) |
Suspension only shortens the stop: a component or receiver that fails to suspend is logged at WARN and keeps receiving until quiescence closes its intake; the others are still suspended, and the failure neither fails the stop nor turns it into a force stop.
Not pulling is safe only where the broker keeps what is not pulled, so only receivers whose TransportReceiver.durable is true are suspended. The in-memory buses and InMemoryTransport would lose a message they do not deliver, so they keep delivering until admission closes. HTTP intake is closed before the runtime stops: under Spring Boot the web server stops in an earlier lifecycle phase.
shutdownTimeout bounds the entire shutdown from creation of the shutdown owner, not one component. Deadline expiry records a TimeoutException and transfers ownership to force cleanup. stop(timeout) limits only that caller's blocking wait; it does not replace the runtime deadline.
Failures and races
| Scenario | Current implementation behavior |
|---|---|
prepare / start fails | Preserve the startup error and roll back entered components in reverse order |
| A component reports a fatal error | Close global admission immediately, skip the normal quiet period, drain admitted work, and terminate the whole runtime |
| Graceful cleanup fails | Continue best-effort cleanup; the first failure remains primary and later failures are suppressed |
| Deadline expires | Cancel the graceful owner and force-stop all components |
| Force overlaps a lifecycle action | Invoke compensating forceStop again after the method returns or publisher terminates when required |
forceStop must therefore tolerate repeated calls in partially initialized states. Once terminal failure is published, it is sealed; late cleanup errors do not mutate the already published result.
Component order and resource ownership
RuntimeComponentGroup requires distinct component identities in one group and uses these orders:
prepare,start,suspendDurableIntake, andquiesce: registration order;stopGracefullyandforceStop: reverse registration order;- once force wins, a detached graceful chain cannot advance into another component.
Dispatchers own no threads: they run on the runtime's KeyedExecutor, which the runtime closes after every component has stopped.
A force stop also stops the dispatch work synchronously, as disposing the 9.2 per-aggregate schedulers did: before forceStop returns, the runtime force-closes the executor, which discards every message still queued in a mailbox and rejects new deliveries. Those messages are not acknowledged, so a broker transport (Kafka, Redis Streams) redelivers them; an in-memory message is dropped. A handler that is already running is not interrupted, but its mailbox starts no further message. A graceful stop is unchanged: dispatchers drain first and the executor is closed only after every component has stopped; a task submitted while the executor closes runs or is rejected, never left behind. A graceful stop that fails (a component's stop errors, or a runtime failure is recorded) escalates to the same synchronous force close before components are forced, and stopping a runtime that never started closes its executor too.
Storage and transport resources
A batch writer (the Mongo and Elasticsearch event-stream appender and snapshot saver with batching enabled) and a Kafka producer hold work the dispatchers already accepted. Each is a RuntimeResource:
interface RuntimeResource {
fun stopGracefully(): Mono<Void>
fun forceStop()
}The RuntimeResources component owns them. Registered first, it stops last: after every dispatcher has drained, it flushes all resources concurrently, inside the same shutdownTimeout. If the deadline expires, forceStop fails the unwritten items. A batch writer's force stop ends its graceful stop. Kafka's client cannot cancel a running close, so the runtime stops waiting for it. The stores and buses stay AutoCloseable. After a flush, their close() returns at once; a batch writer's close() after a force stop also returns at once, without an error, however often Spring calls it.
A force stop bounds how long the runtime waits, not what is still running:
- Kafka: the abandoned close keeps flushing in the background. When Spring destroys the bus, its
close()can block until that flush ends. - Batch writers: a write that had already started is cancelled and its items fail, but the store may already have received it. The JVM does not wait for it either, since the lifecycle threads are daemon threads: a process that exits mid-flush can leave such a write applied or not.
Size wow.shutdown-timeout so that a normal flush fits inside it.
Lifecycle threads
The runtime owns its lifecycle threads in one place, RuntimeExecutionResources:
- One bounded, daemon lifecycle pool. Shutdown, public terminal observers, termination control and physical cleanup each run on their own lane.
- One deadline timer thread.
The pool reserves each lane's threads. An observer that blocks therefore holds only the observer lane, and cannot delay shutdown, the termination control plane or cleanup. Idle threads time out.
Spring ownership
The Starter supplies the single WowRuntimeLifecycle adapter to Spring SmartLifecycle. The default runtime collects singleton RuntimeComponent beans from the current application context, orders them with Spring semantics, and rejects competing Spring lifecycle, destroy-method, or cleanup owners.
The default runtime also registers RuntimeResources first, resolving every RuntimeResource bean when the runtime prepares. The Mongo, Elasticsearch and Kafka auto-configurations register one for each store and bus they create.
An application-provided runtime explicitly owns its component topology; the Starter does not append auto-discovered components. Such a runtime should put a RuntimeResources first, or its batch writers are flushed only when Spring closes the stores. Spring Boot Starter owns bean names, configuration, and replacement rules.
Custom component checklist
- Do not open subscriptions or background threads in the constructor.
- Complete
prepareat real readiness while processing remains closed. - Hold one lease for every admitted asynchronous operation until full termination.
- Close logical intake synchronously in
quiesce; do not block for a long operation. - A component that pulls from a broker that keeps unacknowledged records stops requesting in
suspendDurableIntake, without cancelling the source or revoking processing. A customTransportdeclares this withTransportReceiver.durable, and the transport bus stops requesting from it. - Make
forceStopsafe before prepare, idempotent, and non-blocking. - Use
reportFailureonly for terminal pipeline failure. - Test force races with prepare, start, quiesce, and graceful stop.
Verification and operations
Defaults and constraints live in the Core Configuration Reference. Narrow implementation evidence is available with:
./gradlew :wow-core:test --tests "me.ahoo.wow.runtime.WowRuntimeTest"
./gradlew :wow-core:test --tests "me.ahoo.wow.runtime.internal.RuntimeComponentGroupTest"
./gradlew :wow-core:test --tests "me.ahoo.wow.runtime.SustainedIngressShutdownTest"
./gradlew :wow-kafka:integrationTest --tests "me.ahoo.wow.kafka.KafkaSustainedIngressShutdownTest"
./gradlew :wow-redis:integrationTest --tests "me.ahoo.wow.redis.bus.RedisSustainedIngressShutdownTest"Module tests verify the implementation contract only. Production quiet-period and timeout values still require evidence from real handoff jitter, maximum drain time, and resource cleanup time.
Source and related pages
WowRuntimeRuntimeComponentRuntimeContextRuntimeComponentGroup- Runtime Orchestration Migration: breaking lifecycle migration boundary
- Keyed Executor: the runtime-owned dispatch workers