From 17a84d08e8543bd393c529777b454b495fde7da5 Mon Sep 17 00:00:00 2001 From: Pasin Suriyentrakorn Date: Thu, 9 Nov 2023 16:36:19 -0800 Subject: [PATCH 1/2] CBL-5074 : Fix backgrounding logic to cover conflict resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * The extending background task shouldn’t be ended if there are still pending conflict resolutions. * When the app was suspended by the background monitor, any current pending conflict resolutions should be canceled. All cancelled conflict resolutions will be notified and put into the queue again when the replicator is resumed or is restarted. * Fixed bug that the backgrounding monitor may not be restarted after it was ended. * Renamed some methods and add comments to code. * Added test to test suspending conflict resolution. --- CouchbaseLite.xcodeproj/project.pbxproj | 46 +++- Objective-C/CBLReplicator.mm | 238 +++++++++++++----- Objective-C/Internal/CBLReplicator+Internal.h | 4 +- .../Replicator/CBLReplicator+Backgrounding.h | 4 +- .../Replicator/CBLReplicator+Backgrounding.m | 21 +- Objective-C/Tests/ReplicatorTest+Main.m | 80 ++++++ .../Tests/Util/CBLBlockConflictResolver.h | 35 +++ .../Tests/Util/CBLBlockConflictResolver.m | 41 +++ 8 files changed, 392 insertions(+), 77 deletions(-) create mode 100644 Objective-C/Tests/Util/CBLBlockConflictResolver.h create mode 100644 Objective-C/Tests/Util/CBLBlockConflictResolver.m diff --git a/CouchbaseLite.xcodeproj/project.pbxproj b/CouchbaseLite.xcodeproj/project.pbxproj index a29ef9f6c..614539f7b 100644 --- a/CouchbaseLite.xcodeproj/project.pbxproj +++ b/CouchbaseLite.xcodeproj/project.pbxproj @@ -351,6 +351,12 @@ 27F9619A1ED8D9440060F804 /* CBLReachability.h in Headers */ = {isa = PBXBuildFile; fileRef = 27F961971ED8D9440060F804 /* CBLReachability.h */; }; 27F9619B1ED8D9440060F804 /* CBLReachability.m in Sources */ = {isa = PBXBuildFile; fileRef = 27F961981ED8D9440060F804 /* CBLReachability.m */; }; 27F9619C1ED8D9440060F804 /* CBLReachability.m in Sources */ = {isa = PBXBuildFile; fileRef = 27F961981ED8D9440060F804 /* CBLReachability.m */; }; + 40BC51EF2AFC39ED0090EDD5 /* CouchbaseLite.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9343F00F207D611600F19A89 /* CouchbaseLite.framework */; }; + 40BC51F02AFC39ED0090EDD5 /* CouchbaseLite.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9343F00F207D611600F19A89 /* CouchbaseLite.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 40BC51F82AFC40F10090EDD5 /* CBLBlockConflictResolver.m in Sources */ = {isa = PBXBuildFile; fileRef = 40BC51F52AFC40930090EDD5 /* CBLBlockConflictResolver.m */; }; + 40BC51F92AFC40F40090EDD5 /* CBLBlockConflictResolver.m in Sources */ = {isa = PBXBuildFile; fileRef = 40BC51F52AFC40930090EDD5 /* CBLBlockConflictResolver.m */; }; + 40BC51FA2AFC56BB0090EDD5 /* CBLBlockConflictResolver.m in Sources */ = {isa = PBXBuildFile; fileRef = 40BC51F52AFC40930090EDD5 /* CBLBlockConflictResolver.m */; }; + 40BC51FB2AFC56BC0090EDD5 /* CBLBlockConflictResolver.m in Sources */ = {isa = PBXBuildFile; fileRef = 40BC51F52AFC40930090EDD5 /* CBLBlockConflictResolver.m */; }; 69002EB9234E693F00776107 /* CBLErrorMessage.h in Headers */ = {isa = PBXBuildFile; fileRef = 69002EA9234E693F00776107 /* CBLErrorMessage.h */; }; 69002EBA234E693F00776107 /* CBLErrorMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = 69002EB8234E693F00776107 /* CBLErrorMessage.m */; }; 69002EBB234E695400776107 /* CBLErrorMessage.h in Headers */ = {isa = PBXBuildFile; fileRef = 69002EA9234E693F00776107 /* CBLErrorMessage.h */; }; @@ -1011,7 +1017,6 @@ 9343F144207D61EC00F19A89 /* ArrayTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 93DD9BA71EB419BB00E502A2 /* ArrayTest.m */; }; 9343F145207D61EC00F19A89 /* FragmentTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 931C14691EAAF08C0094F9B2 /* FragmentTest.m */; }; 9343F146207D61EC00F19A89 /* QueryTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 9332082B1E774419000D9993 /* QueryTest.m */; }; - 9343F148207D61EC00F19A89 /* CouchbaseLite.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9398D9121E03434200464432 /* CouchbaseLite.framework */; }; 9343F14A207D61EC00F19A89 /* Support in Resources */ = {isa = PBXBuildFile; fileRef = 93DECF3E200DBE5800F44953 /* Support */; }; 9343F14B207D61EC00F19A89 /* iTunesMusicLibrary.json in Resources */ = {isa = PBXBuildFile; fileRef = 275FF6081E3FC24D005F90DD /* iTunesMusicLibrary.json */; }; 9343F157207D62C900F19A89 /* PerfTest.mm in Sources */ = {isa = PBXBuildFile; fileRef = 275FF6381E3FFBC0005F90DD /* PerfTest.mm */; }; @@ -1738,6 +1743,13 @@ remoteGlobalIDString = 27DF7D631F4236500022F3DF; remoteInfo = SQLite; }; + 40BC51F12AFC39ED0090EDD5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9398D9091E03434200464432 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9343EF2A207D611600F19A89; + remoteInfo = CBL_EE_ObjC; + }; 9308F40D1E64B24700F53EE4 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 9398D9311E0347B600464432 /* LiteCore.xcodeproj */; @@ -1954,6 +1966,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 40BC51F32AFC39ED0090EDD5 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 40BC51F02AFC39ED0090EDD5 /* CouchbaseLite.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; 93095B0A246BC325005633B4 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -2157,6 +2180,8 @@ 27EF6A931E298E26004748DF /* PredicateQueryTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PredicateQueryTest.m; sourceTree = ""; }; 27F961971ED8D9440060F804 /* CBLReachability.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CBLReachability.h; sourceTree = ""; }; 27F961981ED8D9440060F804 /* CBLReachability.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CBLReachability.m; sourceTree = ""; }; + 40BC51F42AFC40930090EDD5 /* CBLBlockConflictResolver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CBLBlockConflictResolver.h; sourceTree = ""; }; + 40BC51F52AFC40930090EDD5 /* CBLBlockConflictResolver.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CBLBlockConflictResolver.m; sourceTree = ""; }; 69002EA9234E693F00776107 /* CBLErrorMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CBLErrorMessage.h; sourceTree = ""; }; 69002EB8234E693F00776107 /* CBLErrorMessage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CBLErrorMessage.m; sourceTree = ""; }; 6932D48B2954640000D28C18 /* CBLQueryFullTextIndexExpression.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = CBLQueryFullTextIndexExpression.h; path = ../CBLQueryFullTextIndexExpression.h; sourceTree = ""; }; @@ -2702,7 +2727,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9343F148207D61EC00F19A89 /* CouchbaseLite.framework in Frameworks */, + 40BC51EF2AFC39ED0090EDD5 /* CouchbaseLite.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2950,6 +2975,8 @@ 930C7F8120FE4F7400C74A12 /* CBLMockConnectionErrorLogic.h */, 930C7F7E20FE4F7400C74A12 /* CBLMockConnectionErrorLogic.m */, 930C7F8020FE4F7400C74A12 /* CBLMockConnectionLifecycleLocation.h */, + 40BC51F42AFC40930090EDD5 /* CBLBlockConflictResolver.h */, + 40BC51F52AFC40930090EDD5 /* CBLBlockConflictResolver.m */, ); path = Util; sourceTree = ""; @@ -4926,11 +4953,13 @@ 9343F138207D61EC00F19A89 /* Sources */, 9343F147207D61EC00F19A89 /* Frameworks */, 9343F149207D61EC00F19A89 /* Resources */, + 40BC51F32AFC39ED0090EDD5 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( 9382351A207D80490022328B /* PBXTargetDependency */, + 40BC51F22AFC39ED0090EDD5 /* PBXTargetDependency */, ); name = CBL_EE_ObjC_Tests; productName = CouchbaseLiteTests; @@ -6244,6 +6273,7 @@ 1A6F0945246C78FC0097D8B5 /* URLEndpointListenerTest.m in Sources */, 9343F141207D61EC00F19A89 /* ConcurrentTest.m in Sources */, 1A961800289BF7F80037E78E /* URLEndpointListenerTest+Collection.m in Sources */, + 40BC51FA2AFC56BB0090EDD5 /* CBLBlockConflictResolver.m in Sources */, 9388CBAD21BD9185005CA66D /* DocumentExpirationTest.m in Sources */, 93F714212490971600624296 /* ReplicatorTest+MessageEndPoint.m in Sources */, 1AC16CF4287D4D9B0041728F /* CollectionTest.m in Sources */, @@ -6303,6 +6333,7 @@ 1A93FAFC24F735250015D54D /* ReplicatorTest+PendingDocIds.m in Sources */, 9343F17B207D633300F19A89 /* ArrayTest.m in Sources */, 1A6F0951246C792A0097D8B5 /* URLEndpointListenerTest.m in Sources */, + 40BC51FB2AFC56BC0090EDD5 /* CBLBlockConflictResolver.m in Sources */, 9388CC4121C1E2BA005CA66D /* LogTest.m in Sources */, 9343F17C207D633300F19A89 /* FragmentTest.m in Sources */, 1AC16CF5287D4D9C0041728F /* CollectionTest.m in Sources */, @@ -6402,6 +6433,7 @@ 931C146B1EAAF0960094F9B2 /* FragmentTest.m in Sources */, 273E555E1F79AF79000182F1 /* MiscTest.m in Sources */, 938CFA2E1E442B5300291631 /* CBLTestCase.m in Sources */, + 40BC51F92AFC40F40090EDD5 /* CBLBlockConflictResolver.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6533,6 +6565,7 @@ 9378C5911E25B3F0001BB196 /* DatabaseTest.m in Sources */, 1A4FE76A225ED344009D5F43 /* MiscCppTest.mm in Sources */, 1A908401288027EE006B1885 /* ReplicatorTest+Collection.m in Sources */, + 40BC51F82AFC40F10090EDD5 /* CBLBlockConflictResolver.m in Sources */, 1AC83BC821C026D100792098 /* DateTimeQueryFunctionTest.m in Sources */, 938B3702200D7D1D004485D8 /* MigrationTest.m in Sources */, 1AA3D78422AB07C50098E16B /* CustomLogger.m in Sources */, @@ -6615,6 +6648,11 @@ target = 275F92731E4D30A4007FD5A2 /* CBL_Swift */; targetProxy = 27BF03371FB62933003D5BB8 /* PBXContainerItemProxy */; }; + 40BC51F22AFC39ED0090EDD5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9343EF2A207D611600F19A89 /* CBL_EE_ObjC */; + targetProxy = 40BC51F12AFC39ED0090EDD5 /* PBXContainerItemProxy */; + }; 9308F40E1E64B24700F53EE4 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = "LiteCore static"; @@ -7004,6 +7042,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9344A3611E44517B0091F581 /* CBL ObjC Tests - iOS App.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = N2Q372V7W2; HEADER_SEARCH_PATHS = ( "${inherited}", "$(SRCROOT)/vendor/couchbase-lite-core/C/include", @@ -7178,6 +7217,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9344A3611E44517B0091F581 /* CBL ObjC Tests - iOS App.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = N2Q372V7W2; HEADER_SEARCH_PATHS = ( "${inherited}", "$(SRCROOT)/vendor/couchbase-lite-core/C/include", @@ -8445,6 +8485,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9344A3611E44517B0091F581 /* CBL ObjC Tests - iOS App.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = N2Q372V7W2; HEADER_SEARCH_PATHS = ( "${inherited}", "$(SRCROOT)/vendor/couchbase-lite-core/C/include", @@ -8462,6 +8503,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9344A3611E44517B0091F581 /* CBL ObjC Tests - iOS App.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = N2Q372V7W2; HEADER_SEARCH_PATHS = ( "${inherited}", "$(SRCROOT)/vendor/couchbase-lite-core/C/include", diff --git a/Objective-C/CBLReplicator.mm b/Objective-C/CBLReplicator.mm index 6aff283ab..7be3a9e6f 100644 --- a/Objective-C/CBLReplicator.mm +++ b/Objective-C/CBLReplicator.mm @@ -89,8 +89,9 @@ @implementation CBLReplicator CBLChangeNotifier* _changeNotifier; CBLChangeNotifier* _docReplicationNotifier; BOOL _resetCheckpoint; // Reset the replicator checkpoint - unsigned _conflictCount; // Current number of conflict resolving tasks - BOOL _deferReplicatorNotification; // Defer replicator notification until finishing all conflict resolving tasks + BOOL _conflictResolutionSuspended; + NSMutableArray* _pendingConflicts; + BOOL _deferChangeNotification; // Defer change notification until finishing all conflict resolving tasks SecCertificateRef _serverCertificate; NSDictionary* _collectionMap; // [scopeName.collectionName : CBLCollection] } @@ -120,6 +121,7 @@ - (instancetype) initWithConfig: (CBLReplicatorConfiguration *)config { _progressLevel = kCBLProgressLevelOverall; _changeNotifier = [CBLChangeNotifier new]; _docReplicationNotifier = [CBLChangeNotifier new]; + _pendingConflicts = [NSMutableArray array]; _status = [[CBLReplicatorStatus alloc] initWithStatus: {kC4Stopped, {}, {}}]; NSString* qName = self.description; @@ -171,13 +173,14 @@ - (void) startWithReset: (BOOL)reset { // Start the C4Replicator: self.serverCertificate = NULL; _state = kCBLStateStarting; + [self setConflictResolutionSuspended: NO]; c4repl_start(_repl, reset); status = c4repl_getStatus(_repl); [_config.database addActiveStoppable: self]; #if TARGET_OS_IPHONE if (!_config.allowReplicatingInBackground) - [self setupBackgrounding]; + [self startBackgroundingMonitor]; #endif } else { // Failed to create C4Replicator: @@ -370,6 +373,10 @@ - (void) stopped { CBLReplicator* repl = self; [_config.database removeActiveStoppable: repl]; +#if TARGET_OS_IPHONE + [self endBackgroundingMonitor]; +#endif + CBLLogInfo(Sync, @"%@: Replicator is now stopped.", self); } @@ -379,6 +386,15 @@ - (void) safeBlock:(void (^)())block { } } +// Always being called inside the lock +- (void) idled { + Assert(_rawStatus.level == kC4Idle); +#if TARGET_OS_IPHONE + [self endCurrentBackgroundTask]; +#endif + CBLLogInfo(Sync, @"%@: Replicator is now idled.", self); +} + #pragma mark - Server Certificate - (SecCertificateRef) serverCertificate { @@ -603,59 +619,63 @@ - (void) c4StatusChanged: (C4ReplicatorStatus)c4Status { // Record raw status: _rawStatus = c4Status; - // Running; idle or busy: - if (c4Status.level > kC4Connecting) { - if (_state == kCBLStateStarting) { - _state = kCBLStateRunning; - } - [self stopReachability]; - } + // Reset to notify for now: + _deferChangeNotification = NO; - // Offline / suspending: if (c4Status.level == kC4Offline) { + // When the replicator is offline, it could be either that : + // 1. The replicator is offline as the remote server is not reachable. + // 2. (iOS Only) The replicator is suspended as the app was backgrounding + // or lost the access to the database file due to the file protection + // level set when the screen was lock. + // When the app went offline, start the reachability monitor to observe the + // network changes and retry when the remote server is reachable again. + // No reachability is started when the replicator is suspended. if (_state == kCBLStateSuspending) { _state = kCBLStateSuspended; } else if (_state > kCBLStateStopping) { _state = kCBLStateOffline; [self startReachability]; } - } - - // Stopped: - if (c4Status.level == kC4Stopped) { + } else { + // Stop the reachability monitor as the replicator is not offline anymore. [self stopReachability]; - #if TARGET_OS_IPHONE - [self endBackgrounding]; - #endif - if (_conflictCount == 0) - [self stopped]; + if (c4Status.level == kC4Stopped) { + // When the replicator is stopped, check if there are any pending conflicts + // to be resolved. If none, the replicator will go into the stopped state, + // and the change listeners will be notified about the stopped status. + // Otherwise, the stopped status will be defered to notify until all pending + // conflicts are resolved. + if ([self hasPendingConflicts]) { + _state = kCBLStateStopping; + _deferChangeNotification = YES; + } else { + [self stopped]; + } + } else if (c4Status.level == kC4Idle) { + // When the replicator is idle, check if there are any pending conflicts + // to be resolved. If none, the change listeners will be notified about + // the idle status. Otherwise, the idle status will be defered to notify + // until all pending conflicts are resolved. + _state = kCBLStateRunning; + if ([self hasPendingConflicts]) { + _deferChangeNotification = YES; + } else { + [self idled]; + } + } else if (c4Status.level == kC4Busy) { + _state = kCBLStateRunning; + } } - - // replicator status callback - /// stopped and idle state, we will defer status callback till conflicts finish resolving - if (_conflictCount > 0 && (c4Status.level == kC4Stopped || c4Status.level == kC4Idle)) { - CBLLogInfo(Sync, @"%@: Status = %d, but waiting for conflict resolution (pending = %d) " - "to finish before notifying.", self, c4Status.level, _conflictCount); - - _deferReplicatorNotification = YES; - if (c4Status.level == kC4Stopped) - _state = kCBLStateStopping; - } else - _deferReplicatorNotification = NO; - if (!_deferReplicatorNotification) - [self updateAndPostStatus]; - - #if TARGET_OS_IPHONE - // End the current background task when the replicator is idle: - if (c4Status.level == kC4Idle) - [self endCurrentBackgroundTask]; - #endif + if (!_deferChangeNotification) { + [self postChangeNotification]; + } } } -- (void) updateAndPostStatus { +- (void) postChangeNotification { NSError* error = nil; if (_rawStatus.error.code) convertError(_rawStatus.error, &error); @@ -672,7 +692,7 @@ - (void) updateAndPostStatus { status: self.status]]; } -#pragma mark - DOCUMENT-LEVEL 0: +#pragma mark - DOCUMENT REPLICATION EVENT HANDLER: static void onDocsEnded(C4Replicator* repl, bool pushing, @@ -696,12 +716,13 @@ static void onDocsEnded(C4Replicator* repl, }); } +// Called inside the lock - (void) onDocsEnded: (NSArray*)docs pushing: (BOOL)pushing { NSMutableArray* posts = [NSMutableArray array]; for (CBLReplicatedDocument *doc in docs) { C4Error c4err = doc.c4Error; if (!pushing && c4err.domain == LiteCoreDomain && c4err.code == kC4ErrorConflict) { - [self resolveConflict: doc]; + [self scheduleConflictResolutionForDocument: doc]; } else { [posts addObject: doc]; [self logErrorOnDocument: doc pushing: pushing]; @@ -711,25 +732,50 @@ - (void) onDocsEnded: (NSArray*)docs pushing: (BOOL)push [self postDocumentReplications: posts pushing: pushing]; } -- (void) resolveConflict: (CBLReplicatedDocument*)doc { - _conflictCount++; - dispatch_async(_conflictQueue, ^{ +- (void) postDocumentReplications: (NSArray*)docs pushing: (BOOL)pushing { + id replication = [[CBLDocumentReplication alloc] initWithReplicator: self + isPush: pushing + documents: docs]; + [_docReplicationNotifier postChange: replication]; +} + +- (void) logErrorOnDocument: (CBLReplicatedDocument*)doc pushing: (BOOL)pushing { + C4Error c4err = doc.c4Error; + if (doc.c4Error.code) + CBLLogInfo(Sync, @"%@: %serror %s '%@': %d/%d", self, (doc.isTransientError ? "transient " : ""), + (pushing ? "pushing" : "pulling"), doc.id, c4err.domain, c4err.code); +} + +#pragma mark - CONFLICT RESOLUTION: + +// Called inside the lock +- (void) scheduleConflictResolutionForDocument: (CBLReplicatedDocument*)doc { + if (_conflictResolutionSuspended || _state == kCBLStateStopped) { + return; + } + + dispatch_block_t resolution = dispatch_block_create(DISPATCH_BLOCK_ASSIGN_CURRENT, ^{ [self _resolveConflict: doc]; - CBL_LOCK(self) { - if (--_conflictCount == 0 && _deferReplicatorNotification) { - if (_rawStatus.level == kC4Stopped) { - Assert(_state == kCBLStateStopping); - [self stopped]; - } - - _deferReplicatorNotification = NO; - [self updateAndPostStatus]; - } - } }); + + dispatch_block_notify(resolution, _dispatchQueue, ^{ + // Called when the resolution was either successful or cancelled: + [self didFinishConflictResolution: resolution]; + }); + + [_pendingConflicts addObject: resolution]; + + dispatch_async(_conflictQueue, resolution); } +// Called inside conflict resolution queue: - (void) _resolveConflict: (CBLReplicatedDocument*)doc { + CBL_LOCK(self) { + if (_conflictResolutionSuspended) { + return; + } + } + CBLLogInfo(Sync, @"%@: Resolve conflicting version of '%@'", self, doc.id); CBLCollection* c = [_collectionMap objectForKey: $sprintf(@"%@.%@", doc.scope, doc.collection)]; @@ -750,18 +796,53 @@ - (void) _resolveConflict: (CBLReplicatedDocument*)doc { [self postDocumentReplications: @[doc] pushing: NO]; } -- (void) postDocumentReplications: (NSArray*)docs pushing: (BOOL)pushing { - id replication = [[CBLDocumentReplication alloc] initWithReplicator: self - isPush: pushing - documents: docs]; - [_docReplicationNotifier postChange: replication]; +- (void) didFinishConflictResolution: (dispatch_block_t)resolution { + CBL_LOCK(self) { + [_pendingConflicts removeObject: resolution]; + + if (_pendingConflicts.count == 0) { + if (_rawStatus.level == kC4Stopped && _state == kCBLStateStopping) { + [self stopped]; + } else if (_rawStatus.level == kC4Idle) { + [self idled]; + } + if (_deferChangeNotification) { + _deferChangeNotification = NO; + [self postChangeNotification]; + } + } + } } -- (void) logErrorOnDocument: (CBLReplicatedDocument*)doc pushing: (BOOL)pushing { - C4Error c4err = doc.c4Error; - if (doc.c4Error.code) - CBLLogInfo(Sync, @"%@: %serror %s '%@': %d/%d", self, (doc.isTransientError ? "transient " : ""), - (pushing ? "pushing" : "pulling"), doc.id, c4err.domain, c4err.code); +// Called inside the lock +- (BOOL) hasPendingConflicts { + return _pendingConflicts.count > 0; +} + +// For test to get number of pending conflicts +- (NSUInteger) pendingConflictCount { + CBL_LOCK(self) { + return _pendingConflicts.count > 0; + } +} + +// Called inside the lock +- (void) setConflictResolutionSuspended: (BOOL)suspended { + _conflictResolutionSuspended = suspended; + if (suspended) { + // Note: All cancelled resolutions will be notified and queued again when + // the replicator is resumed or restarted. + for (dispatch_block_t resolution in _pendingConflicts) { + dispatch_block_cancel(resolution); + } + } +} + +// For test to check the suspended status +- (BOOL) conflictResolutionSuspended { + CBL_LOCK(self) { + return _conflictResolutionSuspended; + } } #pragma mark - PUSH/PULL FILTER: @@ -815,13 +896,34 @@ - (bool) filterDocument: (C4CollectionSpec)c4spec - (BOOL) active { CBL_LOCK(self) { - return (_rawStatus.level == kC4Connecting || _rawStatus.level == kC4Busy); + return (_rawStatus.level == kC4Connecting || _rawStatus.level == kC4Busy || [self hasPendingConflicts]); } } - (void) setSuspended: (BOOL)suspended { + // (iOS Only) The replicator could be suspended by the backgrounding + // monitor either when : + // 1. The app was in the background && the replicator was caught up + // (IDLE) or the background task for the replicator was expired. + // 2. The app lost access to the database files under the lock screen + // as the file protection level as set to "Complete" or + // "Complete Unless Open". + // + // When the replicator is suspended, the internal replicator at + // the LiteCore level will be stopped and the replicator status + // will be OFFLINE (instead of stopped). Any pending conflict + // resolution will be cancelled as best effort as any being-excuted + // conflict resolution cannot be cancelled. + // + // All cancelled conflict resolutions will be notified, and put into the queue + // again when the replicator is resumed or is restarted. CBL_LOCK(self) { + if (suspended && _state > kCBLStateSuspending) { + // Currently not in any suspend* or stop* state: + _state = kCBLStateSuspending; + } c4repl_setSuspended(_repl, suspended); + [self setConflictResolutionSuspended: suspended]; } } diff --git a/Objective-C/Internal/CBLReplicator+Internal.h b/Objective-C/Internal/CBLReplicator+Internal.h index 00ec33067..6804a7791 100644 --- a/Objective-C/Internal/CBLReplicator+Internal.h +++ b/Objective-C/Internal/CBLReplicator+Internal.h @@ -60,7 +60,9 @@ NS_ASSUME_NONNULL_BEGIN } @property (readonly, atomic) BOOL active; -@property (nonatomic) MYBackgroundMonitor* bgMonitor; +@property (readonly, atomic) BOOL conflictResolutionSuspended; +@property (readonly, atomic) NSUInteger pendingConflictCount; +@property (nonatomic, nullable) MYBackgroundMonitor* bgMonitor; @property (readonly, atomic) dispatch_queue_t dispatchQueue; // For CBLWebSocket to set the current server certificate diff --git a/Objective-C/Internal/Replicator/CBLReplicator+Backgrounding.h b/Objective-C/Internal/Replicator/CBLReplicator+Backgrounding.h index fade2ebb0..61fd8589d 100644 --- a/Objective-C/Internal/Replicator/CBLReplicator+Backgrounding.h +++ b/Objective-C/Internal/Replicator/CBLReplicator+Backgrounding.h @@ -23,9 +23,9 @@ @interface CBLReplicator (Backgrounding) -- (void) setupBackgrounding; +- (void) startBackgroundingMonitor; -- (void) endBackgrounding; +- (void) endBackgroundingMonitor; - (void) endCurrentBackgroundTask; diff --git a/Objective-C/Internal/Replicator/CBLReplicator+Backgrounding.m b/Objective-C/Internal/Replicator/CBLReplicator+Backgrounding.m index ed31f8ca1..d3d73e40d 100644 --- a/Objective-C/Internal/Replicator/CBLReplicator+Backgrounding.m +++ b/Objective-C/Internal/Replicator/CBLReplicator+Backgrounding.m @@ -27,7 +27,12 @@ @implementation CBLReplicator (Backgrounding) -- (void) setupBackgrounding { +- (void) startBackgroundingMonitor { + if (self.bgMonitor) { + CBLLogInfo(Sync, @"%@: Ignored starting backgrounding monitor as already started", self); + return; + } + CBLLogInfo(Sync, @"%@: Starting backgrounding monitor...", self); NSFileProtectionType prot = self.fileProtection; if ([prot isEqual: NSFileProtectionComplete] || @@ -59,7 +64,12 @@ - (NSFileProtectionType) fileProtection { #pragma clang diagnostic pop } -- (void) endBackgrounding { +- (void) endBackgroundingMonitor { + if (!self.bgMonitor) { + CBLLogInfo(Sync, @"%@: Ignored ending backgrounding monitor as not started", self); + return; + } + CBLLogInfo(Sync, @"%@: Ending backgrounding monitor...", self); [NSNotificationCenter.defaultCenter removeObserver: self name: UIApplicationProtectedDataWillBecomeUnavailable @@ -68,6 +78,7 @@ - (void) endBackgrounding { name: UIApplicationProtectedDataDidBecomeAvailable object: nil]; [self.bgMonitor stop]; + self.bgMonitor = nil; } // Called when the replicator goes idle @@ -96,8 +107,9 @@ - (void) appBackgrounding { - (void) appForegrounding { BOOL ended = [self.bgMonitor endBackgroundTask]; - if (ended) + if (ended) { CBLLogInfo(Sync, @"%@: App foregrounding, ending background task.", self); + } if (_deepBackground) { _deepBackground = NO; [self updateSuspended]; @@ -112,13 +124,14 @@ - (void) backgroundTaskExpired { // Called when the app is about to lose access to files: - (void) fileAccessChanged: (NSNotification*)n { - CBLLogInfo(Sync, @"%@: Device locked, database unavailable.", self); + CBLLogInfo(Sync, @"%@: Device lock status and file access changed to %@", self, n.name); _filesystemUnavailable = [n.name isEqual: UIApplicationProtectedDataWillBecomeUnavailable]; [self updateSuspended]; } - (void) updateSuspended { BOOL suspended = (_filesystemUnavailable || _deepBackground); + CBLLogInfo(Sync, @"%@: Update suspended status to '%@'", self, suspended ? @"suspended" : @"resumed"); self.suspended = suspended; } diff --git a/Objective-C/Tests/ReplicatorTest+Main.m b/Objective-C/Tests/ReplicatorTest+Main.m index 9346faba7..27277891b 100644 --- a/Objective-C/Tests/ReplicatorTest+Main.m +++ b/Objective-C/Tests/ReplicatorTest+Main.m @@ -18,6 +18,7 @@ // #import "ReplicatorTest.h" +#import "CBLBlockConflictResolver.h" #import "CBLDatabase+Internal.h" #import "CBLDocumentReplication+Internal.h" #import "CBLReplicator+Backgrounding.h" @@ -374,9 +375,11 @@ - (void) testSwitchBackgroundForeground { for (int i = 0; i < numRounds; i++) { [r appBackgrounding]; [self waitForExpectations: @[backgroundExps[i]] timeout: 5.0]; + Assert(r.conflictResolutionSuspended); [r appForegrounding]; [self waitForExpectations: @[foregroundExps[i+1]] timeout: 5.0]; + AssertFalse(r.conflictResolutionSuspended); } [r stop]; @@ -534,6 +537,83 @@ - (void) testBackgroundingDuringDataTransfer { CBLBlob* blob2 = [doc blobForKey: @"blob"]; AssertEqualObjects(blob2.digest, blob.digest); } + +- (void) testSuspendConflictResolution { + // Prepare conflicts: + NSUInteger numDocs = 1000; + for (NSUInteger i = 0; i < numDocs; i++) { + NSError* error; + NSString* docID = [NSString stringWithFormat: @"doc-%lu", (unsigned long)i]; + CBLMutableDocument *doc1a = [[CBLMutableDocument alloc] initWithID: docID]; + [doc1a setString: self.db.name forKey: @"name"]; + Assert([self.db saveDocument: doc1a error: &error]); + + CBLMutableDocument *doc1b = [[CBLMutableDocument alloc] initWithID: docID]; + [doc1b setString: self.otherDB.name forKey: @"name"]; + Assert([self.otherDB saveDocument: doc1b error: &error]); + } + + NSLock* lock = [[NSLock alloc] init]; + + __block NSUInteger resolvingCount = 0; + XCTestExpectation* resolving = [self allowOverfillExpectationWithDescription: @"Resolver was called"]; + CBLBlockConflictResolver* resolver = [[CBLBlockConflictResolver alloc] initWithResolver: ^CBLDocument* (CBLConflict* conflict) { + [lock lock]; + resolvingCount++; + [lock unlock]; + + [resolving fulfill]; + return conflict.remoteDocument; + }]; + + id target = [[CBLDatabaseEndpoint alloc] initWithDatabase: self.otherDB]; + CBLReplicatorConfiguration* config = [self configWithTarget: target type: kCBLReplicatorTypePull continuous: YES]; + config.conflictResolver = resolver; + CBLReplicator* r = [[CBLReplicator alloc] initWithConfig: config]; + + XCTestExpectation* offline = [self expectationWithDescription: @"Offline"]; + XCTestExpectation* stopped = [self expectationWithDescription: @"Stopped"]; + + id token = [r addChangeListener: ^(CBLReplicatorChange* change) { + NSLog(@">>> %d (%llu/%llu) %@", change.status.activity, change.status.progress.completed, change.status.progress.total, change.status.error); + if (change.status.activity == kCBLReplicatorOffline) { + [offline fulfill]; + } else if (change.status.activity == kCBLReplicatorStopped) { + [stopped fulfill]; + } + }]; + + [r start]; + + // Wait until there is at least one conflict resolver is called. + [self waitForExpectations: @[resolving] timeout: 10.0]; + + // Now suspend. + [r setSuspended: YES]; + + // Wait until no pending conflcit resolver: + NSDate* checkTimeout = [NSDate dateWithTimeIntervalSinceNow: 10.0]; + while (r.pendingConflictCount != 0 && checkTimeout.timeIntervalSinceNow > 0.0) { + if (![[NSRunLoop currentRunLoop] runMode: NSDefaultRunLoopMode beforeDate: [NSDate dateWithTimeIntervalSinceNow: 0.5]]) { + break; + } + } + + AssertEqual(r.pendingConflictCount, 0); + Assert(resolvingCount > 0); + Assert(resolvingCount < numDocs); + + // Wait until suspended: + [self waitForExpectations: @[offline] timeout: 10.0]; + + // Stop the replicator: + [r stop]; + + // Wait until the replicator is stopped: + [self waitForExpectations: @[stopped] timeout: 5.0]; + + [r removeChangeListenerWithToken: token]; +} #endif // TARGET_OS_IPHONE diff --git a/Objective-C/Tests/Util/CBLBlockConflictResolver.h b/Objective-C/Tests/Util/CBLBlockConflictResolver.h new file mode 100644 index 000000000..cc3ab24f0 --- /dev/null +++ b/Objective-C/Tests/Util/CBLBlockConflictResolver.h @@ -0,0 +1,35 @@ +// +// CBLBlockConflictResolver.h +// CouchbaseLite +// +// Copyright (c) 2023 Couchbase, Inc. All rights reserved. +// +// Licensed under the Couchbase License Agreement (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// https://info.couchbase.com/rs/302-GJY-034/images/2017-10-30_License_Agreement.pdf +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import "CouchbaseLite.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface CBLBlockConflictResolver : NSObject + +@property(nonatomic, nullable) CBLDocument* winner; + +- (instancetype) init NS_UNAVAILABLE; + +// set this resolver, which will be used while resolving the conflict +- (instancetype) initWithResolver: (CBLDocument* (^)(CBLConflict*))resolver; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Objective-C/Tests/Util/CBLBlockConflictResolver.m b/Objective-C/Tests/Util/CBLBlockConflictResolver.m new file mode 100644 index 000000000..0e2a31997 --- /dev/null +++ b/Objective-C/Tests/Util/CBLBlockConflictResolver.m @@ -0,0 +1,41 @@ +// +// CBLBlockConflictResolver.m +// CouchbaseLite +// +// Copyright (c) 2023 Couchbase, Inc. All rights reserved. +// +// Licensed under the Couchbase License Agreement (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// https://info.couchbase.com/rs/302-GJY-034/images/2017-10-30_License_Agreement.pdf +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "CBLBlockConflictResolver.h" + +@implementation CBLBlockConflictResolver { + CBLDocument* (^_resolver)(CBLConflict*); +} + +@synthesize winner=_winner; + +// set this resolver, which will be used while resolving the conflict +- (instancetype) initWithResolver: (CBLDocument* (^)(CBLConflict*))resolver { + self = [super init]; + if (self) { + _resolver = resolver; + } + return self; +} + +- (CBLDocument *) resolve:(CBLConflict *)conflict { + _winner = _resolver(conflict); + return _winner; +} + +@end From 4f2e0739ef1e310b51c56984939e588103cf8ee9 Mon Sep 17 00:00:00 2001 From: Pasin Suriyentrakorn Date: Mon, 12 Feb 2024 20:26:55 -0800 Subject: [PATCH 2/2] Fix XCode 15 test build error and errors in tests --- Objective-C/Tests/CBLTestCase.m | 2 +- Objective-C/Tests/ReplicatorTest+Collection.m | 10 ++++------ Objective-C/Tests/TLSIdentityTest.m | 2 +- Objective-C/Tests/URLEndpointListenerTest.h | 4 ++-- Objective-C/Tests/URLEndpointListenerTest.m | 9 --------- Swift/Tests/ReplicatorTest+Collection.swift | 12 +++++------- 6 files changed, 13 insertions(+), 26 deletions(-) diff --git a/Objective-C/Tests/CBLTestCase.m b/Objective-C/Tests/CBLTestCase.m index 80ec7aca6..dbc617f41 100644 --- a/Objective-C/Tests/CBLTestCase.m +++ b/Objective-C/Tests/CBLTestCase.m @@ -74,7 +74,7 @@ - (void) tearDown { } // Wait a little while for objects to be cleaned up: - int leaks; + int leaks = 0; for (int i = 0; i < 20; i++) { leaks = c4_getObjectCount() - _c4ObjectCount; if (leaks == 0) diff --git a/Objective-C/Tests/ReplicatorTest+Collection.m b/Objective-C/Tests/ReplicatorTest+Collection.m index a33386264..ed7aff8cd 100644 --- a/Objective-C/Tests/ReplicatorTest+Collection.m +++ b/Objective-C/Tests/ReplicatorTest+Collection.m @@ -920,19 +920,17 @@ - (void) testCollectionConflictResolver { CBLReplicator* r = [[CBLReplicator alloc] initWithConfig: config]; id token = [r addDocumentReplicationListener: ^(CBLDocumentReplication* docReplication) { - // change with single document are the update revisions from colA & colB collections. - if (docReplication.documents.count == 1) { + if (!docReplication.isPush) { + // Pull will resolve the conflicts: CBLReplicatedDocument* doc = docReplication.documents[0]; AssertEqualObjects(doc.id, [doc.collection isEqualToString: @"colA"] ? @"doc1" : @"doc2"); AssertEqual(doc.error.code, 0); - } else if (docReplication.documents.count == 2) { - // change with 2 docs, will be the conflict + } else { + // Push will have conflict errors: for (CBLReplicatedDocument* doc in docReplication.documents) { AssertEqualObjects(doc.id, [doc.collection isEqualToString: @"colA"] ? @"doc1" : @"doc2"); AssertEqual(doc.error.code, CBLErrorHTTPConflict); } - } else { - AssertFalse(true, @"Unexpected document change listener"); } }]; [self runWithReplicator: r errorCode: 0 errorDomain: nil]; diff --git a/Objective-C/Tests/TLSIdentityTest.m b/Objective-C/Tests/TLSIdentityTest.m index 4d0e08fa1..347d41c6c 100644 --- a/Objective-C/Tests/TLSIdentityTest.m +++ b/Objective-C/Tests/TLSIdentityTest.m @@ -45,7 +45,7 @@ - (CFTypeRef) findInKeyChain: (NSDictionary*)params { - (NSData*) publicKeyHashFromCert: (SecCertificateRef)certRef { // Get public key from the certificate: - SecKeyRef publicKeyRef; + SecKeyRef publicKeyRef = NULL; if (@available(iOS 12, macOS 10.14, *)) { publicKeyRef = SecCertificateCopyKey(certRef); } else { diff --git a/Objective-C/Tests/URLEndpointListenerTest.h b/Objective-C/Tests/URLEndpointListenerTest.h index 19fe0046f..e38726300 100644 --- a/Objective-C/Tests/URLEndpointListenerTest.h +++ b/Objective-C/Tests/URLEndpointListenerTest.h @@ -68,8 +68,8 @@ NS_ASSUME_NONNULL_BEGIN @interface CBLURLEndpointListener (Test) -- (NSURL*) localURL; -- (CBLURLEndpoint*) localEndpoint; +@property (nonatomic, readonly) NSURL* localURL; +@property (nonatomic, readonly) CBLURLEndpoint* localEndpoint; @end diff --git a/Objective-C/Tests/URLEndpointListenerTest.m b/Objective-C/Tests/URLEndpointListenerTest.m index 312d28267..854baefd1 100644 --- a/Objective-C/Tests/URLEndpointListenerTest.m +++ b/Objective-C/Tests/URLEndpointListenerTest.m @@ -24,15 +24,6 @@ #import "CollectionUtils.h" #import "URLEndpointListenerTest.h" -NS_ASSUME_NONNULL_BEGIN - -@interface CBLURLEndpointListener (Test) -@property (nonatomic, readonly) NSURL* localURL; -@property (nonatomic, readonly) CBLURLEndpoint* localEndpoint; -@end - -NS_ASSUME_NONNULL_END - @implementation CBLURLEndpointListener (Test) // TODO: Remove https://issues.couchbase.com/browse/CBL-3206 diff --git a/Swift/Tests/ReplicatorTest+Collection.swift b/Swift/Tests/ReplicatorTest+Collection.swift index ad337b77b..ee14ed3c1 100644 --- a/Swift/Tests/ReplicatorTest+Collection.swift +++ b/Swift/Tests/ReplicatorTest+Collection.swift @@ -640,21 +640,19 @@ class ReplicatorTest_Collection: ReplicatorTest { let r = Replicator(config: config) r.addDocumentReplicationListener { docReplication in - if docReplication.documents.count == 1 { - // change with single doc, will be update revisions from colA & colB collections. + if !docReplication.isPush { + // Pull will resolve the conflicts: let doc = docReplication.documents[0] XCTAssertEqual(doc.id, doc.collection == "colA" ? "doc1" : "doc2") XCTAssertNil(doc.error) - } else if docReplication.documents.count == 2 { - // change with 2 docs, will be the conflict change + } else { + // Pull will have conflict errors: for doc in docReplication.documents { XCTAssertEqual(doc.id, doc.collection == "colA" ? "doc1" : "doc2") XCTAssertEqual((doc.error as? NSError)?.code, CBLError.httpConflict) } - } else { - XCTFail("Unexpected document change listener") - } + } } run(replicator: r, expectedError: nil)