MOB-1832: Apply server switch hysteresis to automatic server selection - #2495
MOB-1832: Apply server switch hysteresis to automatic server selection#2495nesence-m wants to merge 4 commits into
Conversation
Automatic selection used to take the benchmark's plain winner on every foreground, so two near-equal servers made the app tear down and rebuild the synchronizer for a few milliseconds of nothing. The decision now comes from the SDK's `Synchronizer.evaluateServerSwitch`, which owns the thresholds and never exposes per-endpoint scores: the repository asks "switch to X or stay?" and applies whatever comes back. `init()` collapses to a single foreground-triggered lane. `mapLatest` cancels an in-flight benchmark when a new trigger arrives, and the automatic-mode and in-transaction guards are re-checked just before applying, so a decision made before the user changed something is not acted on. The user-facing "recommended servers" benchmark is untouched - saving Automatic explicitly is still a request to pick the fastest server right now, with no hysteresis. SDK_COMMIT_PIN points at the companion SDK feature-branch commit; repoint it to the merged sha once the SDK PR lands. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PYn1vxkynZJHRJQWVSkvtE
…esis Resolves the overlap with #2489 (MOB-1723): the automatic lane keeps the hysteresis pipeline (foreground edge -> SDK evaluateServerSwitch -> apply) and folds #2489's balance-snapshot gate into it. A switch candidate now waits for the first local balance snapshot inside the cancellable mapLatest block before it is applied. resolveAutomaticServerCandidate is gone with the fastestEndpoints lane it gated; its test is replaced by tests of the wait on the merged pipeline. SDK_COMMIT_PIN keeps this branch's companion SDK commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PYn1vxkynZJHRJQWVSkvtE
|
This app PR builds against the SDK companion via SDK_COMMIT_PIN pointing at zodl-inc/zodl-android-wallet-sdk#14, and must land only after the SDK side ships. |
LukasKorba
left a comment
There was a problem hiding this comment.
Reviewed together with zodl-inc/zodl-android-wallet-sdk#14 — most of the substance is over there (the failure path has no hysteresis, so A→B→A flapping is still reachable; plus two gRPC leak paths). This side is smaller, but one blocking item and a bad merge artifact.
Blocking — the only lane has no error handling
AutomaticServerRepository.kt:104-111:
applicationStateProvider.observeOnForeground()
.mapLatest { resolveSwitchCandidate() }
.filterNotNull()
.onEach { applyServerSwitch(it) }
.launchIn(scope)No .catch, no runCatching, no retry. resolveSwitchCandidate (:117-129) reaches a DataStore read (isServerAutomatic()), an encrypted-prefs read (getPersistableWallet()), and a multi-endpoint network benchmark. Any throw kills the flow — a StatusRuntimeException escaping the un-runCatching'd getLatestBlockHeight at FastestServerFetcher.kt:291, an IOException from prefs, or the IllegalArgumentException from the SDK's require at :96.
init() is called once, and scope is CoroutineScope(Dispatchers.IO + SupervisorJob()) (:48) with no CoroutineExceptionHandler — so the failure goes to the global handler and then the thread's uncaught handler. Likely a crash; at best automatic server selection is permanently dead until process restart, silently. Wrap resolveSwitchCandidate() in runCatching and log.
Should-fix
gradle.properties:112 — the merge reverted this comment to the wrong upstream repo. Base maint/v3.10.x reads # Optional commit hash on zodl-inc/zodl-android-wallet-sdk.; this PR changes it to zcash/zcash-android-wallet-sdk. Nothing in the description mentions it and neither commit needed it — looks like a bad conflict resolution in merge commit 69f48b5. The pinned SHA ba6d49dd… doesn't exist in zcash/zcash-android-wallet-sdk, so the comment now points a reader at a repo where the pin is unresolvable. Revert that line.
The fake Synchronizer can't fail, so the blocking item above is invisible to the suite — every stub in AutomaticServerRepositoryTest.kt is returns null or returns known.last(), no throws case, and nothing tests that a cancelled mapLatest leaves nothing applied. The 12 tests do faithfully pin the arguments (sdkIsCalledWithTheCurrentEndpointAndEveryKnownCandidate — current, all known, 5.seconds, blocksToFetch = 1) and the balance-snapshot gate, which is the right thing to pin.
Merge order — yes, this needs unpinning before it goes anywhere
- SDK #14 lands on
maint/v3.1.xfirst — this can't compile without it, sinceSynchronizergains an abstractevaluateServerSwitch(breaking for every implementer; the CHANGELOG flags it correctly andSlipstreamSynchronizer.kt:933-944delegates properly). - Cut an SDK release from
maint/v3.1.x. - Immediately after #14 merges: repoint
SDK_COMMIT_PIN—gradle.properties:127currently pinsba6d49dd…, which is the SDK PR head, i.e. a feature-branch commit that stops being fetchable once GitHub deletes the branch. That violates the constraint written a few lines above it in the same file ("must be reachable from something more durable than the SDK's own PR branch"). Same for the base branch's stale0865690d…if it's in the same state. - Before this reaches
release/*orcandidate/*: clearSDK_COMMIT_PINentirely and bumpZCASH_SDK_VERSIONfrom3.1.1-SNAPSHOTto the released version. Per the file's own comment CI hard-fails on a non-blank pin there, so it's a gate, not a nicety.
Base branches pair correctly (SDK maint/v3.1.x ↔ app maint/v3.10.x at 3.1.1-SNAPSHOT), and the call site itself is clean — named arguments throughout (:123-128), Duration makes a ms/s mixup impossible, the explicit 5.seconds/1 match the SDK defaults, nullable return handled. The one real integration gap is the unenforced "candidates must include current" invariant, which I've written up on the SDK PR — worth fixing on whichever side you prefer, but not leaving to a doc comment.
…ations Addresses Lukas Korba's review of PR #2495 (MOB-1832). The foreground lane had no error handling (blocking): it is launched once per process on a scope with no CoroutineExceptionHandler, so any throw from resolveSwitchCandidate - a gRPC failure, an encrypted-prefs read, the SDK's own argument checks - killed automatic server selection until the next process start and reached the thread's uncaught handler on its way out. The evaluation and the application of its result are now each wrapped in runCatching, logged with Twig.error, and the lane keeps serving the next foreground edge. Cancellation still propagates, so mapLatest keeps its semantics. observeOnForeground() has no throttle, so a full benchmark of every bundled host ran on every foreground edge. Evaluations are now at least ten minutes apart, counted from the last one that actually completed. The lane moved into observeSwitchCandidates(), which init() launches, so these two behaviours are unit-testable without injecting the repository's internal scope. Also reverts the gradle.properties comment to the base's zodl-inc/zodl-android-wallet-sdk - a bad conflict resolution in the merge commit had pointed it at zcash/zcash-android-wallet-sdk, where the pinned sha does not exist - and repoints SDK_COMMIT_PIN at the companion SDK commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
|
Thanks — addressed in Blocking — the only lane has no error handlingFixed. The lane moved into New Should-fixMinimum interval between evaluations. Added, since
The fake The unenforced " Merge order — acknowledged, exactly as you laid it out
Gates: |
LukasKorba
left a comment
There was a problem hiding this comment.
Lane protection and the comment revert are both properly fixed. The substantive findings this round are cross-repo, so they're written up on zodl-inc/zodl-android-wallet-sdk#14 — the short version is that the SDK's 10-minute cooldown can never fire on this app's path, because evaluationInterval.markCompleted() (:158) runs after the SDK has already stamped lastSwitchAt, and both windows are 10 minutes. K=2 is the only hysteresis actually running. Two more over there: markCompleted() only fires on success, so a user toggling foreground faster than an evaluation completes gets an unthrottled full benchmark every edge; and TimeSource.Monotonic at :177 doesn't advance in deep sleep, so an overnight sleep can filter out the morning foreground.
A1 fixed properly. :121-128 — guarded(...) wraps both resolveSwitchCandidate() and applyServerSwitch(candidate), and guarded (:130-139) rethrows CancellationException at :136 before logging, so mapLatest semantics are preserved. Failures return null and get dropped by filterNotNull(), lane stays alive for the next edge. The mockk tests with explicit throws (:207-227, :230-244) are the right shape, and asserting that the next edge still applies a switch is the assertion that matters.
Two leftovers: observeOnForeground() itself is still uncaught, so a throw from the upstream flow rather than the transform still kills the lane at :106 (low). And there's still no test that a cancelled mapLatest leaves nothing applied — theLaneSurvivesAFailureToApplyTheSwitch (:230) asserts one call happened, not that the lane survived, because the 10-minute interval makes a second edge unobservable in that test.
A2 fixed — gradle.properties:112 reads zodl-inc/zodl-android-wallet-sdk again.
A4 — not fixed, and it can't be yet. To be clear, I don't read this as a misrepresentation: your commit message says "repoints SDK_COMMIT_PIN at the companion SDK commit", which is exactly what happened. But gradle.properties:127 is 6a30e60f…, and I resolved it against the SDK repo:
branches-where-head→feature/mob-1832-server-switch-hysteresis, and nothing elsecompare maint/v3.1.x...6a30e60f→ahead_by: 4, behind_by: 0, so it is not an ancestor of the maint branch
It's still a feature-branch commit that stops being fetchable when the branch is deleted, which is what the constraint at gradle.properties:123-126 exists to prevent. Unchanged sequence: merge #14 → repoint to the merged SHA on origin/maint/v3.1.x → blank the pin and bump ZCASH_SDK_VERSION off 3.1.1-SNAPSHOT (:206) before this touches release/* or candidate/*.
… wait MOB-1832 The SDK now separates recommending a switch from recording one, so the lane calls confirmServerSwitch() after the endpoint has actually been written, and never when the switch was declined - a wallet on a dead server whose switch was declined mid-transaction keeps its consecutive-failure count instead of having to earn its way out from scratch. The evaluation interval marks the attempt in a finally rather than the success, so an evaluation that throws or is cancelled by the next foreground edge still counts: a user toggling foreground faster than an evaluation completes was otherwise never rate limited at all. It measures with SystemClock.elapsedRealtime() instead of TimeSource.Monotonic, which does not advance in deep sleep and would have skipped automatic selection for hours after an overnight sleep. The wait for the first local balance snapshot before applying a switch is capped at thirty seconds and the switch is applied anyway on timeout: in the unhealthy case the current server has already failed two evaluations and is plausibly why no snapshot exists, so the failover that matters most cannot be gated forever on the sync it is meant to repair. Which path was taken is logged. Also catch and log around the foreground signal itself, so a throw from upstream of the transform cannot take the lane down either, and repoint SDK_COMMIT_PIN at the companion SDK commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
|
Thanks — second round addressed in
Wrong monotonic clock → Unbounded balance-snapshot wait → Confirming the switch → Leftovers from your review:
A4 — agreed and unchanged: Gates: |
LukasKorba
left a comment
There was a problem hiding this comment.
All three app findings fixed correctly, and the confirm handshake is called on exactly the right paths. Good from my side — the remaining work is on zodl-inc/zodl-android-wallet-sdk#14 (the SDK cooldown still uses TimeSource.Monotonic, which your own KDoc at :227-230 explains is the wrong clock — so a phone that sleeps overnight can have the app's interval elapse while the SDK's cooldown hasn't, deferring the morning failover off a dead server for hours).
N2 — markAttempted() in the finally at :180-182, and it's non-suspending so it runs on the cancellation path too. I checked the starvation risk I warned about and it isn't reachable: a user toggling every 30s gets edges dropped for 10 minutes, then the next window opens an attempt again — it retries once per 10 min indefinitely and never latches off. Early returns at :168-172 sit before the try and so don't mark, which is right: no network work was done.
N3 — ElapsedRealtimeTimeSource at :232-240, wired as the default at :215. The rationale comment is the clearest thing in the diff.
N7 — bounded at 30s (:247), and you made the right call on semantics: the switch still applies on timeout and which path was taken is logged (:152-164). That's correct for the CurrentUnhealthy case, which is exactly why the unbounded wait was the wrong gate. Both ends pinned.
The handshake. Taking the synchronizer before the write (:197 vs :199) is load-bearing and the comment at :185-192 gets it right — getSynchronizerOrNull() suspends on filterNotNull().first(), so calling it after would block until the rebuild finished. I traced the decline, throw and process-death paths and they all preserve the count correctly.
Two lows, neither blocking:
- The cancellation gap at
:199-200is closed by accident, not by design.markAttempted()runs inevaluateServerSwitch's finally, before the balance wait and beforeapplyServerSwitch, so by the time you reach the writelastAttemptAtis ~30s old and the filter at:128drops every edge for ~9.5 min — meaning nothing can cancel you between the write and the confirm. That holds today, but it depends onMINIMUM_EVALUATION_INTERVALstaying long and the filter staying where it is. If a confirm is ever missed after a successful write and the new server also fails to measure, the stale count of 2 increments to 3, already past threshold, with no cooldown — a switch every 10 minutes walking the host list. Cheap insurance:withContext(NonCancellable) { updateWalletEndpoint(candidate); synchronizer?.confirmServerSwitch(candidate) }. confirmServerSwitchfires even whenupdateWalletEndpointno-ops.WalletRepository.kt:218-223returns silently on a null wallet or an unchanged endpoint, and:200confirms unconditionally — starting a 30-minute cooldown for a switch that never happened, which is the hazard your own SDK doc atSynchronizer.kt:285-286warns about. Narrow reachability, but returning aBooleanfromupdateWalletEndpointand gating the confirm on it would be exact.
Nit: EvaluationInterval.lastAttemptAt (:217) is a non-volatile var written from the mapLatest child and read from the collector in filter, on a shared Koin single. Worst case is one extra benchmark and dispatch makes a stale read unlikely, but @Volatile is free.
Pin — correctly repointed to ed11a76, the new SDK head. Still a feature-branch SHA, as expected; same sequence stands: merge #14 → repoint to the merged SHA on origin/maint/v3.1.x → blank the pin and bump ZCASH_SDK_VERSION before release/* or candidate/*.
CI: Keystone is red on every completed run of e2e-smoke.yml across six unrelated branches over the last three days — repo-wide, not this PR. test_android_modules_emulator (app) shows AdbCommandRejectedException: device offline and "Expected 12 tests, received 0", i.e. the emulator died rather than an assertion failing; the prior head passed the same job. Worth one rerun, but the signature is infra.
Summary
App half of the server-switch hysteresis for automatic server selection, tracked in MOB-1832 and mirroring iOS (zodl-ios #2049). Companion SDK PR: zodl-inc/zodl-android-wallet-sdk#14 (this branch pins it via
SDK_COMMIT_PIN).AutomaticServerRepositoryImpl.init()is now a single lane: every foreground edge (including the first real foreground on launch) calls the SDK'sevaluateServerSwitch(current, allBundledEndpoints, 5 s, 1 block); a new edge cancels an in-flight benchmark (mapLatest). A non-null answer is re-validated (automatic still on, no transaction in flight, candidate still bundled) and applied through the existingupdateWalletEndpointpipeline. Anullanswer changes nothing.Merged with
maint/v3.10.xafter #2489 (MOB-1723) landed: its balance-snapshot gate now lives inside the hysteresis pipeline, so a switch candidate waits for the first local balance snapshot (inside the cancellablemapLatestblock) before it is applied;resolveAutomaticServerCandidatewent away with thefastestEndpointslane it gated. Shipping on 3.10.x requires an SDK release frommaint/v3.1.xfirst, then clearingSDK_COMMIT_PINand bumpingZCASH_SDK_VERSION.Test plan
AutomaticServerRepositoryTest(12 tests): automatic off → no SDK call; in-transaction state → no SDK call; SDK null/endpoint passed through; SDK called with current endpoint, every bundled candidate, 5 s and 1 block; candidate waits for / is released by the local balance snapshot./gradlew :zashi-android:ktlint :zashi-android:detektAllclean (from the task-workspace root)./gradlew :zashi-android:app:assembleZcashmainnetStoreDebugagainst the pinned SDK branchServer Switch:log line per foreground and no wallet reconnect when the decision is "stay"Author
Reviewer
https://claude.ai/code/session_01PYn1vxkynZJHRJQWVSkvtE
Footnotes
Code often looks different when reviewing the diff in a browser, making it easier to spot potential bugs. ↩
While we aim for automated testing of the application, some aspects require manual testing. If you had to manually test something during development of this pull request, write those steps down. ↩
While we are not looking for perfect coverage, the tool can point out potential cases that have been missed. Code coverage can be generated with:
./gradlew checkfor Kotlin modules and./gradlew connectedCheck -PIS_ANDROID_INSTRUMENTATION_TEST_COVERAGE_ENABLED=truefor Android modules. ↩Having your code up to date and squashed will make it easier for others to review. Use best judgement when squashing commits, as some changes (such as refactoring) might be easier to review as a separate commit. ↩
In addition to a first pass using the code review guidelines, do a second pass using your best judgement and experience which may identify additional questions or comments. Research shows that code review is most effective when done in multiple passes, where reviewers look for different things through each pass. ↩
While the CI server runs the app to look for build failures or crashes, humans running the app are more likely to notice unexpected log messages, UI inconsistencies, or bad output data. Perform this step last, after verifying the code changes are safe to run locally.
🤖 Generated with Claude Code ↩