Spaces:
Running on Zero
Running on Zero
Commit ·
a48b894
1
Parent(s): 4566c49
fix gather bug
Browse files- app/bot/handlers/library.py +2 -4
- app/core/aio.py +16 -3
- tests/test_logic.py +32 -0
app/bot/handlers/library.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
import logging
|
| 3 |
from typing import Union
|
| 4 |
|
|
@@ -8,7 +7,7 @@ from aiogram.types import CallbackQuery, Message
|
|
| 8 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
|
| 10 |
from app.bot.keyboards import library as kb
|
| 11 |
-
from app.core.aio import gather_safe
|
| 12 |
from app.core.catalog import KINDS
|
| 13 |
from app.core.i18n import all_translations, t
|
| 14 |
from app.db.models import User
|
|
@@ -26,10 +25,9 @@ async def _show(target: Union[Message, CallbackQuery], text: str, markup) -> Non
|
|
| 26 |
if not target.message:
|
| 27 |
await target.answer()
|
| 28 |
return
|
| 29 |
-
_, edited = await
|
| 30 |
target.answer(),
|
| 31 |
target.message.edit_text(text, reply_markup=markup),
|
| 32 |
-
return_exceptions=True,
|
| 33 |
)
|
| 34 |
if isinstance(edited, Exception) and "not modified" not in str(edited):
|
| 35 |
log.warning("edit_text failed (%r); sending a new message instead", edited)
|
|
|
|
|
|
|
| 1 |
import logging
|
| 2 |
from typing import Union
|
| 3 |
|
|
|
|
| 7 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
|
| 9 |
from app.bot.keyboards import library as kb
|
| 10 |
+
from app.core.aio import gather_results, gather_safe
|
| 11 |
from app.core.catalog import KINDS
|
| 12 |
from app.core.i18n import all_translations, t
|
| 13 |
from app.db.models import User
|
|
|
|
| 25 |
if not target.message:
|
| 26 |
await target.answer()
|
| 27 |
return
|
| 28 |
+
_, edited = await gather_results(
|
| 29 |
target.answer(),
|
| 30 |
target.message.edit_text(text, reply_markup=markup),
|
|
|
|
| 31 |
)
|
| 32 |
if isinstance(edited, Exception) and "not modified" not in str(edited):
|
| 33 |
log.warning("edit_text failed (%r); sending a new message instead", edited)
|
app/core/aio.py
CHANGED
|
@@ -4,9 +4,22 @@ import logging
|
|
| 4 |
log = logging.getLogger(__name__)
|
| 5 |
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
async def gather_safe(*aws) -> None:
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
for res in results:
|
| 11 |
if isinstance(res, Exception):
|
| 12 |
log.info("Parallel call failed: %r", res)
|
|
|
|
| 4 |
log = logging.getLogger(__name__)
|
| 5 |
|
| 6 |
|
| 7 |
+
async def _await(aw):
|
| 8 |
+
return await aw
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def gather_results(*aws) -> list:
|
| 12 |
+
"""Run awaitables in parallel and return results (exceptions included, never raised).
|
| 13 |
+
|
| 14 |
+
aiogram's bot-method objects (call.answer(), message.edit_text(), ...) are awaitable but are
|
| 15 |
+
unhashable pydantic models, and asyncio.gather() puts its arguments in a dict -> TypeError
|
| 16 |
+
"unhashable type". Wrapping each one in a coroutine avoids that.
|
| 17 |
+
"""
|
| 18 |
+
return await asyncio.gather(*(_await(a) for a in aws), return_exceptions=True)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
async def gather_safe(*aws) -> None:
|
| 22 |
+
"""Parallel Telegram calls; failures are logged, not raised."""
|
| 23 |
+
for res in await gather_results(*aws):
|
|
|
|
| 24 |
if isinstance(res, Exception):
|
| 25 |
log.info("Parallel call failed: %r", res)
|
tests/test_logic.py
CHANGED
|
@@ -6,7 +6,10 @@ from types import SimpleNamespace
|
|
| 6 |
|
| 7 |
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 8 |
|
|
|
|
|
|
|
| 9 |
from app.core import cache # noqa: E402
|
|
|
|
| 10 |
from app.services.engagement import DAILY_POINTS, MILESTONES, touch # noqa: E402
|
| 11 |
from app.services.ingest import parse_caption # noqa: E402
|
| 12 |
|
|
@@ -53,6 +56,35 @@ def test_cache_roundtrip_and_isolation():
|
|
| 53 |
assert cache.get(7) is None
|
| 54 |
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
if __name__ == "__main__":
|
| 57 |
for name, fn in list(globals().items()):
|
| 58 |
if name.startswith("test_"):
|
|
|
|
| 6 |
|
| 7 |
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 8 |
|
| 9 |
+
import asyncio # noqa: E402
|
| 10 |
+
|
| 11 |
from app.core import cache # noqa: E402
|
| 12 |
+
from app.core.aio import gather_results, gather_safe # noqa: E402
|
| 13 |
from app.services.engagement import DAILY_POINTS, MILESTONES, touch # noqa: E402
|
| 14 |
from app.services.ingest import parse_caption # noqa: E402
|
| 15 |
|
|
|
|
| 56 |
assert cache.get(7) is None
|
| 57 |
|
| 58 |
|
| 59 |
+
class _Unhashable:
|
| 60 |
+
"""Mimics aiogram method objects: awaitable, but unhashable (pydantic model)."""
|
| 61 |
+
|
| 62 |
+
__hash__ = None
|
| 63 |
+
|
| 64 |
+
def __init__(self, value):
|
| 65 |
+
self.value = value
|
| 66 |
+
|
| 67 |
+
def __await__(self):
|
| 68 |
+
async def run():
|
| 69 |
+
return self.value
|
| 70 |
+
|
| 71 |
+
return run().__await__()
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_plain_gather_fails_but_ours_works():
|
| 75 |
+
async def go():
|
| 76 |
+
try:
|
| 77 |
+
await asyncio.gather(_Unhashable(1))
|
| 78 |
+
plain_ok = True
|
| 79 |
+
except TypeError:
|
| 80 |
+
plain_ok = False
|
| 81 |
+
assert plain_ok is False # this is the bug that broke the library buttons
|
| 82 |
+
assert await gather_results(_Unhashable(1), _Unhashable(2)) == [1, 2]
|
| 83 |
+
await gather_safe(_Unhashable(3))
|
| 84 |
+
|
| 85 |
+
asyncio.run(go())
|
| 86 |
+
|
| 87 |
+
|
| 88 |
if __name__ == "__main__":
|
| 89 |
for name, fn in list(globals().items()):
|
| 90 |
if name.startswith("test_"):
|