Skip to content

Commit 21cf0dc

Browse files
feat: latestSequenceFrom for sparse changes feeds
1 parent b250a44 commit 21cf0dc

2 files changed

Lines changed: 445 additions & 1 deletion

File tree

‎ibmcloudant/features/changes_follower.py‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from datetime import datetime, timezone, timedelta
2424
import functools
2525
from queue import Queue
26-
from threading import Thread, Event
26+
from threading import Thread, Event, Lock
2727

2828
from enum import Enum, auto
2929
from typing import Dict, Iterator
@@ -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_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+
6373
class _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

Comments
 (0)