Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion rest/admin_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1081,7 +1081,10 @@ func (h *handler) handlePutDbConfig() (err error) {
h.setEtag(updatedDbConfig.Version)
h.server._databasesLock.Lock()
defer h.server._databasesLock.Unlock()
h.server._dbConfigs[dbName].cfgCas = cas
// Another request or the config poller can remove or reload the database after this update loaded it.
if loadedConfig, ok := h.server._dbConfigs[dbName]; ok && loadedConfig.Version == updatedDbConfig.Version {
loadedConfig.cfgCas = cas
}

base.Audit(h.ctx(), base.AuditIDUpdateDatabaseConfig, auditFields)
return base.HTTPErrorf(http.StatusCreated, "updated")
Expand Down
43 changes: 43 additions & 0 deletions rest/cluster_compat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"fmt"
"maps"
"net/http"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -643,6 +644,48 @@ func TestClusterCompatAppliedDBVersionUpdatedByUpdateConfigAndReloadDatabase(t *
assert.NotEqual(t, versionAfterCreate, versionAfterOffline, "version should change after taking db offline")
}

// TestUpdateDbConfigWithConcurrentPeerHeartbeats verifies that POST /{db}/_config succeeds when a peer
// heartbeat writes the registry during every database reload, and that the reload is not repeated.
func TestUpdateDbConfigWithConcurrentPeerHeartbeats(t *testing.T) {
// Heartbeat from inside the reload, between UpdateConfig's registry read and write, so every
// attempt that reloads the database gets a CAS mismatch.
var reloads atomic.Int32
var rt *RestTester
rt = NewRestTester(t, &RestTesterConfig{
PersistentConfig: true,
ConnectToBucketFn: func(ctx context.Context, spec base.BucketSpec, failFast bool) (base.Bucket, error) {
reloads.Add(1)
_, err := rt.ServerContext().BootstrapContext.RegisterNodeVersion(ctx, RegisterNodeVersionOpts{
BucketName: spec.BucketName,
NodeUID: "peer",
Version: base.NodeClusterCompatVersion,
HeartbeatExpiry: time.Hour,
})
assert.NoError(t, err)
return db.ConnectToBucket(ctx, spec, failFast)
},
})
defer rt.Close()
RequireStatus(t, rt.CreateDatabase("db", rt.NewDbConfig()), http.StatusCreated)
reloads.Store(0)
sc := rt.ServerContext()

dbConfig := rt.NewDbConfig()
dbConfig.RevsLimit = new(uint32(123))
RequireStatus(t, rt.UpsertDbConfig("db", dbConfig), http.StatusCreated)
assert.Equal(t, int32(1), reloads.Load())

var bucketConfig DatabaseConfig
bucketCas, err := sc.BootstrapContext.GetConfig(rt.Context(), rt.Bucket().GetName(), sc.Config.Bootstrap.ConfigGroupID, "db", &bucketConfig)
require.NoError(t, err)
require.Equal(t, uint32(123), *bucketConfig.RevsLimit)
loadedConfig := sc.GetDatabaseConfig("db")
require.NotNil(t, loadedConfig)
assert.Equal(t, bucketConfig.Version, loadedConfig.Version)
assert.Equal(t, bucketCas, loadedConfig.cfgCas)
assert.Equal(t, uint32(123), *loadedConfig.RevsLimit)
}

// TestIsConfigFullyApplied is a truth-table test covering the edge cases of
// GatewayRegistry.IsConfigFullyApplied.
func TestIsConfigFullyApplied(t *testing.T) {
Expand Down
206 changes: 206 additions & 0 deletions rest/config_concurrency_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// 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 rest

import (
"context"
"fmt"
"math/rand/v2"
"net/http"
"slices"
"sync"
"testing"
"time"

"github.com/couchbase/sync_gateway/base"
"github.com/couchbase/sync_gateway/db"
"github.com/couchbase/sync_gateway/testing/assert"
"github.com/couchbase/sync_gateway/testing/require"
)

// dbConfigOperation is one admin API operation that TestConcurrentDbConfigOperationsAcrossNodes sends to a random node.
type dbConfigOperation struct {
Comment on lines +27 to +28

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inline struct if it's test specific

name string
weight int
allowed []int // statuses expected while other nodes create, update and delete the same database
sendFunc func(rt *RestTester, rng *rand.Rand) *TestResponse
}

func dbConfigOperations() []dbConfigOperation {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think I'd expect this to be used for another test, so having standalone builder functions like this (Claude tends to gravitate towards this?) means we're just polluting the rest package APIs really

return []dbConfigOperation{
{
name: "create",
weight: 2,
allowed: []int{http.StatusCreated, http.StatusPreconditionFailed},
sendFunc: func(rt *RestTester, _ *rand.Rand) *TestResponse {
return rt.SendAdminRequest(http.MethodPut, "/db/", string(base.MustJSONMarshal(rt.TB(), rt.NewDbConfig())))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be using a placeholder instead of db?

},
},
{
name: "upsert config",
weight: 4,
allowed: []int{http.StatusCreated, http.StatusNotFound},
sendFunc: func(rt *RestTester, rng *rand.Rand) *TestResponse {
dbConfig := rt.NewDbConfig()
dbConfig.RevsLimit = new(uint32(100 + rng.IntN(100)))
return rt.SendAdminRequest(http.MethodPost, "/db/_config", string(base.MustJSONMarshal(rt.TB(), dbConfig)))
},
},
{
name: "replace config",
weight: 2,
allowed: []int{http.StatusCreated, http.StatusNotFound},
sendFunc: func(rt *RestTester, rng *rand.Rand) *TestResponse {
dbConfig := rt.NewDbConfig()
dbConfig.RevsLimit = new(uint32(100 + rng.IntN(100)))
return rt.SendAdminRequest(http.MethodPut, "/db/_config", string(base.MustJSONMarshal(rt.TB(), dbConfig)))
},
},
{
name: "update sync function",
weight: 2,
allowed: []int{http.StatusOK, http.StatusNotFound},
sendFunc: func(rt *RestTester, rng *rand.Rand) *TestResponse {
return rt.SendAdminRequest(http.MethodPut, "/db/_config/sync", fmt.Sprintf(`function(doc) { channel("ch%d"); }`, rng.IntN(10)))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint uses a full keyspace not db (sync functions are collection specific)

},
},
{
name: "offline",
weight: 1,
allowed: []int{http.StatusOK, http.StatusNotFound},
sendFunc: func(rt *RestTester, _ *rand.Rand) *TestResponse {
return rt.SendAdminRequest(http.MethodPost, "/db/_offline", "")
},
},
{
name: "online",
weight: 1,
allowed: []int{http.StatusOK, http.StatusNotFound},
sendFunc: func(rt *RestTester, _ *rand.Rand) *TestResponse {
return rt.SendAdminRequest(http.MethodPost, "/db/_online", "")
},
},
{
name: "delete",
weight: 1,
allowed: []int{http.StatusOK, http.StatusNotFound},
sendFunc: func(rt *RestTester, _ *rand.Rand) *TestResponse {
return rt.SendAdminRequest(http.MethodDelete, "/db/", "")
},
},
}
}

// isExpectedStatus reports whether resp is one of the operation's allowed statuses.
func (op dbConfigOperation) isExpectedStatus(resp *TestResponse) bool {
return slices.Contains(op.allowed, resp.Code)
}

// pickDbConfigOperation returns a random operation, chosen in proportion to its weight.
func pickDbConfigOperation(rng *rand.Rand, operations []dbConfigOperation) dbConfigOperation {
totalWeight := 0
for _, op := range operations {
totalWeight += op.weight
}
n := rng.IntN(totalWeight)
for _, op := range operations {
if n < op.weight {
return op
}
n -= op.weight
}
return operations[len(operations)-1]
}

// TestConcurrentDbConfigOperationsAcrossNodes sends random database config operations to random nodes of a
// cluster that polls configs, and therefore heartbeats into the registry, more often than a database reloads.
// It then checks that every node and the registry converge on one config. The seed is logged, but goroutine
// scheduling still varies between runs, so a failure is not guaranteed to reproduce.
func TestConcurrentDbConfigOperationsAcrossNodes(t *testing.T) {
t.Skip("CBG-5935 config writes can panic when a concurrent config write reloads the database")
if testing.Short() {
t.Skip("skipping randomized concurrency test in short mode")
}
const (
numNodes = 3
numWorkers = 4
opsPerWorker = 15
pollInterval = 50 * time.Millisecond
reloadDelay = 150 * time.Millisecond // longer than pollInterval, as a Couchbase Server backed reload is in a real cluster
convergeTimeout = 30 * time.Second
)
seed := rand.Uint64()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if this randomness was an injectable seed so it could be deterministically re-run. It's not too easy to grab shuffle value when -shuffle=on is used - but it is possible (via TestMain) which might be nice?

// testing keeps the seed it picks for -shuffle=on to itself. Choose our own value here instead so tests can read and a run can be repeated with -shuffle=N.
flag.Parse()
if f := flag.Lookup("test.shuffle"); f != nil && f.Value.String() == "on" {
		if err := flag.Set("test.shuffle", strconv.FormatInt(time.Now().UnixNano(), 10)); err != nil {
				FatalfCtx(ctx, "could not set test.shuffle: %v", err)
		}
}

t.Logf("seed: %d", seed)

ctx := base.TestCtx(t)
rtc := NewRestTesterCluster(t, &RestTesterClusterConfig{
NumNodes: numNodes,
MutateStartupConfig: func(config *StartupConfig) {
config.Bootstrap.ConfigUpdateFrequency = base.NewConfigDuration(pollInterval)
},
ConnectToBucketFn: func(ctx context.Context, spec base.BucketSpec, failFast bool) (base.Bucket, error) {
time.Sleep(reloadDelay)
return db.ConnectToBucket(ctx, spec, failFast)
},
})
defer rtc.Close(ctx)

operations := dbConfigOperations()
var wg sync.WaitGroup
for worker := range numWorkers {
wg.Go(func() {
rng := rand.New(rand.NewPCG(seed, uint64(worker)))
for i := range opsPerWorker {
op := pickDbConfigOperation(rng, operations)
node := rng.IntN(numNodes)
resp := op.sendFunc(rtc.Node(node), rng)
assert.Truef(t, op.isExpectedStatus(resp), "worker %d op %d: %s on node %d returned %d, expected one of %v: %s",
worker, i, op.name, node, resp.Code, op.allowed, resp.Body.String())
}
})
}
wg.Wait()

// Leave the database online with a known config, then wait for every node and the registry to agree on it.
rtA := rtc.Node(0)
require.EventuallyWithT(t, func(c *assert.CollectT) {
resp := rtA.CreateDatabase("db", rtA.NewDbConfig())
assert.Contains(c, []int{http.StatusCreated, http.StatusPreconditionFailed}, resp.Code, resp.Body.String())
resp = rtA.UpsertDbConfig("db", rtA.NewDbConfig())
assert.Equal(c, http.StatusCreated, resp.Code, resp.Body.String())
resp = rtA.SendAdminRequest(http.MethodPost, "/db/_online", "")
assert.Equal(c, http.StatusOK, resp.Code, resp.Body.String())
}, convergeTimeout, 100*time.Millisecond)

bootstrap := rtA.ServerContext().BootstrapContext
bucketName := rtA.Bucket().GetName()
groupID := rtA.ServerContext().Config.Bootstrap.ConfigGroupID
require.EventuallyWithT(t, func(c *assert.CollectT) {
var bucketConfig DatabaseConfig
_, err := bootstrap.GetConfig(ctx, bucketName, groupID, "db", &bucketConfig)
if !assert.NoError(c, err) {
return
}
registry, err := bootstrap.getGatewayRegistry(ctx, bucketName)
if !assert.NoError(c, err) {
return
}
registryDb, ok := registry.getRegistryDatabase(groupID, "db")
if assert.True(c, ok, "db missing from registry") {
assert.Equal(c, bucketConfig.Version, registryDb.Version, "registry version")
assert.Nil(c, registryDb.PreviousVersion, "registry has an unfinished update")
}
rtc.ForEachNode(func(rt *RestTester) {
nodeConfig := rt.ServerContext().GetDatabaseConfig("db")
if assert.NotNil(c, nodeConfig, "db not loaded on node") {
assert.Equal(c, bucketConfig.Version, nodeConfig.Version, "node config version")
}
})
}, convergeTimeout, 100*time.Millisecond)
}
40 changes: 33 additions & 7 deletions rest/config_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ func (b *bootstrapContext) InsertConfig(ctx context.Context, bucketName, groupID
// 4. Update the registry to remove the previous version
func (b *bootstrapContext) UpdateConfig(ctx context.Context, bucketName, groupID, dbName string, updateCallback func(bucketConfig *DatabaseConfig) (updatedConfig *DatabaseConfig, err error)) (newCAS uint64, err error) {
var updatedConfig *DatabaseConfig
var callbackCfgCas uint64 // cfgCas of the bucket config that produced updatedConfig
var previousVersion string
var createdAtTime *time.Time

Expand All @@ -183,10 +184,15 @@ func (b *bootstrapContext) UpdateConfig(ctx context.Context, bucketName, groupID

// Step 2. Update registry to update registry entry, and move previous registry entry to PreviousVersion
previousVersion = existingConfig.Version
var callbackErr error
updatedConfig, callbackErr = updateCallback(existingConfig)
if callbackErr != nil {
return false, callbackErr, nil
// A retry after a concurrent registry write (e.g. a heartbeat) reuses the callback's result, because the callback
// can have side effects such as reloading the database. An unchanged cfgCas means the bucket config is unchanged.
if updatedConfig == nil || existingConfig.cfgCas != callbackCfgCas {
var callbackErr error
updatedConfig, callbackErr = updateCallback(existingConfig)
if callbackErr != nil {
return false, callbackErr, nil
}
callbackCfgCas = existingConfig.cfgCas
}

// Update database in registry
Expand Down Expand Up @@ -459,6 +465,7 @@ func (b *bootstrapContext) GetDatabaseConfigs(ctx context.Context, bucketName, g
// getConfigVersionWithRetry attempts to retrieve the specified config file.
// On file not found, will perform backoff retry up to specified timeout. On timeout, returns the nil and rollback error.
// On mismatched version when registry version is newer, will perform backoff retry up to timeout. On timeout, returns the config and rollback error.
// While retrying, returns ErrConfigRegistryReloadRequired if the registry no longer has the requested version.
// On mismatched version when config version is newer, returns the config and ErrConfigVersionMismatch.
func (b *bootstrapContext) getConfigVersionWithRetry(ctx context.Context, bucketName, groupID, dbName, version string) (*DatabaseConfig, error) {

Expand All @@ -472,7 +479,7 @@ func (b *bootstrapContext) getConfigVersionWithRetry(ctx context.Context, bucket
metadataKey := PersistentConfigKey(ctx, groupID, dbName)
cas, err := b.Connection.GetMetadataDocument(ctx, bucketName, metadataKey, config)
if base.IsDocNotFoundError(err) {
return true, base.ErrConfigRegistryRollback, nil
return b.retryUnlessRegistryVersionChanged(ctx, bucketName, groupID, dbName, version, nil)
}
if err != nil {
return false, err, nil
Expand Down Expand Up @@ -501,7 +508,7 @@ func (b *bootstrapContext) getConfigVersionWithRetry(ctx context.Context, bucket
}

base.InfofCtx(ctx, base.KeyConfig, "getConfigVersionWithRetry for key %s found version mismatch, retrying. Requested: %s, Found: %s", metadataKey, version, config.Version)
return true, base.ErrConfigRegistryRollback, config
return b.retryUnlessRegistryVersionChanged(ctx, bucketName, groupID, dbName, version, config)
}

// Kick off the retry loop
Expand All @@ -528,6 +535,21 @@ func (b *bootstrapContext) getConfigVersionWithRetry(ctx context.Context, bucket
return nil, err
}

// retryUnlessRegistryVersionChanged keeps waiting for an in-flight write of version while the registry still has it,
// and otherwise (for example after a concurrent delete) asks the caller to reload the registry.
func (b *bootstrapContext) retryUnlessRegistryVersionChanged(ctx context.Context, bucketName, groupID, dbName, version string, config *DatabaseConfig) (shouldRetry bool, err error, value any) {
registry, err := b.getGatewayRegistry(ctx, bucketName)
if err != nil {
return false, err, nil
}
registryDb, ok := registry.getRegistryDatabase(groupID, dbName)
if !ok || registryDb.IsDeleted() || registryDb.Version != version {
base.InfofCtx(ctx, base.KeyConfig, "Registry no longer has version %s for db %s while waiting for its config - reload required", version, base.MD(dbName))
return false, base.ErrConfigRegistryReloadRequired, nil
}
return true, base.ErrConfigRegistryRollback, config
}

// getDatabaseConfig retrieves the database config, and enforces version match. On config not found or mismatched
// version, will retry with backoff (to wait for in-flight updates to complete). After retry timeout,
// triggers registry rollback and returns rollback error
Expand All @@ -536,7 +558,11 @@ func (b *bootstrapContext) getDatabaseConfig(ctx context.Context, bucketName, gr
ctx = b.addDatabaseLogContext(ctx, &DbConfig{Name: dbName})
config, err := b.getConfigVersionWithRetry(ctx, bucketName, groupID, dbName, version)
if err != nil {
if err == base.ErrConfigRegistryRollback {
// A config newer than the registry means a concurrent update wrote both after the registry was read.
if errors.Is(err, base.ErrConfigVersionMismatch) {
return nil, base.ErrConfigRegistryReloadRequired
}
if errors.Is(err, base.ErrConfigRegistryRollback) {
base.InfofCtx(ctx, base.KeyConfig, "Registry rollback required for bucket: %s, groupID: %s, dbName:%s", bucketName, groupID, dbName)
rollbackErr := b.rollbackRegistry(ctx, bucketName, groupID, dbName, config, registry)
// On successful registry rollback, caller needs reload registry
Expand Down
Loading
Loading