Skip to content

Commit 1b34a6a

Browse files
bbrksclaude
andauthored
CBG-5226: Opt-in use of _system._mobile for databases and bootstrap (#8309)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4032c91 commit 1b34a6a

12 files changed

Lines changed: 1818 additions & 130 deletions

base/bootstrap.go

Lines changed: 503 additions & 33 deletions
Large diffs are not rendered by default.

base/bootstrap_test.go

Lines changed: 298 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@
99
package base
1010

1111
import (
12+
"encoding/json"
13+
"errors"
1214
"sort"
1315
"strings"
1416
"sync"
1517
"testing"
1618
"time"
1719

1820
"dario.cat/mergo"
21+
"github.com/couchbase/gocb/v2"
1922
"github.com/couchbaselabs/rosmar"
2023
"github.com/stretchr/testify/assert"
2124
"github.com/stretchr/testify/require"
@@ -51,7 +54,7 @@ func TestBootstrapRefCounting(t *testing.T) {
5154

5255
var perBucketCredentialsConfig map[string]*CredentialsConfig
5356
forcePerBucketAuth := false
54-
cluster, err := NewCouchbaseCluster(ctx, TestClusterSpec(t), forcePerBucketAuth, perBucketCredentialsConfig, TestUseXattrs(), CachedClusterConnections)
57+
cluster, err := NewCouchbaseCluster(ctx, TestClusterSpec(t), forcePerBucketAuth, perBucketCredentialsConfig, TestUseXattrs(), false, CachedClusterConnections)
5558
require.NoError(t, err)
5659
defer cluster.Close()
5760
require.NotNil(t, cluster)
@@ -138,12 +141,12 @@ func newTestBootstrapConnection(t *testing.T) BootstrapConnection {
138141
t.Helper()
139142
ctx := TestCtx(t)
140143
if UnitTestUrlIsWalrus() {
141-
cluster, err := NewRosmarCluster(rosmar.InMemoryURL)
144+
cluster, err := NewRosmarCluster(rosmar.InMemoryURL, false)
142145
require.NoError(t, err)
143146
t.Cleanup(cluster.Close)
144147
return cluster
145148
}
146-
cluster, err := NewCouchbaseCluster(ctx, TestClusterSpec(t), false, nil, TestUseXattrs(), CachedClusterConnections)
149+
cluster, err := NewCouchbaseCluster(ctx, TestClusterSpec(t), false, nil, TestUseXattrs(), false, CachedClusterConnections)
147150
require.NoError(t, err)
148151
t.Cleanup(cluster.Close)
149152
return cluster
@@ -244,3 +247,295 @@ func TestTouchMetadataDocument(t *testing.T) {
244247
require.Error(t, err)
245248
require.True(t, IsCasMismatch(err), "expected CasMismatch on stale CAS retry, got %T: %v", err, err)
246249
}
250+
251+
// bootstrapTestCfg is the value shape used by the dual-collection bootstrap tests.
252+
type bootstrapTestCfg struct {
253+
Foo string `json:"foo"`
254+
}
255+
256+
// seedLegacyBootstrapDoc writes a bootstrap-config-shaped document directly into the bucket's
257+
// _default._default collection, mimicking pre-migration state. The on-disk shape must match what
258+
// configPersistence.loadConfig expects, which is controlled by NewCouchbaseCluster's
259+
// useXattrConfig — a distinct setting from SG's general use_xattrs for application metadata.
260+
// Callers must pass the same useXattrs value used to construct the cluster under test.
261+
func seedLegacyBootstrapDoc(t *testing.T, bucket *gocb.Bucket, docID string, value bootstrapTestCfg, useXattrs bool) {
262+
t.Helper()
263+
if useXattrs {
264+
_, err := bucket.DefaultCollection().MutateIn(docID, []gocb.MutateInSpec{
265+
gocb.UpsertSpec(cfgXattrConfigPath, value, UpsertSpecXattr),
266+
gocb.ReplaceSpec("", json.RawMessage(cfgXattrBody), nil),
267+
}, &gocb.MutateInOptions{StoreSemantic: gocb.StoreSemanticsInsert})
268+
require.NoError(t, err)
269+
return
270+
}
271+
_, err := bucket.DefaultCollection().Insert(docID, value, nil)
272+
require.NoError(t, err)
273+
}
274+
275+
// bootstrapDualTestFixture pairs a BootstrapConnection in dual-collection mode with helpers that
276+
// reach past the cluster API to inspect or seed each underlying collection. Implementations exist
277+
// for both Rosmar and CouchbaseCluster; newBootstrapDualTestFixture selects based on the test's
278+
// backing store.
279+
type bootstrapDualTestFixture struct {
280+
Cluster BootstrapConnection
281+
BucketName string
282+
// SeedLegacy writes a config-shaped doc directly into the fallback (_default._default)
283+
// collection, bypassing the cluster API.
284+
SeedLegacy func(t *testing.T, docID string, value bootstrapTestCfg)
285+
// PrimaryExists reports whether docID currently exists in the primary (_system._mobile).
286+
PrimaryExists func(t *testing.T, docID string) bool
287+
// FallbackExists reports whether docID currently exists in _default._default.
288+
FallbackExists func(t *testing.T, docID string) bool
289+
// CleanupDoc removes docID from both collections. Idempotent.
290+
CleanupDoc func(t *testing.T, docID string)
291+
}
292+
293+
// newBootstrapDualTestFixture constructs a fixture for dual-collection bootstrap tests. useXattrs
294+
// selects the bootstrap-config persistence mode on Couchbase Server (NewCouchbaseCluster's
295+
// useXattrConfig); on Rosmar it's ignored since Rosmar's bootstrap path only supports
296+
// document-mode persistence — callers that want to exercise useXattrs=true should skip on
297+
// UnitTestUrlIsWalrus().
298+
func newBootstrapDualTestFixture(t *testing.T, useXattrs bool) bootstrapDualTestFixture {
299+
t.Helper()
300+
if UnitTestUrlIsWalrus() {
301+
return newRosmarBootstrapDualFixture(t)
302+
}
303+
return newCouchbaseBootstrapDualFixture(t, useXattrs)
304+
}
305+
306+
// newRosmarBootstrapDualFixture builds a fixture over a test-pool Rosmar bucket. The cluster and
307+
// the seed/inspect helpers all point at the same in-memory bucket via rosmar's process-global
308+
// bucket registry.
309+
func newRosmarBootstrapDualFixture(t *testing.T) bootstrapDualTestFixture {
310+
t.Helper()
311+
ctx := TestCtx(t)
312+
tb := GetTestBucket(t)
313+
t.Cleanup(func() { tb.Close(ctx) })
314+
bucketName := tb.GetName()
315+
316+
cluster, err := NewRosmarCluster(UnitTestUrl(), true)
317+
require.NoError(t, err)
318+
t.Cleanup(cluster.Close)
319+
320+
defaultDS, err := tb.Bucket.NamedDataStore(ctx, DefaultScopeAndCollectionName())
321+
require.NoError(t, err)
322+
systemDS, err := tb.Bucket.NamedDataStore(ctx, MobileSystemScopeAndCollectionName())
323+
require.NoError(t, err)
324+
325+
return bootstrapDualTestFixture{
326+
Cluster: cluster,
327+
BucketName: bucketName,
328+
SeedLegacy: func(t *testing.T, docID string, value bootstrapTestCfg) {
329+
t.Helper()
330+
_, err := defaultDS.WriteCas(TestCtx(t), docID, 0, 0, value, 0)
331+
require.NoError(t, err)
332+
},
333+
PrimaryExists: func(t *testing.T, docID string) bool {
334+
t.Helper()
335+
ok, err := systemDS.Exists(TestCtx(t), docID)
336+
require.NoError(t, err)
337+
return ok
338+
},
339+
FallbackExists: func(t *testing.T, docID string) bool {
340+
t.Helper()
341+
ok, err := defaultDS.Exists(TestCtx(t), docID)
342+
require.NoError(t, err)
343+
return ok
344+
},
345+
CleanupDoc: func(t *testing.T, docID string) {
346+
t.Helper()
347+
_, _ = systemDS.Remove(TestCtx(t), docID, 0)
348+
_, _ = defaultDS.Remove(TestCtx(t), docID, 0)
349+
},
350+
}
351+
}
352+
353+
// newCouchbaseBootstrapDualFixture builds a fixture over the first available test-pool bucket on a
354+
// real Couchbase Server cluster running in dual-collection mode. useXattrs selects the
355+
// bootstrap-config persistence mode and must match the seed format used by SeedLegacy.
356+
func newCouchbaseBootstrapDualFixture(t *testing.T, useXattrs bool) bootstrapDualTestFixture {
357+
t.Helper()
358+
ctx := TestCtx(t)
359+
require.EventuallyWithT(t, func(c *assert.CollectT) {
360+
assert.Equal(c, int32(GTestBucketPool.numBuckets), GTestBucketPool.stats.TotalBucketInitCount.Load())
361+
}, 2*time.Minute, 5*time.Millisecond)
362+
363+
cluster, err := NewCouchbaseCluster(ctx, TestClusterSpec(t), false, nil, useXattrs, true, CachedClusterConnections)
364+
require.NoError(t, err)
365+
t.Cleanup(cluster.Close)
366+
367+
buckets, err := cluster.GetConfigBuckets(ctx)
368+
require.NoError(t, err)
369+
require.NotEmpty(t, buckets)
370+
bucketName := buckets[0]
371+
372+
bucket, teardown, err := cluster.getBucket(ctx, bucketName)
373+
require.NoError(t, err)
374+
t.Cleanup(teardown)
375+
376+
primaryCol := bucket.Scope(SystemScope).Collection(SystemCollectionMobile)
377+
fallbackCol := bucket.DefaultCollection()
378+
colExists := func(c *gocb.Collection, docID string) bool {
379+
t.Helper()
380+
_, err := c.Get(docID, nil)
381+
if errors.Is(err, gocb.ErrDocumentNotFound) {
382+
return false
383+
}
384+
require.NoError(t, err)
385+
return true
386+
}
387+
388+
return bootstrapDualTestFixture{
389+
Cluster: cluster,
390+
BucketName: bucketName,
391+
SeedLegacy: func(t *testing.T, docID string, value bootstrapTestCfg) {
392+
t.Helper()
393+
seedLegacyBootstrapDoc(t, bucket, docID, value, useXattrs)
394+
},
395+
PrimaryExists: func(t *testing.T, docID string) bool { return colExists(primaryCol, docID) },
396+
FallbackExists: func(t *testing.T, docID string) bool { return colExists(fallbackCol, docID) },
397+
CleanupDoc: func(t *testing.T, docID string) {
398+
t.Helper()
399+
_, _ = primaryCol.Remove(docID, nil)
400+
_, _ = fallbackCol.Remove(docID, nil)
401+
},
402+
}
403+
}
404+
405+
// forEachBootstrapXattrMode runs body once with useXattrs=false and once with useXattrs=true,
406+
// each as a t.Run subtest. The useXattrs=true subtest is skipped on Rosmar — its bootstrap
407+
// path doesn't support xattr-mode persistence, so the variant has no meaningful coverage
408+
// there.
409+
func forEachBootstrapXattrMode(t *testing.T, body func(t *testing.T, useXattrs bool)) {
410+
t.Helper()
411+
for _, useXattrs := range []bool{false, true} {
412+
name := "bootstrap_xattr=false"
413+
if useXattrs {
414+
name = "bootstrap_xattr=true"
415+
}
416+
t.Run(name, func(t *testing.T) {
417+
if useXattrs && UnitTestUrlIsWalrus() {
418+
t.Skip("Rosmar bootstrap does not support xattr-mode persistence")
419+
}
420+
body(t, useXattrs)
421+
})
422+
}
423+
}
424+
425+
// TestBootstrapInsertMetadataDocumentWritesPrimary verifies that with no pre-existing legacy copy,
426+
// InsertMetadataDocument lands in the primary (_system._mobile) collection and not the fallback.
427+
func TestBootstrapInsertMetadataDocumentWritesPrimary(t *testing.T) {
428+
forEachBootstrapXattrMode(t, func(t *testing.T, useXattrs bool) {
429+
f := newBootstrapDualTestFixture(t, useXattrs)
430+
ctx := TestCtx(t)
431+
docID := SyncDocPrefix + "metadata-insert-primary"
432+
t.Cleanup(func() { f.CleanupDoc(t, docID) })
433+
434+
_, err := f.Cluster.InsertMetadataDocument(ctx, f.BucketName, docID, bootstrapTestCfg{Foo: "primary"})
435+
require.NoError(t, err)
436+
437+
require.True(t, f.PrimaryExists(t, docID))
438+
require.False(t, f.FallbackExists(t, docID))
439+
440+
var loaded bootstrapTestCfg
441+
_, err = f.Cluster.GetMetadataDocument(ctx, f.BucketName, docID, &loaded)
442+
require.NoError(t, err)
443+
require.Equal(t, "primary", loaded.Foo)
444+
})
445+
}
446+
447+
// TestBootstrapWriteMetadataDocumentFallbackCAS verifies WriteMetadataDocument replays against the
448+
// fallback collection when the supplied CAS came from a fallback read, so callers don't see a
449+
// spurious not-found just because the doc hasn't migrated to _system._mobile yet.
450+
func TestBootstrapWriteMetadataDocumentFallbackCAS(t *testing.T) {
451+
forEachBootstrapXattrMode(t, func(t *testing.T, useXattrs bool) {
452+
f := newBootstrapDualTestFixture(t, useXattrs)
453+
ctx := TestCtx(t)
454+
docID := SyncDocPrefix + "metadata-write-fallback-cas"
455+
t.Cleanup(func() { f.CleanupDoc(t, docID) })
456+
f.SeedLegacy(t, docID, bootstrapTestCfg{Foo: "legacy"})
457+
458+
var initial bootstrapTestCfg
459+
cas, err := f.Cluster.GetMetadataDocument(ctx, f.BucketName, docID, &initial)
460+
require.NoError(t, err)
461+
require.Equal(t, "legacy", initial.Foo)
462+
require.NotZero(t, cas)
463+
464+
newCAS, err := f.Cluster.WriteMetadataDocument(ctx, f.BucketName, docID, cas, bootstrapTestCfg{Foo: "updated"})
465+
require.NoError(t, err)
466+
require.NotEqual(t, cas, newCAS)
467+
468+
var reloaded bootstrapTestCfg
469+
_, err = f.Cluster.GetMetadataDocument(ctx, f.BucketName, docID, &reloaded)
470+
require.NoError(t, err)
471+
require.Equal(t, "updated", reloaded.Foo)
472+
473+
require.False(t, f.PrimaryExists(t, docID), "write must not have migrated the doc to primary")
474+
require.True(t, f.FallbackExists(t, docID))
475+
})
476+
}
477+
478+
// TestBootstrapInsertMetadataDocumentFallbackDuplicate verifies InsertMetadataDocument returns
479+
// ErrAlreadyExists when the doc already lives in the fallback collection - never silently creating
480+
// a divergent primary copy.
481+
func TestBootstrapInsertMetadataDocumentFallbackDuplicate(t *testing.T) {
482+
forEachBootstrapXattrMode(t, func(t *testing.T, useXattrs bool) {
483+
f := newBootstrapDualTestFixture(t, useXattrs)
484+
ctx := TestCtx(t)
485+
docID := SyncDocPrefix + "metadata-insert-fallback-dup"
486+
t.Cleanup(func() { f.CleanupDoc(t, docID) })
487+
f.SeedLegacy(t, docID, bootstrapTestCfg{Foo: "legacy"})
488+
489+
_, err := f.Cluster.InsertMetadataDocument(ctx, f.BucketName, docID, bootstrapTestCfg{Foo: "primary"})
490+
require.ErrorIs(t, err, ErrAlreadyExists)
491+
require.False(t, f.PrimaryExists(t, docID), "InsertMetadataDocument must not have created a primary copy")
492+
})
493+
}
494+
495+
// TestBootstrapTouchMetadataDocumentFallback verifies TouchMetadataDocument retries against the
496+
// fallback collection when the primary returns ErrNotFound, leaving the doc in place rather than
497+
// migrating it.
498+
func TestBootstrapTouchMetadataDocumentFallback(t *testing.T) {
499+
forEachBootstrapXattrMode(t, func(t *testing.T, useXattrs bool) {
500+
f := newBootstrapDualTestFixture(t, useXattrs)
501+
ctx := TestCtx(t)
502+
docID := SyncDocPrefix + "metadata-touch-fallback"
503+
t.Cleanup(func() { f.CleanupDoc(t, docID) })
504+
f.SeedLegacy(t, docID, bootstrapTestCfg{Foo: "legacy"})
505+
506+
var initial bootstrapTestCfg
507+
cas, err := f.Cluster.GetMetadataDocument(ctx, f.BucketName, docID, &initial)
508+
require.NoError(t, err)
509+
require.NotZero(t, cas)
510+
511+
newCAS, err := f.Cluster.TouchMetadataDocument(ctx, f.BucketName, docID, "version", "v2", cas)
512+
require.NoError(t, err)
513+
require.NotEqual(t, cas, newCAS, "Touch must bump CAS")
514+
require.False(t, f.PrimaryExists(t, docID))
515+
require.True(t, f.FallbackExists(t, docID))
516+
})
517+
}
518+
519+
// TestBootstrapDeleteMetadataDocumentFallback verifies DeleteMetadataDocument retries against the
520+
// fallback collection when the primary returns ErrNotFound, removing the legacy doc.
521+
func TestBootstrapDeleteMetadataDocumentFallback(t *testing.T) {
522+
forEachBootstrapXattrMode(t, func(t *testing.T, useXattrs bool) {
523+
f := newBootstrapDualTestFixture(t, useXattrs)
524+
ctx := TestCtx(t)
525+
docID := SyncDocPrefix + "metadata-delete-fallback"
526+
t.Cleanup(func() { f.CleanupDoc(t, docID) })
527+
f.SeedLegacy(t, docID, bootstrapTestCfg{Foo: "legacy"})
528+
529+
var initial bootstrapTestCfg
530+
cas, err := f.Cluster.GetMetadataDocument(ctx, f.BucketName, docID, &initial)
531+
require.NoError(t, err)
532+
require.NotZero(t, cas)
533+
534+
require.NoError(t, f.Cluster.DeleteMetadataDocument(ctx, f.BucketName, docID, cas))
535+
require.False(t, f.FallbackExists(t, docID))
536+
537+
var afterDelete bootstrapTestCfg
538+
_, err = f.Cluster.GetMetadataDocument(ctx, f.BucketName, docID, &afterDelete)
539+
require.True(t, IsDocNotFoundError(err), "expected not-found after delete, got %T: %v", err, err)
540+
})
541+
}

0 commit comments

Comments
 (0)