Skip to content

fix(etl): key subscription identity on entity_type, not just numeric id - #469

Merged
rickyrombo merged 2 commits into
mainfrom
fix/subscription-identity-entity-type
Aug 8, 2026
Merged

fix(etl): key subscription identity on entity_type, not just numeric id#469
rickyrombo merged 2 commits into
mainfrom
fix/subscription-identity-entity-type

Conversation

@rickyrombo

Copy link
Copy Markdown
Contributor

Problem

subscriptions.user_id is overloaded: it holds the followed user's id for User subscriptions and the event id for Event subscriptions. User ids and event ids are allocated independently, so user N and event N can both exist — but subscriptions_current_uniq_idx (migration 0030) keyed current rows on (subscriber_id, user_id) alone, conflating the two. Two failure modes:

  • Hard rejection: Subscribe refused any subscription whose target collided numerically with an existing subscription of the other entity type — including tombstoned ones, since unsubscribes keep is_current = true. Having once followed and unfollowed user N permanently blocked following event N, and vice versa.
  • Silent clobber: the Follow auto-subscribe upsert had no cross-type guard and could land on a colliding Event row. Following the user flipped that row instead of creating a User subscription; unfollowing then tombstoned the Event subscription — silently unsubscribing the user from a remix contest because they unfollowed an unrelated user.

This surfaced downstream in AudiusProject/api, where seeding the production indexes into the test schema (AudiusProject/api#1011) made an existing events-followers test fixture — a legitimate cross-type collision — unseedable, breaking CI on main there.

Fix

  • Migration 0037 rebuilds the index under the same name as (subscriber_id, user_id, entity_type) WHERE is_current = true. The 0030 key is strictly tighter, so existing data cannot violate the widened key; no dedupe or backfill needed.
  • social_subscription.go: upsert arbitrates on the widened key, entity_type moves out of the DO UPDATE set (it's identity now), and the cross-type rejection (subscriptionIdentityTypeConflict) is deleted.
  • social_follow.go: the auto-subscribe upsert writes entity_type = 'User' explicitly and arbitrates on the widened key, so it can never land on — or tombstone — a colliding Event subscription.

track_contest_subscribe.go is unchanged: its ON CONFLICT targets the PK, and the widened index makes its insert strictly less conflict-prone.

Tests

  • TestSubscribe_UserAndEventWithSameIdCoexist: subscribe to user N and event N, unsubscribe the user, the event subscription survives.
  • TestFollow_AutoSubscribeIgnoresCollidingEventSubscription: the exact silent-clobber scenario — follow then unfollow a user whose id collides with a followed event.

Full pkg/etl suite passes with -count=1 against Postgres 17.

🤖 Generated with Claude Code

subscriptions.user_id is overloaded: it holds the followed user's id for
User subscriptions and the event id for Event subscriptions. User and
event ids are allocated independently, so user N and event N can both
exist — but subscriptions_current_uniq_idx (0030) keyed current rows on
(subscriber_id, user_id) alone, conflating the two:

- Subscribe rejected any subscription whose target collided numerically
  with an existing subscription of the other type ("already exists with
  a different entity type") — including tombstoned ones, since
  unsubscribes keep is_current = true.
- The Follow auto-subscribe upsert had no such guard and could land on a
  colliding Event row: following the user flipped that row instead of
  creating a User subscription, and unfollowing tombstoned the Event
  subscription — silently unsubscribing the user from a remix contest.

Migration 0037 rebuilds the index as (subscriber_id, user_id,
entity_type) WHERE is_current; the old key is strictly tighter, so
existing data cannot violate the new one and no dedupe is needed. Both
upserts now arbitrate on the widened key (the follow path writes
entity_type explicitly instead of relying on the column default), and
the cross-type rejection in validateSubscribe is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tity-entity-type

# Conflicts:
#	pkg/etl/processors/entity_manager/social_follow.go
@rickyrombo
rickyrombo merged commit 7d7f7d6 into main Aug 8, 2026
2 of 3 checks passed
@rickyrombo
rickyrombo deleted the fix/subscription-identity-entity-type branch August 8, 2026 08:06
rickyrombo added a commit to AudiusProject/api that referenced this pull request Aug 8, 2026
…main CI (#1013)

## What

Four related fixes around the overloaded `subscriptions.user_id` column
(it mirrors the event id for Event rows, and event ids are allocated
independently of user ids). Background: #1011 seeded production's
`subscriptions_current_uniq_idx` into the test schema, which both broke
main's CI and led to the discovery of the bugs below (see
OpenAudio/go-openaudio#469 for the upstream identity fix).

**1. Live bug: event followers counted as user subscribers.**
`/v1/users/{id}/subscribers` and the upload-notification fan-outs in
`handle_track` / `handle_playlist` match on `user_id` alone. A follower
of event N therefore counts as a subscriber of user N whenever the ids
collide — they appear in the subscriber list and get "new upload"
notifications for an artist they never subscribed to. Reachable today
with a single Event row; go-openaudio#469 (which legalizes cross-type
coexistence) widens the exposure. Fixed by adding `entity_type = 'User'`
to all three readers (trigger functions updated in `ddl/functions/`,
which `pg_migrate.sh` re-applies on md5 change, with the schema dump
edited to match). The Event-side readers were already correctly scoped
since #977; this is the mirror image nobody did.

**2. Unbreak main CI.** The events-followers fixture seeds a legacy
User-type row sharing `(subscriber_id, user_id)` with a deleted-event
row — illegal under the index #1011 seeded, so `database.Seed` panics.
The legacy row moves to its own subscriber; both exclusion behaviors
stay covered. Once go-openaudio#469 widens the index to include
`entity_type`, the same-subscriber collision becomes legal again and is
worth re-adding (noted in a comment).

**2b. Same bug in `does_current_user_subscribe`.** The
`current_user_subscribed_targets` CTE in `get_users.sql` also matched on
`user_id` alone, so a viewer following event N showed as subscribed to
user N on every user-list surface. Same `entity_type = 'User'` fix (sqlc
regenerated). Note: this CTE filters only `is_delete` and not
`is_current`, unlike the other readers — that predates the #892 refactor
and is left as-is here.

**3. Close the cache hole that let this merge green.** `setup-go`
restores the go-build cache (including test results) keyed on `go.sum`,
but the test schema lives in dockerized Postgres, invisible to Go's
cache invalidation — so #1011's sql/-only commits rode a stale green
while its own schema change broke a test. `-count=1` forces tests to
actually run.

## Tests

- `TestUsersSubscribers` now seeds an Event subscription whose event id
collides with the artist's user id and asserts the follower is not
listed (fails without the filter).
- `TestUserQuery_DoesCurrentUserSubscribeIgnoresEventSubscriptions`
seeds a colliding Event subscription and asserts the flag stays false
(fails without the filter).
- `TestEventsFollowers_ReturnsOnlyLiveEventSubscribers` passes again
(was panicking on main).
- Full suite green locally with `-count=1` against a freshly initialized
schema volume.

## Not in this PR

The `pkg/etl` pin bump + seeded-index update land separately once
go-openaudio#469/#470 cut a release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 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