|
9 | 9 | package base |
10 | 10 |
|
11 | 11 | import ( |
| 12 | + "encoding/json" |
| 13 | + "errors" |
12 | 14 | "sort" |
13 | 15 | "strings" |
14 | 16 | "sync" |
15 | 17 | "testing" |
16 | 18 | "time" |
17 | 19 |
|
18 | 20 | "dario.cat/mergo" |
| 21 | + "github.com/couchbase/gocb/v2" |
19 | 22 | "github.com/couchbaselabs/rosmar" |
20 | 23 | "github.com/stretchr/testify/assert" |
21 | 24 | "github.com/stretchr/testify/require" |
@@ -51,7 +54,7 @@ func TestBootstrapRefCounting(t *testing.T) { |
51 | 54 |
|
52 | 55 | var perBucketCredentialsConfig map[string]*CredentialsConfig |
53 | 56 | 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) |
55 | 58 | require.NoError(t, err) |
56 | 59 | defer cluster.Close() |
57 | 60 | require.NotNil(t, cluster) |
@@ -138,12 +141,12 @@ func newTestBootstrapConnection(t *testing.T) BootstrapConnection { |
138 | 141 | t.Helper() |
139 | 142 | ctx := TestCtx(t) |
140 | 143 | if UnitTestUrlIsWalrus() { |
141 | | - cluster, err := NewRosmarCluster(rosmar.InMemoryURL) |
| 144 | + cluster, err := NewRosmarCluster(rosmar.InMemoryURL, false) |
142 | 145 | require.NoError(t, err) |
143 | 146 | t.Cleanup(cluster.Close) |
144 | 147 | return cluster |
145 | 148 | } |
146 | | - cluster, err := NewCouchbaseCluster(ctx, TestClusterSpec(t), false, nil, TestUseXattrs(), CachedClusterConnections) |
| 149 | + cluster, err := NewCouchbaseCluster(ctx, TestClusterSpec(t), false, nil, TestUseXattrs(), false, CachedClusterConnections) |
147 | 150 | require.NoError(t, err) |
148 | 151 | t.Cleanup(cluster.Close) |
149 | 152 | return cluster |
@@ -244,3 +247,295 @@ func TestTouchMetadataDocument(t *testing.T) { |
244 | 247 | require.Error(t, err) |
245 | 248 | require.True(t, IsCasMismatch(err), "expected CasMismatch on stale CAS retry, got %T: %v", err, err) |
246 | 249 | } |
| 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