Fix live AdminLog worker startup with safe historical guard
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from contextlib import closing, contextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from app import postgres_rcon_storage
|
||||
from app import rcon_admin_log_storage
|
||||
from app.config import (
|
||||
get_current_match_adminlog_enabled,
|
||||
get_current_match_adminlog_interval_seconds,
|
||||
@@ -190,6 +194,101 @@ class RconCurrentMatchWorkerTests(unittest.TestCase):
|
||||
self.assertEqual(second["totals"]["events_inserted"], 0)
|
||||
self.assertEqual(second["totals"]["duplicate_events"], 1)
|
||||
|
||||
def test_live_worker_starts_when_duplicate_historical_runs_exist(self) -> None:
|
||||
entry = {
|
||||
"timestamp": "2026-06-18T18:00:00Z",
|
||||
"message": (
|
||||
"[5:00 min (321)] KILL: Alpha(Allies/steam-alpha) -> "
|
||||
"Victim(Axis/steam-victim) with Rifle"
|
||||
),
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
db_path = os.path.join(temp_dir, "current_match.sqlite3")
|
||||
rcon_admin_log_storage.initialize_rcon_admin_log_storage(db_path=Path(db_path))
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO rcon_historical_capture_runs (
|
||||
mode, status, target_scope, started_at
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
("historical", "running", "all-configured-rcon-targets", "2099-01-01T00:00:00Z"),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO rcon_historical_capture_runs (
|
||||
mode, status, target_scope, started_at
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
("historical", "running", "all-configured-rcon-targets", "2099-01-01T00:05:00Z"),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
result = run_current_match_adminlog_refresh_once_unlocked(
|
||||
lookback_seconds=180,
|
||||
targets=[TARGET_01],
|
||||
fetch_entries_fn=lambda *_args, **_kwargs: [entry],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "ok")
|
||||
self.assertEqual(result["totals"]["events_inserted"], 1)
|
||||
self.assertEqual(result["totals"]["failed_targets"], 0)
|
||||
|
||||
def test_live_worker_does_not_trigger_admin_log_materialization(self) -> None:
|
||||
def fake_fetch(target, *, lookback_seconds, timeout_seconds):
|
||||
return [
|
||||
{
|
||||
"timestamp": "2026-06-18T18:00:00Z",
|
||||
"message": "[1:00 min (60)] CONNECTED Alpha (steam-alpha)",
|
||||
}
|
||||
]
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as temp_dir,
|
||||
patch("app.rcon_admin_log_materialization.materialize_rcon_admin_log") as materialize,
|
||||
):
|
||||
db_path = os.path.join(temp_dir, "current_match.sqlite3")
|
||||
result = run_current_match_adminlog_refresh_once_unlocked(
|
||||
lookback_seconds=180,
|
||||
targets=[TARGET_01],
|
||||
fetch_entries_fn=fake_fetch,
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
materialize.assert_not_called()
|
||||
self.assertEqual(result["status"], "ok")
|
||||
|
||||
def test_live_worker_does_not_use_historical_runtime_guard(self) -> None:
|
||||
def fake_fetch(target, *, lookback_seconds, timeout_seconds):
|
||||
return [
|
||||
{
|
||||
"timestamp": "2026-06-18T18:00:00Z",
|
||||
"message": "[1:00 min (60)] CONNECTED Alpha (steam-alpha)",
|
||||
}
|
||||
]
|
||||
|
||||
def fake_persist(*, target, entries, db_path=None, ensure_storage=True):
|
||||
return {
|
||||
"events_seen": len(entries),
|
||||
"events_inserted": len(entries),
|
||||
"duplicate_events": 0,
|
||||
}
|
||||
|
||||
with (
|
||||
patch("app.rcon_current_match_worker.initialize_rcon_admin_log_storage"),
|
||||
patch("app.rcon_historical_storage.historical_capture_runtime_guard") as guard,
|
||||
):
|
||||
result = run_current_match_adminlog_refresh_once_unlocked(
|
||||
lookback_seconds=180,
|
||||
targets=[TARGET_01],
|
||||
fetch_entries_fn=fake_fetch,
|
||||
persist_entries_fn=fake_persist,
|
||||
)
|
||||
|
||||
guard.assert_not_called()
|
||||
self.assertEqual(result["status"], "ok")
|
||||
|
||||
def test_loop_honors_max_runs(self) -> None:
|
||||
results = [{"status": "ok"}, {"status": "ok"}]
|
||||
|
||||
@@ -211,6 +310,51 @@ class RconCurrentMatchWorkerTests(unittest.TestCase):
|
||||
self.assertEqual(run_once.call_count, 2)
|
||||
self.assertEqual(sleep.call_count, 1)
|
||||
|
||||
def test_postgres_admin_log_storage_uses_admin_log_bootstrap_only(self) -> None:
|
||||
with (
|
||||
patch.object(rcon_admin_log_storage, "use_postgres_rcon_storage", return_value=True),
|
||||
patch("app.postgres_rcon_storage.initialize_postgres_admin_log_storage") as admin_log_init,
|
||||
patch("app.postgres_rcon_storage.initialize_postgres_rcon_storage") as full_init,
|
||||
):
|
||||
rcon_admin_log_storage.initialize_rcon_admin_log_storage()
|
||||
|
||||
admin_log_init.assert_called_once_with()
|
||||
full_init.assert_not_called()
|
||||
|
||||
def test_postgres_schema_strings_no_longer_create_historical_running_unique_index(self) -> None:
|
||||
self.assertNotIn(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_rcon_historical_single_running_historical",
|
||||
postgres_rcon_storage.RCON_SCHEMA_SQL,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"idx_rcon_historical_single_running_historical",
|
||||
postgres_rcon_storage.POSTGRES_ADMIN_LOG_SCHEMA_SQL,
|
||||
)
|
||||
self.assertIn(
|
||||
"DROP INDEX IF EXISTS idx_rcon_historical_single_running_historical",
|
||||
postgres_rcon_storage.DROP_LEGACY_HISTORICAL_GUARD_INDEX_SQL,
|
||||
)
|
||||
|
||||
def test_initialize_postgres_rcon_storage_does_not_execute_removed_unique_index(self) -> None:
|
||||
executed_sql: list[str] = []
|
||||
|
||||
with patch.object(
|
||||
postgres_rcon_storage,
|
||||
"connect_postgres",
|
||||
return_value=_FakePostgresConnectionScope(executed_sql),
|
||||
):
|
||||
postgres_rcon_storage.initialize_postgres_rcon_storage()
|
||||
|
||||
executed_text = "\n".join(executed_sql)
|
||||
self.assertIn(
|
||||
"DROP INDEX IF EXISTS idx_rcon_historical_single_running_historical",
|
||||
executed_text,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_rcon_historical_single_running_historical",
|
||||
executed_text,
|
||||
)
|
||||
|
||||
def test_compose_nas_runs_split_live_worker_and_safe_historical_interval(self) -> None:
|
||||
compose_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)),
|
||||
@@ -264,3 +408,31 @@ def _temporary_env(**values: str | None):
|
||||
os.environ.pop(name, None)
|
||||
else:
|
||||
os.environ[name] = value
|
||||
|
||||
|
||||
class _FakePostgresConnectionScope:
|
||||
def __init__(self, executed_sql: list[str]) -> None:
|
||||
self.connection = _FakePostgresConnection(executed_sql)
|
||||
|
||||
def __enter__(self):
|
||||
return self.connection
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _FakePostgresConnection:
|
||||
def __init__(self, executed_sql: list[str]) -> None:
|
||||
self.executed_sql = executed_sql
|
||||
|
||||
@contextmanager
|
||||
def cursor(self):
|
||||
yield _FakePostgresCursor(self.executed_sql)
|
||||
|
||||
|
||||
class _FakePostgresCursor:
|
||||
def __init__(self, executed_sql: list[str]) -> None:
|
||||
self.executed_sql = executed_sql
|
||||
|
||||
def execute(self, sql: str) -> None:
|
||||
self.executed_sql.append(sql)
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from contextlib import closing, contextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from app import rcon_historical_storage
|
||||
from app.rcon_historical_worker import (
|
||||
CAPTURE_MODE_CURRENT_LIVE,
|
||||
CAPTURE_MODE_HISTORICAL,
|
||||
@@ -157,19 +161,98 @@ class RconHistoricalWorkerTests(unittest.TestCase):
|
||||
|
||||
def test_historical_capture_skips_when_previous_heavy_run_is_still_running(self) -> None:
|
||||
with (
|
||||
patch("app.rcon_historical_worker.initialize_rcon_historical_storage"),
|
||||
patch("app.rcon_historical_worker._select_targets", return_value=[TARGET]),
|
||||
patch(
|
||||
"app.rcon_historical_worker.start_rcon_historical_capture_run",
|
||||
side_effect=RuntimeError("historical materialization capture already running"),
|
||||
"app.rcon_historical_worker.historical_capture_runtime_guard",
|
||||
side_effect=_yield_guard(False),
|
||||
),
|
||||
patch("app.rcon_historical_worker.start_rcon_historical_capture_run") as start_run,
|
||||
):
|
||||
payload = run_rcon_historical_capture_unlocked(capture_mode=CAPTURE_MODE_HISTORICAL)
|
||||
|
||||
start_run.assert_not_called()
|
||||
self.assertEqual(payload["status"], "skipped")
|
||||
self.assertEqual(payload["run_status"], "skipped")
|
||||
self.assertEqual(payload["materialization_result"]["reason"], "already-running")
|
||||
|
||||
def test_historical_storage_can_start_with_duplicate_stale_running_rows(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
db_path = Path(temp_dir) / "historical.sqlite3"
|
||||
rcon_historical_storage.initialize_rcon_historical_storage(db_path=db_path)
|
||||
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO rcon_historical_capture_runs (
|
||||
mode, status, target_scope, started_at
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
("historical", "running", "all-configured-rcon-targets", "2026-01-01T00:00:00Z"),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO rcon_historical_capture_runs (
|
||||
mode, status, target_scope, started_at
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
("historical", "running", "all-configured-rcon-targets", "2026-01-01T00:05:00Z"),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
run_id = rcon_historical_storage.start_rcon_historical_capture_run(
|
||||
mode="historical",
|
||||
target_scope="all-configured-rcon-targets",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
self.assertGreater(run_id, 0)
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT status
|
||||
FROM rcon_historical_capture_runs
|
||||
WHERE mode = 'historical'
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
).fetchall()
|
||||
self.assertEqual([row[0] for row in rows[:2]], ["stale", "stale"])
|
||||
self.assertEqual(rows[-1][0], "running")
|
||||
|
||||
def test_historical_runtime_guard_releases_non_stale_conflict_only(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
db_path = Path(temp_dir) / "historical.sqlite3"
|
||||
rcon_historical_storage.initialize_rcon_historical_storage(db_path=db_path)
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO rcon_historical_capture_runs (
|
||||
mode, status, target_scope, started_at
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
("historical", "running", "all-configured-rcon-targets", "2099-01-01T00:00:00Z"),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
with rcon_historical_storage.historical_capture_runtime_guard(
|
||||
capture_mode="historical",
|
||||
db_path=db_path,
|
||||
) as acquired:
|
||||
self.assertIs(acquired, False)
|
||||
|
||||
def test_postgres_historical_runtime_guard_uses_advisory_lock(self) -> None:
|
||||
with (
|
||||
patch.object(rcon_historical_storage, "use_postgres_rcon_storage", return_value=True),
|
||||
patch(
|
||||
"app.postgres_rcon_storage.postgres_historical_capture_advisory_guard",
|
||||
side_effect=_yield_guard(False),
|
||||
) as advisory_guard,
|
||||
):
|
||||
with rcon_historical_storage.historical_capture_runtime_guard(
|
||||
capture_mode="historical",
|
||||
) as acquired:
|
||||
self.assertIs(acquired, False)
|
||||
|
||||
advisory_guard.assert_called_once_with()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _temporary_env(**values: str):
|
||||
@@ -184,3 +267,11 @@ def _temporary_env(**values: str):
|
||||
os.environ.pop(name, None)
|
||||
else:
|
||||
os.environ[name] = value
|
||||
|
||||
|
||||
def _yield_guard(value: bool):
|
||||
@contextmanager
|
||||
def _guard(*_args, **_kwargs):
|
||||
yield value
|
||||
|
||||
return _guard
|
||||
|
||||
Reference in New Issue
Block a user