Skip to content

Commit a1a3132

Browse files
bbrksclaude
andauthored
[4.1.1 Backport] CBG-5541: Explicitly ignore collection-scoped 'non-metadata' _sync docs from metadata migration (#8428)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f10e6d3 commit a1a3132

2 files changed

Lines changed: 104 additions & 0 deletions

File tree

db/background_mgr_metadata_migration_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,97 @@ func TestMetadataMigrationManagerMovesUsersAndRoles(t *testing.T) {
211211
assert.True(t, ms.MigrationComplete(), "clean run must flip MigrationComplete so future reads skip the fallback")
212212
}
213213

214+
// TestMetadataMigrationCompletesWithCollectionScopedDataInFallback is a regression test for a
215+
// database whose data lives in the default collection. In that legacy layout the fallback
216+
// (`bucket.DefaultDataStore`) holds not just metadata but the documents' collection-scoped data
217+
// docs: attachments (`_sync:att:`/`_sync:att2:`), backup/old revision bodies
218+
// (`_sync:rev:`/`_sync:rb:`) and local docs such as replication checkpoints (`_sync:local:`). The
219+
// `_sync:` range scan returns all of these.
220+
//
221+
// These are collection data, not database metadata: they must never move to the metadata store,
222+
// and their presence must NOT prevent the migration from completing. Before the fix they were
223+
// classified as unknown-prefix and counted toward `remaining`, so the job looped to maxPasses and
224+
// errored. This test fails if any of them wedges the migration rather than being treated as
225+
// out-of-scope and left in place.
226+
//
227+
// The seeded keys are produced by the real SG key constructors so this exercises genuine
228+
// SG-written docs rather than synthetic prefixes.
229+
func TestMetadataMigrationCompletesWithCollectionScopedDataInFallback(t *testing.T) {
230+
ctx := base.TestCtx(t)
231+
bucket := base.GetTestBucket(t)
232+
defer bucket.Close(ctx)
233+
234+
primary, err := bucket.GetNamedDataStore(0)
235+
require.NoError(t, err)
236+
fallback := bucket.DefaultDataStore(ctx)
237+
if _, ok := base.AsRangeScanStore(fallback); !ok {
238+
t.Skipf("metadata migration requires KV range scan support on the fallback datastore")
239+
}
240+
ms := base.NewMetadataStore(primary, fallback)
241+
242+
const metadataID = "test-mgr-collection-data"
243+
metaKeys := base.NewMetadataKeys(metadataID)
244+
245+
// In-scope metadata that SHOULD migrate — the control that proves the wedge (if any) is caused
246+
// by the collection-scoped data docs and not a broken harness.
247+
userKey := metaKeys.UserKey("alice")
248+
249+
// One doc per collection-scoped data-doc type, keyed exactly as SG writes them.
250+
const docID = "doc1"
251+
dataKeys := []string{
252+
MakeAttachmentKey(AttVersion2, docID, "sha1-abc"), // _sync:att2:<sha256(docID)>:<digest>
253+
MakeAttachmentKey(AttVersion1, docID, "sha1-def"), // _sync:att:<digest>
254+
oldRevisionKey(docID, "2-abc"), // _sync:rev:<docid>:<n>:<rev>
255+
generateRevBodyKey(docID, "3-def"), // _sync:rb:<digest>
256+
RealSpecialDocID(DocTypeLocal, CheckpointDocIDPrefix+"client1"), // _sync:local:checkpoint/<id>
257+
}
258+
259+
seeded := map[string][]byte{userKey: []byte(`{"name":"alice"}`)}
260+
for _, k := range dataKeys {
261+
seeded[k] = []byte(`{"collection":"data"}`)
262+
}
263+
for k, v := range seeded {
264+
_, err := ms.Fallback().AddRaw(ctx, k, 0, v)
265+
require.NoError(t, err, "seed fallback %s", k)
266+
}
267+
base.RequireDocsVisibleToRangeScan(t, ms.Fallback(), slices.Collect(maps.Keys(seeded)))
268+
269+
dbCtx := &DatabaseContext{
270+
Name: "test",
271+
terminator: make(chan bool),
272+
MetadataStore: ms,
273+
MetadataKeys: metaKeys,
274+
Options: DatabaseContextOptions{MetadataID: metadataID},
275+
MetadataMigrationStatusUpdater: inMemoryMigrationStatusUpdater(),
276+
}
277+
dbCtx.MetadataMigrationManager = NewMetadataMigrationManager(dbCtx)
278+
279+
require.NoError(t, dbCtx.MetadataMigrationManager.Start(ctx, nil))
280+
// Must complete — the collection-scoped data docs are out-of-scope, not blockers. If any wedges
281+
// the job it exhausts maxPasses and lands in Error instead.
282+
RequireBackgroundManagerState(t, dbCtx.MetadataMigrationManager, BackgroundProcessStateCompleted)
283+
284+
assert.True(t, ms.MigrationComplete(), "clean run must flip MigrationComplete so future reads skip the fallback")
285+
286+
rawStatus, err := dbCtx.MetadataMigrationManager.GetStatus(ctx)
287+
require.NoError(t, err)
288+
var resp MigrationManagerResponse
289+
require.NoError(t, base.JSONUnmarshal(rawStatus, &resp))
290+
assert.Equal(t, int64(1), resp.DocsProcessed, "only the in-scope user doc should migrate")
291+
assert.Zero(t, resp.DocsFailed, "no per-doc errors should occur")
292+
assert.Equal(t, int64(len(dataKeys)), resp.DocsOutOfScope, "every collection-scoped data doc classified out-of-scope")
293+
294+
// The user doc moved to primary; the data docs are left in place on the fallback (non-destructive).
295+
_, _, getErr := ms.Primary().GetRaw(ctx, userKey)
296+
assert.NoError(t, getErr, "user doc should have migrated to primary")
297+
_, _, getErr = ms.Fallback().GetRaw(ctx, userKey)
298+
assert.True(t, base.IsDocNotFoundError(getErr), "user doc should be gone from fallback")
299+
for _, k := range dataKeys {
300+
_, _, getErr := ms.Fallback().GetRaw(ctx, k)
301+
assert.NoError(t, getErr, "collection-scoped data doc should be left in place on fallback: %s", k)
302+
}
303+
}
304+
214305
// TestMetadataMigrationManagerErrorsOnUnclearableUnknownPrefix forces the bounded-pass
215306
// give-up branch: an in-scope unknown-prefix doc is reported as "remaining" every pass
216307
// (the dispatcher classifies it as DocsUnknownPrefix and leaves it on the fallback), so

db/metadata_migration.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,19 @@ func handleMigrationKey(ctx context.Context, ms *base.MetadataStore, keys *base.
298298
strings.HasPrefix(key, base.PersistentConfigPrefixWithoutGroupID):
299299
stats.DocsOutOfScope.Add(1)
300300

301+
// collection-scoped data documents — attachments, backup/old revision bodies, and local
302+
// docs (e.g. replication checkpoints). These live in the same collection as the documents
303+
// they belong to, which in a legacy default-collection deployment is the migration fallback.
304+
// They are collection data, not database metadata: never move them to the metadata store,
305+
// and never let their presence block completion.
306+
case
307+
strings.HasPrefix(key, base.Att2Prefix), // _sync:att2: (v2 attachments)
308+
strings.HasPrefix(key, base.AttPrefix), // _sync:att: (v1 attachments)
309+
strings.HasPrefix(key, base.RevPrefix), // _sync:rev: (old / delta-sync revision bodies)
310+
strings.HasPrefix(key, base.RevBodyPrefix), // _sync:rb: (conflicting revision bodies)
311+
strings.HasPrefix(key, base.SyncDocPrefix+"local:"): // _sync:local: (RealSpecialDocID(DocTypeLocal, …))
312+
stats.DocsOutOfScope.Add(1)
313+
301314
// non-matching sync docs (metadata prefix before sync doc type - formatInvertedMetadataKey)
302315
case
303316
strings.HasPrefix(key, base.SyncDocMetadataPrefix) &&

0 commit comments

Comments
 (0)