mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
parent
94328996f1
commit
28f2f276a7
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user