fix(mobile/ios): advance catch-up past consumed events

Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
Signed-off-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
This commit is contained in:
npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp
2026-07-27 20:32:42 -07:00
parent 94328996f1
commit 28f2f276a7
3 changed files with 220 additions and 21 deletions
@@ -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
}
}
@@ -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)
}
}
@@ -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
)
)
}