Wake deadline consumers on enqueue instead of polling every second
CI / Build and push docker image (push) Successful in 1m15s
CI / Build and push docker image (push) Successful in 1m15s
This commit is contained in:
7 files changed
+162
-19
No files matched your search
+4
-1
@@ -72,7 +72,7 @@ All configuration comes from environment variables (see `.env.example`):
|
|||||||
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
||||||
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the scopone between-hands scoring summary waits for acknowledgements (wired into the `ScoponeEngine`) |
|
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the scopone between-hands scoring summary waits for acknowledgements (wired into the `ScoponeEngine`) |
|
||||||
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a scopone player has to play before the server plays a random legal card for them (wired into the `ScoponeEngine`) |
|
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a scopone player has to play before the server plays a random legal card for them (wired into the `ScoponeEngine`) |
|
||||||
| `DEADLINE_HEARTBEAT_MS` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) |
|
| `DEADLINE_HEARTBEAT_MS` | `30000` | Backstop poll interval of the deadline consumer; enqueued deadlines are announced over pub/sub and wake consumers immediately, so this only bounds recovery from lost notifications or a dead worker |
|
||||||
| `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
|
| `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
|
||||||
| `CORS_ALLOW_ORIGINS` | unset | Comma-separated origins allowed for cross-origin requests, or `*` for any. CORS is disabled unless this or `CORS_ALLOW_ORIGIN_REGEX` is set |
|
| `CORS_ALLOW_ORIGINS` | unset | Comma-separated origins allowed for cross-origin requests, or `*` for any. CORS is disabled unless this or `CORS_ALLOW_ORIGIN_REGEX` is set |
|
||||||
| `CORS_ALLOW_ORIGIN_REGEX` | unset | Regex (fullmatch) additionally matched against request origins, e.g. `https://tavolo-[a-z0-9-]+\.vercel\.app` |
|
| `CORS_ALLOW_ORIGIN_REGEX` | unset | Regex (fullmatch) additionally matched against request origins, e.g. `https://tavolo-[a-z0-9-]+\.vercel\.app` |
|
||||||
@@ -176,6 +176,9 @@ aggregate with `max`, not `sum`.
|
|||||||
on any player being connected and survive the death of any worker
|
on any player being connected and survive the death of any worker
|
||||||
(delivery is at-least-once; engines revalidate the entry's token
|
(delivery is at-least-once; engines revalidate the entry's token
|
||||||
against the live state before firing).
|
against the live state before firing).
|
||||||
|
- `tavolo:deadlines:events` — a pub/sub channel announcing newly enqueued
|
||||||
|
deadlines; it wakes every worker's consumer immediately, while the
|
||||||
|
heartbeat poll remains as a backstop for lost signals.
|
||||||
|
|
||||||
### Postgres (statistics, via Tortoise ORM + aerich migrations)
|
### Postgres (statistics, via Tortoise ORM + aerich migrations)
|
||||||
|
|
||||||
|
|||||||
@@ -97,10 +97,10 @@ class Settings:
|
|||||||
# card for them, covering disconnects and idle players (wired into the
|
# card for them, covering disconnects and idle players (wired into the
|
||||||
# ScoponeEngine; see tavolo.app).
|
# ScoponeEngine; see tavolo.app).
|
||||||
turn_timeout_seconds: int
|
turn_timeout_seconds: int
|
||||||
# Upper bound on how long the deadline consumer sleeps between polls.
|
# Upper bound on how long the deadline consumer sleeps between polls
|
||||||
# Locally enqueued deadlines wake the consumer immediately; the
|
# when nothing else wakes it. Enqueued deadlines are announced over a
|
||||||
# heartbeat only bounds the discovery delay for deadlines enqueued by
|
# pub/sub channel, so this is only the backstop recovering lost
|
||||||
# other workers.
|
# notifications and deadlines orphaned by a dead worker.
|
||||||
deadline_heartbeat_ms: int
|
deadline_heartbeat_ms: int
|
||||||
# Path to a YAML logging configuration file (logging.config.dictConfig
|
# Path to a YAML logging configuration file (logging.config.dictConfig
|
||||||
# schema). Unset uses the built-in default: DEBUG to the console.
|
# schema). Unset uses the built-in default: DEBUG to the console.
|
||||||
@@ -163,7 +163,7 @@ class Settings:
|
|||||||
static_dir=_env("STATIC_DIR", "web/dist"),
|
static_dir=_env("STATIC_DIR", "web/dist"),
|
||||||
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
||||||
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
||||||
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
|
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "30000")),
|
||||||
logging_config=os.environ.get("LOGGING_CONFIG"),
|
logging_config=os.environ.get("LOGGING_CONFIG"),
|
||||||
# CORS is disabled unless CORS_ALLOW_ORIGINS (a comma-separated
|
# CORS is disabled unless CORS_ALLOW_ORIGINS (a comma-separated
|
||||||
# list of origins, or "*" for any) or CORS_ALLOW_ORIGIN_REGEX
|
# list of origins, or "*" for any) or CORS_ALLOW_ORIGIN_REGEX
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ machinery; the *meaning* of each deadline belongs to the game engine:
|
|||||||
token), and the scheduler enqueues it in the store's shared deadline
|
token), and the scheduler enqueues it in the store's shared deadline
|
||||||
queue (a Redis sorted set in production, see
|
queue (a Redis sorted set in production, see
|
||||||
:mod:`tavolo.platform.store`); enqueueing is idempotent;
|
:mod:`tavolo.platform.store`); enqueueing is idempotent;
|
||||||
* a background consumer running on **every** worker polls the queue for
|
* enqueueing a deadline announces it on a shared pub/sub channel, so
|
||||||
due entries and hands them to the engine's
|
every worker's consumer wakes immediately instead of waiting for its
|
||||||
|
next poll; the consumer also wakes on a slow heartbeat, which is the
|
||||||
|
backstop recovering notifications lost to a reconnect or a worker
|
||||||
|
crash. Due entries are handed to the engine's
|
||||||
:meth:`~tavolo.platform.engine.GameEngine.fire_deadline` under the
|
:meth:`~tavolo.platform.engine.GameEngine.fire_deadline` under the
|
||||||
per-game lock; the engine revalidates the token against the live state
|
per-game lock; the engine revalidates the token against the live state
|
||||||
and raises :class:`~tavolo.platform.errors.GameError` when the entry
|
and raises :class:`~tavolo.platform.errors.GameError` when the entry
|
||||||
@@ -65,8 +68,8 @@ class DeadlineScheduler:
|
|||||||
"""Enqueue and fire the deadlines game engines ask for.
|
"""Enqueue and fire the deadlines game engines ask for.
|
||||||
|
|
||||||
Holds the store and the registry so a single instance serves every
|
Holds the store and the registry so a single instance serves every
|
||||||
game type. One consumer task (and its wake-up event) is kept per
|
game type. One consumer task, its listener task and its wake-up
|
||||||
event loop — tests run each test on a fresh loop.
|
event are kept per event loop — tests run each test on a fresh loop.
|
||||||
|
|
||||||
``context_binder`` is an optional async callable the consumer awaits
|
``context_binder`` is an optional async callable the consumer awaits
|
||||||
before persisting a finished match. The consumer is a long-lived task
|
before persisting a finished match. The consumer is a long-lived task
|
||||||
@@ -80,14 +83,19 @@ class DeadlineScheduler:
|
|||||||
store: GameStore,
|
store: GameStore,
|
||||||
registry: GameRegistry,
|
registry: GameRegistry,
|
||||||
heartbeat_ms: int = 1000,
|
heartbeat_ms: int = 1000,
|
||||||
|
retry_ms: int = 1000,
|
||||||
context_binder: Optional[Callable[[], Awaitable[None]]] = None,
|
context_binder: Optional[Callable[[], Awaitable[None]]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._store = store
|
self._store = store
|
||||||
self._registry = registry
|
self._registry = registry
|
||||||
self._heartbeat = heartbeat_ms / 1000
|
self._heartbeat = heartbeat_ms / 1000
|
||||||
|
# Backoff for entries that failed to process and for listener
|
||||||
|
# reconnects; deliberately independent of the (slow) heartbeat.
|
||||||
|
self._retry_delay = retry_ms / 1000
|
||||||
self._context_binder = context_binder
|
self._context_binder = context_binder
|
||||||
self._consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
|
self._consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
|
||||||
self._wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {}
|
self._wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {}
|
||||||
|
self._listeners: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
|
||||||
|
|
||||||
async def sync_deadline(self, session: GameSession) -> None:
|
async def sync_deadline(self, session: GameSession) -> None:
|
||||||
"""Enqueue the deadline the current state implies, if any.
|
"""Enqueue the deadline the current state implies, if any.
|
||||||
@@ -109,6 +117,16 @@ class DeadlineScheduler:
|
|||||||
self.ensure_consumer()
|
self.ensure_consumer()
|
||||||
due_at = deadline.due_at.timestamp()
|
due_at = deadline.due_at.timestamp()
|
||||||
await self._store.add_deadline(encode(entry), due_at)
|
await self._store.add_deadline(encode(entry), due_at)
|
||||||
|
# Announce the enqueue so consumers on other workers wake up too.
|
||||||
|
# The entry is already queued, so a lost notification only delays
|
||||||
|
# their discovery to the next poll backstop.
|
||||||
|
try:
|
||||||
|
await self._store.notify_deadline()
|
||||||
|
except Exception:
|
||||||
|
log.warning(
|
||||||
|
"deadline enqueue notification failed; relying on the poll backstop",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
wake = self._wake_events.get(asyncio.get_running_loop())
|
wake = self._wake_events.get(asyncio.get_running_loop())
|
||||||
if wake is not None:
|
if wake is not None:
|
||||||
wake.set()
|
wake.set()
|
||||||
@@ -190,15 +208,22 @@ class DeadlineScheduler:
|
|||||||
if old.is_closed():
|
if old.is_closed():
|
||||||
self._consumers.pop(old, None)
|
self._consumers.pop(old, None)
|
||||||
self._wake_events.pop(old, None)
|
self._wake_events.pop(old, None)
|
||||||
|
self._listeners.pop(old, None)
|
||||||
task = self._consumers.get(loop)
|
task = self._consumers.get(loop)
|
||||||
if task is None or task.done():
|
if task is None or task.done():
|
||||||
self._wake_events[loop] = asyncio.Event()
|
self._wake_events[loop] = asyncio.Event()
|
||||||
self._consumers[loop] = loop.create_task(self._run(loop))
|
self._consumers[loop] = loop.create_task(self._run(loop))
|
||||||
|
listener = self._listeners.get(loop)
|
||||||
|
if listener is None or listener.done():
|
||||||
|
self._listeners[loop] = loop.create_task(self._listen(loop))
|
||||||
log.debug("deadline consumer started")
|
log.debug("deadline consumer started")
|
||||||
|
|
||||||
def stop_consumer(self, loop: asyncio.AbstractEventLoop) -> None:
|
def stop_consumer(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
task = self._consumers.pop(loop, None)
|
task = self._consumers.pop(loop, None)
|
||||||
self._wake_events.pop(loop, None)
|
self._wake_events.pop(loop, None)
|
||||||
|
listener = self._listeners.pop(loop, None)
|
||||||
|
if listener is not None:
|
||||||
|
listener.cancel()
|
||||||
if task is not None:
|
if task is not None:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
@@ -228,21 +253,48 @@ class DeadlineScheduler:
|
|||||||
log.exception("deadline consumer: poll failed; retrying")
|
log.exception("deadline consumer: poll failed; retrying")
|
||||||
failed = True
|
failed = True
|
||||||
if failed:
|
if failed:
|
||||||
# A failed entry is still due: back off to the heartbeat
|
# A failed entry is still due: back off briefly instead of
|
||||||
# instead of polling it again immediately.
|
# polling it again immediately. Kept independent of the
|
||||||
delay = self._heartbeat
|
# heartbeat, which is only a slow backstop.
|
||||||
|
delay = self._retry_delay
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(wake.wait(), timeout=delay)
|
await asyncio.wait_for(wake.wait(), timeout=delay)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _listen(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
"""Wake the consumer on deadline-enqueue notifications.
|
||||||
|
|
||||||
|
The subscription is best effort: a dropped connection or a lost
|
||||||
|
message is recovered by the consumer's heartbeat backstop, so
|
||||||
|
this task only has to reconnect and keep forwarding signals.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with self._store.subscribe_deadlines() as events:
|
||||||
|
async for _ in events:
|
||||||
|
wake = self._wake_events.get(loop)
|
||||||
|
if wake is None:
|
||||||
|
# Shut down: the consumer is gone.
|
||||||
|
return
|
||||||
|
wake.set()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
log.exception("deadline listener failed; reconnecting")
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(self._retry_delay)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class DeadlineSchedulerMixin(KayaMixin):
|
class DeadlineSchedulerMixin(KayaMixin):
|
||||||
"""Run the deadline consumer for the whole app lifetime.
|
"""Run the deadline consumer for the whole app lifetime.
|
||||||
|
|
||||||
Every worker (and every pod) runs the same consumer; coordination
|
Every worker (and every pod) runs the same consumer and listener;
|
||||||
happens exclusively through the shared deadline queue and the
|
coordination happens exclusively through the shared deadline queue,
|
||||||
per-game locks, so any worker may fire any game's deadline.
|
its notification channel and the per-game locks, so any worker may
|
||||||
|
fire any game's deadline.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, scheduler: DeadlineScheduler) -> None:
|
def __init__(self, scheduler: DeadlineScheduler) -> None:
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ signal (never the state) means updated state reaches connections on
|
|||||||
every worker without leaking hidden information into the channel.
|
every worker without leaking hidden information into the channel.
|
||||||
|
|
||||||
Timeouts are driven by a shared delayed-deadline queue: producers
|
Timeouts are driven by a shared delayed-deadline queue: producers
|
||||||
enqueue an opaque ``member`` string with a due timestamp, and a consumer
|
enqueue an opaque ``member`` string with a due timestamp and announce it
|
||||||
on every worker polls for due entries (see
|
on a shared "deadline enqueued" channel, and a consumer on every worker
|
||||||
|
wakes on that signal to process due entries (see
|
||||||
:mod:`tavolo.platform.deadlines`). Delivery is at-least-once — entries
|
:mod:`tavolo.platform.deadlines`). Delivery is at-least-once — entries
|
||||||
are removed only after they are processed — so a worker dying
|
are removed only after they are processed — so a worker dying
|
||||||
mid-processing cannot lose a deadline; engines revalidate entries
|
mid-processing cannot lose a deadline; engines revalidate entries
|
||||||
@@ -55,6 +56,9 @@ GAME_KEY_PREFIX = "tavolo:game:"
|
|||||||
CODE_KEY_PREFIX = "tavolo:code:"
|
CODE_KEY_PREFIX = "tavolo:code:"
|
||||||
CHANNEL_PREFIX = "tavolo:game:"
|
CHANNEL_PREFIX = "tavolo:game:"
|
||||||
DEADLINES_KEY = "tavolo:deadlines"
|
DEADLINES_KEY = "tavolo:deadlines"
|
||||||
|
# Channel announcing newly enqueued deadline entries, so every worker's
|
||||||
|
# consumer can wake immediately instead of polling on its heartbeat.
|
||||||
|
DEADLINES_CHANNEL = "tavolo:deadlines:events"
|
||||||
# Sorted set of running games (score = expiry timestamp), maintained by the
|
# Sorted set of running games (score = expiry timestamp), maintained by the
|
||||||
# stores so the telemetry gauge can count ongoing matches without loading
|
# stores so the telemetry gauge can count ongoing matches without loading
|
||||||
# every session. Finished games are removed on their final save.
|
# every session. Finished games are removed on their final save.
|
||||||
@@ -165,6 +169,18 @@ class GameStore(ABC):
|
|||||||
async def remove_deadline(self, member: str) -> None:
|
async def remove_deadline(self, member: str) -> None:
|
||||||
"""Remove ``member`` from the queue; a no-op when absent."""
|
"""Remove ``member`` from the queue; a no-op when absent."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def notify_deadline(self) -> None:
|
||||||
|
"""Wake every worker's deadline consumer (best effort).
|
||||||
|
|
||||||
|
Notification is advisory: consumers stay correct when a signal
|
||||||
|
is lost, because their poll backstop recovers it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def subscribe_deadlines(self) -> AsyncContextManager[AsyncIterator[None]]:
|
||||||
|
"""Async context manager yielding deadline-enqueue signals."""
|
||||||
|
|
||||||
|
|
||||||
def _channel(game_id: str) -> str:
|
def _channel(game_id: str) -> str:
|
||||||
return f"{CHANNEL_PREFIX}{game_id}:events"
|
return f"{CHANNEL_PREFIX}{game_id}:events"
|
||||||
@@ -266,6 +282,20 @@ class RedisGameStore(GameStore):
|
|||||||
async def remove_deadline(self, member: str) -> None:
|
async def remove_deadline(self, member: str) -> None:
|
||||||
await self._redis.zrem(DEADLINES_KEY, member)
|
await self._redis.zrem(DEADLINES_KEY, member)
|
||||||
|
|
||||||
|
async def notify_deadline(self) -> None:
|
||||||
|
await self._redis.publish(DEADLINES_CHANNEL, "deadline")
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def subscribe_deadlines(self) -> AsyncIterator[AsyncIterator[None]]:
|
||||||
|
pubsub = self._redis.pubsub()
|
||||||
|
await pubsub.subscribe(DEADLINES_CHANNEL)
|
||||||
|
try:
|
||||||
|
yield _redis_events(pubsub)
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await pubsub.unsubscribe(DEADLINES_CHANNEL)
|
||||||
|
await pubsub.aclose()
|
||||||
|
|
||||||
|
|
||||||
async def _redis_events(pubsub) -> AsyncIterator[None]:
|
async def _redis_events(pubsub) -> AsyncIterator[None]:
|
||||||
async for message in pubsub.listen():
|
async for message in pubsub.listen():
|
||||||
@@ -284,6 +314,7 @@ class InMemoryGameStore(GameStore):
|
|||||||
self._locks: Dict[str, asyncio.Lock] = {}
|
self._locks: Dict[str, asyncio.Lock] = {}
|
||||||
self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
|
self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
|
||||||
self._deadlines: Dict[str, float] = {}
|
self._deadlines: Dict[str, float] = {}
|
||||||
|
self._deadline_subscribers: Set[asyncio.Queue] = set()
|
||||||
|
|
||||||
def _lock_for(self, game_id: str) -> asyncio.Lock:
|
def _lock_for(self, game_id: str) -> asyncio.Lock:
|
||||||
lock = self._locks.get(game_id)
|
lock = self._locks.get(game_id)
|
||||||
@@ -352,6 +383,19 @@ class InMemoryGameStore(GameStore):
|
|||||||
async def remove_deadline(self, member: str) -> None:
|
async def remove_deadline(self, member: str) -> None:
|
||||||
self._deadlines.pop(member, None)
|
self._deadlines.pop(member, None)
|
||||||
|
|
||||||
|
async def notify_deadline(self) -> None:
|
||||||
|
for queue in list(self._deadline_subscribers):
|
||||||
|
queue.put_nowait(_BUMP)
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def subscribe_deadlines(self) -> AsyncIterator[AsyncIterator[None]]:
|
||||||
|
queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
self._deadline_subscribers.add(queue)
|
||||||
|
try:
|
||||||
|
yield _queue_events(queue)
|
||||||
|
finally:
|
||||||
|
self._deadline_subscribers.discard(queue)
|
||||||
|
|
||||||
|
|
||||||
async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]:
|
async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]:
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ class DummyEngine(GameEngine):
|
|||||||
|
|
||||||
def make_platform(
|
def make_platform(
|
||||||
engines: Sequence[GameEngine] = (DummyEngine(),),
|
engines: Sequence[GameEngine] = (DummyEngine(),),
|
||||||
|
heartbeat_ms: int = 50,
|
||||||
) -> Tuple[KayaApp, Platform, TortoiseMixin]:
|
) -> Tuple[KayaApp, Platform, TortoiseMixin]:
|
||||||
"""Build a throwaway app + platform wired with in-memory stores."""
|
"""Build a throwaway app + platform wired with in-memory stores."""
|
||||||
registry = GameRegistry(engines)
|
registry = GameRegistry(engines)
|
||||||
@@ -251,7 +252,7 @@ def make_platform(
|
|||||||
scheduler = DeadlineScheduler(
|
scheduler = DeadlineScheduler(
|
||||||
game_store,
|
game_store,
|
||||||
registry,
|
registry,
|
||||||
heartbeat_ms=50,
|
heartbeat_ms=heartbeat_ms,
|
||||||
context_binder=tortoise_mixin.ensure_context,
|
context_binder=tortoise_mixin.ensure_context,
|
||||||
)
|
)
|
||||||
platform = Platform(
|
platform = Platform(
|
||||||
|
|||||||
@@ -112,6 +112,38 @@ async def _finished(store, game_id: str) -> Optional[GameSession]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTest(unittest.TestCase):
|
||||||
|
"""Deadline-enqueue notifications wake consumers on other workers."""
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_notification_wakes_a_long_heartbeat_consumer(self) -> None:
|
||||||
|
# The consumer's heartbeat is a minute away and nothing sets its
|
||||||
|
# local wake event: only the pub/sub notification can deliver the
|
||||||
|
# deadline in time. This is the cross-worker path.
|
||||||
|
_, platform, _ = make_platform(heartbeat_ms=60_000)
|
||||||
|
store = platform.game_store
|
||||||
|
scheduler = platform.scheduler
|
||||||
|
session = _started_session(deadline_in_seconds=0.1)
|
||||||
|
await store.save(session)
|
||||||
|
scheduler.ensure_consumer()
|
||||||
|
# Let the listener task subscribe before announcing.
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
engine = platform.registry.require(session.game_type)
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
member = encode({
|
||||||
|
"game_id": session.id,
|
||||||
|
"kind": deadline.kind,
|
||||||
|
"token": deadline.token,
|
||||||
|
})
|
||||||
|
await store.add_deadline(member, deadline.due_at.timestamp())
|
||||||
|
await store.notify_deadline()
|
||||||
|
|
||||||
|
result = await _wait_for(lambda: _plays_is(store, session.id, 1))
|
||||||
|
self.assertIsNotNone(result, "notification did not wake the consumer")
|
||||||
|
|
||||||
|
|
||||||
class ProcessDueTest(unittest.TestCase):
|
class ProcessDueTest(unittest.TestCase):
|
||||||
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
|
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,17 @@ class InMemoryGameStoreTest(unittest.TestCase):
|
|||||||
self.assertEqual(60.0, await store.next_deadline())
|
self.assertEqual(60.0, await store.next_deadline())
|
||||||
self.assertEqual(["b"], await store.due_deadlines(now=100.0))
|
self.assertEqual(["b"], await store.due_deadlines(now=100.0))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_deadline_notifications_reach_subscribers(self) -> None:
|
||||||
|
store = InMemoryGameStore(_registry())
|
||||||
|
async with store.subscribe_deadlines() as events:
|
||||||
|
await store.notify_deadline()
|
||||||
|
signal = await asyncio.wait_for(anext(events), timeout=1.0)
|
||||||
|
self.assertIsNone(signal)
|
||||||
|
# The subscription is gone after the context exits: notifying
|
||||||
|
# again is a no-op rather than an error.
|
||||||
|
await store.notify_deadline()
|
||||||
|
|
||||||
|
|
||||||
class RedisGameStoreTest(unittest.TestCase):
|
class RedisGameStoreTest(unittest.TestCase):
|
||||||
@async_test
|
@async_test
|
||||||
|
|||||||
Reference in new issue
Block a user