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..c1648fa9b 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,83 @@ - (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) { + [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 - (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