diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushCatchUp.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushCatchUp.swift new file mode 100644 index 000000000..1dcf67475 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushCatchUp.swift @@ -0,0 +1,85 @@ +import Foundation + +public struct PushCatchUpSelection: Sendable { + public let event: VerifiedNostrEvent + public let wasPreviouslyConsumed: Bool + + public init(event: VerifiedNostrEvent, wasPreviouslyConsumed: Bool) { + self.event = event + self.wasPreviouslyConsumed = wasPreviouslyConsumed + } +} + +public enum PushCatchUp { + /// Build catch-up filters that preserve the lease's constraints while moving + /// strictly beyond a composite cursor. A second, exact-id filter keeps the + /// current cursor event available only as a duplicate-cleanup fallback. + public static func queryFilters( + subscriptions: [PushLeaseSubscription], + cursor: PushEventPosition?, + limit: Int + ) -> [[String: Any]] { + subscriptions.flatMap { subscription in + guard let cursor else { + return [subscription.filter.queryFilter(since: nil, limit: limit)] + } + + var filters: [[String: Any]] = [] + if cursor.createdAt < Int.max { + filters.append( + subscription.filter.queryFilter( + since: cursor.createdAt + 1, + limit: limit + ) + ) + } + + var sameSecond = subscription.filter.queryFilter( + since: cursor.createdAt, + limit: limit + ) + sameSecond["until"] = cursor.createdAt + sameSecond["before_id"] = cursor.id + filters.append(sameSecond) + + var duplicateFallback = subscription.filter.queryFilter(since: nil, limit: 1) + duplicateFallback["ids"] = [cursor.id] + filters.append(duplicateFallback) + return filters + } + } + + /// Return selectable events first and consumed duplicate fallbacks last. + /// Duplicate cleanup therefore never competes with forward progress. + public static func orderedSelections( + events: [VerifiedNostrEvent], + origin: String, + subscriptions: [PushLeaseSubscription], + consumptionState: PushConsumptionState, + verify: (VerifiedNostrEvent) -> Bool = { $0.hasValidIDAndSignature() } + ) -> [PushCatchUpSelection] { + var eventsByID: [String: VerifiedNostrEvent] = [:] + for event in events where verify(event) { + guard subscriptions.contains(where: { + PushLeaseMatcher.matches(event: event, subscription: $0) + }) else { + continue + } + eventsByID[event.id] = event + } + + let ordered = eventsByID.values.sorted { + $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt + } + let selectable = ordered.compactMap { event -> PushCatchUpSelection? in + let position = PushEventPosition(createdAt: event.createdAt, id: event.id) + guard consumptionState.canSelect(position, for: origin) else { return nil } + return PushCatchUpSelection(event: event, wasPreviouslyConsumed: false) + } + let duplicates = ordered.compactMap { event -> PushCatchUpSelection? in + guard consumptionState.hasConsumed(eventID: event.id, for: origin) else { return nil } + return PushCatchUpSelection(event: event, wasPreviouslyConsumed: true) + } + return selectable + duplicates + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushCatchUpTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushCatchUpTests.swift new file mode 100644 index 000000000..043ca620c --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushCatchUpTests.swift @@ -0,0 +1,120 @@ +import XCTest + +@testable import BuzzPushKit + +final class PushCatchUpTests: XCTestCase { + private let mine = String(repeating: "a", count: 64) + private let other = String(repeating: "b", count: 64) + + func testSequentialWakesSelectSameSecondSiblingBeforeConsumedDuplicateFallback() { + let subscription = self.subscription() + let id0 = String(format: "%064x", 0) + let id1 = String(format: "%064x", 1) + let events = [event(id: id0), event(id: id1)] + var state = PushConsumptionState() + + let first = PushCatchUp.orderedSelections( + events: events, + origin: "origin", + subscriptions: [subscription], + consumptionState: state, + verify: { _ in true } + ) + XCTAssertEqual(first.first?.event.id, id0) + XCTAssertEqual(first.first?.wasPreviouslyConsumed, false) + state.consume(PushEventPosition(createdAt: 1_000, id: id0), for: "origin") + + let second = PushCatchUp.orderedSelections( + events: events, + origin: "origin", + subscriptions: [subscription], + consumptionState: state, + verify: { _ in true } + ) + XCTAssertEqual(second.map(\.event.id), [id1, id0]) + XCTAssertEqual(second.map(\.wasPreviouslyConsumed), [false, true]) + } + + func testCompositeSameSecondQueryReachesSuccessorsBeyondFirstRelayPage() { + let subscription = self.subscription() + let events = (0..<25).map { event(id: String(format: "%064x", $0)) } + var state = PushConsumptionState() + var selected: [String] = [] + + while selected.count < events.count { + let cursor = state.state(for: "origin").cursor + let filters = PushCatchUp.queryFilters( + subscriptions: [subscription], + cursor: cursor, + limit: 10 + ) + let relayPage = relayResponse(events: events, filters: filters) + let selection = PushCatchUp.orderedSelections( + events: relayPage, + origin: "origin", + subscriptions: [subscription], + consumptionState: state, + verify: { _ in true } + ).first + let winner = try! XCTUnwrap(selection) + + XCTAssertFalse(winner.wasPreviouslyConsumed) + XCTAssertFalse(selected.contains(winner.event.id)) + selected.append(winner.event.id) + state.consume( + PushEventPosition(createdAt: winner.event.createdAt, id: winner.event.id), + for: "origin" + ) + } + + XCTAssertEqual(selected, events.map(\.id)) + } + + private func subscription() -> PushLeaseSubscription { + PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [9], pTags: [mine]), + notificationClass: "default" + ) + } + + private func event(id: String) -> VerifiedNostrEvent { + VerifiedNostrEvent( + id: id, + pubkey: other, + createdAt: 1_000, + kind: 9, + tags: [["p", mine]], + content: "message", + sig: String(repeating: "c", count: 128) + ) + } + + private func relayResponse( + events: [VerifiedNostrEvent], + filters: [[String: Any]] + ) -> [VerifiedNostrEvent] { + var byID: [String: VerifiedNostrEvent] = [:] + for filter in filters { + let limit = filter["limit"] as? Int ?? events.count + let ids = (filter["ids"] as? [String]).map(Set.init) + let since = filter["since"] as? Int + let until = filter["until"] as? Int + let beforeID = filter["before_id"] as? String + let matches = events.filter { event in + guard ids?.contains(event.id) ?? true else { return false } + guard since.map({ event.createdAt >= $0 }) ?? true else { return false } + if let until, let beforeID { + return event.createdAt < until + || (event.createdAt == until && event.id > beforeID) + } + return until.map({ event.createdAt <= $0 }) ?? true + }.sorted { + $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt + }.prefix(limit) + for event in matches { + byID[event.id] = event + } + } + return Array(byID.values) + } +} diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index 1af22003c..c187b5037 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -233,8 +233,12 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { completion(.diagnostic("Open Buzz to refresh notification subscriptions.")) return } - let since = consumptionState.querySince(for: community.id) - let filters = subscriptions.map { $0.filter.queryFilter(since: since, limit: 10) } + let cursor = consumptionState.state(for: community.id).cursor + let filters = PushCatchUp.queryFilters( + subscriptions: subscriptions, + cursor: cursor, + limit: 10 + ) guard let body = try? JSONSerialization.data(withJSONObject: filters) else { completion(.diagnostic("Buzz notification subscriptions are invalid.")) return @@ -288,22 +292,15 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { subscriptions: [PushLeaseSubscription], consumptionState: PushConsumptionState ) -> QueryResult { - let matching = events.filter { event in - guard event.hasValidIDAndSignature(), - subscriptions.contains(where: { - PushLeaseMatcher.matches(event: event, subscription: $0) - }) - else { - return false - } - let position = PushEventPosition(createdAt: event.createdAt, id: event.id) - return consumptionState.hasConsumed(eventID: event.id, for: community.id) - || consumptionState.canSelect(position, for: community.id) - }.sorted { - $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt - } + let matching = PushCatchUp.orderedSelections( + events: events, + origin: community.id, + subscriptions: subscriptions, + consumptionState: consumptionState + ) - for event in matching { + for selection in matching { + let event = selection.event let identity = PushNotificationIdentity(eventID: event.id, origin: community.id) let resolution: BuzzPushResolution if event.kind == 9 { @@ -335,10 +332,7 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { resolution: resolution, event: event, community: community, - wasPreviouslyConsumed: consumptionState.hasConsumed( - eventID: event.id, - for: community.id - ) + wasPreviouslyConsumed: selection.wasPreviouslyConsumed ) ) }