Skip to content

Commit 770a635

Browse files
authored
CBG-5547 handle topologytest conflicts for matching timestamps (#8424)
1 parent 8e9d950 commit 770a635

8 files changed

Lines changed: 346 additions & 48 deletions

File tree

db/utilities_hlv_testing.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,3 +421,8 @@ func AlterHLVForTest(t *testing.T, ctx context.Context, dataStore base.DataStore
421421
func (db *DatabaseContext) GetHLCValueForTest(floorValue uint64) uint64 {
422422
return db.hlc.Now(floorValue)
423423
}
424+
425+
// SetHLCClockForTest overrides the database's HLC clock function, for deterministic HLV version generation in tests.
426+
func (db *DatabaseContext) SetHLCClockForTest(clockFn func() uint64) {
427+
db.hlc.SetClockForTest(clockFn)
428+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ require (
1717
github.com/couchbasedeps/fast-skiplist v0.0.0-20250722125747-e0dd031fe2ac
1818
github.com/couchbaselabs/go-fleecedelta v0.0.0-20220909152808-6d09efa7a338
1919
github.com/couchbaselabs/gocbconnstr v1.0.5
20-
github.com/couchbaselabs/rosmar v0.0.0-20260706131717-11d9680fd14d
20+
github.com/couchbaselabs/rosmar v0.0.0-20260706134933-2f0dabde975c
2121
github.com/elastic/gosigar v0.14.4
2222
github.com/felixge/fgprof v0.9.5
2323
github.com/go-jose/go-jose/v4 v4.1.4

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ github.com/couchbaselabs/gocbconnstr v1.0.5 h1:e0JokB5qbcz7rfnxEhNRTKz8q1svoRvDo
6868
github.com/couchbaselabs/gocbconnstr v1.0.5/go.mod h1:KV3fnIKMi8/AzX0O9zOrO9rofEqrRF1d2rG7qqjxC7o=
6969
github.com/couchbaselabs/gocbconnstr/v2 v2.0.0 h1:HU9DlAYYWR69jQnLN6cpg0fh0hxW/8d5hnglCXXjW78=
7070
github.com/couchbaselabs/gocbconnstr/v2 v2.0.0/go.mod h1:o7T431UOfFVHDNvMBUmUxpHnhivwv7BziUao/nMl81E=
71-
github.com/couchbaselabs/rosmar v0.0.0-20260706131717-11d9680fd14d h1:aO+wy8QnEj2XsLI5jk01zXvxSjVbOMbpWeLzTtWdkms=
72-
github.com/couchbaselabs/rosmar v0.0.0-20260706131717-11d9680fd14d/go.mod h1:9tmSd+tF1TDFlrlbUB8Q1XKxOoZyJu97wIQ5pTN6B20=
71+
github.com/couchbaselabs/rosmar v0.0.0-20260706134933-2f0dabde975c h1:rTggaJZ5WvEizK3YV6B+wFvqlC3pUxHRCqH4oYzff9M=
72+
github.com/couchbaselabs/rosmar v0.0.0-20260706134933-2f0dabde975c/go.mod h1:9tmSd+tF1TDFlrlbUB8Q1XKxOoZyJu97wIQ5pTN6B20=
7373
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7474
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7575
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=

rest/replicatortest/replicator_conflict_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"testing"
1717
"time"
1818

19+
sgbucket "github.com/couchbase/sg-bucket"
1920
"github.com/couchbase/sync_gateway/base"
2021
"github.com/couchbase/sync_gateway/db"
2122
"github.com/couchbase/sync_gateway/rest"
@@ -2212,6 +2213,78 @@ func TestActiveReplicatorV4DefaultResolverWithTombstoneRemote(t *testing.T) {
22122213
})
22132214
}
22142215

2216+
// TestDefaultConflictResolverMatchingTimestamps verifies that default (HLV-aware) conflict resolution for different
2217+
// source IDs but matching versions is local wins. See CBG-5570 for changing this.
2218+
func TestDefaultConflictResolverMatchingTimestamps(t *testing.T) {
2219+
base.LongRunningTest(t)
2220+
2221+
base.RequireNumTestBuckets(t, 2)
2222+
2223+
// Matching-timestamps ties are an HLV/V4-only scenario (V3/revTree conflicts are resolved by generation and
2224+
// digest, not by timestamp), so only the V4 subtest is run.
2225+
sgrRunner := rest.NewSGRTestRunner(t)
2226+
sgrRunner.RunSubprotocolV4(func(t *testing.T) {
2227+
activeRT, passiveRT, remoteDBURLString := sgrRunner.SetupSGRPeers(t)
2228+
remoteURL, err := url.Parse(remoteDBURLString)
2229+
require.NoError(t, err)
2230+
activeRTCtx := activeRT.Context()
2231+
2232+
fixedTime := sgbucket.HLCWallClock()
2233+
tieClock := func() uint64 { return fixedTime }
2234+
activeRT.GetDatabase().SetHLCClockForTest(tieClock)
2235+
passiveRT.GetDatabase().SetHLCClockForTest(tieClock)
2236+
2237+
docID := "doc"
2238+
activeRTVersion := createDoc(activeRT, docID, "local")
2239+
passiveRTVersion := createDoc(passiveRT, docID, "remote")
2240+
require.Equal(t, activeRTVersion.CV.Value, passiveRTVersion.CV.Value, "test requires activeRT and passiveRT to generate matching HLC timestamps")
2241+
require.NotEqual(t, activeRTVersion.CV.SourceID, passiveRTVersion.CV.SourceID)
2242+
2243+
replicationStats := dbReplicatorStats(t)
2244+
config := db.ActiveReplicatorConfig{
2245+
ID: t.Name(),
2246+
Direction: db.ActiveReplicatorTypePushAndPull,
2247+
RemoteDBURL: remoteURL,
2248+
ActiveDB: &db.Database{
2249+
DatabaseContext: activeRT.GetDatabase(),
2250+
},
2251+
Continuous: true,
2252+
ConflictResolverFuncForHLV: db.DefaultLWWConflictResolutionType,
2253+
ReplicationStatsMap: replicationStats,
2254+
CollectionsEnabled: !activeRT.GetDatabase().OnlyDefaultCollection(),
2255+
SupportedBLIPProtocols: sgrRunner.SupportedSubprotocols,
2256+
}
2257+
2258+
// Create active replicator and start replication.
2259+
ar, err := db.NewActiveReplicator(activeRTCtx, &config)
2260+
require.NoError(t, err)
2261+
require.NoError(t, ar.Start(activeRTCtx), "Error starting replication")
2262+
defer func() { require.NoError(t, ar.Stop(), "Error stopping replication") }()
2263+
2264+
requireConflictResolvedLocally := func(t *testing.T, rt *rest.RestTester) {
2265+
t.Helper()
2266+
collection, ctx := rt.GetSingleTestDatabaseCollectionWithUser()
2267+
require.EventuallyWithT(t, func(c *assert.CollectT) {
2268+
doc, err := collection.GetDocument(ctx, docID, db.DocUnmarshalAll)
2269+
if !assert.NoError(c, err) {
2270+
return
2271+
}
2272+
if !assert.NotNil(c, doc.HLV) {
2273+
return
2274+
}
2275+
assert.Equal(c, activeRTVersion.CV.SourceID, doc.HLV.SourceID, "expected local (activeRT) source to win the tie")
2276+
assert.Equal(c, "local", doc.Body(base.TestCtx(t))["key"], "expected local (activeRT) body to win the tie")
2277+
}, 10*time.Second, 100*time.Millisecond)
2278+
}
2279+
requireConflictResolvedLocally(t, activeRT)
2280+
requireConflictResolvedLocally(t, passiveRT)
2281+
2282+
base.RequireWaitForStat(t, replicationStats.ConflictResolvedLocalCount.Value, 1)
2283+
base.RequireWaitForStat(t, replicationStats.ConflictResolvedMergedCount.Value, 0)
2284+
base.RequireWaitForStat(t, replicationStats.ConflictResolvedRemoteCount.Value, 0)
2285+
})
2286+
}
2287+
22152288
// getRevTreeID create a revtree ID for a new revision that is a child of the parentRevID for a given body.
22162289
func getRevTreeID(t *testing.T, parentRevID string, body []byte) string {
22172290
prevGeneration, _ := db.ParseRevID(t.Context(), parentRevID)

rest/utilities_testing_blip_client.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,8 +358,9 @@ func (btcc *BlipTesterCollectionClient) _resolveConflict(incomingHLV *db.HybridL
358358
func (btcc *BlipTesterCollectionClient) _resolveConflictLWW(incomingHLV *db.HybridLogicalVector, incomingBody []byte, latestLocalRev *clientDocRev) (body []byte, hlv db.HybridLogicalVector) {
359359
latestLocalHLV := latestLocalRev.HLV
360360
updatedHLV := latestLocalRev.HLV.Copy()
361-
// resolve conflict in favor of remote document
362-
if incomingHLV.Version > latestLocalHLV.Version {
361+
// Resolve conflict in favor of the remote document, including on a tie: this mirrors real Couchbase Lite's
362+
// LWW conflict resolution, which favors the incoming (remote) revision rather than the existing local one.
363+
if incomingHLV.Version >= latestLocalHLV.Version {
363364
updatedHLV.UpdateWithIncomingHLV(incomingHLV)
364365
return incomingBody, *updatedHLV
365366
}
@@ -1053,6 +1054,11 @@ func (btc *BlipTesterClient) TB() testing.TB {
10531054
return btc.rt.TB()
10541055
}
10551056

1057+
// SetHLCClockForTest overrides the client's HLC clock function, for deterministic HLV version generation in tests.
1058+
func (btc *BlipTesterClient) SetHLCClockForTest(clockFn func() uint64) {
1059+
btc.hlc.SetClockForTest(clockFn)
1060+
}
1061+
10561062
// Close shuts down all the clients and clears all messages stored.
10571063
func (btc *BlipTesterClient) Close() {
10581064
for _, collectionClient := range btc.collectionClients {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright 2026-Present Couchbase, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License included
4+
// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified
5+
// in that file, in accordance with the Business Source License, use of this
6+
// software will be governed by the Apache License, Version 2.0, included in
7+
// the file licenses/APL2.txt.
8+
9+
package topologytest
10+
11+
import (
12+
"testing"
13+
14+
sgbucket "github.com/couchbase/sg-bucket"
15+
"github.com/couchbase/sync_gateway/base"
16+
"github.com/couchbase/sync_gateway/testing/require"
17+
"github.com/couchbaselabs/rosmar"
18+
)
19+
20+
// This file supports the forceClockTie dimension of the TestMultiActorConflict* tests in
21+
// multi_actor_conflict_test.go, to allow conflicting documents to be generated with matching timestamps.
22+
//
23+
// There are several independent clocks:
24+
//
25+
// - Sync Gateway, used for REST writes
26+
// - Couchbase Lite mock client
27+
// - Couchbase Server
28+
// - Couchbase Server has independent clocks per bucket
29+
// - Rosmar shares one clock across all buckets (implementation detail)
30+
//
31+
// controllableClockPeerCount returns the number of peers in the spec whose HLC clock forceHLCClockTieForTest can
32+
// override. Used to skip the ClockTie subtest before setupTests, since skipping a topology mid-test (after
33+
// peers and replications are configured but before they're started) trips replication teardown, which assumes
34+
// StartReplications was called.
35+
func controllableClockPeerCount(topologySpec TopologySpecification) (count int) {
36+
for _, opts := range topologySpec.peers {
37+
switch opts.Type {
38+
case PeerTypeSyncGateway, PeerTypeCouchbaseLite, PeerTypeCouchbaseLiteV3:
39+
count++
40+
case PeerTypeCouchbaseServer:
41+
// A Couchbase Server peer's clock is only controllable when it's backed by rosmar - see
42+
// forceHLCClockTieForTest.
43+
if base.UnitTestUrlIsWalrus() {
44+
count++
45+
}
46+
} // exhaustive:enforce
47+
}
48+
return count
49+
}
50+
51+
// forceHLCClockTieForTest pins the HLC clocks of every controllable peer in the topology to the same fixed
52+
// timestamp, forcing each of their next brand-new-document writes to generate an identical HLV current-version
53+
// Value. Returns the number of peers whose clock was overridden.
54+
//
55+
// Unlike a Sync Gateway/Couchbase Lite peer's clock (a fresh instance per peer, discarded with the test), rosmar's
56+
// clock is a single instance shared by every rosmar bucket in the whole test process. Freezing it here without
57+
// restoring it would leak into every later test in the process (CAS values frozen in the past, or colliding with
58+
// already-written docs) - so t is required purely to register a t.Cleanup that restores the real clock once this
59+
// specific (sub)test finishes, before any sibling or later test runs.
60+
func forceHLCClockTieForTest(t testing.TB, topology Topology) (forced int) {
61+
ctx := base.TestCtx(t)
62+
fixedTime := sgbucket.HLCWallClock()
63+
tieClock := func() uint64 { return fixedTime }
64+
rosmarClockSet := false
65+
for name, peer := range topology.peers.NonImportSortedPeers() {
66+
switch peer.Type() {
67+
case PeerTypeSyncGateway:
68+
p, ok := peer.(*SyncGatewayPeer)
69+
require.True(t, ok, "peer %s (%T) is a PeerTypeSyncGateway but not a *SyncGatewayPeer", name, peer)
70+
p.rt.GetDatabase().SetHLCClockForTest(tieClock)
71+
forced++
72+
case PeerTypeCouchbaseLite, PeerTypeCouchbaseLiteV3:
73+
p, ok := peer.(*CouchbaseLiteMockPeer)
74+
require.True(t, ok, "peer %s (%T) is a %s but not a *CouchbaseLiteMockPeer", name, peer, peer.Type())
75+
p.getSingleSGBlipClient().btc.SetHLCClockForTest(tieClock)
76+
forced++
77+
case PeerTypeCouchbaseServer:
78+
if !base.UnitTestUrlIsWalrus() {
79+
base.InfofCtx(ctx, base.KeySGTest, "forceHLCClockTieForTest: not overriding clock for peer %s (%T), Couchbase Server-backed peers can only have their clock forced when backed by rosmar", name, peer)
80+
continue
81+
}
82+
if !rosmarClockSet {
83+
rosmar.SetClockForTest(tieClock)
84+
t.Cleanup(func() { rosmar.SetClockForTest(sgbucket.HLCWallClock) })
85+
rosmarClockSet = true
86+
}
87+
forced++
88+
} // exhaustive:enforce
89+
}
90+
return forced
91+
}

0 commit comments

Comments
 (0)