Threading model
One thread per stream
In imperative mode, each@ChangeStream runs on its own dedicated thread managed by Spring Data MongoDB’s DefaultMessageListenerContainer. Events are processed one at a time in order:
concatMap (not flatMap), which guarantees sequential processing per stream on Reactor’s scheduler threads.
Why sequential?
Three constraints make sequential processing the natural default:- Checkpoint is a linear cursor — FlowWarden persists a single resume token per stream. If events were processed out of order, a crash after checkpointing event 3 (but before event 2 completes) would lose event 2 forever.
- Event ordering matters — MongoDB Change Streams deliver events in oplog order. An update to a document should be processed after its insert, not before.
- Simplicity — sequential processing eliminates race conditions, makes handlers easy to reason about, and avoids the need for complex coordination.
When sequential is enough
For most use cases, sequential processing is not the bottleneck:- A single handler typically processes thousands of events per second — MongoDB’s Change Stream delivery is usually the limiting factor, not the handler
- Multiple
@ChangeStreamclasses watching different collections run in parallel on separate threads @Pipelineserver-side filtering reduces the event volume before it reaches your handler
Virtual threads
Spring Boot 3.2+ supports virtual threads (Project Loom) viaspring.threads.virtual.enabled=true. FlowWarden is compatible with virtual threads, but there are important considerations.
What changes with virtual threads
Avoid synchronized on handlers
In practice,
synchronized on a handler is redundant: FlowWarden processes events sequentially within a single stream — there is never concurrent access to the same handler for the same stream. If you need cross-stream coordination, use a ReentrantLock instead.Scaling with message brokers
When a single stream’s sequential throughput is not enough, the recommended pattern is to use FlowWarden as a reliable CDC consumer that fans out events to a message broker. The broker handles parallelism natively via partitions (Kafka) or competing consumers (RabbitMQ).Why this pattern?
FlowWarden handles
- Reliable Change Stream consumption
- Checkpoint & resume on restart
- Retry & DLQ for publish failures
- Leader election (single consumer)
The broker handles
- Parallel processing across N consumers
- Message ordering per partition/key
- Backpressure and flow control
- Independent retry per consumer