Repository navigation
Fix batch 3 of the v0.4.0 review: account pass retries, lifecycle, and database outages - #25
Merged
Merged
Conversation
SyncRunLifecycleService.requeue, used for a run interrupted by a worker shutdown and for one the full worker pool could not take, set the next attempt with a backoff over the run's claim count. Continuations count as claims, so after a deploy a long account sync waited up to 15 minutes. Neither case says anything about the run: both are now due at once, and the claim-count backoff is gone.
The Alchemy fetch reads the asset config from the database. A database failure there fell into the provider's catch-all, which records any other exception as an Alchemy failure: health turned DOWN and showed the raw exception text, which can quote SQL, while the run itself stored only the class. The provider now rethrows a database failure without recording it, so health keeps its state and the run records Database error (<class>), as for any other outage of the database.
HTTP Basic read the user store on every request, so during a database outage every request with credentials waited for the connection pool, and the outbox gauges queried the database on every scrape: Prometheus lost every metric, including the ones that show the outage. The user store is now a CachingUserDetailsManager in front of JdbcUserDetailsManager. A user loaded within a minute comes from memory, so a password changed directly in the database applies within a minute, while writes through the manager evict at once. When the database cannot serve a reload, a user loaded within ten minutes stays accepted and the next reload waits a minute. Callers get a copy, because Spring Security erases the principal's password. The outbox gauges read counts that OutboxGaugeRefreshJob refreshes every 10 seconds, and keep the last counts while the database is down.
Account and address syncs picked watched addresses by their own status
only, so an address on a disabled chain was still synced: it failed
its runs and every account sync that included it, and the runbook had
to tell operators to disable the addresses too. Account sync now
passes over the active addresses on enabled chains only, in its scan,
its revisits, and its address count, and POST
/api/v1/addresses/{id}/sync answers 404 Unsupported chain for such an
address, as for a disabled address. Disabling the chain before an
Alchemy rollout is enough again.
…ount sync An account sync failed its whole claim when the provider fetch of one address failed retryably, such as a timeout on a heavy address or a 5xx. The next claim began at that address again, so the run failed after five attempts and the addresses after it were never synced, and each failed claim lost the progress of the pass, because requeueFailure kept the old run checkpoint. Such an address is now deferred and retried once in each later claim, retry-backoff-base-delay apart, as an ADDRESS_RETRY continuation that spends no failure attempt (changeset 018); its third failed retry records it as failed while the others sync. Retryable failures of three addresses in a row, or of two in a claim in which the provider served no page, are a provider outage: the claim fails as before, and the retries that failed in it do not count against their addresses. requeueFailure and the shutdown requeue now save the pass, so the retried run resumes where the failed claim stopped. Recorded errors are kept to printable ASCII, so the larger checkpoint stays within 16 KiB.
Ingest turned any IllegalArgumentException during a provider sync into a terminal "Provider returned an event that breaks a domain invariant", so a broken rule of the service's own logic, such as a requireNotNull in a repository, failed the run for good and blamed the provider. The domain model's checks of event values now throw DomainInvariantException, a subclass, and ingest maps only that one; the model's checks of its own logic stay plain require calls, and anything else is an unexpected error, retried as one.
Once a cached user was a minute old, every request with its credentials read the user store again, and only a read that had failed let the cache stand in for the database. During an outage each of those reads waited up to the pool's connection timeout, or the socket timeout on a database that stopped answering, so for half a minute out of every minute concurrent requests, Prometheus scrapes among them, hung and timed out. One request now reads the user again while the others get the cached copy, and a failed read counts its minute from when it failed rather than from when it started.
The review of #25 found that the retry rules of B18 blamed the wrong side in both directions. A pass with one address left to retry charged a full provider outage to it and failed in about 90 s, where a run gets five attempts over 7.5 minutes; two addresses that kept failing were taken for an outage in every claim and never recorded; pages an address committed before failing counted for nothing; a 429 or rate limit was put on one address; retries came seconds apart after short continuations; and a full retry list held the scan for good. An address that fails retryably now gets the attempts and backoff of a run on its own schedule, stored in the pass, and due retries go first in each claim, so the list drains while the scan goes on; with the list full the scan waits instead of failing the claim. Committed pages count as progress. Only three failures in a row with no committed page between them are an outage, and throttling, the provider's or the local rate limiter's, fails the claim like Retry-After. While addresses wait, the run's last_error says so. An address run queued before its chain was disabled now fails without calling the provider. A requeue reason this version does not know reads as none, and docs/database.md has the SQL to run before rolling back to 0.4.1, which cannot read PROVIDER_BUSY or ADDRESS_RETRY.
The review of #25 found the user cache of B37 in the wrong place and too loose. It sat inside the UserDetailsService, where Spring Security's own cache logic could not see it, so a password rotated on another instance was refused for up to a minute; a load that read the old row could store it after a write through the service had emptied the entry; a failed re-read extended the outage fallback past its ten minutes; and during an outage a cached name with a wrong password got 401 while any other name got 503, which told which names were used. The cache is now Spring Security's UserCache on a DaoAuthenticationProvider built for the chain: a mismatch reads the store again, loads fill the cache only when no write came in meanwhile, the fallback ends ten minutes after the last successful read, and a wrong password for a user served from the fallback gets 503 like an unknown name. During an outage one request per user and minute still waits for the connection timeout while it reads the database again; the docs now say so rather than promising scrapes that never wait.
The review of #25 found the new gauge refresh job without a switch or a configurable delay, so it ran in the test and e2e profiles, which turn every other database job off, where a background refresh could land after a test's own and make a gauge assertion flaky. ASSET_SYNC_OUTBOX_GAUGES_ENABLED and ASSET_SYNC_OUTBOX_GAUGES_FIXED_DELAY now control it, and both test profiles switch it off.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Batch 3 of the fixes from the merged review of v0.4.0: operations. It covers the sync run lifecycle, the account pass, and behavior while the database is down. B18 got a short design first: which address failures are that address's and which are the provider's. There is one commit per fix.
5xx, waits on a retry list with the attempts and backoff of a run (5, from 30 s doubling), on its own schedule stored in the pass, while the others sync. It is recorded as failed once its attempts are spent. Due retries go first in each claim. Pages committed before a failure count as progress. Three addresses failing in a row are a provider outage, which costs them nothing; throttling fails the claim likeRetry-After. While addresses wait,lastErrorsays so (ADDRESS_RETRY, changeset 018). Failed and interrupted claims save the pass.POST /api/v1/addresses/{id}/syncanswers404 Unsupported chain, and a run queued before fails without calling the provider.UserCache. A user is fresh for a minute, a password mismatch reads the store again, and writes through the service apply at once. While the database is down, a user read in the last ten minutes stays accepted, re-read by one request at a time. A wrong password then gets503, like an unknown name. The outbox gauges read counts that a background job refreshes.DOWNand put raw exception text, which can quote SQL, into the healtherrorDomainInvariantException) count as bad provider data.IllegalArgumentExceptionin ingestion failed the run terminally and blamed the providerB37 goes beyond the agreed one-minute cache. A plain TTL would not have kept the metrics reachable during an outage, which is what the finding is about. So a cached user outlives a failed read by up to ten minutes, and a scrape no longer counts the outbox in the database. One request per user and minute still waits for the pool timeout while it reads the database again (B43, recorded).
Visible changes:
last_requeue_reasonvalue,ADDRESS_RETRY;max-attemptsand the retry backoff now also apply to each address of an account run;userstable, keeps working for up to a minute; a new password works at once;ASSET_SYNC_OUTBOX_GAUGES_ENABLEDandASSET_SYNC_OUTBOX_GAUGES_FIXED_DELAYcontrol the gauge refresh.Verification
./gradlew clean check: 372 tests, 0 failures, 2 skipped (the env-gated Alchemy live smoke), no compiler warnings.run_checkpoint. That is why the retry list holds 50 addresses and the pass lists 10 failures.prodagainst real PostgreSQL and a stub bridge, withretry-backoff-base-delay=2s:503, between two good ones: the run wasFAILEDafter 29 seconds with1 of 3 addresses failed terminally: <id>: failed 5 times: Provider returned HTTP 503.andfailure_attempts0. The bridge saw five requests for that address, and the other two addresses were stored.503twice and then a page: the runSUCCEEDEDwith no failure attempt spent.Provider unavailable: 3 addresses in a row failed, the last with: Provider returned HTTP 503.withfailure_attempts1, and the pass kept the three addresses with no attempt counted. The retry took them first, then the fourth, andSUCCEEDED.429withRetry-After, and one without, each failed the claim as throttling, five times untilFAILED, rather than being put on one address. The address before each was fetched once.docker pauseon PostgreSQL: a scrape of/actuator/prometheuswith credentials loaded a moment earlier answered200in 0.07 s. A minute later three concurrent scrapes all answered200: one waited 30 seconds while it read the user again, the other two answered in 0.07 s. Afterdocker unpause, reads worked again anddbhealth wasUP.Review
One
/code-reviewround ran on this PR, at medium, with a verifier for every candidate. It found 15 problems; 14 were confirmed, and the four most serious reproduced against PostgreSQL. All are fixed in three commits, without another round:3555bfd, the account pass retries:429or rate limit was put on one address. It now fails the claim.ADDRESS_RETRY. An unknown reason now reads as none, with SQL for the rollback indocs/database.md.lastErrornow says what it waits for.d8b6101, the user cache:UserCache, so a password mismatch reads the store again.503like an unknown name, so the answer no longer tells which names were used.a8daa83: the gauge job has a switch and a delay, and both test profiles turn it off.Recorded rather than done:
Left out