2323from datetime import datetime , timezone , timedelta
2424import functools
2525from queue import Queue
26- from threading import Thread , Event
26+ from threading import Thread , Event , Lock
2727
2828from enum import Enum , auto
2929from typing import Dict , Iterator
4444# before the client timeout it is set to 3 seconds less.
4545_LONGPOLL_TIMEOUT = _MIN_CLIENT_TIMEOUT - 3000
4646_BATCH_SIZE = 10000
47+ _SEQ_RETAINED_SIZE = 2048
48+ _SEQ_EVICTION_COUNT = _SEQ_RETAINED_SIZE // 2
4749
4850# Base delay in milliseconds between unsuccessful attempts to pull changes feed
4951# in presence of transient errors
@@ -60,6 +62,14 @@ class _Mode(Enum):
6062 LISTEN = auto ()
6163
6264
65+ class _SeqMarkerType (Enum ):
66+ """
67+ Enum for the type of a seq marker entry tracked by the changes follower.
68+ """
69+ ROW = auto ()
70+ LAST = auto ()
71+
72+
6373class _TransientErrorSuppression (Enum ):
6474 """
6575 Enums for changes follower's transient errors suppression mode.
@@ -107,6 +117,8 @@ def __init__(
107117 self ._limit = None
108118 self ._stop = Event ()
109119 self .logger = logging .getLogger (__name__ )
120+ self ._seq_markers : list = []
121+ self ._seq_markers_lock = Lock ()
110122
111123 @property
112124 def limit (self ) -> int :
@@ -164,6 +176,48 @@ def __next__(self):
164176 )
165177 self ._buffer .task_done ()
166178
179+ def last_seq_since (self , last_persisted_seq : str ) -> str :
180+ """
181+ Return the newest sequence ID that is safe to use as a checkpoint
182+ after the given persisted sequence ID.
183+
184+ Walks forward through the retained seq markers from the given
185+ last_persisted_seq, fast-forwarding through consecutive page entries
186+ to return the furthest safe last_seq without advancing past later
187+ change rows that might not yet have been processed.
188+
189+ Returns last_persisted_seq unchanged if not found in the markers.
190+ """
191+ with self ._seq_markers_lock :
192+ markers = list (self ._seq_markers )
193+ found = False
194+ result = None
195+ for entry in markers :
196+ if found :
197+ if entry ['seqMarkerType' ] == _SeqMarkerType .ROW :
198+ break
199+ if entry ['seq' ] is not None :
200+ result = entry ['seq' ]
201+ elif entry ['seq' ] == last_persisted_seq :
202+ found = True
203+ result = entry ['seq' ]
204+ return result if found else last_persisted_seq
205+
206+ def _update_seq_markers (self , results : list , last_seq : str ) -> None :
207+ """
208+ Update the seq markers list with entries from a completed page.
209+
210+ Bulk-evicts the oldest entries if the list is at or above the eviction
211+ threshold, then appends a ROW entry for the last change item (if any)
212+ and a LAST entry for the page's last_seq.
213+ """
214+ with self ._seq_markers_lock :
215+ if len (self ._seq_markers ) >= (_SEQ_RETAINED_SIZE + _SEQ_EVICTION_COUNT ):
216+ del self ._seq_markers [:_SEQ_EVICTION_COUNT ]
217+ if len (results ) > 0 :
218+ self ._seq_markers .append ({'seqMarkerType' : _SeqMarkerType .ROW , 'seq' : results [- 1 ].get ('seq' )})
219+ self ._seq_markers .append ({'seqMarkerType' : _SeqMarkerType .LAST , 'seq' : last_seq })
220+
167221 def _request_callback (self ):
168222 while True :
169223 try :
@@ -179,6 +233,7 @@ def _request_callback(self):
179233 self ._has_next = False
180234 results = result ['results' ]
181235 self .logger .debug (f'_request_callback results { results } ' )
236+ self ._update_seq_markers (results , self .since )
182237 self ._buffer .join ()
183238 if self ._stop .is_set ():
184239 raise StopIteration
@@ -432,6 +487,41 @@ def stop(self) -> None:
432487 """
433488 self ._iter .stop ()
434489
490+ def latest_sequence_from (self , checkpoint_sequence_id : str ) -> str :
491+ """
492+ Returns the most recent sequence ID that is safe to use as a
493+ checkpoint, advancing beyond the supplied checkpoint sequence ID
494+ where possible.
495+
496+ With highly filtered changes feeds, multiple pages can pass through
497+ the follower without returning any changes. Using only the ``seq``
498+ of the last processed ``ChangesResultItem`` in those cases causes a
499+ long changes feed rewind on the next run. To avoid this, call this
500+ method after fully processing each ``ChangesResultItem`` with a
501+ non-``None`` ``seq`` and persist the returned value to use as the
502+ ``since`` parameter for the next run.
503+
504+ Note: the follower retains a bounded window of recent sequence IDs.
505+ If subsequent page fetches displace the supplied
506+ ``checkpoint_sequence_id`` from that window, this method returns
507+ the supplied value unchanged. Call this method and persist its result
508+ regularly to obtain a fresh sequence ID.
509+
510+ :param str checkpoint_sequence_id: The last checkpoint sequence ID
511+ — either the non-``None`` ``seq`` of the last ``ChangesResultItem``
512+ fully processed, or a value previously returned by this method.
513+ :return: The most recent safe sequence ID to use as a checkpoint,
514+ or the supplied value if no newer sequence is available.
515+ :rtype: str
516+
517+ Throws ValueError if ``checkpoint_sequence_id`` is ``None`` or empty.
518+ """
519+ if not checkpoint_sequence_id :
520+ raise ValueError ('Provided sequence ID must be a non-empty string.' )
521+ if self ._iter is None :
522+ return checkpoint_sequence_id
523+ return self ._iter .last_seq_since (checkpoint_sequence_id )
524+
435525 def _run (self , mode : _Mode ):
436526 if self ._iter is not None :
437527 raise RuntimeError ('Cannot start a feed that has already started.' )
0 commit comments