Skip to content

Commit a21f73f

Browse files
feat: add last_seq checkpoint
1 parent b250a44 commit a21f73f

2 files changed

Lines changed: 368 additions & 0 deletions

File tree

‎ibmcloudant/features/changes_follower.py‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
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+
6373
class _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

Comments
 (0)