From 134d8d78a136c0a0f602ca63f82bee5ae667973a Mon Sep 17 00:00:00 2001 From: Jim Borden Date: Wed, 17 Jun 2026 15:10:24 +0900 Subject: [PATCH 1/3] CBL-8202: Retry expiration logic on SQLITE_BUSY Also fix the expiration torture test to not exit early on the same condition --- C/tests/c4DatabaseTest.cc | 16 +++++++++++++- LiteCore/Database/Housekeeper.cc | 36 +++++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/C/tests/c4DatabaseTest.cc b/C/tests/c4DatabaseTest.cc index d39af0d48..8f1bd6e77 100644 --- a/C/tests/c4DatabaseTest.cc +++ b/C/tests/c4DatabaseTest.cc @@ -780,7 +780,21 @@ N_WAY_TEST_CASE_METHOD(C4DatabaseTest, "Document expiration torture test", "[Dat int setCount = 1; C4Error error{}; for ( ; setCount <= total; ++setCount ) { - if ( !c4coll_setDocExpiration(collection, c4str(docID(setCount)), expire, &error) ) { + // CBL-8202: Under this artificial contention (a no-pause reader on another connection + // plus the background purge) setDocExpiration can momentarily fail with a transient + // "busy" error when it can't grab the write lock. That is expected and retryable, so + // retry rather than aborting the loop and leaving documents without an expiration. + constexpr int kMaxBusyRetries = 200; + bool ok = false; + for ( int busyRetries = 0; !ok; ) { + ok = c4coll_setDocExpiration(collection, c4str(docID(setCount)), expire, &error); + if ( ok ) break; + if ( error.domain != LiteCoreDomain || error.code != kC4ErrorBusy + || ++busyRetries > kMaxBusyRetries ) + break; + std::this_thread::sleep_for(5ms); + } + if ( !ok ) { stop.store(true); break; } diff --git a/LiteCore/Database/Housekeeper.cc b/LiteCore/Database/Housekeeper.cc index e52a001af..007da6482 100644 --- a/LiteCore/Database/Housekeeper.cc +++ b/LiteCore/Database/Housekeeper.cc @@ -16,6 +16,7 @@ #include "SequenceTracker.hh" #include "BackgroundDB.hh" #include "DataFile.hh" +#include "Error.hh" #include "Logging.hh" #include "SQLiteKeyStore.hh" #include "StringUtil.hh" @@ -98,18 +99,39 @@ namespace litecore { enqueue(FUNCTION_TO_QUEUE(Housekeeper::_doExpiration)); } + // CBL-8202: How long to wait before retrying expiration after a transient "database busy" + // error. Another connection (or thread) held the write lock while we tried to purge; we just + // need to try again shortly. + static constexpr chrono::milliseconds kRetryExpirationAfterBusyMS{100}; + void Housekeeper::_doExpiration() { if ( _isStopped() ) return; logInfo("Housekeeper: expiring documents..."); - _bgdb->useInTransaction(_keyStoreName, [&](KeyStore& keyStore, SequenceTracker* sequenceTracker) -> bool { - if ( sequenceTracker ) { - keyStore.expireRecords([&](slice docID) { sequenceTracker->documentPurged(docID); }); - } else { - keyStore.expireRecords(); + try { + _bgdb->useInTransaction(_keyStoreName, [&](KeyStore& keyStore, SequenceTracker* sequenceTracker) -> bool { + if ( sequenceTracker ) { + keyStore.expireRecords([&](slice docID) { sequenceTracker->documentPurged(docID); }); + } else { + keyStore.expireRecords(); + } + return true; + }); + } catch ( const exception& x ) { + // CBL-8202: A transient lock conflict (another connection holding the write lock) must + // not abort the Housekeeper. If we let the exception propagate, the actor swallows it + // and the _scheduleExpiration() below is skipped, so the timer never fires again and + // expired documents are never purged once the app stops calling setDocExpiration. + // Instead, reschedule a near-term retry of the purge. + error e = error::convertException(x).standardized(); + if ( e.domain == error::LiteCore && e.code == error::Busy ) { + logInfo("Housekeeper: expiration deferred (database busy); retrying in %lldms", + (long long)kRetryExpirationAfterBusyMS.count()); + _expiryTimer->fireAfter(kRetryExpirationAfterBusyMS); + return; } - return true; - }); + throw; + } _scheduleExpiration(false); } From 2a2f096da31ac37bc1df3bb7359a0c984c207eb1 Mon Sep 17 00:00:00 2001 From: Jim Borden Date: Wed, 17 Jun 2026 15:17:29 +0900 Subject: [PATCH 2/3] Linting --- C/tests/c4DatabaseTest.cc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/C/tests/c4DatabaseTest.cc b/C/tests/c4DatabaseTest.cc index 8f1bd6e77..b2a2b1a4b 100644 --- a/C/tests/c4DatabaseTest.cc +++ b/C/tests/c4DatabaseTest.cc @@ -789,8 +789,7 @@ N_WAY_TEST_CASE_METHOD(C4DatabaseTest, "Document expiration torture test", "[Dat for ( int busyRetries = 0; !ok; ) { ok = c4coll_setDocExpiration(collection, c4str(docID(setCount)), expire, &error); if ( ok ) break; - if ( error.domain != LiteCoreDomain || error.code != kC4ErrorBusy - || ++busyRetries > kMaxBusyRetries ) + if ( error.domain != LiteCoreDomain || error.code != kC4ErrorBusy || ++busyRetries > kMaxBusyRetries ) break; std::this_thread::sleep_for(5ms); } From 5a90ae66faeb4ad88f68668fcd10b902f62fc578 Mon Sep 17 00:00:00 2001 From: Jim Borden Date: Thu, 18 Jun 2026 07:13:45 +0900 Subject: [PATCH 3/3] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- LiteCore/Database/Housekeeper.cc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LiteCore/Database/Housekeeper.cc b/LiteCore/Database/Housekeeper.cc index 007da6482..11dc60179 100644 --- a/LiteCore/Database/Housekeeper.cc +++ b/LiteCore/Database/Housekeeper.cc @@ -107,7 +107,7 @@ namespace litecore { void Housekeeper::_doExpiration() { if ( _isStopped() ) return; - logInfo("Housekeeper: expiring documents..."); + logVerbose("Housekeeper: expiring documents..."); try { _bgdb->useInTransaction(_keyStoreName, [&](KeyStore& keyStore, SequenceTracker* sequenceTracker) -> bool { if ( sequenceTracker ) { @@ -125,8 +125,8 @@ namespace litecore { // Instead, reschedule a near-term retry of the purge. error e = error::convertException(x).standardized(); if ( e.domain == error::LiteCore && e.code == error::Busy ) { - logInfo("Housekeeper: expiration deferred (database busy); retrying in %lldms", - (long long)kRetryExpirationAfterBusyMS.count()); + logVerbose("Housekeeper: expiration deferred (database busy); retrying in %lldms", + (long long)kRetryExpirationAfterBusyMS.count()); _expiryTimer->fireAfter(kRetryExpirationAfterBusyMS); return; }