From a6dfa2b4724b5b65a9a864821635cfa0337aecdf Mon Sep 17 00:00:00 2001 From: Pasin Suriyentrakorn Date: Wed, 8 Nov 2023 16:59:33 -0800 Subject: [PATCH] CBL-5035 : 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 | 243 +++++++++++++----- Objective-C/Internal/CBLReplicator+Internal.h | 4 +- .../Replicator/CBLReplicator+Backgrounding.h | 4 +- .../Replicator/CBLReplicator+Backgrounding.m | 21 +- Objective-C/Tests/ReplicatorTest+Main.m | 81 ++++++ .../Tests/Util/CBLBlockConflictResolver.h | 35 +++ .../Tests/Util/CBLBlockConflictResolver.m | 41 +++ 8 files changed, 397 insertions(+), 78 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 161ffbbc2..a0a3456a1 100644 --- a/CouchbaseLite.xcodeproj/project.pbxproj +++ b/CouchbaseLite.xcodeproj/project.pbxproj @@ -222,6 +222,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 */; }; @@ -877,7 +883,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 */; }; @@ -1616,6 +1621,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 */; @@ -1832,6 +1844,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; @@ -1994,6 +2017,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 = ""; }; 6992582A22DFE9A100E0D1D2 /* build_xcframework.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = build_xcframework.sh; sourceTree = ""; }; @@ -2540,7 +2565,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9343F148207D61EC00F19A89 /* CouchbaseLite.framework in Frameworks */, + 40BC51EF2AFC39ED0090EDD5 /* CouchbaseLite.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2780,6 +2805,8 @@ 930C7F8120FE4F7400C74A12 /* CBLMockConnectionErrorLogic.h */, 930C7F7E20FE4F7400C74A12 /* CBLMockConnectionErrorLogic.m */, 930C7F8020FE4F7400C74A12 /* CBLMockConnectionLifecycleLocation.h */, + 40BC51F42AFC40930090EDD5 /* CBLBlockConflictResolver.h */, + 40BC51F52AFC40930090EDD5 /* CBLBlockConflictResolver.m */, ); path = Util; sourceTree = ""; @@ -4666,11 +4693,13 @@ 9343F138207D61EC00F19A89 /* Sources */, 9343F147207D61EC00F19A89 /* Frameworks */, 9343F149207D61EC00F19A89 /* Resources */, + 40BC51F32AFC39ED0090EDD5 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( 9382351A207D80490022328B /* PBXTargetDependency */, + 40BC51F22AFC39ED0090EDD5 /* PBXTargetDependency */, ); name = CBL_EE_ObjC_Tests; productName = CouchbaseLiteTests; @@ -5942,6 +5971,7 @@ 9343F140207D61EC00F19A89 /* DictionaryTest.m in Sources */, 1A6F0945246C78FC0097D8B5 /* URLEndpointListenerTest.m in Sources */, 9343F141207D61EC00F19A89 /* ConcurrentTest.m in Sources */, + 40BC51FA2AFC56BB0090EDD5 /* CBLBlockConflictResolver.m in Sources */, 9388CBAD21BD9185005CA66D /* DocumentExpirationTest.m in Sources */, 93F714212490971600624296 /* ReplicatorTest+MessageEndPoint.m in Sources */, 9343F142207D61EC00F19A89 /* ReplicatorTest.m in Sources */, @@ -5995,6 +6025,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 */, 1AA6744D227924130018CC6D /* QueryTest+Join.m in Sources */, @@ -6088,6 +6119,7 @@ 931C146B1EAAF0960094F9B2 /* FragmentTest.m in Sources */, 273E555E1F79AF79000182F1 /* MiscTest.m in Sources */, 938CFA2E1E442B5300291631 /* CBLTestCase.m in Sources */, + 40BC51F92AFC40F40090EDD5 /* CBLBlockConflictResolver.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6214,6 +6246,7 @@ 1A4160C62283673E0061A567 /* ReplicatorTest+CustomConflict.m in Sources */, 9378C5911E25B3F0001BB196 /* DatabaseTest.m in Sources */, 1A4FE76A225ED344009D5F43 /* MiscCppTest.mm in Sources */, + 40BC51F82AFC40F10090EDD5 /* CBLBlockConflictResolver.m in Sources */, 1AC83BC821C026D100792098 /* DateTimeQueryFunctionTest.m in Sources */, 938B3702200D7D1D004485D8 /* MigrationTest.m in Sources */, 1AA3D78422AB07C50098E16B /* CustomLogger.m in Sources */, @@ -6291,6 +6324,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"; @@ -6688,6 +6726,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", @@ -6866,6 +6905,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", @@ -8155,6 +8195,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", @@ -8172,6 +8213,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 4c14003dc..82a8021f2 100644 --- a/Objective-C/CBLReplicator.mm +++ b/Objective-C/CBLReplicator.mm @@ -83,8 +83,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; } @@ -154,13 +155,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: @@ -324,9 +326,24 @@ - (void) stopped { CBLReplicator* repl = self; [_config.database removeActiveStoppable: repl]; +#if TARGET_OS_IPHONE + [self endBackgroundingMonitor]; +#endif + CBLLogInfo(Sync, @"%@: Replicator is now stopped.", self); } +// 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 { @@ -521,59 +538,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); @@ -590,7 +611,7 @@ - (void) updateAndPostStatus { status: self.status]]; } -#pragma mark - DOCUMENT-LEVEL 0: +#pragma mark - DOCUMENT REPLICATION EVENT HANDLER: static void onDocsEnded(C4Replicator* repl, bool pushing, @@ -614,12 +635,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]; @@ -629,26 +651,53 @@ - (void) onDocsEnded: (NSArray*)docs pushing: (BOOL)push [self postDocumentReplications: posts pushing: pushing]; } -- (void) resolveConflict: (CBLReplicatedDocument*)doc { - _conflictCount++; - dispatch_async(_conflictQueue, ^{ +// Safe to call outside lock +- (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) { + 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 { - CBLLogInfo(Sync, @"%@: Resolve conflicting version of '%@'", self, doc.id); + CBL_LOCK(self) { + if (_conflictResolutionSuspended) { + return; + } + } + + CBLLogInfo(Sync, @"%@: Resolve conflicting version for '%@'", self, doc.id); + NSError* error = nil; if (![_config.database resolveConflictInDocument: doc.id withConflictResolver: _config.conflictResolver @@ -661,18 +710,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: @@ -717,13 +801,34 @@ - (bool) filterDocument: (C4String)docID - (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 d7131eaef..107f1d818 100644 --- a/Objective-C/Internal/CBLReplicator+Internal.h +++ b/Objective-C/Internal/CBLReplicator+Internal.h @@ -56,7 +56,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 88d04a95b..fe02e0de8 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] || @@ -55,7 +60,12 @@ - (NSFileProtectionType) fileProtection { return attrs[NSFileProtectionKey] ?: NSFileProtectionNone; } -- (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 @@ -64,6 +74,7 @@ - (void) endBackgrounding { name: UIApplicationProtectedDataDidBecomeAvailable object: nil]; [self.bgMonitor stop]; + self.bgMonitor = nil; } // Called when the replicator goes idle @@ -92,8 +103,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]; @@ -108,13 +120,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 f4340261c..249baa1dc 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" @@ -347,9 +348,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]; @@ -447,6 +450,84 @@ - (void) testBackgroundingWhenStopping { r = nil; } +- (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) { + [resolving fulfill]; + + [lock lock]; + resolvingCount++; + [lock unlock]; + + 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 - (void) testStartWithResetCheckpoint { 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