fix: isolate activity backup restore slices - #1171
Conversation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e073d85 to
b4377f5
Compare
|
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jvsena42
left a comment
There was a problem hiding this comment.
Approved — no HIGH/MEDIUM findings at head 1bb6ee877. (Posted as a comment because GitHub does not allow approving your own PR.)
Checked: Traced ActivityRepo.restoreFromBackup (app/src/main/java/to/bitkit/repositories/ActivityRepo.kt:993-1020) at the PR head: each slice runs in its own runSuspendCatching, so a Core rejection of tags no longer skips closed channels; CancellationException is rethrown by runSuspendCatching (app/src/main/java/to/bitkit/ext/Coroutines.kt) and no slice result is fabricated for it. The overall Result is still a failure on any slice error, so BackupRepo.restoreActivityBackup (BackupRepo.kt:710-714) computes needsRewrite=false and rewriteMigratedBackups (BackupRepo.kt:659-661, 823-828) never re-uploads ACTIVITY from a partial local set; BackupRepoTest 'activity backup is not rewritten when core restore fails' pins that. Checked what is marked restored after a partial failure: performRestore (BackupRepo.kt:849-851) writes synced=required=createdAt for ACTIVITY and the full restore sets backupVerified regardless of the activity outcome, but that is identical on master (only WALLET is fatal) and is not changed by this PR. The tag signal now fires only when the tags slice was applied (ActivityRepo.kt:1010), so a rejected tags slice cannot dirty METADATA; the remaining path where the next ordinary activity change uploads a partial local ACTIVITY set after _isRestoring is cleared (BackupRepo.kt:657, 337) was raised by greptile, accepted by the author as a pre-existing BackupRepo limitation and recorded in Out of Scope, so it is not re-raised. The restore is only invoked from WalletViewModel.collectStates inside viewModelScope, so cancellation is not user-reachable mid-restore. iOS BackupService.swift:232-234 still restores the three slices in one block; no iOS twin PR exists, which the PR body already lists as out of scope.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — the gate is in, in aa06584. What I verified firstYour reading of the trace holds, and the window is wider than the 30s wait alone:
The gate
It is the same reach
Cross-checked against bitkit-ios while writing it: Tests and negative controlFour in
Negative control, run through the same task: dropping JourneyYour Test 1 is committed as Manual test 2 stays unticked — I have no device here, so that one is still yours to confirm.
|
# Conflicts: # journeys/README.md
jvsena42
left a comment
There was a problem hiding this comment.
Approved — no blocking HIGH/MEDIUM findings at head ba3359f47. (Posted as a comment because GitHub does not allow approving your own PR.)
Checked: Follow-up on aa06584 (backup gate while a restore is pending, plus journey): Traced the new gate at head ba3359f (aa06584). Permanent suppression: restorePendingUntil is an in-memory deadline (BackupRepo.kt:128,149), expiry is 10 minutes (BackupRepo.kt:892), and every terminal path of the restore clears it: restoreFromBackup wraps restoreFromMostRecentBackup in runCatching so success, failure and even a CancellationException from a cleared viewModelScope still reach setRestorePending(false) (WalletViewModel.kt:216-222); a failed walletRepo.restoreWallet clears it (WalletViewModel.kt:571-573) and that repo uses runCatching so a cancelled DataStore write also returns failure (WalletRepo.kt:459-473); process death drops it; the only path that leaves it up is the top-level rememberCoroutineScope (MainActivity.kt:122) being cancelled by Activity destruction inside the few milliseconds of keychain/settings writes, which also strands _restoreState on master and is bounded by the expiry. Wipe cannot run mid-restore (onboarding only) and reset() leaving the deadline in place is harmless since createWallet after a stuck restore still triggers restoreFromBackup, which clears it. Nothing else reads restorePendingUntil, and triggerBackup (retry button, rewriteMigratedBackups) stays ungated. Partial-restore overwrite: the gate now covers getLatestBackupTime, performFullRestoreFromLatestBackup, pubkyRepo.initialize and the RN fallback, so the three pre-restore 'Backup succeeded for: ACTIVITY' uploads QA observed are dropped at observeBackupChanges (BackupRepo.kt:385) and at the post-debounce recheck (BackupRepo.kt:423); the post-restore ordinary upload of a partial local set after the gate clears is the pre-existing limitation already accepted in the greptile thread and is not re-raised. Persistence/upgrade: not persisted, initial value 0L, so a released build upgrading has the gate open and no migration is needed. RN path: signals raised by applyRNRemote* while the gate is up are dropped instead of deferred, so VSS seeding of RN-migrated widgets waits for the next change in that category; on master that upload only happened when the node beat the 30s wait, the RN remote backup still holds the data, and every other category re-marks on its own within minutes, so no user-visible outcome. Tests: the two BackupRepoTest gate tests drive the real markBackupRequired -> status observer -> scheduleBackup -> putObject chain through stubBackupStatuses (BackupRepoTest.kt:1006-1023) and assert never() on putObject, so they fail without the shouldSkipBackup change; the expiry and rewrite tests assert the upload does happen (mocked clock at 700_000 > 1_000 + 600_000), and the WalletViewModelTest inOrder test fails without the setRestorePending(true) call. Journey: every testTag named resolves at head (Tab-${name.lowercase()} in CustomTabRowWithSpacing.kt:62, N$text in NumberPad.kt:240, ActivityShort-$index in ActivityListSimple.kt:52, Word-$index in RestoreWalletScreen.kt:416, restore_reset_button/dialog in ResetAndRestoreScreen.kt:125,139, the rest by literal), the log lines match BackupRepo.kt:510,633 and ActivityRepo.kt:1018-1020, the flow order matches the code, the suite is listed in the suites table and the cross-platform table records it as not ported with the reason; no bitkit-ios twin PR exists.
addressed - reaudit confirmed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Verdict: ✅ Approve
Reaudit: diff 1 file.
Retest suggested: items 1, 2 (Restore upload gate awaits queued first- and second-restore device regression pass).
Findings:
N/A
Audit:
Already done in comment.
Coverage:
QA: journeys and manual tests await green CI checks
Reviewed by gpt-5.6-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)
Fixes #1167
Related to #1275
This PR applies the three Core-owned slices of an activity backup independently on restore, so one rejected record can no longer discard the others, and holds ordinary backup uploads for the whole restore so they cannot overwrite the envelope before it is read.
Follow-up to #1163, and the app half of synonymdev/bitkit-core#134.
Description
The activity restore upserted activities, tags and closed channels inside a single
runCatching. Core fails a bulk write as a whole, so the first slice to throw aborted the calls after it. A single tag whose parent activity was missing therefore cost the activities and the closed channels too. This was reproduced on regtest while building #1163, whereactivity_tagswent from one row to zero and closed channels were skipped entirely:#1163 removed the hardware wallet trigger by carrying those tags as pre-activity metadata, so this is no longer easy to hit, but it can still happen. The backup envelope is assembled from three separate Core reads rather than one snapshot, so a tag written against a brand-new activity between the first and the third read lands in the envelope without its parent, in the default wallet scope, with no hardware wallet involved.
runSuspendCatchingso a failing slice no longer skips the slices after it.Holding ordinary uploads for the whole restore
Isolating the slices is not enough on its own, as the device run on this PR showed: a second restore of the same wallet came back without the tag and without the closed channel, because three
ACTIVITYuploads landed before the restore had read the backup._isRestoringis only raised insideperformFullRestoreFromLatestBackup, which the restore flow reaches aftergetLatestBackupTime()has probed VSS (bounded byVSS_TIMESTAMP_TIMEOUT, 60s). The node start only waitsTIMEOUT_RESTORE_WAIT(30s) for the restore, so the node starts, syncs, and its activity traffic bumpsactivitiesChangedwhile every ordinary upload path is still open — the data listeners, the status observers and the post-debounce re-check inscheduleBackupall consultshouldSkipBackup(), which was still false.BackupRepo.setRestorePending()and folds it intoshouldSkipBackup(), so the whole restore flow is covered rather than just the part after the envelope is fetched.WalletViewModel.restoreWalletraises the gate as the restore starts, and it is cleared when the restore returns, and when the wallet was never created so nothing reaches the restore at all.triggerBackup— the restore's own migration rewrite and the Data Backups retry button keep working.bitkit-ios already gates the same way (
AppScene.restoreFromMostRecentBackupsetsBackupService.setRestoring(true)before the timestamp probe, with the same "triggerBackupdeliberately not gated" note), so this brings Android in line, with an expiry iOS does not have.Out of Scope
ActivityRepo.restoreFromBackup: returns the first slice failure rather than an aggregate error; the caller only needs success or failure.BackupRepo: the new "skipped rewrite" warning is logged on any restore failure, including when no migration made a rewrite due.master.Design
N/A — no UI changes.
Preview
N/A — no user-visible UI change; the fix only affects how restored data is written, so there is nothing new to screenshot.
QA Notes
Reaching the failing-slice path by hand needs a tag whose parent activity is missing from the envelope, which #1163 made hard to produce, so the isolation contract is covered by unit tests and manual QA is a restore regression pass.
Manual Tests
regression:Wallet with tagged activity and a closed channel → wipe wallet → restore from seed → Home: activities and their tags are present.regression:Settings → Lightning Connections → Closed: the closed channel is present.regression:Restore the same wallet a second time → Settings → Data Backups: Transaction Log is not re-uploaded.Journey
Test 1 is now a committed journey, as suggested in review. It walks the whole route — record the phrase, receive 500 000 regtest sats, tag the activity
restoretag, buy a Blocktank channel, close it, wait for the Transaction Log, reset, restore — and ends by repeating the reset and restore once more, since the second restore is the one the ungated upload used to lose.journeys/backup-restore/restore-keeps-tags-and-closed-channels.xmlEvery identifier it names was checked against
app/src/mainon this branch. The suite has its ownjourneys/backup-restore/README.md, andjourneys/README.mdcarries its suites row and a cross-platform row.Automated Checks
ActivityRepoTest.kt: all slices succeed; tags, activities and closed channels slices each fail while the other two are still applied and the failure is returned; a failing tags slice raises the activity signal but not the tag signal; several slices fail and the first failure is returned; every slice fails and observers are not notified; cancellation is rethrown.BackupRepoTest.ktfor the upload gate: an ordinary activity upload is skipped while a restore is pending; it resumes once the restore is no longer pending; the gate stops holding uploads once it expires; the migration rewrite still uploads while a restore is pending.WalletViewModelTest.kt: the restore holds ordinary backups from its start until it completes, and releases them when the wallet is never created.|| isRestorePending()fromshouldSkipBackup()fails the two gate tests and leaves the expiry and rewrite tests green; dropping thesetRestorePending(true)call fails the view model ordering test. Both were restored and re-run green.BackupRepoTest.ktstays green, includingactivity backup is not rewritten when core restore fails, which pins the VSS rewrite gate this PR relies on.just compile,just test,just test file BackupRepoTest,just test file WalletViewModelTest,just test file ActivityRepoTestandjust lintpass.