Guides
Change streams
Behind an experimental flag, collection.watch() and database.watch() run against the embedded store, which makes Mongo listeners integration-testable with no Docker: Spring Data's MessageListenerContainer, outbox tailers, cache invalidators, projection updaters.
The experimental flag
One system or Spring property enables collection.watch() and database.watch() against the embedded store:
bonsai.experimental.change-streams=trueWith it off, watch() is a named refusal under driver.watch. See the compatibility matrix. The entire shim is byte-for-byte what 3.4.0 shipped.
The captured event shape
The event shape is not approximated. Every emitted event is pinned against a 58-case catalogue captured from real mongod 7.0, 8.0 and 8.3 replica sets (the evaluation module's ground-truth resources; a differential corpus compares 174 cases). The rules that surprise people are all captured:
- A
$setto the same value emits no event.updatedFieldsarrive in lexicographic order regardless of the update command's order; a front$popreports the full resulting array;truncatedArraysappears only for specific operators. fullDocumentis omitted on a default update event, present as an explicitnullwhere the server does that (for exampleupdateLookupafter the document was deleted), andupdateLookuplooks up at read time, so the returned document can be newer than the event.- Dropping a watched collection delivers
droptheninvalidate; a database-level stream survives a collection drop;dropDatabaseends withinvalidate; a post-invalidatenext()raises the driver's cursor exhaustion. - Transactions publish their events at commit, in write order, sharing one
clusterTimewithlsid/txnNumber; an aborted transaction emits nothing. - Both
fullDocument*: requiredmodes without images fail with server code47; pre/post images are honoured viachangeStreamPreAndPostImageson create/collMod. - The root-level event field order is compared as a key-set, never byte-pinned. The server itself changed it between 8.0 and 8.3, and that discovery is documented with the captures.
Matching your production server version
bonsai.experimental.change-streams.server-version (3.5.0+) selects the proven real-server event profile. The default is 8.0. This matters when a CDC consumer applies updatedFields incrementally: an $addToSet append produces an indexed delta on 8.0+, while a 7.x server sends the whole resulting array.
bonsai.experimental.change-streams=true
bonsai.experimental.change-streams.server-version=7.0The registry currently contains that one catalogued difference. Any other value refuses by name under changeStream.serverVersion; the property is inert while change streams are off. Ask which server version production runs before setting it, under the same consent rule as enabling this highly experimental feature.
The Spring listener pattern
Per-method cleanup drops databases, and a real server invalidates a stream whose collection is dropped. The documented pattern is therefore a method-scoped container: start it in @BeforeEach, stop it in @AfterEach, so each method watches a live collection:
@BeforeEach void startContainer() {
container = ...;
container.register(request, Document.class);
container.start();
}
@AfterEach void stopContainer() {
container.stop();
}A class-level container is not wrong: it receives invalidate after the first method, exactly as a real deployment does when its collection drops. There is an integration test pinning both shapes.
Cursors and resume tokens
next()andhasNext()block like the driver's until an event orclose(). A cross-threadclose()promptly wakes a blockednext()intoMongoException("Cursor has been closed"). The captured behaviour.tryNext()blocks up tomaxAwaitTime(default 1s), so listener containers do not spin;available()counts only the locally fetched batch.- Resume tokens are monotonic, survive within a context, and resume strictly after their event. History that was evicted, meaning the log is bounded by
bonsai.experimental.change-streams.log-limit(default 10,000), or that belongs to a dropped incarnation fails with the history-lost signature. A foreign-deployment token reads from the tail, as captured.
Named boundaries
Everything the shim does not model fails fast under a changeStream.* matrix key, so an unsupported stream option is a named refusal rather than a silently wrong event feed:
| Matrix key | The edge it names |
|---|---|
changeStream.client.watch | client.watch(): a deployment-wide stream across every database. The collection- and database-level forms are the supported surface. |
changeStream.startAtOperationTime | Opening a stream at a server timestamp. |
changeStream.showExpandedEvents | The expanded-event set (DDL events beyond the captured drop/invalidate shapes). |
changeStream.collation | A collation on the stream. |
changeStream.pipeline.stage | A pipeline stage other than $match, $project or $addFields. |
changeStream.match.operator | A $match operator other than equality and $in. |
comment is accepted with no observable effect, exactly as observed on a real server. The precise key spellings live in the compatibility matrix, beside the rest of the driver surface.