Turn auto-play and hand-end auto-continue were process-local asyncio tasks armed only by client connects and state broadcasts: with no sockets connected the next turn's timer was never armed, a hand-end timer died with its worker, and neither survived a pod restart. Deadlines are now driven by the absolute timestamps persisted on the game state and enqueued in a shared Redis sorted set. Every worker runs a consumer that fires due entries under the per-game lock after revalidating them against the live state, so timeouts no longer depend on any player being connected and survive the death of any worker. Delivery is at-least-once: entries are removed only after processing, and revalidation makes duplicate deliveries no-ops. Queue entries carry the deadline as integer epoch milliseconds, which also serves as the revalidation token, and the score derives from the same value.
127 lines
5.4 KiB
Python
127 lines
5.4 KiB
Python
"""Environment-driven configuration for the tavolo application.
|
|
|
|
Mirrors kaya's own pattern: read ``os.environ`` directly into a plain
|
|
dataclass. No pydantic-settings, no settings module.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
from urllib.parse import quote
|
|
|
|
|
|
def _env(name: str, default: Optional[str] = None) -> str:
|
|
value = os.environ.get(name)
|
|
if value is None or value == "":
|
|
if default is None:
|
|
raise RuntimeError(f"Missing required environment variable: {name}")
|
|
return default
|
|
return value
|
|
|
|
|
|
def _database_url_from_parts(engine: str,
|
|
user: str,
|
|
password: Optional[str],
|
|
host: str,
|
|
port: str,
|
|
name: str,
|
|
options: str) -> str:
|
|
"""Assemble a database DSN from individual components.
|
|
|
|
``user`` and ``password`` are percent-encoded so credentials containing
|
|
URL-reserved characters (``@``, ``:``, ``/``, ...) do not corrupt the
|
|
DSN. ``port`` and ``options`` are omitted when empty: a missing port
|
|
lets the driver pick its default (5432 for asyncpg). ``options`` is a
|
|
raw query string (e.g. ``ssl=require``) appended after a ``?``.
|
|
"""
|
|
netloc = quote(user, safe="")
|
|
if password:
|
|
netloc += ":" + quote(password, safe="")
|
|
netloc += "@" + host
|
|
if port:
|
|
netloc += ":" + port
|
|
url = f"{engine}://{netloc}/{name}"
|
|
options = options.lstrip("?")
|
|
if options:
|
|
url += "?" + options
|
|
return url
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Settings:
|
|
database_url: str
|
|
oidc_issuer: str
|
|
oidc_client_id: str
|
|
oidc_client_secret: Optional[str]
|
|
oidc_redirect_uri: str
|
|
# Where the browser is sent after login/logout. In production the SPA is
|
|
# served by this app ("/"); in development point these at the trunk dev
|
|
# server (e.g. "http://localhost:8000/").
|
|
oidc_post_login_redirect: str
|
|
oidc_post_logout_redirect: str
|
|
app_host: str
|
|
app_port: int
|
|
redis_url: Optional[str]
|
|
# How long a live game (and its join-code index) survives in Redis
|
|
# without activity, in seconds. Defaults to 24h.
|
|
game_ttl_seconds: int
|
|
# Directory holding the compiled frontend (trunk's dist output). Only
|
|
# used to locate index.html for the SPA shell; the assets themselves
|
|
# are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars).
|
|
static_dir: str
|
|
# Seconds the between-hands scoring summary waits for acknowledgements
|
|
# before dealing the next hand anyway.
|
|
hand_ack_timeout_seconds: int
|
|
# Seconds a player has to play before the server plays a random legal
|
|
# card for them (covering disconnects and idle players).
|
|
turn_timeout_seconds: int
|
|
# Upper bound on how long the deadline consumer sleeps between polls.
|
|
# Locally enqueued deadlines wake the consumer immediately; the
|
|
# heartbeat only bounds the discovery delay for deadlines enqueued by
|
|
# other workers.
|
|
deadline_heartbeat_ms: int
|
|
# Path to a YAML logging configuration file (logging.config.dictConfig
|
|
# schema). Unset uses the built-in default: DEBUG to the console.
|
|
logging_config: Optional[str]
|
|
|
|
@staticmethod
|
|
def from_env() -> "Settings":
|
|
return Settings(
|
|
# DATABASE_URL, when set, is used verbatim and the DATABASE_*
|
|
# parts below are ignored (sqlite in tests, managed-DB DSNs).
|
|
database_url=os.environ.get("DATABASE_URL") or _database_url_from_parts(
|
|
engine=_env("DATABASE_ENGINE", "postgres"),
|
|
user=_env("DATABASE_USER", "tavolo"),
|
|
password=_env("DATABASE_PASSWORD", "password"),
|
|
host=_env("DATABASE_HOST", "localhost"),
|
|
# Unset: the port segment is omitted and the driver default
|
|
# (5432 for asyncpg) applies.
|
|
port=os.environ.get("DATABASE_PORT", ""),
|
|
name=_env("DATABASE_NAME", "tavolo"),
|
|
# Raw DSN query string (e.g. "ssl=require"); empty = none.
|
|
options=os.environ.get("DATABASE_OPTIONS", ""),
|
|
),
|
|
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"),
|
|
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
|
|
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
|
|
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
|
|
oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"),
|
|
oidc_post_logout_redirect=_env("OIDC_POST_LOGOUT_REDIRECT", "/"),
|
|
app_host=_env("APP_HOST", "0.0.0.0"),
|
|
app_port=int(_env("APP_PORT", "8080")),
|
|
# When unset, sessions and live games fall back to in-memory
|
|
# stores (tests, ephemeral dev). Set to e.g.
|
|
# redis://localhost:6379/0 to persist both in Redis.
|
|
redis_url=os.environ.get("REDIS_URL"),
|
|
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
|
|
static_dir=_env("STATIC_DIR", "web/dist"),
|
|
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
|
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
|
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
|
|
logging_config=os.environ.get("LOGGING_CONFIG"),
|
|
)
|
|
|
|
|
|
settings: Settings = Settings.from_env()
|