13 KiB
Current Match AdminLog Freshness
Production Evidence
The current-match public feed was stale because the persisted AdminLog read model was coupled to slow historical materialization.
Observed production/runtime evidence:
hll-vietnam-rcon-historical-worker-1was runningpython -m app.rcon_historical_worker loop- configured capture interval was
HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS=2 - configured retries were
HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES=2andHLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS=1 - configured AdminLog lookback was
HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES=10 - real
rcon_historical_capture_runsdurations were around15-30minutes instead of2seconds - recent examples included:
40789:10:17:54 -> 10:33:4540790:10:33:44 -> 10:49:3840791:10:33:47 -> 11:05:2040792:11:05:19 -> 11:21:15failed with PostgreSQL deadlock40793:11:05:22still running at inspection time
- each heavy cycle was also doing large historical work:
player_stats_seenaround838kplayer_stats_materializedaround838kmaterialized_matches_updatedaround392-394
This matters because:
/api/current-match/killsreadsrcon_admin_log_events/api/current-match/playersreadsrcon_admin_log_events- those endpoints do not query RCON/CRCON directly per public request
- if the worker cycle lasts longer than the AdminLog lookback window, new live events can be delayed or missed from the current-match API perspective
The feed freshness problem was therefore primarily a backend ingestion architecture issue, not a frontend rendering issue.
Architecture Before
Before this change, one worker path mixed three concerns:
- lightweight live sample capture
- AdminLog ingestion
- heavy historical materialization of matches, player stats, rankings and search inputs
That design had two bad properties:
- current-match freshness depended on heavy historical work finishing
- the heavy path could deadlock or overlap while still starving
rcon_admin_log_eventsrefreshes
Architecture After
Live AdminLog ingestion path
- dedicated worker module:
python -m app.rcon_current_match_worker - scope: trusted current-match servers only
comunidad-hispana-01comunidad-hispana-02
- cadence: explicit fast loop, intended for
5s - overlap safety: relies on idempotent AdminLog inserts and table uniqueness instead of the long historical writer lock
- output target:
rcon_admin_log_events - explicitly does not:
- run
materialize_rcon_admin_log - rebuild
rcon_match_player_stats - refresh annual rankings
- rebuild player search
- process hundreds of thousands of historical stat rows
- run
Operational behavior:
- worker startup initializes AdminLog storage once
- each loop iteration fetches recent AdminLog only
- each target is handled independently
- per-target results include:
target_keyentries_seenevents_insertedduplicate_eventsduration_ms
- target failures are isolated and do not stop healthy targets
Historical materialization path
- historical worker remains
python -m app.rcon_historical_worker loop - it now runs on an explicit safe interval in Portainer instead of inheriting a too-fast runtime cadence
- heavy materialization stays in the historical worker
- the single-running-historical guard now uses a runtime lock instead of a schema-level unique index
- PostgreSQL uses an advisory lock only around the heavy historical worker path
- if a historical run is already active, the next historical attempt returns a skipped result with reason
already-running - stale or duplicated old
mode='historical'run rows no longer crash live worker startup and no longer block PostgreSQL schema bootstrap
Schema and initialization behavior
The hot live path was reduced in two places:
- current-match API reads already use
ensure_storage=False, so publicGET /api/current-match/killsandGET /api/current-match/playersdo not request schema initialization on each read - the live AdminLog worker now initializes only the PostgreSQL AdminLog tables at startup or once-mode execution, then persists with
ensure_storage=False - the full PostgreSQL bootstrap now removes the obsolete
idx_rcon_historical_single_running_historicalindex instead of attempting to recreate it
This removes obvious repeated PostgreSQL DDL/init from the live ingestion loop.
TASK-271 production hotfix note:
- the original split exposed a startup crash when production already contained duplicate historical capture rows
- the crash came from schema-level creation of
idx_rcon_historical_single_running_historical - historical single-running protection now uses a runtime PostgreSQL advisory lock on the heavy worker path
- the live AdminLog worker remains independent from historical materialization and historical capture-run state
Deployment Decision
The chosen deployment split in deploy/portainer/docker-compose.nas.yml is:
- add a dedicated live worker service:
rcon-live-adminlog-worker:
command:
- python
- -m
- app.rcon_current_match_worker
- loop
- --interval
- "5"
- --lookback-minutes
- "15"
- keep the historical worker for heavy materialization, but force a safe cadence:
rcon-historical-worker:
command:
- python
- -m
- app.rcon_historical_worker
- loop
- --capture-mode
- historical
- --interval
- "900"
Rationale:
- the repository already uses the historical worker for AdminLog-backed materialization
historical-runneris not a drop-in replacement formaterialize_rcon_admin_log- the immediate production risk was heavy materialization running effectively every few seconds
- slowing the heavy worker to
900swhile moving live ingestion to a dedicated5sworker cleanly separates freshness from historical backfill/materialization cost
Non-negotiable result:
- no worker should be materializing
~838kplayer-stat rows every2seconds - live current-match freshness no longer depends on heavy historical materialization completing first
Current-Match API Read Path
Confirmed after this change:
/api/current-match/killsreads fromrcon_admin_log_events/api/current-match/playersreads fromrcon_admin_log_events- those routes do not trigger
materialize_rcon_admin_log - those routes do not query RCON directly per public request
- those routes avoid repeated storage initialization through
ensure_storage=False
Validation
Code validation completed locally:
python -m compileall backend/appcd backend; python -m unittest tests.test_current_match_payloadcd backend; python -m unittest tests.test_rcon_historical_workercd backend; python -m unittest tests.test_rcon_current_match_worker
Coverage added/updated for:
- live worker one-shot persistence reuse
- live worker loop
max-runs - live worker one-time storage initialization with
ensure_storage=Falseon hot writes - live worker per-target failure isolation
- historical worker skip when a heavy run is already active
- compose split validation for live worker presence and historical safe interval
- current-match read paths remaining read-only and not triggering materialization
Post-Deploy Validation Commands
1. Verify services
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Command}}\t{{.Status}}"
2. Verify live worker command
docker inspect -f '{{.Path}} {{json .Args}}' hll-vietnam-rcon-live-adminlog-worker-1
Adapt the container name to the actual stack service name if needed.
3. Verify historical worker is no longer 2-second heavy materialization
docker inspect -f '{{.Path}} {{json .Args}}' hll-vietnam-rcon-historical-worker-1
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' hll-vietnam-rcon-historical-worker-1 | sort | grep -Ei 'CAPTURE|CURRENT|RCON|ADMIN|MATERIAL|INTERVAL|RETRY|LOOKBACK|LOCK'
Expected:
- no heavy materialization every
2seconds - historical interval should be safe if materialization remains in this service
4. Live worker logs
docker logs --tail=200 hll-vietnam-rcon-live-adminlog-worker-1
Expected:
- short cycles
- per-target AdminLog counts
- no traceback or
UniqueViolation - no attempt to create
idx_rcon_historical_single_running_historical - no
player_stats_seen 838k - no
materialized_matches_updatedoutput every few seconds - no repeated deadlocks
4a. Verify removed runtime index creation
docker exec hll-vietnam-backend-1 sh -lc "grep -R \"idx_rcon_historical_single_running_historical\" -n /app/app || true"
Expected:
- no runtime schema code creates the removed unique index
- documentation or tests may still mention it as a removed regression string
5. Historical worker/materializer logs
docker logs --since 30m hll-vietnam-rcon-historical-worker-1 2>&1 | grep -Ei "deadlock|materialized|player_stats_seen|capture-cycle|error|exception|traceback"
Expected:
- no repeated deadlocks
- no overlapping heavy cycles
- no heavy cycle every
2seconds
6. DB capture run sanity
docker exec -i hll-vietnam-postgres-1 psql -U hll_vietnam -d hll_vietnam -P pager=off -c "
SELECT
id,
mode,
status,
started_at,
completed_at,
targets_seen,
samples_inserted,
duplicate_samples,
failed_targets,
LEFT(COALESCE(notes,''), 160) AS notes
FROM rcon_historical_capture_runs
ORDER BY id DESC
LIMIT 20;
"
7. AdminLog freshness
docker exec -i hll-vietnam-postgres-1 psql -U hll_vietnam -d hll_vietnam -P pager=off -c "
SELECT
target_key,
event_type,
COUNT(*) AS n,
MAX(server_time) AS max_server_time,
MAX(event_timestamp) AS max_event_timestamp,
MAX(created_at) AS max_created_at
FROM rcon_admin_log_events
WHERE target_key IN ('comunidad-hispana-01', 'comunidad-hispana-02')
AND created_at >= now() - interval '15 minutes'
GROUP BY target_key, event_type
ORDER BY target_key, n DESC;
"
8. API validation from Windows PowerShell
cd "D:\Proyectos\HLL Vietnam"
$servers = @("comunidad-hispana-01", "comunidad-hispana-02")
foreach ($server in $servers) {
$ts = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
$kills = Invoke-RestMethod "https://comunidadhll.devzamode.es/api/current-match/kills?server=$server&limit=20&_ts=$ts" -Headers @{
"Cache-Control" = "no-cache"
"Pragma" = "no-cache"
}
$players = Invoke-RestMethod "https://comunidadhll.devzamode.es/api/current-match/players?server=$server&_ts=$ts" -Headers @{
"Cache-Control" = "no-cache"
"Pragma" = "no-cache"
}
""
"===== $server ====="
[PSCustomObject]@{
server = $server
kills_scope = $kills.data.scope
kills_confidence = $kills.data.confidence
kills_count = @($kills.data.items).Count
kills_stale_filtered = $kills.data.stale_events_filtered
players_scope = $players.data.scope
players_confidence = $players.data.confidence
players_count = @($players.data.items).Count
players_updated_at = $players.data.updated_at
}
"Latest kills:"
$kills.data.items | Select-Object -First 10 event_id,server_time,event_timestamp,killer_name,killer_team,victim_name,victim_team,weapon
"Top players:"
$players.data.items | Sort-Object kills -Descending | Select-Object -First 10 player_name,team,kills,deaths,teamkills,deaths_by_teamkill,most_used_weapon
}
Expected acceptance after redeploy:
- new CRCON kills should reach
rcon_admin_log_eventswithin roughly5-30seconds under normal conditions /api/current-match/killsshould reflect them shortly after/api/current-match/playersshould also advance shortly after- no worker should materialize
838kplayer stats every few seconds - repeated PostgreSQL deadlocks should stop
Known Remaining Issue
TEAM KILL AdminLog parsing was left as follow-up work in this task.
Known behavior:
- parser currently recognizes
KILL:but not the separateTEAM KILLlabel in some lines - those lines can still land as
event_type="unknown" - that means some teamkills may remain absent from current-match feed/stats until a follow-up parser fix lands
This was intentionally not expanded into the central scope of the ingestion/materialization split.
Risks And Rollback
Primary residual risks:
- if production has multiple unexpected worker replicas, logs should be reviewed after redeploy to confirm the split behaves as intended
- live freshness still depends on RCON AdminLog availability per target
Rollback path:
- stop the dedicated
rcon-live-adminlog-worker - restore the previous historical worker command/env in Portainer if required
- redeploy the stack
Rollback should only be used if the new live worker introduces unexpected operational issues, because the previous combined design is known to starve current-match freshness under heavy materialization load.