feat: store metadata once per compilation in compiled_contracts_metadata - #2941
feat: store metadata once per compilation in compiled_contracts_metadata#2941marcocastignoli wants to merge 6 commits into
Conversation
|
While I review this, can you add release todos according to #2945 |
kuzdogan
left a comment
There was a problem hiding this comment.
The core change looks right: the dual-write sends the same object to sourcify_matches.metadata and the side table, the migration only creates a new table, the schema dump is a real dbmate regeneration, and the backfill query uses the existing indexes. Requesting changes for the items below; the rest are nits.
Should be addressed
replace-metadata(customReplaceMethods.ts) only updatessourcify_matches.metadata; nothing can ever overwrite acompiled_contracts_metadatarow, so repairs made before the read switch are lost. Needs aDO UPDATEthere, or a listed prerequisite of the read-switch PR.--verifycompares a count of current matches againstcount(*)of the side table, which also holds rows for compilations no current match points to any more, so it can hide gaps. ANOT EXISTSgap count is exact (query in the inline comment).services/database/README.md"Available upgrade scripts" needs a row for the new script (suggested text inline).CLAUDE.mdshould say PostgreSQL 15 (what CI uses), not 16.
Nits (inline): uncaught errors in the script (program.parseAsync().catch), slow re-runs (NOT EXISTS in batch_metadata), ETA on resume, metadata insert when the compilation already exists, metadata?: Metadata instead of as any, ON CONFLICT style, redundant query in assertContractSaved, new table in the CLAUDE.md join list.
Posted with Claude Code
|
|
||
| // Temporary dual-write: reads stay on sourcify_matches.metadata until | ||
| // the backfill completes, then the column gets dropped (issue #2924) | ||
| await this.database.insertCompiledContractMetadata(poolClient, { |
There was a problem hiding this comment.
replace-metadata never updates the side table
The private replace-metadata method in customReplaceMethods.ts (the repair tool for contracts with wrong stored metadata, #2227) runs only:
UPDATE sourcify_matches SET metadata = $2 WHERE id = $1It does not touch compiled_contracts_metadata, and this insert is ON CONFLICT DO NOTHING, so nothing can ever overwrite a side-table row. After the read switch and the column drop, every repair made in the meantime is silently lost.
Suggest that replaceMetadata also upserts (DO UPDATE) the side-table row for the match's compilation_id — here, or as a listed prerequisite of the read-switch PR.
Posted with Claude Code
|
|
||
| // Temporary dual-write: reads stay on sourcify_matches.metadata until | ||
| // the backfill completes, then the column gets dropped (issue #2924) | ||
| await this.database.insertCompiledContractMetadata(poolClient, { |
There was a problem hiding this comment.
Wasted work when the compilation already exists
This runs for every stored verification. When the compilation was already in compiled_contracts (byte-identical contract verified before), the full metadata JSON is sent to Postgres, parsed, and then discarded by DO NOTHING.
The base class already computes isNewCompilation (it uses it to skip re-inserting sources). Returning that flag from insertNewVerifiedContract / updateExistingVerifiedContract and only inserting when it is true avoids that. The backfill covers pre-migration compilations anyway.
Posted with Claude Code
| (SELECT count(DISTINCT vc.compilation_id) | ||
| FROM ${schema}.verified_contracts vc | ||
| JOIN ${schema}.sourcify_matches sm ON sm.verified_contract_id = vc.id | ||
| WHERE sm.metadata IS NOT NULL) AS expected_rows, |
There was a problem hiding this comment.
--verify can under-report missing rows
expected_rows counts distinct compilations with a current match that has metadata. actual_rows is count(*) of the side table. But the side table also holds rows for compilations that no current match points to any more:
- A contract is verified with compilation X after deploy; the dual-write inserts a row for X.
- A better match with a different compilation Y replaces it. The old
verified_contractsrow for X stays, but nosourcify_matchesrow points to it. expected_rowscounts Y only;actual_rowscounts X and Y.
So surplus rows offset real gaps, and missingRows can even go negative. Counting the gap directly is exact and should be 0:
SELECT count(DISTINCT vc.compilation_id)
FROM verified_contracts vc
JOIN sourcify_matches sm ON sm.verified_contract_id = vc.id
WHERE sm.metadata IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM compiled_contracts_metadata m WHERE m.compilation_id = vc.compilation_id)Posted with Claude Code
| @@ -0,0 +1,310 @@ | |||
| /** | |||
| * Backfills compiled_contracts_metadata from sourcify_matches.metadata. | |||
There was a problem hiding this comment.
Missing row in the README's "Available upgrade scripts" table
services/database/README.md tells operators to check that table when they upgrade a live database. This script has no row there. Suggested:
| Script | When to run | What it does |
|---|---|---|
backfill-compiled-contracts-metadata.mjs |
After applying the migration that adds compiled_contracts_metadata, on a server that already dual-writes to it. Run --verify before switching reads to the table. |
Backfills compiled_contracts_metadata from sourcify_matches.metadata, one row per compilation (first current match by verified_contracts.id). |
Posted with Claude Code
| await closePool(); | ||
| }); | ||
|
|
||
| program.parse(); |
There was a problem hiding this comment.
Errors are not caught
program.parse() does not wait for the async action, so a failed query (a bad --start-id failing the ::uuid cast, a dropped connection, a permission error) is an unhandled rejection: raw stack trace, the logger is bypassed, and closePool() is skipped.
post-v2.12-to-v2.13-upgrade.mjs does it right:
program.parseAsync().catch(async (err) => {
logger.error(...);
await closePool();
process.exit(1);
});Posted with Claude Code
|
|
||
| ```bash | ||
| # throwaway postgres (matches the version the dump was generated with) | ||
| docker run -d --name schema-regen -e POSTGRES_PASSWORD=password -e POSTGRES_USER=postgres -e POSTGRES_DB=test_db -p 127.0.0.1:55432:5432 postgres:16 |
There was a problem hiding this comment.
Should be PostgreSQL 15, not 16
CI's validate-database-schema job runs postgres:15-alpine with postgresql-client-15, and the README says 15.13. The dump's version header is a comment line that CI strips, so 15 passes; there is no reason to require 16 here.
Suggest postgres:15 in the docker run and postgresql-client-15 in the sentence below, wording it as "pg_dump 15, to match CI". Keep the "not 17" warning (SET transaction_timeout), that part is correct.
Optional: this recipe could live in services/database/README.md under "Adding new migrations", which already describes regeneration and which CLAUDE.md links to above.
Posted with Claude Code
|
|
||
| How the tables join: | ||
|
|
||
| - `sourcify_matches.verified_contract_id` → `verified_contracts.id` (Sourcify-specific match info: `creation_match`/`runtime_match` quality, `chain_id`, and the contract's `metadata`) |
There was a problem hiding this comment.
Nit: the "How the tables join" list should mention the new table, e.g. compiled_contracts_metadata.compilation_id → compiled_contracts.id (one metadata blob per compilation; sourcify_matches.metadata is kept only until the read switch).
Posted with Claude Code
|
|
||
| export interface CompiledContractMetadata { | ||
| compilation_id: string; | ||
| metadata: Metadata; |
There was a problem hiding this comment.
Nit: metadata is declared required, but insertCompiledContractMetadata guards if (!metadata) return (Vyper has none) and the call site uses as any to get undefined past the type checker. metadata?: Metadata makes the type match reality and removes the cast. (The same as any exists on the sourcify_matches insert, so this is inherited.)
Posted with Claude Code
| compilation_id, | ||
| metadata | ||
| ) VALUES ($1, $2) | ||
| ON CONFLICT (compilation_id) DO NOTHING`, |
There was a problem hiding this comment.
Nit: every other insert in this file uses ON CONFLICT ON CONSTRAINT <name> DO NOTHING; this is the only ON CONFLICT (column) form. Same behavior, just inconsistent — ON CONFLICT ON CONSTRAINT compiled_contracts_metadata_pkey DO NOTHING.
Posted with Claude Code
| FROM sourcify_matches sm | ||
| JOIN verified_contracts vc ON vc.id = sm.verified_contract_id | ||
| JOIN contract_deployments cd ON cd.id = vc.deployment_id | ||
| JOIN compiled_contracts_metadata ccm ON ccm.compilation_id = vc.compilation_id |
There was a problem hiding this comment.
Nit: this repeats the three-table join of the query right above it. A LEFT JOIN compiled_contracts_metadata ccm ON ccm.compilation_id = vc.compilation_id on that query, selecting ccm.metadata AS compilation_metadata, does the same job with one query (assert non-null instead of length(1)).
Posted with Claude Code
Compiler metadata describes a compilation, not a deployment, yet it is stored once per verified contract in sourcify_matches.metadata (~139 GB of duplicates). The new side table holds one metadata blob per compilation, following the same pattern as compiled_contracts_runtime_code_prefixes. The migration only creates the table. Existing compilations are backfilled out-of-band by the batched, idempotent and resumable backfill-compiled-contracts-metadata.mjs script, same approach as the runtime code prefixes backfill. The column stays json (not jsonb) on purpose: json preserves the exact stored text, so the blob keeps hashing to the metadata hash embedded in the onchain bytecode. See #2924 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every stored verification now also writes its compilation's metadata to the compiled_contracts_metadata side table (first submitter wins via ON CONFLICT DO NOTHING, consistent with compiled_contracts_sources). sourcify_matches.metadata keeps being written and remains the read source; reads switch over in a follow-up once the backfill of pre-existing compilations has completed in production. The write lives in SourcifyDatabaseService, not AbstractDatabaseService, because the side table is Sourcify-specific and must not be written by AllianceDatabaseService. See #2924 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hand-edited sourcify-database.sql had the new table's PK and FK constraints in the wrong position: pg_dump orders constraints by constraint name, not by table name. Regenerated with dbmate against a clean postgres 16, matching what the validate-database-schema CI job produces. Also fix two check:tsc errors in the metadata dedup spec: compilation.metadata is typed as possibly undefined. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documents the validate-database-schema CI check and the local regeneration procedure, including the pg_dump version requirement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9b4a5ac to
e6eac37
Compare
Summary
First of two steps for #2924 (option B: a Sourcify-specific side table). Based on #2929 by @peterlodri-sec — same table shape and first-submitter-wins semantics, restructured for a zero-downtime rollout.
compiled_contracts_metadata(compilation_id uuid PK → compiled_contracts,metadata json NOT NULL) and nothing else — no in-migration backfill.services/database/schema-updates/backfill-compiled-contracts-metadata.mjs: batched with a keyset cursor, throttled, idempotent (ON CONFLICT DO NOTHING), resumable, with--dry-runand a--verifycompleteness check. Same pattern as the runtime-code-prefixes backfill from Fix similarity API database load and timeouts #2918 and thechain_idbackfill from/v2/contracts/{chainId}endpoint hangs beyond certainmatchId#2111. The variant picked per compilation is deterministic (lowestverified_contracts.id, i.e. first submitter).sourcify_matches.metadatakeeps being written and remains the only read source — no read path changes in this PR, so nothing depends on the backfill being complete.Rollout
--verify.compiled_contracts_metadatato the BigQuery Datastream mirror — the prod stream's include list is an explicit table allowlist, plus thesourcify_publicationpublication on the Postgres side, then an object backfill. Must happen before step 5: BigQuery'spublic_sourcify_matchescurrently mirrors themetadatacolumn, so dropping it without mirroring the new table would silently remove metadata from BigQuery.STORED_PROPERTIES_TO_SELECTORS,getCompilationsByIds's LATERAL detour) to the side table and stop writingsourcify_matches.metadata.pg_repack.Staging reads through the whole window means no
COALESCEfallback ever ships.Notes
json, notjsonb:jsonpreserves the exact stored text, so the blob keeps hashing to the metadata hash embedded in the onchain bytecode (this property is asserted by the existing test inassertContractSaved). Thejsonequality limitation doesn't bite here because grouping happens on the table's primary key.SourcifyDatabaseService, notAbstractDatabaseService, because the side table is Sourcify-specific andAllianceDatabaseServiceshares the abstract insert path (the Verifier Alliance DB has no such table).assertContractSavednow also checks the side table, so all existing integration verification tests cover the dual-write; dedicated tests cover first-submitter-wins and the no-metadata (Vyper) case.TODO
sourcify-prod-pg-bq-stream-sourcify-matchesdatastream in the GCP console and tickcompiled_contracts_metadata;Closes #2929
🤖 Generated with Claude Code