fix(migration-keeper): preserve an active migration snapshot under alternating candidate rates - #705
Conversation
|
@ZacLou is attempting to deploy a commit to the Collins' projects Team on Vercel. A member of the Team first needs to authorize it. |
bc6857e to
52ffa12
Compare
The migration keeper re-derives the best-rate candidate from scratch on every scheduled run with no memory of an already-begun migration. When the top candidate flips between two adapters across consecutive runs, each run overwrites the prior begin_migration snapshot and resets the ledger-gap cooldown, so a migration can in principle never reach migrate_adapter despite a real, sustained improvement opportunity. Fix: before falling through to begin_migration for the freshly-derived best candidate, check whether an active migration snapshot already exists for a different adapter. If it does and that adapter still clears minImprovementBps against the current rate, override best to the snapshotted adapter so the existing migrate_adapter path completes it instead of resetting the cooldown. Only let the snapshot lapse when its adapter's rate genuinely stops clearing the threshold. Two new tests cover both halves: preservation when the snapshot still qualifies, and replacement when it has genuinely decayed.
52ffa12 to
ccfd342
Compare
| // migrate_adapter. Only let the snapshot lapse (and pick a new | ||
| // candidate) once the snapshotted adapter's rate genuinely stops | ||
| // clearing the threshold. | ||
| if ( |
There was a problem hiding this comment.
The #699 fix is bolted on as a second, separate re-evaluation pass after findBestCandidate already returned, rather than making findBestCandidate itself snapshot-aware, e.g. by passing the existing snapshot's adapter in so it's evaluated alongside the other candidates in the same concurrent batch. As written, every future case needing to reason about "the current best vs. an existing on-chain commitment" will be tempted to bolt on another special-cased post-pass with its own duplicated rate fetch and threshold check (see the two comments below) instead of reusing one generalized candidate-evaluation path.
| // of this check. Each is retried individually via | ||
| // withKeeperRetry, same as findBestCandidate's own candidate | ||
| // evaluation. | ||
| const [snapshotRateResult, currentRateResult] = await Promise.all([ |
There was a problem hiding this comment.
currentRateResult refetches the vault's current-adapter rate via a second retried rateSource/withKeeperRetry call, even though findBestCandidate already fetched and validated that exact rate moments earlier in the same run. On every run where an active snapshot exists for a different adapter than the freshly-derived best, the common steady-state case this PR fixes, this adds an extra full-retry RPC round trip purely to re-derive a rate the run already has, without changing the outcome when the rate hasn't moved.
| if ( | ||
| isUsableRate(snapshotRate) && | ||
| isUsableRate(currentRate) && | ||
| snapshotRate - currentRate >= config.minImprovementBps |
There was a problem hiding this comment.
This improvement-threshold comparison duplicates the identical comparison already implemented inside findBestCandidate's candidate loop instead of extracting a shared helper. A future change to the threshold semantics, e.g. switching to a percentage-based or rounding-aware comparison, only updates one of the two inline copies, silently making the snapshot-preservation path and the fresh-candidate path disagree about what counts as a clearing improvement.
Summary
The migration keeper re-derives the best-rate candidate from scratch on every scheduled run with no memory of an already-begun migration. When the top candidate flips between two adapters across consecutive runs, each run overwrites the prior
begin_migrationsnapshot and resets the ledger-gap cooldown, so a migration can in principle never reachmigrate_adapterdespite a real, sustained improvement opportunity existing throughout.Closes #699
Problem
minImprovementBpsover the current adapter's rate. Keeper callsbegin_migration(A), snapshotting A.get_migration_snapshot(), seesadapter = A != best.adapterId = B, and callsbegin_migration(B), overwriting A's snapshot and resetting the cooldown.MIN_LEDGER_GAPwith a matching "best" on a later run.Fix
Before falling through to
begin_migrationfor the freshly-derived best candidate, check whether an active migration snapshot already exists for a different adapter. If it does and that adapter still clearsminImprovementBpsagainst the current rate, overridebestto the snapshotted adapter so the existingmigrate_adapterpath completes it instead of resetting the cooldown. Only let the snapshot lapse (and pick a new candidate) once the snapshotted adapter's rate genuinely stops clearing the threshold.Key design decisions
begin_migrationrun), so a stale snapshot whose adapter has genuinely decayed is correctly replaced.Promise.all+withKeeperRetry, matching the existing candidate evaluation pattern.begin_migrationfor the fresh best rather than blocking the migration entirely — strictly better for liveness.migrate_adapter's contract-side slippage/stability checks are unchanged; this only affects which candidate the keeper tries to complete.Testing
Two new test cases covering both halves of the fix:
begin_migrationfor A, correctly replacing the stale snapshot.All 54 existing + new tests pass.
Scope