Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions features/changes_follower.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ import (
// exceed it.
const forever time.Duration = math.MaxInt64

// seqMarkersCapacity is the maximum number of seq marker entries retained.
const seqMarkersCapacity = 200

// seqMarkersEvictionCount is the number of entries removed when capacity is reached.
const seqMarkersEvictionCount = seqMarkersCapacity / 10

// Minimal client timeout set to 1 minute.
const minClientTimeout time.Duration = time.Minute

Expand All @@ -53,6 +59,23 @@ const baseDelay time.Duration = 100 * time.Millisecond
// Once we reach this number of retries we'll be capping the backoff
var expRetryGate int = int(math.Log(float64(LongpollTimeout/baseDelay)) / math.Log(2))

// seqEntryType distinguishes between a row (change item) and a page (batch) entry
// in the seq markers list.
type seqEntryType int

const (
seqEntryRow seqEntryType = iota
seqEntryPage
)

// seqEntry records a sequence marker along with whether it came from a change
// row or from a page boundary. seq may be nil (e.g. when seq_interval causes
// the server to return a null seq).
type seqEntry struct {
entryType seqEntryType
seq *string
}

// Mode are enums for changes follower's operation mode.
type Mode int

Expand Down Expand Up @@ -136,6 +159,8 @@ type ChangesFollower struct {
running bool
runLock sync.Mutex
logger core.Logger
seqMarkers []seqEntry
seqMarkersLock sync.RWMutex
}

// ChangesItem is a wrapper structure around cloudantv1.ChangesResultItem
Expand Down Expand Up @@ -251,6 +276,89 @@ func (cf *ChangesFollower) StartOneOff() (<-chan ChangesItem, error) {
return cf.run(Finite)
}

// GetLastSeqNewerThan returns the newest sequence ID that is safe to use as a
// checkpoint after the given persisted sequence ID.
//
// Use this after fully processing a ChangesResultItem to determine whether
// this ChangesFollower has observed a later safe checkpoint. This is useful
// for filtered or sparse changes feeds, where the feed can advance across
// pages even when no additional user-processable change rows are returned.
//
// The supplied sequence ID must be the Seq of a ChangesResultItem that your
// application has fully processed and already persisted. This method returns
// a newer sequence only when doing so does not advance past later change rows
// that might not yet have been processed by your application.
//
// Returns the supplied ID unchanged when:
// - the follower has not started yet,
// - the supplied ID was not seen by this ChangesFollower instance, or
// - no newer safe checkpoint is available.
//
// Returns an error if lastPersistedSeqID is empty.
func (cf *ChangesFollower) GetLastSeqNewerThan(lastPersistedSeqID string) (string, error) {
if lastPersistedSeqID == "" {
return "", core.SDKErrorf(nil, "the provided sequence ID cannot be null or empty", "changes-follower-invalid-seq", common.GetComponentInfo())
}
cf.seqMarkersLock.RLock()
defer cf.seqMarkersLock.RUnlock()
if len(cf.seqMarkers) == 0 {
return lastPersistedSeqID, nil
}
return cf.lastSeqSince(lastPersistedSeqID), nil
}

// lastSeqSince walks forward through the retained seq markers from the given
// lastPersistedSeqID, fast-forwarding through consecutive page entries to
// return the furthest safe last_seq without advancing past later change rows
// that might not yet have been processed.
//
// Returns lastPersistedSeqID unchanged if not found in the markers.
// Must be called with seqMarkersLock at least read-held.
func (cf *ChangesFollower) lastSeqSince(lastPersistedSeqID string) string {
found := false
result := lastPersistedSeqID

for _, entry := range cf.seqMarkers {
if found {
if entry.entryType == seqEntryRow {
break
}
result = *entry.seq

@ricellis ricellis Aug 28, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be defensive with the != nil check in the same way as in the following else if.
I think the only way it could be nil is if a page was returned with a nil last_seq - I don't think that is very likely, but I'd rather not find via the nil de-reference panic if it ever does happen.

In the case that it did we should probably just skip it, so that we can return the furthest possible (if there are pages with non-null last_seq available) or the previous result that was the furthest one we reached before finding the nil.

} else if entry.seq != nil && *entry.seq == lastPersistedSeqID {
found = true
result = *entry.seq
}
}

if found {
return result
}
return lastPersistedSeqID
}

// updateSeqMarkers updates the seq markers list with entries from a completed page.
//
// Evicts the oldest entries if the list is at capacity, then appends a ROW
// entry for the last change item (if any) and a PAGE entry for the page's
// last_seq.
func (cf *ChangesFollower) updateSeqMarkers(results []cloudantv1.ChangesResultItem, lastSeq *string) {
cf.seqMarkersLock.Lock()
defer cf.seqMarkersLock.Unlock()
if len(cf.seqMarkers) >= seqMarkersCapacity {
cf.seqMarkers = cf.seqMarkers[seqMarkersEvictionCount:]
}
if len(results) > 0 {
cf.seqMarkers = append(cf.seqMarkers, seqEntry{
entryType: seqEntryRow,
seq: results[len(results)-1].Seq,
})
}
cf.seqMarkers = append(cf.seqMarkers, seqEntry{
entryType: seqEntryPage,
seq: lastSeq,
})
}

// Stop this ChangesFollower.
func (cf *ChangesFollower) Stop() {
cf.cancel()
Expand Down Expand Up @@ -436,6 +544,9 @@ func (cf *ChangesFollower) getChangesBatch() chan changesItems {
if cf.suppression == Timer {
cf.successTimestamp = time.Now()
}

cf.updateSeqMarkers(result.Results, result.LastSeq)

changes <- changesItems{items: result.Results}
if cf.mode == Finite && *result.Pending == 0 {
return
Expand Down
Loading