@@ -19,6 +19,7 @@ import (
1919 "net/http"
2020 "strconv"
2121 "strings"
22+ "sync"
2223 "testing"
2324
2425 "github.com/couchbase/sync_gateway/base"
@@ -1521,3 +1522,150 @@ func TestLargeAttachments(t *testing.T) {
15211522 require .ErrorAs (t , err , & httpErr , "Created doc with huge attachment" )
15221523 require .Equal (t , http .StatusRequestEntityTooLarge , httpErr .Status )
15231524}
1525+
1526+ // attachmentMigrationCASRetrySetup creates a doc with an attachment, then moves the attachment
1527+ // back to AttachmentsPre4dot0 to simulate a document that has not yet been through the background
1528+ // migration.
1529+ func attachmentMigrationCASRetrySetup (t * testing.T , ctx context.Context , collection * DatabaseCollectionWithUser , docID string ) {
1530+ t .Helper ()
1531+ _ , _ , err := collection .Put (ctx , docID , Body {
1532+ "test" : "doc" ,
1533+ BodyAttachments : AttachmentsMeta {
1534+ "camera.txt" : map [string ]any {"data" : "Q2Fub24gRU9TIDVEIE1hcmsgSVY=" },
1535+ },
1536+ })
1537+ require .NoError (t , err )
1538+
1539+ ds := collection .GetCollectionDatastore ()
1540+ xattrs , cas , err := ds .GetXattrs (ctx , docID , []string {base .SyncXattrName , base .GlobalXattrName })
1541+ require .NoError (t , err )
1542+ var bucketSyncData SyncData
1543+ require .NoError (t , base .JSONUnmarshal (xattrs [base .SyncXattrName ], & bucketSyncData ))
1544+ var globalXattr GlobalSyncData
1545+ require .NoError (t , base .JSONUnmarshal (xattrs [base .GlobalXattrName ], & globalXattr ))
1546+
1547+ bucketSyncData .AttachmentsPre4dot0 = globalXattr .Attachments
1548+ syncBytes := base .MustJSONMarshal (t , bucketSyncData )
1549+ _ , err = ds .WriteWithXattrs (ctx , docID , 0 , cas , []byte (`{"test":"doc"}` ),
1550+ map [string ][]byte {base .SyncXattrName : syncBytes }, []string {base .GlobalXattrName }, nil )
1551+ require .NoError (t , err )
1552+ }
1553+
1554+ // attachmentMigrationCASRetryCallback returns an UpdateCallback that calls MigrateAttachmentMetadata
1555+ // exactly once for the given docID, changing the document CAS and forcing WriteUpdateWithXattrs to retry.
1556+ func attachmentMigrationCASRetryCallback (t * testing.T , ctx context.Context , collection * DatabaseCollectionWithUser , docID string ) func (string ) {
1557+ t .Helper ()
1558+ var once sync.Once
1559+ return func (key string ) {
1560+ if key != docID {
1561+ return
1562+ }
1563+ once .Do (func () {
1564+ ds := collection .GetCollectionDatastore ()
1565+ xattrs , cas , err := ds .GetXattrs (ctx , docID , []string {base .SyncXattrName })
1566+ require .NoError (t , err )
1567+ var syncData SyncData
1568+ require .NoError (t , base .JSONUnmarshal (xattrs [base .SyncXattrName ], & syncData ))
1569+ require .NoError (t , collection .MigrateAttachmentMetadata (ctx , docID , cas , & syncData ))
1570+ })
1571+ }
1572+ }
1573+
1574+ const attachmentMigrationCASRetryDigest = "sha1-VoSNiNQGHE1HirIS5HMxj6CrlHI="
1575+ const attachmentMigrationCASRetryData = "Q2Fub24gRU9TIDVEIE1hcmsgSVY="
1576+
1577+ var attachmentMigrationCASRetryExpected = AttachmentMap {
1578+ "camera.txt" : {
1579+ Digest : attachmentMigrationCASRetryDigest ,
1580+ Length : 20 ,
1581+ Revpos : 2 ,
1582+ Version : 2 ,
1583+ Stub : true ,
1584+ },
1585+ }
1586+
1587+ // TestAttachmentMigrationCASRetryOnUpdate verifies that PutExistingCurrentVersion and
1588+ // PutExistingRevWithConflictResolution both preserve revpos=2 when a CAS retry is forced
1589+ // by concurrent attachment migration. The maps.Clone reset in each function is the guard:
1590+ // without it, the retry sees a stub mutated by the first invocation and applies a parent
1591+ // lookup that returns the migrated revpos=1.
1592+ func TestAttachmentMigrationCASRetryOnUpdate (t * testing.T ) {
1593+ db , ctx := setupTestLeakyDBWithCacheOptions (t , DefaultCacheOptions (), base.LeakyBucketConfig {})
1594+ defer db .Close (ctx )
1595+ collection , ctx := GetSingleDatabaseCollectionWithUser (ctx , t , db )
1596+ ds := collection .GetCollectionDatastore ()
1597+
1598+ leakyDS , ok := base .AsLeakyDataStore (ds )
1599+ require .True (t , ok )
1600+
1601+ // putCurrentVersion calls PutExistingCurrentVersion (HLV/versionVector BLIP path).
1602+ putCurrentVersion := func (t * testing.T , docID string ) {
1603+ existingDoc , err := collection .GetDocument (ctx , docID , DocUnmarshalSync )
1604+ require .NoError (t , err )
1605+ incomingHLV := & HybridLogicalVector {
1606+ SourceID : "cbl1" ,
1607+ Version : existingDoc .HLV .Version + 1 ,
1608+ PreviousVersions : HLVVersions {existingDoc .HLV .SourceID : existingDoc .HLV .Version },
1609+ }
1610+ // newDoc carries the attachment as inline data, simulating what ForEachStubAttachment
1611+ // places in newDoc.Attachments() after downloading the data from the CBL client.
1612+ newDoc := & Document {ID : docID }
1613+ newDoc .SetAttachments (AttachmentsMeta {
1614+ "camera.txt" : map [string ]any {"data" : attachmentMigrationCASRetryData },
1615+ })
1616+ newDoc .UpdateBody (Body {"some" : "update" })
1617+ _ , _ , _ , err = collection .PutExistingCurrentVersion (ctx , PutDocOptions {
1618+ NewDoc : newDoc ,
1619+ NewDocHLV : incomingHLV ,
1620+ })
1621+ require .NoError (t , err )
1622+ }
1623+
1624+ // putExistingRev calls PutExistingRevWithConflictResolution (rev-tree BLIP path).
1625+ putExistingRev := func (t * testing.T , docID string ) {
1626+ existingDoc , err := collection .GetDocument (ctx , docID , DocUnmarshalSync )
1627+ require .NoError (t , err )
1628+ existingRevID := existingDoc .GetRevTreeID ()
1629+ incomingRevID := "2-attachmentmigrationcasretrytest"
1630+ newDoc := & Document {ID : docID , RevID : incomingRevID }
1631+ newDoc .SetAttachments (AttachmentsMeta {
1632+ "camera.txt" : map [string ]any {"data" : attachmentMigrationCASRetryData },
1633+ })
1634+ newDoc .UpdateBody (Body {"some" : "update" })
1635+ _ , _ , err = collection .PutExistingRevWithConflictResolution (ctx , PutDocOptions {
1636+ NewDoc : newDoc ,
1637+ RevTreeHistory : []string {incomingRevID , existingRevID },
1638+ DocUpdateEvent : ExistingVersionWithUpdateToHLV ,
1639+ })
1640+ require .NoError (t , err )
1641+ }
1642+
1643+ testCases := []struct {
1644+ name string
1645+ casFailure bool
1646+ update func (t * testing.T , docID string )
1647+ }{
1648+ {name : "PutExistingCurrentVersion no CAS failure" , casFailure : false , update : putCurrentVersion },
1649+ {name : "PutExistingCurrentVersion CAS failure" , casFailure : true , update : putCurrentVersion },
1650+ {name : "PutExistingRevWithConflictResolution no CAS failure" , casFailure : false , update : putExistingRev },
1651+ {name : "PutExistingRevWithConflictResolution CAS failure" , casFailure : true , update : putExistingRev },
1652+ }
1653+
1654+ for _ , tc := range testCases {
1655+ t .Run (tc .name , func (t * testing.T ) {
1656+ docID := "doc_" + strings .ReplaceAll (tc .name , " " , "_" )
1657+
1658+ attachmentMigrationCASRetrySetup (t , ctx , collection , docID )
1659+
1660+ if tc .casFailure {
1661+ leakyDS .SetUpdateCallback (attachmentMigrationCASRetryCallback (t , ctx , collection , docID ))
1662+ defer leakyDS .SetUpdateCallback (nil )
1663+ }
1664+
1665+ tc .update (t , docID )
1666+
1667+ require .Empty (t , GetRawSyncXattr (t , ds , docID ).AttachmentsPre4dot0 )
1668+ require .Equal (t , attachmentMigrationCASRetryExpected , GetRawGlobalSyncAttachments (t , ds , docID ))
1669+ })
1670+ }
1671+ }
0 commit comments