Gate finding C01.r1.2c [High]: the hand-back added for failed candidates also ran in the post-install exception/cancel branches. After a successful secondary install (or a primary publish) the predecessor already forwards to the new adapter; if the task was cancelled during redelivery (gateway teardown cancels secondary reconnect tasks) the hand-back linked the new adapter back to the predecessor, and any hold - e.g. the disconnect() teardown salvage - ping-ponged between the two until RecursionError. Break the cycle at both ends: adopt_held_inbound clears the adopter's own successor link (it owns the queue again), and the runner skips the hand-back when the adapter is the one installed in its slot. Covered by a cancel-after-install row on the secondary hand-back test.
143 lines
7.5 KiB
Python
143 lines
7.5 KiB
Python
"""Telegram held-inbound queue: events PTB already acked that cannot dispatch yet are held,
|
|
redispatched on reconnect, and handed to the adapter that replaces this one (#132829)."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import weakref
|
|
from typing import Optional
|
|
|
|
from gateway.platforms.event import MessageEvent
|
|
from gateway.platforms.helpers import cancel_task
|
|
|
|
logger = logging.getLogger("plugins.platforms.telegram.adapter")
|
|
|
|
|
|
class TelegramHeldInboundMixin:
|
|
"""Hold / redispatch / hand-over of inbound events for ``TelegramAdapter``."""
|
|
|
|
def _schedule_held_inbound_redispatch(self) -> None:
|
|
"""Ensure a tracked drain runs when held events exist and delivery is live (no-op while
|
|
down or after permanent fatal; an in-flight drain schedules its own follow-up)."""
|
|
if self._is_permanent_fatal() or self._should_drop_delayed_delivery():
|
|
return
|
|
if not getattr(self, "_held_inbound_events", None):
|
|
return
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
return
|
|
prior = getattr(self, "_held_inbound_redispatch_task", None)
|
|
try:
|
|
current = asyncio.current_task()
|
|
except RuntimeError:
|
|
current = None
|
|
if prior is not None and not prior.done() and prior is not current:
|
|
return
|
|
self._held_inbound_redispatch_task = loop.create_task(self._redispatch_held_inbound(prior=None if prior is current else prior))
|
|
|
|
def _hold_inbound_event(self, event: MessageEvent, *, where: str, schedule: bool = True) -> None:
|
|
"""Preserve an inbound event that cannot be dispatched now (PTB already acked the update, so dropping is silent loss).
|
|
Capped, identity-deduped; permanent fatal discards. ``schedule=False`` inside a drain avoids poison-event loops.
|
|
|
|
The disconnect drop-guard (#55971) correctly prevents dispatch into a torn-down session. Destroying
|
|
the event is wrong: by the time we reach enqueue/flush, python-telegram-bot has already acked the
|
|
update and advanced the offset — silent permanent loss, no log, no error.
|
|
"""
|
|
if self._is_permanent_fatal():
|
|
logger.warning(
|
|
"[Telegram] Discarding inbound under non-retryable fatal (%s, %d chars)", where, len(getattr(event, "text", None) or ""))
|
|
return
|
|
successor = getattr(self, "_held_inbound_successor", None)
|
|
target = successor() if successor is not None else None
|
|
if target is not None: # retired by a runner rebuild (#132829)
|
|
self._accept_update() # the claim being dispatched is ours, not the successor's
|
|
target._adopt_held_event(event, where=f"{where}-forwarded", schedule=schedule)
|
|
return
|
|
held = getattr(self, "_held_inbound_events", None)
|
|
if held is None:
|
|
self._held_inbound_events = held = []
|
|
if any(existing is event for existing in held):
|
|
return
|
|
max_n = int(getattr(self, "HELD_INBOUND_MAX", 64) or 64)
|
|
while len(held) >= max_n:
|
|
dropped = held.pop(0)
|
|
logger.warning(
|
|
"[Telegram] Held-inbound queue full (%d); dropping oldest (%d chars)", max_n, len(getattr(dropped, "text", None) or ""))
|
|
held.append(event)
|
|
self._accept_update()
|
|
logger.warning(
|
|
"[Telegram] Holding inbound (%s, %d chars, queue=%d)%s", where, len(getattr(event, "text", None) or ""), len(held),
|
|
" - will redispatch on reconnect" if self._should_drop_delayed_delivery() else (" - scheduling redispatch" if schedule else ""))
|
|
# A live-path hold must not orphan the event waiting for a reconnect that never comes.
|
|
if schedule and not self._should_drop_delayed_delivery():
|
|
self._schedule_held_inbound_redispatch()
|
|
|
|
def adopt_held_inbound(self, predecessor: "TelegramHeldInboundMixin") -> None:
|
|
"""Take over the hold queue of the instance the runner just replaced with us (#132829): it only
|
|
drains on its own ``_mark_connected``, which never comes; later holds there forward here."""
|
|
self._held_inbound_successor = None # we own the queue again: a reverse link would forward in a cycle
|
|
predecessor._held_inbound_successor = weakref.ref(self)
|
|
held = getattr(predecessor, "_held_inbound_events", None) or []
|
|
events = list(held)
|
|
held.clear()
|
|
for event in events:
|
|
self._adopt_held_event(event, where="adopted", schedule=False)
|
|
self._schedule_held_inbound_redispatch()
|
|
|
|
def _adopt_held_event(self, event: MessageEvent, *, where: str, schedule: bool) -> None:
|
|
"""Hold a predecessor's event here; only the transport ref moves (profile/authz stay as resolved)."""
|
|
if getattr(event, "source", None) is not None:
|
|
event.source._transport_adapter_ref = weakref.ref(self)
|
|
self._hold_inbound_event(event, where=where, schedule=schedule)
|
|
|
|
def _rehold_from(self, events: list, idx: int, where: str) -> None:
|
|
"""Re-hold ``events[idx:]`` without rescheduling (drain interrupted / failed / cancelled)."""
|
|
for rest in events[idx:]:
|
|
self._hold_inbound_event(rest, where=where, schedule=False)
|
|
|
|
async def _redispatch_held_inbound(self, prior: Optional[asyncio.Task] = None) -> None:
|
|
"""Drain the hold queue after reconnect or a connected-path hold; ``prior`` (previous
|
|
redispatch task) is cancelled+awaited here so ``_mark_connected`` stays synchronous."""
|
|
if prior is not asyncio.current_task(): # a self-redispatch must not cancel itself
|
|
await cancel_task(prior)
|
|
held = getattr(self, "_held_inbound_events", None)
|
|
if self._is_permanent_fatal():
|
|
if held:
|
|
n = len(held)
|
|
held.clear()
|
|
logger.warning("[Telegram] Redispatch aborted; discarded %d held inbound under non-retryable fatal", n)
|
|
return
|
|
if not held:
|
|
return
|
|
# Take ownership atomically; concurrent holds append to the fresh list for a follow-up.
|
|
events = list(held)
|
|
held.clear()
|
|
logger.warning("[Telegram] Redispatching %d held inbound message(s)", len(events))
|
|
allow_followup_schedule = True
|
|
try:
|
|
for idx, event in enumerate(events):
|
|
if self._is_permanent_fatal() or self._should_drop_delayed_delivery():
|
|
self._rehold_from(events, idx, "redispatch-interrupted")
|
|
return
|
|
try:
|
|
await self.handle_message(event)
|
|
except asyncio.CancelledError:
|
|
self._rehold_from(events, idx, "redispatch-cancelled")
|
|
raise
|
|
except Exception:
|
|
# Retryable failure: re-hold but do NOT reschedule now (a poison event would
|
|
# tight-loop); the next mark_connected/live hold drains.
|
|
logger.exception(
|
|
"[Telegram] Failed to redispatch held inbound (%d chars); re-holding", len(getattr(event, "text", None) or ""))
|
|
self._rehold_from(events, idx, "redispatch-failed")
|
|
allow_followup_schedule = False
|
|
return
|
|
finally:
|
|
# Events that arrived mid-drain while still connected need another pass.
|
|
if (
|
|
allow_followup_schedule
|
|
and getattr(self, "_held_inbound_events", None)
|
|
and not self._should_drop_delayed_delivery()
|
|
and not self._is_permanent_fatal()):
|
|
self._schedule_held_inbound_redispatch()
|