Skip to content

Commit 3be601d

Browse files
authored
fix: Remove newInboxProtectionWindow from LRU eviction (#518)
Remove the 12-minute protection window that prevented newly-created inboxes from being evicted. When many recent inboxes exist (common after creating/joining several conversations), all slots become protected and eviction fails, causing push notification wakes to fail with wakeCapacityExceeded. Inboxes with nil lastActivity are now treated as preferred eviction candidates since they have no message activity to lose. They will be re-woken when the user navigates back to them. Changes: - Remove newInboxProtectionWindow constant from SleepingInboxMessageChecker - Remove protection window check from sleepLeastRecentlyUsed() - Update test to expect NULL activity inboxes to be evicted - Remove obsolete test for protection window expiration
1 parent 321087d commit 3be601d

3 files changed

Lines changed: 10 additions & 46 deletions

File tree

ConvosCore/Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -650,36 +650,30 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol {
650650

651651
/// Attempts to sleep the least recently used inbox to free capacity.
652652
///
653-
/// Inboxes with `nil` lastActivity are treated as "newly created" and protected from eviction
654-
/// for `newInboxProtectionWindow` seconds after creation. This prevents newly created inboxes
655-
/// (which haven't received messages yet) from being immediately evicted.
656-
///
657-
/// Note: This differs from `SleepingInboxMessageChecker.findOldestAwakeLastActivity()` which
658-
/// treats `nil` lastActivity as `.distantPast` for message timestamp comparisons. The semantics
659-
/// differ because eviction protection (here) and message recency comparison (there) have
660-
/// different goals.
653+
/// Inboxes with `nil` lastActivity are treated as "never used" and are preferred eviction
654+
/// candidates since they have no message activity to lose. The activity repository returns
655+
/// inboxes sorted by lastActivity (oldest first, nil treated as distantPast), so iterating
656+
/// from the end finds the most recently used inbox that can be evicted.
661657
///
662658
/// - Returns: `true` if an inbox was successfully slept, `false` otherwise.
663659
@discardableResult
664660
private func sleepLeastRecentlyUsed(excluding excludedClientIds: Set<String>) async -> Bool {
665661
do {
666662
let activities = try activityRepository.allInboxActivities()
667-
let newInboxThreshold = Date().addingTimeInterval(-SleepingInboxMessageChecker.newInboxProtectionWindow)
668663

669664
let sleepCandidate = activities.last { activity in
670665
awakeInboxes[activity.clientId] != nil &&
671666
!excludedClientIds.contains(activity.clientId) &&
672667
!pendingInviteClientIds.contains(activity.clientId) &&
673-
activity.clientId != _activeClientId &&
674-
(activity.lastActivity != nil || activity.createdAt < newInboxThreshold)
668+
activity.clientId != _activeClientId
675669
}
676670

677671
if let candidate = sleepCandidate {
678672
Log.debug("LRU sleep candidate: \(candidate.clientId), lastActivity: \(candidate.lastActivity?.description ?? "nil"), createdAt: \(candidate.createdAt)")
679673
await sleep(clientId: candidate.clientId)
680674
return true
681675
} else {
682-
Log.warning("No suitable inbox found for LRU sleep - all inboxes are protected, active, have pending invites, or are newly created")
676+
Log.warning("No suitable inbox found for LRU sleep - all inboxes are active or have pending invites")
683677
return false
684678
}
685679
} catch {

ConvosCore/Sources/ConvosCore/Inboxes/SleepingInboxMessageChecker.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,6 @@ public actor SleepingInboxMessageChecker {
1111
/// Default interval between periodic checks (60 seconds in production)
1212
public static let defaultCheckInterval: TimeInterval = 60
1313

14-
/// How long new inboxes with no activity are protected from eviction (multiple check cycles)
15-
/// In production: 60 * 12 = 720 seconds (12 minutes)
16-
public static let newInboxProtectionWindow: TimeInterval = defaultCheckInterval * 12
17-
1814
private let checkInterval: TimeInterval
1915
private let environment: AppEnvironment
2016
private let activityRepository: any InboxActivityRepositoryProtocol

ConvosCore/Tests/ConvosCoreTests/InboxLifecycleManagerTests.swift

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,8 @@ struct InboxLifecycleManagerTests {
174174
#expect(await manager.isAwake(clientId: "client-3"))
175175
}
176176

177-
@Test("Inbox with NULL lastActivity is not evicted by LRU")
178-
func testNullActivityInboxNotEvicted() async throws {
177+
@Test("Inbox with NULL lastActivity IS evicted by LRU (preferred candidate)")
178+
func testNullActivityInboxIsEvicted() async throws {
179179
let fixtures = makeTestFixtures(maxAwake: 2)
180180
let manager = fixtures.manager
181181

@@ -194,34 +194,8 @@ struct InboxLifecycleManagerTests {
194194

195195
try await manager.wakeAndDiscard(clientId: "client-3", inboxId: "inbox-3", reason: .userInteraction)
196196

197-
#expect(await manager.isAwake(clientId: "client-2"), "NULL activity inbox should NOT be evicted")
198-
#expect(await manager.isSleeping(clientId: "client-1"), "Inbox with activity should be evicted instead")
199-
#expect(await manager.isAwake(clientId: "client-3"))
200-
}
201-
202-
@Test("Old inbox with NULL lastActivity CAN be evicted after protection window")
203-
func testOldNullActivityInboxCanBeEvicted() async throws {
204-
let fixtures = makeTestFixtures(maxAwake: 2)
205-
let manager = fixtures.manager
206-
207-
let recentDate = Date()
208-
let oldCreatedAt = Date().addingTimeInterval(-(SleepingInboxMessageChecker.newInboxProtectionWindow + 60))
209-
210-
fixtures.activityRepo.activities = [
211-
InboxActivity(clientId: "client-1", inboxId: "inbox-1", lastActivity: recentDate, conversationCount: 1),
212-
InboxActivity(clientId: "client-2", inboxId: "inbox-2", lastActivity: nil, conversationCount: 0, createdAt: oldCreatedAt),
213-
InboxActivity(clientId: "client-3", inboxId: "inbox-3", lastActivity: Date(), conversationCount: 1),
214-
]
215-
216-
try await manager.wakeAndDiscard(clientId: "client-1", inboxId: "inbox-1", reason: .appLaunch)
217-
try await manager.wakeAndDiscard(clientId: "client-2", inboxId: "inbox-2", reason: .userInteraction)
218-
219-
#expect(await manager.awakeClientIds.count == 2)
220-
221-
try await manager.wakeAndDiscard(clientId: "client-3", inboxId: "inbox-3", reason: .userInteraction)
222-
223-
#expect(await manager.isSleeping(clientId: "client-2"), "Old NULL activity inbox SHOULD be evicted after protection window")
224-
#expect(await manager.isAwake(clientId: "client-1"))
197+
#expect(await manager.isSleeping(clientId: "client-2"), "NULL activity inbox SHOULD be evicted (no activity to lose)")
198+
#expect(await manager.isAwake(clientId: "client-1"), "Inbox with activity should be preserved")
225199
#expect(await manager.isAwake(clientId: "client-3"))
226200
}
227201

0 commit comments

Comments
 (0)