-
Notifications
You must be signed in to change notification settings - Fork 4
refactor: batch paykit contact cleanup #1099
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-rc39
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 |
|---|---|---|
|
|
@@ -107,6 +107,22 @@ class PrivatePaykitRepo @Inject constructor( | |
| val firstError: Throwable?, | ||
| ) | ||
|
|
||
| private data class PrivateEndpointCleanupPreparation( | ||
| val clearedRetryKeys: List<PrivateMessageDrainRetryKey>, | ||
| val failedPublicKeys: Set<String>, | ||
| val firstError: Throwable?, | ||
| ) | ||
|
|
||
| private data class NormalizedPublicKeyBatch( | ||
| val publicKeys: List<String>, | ||
| val hadInvalidKey: Boolean, | ||
| ) | ||
|
|
||
| private data class LinkedReceiverPathInspection( | ||
| val receiverPaths: Set<String>, | ||
| val error: Throwable?, | ||
| ) | ||
|
|
||
| private data class PrivateMessageDrainRetryKey( | ||
| val publicKey: String, | ||
| val receiverPath: String, | ||
|
|
@@ -703,6 +719,7 @@ class PrivatePaykitRepo @Inject constructor( | |
| var firstError: Throwable? = null | ||
| val updates = mutableListOf<PrivatePaymentListReservationUpdateInput>() | ||
| val linkRetryKeys = mutableListOf<PrivateMessageDrainRetryKey>() | ||
| val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshotOrNull(reason) | ||
|
|
||
| for (publicKey in publicKeys) { | ||
| val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } | ||
|
|
@@ -719,27 +736,21 @@ class PrivatePaykitRepo @Inject constructor( | |
| val publicationReceiverPaths = receiverPathSelection.publishableReceiverPaths | ||
| receiverPathSelection.error?.let { | ||
| firstError = firstError ?: it | ||
| Logger.warn( | ||
| "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", | ||
| it, | ||
| context = TAG, | ||
| ) | ||
| logPrivateReceiverPathSelectionFailure(publicKey, reason, it) | ||
| } | ||
| val linkedReceiverPathInspection = inspectLinkedReceiverPaths( | ||
| publicKey, | ||
| linkedReceiverPathsSnapshot, | ||
| reason, | ||
| ) | ||
| firstError = firstError ?: linkedReceiverPathInspection.error | ||
| val cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( | ||
| publicKey = publicKey, | ||
| excludedReceiverPaths = publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, | ||
| linkedReceiverPaths = linkedReceiverPathInspection.receiverPaths, | ||
| ) | ||
|
|
||
| (linkableReceiverPaths + cleanupReceiverPaths).distinct().forEach { receiverPath -> | ||
| linkRetryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) | ||
| runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { | ||
| Logger.warn( | ||
| "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", | ||
| it, | ||
| context = TAG, | ||
| ) | ||
| } | ||
| } | ||
| linkRetryKeys += preparePrivateLinks(publicKey, linkableReceiverPaths + cleanupReceiverPaths, reason) | ||
|
|
||
| cleanupReceiverPaths.forEach { receiverPath -> | ||
| updates += PrivatePaymentListReservationUpdateInput( | ||
|
|
@@ -791,6 +802,21 @@ class PrivatePaykitRepo @Inject constructor( | |
| drainAndSchedulePrivateLinkRetries(reason, retryKeys.distinct()) | ||
| } | ||
|
|
||
| private suspend fun preparePrivateLinks( | ||
| publicKey: String, | ||
| receiverPaths: Collection<String>, | ||
| reason: String, | ||
| ): List<PrivateMessageDrainRetryKey> = receiverPaths.distinct().map { receiverPath -> | ||
| runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { | ||
| Logger.warn( | ||
| "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", | ||
| it, | ||
| context = TAG, | ||
| ) | ||
| } | ||
| PrivateMessageDrainRetryKey(publicKey, receiverPath) | ||
| } | ||
|
|
||
| private suspend fun drainAndSchedulePrivateLinkRetries( | ||
| reason: String, | ||
| retryKeys: Collection<PrivateMessageDrainRetryKey>, | ||
|
|
@@ -837,6 +863,18 @@ class PrivatePaykitRepo @Inject constructor( | |
| } | ||
| } | ||
|
|
||
| private fun logPrivateReceiverPathSelectionFailure( | ||
| publicKey: String, | ||
| reason: String, | ||
| error: Throwable, | ||
| ) { | ||
| Logger.warn( | ||
| "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", | ||
| error, | ||
| context = TAG, | ||
| ) | ||
| } | ||
|
|
||
| private suspend fun applyPrivatePaymentListDeliveryReport( | ||
| report: PrivatePaymentListDeliveryReport, | ||
| reason: String, | ||
|
|
@@ -1190,41 +1228,96 @@ class PrivatePaykitRepo @Inject constructor( | |
| } | ||
|
|
||
| private suspend fun removePublishedEndpoints(): Result<Unit> = withContext(serializedDispatcher) { | ||
| runSuspendCatching { | ||
| val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) | ||
| .distinct() | ||
| val firstError = keys.mapNotNull { publicKey -> | ||
| removePublishedEndpoints(publicKey).exceptionOrNull() | ||
| }.firstOrNull() | ||
| if (firstError != null) throw firstError | ||
| } | ||
| val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) | ||
| .distinct() | ||
| removePublishedEndpoints(keys) | ||
| } | ||
|
|
||
| private suspend fun removePublishedEndpoints(publicKey: String): Result<Unit> = withContext(serializedDispatcher) { | ||
| runSuspendCatching { | ||
| var firstError: Throwable? = null | ||
| receiverPathsForCleanup(publicKey).forEach { receiverPath -> | ||
| val result = runSuspendCatching { | ||
| val report = paykitSdkService.clearPrivatePaymentList( | ||
| counterparty = publicKey, | ||
| receiverPath = receiverPath, | ||
| private suspend fun removePublishedEndpoints(publicKey: String): Result<Unit> = | ||
| removePublishedEndpoints(listOf(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.
Separately (pre-existing, but it interacts badly with the invalid-key issue): the |
||
|
|
||
| private suspend fun removePublishedEndpoints(publicKeys: Collection<String>): Result<Unit> = | ||
| withContext(serializedDispatcher) { | ||
| runSuspendCatching { | ||
| val normalizedBatch = normalizedPublicKeyBatch(publicKeys) | ||
| val publicKeys = normalizedBatch.publicKeys | ||
|
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. Nit: this shadows the function parameter. It's consistent with the existing |
||
| var firstError: Throwable? = PrivatePaykitError.InvalidPublicKey.takeIf { | ||
|
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. Invalid pending key now poisons every cleanup pass, permanently. (Android-only, new in Seeding
Pre-PR, the un-normalized single-key path silently no-oped and then cleared the marker via An unnormalizable key cannot correspond to any SDK state (the SDK is keyed by normalized pubkeys), so I'd drop it from the pending set with a warning rather than retaining it forever. At minimum, don't let it feed the batch-wide |
||
| normalizedBatch.hadInvalidKey | ||
| } | ||
| if (publicKeys.isEmpty()) { | ||
| firstError?.let { throw it } | ||
| return@runSuspendCatching Unit | ||
| } | ||
|
|
||
| val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshotOrNull("private endpoint cleanup") | ||
| val preparation = clearPrivatePaymentLists(publicKeys, linkedReceiverPathsSnapshot) | ||
| val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() | ||
| firstError = firstError ?: preparation.firstError | ||
|
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.
|
||
|
|
||
| if (preparation.clearedRetryKeys.isNotEmpty()) { | ||
| drainPendingPrivateMessages( | ||
| reason = "private endpoint cleanup", | ||
| advancingLinksFor = preparation.clearedRetryKeys, | ||
| ) | ||
| if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { | ||
| throw PrivatePaykitError.PrivateUnavailable | ||
| val pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) | ||
|
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 — same as iOS #638.
The retry path covers it, so not 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 surfaces as "cleanup never converges" if the SDK is flaky during a full pass. Worth noting in the description alongside the perf win, and no test covers it. |
||
| if (pendingRetryKeys.isNotEmpty()) { | ||
| failedPublicKeys += pendingRetryKeys.map { it.publicKey } | ||
| firstError = firstError ?: PrivatePaykitError.PrivateUnavailable | ||
| } | ||
| val retryKey = PrivateMessageDrainRetryKey(publicKey, receiverPath) | ||
| drainPendingPrivateMessages("private endpoint cleanup", advancingLinksFor = listOf(retryKey)) | ||
| if (retryKey in pendingPrivateMessageDrainKeys(listOf(retryKey))) { | ||
| } | ||
|
|
||
| val successfulPublicKeys = publicKeys.filterNot { it in failedPublicKeys } | ||
| clearPublishedEndpointCache(successfulPublicKeys) | ||
|
|
||
| firstError?.let { throw it } | ||
| Unit | ||
| } | ||
| } | ||
|
|
||
| private suspend fun clearPrivatePaymentLists( | ||
| publicKeys: Collection<String>, | ||
| linkedReceiverPathsSnapshot: Map<String, Set<String>>?, | ||
| ): PrivateEndpointCleanupPreparation { | ||
| val failedPublicKeys = mutableSetOf<String>() | ||
| val clearedRetryKeys = mutableListOf<PrivateMessageDrainRetryKey>() | ||
| var firstError: Throwable? = null | ||
|
|
||
| publicKeys.forEach { publicKey -> | ||
| val contactLinkedReceiverPaths = linkedReceiverPaths( | ||
|
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 (mirrors the iOS #638 duplication finding, just in a different spot). This hand-rolls exactly what |
||
| publicKey = publicKey, | ||
| snapshot = linkedReceiverPathsSnapshot, | ||
| ).onFailure { | ||
| failedPublicKeys += publicKey | ||
| firstError = firstError ?: it | ||
| Logger.warn( | ||
| "Failed to inspect private Paykit links for '${redacted(publicKey)}' during cleanup", | ||
| it, | ||
| context = TAG, | ||
| ) | ||
| }.getOrDefault(emptySet()) | ||
| 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." Same finding as iOS #638. Previously 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. Worth a line in the PR description. |
||
| publicKey = publicKey, | ||
| linkedReceiverPaths = contactLinkedReceiverPaths, | ||
| ).forEach { receiverPath -> | ||
| runSuspendCatching { | ||
| val report = paykitSdkService.clearPrivatePaymentList(publicKey, receiverPath) | ||
| if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { | ||
| throw PrivatePaykitError.PrivateUnavailable | ||
| } | ||
| } | ||
| if (result.isFailure) { | ||
| firstError = firstError ?: result.exceptionOrNull() | ||
| }.onSuccess { | ||
| clearedRetryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) | ||
| }.onFailure { | ||
| failedPublicKeys += publicKey | ||
| firstError = firstError ?: it | ||
| } | ||
| } | ||
| } | ||
|
|
||
| firstError?.let { throw it } | ||
| return PrivateEndpointCleanupPreparation(clearedRetryKeys, failedPublicKeys, firstError) | ||
| } | ||
|
|
||
| private suspend fun clearPublishedEndpointCache(publicKeys: Collection<String>) { | ||
|
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. Reentrancy window for the cache wipe is now much wider — Android analogue of the iOS actor-reentrancy finding.
Before this PR each contact's 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: cleanup runs while a foreground The hazard pre-exists, but the batching widens it from one-contact latency to whole-batch latency. Cheapest mitigations: wrap the cleanup pass in |
||
| publicKeys.forEach { publicKey -> | ||
| state?.contacts?.get(publicKey)?.let { contactState -> | ||
| contactState.remoteEndpoints = emptyList() | ||
| contactState.localInvoicesByReceiverPath = emptyMap() | ||
|
|
@@ -1234,6 +1327,9 @@ class PrivatePaykitRepo @Inject constructor( | |
| } | ||
| } | ||
| updateDeletedContactCleanupPending(publicKey, isPending = false) | ||
|
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. Missed batching in a batching PR: Collapse into a single update, e.g.: cacheStore.update {
it.copy(deletedContactCleanupPendingPublicKeys = it.deletedContactCleanupPendingPublicKeys - publicKeys.toSet())
} |
||
| } | ||
|
|
||
| if (publicKeys.isNotEmpty()) { | ||
| persistState(markWalletBackup = true) | ||
| } | ||
| } | ||
|
|
@@ -1246,42 +1342,85 @@ class PrivatePaykitRepo @Inject constructor( | |
| return paths.ifEmpty { listOf(PaykitReceiverPaths.WALLET) } | ||
| } | ||
|
|
||
| private suspend fun receiverPathsForPrivateEndpointCleanup( | ||
| private fun receiverPathsForPrivateEndpointCleanup( | ||
| publicKey: String, | ||
| excludedReceiverPaths: List<String>, | ||
| linkedReceiverPaths: Collection<String>, | ||
| ): List<String> { | ||
| val publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey) | ||
| val linkedPaths = linkedReceiverPaths(publicKey) | ||
| return (publishedPaths + linkedPaths) | ||
| return (publishedPaths + linkedReceiverPaths) | ||
| .filter { it in PaykitReceiverPaths.supported } | ||
| .filterNot { it in excludedReceiverPaths } | ||
| .distinct() | ||
| .sorted() | ||
| } | ||
|
|
||
| private suspend fun receiverPathsForCleanup(publicKey: String): List<String> { | ||
| val paths = ( | ||
| linkedReceiverPaths(publicKey) + | ||
| publishedPrivatePaymentReceiverPaths(publicKey) | ||
| ) | ||
| private fun receiverPathsForCleanup( | ||
| publicKey: String, | ||
| linkedReceiverPaths: Collection<String>, | ||
| ): List<String> { | ||
| return (linkedReceiverPaths + publishedPrivatePaymentReceiverPaths(publicKey)) | ||
| .filter { it in PaykitReceiverPaths.supported } | ||
| .distinct() | ||
| .sorted() | ||
| return paths | ||
| } | ||
|
|
||
| private suspend fun linkedReceiverPaths(publicKey: String): List<String> { | ||
| val normalizedKey = normalizedPublicKey(publicKey) ?: return emptyList() | ||
| val paths = paykitSdkService.linkedPeers() | ||
| .mapNotNull { peer -> | ||
| val peerKey = normalizedPublicKey(peer.counterparty) | ||
| peer.counterpartyReceiverPath.takeIf { | ||
| peerKey == normalizedKey && it in PaykitReceiverPaths.supported | ||
| } | ||
| private suspend fun linkedReceiverPathsByPublicKey(): Map<String, Set<String>> { | ||
| val linkedPaths = mutableMapOf<String, MutableSet<String>>() | ||
| paykitSdkService.linkedPeers().forEach { peer -> | ||
| val publicKey = normalizedPublicKey(peer.counterparty) ?: return@forEach | ||
| if (peer.counterpartyReceiverPath in PaykitReceiverPaths.supported) { | ||
| linkedPaths.getOrPut(publicKey, ::mutableSetOf) += peer.counterpartyReceiverPath | ||
| } | ||
| .distinct() | ||
| .sorted() | ||
| return paths | ||
| } | ||
| return linkedPaths | ||
| } | ||
|
|
||
| private suspend fun linkedReceiverPathsSnapshotOrNull(reason: String): Map<String, Set<String>>? = | ||
| runSuspendCatching { linkedReceiverPathsByPublicKey() } | ||
| .onFailure { | ||
| Logger.warn( | ||
| "Failed to inspect private Paykit links during '$reason'; retrying per contact", | ||
| it, | ||
| context = TAG, | ||
| ) | ||
| }.getOrNull() | ||
|
|
||
| private suspend fun linkedReceiverPaths( | ||
| publicKey: String, | ||
| snapshot: Map<String, Set<String>>?, | ||
| ): Result<Set<String>> = if (snapshot != null) { | ||
| Result.success(snapshot[publicKey].orEmpty()) | ||
| } else { | ||
| runSuspendCatching { linkedReceiverPathsByPublicKey()[publicKey].orEmpty() } | ||
|
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. This fallback re-issues the exact call that just failed — same finding as iOS #638.
Suggest picking one: on snapshot failure either fail the pass once (set |
||
| } | ||
|
|
||
| private suspend fun inspectLinkedReceiverPaths( | ||
| publicKey: String, | ||
| snapshot: Map<String, Set<String>>?, | ||
| reason: String, | ||
| ): LinkedReceiverPathInspection { | ||
| val result = linkedReceiverPaths(publicKey, snapshot) | ||
| val error = result.exceptionOrNull() | ||
| if (error != null) { | ||
| Logger.warn( | ||
| "Failed to inspect private Paykit links for '${redacted(publicKey)}' during '$reason'", | ||
| error, | ||
| context = TAG, | ||
| ) | ||
| } | ||
| return LinkedReceiverPathInspection(result.getOrDefault(emptySet()), error) | ||
| } | ||
|
|
||
| private fun normalizedPublicKeyBatch(publicKeys: Collection<String>): NormalizedPublicKeyBatch { | ||
| var hadInvalidKey = false | ||
| val normalizedKeys = publicKeys.mapNotNull { publicKey -> | ||
| normalizedPublicKey(publicKey) ?: run { | ||
| hadInvalidKey = true | ||
| null | ||
| } | ||
| }.distinct() | ||
| return NormalizedPublicKeyBatch(normalizedKeys, hadInvalidKey) | ||
| } | ||
|
|
||
| private fun publishedPrivatePaymentReceiverPaths(publicKey: String): List<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.
Nit:
.map { }here is really a side-effecting loop — the lambda's actual work isensureLinkWithPeer, and the returned value is unrelated to it.onEach/an explicit loop building the list would read more honestly about the intent. Behavior is preserved (the retry key is added regardless of whether the link succeeded), so this is purely stylistic.