fix (schedule): Bugfixes.

Fix ScheduleUI issue where clear button was not enabling.
Fix ScheduleUI menus showing soon entries.
Fix ScheduleUI time format being T instead of f.
Fix ScheduleUI cancel menu out of order.
Add special case format for `_format_until` with 0 distance.

Add `TimeSlot` repr.
Add `Sesson` repr.
Fix issue where noshow could potentially deadlock sessions.
Fix issue where `create_booking` could add garbage to cache.
Add `bot.idlock` for snowflake locking.
Remove valid channel check from clock off.
Changed implementation of batchrun.
Add `cog.nowid` for easier introspection.
Add more logging.
This commit is contained in:
2023-07-08 08:57:00 +03:00
parent 78fb398e03
commit 043f358f57
7 changed files with 309 additions and 173 deletions

View File

@@ -1,6 +1,7 @@
from typing import List, Optional, TYPE_CHECKING from typing import List, Optional, TYPE_CHECKING
import logging import logging
import asyncio import asyncio
from weakref import WeakValueDictionary
import discord import discord
from discord.utils import MISSING from discord.utils import MISSING
@@ -44,6 +45,8 @@ class LionBot(Bot):
self.core: Optional['CoreCog'] = None self.core: Optional['CoreCog'] = None
self.translator = translator self.translator = translator
self._locks = WeakValueDictionary()
async def setup_hook(self) -> None: async def setup_hook(self) -> None:
log_context.set(f"APP: {self.application_id}") log_context.set(f"APP: {self.application_id}")
await self.app_ipc.connect() await self.app_ipc.connect()
@@ -81,6 +84,12 @@ class LionBot(Bot):
with logging_context(action=f"Dispatch {event_name}"): with logging_context(action=f"Dispatch {event_name}"):
super().dispatch(event_name, *args, **kwargs) super().dispatch(event_name, *args, **kwargs)
def idlock(self, snowflakeid):
lock = self._locks.get(snowflakeid, None)
if lock is None:
lock = self._locks[snowflakeid] = asyncio.Lock()
return lock
async def on_ready(self): async def on_ready(self):
logger.info( logger.info(
f"Logged in as {self.application.name}\n" f"Logged in as {self.application.name}\n"

View File

@@ -57,6 +57,11 @@ class ScheduleCog(LionCog):
self.session_channels = self.settings.SessionChannels._cache self.session_channels = self.settings.SessionChannels._cache
@property
def nowid(self):
now = utc_now()
return time_to_slotid(now)
async def cog_load(self): async def cog_load(self):
await self.data.init() await self.data.init()
@@ -143,6 +148,7 @@ class ScheduleCog(LionCog):
Every hour, starting at start_at, Every hour, starting at start_at,
the spawn loop will use `_spawner` to ensure the next slotid has been launched. the spawn loop will use `_spawner` to ensure the next slotid has been launched.
""" """
logger.info(f"Started scheduled session spawner at {start_at}")
next_spawn = start_at next_spawn = start_at
while True: while True:
try: try:
@@ -185,6 +191,7 @@ class ScheduleCog(LionCog):
lock = self._slot_locks.get(slotid, None) lock = self._slot_locks.get(slotid, None)
if lock is None: if lock is None:
lock = self._slot_locks[slotid] = asyncio.Lock() lock = self._slot_locks[slotid] = asyncio.Lock()
logger.debug(f"Getting slotlock <slotid: {slotid}> (locked: {lock.locked()})")
return lock return lock
@log_wrap(action='Cancel Booking') @log_wrap(action='Cancel Booking')
@@ -255,28 +262,19 @@ class ScheduleCog(LionCog):
nextsession = nextslot.sessions.get(guildid, None) if nextslot else None nextsession = nextslot.sessions.get(guildid, None) if nextslot else None
nextmember = (userid in nextsession.members) if nextsession else None nextmember = (userid in nextsession.members) if nextsession else None
unlock = None if (nextmember is None) or not (nextsession.prepared):
try: async with self.bot.idlock(room.id):
if nextmember: try:
unlock = nextsession.lock
await unlock.acquire()
update = (not nextsession.prepared)
else:
update = True
if update:
await room.set_permissions(member, overwrite=None) await room.set_permissions(member, overwrite=None)
except discord.HTTPException: except discord.HTTPException:
pass pass
finally: elif slot is not None and member is None:
if unlock is not None: # Should not happen
unlock.release() logger.error(
elif slot is not None and member is None: f"Cancelling booking <slotid: {slotid}> <gid: {guildid}> <uid: {userid}> "
# Should not happen "for active slot "
logger.error( "but the session member was not found. This should not happen."
f"Cancelling booking <slotid: {slotid}> <gid: {guildid}> <uid: {userid}> " )
"for active slot "
"but the session member was not found. This should not happen."
)
@log_wrap(action='Clear Member Schedule') @log_wrap(action='Clear Member Schedule')
async def clear_member_schedule(self, guildid, userid, refund=False): async def clear_member_schedule(self, guildid, userid, refund=False):
@@ -305,6 +303,9 @@ class ScheduleCog(LionCog):
blacklists depending on guild settings, blacklists depending on guild settings,
and notifies the user. and notifies the user.
""" """
logger.debug(
"Handling TimeSlot noshow for members: {}".format(', '.join(map(str, memberids)))
)
now = utc_now() now = utc_now()
nowid = time_to_slotid(now) nowid = time_to_slotid(now)
member_model = self.data.ScheduleSessionMember member_model = self.data.ScheduleSessionMember
@@ -358,6 +359,9 @@ class ScheduleCog(LionCog):
tasks.append(task) tasks.append(task)
# TODO: Logging and some error handling # TODO: Logging and some error handling
await asyncio.gather(*tasks, return_exceptions=True) await asyncio.gather(*tasks, return_exceptions=True)
logger.info(
f"Applied scheduled session blacklist to {len(to_blacklist)} missing members."
)
# Now cancel future sessions for members who were not blacklisted and are not currently clocked on # Now cancel future sessions for members who were not blacklisted and are not currently clocked on
to_clear = [] to_clear = []
@@ -380,7 +384,12 @@ class ScheduleCog(LionCog):
bookingids = [(b.slotid, b.guildid, b.userid) for b in bookings] bookingids = [(b.slotid, b.guildid, b.userid) for b in bookings]
if bookingids: if bookingids:
await self.cancel_bookings(*bookingids, refund=False) await self.cancel_bookings(*bookingids, refund=False)
# TODO: Logging and error handling logger.info(
f"Cancelled future sessions for {len(to_clear)} missing members."
)
logger.debug(
"Completed NoShow handling"
)
@log_wrap(action='Create Booking') @log_wrap(action='Create Booking')
async def create_booking(self, guildid, userid, *slotids): async def create_booking(self, guildid, userid, *slotids):
@@ -390,77 +399,80 @@ class ScheduleCog(LionCog):
Probably best refactored into an interactive method, Probably best refactored into an interactive method,
with some parts in slot and session. with some parts in slot and session.
""" """
logger.debug(
f"Creating bookings for member <uid: {userid}> in <gid: {guildid}> "
f"for slotids: {', '.join(map(str, slotids))}"
)
t = self.bot.translator.t t = self.bot.translator.t
locks = [self.slotlock(slotid) for slotid in slotids] locks = [self.slotlock(slotid) for slotid in slotids]
await asyncio.gather(*(lock.acquire() for lock in locks)) await asyncio.gather(*(lock.acquire() for lock in locks))
try: try:
conn = await self.bot.db.get_connection() # Validate bookings
async with conn.transaction(): guild_data = await self.data.ScheduleGuild.fetch_or_create(guildid)
# Validate bookings config = ScheduleConfig(guildid, guild_data)
guild_data = await self.data.ScheduleGuild.fetch_or_create(guildid)
config = ScheduleConfig(guildid, guild_data)
# Check guild lobby exists # Check guild lobby exists
if config.get(ScheduleSettings.SessionLobby.setting_id).value is None: if config.get(ScheduleSettings.SessionLobby.setting_id).value is None:
error = t(_p( error = t(_p(
'create_booking|error:no_lobby', 'create_booking|error:no_lobby',
"This server has not set a `session_lobby`, so the scheduled session system is disabled!" "This server has not set a `session_lobby`, so the scheduled session system is disabled!"
)) ))
raise UserInputError(error) raise UserInputError(error)
# Fetch up to data lion data and member data # Fetch up to data lion data and member data
lion = await self.bot.core.lions.fetch_member(guildid, userid) lion = await self.bot.core.lions.fetch_member(guildid, userid)
member = await lion.fetch_member() member = await lion.fetch_member()
await lion.data.refresh() await lion.data.refresh()
if not member: if not member:
# This should pretty much never happen unless something went wrong on Discord's end # This should pretty much never happen unless something went wrong on Discord's end
error = t(_p( error = t(_p(
'create_booking|error:no_member', 'create_booking|error:no_member',
"An unknown Discord error occurred. Please try again in a few minutes." "An unknown Discord error occurred. Please try again in a few minutes."
)) ))
raise UserInputError(error) raise UserInputError(error)
# Check member blacklist # Check member blacklist
if (role := config.get(ScheduleSettings.BlacklistRole.setting_id).value) and role in member.roles: if (role := config.get(ScheduleSettings.BlacklistRole.setting_id).value) and role in member.roles:
error = t(_p( error = t(_p(
'create_booking|error:blacklisted', 'create_booking|error:blacklisted',
"You have been blacklisted from the scheduled session system in this server." "You have been blacklisted from the scheduled session system in this server."
)) ))
raise UserInputError(error) raise UserInputError(error)
# Check member balance # Check member balance
requested = len(slotids) requested = len(slotids)
required = len(slotids) * config.get(ScheduleSettings.ScheduleCost.setting_id).value required = len(slotids) * config.get(ScheduleSettings.ScheduleCost.setting_id).value
balance = lion.data.coins balance = lion.data.coins
if balance < required: if balance < required:
error = t(_np( error = t(_np(
'create_booking|error:insufficient_balance', 'create_booking|error:insufficient_balance',
"Booking a session costs {coin}**{required}**, but you only have {coin}**{balance}**.", "Booking a session costs {coin}**{required}**, but you only have {coin}**{balance}**.",
"Booking `{count}` sessions costs {coin}**{required}**, but you only have {coin}**{balance}**.", "Booking `{count}` sessions costs {coin}**{required}**, but you only have {coin}**{balance}**.",
requested requested
)).format( )).format(
count=requested, coin=self.bot.config.emojis.coin, count=requested, coin=self.bot.config.emojis.coin,
required=required, balance=balance required=required, balance=balance
)
raise UserInputError(error)
# Check existing bookings
schedule = await self._fetch_schedule(userid)
if set(slotids).intersection(schedule.keys()):
error = t(_p(
'create_booking|error:already_booked',
"One or more requested timeslots are already booked!"
))
raise UserInputError(error)
# Booking request is now validated. Perform bookings.
# Fetch or create session data
await self.data.ScheduleSlot.fetch_multiple(*slotids)
session_data = await self.data.ScheduleSession.fetch_multiple(
*((guildid, slotid) for slotid in slotids)
) )
raise UserInputError(error)
# Check existing bookings
schedule = await self._fetch_schedule(userid)
if set(slotids).intersection(schedule.keys()):
error = t(_p(
'create_booking|error:already_booked',
"One or more requested timeslots are already booked!"
))
raise UserInputError(error)
conn = await self.bot.db.get_connection()
# Booking request is now validated. Perform bookings.
# Fetch or create session data
await self.data.ScheduleSlot.fetch_multiple(*slotids)
session_data = await self.data.ScheduleSession.fetch_multiple(
*((guildid, slotid) for slotid in slotids)
)
async with conn.transaction():
# Create transactions # Create transactions
economy = self.bot.get_cog('Economy') economy = self.bot.get_cog('Economy')
trans_data = ( trans_data = (
@@ -482,42 +494,56 @@ class ScheduleCog(LionCog):
) )
) )
# Now pass to activated slots # Now pass to activated slots
for record in booking_data: for record in booking_data:
slotid = record['slotid'] slotid = record['slotid']
if (slot := self.active_slots.get(slotid, None)): if (slot := self.active_slots.get(slotid, None)):
session = slot.sessions.get(guildid, None) session = slot.sessions.get(guildid, None)
if session is None: if session is None:
# Create a new session in the slot and set it up # Create a new session in the slot and set it up
sessions = await slot.load_sessions([session_data[guildid, slotid]]) sessions = await slot.load_sessions([session_data[guildid, slotid]])
session = sessions[guildid] session = sessions[guildid]
slot.sessions[guildid] = session slot.sessions[guildid] = session
if slot.closing.is_set(): if slot.closing.is_set():
# This should never happen # This should never happen
logger.error( logger.error(
"Attempt to book a session in a closing slot. This should be impossible." "Attempt to book a session in a closing slot. This should be impossible."
) )
raise ValueError('Cannot book a session in a closing slot.') raise ValueError('Cannot book a session in a closing slot.')
elif slot.opening.is_set(): elif slot.opening.is_set():
await slot.open([session]) await slot.open([session])
elif slot.preparing.is_set(): elif slot.preparing.is_set():
await slot.prepare([session]) await slot.prepare([session])
else: else:
# Session already exists in the slot # Session already exists in the slot
async with session.lock: async with session.lock:
if session.prepared: if session.prepared:
session.update_status_soon() session.update_status_soon()
if (room := session.room_channel) and (mem := session.guild.get_member(userid)): if (room := session.room_channel) and (mem := session.guild.get_member(userid)):
try: try:
await room.set_permissions( await room.set_permissions(
mem, connect=True, view_channel=True mem, connect=True, view_channel=True
) )
except discord.HTTPException: except discord.HTTPException:
pass logger.info(
f"Could not set room permissions for newly booked session "
f"<uid: {userid}> in {session!r}",
exc_info=True
)
logger.info(
f"Member <uid: {userid}> in <gid: {guildid}> booked scheduled sessions: " +
', '.join(map(str, slotids))
)
except UserInputError:
raise
except Exception:
logger.exception(
"Unexpected exception occurred while booking scheduled sessions."
)
raise
finally: finally:
for lock in locks: for lock in locks:
lock.release() lock.release()
# TODO: Logging and error handling
return booking_data return booking_data
# Event listeners # Event listeners
@@ -592,7 +618,7 @@ class ScheduleCog(LionCog):
member.clock_on(session_data.start_time) member.clock_on(session_data.start_time)
session.update_status_soon() session.update_status_soon()
logger.debug( logger.debug(
f"Clocked on member {member.data!r} with session {session_data!r}" f"Clocked on member {member.data!r} in session {session!r}"
) )
except Exception: except Exception:
logger.exception( logger.exception(
@@ -616,11 +642,11 @@ class ScheduleCog(LionCog):
member = session.members.get(session_data.userid, None) if session else None member = session.members.get(session_data.userid, None) if session else None
if member is not None: if member is not None:
async with session.lock: async with session.lock:
if session.listening and session.validate_channel(session_data.channelid): if session.listening and member.clock_start is not None:
member.clock_off(ended_at) member.clock_off(ended_at)
session.update_status_soon() session.update_status_soon()
logger.debug( logger.debug(
f"Clocked off member {member.data!r} from session {session_data!r}" f"Clocked off member {member.data!r} from session {session!r}"
) )
except Exception: except Exception:
logger.exception( logger.exception(

View File

@@ -80,6 +80,26 @@ class ScheduledSession:
self._updater = None self._updater = None
self._status_task = None self._status_task = None
def __repr__(self):
return ' '.join(
"<ScheduledSession"
f"slotid={self.slotid}",
f"guildid={self.guildid}",
f"lobbyid={ch.id if (ch := self.lobby_channel) else None}",
f"roomid={ch.id if (ch := self.room_channel) else None}",
f"members={len(self.members)}",
f"listening={self.listening}",
f"prepared={self.prepared}",
f"opened={self.opened}",
f"cancelled={self.cancelled}",
f"locked={self.lock.locked()}",
f"status_message={msg.id if (msg := self.status_message) else None}",
f"lobby_hook={hook.webhookid if (hook := self._hook) else None}",
f"last_update={self._last_update}",
f"updater_running={True if (self._updater and not self._updater.done()) else False}",
">"
)
# Setting shortcuts # Setting shortcuts
@property @property
def room_channel(self) -> Optional[discord.VoiceChannel]: def room_channel(self) -> Optional[discord.VoiceChannel]:
@@ -185,7 +205,7 @@ class ScheduledSession:
self._hook = None self._hook = None
except discord.HTTPException: except discord.HTTPException:
logger.warning( logger.warning(
f"Exception occurred sending to webhooks for scheduled session {self.data!r}", f"Exception occurred sending to webhooks for scheduled session {self!r}",
exc_info=True exc_info=True
) )
@@ -221,14 +241,12 @@ class ScheduledSession:
await room.edit(overwrites=overwrites) await room.edit(overwrites=overwrites)
except discord.HTTPException: except discord.HTTPException:
logger.warning( logger.warning(
f"Unexpected discord exception received while preparing schedule session room <cid: {room.id}> " f"Unexpected discord exception received while preparing schedule session room {self!r}",
f"in guild <gid: {self.guildid}> for timeslot <sid: {self.slotid}>.",
exc_info=True exc_info=True
) )
else: else:
logger.debug( logger.debug(
f"Prepared schedule session room <cid: {room.id}> " f"Prepared schedule session room for session {self!r}"
f"in guild <gid: {self.guildid}> for timeslot <sid: {self.slotid}>.",
) )
else: else:
t = self.bot.translator.t t = self.bot.translator.t
@@ -266,13 +284,11 @@ class ScheduledSession:
await room.edit(overwrites=overwrites) await room.edit(overwrites=overwrites)
except discord.HTTPException: except discord.HTTPException:
logger.exception( logger.exception(
f"Unhandled discord exception received while opening schedule session room <cid: {room.id}> " f"Unhandled discord exception received while opening schedule session room {self!r}"
f"in guild <gid: {self.guildid}> for timeslot <sid: {self.slotid}>."
) )
else: else:
logger.debug( logger.debug(
f"Opened schedule session room <cid: {room.id}> " f"Opened schedule session room for session {self!r}"
f"in guild <gid: {self.guildid}> for timeslot <sid: {self.slotid}>.",
) )
else: else:
t = self.bot.translator.t t = self.bot.translator.t
@@ -485,7 +501,7 @@ class ScheduledSession:
except discord.HTTPException: except discord.HTTPException:
# Unexpected issue updating the message # Unexpected issue updating the message
logger.exception( logger.exception(
f"Exception occurred updating status for scheduled session {self.data!r}" f"Exception occurred updating status for scheduled session {self!r}"
) )
if repost and resend and self.members: if repost and resend and self.members:
@@ -531,12 +547,11 @@ class ScheduledSession:
await self.update_status() await self.update_status()
except asyncio.CancelledError: except asyncio.CancelledError:
logger.debug( logger.debug(
f"Cancelled scheduled session update loop <slotid: {self.slotid}> ,gid: {self.guildid}>" f"Cancelled scheduled session update loop for session {self!r}"
) )
except Exception: except Exception:
logger.exception( logger.exception(
"Unknown exception encountered during session " "Unknown exception encountered during session update loop for session {self!r} "
f"update loop <slotid: {self.slotid}> ,gid: {self.guildid}>"
) )
def start_updating(self): def start_updating(self):

View File

@@ -13,12 +13,13 @@ from core.lion_member import LionMember
from core.lion_guild import LionGuild from core.lion_guild import LionGuild
from tracking.voice.session import SessionState from tracking.voice.session import SessionState
from utils.data import as_duration, MEMBERS, TemporaryTable from utils.data import as_duration, MEMBERS, TemporaryTable
from utils.ratelimits import Bucket
from modules.economy.cog import Economy from modules.economy.cog import Economy
from modules.economy.data import EconomyData, TransactionType from modules.economy.data import EconomyData, TransactionType
from .. import babel, logger from .. import babel, logger
from ..data import ScheduleData as Data from ..data import ScheduleData as Data
from ..lib import slotid_to_utc, batchrun_per_second from ..lib import slotid_to_utc, batchrun_per_second, limit_concurrency
from ..settings import ScheduleSettings from ..settings import ScheduleSettings
from .session import ScheduledSession from .session import ScheduledSession
@@ -63,6 +64,41 @@ class TimeSlot:
self.run_task = None self.run_task = None
self.loaded = False self.loaded = False
def __repr__(self):
if self.closing.is_set():
state = 'closing'
elif self.opened.is_set():
state = 'opened'
elif self.opening.is_set():
state = 'opening'
elif self.preparing.is_set():
state = 'preparing'
elif self.loaded:
state = 'loaded'
else:
state = 'unloaded'
if self.run_task:
if self.run_task.cancelled():
running = 'Cancelled'
elif self.run_task.done():
running = 'Done'
else:
running = 'Running'
else:
running = 'None'
return (
"<TimeSlot "
f"slotid={self.slotid} "
f"state='{state}' "
f"sessions={len(self.sessions)} "
f"members={sum(len(s.members) for s in self.sessions.values())} "
f"loaded={self.loaded} "
f"run_task='{running}'"
">"
)
@log_wrap(action="Fetch sessions") @log_wrap(action="Fetch sessions")
async def fetch(self): async def fetch(self):
""" """
@@ -81,7 +117,7 @@ class TimeSlot:
self.sessions.update(sessions) self.sessions.update(sessions)
self.loaded = True self.loaded = True
logger.info( logger.info(
f"Timeslot <slotid: {self.slotid}> finished preloading {len(self.sessions)} guilds. Ready to open." f"Timeslot {self!r}> finished preloading {len(self.sessions)} guilds. Ready to open."
) )
@log_wrap(action="Load sessions") @log_wrap(action="Load sessions")
@@ -129,7 +165,7 @@ class TimeSlot:
sessions[row.guildid] = session sessions[row.guildid] = session
logger.debug( logger.debug(
f"Timeslot <slotid: {self.slotid}> " f"Timeslot {self!r} "
f"loaded guild data for {len(sessions)} guilds: {', '.join(map(str, guildids))}" f"loaded guild data for {len(sessions)} guilds: {', '.join(map(str, guildids))}"
) )
return sessions return sessions
@@ -204,10 +240,12 @@ class TimeSlot:
This does not take the session lock for setting perms, because this is race-safe This does not take the session lock for setting perms, because this is race-safe
(aside from potentially leaving extra permissions, which will be overwritten by `open`). (aside from potentially leaving extra permissions, which will be overwritten by `open`).
""" """
logger.debug(f"Running prepare for time slot <slotid: {self.slotid}> with {len(sessions)} sessions.") logger.debug(f"Running prepare for time slot: {self!r}")
try: try:
coros = [session.prepare(save=False) for session in sessions if session.can_run] bucket = Bucket(5, 1)
await batchrun_per_second(coros, 5) coros = [bucket.wrapped(session.prepare(save=False)) for session in sessions if session.can_run]
async for task in limit_concurrency(coros, 5):
await task
# Save messageids # Save messageids
tmptable = TemporaryTable( tmptable = TemporaryTable(
@@ -227,11 +265,11 @@ class TimeSlot:
).from_expr(tmptable) ).from_expr(tmptable)
except Exception: except Exception:
logger.exception( logger.exception(
f"Unhandled exception while preparing timeslot <slotid: {self.slotid}>." f"Unhandled exception while preparing timeslot: {self!r}"
) )
else: else:
logger.info( logger.info(
f"Prepared {len(sessions)} for scheduled session timeslot <slotid: {self.slotid}>" f"Prepared {len(sessions)} for scheduled session timeslot: {self!r}"
) )
@log_wrap(action="Open Sessions") @log_wrap(action="Open Sessions")
@@ -269,12 +307,14 @@ class TimeSlot:
session.start_updating() session.start_updating()
# Bulk run guild open to open session rooms # Bulk run guild open to open session rooms
bucket = Bucket(5, 1)
voice_coros = [ voice_coros = [
session.open_room() bucket.wrapped(session.open_room())
for session in fresh for session in fresh
if session.room_channel is not None and session.data.opened_at is None if session.room_channel is not None and session.data.opened_at is None
] ]
await batchrun_per_second(voice_coros, 5) async for task in limit_concurrency(voice_coros, 5):
await task
await asyncio.gather(*message_tasks) await asyncio.gather(*message_tasks)
await asyncio.gather(*notify_tasks) await asyncio.gather(*notify_tasks)
@@ -297,11 +337,11 @@ class TimeSlot:
).from_expr(tmptable) ).from_expr(tmptable)
except Exception: except Exception:
logger.exception( logger.exception(
f"Unhandled exception while opening sessions for timeslot <slotid: {self.slotid}>." f"Unhandled exception while opening sessions for timeslot: {self!r}"
) )
else: else:
logger.info( logger.info(
f"Opened {len(sessions)} sessions for scheduled session timeslot <slotid: {self.slotid}>" f"Opened {len(sessions)} sessions for scheduled session timeslot: {self!r}"
) )
@log_wrap(action="Close Sessions") @log_wrap(action="Close Sessions")
@@ -394,11 +434,11 @@ class TimeSlot:
await self.cog.handle_noshow(*did_not_show) await self.cog.handle_noshow(*did_not_show)
except Exception: except Exception:
logger.exception( logger.exception(
f"Unhandled exception while closing sessions for timeslot <slotid: {self.slotid}>." f"Unhandled exception while closing sessions for timeslot: {self!r}"
) )
else: else:
logger.info( logger.info(
f"Closed {len(sessions)} for scheduled session timeslot <slotid: {self.slotid}>" f"Closed {len(sessions)} for scheduled session timeslot: {self!r}"
) )
def launch(self) -> asyncio.Task: def launch(self) -> asyncio.Task:
@@ -420,32 +460,30 @@ class TimeSlot:
if now < self.start_at: if now < self.start_at:
await discord.utils.sleep_until(self.prep_at) await discord.utils.sleep_until(self.prep_at)
self.preparing.set() self.preparing.set()
logger.info(f"Active timeslot preparing. {self!r}")
await self.prepare(list(self.sessions.values())) await self.prepare(list(self.sessions.values()))
else: logger.info(f"Active timeslot prepared. {self!r}")
await discord.utils.sleep_until(self.start_at) await discord.utils.sleep_until(self.start_at)
else:
self.preparing.set() self.preparing.set()
self.opening.set() self.opening.set()
logger.info(f"Active timeslot opening. {self!r}")
await self.open(list(self.sessions.values())) await self.open(list(self.sessions.values()))
logger.info(f"Active timeslot opened. {self!r}")
self.opened.set() self.opened.set()
await discord.utils.sleep_until(self.end_at) await discord.utils.sleep_until(self.end_at)
self.closing.set() self.closing.set()
logger.info(f"Active timeslot closing. {self!r}")
await self.close(list(self.sessions.values()), consequences=True) await self.close(list(self.sessions.values()), consequences=True)
logger.info(f"Active timeslot closed. {self!r}")
except asyncio.CancelledError: except asyncio.CancelledError:
if self.closing.is_set():
state = 'closing'
elif self.opened.is_set():
state = 'opened'
elif self.opening.is_set():
state = 'opening'
elif self.preparing.is_set():
state = 'preparing'
logger.info( logger.info(
f"Deactivating active time slot <slotid: {self.slotid}> " f"Deactivating active time slot: {self!r}"
f"with state '{state}'."
) )
except Exception: except Exception:
logger.exception( logger.exception(
f"Unexpected exception occurred while running active time slot <slotid: {self.slotid}>." f"Unexpected exception occurred while running active time slot: {self!r}."
) )
@log_wrap(action="Slot Cleanup") @log_wrap(action="Slot Cleanup")

View File

@@ -2,6 +2,7 @@ import asyncio
import itertools import itertools
import datetime as dt import datetime as dt
from . import logger
from utils.ratelimits import Bucket from utils.ratelimits import Bucket
@@ -39,3 +40,34 @@ async def batchrun_per_second(awaitables, batchsize):
task = asyncio.create_task(awaitable) task = asyncio.create_task(awaitable)
task.add_done_callback(lambda fut: sem.release()) task.add_done_callback(lambda fut: sem.release())
return await asyncio.gather(*tasks, return_exceptions=True) return await asyncio.gather(*tasks, return_exceptions=True)
async def limit_concurrency(aws, limit):
"""
Run provided awaitables concurrently,
ensuring that no more than `limit` are running at once.
"""
aws = iter(aws)
aws_ended = False
pending = set()
count = 0
logger.debug("Starting limited concurrency executor")
while pending or not aws_ended:
while len(pending) < limit and not aws_ended:
aw = next(aws, None)
if aw is None:
aws_ended = True
else:
pending.add(asyncio.create_task(aw))
count += 1
if not pending:
break
done, pending = await asyncio.wait(
pending, return_when=asyncio.FIRST_COMPLETED
)
while done:
yield done.pop()
logger.debug(f"Completed {count} tasks")

View File

@@ -141,8 +141,7 @@ class ScheduleUI(MessageUI):
'ui:schedule|button:clear|label', 'ui:schedule|button:clear|label',
"Clear Schedule" "Clear Schedule"
)) ))
if not self.schedule: self.clear_button.disabled = (not self.schedule)
self.clear_button.disabled = True
@button(label='ABOUT_PLACEHOLDER', emoji=conf.emojis.question, style=ButtonStyle.grey) @button(label='ABOUT_PLACEHOLDER', emoji=conf.emojis.question, style=ButtonStyle.grey)
async def about_button(self, press: discord.Interaction, pressed: Button): async def about_button(self, press: discord.Interaction, pressed: Button):
@@ -220,7 +219,7 @@ class ScheduleUI(MessageUI):
try: try:
await self.cog.create_booking(self.guildid, self.userid, *slotids) await self.cog.create_booking(self.guildid, self.userid, *slotids)
timestrings = [ timestrings = [
discord.utils.format_dt(slotid_to_utc(slotid), style='T') discord.utils.format_dt(slotid_to_utc(slotid), style='f')
for slotid in slotids for slotid in slotids
] ]
ack = t(_np( ack = t(_np(
@@ -264,6 +263,9 @@ class ScheduleUI(MessageUI):
# Populate with choices # Populate with choices
nowid = self.nowid nowid = self.nowid
if ((slotid_to_utc(nowid + 3600) - utc_now()).total_seconds() < 60):
# Start from next session instead
nowid += 3600
upcoming = [nowid + 3600 * i for i in range(1, 25)] upcoming = [nowid + 3600 * i for i in range(1, 25)]
upcoming = [slotid for slotid in upcoming if slotid not in self.schedule] upcoming = [slotid for slotid in upcoming if slotid not in self.schedule]
options = self._format_slot_options(*upcoming) options = self._format_slot_options(*upcoming)
@@ -298,7 +300,7 @@ class ScheduleUI(MessageUI):
slot_format = t(_p( slot_format = t(_p(
'ui:schedule|menu:slots|option|format', 'ui:schedule|menu:slots|option|format',
"{day} {time} (in {until})" "{day} {time} ({until})"
)) ))
today_name = t(_p( today_name = t(_p(
'ui:schedule|menu:slots|option|day:today', 'ui:schedule|menu:slots|option|day:today',
@@ -325,12 +327,18 @@ class ScheduleUI(MessageUI):
def _format_until(self, distance): def _format_until(self, distance):
t = self.bot.translator.t t = self.bot.translator.t
return t(_np( if distance:
'ui:schedule|format_until', return t(_np(
"<1 hour", 'ui:schedule|format_until|positive',
"{number} hours", "in <1 hour",
distance "in {number} hours",
)).format(number=distance) distance
)).format(number=distance)
else:
return t(_p(
'ui:schedule|format_until|now',
"right now!"
))
@select(cls=Select, placeholder='CANCEL_MENU_PLACEHOLDER') @select(cls=Select, placeholder='CANCEL_MENU_PLACEHOLDER')
async def cancel_menu(self, selection: discord.Interaction, selected): async def cancel_menu(self, selection: discord.Interaction, selected):
@@ -379,7 +387,7 @@ class ScheduleUI(MessageUI):
embed = error_embed(error) embed = error_embed(error)
else: else:
timestrings = [ timestrings = [
discord.utils.format_dt(slotid_to_utc(record['slotid']), style='T') discord.utils.format_dt(slotid_to_utc(record['slotid']), style='f')
for record in booking_records for record in booking_records
] ]
ack = t(_np( ack = t(_np(
@@ -407,8 +415,11 @@ class ScheduleUI(MessageUI):
'ui:schedule|menu:cancel|placeholder', 'ui:schedule|menu:cancel|placeholder',
"Cancel booked sessions" "Cancel booked sessions"
)) ))
can_cancel = set(self.schedule.keys()) minid = self.nowid
can_cancel.discard(self.nowid) if ((slotid_to_utc(self.nowid + 3600) - utc_now()).total_seconds() < 60):
minid = self.nowid + 3600
can_cancel = list(slotid for slotid in self.schedule.keys() if slotid > minid)
menu.options = self._format_slot_options(*can_cancel) menu.options = self._format_slot_options(*can_cancel)
menu.max_values = len(menu.options) menu.max_values = len(menu.options)
@@ -520,11 +531,11 @@ class ScheduleUI(MessageUI):
t = self.bot.translator.t t = self.bot.translator.t
short_format = t(_p( short_format = t(_p(
'ui:schedule|booking_format:short', 'ui:schedule|booking_format:short',
"`in {until}` | {start} - {end}" "`{until}` | {start} - {end}"
)) ))
long_format = t(_p( long_format = t(_p(
'ui:schedule|booking_format:long', 'ui:schedule|booking_format:long',
"> `in {until}` | {start} - {end}" "> `{until}` | {start} - {end}"
)) ))
items = [] items = []
format = long_format if show_guild else short_format format = long_format if show_guild else short_format

View File

@@ -92,6 +92,11 @@ class Bucket:
while self.full: while self.full:
await asyncio.sleep(self.delay) await asyncio.sleep(self.delay)
async def wrapped(self, coro):
await self.wait()
self.request()
await coro
class RateLimit: class RateLimit:
def __init__(self, max_level, empty_time, error=None, cache=TTLCache(1000, 60 * 60)): def __init__(self, max_level, empty_time, error=None, cache=TTLCache(1000, 60 * 60)):