fix(etl): index genesis-migration entities without data loss - #425
Conversation
The ETL rejected almost everything genesis-writer replays. Indexing a real
migration produced 41,809 rejections and zero rows in users, tracks,
playlists, follows, saves and reposts.
Migration transactions were converted into a plain ManageEntityLegacy and
dispatched through the production handlers, so the validations that guard
*newly submitted* entities were applied to replayed historical state:
- legacy ID offsets (user 3M / track 2M / playlist 400k) rejected 65% of
users, 54% of tracks and 45% of playlists, whose ids are below them by
definition
- content limits and reserved handles rejected ~310 users and 48
tracks/playlists that predate those rules
- the wallet_signature / dashboard-wallet ecrecover proofs rejected all
28,927 associated wallets and all 298 dashboard wallet users; those
signatures are interactive artifacts the source tables do not retain
Rather than teach every handler about migration, derive the migration
handler set from the production one and replace only the handlers whose
*validation policy* differs (Dispatcher.Clone + RegisterMigrationOverrides).
The migration handlers reuse the production insert functions verbatim, so
there is one implementation of how an entity is written and the two paths
cannot drift. Production validators are unchanged and contain no migration
conditionals; all policy lives in entity_manager/migration.go.
Also fixes silent state loss in both directions:
- insertUser hardcoded is_verified/is_deactivated/is_available, so 3,181
verified users were being unverified. State now travels in metadata and
only the migration handler may set it, so a new account still cannot
self-assign verification.
- genesis-writer filtered out deactivated (61,528) and unavailable (1,416)
users while still migrating their content, orphaning 65,634 tracks and
9,774 playlists that the ETL then rejected as "user does not exist". All
current rows are now migrated with their state, including soft-deleted
tracks, playlists and social rows, so a parity check can distinguish an
intentional omission from real data loss.
- associated wallets are signed as the owning user rather than the wallet
being linked. The indexer takes the wallet from metadata and uses the
signer only to authorize against the user, so signer authority is still
enforced instead of bypassed.
State flags are serialized without `omitempty`: it drops false values, and
the indexer cannot distinguish absent from false (is_available defaults true).
Unrelated to the above, two genesis-writer robustness fixes found while
running this end to end: raise maintenance_work_mem to 2GB for the
post-load index rebuild (1GB fails with "invalid memory alloc request size"
on a 54M-row core_transactions) and disable synchronous_commit on the write
path so an external --dst-dsn is not slowed by a per-block fsync.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The parity tool covered 13 domain tables but not plays, so a genesis
migration could lose or corrupt play history without the comparison
noticing.
Plays cannot use the existing strategy — loading every ETL row and issuing
one source lookup per row — because there are ~40M of them. Instead:
- total row counts, which catches wholesale loss;
- a full merge-walk of aggregate_plays (~1.7M rows, read once per side with
no per-row queries). Because it holds the per-track play count, it covers
all 40M plays in aggregate: a dropped or misattributed subset shows up
here even though no individual play was inspected;
- a bounded random sample (default 2000, --plays-sample) compared field by
field, to catch corruption that preserves counts. TABLESAMPLE keeps this
cheap where ORDER BY random() would not.
Only the fields genesis-writer carries are compared: user_id, play_item_id,
created_at, city, region, country. source, slot and signature are not part of
the migration payload and would otherwise report as false differences.
Two properties of the data the comparison has to respect: user_id is NULL for
anonymous plays (~47% of rows), so sample lookups match with IS NOT DISTINCT
FROM; and (user_id, play_item_id, created_at) is not unique — the source has
~1.8M duplicate triples — so the lookup compares occurrence counts rather
than assuming a single row.
Off by default; enable with --plays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Added a second commit extending the parity tool to cover plays, which the 13-table comparison didn't touch — so a migration could lose or corrupt play history without parity noticing. Plays can't use the existing strategy (load every ETL row, one source lookup per row) at ~40M rows, so it uses three layers instead:
Two data properties the comparison has to respect, both of which would otherwise produce false results:
Only the fields genesis-writer actually carries are compared ( Off by default ( A full end-to-end validation run is in progress against the 2026-06-30 snapshot: regenerating the genesis with these writer fixes, then booting a node from that output and indexing it with the fixed ETL, then running parity. I'll post the results here. |
Every entity query joins `users` to get the owner's wallet for the signer. The source contains 5 user_ids with more than one is_current row (a legacy indexer artifact — the pairs are identical apart from blocknumber), so those joins fan out and emit a duplicate Create for each of those users' entities. Measured on the 2026-06-30 snapshot, the writer emitted 18 extra tracks, 6 playlists, 3,893 saves and 787 follows — exactly the row counts those 5 users own. The duplicates are harmless downstream, because the indexer rejects the second Create as "already exists", but they inflate the transaction count and produce thousands of rejections that mask real ones in the logs. Join a wallet lookup that yields at most one row per user instead. Verified against the snapshot: the deduplicated join returns exactly the source is_current counts (tracks 1955896, saves 10236225) where the previous join returned 1955914 and 10240118. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
insertTrackAndRoute raised "title is required for track creation" from the insert path, so the genesis migration dropped tracks with an empty title even though the migration handler skips the create validator. Five live tracks in the 2026-06-30 snapshot have an empty title. A required title is a validation rule, not a property of writing the row, so move the check into validateTrackCreate. Production behaviour is unchanged — the validator runs before the insert on that path — while the migration keeps the row. Title only feeds the route id and slug, and slug generation already resolves collisions, so an empty legacy title degrades the route rather than losing the track. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first version of this comparison was written against assumptions rather
than the schema, and would have failed at runtime:
- it queried `plays` on the ETL side, but the ETL writes `etl_plays`;
- it expected `play_item_id` / `created_at`, but etl_plays has `track_id` /
`played_at`, and its ids are text rather than integers;
- it compared `aggregate_plays` on both sides, but that table does not exist
in an ETL database at all — pkg/etl migration 0017 is a no-op stub, and the
aggregate tables are owned by the consumer and maintained by triggers
there.
Compare etl_plays against the source plays table with the ids cast to bigint
on both sides, so the merge-walk orders numerically instead of
lexicographically ('10' < '9'). Per-track counts are now computed from the
source plays table directly rather than from its aggregate_plays rollup:
comparing against a trigger-maintained rollup would attribute any drift in
that rollup to the migration.
Verified the queries against the live databases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The managed instance hardcoded shared_buffers to 256MB, which is far too small for what a genesis migration writes: a real run against the 2026-06-30 snapshot produced a 104GB database, so postgres was fetching index pages from disk on nearly every insert. Measured on that run with 256MB: a 71% buffer cache hit ratio and ~6.5TB read back off the disk, with the indexer at 27% CPU and postgres at 14% — both idle and blocked on IO/DataFileRead. Raising shared_buffers to 8GB on the same machine took the hit ratio to 96.9% and indexing from 38.9 to 55.7 blocks/h (1.43x) while the cache was still warming. Size it from system memory instead: a quarter of RAM for shared_buffers, clamped to [256MB, 8GB] so a small machine still works and a large one does not reserve an absurd amount, plus a matching effective_cache_size so the planner knows what is actually cached. Also raise maintenance_work_mem to 2GB, matching the value the post-load index rebuild already needs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0d8943b to
7338103
Compare
PlayCount/Reconcile writes to aggregate_plays, but that table is owned by the consumer rather than by pkg/etl — migration 0017 is a no-op stub precisely because those derived tables are maintained downstream via triggers. So the table is present in some databases and missing in others. Where it is missing the handler did not merely lose the rollup update: an undefined relation is a plain pgx error, not a ValidationError, so it took the non-validation branch, rolled back the savepoint and failed the whole block. The indexer would then retry the same block indefinitely. A genesis migration emits ~1.4M Reconcile transactions, so a real run stalls outright rather than degrading. Probe for the table once per process with to_regclass — which returns NULL instead of raising, so it is safe inside the caller's transaction — and skip the delta when it is absent. The play rows themselves are still indexed; only the rollup is dropped. A per-transaction catalog lookup would be wasteful across 1.4M transactions, and the table is not expected to appear mid-run. A malformed Reconcile is still a ValidationError, so genuinely bad input is not silently swallowed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The node always ran the ETL with DefaultConfig, so the materialized view
refresher, the scheduled-release publisher and the pg_notify listener were
always on with no way to turn them off.
That matters while catching up on a large backlog. Sampling pg_stat_activity
during a genesis migration, REFRESH MATERIALIZED VIEW accounted for 60% of all
active database samples — more than the indexing statements themselves. The
refresher rebuilds mv_dashboard_transaction_stats and _types every two minutes
by aggregating the whole of etl_transactions: 8.7s per view against 32.7M rows
and rising with the table, to produce views that are 32KB and 40KB of dashboard
analytics nothing reads during a replay. Because the cost grows with the table,
it also makes indexing progressively slower over a long run.
Add ReadBackgroundJobsEnv so each job can be switched off independently:
OPENAUDIO_ETL_MV_REFRESH_ENABLED=false
OPENAUDIO_ETL_SCHEDULED_RELEASES_ENABLED=false
OPENAUDIO_ETL_PG_NOTIFY_ENABLED=false
All default to enabled and only the literal "false" disables one, so existing
deployments are unaffected — matching the opt-out shape of the existing
ReadDataTypesEnv.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0030 added a current-row unique arbiter for reposts/saves/follows/
subscriptions on the grounds that "only one current row per identity is an
existing invariant". users holds the same invariant — Create inserts a
single is_current row and Update mutates it in place — but was left out,
and it is the one table where the invariant has actually been violated.
users_pkey is (user_id, txhash), so a second Create for a user that already
exists inserts alongside rather than conflicting; nothing validates that a
user is new. Five users picked up a duplicate that way on a production
clone, spread from 2025-06 to 2026-06.
Five rows, but the blast radius is not five rows. Every join from an entity
to its owner's wallet fans out: measured against the clone, the plain joins
return 18 extra tracks and 787 extra follows. That is why genesis-writer
carried a DISTINCT ON subquery in all fifteen of its joins — 15 sorts of
3.15M rows, 15.3s and a 191MB external merge each.
So make the invariant real rather than coding around it:
* 0035 deletes the duplicate rows (highest blocknumber wins, matching how
consumers pick the live row — it also keeps is_deactivated = true for
user 666149592) and adds users_current_uniq_idx. Deleted rather than
demoted because users keeps no versioned history: the in-place writes
mean the clone has zero is_current = false rows, so demoting would
invent a category of row nothing reads.
* user_create gets ON CONFLICT DO NOTHING so a second Create is a no-op
instead of violating the new index, mirroring what 0030 did for the
social inserts.
* genesis-writer's fifteen subqueries collapse to plain joins. Verified
against the clone: before the backfill the two forms differ by exactly
the fan-out above, after it they are identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4bd2791 to
8e55dc8
Compare
#425 justified the clause with the claim that nothing validates that a user is new. That is wrong: validateUserCreate has called userExists since #148, and migratedUserCreateHandler does the same on the genesis replay path. The clause is still needed, for a different reason. userExists followed by INSERT is check-then-act, and that is atomic only within one transaction. A single writer therefore cannot reach the conflict, but a second writer can, by passing its own check before this insert commits — which is the most plausible account of the five duplicate current rows 0035 cleans up, three of which pair a bare-hex txhash with a 0x-prefixed one. users_current_uniq_idx is what closes that race. DO NOTHING decides what the loser does about it, and no-op is both correct (the row it would write already exists) and better than the alternative: a hard error here rolls back the savepoint and drops the tx along with its audit row, which is silent apart from a log line. Comments only — no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…429) #425 justified the clause with the claim that nothing validates that a user is new. That is wrong: validateUserCreate has called userExists since #148, and migratedUserCreateHandler does the same on the genesis replay path. The clause is still needed, for a different reason. userExists followed by INSERT is check-then-act, and that is atomic only within one transaction. A single writer therefore cannot reach the conflict, but a second writer can, by passing its own check before this insert commits — which is the most plausible account of the five duplicate current rows 0035 cleans up, three of which pair a bare-hex txhash with a 0x-prefixed one. users_current_uniq_idx is what closes that race. DO NOTHING decides what the loser does about it, and no-op is both correct (the row it would write already exists) and better than the alternative: a hard error here rolls back the savepoint and drops the tx along with its audit row, which is silent apart from a log line. Comments only — no behaviour change. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Problem
The ETL rejects almost everything
genesis-writerreplays. Indexing a real migration (54.3M txs from a 2026-06-30 prod snapshot) produced 41,809 rejections and zero rows inusers,tracks,playlists,follows,saves,reposts:indexer.goconvertedManageEntityLegacyMigrationinto a plainManageEntityLegacyand dispatched it through the production handlers, so validations meant for newly submitted entities were applied to replayed historical state:wallet_signature/ dashboard ecrecover proofsThis contradicts
cmd/genesis-writer/README.md, which states the distinct migration proto type exists so indexers verify only the migration authority.Approach: don't teach the handlers about migration
Dispatcher.Clone()derives the migration handler set from the production one;RegisterMigrationOverridesreplaces only the handlers whose validation policy differs. Every other entity keeps production behavior automatically, so the two registration lists cannot drift.The migration handlers reuse the production insert functions verbatim (
insertUser,insertTrackAndRoute,insertPlaylistAndRoute,insertAssociatedWallet,insertFollow, …) — one implementation of how an entity is written, so route generation, stems, aggregates and triggers stay shared.Production validators are unchanged and contain zero migration conditionals. All policy lives in
entity_manager/migration.go, which documents what each override drops and why. Production-side changes are extract-function refactors only.Also fixes silent state loss
insertUserhardcodedis_verified/is_deactivated/is_available— 3,181 verified users were being silently unverified. State now travels in metadata, and only the migration handler may set it, so a new account still cannot self-assign verification (TestProductionInsertUser_IgnoresStateMetadata).Subtleties worth knowing
omitemptyon state flags. It dropsfalse, and the indexer cannot distinguish absent from false (is_availabledefaults totrue), so an unavailable row would silently become available.dataenvelope.NewParamsunwrapsmetadata["data"]; fields outside it are invisible toMetadataBoolOr.Unrelated genesis-writer robustness (found running this end to end)
maintenance_work_mem→ 2GB for the post-load index rebuild. At 1GB the btree sort fails withinvalid memory alloc request sizeon a 54M-rowcore_transactions; 2GB succeeds. Previously the run died after all data was written.synchronous_commit = offon the dst pool, so an external--dst-dsnisn't slowed by a per-block fsync.Testing
pkg/etlbuilds and its full suite passes. Newmigration_test.gocovers: legacy rows accepted; production still rejects the same rows; account state round-trips into the INSERT (verified/deactivated/unavailable/defaults); production insert ignores state metadata; socialis_deletepiped through; validation failure blocks the write; overrides replace only the intended handlers without mutating the production set.Verified end to end against the real snapshot: users went from 0 indexed / 41,809 rejections to 3,090,600 indexed at exactly 10k/block with 0 rejections (the 4 shortfall are duplicate
is_currentrows in the source, correctly deduped).make lintfails on this repo before this change (pre-existing findings inpkg/mediorum,pkg/core/console); none of the findings are in files this PR touches.Not included
Soft-deleted
muted_usersstill needs the same treatment, andpkg/etl/parity'sem_blockboundary logic was written for the Python→Go cutover and should be reviewed before its match rate is trusted on an all-genesis dataset.🤖 Generated with Claude Code