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.
There was a problem hiding this comment.
Addressed in 073bb5d. Cleanup now uses the same publication lock as endpoint publication, snapshots the cleanup-owned contact fields before SDK awaits, and defers the local wipe while retaining the retry marker if those fields changed during the operation.
| 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.
There was a problem hiding this comment.
Addressed in 073bb5d. The per-contact fallback is gone. Both cleanup and publication now use one shared linked-peer snapshot helper that makes one initial call and at most one batch-wide retry, so the failure path is bounded at two calls rather than N+1.
| 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.)
There was a problem hiding this comment.
Addressed in 073bb5d. The duplicated snapshot/fallback blocks are replaced by the shared linkedReceiverPathsSnapshot helper, used by both cleanup and publication.
| } | ||
| } | ||
|
|
||
| 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.
There was a problem hiding this comment.
Agreed. I kept the partial-progress behavior intentionally: after both shared link-snapshot attempts fail, locally recorded published paths are still cleared, while the contact state and retry marker are retained. The PR description now calls this error-path behavior out explicitly.
| 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.
There was a problem hiding this comment.
Agreed on the tradeoff. This remains deliberately conservative because a failed batch-level inspection cannot prove which clears have fully drained. The affected contacts keep their state and retry markers, so the next cleanup pass retries safely. The PR description now calls out this batch-level failure granularity.
| } | ||
| } | ||
|
|
||
| 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.
There was a problem hiding this comment.
Addressed in 073bb5d. Deleted-contact cleanup markers are now persisted synchronously in removeSavedContact and removeSavedContacts before the first remote await, closing the no-cache crash gap. Successful cleanup clears only the corresponding markers.
| } | ||
|
|
||
| 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.
There was a problem hiding this comment.
Addressed in 073bb5d. The normalized keys are converted to publicKeySet once and that set is reused for failed/successful membership calculations.
0dd87ec to
073bb5d
Compare
073bb5d to
907b4ac
Compare
Description
This PR builds on #637 to address the Paykit cleanup performance follow-ups from #620:
On the error path, a failed link snapshot still clears locally recorded published receiver paths while retaining the contact state and retry marker. A batch-level drain-inspection failure conservatively keeps the whole affected batch pending for retry. 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 13 focused tests passed in the iOS simulator, including mixed cleanup success/failure coverage.git diff --checkpassed.