-
Notifications
You must be signed in to change notification settings - Fork 3
refactor: batch paykit contact cleanup #638
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: codex/paykit-incoming-payment-requests
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -87,54 +87,86 @@ extension PrivatePaykitService { | |
| } | ||
|
|
||
| func removePublishedEndpoints(for publicKeys: [String]) async throws { | ||
| let publicKeys = normalizedSavedContactKeys(publicKeys) | ||
| guard !publicKeys.isEmpty else { return } | ||
|
|
||
| let linkedReceiverPathsSnapshot: [String: Set<String>]? | ||
| do { | ||
| linkedReceiverPathsSnapshot = try await linkedReceiverPathsByPublicKey() | ||
| } catch { | ||
| linkedReceiverPathsSnapshot = nil | ||
| Logger.warn( | ||
| "Failed to inspect private Paykit links for cleanup; retrying per contact: \(error)", | ||
| context: "PrivatePaykit" | ||
| ) | ||
| } | ||
|
|
||
| var firstError: Error? | ||
| var failedPublicKeys = Set<String>() | ||
| var clearedRetryKeys = [PrivateMessageDrainRetryKey]() | ||
| var didChangeState = false | ||
| for publicKey in normalizedSavedContactKeys(publicKeys) { | ||
| var didFail = false | ||
| let cleanupReceiverPaths: [String] | ||
| do { | ||
| cleanupReceiverPaths = try await receiverPathsForCleanup(publicKey: publicKey) | ||
| } catch { | ||
| firstError = firstError ?? error | ||
| Self.markDeletedContactCleanupPending([publicKey]) | ||
| continue | ||
| for publicKey in publicKeys { | ||
| let contactLinkedReceiverPaths: Set<String> | ||
| if let linkedReceiverPathsSnapshot { | ||
| contactLinkedReceiverPaths = linkedReceiverPathsSnapshot[publicKey, default: []] | ||
| } else { | ||
| do { | ||
| contactLinkedReceiverPaths = try await linkedReceiverPaths(publicKey: publicKey) | ||
| } catch { | ||
| contactLinkedReceiverPaths = [] | ||
| failedPublicKeys.insert(publicKey) | ||
| firstError = firstError ?? error | ||
| Logger.warn( | ||
| "Failed to inspect private Paykit links for \(PubkyPublicKeyFormat.redacted(publicKey)) during cleanup: \(error)", | ||
| context: "PrivatePaykit" | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| let cleanupReceiverPaths = receiverPathsForCleanup( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Behavior change worth calling out (the description says "no user-facing behavior changes are intended"). Previously, when This is arguably an improvement (partial progress, and the contact is marked failed either way so it retries), but it's a real change in what hits the SDK on the error path. Probably worth a line in the PR description. |
||
| publicKey: publicKey, | ||
| linkedReceiverPaths: contactLinkedReceiverPaths | ||
| ) | ||
| for receiverPath in cleanupReceiverPaths { | ||
| do { | ||
| let report = try await PaykitSdkService.shared.clearPrivatePaymentList(to: publicKey, receiverPath: receiverPath) | ||
| if !report.failedToQueue.isEmpty || !report.failedToDeliver.isEmpty { | ||
| throw PrivatePaykitError.privateUnavailable | ||
| } | ||
| let retryKey = PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiverPath) | ||
| await drainPendingPrivateMessages(reason: "cleanup", advancing: [retryKey]) | ||
| if await pendingPrivateMessageDrainKeys([retryKey]).contains(retryKey) { | ||
| throw PrivatePaykitError.privateUnavailable | ||
| } | ||
| clearedRetryKeys.append(PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiverPath)) | ||
| } catch { | ||
| didFail = true | ||
| failedPublicKeys.insert(publicKey) | ||
| firstError = firstError ?? error | ||
| Self.markDeletedContactCleanupPending([publicKey]) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if !didFail { | ||
| if var contactState = state.contacts[publicKey] { | ||
| let hadStateToClear = !contactState.cachedResolvedEndpoints.isEmpty || | ||
| !contactState.localInvoicesByReceiverPath.isEmpty || | ||
| !contactState.publishedPrivatePaymentReceiverPaths.isEmpty | ||
| contactState.cachedResolvedEndpoints = [] | ||
| contactState.localInvoicesByReceiverPath = [:] | ||
| contactState.publishedPrivatePaymentReceiverPaths = [] | ||
| let shouldRemoveContact = !contactState.hasCacheState | ||
| state.contacts[publicKey] = shouldRemoveContact ? nil : contactState | ||
| didChangeState = didChangeState || hadStateToClear || shouldRemoveContact | ||
| } | ||
| if !clearedRetryKeys.isEmpty { | ||
| await drainPendingPrivateMessages(reason: "cleanup", advancing: clearedRetryKeys) | ||
| let pendingRetryKeys = await pendingPrivateMessageDrainKeys(clearedRetryKeys) | ||
| if !pendingRetryKeys.isEmpty { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blast radius of a transient drain-check failure grew from one contact to the whole batch.
The retry path covers it, so this isn't a correctness bug — but it's a real reduction in transient-failure granularity that comes with the batching, and it's the kind of thing that shows up as "cleanup never converges" if the SDK is flaky during a full cleanup pass. Worth noting in the description alongside the perf win. |
||
| failedPublicKeys.formUnion(pendingRetryKeys.map(\.publicKey)) | ||
| firstError = firstError ?? PrivatePaykitError.privateUnavailable | ||
| } | ||
| } | ||
|
|
||
| Self.clearDeletedContactCleanupPending([publicKey]) | ||
| let successfulPublicKeys = Set(publicKeys).subtracting(failedPublicKeys) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: |
||
| for publicKey in successfulPublicKeys { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actor-reentrancy window for the state wipe is now much wider.
Scenario: The hazard pre-exists, but the batching widens it from one-contact latency to whole-batch latency. Cheapest mitigations: snapshot the relevant |
||
| if var contactState = state.contacts[publicKey] { | ||
| let hadStateToClear = !contactState.cachedResolvedEndpoints.isEmpty || | ||
| !contactState.localInvoicesByReceiverPath.isEmpty || | ||
| !contactState.publishedPrivatePaymentReceiverPaths.isEmpty | ||
| contactState.cachedResolvedEndpoints = [] | ||
| contactState.localInvoicesByReceiverPath = [:] | ||
| contactState.publishedPrivatePaymentReceiverPaths = [] | ||
| let shouldRemoveContact = !contactState.hasCacheState | ||
| state.contacts[publicKey] = shouldRemoveContact ? nil : contactState | ||
| didChangeState = didChangeState || hadStateToClear || shouldRemoveContact | ||
| } | ||
| } | ||
|
|
||
| Self.markDeletedContactCleanupPending(Array(failedPublicKeys)) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Failure markers are now written once at the end instead of eagerly. The old code called Mostly recoverable, since |
||
| Self.clearDeletedContactCleanupPending(Array(successfulPublicKeys)) | ||
|
|
||
| if didChangeState { | ||
| persistState(markWalletBackup: true) | ||
| } | ||
|
|
@@ -265,6 +297,17 @@ extension PrivatePaykitService { | |
| var firstError: Error? | ||
| var updates = [PrivatePaymentListReservationUpdateInput]() | ||
| var linkRetryKeys = [PrivateMessageDrainRetryKey]() | ||
| let linkedReceiverPathsSnapshot: [String: Set<String>]? | ||
| do { | ||
| linkedReceiverPathsSnapshot = try await linkedReceiverPathsByPublicKey() | ||
| } catch { | ||
| linkedReceiverPathsSnapshot = nil | ||
| Logger.warn( | ||
| "Failed to inspect private Paykit links during \(reason); retrying per contact: \(error)", | ||
| context: "PrivatePaykit" | ||
| ) | ||
| } | ||
|
|
||
| for publicKey in publicKeys { | ||
| let receiverPaths: [String] | ||
| do { | ||
|
|
@@ -295,20 +338,26 @@ extension PrivatePaykitService { | |
| context: "PrivatePaykit" | ||
| ) | ||
| } | ||
| let cleanupReceiverPaths: [String] | ||
| do { | ||
| cleanupReceiverPaths = try await receiverPathsForPrivateEndpointCleanup( | ||
| publicKey: publicKey, | ||
| excluding: publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths | ||
| ) | ||
| } catch { | ||
| cleanupReceiverPaths = [] | ||
| firstError = firstError ?? error | ||
| Logger.warn( | ||
| "Failed to inspect private Paykit links for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", | ||
| context: "PrivatePaykit" | ||
| ) | ||
| let contactLinkedReceiverPaths: Set<String> | ||
| if let linkedReceiverPathsSnapshot { | ||
| contactLinkedReceiverPaths = linkedReceiverPathsSnapshot[publicKey, default: []] | ||
| } else { | ||
| do { | ||
| contactLinkedReceiverPaths = try await linkedReceiverPaths(publicKey: publicKey) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicated snapshot-or-fallback block. This is the same ~14 lines as the block in (Same redundant-refetch caveat as the other block applies here too.) |
||
| } catch { | ||
| contactLinkedReceiverPaths = [] | ||
| firstError = firstError ?? error | ||
| Logger.warn( | ||
| "Failed to inspect private Paykit links for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", | ||
| context: "PrivatePaykit" | ||
| ) | ||
| } | ||
| } | ||
| let cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( | ||
| publicKey: publicKey, | ||
| excluding: publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, | ||
| linkedReceiverPaths: contactLinkedReceiverPaths | ||
| ) | ||
|
|
||
| for receiverPath in Set(linkableReceiverPaths).union(cleanupReceiverPaths) { | ||
| linkRetryKeys.append(PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiverPath)) | ||
|
|
@@ -652,35 +701,38 @@ extension PrivatePaykitService { | |
|
|
||
| private func receiverPathsForPrivateEndpointCleanup( | ||
| publicKey: String, | ||
| excluding publicationReceiverPaths: [String] | ||
| ) async throws -> [String] { | ||
| excluding publicationReceiverPaths: [String], | ||
| linkedReceiverPaths: Set<String> | ||
| ) -> [String] { | ||
| let publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey: publicKey) | ||
| let linkedPaths = try await linkedReceiverPaths(publicKey: publicKey) | ||
| let excluded = Set(publicationReceiverPaths) | ||
| return Array(Set(publishedPaths).union(linkedPaths).subtracting(excluded)) | ||
| return Array(Set(publishedPaths).union(linkedReceiverPaths).subtracting(excluded)) | ||
| .filter { PaykitReceiverPath.supported.contains($0) } | ||
| .sorted() | ||
| } | ||
|
|
||
| private func receiverPathsForCleanup(publicKey: String) async throws -> [String] { | ||
| let linkedPaths = try await linkedReceiverPaths(publicKey: publicKey) | ||
| private func receiverPathsForCleanup(publicKey: String, linkedReceiverPaths: Set<String>) -> [String] { | ||
| let publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey: publicKey) | ||
| return Array(Set(linkedPaths).union(publishedPaths)) | ||
| return Array(linkedReceiverPaths.union(publishedPaths)) | ||
| .filter { PaykitReceiverPath.supported.contains($0) } | ||
| .sorted() | ||
| } | ||
|
|
||
| private func linkedReceiverPaths(publicKey: String) async throws -> [String] { | ||
| guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { return [] } | ||
| private func linkedReceiverPathsByPublicKey() async throws -> [String: Set<String>] { | ||
| let peers = try await PaykitSdkService.shared.linkedPeers() | ||
|
|
||
| let linkedPaths = peers.compactMap { peer -> String? in | ||
| guard PubkyPublicKeyFormat.normalized(peer.counterparty) == publicKey, | ||
| var linkedPaths = [String: Set<String>]() | ||
| for peer in peers { | ||
| guard let publicKey = PubkyPublicKeyFormat.normalized(peer.counterparty), | ||
| PaykitReceiverPath.supported.contains(peer.counterpartyReceiverPath) | ||
| else { return nil } | ||
| return peer.counterpartyReceiverPath | ||
| else { continue } | ||
| linkedPaths[publicKey, default: []].insert(peer.counterpartyReceiverPath) | ||
| } | ||
| return Array(Set(linkedPaths)).sorted() | ||
| return linkedPaths | ||
| } | ||
|
|
||
| private func linkedReceiverPaths(publicKey: String) async throws -> Set<String> { | ||
| guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { return [] } | ||
| return try await linkedReceiverPathsByPublicKey()[publicKey, default: []] | ||
| } | ||
|
|
||
| private func publishedPrivatePaymentReceiverPaths(publicKey: String) -> [String] { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This fallback re-issues the exact call that just failed.
linkedReceiverPaths(publicKey:)now callslinkedReceiverPathsByPublicKey(), which is the samePaykitSdkService.shared.linkedPeers()that just threw when the snapshot was taken. So when the snapshot fails, this pass makes N+1linkedPeers()calls instead of the pre-PR N — a regression in exactly the dimension the PR optimizes — and the "retrying per contact" wording in the warning above oversells what the retry can accomplish.Suggest picking one: on snapshot failure either fail the pass once (set
firstError, mark all keys pending, return) or continue with an empty snapshot, and drop the per-contact re-fetch entirely. Same applies to the mirrored block insyncLocalEndpointPublicationLocked.