fix: prevent and auto-recover stuck v2 verification jobs (compiler OOM/hang) (#2880) - #2886
fix: prevent and auto-recover stuck v2 verification jobs (compiler OOM/hang) (#2880)#2886kuzdogan wants to merge 9 commits into
Conversation
The wildcard outputSelection ('*': '*') made solc emit heavy artifacts
(legacyAssembly, generatedSources, source maps, storage layout, metadata)
for every contract in every source file. On large projects this produced
~40MB of output and ~800MB solc Resident Set Size (RSS) per compile;
several concurrent large verifications OOM-killed the worker process,
wedging jobs at completed_at IS NULL (#2880).
Scope the selection to the target contract only (~20x smaller output, ~4x
less solc RSS). lib-sourcify only reads the target's output, and bytecode/
metadata are unchanged since outputSelection only filters what is reported.
Also fix a latent optional-chaining bug in AbstractCompilation exposed by
scoping: an invalid target now yields no `contracts` object.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uck verification jobs (#2880) Native solc runs as a child process via asyncExec. When the process was OOM-killed mid-write, the stdin pipe emitted an unhandled EPIPE 'error' (uncaught exception in the Piscina worker) and the compile promise never settled, leaving verification_jobs.completed_at = NULL forever and locking the address against resubmission. A genuinely hung solc likewise blocked forever. asyncExec now: - settles exactly once via a `settled` guard - listens for child.stdin 'error' and wraps write/end in try/catch, rejecting instead of throwing an uncaught EPIPE (the load-bearing fix) - enforces a wall-clock timeout (exec timeout + killSignal SIGKILL), read from SOLC_COMPILE_TIMEOUT_MS, default 2700000ms (45 min) - attributes the death: Node timeout kill (error.killed) -> COMPILER_TIMEOUT, external SIGKILL / stdin EPIPE -> COMPILER_OOM, exposed as a `.code` discriminator (plain string constants, since compilers cannot import lib-sourcify error types) lib-sourcify maps the discriminator in AbstractCompilation to two new CompilationErrorCodes compiler_timeout / compiler_out_of_memory, with human-readable messages in SourcifyLibError and the server error surface. These flow through the existing worker error path so completed_at is written and the input is saved to S3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ess locks (#2880) OOM/hung compiler jobs never set completed_at on verification_jobs, so the affected chain+address stays locked against resubmission forever (measured: 1,260 stuck jobs in prod). This adds a periodic reaper that marks in-progress jobs older than a threshold as failed (error_code = job_abandoned), releasing the lock. A partial index over the tiny in-flight set backs the reaper query so it stays sub-ms on the ~77M-row table instead of a multi-minute seq scan. - migration: CREATE INDEX CONCURRENTLY verification_jobs_in_progress_idx on (started_at) WHERE completed_at IS NULL - Database.reapStaleVerificationJobs: single atomic, race-safe UPDATE - SourcifyDatabaseService.reapStaleJobs + RWStorageService optional method - VerificationService: setInterval reaper started in init(), cleared in close(), unref'd, overlap-guarded, errors caught and logged - config knobs REAPER_ENABLED / REAPER_INTERVAL_MS / REAPER_STALE_JOB_THRESHOLD_MS wired via cli.ts - new job_abandoned VerificationErrorCode + message + OpenAPI docs - unit tests for reaper wiring Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s; drop unused DEFAULT_OUTPUT_SELECTION (#2880) - Move COMPILER_TIMEOUT_CODE/COMPILER_OOM_CODE into @ethereum-sourcify/compilers-types (the boundary both packages already share) so common.ts and AbstractCompilation reference the same constant instead of duplicating the magic string. - Remove the now-unused DEFAULT_OUTPUT_SELECTION wildcard export (superseded by target-scoped selection); the fixture generator builds it inline from DEFAULT_OUTPUT_SELECTION_FIELDS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
a6c2b4a to
75bc3e1
Compare
…-process timer (#2880) Replace the in-process setInterval reaper with a pg_cron scheduled job, following the existing refresh-signature-stats pattern (graceful fallback when the extension is unavailable). pg_cron is already enabled in production, gives exactly-once execution, and keeps the reaping logic out of the app lifecycle. - New migration schedules 'reap-stale-verification-jobs' every 15 min (3h threshold). - Remove the in-process reaper wiring, REAPER_* env config, Database.reapStaleVerificationJobs, SourcifyDatabaseService.reapStaleJobs, the StorageService interface method, and the reaper unit tests. - Keep the partial index (verification_jobs_in_progress_idx) and the job_abandoned error code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
75bc3e1 to
78eefc5
Compare
…target (#2880) The assertVerificationExport helper hardcoded the old wildcard ('*':'*') outputSelection; update it to the target-scoped form now produced by SolidityCompilation.initSolidityJsonInput. Fixes the 7 failing verificationWorker assertions (verifyFromJsonInput / verifyFromMetadata / verifySimilarity). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#2880) Asserts SolidityCompilation.initSolidityJsonInput narrows a wildcard outputSelection down to { [target.path]: { [target.name]: DEFAULT_OUTPUT_SELECTION_FIELDS } }. Previously this core behavior was only covered indirectly via the server's verificationWorker tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| case "compiler_timeout": | ||
| return "The compiler timed out while compiling the contract. The compilation took too long and was aborted."; | ||
| case "compiler_out_of_memory": | ||
| return "The compiler process was killed unexpectedly, likely because it ran out of memory while compiling the contract."; |
There was a problem hiding this comment.
These cases are duplicated across SourcifyLibError and here. Remove the occurrence here.
| import { | ||
| COMPILER_TIMEOUT_CODE, | ||
| COMPILER_OOM_CODE, | ||
| } from '@ethereum-sourcify/compilers-types'; |
There was a problem hiding this comment.
@ethereum-sourcify/compilers-types is only a devDependency of this package (and of lib-sourcify, which gets the same value imports in AbstractCompilation.ts). That was fine while every import was import type (erased at build), but these are value imports, so the built JS now contains a real require('@ethereum-sourcify/compilers-types').
Both packages are published to npm, and npm doesn't install devDependencies of a dependency — external consumers will crash at load with Cannot find module '@ethereum-sourcify/compilers-types'. It only works in this repo because of workspace hoisting.
Please move @ethereum-sourcify/compilers-types to dependencies in both packages/compilers/package.json and packages/lib-sourcify/package.json.
| // Default wall-clock timeout for a single compiler invocation: 45 minutes. | ||
| // Overridable via SOLC_COMPILE_TIMEOUT_MS. A genuinely hung compiler must be | ||
| // killed so the verification job can fail instead of hanging forever (#2880). | ||
| const DEFAULT_COMPILE_TIMEOUT_MS = 2_700_000; |
There was a problem hiding this comment.
This timeout only covers asyncExec, i.e. the native-binary path. Two compile paths still have no wall-clock timeout:
- the soljson fallback (worker thread in
solidityCompiler.ts), used for versions below the native-binary thresholds - the Fe compiler (
spawnSync, which also blocks synchronously)
A hung compile there still wedges a Piscina worker until the 3h reaper marks the job abandoned — so #2792 is only partially closed by this PR. Probably acceptable given soljson is only used for old versions, but worth stating explicitly whether that's intentional (or noting it in #2792).
| const DEFAULT_COMPILE_TIMEOUT_MS = 2_700_000; | ||
|
|
||
| function getCompileTimeoutMs(): number { | ||
| const fromEnv = process.env.SOLC_COMPILE_TIMEOUT_MS; |
There was a problem hiding this comment.
This introduces the first functional env var in the published packages. Since #2058 removed process.env from lib-sourcify, all functional configuration in the packages flows explicitly through the API (solcRepoPath, vyperRepoPath, ...) — an ambient env read buried here in asyncExec breaks that convention, and library consumers won't discover it. (Browser compatibility isn't the concern here — compilers is inherently Node-only — just consistency of the config design.)
Consider making it an explicit timeoutMs parameter on useSolidityCompiler/useVyperCompiler, threaded down to asyncExec, and reading the env var in the server's config layer instead. The server already wraps these functions (SolcLocal/VyperLocal), so the parameter doesn't need to touch lib-sourcify. That would also solve two smaller issues with the current shape:
- the name:
SOLC_COMPILE_TIMEOUT_MSalso governs Vyper compiles sinceasyncExecis shared — the server config would own a compiler-agnostic name (e.g.COMPILER_TIMEOUT_MS) and document it next to the other settings (right now the variable isn't documented anywhere) - the tests:
common.spec.tscould passtimeoutMs: 150directly instead of mutating and restoringprocess.env
| // an uncaught exception in the Piscina worker thread and the compile promise | ||
| // never rejects -> the verification job hangs forever (#2880). Treat it as | ||
| // an unexpected process death (out of memory). | ||
| child.stdin.on('error', (err: NodeJS.ErrnoException) => { |
There was a problem hiding this comment.
Attributing every stdin 'error' to OOM can mislabel unrelated failures: any child that exits without reading its stdin — e.g. a missing/corrupt compiler binary (exit 127) — triggers EPIPE on a large input before the exec callback fires, so it surfaces as compiler_out_of_memory instead of the real spawn/exit error. That could mask e.g. a bad-binary incident as OOM.
An alternative that keeps the crash fix but improves attribution: don't settle here — just record that an stdin error happened (the listener alone prevents the uncaught exception). The exec callback always fires eventually and has better signals (killed, signal, exit code) to attribute the death; only reject from the recorded stdin error in the (practically impossible) case where the callback would otherwise resolve successfully.
| PERFORM cron.schedule( | ||
| 'reap-stale-verification-jobs', | ||
| '*/15 * * * *', | ||
| $job$ | ||
| UPDATE public.verification_jobs | ||
| SET completed_at = NOW(), | ||
| error_code = 'job_abandoned', | ||
| error_id = gen_random_uuid(), | ||
| verified_contract_id = NULL, | ||
| compilation_time = NULL | ||
| WHERE completed_at IS NULL | ||
| AND started_at < NOW() - INTERVAL '3 hours'; | ||
| $job$ | ||
| ); |
There was a problem hiding this comment.
Consider extracting the UPDATE into a database function and scheduling a one-line call to it:
CREATE FUNCTION public.reap_stale_verification_jobs(stale_threshold interval DEFAULT '3 hours')
RETURNS integer
LANGUAGE sql
AS $$
WITH reaped AS (
UPDATE public.verification_jobs
SET completed_at = NOW(),
error_code = 'job_abandoned',
error_id = gen_random_uuid(),
verified_contract_id = NULL,
compilation_time = NULL
WHERE completed_at IS NULL
AND started_at < NOW() - stale_threshold
RETURNING 1
)
SELECT count(*)::integer FROM reaped;
$$;
-- in the DO block:
PERFORM cron.schedule('reap-stale-verification-jobs', '*/15 * * * *',
'SELECT public.reap_stale_verification_jobs();');Benefits:
- Makes the reaper testable. The test DB (
postgres:15-alpine) has no pg_cron, so the schedule is skipped there and the reaper logic is currently untestable without duplicating the SQL. A plainCREATE FUNCTIONlands in the test DB through the normal migration path, so a server integration test can exercise the production reaper SQL verbatim. - The manual fallback becomes a single call returning the reaped count, instead of copy-pasting the UPDATE out of a migration file.
- Matches the existing pattern:
refresh-signature-statsschedules a one-liner; the logic stays out of the cron entry. Changing the threshold later is aCREATE OR REPLACE FUNCTIONinstead of unschedule + reschedule.
When addressing this, please also add the integration test: create a pending job (createMockJob-style in jobs.spec.ts), run SELECT reap_stale_verification_jobs('0 seconds'), then assert that GET /v2/verify/:id returns isJobCompleted: true with error.customCode: "job_abandoned", and that resubmitting the same chain/address no longer returns 429 duplicate_verification_request. This also locks in the subtlety that error_id must be set for the API to build the error object at all (SourcifyDatabaseService requires both error_code and error_id).
(The down migration would gain a DROP FUNCTION IF EXISTS, and the function needs to be in the regenerated sourcify-database.sql dump.)
Background
Fixes #2880. Nine v2 verification jobs on Avalanche C-Chain (43114) got stuck at
isJobCompleted: falsefor 7+ hours, each blocking resubmission for its address with429 duplicate_verification_request.Root cause: a verification worker's native
solcsubprocess was OOM-killed mid-compile. The immediate trigger is memory (see below), but the reason the job wedged is a bug inasyncExec(packages/compilers): it had no error handler on the child'sstdin, so a compiler killed mid-write of the standard-JSON input emittedEPIPEwith no listener → an uncaught exception in the Piscina worker thread → the compile promise never rejected. Socompleted_atwas never set. The v2 duplicate check is DB-based (completed_at IS NULL⇒ "in progress"), so the address stays locked — and because the lock is a DB row, a server restart doesn't clear it.Investigation against production found this is not Avalanche-specific: ~1,260 jobs are currently stuck with
completed_at IS NULL, the oldest ~2 months old, across many chains and both/v2/verifyand/v2/verify/similarity.Related previous issues
verificationId, with no way to force a fresh job.isJobCompleted: false.The OOM trigger
We set solc
outputSelectionto{"*":{"*":[…]}}— i.e. output every contract in every source file, with heavy artifacts (evm.legacyAssembly,evm.bytecode.generatedSources, source maps, storage layout, metadata). For a 119-source contract that's ~40 MB of output and ~800 MB of solc Resident Set Size (RSS) per compile; several running concurrently exhaust the container and it gets OOM-killed.Reproduced with the native linux-amd64 solc 0.8.29 on a real contract from the issue:
"*":"*"I recall the
"*":"*"(all contracts) was originally needed forextra-file-inputhandling, but that shouldn't be necessary for newer compilers — so we can likely turn it off, or only enable the broad selection for that specific case. For the common path we should be able to safely output only the target contract.Fixes (multi-step)
outputSelectionto the compilation target (lib-sourcify) — prevents the OOM at the source: ~20× smaller output, ~4× less solc RSS. Also shrinks storage/network payloads for every verification.asyncExecnow settles exactly once, rejects onstdinerror / write failure (so a compiler killed mid-write no longer hangs), and enforces a wall-clock timeout (SOLC_COMPILE_TIMEOUT_MS, default 45 min,SIGKILL). Failures are attributed with dedicatedcompiler_timeout/compiler_out_of_memorycodes. Covers Solidity and Vyper.completed_at IS NULLjobs older than 3 h as failed with a newjob_abandonedcode, releasing the address lock. Follows the existingrefresh-signature-statspg_cron pattern, with the same graceful fallback when the extension isn't available (pg_cron is already enabled in production). Backed by a partial indexverification_jobs (started_at) WHERE completed_at IS NULL— required because the table is ~77 M rows / ~50 GB with no timestamp index, turning the reaper query from a ~4.5-min full sequential scan into a sub-ms lookup (index builtCONCURRENTLY).Considered but deferred
ulimit -v(opt-inSOLC_MAX_ADDRESS_SPACE_KB, default-off) so solc dies as a cleancompiler_out_of_memorybefore the container OOM-killer targets the Node server. Deferred:ulimit -vcaps virtual (not resident) memory, needs ops tuning against real metrics, and risks false-failing legitimate large compiles. Straightforward to add later as an opt-in knob if the outputSelection reduction + timeout prove insufficient under load.Migration / deploy notes
CREATE INDEX CONCURRENTLYfor the partial index (non-transactional), and (2) thecron.schedulefor the reaper. The reaper relies on pg_cron being enabled (as it already is forrefresh-signature-stats); on a stack without it the schedule is skipped gracefully and jobs can be reaped by running the same UPDATE manually.services/database/sourcify-database.sqldump after applying.completed_at+error_code = 'internal_error'— recognized by the currently-deployed server — on jobs older than the threshold), releasing those locks immediately.Open question: forcing a fresh job (#2669)
Should we let a client force a fresh job instead of returning the same stuck
verificationId? IMO we shouldn't need this in the first place — an OOM-killed or otherwise dead process should never leave a job stuck (the reaper + timeouts above should guarantee that). Linking #2669 for discussion rather than committing to it.Related: #2670 (large-contract compilation timeout).
🤖 Generated with Claude Code