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_MARKERS_CAPACITY = 200
48+ _SEQ_MARKERS_EVICTION_COUNT = _SEQ_MARKERS_CAPACITY // 10
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 _SeqEntryType (Enum ):
66+ """
67+ Enum for the type of a seq marker entry tracked by the changes follower.
68+ """
69+ ROW = auto ()
70+ PAGE = auto ()
71+
72+
6373class _TransientErrorSuppression (Enum ):
6474 """
6575 Enums for changes follower's transient errors suppression mode.
@@ -107,6 +117,7 @@ def __init__(
107117 self ._limit = None
108118 self ._stop = Event ()
109119 self .logger = logging .getLogger (__name__ )
120+ self ._seq_markers : list = []
110121
111122 @property
112123 def limit (self ) -> int :
@@ -164,6 +175,44 @@ def __next__(self):
164175 )
165176 self ._buffer .task_done ()
166177
178+ def last_seq_since (self , last_persisted_seq : str ) -> str :
179+ """
180+ Return the newest sequence ID that is safe to use as a checkpoint
181+ after the given persisted sequence ID.
182+
183+ Walks forward through the retained seq markers from the given
184+ last_persisted_seq, fast-forwarding through consecutive page entries
185+ to return the furthest safe last_seq without advancing past later
186+ change rows that might not yet have been processed.
187+
188+ Returns last_persisted_seq unchanged if not found in the markers.
189+ """
190+ found = False
191+ result = None
192+ for entry in self ._seq_markers :
193+ if found :
194+ if entry ['type' ] == _SeqEntryType .ROW :
195+ break
196+ result = entry ['seq' ]
197+ elif entry ['seq' ] == last_persisted_seq :
198+ found = True
199+ result = entry ['seq' ]
200+ return result if found else last_persisted_seq
201+
202+ def _update_seq_markers (self , results : list , last_seq : str ) -> None :
203+ """
204+ Update the seq markers list with entries from a completed page.
205+
206+ Evicts the oldest entries if the list is at capacity, then appends
207+ a ROW entry for the last change item (if any) and a PAGE entry for
208+ the page's last_seq.
209+ """
210+ if len (self ._seq_markers ) >= _SEQ_MARKERS_CAPACITY :
211+ del self ._seq_markers [:_SEQ_MARKERS_EVICTION_COUNT ]
212+ if len (results ) > 0 :
213+ self ._seq_markers .append ({'type' : _SeqEntryType .ROW , 'seq' : results [- 1 ].get ('seq' )})
214+ self ._seq_markers .append ({'type' : _SeqEntryType .PAGE , 'seq' : last_seq })
215+
167216 def _request_callback (self ):
168217 while True :
169218 try :
@@ -179,6 +228,7 @@ def _request_callback(self):
179228 self ._has_next = False
180229 results = result ['results' ]
181230 self .logger .debug (f'_request_callback results { results } ' )
231+ self ._update_seq_markers (results , self .since )
182232 self ._buffer .join ()
183233 if self ._stop .is_set ():
184234 raise StopIteration
@@ -432,6 +482,40 @@ def stop(self) -> None:
432482 """
433483 self ._iter .stop ()
434484
485+ def get_last_seq_newer_than (self , last_persisted_seq : str ) -> str :
486+ """
487+ Return the newest sequence ID that is safe to use as a checkpoint
488+ after the given persisted sequence ID.
489+
490+ Use this after fully processing a ``ChangesResultItem`` to determine
491+ whether this ``ChangesFollower`` has observed a later safe checkpoint.
492+ This is useful for filtered or sparse changes feeds, where the feed
493+ can advance across pages even when no additional user-processable
494+ change rows are returned.
495+
496+ The supplied sequence ID must be the ``seq`` of a
497+ ``ChangesResultItem`` that your application has fully processed and
498+ already persisted. This method returns a newer sequence only when
499+ doing so does not advance past later change rows that might not yet
500+ have been processed by your application.
501+
502+ :param str last_persisted_seq: The ``seq`` of the last
503+ ``ChangesResultItem`` that your application has fully processed
504+ and persisted.
505+ :raises ValueError: If the provided sequence ID is null or empty.
506+ :return: The newest safe sequence ID to persist as
507+ ``PostChangesParams.since``. Returns the supplied ID unchanged
508+ if no newer safe checkpoint is available, the feed has not
509+ started yet, or the supplied ID is not present in this
510+ ``ChangesFollower`` instance's retained sequence history.
511+ :rtype: str
512+ """
513+ if not last_persisted_seq :
514+ raise ValueError ('The provided sequence ID cannot be null or empty.' )
515+ if self ._iter is None :
516+ return last_persisted_seq
517+ return self ._iter .last_seq_since (last_persisted_seq )
518+
435519 def _run (self , mode : _Mode ):
436520 if self ._iter is not None :
437521 raise RuntimeError ('Cannot start a feed that has already started.' )
0 commit comments