Skip to content

feat(indexer): bridge on-chain plays into the plays table#881

Merged
raymondjacobson merged 1 commit into
mainfrom
api/index-core-plays-hook
May 29, 2026
Merged

feat(indexer): bridge on-chain plays into the plays table#881
raymondjacobson merged 1 commit into
mainfrom
api/index-core-plays-hook

Conversation

@raymondjacobson
Copy link
Copy Markdown
Member

@raymondjacobson raymondjacobson commented May 29, 2026

Summary

The vendored ETL play processor only writes its own etl_plays table — nothing in api/ reads etl_plays. The plays table is the one every downstream consumer depends on, so under the new ETL indexer plays were effectively a no-op. This restores the legacy Python index_core_plays behavior by bridging each on-chain play into plays.

Why plays matters:

  • The on_play trigger fans each row out to aggregate_plays, aggregate_monthly_plays, milestones, notification, and user_distinct_play_*.
  • The challenge processors (listen_streak, play_count_milestones) poll plays directly.
  • Trending and hourly-play-count jobs read plays.

How

Uses the RegisterPlaysHook extension point added upstream in go-openaudio#322. The hook runs in the same DB transaction the ETL play processor used for etl_plays, so plays rows commit atomically with etl_plays and the rest of the block.

Field mapping mirrors index_core_plays exactly:

  • play_item_id = int(track_id); the play is skipped if track_id isn't an integer.
  • user_id = int(user_id), or NULL for an anonymous listen (non-integer user_id).
  • source = "relay", created_at = play timestamp, updated_at = now().
  • slot = Core block height (monotonic; shared by all plays in a block, the same shape as Python's per-tx next_slot). No api/ Go consumer reads plays.slot; the on_play trigger only forwards it onto milestone/notification rows.

Intentionally not ported: Python's challenge-event dispatch (track_listen / track_played). The new challenge processors reconcile from plays by polling, so the bridge only needs to land the rows.

Hook errors are logged but non-fatal (the etl.PlaysHook contract) — a malformed play must not roll back etl_plays or halt the indexer.

Dependency

  • go-openaudio#322 merged (commit 4d1c9df). This PR is rebased on latest main and pins that merged commit.

Test plan

  • go build ./... and go vet ./indexer/... clean
  • New indexer/plays_hook_test.go (real test DB): inserts a play with correct fields + slot=block height; anonymous listen → NULL user_id; non-integer track_id skipped (valid sibling still lands); empty tx is a no-op; the on_play trigger increments aggregate_plays for written rows
  • Full ./indexer/ package suite passes against the merged go-openaudio commit

🤖 Generated with Claude Code

The vendored ETL play processor only writes etl_plays, which nothing in
api/ reads. Restore the legacy Python index_core_plays behavior with a
PlaysHook (go-openaudio #322) that writes each on-chain play into the
`plays` table — the row every downstream consumer depends on (the on_play
trigger's aggregates/milestones/notifications, the challenge processors,
trending, hourly play counts).

Field mapping mirrors index_core_plays exactly: play_item_id = int(track_id)
(skip non-integer), user_id = int(user_id) or NULL for anonymous listens,
source = "relay", created_at = play timestamp, slot = Core block height.
Challenge-event dispatch is intentionally omitted — the new challenge
processors reconcile from `plays` by polling.

Runs in the same DB tx as etl_plays, so the rows commit atomically. Bumps
the go-openaudio pin to the commit that adds RegisterPlaysHook.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@raymondjacobson raymondjacobson force-pushed the api/index-core-plays-hook branch from 705ec6a to 5818401 Compare May 29, 2026 22:21
@raymondjacobson raymondjacobson merged commit 4a39b3b into main May 29, 2026
5 checks passed
@raymondjacobson raymondjacobson deleted the api/index-core-plays-hook branch May 29, 2026 22:29
raymondjacobson added a commit that referenced this pull request May 30, 2026
…)" (#885)

## Summary

Reverts #883, pinning go-openaudio back to
`v1.3.1-0.20260529221831-4d1c9dfdfb52`.

The halt-on-error behavior from upstream go-openaudio#323 is correct in
isolation, but is **incompatible with the current dual-run state**:
Python and api-side ETL both write to overlapping tables, and the
on-chain plays bridge from #881 doesn't ON CONFLICT-protect against rows
Python has already written. So:

- Pre-#883: the failure was silently swallowed by `continue` — ETL was
effectively a no-op on essentially every block since #881 deployed, but
block_diff stayed green because Python's writes kept
`MAX(blocks.height)` moving. Block-level data loss masked by Python
carrying the load.
- Post-#883: the same failure crashes the indexing loop. We saw it
tonight at `processBlock failed` on block 25415514, reproducibly across
pod restarts because Python writes the same plays in the same block
before the ETL gets to it. Once #884 (the api-wrapper fix that makes
that halt actually exit the process) ships, every pod would crashloop
the moment it tries to index any recent block.

So shipping #883 + #884 without first handling the cross-writer
collision points would convert today's silent wedge into a continuous
outage that takes the parity jobs (`IndexChallengesJob`,
`UserListeningHistory`, `HourlyPlayCounts`, etc.) down with the ETL.
Strictly worse.

## Plan

1. **This PR**: pin upstream back to the pre-halt version. Today's
silent wedge stays in place — bad, but bounded — and the parity jobs
keep ticking.
2. Close #884 (already done). The diagnosis there is correct, but it
amplifies #883's bad sequencing, so we re-land it after #883 is safe to
re-ship.
3. Revert OpenAudio/go-openaudio#323 upstream too, so no future bump
trips this accidentally.
4. **Audit + fix the cross-writer collision points in pkg/etl** — start
with the plays bridge (#881), apply the same ON CONFLICT pattern #319
used for the `blocks` table. Then sweep anywhere else ETL and Python
touch the same row.
5. Re-land go-openaudio#323, then api#883, then api#884 (in that order).
At that point the halt-on-error guarantee is honest.

## Bump details (revert direction)

| | from | to |
|---|---|---|
| `github.com/OpenAudio/go-openaudio` |
`v1.3.1-0.20260529230137-819100b28c94` |
`v1.3.1-0.20260529221831-4d1c9dfdfb52` |
| `github.com/OpenAudio/go-openaudio/pkg/etl` |
`v1.3.1-0.20260529230137-819100b28c94` |
`v1.3.1-0.20260529221831-4d1c9dfdfb52` |

## Test plan

- [x] `go build ./...` clean.
- [ ] After deploy: confirm new pod boots, no `processBlock failed` halt
log on block 25415514 (it'll go back to silent `continue`).
- [ ] Verify parity jobs still tick and block_diff stays at 0 (no
functional change vs. pre-#883 prod).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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