-
Type:
Bug
-
Resolution: Unresolved
-
Priority:
Unknown
-
None
-
Affects Version/s: None
-
Component/s: None
-
None
-
None
-
Go Drivers
-
None
-
None
-
None
-
None
-
None
-
None
When the context passed to ChangeStream.Next expires, the driver tries to resume using that expired context. The killCursors for the old cursor fails without being sent, but BatchCursor.Close sets the cursor ID to 0 anyway. A later ChangeStream.Close, even with a live context, therefore sends nothing. The server cursor stays open until the idle cursor reaper removes it.
Applications that bound each Next call with a timeout leak one server cursor per timeout. On deployments that cap open cursors (e.g. Amazon DocumentDB) this eventually fails with "Cannot open a new cursor since too many cursors are already opened". We hit this in ClickPipes/PeerDB, which calls Next with an idle timeout once per CDC batch and then closes the stream with a fresh context.
Detailed steps to reproduce the problem?
cs, _ := coll.Watch(context.Background(), mongo.Pipeline{},
options.ChangeStream().SetMaxAwaitTime(50*time.Millisecond))
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
cs.Next(ctx) // returns false, cs.Err() wraps context.DeadlineExceeded
cs.Close(context.Background()) // expected: killCursors is sent; actual: no command is sent
Result: Close sends no killCursors, so the server cursor stays open.
Cause
In ChangeStream.loopNext (mongo/change_stream.go):
- A canceled or expired context is treated as resumable, like every non-server error.
- The resume closes the old cursor using the dead context, so killCursors is never sent.
- BatchCursor.Close sets bc.id to 0 unconditionally (x/mongo/driver/batch_cursor.go), after a best-effort KillCursor that may never have reached the server.
The bc.id assignment is what makes the leak permanent. bc.id is the only state the driver keeps that records "a server cursor still needs reclaiming", and it is cleared before the driver knows whether the kill succeeded. KillCursor is itself guarded on bc.id == 0, so once the ID is zeroed every later Close short-circuits and returns nil without sending anything. The failed kill is reported once, through the return value of Close, and after that the driver has no way to retry.
This is not specific to the resume path: any BatchCursor.Close whose killCursors fails discards the ID the same way. Change streams are where it surfaces as an orphan, because the resume path is what closes a cursor using a dead context.
This also conflicts with the CSOT spec:
If a next call fails with a timeout error, drivers MUST NOT invalidate the change stream. The subsequent next call MUST perform a resume attempt to establish a new change stream on the server.
The change streams spec, however, defines every non-server error as resumable and requires a resume:
Any error encountered which is not a server error (e.g. a timeout error or network error)
A change stream MUST attempt to resume a single time if it encounters any resumable error per Resumable Error.
For a timeout these disagree: CSOT says do not invalidate, the change streams spec says resume. The driver currently follows the change streams spec, resumes with the dead context, and orphans the cursor. The same applies to application cancellation, which is also not a server error and so is also treated as resumable, even though the cancellation is exactly what prevents the resume's killCursors from reaching the server.
DRIVERS-3734 proposes updating the change streams spec to exclude cancellation and deadline errors from the resumable set and adds a prose test.
Related: GODRIVER-3380 (resume on the next call after a timeout), GODRIVER-1247 (same repro, fixed the connection leak only)
Definition of done: what must be done to consider the task complete?
- When the context is expired or canceled, Next returns without resuming and leaves the cursor ID intact, so a later Close with a live context sends killCursors.
- The cursor ID survives a resume whose killCursors never reaches the server.
- Decide and document where this is enforced. Either BatchCursor.Close stops zeroing the ID when the kill is not sent, or callers are guaranteed never to close with a dead context. If the former, it applies to every cursor type, not just change streams.
- Both context.Canceled and context.DeadlineExceeded are handled. Cancellation is the more common trigger and is not CSOT-specific.
- Existing resume behavior is unchanged for errors that should still resume (network errors, allowlisted server codes, ResumableChangeStreamError).
The exact Go version used, with patch level:
go version go1.27.1 darwin/arm64
The exact version of the Go driver used:
go.mongodb.org/mongo-driver/v2 v2.9.2 (also reproduced on release/2.9 HEAD)
Describe how MongoDB is set up. Local vs Hosted, version, topology, load balanced, etc.
Reproduced locally on MongoDB 8.0.34, single-node replica set in Docker. Seen in production by ClickPipes against Amazon DocumentDB with a 30-cursor limit.
The operating system and version (e.g. Windows 7, OSX 10.8, ...)
macOS 26.7.1 (arm64). The bug is not OS-specific.
Security Vulnerabilities
If you’ve identified a security vulnerability in a driver or any other MongoDB project, please report it according to the instructions here
- is related to
-
DRIVERS-3734 Change stream resume after app cancellation orphans the server cursor
-
- Needs Triage
-
- related to
-
GODRIVER-3380 Change stream should resume with CSOT failure
-
- Backlog
-
-
GODRIVER-1247 connection leak with change stream
-
- Closed
-
- links to