@@ -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
0 commit comments