Skip to content

fix(etl): index genesis-migration entities without data loss - #425

Merged
rickyrombo merged 9 commits into
mainfrom
fix/genesis-migration-indexing
Aug 4, 2026
Merged

fix(etl): index genesis-migration entities without data loss#425
rickyrombo merged 9 commits into
mainfrom
fix/genesis-migration-indexing

Conversation

@rickyrombo

Copy link
Copy Markdown
Contributor

Problem

The ETL rejects almost everything genesis-writer replays. Indexing a real migration (54.3M txs from a 2026-06-30 prod snapshot) produced 41,809 rejections and zero rows in users, tracks, playlists, follows, saves, reposts:

entity manager migration validation rejected  entity_type=User action=Create
entity_id=7415  reason="user id 7415 below offset 3000000"

indexer.go converted ManageEntityLegacyMigration into a plain ManageEntityLegacy and dispatched it through the production handlers, so validations meant for newly submitted entities were applied to replayed historical state:

check impact if unfixed
legacy ID offsets (user 3M / track 2M / playlist 400k) 65% of users (2,054,760), 54% of tracks (788,794), 45% of playlists (121,120) — ids are below the offsets by definition
content limits + reserved handles ~310 users, 48 tracks/playlists that predate the rules
wallet_signature / dashboard ecrecover proofs all 28,927 associated wallets, all 298 dashboard wallet users

This 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; RegisterMigrationOverrides replaces 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

  • insertUser hardcoded is_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).
  • Orphaned content — the 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 correctly rejected as "user does not exist". All current rows now migrate with their state, including soft-deleted tracks/playlists/social rows, so a parity check can distinguish an intentional omission from real data loss.
  • Associated wallets are signed as the owning user, not 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 rather than bypassed — the only dropped check is the unreplayable ownership proof.

Subtleties worth knowing

  • No omitempty on state flags. It drops false, and the indexer cannot distinguish absent from false (is_available defaults to true), so an unavailable row would silently become available.
  • Flags must live in the inner data envelope. NewParams unwraps metadata["data"]; fields outside it are invisible to MetadataBoolOr.

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 with invalid memory alloc request size on a 54M-row core_transactions; 2GB succeeds. Previously the run died after all data was written.
  • synchronous_commit = off on the dst pool, so an external --dst-dsn isn't slowed by a per-block fsync.

Testing

pkg/etl builds and its full suite passes. New migration_test.go covers: 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; social is_delete piped 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_current rows in the source, correctly deduped).

make lint fails on this repo before this change (pre-existing findings in pkg/mediorum, pkg/core/console); none of the findings are in files this PR touches.

Not included

Soft-deleted muted_users still needs the same treatment, and pkg/etl/parity's em_block boundary 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

rickyrombo and others added 2 commits July 29, 2026 23:04
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>
@rickyrombo

Copy link
Copy Markdown
Contributor Author

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:

  1. Total row counts — catches wholesale loss.
  2. Full merge-walk of aggregate_plays (~1.7M rows, read once per side, no per-row queries). Because it holds the per-track play count, this covers all 40M plays in aggregate — a dropped or misattributed subset surfaces here even though no individual play was inspected.
  3. Bounded random sample (default 2000, --plays-sample) compared field by field, to catch corruption that preserves counts. TABLESAMPLE keeps it cheap where ORDER BY random() would not.

Two data properties the comparison has to respect, both of which would otherwise produce false results:

  • user_id is NULL for anonymous plays (~47% of rows), so sample lookups match with IS NOT DISTINCT FROM.
  • (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 one row.

Only the fields genesis-writer actually carries are compared (user_id, play_item_id, created_at, city, region, country); source, slot and signature aren't in the migration payload and would report as false differences.

Off by default (--plays).

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.

rickyrombo and others added 4 commits July 30, 2026 02:40
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>
@rickyrombo
rickyrombo force-pushed the fix/genesis-migration-indexing branch from 0d8943b to 7338103 Compare August 3, 2026 04:22
rickyrombo and others added 3 commits August 3, 2026 08:59
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>
@rickyrombo
rickyrombo force-pushed the fix/genesis-migration-indexing branch from 4bd2791 to 8e55dc8 Compare August 4, 2026 21:17
@rickyrombo
rickyrombo merged commit 6c116fa into main Aug 4, 2026
2 of 3 checks passed
@rickyrombo
rickyrombo deleted the fix/genesis-migration-indexing branch August 4, 2026 21:19
This was referenced Aug 4, 2026
rickyrombo added a commit that referenced this pull request Aug 4, 2026
#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>
rickyrombo added a commit that referenced this pull request Aug 4, 2026
…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>
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