Skip to content

refactor(core): prefix chain-derived tables with core_ - #502

Open
rickyrombo wants to merge 1 commit into
mainfrom
refactor/core-table-prefix
Open

refactor(core): prefix chain-derived tables with core_#502
rickyrombo wants to merge 1 commit into
mainfrom
refactor/core-table-prefix

Conversation

@rickyrombo

Copy link
Copy Markdown
Contributor

Renames ten chain-derived tables to carry the core_ prefix. No behavior change; no data movement.

before after
access_keys core_access_keys
launchpad_authority_rm core_launchpad_authority_rm
management_keys core_management_keys
sla_node_reports core_sla_node_reports
sla_rollups core_sla_rollups
sound_recordings core_sound_recordings
storage_proof_peers core_storage_proof_peers
storage_proofs core_storage_proofs
track_releases core_track_releases
validator_history core_validator_history

Why: making the prefix load-bearing

core_ already means something specific in this schema — the table is derived from block execution, is therefore byte-identical on every validator, and must ship in the state-sync snapshot dumped by createPgDump in pkg/core/server/state_sync.go. These ten tables have always met that definition. They predate the convention and never got the prefix.

The gap is not cosmetic, because the snapshot table list is hand-maintained and the schema is not. Nothing connects "a migration added a chain-derived table" to "the table ships in snapshots" except whoever wrote the migration remembering. That has already failed twice:

  • 00028_fix_missing_tables_from_state_sync.sql exists for no other reason than to recreate core_ern / core_mead / core_pie / core_rewards / core_uploads on nodes that state-synced while those five were absent from the list.
  • core_auth_cids (added in 00038) was missed the same way and is being added to the list separately in fix(core): include core_auth_cids in state sync snapshots #499.

With the prefix exhaustive, that invariant becomes mechanically checkable: every core_* table in the schema appears in the snapshot list, or is explicitly exempted with a stated reason. That is the payoff. The guard test in #499 now naturally covers all ten of these tables — after this lands it does not need a hand-maintained addition per table, it just needs the prefix rule. #499 is not merged or duplicated here; it rebases on top of this.

Complete consumer list

The original framing of this change assumed the consumers were the sqlc queries and the snapshot list. They are not. The full set:

Schema

  • pkg/core/db/sql/migrations/00039_prefix_chain_derived_tables.sql (new)

sqlc queries and generated code — regenerated with make regen-sql, not hand-edited

  • pkg/core/db/sql/reads.sql, pkg/core/db/sql/writes.sql
  • pkg/core/db/models.go, reads.sql.go, writes.sql.go (generated; model structs rename SlaRollupCoreSlaRollup, etc. Query names and *Params/*Row struct names are unchanged, so call sites are unaffected except where a model type is named directly.)

State-sync snapshot list

  • pkg/core/server/state_sync.go — the tables slice in createPgDump

Go call sites naming a model type

  • pkg/core/console/uptime.go (db.SlaRollupdb.CoreSlaRollup)
  • pkg/core/server/auditor.go (db.SlaNodeReportdb.CoreSlaNodeReport)

Raw SQL outside sqlc — the risky ones, see below

  • pkg/mediorum/server/serve_blob.go (4 sites: 3 × .Raw, 1 × .Table)
  • pkg/mediorum/server/serve_blob_grpc.go (2 × .Raw)

Operational tooling that the original brief missed

  • cmd/rollback/main.go — hand-written DELETE FROM validator_history … / DELETE FROM sla_node_reports … in both the executing path (cleanPG) and the copy-pasteable SQL it prints to stderr (printPGCleanup). A miss here would have made the rollback tool silently skip two tables during an incident.
  • pkg/core/console/uptime.go — as above.

Tests that create these tables themselves

  • pkg/core/server/registration_consensus_test.go (DDL + FK + cleanup DROP TABLEs)
  • pkg/mediorum/server/serve_blob_test.go (DDL + fixture inserts + cleanup)

Deliberately not changed

  • pkg/core/db/sql/migrations/00006, 00013, 00014, 0001800020, 0002300025, 0003000032, 00034, 00036 — historical migrations, which run before 00039 and must keep referring to the pre-rename names.
  • cmd/genesis-writer/testdata/dp_schema.sql — a dump of the legacy Audius discovery-provider database, a different database with its own unrelated access_keys.
  • The separate API repo's access_keys / sla_rollups hits — different database (api_access_keys), unrelated.

The raw-SQL risk, and how it was addressed

Mediorum reads sound_recordings and management_keys through raw SQL string literals in the cidstream authorization path — .Raw("SELECT ... FROM management_keys ..."), .Table("sound_recordings"). These compile fine when wrong and fail at runtime, and none of them check the returned error: s.crud.DB.Raw(...).Scan(&trackID) discards res.Error. A missed literal does not throw — it yields a zero value, which in requireRegisteredSignature means "this track has no access authorities", which means a gated track silently becomes streamable by any registered validator signature. That is the failure mode this change had to rule out.

How the literals were found, and why the search is believed exhaustive:

  1. Content grep over the entire repo, word-boundary anchored on all ten names ((^|[^A-Za-z0-9_])name([^A-Za-z0-9_]|$), which excludes core_-prefixed matches). This is exhaustive by construction for literal occurrences: any Raw/Exec/Query/Table call, templ-generated _templ.go, .sql, YAML, shell, or docs containing one of these names as a whole word had to match, regardless of which API wrapped it. After the change the only remaining hits repo-wide are the historical migrations and the genesis-writer discovery-provider testdata listed above.
  2. Fragment grep for string concatenation, the one way a literal could evade (1) — every string literal containing sla_, sound_, management, storage_proof, track_release, validator_hist, access_key, or launchpad_. All surviving hits are unrelated: transaction-type constants ("sla_rollup", "storage_proof"), protobuf field tags, ETL materialized views (mv_sla_rollup*), Go struct JSON tags, and two singular row-level error strings in manage_entity.go. No table name is assembled at runtime anywhere.
  3. Enumerated every raw-SQL surface by API rather than by name, as a cross-check on (1): all 16 .Raw( sites and all 3 .Table( sites in the repo were read individually. The ones not touched here query ops, cursors, uploads, pg_class — mediorum's own tables. There are no TableName() methods and no ON CONFLICT ON CONSTRAINT anywhere. crudStatusTables in serve_crud.go, the only place a table-name list is passed as data, contains mediorum tables only.
  4. Regenerated rather than trusted the sqlc output (cd pkg/core/db && sqlc generate); the result is byte-identical to what is committed here.

What the tests actually prove, and what they do not. TestRequireRegisteredSignatureWithLowercaseAccessAuthority and TestRequireRegisteredSignatureWithUnrelatedAccessAuthority in pkg/mediorum/server/serve_blob_test.go do execute the real requireRegisteredSignature handler against a real Postgres with the memo cache cleared, so they genuinely run three of the six renamed literals — serve_blob.go:494, :496, and :517.

This was confirmed with a negative control rather than assumed. Reverting the single literal at serve_blob.go:494 back to sound_recordings and re-running turns both tests red:

--- FAIL: TestRequireRegisteredSignatureWithLowercaseAccessAuthority (0.02s)
--- FAIL: TestRequireRegisteredSignatureWithUnrelatedAccessAuthority (0.00s)
    "…\"error\": \"signer not in list of registered nodes\"…"
      does not contain "signer not authorized for this track (access_authorities)"

Note what that observed failure output is: the handler found no track for the cid, treated the gated track as ungated, and fell through to the registered-node signature path. That is the silent authorization bypass described above, reproduced. The literal was restored immediately after.

Three literals have no test coverage and were verified by inspection only:

  • serve_blob.go:56.Table("core_sound_recordings") in InvalidateTrackAccessCacheForTrack, which is wrapped in recover() and logs at Debug.
  • serve_blob.go:704/:711 — the serveTrack handler.
  • serve_blob_grpc.go:42/:49 — the streamTrackGRPC handler.

There is also a limit on what those tests can ever prove: the fixture creates its own tables, so it only catches Go-literal/test disagreement, not Go-literal/migration disagreement. If the migration and the Go code drifted apart in the same direction, the unit tests would still pass. The repo-wide grep is what covers that, not the test suite.

Index and constraint naming: deliberately unchanged

ALTER TABLE … RENAME TO does not rename that table's indexes, constraints, or sequences. Applying the full migration chain to a fresh database and reading pg_indexes back confirms exactly what survives:

core_sla_rollups       idx_time, idx_sla_rollups_block_end, sla_rollups_pkey
core_sla_node_reports  idx_sla_node_reports_rollup_address,
                       sla_node_reports_address_sla_rollup_id_key,
                       sla_node_reports_pkey
core_sound_recordings  idx_sound_recordings_cid, idx_sound_recordings_track_id,
                       sound_recordings_pkey

…plus idx_storage_proofs_block_height on core_storage_proofs, idx_track_releases_track_id, idx_management_keys_track_id, and the serial sequences (sla_rollups_id_seq and friends).

This was left alone on purpose:

  • Nothing references them. No Go code, no sqlc query, and no migration names an index, constraint, or sequence belonging to these ten tables — verified by grep, including for ON CONFLICT ON CONSTRAINT. Renaming buys zero safety, and PostgreSQL tracks these dependencies by OID rather than by name, so the stale names stay fully functional.
  • A complete rename is not achievable by reading this repo. Some names are implicit and appear in no migration file — every *_pkey, and sla_node_reports_address_sla_rollup_id_key, generated from the unnamed unique (address, sla_rollup_id) in 00006. That name above was recovered from a live catalog, not from the source; renaming them properly would mean doing that for every database.
  • A half-renamed set is worse than a uniformly stale one, because it reads as intentional and hides whatever was missed.

So this is stated explicitly here and in the migration's own comment: if you are looking at pg_indexes and wondering why core_sla_rollups has an index called idx_time, the rename was not left unfinished — index, constraint, and sequence names were out of scope. Old names remain fully functional; PostgreSQL tracks these dependencies by OID, not by name.

Migration cost

ALTER TABLE … RENAME TO is catalog-only: it rewrites one row in pg_class and moves no data. That matters because these migrations run at node startup against production databases in the hundreds of gigabytes, where anything that rewrote a heap would mean a long outage. Ten renames in one transaction take an ACCESS EXCLUSIVE lock for the duration, which is milliseconds — but note it is still an ACCESS EXCLUSIVE lock, so it will queue behind any long-running transaction holding a conflicting lock on these tables.

Cross-version state-sync hazard — read before deploying

This rename must not straddle a state-sync window. During a partial rollout, a new-code node's snapshot contains core_sound_recordings while an old-code node's contains sound_recordings. Both directions break, and they break differently.

The mechanism is that RestoreDatabase never drops tables. It runs pg_restore --section=pre-data with errors ignored (_ = pgRestore("pre-data")), then TRUNCATE … CASCADE over every table in public, then loads the data section. So a snapshot whose tables are named differently from the local schema does not fail — pre-data simply creates the foreign-named tables alongside the local ones, TRUNCATE empties the local ones, and the data lands in the tables the running binary does not read.

Old-code node state-syncing from a new-code peer. The old node's migrations created sound_recordings et al. The snapshot creates and populates core_sound_recordings et al. TRUNCATE empties everything first. The old binary then queries the unprefixed names and gets ten empty tables: no SLA rollups, no node reports, no validator history, no storage proofs, no management keys, no sound recordings.

New-code node state-syncing from an old-code peer. Exact mirror. The snapshot's unprefixed tables are created and populated; the new binary reads the prefixed ones, which are empty.

Consequences in both directions:

  1. Consensus divergence. These tables feed FinalizeBlockcreateRollup in auditor.go reads core_sla_node_reports to build the next SLA rollup, and the storage-proof and validator-history paths do the same. A validator computing over empty inputs produces a different app hash from its peers. This is precisely the class of failure 00028 was written to repair.

  2. Authorization bypass. Empty core_management_keys / core_sound_recordings means requireRegisteredSignature finds no track for a cid, treats the track as ungated, and falls through to the registered-validator-signature path. Gated tracks become streamable by any registered signer on that node.

  3. The node will not restart. core_db_migrations is itself in the snapshot list, so the restore replaces the local migration bookkeeping with the peer's. The repo uses sql-migrate with the default IgnoreUnknown=false.

    • Old code, new snapshot: core_db_migrations now records 00039, which the old binary's embedded migration set does not contain → unknown migration in databaseerror running migrations → boot fails.
    • New code, old snapshot: core_db_migrations no longer records 00039, so it is replanned and re-runs alter table access_keys rename to core_access_keys — but core_access_keys already exists locally (created by the pre-restore migration run, then truncated) → relation already exists → boot fails.

    So the node runs on silently-wrong state until it is restarted, and then refuses to start. Recovery is manual in both cases.

Deploy guidance. Do not let a node state-sync while the fleet is mixed-version. Either complete the rollout before any node state-syncs, or gate state sync off for the duration. Note that the hazard is not created by this PR — any change to the snapshot table set has it, including #499 — but this PR changes ten entries at once, which makes the mixed-version window unusually wide.

Verification

  • go build ./... — clean.
  • go vet ./pkg/core/... ./pkg/mediorum/... — byte-identical to the pre-change baseline (captured by stashing the change); no new findings.
  • gofmt -l on all touched files — clean. (The repo has 17 pre-existing unformatted files; none are touched here.)
  • sqlc generate reproduces the committed generated files byte-for-byte — the generated .sql.go files were regenerated, not hand-edited.
  • make test-mediorum — pass, plus the negative control above.
  • make test-unit — pass.
  • The full migration chain applied to a fresh PostgreSQL 16 database: all ten tables present under the new names, none present under the old, 00039's Down section verified to restore all ten. Worth noting for reviewers that the DB-backed tests in pkg/core/server (registration_consensus_test.go, validator_state_test.go) skip in the make test-unit harness because TEST_DB_URL is unset there; they were run separately against a real Postgres for this change and pass.

🤖 Generated with Claude Code

Rename access_keys, launchpad_authority_rm, management_keys,
sla_node_reports, sla_rollups, sound_recordings, storage_proof_peers,
storage_proofs, track_releases and validator_history to carry the core_
prefix.

core_ already means "derived from block execution, identical on every
validator, must appear in the state-sync snapshot". These ten met that
definition without the prefix, and because the snapshot list in
createPgDump is hand-maintained while the schema is not, the ambiguity
has caused the same bug twice: 00028_fix_missing_tables_from_state_sync
exists solely to repair nodes that state-synced while five core_ tables
were absent from the list, and core_auth_cids was missed the same way
(#499). With the prefix exhaustive, a guard test can assert the
invariant by prefix instead of by a list someone must remember to
update.

The rename is catalog-only (ALTER TABLE ... RENAME TO), which matters
against production databases in the hundreds of gigabytes. Indexes,
constraints and sequences intentionally keep their old names; nothing
references them by name and a half-renamed set would hide whatever was
missed. See the migration comment.

Consumers updated beyond the sqlc queries and the snapshot list:
cmd/rollback (hand-written DELETEs, both executed and printed),
pkg/core/console, and six raw-SQL string literals in mediorum's
cidstream authorization path, where a missed literal would not fail
loudly but would silently ungate a gated track.

Generated sqlc files were regenerated, not hand-edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant