Skip to content

Commit e04ae34

Browse files
feat(jobs/challenges): Phase 1 challenge processors (poll-based)
Adds the IndexChallengesJob + 11 challenge processors (in 7 files) that mirror apps' discovery-provider challenges system. Unlike apps' Redis event-bus design, processors here reconcile derived state from existing source tables on a tick — idempotent, restart-safe, no producer-side plumbing required (architecture decision; see processor.go package docs). Phase 1 challenges: p profile completion — 7 boolean steps from users/follows/saves/reposts u track upload — 3 public, non-stem tracks since starting_block fp first playlist — any non-deleted playlist v connect-verified — users.is_verified = true e listen streak — endless streak from plays (currently inactive) p1 play count 250 milestone — verified artists, 2025+ play sum p2 play count 1000 milestone — gated on p1 complete p3 play count 10000 milestone — gated on p2 complete tt trending track — top-10 of week, Fridays UTC, idempotent tut trending underground — same, UNDERGROUND_TRACKS type tp trending playlist — top-10 playlists, rank-tiered payout Each processor: * Implements challenges.Processor (ChallengeID + Reconcile). * Runs in its own pgx tx via the umbrella IndexChallengesJob. * Skips quietly when its catalog row is inactive or absent. The umbrella job runs every 30s and is wired into CoreIndexer.Start alongside the rest of the parity jobs in #834. Migration 0203 seeds the Phase 1 challenges catalog rows from challenges.json (mirrors apps' create_new_challenges with ON CONFLICT DO UPDATE so catalog stays in sync). Out of scope here (per the architecture discussion): * Trending notifications + tastemaker challenge — depend on the challenge-event mechanism we explicitly skipped. * Send-tip / audio-matching (Solana). * Mobile install / one-shot / referrals — need a signals endpoint (Phase 3). Tests: 12 DB-backed tests (one Friday-coupled trending test auto-skips on non-Fridays), all passing against test_jobs template DB.
1 parent 9f88a50 commit e04ae34

19 files changed

Lines changed: 1717 additions & 0 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
-- Seed the challenges catalog rows for Phase 1 challenge processors.
2+
--
3+
-- Values mirror apps/packages/discovery-provider/src/challenges/challenges.json
4+
-- as of this writing. Production may already have these rows (from earlier
5+
-- migrations or seeded another way); we use ON CONFLICT DO UPDATE so the
6+
-- catalog stays aligned with the JSON source-of-truth — matching apps'
7+
-- create_new_challenges.py behavior.
8+
--
9+
-- Phase 1 set:
10+
-- p profile completion (numeric, 7 steps)
11+
-- u track upload (numeric, 3 tracks)
12+
-- fp first playlist (boolean)
13+
-- v connect-verified (boolean)
14+
-- e listen streak (aggregate; currently inactive)
15+
-- p1/p2/p3 play count milestones (numeric, 250/1k/10k)
16+
-- tt/tut/tp trending track/under/playlist (trending)
17+
18+
BEGIN;
19+
20+
INSERT INTO challenges (id, type, amount, active, step_count, starting_block, weekly_pool, cooldown_days) VALUES
21+
('p', 'numeric', '1', true, 7, 0, 25000, 7),
22+
('u', 'numeric', '1', true, 3, 25346436, 25000, 7),
23+
('fp', 'boolean', '2', true, NULL, 28350000, 25000, 7),
24+
('v', 'boolean', '5', true, NULL, 0, 25000, NULL),
25+
('e', 'aggregate', '1', false, 2147483647, 116023891,25000, NULL),
26+
('p1', 'numeric', '25', true, 250, 0, 2147483647, 7),
27+
('p2', 'numeric', '100', true, 1000, 0, 2147483647, 7),
28+
('p3', 'numeric', '1000', true, 10000, 0, 2147483647, 7),
29+
('tt', 'trending', '1000', true, NULL, 25346436, 100000, NULL),
30+
('tut', 'trending', '1000', true, NULL, 25346436, 100000, NULL),
31+
('tp', 'trending', '100', true, NULL, 25346436, 10000, NULL)
32+
ON CONFLICT (id) DO UPDATE SET
33+
type = EXCLUDED.type,
34+
amount = EXCLUDED.amount,
35+
active = EXCLUDED.active,
36+
step_count = EXCLUDED.step_count,
37+
starting_block = EXCLUDED.starting_block,
38+
weekly_pool = EXCLUDED.weekly_pool,
39+
cooldown_days = EXCLUDED.cooldown_days;
40+
41+
COMMIT;

indexer/indexer.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,11 @@ func (ci *CoreIndexer) startParityJobs(ctx context.Context) {
134134

135135
jobs.NewUpdateDelistStatusesJob(ci.Config, ci.pool).
136136
ScheduleEvery(ctx, 5*time.Minute)
137+
138+
// Reconcile derived challenge state from source tables. Per-challenge
139+
// scanners live in api/jobs/challenges/.
140+
jobs.NewIndexChallengesJob(ci.Config, ci.pool).
141+
ScheduleEvery(ctx, 30*time.Second)
137142
}
138143

139144
func (ci *CoreIndexer) Close() {

jobs/challenges/first_playlist.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package challenges
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/jackc/pgx/v5"
8+
)
9+
10+
// FirstPlaylistProcessor implements challenge "fp" — boolean: the user has
11+
// created at least one playlist.
12+
// Mirrors apps/packages/discovery-provider/src/challenges/first_playlist_challenge.py
13+
// (Python just sets is_complete=true when an event fires; we derive it from
14+
// playlists table state directly).
15+
type FirstPlaylistProcessor struct{}
16+
17+
func (p *FirstPlaylistProcessor) ChallengeID() string { return "fp" }
18+
19+
func (p *FirstPlaylistProcessor) Reconcile(ctx context.Context, tx pgx.Tx) error {
20+
c, ok, err := LoadChallenge(ctx, tx, p.ChallengeID())
21+
if err != nil {
22+
return fmt.Errorf("load challenge: %w", err)
23+
}
24+
if !ok || !c.Active {
25+
return nil
26+
}
27+
startingBlock := int32(0)
28+
if c.StartingBlock != nil {
29+
startingBlock = *c.StartingBlock
30+
}
31+
amount := c.AmountInt()
32+
33+
// Find every user with at least one non-deleted playlist at or after
34+
// the starting block. Boolean challenges complete in a single step
35+
// (step_count is null/0 — we treat current_step_count=1, step=1).
36+
rows, err := tx.Query(ctx, `
37+
SELECT DISTINCT playlist_owner_id
38+
FROM playlists
39+
WHERE is_current = true
40+
AND is_delete = false
41+
AND blocknumber >= $1
42+
`, startingBlock)
43+
if err != nil {
44+
return fmt.Errorf("scan playlists: %w", err)
45+
}
46+
var userIDs []int64
47+
for rows.Next() {
48+
var userID int64
49+
if err := rows.Scan(&userID); err != nil {
50+
rows.Close()
51+
return err
52+
}
53+
userIDs = append(userIDs, userID)
54+
}
55+
rows.Close()
56+
if err := rows.Err(); err != nil {
57+
return err
58+
}
59+
60+
for _, userID := range userIDs {
61+
if err := UpsertUserChallenge(ctx, tx,
62+
p.ChallengeID(), SpecifierFromUserID(userID),
63+
userID, 1, 1, amount,
64+
); err != nil {
65+
return fmt.Errorf("upsert: %w", err)
66+
}
67+
}
68+
return nil
69+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package challenges
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"api.audius.co/database"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestFirstPlaylist_CompletesOnAnyPlaylist(t *testing.T) {
12+
pool := withChallengesDB(t)
13+
database.Seed(pool, database.FixtureMap{
14+
"blocks": {{"blockhash": "blk_28350001", "number": 28350001}},
15+
"users": {{"user_id": 100, "wallet": "0x100"}, {"user_id": 101, "wallet": "0x101"}},
16+
"playlists": {{"playlist_id": 1, "playlist_owner_id": 100, "blocknumber": 28350001}},
17+
})
18+
19+
runProcessor(t, pool, &FirstPlaylistProcessor{})
20+
21+
r1, ok := queryUserChallenge(t, pool, "fp", fmt.Sprintf("%x", 100))
22+
if assert.True(t, ok) {
23+
assert.True(t, r1.IsComplete)
24+
assert.Equal(t, int32(2), r1.Amount, "amount=2 per challenges.json")
25+
}
26+
_, ok = queryUserChallenge(t, pool, "fp", fmt.Sprintf("%x", 101))
27+
assert.False(t, ok, "user 101 has no playlist; no row")
28+
}

jobs/challenges/listen_streak.go

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package challenges
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"time"
8+
9+
"github.com/jackc/pgx/v5"
10+
)
11+
12+
// ListenStreakProcessor implements challenge "e" — daily listen streak.
13+
// Mirrors apps' listen_streak_endless_challenge.py.
14+
//
15+
// State lives in `challenge_listen_streak` (last_listen_date, listen_streak).
16+
// Rules (per apps):
17+
// - First listen: streak = 1, last_listen_date = play timestamp.
18+
// - Subsequent listen >= 16h after last_listen_date: advance streak by 1.
19+
// - If >= 48h gap: reset streak to 1.
20+
// - Otherwise (<16h): ignore.
21+
//
22+
// user_challenges rows have specifier "<hex_user_id><YYYYMMDDHH>". When the
23+
// streak crosses 7 days the row is completed; subsequent "endless" rows are
24+
// minted at amount=1 each.
25+
//
26+
// NOTE: in challenges.json this challenge is currently active=false, so the
27+
// processor is a no-op at present. Reconcile still does the right thing if
28+
// it gets enabled.
29+
type ListenStreakProcessor struct{}
30+
31+
func (p *ListenStreakProcessor) ChallengeID() string { return "e" }
32+
33+
const (
34+
listenStreakNextWindow = 16 * time.Hour
35+
listenStreakBrokenWindow = 48 * time.Hour
36+
listenStreakTarget = int32(7)
37+
)
38+
39+
const listenStreakCheckpoint = "challenges:e:last_play_id"
40+
41+
// listenStreakPlay is one row from the plays scan.
42+
type listenStreakPlay struct {
43+
id int64
44+
userID int64
45+
createdAt time.Time
46+
}
47+
48+
func (p *ListenStreakProcessor) Reconcile(ctx context.Context, tx pgx.Tx) error {
49+
c, ok, err := LoadChallenge(ctx, tx, p.ChallengeID())
50+
if err != nil {
51+
return fmt.Errorf("load challenge: %w", err)
52+
}
53+
if !ok || !c.Active {
54+
return nil
55+
}
56+
amount := c.AmountInt()
57+
58+
prev, err := readCheckpointInt(ctx, tx, listenStreakCheckpoint)
59+
if err != nil {
60+
return fmt.Errorf("read checkpoint: %w", err)
61+
}
62+
63+
// Pull new plays ordered by id. We deliberately use id ordering rather
64+
// than created_at because id is monotonic per insert and matches the
65+
// invariant apps' Python pipeline assumes (plays are processed in
66+
// arrival order).
67+
rows, err := tx.Query(ctx, `
68+
SELECT id, user_id, created_at
69+
FROM plays
70+
WHERE id > $1 AND user_id IS NOT NULL
71+
ORDER BY id ASC
72+
LIMIT 50000
73+
`, prev)
74+
if err != nil {
75+
return fmt.Errorf("scan plays: %w", err)
76+
}
77+
var plays []listenStreakPlay
78+
for rows.Next() {
79+
var r listenStreakPlay
80+
if err := rows.Scan(&r.id, &r.userID, &r.createdAt); err != nil {
81+
rows.Close()
82+
return err
83+
}
84+
plays = append(plays, r)
85+
}
86+
rows.Close()
87+
if err := rows.Err(); err != nil {
88+
return err
89+
}
90+
if len(plays) == 0 {
91+
return nil
92+
}
93+
94+
// Apply transitions in-process. We load existing streak state for
95+
// users with new plays in one go.
96+
userIDs := uniqueUserIDs(plays)
97+
type streakState struct {
98+
lastListen *time.Time
99+
streak int32
100+
}
101+
state := make(map[int64]*streakState, len(userIDs))
102+
103+
srows, err := tx.Query(ctx, `
104+
SELECT user_id, last_listen_date, listen_streak
105+
FROM challenge_listen_streak
106+
WHERE user_id = ANY($1)
107+
`, userIDs)
108+
if err != nil {
109+
return fmt.Errorf("load streak state: %w", err)
110+
}
111+
for srows.Next() {
112+
var uid int64
113+
var last *time.Time
114+
var streak int32
115+
if err := srows.Scan(&uid, &last, &streak); err != nil {
116+
srows.Close()
117+
return err
118+
}
119+
state[uid] = &streakState{lastListen: last, streak: streak}
120+
}
121+
srows.Close()
122+
if err := srows.Err(); err != nil {
123+
return err
124+
}
125+
126+
// Walk plays in order, applying the transition rules per user. Each
127+
// play either advances or resets a streak — we batch the writes after
128+
// the simulation.
129+
type advance struct {
130+
newStreak int32
131+
whenLogged time.Time
132+
}
133+
advancesByUser := make(map[int64][]advance)
134+
135+
for _, pl := range plays {
136+
s, ok := state[pl.userID]
137+
if !ok {
138+
s = &streakState{lastListen: nil, streak: 0}
139+
state[pl.userID] = s
140+
}
141+
if s.lastListen == nil {
142+
s.streak = 1
143+
t := pl.createdAt
144+
s.lastListen = &t
145+
advancesByUser[pl.userID] = append(advancesByUser[pl.userID], advance{1, pl.createdAt})
146+
continue
147+
}
148+
gap := pl.createdAt.Sub(*s.lastListen)
149+
if gap < listenStreakNextWindow {
150+
continue // too soon, ignore
151+
}
152+
if gap >= listenStreakBrokenWindow {
153+
s.streak = 1
154+
} else {
155+
s.streak++
156+
}
157+
t := pl.createdAt
158+
s.lastListen = &t
159+
advancesByUser[pl.userID] = append(advancesByUser[pl.userID], advance{s.streak, pl.createdAt})
160+
}
161+
162+
// Write updated streak state per user.
163+
for userID, s := range state {
164+
if _, err := tx.Exec(ctx, `
165+
INSERT INTO challenge_listen_streak (user_id, last_listen_date, listen_streak)
166+
VALUES ($1, $2, $3)
167+
ON CONFLICT (user_id) DO UPDATE SET
168+
last_listen_date = EXCLUDED.last_listen_date,
169+
listen_streak = EXCLUDED.listen_streak
170+
`, userID, s.lastListen, s.streak); err != nil {
171+
return fmt.Errorf("upsert streak state: %w", err)
172+
}
173+
}
174+
175+
// For each advance, mint or update the relevant user_challenge row.
176+
// Specifier format follows apps' new post-cutover format:
177+
// first 7 days: "<hex>:YYYYMMDDHH" of *new streak boundary*
178+
// endless : same, one row per day after 7
179+
for userID, advances := range advancesByUser {
180+
for _, a := range advances {
181+
specifier := fmt.Sprintf("%x%s", userID, a.whenLogged.UTC().Format("2006010215"))
182+
// In the first-7-day window, current_step_count = streak (1..7),
183+
// is_complete when streak >= 7. After 7, this row is the
184+
// endless +1-per-day reward (step_count = 1, amount = 1).
185+
var stepCount int32 = listenStreakTarget
186+
report := a.newStreak
187+
if a.newStreak > listenStreakTarget {
188+
stepCount = 1
189+
report = 1
190+
}
191+
if err := UpsertUserChallenge(ctx, tx,
192+
p.ChallengeID(), specifier, userID, report, stepCount, amount,
193+
); err != nil {
194+
return fmt.Errorf("upsert listen-streak user_challenge: %w", err)
195+
}
196+
}
197+
}
198+
199+
// Advance checkpoint to the last play id we processed.
200+
if err := writeCheckpointInt(ctx, tx, listenStreakCheckpoint, plays[len(plays)-1].id); err != nil {
201+
return fmt.Errorf("save checkpoint: %w", err)
202+
}
203+
return nil
204+
}
205+
206+
func uniqueUserIDs(plays []listenStreakPlay) []int64 {
207+
seen := make(map[int64]struct{}, len(plays))
208+
out := make([]int64, 0, len(plays))
209+
for _, p := range plays {
210+
if _, ok := seen[p.userID]; ok {
211+
continue
212+
}
213+
seen[p.userID] = struct{}{}
214+
out = append(out, p.userID)
215+
}
216+
return out
217+
}
218+
219+
// readCheckpointInt reads a named integer checkpoint from indexing_checkpoints,
220+
// returning 0 if absent.
221+
func readCheckpointInt(ctx context.Context, tx pgx.Tx, name string) (int64, error) {
222+
var v int64
223+
err := tx.QueryRow(ctx, "SELECT last_checkpoint FROM indexing_checkpoints WHERE tablename = $1", name).Scan(&v)
224+
if errors.Is(err, pgx.ErrNoRows) {
225+
return 0, nil
226+
}
227+
return v, err
228+
}
229+
230+
func writeCheckpointInt(ctx context.Context, tx pgx.Tx, name string, value int64) error {
231+
_, err := tx.Exec(ctx, `
232+
INSERT INTO indexing_checkpoints (tablename, last_checkpoint)
233+
VALUES ($1, $2)
234+
ON CONFLICT (tablename) DO UPDATE SET last_checkpoint = EXCLUDED.last_checkpoint
235+
`, name, value)
236+
return err
237+
}

0 commit comments

Comments
 (0)