Skip to content

Flow: dropbet leaderboard — flag-gated read + always-on bet upsert

List trace: c5196133eeb90ee78f22a149c4c11f4a (17 spans, 6.3 ms — 404 short-circuit, no DB hit) · Jaeger: http://localhost:16686/ · E2E: tests-e2e/tests/dropbet-leaderboard.spec.ts Generated: 2026-04-16 · Author: prisma-otel-engineer · Services traced: ebit-api. Companion to dropbet-bet-place.md (#31 — write-side originates inside the bet_settled_queue worker documented there) and dropbet-wallet.md (#39 — shares the usd_amount USD-conversion plumbing via ExchangeRatesService.toUsd). Scope: the read path (three HTTP GETs on LeaderboardController) plus the write trigger fired from bet settlement. PUT /admin/leaderboards/* (bo backoffice) and the gateway variant (apps/api/src/leaderboard/leaderboard.gateway.controller.ts) are out of scope — separate task #40.

1. User-visible contract

Three read endpoints on LeaderboardController (apps/api/src/leaderboard/leaderboard.controller.ts), every one gated on configService.get('RACE_ENABLED') === 'true'. The local ebit-api container's Doppler config does not set the flag, so all three return 404 ApiCode.NOT_FOUND in the dev environment — the E2E pins that state. None of them are JWT-guarded; the per-type / per-id endpoints use OptionalAuthGuard so anonymous reads are fine when the flag is on.

List Per-type (active) Per-id (history)
Method · path GET /leaderboards GET /leaderboards/:type GET /leaderboards/id/:id
Query / params FindManyLeaderboardPublicQuery (paginated) type ∈ {DAILY,WEEKLY,MONTHLY} via ParseEnumPipe id: uuid
Auth none (flag only) OptionalAuthGuard OptionalAuthGuard
Throttle global bucket only same same
Flag-on response PaginatedDto<LeaderboardPublicDto> LeaderboardPublicDto (active row for that type, includes top-N users + caller's position if req.user is set) same
Flag-off response 404 {code: 'NOT_FOUND', message: 'Leaderboard not found'} same same

LeaderboardPublicDto projects { id, type, status, startDate, endDate, config: { prizes: [{ position, usdPrize }] }, leaderboardUsers: top-3 by usdAmount DESC, currentUser? }. The per-type handler injects req.user (when present) so the FE can badge the user's row even if they aren't in the top N. ParseEnumPipe rejects unknown :type values with a 400 before the flag check runs (E2E pins this ordering).

The write side is trigger-driven, not UI-driven: every settled bet from any house game / slot / sportsbook fires leaderboardService.handleBet(bet) from the bet_settled_queue worker, which upserts leaderboard_user.usd_amount += bet.usdAmount for every ACTIVE leaderboard the user qualifies for. The E2E verifies this against Postgres even with the read flag off: placing a dice bet increments the user's DAILY, WEEKLY, and MONTHLY rows atomically (all three are ACTIVE from the seed).

2. Sequence diagram

sequenceDiagram
  participant U as Browser
  participant C as LeaderboardController
  participant CfgS as ConfigService
  participant S as LeaderboardCrudService
  participant Repo as LeaderboardRepository
  participant MemCache as @type-cacheable (in-process)
  participant PG as Postgres
  participant BetW as BetQueueProcessor (bet_settled_queue)
  participant LSvc as LeaderboardService
  alt Read (flag off — default in local)
    U->>C: GET /leaderboards[/DAILY|/id/:id]
    C->>CfgS: get('RACE_ENABLED')
    CfgS-->>C: undefined
    C-->>U: 404 {code: NOT_FOUND, message: 'Leaderboard not found'}
  else Read (flag on)
    U->>C: GET /leaderboards/DAILY
    C->>CfgS: get('RACE_ENABLED') === 'true'
    C->>S: findUniqueActiveLeaderboardPublic(type, req.user)
    S->>Repo: findManyLeaderboardsActive()
    Repo->>MemCache: get leaderboard:active (60 s TTL)
    alt cache miss
      Repo->>PG: findMany leaderboard (status=ACTIVE) + include top-N users with position view
      PG-->>Repo: rows + ranks (row_number via leaderboard_user_position_view)
      Repo->>MemCache: set leaderboard:active (60 s)
    end
    S-->>C: LeaderboardPublicDto
    C-->>U: 200
  else Write (bet settles)
    BetW->>LSvc: handleBet(bet, delayMs) [bet.queue-processor.ts:107]
    LSvc->>LSvc: processUserAction({key:bet.id, userId, usdAmount}) [@PrismaTransactional]
    LSvc->>Repo: findManyLeaderboardsActive() — serves from MemCache if hot
    loop per active leaderboard
      LSvc->>PG: leaderboardUser.upsert({update:{usdAmount:{increment: N}}, create:{...}})
    end
    Note over LSvc: NO rt push · NO Redis publish · handleBet is fire-and-forget
  end

The captured read trace (c5196133ee…) is the flag-off short-circuit: 17 spans, 6.3 ms, the entire body collapses to LeaderboardController.getLeaderboardsPublic (1.9 ms) + Nest's getLeaderboardsPublic handler span (0.33 ms) + middleware prefix — zero Prisma spans, the throw fires before leaderboardCrudService is even called. This is the only span shape locally reachable until someone flips RACE_ENABLED=true in Doppler (§6 SF-019).

3. Component diagram

Edges are numbered in request-flow order. Section §4 below has the same numbers — each (N) on the diagram has its own §4.N subsection, so you can click straight through. Steps (1)–(4) cover the read path (flag-gated controller → repo → Postgres); steps (5)–(9) cover the write trigger fired from the bet_settled_queue worker on every settled bet.

flowchart TD
    %% Datastores
    pg[("Postgres<br/>leaderboard · leaderboard_user · leaderboard_user_position_view (VIEW)")]
    rd[("Redis (cache)<br/>bull:bet_settled_queue · bull:leaderboard_queue (dead, SF-020)")]
    mem[("In-process cache<br/>leaderboard:active · @type-cacheable 60 s (SF-022)")]

    %% NestJS process — read path
    subgraph api["ebit-api (NestJS) — read path"]
        ctrl["LeaderboardController<br/><i>GET /leaderboards · /:type · /id/:id</i>"]
        cfg["ConfigService.get<br/><i>RACE_ENABLED flag gate (SF-019)</i>"]
        crud["LeaderboardCrudService<br/><i>findMany/unique LeaderboardsPublic</i>"]
        repo["LeaderboardRepository<br/><i>findManyLeaderboardsActive · incrementLeaderboardUserUsdAmount</i>"]
    end

    %% Bet worker subgraph — same process, separate consumer (mirrors dropbet-bet-place §3)
    subgraph wk["Bet worker (same process, separate consumer) — write trigger"]
        betProc["BetQueueProcessor<br/><i>@Processor bet_settled_queue · line 107</i>"]
        svc["LeaderboardService.handleBet<br/><i>processUserAction · @PrismaTransactional</i>"]
    end

    %% (1)-(4) Read path
    ctrl -- "(1) RACE_ENABLED probe" --> cfg
    ctrl -- "(2) findUnique/findManyPublic (flag-on only)" --> crud
    crud -- "(3) status-filtered repo call" --> repo
    repo -- "(4) Leaderboard + LeaderboardUser + position view" --> pg

    %% (5)-(9) Write trigger from bet_settled_queue (separate trace, AF-2)
    rd -- "(5) job pulled (bet_settled_queue)" --> betProc
    betProc -- "(6) handleBet(bet, delayMs)" --> svc
    svc -- "(7) find active + increment per LB" --> repo
    repo -- "(8) @Cacheable read/fill (60 s in-proc)" --> mem
    repo -- "(9) upsert leaderboardUser ×N (SF-021 — no OTel)" --> pg

    %% Style: datastores stand out
    classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
    class pg,rd,mem db;

4. Per-step walkthrough

Section headers below mirror the diagram step numbers in §3 — each §4.N covers (N) on the diagram. The captured trace c5196133ee… is the flag-off short-circuit and only contains steps (1) → 404. Steps (2)–(4) require RACE_ENABLED=true (not set locally, SF-019). Steps (5)–(9) live in the bet_settled_queue worker and are a separate trace (SF-021 — same instrumentation gap as AF-2 in ../weaknesses-register.md).

4.1 Step (1) — LeaderboardControllerConfigService.get('RACE_ENABLED')

Handler at leaderboard.controller.ts:31-38. First line of every endpoint (getLeaderboardsPublic, getActiveLeaderboardPublic, getLeaderboardByIdPublic) is if (configService.get('RACE_ENABLED') !== 'true') throw new ApiException(NOT_FOUND). The flag is a startup read; changing it at runtime requires a container restart (Nest's ConfigService caches the parsed env).

The captured trace contains exactly this short-circuit:

  • 12 middleware spans (cors, cookieParser, session, authenticate, …) — identical to the sign-in prefix.
  • request handler - /leaderboards (0.03 ms).
  • LeaderboardController.getLeaderboardsPublic (1.92 ms) — Nest's type: request_context wrapper.
  • getLeaderboardsPublic (0.33 ms, type: handler) — the actual method, throws immediately.
  • No Prisma spans, no Redis cache hits beyond the global auth prefix.

ParseEnumPipe on /:type runs before the flag check, so a request to GET /leaderboards/garbage returns 400 even when the flag is off — pinned by the E2E.

4.2 Step (2) — LeaderboardControllerLeaderboardCrudService (flag-on only)

With RACE_ENABLED=true:

  • getLeaderboardsPublic calls LeaderboardCrudService.findManyLeaderboardsPublic(query) — paginated list, no req.user injection.
  • getActiveLeaderboardPublic calls findUniqueActiveLeaderboardPublic(type, req.user)OptionalAuthGuard populates req.user, the service attaches the caller's currentUser row to the DTO so the FE can badge their position even outside top-N.
  • getLeaderboardByIdPublic calls findUniqueLeaderboardPublic(id) — reads any status (intended for "past races" UI).

4.3 Step (3) — LeaderboardCrudServiceLeaderboardRepository (status filter)

The CRUD service is a thin pass-through that injects the status/type filter shape and the LeaderboardDto.prismaInclude(...) projection. List endpoint uses {withUsers: false}; per-type / per-id endpoints opt-in withUsers: true to pull the top-N + position-view join.

findManyLeaderboardsActive (called by both step (3) for /:type and step (7) below) is the only cached method — @Cacheable({ cacheKey: LEADERBOARD_ACTIVE_KEY, ttlSeconds: 60 }). The list endpoint (findManyLeaderboards) is uncached and runs prisma.leaderboard.count + findMany in a Promise.all.

4.4 Step (4) — LeaderboardRepository → Postgres (Leaderboard + position view)

The Prisma read joins Leaderboard with LeaderboardUser and the leaderboard_user_position_view VIEW (SELECT id, ROW_NUMBER() OVER (PARTITION BY leaderboard_id ORDER BY usd_amount DESC) position FROM leaderboard_user). The view recomputes window functions on every query — fine at top-3 cardinality, expensive on full pagination (SF-023).

GET /leaderboards/id/:id additionally surfaces the finalPosition column stamped at ACTIVE→ENDED transition. The stamping is raw SQL: updateLeaderboardActiveStatus (leaderboard.repository.ts:66-90) issues UPDATE leaderboard SET status = ENDED WHERE NOW() >= end_date AND status = ACTIVE RETURNING id, then UPDATE leaderboard_user SET final_position = ... FROM (SELECT ROW_NUMBER() OVER (...)) final_positions WHERE final_positions.id = leaderboard_user.id. @CacheClear({ cacheKey: LEADERBOARD_ACTIVE_KEY }) on that method wipes the in-process list cache when anyone transitions status — only on the node that ran the sweep (SF-022).

4.5 Step (5) — bet_settled_queueBetQueueProcessor (separate trace)

Every settled bet (house game, slots, sportsbook — the bet queue processes all of them) lands on bull:bet_settled_queue (see dropbet-bet-place.md §4.6 step (8) for the producer side). BetQueueProcessor picks the job up; this is a separate trace rooted on BullMQJob bet_settled_queue because @nestjs/bullmq doesn't propagate the producing request's traceparent through job payload (same gap as dropbet-bet-place.md §4.9 / AF-2).

4.6 Step (6) — BetQueueProcessorLeaderboardService.handleBet

bet.queue-processor.ts:107 calls leaderboardService.handleBet(bet, delayMs). The method unpacks the BetDto into { key: bet.id, userId, usdAmount } and delegates to processUserAction — decorated @PrismaTransactional so steps (7)–(9) run in one transaction.

4.7 Step (7) — LeaderboardServiceLeaderboardRepository (find active + increment per LB)

processUserAction does two things, collapsed to one diagram edge:

  1. findManyLeaderboardsActive() — same cached call as step (3), served from the in-process map (or miss-fills via step (8)). Returns 0–N LeaderboardDto (seeded fixture ships 3: DAILY/WEEKLY/MONTHLY).
  2. For each active leaderboard, calls incrementLeaderboardUserUsdAmount({ leaderboardId, userId, incrementUsdAmount: bet.usdAmount }). Detailed per-LB multiplicity is on step (9).

4.8 Step (8) — LeaderboardRepository → in-process @Cacheable cache

leaderboard:active lives in the local Map that @type-cacheable/core default-instantiates — not Redis, so ebit-api and any other Nest process (e.g. ebit-bo) maintain independent copies. 60 s TTL, invalidated by @CacheClear on status sweeps inside the same process only. On cache miss the repo issues prisma.leaderboard.findMany({where: {status: ACTIVE}, include: {leaderboardUsers: top-N}}) (functionally a sub-edge of step (9)).

4.9 Step (9) — LeaderboardRepository → Postgres (upsert ×N, NO OTel)

leaderboard.repository.ts:237-265 issues prisma.leaderboardUser.upsert({ where: userId_leaderboardId, update: { usdAmount: { increment: N } }, create: {...defaults..., usdAmount: N} }) — one call per active leaderboard. First-bet-of-window creates the row; subsequent bets increment. isWinner = false, remainingUsdPrize = 0, isPrizeReceived = false default on the create branch. The @@unique([userId, leaderboardId]) constraint backs the upsert key.

OTel gap (SF-021). Because step (5) restarts trace context, the originating POST /casino/games/house/dice/bet trace (8408132830cf…) contains zero LeaderboardUser Prisma spans even though the E2E confirms via Postgres that the upsert ran. Fix lands with task #36 (rt flow) — propagating traceparent through ContextQueueData lets the worker start a child span.

Out of band — settlement (onModuleInit, no diagram edge)

LeaderboardService.onModuleInit (:30-32) calls updateLeaderboards(), decorated with @IdempotencyLock({ key: 'updateLeaderboardsActivity', lockTtl: 30 s, throwOnLock: false }) — single node runs it at boot. The method: (a) moves ACTIVE→ENDED by end_date, stamps final_position; (b) updateSettlementLeaderboards() iterates ENDED leaderboards, moves top-N (≤ LEADERBOARD_MAX_PRIZE_POSITION = 50) to isWinner = true with remainingUsdPrize from config.prizes[i]; (c) finishSettledLeaderboards() moves SETTLEMENT→FINISHED once every winner's remainingUsdPrize = 0 AND isPrizeReceived = true (prizes paid through givePrizeLeaderboardUser); (d) updateScheduledLeaderboards() creates the next race from each enabled LeaderboardSchedule. No cron is wired — see SF-024. Not numbered because no inbound request edge triggers it.

5. Data model

Object R/W Fields touched Notes
Leaderboard (api.prisma:1285-1320) R (list / per-type / per-id) · W (status transitions, $queryRaw) id, type, status, startDate, endDate, config (Json), scheduleId Indexes: @@index([type]), @@index([status]), @@index([status,type]), @@index([endDate]) — the last supports the NOW() >= end_date status sweep.
LeaderboardUser (api.prisma:1334-1360) R (top-N + current user's rank) · W (upsert per bet, settle/pay-prize) userId, leaderboardId, usdAmount, finalPosition, isWinner, remainingUsdPrize, isPrizeReceived @@unique([userId, leaderboardId]) (idempotency for the upsert), @@index([leaderboardId]), @@index([usdAmount]) — the usdAmount index backs the ORDER BY DESC LIMIT 3 top-N.
LeaderboardUserPositionView (view) R id, position SELECT id, ROW_NUMBER() OVER (PARTITION BY leaderboard_id ORDER BY usd_amount DESC) — recomputed on every query, no materialisation.
LeaderboardSchedule (api.prisma:1375) R (boot) · W (admin only) id ∈ {'daily','weekly','monthly'}, enabled, config (Json {name, type, prizes, description}) Seeded by libs/_prisma/src/seed/index.ts; the admin schedule editor (out of scope) is the only writer.
Redis bull:bet_settled_queue R job data { bet, notificationDelayMs } entry-point into the write path; bull:leaderboard_queue exists but receives nothing (SF-020).
In-process cache key leaderboard:active R/W LeaderboardDto[] (ACTIVE rows, users not included) @type-cacheable/core map-based cache, 60 s TTL, @CacheClear on status transitions. Not Redis — invisible to other NestJS apps.

The enum on Leaderboard.status is { NOT_STARTED, ACTIVE, ENDED, SETTLEMENT, FINISHED }. Only ACTIVE rows receive bet increments (the findManyLeaderboardsActive filter is the gate), so a race past its endDate stops accruing wagers as soon as updateLeaderboardActiveStatus runs — which, as noted, is boot-only.

6. Failure modes

  1. SF-019 — RACE_ENABLED gates the entire controller, not the module. Three handlers each inline if (configService.get('RACE_ENABLED') !== 'true') throw .... Removing the feature would leave dead endpoints; adding a new one invites forgetting the check. Either (a) conditionally mount LeaderboardModule via ConditionalModule, (b) move the guard into a @UseGuards(RaceFlagGuard), or (c) drop the flag entirely and set status = FINISHED on all leaderboards to hide them. The flag is off in local Doppler, so the feature is effectively dark to every dev — worth making that explicit in .example.env.
  2. SF-020 — LeaderboardQueueProducer is dead code. pushUserAction has zero call sites (grep confirms). @Processor(leaderboard_queue) subscribes to a queue no producer writes to. Dev-time overhead is minor (stalled-interval polling on Redis) but the class is misleading — anyone refactoring assumes it's the real path. Either delete the producer + processor (Postgres is the source of truth, handleBet already writes directly) or move the UPSERT into the processor and have handleBet enqueue — the latter would give the write path retry + backoff semantics the direct call lacks.
  3. SF-021 — Write path has no OTel visibility. leaderboardService.handleBet runs inside the BullMQ bet_settled_queue worker, which is not auto-instrumented. The bet's HTTP trace ends at the 201; the subsequent Postgres UPSERT on leaderboard_user is invisible. A bet.leaderboard.increment_usd_amount{leaderboardId,userId} metric or a manual tracer.startSpan('leaderboard.handleBet') in the service would fill the gap. Wire alongside task #25.
  4. SF-022 — @Cacheable is in-process, not Redis. leaderboard:active lives in the local Map that @type-cacheable/core default-instantiates. ebit-api and ebit-bo are separate Nest apps (separate containers) so the cache is per-process. @CacheClear on updateLeaderboardActiveStatus only wipes the process that ran the status sweep — the other node serves stale for up to 60 s. Switching to the @type-cacheable/ioredis-adapter (already in dependency tree for other caches) would coordinate across nodes.
  5. SF-023 — leaderboard_user_position_view recomputes ROW_NUMBER every query. No materialisation, no index can short-circuit the window function. At the per-leaderboard cardinality of "top-3 for the DAILY race", fine. At full-leaderboard pagination — findManyLeaderboardUsers(leaderboardId, { take: 50, page: N }) joins the view for every page — each page scans all rows in the partition. Materialising as a CREATE MATERIALIZED VIEW ... REFRESH CONCURRENTLY on the 60 s cache-invalidation tick would cap it.
  6. SF-024 — updateLeaderboards runs once at boot, never cron'd. An OnModuleInit call + @IdempotencyLock is the only driver for status transitions and settlement. A DAILY race with end_date = 23:59:59.999 stays ACTIVE past midnight until something triggers a restart. Admin endpoints can call it manually, but the product expectation is presumably automatic. Add a @nestjs/schedule @Cron('*/5 * * * *') alongside the init call.

7. Unresolved

  • FE polling cadence unobserved. The ebit-fe wallet/race widgets' poll interval was not profiled; with a 60 s @Cacheable TTL the ideal poll is ≥ 60 s, but we don't know if the FE respects that. Wire a counter on cache hit-rate once the flag flips on.
  • No per-currency leaderboard. Prizes are USD-denominated and usd_amount is summed regardless of the wagered currencyId. A DBC-only or crypto-only leaderboard would need a schema change. Called out so product doesn't plan around the current shape.
  • Schedule config is config.prizes: [{position, usdPrize}] JSON — no typing at the Prisma layer. A typo in the seed puts bad prize data into a running race with no loud failure; settlement happily stamps remainingUsdPrize = undefined → 0. Either a JSON schema validator at seed time or a typed migration would help.
  • WS-side variant. apps/api/src/leaderboard/leaderboard.gateway.controller.ts mirrors the HTTP endpoints over rt (Private.LeaderboardFindUnique{Active,Finished}) with the same RACE_ENABLED gate. Same behavioural notes apply; folded into task #36 (rt flow).
  • Admin path deferred. admin.leaderboard.controller.ts (backoffice) exposes create/settle/give-prize and has its own auth + throttle story — scoped to task #40.