Skip to content

Commit c5668a8

Browse files
fix(triggers): rewrite user_ids = ANY(...) to @> so GIN indexes can be used (#880)
## Summary The actual fix for the `IndexChallengesJob` wedge that #877 was supposed to enable. #877's partial GIN was correct — but the trigger SQL couldn't reach it. ## Root cause The `on_user_challenge` and `on_challenge_disbursement` triggers both do per-row lookups against the 8 GB / ~23.5 M-row `notification` table to dedupe reward / cooldown notifications, e.g.: ```sql SELECT id FROM notification WHERE type = 'reward_in_cooldown' AND new.user_id = ANY(user_ids) AND timestamp >= (new.completed_at - interval '1 hour') ``` **PostgreSQL's GIN operator class supports `@>`, `&&`, `<@` — but NOT `scalar = ANY(array)`.** Even with the full `ix_notification` GIN, and the partial GIN added by `0210` (#877), every trigger call fell back to a parallel sequential scan of the entire 8 GB table. Confirmed in prod on cd94ede: ``` -> Parallel Seq Scan on notification (cost=0.00..963930.62) Filter: type='reward_in_cooldown' AND scalar = ANY(user_ids) Rows Removed by Filter: 7,846,045 Execution: 13,640 ms ``` And `pg_stat_user_indexes` showed `ix_notification_cooldown_user_ids.idx_scan = 0` since it was built — completely unused. ## Fix Rewrite the predicate to the canonical `@>` form. Semantics are identical (both test array membership), but only `@>` is GIN-eligible. Same EXPLAIN on the same row, with the same data: | Form | Plan | Execution | |---|---|---| | `new.user_id = ANY(user_ids)` (before) | Parallel Seq Scan | **13,640 ms** | | `user_ids @> ARRAY[new.user_id]` (after) | Bitmap Index Scan on `ix_notification_cooldown_user_ids` | **2 ms** | Three call sites updated: | File | What it does | |---|---| | `ddl/functions/handle_user_challenges.sql` | `reward_in_cooldown` dedupe path of `handle_on_user_challenge()` — fires on every `is_complete=true` write to `user_challenges` | | `ddl/functions/handle_challenge_disbursements.sql` (×2) | `challenge_reward` dedupe in both `handle_challenge_disbursement()` (legacy table) and `handle_sol_reward_disbursement()` (new indexer's table) | Schema dump (`sql/01_schema.sql`) and migration tracker checksums updated to match. ## Impact - **Challenge job**: per-upsert trigger cost drops from ~13 s → ~2 ms. The `IndexChallengesJob` first-tick wedge clears once a fresh backend picks up the new function (so a `core-indexer` pod restart after this deploys is the last manual step, unless the deploy itself replaces the pod). - **Rewards / disbursements**: per-disbursement trigger cost drops by the same factor; the rewards attester is no longer rate-limited by trigger latency. - **All other `cooldown_days > 0` challenges** (`p`, `u`, the Phase 2 ones, etc.) get the same speedup whenever they fire the trigger. ## Out of scope The wider codebase has **~50 other `= any(user_ids)` occurrences** across other trigger functions (notification triggers added in #851, etc.). Same anti-pattern — same fix. Worth a separate sweep PR; I left it out here to keep this one minimal and reviewable. ## Test plan - [x] `go build ./...`, `go vet ./...` clean (no Go changes; sanity check). - [x] Confirmed in prod via `EXPLAIN (ANALYZE, BUFFERS)` that the `@>` form picks `ix_notification_cooldown_user_ids` and completes in 2 ms (vs 13.6 s for the `= ANY` form). - [x] Function checksums in `sql/03_migration_tracker.sql` updated so `pg_migrate.sh` re-applies them on deploy. - [x] `sql/01_schema.sql` updated in lockstep so a fresh test template reflects the new function bodies. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d0f0bf4 commit c5668a8

4 files changed

Lines changed: 8 additions & 8 deletions

File tree

ddl/functions/handle_challenge_disbursements.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ begin
1616
from notification
1717
where
1818
type = 'challenge_reward' and
19-
new.user_id = any(user_ids) and
19+
user_ids @> ARRAY[new.user_id] and
2020
timestamp >= (new.created_at - interval '1 hour')
2121
limit 1;
2222

@@ -87,7 +87,7 @@ begin
8787
select id into existing_notification
8888
from notification
8989
where type = 'challenge_reward'
90-
and resolved_user_id = any(user_ids)
90+
and user_ids @> ARRAY[resolved_user_id]
9191
and timestamp >= (new.created_at - interval '1 hour')
9292
limit 1;
9393

ddl/functions/handle_user_challenges.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ begin
6868
from notification
6969
where
7070
type = 'reward_in_cooldown' and
71-
new.user_id = any(user_ids) and
71+
user_ids @> ARRAY[new.user_id] and
7272
timestamp >= (new.completed_at - interval '1 hour')
7373
limit 1;
7474

sql/01_schema.sql

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2199,7 +2199,7 @@ begin
21992199
from notification
22002200
where
22012201
type = 'challenge_reward' and
2202-
new.user_id = any(user_ids) and
2202+
user_ids @> ARRAY[new.user_id] and
22032203
timestamp >= (new.created_at - interval '1 hour')
22042204
limit 1;
22052205

@@ -3305,7 +3305,7 @@ begin
33053305
from notification
33063306
where
33073307
type = 'reward_in_cooldown' and
3308-
new.user_id = any(user_ids) and
3308+
user_ids @> ARRAY[new.user_id] and
33093309
timestamp >= (new.completed_at - interval '1 hour')
33103310
limit 1;
33113311

@@ -4450,7 +4450,7 @@ begin
44504450
select id into existing_notification
44514451
from notification
44524452
where type = 'challenge_reward'
4453-
and resolved_user_id = any(user_ids)
4453+
and user_ids @> ARRAY[resolved_user_id]
44544454
and timestamp >= (new.created_at - interval '1 hour')
44554455
limit 1;
44564456

sql/03_migration_tracker.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ functions/get_user_score.sql 2d5e3003ae1074cbe22ee780dd0ff901 2026-05-27 00:22:3
109109
functions/get_user_scores.sql b22e36599e6503649d7cabff0609bc05 2026-05-27 00:22:35.829695+00
110110
functions/handle_artist_coins.sql 21ed1610d80cceca2262efb1338060ed 2026-05-27 00:22:35.905536+00
111111
functions/handle_associated_wallet.sql 6c9299c3b14bc1db4a578f4ad46e2225 2026-05-27 00:22:35.99395+00
112-
functions/handle_challenge_disbursements.sql 5177268be28586a350606b8cec13b294 2026-05-27 00:22:36.072508+00
112+
functions/handle_challenge_disbursements.sql 1bbf82cc035971d828ed96f3ea1a23d6 2026-05-29 22:00:00.000000+00
113113
functions/handle_chat_blast.sql ccd276957c1b68bfc82fb5a11bac09ee 2026-05-27 00:22:36.155215+00
114114
functions/handle_chat_message.sql 31bebee3d0437133f1f53c2eadb7b391 2026-05-27 00:22:36.229275+00
115115
functions/handle_chat_message_reaction.sql b898313aa8f31c61df7f1e6dd791ef31 2026-05-27 00:22:36.304728+00
@@ -134,7 +134,7 @@ functions/handle_track.sql 89ef5a957b3662310fb30f914aab9ed4 2026-05-27 00:22:37.
134134
functions/handle_usdc_purchase.sql c35bccc2789ae0641d413d28a131ef8d 2026-05-27 00:22:37.800109+00
135135
functions/handle_user.sql 169778112e5362ee20af56d9b8f274c5 2026-05-27 00:22:37.955619+00
136136
functions/handle_user_balance_changes.sql 1ae7f99f4f37194cdf27dc3189ebc858 2026-05-27 00:22:38.02983+00
137-
functions/handle_user_challenges.sql c2adfee92cab36dee37ab7b8af5449c1 2026-05-27 00:22:38.103198+00
137+
functions/handle_user_challenges.sql 8a1287bc971c83e7440b88da7c86e93d 2026-05-29 22:00:00.100000+00
138138
functions/handle_user_tip.sql e4bde4e7e04b0ed8254690959513bd69 2026-05-27 00:22:38.167097+00
139139
functions/is_country_eur.sql c5641d570edb9cd47cd4e38d883e941a 2026-05-27 00:22:38.234372+00
140140
functions/notify_on_row.sql 8b0232ed60eb108aa45f68c6dfe05d59 2026-05-27 00:22:38.304698+00

0 commit comments

Comments
 (0)