Review fixes: offline-drop durability, gateway handoff retry, restore-log freshness, coalesced persist

Adversarial review of the storm/deadlock PR surfaced four issues:

- Offline blackhole (must-fix): a deposit made while relays were down
  persisted its dedup key even though the drop only sat in the in-memory
  pending queue — app killed before reconnect meant the relaunch lost the
  drop but the persisted key blocked every re-deposit for 24h. The
  persisted snapshot now excludes keys still pending; they become durable
  only when flushPendingDrops actually publishes them.
- Gateway handoff: seen-event IDs were consumed before the deliverToPeer
  handoff; a failed handoff (peer walked away) permanently dropped the
  event for a single-gateway island. deliverToPeer now reports whether
  the handoff was attempted, and a failure releases the seen slot so a
  relaunch or backlog redelivery retries.
- Restore-path logs: central/peripheral-restore captures logged the init
  sentinel bgRemaining=∞. The cache is now seeded in init's main-thread
  branch and restore captures route through the sampler, which refreshes
  the cached budget before logging.
- Persist cost: the dedup record was a full JSON encode + atomic write on
  the main actor per mutation (once per event during a backlog re-fetch).
  Writes now coalesce behind a 1s window, flushed immediately on
  background/terminate; panic wipe stays immediate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-09 18:11:49 +02:00
co-authored by Claude Fable 5
parent d905452df2
commit 07b5ac31d8
5 changed files with 163 additions and 9 deletions
@@ -24,6 +24,7 @@ struct BridgeCourierServiceTests {
var localPeers: [(peerID: PeerID, noiseKey: Data)] = []
var held: [CourierEnvelope] = []
var sealResult: CourierEnvelope?
var deliverResult = true
private(set) var publishedEvents: [NostrEvent] = []
private(set) var openedSubscriptions: [[String]] = []
@@ -49,7 +50,10 @@ struct BridgeCourierServiceTests {
return self?.sealResult
}
service.openEnvelope = { [weak self] envelope in self?.openedEnvelopes.append(envelope) }
service.deliverToPeer = { [weak self] envelope, peer in self?.delivered.append((envelope, peer)) }
service.deliverToPeer = { [weak self] envelope, peer in
self?.delivered.append((envelope, peer))
return self?.deliverResult ?? false
}
service.heldEnvelopes = { [weak self] cooldown in
self?.heldCooldowns.append(cooldown)
return self?.held ?? []
@@ -190,6 +194,9 @@ struct BridgeCourierServiceTests {
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
#expect(fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey))
#expect(fixture.publishedEvents.count == 1)
// Persistence is coalesced; a real launch flushes within a second or
// on backgrounding tests flush explicitly.
fixture.service.flushDedupSnapshot()
// "Relaunch": a fresh service over the same store must refuse to
// publish the same message ID again (before even re-sealing it).
@@ -214,6 +221,7 @@ struct BridgeCourierServiceTests {
let event = try makeDropEvent(for: makeEnvelope(recipientKey: myKey))
fixture.service.handleDropEvent(event)
#expect(fixture.openedEnvelopes.count == 1)
fixture.service.flushDedupSnapshot()
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
relaunched.myKey = myKey
@@ -222,6 +230,83 @@ struct BridgeCourierServiceTests {
#expect(relaunched.openedEnvelopes.isEmpty)
}
@Test func offlineQueuedDropStaysRedepositableAfterRelaunch() throws {
// A deposit made while relays are down only joins the in-memory
// pending queue. Its dedup key must NOT be durable yet: if the app is
// killed before relays connect, the relaunch loses the queued drop
// a persisted key would then block every 120s re-deposit for 24h and
// the message would silently never reach a relay.
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: fileURL) }
let recipientKey = Fixture.randomKey()
let messageID = UUID().uuidString
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
fixture.relaysConnected = false
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
#expect(fixture.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey))
#expect(fixture.publishedEvents.isEmpty)
// Even a flush while the drop is still pending must exclude its key.
fixture.service.flushDedupSnapshot()
// "App killed before relays connected": pendingDrops were memory-only.
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
relaunched.sealResult = makeEnvelope(recipientKey: recipientKey)
#expect(relaunched.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey))
#expect(relaunched.publishedEvents.count == 1)
}
@Test func publishedPendingDropBecomesDurableAfterFlush() throws {
// Counterpart: once the queued drop actually publishes on reconnect,
// its key becomes durable and a relaunch must not republish.
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: fileURL) }
let recipientKey = Fixture.randomKey()
let messageID = UUID().uuidString
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
fixture.relaysConnected = false
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
#expect(fixture.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey))
fixture.relaysConnected = true
fixture.service.flushPendingDrops()
#expect(fixture.publishedEvents.count == 1)
fixture.service.flushDedupSnapshot()
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
relaunched.sealResult = makeEnvelope(recipientKey: recipientKey)
#expect(!relaunched.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey))
#expect(relaunched.publishedEvents.isEmpty)
}
@Test func failedGatewayHandoffReleasesSeenSlot() throws {
// A gateway's deliverToPeer handoff is best-effort: when it fails
// (the peer walked away between relay fetch and mesh send), the drop
// event must stay retryable for a single-gateway mesh island this
// gateway is the recipient's only carrier.
let fixture = Fixture()
let peerKey = Fixture.randomKey()
let peer = PeerID(str: "aabbccdd00112233")
fixture.localPeers = [(peer, peerKey)]
fixture.service.refresh()
let event = try makeDropEvent(for: makeEnvelope(recipientKey: peerKey))
fixture.deliverResult = false
fixture.service.handleDropEvent(event)
#expect(fixture.delivered.count == 1)
// Redelivery (relaunch/backlog re-fetch) retries the handoff
fixture.deliverResult = true
fixture.service.handleDropEvent(event)
#expect(fixture.delivered.count == 2)
// and a successful handoff consumes the event for good.
fixture.service.handleDropEvent(event)
#expect(fixture.delivered.count == 2)
}
@Test func distinctDropsUseDistinctThrowawayKeys() {
let fixture = Fixture()
let keyA = Fixture.randomKey()