6.6 KiB
Player Period Stats Read Model
Objective
Define the PostgreSQL read model that backs personal player stats by period so /api/stats/players/{player_id} does not aggregate large RCON historical tables on every public request when a regenerated read model is available.
Table
Primary table:
player_period_stats
Operational scope:
- PostgreSQL is the default operational store
- SQLite remains available only as explicit local compatibility for validation and isolated maintenance runs
Read-model nature:
- this table is regenerable
- it is not the canonical source of truth
- canonical historical data remains in
rcon_materialized_matchesandrcon_match_player_stats
Stored Fields
Each row stores one player projection for one public scope and one active period window:
period_typewindow_kindperiod_startperiod_endserver_idplayer_idplayer_namematches_consideredkillsdeathsteamkillsranking_positionkd_ratiokills_per_matchfirst_seen_atlast_seen_atupdated_at
Current period types:
weeklymonthlyyearly
Current scope rows:
all-serverscomunidad-hispana-01comunidad-hispana-02
Why server_id exists:
- the public profile endpoint already supports
server_id - keeping one row per scope preserves the current frontend contract without recalculating large runtime aggregates for server-filtered profiles
Why window_kind exists:
- the profile payload already exposes weekly and monthly ranking context
- persisting
current-week,previous-week,current-monthorprevious-monthkeeps the public contract stable without recomputing that selection on each request
Period Windows
Window selection follows the same policy already used by ranking/stats reads where applicable:
weekly- uses
select_leaderboard_window(... timeframe="weekly") - current week is used only when the closed-match threshold is sufficient
- otherwise the read model stores the previous week window
- uses
monthly- uses
select_leaderboard_window(... timeframe="monthly") - from day 1 to day 7 it stores the previous month
- from day 8 onward it stores the current month
- uses
yearly- stores the current UTC year from January 1 until refresh time
- it is prepared internally even if the current public profile contract still requests weekly/monthly only
Refresh Command
Manual command:
python -m app.rcon_historical_player_stats refresh-player-period-stats
SQLite-only local override:
python -m app.rcon_historical_player_stats refresh-player-period-stats --sqlite-path backend/data/hll_vietnam_dev.sqlite3
Refresh policy:
- rebuild from materialized RCON/AdminLog tables
- replace rows scope by scope and period by period
- keep the latest player name seen inside the selected period window
- persist ranking position by kills inside each generated period window
Automatic runner refresh:
backend/app/historical_runner.pyrefreshesplayer_period_statsautomatically- it inherits the periodic cadence of the historical runner via
HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS - the runner executes this step after
player_search_indexand beforeranking_snapshots - the runner still attempts this step even if the legacy historical snapshot block fails earlier in the same cycle
- the overall runner result may end as
partialwhen that legacy block fails but operational PostgreSQL refreshes continue - the public profile path keeps runtime fallback preserved if the read model is empty, incomplete or unavailable
Emergency manual command:
python -m app.rcon_historical_player_stats refresh-player-period-stats
Public Read Path
Priority for /api/stats/players/{player_id}:
- use
player_period_statswhen the requested scope has generated rows for the required periods - serve the requested weekly/monthly totals and ranking context directly from the read model
- fall back to runtime aggregation only when:
- the read model table is unavailable
- the requested scope has no generated rows yet
- the player has no generated row for one required period
- a controlled read error occurs
Returned compatibility:
- the payload still returns
player_id - the payload still returns
player_name - the payload still returns
matches_considered - the payload still returns
kills - the payload still returns
deaths - the payload still returns
teamkills - the payload still returns
kd_ratio - the payload still returns
kills_per_match - the payload still returns weekly/monthly ranking blocks
Source metadata:
- read-model path reports
source.read_model = "player-period-stats" - read-model path reports
source.fallback_used = false - runtime fallback keeps the same public contract and reports
source.fallback_used = true
PostgreSQL Notes
No extra PostgreSQL extensions are required.
Indexes kept for the public profile flow:
(player_id, period_type, server_id)(server_id, period_type)last_seen_atupdated_at
Production Validation
Recommended checks after refresh:
- confirm the historical runner output reports either
status=okorstatus=partial - confirm
historical_snapshot_result,player_search_index_result,player_period_stats_resultandranking_snapshot_resultare present in the cycle payload - if the cycle is
partial, inspecthistorical_snapshot_result.error_type,historical_snapshot_result.errorand the runner logs for the legacy failure - confirm
player_period_stats.updated_atadvanced even when a legacy snapshot error was reported - confirm the historical runner output reports
player_period_stats_result - if an emergency rebuild is needed, run
python -m app.rcon_historical_player_stats refresh-player-period-stats - confirm the command reports rows for:
all-serverscomunidad-hispana-01comunidad-hispana-02weeklymonthlyyearly
- call
/api/stats/players/<known-player>?timeframe=weekly - verify response metadata reports
read_model=player-period-stats - verify response metadata reports
fallback_used=falsewhen the read model is populated - verify fallback metadata appears only when the read model is empty, incomplete or unavailable
Current Limitations
- the public route still exposes weekly/monthly only; yearly is prepared internally for future use
- the runner refreshes all supported public scopes and periods on each cycle even when a manual runner execution is limited with
--server - runtime fallback remains necessary as a safety net even with periodic automation in place
- canonical historical truth remains in materialized RCON tables, not in the read model