Indexer + frontend P3 polish: COUNT(*) per page, leaderboard tie-breaker, silent catch in refresh section, post-end podium hints #170

Closed
opened 2026-05-06 06:12:36 +00:00 by Brouie · 11 comments
Brouie commented 2026-05-06 06:12:36 +00:00 (Migrated from gitlab.com)

four small hardening / consistency findings on the indexer + frontend, surfaced from a code review pass against 390f9be. none are blockers, all P3, lumped together since the fixes are tiny and tracking them as separate tickets would be heavier than the work.

Finding 1 — /v1/timecurve/buys runs full-table COUNT(*) on every paginated request

Severity: P3 perf

api.rs:381-390:

let total: i64 =
    match sqlx::query_scalar::<_, i64>("SELECT COUNT(*)::bigint FROM idx_timecurve_buy")
        .fetch_one(&state.pool).await { ... };

every paginated read does a sequential scan to populate total. on a busy launch with tens of thousands of buys, with the Simple page polling at refresh cadence and a couple dozen viewers paginating, this drives unnecessary pg cpu before anyone notices.

fix shape: either (a) cache total for ~30s in AppState, (b) replace with MAX(buy_index)::bigint (already 1-based per TimeCurve.sol:504), or (c) drop total from non-first pages by accepting it only when offset == 0. option (b) is one-line.

Finding 2 — referrer leaderboard pagination is non-deterministic on ties

Severity: P3 consistency

api.rs:1896-1903:

GROUP BY referrer
ORDER BY SUM(referrer_amount) DESC NULLS LAST
LIMIT $1 OFFSET $2

no secondary tie-breaker. when multiple referrers tie on summed amount, postgres returns them in arbitrary order — paginating with LIMIT/OFFSET can drop a referrer or duplicate one across pages. the rank field computed off i + 1 is wrong for ties. would surface under load even with modest traffic.

fix shape: append , referrer ASC (or , MIN(block_number) ASC for entry-time ranking) as a deterministic secondary key.

Finding 3 — TimeCurveProtocolWarbowRefreshSection.loadFromIndexer swallows exceptions silently

Severity: P3 UX

TimeCurveProtocolWarbowRefreshSection.tsx:88-132:

try {
  // 50-iter pagination loop, no catch
} finally {
  setLoadingIdx(false);
}

the null-from-fetch path is handled (sets loadErr with a friendly message), but anything inside the loop that throws (unexpected response shape, checksumCandidates exception, runtime promise rejection in setState) escapes upward. react logs to console, user sees the spinner stop with no message and an empty list — they will click again and again with no idea what is wrong.

fix shape: wrap with try { ... } catch (e) { setLoadErr(friendlyRevertFromUnknown(e)); setLoadedCandidates([]); setIdxExtras(null); } before the finally.

Finding 4 — refresh-candidates API merges podium hints post-endSale despite contract reverting

Severity: P3 consistency

api.rs:1006-1015:

if let Some(head) = guard.as_ref() {
    for w in &head.podium_contract[3].winners {
        if let Some(a) = normalize_refresh_candidate_addr(w) {
            if !podium_hints.contains(&a) {
                podium_hints.push(a);
            }
        }
    }
}

unconditional. no check on head.sale_ended. post-end, head.podium_contract[3] is the WarBow finalized snapshot — the indexer still merges those into the candidate response as if the refresh flow were live. contract reverts on refreshWarbowPodium post-end (per the API note about "Post-end operators must call owner finalizeWarbowPodium"), so the chain side is safe, but the API shape encourages clients to keep calling refresh. the frontend correctly gates on saleEnded upstream — the API itself does not.

fix shape: when head.sale_ended, either return 410 / drop hints from the response, or surface sale_ended: true in the body so the client does not have to cross-reference chain-timer separately.

Disposition

all four are independently fixable. happy to take any of them as a small MR if you want them off your queue — finding 2 is one line, finding 1 with option (b) is one line, finding 3 is one try/catch wrap, finding 4 needs an if head.sale_ended branch. flag and i can ship.

cc @PlasticDigits

four small hardening / consistency findings on the indexer + frontend, surfaced from a code review pass against `390f9be`. none are blockers, all P3, lumped together since the fixes are tiny and tracking them as separate tickets would be heavier than the work. ## Finding 1 — `/v1/timecurve/buys` runs full-table COUNT(*) on every paginated request Severity: P3 perf `api.rs:381-390`: ``` let total: i64 = match sqlx::query_scalar::<_, i64>("SELECT COUNT(*)::bigint FROM idx_timecurve_buy") .fetch_one(&state.pool).await { ... }; ``` every paginated read does a sequential scan to populate `total`. on a busy launch with tens of thousands of buys, with the Simple page polling at refresh cadence and a couple dozen viewers paginating, this drives unnecessary pg cpu before anyone notices. fix shape: either (a) cache total for ~30s in `AppState`, (b) replace with `MAX(buy_index)::bigint` (already 1-based per `TimeCurve.sol:504`), or (c) drop `total` from non-first pages by accepting it only when `offset == 0`. option (b) is one-line. ## Finding 2 — referrer leaderboard pagination is non-deterministic on ties Severity: P3 consistency `api.rs:1896-1903`: ``` GROUP BY referrer ORDER BY SUM(referrer_amount) DESC NULLS LAST LIMIT $1 OFFSET $2 ``` no secondary tie-breaker. when multiple referrers tie on summed amount, postgres returns them in arbitrary order — paginating with LIMIT/OFFSET can drop a referrer or duplicate one across pages. the `rank` field computed off `i + 1` is wrong for ties. would surface under load even with modest traffic. fix shape: append `, referrer ASC` (or `, MIN(block_number) ASC` for entry-time ranking) as a deterministic secondary key. ## Finding 3 — `TimeCurveProtocolWarbowRefreshSection.loadFromIndexer` swallows exceptions silently Severity: P3 UX `TimeCurveProtocolWarbowRefreshSection.tsx:88-132`: ``` try { // 50-iter pagination loop, no catch } finally { setLoadingIdx(false); } ``` the null-from-fetch path is handled (sets `loadErr` with a friendly message), but anything inside the loop that throws (unexpected response shape, `checksumCandidates` exception, runtime promise rejection in `setState`) escapes upward. react logs to console, user sees the spinner stop with no message and an empty list — they will click again and again with no idea what is wrong. fix shape: wrap with `try { ... } catch (e) { setLoadErr(friendlyRevertFromUnknown(e)); setLoadedCandidates([]); setIdxExtras(null); }` before the finally. ## Finding 4 — refresh-candidates API merges podium hints post-endSale despite contract reverting Severity: P3 consistency `api.rs:1006-1015`: ``` if let Some(head) = guard.as_ref() { for w in &head.podium_contract[3].winners { if let Some(a) = normalize_refresh_candidate_addr(w) { if !podium_hints.contains(&a) { podium_hints.push(a); } } } } ``` unconditional. no check on `head.sale_ended`. post-end, `head.podium_contract[3]` is the WarBow finalized snapshot — the indexer still merges those into the candidate response as if the refresh flow were live. contract reverts on `refreshWarbowPodium` post-end (per the API note about "Post-end operators must call owner finalizeWarbowPodium"), so the chain side is safe, but the API shape encourages clients to keep calling refresh. the frontend correctly gates on `saleEnded` upstream — the API itself does not. fix shape: when `head.sale_ended`, either return 410 / drop hints from the response, or surface `sale_ended: true` in the body so the client does not have to cross-reference chain-timer separately. ## Disposition all four are independently fixable. happy to take any of them as a small MR if you want them off your queue — finding 2 is one line, finding 1 with option (b) is one line, finding 3 is one try/catch wrap, finding 4 needs an `if head.sale_ended` branch. flag and i can ship. cc @PlasticDigits
PlasticDigits commented 2026-05-06 08:44:08 +00:00 (Migrated from gitlab.com)

mentioned in commit fff7946aaa

mentioned in commit fff7946aaa6f49499bbea6abd49e3992244d5423
PlasticDigits commented 2026-05-06 08:44:28 +00:00 (Migrated from gitlab.com)

Shipped in main (commit range includes schema 1.15.1)

What changed

  • GET /v1/timecurve/buys: total now uses COALESCE(MAX(buy_index),0) instead of a full-table COUNT(*) (GitLab #170 finding 1). Invariant: matches row count while buy_index stays sequential 1..N from TimeCurve Buy — documented as INV-INDEXER-170-BUYS-TOTAL in docs/testing/invariants-and-business-logic.md §170.
  • GET /v1/referrals/referrer-leaderboard: added deterministic tie-breaker , referrer ASC after the sum order (finding 2) — INV-INDEXER-170-REFERRAL-LB-TIE (note: JSON rank is still page ordinal, not dense competitive rank on ties).
  • TimeCurveProtocolWarbowRefreshSection: loadFromIndexer now catches unexpected errors and shows friendlyRevertFromUnknown instead of stopping the spinner with no copy (finding 3) — INV-FRONTEND-170-WARBOW-IDX-ERR.
  • GET /v1/timecurve/warbow/refresh-candidates: when head chain_timer has sale_ended, WarBow podium hints are not merged; response adds boolean sale_ended (finding 4 / align with #149). Schema 1.15.1; INV-INDEXER-170-WARBOW-REFRESH-POSTEND, updated INV-INDEXER-160-WARBOW-REFRESH-CANDIDATES.

Verification checklist (for humans / staging)

  • Indexer x-schema-version / /v1/status shows 1.15.1 on the deployed build.
  • curl "…/v1/timecurve/buys?limit=1&offset=0" — total matches expectation vs max buy index on a DB spot-check (SELECT MAX(buy_index), COUNT(*) FROM idx_timecurve_buy).
  • curl "…/v1/timecurve/warbow/refresh-candidates?limit=5" — JSON includes sale_ended; with an indexer that has chain-timer post-end, podium_warbow_hint_count should be 0 and hints absent from merged ordering.
  • /timecurve/protocol → Load candidates from indexer: induce a bad indexer URL or malformed mock — confirm a StatusMessage error appears (not a silent empty load).
  • Referrer leaderboard: under synthetic ties on Σ referrer CHARM, pagination does not duplicate/skip referrers across pages.

Requested verification: @brouie please sanity-check the above on staging or production indexer when convenient.

Issue left open until you sign off.

## Shipped in `main` (commit range includes schema **1.15.1**) ### What changed - **`GET /v1/timecurve/buys`**: `total` now uses `COALESCE(MAX(buy_index),0)` instead of a full-table `COUNT(*)` (GitLab #170 finding 1). Invariant: matches row count while `buy_index` stays sequential 1..N from `TimeCurve` `Buy` — documented as **`INV-INDEXER-170-BUYS-TOTAL`** in [`docs/testing/invariants-and-business-logic.md` §170](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/docs/testing/invariants-and-business-logic.md#gitlab-170-indexer-frontend-p3-polish). - **`GET /v1/referrals/referrer-leaderboard`**: added deterministic tie-breaker `, referrer ASC` after the sum order (finding 2) — **`INV-INDEXER-170-REFERRAL-LB-TIE`** (note: JSON `rank` is still page ordinal, not dense competitive rank on ties). - **`TimeCurveProtocolWarbowRefreshSection`**: `loadFromIndexer` now `catch`es unexpected errors and shows `friendlyRevertFromUnknown` instead of stopping the spinner with no copy (finding 3) — **`INV-FRONTEND-170-WARBOW-IDX-ERR`**. - **`GET /v1/timecurve/warbow/refresh-candidates`**: when head `chain_timer` has `sale_ended`, **WarBow podium hints are not merged**; response adds boolean `sale_ended` (finding 4 / align with #149). Schema **1.15.1**; **`INV-INDEXER-170-WARBOW-REFRESH-POSTEND`**, updated **`INV-INDEXER-160-WARBOW-REFRESH-CANDIDATES`**. ### Docs / third-party agent cross-links - [`docs/indexer/design.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/docs/indexer/design.md), [`docs/frontend/timecurve-views.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/docs/frontend/timecurve-views.md), [`docs/testing/manual-qa-checklists.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/docs/testing/manual-qa-checklists.md) (protocol WarBow refresh section), [`skills/README.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/skills/README.md) + [`skills/play-timecurve-warbow/SKILL.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/skills/play-timecurve-warbow/SKILL.md), [`.cursor/skills/yieldomega-guardrails/SKILL.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/.cursor/skills/yieldomega-guardrails/SKILL.md). ### Verification checklist (for humans / staging) - [ ] Indexer `x-schema-version` / `/v1/status` shows **1.15.1** on the deployed build. - [ ] `curl "…/v1/timecurve/buys?limit=1&offset=0"` — `total` matches expectation vs max buy index on a DB spot-check (`SELECT MAX(buy_index), COUNT(*) FROM idx_timecurve_buy`). - [ ] `curl "…/v1/timecurve/warbow/refresh-candidates?limit=5"` — JSON includes `sale_ended`; with an indexer that has chain-timer **post-end**, `podium_warbow_hint_count` should be **0** and hints absent from merged ordering. - [ ] `/timecurve/protocol` → **Load candidates from indexer**: induce a bad indexer URL or malformed mock — confirm a **StatusMessage** error appears (not a silent empty load). - [ ] Referrer leaderboard: under synthetic ties on Σ referrer CHARM, pagination does not duplicate/skip referrers across pages. **Requested verification:** @brouie please sanity-check the above on staging or production indexer when convenient. Issue left **open** until you sign off.
PlasticDigits commented 2026-05-06 09:09:52 +00:00 (Migrated from gitlab.com)

Follow-up for review (not shipped in #170): GET /v1/referrals/referrer-leaderboard still exposes rank as page ordinal (offset + row_index + 1). Deterministic ORDER BY …, referrer ASC fixes duplicate/skip rows across pages when Σ referrer CHARM ties, but dense competitive ranking (same numeric rank for tied totals, next rank skips — e.g. 1, 2, 2, 4) would need an explicit spec + API change (window function or two-phase query). Worth a separate ticket if product wants leaderboard UX to show “true” placement under ties.

**Follow-up for review (not shipped in #170):** `GET /v1/referrals/referrer-leaderboard` still exposes `rank` as **page ordinal** (`offset + row_index + 1`). Deterministic **`ORDER BY …, referrer ASC`** fixes duplicate/skip rows across pages when Σ referrer CHARM ties, but **dense competitive ranking** (same numeric rank for tied totals, next rank skips — e.g. 1, 2, 2, 4) would need an explicit spec + API change (window function or two-phase query). Worth a separate ticket if product wants leaderboard UX to show “true” placement under ties.
PlasticDigits commented 2026-05-06 12:37:08 +00:00 (Migrated from gitlab.com)

Yes we do need to show true placement under ties.

Yes we do need to show true placement under ties.
Brouie commented 2026-05-07 02:02:52 +00:00 (Migrated from gitlab.com)

mentioned in issue #175

mentioned in issue #175
Brouie commented 2026-05-07 02:09:59 +00:00 (Migrated from gitlab.com)

mentioned in issue #160

mentioned in issue #160
Brouie commented 2026-05-07 02:48:10 +00:00 (Migrated from gitlab.com)

mentioned in issue #177

mentioned in issue #177
Brouie commented 2026-05-07 02:49:02 +00:00 (Migrated from gitlab.com)

@PlasticDigits — verification PASS on the 4 P3 fixes shipped via schema 1.15.1 (since superseded to 1.16.1 by #168).

  1. /v1/timecurve/buys?limit=1&offset=0 total matches MAX(buy_index) exactly. live spot-check on the active sale at HEAD 284c15c:
API total:           26822
MAX(buy_index):      26822
COUNT(*):            26822

invariant INV-INDEXER-170-BUYS-TOTAL holds — total keeps stride with row count while buy_index stays sequential 1..N. full-table COUNT(*) replaced by COALESCE(MAX(buy_index),0) removes the per-page sequential scan.

  1. /v1/timecurve/warbow/refresh-candidates?limit=5 returns sale_ended as a top-level boolean (live response: sale_ended: false on the active sale, total: 21, podium_warbow_hint_count: 0, distinct_sql_cap_hit: false). field schema validated.

  2. TimeCurveProtocolWarbowRefreshSection.loadFromIndexer catch arm at lines 131-136 — explicit catch (e) { setLoadErr(friendlyRevertFromUnknown(e)); setLoadedCandidates([]); setIdxExtras(null); }. silent-fail closed for malformed responses, exceptions inside the page-loop, etc. (this also absorbed two of my #160 follow-up findings — flagged in the #160 close note.)

  3. /v1/referrals/referrer-leaderboard SQL has the deterministic , referrer ASC tiebreaker at api.rs:1919 post-fix. dup/skip across pages on tied sums no longer possible. only one referrer in the active sale dataset (single swarm code) so couldn\t exercise paginated ties on live data, but source-side check confirms the change.

  4. x-schema-version: 1.16.1 in response headers (post-#168). cargo clippy --all-targets -- -D warnings && cargo test clean — 42/42 PASS at HEAD 284c15c.

item 4 of your verification checklist (Load candidates → bad indexer URL → confirm StatusMessage error in the live UI) — live UX walk pending laptop session, parking on the live walk debt list. source path is straightforward, the catch arm shipped in fff7946 is the same code path.

on your dense-rank follow-up — opened #177 with the full design (RANK() window function + inner subquery + handler row-mapping + schema bump + integration test) and offered to ship the MR.

ready for close on your side.

@PlasticDigits — verification PASS on the 4 P3 fixes shipped via schema 1.15.1 (since superseded to 1.16.1 by #168). 1. `/v1/timecurve/buys?limit=1&offset=0` `total` matches `MAX(buy_index)` exactly. live spot-check on the active sale at HEAD `284c15c`: ``` API total: 26822 MAX(buy_index): 26822 COUNT(*): 26822 ``` invariant `INV-INDEXER-170-BUYS-TOTAL` holds — `total` keeps stride with row count while `buy_index` stays sequential 1..N. full-table COUNT(*) replaced by `COALESCE(MAX(buy_index),0)` removes the per-page sequential scan. 2. `/v1/timecurve/warbow/refresh-candidates?limit=5` returns `sale_ended` as a top-level boolean (live response: `sale_ended: false` on the active sale, `total: 21`, `podium_warbow_hint_count: 0`, `distinct_sql_cap_hit: false`). field schema validated. 3. `TimeCurveProtocolWarbowRefreshSection.loadFromIndexer` catch arm at lines 131-136 — explicit `catch (e) { setLoadErr(friendlyRevertFromUnknown(e)); setLoadedCandidates([]); setIdxExtras(null); }`. silent-fail closed for malformed responses, exceptions inside the page-loop, etc. (this also absorbed two of my #160 follow-up findings — flagged in the #160 close note.) 4. `/v1/referrals/referrer-leaderboard` SQL has the deterministic `, referrer ASC` tiebreaker at `api.rs:1919` post-fix. dup/skip across pages on tied sums no longer possible. only one referrer in the active sale dataset (single swarm code) so couldn\\t exercise paginated ties on live data, but source-side check confirms the change. 5. `x-schema-version: 1.16.1` in response headers (post-#168). `cargo clippy --all-targets -- -D warnings && cargo test` clean — 42/42 PASS at HEAD `284c15c`. item 4 of your verification checklist (Load candidates → bad indexer URL → confirm StatusMessage error in the live UI) — live UX walk pending laptop session, parking on the live walk debt list. source path is straightforward, the catch arm shipped in `fff7946` is the same code path. on your dense-rank follow-up — opened #177 with the full design (RANK() window function + inner subquery + handler row-mapping + schema bump + integration test) and offered to ship the MR. ready for close on your side.
Brouie commented 2026-05-07 03:11:42 +00:00 (Migrated from gitlab.com)

mentioned in merge request !376

mentioned in merge request !376
Brouie commented 2026-05-07 03:19:40 +00:00 (Migrated from gitlab.com)

mentioned in merge request !377

mentioned in merge request !377
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-05-09 04:58:05 +00:00
PlasticDigits commented 2026-05-13 15:13:34 +00:00 (Migrated from gitlab.com)

mentioned in commit 456ac1deab

mentioned in commit 456ac1deab53a6e6e0487e0f4507b2883100aa3c
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
code/yieldomega#170
No description provided.