Skip to content

refactor: batch paykit contact cleanup - #638

Open
ben-kaufman wants to merge 2 commits into
codex/paykit-incoming-payment-requestsfrom
codex/batch-paykit-contact-cleanup
Open

refactor: batch paykit contact cleanup#638
ben-kaufman wants to merge 2 commits into
codex/paykit-incoming-payment-requestsfrom
codex/batch-paykit-contact-cleanup

Conversation

@ben-kaufman

Copy link
Copy Markdown
Contributor

Description

This PR builds on #637 to address the Paykit cleanup performance follow-ups from #620:

  • Reads the SDK linked-peer list once per multi-contact publication or cleanup pass instead of once per contact.
  • Clears receiver-scoped payment lists as before, then drains outbound private messages and checks pending status once for the whole successful batch.
  • Preserves per-contact failure isolation: contacts with failed or still-pending cleanup retain their cached state and retry marker, while successful contacts are cleared.

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.
  • SwiftFormat lint passed for the changed Swift file.
  • git diff --check passed.

@ben-kaufman
ben-kaufman marked this pull request as ready for review July 22, 2026 09:32
@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR batches Paykit contact cleanup work to reduce repeated SDK calls. The main changes are:

  • Reads and groups linked peers once per publication or cleanup batch.
  • Drains outbound private messages once after clearing the batch.
  • Tracks cleanup success and cached-state removal per contact.

Confidence Score: 4/5

The batch-wide linked-peer failure path can leave stale endpoints without a cleanup retry.

  • Successful batch processing keeps failures isolated by contact.
  • A failed shared lookup suppresses cleanup updates and retry keys for every contact.
  • Non-immediate publication callers can discard that failure.

Bitkit/Services/PrivatePaykitService+Contacts.swift

Important Files Changed

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

Comment thread Bitkit/Services/PrivatePaykitService+Contacts.swift Outdated
@ben-kaufman
ben-kaufman force-pushed the codex/paykit-incoming-payment-requests branch from ee9a95c to ae345b3 Compare July 22, 2026 13:01
@ben-kaufman
ben-kaufman force-pushed the codex/batch-paykit-contact-cleanup branch from be606b0 to 1b7bc5e Compare July 22, 2026 13:01
@ben-kaufman
ben-kaufman force-pushed the codex/paykit-incoming-payment-requests branch from ae345b3 to 778f6d0 Compare July 22, 2026 14:09
@ben-kaufman
ben-kaufman force-pushed the codex/batch-paykit-contact-cleanup branch from 1b7bc5e to 4e8c5a4 Compare July 22, 2026 14:11
@ben-kaufman
ben-kaufman force-pushed the codex/paykit-incoming-payment-requests branch from 778f6d0 to 9dcc431 Compare July 22, 2026 15:34
@ben-kaufman
ben-kaufman force-pushed the codex/batch-paykit-contact-cleanup branch from 4e8c5a4 to 0dd87ec Compare July 22, 2026 15:36

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

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 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

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))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants