feat(etl): carry playlist removal history through the migration - #485
Merged
Conversation
playlist_tracks.is_removed is a tombstone: because the primary key is (playlist_id, track_id), taking a track out of a playlist flips the flag rather than deleting the row, leaving a record that the track was once a member and when it left. Nothing in a migration can produce one. updatePlaylistTracks recognizes a removal as a transition -- a track in playlist_contents before an update and absent after it -- and the migration replays a snapshot: one Playlist/Create holding the playlist's final contents, with no earlier state to differ from. The "was present, now absent" loop has nothing to iterate, so only the insert loop runs, and it writes is_removed = false. On a production clone that is 20,763 tombstones across 3,917 playlists that cannot be reproduced at all. They are not bookkeeping. They are the input to tracks.playlists_previously_containing_track, which the API's usdc_purchase check reads to decide whether someone who bought an album still has access to a track that later left it (AudiusProject/api #1014). 19,441 tracks in the source carry such a record; migrated data would carry none, and every one of those purchases would quietly stop covering the track it was bought for. So the removals ride on the Create as state, the way trackState, playlistState and userState already carry what a client could never send: genesis-writer preloads the source's is_removed rows -- a table it did not read at all until now -- and emits them under a `removed_tracks` metadata key with each track's join and departure timestamps; migratedPlaylistCreate reads them into playlistState, and insertPlaylistAndRouteWithState writes the junction rows with the source timestamps and updates the reverse index through the same function the production removal path uses. Production passes the zero value and writes nothing, exactly as today. Two things the source's own timestamps decide rather than the block: updated_at on the junction row, which is when the track left, and the removal epoch in the reverse index, which is compared against a purchase date -- stamping either at replay time would move who is entitled. Since tracks that left at different moments cannot share one timestamp, the reverse index is written once per distinct removal time rather than once per playlist. A track that appears in both the contents and the removals is a contradiction in the source; the live row wins, so a replay can never mark a present track removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 10, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #481 (which merged while this was in flight, so this is now rebased directly onto
main).The gap
playlist_tracks.is_removedis a tombstone. The primary key is(playlist_id, track_id), so taking a track out of a playlist flips the flag instead of deleting the row — the row survives as the record that the track was once a member, and when it left.The migration cannot produce one.
updatePlaylistTracksrecognizes a removal as a transition: a track present inplaylist_contentsbefore an update and absent after it. A migration replays a snapshot — onePlaylist/Createcarrying the playlist's final contents, with no earlier state to differ from. The "was present, now absent" loop has nothing to iterate, so only the insert loop runs, and it hardcodesis_removed = false.Measured on a production clone: 20,763 tombstones across 3,917 playlists that cannot be reproduced at all (24,579 rows over 4,064 playlists source-wide; the smaller figures are the subset whose contents are byte-identical across two snapshots).
They are not bookkeeping. They are the input to
tracks.playlists_previously_containing_track, which the API'susdc_purchasecheck reads to decide whether someone who bought an album still has access to a track that later left it (AudiusProject/api#1014). 19,441 tracks in the source carry such a record. Migrated data would carry none, and every one of those purchases would quietly stop covering the track it was bought for.The shape
The removals ride on the Create as state, matching the established pattern (
trackState,playlistState,userState: production passes the zero value, only the migration fills it in). No second Update transaction per playlist.cmd/genesis-writer/entities_playlist.go— preloads the source'sis_removed = truerows, a table the writer did not read at all until now, and emits them under a newremoved_tracksmetadata key:[{"track_id", "created_at", "updated_at"}], wherecreated_atis when the track joined andupdated_atis when it left. 24,579 rows is small enough to hold in memory.migration.go—migratedPlaylistCreateparses them intoplaylistState.RemovedTracks.playlist_create.go—insertPlaylistAndRouteWithStatepasses them through. Zero value writes nothing, so production is unaffected.playlist_tombstones.go(new) — inserts the junction rows withis_removed = trueand the source timestamps, then feeds the reverse index through the sameupdateTrackPlaylistIndexthe production removal path uses, so the two cannot disagree. The record shape stays{"<playlist_id>": {"time": <unix epoch>}}— that is the API contract.Two things the source's timestamps decide rather than the block:
updated_aton the junction row, and the removal epoch in the reverse index, which is compared against a purchase date. Stamping either at replay time would move who is entitled. Because tracks that left at different moments cannot share one timestamp, the reverse index is written once per distinct removal time rather than once per playlist.A track appearing in both the contents and the removals is a contradiction in the source; the live row wins, so a replay can never mark a present track removed.
Merge note
playlist_tracks.gois untouched — the new work lives in a new file and calls the existingupdateTrackPlaylistIndex. This keeps it clear of the concurrent change converting that file'supdated_at = now()statements to block time.Testing
Four DB-backed tests in
playlist_tombstones_test.go:TestMigratedPlaylistCreate_CarriesRemovalHistorycreated_at/updated_at, live track unaffected, reverse index carries{"<pid>": {"time": <source epoch>}}TestPlaylistCreate_WritesNoTombstonesTestMigratedPlaylistCreate_LiveMembershipBeatsTombstoneTestMigratedPlaylistCreate_PerTrackRemovalTimesNegative check — with the
insertPlaylistTrackTombstonescall removed:The other two stay green by design — they assert what must not happen.
gofmt -landgo vet ./...clean inpkg/etl(three pre-existing gofmt hits unrelated to this change),go build ./cmd/genesis-writer/clean.🤖 Generated with Claude Code