Skip to content

Commit 2e5d0ad

Browse files
authored
CBG-5413: supportability enhancements around metadata migration (#8320)
1 parent 8974d76 commit 2e5d0ad

13 files changed

Lines changed: 408 additions & 11 deletions

base/bootstrap.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ type BootstrapConnection interface {
8686
// information about whether a new DB on this bucket has opted in (e.g., PUT /<db>/ with
8787
// use_system_metadata_collection: true) so the very first bootstrap doc lands correctly.
8888
SetBucketBootstrapTargetHint(ctx context.Context, bucket string, optInHint bool) error
89+
// CachedBootstrapTargets returns the cached bootstrap doc target for each bucket, for observability purposes. Values are "system_mobile", "default".
90+
// The snapshot is not guaranteed to be consistent across concurrent updates.
91+
CachedBootstrapTargets() map[string]string
8992
// Close releases any long-lived connections
9093
Close()
9194
}
@@ -116,9 +119,9 @@ type CouchbaseCluster struct {
116119
useSystemMetadataCollection bool // When true, bootstrap metadata is stored in _system._mobile, with read-fallback to _default._default during migration
117120
migrationComplete atomic.Bool // When set, fallback reads are skipped even if useSystemMetadataCollection is true
118121
// bucketBootstrapTargets caches the resolved bootstrap-doc location for each bucket.
119-
// Resolved lazily on first interaction (or eagerly via SetBucketBootstrapTargetHint). Values
120-
// are bucketBootstrapTarget; absence of an entry means "fall back to the connection-wide flag."
121-
bucketBootstrapTargets sync.Map
122+
// Resolved lazily on first interaction (or eagerly via SetBucketBootstrapTargetHint).
123+
// Absence of an entry means "fall back to the connection-wide flag."
124+
bucketBootstrapTargets SyncMap[string, bucketBootstrapTarget]
122125
useGOCBFastFailRetry bool // When true, readiness checks fail fast instead of using the best-effort retry strategy
123126
}
124127

@@ -131,6 +134,16 @@ const (
131134
bucketTargetSystemMobile // _system._mobile primary; _default._default fallback until migration complete
132135
)
133136

137+
func (t bucketBootstrapTarget) String() string {
138+
switch t {
139+
case bucketTargetSystemMobile:
140+
return "_system._mobile"
141+
case bucketTargetDefault:
142+
return "_default._default"
143+
} // exhaustive:enforce
144+
return ""
145+
}
146+
134147
type BucketConnectionMode int
135148

136149
const (
@@ -372,7 +385,7 @@ func (cc *CouchbaseCluster) metadataCollections(b *gocb.Bucket) (primary, fallba
372385
cached, hasCachedTarget := cc.bucketBootstrapTargets.Load(b.Name())
373386
useSystemMobile := cc.useSystemMetadataCollection
374387
if hasCachedTarget {
375-
useSystemMobile = cached.(bucketBootstrapTarget) == bucketTargetSystemMobile
388+
useSystemMobile = cached == bucketTargetSystemMobile
376389
}
377390
if !useSystemMobile {
378391
// When this bucket has any opt-in indication — either cached as bucketTargetDefault
@@ -399,7 +412,7 @@ func (cc *CouchbaseCluster) metadataCollections(b *gocb.Bucket) (primary, fallba
399412
// the bucket-level migration). A no-op when the cache already says _system._mobile, since the
400413
// systemMobile→default fallback direction is the legitimate in-progress-migration legacy path.
401414
func (cc *CouchbaseCluster) noteBucketFallbackHit(bucketName string) {
402-
if cached, ok := cc.bucketBootstrapTargets.Load(bucketName); ok && cached.(bucketBootstrapTarget) == bucketTargetSystemMobile {
415+
if cached, ok := cc.bucketBootstrapTargets.Load(bucketName); ok && cached == bucketTargetSystemMobile {
403416
return
404417
}
405418
cc.bucketBootstrapTargets.Store(bucketName, bucketTargetSystemMobile)
@@ -436,6 +449,20 @@ func (cc *CouchbaseCluster) SetBucketBootstrapTargetHint(ctx context.Context, bu
436449
return nil
437450
}
438451

452+
// CachedBootstrapTargets returns the cached bootstrap doc target for each bucket, for observability purposes. Values are "system_mobile", "default".
453+
// The snapshot is not guaranteed to be consistent across concurrent updates.
454+
func (cc *CouchbaseCluster) CachedBootstrapTargets() map[string]string {
455+
targets := make(map[string]string)
456+
for key, value := range cc.bucketBootstrapTargets.Range {
457+
if s := value.String(); s != "" {
458+
targets[key] = s
459+
} else {
460+
targets[key] = "unknown"
461+
}
462+
}
463+
return targets
464+
}
465+
439466
// probeRegistryLocation checks both collections for an existing _sync:registry doc. Returns
440467
// (target, found): when found, target identifies the collection; when not found, target is
441468
// undefined and the caller picks based on its own policy (cluster flag or per-DB hint).

base/dual_metadata_store.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,32 @@ func (ms *MetadataStore) MigrationComplete() bool {
6262
return ms.migrationComplete.Load()
6363
}
6464

65+
// MetadataStoreMode classifies a database's metadata store for support / observability.
66+
type MetadataStoreMode string
67+
68+
const (
69+
// MetadataStoreModeFallbackActive means the dual store is wrapping primary+fallback and
70+
// fallback reads are still in play (migration not yet complete).
71+
MetadataStoreModeFallbackActive MetadataStoreMode = "fallback_active"
72+
// MetadataStoreModeFallbackInactive means the dual store is wrapping primary+fallback but
73+
// migration has completed, so reads no longer fall back.
74+
MetadataStoreModeFallbackInactive MetadataStoreMode = "fallback_inactive"
75+
)
76+
77+
// GetMetadataStoreMode classifies the given datastore. Returns an empty string when the
78+
// datastore isn't a *MetadataStore (single-store databases); callers should use JSON
79+
// omitempty so the field is dropped in that case rather than emitted as "".
80+
func GetMetadataStoreMode(ds DataStore) MetadataStoreMode {
81+
ms, ok := ds.(*MetadataStore)
82+
if !ok {
83+
return ""
84+
}
85+
if ms.MigrationComplete() {
86+
return MetadataStoreModeFallbackInactive
87+
}
88+
return MetadataStoreModeFallbackActive
89+
}
90+
6591
// readFromFallback returns true when err indicates the key was not found in primary and metadata migration has
6692
// not yet complete, meaning the operation should be retried against the fallback DataStore.
6793
func (ms *MetadataStore) readFromFallback(ctx context.Context, err error) bool {

base/rosmar_cluster.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import (
1616
"os"
1717
"runtime"
1818
"strings"
19-
"sync"
2019
"sync/atomic"
2120

2221
sgbucket "github.com/couchbase/sg-bucket"
@@ -33,7 +32,7 @@ type RosmarCluster struct {
3332
migrationComplete atomic.Bool // When set, fallback reads are skipped even if useSystemMetadataCollection is true
3433
// bucketBootstrapTargets caches the resolved bootstrap-doc target for each bucket.
3534
// Mirrors CouchbaseCluster.bucketBootstrapTargets — see that comment for the decision tree.
36-
bucketBootstrapTargets sync.Map
35+
bucketBootstrapTargets SyncMap[string, bucketBootstrapTarget]
3736
}
3837

3938
// NewRosmarCluster creates a from a given URL. useSystemMetadataCollection mirrors
@@ -162,7 +161,7 @@ func (c *RosmarCluster) metadataDataStores(ctx context.Context, bucketName strin
162161
cached, hasCachedTarget := c.bucketBootstrapTargets.Load(bucketName)
163162
useSystemMobile := c.useSystemMetadataCollection
164163
if hasCachedTarget {
165-
useSystemMobile = cached.(bucketBootstrapTarget) == bucketTargetSystemMobile
164+
useSystemMobile = cached == bucketTargetSystemMobile
166165
}
167166
if !useSystemMobile {
168167
// Reads/writes route to default first. When this bucket has any opt-in indication —
@@ -197,7 +196,7 @@ func (c *RosmarCluster) metadataDataStores(ctx context.Context, bucketName strin
197196
// the bucket-level migration). A no-op when the cache already says _system._mobile, since the
198197
// systemMobile→default fallback direction is the legitimate in-progress-migration legacy path.
199198
func (c *RosmarCluster) noteBucketFallbackHit(bucketName string) {
200-
if cached, ok := c.bucketBootstrapTargets.Load(bucketName); ok && cached.(bucketBootstrapTarget) == bucketTargetSystemMobile {
199+
if cached, ok := c.bucketBootstrapTargets.Load(bucketName); ok && cached == bucketTargetSystemMobile {
201200
return
202201
}
203202
c.bucketBootstrapTargets.Store(bucketName, bucketTargetSystemMobile)
@@ -672,6 +671,20 @@ func (c *RosmarCluster) MigrateBootstrapDocs(ctx context.Context, bucket string,
672671
return nil
673672
}
674673

674+
// CachedBootstrapTargets returns the cached bootstrap doc target for each bucket, for observability purposes. Values are "system_mobile", "default".
675+
// The snapshot is not guaranteed to be consistent across concurrent updates.
676+
func (c *RosmarCluster) CachedBootstrapTargets() map[string]string {
677+
targets := make(map[string]string)
678+
for key, value := range c.bucketBootstrapTargets.Range {
679+
if s := value.String(); s != "" {
680+
targets[key] = s
681+
} else {
682+
targets[key] = "unknown"
683+
}
684+
}
685+
return targets
686+
}
687+
675688
// Close calls teardown for any cached buckets and removes from cachedBucketConnections
676689
func (c *RosmarCluster) Close() {
677690
}

base/stats.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,13 @@ type MigrationStats struct {
928928
SeqPoisonPillApplied *SgwIntStat `json:"seq_poison_pill_applied"`
929929
// Cumulative count of MigrateMetadata pass invocations.
930930
Passes *SgwIntStat `json:"passes"`
931+
// AbandonedRuns counts metadata migration runs that the orchestrator gave up on after the
932+
// bounded pass loop exhausted itself without a clean pass (zero unknown-prefix remaining
933+
// AND zero per-doc errors on the same pass). Runs that exit early via a hard error from
934+
// MigrateMetadata, or are stopped cooperatively via the terminator, are not counted here —
935+
// this stat is specifically the "we hit the retry ceiling" failure mode, useful for
936+
// distinguishing transient/abortive errors from buckets that need operator intervention.
937+
AbandonedRuns *SgwIntStat `json:"abandoned_runs"`
931938
}
932939

933940
type SgwStatWrapper interface {
@@ -2577,6 +2584,10 @@ func (d *DbStats) InitMigrationStats() error {
25772584
if err != nil {
25782585
return err
25792586
}
2587+
resUtil.AbandonedRuns, err = NewIntStat(SubsystemMetadataMigration, "abandoned_runs", StatUnitNoUnits, MetadataMigrationAbandonedRunsDesc, StatAddedVersion4dot1dot0, StatDeprecatedVersionNotDeprecated, StatStabilityCommitted, labelKeys, labelVals, prometheus.CounterValue, 0)
2588+
if err != nil {
2589+
return err
2590+
}
25802591
d.MigrationStats = resUtil
25812592
return nil
25822593
}
@@ -2589,6 +2600,7 @@ func (d *DbStats) unregisterMigrationStats() {
25892600
prometheus.Unregister(d.MigrationStats.Errors)
25902601
prometheus.Unregister(d.MigrationStats.SeqPoisonPillApplied)
25912602
prometheus.Unregister(d.MigrationStats.Passes)
2603+
prometheus.Unregister(d.MigrationStats.AbandonedRuns)
25922604
}
25932605

25942606
func (d *DbStats) MetadataMigration() *MigrationStats {

base/stats_descriptions.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,13 @@ const (
426426
MetadataMigrationSeqPoisonPillAppliedDesc = "The total number of times this node applied the seq-counter poison pill to initiate fallback→primary sequence handoff. Typically 0 or 1 per migration run."
427427

428428
MetadataMigrationPassesDesc = "The total number of MigrateMetadata range-scan passes executed for this database."
429+
430+
MetadataMigrationAbandonedRunsDesc = "The total number of metadata migration runs that the " +
431+
"orchestrator gave up on after the bounded pass loop exhausted itself without a clean pass. " +
432+
"Runs that exit early via a hard error from MigrateMetadata, or are stopped cooperatively via " +
433+
"the terminator, are not counted here. This stat specifically captures the \"hit the retry " +
434+
"ceiling\" failure mode, useful for distinguishing transient/abortive errors from buckets that " +
435+
"need operator intervention."
429436
)
430437

431438
// DB Replicators stats descriptions (ISGR Specific)

base/sync_map.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
Copyright 2026-Present Couchbase, Inc.
3+
4+
Use of this software is governed by the Business Source License included in
5+
the file licenses/BSL-Couchbase.txt. As of the Change Date specified in that
6+
file, in accordance with the Business Source License, use of this software will
7+
be governed by the Apache License, Version 2.0, included in the file
8+
licenses/APL2.txt.
9+
*/
10+
11+
package base
12+
13+
import (
14+
"iter"
15+
"sync"
16+
)
17+
18+
// SyncMap is a type-safe wrapper around sync.Map that eliminates runtime type assertions.
19+
type SyncMap[K comparable, V any] struct {
20+
m sync.Map
21+
}
22+
23+
func (s *SyncMap[K, V]) Load(key K) (V, bool) {
24+
v, ok := s.m.Load(key)
25+
if !ok {
26+
var zero V
27+
return zero, false
28+
}
29+
return v.(V), true
30+
}
31+
32+
func (s *SyncMap[K, V]) Store(key K, value V) {
33+
s.m.Store(key, value)
34+
}
35+
36+
func (s *SyncMap[K, V]) LoadOrStore(key K, value V) (V, bool) {
37+
actual, loaded := s.m.LoadOrStore(key, value)
38+
return actual.(V), loaded
39+
}
40+
41+
func (s *SyncMap[K, V]) Range(yield func(K, V) bool) {
42+
s.m.Range(func(key, value any) bool {
43+
return yield(key.(K), value.(V))
44+
})
45+
}
46+
47+
// All returns an iterator over all key-value pairs.
48+
func (s *SyncMap[K, V]) All() iter.Seq2[K, V] {
49+
return s.Range
50+
}

db/background_mgr_metadata_migration.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,9 @@ func (m *MetadataMigrationManager) Run(ctx context.Context, options map[string]a
242242

243243
if pass+1 >= maxPasses {
244244
base.WarnfCtx(ctx, "[%s] gave up after %d passes with %d unknown-prefix doc(s) and %d per-doc error(s) on the last pass", metadataMigrationLoggingID, maxPasses, remaining, passErrors)
245+
if promStats != nil {
246+
promStats.AbandonedRuns.Add(1)
247+
}
245248
return fmt.Errorf("%s still not clear of metadata after %d passes: %d unknown-prefix doc(s), %d per-doc error(s) remain", ms.Fallback().GetName(), maxPasses, remaining, passErrors)
246249
}
247250
}

db/util_testing.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,3 +1191,10 @@ func (db *DatabaseContext) RestartChangeListener(t testing.TB, flushCache bool)
11911191
func (db *DatabaseContext) FlushChannelCache(t testing.TB) {
11921192
db.RestartChangeListener(t, true)
11931193
}
1194+
1195+
// MigrateSeqCounterForTest exposes the unexported migrateSeqCounter for cross-package tests.
1196+
func MigrateSeqCounterForTest(t testing.TB, ctx context.Context, ms *base.MetadataStore, seqKey string) {
1197+
t.Helper()
1198+
stats := &MigrationStats{}
1199+
require.NoError(t, migrateSeqCounter(ctx, ms, seqKey, stats))
1200+
}

0 commit comments

Comments
 (0)