CBG-5429: Fix SequenceID String() and Before() for LowSeq/TriggeredBy corner cases #8423
CBG-5429: Fix SequenceID String() and Before() for LowSeq/TriggeredBy corner cases #8423RIT3shSapata wants to merge 8 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes ordering/serialization edge cases for db.SequenceID used by the _changes feed, specifically around compound sequences that include both LowSeq (skipped-sequence mode) and TriggeredBy (channel-grant backfill). It also adds an integration test intended to prevent regressions in multi-channel backfill behavior.
Changes:
- Fix
SequenceID.String()to preserveLowSeqforLowSeq:TriggeredBy:Seqcases whereLowSeq > Seq. - Rewrite
SequenceID.Before()to correctly order sequences whenLowSeqand/orTriggeredByare present. - Add a
_changesintegration test scenario combining skipped sequences (LowSeq mode) with a mid-stream channel grant (TriggeredBy backfill).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
db/sequence_id.go |
Adjusts string formatting and comparison semantics for compound SequenceID corner cases affecting _changes ordering and continuation. |
db/sequence_id_test.go |
Extends unit coverage for String() and ordering with LowSeq+TriggeredBy combinations. |
rest/changestest/changes_api_test.go |
Adds an integration test meant to cover LowSeq+TriggeredBy behavior during changes backfill. |
| if s.LowSeq > 0 { | ||
| if s.TriggeredBy > 0 { | ||
| return fmt.Sprintf("%d:%d:%d", s.LowSeq, s.TriggeredBy, s.Seq) | ||
| } else { |
| // With seq 7 now in the cache, the changes feed should deliver it via the late-sequence | ||
| // path. The user still has no DEF access, but the LowSeq advancement itself produces a | ||
| // result (last_seq moves forward). The last_seq from this response is used as the since | ||
| // value for the final request below. | ||
| changes = rt.PostChanges("/{{.keyspace}}/_changes", changesJSON, "sg-user") | ||
| require.Len(t, changes.Results, 1) | ||
|
|
There was a problem hiding this comment.
false, you will get sequence 9 again here in this scenario when stable sequence moves on
| // Use the last_seq from the post-seq7 response as the since value. This since has LowSeq | ||
| // set, and after the DEF grant the changes feed will add TriggeredBy, producing a compound | ||
| // since value {LowSeq: L, TriggeredBy: 10, Seq: S} — the CBG-5429 corner case. | ||
| changesJSON = fmt.Sprintf(`{"since":"%s"}`, changes.Last_Seq.String()) | ||
|
|
||
| // Expect 4 results: the user update (seq 10) plus the 3 DEF backfill docs (seqs 3, 4, 7). | ||
| // If Before() or String() are broken for the compound since value, backfill docs will be | ||
| // missed and this assertion will fail. | ||
| changes = rt.PostChanges("/{{.keyspace}}/_changes", changesJSON, "sg-user") |
There was a problem hiding this comment.
I think this may be worth doing
gregns1
left a comment
There was a problem hiding this comment.
I think we have issues with these changes with interrupted/oneshot feeds. I don't think this is effected for continuous feeds but will be effected for continuous feeds on the first iteration of the feed. So lower blast radius.
Scenario 1 — continuing backfill skips a doc
| Seq | Channel | Note |
|---|---|---|
| 1 | (user1) | access: ABC |
| 2,3,4 | DEF | backfill targets |
| 5 | ABC | |
| 6 | — | gap (skipped) |
| 7 | ABC |
GET _changesas user1 →last_seq = 5::7.- Grant user1 access to
ABC, DEF→ lands at seq 10. POST _changeswithsince=5::7&limit=2(cuts the backfill short) →last_seq = 5:10:3.
Old code:LowSeq(5)is not< Seq(3), so it emitted10:3.- Seq 6 arrives; the feed's
lowSequencemoves to 7. POST _changeswithsince=5:10:3(no/high limit) → returns only the user doc,last_seq = 7::10.
Result: Seq 4 is not delivered in the feed. Line 850 in changes.go will see that since low seq is != 0 but since low seq != low seq on feed (it’s moved to 7 now) and this does not set since low seq to 0. This stays as 5 here. Then in changesFeed SafeSequence() calculates safe seq to be low seq, in this case 5 and this leads to sequence 4 above getting missed on the feed.
Scenario 2 — grant's fresh backfill is suppressed
| Seq | Channel | Note |
|---|---|---|
| 1 | (user1) | access: ABC |
| 2 | DEF | backfill target |
| 3 | GHI | backfill target |
| 4 | (grant) | user1 → ABC, DEF |
| 5 | (grant) | user1 → ABC, DEF, GHI |
| 6,7 | ABC | |
| 8 | — | gap (skipped) |
| 9 | ABC |
POST _changeswithlimit=1(cuts the backfill short) →last_seq = 7:4:2.- Seq 8 arrives; the feed's
lowSequencemoves off 7. POST _changeswithsince=7:4:2.
Result: GHI's backfill doc (seq 3) is not delivered in feed. On this first iteration isNewChannel is false (changedChannels is only populated on later iterations of a waiting feed), so the decision to backfill falls to backfillRequired at changes.go:929
So seqAdded at for GHI will be seq 5:
So seqAddedAt > 1 -> true
But 7:4:2.Before(5) -> false
backfillRequired is false
I think overall it may be worth discussing as a team this PR in sync up.
| // With seq 7 now in the cache, the changes feed should deliver it via the late-sequence | ||
| // path. The user still has no DEF access, but the LowSeq advancement itself produces a | ||
| // result (last_seq moves forward). The last_seq from this response is used as the since | ||
| // value for the final request below. | ||
| changes = rt.PostChanges("/{{.keyspace}}/_changes", changesJSON, "sg-user") | ||
| require.Len(t, changes.Results, 1) | ||
|
|
There was a problem hiding this comment.
false, you will get sequence 9 again here in this scenario when stable sequence moves on
| // Use the last_seq from the post-seq7 response as the since value. This since has LowSeq | ||
| // set, and after the DEF grant the changes feed will add TriggeredBy, producing a compound | ||
| // since value {LowSeq: L, TriggeredBy: 10, Seq: S} — the CBG-5429 corner case. | ||
| changesJSON = fmt.Sprintf(`{"since":"%s"}`, changes.Last_Seq.String()) | ||
|
|
||
| // Expect 4 results: the user update (seq 10) plus the 3 DEF backfill docs (seqs 3, 4, 7). | ||
| // If Before() or String() are broken for the compound since value, backfill docs will be | ||
| // missed and this assertion will fail. | ||
| changes = rt.PostChanges("/{{.keyspace}}/_changes", changesJSON, "sg-user") |
There was a problem hiding this comment.
I think this may be worth doing
Fixes String() and Before() for corner cases related to backfill boundaries (a.TriggeredBy = b.Seq) and LowSeq/TriggeredBy interactions.
…ompound sequences.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
db/sequence_id.go:68
- The PR description says the String() fix should preserve LowSeq in the wire format when LowSeq > Seq (to avoid returning an incorrect last_seq during backfill), but intSeqToString still explicitly omits LowSeq when Seq < LowSeq (and the new changestest assertions expect a 2-component string like "TriggeredBy:Seq"). Please reconcile the intended behaviour: either update the PR description/tests to match the documented rule "ignore LowSeq when LowSeq > Seq", or change the formatting rule so LowSeq is preserved for the backfill corner case described in the PR.
// intSeqToString implements the formatting rules documented on String() above.
func (s SequenceID) intSeqToString() string {
// LowSeq is omitted from the output here for two independent reasons: it's zero (there's
// nothing to report), or it's stale - greater than Seq. LowSeq is stale when this entry is a
// previously-skipped sequence being delivered after the feed's lowest contiguous sequence has
// already moved past it, so including it here would misrepresent this entry's position.
// TriggeredBy, if set, reflects an in-progress channel backfill and is unrelated to which of
// those two reasons applies.
if s.LowSeq == 0 || s.Seq < s.LowSeq {
if s.TriggeredBy > 0 {
return fmt.Sprintf("%d:%d", s.TriggeredBy, s.Seq)
}
return strconv.FormatUint(s.Seq, 10)
}
db/sequence_id.go:166
- SequenceID marshaling determines the public REST API representation of both changes feed
seqentries andlast_seq/sincevalues (number for simple sequences, colon-delimited string for compound). The OpenAPI spec currently documentsChanges-feed.results[].seqas a number andlast_seqas a string, which doesn’t match the actual behavior (both may be string/number depending on whether the sequence is compound). Since this PR changes compound sequence formatting/ordering, please also updatedocs/apito reflect the correct types/formats (e.g.,oneOf: [string, number]and note colon-delimited forms are opaque).
// MarshalJSON implements json.Marshaler, encoding a SequenceID via String() when TriggeredBy or
// LowSeq is set (any compound form), or as a bare integer for a simple sequence.
func (s SequenceID) MarshalJSON() ([]byte, error) {
if s.TriggeredBy > 0 || s.LowSeq > 0 {
return fmt.Appendf(nil, "\"%s\"", s.String()), nil
} else {
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
adamcfraser
left a comment
There was a problem hiding this comment.
A few changes needed.
| // LowSeq is omitted from the output here for two independent reasons: it's zero (there's | ||
| // nothing to report), or it's stale - greater than Seq. LowSeq is stale when this entry is a | ||
| // previously-skipped sequence being delivered after the feed's lowest contiguous sequence has | ||
| // already moved past it, so including it here would misrepresent this entry's position. | ||
| // TriggeredBy, if set, reflects an in-progress channel backfill and is unrelated to which of | ||
| // those two reasons applies. | ||
| if s.LowSeq == 0 || s.Seq < s.LowSeq { |
There was a problem hiding this comment.
"stale" is the wrong way to describe this scenario. If LowSeq is greater than s.Seq, it represents a case where the sequence of the revision being sent (s.Seq) is older than the cache's stable sequence (LowSeq), and so it's not necessary to include the stable sequence information.
The low (stable) sequence is only required for revisions with sequences higher than the stable sequence, to keep track of the fact that we're sending a sequence later than the stable sequence, and so there are potentially sequences n in the range s.LowSeq < n < s.Seq that the client hasn't yet received.
| { | ||
| name: "compound sequence with lowSeq, triggeredBy seq and seq and seq < lowSeq", | ||
| seq: SequenceID{LowSeq: 100, TriggeredBy: 105, Seq: 90}, | ||
| seqStr: "105:90", |
There was a problem hiding this comment.
This is incorrect - this should be 100:105:90.
105:90 indicates that the changes feed is currently at sequence 105, and there's a channel grant at sequence 105 that has triggered a backfill. Sequence 90 is being sent as part of that backfill, but the overall feed is still at sequence 105. Omitting lowSeq here loses track of the fact that the client hasn't been sent all sequences between 100 and 105.
LowSeq should be included whenever lowSeq is lower than the overall position of the feed. For simple sequences (no triggered by), that's just when lowSeq < Seq. But if there's a backfill, the position of the feed is TriggeredBy, so you need to compare lowSeq < TriggeredBy.
There was a problem hiding this comment.
I added another table to the spreadsheet for all the String() permutations, have a look and add them as use cases for this test.
| // already moved past it, so including it here would misrepresent this entry's position. | ||
| // TriggeredBy, if set, reflects an in-progress channel backfill and is unrelated to which of | ||
| // those two reasons applies. | ||
| if s.LowSeq == 0 || s.Seq < s.LowSeq { |
There was a problem hiding this comment.
The evaluation to determine whether lowSeq should be sent (s.Seq < s.LowSeq) is only valid when triggeredBy=0. If triggeredBy is non-zero the evaluation should be (s.Seq < s.TriggeredBy). See the notes in the unit test below.
CBG-5429
SequenceID.String()andSequenceID.Before()for corner cases involving both LowSeq and TriggeredBy, and add a changes feed integration test to verify no regression in backfill behaviour.String()fix: the old implementation only emitted the LowSeq prefix when LowSeq < Seq. When LowSeq > Seq (which occurs during a backfill-in-progress with active skipped sequences), the LowSeq field was silently dropped, causing the client to receive an incorrect last_seq. On the next request the changes feed would resume from the wrong position, potentially missing backfill documents.Before()fix — the old implementation routed all comparisons through SafeSequence() (which returns LowSeq when non-zero), ignoring the Seq field entirely when LowSeq is set. This produced wrong ordering for cases such as:Pre-review checklist
fmt.Print,log.Print, ...)base.UD(docID),base.MD(dbName))docs/apiDependencies (if applicable)
Integration Tests