Skip to content

Commit 499e8b4

Browse files
committed
Merge remote-tracking branch 'origin/main' into CBG-5509
2 parents 577f82a + fefbba4 commit 499e8b4

65 files changed

Lines changed: 2942 additions & 918 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ env:
3535
ACTIONS_CHECKOUT_VERSION: &actions_checkout_version actions/checkout@v6
3636
ACTIONS_SETUP_GO_VERSION: &actions_setup_go_version actions/setup-go@v6
3737
ACTIONS_RUFF_VERSION: &actions_ruff_version astral-sh/ruff-action@v4.0.0
38-
GO_VERSION: &go_version 1.26.4
38+
GO_VERSION: &go_version 1.26.5
3939
GOTESTSUM_VERSION: 1.13.0
4040
GO_TEST_ANNOTATIONS_VERSION: &go_test_annotations_version guyarb/golang-test-annotations@v0.9.0
4141

.github/workflows/service.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ jobs:
4747
# build sync gateway since the executable is needed for service installation
4848
- uses: actions/setup-go@v6
4949
with:
50-
go-version: 1.26.4
50+
go-version: 1.26.5
5151
- name: "Build Sync Gateway"
5252
run: mkdir -p ./bin && go build -o ./bin ./...
5353
- name: "Run test scripts"

base/collection_rangescan.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,28 @@ func (it *gocbScanResultIterator) Next(_ context.Context) *sgbucket.ScanResultIt
8484
return result
8585
}
8686

87+
func (it *gocbScanResultIterator) Err() error {
88+
if it.err != nil {
89+
return it.err
90+
}
91+
return it.result.Err()
92+
}
93+
8794
func (it *gocbScanResultIterator) Close(_ context.Context) error {
88-
closeErr := it.result.Close()
89-
if it.err == nil {
95+
if it.err != nil {
96+
// A real error is already recorded (e.g. a content-decode failure);
97+
// release the stream but keep the first error.
98+
_ = it.result.Close()
99+
return it.err
100+
}
101+
// gocb's Close returns the first recorded stream error, or nil after
102+
// cancelling a clean scan.
103+
if closeErr := it.result.Close(); closeErr != nil {
90104
it.err = closeErr
105+
return it.err
91106
}
92-
return it.err
107+
// Clean end-of-stream: record ErrScanCancelled so a later Err reports it
108+
// (matching Rosmar) rather than leaking gocb's internal ErrRequestCanceled.
109+
it.err = sgbucket.ErrScanCancelled
110+
return nil
93111
}

base/collection_rangescan_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ func collectScanIDs(t testing.TB, ctx context.Context, rss sgbucket.RangeScanSto
3333
for item := iter.Next(ctx); item != nil; item = iter.Next(ctx) {
3434
ids = append(ids, item.ID)
3535
}
36+
// A clean drain must leave no error, inspectable without closing the iterator.
37+
assert.NoError(t, iter.Err())
3638
sort.Strings(ids)
3739
return ids
3840
}
@@ -82,6 +84,7 @@ func runRangeScanSubtests(t *testing.T, ctx context.Context, writeDS sgbucket.Da
8284
assert.NotNil(t, item.Body, "Expected body for key %s", item.ID)
8385
assert.NotZero(t, item.Cas, "Expected non-zero CAS for key %s", item.ID)
8486
}
87+
assert.NoError(t, iter.Err())
8588
sort.Strings(ids)
8689
require.Equal(t, allDocIDs, ids)
8790
})
@@ -105,6 +108,7 @@ func runRangeScanSubtests(t *testing.T, ctx context.Context, writeDS sgbucket.Da
105108
ids = append(ids, item.ID)
106109
assert.Nil(t, item.Body, "Expected nil body for IDsOnly scan, key %s", item.ID)
107110
}
111+
assert.NoError(t, iter.Err())
108112
sort.Strings(ids)
109113
require.Equal(t, allDocIDs, ids)
110114
})
@@ -114,6 +118,21 @@ func runRangeScanSubtests(t *testing.T, ctx context.Context, writeDS sgbucket.Da
114118
assert.Empty(t, ids)
115119
})
116120

121+
t.Run("CloseCancellation", func(t *testing.T) {
122+
iter, err := scanStore.Scan(ctx, sgbucket.NewRangeScanForPrefix("doc_"), sgbucket.ScanOptions{IDsOnly: true})
123+
require.NoError(t, err)
124+
for item := iter.Next(ctx); item != nil; item = iter.Next(ctx) {
125+
require.NotEmpty(t, item.ID)
126+
}
127+
// Clean end-of-stream: no error is reported until Close is called.
128+
require.NoError(t, iter.Err())
129+
// A clean Close returns nil...
130+
require.NoError(t, iter.Close(ctx))
131+
// ...but records a cancellation, surfaced by a later Err (gocb parity),
132+
// identically on the Rosmar and gocb-backed implementations.
133+
require.ErrorIs(t, iter.Err(), sgbucket.ErrScanCancelled)
134+
})
135+
117136
t.Run("PrefixScan", func(t *testing.T) {
118137
ids := collectScanIDs(t, ctx, scanStore, sgbucket.NewRangeScanForPrefix("doc_c"), sgbucket.ScanOptions{})
119138
require.Equal(t, []string{"doc_c"}, ids)

base/dcp_client_test.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -937,8 +937,7 @@ func TestDCPCheckpointCleanup(t *testing.T) {
937937
foundDocs := make(chan string, len(dataStores))
938938
callback := func(event sgbucket.FeedEvent) bool {
939939
if strings.HasSuffix(string(event.Key), "_doc") {
940-
mutationCount.Add(1)
941-
if mutationCount.Load() == uint64(len(dataStores)) {
940+
if mutationCount.Add(1) == uint64(len(dataStores)) {
942941
close(foundDocs)
943942
}
944943
}

base/stats.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,12 +1263,24 @@ func (s *SgwFloatStat) Add(delta float64) {
12631263
}
12641264
}
12651265

1266+
// formatValue renders the stat's current value as a JSON-safe string. JSON has no representation for
1267+
// non-finite floats (+Inf/-Inf/NaN); emitting them as raw bytes ("+Inf" etc.) produces invalid JSON
1268+
// that aborts the entire stats marshal (and the expvar output), so a single degenerate stat could
1269+
// otherwise corrupt the whole serialized blob. Fall back to "0" in that case.
1270+
func (s *SgwFloatStat) formatValue() string {
1271+
val := math.Float64frombits(atomic.LoadUint64(&s.Val))
1272+
if math.IsInf(val, 0) || math.IsNaN(val) {
1273+
return "0"
1274+
}
1275+
return strconv.FormatFloat(val, 'g', -1, 64)
1276+
}
1277+
12661278
func (s *SgwFloatStat) MarshalJSON() ([]byte, error) {
1267-
return []byte(strconv.FormatFloat(math.Float64frombits(atomic.LoadUint64(&s.Val)), 'g', -1, 64)), nil
1279+
return []byte(s.formatValue()), nil
12681280
}
12691281

12701282
func (s *SgwFloatStat) String() string {
1271-
return strconv.FormatFloat(math.Float64frombits(atomic.LoadUint64(&s.Val)), 'g', -1, 64)
1283+
return s.formatValue()
12721284
}
12731285

12741286
func (s *SgwFloatStat) Value() float64 {

base/stats_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ package base
1212

1313
import (
1414
"expvar"
15+
"math"
1516
"testing"
1617

1718
"github.com/couchbase/sync_gateway/testing/require"
@@ -162,3 +163,39 @@ func initExpvarBaseEquivalent() *expvar.Map {
162163

163164
return expvarMap
164165
}
166+
167+
// TestSgwFloatStatMarshalNonFinite is a regression test for CBG-3658: a non-finite float value
168+
// (e.g. +Inf produced by a divide-by-zero in the process CPU percentage calculation) must not
169+
// corrupt stats serialization. Previously MarshalJSON emitted the raw bytes "+Inf"/"NaN", which the
170+
// JSON encoder rejected ("invalid character '+' looking for beginning of value"), collapsing the
171+
// entire SgwStats blob to "null".
172+
func TestSgwFloatStatMarshalNonFinite(t *testing.T) {
173+
for _, test := range []struct {
174+
name string
175+
value float64
176+
}{
177+
{"positive_infinity", math.Inf(1)},
178+
{"negative_infinity", math.Inf(-1)},
179+
{"nan", math.NaN()},
180+
} {
181+
t.Run(test.name, func(t *testing.T) {
182+
stat := &SgwFloatStat{}
183+
stat.Set(test.value)
184+
185+
// Marshal the stat inside a larger structure: this is the exact path that failed via
186+
// SgwStats.String -> JSONMarshalCanonical.
187+
marshalled, err := JSONMarshalCanonical(map[string]*SgwFloatStat{"stat": stat})
188+
require.NoError(t, err)
189+
190+
// The output must be valid, parseable JSON, and non-finite values must fall back to 0.
191+
var roundTripped map[string]float64
192+
require.NoError(t, JSONUnmarshal(marshalled, &roundTripped))
193+
require.Equal(t, 0.0, roundTripped["stat"])
194+
195+
// String() satisfies expvar.Var, which likewise requires a valid JSON value.
196+
var viaString float64
197+
require.NoError(t, JSONUnmarshal([]byte(stat.String()), &viaString))
198+
require.Equal(t, 0.0, viaString)
199+
})
200+
}
201+
}

base/util_testing.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -794,12 +794,12 @@ func AssertWaitForStat(t testing.TB, getStatFunc func() int64, expected int64) (
794794
}
795795

796796
// RequireWaitForStat will retry for up to 20 seconds until the result of getStatFunc is equal to the expected value.
797-
func RequireWaitForStat(t testing.TB, getStatFunc func() int64, expected int64) (val int64) {
797+
func RequireWaitForStat(t testing.TB, getStatFunc func() int64, expected int64, msgAndArgs ...any) (val int64) {
798798
t.Helper()
799799
require.NotNil(t, getStatFunc, "Function for RequireWaitForStat cannot be nil")
800800
require.EventuallyWithT(t, func(c *assert.CollectT) {
801801
val = getStatFunc()
802-
assert.Equal(c, expected, val)
802+
assert.Equal(c, expected, val, msgAndArgs...)
803803
}, 20*time.Second, 100*time.Millisecond)
804804
return val
805805
}

db/attachment_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"net/http"
2020
"strconv"
2121
"strings"
22+
"sync"
2223
"testing"
2324

2425
"github.com/couchbase/sync_gateway/base"
@@ -1521,3 +1522,150 @@ func TestLargeAttachments(t *testing.T) {
15211522
require.ErrorAs(t, err, &httpErr, "Created doc with huge attachment")
15221523
require.Equal(t, http.StatusRequestEntityTooLarge, httpErr.Status)
15231524
}
1525+
1526+
// attachmentMigrationCASRetrySetup creates a doc with an attachment, then moves the attachment
1527+
// back to AttachmentsPre4dot0 to simulate a document that has not yet been through the background
1528+
// migration.
1529+
func attachmentMigrationCASRetrySetup(t *testing.T, ctx context.Context, collection *DatabaseCollectionWithUser, docID string) {
1530+
t.Helper()
1531+
_, _, err := collection.Put(ctx, docID, Body{
1532+
"test": "doc",
1533+
BodyAttachments: AttachmentsMeta{
1534+
"camera.txt": map[string]any{"data": "Q2Fub24gRU9TIDVEIE1hcmsgSVY="},
1535+
},
1536+
})
1537+
require.NoError(t, err)
1538+
1539+
ds := collection.GetCollectionDatastore()
1540+
xattrs, cas, err := ds.GetXattrs(ctx, docID, []string{base.SyncXattrName, base.GlobalXattrName})
1541+
require.NoError(t, err)
1542+
var bucketSyncData SyncData
1543+
require.NoError(t, base.JSONUnmarshal(xattrs[base.SyncXattrName], &bucketSyncData))
1544+
var globalXattr GlobalSyncData
1545+
require.NoError(t, base.JSONUnmarshal(xattrs[base.GlobalXattrName], &globalXattr))
1546+
1547+
bucketSyncData.AttachmentsPre4dot0 = globalXattr.Attachments
1548+
syncBytes := base.MustJSONMarshal(t, bucketSyncData)
1549+
_, err = ds.WriteWithXattrs(ctx, docID, 0, cas, []byte(`{"test":"doc"}`),
1550+
map[string][]byte{base.SyncXattrName: syncBytes}, []string{base.GlobalXattrName}, nil)
1551+
require.NoError(t, err)
1552+
}
1553+
1554+
// attachmentMigrationCASRetryCallback returns an UpdateCallback that calls MigrateAttachmentMetadata
1555+
// exactly once for the given docID, changing the document CAS and forcing WriteUpdateWithXattrs to retry.
1556+
func attachmentMigrationCASRetryCallback(t *testing.T, ctx context.Context, collection *DatabaseCollectionWithUser, docID string) func(string) {
1557+
t.Helper()
1558+
var once sync.Once
1559+
return func(key string) {
1560+
if key != docID {
1561+
return
1562+
}
1563+
once.Do(func() {
1564+
ds := collection.GetCollectionDatastore()
1565+
xattrs, cas, err := ds.GetXattrs(ctx, docID, []string{base.SyncXattrName})
1566+
require.NoError(t, err)
1567+
var syncData SyncData
1568+
require.NoError(t, base.JSONUnmarshal(xattrs[base.SyncXattrName], &syncData))
1569+
require.NoError(t, collection.MigrateAttachmentMetadata(ctx, docID, cas, &syncData))
1570+
})
1571+
}
1572+
}
1573+
1574+
const attachmentMigrationCASRetryDigest = "sha1-VoSNiNQGHE1HirIS5HMxj6CrlHI="
1575+
const attachmentMigrationCASRetryData = "Q2Fub24gRU9TIDVEIE1hcmsgSVY="
1576+
1577+
var attachmentMigrationCASRetryExpected = AttachmentMap{
1578+
"camera.txt": {
1579+
Digest: attachmentMigrationCASRetryDigest,
1580+
Length: 20,
1581+
Revpos: 2,
1582+
Version: 2,
1583+
Stub: true,
1584+
},
1585+
}
1586+
1587+
// TestAttachmentMigrationCASRetryOnUpdate verifies that PutExistingCurrentVersion and
1588+
// PutExistingRevWithConflictResolution both preserve revpos=2 when a CAS retry is forced
1589+
// by concurrent attachment migration. The maps.Clone reset in each function is the guard:
1590+
// without it, the retry sees a stub mutated by the first invocation and applies a parent
1591+
// lookup that returns the migrated revpos=1.
1592+
func TestAttachmentMigrationCASRetryOnUpdate(t *testing.T) {
1593+
db, ctx := setupTestLeakyDBWithCacheOptions(t, DefaultCacheOptions(), base.LeakyBucketConfig{})
1594+
defer db.Close(ctx)
1595+
collection, ctx := GetSingleDatabaseCollectionWithUser(ctx, t, db)
1596+
ds := collection.GetCollectionDatastore()
1597+
1598+
leakyDS, ok := base.AsLeakyDataStore(ds)
1599+
require.True(t, ok)
1600+
1601+
// putCurrentVersion calls PutExistingCurrentVersion (HLV/versionVector BLIP path).
1602+
putCurrentVersion := func(t *testing.T, docID string) {
1603+
existingDoc, err := collection.GetDocument(ctx, docID, DocUnmarshalSync)
1604+
require.NoError(t, err)
1605+
incomingHLV := &HybridLogicalVector{
1606+
SourceID: "cbl1",
1607+
Version: existingDoc.HLV.Version + 1,
1608+
PreviousVersions: HLVVersions{existingDoc.HLV.SourceID: existingDoc.HLV.Version},
1609+
}
1610+
// newDoc carries the attachment as inline data, simulating what ForEachStubAttachment
1611+
// places in newDoc.Attachments() after downloading the data from the CBL client.
1612+
newDoc := &Document{ID: docID}
1613+
newDoc.SetAttachments(AttachmentsMeta{
1614+
"camera.txt": map[string]any{"data": attachmentMigrationCASRetryData},
1615+
})
1616+
newDoc.UpdateBody(Body{"some": "update"})
1617+
_, _, _, err = collection.PutExistingCurrentVersion(ctx, PutDocOptions{
1618+
NewDoc: newDoc,
1619+
NewDocHLV: incomingHLV,
1620+
})
1621+
require.NoError(t, err)
1622+
}
1623+
1624+
// putExistingRev calls PutExistingRevWithConflictResolution (rev-tree BLIP path).
1625+
putExistingRev := func(t *testing.T, docID string) {
1626+
existingDoc, err := collection.GetDocument(ctx, docID, DocUnmarshalSync)
1627+
require.NoError(t, err)
1628+
existingRevID := existingDoc.GetRevTreeID()
1629+
incomingRevID := "2-attachmentmigrationcasretrytest"
1630+
newDoc := &Document{ID: docID, RevID: incomingRevID}
1631+
newDoc.SetAttachments(AttachmentsMeta{
1632+
"camera.txt": map[string]any{"data": attachmentMigrationCASRetryData},
1633+
})
1634+
newDoc.UpdateBody(Body{"some": "update"})
1635+
_, _, err = collection.PutExistingRevWithConflictResolution(ctx, PutDocOptions{
1636+
NewDoc: newDoc,
1637+
RevTreeHistory: []string{incomingRevID, existingRevID},
1638+
DocUpdateEvent: ExistingVersionWithUpdateToHLV,
1639+
})
1640+
require.NoError(t, err)
1641+
}
1642+
1643+
testCases := []struct {
1644+
name string
1645+
casFailure bool
1646+
update func(t *testing.T, docID string)
1647+
}{
1648+
{name: "PutExistingCurrentVersion no CAS failure", casFailure: false, update: putCurrentVersion},
1649+
{name: "PutExistingCurrentVersion CAS failure", casFailure: true, update: putCurrentVersion},
1650+
{name: "PutExistingRevWithConflictResolution no CAS failure", casFailure: false, update: putExistingRev},
1651+
{name: "PutExistingRevWithConflictResolution CAS failure", casFailure: true, update: putExistingRev},
1652+
}
1653+
1654+
for _, tc := range testCases {
1655+
t.Run(tc.name, func(t *testing.T) {
1656+
docID := "doc_" + strings.ReplaceAll(tc.name, " ", "_")
1657+
1658+
attachmentMigrationCASRetrySetup(t, ctx, collection, docID)
1659+
1660+
if tc.casFailure {
1661+
leakyDS.SetUpdateCallback(attachmentMigrationCASRetryCallback(t, ctx, collection, docID))
1662+
defer leakyDS.SetUpdateCallback(nil)
1663+
}
1664+
1665+
tc.update(t, docID)
1666+
1667+
require.Empty(t, GetRawSyncXattr(t, ds, docID).AttachmentsPre4dot0)
1668+
require.Equal(t, attachmentMigrationCASRetryExpected, GetRawGlobalSyncAttachments(t, ds, docID))
1669+
})
1670+
}
1671+
}

0 commit comments

Comments
 (0)