-
Notifications
You must be signed in to change notification settings - Fork 3
api: move tiredsolid scoreboard feed config to postgres #701
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
95d6f52
54f3d0e
d9f1eb8
0aed2bf
e31ad08
cc2c49d
86f86b7
2401f11
037079a
e42daca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| -- +goose Up | ||
| -- Scoreboard feed allow-list. Only enabled rows are raced, counted, or displayed on the | ||
| -- Hyperliquid scoreboard. Intentionally seeded with no rows: the rows are environment | ||
| -- config, inserted out of band, so they never live in this repository. | ||
| CREATE TABLE hyperliquid_scoreboard_entry ( | ||
| feed TEXT PRIMARY KEY, | ||
| label TEXT NOT NULL CHECK (length(label) BETWEEN 1 AND 64), | ||
| display_order INT NOT NULL DEFAULT 0, | ||
| enabled BOOLEAN NOT NULL DEFAULT TRUE, | ||
| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | ||
| ); | ||
|
|
||
| -- +goose Down | ||
| DROP TABLE IF EXISTS hyperliquid_scoreboard_entry; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,35 +13,105 @@ import ( | |
| "golang.org/x/sync/errgroup" | ||
| ) | ||
|
|
||
| // hyperliquidCompetitors lists the non-DoubleZero feeds shown on the scoreboard, | ||
| // in display order, mapping each raw feed name to its label. | ||
| var hyperliquidCompetitors = []struct{ Feed, Label string }{ | ||
| {"hyperliquid_public_bbo", "Public API"}, | ||
| {"hydromancer_bbo", "Hydromancer"}, | ||
| {"dwellir_l2book_bbo", "Dwellir"}, | ||
| {"quicknode_l2book_bbo", "QuickNode"}, | ||
| // hyperliquidFeedRe bounds feed ids to characters safe to inline into ClickHouse SQL. | ||
| // Config rows are operator-managed rather than user input, but the values are concatenated | ||
| // into queries, so anything outside this set is dropped rather than trusted. | ||
| var hyperliquidFeedRe = regexp.MustCompile(`^[A-Za-z0-9:_.-]{1,64}$`) | ||
|
|
||
| // hyperliquidEntry is one configured scoreboard feed. | ||
| type hyperliquidEntry struct{ Feed, Label string } | ||
|
|
||
| // hyperliquidEntries is the scoreboard's configured feed allow-list, loaded from Postgres. | ||
| // Only feeds present here are raced, counted, or displayed, so a feed is added, removed, or | ||
| // reordered by changing rows — no code change and no deploy. | ||
| type hyperliquidEntries struct { | ||
| ordered []hyperliquidEntry // display order | ||
| labels map[string]string // feed -> label | ||
| } | ||
|
|
||
| // hyperliquidExcludedFeeds are competitor feeds withheld from every scoreboard measurement | ||
| // (per-competitor, per-node, headline totals, and recent races) because their upstream data is | ||
| // currently unreliable. hyperpc_shared_bbo (HypeRPC) arrives a median ~100s stale during | ||
| // recurring multi-hour backlog episodes — vs ~300ms for the other feeds — which would render | ||
| // DoubleZero "winning" by seconds-to-minutes and is a HypeRPC-side feed fault, not a real result. | ||
| // Remove entries here (and re-add to hyperliquidCompetitors) once the feed is fixed. | ||
| var hyperliquidExcludedFeeds = []string{"hyperpc_shared_bbo"} | ||
|
|
||
| // hyperliquidExcludedFeedsClause returns a SQL predicate dropping excluded feeds from either | ||
| // side of a race, or "" if none are excluded. | ||
| func hyperliquidExcludedFeedsClause() string { | ||
| if len(hyperliquidExcludedFeeds) == 0 { | ||
| // empty reports whether any feed is configured. | ||
| func (e hyperliquidEntries) empty() bool { return len(e.ordered) == 0 } | ||
|
|
||
| // inClause returns the SQL predicate restricting a race to configured feeds, or "" if none | ||
| // are configured. Every race pairs one tob_* DoubleZero feed with one competing feed, and DZ | ||
| // feeds are never configured entries, so requiring either side to be in the set is equivalent | ||
| // to requiring the non-DZ side to be configured. | ||
| func (e hyperliquidEntries) inClause() string { | ||
| if e.empty() { | ||
| return "" | ||
| } | ||
| quoted := make([]string, len(hyperliquidExcludedFeeds)) | ||
| for i, f := range hyperliquidExcludedFeeds { | ||
| quoted[i] = "'" + f + "'" | ||
| quoted := make([]string, 0, len(e.ordered)) | ||
| for _, en := range e.ordered { | ||
| quoted = append(quoted, "'"+en.Feed+"'") | ||
| } | ||
| in := strings.Join(quoted, ", ") | ||
| return fmt.Sprintf("AND feed NOT IN (%[1]s) AND loser_feed NOT IN (%[1]s)", in) | ||
| return fmt.Sprintf("AND (feed IN (%[1]s) OR loser_feed IN (%[1]s))", in) | ||
| } | ||
|
|
||
| // label maps a raw feed to its configured display label (falls back to the raw name). | ||
| func (e hyperliquidEntries) label(feed string) string { | ||
| if l, ok := e.labels[feed]; ok { | ||
| return l | ||
| } | ||
| return feed | ||
| } | ||
|
|
||
| // display returns "DoubleZero" for any tob_ DZ feed, else the configured label. Used so a | ||
| // competitor-won recent race reads "<Label> … vs DoubleZero", not a raw tob_ id. | ||
| func (e hyperliquidEntries) display(feed string) string { | ||
| if strings.HasPrefix(feed, "tob_") { | ||
| return "DoubleZero" | ||
| } | ||
| return e.label(feed) | ||
| } | ||
|
|
||
| // loadHyperliquidScoreboardEntries reads the enabled scoreboard feeds from Postgres, in | ||
| // display order. Zero configured rows is not an error — it is the deliberate "nothing | ||
| // configured yet" state and returns a clean empty set with a nil error. A genuine load | ||
| // failure (query, scan, or row iteration) returns a non-nil error instead of degrading to an | ||
| // empty set: the caller must not treat a Postgres blip as "zero feeds configured", or the | ||
| // background refresher and page-cache worker would overwrite the last-good cached payload | ||
| // with an empty one. Logged at WARN, never ERROR — ERROR pages on-call. | ||
| func (a *API) loadHyperliquidScoreboardEntries(ctx context.Context) (hyperliquidEntries, error) { | ||
| e := hyperliquidEntries{labels: map[string]string{}} | ||
| if a.PgPool == nil { | ||
| return e, nil | ||
| } | ||
| rows, err := a.PgPool.Query(ctx, ` | ||
| SELECT feed, label FROM hyperliquid_scoreboard_entry | ||
| WHERE enabled ORDER BY display_order, feed`) | ||
| if err != nil { | ||
| slog.Warn("hyperliquid scoreboard entry load failed", "error", err) | ||
| return hyperliquidEntries{}, err | ||
| } | ||
| defer rows.Close() | ||
| for rows.Next() { | ||
| var feed, label string | ||
| if err := rows.Scan(&feed, &label); err != nil { | ||
| slog.Warn("hyperliquid scoreboard entry scan failed", "error", err) | ||
| return hyperliquidEntries{}, err | ||
| } | ||
| // A malformed feed would be inlined into SQL; drop it and keep serving the rest. | ||
| if !hyperliquidFeedRe.MatchString(feed) { | ||
| slog.Warn("hyperliquid scoreboard entry skipped: unsafe feed id", "feed", feed) | ||
| continue | ||
| } | ||
| // tob_ feeds are DoubleZero's own; the allow-list clause relies on them never being | ||
| // configured entries (see inClause). A tob_ config row would broaden the clause to | ||
| // match races against unconfigured competitors, leaking their raw feed ids into the | ||
| // public payload, so it is dropped rather than trusted. | ||
| if strings.HasPrefix(feed, "tob_") { | ||
| slog.Warn("hyperliquid scoreboard entry skipped: tob_ feed not allowed", "feed", feed) | ||
| continue | ||
| } | ||
| e.ordered = append(e.ordered, hyperliquidEntry{Feed: feed, Label: label}) | ||
| e.labels[feed] = label | ||
| } | ||
| if err := rows.Err(); err != nil { | ||
| slog.Warn("hyperliquid scoreboard entry iteration failed", "error", err) | ||
| return hyperliquidEntries{}, err | ||
| } | ||
| return e, nil | ||
| } | ||
|
|
||
| // hyperliquidWindows maps window params to ClickHouse interval expressions. | ||
|
|
@@ -176,23 +246,19 @@ type HyperliquidCompositeLatency struct { | |
|
|
||
| const hyperliquidCompositeLatencyCacheKey = "hyperliquid_composite_latency" | ||
|
|
||
| // labelForFeed maps a raw competitor feed to its display label (falls back to the raw name). | ||
| func labelForFeed(feed string) string { | ||
| for _, c := range hyperliquidCompetitors { | ||
| if c.Feed == feed { | ||
| return c.Label | ||
| } | ||
| } | ||
| return feed | ||
| } | ||
|
|
||
| // hyperliquidFeedDisplay returns "DoubleZero" for any tob_ DZ feed, else the competitor label. | ||
| // Used so a competitor-won recent race reads "Hydromancer … vs DoubleZero", not a raw tob_ id. | ||
| func hyperliquidFeedDisplay(feed string) string { | ||
| if strings.HasPrefix(feed, "tob_") { | ||
| return "DoubleZero" | ||
| // emptyHyperliquidScoreboard is the empty-but-valid response served when the scoreboard has | ||
| // nothing to compute — the proxied summary table is absent (e.g. local dev) or no feeds are | ||
| // configured. Returning this instead of an error keeps the page-cache refresher caching a | ||
| // clean payload rather than logging every cycle. | ||
| func emptyHyperliquidScoreboard(window string) *HyperliquidScoreboardResponse { | ||
| return &HyperliquidScoreboardResponse{ | ||
| Window: window, | ||
| GeneratedAt: time.Now().UTC(), | ||
| FeedType: "bbo", | ||
| Competitors: []HyperliquidCompetitor{}, | ||
| Nodes: []HyperliquidNode{}, | ||
| RecentRaces: []HyperliquidRace{}, | ||
| } | ||
| return labelForFeed(feed) | ||
| } | ||
|
|
||
| // FetchHyperliquidScoreboardData computes the aggregated scoreboard for a window and | ||
|
|
@@ -209,23 +275,30 @@ func (a *API) FetchHyperliquidScoreboardData(ctx context.Context, window, symbol | |
| // proxy/seed), return an empty-but-valid response so the page-cache refresher | ||
| // caches a clean empty payload instead of logging an error every cycle. | ||
| if !a.hyperliquidFeedsTableExists(ctx) { | ||
| return &HyperliquidScoreboardResponse{ | ||
| Window: window, | ||
| GeneratedAt: time.Now().UTC(), | ||
| FeedType: "bbo", | ||
| Competitors: []HyperliquidCompetitor{}, | ||
| Nodes: []HyperliquidNode{}, | ||
| RecentRaces: []HyperliquidRace{}, | ||
| }, nil | ||
| return emptyHyperliquidScoreboard(window), nil | ||
| } | ||
|
|
||
| // The configured feed allow-list drives which feeds are raced, counted, and labelled. | ||
| // A load failure propagates as an error so the caller (page-cache refresher, request | ||
| // handler) does not mistake it for "nothing configured" and overwrite a last-good cached | ||
| // payload with an empty one. Zero configured rows is not an error — with nothing | ||
| // configured there is no scoreboard to compute, so serve the empty-but-valid payload | ||
| // rather than scanning for feeds we would then discard. | ||
| entries, err := a.loadHyperliquidScoreboardEntries(ctx) | ||
| if err != nil { | ||
| return nil, err | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The live request path now hard-fails during a Postgres outage where it previously served. Before this PR a Pg outage made readPageCache miss and the handler still served the scoreboard live from ClickHouse (config was in code); now the live path dies here with a 500 — and there is no cached fallback either, because the page cache also lives in Postgres. Other pages degrade to live-serving during a Pg outage; this one now 500s. The propagate-don't-blank choice is right for the refreshers, but if scoreboard uptime through Pg maintenance matters, consider a process-local last-good copy of hyperliquidEntries (e.g. an atomic.Pointer on API, updated on every successful load) as a fallback when the load fails. |
||
| } | ||
| if entries.empty() { | ||
| return emptyHyperliquidScoreboard(window), nil | ||
| } | ||
|
|
||
| symbolFilter := hyperliquidLiquidSymbolFilter() | ||
| if symbol != "" { | ||
| symbolFilter = fmt.Sprintf("AND symbol = '%s'", symbol) | ||
| } | ||
| // Drop excluded (unreliable) competitor feeds from the per-competitor and per-node | ||
| // aggregations. Flows into both compQ and nodeQ via the shared symbolFilter slot. | ||
| symbolFilter = strings.TrimSpace(symbolFilter + " " + hyperliquidExcludedFeedsClause()) | ||
| // Restrict both aggregations to configured feeds. Flows into the scan via the shared | ||
| // symbolFilter slot. | ||
| symbolFilter = strings.TrimSpace(symbolFilter + " " + entries.inClause()) | ||
| db := fmt.Sprintf("`%s`", a.FeedsDB) | ||
|
|
||
| resp := &HyperliquidScoreboardResponse{ | ||
|
|
@@ -318,7 +391,7 @@ func (a *API) FetchHyperliquidScoreboardData(ctx context.Context, window, symbol | |
| return rows.Err() | ||
| }) | ||
| g.Go(func() error { | ||
| r, err := a.fetchHyperliquidRecentRaces(gctx, time.Time{}, 10) | ||
| r, err := a.fetchHyperliquidRecentRaces(gctx, time.Time{}, 10, entries) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
@@ -337,7 +410,7 @@ func (a *API) FetchHyperliquidScoreboardData(ctx context.Context, window, symbol | |
| } | ||
|
|
||
| // Emit competitors in configured order. | ||
| for _, c := range hyperliquidCompetitors { | ||
| for _, c := range entries.ordered { | ||
| s, ok := byFeed[c.Feed] | ||
| if !ok { | ||
| continue | ||
|
|
@@ -366,7 +439,7 @@ func (a *API) FetchHyperliquidScoreboardData(ctx context.Context, window, symbol | |
| Competitors: []HyperliquidCompetitor{}, | ||
| } | ||
| var wins, races uint64 | ||
| for _, c := range hyperliquidCompetitors { | ||
| for _, c := range entries.ordered { | ||
| s, ok := na.byFeed[c.Feed] | ||
| if !ok { | ||
| continue | ||
|
|
@@ -419,7 +492,7 @@ func (a *API) FetchHyperliquidScoreboardData(ctx context.Context, window, symbol | |
|
|
||
| // fetchHyperliquidRecentRaces returns the most recent races (one row per race, | ||
| // winner + closest competitor + lead). sinceTs zero -> last 5 minutes. | ||
| func (a *API) fetchHyperliquidRecentRaces(ctx context.Context, sinceTs time.Time, perSymbol int) ([]HyperliquidRace, error) { | ||
| func (a *API) fetchHyperliquidRecentRaces(ctx context.Context, sinceTs time.Time, perSymbol int, entries hyperliquidEntries) ([]HyperliquidRace, error) { | ||
| if perSymbol <= 0 || perSymbol > 50 { | ||
| perSymbol = 10 | ||
| } | ||
|
|
@@ -447,7 +520,7 @@ func (a *API) fetchHyperliquidRecentRaces(ctx context.Context, sinceTs time.Time | |
| GROUP BY capture_run_id, measurement_node_id, symbol, source_ts_ms, bbo_hash, location_code, feed | ||
| ORDER BY max_event_ts DESC | ||
| LIMIT %d BY symbol`, db, | ||
| strings.TrimSpace(hyperliquidRecentRaceSymbolFilter()+" "+hyperliquidExcludedFeedsClause()), timeFilter, perSymbol) | ||
| strings.TrimSpace(hyperliquidRecentRaceSymbolFilter()+" "+entries.inClause()), timeFilter, perSymbol) | ||
| rows, err := a.envDB(ctx).Query(ctx, q) | ||
| if err != nil { | ||
| return nil, err | ||
|
|
@@ -461,8 +534,8 @@ func (a *API) fetchHyperliquidRecentRaces(ctx context.Context, sinceTs time.Time | |
| return nil, err | ||
| } | ||
| r.IsDZ = isDZ == 1 | ||
| r.WinnerLabel = hyperliquidFeedDisplay(r.WinnerFeed) | ||
| r.RunnerUpLabel = hyperliquidFeedDisplay(r.RunnerUpFeed) | ||
| r.WinnerLabel = entries.display(r.WinnerFeed) | ||
| r.RunnerUpLabel = entries.display(r.RunnerUpFeed) | ||
| out = append(out, r) | ||
| } | ||
| return out, rows.Err() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| package handlers | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
| func TestHyperliquidEntries_InClause(t *testing.T) { | ||
| var empty hyperliquidEntries | ||
| assert.Equal(t, "", empty.inClause(), "no configured feeds must produce no predicate") | ||
|
|
||
| e := hyperliquidEntries{ordered: []hyperliquidEntry{ | ||
| {Feed: "feed_a_bbo", Label: "Feed A"}, | ||
| {Feed: "feed_b_bbo", Label: "Feed B"}, | ||
| }} | ||
| assert.Equal(t, | ||
| "AND (feed IN ('feed_a_bbo', 'feed_b_bbo') OR loser_feed IN ('feed_a_bbo', 'feed_b_bbo'))", | ||
| e.inClause()) | ||
| } | ||
|
|
||
| func TestHyperliquidEntries_Display(t *testing.T) { | ||
| e := hyperliquidEntries{labels: map[string]string{"feed_a_bbo": "Feed A"}} | ||
|
|
||
| // Any tob_ feed is DoubleZero regardless of config. | ||
| assert.Equal(t, "DoubleZero", e.display("tob_gcp_tyo_hl_mainnet1")) | ||
| assert.Equal(t, "Feed A", e.display("feed_a_bbo")) | ||
| // Unknown feeds fall back to the raw name. | ||
| assert.Equal(t, "feed_z_bbo", e.display("feed_z_bbo")) | ||
| } | ||
|
|
||
| func TestHyperliquidEntries_Empty(t *testing.T) { | ||
| var e hyperliquidEntries | ||
| assert.True(t, e.empty()) | ||
|
|
||
| e.ordered = []hyperliquidEntry{{Feed: "feed_a_bbo", Label: "Feed A"}} | ||
| assert.False(t, e.empty()) | ||
| } | ||
|
|
||
| func TestHyperliquidFeedRe(t *testing.T) { | ||
| assert.True(t, hyperliquidFeedRe.MatchString("feed_a_bbo")) | ||
| assert.True(t, hyperliquidFeedRe.MatchString("tob_gcp_tyo_hl_mainnet1")) | ||
| // Anything that could break out of a quoted SQL literal must be rejected. | ||
| assert.False(t, hyperliquidFeedRe.MatchString("bad'; DROP TABLE x --")) | ||
| assert.False(t, hyperliquidFeedRe.MatchString("")) | ||
| assert.False(t, hyperliquidFeedRe.MatchString("has space")) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
updated_at freshness relies on operators remembering
updated_at = NOW()in the runbook UPDATE. The repo's update_updated_at_column() trigger function (migrations 00001/00003) is attached to sibling tables for this; attach it here so a hand-typed UPDATE can't leave a stale timestamp.