@@ -77,13 +77,16 @@ def __init__(self, target, pipeline, full_document, resume_after,
7777
7878 self ._pipeline = copy .deepcopy (pipeline )
7979 self ._full_document = full_document
80- self ._resume_token = copy .deepcopy (resume_after )
80+ self ._uses_start_after = start_after is not None
81+ self ._uses_resume_after = resume_after is not None
82+ self ._resume_token = copy .deepcopy (start_after or resume_after )
8183 self ._max_await_time_ms = max_await_time_ms
8284 self ._batch_size = batch_size
8385 self ._collation = collation
8486 self ._start_at_operation_time = start_at_operation_time
8587 self ._session = session
86- self ._start_after = copy .deepcopy (start_after )
88+
89+ # Initialize cursor.
8790 self ._cursor = self ._create_cursor ()
8891
8992 @property
@@ -102,10 +105,14 @@ def _change_stream_options(self):
102105 options = {}
103106 if self ._full_document is not None :
104107 options ['fullDocument' ] = self ._full_document
105- if self ._resume_token is not None :
106- options ['resumeAfter' ] = self ._resume_token
107- if self ._start_after is not None :
108- options ['startAfter' ] = self ._start_after
108+
109+ resume_token = self .resume_token
110+ if resume_token is not None :
111+ if self ._uses_start_after :
112+ options ['startAfter' ] = resume_token
113+ if self ._uses_resume_after :
114+ options ['resumeAfter' ] = resume_token
115+
109116 if self ._start_at_operation_time is not None :
110117 options ['startAtOperationTime' ] = self ._start_at_operation_time
111118 return options
@@ -127,12 +134,18 @@ def _aggregation_pipeline(self):
127134 return full_pipeline
128135
129136 def _process_result (self , result , session , server , sock_info , slave_ok ):
130- """Callback that records a change stream cursor's operationTime."""
131- if (self ._start_at_operation_time is None and
132- self ._resume_token is None and
133- self ._start_after is None and
134- sock_info .max_wire_version >= 7 ):
135- self ._start_at_operation_time = result ["operationTime" ]
137+ """Callback that caches the startAtOperationTime from a changeStream
138+ aggregate command response containing an empty batch of change
139+ documents.
140+
141+ This is implemented as a callback because we need access to the wire
142+ version in order to determine whether to cache this value.
143+ """
144+ if not result ['cursor' ]['firstBatch' ]:
145+ if (self ._start_at_operation_time is None and
146+ self .resume_token is None and
147+ sock_info .max_wire_version >= 7 ):
148+ self ._start_at_operation_time = result ["operationTime" ]
136149
137150 def _run_aggregation_cmd (self , session , explicit_session ):
138151 """Run the full aggregation pipeline for this ChangeStream and return
@@ -168,6 +181,15 @@ def close(self):
168181 def __iter__ (self ):
169182 return self
170183
184+ @property
185+ def resume_token (self ):
186+ """The cached resume token that will be used to resume after the most
187+ recently returned change.
188+
189+ .. versionadded:: 3.9
190+ """
191+ return copy .deepcopy (self ._resume_token )
192+
171193 def next (self ):
172194 """Advance the cursor.
173195
@@ -249,20 +271,39 @@ def try_next(self):
249271 self ._resume ()
250272 change = self ._cursor ._try_next (False )
251273
252- # No changes are available.
274+ # If no changes are available.
253275 if change is None :
254- return None
255-
276+ # We have either iterated over all documents in the cursor,
277+ # OR the most-recently returned batch is empty. In either case,
278+ # update the cached resume token with the postBatchResumeToken if
279+ # one was returned. We also clear the startAtOperationTime.
280+ if self ._cursor ._post_batch_resume_token is not None :
281+ self ._resume_token = self ._cursor ._post_batch_resume_token
282+ self ._start_at_operation_time = None
283+ return change
284+
285+ # Else, changes are available.
256286 try :
257287 resume_token = change ['_id' ]
258288 except KeyError :
259289 self .close ()
260290 raise InvalidOperation (
261291 "Cannot provide resume functionality when the resume "
262292 "token is missing." )
263- self ._resume_token = copy .copy (resume_token )
293+
294+ # If this is the last change document from the current batch, cache the
295+ # postBatchResumeToken.
296+ if (not self ._cursor ._has_next () and
297+ self ._cursor ._post_batch_resume_token ):
298+ resume_token = self ._cursor ._post_batch_resume_token
299+
300+ # Hereafter, don't use startAfter; instead use resumeAfter.
301+ self ._uses_start_after = False
302+ self ._uses_resume_after = True
303+
304+ # Cache the resume token and clear startAtOperationTime.
305+ self ._resume_token = resume_token
264306 self ._start_at_operation_time = None
265- self ._start_after = None
266307
267308 if self ._decode_custom :
268309 return _bson_to_dict (change .raw , self ._orig_codec_options )
0 commit comments