Skip to content

deps: pin pkg/etl v1.6.4 and backfill users + save/repost types - #1011

Merged
rickyrombo merged 6 commits into
mainfrom
deps/etl-bump-and-backfills
Aug 5, 2026
Merged

deps: pin pkg/etl v1.6.4 and backfill users + save/repost types#1011
rickyrombo merged 6 commits into
mainfrom
deps/etl-bump-and-backfills

Conversation

@rickyrombo

@rickyrombo rickyrombo commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Supersedes #1008, #1009 and #1010, which were the same work split three ways.

Why one PR

These three changes are only correct together, in one deploy. Split, each one alone breaks something:

merged alone result
the bump ETL 0035 creates users_current_uniq_idx, fails on the existing duplicates, RunMigrations errors and the indexer won't start
0236 (album) the old indexer keeps deriving album from is_album and undoes the backfill
0237 (users) harmless, but pointless without the index that stops it recurring

As one PR the deploy is atomic, and the ordering inside it is guaranteed by existing machinery: bridge migrate runs as a pre-roll Job that every serving Deployment DependsOn (serving pods get runMigrations=false), so both ddl migrations complete before the indexer starts and runs the ETL's.

Contents

0237_users_one_current_row_backfill — deletes 5 duplicate is_current rows from users. Small count, large blast radius: joins from an entity to its owner's wallet fan out, measured at +18 tracks and +787 follows on a production clone. Must precede the ETL index.

0236_saves_reposts_album_to_playlist — 670 saves and 528 reposts written as album collapse to playlist. on_save/on_repost are disabled for the update (their notification group_id embeds the type, so a plain UPDATE would mint duplicate favourite notifications); trg_saves/trg_reposts stay enabled so the search indexer sees the change.

deps: pin pkg/etl v1.6.4 — brings OpenAudio/go-openaudio#428 (album type), #425 (the users invariant + genesis-writer join simplification) and #433 (0035 no longer deletes anything).

The delete moved out of the ETL migration

v1.6.3's 0035 deleted the duplicates itself. Since ETL migrations run automatically at indexer start, that made a go get able to remove rows from this database. #433 split it: the index stays in the ETL migration, the repair moved to 0237 here. v1.6.4 ships zero DELETE statements — verified against the resolved module, not just the tag.

The comment above the ETL config now records that line, and its corollary: an ETL migration can depend on a ddl one having run, and 0035 fails loudly if 0237 hasn't.

Verified

  • Resolved module pkg/etl@v1.6.4 contains 0035 with CREATE UNIQUE INDEX and 0 DELETE statements.
  • Both migration orders against fixtures: backfill→index applies cleanly (violations 0, indisvalid = t); index→backfill fails with could not create unique index … Key (user_id)=(98311147) is duplicated, which is the intended signal that 0237 hasn't run.
  • Both migrations idempotent; re-running is a no-op.
  • 0236 fires zero on_save/on_repost triggers against a fixture with the real wiring, and exactly one pg_notify per updated row.
  • go build ./... and go vet ./indexer/ clean.
  • No FK references users; its triggers are INSERT / INSERT OR UPDATE, so the delete fires neither.
  • Cutting pkg/etl/v1.6.4 did not move openaudio/go-openaudio:stable — still the 2026-07-30 v1.8.2 digest, so no node-operator rollout.

Not established

The cause of the duplicate users rows. Both indexer create paths reject an existing user, so a single writer can't produce them; a second writer can, since check-then-act isn't atomic across transactions. Three of five pairs put a bare-hex txhash next to a 0x-prefixed one, which fits but doesn't prove it. The index will surface it if it recurs.

🤖 Generated with Claude Code

rickyrombo and others added 2 commits August 4, 2026 16:49
users_pkey is (user_id, txhash), which admits a second is_current row under a
different txhash. Five users have one, spread from 2025-06 to 2026-06. Five
rows, but the blast radius is not five rows: anything joining an entity to
its owner's wallet fans out and silently duplicates every entity those users
own — 18 extra tracks and 787 extra follows on a production clone.

This is the backfill half of pkg/etl migration 0035, which adds
users_current_uniq_idx to stop it recurring. The delete lives here rather
than in that migration because ETL migrations run automatically at indexer
start, so shipping it there would make a `go get` silently remove rows from
this database.

The index cannot be created while duplicates exist, so this has to run first,
and it does: ddl migrations run in the pre-roll `migrate` Job that every
serving Deployment depends on, while the ETL's run at indexer start after
that Job completes. Verified both orders against a fixture — backfill then
index succeeds; index first fails with "could not create unique index ...
Key (user_id)=(98311147) is duplicated" rather than repairing anything, which
is the signal that this migration has not run.

Deleted rather than demoted: users keeps no versioned history, so demoting
would leave a category of row nothing reads. No foreign key references users,
and its triggers are INSERT or INSERT OR UPDATE, so a delete fires neither.
Winner is the highest blocknumber, which also keeps is_deactivated = true for
user 666149592. Re-running is a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit a20666a)
An album is a playlist with is_album = true. The indexer briefly derived a
separate 'album' save_type/repost_type by reading playlists.is_album at
index time; it no longer does (OpenAudio/go-openaudio#428). This backfills
the rows written while that was live: 670 saves and 528 reposts, all
first appearing on 2026-05-28.

Two hazards this has to avoid:

  * on_save/on_repost fire on AFTER INSERT OR UPDATE, and the notification
    group_id embeds the type ('save:<id>:type:<save_type>'). A plain UPDATE
    would mint a second favourite/repost notification per row under a new
    group_id, so those two triggers are disabled for the backfill.
    trg_saves/trg_reposts stay enabled so the search indexer still sees the
    rows change.
  * save_type/repost_type are part of the primary key. Verified against
    prod data that no (user_id, item_id, txhash) has both a 'playlist' and
    an 'album' row, so this is a straight UPDATE with no conflict handling.

Aggregate counts are unaffected either way: handle_save's delta is
transition-aware and evaluates to 0 when is_delete does not change.

Each table gets its own transaction to keep the ACCESS EXCLUSIVE lock taken
by ALTER TABLE ... DISABLE TRIGGER as short as possible. Re-running is a
no-op once no 'album' rows remain. The 'album' label is left in the
savetype/reposttype enums since Postgres cannot drop an enum value without
rebuilding the type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 737918c)
Moves off the 2026-07-27 pseudo-version onto the real tag. Three changes come
with it, all pkg/etl-only, so the root module pin is untouched:

  * album saves and reposts record save_type/repost_type 'playlist' rather
    than deriving 'album' from the mutable playlists.is_album at index time
    (OpenAudio/go-openaudio#428). The rows already written as 'album' are
    backfilled by ddl 0236 in this PR.
  * one current row per user is enforced via users_current_uniq_idx, and the
    fifteen DISTINCT ON dedupe subqueries in genesis-writer collapse to plain
    joins (#425). The duplicates that index cannot coexist with are removed by
    ddl 0237 in this PR, which runs first.
  * that index migration no longer deletes anything itself (#433), so no ETL
    migration modifies row data.

Also rewrites the comment above the ETL config. It described the migrations
as additive without saying why that matters; it now records the line being
held — a module bump reaches this database automatically, so data repair
belongs in ddl/ where it goes through review and the pre-roll migrate Job —
and the corollary that 0035 fails if ddl 0237 has not run, which the Job's
ordering is what prevents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rickyrombo
rickyrombo force-pushed the deps/etl-bump-and-backfills branch from c552a8e to bdc41d9 Compare August 5, 2026 00:18
@rickyrombo rickyrombo changed the title deps: pin pkg/etl and backfill users + save/repost types deps: pin pkg/etl v1.6.4 and backfill users + save/repost types Aug 5, 2026
@rickyrombo
rickyrombo marked this pull request as ready for review August 5, 2026 00:18
rickyrombo and others added 3 commits August 4, 2026 17:38
0236 as written would abort against production data. It justified a blind
UPDATE by checking saves_pkey (user_id, save_item_id, save_type, txhash),
where nothing collides. But pkg/etl 0030 also added

  saves_current_uniq_idx    ON saves   (user_id, save_item_id, save_type)     WHERE is_current
  reposts_current_uniq_idx  ON reposts (user_id, repost_item_id, repost_type) WHERE is_current

which carry no txhash. Two current rows for one (user, item) are legal today
precisely because one is 'album' and one is 'playlist', so collapsing the
type makes them duplicates: 43 pairs in saves, 34 in reposts. The UPDATE
would have raised a unique violation, failed the pre-roll migrate Job, and
blocked the deploy — taking 0237 with it, since psql exits on the first
error and 0237 sorts after 0236.

So resolve the pairs first, then retype the rest. The loser of each pair is
demoted (is_current = false), which these tables already use for superseded
rows, unlike users. Winner is the highest blocknumber: of the 43 save pairs
38 agree on is_delete and 5 disagree, and in every disagreeing pair the
blocknumbers differ with the later row holding the correct state. Zero pairs
both disagree and tie, so the choice is always decidable; the created_at and
type tiebreaks only make it deterministic. Reposts are the same shape.

This is also a net aggregate correction — reconcile_aggregates counts these
rows with count(*) across both types, so each pair was double-counting
aggregate_playlist.save_count / repost_count.

Verified against a fixture carrying the real partial unique indexes and real
colliding rows from a production clone: applies cleanly, leaves no 'album'
rows and no duplicate current pairs, preserves the 2026 unfavourite for user
9014, fires on_save/on_repost zero times, re-enables both triggers, and is a
no-op on a second run.

Also from review:
  * guard the DISABLE/ENABLE TRIGGER statements on the trigger existing.
    pg_migrate.sh applies migrations/ before functions/, so on a database
    bootstrapped from ddl/ alone they do not exist yet.
  * correct the lock level in 0236's comment: ALTER TABLE ... DISABLE TRIGGER
    takes ShareRowExclusiveLock, which blocks writes but not reads.
  * narrow 0237 to the offending user_ids and compare txhash under the C
    collation so its tiebreak cannot vary by database collation.
  * drop the claim that ETL migrations are all additive DDL — 0026 drops a
    constraint and 0027 recreates playlist_seen's primary key. They do not
    touch row data, which is the property that actually matters here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI could not have caught 0236's unique violation. The four partial unique
indexes from pkg/etl migration 0030 exist in every deployed database, because
the ETL module runs its own migrations at indexer start — but they are absent
from sql/01_schema.sql, so the constraint that 0236 actually had to satisfy
simply did not exist under test.

The gap cannot close on its own. `make test-schema` brings up a database from
this dump, applies only ddl/ migrations, and dumps the result. ETL-created
objects are never introduced anywhere in that loop, so regenerating the file
reproduces the same omission.

Adding them once is enough, for the same reason: regeneration starts from this
file, so they carry forward.

users_current_uniq_idx is deliberately excluded. pkg/etl 0035 has not run in
production, and ddl 0237 is specified to run before it does — seeding it would
make the test schema describe a state that does not exist yet, and would hide
whether 0237 works on the schema it will actually meet.

The definitions are copied verbatim from a production clone rather than from
the migration source, so they match what deployments really have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`make test-schema` seeds a database from sql/01_schema.sql, runs `make
migrate`, and dumps the result. Only ddl/ migrations ran in that loop, so
objects created by the vendored pkg/etl migrations could never enter the
dump no matter how often it was regenerated. That is why the four partial
unique indexes from pkg/etl 0030 were absent under test while present in
every deployment — and why ddl 0236 could be written against the wrong
constraint and still pass CI.

The `migrate` subcommand now applies the ETL migrations after the ddl ones.
They are idempotent and tracked separately in etl_db_migrations, so the
indexer applying them again at startup is a no-op. Useful side effect: a ddl
migration that must precede an ETL one (0237 before 0035) is now sequenced
within a single process rather than across two pods.

Two things had to be fixed for that to work at all:

  * the ETL migrator uses lib/pq, which defaults to sslmode=require, while
    api's connection strings rely on pgx's laxer default. Against the compose
    database it failed with "pq: SSL is not enabled on the server", so the
    target's writeDbUrl now carries sslmode=disable.
  * the recipe chained with ';' and never set errexit, so that failure was
    swallowed: `make migrate` exited 1 and the target went on to dump a
    schema missing everything and print "Done". Now `set -e`.

Regenerating with both fixed shows the seed was stale in two independent
ways. It gained the 14 etl_ tables, etl_db_migrations and the fifth unique
index as expected — but the migration tracker also went from 185 rows to
192, picking up five ddl migrations whose objects the dump had never
contained: 0232_new_chain_queue, both 0233s, 0234 and 0235. Two of the
remaining entries are this branch's own 0236 and 0237; the rest are
refreshed md5s for two functions and a view.

Nothing was removed — the object inventory is purely additive (tables
164→179, indexes 201→265, views 16→18, functions 90→92, sequences 23→37,
zero dropped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rickyrombo
rickyrombo force-pushed the deps/etl-bump-and-backfills branch from b79f8f2 to 4939feb Compare August 5, 2026 00:56
@rickyrombo
rickyrombo merged commit 997c44c into main Aug 5, 2026
2 checks passed
@rickyrombo
rickyrombo deleted the deps/etl-bump-and-backfills branch August 5, 2026 00:59
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