From d411c27e785f8ef5b201325bc6fc611c9892ca2f Mon Sep 17 00:00:00 2001 From: Nitish Gupta Date: Fri, 5 Jun 2026 04:29:56 +0530 Subject: [PATCH 1/2] CBL-8390: Implement Parse and Compare Non-numeric checkpoint resolution --- LiteCore/tests/CMakeLists.txt | 1 + Replicator/Checkpoint.cc | 18 +- Replicator/ParsedSequenceID.hh | 147 ++++++++ Replicator/RemoteSequence.hh | 12 + Replicator/tests/ParsedSequenceIDTest.cc | 437 +++++++++++++++++++++++ 5 files changed, 609 insertions(+), 6 deletions(-) create mode 100644 Replicator/ParsedSequenceID.hh create mode 100644 Replicator/tests/ParsedSequenceIDTest.cc diff --git a/LiteCore/tests/CMakeLists.txt b/LiteCore/tests/CMakeLists.txt index 78b4a49ecb..90cade3238 100644 --- a/LiteCore/tests/CMakeLists.txt +++ b/LiteCore/tests/CMakeLists.txt @@ -90,6 +90,7 @@ add_executable( ${TOP}Crypto/CertificateTest.cc ${TOP}Networking/tests/CookieStoreTest.cc ${TOP}Replicator/tests/DBAccessTestWrapper.cc + ${TOP}Replicator/tests/ParsedSequenceIDTest.cc ${TOP}Replicator/tests/PropertyEncryptionTests.cc ${TOP}Replicator/tests/ReplicatorLoopbackTest.cc ${TOP}Replicator/tests/ReplicatorAPITest.cc diff --git a/Replicator/Checkpoint.cc b/Replicator/Checkpoint.cc index b0d3e97dbf..1e496c851b 100644 --- a/Replicator/Checkpoint.cc +++ b/Replicator/Checkpoint.cc @@ -114,19 +114,25 @@ namespace litecore::repl { if ( _remote && _remote != remoteSequences._remote ) { LogTo(SyncLog, "Remote sequence mismatch: I had '%s', remote had '%s'", _remote.toJSONString().c_str(), remoteSequences._remote.toJSONString().c_str()); - if ( _remote.isInt() && remoteSequences._remote.isInt() ) { - if ( _remote.intValue() > remoteSequences._remote.intValue() ) { + + ParsedSequenceID localParsed, remoteParsed; + bool localParseable = _remote.toParsedSequenceID(localParsed); + bool remoteParseable = remoteSequences._remote.toParsedSequenceID(remoteParsed); + if ( localParseable && remoteParseable ) { + if ( remoteParsed.before(localParsed) ) { LogTo(SyncLog, "Rolling back to earlier remote sequence from server, some redundant changes may be " "proposed..."); _remote = remoteSequences._remote; match = false; } else { - LogTo(SyncLog, "Ignoring remote sequence on server since client side is older, some redundant " - "changes may be proposed..."); + LogTo(SyncLog, "Ignoring remote sequence on server since client side is older or equal, some " + "redundant changes may be proposed..."); } } else { - Warn("Non-numeric remote sequence detected, resetting replication back to start. Redundant changes " - "will be proposed..."); + Warn("Unparseable remote sequence: locally-saved remote seq '%s' is %s, " + "server-side remote seq '%s' is %s. Resetting replication.", + _remote.toJSONString().c_str(), localParseable ? "parseable" : "UNPARSEABLE", + remoteSequences._remote.toJSONString().c_str(), remoteParseable ? "parseable" : "UNPARSEABLE"); _remote = {}; match = false; } diff --git a/Replicator/ParsedSequenceID.hh b/Replicator/ParsedSequenceID.hh new file mode 100644 index 0000000000..dd6012fa20 --- /dev/null +++ b/Replicator/ParsedSequenceID.hh @@ -0,0 +1,147 @@ +#pragma once +#include "StringUtil.hh" +#include "slice_stream.hh" +#include +#include + +namespace litecore::repl { + + /** + * Represents a parsed Sync Gateway compound sequence ID. + * + * Sync Gateway emits sequence IDs in three formats on the _changes feed: + * + * - Simple: "Seq" e.g. "42" + * A plain database sequence number. + * + * - Backfill: "TriggeredBy:Seq" e.g. "100:35" + * A revision (seq 35) is being sent retroactively because an access-grant + * change at sequence 100 gave the user access to its channel. + * + * - LowSeq: "LowSeq:TriggeredBy:Seq" e.g. "20:100:35" + * Same as backfill, but also records the lowest contiguous sequence (20) + * that the client has fully processed on the feed. TriggeredBy may be + * omitted (e.g. "20::35") when there is no access-grant trigger. + * + * For checkpoint comparison the "comparison value" of each format is: + * - Simple → seq + * - Backfill → triggeredBy + * - LowSeq → lowSeq + * + * The before() method implements the ordering rules that match Sync Gateway's + * SequenceID.Before(), so that LiteCore and SG always agree on which checkpoint is older. + */ + + class ParsedSequenceID { + public: + ParsedSequenceID() = default; + + ParsedSequenceID(uint64_t seq, uint64_t triggeredBy, uint64_t lowSeq) + : _seq(seq), _triggeredBy(triggeredBy), _lowSeq(lowSeq) {} + + [[nodiscard]] uint64_t seq() const { return _seq; } + + [[nodiscard]] uint64_t triggeredBy() const { return _triggeredBy; } + + [[nodiscard]] uint64_t lowSeq() const { return _lowSeq; } + + /// "Seq" — plain sequence, no trigger, no lowSeq. + [[nodiscard]] bool isSimpleRevision() const { return _lowSeq == 0 && _triggeredBy == 0; } + + /// "TriggeredBy:Seq" — sent retroactively due to an access-grant, no lowSeq tracking. + /// triggeredBy is the primary comparison value in before(). + [[nodiscard]] bool isTriggeredRevision() const { return _lowSeq == 0 && _triggeredBy > 0; } + + /// "LowSeq:TriggeredBy:Seq" or "LowSeq::Seq" — includes lowSeq feed-position tracking. + /// lowSeq is the primary comparison value in before(). May or may not include a trigger. + [[nodiscard]] bool isLowSeqRevision() const { return _lowSeq > 0; } + + /** + * Parse a sequence string into a ParsedSequenceID. + * + * Accepted formats: + * "42" → {seq=42, triggeredBy=0, lowSeq=0} + * "100:35" → {seq=35, triggeredBy=100, lowSeq=0} + * "20:100:35" → {seq=35, triggeredBy=100, lowSeq=20} + * "20::35" → {seq=35, triggeredBy=0, lowSeq=20} + * + * @param str The string to parse. + * @param out Receives the parsed result on success. + * @return true on success, false if the string is empty, malformed, or has + * an unexpected number of components. + */ + static bool parse(const std::string& str, ParsedSequenceID& out) { + if ( str.empty() || str.back() == ':' ) return false; // litecore::split drops trailing empty tokens + + auto parts = litecore::split(str, ":"); + if ( parts.size() < 1 || parts.size() > 3 ) return false; + + // Parse a single component as uint64. Empty is only valid for middle part of "LowSeq::Seq". + auto readUInt = [](std::string_view sv, uint64_t& val) -> bool { + if ( sv.empty() ) return false; + fleece::slice_istream stream(sv.data(), sv.size()); + val = stream.readDecimal(); + return stream.eof(); + }; + + uint64_t v[3] = {}; + switch ( parts.size() ) { + case 1: + if ( !readUInt(parts[0], v[0]) ) return false; + out = ParsedSequenceID(v[0], 0, 0); // Seq + return true; + case 2: + if ( !readUInt(parts[0], v[0]) || !readUInt(parts[1], v[1]) ) return false; + out = ParsedSequenceID(v[1], v[0], 0); // TriggeredBy:Seq + return true; + case 3: + if ( !readUInt(parts[0], v[0]) ) return false; + if ( !parts[1].empty() && !readUInt(parts[1], v[1]) ) return false; // empty → 0 for "LowSeq::Seq" + if ( !readUInt(parts[2], v[2]) ) return false; + out = ParsedSequenceID(v[2], v[1], v[0]); // LowSeq:TriggeredBy:Seq + return true; + default: + return false; + } + } + + [[nodiscard]] bool before(const ParsedSequenceID& seqID2) const { + const auto& a = *this; + const auto& b = seqID2; + + if ( a.isLowSeqRevision() ) { + if ( b.isLowSeqRevision() ) { + if ( a.lowSeq() != b.lowSeq() ) return a.lowSeq() < b.lowSeq(); + ParsedSequenceID aInner(a.seq(), a.triggeredBy(), 0); + ParsedSequenceID bInner(b.seq(), b.triggeredBy(), 0); + return aInner.before(bInner); + } + if ( b.isTriggeredRevision() ) return a.lowSeq() < b.triggeredBy(); + /* b is simple seq*/ + return a.lowSeq() < b.seq(); + } + + if ( a.isTriggeredRevision() ) { + if ( b.isLowSeqRevision() ) return a.triggeredBy() <= b.lowSeq(); + if ( b.isTriggeredRevision() ) { + if ( a.triggeredBy() != b.triggeredBy() ) return a.triggeredBy() < b.triggeredBy(); + return a.seq() < b.seq(); + } + /* b is simple */ + return a.triggeredBy() <= b.seq(); + } + + // a is simple revision + if ( b.isLowSeqRevision() ) return a.seq() <= b.lowSeq(); + if ( b.isTriggeredRevision() ) return a.seq() < b.triggeredBy(); + /* both simple */ + return a.seq() < b.seq(); + } + + private: + uint64_t _seq{0}; ///< The actual internal database sequence number. + uint64_t _triggeredBy{0}; ///< Sequence# of the access-grant that triggered backfill (0 = none). + uint64_t _lowSeq{0}; ///< Lowest contiguous sequence seen on the feed (0 = not present). + }; + +} // namespace litecore::repl diff --git a/Replicator/RemoteSequence.hh b/Replicator/RemoteSequence.hh index 21c3303078..1fec26bbe4 100644 --- a/Replicator/RemoteSequence.hh +++ b/Replicator/RemoteSequence.hh @@ -11,6 +11,7 @@ // #pragma once +#include "ParsedSequenceID.hh" #include "StringUtil.hh" #include "fleece/Fleece.hh" #include "slice_stream.hh" @@ -19,6 +20,7 @@ namespace litecore::repl { + /** A sequence received from a remote peer. Can be any JSON value, but optimized for positive ints. */ class RemoteSequence { public: @@ -73,6 +75,16 @@ namespace litecore::repl { bool operator!=(const RemoteSequence& other) const noexcept FLPURE { return _value != other._value; } + /** Convert this RemoteSequence to a ParsedSequenceID. Returns false if unparseable. */ + [[nodiscard]] bool toParsedSequenceID(ParsedSequenceID& out) const { + if ( !*this ) return false; + if ( isInt() ) { + out = {intValue(), 0, 0}; + return true; + } + return ParsedSequenceID::parse(std::string(sliceValue()), out); + } + bool operator<(const RemoteSequence& other) const noexcept FLPURE { if ( isInt() ) return !other.isInt() || intValue() < other.intValue(); else diff --git a/Replicator/tests/ParsedSequenceIDTest.cc b/Replicator/tests/ParsedSequenceIDTest.cc new file mode 100644 index 0000000000..fe91672cee --- /dev/null +++ b/Replicator/tests/ParsedSequenceIDTest.cc @@ -0,0 +1,437 @@ +#include "ParsedSequenceID.hh" +#include "c4Test.hh" + +using namespace litecore::repl; + +/** Parse str and assert it succeeds, returning the result. */ +static ParsedSequenceID mustParse(const std::string& str) { + ParsedSequenceID out; + REQUIRE(ParsedSequenceID::parse(str, out)); + return out; +} + +/** Assert that parse(str) fails. */ +static void mustFail(const std::string& str) { + ParsedSequenceID out; + CHECK_FALSE(ParsedSequenceID::parse(str, out)); +} + +TEST_CASE("ParsedSequenceID parse - simple revision", "[ParsedSequenceID]") { + // Format: "Seq" + // Fields: seq=N, triggeredBy=0, lowSeq=0 + + SECTION("single digit") { + auto s = mustParse("5"); + CHECK(s.seq() == 5); + CHECK(s.triggeredBy() == 0); + CHECK(s.lowSeq() == 0); + CHECK(s.isSimpleRevision()); + CHECK_FALSE(s.isTriggeredRevision()); + CHECK_FALSE(s.isLowSeqRevision()); + } + + SECTION("multi digit") { + auto s = mustParse("12345"); + CHECK(s.seq() == 12345); + CHECK(s.triggeredBy() == 0); + CHECK(s.lowSeq() == 0); + CHECK(s.isSimpleRevision()); + } + + SECTION("zero") { + auto s = mustParse("0"); + CHECK(s.seq() == 0); + CHECK(s.isSimpleRevision()); + } + + SECTION("large uint64 value") { + auto s = mustParse("18446744073709551615"); // UINT64_MAX + CHECK(s.seq() == UINT64_MAX); + CHECK(s.isSimpleRevision()); + } +} + +TEST_CASE("ParsedSequenceID parse - backfill (TriggeredBy:Seq)", "[ParsedSequenceID]") { + // Format: "TriggeredBy:Seq" + // Fields: seq=Seq, triggeredBy=TriggeredBy, lowSeq=0 + + SECTION("basic backfill") { + auto s = mustParse("100:35"); + CHECK(s.triggeredBy() == 100); + CHECK(s.seq() == 35); + CHECK(s.lowSeq() == 0); + CHECK(s.isTriggeredRevision()); + CHECK_FALSE(s.isSimpleRevision()); + CHECK_FALSE(s.isLowSeqRevision()); + } + + SECTION("both components equal") { + auto s = mustParse("50:50"); + CHECK(s.triggeredBy() == 50); + CHECK(s.seq() == 50); + CHECK(s.isTriggeredRevision()); + } +} + +TEST_CASE("ParsedSequenceID parse - LowSeq with TriggeredBy (LowSeq:TriggeredBy:Seq)", "[ParsedSequenceID]") { + // Format: "LowSeq:TriggeredBy:Seq" + // Fields: seq=Seq, triggeredBy=TriggeredBy, lowSeq=LowSeq + + SECTION("full three-part") { + auto s = mustParse("20:100:35"); + CHECK(s.lowSeq() == 20); + CHECK(s.triggeredBy() == 100); + CHECK(s.seq() == 35); + CHECK(s.isLowSeqRevision()); + CHECK_FALSE(s.isTriggeredRevision()); + CHECK_FALSE(s.isSimpleRevision()); + } + + SECTION("all parts equal") { + auto s = mustParse("10:10:10"); + CHECK(s.lowSeq() == 10); + CHECK(s.triggeredBy() == 10); + CHECK(s.seq() == 10); + CHECK(s.isLowSeqRevision()); + } +} + +TEST_CASE("ParsedSequenceID parse - LowSeq without TriggeredBy (LowSeq::Seq)", "[ParsedSequenceID]") { + // Format: "LowSeq::Seq" — TriggeredBy is empty, treated as 0 + + SECTION("basic lowseq without triggeredby") { + auto s = mustParse("20::35"); + CHECK(s.lowSeq() == 20); + CHECK(s.triggeredBy() == 0); + CHECK(s.seq() == 35); + CHECK(s.isLowSeqRevision()); + } + + SECTION("lowseq large values") { + auto s = mustParse("1000::9999"); + CHECK(s.lowSeq() == 1000); + CHECK(s.triggeredBy() == 0); + CHECK(s.seq() == 9999); + CHECK(s.isLowSeqRevision()); + } +} + +// ══════════════════════════════════════════════════════════════════ +// parse() — invalid inputs (safety net) +// ══════════════════════════════════════════════════════════════════ + +TEST_CASE("ParsedSequenceID parse - invalid inputs", "[ParsedSequenceID]") { + SECTION("empty string") { mustFail(""); } + SECTION("non-numeric single") { mustFail("abc"); } + SECTION("leading alpha") { mustFail("abc:35"); } + SECTION("trailing alpha") { mustFail("100:abc"); } + SECTION("too many parts (4)") { mustFail("1:2:3:4"); } + SECTION("too many parts (5)") { mustFail("1:2:3:4:5"); } + SECTION("trailing colon two-part") { mustFail("100:"); } + SECTION("leading colon") { mustFail(":35"); } + SECTION("empty lowseq in three-part") { mustFail("::35"); } + SECTION("empty seq in three-part") { mustFail("20:100:"); } + SECTION("float value") { mustFail("1.5"); } + SECTION("negative value") { mustFail("-1"); } + SECTION("spaces") { mustFail("100 : 35"); } +} + +TEST_CASE("ParsedSequenceID format classification", "[ParsedSequenceID]") { + SECTION("simple is exclusively simple") { + auto s = mustParse("42"); + CHECK(s.isSimpleRevision()); + CHECK_FALSE(s.isTriggeredRevision()); + CHECK_FALSE(s.isLowSeqRevision()); + } + + SECTION("backfill is exclusively backfill") { + auto s = mustParse("100:35"); + CHECK_FALSE(s.isSimpleRevision()); + CHECK(s.isTriggeredRevision()); + CHECK_FALSE(s.isLowSeqRevision()); + } + + SECTION("lowseq with triggeredby is exclusively lowseq") { + auto s = mustParse("20:100:35"); + CHECK_FALSE(s.isSimpleRevision()); + CHECK_FALSE(s.isTriggeredRevision()); + CHECK(s.isLowSeqRevision()); + } + + SECTION("lowseq without triggeredby is still lowseq") { + auto s = mustParse("20::35"); + CHECK_FALSE(s.isSimpleRevision()); + CHECK_FALSE(s.isTriggeredRevision()); + CHECK(s.isLowSeqRevision()); + } +} + +TEST_CASE("ParsedSequenceID before - Simple vs Simple", "[ParsedSequenceID]") { + // Both plain "Seq": compare seq directly. + + SECTION("a < b → a.before(b) = true") { CHECK(mustParse("42").before(mustParse("100"))); } + + SECTION("a > b → a.before(b) = false") { CHECK_FALSE(mustParse("100").before(mustParse("42"))); } + + SECTION("a == b → a.before(b) = false (not strictly before)") { + CHECK_FALSE(mustParse("42").before(mustParse("42"))); + } + + SECTION("zero is before any positive") { CHECK(mustParse("0").before(mustParse("1"))); } +} + +TEST_CASE("ParsedSequenceID before - Backfill vs Backfill", "[ParsedSequenceID]") { + // Both "TriggeredBy:Seq": compare triggeredBy first, seq as tie-break. + + SECTION("different triggeredBy: smaller triggeredBy is before") { + // "100:35" before "200:55" + CHECK(mustParse("100:35").before(mustParse("200:55"))); + CHECK_FALSE(mustParse("200:55").before(mustParse("100:35"))); + } + + SECTION("same triggeredBy, different seq: smaller seq is before") { + // "100:35" before "100:55" + CHECK(mustParse("100:35").before(mustParse("100:55"))); + CHECK_FALSE(mustParse("100:55").before(mustParse("100:35"))); + } + + SECTION("identical → not before") { CHECK_FALSE(mustParse("100:35").before(mustParse("100:35"))); } + + SECTION("same triggeredBy, same seq → not before") { CHECK_FALSE(mustParse("50:50").before(mustParse("50:50"))); } +} + +TEST_CASE("ParsedSequenceID before - LowSeq vs LowSeq", "[ParsedSequenceID]") { + // Both "LowSeq:TB:Seq": compare lowSeq first, then inner pair as tie-break. + + SECTION("same lowSeq, backfill inner vs simple inner at boundary") { + // "20:100:35" vs "20::100": + // lowSeq equal (20); recurse → backfill(100:35).before(simple(100)) + // → triggeredBy(100) <= seq(100) → true + CHECK(mustParse("20:100:35").before(mustParse("20::100"))); + // Reverse: simple(100).before(backfill(100:35)) + // → seq(100) < triggeredBy(100) → false (strict <) + CHECK_FALSE(mustParse("20::100").before(mustParse("20:100:35"))); + } + + SECTION("different lowSeq: smaller lowSeq is before") { + // "20:100:35" before "25:100:40" + CHECK(mustParse("20:100:35").before(mustParse("25:100:40"))); + CHECK_FALSE(mustParse("25:100:40").before(mustParse("20:100:35"))); + } + + SECTION("same lowSeq, different triggeredBy: smaller triggeredBy is before") { + // "20:100:35" before "20:200:35" + CHECK(mustParse("20:100:35").before(mustParse("20:200:35"))); + } + + SECTION("same lowSeq, same triggeredBy, different seq: smaller seq is before") { + // "20:100:35" before "20:100:55" (tie-break into inner pair) + CHECK(mustParse("20:100:35").before(mustParse("20:100:55"))); + CHECK_FALSE(mustParse("20:100:55").before(mustParse("20:100:35"))); + } + + SECTION("identical → not before") { CHECK_FALSE(mustParse("20:100:35").before(mustParse("20:100:35"))); } + + SECTION("LowSeq without triggeredBy vs LowSeq with triggeredBy, same lowSeq") { + // "20::35" vs "20:100:55" → inner: simple(35) vs backfill(100:55) + // simple.before(backfill): 35 < 100 → true + CHECK(mustParse("20::35").before(mustParse("20:100:55"))); + } + + SECTION("LowSeq::Seq vs LowSeq::Seq, smaller lowSeq first") { + CHECK(mustParse("20::35").before(mustParse("25::40"))); + } +} + +// before() — Cross-format comparisons (the new enhancement) + +TEST_CASE("ParsedSequenceID before - Simple vs Backfill", "[ParsedSequenceID]") { + // Rule: simple.seq < backfill.triggeredBy (strict <) + // A plain seq N sorts BEFORE a backfill triggered at N, + // meaning "N" < "N:M" for any M. + + SECTION("simple seq < triggeredBy → simple is before") { + // "42" before "100:35": 42 < 100 → true + CHECK(mustParse("42").before(mustParse("100:35"))); + } + + SECTION("simple seq == triggeredBy → simple is NOT before (equal tier boundary)") { + // "100" vs "100:35": 100 < 100 → false + CHECK_FALSE(mustParse("100").before(mustParse("100:35"))); + } + + SECTION("simple seq > triggeredBy → simple is not before") { + // "150" vs "100:35": 150 < 100 → false + CHECK_FALSE(mustParse("150").before(mustParse("100:35"))); + } +} + +TEST_CASE("ParsedSequenceID before - Backfill vs Simple", "[ParsedSequenceID]") { + // Rule: backfill.triggeredBy <= simple.seq (<=) + // A backfill triggered at N comes at-or-before simple seq N. + + SECTION("triggeredBy < simple seq → backfill is before") { + // "100:35" before "150": 100 <= 150 → true + CHECK(mustParse("100:35").before(mustParse("150"))); + } + + SECTION("triggeredBy == simple seq → backfill IS before (<=)") { + // "100:35" before "100": 100 <= 100 → true + CHECK(mustParse("100:35").before(mustParse("100"))); + } + + SECTION("triggeredBy > simple seq → backfill is not before") { + // "100:35" vs "42": 100 <= 42 → false + CHECK_FALSE(mustParse("100:35").before(mustParse("42"))); + } +} + +TEST_CASE("ParsedSequenceID before - Simple vs LowSeq", "[ParsedSequenceID]") { + // Rule: simple.seq <= lowseq.lowSeq (<=) + + SECTION("simple seq < lowSeq → simple is before") { + // "15" before "20:100:35": 15 <= 20 → true + CHECK(mustParse("15").before(mustParse("20:100:35"))); + } + + SECTION("simple seq == lowSeq → simple IS before (<=)") { + // "20" before "20:100:35": 20 <= 20 → true + CHECK(mustParse("20").before(mustParse("20:100:35"))); + } + + SECTION("simple seq > lowSeq → simple is not before") { + // "25" vs "20:100:35": 25 <= 20 → false + CHECK_FALSE(mustParse("25").before(mustParse("20:100:35"))); + } + + SECTION("simple vs LowSeq::Seq") { + CHECK(mustParse("10").before(mustParse("20::35"))); + CHECK_FALSE(mustParse("30").before(mustParse("20::35"))); + } +} + +TEST_CASE("ParsedSequenceID before - LowSeq vs Simple", "[ParsedSequenceID]") { + // Rule: lowseq.lowSeq < simple.seq (strict <) + + SECTION("lowSeq < simple seq → lowseq is before") { + // "20:100:35" before "25": 20 < 25 → true + CHECK(mustParse("20:100:35").before(mustParse("25"))); + } + + SECTION("lowSeq == simple seq → lowseq is NOT before") { + // "20:100:35" vs "20": 20 < 20 → false + CHECK_FALSE(mustParse("20:100:35").before(mustParse("20"))); + } + + SECTION("lowSeq > simple seq → lowseq is not before") { + CHECK_FALSE(mustParse("20:100:35").before(mustParse("10"))); + } +} + +TEST_CASE("ParsedSequenceID before - Backfill vs LowSeq", "[ParsedSequenceID]") { + // Rule: backfill.triggeredBy <= lowseq.lowSeq (<=) + + SECTION("triggeredBy < lowSeq → backfill is before") { + // "100:35" before "20:200:55"? 100 <= 20 → false. Use "10:35" before "20:200:55" + CHECK(mustParse("10:35").before(mustParse("20:200:55"))); + } + + SECTION("triggeredBy == lowSeq → backfill IS before (<=)") { + // "20:35" before "20:100:55": 20 <= 20 → true + CHECK(mustParse("20:35").before(mustParse("20:100:55"))); + } + + SECTION("triggeredBy > lowSeq → backfill is not before") { + // "100:35" before "20:200:55": 100 <= 20 → false + CHECK_FALSE(mustParse("100:35").before(mustParse("20:200:55"))); + } +} + +TEST_CASE("ParsedSequenceID before - LowSeq vs Backfill", "[ParsedSequenceID]") { + // Rule: lowseq.lowSeq < backfill.triggeredBy (strict <) + + SECTION("lowSeq < triggeredBy → lowseq is before") { + // "20:100:35" before "100:35"? 20 < 100 → true + CHECK(mustParse("20:100:35").before(mustParse("100:35"))); + } + + SECTION("lowSeq == triggeredBy → lowseq is NOT before") { + // "20:100:35" vs "20:35": 20 < 20 → false + CHECK_FALSE(mustParse("20:100:35").before(mustParse("20:35"))); + } + + SECTION("lowSeq > triggeredBy → lowseq is not before") { + CHECK_FALSE(mustParse("50:100:35").before(mustParse("20:35"))); + } +} + +TEST_CASE("ParsedSequenceID checkpoint resolution scenarios", "[ParsedSequenceID]") { + SECTION("Scenario 1 - both int, local older → keep local") { + // local=42, remote=100 → remote.before(local) = 100<42 = false + CHECK_FALSE(mustParse("100").before(mustParse("42"))); + } + + SECTION("Scenario 2 - both int, remote older → roll back") { + // local=150, remote=42 → remote.before(local) = 42<150 = true + CHECK(mustParse("42").before(mustParse("150"))); + } + + SECTION("Scenario 3 - local int, remote backfill, local older → keep local") { + // local=42, remote=100:35 → remote.before(local): triggeredBy(100) <= seq(42) = false + CHECK_FALSE(mustParse("100:35").before(mustParse("42"))); + } + + SECTION("Scenario 4 - local int, remote backfill, remote older → roll back") { + // local=150, remote=100:35 → remote.before(local): 100 <= 150 = true + CHECK(mustParse("100:35").before(mustParse("150"))); + } + + SECTION("Scenario 5 - same triggeredBy, remote has lower seq → roll back") { + // local=100:55, remote=100:35 → remote.before(local): TB equal, 35<55 = true + CHECK(mustParse("100:35").before(mustParse("100:55"))); + } + + SECTION("Scenario 6 - same triggeredBy, remote has higher seq → keep local") { + // local=100:35, remote=100:55 → remote.before(local): TB equal, 55<35 = false + CHECK_FALSE(mustParse("100:55").before(mustParse("100:35"))); + } + + SECTION("Scenario 7 - three-part: different lowSeq, remote lower → roll back") { + // local=25:100:40, remote=20:100:35 → remote.before(local): 20<25 = true + CHECK(mustParse("20:100:35").before(mustParse("25:100:40"))); + } + + SECTION("Scenario 8 - three-part: same lowSeq tie-break, remote lower seq → roll back") { + // local=20:100:55, remote=20:100:35 → tie on lowSeq, 35<55 = true → roll back + CHECK(mustParse("20:100:35").before(mustParse("20:100:55"))); + } + + SECTION("Scenario 9 - LowSeq::Seq format: remote lower → roll back") { + // local=25::40, remote=20::35 → 20<25 = true → roll back + CHECK(mustParse("20::35").before(mustParse("25::40"))); + } + + SECTION("Scenario 10 - exact match → not before (fast-path, no rollback)") { + CHECK_FALSE(mustParse("100:35").before(mustParse("100:35"))); + } +} + +TEST_CASE("ParsedSequenceID before - asymmetry at tier boundary", "[ParsedSequenceID]") { + // A backfill triggered at N is considered at-or-after simple sequence N. + // Verify the < vs <= asymmetry is correct at the exact boundary. + + SECTION("simple(N) is NOT before backfill(N:M) — equal boundary") { + // "100" vs "100:35": simple 100 < triggeredBy 100 → false (strict <) + CHECK_FALSE(mustParse("100").before(mustParse("100:35"))); + } + + SECTION("backfill(N:M) IS before simple(N) — equal boundary") { + // "100:35" vs "100": triggeredBy 100 <= seq 100 → true (<=) + CHECK(mustParse("100:35").before(mustParse("100"))); + } + + SECTION("simple(N) IS before backfill(N+1:M)") { + // "100" before "101:35": 100 < 101 → true + CHECK(mustParse("100").before(mustParse("101:35"))); + } +} From 253824de94e2275026aa373c94c604d95ae27c44 Mon Sep 17 00:00:00 2001 From: Jianmin Zhao Date: Thu, 4 Jun 2026 19:14:42 -0700 Subject: [PATCH 2/2] update tools submodule to release/3.4. --- tools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools b/tools index 93274f9fb0..a71a5a9f2b 160000 --- a/tools +++ b/tools @@ -1 +1 @@ -Subproject commit 93274f9fb0936af0b754a408154a2a24d2dd0041 +Subproject commit a71a5a9f2bb808cb2c45a82fe39fd5d4c43e5597