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.tsGenerated: 2026-04-16 · Author: prisma-otel-engineer · Services traced:ebit-api. Companion todropbet-bet-place.md(#31 — write-side originates inside thebet_settled_queueworker documented there) anddropbet-wallet.md(#39 — shares theusd_amountUSD-conversion plumbing viaExchangeRatesService.toUsd). Scope: the read path (three HTTP GETs onLeaderboardController) 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) — LeaderboardController → ConfigService.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'stype: request_contextwrapper.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) — LeaderboardController → LeaderboardCrudService (flag-on only)¶
With RACE_ENABLED=true:
getLeaderboardsPubliccallsLeaderboardCrudService.findManyLeaderboardsPublic(query)— paginated list, noreq.userinjection.getActiveLeaderboardPubliccallsfindUniqueActiveLeaderboardPublic(type, req.user)—OptionalAuthGuardpopulatesreq.user, the service attaches the caller'scurrentUserrow to the DTO so the FE can badge their position even outside top-N.getLeaderboardByIdPubliccallsfindUniqueLeaderboardPublic(id)— reads any status (intended for "past races" UI).
4.3 Step (3) — LeaderboardCrudService → LeaderboardRepository (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_queue → BetQueueProcessor (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) — BetQueueProcessor → LeaderboardService.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) — LeaderboardService → LeaderboardRepository (find active + increment per LB)¶
processUserAction does two things, collapsed to one diagram edge:
findManyLeaderboardsActive()— same cached call as step (3), served from the in-process map (or miss-fills via step (8)). Returns 0–NLeaderboardDto(seeded fixture ships 3: DAILY/WEEKLY/MONTHLY).- 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¶
- SF-019 —
RACE_ENABLEDgates the entire controller, not the module. Three handlers each inlineif (configService.get('RACE_ENABLED') !== 'true') throw .... Removing the feature would leave dead endpoints; adding a new one invites forgetting the check. Either (a) conditionally mountLeaderboardModuleviaConditionalModule, (b) move the guard into a@UseGuards(RaceFlagGuard), or (c) drop the flag entirely and setstatus = FINISHEDon 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. - SF-020 —
LeaderboardQueueProduceris dead code.pushUserActionhas 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,handleBetalready writes directly) or move the UPSERT into the processor and havehandleBetenqueue — the latter would give the write path retry + backoff semantics the direct call lacks. - SF-021 — Write path has no OTel visibility.
leaderboardService.handleBetruns inside the BullMQbet_settled_queueworker, which is not auto-instrumented. The bet's HTTP trace ends at the 201; the subsequent Postgres UPSERT onleaderboard_useris invisible. Abet.leaderboard.increment_usd_amount{leaderboardId,userId}metric or a manualtracer.startSpan('leaderboard.handleBet')in the service would fill the gap. Wire alongside task #25. - SF-022 —
@Cacheableis in-process, not Redis.leaderboard:activelives in the localMapthat@type-cacheable/coredefault-instantiates.ebit-apiandebit-boare separate Nest apps (separate containers) so the cache is per-process.@CacheClearonupdateLeaderboardActiveStatusonly 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. - SF-023 —
leaderboard_user_position_viewrecomputes 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 aCREATE MATERIALIZED VIEW ... REFRESH CONCURRENTLYon the 60 s cache-invalidation tick would cap it. - SF-024 —
updateLeaderboardsruns once at boot, never cron'd. AnOnModuleInitcall +@IdempotencyLockis the only driver for status transitions and settlement. A DAILY race withend_date = 23:59:59.999staysACTIVEpast 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-fewallet/race widgets' poll interval was not profiled; with a 60 s@CacheableTTL 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_amountis summed regardless of the wageredcurrencyId. 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 stampsremainingUsdPrize = 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.tsmirrors the HTTP endpoints over rt (Private.LeaderboardFindUnique{Active,Finished}) with the sameRACE_ENABLEDgate. 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.