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:

Properties
bonsai.experimental.change-streams=true

With 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 $set to the same value emits no event. updatedFields arrive in lexicographic order regardless of the update command's order; a front $pop reports the full resulting array; truncatedArrays appears only for specific operators.
  • fullDocument is omitted on a default update event, present as an explicit null where the server does that (for example updateLookup after the document was deleted), and updateLookup looks up at read time, so the returned document can be newer than the event.
  • Dropping a watched collection delivers drop then invalidate; a database-level stream survives a collection drop; dropDatabase ends with invalidate; a post-invalidate next() raises the driver's cursor exhaustion.
  • Transactions publish their events at commit, in write order, sharing one clusterTime with lsid/txnNumber; an aborted transaction emits nothing.
  • Both fullDocument*: required modes without images fail with server code 47; pre/post images are honoured via changeStreamPreAndPostImages on 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.

Properties
bonsai.experimental.change-streams=true
bonsai.experimental.change-streams.server-version=7.0

The 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:

A method-scoped MessageListenerContainer
@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() and hasNext() block like the driver's until an event or close(). A cross-thread close() promptly wakes a blocked next() into MongoException("Cursor has been closed"). The captured behaviour.
  • tryNext() blocks up to maxAwaitTime (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 keyThe edge it names
changeStream.client.watchclient.watch(): a deployment-wide stream across every database. The collection- and database-level forms are the supported surface.
changeStream.startAtOperationTimeOpening a stream at a server timestamp.
changeStream.showExpandedEventsThe expanded-event set (DDL events beyond the captured drop/invalidate shapes).
changeStream.collationA collation on the stream.
changeStream.pipeline.stageA pipeline stage other than $match, $project or $addFields.
changeStream.match.operatorA $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.