From 089bdbafa82d9357951732277ec2583270edfa56 Mon Sep 17 00:00:00 2001 From: Ben Brooks Date: Thu, 26 Feb 2026 16:53:55 +0000 Subject: [PATCH 1/9] Add gocb range scan wrapper and dual-backend tests Wrap gocb.Collection.Scan() in the SG collection adapter, converting sgbucket types to gocb types with RawJSONTranscoder. Add AsRangeScanStore helper, LeakyDataStore passthrough, and IsSupported for CBS 7.5+. Includes dual-backend test covering full range, partial range, IDsOnly, sampling, empty range, prefix, and tombstone exclusion. --- base/bucket.go | 6 + base/collection.go | 15 ++ base/collection_rangescan.go | 94 +++++++++ base/collection_rangescan_test.go | 227 +++++++++++++++++++++ base/leaky_datastore.go | 11 +- base/utilities_testing_rbac.go | 75 +++++++ go.mod | 4 +- go.sum | 8 +- rest/admin_api_auth_test.go | 62 +++--- rest/adminapitest/admin_api_test.go | 12 +- rest/audit_test.go | 16 +- rest/bytes_read_public_api_test.go | 4 +- rest/upgradetest/remove_collection_test.go | 4 +- rest/utilities_testing_user.go | 63 ------ 14 files changed, 482 insertions(+), 119 deletions(-) create mode 100644 base/collection_rangescan.go create mode 100644 base/collection_rangescan_test.go create mode 100644 base/utilities_testing_rbac.go diff --git a/base/bucket.go b/base/bucket.go index b9f663d656..f56ba3583d 100644 --- a/base/bucket.go +++ b/base/bucket.go @@ -473,6 +473,12 @@ func AsSubdocStore(ds DataStore) (sgbucket.SubdocStore, bool) { return subdocStore, ok && ds.IsSupported(sgbucket.BucketStoreFeatureSubdocOperations) } +// AsRangeScanStore returns a RangeScanStore if the dataStore implements and supports range scan operations. +func AsRangeScanStore(ds DataStore) (sgbucket.RangeScanStore, bool) { + rss, ok := ds.(sgbucket.RangeScanStore) + return rss, ok && ds.IsSupported(sgbucket.BucketStoreFeatureRangeScan) +} + // WaitUntilDataStoreReady will try to perform a basic operation in the given DataStore until it can succeed. // It's not necessarily the case that a datastore that exists is ready to be used. // diff --git a/base/collection.go b/base/collection.go index f6dab38ea9..4936d2f0f7 100644 --- a/base/collection.go +++ b/base/collection.go @@ -257,6 +257,8 @@ func (b *GocbV2Bucket) IsSupported(feature sgbucket.BucketStoreFeature) bool { return b.IsMinimumVersion(7, 6) case sgbucket.BucketStoreFeatureMobileXDCR: return b.supportsHLV + case sgbucket.BucketStoreFeatureRangeScan: + return b.IsMinimumVersion(7, 6) default: return false } @@ -717,6 +719,19 @@ func (b *GocbV2Bucket) DefaultDataStore(_ context.Context) sgbucket.DataStore { } } +// GetMatchingDataStore returns a DataStore on this bucket that matches the scope and collection +// of the given DataStore. Useful when opening a second connection to the same bucket and needing +// to operate on the same collection as the original. +func (b *GocbV2Bucket) GetMatchingDataStore(ctx context.Context, other sgbucket.DataStoreName) (sgbucket.DataStore, error) { + if other.ScopeName() == DefaultScope && other.CollectionName() == DefaultCollection { + return b.DefaultDataStore(ctx), nil + } + return b.NamedDataStore(ctx, ScopeAndCollectionName{ + Scope: other.ScopeName(), + Collection: other.CollectionName(), + }) +} + // NamedDataStore returns a collection on a bucket within the given scope and collection. func (b *GocbV2Bucket) NamedDataStore(_ context.Context, name sgbucket.DataStoreName) (sgbucket.DataStore, error) { c, err := NewCollection( diff --git a/base/collection_rangescan.go b/base/collection_rangescan.go new file mode 100644 index 0000000000..f828b774cb --- /dev/null +++ b/base/collection_rangescan.go @@ -0,0 +1,94 @@ +/* +Copyright 2025-Present Couchbase, Inc. + +Use of this software is governed by the Business Source License included in +the file licenses/BSL-Couchbase.txt. As of the Change Date specified in that +file, in accordance with the Business Source License, use of this software will +be governed by the Apache License, Version 2.0, included in the file +licenses/APL2.txt. +*/ + +package base + +import ( + "context" + "fmt" + + "github.com/couchbase/gocb/v2" + sgbucket "github.com/couchbase/sg-bucket" +) + +var _ sgbucket.RangeScanStore = &Collection{} + +func (c *Collection) Scan(_ context.Context, scanType sgbucket.ScanType, opts sgbucket.ScanOptions) (sgbucket.ScanResultIterator, error) { + c.Bucket.waitForAvailKvOp() + defer c.Bucket.releaseKvOp() + + gocbScanType, err := toGocbScanType(scanType) + if err != nil { + return nil, err + } + + scanOpts := &gocb.ScanOptions{ + IDsOnly: opts.IDsOnly, + Transcoder: gocb.NewRawBinaryTranscoder(), + } + + result, err := c.Collection.Scan(gocbScanType, scanOpts) + if err != nil { + return nil, err + } + + return &gocbScanResultIterator{result: result, idsOnly: opts.IDsOnly}, nil +} + +func toGocbScanType(scanType sgbucket.ScanType) (gocb.ScanType, error) { + switch st := scanType.(type) { + case sgbucket.RangeScan: + rs := gocb.RangeScan{} + if st.From != nil { + rs.From = &gocb.ScanTerm{Term: st.From.Term, Exclusive: st.From.Exclusive} + } + if st.To != nil { + rs.To = &gocb.ScanTerm{Term: st.To.Term, Exclusive: st.To.Exclusive} + } + return rs, nil + default: + return nil, fmt.Errorf("unsupported scan type: %T", scanType) + } +} + +type gocbScanResultIterator struct { + result *gocb.ScanResult + idsOnly bool + err error +} + +func (it *gocbScanResultIterator) Next(_ context.Context) *sgbucket.ScanResultItem { + if it.err != nil { + return nil + } + item := it.result.Next() + if item == nil { + return nil + } + result := &sgbucket.ScanResultItem{ + ID: item.ID(), + Cas: uint64(item.Cas()), + } + if !it.idsOnly { + if err := item.Content(&result.Body); err != nil { + it.err = fmt.Errorf("failed to decode scan result body for %s: %w", item.ID(), err) + return nil + } + } + return result +} + +func (it *gocbScanResultIterator) Close(_ context.Context) error { + closeErr := it.result.Close() + if it.err == nil { + it.err = closeErr + } + return it.err +} diff --git a/base/collection_rangescan_test.go b/base/collection_rangescan_test.go new file mode 100644 index 0000000000..24a96ce1c1 --- /dev/null +++ b/base/collection_rangescan_test.go @@ -0,0 +1,227 @@ +/* +Copyright 2025-Present Couchbase, Inc. + +Use of this software is governed by the Business Source License included in +the file licenses/BSL-Couchbase.txt. As of the Change Date specified in that +file, in accordance with the Business Source License, use of this software will +be governed by the Apache License, Version 2.0, included in the file +licenses/APL2.txt. +*/ + +package base + +import ( + "context" + "fmt" + "sort" + "testing" + "time" + + sgbucket "github.com/couchbase/sg-bucket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// collectScanIDs runs a scan and returns the sorted list of document IDs. +func collectScanIDs(t testing.TB, ctx context.Context, rss sgbucket.RangeScanStore, scanType sgbucket.ScanType, opts sgbucket.ScanOptions) []string { + t.Helper() + iter, err := rss.Scan(ctx, scanType, opts) + require.NoError(t, err) + defer func() { assert.NoError(t, iter.Close(ctx)) }() + + var ids []string + for item := iter.Next(ctx); item != nil; item = iter.Next(ctx) { + ids = append(ids, item.ID) + } + sort.Strings(ids) + return ids +} + +// rangeScanFixture seeds a fixed set of documents and waits for them to be visible to scan. +// CBS range scan may not immediately reflect recent writes (requires persistence) so callers +// should wait via the returned helper before running ordering-sensitive assertions. +func rangeScanFixture(t *testing.T, ctx context.Context, writeDataStore sgbucket.DataStore, scanStore sgbucket.RangeScanStore) []string { + t.Helper() + docs := map[string][]byte{ + "doc_a": []byte(`{"name":"alpha"}`), + "doc_b": []byte(`{"name":"bravo"}`), + "doc_c": []byte(`{"name":"charlie"}`), + "doc_d": []byte(`{"name":"delta"}`), + "doc_e": []byte(`{"name":"echo"}`), + } + for k, v := range docs { + require.NoError(t, writeDataStore.SetRaw(ctx, k, 0, nil, v)) + } + allDocIDs := []string{"doc_a", "doc_b", "doc_c", "doc_d", "doc_e"} + + require.EventuallyWithT(t, func(c *assert.CollectT) { + ids := collectScanIDs(t, ctx, scanStore, sgbucket.NewRangeScanForPrefix("doc_"), sgbucket.ScanOptions{IDsOnly: true}) + assert.Equal(c, allDocIDs, ids) + }, 30*time.Second, 100*time.Millisecond) + + return allDocIDs +} + +// runRangeScanSubtests runs the common range-scan assertions against the given (writeDS, scanStore) pair. +// writeDS is used to seed documents and delete one for the tombstone test; scanStore drives Scan() calls. +// In the admin case these are the same datastore; in the RBAC case writeDS is the admin connection +// (so we don't need management permissions on the RBAC user) while scanStore is the RBAC connection. +func runRangeScanSubtests(t *testing.T, ctx context.Context, writeDS sgbucket.DataStore, scanStore sgbucket.RangeScanStore) { + allDocIDs := rangeScanFixture(t, ctx, writeDS, scanStore) + + t.Run("FullRange", func(t *testing.T) { + iter, err := scanStore.Scan(ctx, sgbucket.NewRangeScanForPrefix("doc_"), sgbucket.ScanOptions{}) + require.NoError(t, err) + defer func() { assert.NoError(t, iter.Close(ctx)) }() + + var ids []string + for item := iter.Next(ctx); item != nil; item = iter.Next(ctx) { + ids = append(ids, item.ID) + assert.NotNil(t, item.Body, "Expected body for key %s", item.ID) + assert.NotZero(t, item.Cas, "Expected non-zero CAS for key %s", item.ID) + } + sort.Strings(ids) + require.Equal(t, allDocIDs, ids) + }) + + t.Run("PartialRange", func(t *testing.T) { + scan := sgbucket.RangeScan{ + From: &sgbucket.ScanTerm{Term: "doc_b"}, + To: &sgbucket.ScanTerm{Term: "doc_d", Exclusive: true}, + } + ids := collectScanIDs(t, ctx, scanStore, scan, sgbucket.ScanOptions{}) + require.Equal(t, []string{"doc_b", "doc_c"}, ids) + }) + + t.Run("IDsOnly", func(t *testing.T) { + iter, err := scanStore.Scan(ctx, sgbucket.NewRangeScanForPrefix("doc_"), sgbucket.ScanOptions{IDsOnly: true}) + require.NoError(t, err) + defer func() { assert.NoError(t, iter.Close(ctx)) }() + + var ids []string + for item := iter.Next(ctx); item != nil; item = iter.Next(ctx) { + ids = append(ids, item.ID) + assert.Nil(t, item.Body, "Expected nil body for IDsOnly scan, key %s", item.ID) + } + sort.Strings(ids) + require.Equal(t, allDocIDs, ids) + }) + + t.Run("EmptyRange", func(t *testing.T) { + ids := collectScanIDs(t, ctx, scanStore, sgbucket.NewRangeScanForPrefix("zzz_nonexistent_"), sgbucket.ScanOptions{}) + assert.Empty(t, ids) + }) + + t.Run("PrefixScan", func(t *testing.T) { + ids := collectScanIDs(t, ctx, scanStore, sgbucket.NewRangeScanForPrefix("doc_c"), sgbucket.ScanOptions{}) + require.Equal(t, []string{"doc_c"}, ids) + }) + + t.Run("TombstonesExcluded", func(t *testing.T) { + require.NoError(t, writeDS.Delete(ctx, "doc_b")) + + require.EventuallyWithT(t, func(c *assert.CollectT) { + ids := collectScanIDs(t, ctx, scanStore, sgbucket.NewRangeScanForPrefix("doc_"), sgbucket.ScanOptions{IDsOnly: true}) + assert.NotContains(c, ids, "doc_b", "Tombstoned doc should not appear in scan") + }, 30*time.Second, 100*time.Millisecond) + + ids := collectScanIDs(t, ctx, scanStore, sgbucket.NewRangeScanForPrefix("doc_"), sgbucket.ScanOptions{}) + assert.NotContains(t, ids, "doc_b") + assert.Contains(t, ids, "doc_a") + assert.Contains(t, ids, "doc_c") + }) + + t.Run("LargeFixture", func(t *testing.T) { + // 8192 docs is enough to spread several keys per vBucket: 8 per vb on + // a 1024-vBucket CBS bucket, more on smaller setups (e.g. 256 per vb + // on rosmar's 32-vBucket simulation). Verifies the full set comes back + // across multiple vBucket scan streams. Range scan returns no global + // ordering guarantee (gocb interleaves per-vBucket streams), so the + // assertion is set equality, not ordered equality. + const numDocs = 8192 + const prefix = "many_" + + expected := make([]string, numDocs) + for i := 0; i < numDocs; i++ { + key := fmt.Sprintf("%s%04d", prefix, i) + expected[i] = key + require.NoError(t, writeDS.SetRaw(ctx, key, 0, nil, []byte(`{}`))) + } + + require.EventuallyWithT(t, func(c *assert.CollectT) { + ids := collectScanIDs(t, ctx, scanStore, sgbucket.NewRangeScanForPrefix(prefix), sgbucket.ScanOptions{IDsOnly: true}) + assert.Equal(c, expected, ids) + }, 60*time.Second, 500*time.Millisecond) + }) +} + +// TestRangeScan exercises KV range scan via the sgbucket abstraction against the +// admin-credentialed test bucket, then (when RBAC is supported) repeats the same +// subtests through a second bucket connection authenticated as a non-admin RBAC +// user. The RBAC path verifies that range scan operations succeed for users with +// only the mobile_sync_gateway / bucket_full_access roles. +func TestRangeScan(t *testing.T) { + ctx := TestCtx(t) + bucket := GetTestBucket(t) + defer bucket.Close(ctx) + + adminDataStore := bucket.GetSingleDataStore() + adminScanStore, ok := AsRangeScanStore(adminDataStore) + require.True(t, ok, "DataStore does not support range scan") + + t.Run("Admin", func(t *testing.T) { + runRangeScanSubtests(t, ctx, adminDataStore, adminScanStore) + }) + + t.Run("RBAC", func(t *testing.T) { + TestsRequireMobileRBAC(t) + + gocbBucket, err := AsGocbV2Bucket(bucket.Bucket) + require.NoError(t, err) + + mgmtEp, err := GoCBBucketMgmtEndpoint(gocbBucket) + require.NoError(t, err) + + httpClient := gocbBucket.HttpClient(ctx) + require.NotNil(t, httpClient) + + // Sync Gateway requires bucket_full_access (KV data) and bucket_admin + // (management). EE additionally exposes mobile_sync_gateway. + const rbacUsername = "sg_rangescan_test" + const rbacPassword = "password" + bucketName := bucket.GetName() + + roles := []string{ + fmt.Sprintf("bucket_full_access[%s]", bucketName), + fmt.Sprintf("bucket_admin[%s]", bucketName), + fmt.Sprintf("mobile_sync_gateway[%s]", bucketName), + } + + MakeUser(t, httpClient, mgmtEp, rbacUsername, rbacPassword, roles) + defer DeleteUser(t, httpClient, mgmtEp, rbacUsername) + + rbacSpec := getTestBucketSpec(TestClusterSpec(t), tbpBucketName(bucketName)) + rbacSpec.Auth = TestAuthenticator{ + Username: rbacUsername, + Password: rbacPassword, + BucketName: bucketName, + } + + // RBAC user creation may take a moment to propagate to the KV service, + // so retry the connection a few times before giving up. + var rbacBucket *GocbV2Bucket + require.EventuallyWithT(t, func(c *assert.CollectT) { + var connectErr error + rbacBucket, connectErr = GetGoCBv2Bucket(ctx, rbacSpec) + assert.NoError(c, connectErr, "Failed to open bucket with RBAC user %q (roles %v)", rbacUsername, roles) + }, 10*time.Second, 500*time.Millisecond) + defer rbacBucket.Close(ctx) + + rbacDataStore, err := rbacBucket.GetMatchingDataStore(ctx, adminDataStore) + require.NoError(t, err, "Failed to open matching data store on RBAC bucket") + rbacScanStore, ok := AsRangeScanStore(rbacDataStore) + require.True(t, ok, "RBAC DataStore does not support range scan") + + runRangeScanSubtests(t, ctx, adminDataStore, rbacScanStore) + }) +} diff --git a/base/leaky_datastore.go b/base/leaky_datastore.go index dc119b87cc..f84cd1f14a 100644 --- a/base/leaky_datastore.go +++ b/base/leaky_datastore.go @@ -557,7 +557,16 @@ func (lds *LeakyDataStore) GetIndexes() (indexes []string, err error) { return n1qlStore.GetIndexes() } +func (lds *LeakyDataStore) Scan(ctx context.Context, scanType sgbucket.ScanType, opts sgbucket.ScanOptions) (sgbucket.ScanResultIterator, error) { + rss, ok := lds.dataStore.(sgbucket.RangeScanStore) + if !ok { + return nil, fmt.Errorf("underlying datastore %T does not support range scan", lds.dataStore) + } + return rss.Scan(ctx, scanType, opts) +} + // Assert interface compliance: var ( - _ sgbucket.DataStore = &LeakyDataStore{} + _ sgbucket.DataStore = &LeakyDataStore{} + _ sgbucket.RangeScanStore = &LeakyDataStore{} ) diff --git a/base/utilities_testing_rbac.go b/base/utilities_testing_rbac.go new file mode 100644 index 0000000000..89a6f734bd --- /dev/null +++ b/base/utilities_testing_rbac.go @@ -0,0 +1,75 @@ +// Copyright 2026-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package base + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// MakeUser creates a Couchbase Server RBAC user via the management REST API, +// retrying on transient errors. The caller must have cluster admin credentials. +func MakeUser(t *testing.T, httpClient *http.Client, serverURL, username, password string, roles []string) { + form := url.Values{} + form.Add("password", password) + form.Add("roles", strings.Join(roles, ",")) + + retryWorker := func() (shouldRetry bool, err error, value any) { + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/settings/rbac/users/local/%s", serverURL, username), strings.NewReader(form.Encode())) + require.NoError(t, err) + + req.SetBasicAuth(TestClusterUsername(), TestClusterPassword()) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + return true, err, nil + } + defer func() { assert.NoError(t, resp.Body.Close()) }() + var bodyResp []byte + if resp.StatusCode != http.StatusOK { + bodyResp, err = io.ReadAll(resp.Body) + require.NoError(t, err, "Failed to create user: %s", bodyResp) + } + require.Equalf(t, http.StatusOK, resp.StatusCode, "Failed to create user: %s", bodyResp) + return false, err, nil + } + + err, _ := RetryLoop(TestCtx(t), "MakeUser", retryWorker, CreateSleeperFunc(10, 100)) + require.NoError(t, err) +} + +// DeleteUser removes a Couchbase Server RBAC user via the management REST API. +func DeleteUser(t *testing.T, httpClient *http.Client, serverURL, username string) { + retryWorker := func() (shouldRetry bool, err error, value *http.Response) { + req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/settings/rbac/users/local/%s", serverURL, username), nil) + require.NoError(t, err) + + req.SetBasicAuth(TestClusterUsername(), TestClusterPassword()) + + resp, err := httpClient.Do(req) + if err != nil { + return true, err, resp + } + assert.NoError(t, resp.Body.Close()) + return false, err, resp + } + + err, resp := RetryLoop(TestCtx(t), "DeleteUser", retryWorker, CreateSleeperFunc(10, 100)) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/go.mod b/go.mod index ef56493b66..2a1d790b1e 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/couchbase/gocb/v2 v2.12.1 github.com/couchbase/gocbcore/v10 v10.9.2-0.20260430103215-edcade542007 github.com/couchbase/gomemcached v0.2.1 - github.com/couchbase/sg-bucket v0.0.0-20260518141224-124a6a2b318e + github.com/couchbase/sg-bucket v0.0.0-20260518185934-fd2c2e344237 github.com/couchbasedeps/fast-skiplist v0.0.0-20250722125747-e0dd031fe2ac github.com/couchbaselabs/go-fleecedelta v0.0.0-20220909152808-6d09efa7a338 github.com/couchbaselabs/gocbconnstr v1.0.5 @@ -67,7 +67,7 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/mattn/go-sqlite3 v1.14.42 // indirect + github.com/mattn/go-sqlite3 v1.14.44 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/go.sum b/go.sum index a004800a85..ec54373bd8 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ github.com/couchbase/goprotostellar v1.0.5 h1:pmR4H87zbYymIdTR1owyUZsfQ7NupkfCuN github.com/couchbase/goprotostellar v1.0.5/go.mod h1:X58ot5FRqlBTBkwG/oI4klunpu4MApjGktheqeRWQw0= github.com/couchbase/goutils v0.1.2 h1:gWr8B6XNWPIhfalHNog3qQKfGiYyh4K4VhO3P2o9BCs= github.com/couchbase/goutils v0.1.2/go.mod h1:h89Ek/tiOxxqjz30nPPlwZdQbdB8BwgnuBxeoUe/ViE= -github.com/couchbase/sg-bucket v0.0.0-20260518141224-124a6a2b318e h1:tPaPP/sHmz3ACUgEDgRIeM68TLSrEax2zNE17zpe1Mw= -github.com/couchbase/sg-bucket v0.0.0-20260518141224-124a6a2b318e/go.mod h1:zZspn0FpL3Q9ol/KN9dpR0u/p5f8cJpETk8GVxcegqA= +github.com/couchbase/sg-bucket v0.0.0-20260518185934-fd2c2e344237 h1:KqPiyDmYV+KzSfMcJl7lBa5UlutkfqE+YG+qRqny1zQ= +github.com/couchbase/sg-bucket v0.0.0-20260518185934-fd2c2e344237/go.mod h1:zZspn0FpL3Q9ol/KN9dpR0u/p5f8cJpETk8GVxcegqA= github.com/couchbase/tools-common/cloud/v8 v8.1.3 h1:+fH2+3E8KV8xyXXzbEJNhosMqh08ahauZWlu0m5qtJA= github.com/couchbase/tools-common/cloud/v8 v8.1.3/go.mod h1:5wEMtM4rX92Zl9ylQKEJFgmYNUyFVoWzXCvo31YNO+U= github.com/couchbase/tools-common/fs v1.0.3 h1:KXhisN+hmp5yicOWkUBNjJcd/WsblHA2SVShjL2eGiY= @@ -131,8 +131,8 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1 github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= -github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/rest/admin_api_auth_test.go b/rest/admin_api_auth_test.go index c0046cdbaa..36aaf6945d 100644 --- a/rest/admin_api_auth_test.go +++ b/rest/admin_api_auth_test.go @@ -128,8 +128,8 @@ func TestCheckPermissions(t *testing.T) { for _, testCase := range testCases { rt.Run(testCase.Name, func(t *testing.T) { if testCase.CreateUser != "" { - MakeUser(t, httpClient, eps[0], testCase.CreateUser, testCase.CreatePassword, testCase.CreateRoles) - defer DeleteUser(t, httpClient, eps[0], testCase.CreateUser) + base.MakeUser(t, httpClient, eps[0], testCase.CreateUser, testCase.CreatePassword, testCase.CreateRoles) + defer base.DeleteUser(t, httpClient, eps[0], testCase.CreateUser) } statusCode, permResults, err := CheckPermissions(base.TestCtx(t), httpClient, eps, "", testCase.Username, testCase.Password, testCase.RequestPermissions, testCase.ResponsePermissions) @@ -281,8 +281,8 @@ func TestCheckRoles(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.Name, func(t *testing.T) { if testCase.CreateUser != "" { - MakeUser(t, httpClient, eps[0], testCase.CreateUser, testCase.CreatePassword, testCase.CreateRoles) - defer DeleteUser(t, httpClient, eps[0], testCase.CreateUser) + base.MakeUser(t, httpClient, eps[0], testCase.CreateUser, testCase.CreatePassword, testCase.CreateRoles) + defer base.DeleteUser(t, httpClient, eps[0], testCase.CreateUser) } statusCode, err := CheckRoles(base.TestCtx(t), httpClient, eps, testCase.Username, testCase.Password, testCase.RequestRoles, testCase.BucketName) @@ -420,8 +420,8 @@ func TestAdminAuth(t *testing.T) { rt.Run(testCase.Name, func(t *testing.T) { if testCase.CreateUser != "" { - MakeUser(t, httpClient, managementEndpoints[0], testCase.CreateUser, testCase.CreatePassword, testCase.CreateRoles) - defer DeleteUser(t, httpClient, managementEndpoints[0], testCase.CreateUser) + base.MakeUser(t, httpClient, managementEndpoints[0], testCase.CreateUser, testCase.CreatePassword, testCase.CreateRoles) + defer base.DeleteUser(t, httpClient, managementEndpoints[0], testCase.CreateUser) } permResults, statusCode, err := checkAdminAuth(base.TestCtx(t), testCase.BucketName, testCase.Username, testCase.Password, testCase.Operation, httpClient, managementEndpoints, true, testCase.CheckPermissions, testCase.ResponsePermissions) @@ -856,19 +856,19 @@ func TestAdminAPIAuth(t *testing.T) { eps, httpClient, err := rt.ServerContext().ObtainManagementEndpointsAndHTTPClient() require.NoError(t, err) - MakeUser(t, httpClient, eps[0], "noaccess", "password", []string{}) - defer DeleteUser(t, httpClient, eps[0], "noaccess") + base.MakeUser(t, httpClient, eps[0], "noaccess", "password", []string{}) + defer base.DeleteUser(t, httpClient, eps[0], "noaccess") - MakeUser(t, httpClient, eps[0], "MobileSyncGatewayUser", "password", []string{fmt.Sprintf("%s[%s]", SGWorBFArole, rt.Bucket().GetName())}) - defer DeleteUser(t, httpClient, eps[0], "MobileSyncGatewayUser") + base.MakeUser(t, httpClient, eps[0], "MobileSyncGatewayUser", "password", []string{fmt.Sprintf("%s[%s]", SGWorBFArole, rt.Bucket().GetName())}) + defer base.DeleteUser(t, httpClient, eps[0], "MobileSyncGatewayUser") - MakeUser(t, httpClient, eps[0], "ROAdminUser", "password", []string{ReadOnlyAdminRole.RoleName}) - defer DeleteUser(t, httpClient, eps[0], "ROAdminUser") + base.MakeUser(t, httpClient, eps[0], "ROAdminUser", "password", []string{ReadOnlyAdminRole.RoleName}) + defer base.DeleteUser(t, httpClient, eps[0], "ROAdminUser") // EE only role if !serverIsCE { - MakeUser(t, httpClient, eps[0], "ClusterAdminUser", "password", []string{ClusterAdminRole.RoleName}) - defer DeleteUser(t, httpClient, eps[0], "ClusterAdminUser") + base.MakeUser(t, httpClient, eps[0], "ClusterAdminUser", "password", []string{ClusterAdminRole.RoleName}) + defer base.DeleteUser(t, httpClient, eps[0], "ClusterAdminUser") } for _, endPoint := range endPoints { @@ -984,11 +984,11 @@ func TestDisablePermissionCheck(t *testing.T) { require.NoError(t, err) if testCase.CreateUserRole.DatabaseScoped { - MakeUser(t, httpClient, eps[0], testCase.CreateUser, "password", []string{fmt.Sprintf("%s[%s]", testCase.CreateUserRole.RoleName, rt.Bucket().GetName())}) + base.MakeUser(t, httpClient, eps[0], testCase.CreateUser, "password", []string{fmt.Sprintf("%s[%s]", testCase.CreateUserRole.RoleName, rt.Bucket().GetName())}) } else { - MakeUser(t, httpClient, eps[0], testCase.CreateUser, "password", []string{testCase.CreateUserRole.RoleName}) + base.MakeUser(t, httpClient, eps[0], testCase.CreateUser, "password", []string{testCase.CreateUserRole.RoleName}) } - defer DeleteUser(t, httpClient, eps[0], testCase.CreateUser) + defer base.DeleteUser(t, httpClient, eps[0], testCase.CreateUser) _, statusCode, err := checkAdminAuth(rt.Context(), rt.Bucket().GetName(), testCase.CreateUser, "password", "", httpClient, eps, testCase.DoPermissionCheck, testCase.RequirePerms, nil) assert.NoError(t, err) @@ -1023,18 +1023,18 @@ func TestNewlyCreateSGWPermissions(t *testing.T) { eps, httpClient, err := rt.ServerContext().ObtainManagementEndpointsAndHTTPClient() require.NoError(t, err) - MakeUser(t, httpClient, eps[0], mobileSyncGateway, "password", []string{fmt.Sprintf("%s[*]", mobileSyncGateway)}) - defer DeleteUser(t, httpClient, eps[0], mobileSyncGateway) - MakeUser(t, httpClient, eps[0], syncGatewayDevOps, "password", []string{syncGatewayDevOps}) - defer DeleteUser(t, httpClient, eps[0], syncGatewayDevOps) - MakeUser(t, httpClient, eps[0], syncGatewayApp, "password", []string{fmt.Sprintf("%s[*]", syncGatewayApp)}) - defer DeleteUser(t, httpClient, eps[0], syncGatewayApp) - MakeUser(t, httpClient, eps[0], syncGatewayAppRo, "password", []string{fmt.Sprintf("%s[*]", syncGatewayAppRo)}) - defer DeleteUser(t, httpClient, eps[0], syncGatewayAppRo) - MakeUser(t, httpClient, eps[0], syncGatewayConfigurator, "password", []string{fmt.Sprintf("%s[*]", syncGatewayConfigurator)}) - defer DeleteUser(t, httpClient, eps[0], syncGatewayConfigurator) - MakeUser(t, httpClient, eps[0], syncGatewayReplicator, "password", []string{fmt.Sprintf("%s[*]", syncGatewayReplicator)}) - defer DeleteUser(t, httpClient, eps[0], syncGatewayReplicator) + base.MakeUser(t, httpClient, eps[0], mobileSyncGateway, "password", []string{fmt.Sprintf("%s[*]", mobileSyncGateway)}) + defer base.DeleteUser(t, httpClient, eps[0], mobileSyncGateway) + base.MakeUser(t, httpClient, eps[0], syncGatewayDevOps, "password", []string{syncGatewayDevOps}) + defer base.DeleteUser(t, httpClient, eps[0], syncGatewayDevOps) + base.MakeUser(t, httpClient, eps[0], syncGatewayApp, "password", []string{fmt.Sprintf("%s[*]", syncGatewayApp)}) + defer base.DeleteUser(t, httpClient, eps[0], syncGatewayApp) + base.MakeUser(t, httpClient, eps[0], syncGatewayAppRo, "password", []string{fmt.Sprintf("%s[*]", syncGatewayAppRo)}) + defer base.DeleteUser(t, httpClient, eps[0], syncGatewayAppRo) + base.MakeUser(t, httpClient, eps[0], syncGatewayConfigurator, "password", []string{fmt.Sprintf("%s[*]", syncGatewayConfigurator)}) + defer base.DeleteUser(t, httpClient, eps[0], syncGatewayConfigurator) + base.MakeUser(t, httpClient, eps[0], syncGatewayReplicator, "password", []string{fmt.Sprintf("%s[*]", syncGatewayReplicator)}) + defer base.DeleteUser(t, httpClient, eps[0], syncGatewayReplicator) testUsers := []string{syncGatewayDevOps, syncGatewayApp, syncGatewayAppRo, syncGatewayReplicator, syncGatewayConfigurator} @@ -1502,8 +1502,8 @@ func TestCreateDBSpecificBucketPerm(t *testing.T) { eps, httpClient, err := rt.ServerContext().ObtainManagementEndpointsAndHTTPClient() require.NoError(t, err) - MakeUser(t, httpClient, eps[0], SGWorBFArole.RoleName, "password", []string{fmt.Sprintf("%s[%s]", SGWorBFArole.RoleName, tb.GetName())}) - defer DeleteUser(t, httpClient, eps[0], SGWorBFArole.RoleName) + base.MakeUser(t, httpClient, eps[0], SGWorBFArole.RoleName, "password", []string{fmt.Sprintf("%s[%s]", SGWorBFArole.RoleName, tb.GetName())}) + defer base.DeleteUser(t, httpClient, eps[0], SGWorBFArole.RoleName) resp := rt.SendAdminRequestWithAuth("PUT", "/db2/", `{"bucket": "`+tb.GetName()+`", "username": "`+base.TestClusterUsername()+`", "password": "`+base.TestClusterPassword()+`", "num_index_replicas": 0, "use_views": `+strconv.FormatBool(base.TestsDisableGSI())+`}`, SGWorBFArole.RoleName, "password") RequireStatus(t, resp, http.StatusCreated) diff --git a/rest/adminapitest/admin_api_test.go b/rest/adminapitest/admin_api_test.go index fef7e67b8a..5b15619946 100644 --- a/rest/adminapitest/admin_api_test.go +++ b/rest/adminapitest/admin_api_test.go @@ -2989,8 +2989,8 @@ func TestNotExistentDBRequest(t *testing.T) { eps, httpClient, err := rt.ServerContext().ObtainManagementEndpointsAndHTTPClient() require.NoError(t, err) - rest.MakeUser(t, httpClient, eps[0], "random", "password", nil) - defer rest.DeleteUser(t, httpClient, eps[0], "random") + base.MakeUser(t, httpClient, eps[0], "random", "password", nil) + defer base.DeleteUser(t, httpClient, eps[0], "random") // Request to non-existent db with valid credentials resp := rt.SendAdminRequestWithAuth("PUT", "/dbx/_config", "", "random", "password") @@ -3927,8 +3927,8 @@ func TestDatabaseCreationWithEnvVariable(t *testing.T) { // create a role to authenticate with in admin endpoint eps, httpClient, err := rt.ServerContext().ObtainManagementEndpointsAndHTTPClient() require.NoError(t, err) - rest.MakeUser(t, httpClient, eps[0], rest.MobileSyncGatewayRole.RoleName, "password", []string{fmt.Sprintf("%s[%s]", rest.MobileSyncGatewayRole.RoleName, tb.GetName())}) - defer rest.DeleteUser(t, httpClient, eps[0], rest.MobileSyncGatewayRole.RoleName) + base.MakeUser(t, httpClient, eps[0], rest.MobileSyncGatewayRole.RoleName, "password", []string{fmt.Sprintf("%s[%s]", rest.MobileSyncGatewayRole.RoleName, tb.GetName())}) + defer base.DeleteUser(t, httpClient, eps[0], rest.MobileSyncGatewayRole.RoleName) cfg := rt.NewDbConfig() input, err := base.JSONMarshal(&cfg) @@ -3963,8 +3963,8 @@ func TestDatabaseCreationWithEnvVariableWithBackticks(t *testing.T) { // create a role to authenticate with in admin endpoint eps, httpClient, err := rt.ServerContext().ObtainManagementEndpointsAndHTTPClient() require.NoError(t, err) - rest.MakeUser(t, httpClient, eps[0], rest.MobileSyncGatewayRole.RoleName, "password", []string{fmt.Sprintf("%s[%s]", rest.MobileSyncGatewayRole.RoleName, tb.GetName())}) - defer rest.DeleteUser(t, httpClient, eps[0], rest.MobileSyncGatewayRole.RoleName) + base.MakeUser(t, httpClient, eps[0], rest.MobileSyncGatewayRole.RoleName, "password", []string{fmt.Sprintf("%s[%s]", rest.MobileSyncGatewayRole.RoleName, tb.GetName())}) + defer base.DeleteUser(t, httpClient, eps[0], rest.MobileSyncGatewayRole.RoleName) cfg := rt.NewDbConfig() input, err := base.JSONMarshal(&cfg) diff --git a/rest/audit_test.go b/rest/audit_test.go index 8c5dc50df1..d4ddd7356b 100644 --- a/rest/audit_test.go +++ b/rest/audit_test.go @@ -99,16 +99,16 @@ func TestAuditLoggingFields(t *testing.T) { eps, httpClient, err := rt.ServerContext().ObtainManagementEndpointsAndHTTPClient() require.NoError(t, err) - MakeUser(t, httpClient, eps[0], filteredAdminUsername, RestTesterDefaultUserPassword, []string{ + base.MakeUser(t, httpClient, eps[0], filteredAdminUsername, RestTesterDefaultUserPassword, []string{ fmt.Sprintf("%s[%s]", MobileSyncGatewayRole.RoleName, rt.Bucket().GetName()), }) - defer DeleteUser(t, httpClient, eps[0], filteredAdminUsername) - MakeUser(t, httpClient, eps[0], filteredAdminRoleUsername, RestTesterDefaultUserPassword, []string{ + defer base.DeleteUser(t, httpClient, eps[0], filteredAdminUsername) + base.MakeUser(t, httpClient, eps[0], filteredAdminRoleUsername, RestTesterDefaultUserPassword, []string{ fmt.Sprintf("%s[%s]", filteredAdminRoleName, rt.Bucket().GetName()), }) - defer DeleteUser(t, httpClient, eps[0], filteredAdminRoleUsername) - MakeUser(t, httpClient, eps[0], unauthorizedAdminUsername, RestTesterDefaultUserPassword, []string{}) - defer DeleteUser(t, httpClient, eps[0], unauthorizedAdminUsername) + defer base.DeleteUser(t, httpClient, eps[0], filteredAdminRoleUsername) + base.MakeUser(t, httpClient, eps[0], unauthorizedAdminUsername, RestTesterDefaultUserPassword, []string{}) + defer base.DeleteUser(t, httpClient, eps[0], unauthorizedAdminUsername) // if we have another bucket available, use it to test cross-bucket role filtering (to ensure it doesn't) if base.GTestBucketPool.NumUsableBuckets() >= 2 { @@ -116,11 +116,11 @@ func TestAuditLoggingFields(t *testing.T) { defer differentBucket.Close(ctx) differentBucketName := differentBucket.GetName() - MakeUser(t, httpClient, eps[0], unfilteredAdminRoleUsername, RestTesterDefaultUserPassword, []string{ + base.MakeUser(t, httpClient, eps[0], unfilteredAdminRoleUsername, RestTesterDefaultUserPassword, []string{ fmt.Sprintf("%s[%s]", filteredAdminRoleName, differentBucketName), fmt.Sprintf("%s[%s]", MobileSyncGatewayRole.RoleName, rt.Bucket().GetName()), }) - defer DeleteUser(t, httpClient, eps[0], unfilteredAdminRoleUsername) + defer base.DeleteUser(t, httpClient, eps[0], unfilteredAdminRoleUsername) } } diff --git a/rest/bytes_read_public_api_test.go b/rest/bytes_read_public_api_test.go index 74d519c1f9..728f02e0cc 100644 --- a/rest/bytes_read_public_api_test.go +++ b/rest/bytes_read_public_api_test.go @@ -433,8 +433,8 @@ func TestPutDBBytesRead(t *testing.T) { eps, httpClient, err := rt.ServerContext().ObtainManagementEndpointsAndHTTPClient() require.NoError(t, err) - MakeUser(t, httpClient, eps[0], "MobileSyncGatewayUser", "password", []string{fmt.Sprintf("%s[%s]", SGWorBFArole, rt.Bucket().GetName())}) - defer DeleteUser(t, httpClient, eps[0], "MobileSyncGatewayUser") + base.MakeUser(t, httpClient, eps[0], "MobileSyncGatewayUser", "password", []string{fmt.Sprintf("%s[%s]", SGWorBFArole, rt.Bucket().GetName())}) + defer base.DeleteUser(t, httpClient, eps[0], "MobileSyncGatewayUser") input := fmt.Sprintf( `{"bucket": "%s", "num_index_replicas": 0, "enable_shared_bucket_access": %t, "use_views": %t,"username": "%s", "password":"%s"}`, diff --git a/rest/upgradetest/remove_collection_test.go b/rest/upgradetest/remove_collection_test.go index 674c79095e..9843c6df1b 100644 --- a/rest/upgradetest/remove_collection_test.go +++ b/rest/upgradetest/remove_collection_test.go @@ -90,8 +90,8 @@ func TestRemoveCollection(t *testing.T) { altBucket := base.GetTestBucket(t) defer altBucket.Close(base.TestCtx(t)) const password = "password2" - rest.MakeUser(t, httpClient, eps[0], bucket2Role.RoleName, password, []string{fmt.Sprintf("%s[%s]", bucket2Role.RoleName, altBucket.GetName())}) - defer rest.DeleteUser(t, httpClient, eps[0], bucket2Role.RoleName) + base.MakeUser(t, httpClient, eps[0], bucket2Role.RoleName, password, []string{fmt.Sprintf("%s[%s]", bucket2Role.RoleName, altBucket.GetName())}) + defer base.DeleteUser(t, httpClient, eps[0], bucket2Role.RoleName) delete(dbConfig.Scopes[deletedDataStore.ScopeName()].Collections, deletedDataStore.CollectionName()) diff --git a/rest/utilities_testing_user.go b/rest/utilities_testing_user.go index 8da9e4aea8..d92e994d1b 100644 --- a/rest/utilities_testing_user.go +++ b/rest/utilities_testing_user.go @@ -10,72 +10,9 @@ package rest import ( "encoding/base64" - "fmt" - "io" - "net/http" - "net/url" - "strings" "testing" - - "github.com/couchbase/sync_gateway/base" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func MakeUser(t *testing.T, httpClient *http.Client, serverURL, username, password string, roles []string) { - form := url.Values{} - form.Add("password", password) - form.Add("roles", strings.Join(roles, ",")) - - retryWorker := func() (shouldRetry bool, err error, value any) { - req, err := http.NewRequest("PUT", fmt.Sprintf("%s/settings/rbac/users/local/%s", serverURL, username), strings.NewReader(form.Encode())) - require.NoError(t, err) - - req.SetBasicAuth(base.TestClusterUsername(), base.TestClusterPassword()) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - - resp, err := httpClient.Do(req) - if err != nil { - return true, err, nil - } - defer func() { assert.NoError(t, resp.Body.Close()) }() - var bodyResp []byte - if resp.StatusCode != http.StatusOK { - bodyResp, err = io.ReadAll(resp.Body) - require.NoError(t, err, "Failed to create user: %s", bodyResp) - } - require.Equalf(t, http.StatusOK, resp.StatusCode, "Failed to create user: %s", bodyResp) - return false, err, nil - } - - err, _ := base.RetryLoop(base.TestCtx(t), "Admin Auth testing MakeUser", retryWorker, base.CreateSleeperFunc(10, 100)) - require.NoError(t, err) - -} - -func DeleteUser(t *testing.T, httpClient *http.Client, serverURL, username string) { - retryWorker := func() (shouldRetry bool, err error, value *http.Response) { - req, err := http.NewRequest("DELETE", fmt.Sprintf("%s/settings/rbac/users/local/%s", serverURL, username), nil) - require.NoError(t, err) - - req.SetBasicAuth(base.TestClusterUsername(), base.TestClusterPassword()) - - resp, err := httpClient.Do(req) - if err != nil { - return true, err, resp - } - assert.NoError(t, resp.Body.Close()) - return false, err, resp - } - - err, resp := base.RetryLoop(base.TestCtx(t), "Admin Auth testing DeleteUser", retryWorker, base.CreateSleeperFunc(10, 100)) - require.NoError(t, err) - - require.Equal(t, http.StatusOK, resp.StatusCode) - - require.NoError(t, resp.Body.Close(), "Error closing response body") -} - func GetBasicAuthHeader(_ testing.TB, username, password string) string { return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) } From 330b8604774e1d63e87c9a6a4f8ecab1fdc49b4b Mon Sep 17 00:00:00 2001 From: Ben Brooks Date: Mon, 18 May 2026 22:55:39 +0100 Subject: [PATCH 2/9] Add TestRangeScanPrefixSentinelBoundary - repro's issue in gocb worked around with an explicit maximum term in sgbucket.NewRangeScanForPrefix --- base/collection_rangescan_test.go | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/base/collection_rangescan_test.go b/base/collection_rangescan_test.go index 24a96ce1c1..6d8c06378e 100644 --- a/base/collection_rangescan_test.go +++ b/base/collection_rangescan_test.go @@ -225,3 +225,67 @@ func TestRangeScan(t *testing.T) { runRangeScanSubtests(t, ctx, adminDataStore, rbacScanStore) }) } + +func TestRangeScanPrefixSentinelBoundary(t *testing.T) { + ctx := TestCtx(t) + + bucket := GetTestBucket(t) + defer bucket.Close(ctx) + + dataStore := bucket.GetSingleDataStore() + scanStore, ok := AsRangeScanStore(dataStore) + require.True(t, ok, "DataStore does not support range scan") + + const prefix = "k" + // Bound = prefix + [0xF4, '8', 'f', 'b', 'f', 'b', 'f'], inclusive. + type test struct { + key string + included bool + why string + } + cases := []test{ + // Byte-level boundary cases + {"j", false, "different prefix, sorts before the requested prefix"}, + {prefix, true, "the prefix itself sorts before any extension"}, + {prefix + "\xf3", true, "0xF3 < 0xF4 at byte after prefix"}, + {prefix + "\xf4", true, "shorter than bound and is a byte-prefix of it"}, + {prefix + "\xf4\x37", true, "byte 2: 0x37 < 0x38"}, + {prefix + "\xf48fbfb", true, "byte-prefix of bound (6 bytes vs 7), shorter so less"}, + {prefix + "\xf48fbfbe", true, "byte 7: 0x65 < 0x66 (final byte just below bound)"}, + {prefix + "\xf48fbfbf", true, "exactly equals the inclusive upper bound"}, + {prefix + "\xf48fbfbf\x00", true, "longer than bound and bytewise > it (length-extension)"}, + {prefix + "\xf48fbfbg", true, "byte 7: 0x67 > 0x66 (final byte just above bound)"}, + {prefix + "\xf48fbfc0", true, "byte 6: 0x63 > 0x62"}, + {prefix + "\xf4\x39", true, "byte 2: 0x39 > 0x38"}, + {prefix + "\xf5", false, "0xF5 is start of invalid UTF-8"}, + {"l", false, "different prefix, sorts after the bound"}, + + // Real-world UTF-8 cases + {prefix + "é", true, "U+00E9 (Latin-1, 0xC3 0xA9): leading byte 0xC3 < 0xF4"}, + {prefix + "中", true, "U+4E2D 中 (CJK BMP, 0xE4 0xB8 0xAD): leading byte 0xE4 < 0xF4"}, + {prefix + "\U0001F600", true, "U+1F600 😀 (emoji, 0xF0 0x9F 0x98 0x80): leading byte 0xF0 < 0xF4"}, + {prefix + "\U000FFFFF", true, "U+FFFFF (PUA-A end, 0xF3 0xBF 0xBF 0xBF): leading byte 0xF3 < 0xF4"}, + // + // Where the filter gocb chose breaks down: + // U+100000–U+10FFFF (Supplementary Private Use Area-B) + // leading byte 0xF4 followed by continuation bytes 0x80–0x8F, all > 0x38 (8 ASCII) + {prefix + "\U00100000", true, "U+100000 (PUA-B start, 0xF4 0x80 0x80 0x80): byte after 0xF4 is 0x80 > 0x38"}, + {prefix + "\U0010FFFF", true, "U+10FFFF (highest valid Unicode, 0xF4 0x8F 0xBF 0xBF): byte after 0xF4 is 0x8F > 0x38"}, + } + for _, c := range cases { + require.NoError(t, dataStore.SetRaw(ctx, c.key, 0, nil, []byte(`{}`)), "insert %q", c.key) + } + + var expected []string + for _, c := range cases { + if c.included { + expected = append(expected, c.key) + } + } + sort.Strings(expected) + + require.EventuallyWithT(t, func(c *assert.CollectT) { + ids := collectScanIDs(t, ctx, scanStore, sgbucket.NewRangeScanForPrefix(prefix), sgbucket.ScanOptions{IDsOnly: true}) + assert.Equal(c, expected, ids) + }, 30*time.Second, 100*time.Millisecond) +} From f50653915b7ed49be8148e39b384fe568d9a35b6 Mon Sep 17 00:00:00 2001 From: Ben Brooks Date: Tue, 19 May 2026 11:11:17 +0100 Subject: [PATCH 3/9] bump sg-bucket --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 2a1d790b1e..1ed398c106 100644 --- a/go.mod +++ b/go.mod @@ -13,11 +13,11 @@ require ( github.com/couchbase/gocb/v2 v2.12.1 github.com/couchbase/gocbcore/v10 v10.9.2-0.20260430103215-edcade542007 github.com/couchbase/gomemcached v0.2.1 - github.com/couchbase/sg-bucket v0.0.0-20260518185934-fd2c2e344237 + github.com/couchbase/sg-bucket v0.0.0-20260519100927-7e52d6f10caa github.com/couchbasedeps/fast-skiplist v0.0.0-20250722125747-e0dd031fe2ac github.com/couchbaselabs/go-fleecedelta v0.0.0-20220909152808-6d09efa7a338 github.com/couchbaselabs/gocbconnstr v1.0.5 - github.com/couchbaselabs/rosmar v0.0.0-20260518154355-0e98444f7414 + github.com/couchbaselabs/rosmar v0.0.0-20260519101035-3ad6c53465b6 github.com/elastic/gosigar v0.14.4 github.com/felixge/fgprof v0.9.5 github.com/go-jose/go-jose/v4 v4.1.4 diff --git a/go.sum b/go.sum index ec54373bd8..48e641e381 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ github.com/couchbase/goprotostellar v1.0.5 h1:pmR4H87zbYymIdTR1owyUZsfQ7NupkfCuN github.com/couchbase/goprotostellar v1.0.5/go.mod h1:X58ot5FRqlBTBkwG/oI4klunpu4MApjGktheqeRWQw0= github.com/couchbase/goutils v0.1.2 h1:gWr8B6XNWPIhfalHNog3qQKfGiYyh4K4VhO3P2o9BCs= github.com/couchbase/goutils v0.1.2/go.mod h1:h89Ek/tiOxxqjz30nPPlwZdQbdB8BwgnuBxeoUe/ViE= -github.com/couchbase/sg-bucket v0.0.0-20260518185934-fd2c2e344237 h1:KqPiyDmYV+KzSfMcJl7lBa5UlutkfqE+YG+qRqny1zQ= -github.com/couchbase/sg-bucket v0.0.0-20260518185934-fd2c2e344237/go.mod h1:zZspn0FpL3Q9ol/KN9dpR0u/p5f8cJpETk8GVxcegqA= +github.com/couchbase/sg-bucket v0.0.0-20260519100927-7e52d6f10caa h1:+PyCQ/N7Dgoc2ldcy1BJTHc9u/TSSjTM2OQaqTAQrRU= +github.com/couchbase/sg-bucket v0.0.0-20260519100927-7e52d6f10caa/go.mod h1:zZspn0FpL3Q9ol/KN9dpR0u/p5f8cJpETk8GVxcegqA= github.com/couchbase/tools-common/cloud/v8 v8.1.3 h1:+fH2+3E8KV8xyXXzbEJNhosMqh08ahauZWlu0m5qtJA= github.com/couchbase/tools-common/cloud/v8 v8.1.3/go.mod h1:5wEMtM4rX92Zl9ylQKEJFgmYNUyFVoWzXCvo31YNO+U= github.com/couchbase/tools-common/fs v1.0.3 h1:KXhisN+hmp5yicOWkUBNjJcd/WsblHA2SVShjL2eGiY= @@ -68,8 +68,8 @@ github.com/couchbaselabs/gocbconnstr v1.0.5 h1:e0JokB5qbcz7rfnxEhNRTKz8q1svoRvDo github.com/couchbaselabs/gocbconnstr v1.0.5/go.mod h1:KV3fnIKMi8/AzX0O9zOrO9rofEqrRF1d2rG7qqjxC7o= github.com/couchbaselabs/gocbconnstr/v2 v2.0.0 h1:HU9DlAYYWR69jQnLN6cpg0fh0hxW/8d5hnglCXXjW78= github.com/couchbaselabs/gocbconnstr/v2 v2.0.0/go.mod h1:o7T431UOfFVHDNvMBUmUxpHnhivwv7BziUao/nMl81E= -github.com/couchbaselabs/rosmar v0.0.0-20260518154355-0e98444f7414 h1:eq6+zJXkB/lcx2FRFmW9/gcHsulonOX66LARFki60JE= -github.com/couchbaselabs/rosmar v0.0.0-20260518154355-0e98444f7414/go.mod h1:026Pe8kW5vBQqxE08AyOsIy0hRfrI0D2h13sByJ+Msk= +github.com/couchbaselabs/rosmar v0.0.0-20260519101035-3ad6c53465b6 h1:vsnUaRFOTdZV1XYBk8+pW2dIFOUtdkW/1NDUJIdKbTQ= +github.com/couchbaselabs/rosmar v0.0.0-20260519101035-3ad6c53465b6/go.mod h1:1l94QpE95rePyFVs0Xad6gaQ5phXwcGIuQxclceP4C4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= From dc70bc5d0d1f73a509ee0133c089952a9a572598 Mon Sep 17 00:00:00 2001 From: Ben Brooks Date: Tue, 19 May 2026 12:34:48 +0100 Subject: [PATCH 4/9] drop idsOnly field from gocbScanResultIterator and use item.IDOnly() --- base/collection_rangescan.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/base/collection_rangescan.go b/base/collection_rangescan.go index f828b774cb..5c710b1370 100644 --- a/base/collection_rangescan.go +++ b/base/collection_rangescan.go @@ -39,7 +39,7 @@ func (c *Collection) Scan(_ context.Context, scanType sgbucket.ScanType, opts sg return nil, err } - return &gocbScanResultIterator{result: result, idsOnly: opts.IDsOnly}, nil + return &gocbScanResultIterator{result: result}, nil } func toGocbScanType(scanType sgbucket.ScanType) (gocb.ScanType, error) { @@ -59,9 +59,8 @@ func toGocbScanType(scanType sgbucket.ScanType) (gocb.ScanType, error) { } type gocbScanResultIterator struct { - result *gocb.ScanResult - idsOnly bool - err error + result *gocb.ScanResult + err error } func (it *gocbScanResultIterator) Next(_ context.Context) *sgbucket.ScanResultItem { @@ -76,7 +75,7 @@ func (it *gocbScanResultIterator) Next(_ context.Context) *sgbucket.ScanResultIt ID: item.ID(), Cas: uint64(item.Cas()), } - if !it.idsOnly { + if !item.IDOnly() { if err := item.Content(&result.Body); err != nil { it.err = fmt.Errorf("failed to decode scan result body for %s: %w", item.ID(), err) return nil From ed09a4fd3ba8009e043068b1e3a18b3b182c529d Mon Sep 17 00:00:00 2001 From: Ben Brooks Date: Tue, 19 May 2026 12:40:28 +0100 Subject: [PATCH 5/9] TestRangeScanPrefix cleanup --- base/collection_rangescan_test.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/base/collection_rangescan_test.go b/base/collection_rangescan_test.go index 6d8c06378e..981ceb5e30 100644 --- a/base/collection_rangescan_test.go +++ b/base/collection_rangescan_test.go @@ -226,7 +226,9 @@ func TestRangeScan(t *testing.T) { }) } -func TestRangeScanPrefixSentinelBoundary(t *testing.T) { +// Tests that a prefix-based range scan actually captures all valid doc IDs in the range. +// Reproduces GOCBC-1821 when using gocb.MaximumTerm() or gocb.NewRangeScanForPrefix() +func TestRangeScanPrefixBoundaries(t *testing.T) { ctx := TestCtx(t) bucket := GetTestBucket(t) @@ -236,17 +238,19 @@ func TestRangeScanPrefixSentinelBoundary(t *testing.T) { scanStore, ok := AsRangeScanStore(dataStore) require.True(t, ok, "DataStore does not support range scan") - const prefix = "k" - // Bound = prefix + [0xF4, '8', 'f', 'b', 'f', 'b', 'f'], inclusive. + const prefix = "_sync" type test struct { key string included bool why string } cases := []test{ - // Byte-level boundary cases - {"j", false, "different prefix, sorts before the requested prefix"}, + // boundaries + {"_synb", false, "different prefix, sorts before the requested prefix"}, {prefix, true, "the prefix itself sorts before any extension"}, + {"_synd", false, "different prefix, sorts after the bound"}, + + // within range {prefix + "\xf3", true, "0xF3 < 0xF4 at byte after prefix"}, {prefix + "\xf4", true, "shorter than bound and is a byte-prefix of it"}, {prefix + "\xf4\x37", true, "byte 2: 0x37 < 0x38"}, @@ -257,8 +261,9 @@ func TestRangeScanPrefixSentinelBoundary(t *testing.T) { {prefix + "\xf48fbfbg", true, "byte 7: 0x67 > 0x66 (final byte just above bound)"}, {prefix + "\xf48fbfc0", true, "byte 6: 0x63 > 0x62"}, {prefix + "\xf4\x39", true, "byte 2: 0x39 > 0x38"}, + + // out of range with prefix {prefix + "\xf5", false, "0xF5 is start of invalid UTF-8"}, - {"l", false, "different prefix, sorts after the bound"}, // Real-world UTF-8 cases {prefix + "é", true, "U+00E9 (Latin-1, 0xC3 0xA9): leading byte 0xC3 < 0xF4"}, @@ -266,18 +271,16 @@ func TestRangeScanPrefixSentinelBoundary(t *testing.T) { {prefix + "\U0001F600", true, "U+1F600 😀 (emoji, 0xF0 0x9F 0x98 0x80): leading byte 0xF0 < 0xF4"}, {prefix + "\U000FFFFF", true, "U+FFFFF (PUA-A end, 0xF3 0xBF 0xBF 0xBF): leading byte 0xF3 < 0xF4"}, // - // Where the filter gocb chose breaks down: + // Where the gocb.MaximumTerm() value breaks: // U+100000–U+10FFFF (Supplementary Private Use Area-B) // leading byte 0xF4 followed by continuation bytes 0x80–0x8F, all > 0x38 (8 ASCII) {prefix + "\U00100000", true, "U+100000 (PUA-B start, 0xF4 0x80 0x80 0x80): byte after 0xF4 is 0x80 > 0x38"}, {prefix + "\U0010FFFF", true, "U+10FFFF (highest valid Unicode, 0xF4 0x8F 0xBF 0xBF): byte after 0xF4 is 0x8F > 0x38"}, } - for _, c := range cases { - require.NoError(t, dataStore.SetRaw(ctx, c.key, 0, nil, []byte(`{}`)), "insert %q", c.key) - } var expected []string for _, c := range cases { + require.NoError(t, dataStore.SetRaw(ctx, c.key, 0, nil, []byte(`{}`)), "insert %q", c.key) if c.included { expected = append(expected, c.key) } From 7ca06127bd1520211dea36d8bf1064be8956f079 Mon Sep 17 00:00:00 2001 From: Ben Brooks Date: Tue, 19 May 2026 12:43:12 +0100 Subject: [PATCH 6/9] cleanup for GetGoCBv2Bucket retry --- base/collection_rangescan_test.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/base/collection_rangescan_test.go b/base/collection_rangescan_test.go index 981ceb5e30..31861cdb93 100644 --- a/base/collection_rangescan_test.go +++ b/base/collection_rangescan_test.go @@ -207,14 +207,8 @@ func TestRangeScan(t *testing.T) { BucketName: bucketName, } - // RBAC user creation may take a moment to propagate to the KV service, - // so retry the connection a few times before giving up. - var rbacBucket *GocbV2Bucket - require.EventuallyWithT(t, func(c *assert.CollectT) { - var connectErr error - rbacBucket, connectErr = GetGoCBv2Bucket(ctx, rbacSpec) - assert.NoError(c, connectErr, "Failed to open bucket with RBAC user %q (roles %v)", rbacUsername, roles) - }, 10*time.Second, 500*time.Millisecond) + rbacBucket, connectErr := GetGoCBv2Bucket(ctx, rbacSpec) + require.NoError(t, connectErr, "Failed to open bucket with RBAC user %q (roles %v)", rbacUsername, roles) defer rbacBucket.Close(ctx) rbacDataStore, err := rbacBucket.GetMatchingDataStore(ctx, adminDataStore) From 54032d93f4329c59a058849b5112abfb3d85da76 Mon Sep 17 00:00:00 2001 From: Ben Brooks Date: Tue, 19 May 2026 14:20:20 +0100 Subject: [PATCH 7/9] bump for merged modules --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 1ed398c106..72edd369b1 100644 --- a/go.mod +++ b/go.mod @@ -13,11 +13,11 @@ require ( github.com/couchbase/gocb/v2 v2.12.1 github.com/couchbase/gocbcore/v10 v10.9.2-0.20260430103215-edcade542007 github.com/couchbase/gomemcached v0.2.1 - github.com/couchbase/sg-bucket v0.0.0-20260519100927-7e52d6f10caa + github.com/couchbase/sg-bucket v0.0.0-20260519125731-9e05bfca7027 github.com/couchbasedeps/fast-skiplist v0.0.0-20250722125747-e0dd031fe2ac github.com/couchbaselabs/go-fleecedelta v0.0.0-20220909152808-6d09efa7a338 github.com/couchbaselabs/gocbconnstr v1.0.5 - github.com/couchbaselabs/rosmar v0.0.0-20260519101035-3ad6c53465b6 + github.com/couchbaselabs/rosmar v0.0.0-20260519130433-967376be7244 github.com/elastic/gosigar v0.14.4 github.com/felixge/fgprof v0.9.5 github.com/go-jose/go-jose/v4 v4.1.4 diff --git a/go.sum b/go.sum index 48e641e381..057408e9e9 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ github.com/couchbase/goprotostellar v1.0.5 h1:pmR4H87zbYymIdTR1owyUZsfQ7NupkfCuN github.com/couchbase/goprotostellar v1.0.5/go.mod h1:X58ot5FRqlBTBkwG/oI4klunpu4MApjGktheqeRWQw0= github.com/couchbase/goutils v0.1.2 h1:gWr8B6XNWPIhfalHNog3qQKfGiYyh4K4VhO3P2o9BCs= github.com/couchbase/goutils v0.1.2/go.mod h1:h89Ek/tiOxxqjz30nPPlwZdQbdB8BwgnuBxeoUe/ViE= -github.com/couchbase/sg-bucket v0.0.0-20260519100927-7e52d6f10caa h1:+PyCQ/N7Dgoc2ldcy1BJTHc9u/TSSjTM2OQaqTAQrRU= -github.com/couchbase/sg-bucket v0.0.0-20260519100927-7e52d6f10caa/go.mod h1:zZspn0FpL3Q9ol/KN9dpR0u/p5f8cJpETk8GVxcegqA= +github.com/couchbase/sg-bucket v0.0.0-20260519125731-9e05bfca7027 h1:4Taf1eBfvKZva1e/RrMmiRLBhLfKHd29jtDrSrfJKjc= +github.com/couchbase/sg-bucket v0.0.0-20260519125731-9e05bfca7027/go.mod h1:zZspn0FpL3Q9ol/KN9dpR0u/p5f8cJpETk8GVxcegqA= github.com/couchbase/tools-common/cloud/v8 v8.1.3 h1:+fH2+3E8KV8xyXXzbEJNhosMqh08ahauZWlu0m5qtJA= github.com/couchbase/tools-common/cloud/v8 v8.1.3/go.mod h1:5wEMtM4rX92Zl9ylQKEJFgmYNUyFVoWzXCvo31YNO+U= github.com/couchbase/tools-common/fs v1.0.3 h1:KXhisN+hmp5yicOWkUBNjJcd/WsblHA2SVShjL2eGiY= @@ -68,8 +68,8 @@ github.com/couchbaselabs/gocbconnstr v1.0.5 h1:e0JokB5qbcz7rfnxEhNRTKz8q1svoRvDo github.com/couchbaselabs/gocbconnstr v1.0.5/go.mod h1:KV3fnIKMi8/AzX0O9zOrO9rofEqrRF1d2rG7qqjxC7o= github.com/couchbaselabs/gocbconnstr/v2 v2.0.0 h1:HU9DlAYYWR69jQnLN6cpg0fh0hxW/8d5hnglCXXjW78= github.com/couchbaselabs/gocbconnstr/v2 v2.0.0/go.mod h1:o7T431UOfFVHDNvMBUmUxpHnhivwv7BziUao/nMl81E= -github.com/couchbaselabs/rosmar v0.0.0-20260519101035-3ad6c53465b6 h1:vsnUaRFOTdZV1XYBk8+pW2dIFOUtdkW/1NDUJIdKbTQ= -github.com/couchbaselabs/rosmar v0.0.0-20260519101035-3ad6c53465b6/go.mod h1:1l94QpE95rePyFVs0Xad6gaQ5phXwcGIuQxclceP4C4= +github.com/couchbaselabs/rosmar v0.0.0-20260519130433-967376be7244 h1:FcgPhQDxgfUA6n3f3FRQ/+SCNfHZsQva23+3ntqgT0g= +github.com/couchbaselabs/rosmar v0.0.0-20260519130433-967376be7244/go.mod h1:ozhv5+nbL11OjaaoGwzNasfcmrG1AFr2DLPpvgNhH3U= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= From 23e27f0391f786e3cdc0cfea1a02057cb2262f64 Mon Sep 17 00:00:00 2001 From: Ben Brooks Date: Tue, 19 May 2026 15:03:07 +0100 Subject: [PATCH 8/9] fix comment about range scan snapshot requirements --- base/collection_rangescan_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/base/collection_rangescan_test.go b/base/collection_rangescan_test.go index 31861cdb93..127c998fee 100644 --- a/base/collection_rangescan_test.go +++ b/base/collection_rangescan_test.go @@ -38,8 +38,10 @@ func collectScanIDs(t testing.TB, ctx context.Context, rss sgbucket.RangeScanSto } // rangeScanFixture seeds a fixed set of documents and waits for them to be visible to scan. -// CBS range scan may not immediately reflect recent writes (requires persistence) so callers -// should wait via the returned helper before running ordering-sensitive assertions. +// KV range scan reads from a per-vBucket snapshot; we do not pass SnapshotRequirements on +// CreateRangeScan, so a scan issued immediately after a write can miss it until the vBucket's +// scan view catches up. The wait is independent of the on-disk storage engine (couchstore vs +// magma) and is not about persistence to disk. func rangeScanFixture(t *testing.T, ctx context.Context, writeDataStore sgbucket.DataStore, scanStore sgbucket.RangeScanStore) []string { t.Helper() docs := map[string][]byte{ From d3200eb2fbff36779877f84c60c864add949f9f8 Mon Sep 17 00:00:00 2001 From: Ben Brooks Date: Tue, 19 May 2026 15:14:31 +0100 Subject: [PATCH 9/9] readd retry loop around GetGoCBv2Bucket after creating RBAC user --- base/collection_rangescan_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/base/collection_rangescan_test.go b/base/collection_rangescan_test.go index 127c998fee..ce7270d9f5 100644 --- a/base/collection_rangescan_test.go +++ b/base/collection_rangescan_test.go @@ -209,8 +209,14 @@ func TestRangeScan(t *testing.T) { BucketName: bucketName, } - rbacBucket, connectErr := GetGoCBv2Bucket(ctx, rbacSpec) - require.NoError(t, connectErr, "Failed to open bucket with RBAC user %q (roles %v)", rbacUsername, roles) + // RBAC user creation propagates to KV asynchronously, so retry the bucket + // open until the new user is accepted by memcached. + var rbacBucket *GocbV2Bucket + require.EventuallyWithT(t, func(c *assert.CollectT) { + var connectErr error + rbacBucket, connectErr = GetGoCBv2Bucket(ctx, rbacSpec) + assert.NoError(c, connectErr, "Failed to open bucket with RBAC user %q (roles %v)", rbacUsername, roles) + }, 10*time.Second, 500*time.Millisecond) defer rbacBucket.Close(ctx) rbacDataStore, err := rbacBucket.GetMatchingDataStore(ctx, adminDataStore)