refactor: batch paykit contact cleanup - #638
Conversation
Greptile SummaryThis PR batches Paykit contact cleanup work to reduce repeated SDK calls. The main changes are:
Confidence Score: 4/5The batch-wide linked-peer failure path can leave stale endpoints without a cleanup retry.
Bitkit/Services/PrivatePaykitService+Contacts.swift
|
| Filename | Overview |
|---|---|
| Bitkit/Services/PrivatePaykitService+Contacts.swift | Batches linked-peer reads and message draining while preserving per-contact cleanup state, but the shared lookup failure path can omit cleanup and retries. |
Reviews (1): Last reviewed commit: "refactor: batch paykit contact cleanup" | Re-trigger Greptile
ee9a95c to
ae345b3
Compare
be606b0 to
1b7bc5e
Compare
ae345b3 to
778f6d0
Compare
1b7bc5e to
4e8c5a4
Compare
778f6d0 to
9dcc431
Compare
4e8c5a4 to
0dd87ec
Compare
jvsena42
left a comment
There was a problem hiding this comment.
Reviewed the batching refactor. The per-contact failure isolation contract does hold — I traced the partial-failure cases (one path of two fails, drain still pending, link inspection fails) and each ends with the contact in failedPublicKeys, state untouched, retry marker set. SwiftFormat lint passes and omitting a changelog fragment is correct for a refactor: with no user-facing change.
Comments inline. The one I'd want addressed (or explicitly accepted) before merge is the widened actor-reentrancy window on the deferred state wipe; the rest are smaller.
Test coverage: no test changes here. PrivatePaykitServiceTests.swift has 12 tests, none of which touch removePublishedEndpoints or linkedPeers, so the new batching, the failedPublicKeys/successfulPublicKeys split, and the snapshot-failure fallback are all untested. The "all 10 focused tests passed" note in the description is a regression run, not coverage of this change. Given the state-clearing logic is the risky part, a test asserting "contact A succeeds, contact B fails → A cleared + marker cleared, B state retained + marker set" would be worth adding.
Merge order: base is codex/paykit-incoming-payment-requests, so this needs to land behind #637.
|
|
||
| Self.clearDeletedContactCleanupPending([publicKey]) | ||
| let successfulPublicKeys = Set(publicKeys).subtracting(failedPublicKeys) | ||
| for publicKey in successfulPublicKeys { |
There was a problem hiding this comment.
Actor-reentrancy window for the state wipe is now much wider.
PrivatePaykitService is an actor (reentrant), and removePublishedEndpoints is not run under withPublicationLock — only syncLocalEndpointPublication is. Before this PR each contact's state.contacts[X] wipe happened immediately after that contact's own clear+drain. Now every wipe is deferred to here, after the whole batch's clears and the full drain.
Scenario: removePublishedEndpoints() (the no-arg variant, which unions all known + cached + pending keys) runs while a foreground refreshKnownSavedContactEndpoints is in flight. Contact A is cleared early in the batch; during the many subsequent awaits the concurrent publish calls recordPublishedPrivatePaymentList for A (or an incoming invoice writes localInvoicesByReceiverPath); this loop then wipes cachedResolvedEndpoints / localInvoicesByReceiverPath / publishedPrivatePaymentReceiverPaths for A, desyncing local state from what the SDK actually holds.
The hazard pre-exists, but the batching widens it from one-contact latency to whole-batch latency. Cheapest mitigations: snapshot the relevant state.contacts[publicKey] fields before the clear and skip the wipe if they changed, or run the cleanup pass under withPublicationLock as well.
| contactLinkedReceiverPaths = linkedReceiverPathsSnapshot[publicKey, default: []] | ||
| } else { | ||
| do { | ||
| contactLinkedReceiverPaths = try await linkedReceiverPaths(publicKey: publicKey) |
There was a problem hiding this comment.
This fallback re-issues the exact call that just failed.
linkedReceiverPaths(publicKey:) now calls linkedReceiverPathsByPublicKey(), which is the same PaykitSdkService.shared.linkedPeers() that just threw when the snapshot was taken. So when the snapshot fails, this pass makes N+1 linkedPeers() 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 in syncLocalEndpointPublicationLocked.
| contactLinkedReceiverPaths = linkedReceiverPathsSnapshot[publicKey, default: []] | ||
| } else { | ||
| do { | ||
| contactLinkedReceiverPaths = try await linkedReceiverPaths(publicKey: publicKey) |
There was a problem hiding this comment.
Duplicated snapshot-or-fallback block.
This is the same ~14 lines as the block in removePublishedEndpoints (lines 109–124), differing only in the log's reason. Per the repo's reuse convention, worth extracting into a single helper — e.g. linkedReceiverPaths(for publicKey: String, snapshot: [String: Set<String>]?, reason: String) async -> (paths: Set<String>, error: Error?) — so the two call sites can't drift.
(Same redundant-refetch caveat as the other block applies here too.)
| } | ||
| } | ||
|
|
||
| let cleanupReceiverPaths = receiverPathsForCleanup( |
There was a problem hiding this comment.
Behavior change worth calling out (the description says "no user-facing behavior changes are intended").
Previously, when receiverPathsForCleanup threw for a contact, the loop continued and made zero clear attempts for it. Now a link-inspection failure yields an empty contactLinkedReceiverPaths and execution falls through to here, so the contact still gets clearPrivatePaymentList called for its locally-published paths.
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.
| if !clearedRetryKeys.isEmpty { | ||
| await drainPendingPrivateMessages(reason: "cleanup", advancing: clearedRetryKeys) | ||
| let pendingRetryKeys = await pendingPrivateMessageDrainKeys(clearedRetryKeys) | ||
| if !pendingRetryKeys.isEmpty { |
There was a problem hiding this comment.
Blast radius of a transient drain-check failure grew from one contact to the whole batch.
pendingPrivateMessageDrainKeys returns all the passed keys when linkedPeers() or pendingOutboundPrivateCounterparties() throws (see its two catch blocks). With the check now run once per batch, a single transient SDK hiccup marks every successfully-cleared contact as failed, so none of them get their state cleared or their retry marker cleared.
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.
| } | ||
| } | ||
|
|
||
| Self.markDeletedContactCleanupPending(Array(failedPublicKeys)) |
There was a problem hiding this comment.
Failure markers are now written once at the end instead of eagerly.
The old code called markDeletedContactCleanupPending([publicKey]) at the moment each contact failed, so a crash mid-loop still left the marker persisted. Now nothing is written until the batch completes.
Mostly recoverable, since removePublishedEndpoints() and pruneUnsavedContactState re-derive keys from state.contacts (which isn't cleared for failed contacts). The narrow gap is a contact deleted via removeSavedContact that has no local cache state — its only record would be this marker, and a crash before this line loses it. Low severity, but it is a durability regression relative to the eager write.
| } | ||
|
|
||
| Self.clearDeletedContactCleanupPending([publicKey]) | ||
| let successfulPublicKeys = Set(publicKeys).subtracting(failedPublicKeys) |
There was a problem hiding this comment.
Minor: publicKeys is already deduplicated by normalizedSavedContactKeys at the top of the function, so this Set(publicKeys) is rebuilding a set that could be constructed once above the loop and reused (it's also implicitly the membership source for the loop). Purely cosmetic.
Description
This PR builds on #637 to address the Paykit cleanup performance follow-ups from #620:
No user-facing behavior changes are intended.
Linked Issues/Tasks
Screenshot / Video
N/A — service-only refactor.
QA Notes
Manual Tests
N/A
Automated Checks
PrivatePaykitServiceTests.swift: all 10 focused tests passed in the iOS simulator.git diff --checkpassed.