Skip to content

Commit 25bcb2d

Browse files
authored
Merge pull request #22 from pictures2333/master
feat(event,guild): refactor event database lock, migrate event read flow and add guild info APIs
2 parents 989f39c + fa2203b commit 25bcb2d

28 files changed

+903
-472
lines changed

ctfeed.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ async def lifespan(app:FastAPI):
9191
app.include_router(router.user_router)
9292
app.include_router(router.ctf_router)
9393
app.include_router(router.config_router)
94+
app.include_router(router.guild_router)
9495

9596
# index
9697
@app.get("/", tags=["Shirakami Fubuki"])

notes/event.md

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,98 @@
11
# About Event
2+
## Rules
23
- **時區都是 utc+0** (要記得轉換)
34
- ``datetime.now(timezone.utc)``
45
- ``datetime_obj_with_timezone.astimezone(timezone.utc)``
5-
- 在讀取(即使用``read_event()``)的時候,如果不是確定「只會返回最多一個結果」的情境
6-
- finish_after=``int((datetime.now(timezone.utc) + timedelta(days=settings.DATABASE_SEARCH_DAYS)).timestamp())``
7-
- 我們需要限制讀出的數量,避免 DoS
6+
- 批量讀取 Event
7+
- 使用 ``read_event_many()``
8+
- ``finish_after`` mode: ``finish_after=int((datetime.now(timezone.utc) + timedelta(days=settings.DATABASE_SEARCH_DAYS)).timestamp())``
9+
- 我們需要限制讀出的數量,避免 DoS
10+
- 為避免日後讀取程式碼困難,使用不同的 mode 需要明確傳參(如 finish_before=None,就算是 None 也要傳)
11+
- 請確保在操作 Database 中的 events table 時遵循以下流程,並確保整個流程被包覆在``try...except...finally...``中:
12+
1. 使用 ``src.crud.read_event(..., lock=True, duration=120) 對單個 event 加鎖,並獲取物件
13+
2. (如果有需要,如創建頻道後將 ID 更新到資料庫)操作 Discord Bot
14+
3. 更新資料庫中的資料
15+
4.``finally...``區塊中解鎖
16+
5. 如有發生錯誤,在``except...``區塊中 rollback(例如:刪除創建出來的 Discord channel)
17+
- 純讀取不受此限制
18+
19+
## Docs
20+
21+
### ``read_event_one``
22+
23+
#### 用途
24+
- 用於讀取單一 Event(以 ``id`` 為主鍵)
25+
- 可選擇是否同時嘗試加鎖(給後續更新流程使用)
26+
27+
#### 使用方法
28+
- 只讀取(不加鎖)
29+
- ``lock=False``
30+
- 回傳:``(event_db, None)``
31+
- 讀取並嘗試加鎖
32+
- ``lock=True````duration`` 必填
33+
- 成功:回傳 ``(event_db, lock_owner_token)``
34+
- Event 不存在:``NotFoundError``
35+
- Event 已被鎖住:``LockedError``
36+
- ``type`` 可為 ``ctftime`` / ``custom`` / ``None``,用來限制 Event 類型
37+
- ``archived`` 可為 ``True`` / ``False`` / ``None``,用來限制封存狀態
38+
39+
#### 設計說明
40+
- 單筆查詢一律以 ``Event.id`` 為核心條件
41+
- 加鎖模式採用原子條件更新(``locked_until`` + ``locked_by``)避免競態
42+
- 回傳的 ``lock_owner_token`` 需在後續 ``update_event`` / ``unlock_event`` 使用
43+
44+
45+
### ``read_event_many``
46+
47+
#### 用途
48+
- 用於讀取多筆 Event,給 API 列表查詢與背景工作使用
49+
- 支援 ``ctftime````custom`` 兩種查詢模式
50+
51+
#### 使用方法
52+
- ``type=ctftime``
53+
- ``finish_after`` mode
54+
- 只傳 ``finish_after``
55+
- 不能同時傳 ``finish_before````limit````before_id``
56+
- ``finish_before`` mode
57+
- ``finish_after`` 必須是 ``None``
58+
- ``limit`` 必填且需大於 0
59+
- 第一頁:``finish_before=None````before_id=None``
60+
- 下一頁:帶上前一頁最後一筆的 ``finish````id``(即 ``finish_before````before_id``
61+
- ``type=custom``
62+
- ``limit`` 必填且需大於 0
63+
- 第一頁:``before_id=None``
64+
- 下一頁:帶上前一頁最後一筆 ``id````before_id``
65+
- 不能傳 ``finish_after`` / ``finish_before``
66+
- ``archived`` 可選,用於限制封存狀態
67+
68+
#### 設計說明
69+
- ``ctftime`` 分頁採用複合游標條件:
70+
- 排序:``ORDER BY finish DESC, id DESC``
71+
- 下一頁條件:``finish < finish_before````(finish == finish_before and id < before_id)``
72+
- ``custom`` 分頁採用 ``id`` 游標:
73+
- 排序:``ORDER BY id DESC``
74+
- 下一頁條件:``id < before_id``
75+
- 參數組合會在函式內做嚴格檢查,不合法時拋 ``ValueError``
76+
77+
78+
### ``ctfmenu``(EventMenu / EventDetailMenu)
79+
80+
#### 用途
81+
- Discord 互動式 Event 清單檢視(ctftime / custom)
82+
- 提供分頁、切換 type、查看單筆 Event 詳細資訊
83+
84+
#### 設計說明
85+
- View timeout 設為 ``60s``,避免互動元件長時間掛著
86+
- ``ctftime`` 清單採「首次查詢後快取」:
87+
- 第一次 ``build_embed_and_view()`` 查一次資料庫
88+
- 後續翻頁只吃記憶體快取,不重查 DB
89+
- ``custom`` 清單採 cursor 分頁(``before_id`` + ``limit``):
90+
- 每頁查 ``per_page + 1`` 判斷 ``has_next``
91+
- 使用 page cache(以頁碼快取已讀頁資料)避免回上一頁時被新資料擠動
92+
- ``EventDetailMenu`` 使用 ``read_event_one(lock=False, ...)`` 讀單筆,找不到時回傳 ``Event not found``
93+
94+
#### 常見坑
95+
- ``custom`` 分頁如果每次都重查 DB(不做頁面快取),新資料插入後會造成頁面漂移或看起來「有些 event 被擠掉」
96+
- ``ctftime`` 模式若每次翻頁都重查 DB,會有不必要的負擔;此場景改用快取較穩定
97+
- ``read_event_many`` 需明確傳 mode 參數(即使是 ``None`` 也傳)以提升可讀性並符合本專案規範
98+
- ``read_event_one`` / lock 流程內部使用 ``session.begin()``,caller 不要在外層再包 transaction 以避免巢狀交易風險

src/backend/channel_op.py

Lines changed: 40 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Optional, Dict, Any
1+
from typing import Optional, Dict, Any, Tuple
22
import logging
33

44
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,16 +13,36 @@
1313
from src.utils import get_category
1414
from src.utils import ctf_api
1515
from src.utils import embed_creator
16-
from src.bot import get_bot
16+
from src.bot import get_guild
1717
from src import crud
1818

1919
# channel_op = "event_op"
2020

2121
# logging
2222
logger = logging.getLogger("uvicorn")
2323

24+
# utils
25+
async def read_event_one_wrapper(session:AsyncSession, event_db_id:int) -> Tuple[model.Event, str]:
26+
try:
27+
event_db, lock_owner_token = await crud.read_event_one(
28+
session=session,
29+
lock=True, duration=120,
30+
archived=False, # ensoure the Event isn't archived
31+
id=event_db_id
32+
)
33+
except crud.NotFoundError:
34+
raise HTTPException(404, f"Event (id={event_db_id}) not found (archived, or invalid id)")
35+
except crud.LockedError:
36+
raise HTTPException(423, F"Event (id={event_db_id}) was locked. Try again later.")
37+
except Exception as e:
38+
logger.error(f"Can't get and lock Event (id={event_db_id}): {str(e)}")
39+
raise HTTPException(500, f"Can't get and lock Event (id={event_db_id})")
40+
41+
return event_db, lock_owner_token
42+
43+
2444
# functions
25-
async def _create_channel(session:AsyncSession, member:discord.Member, event_db_id:int, lock_owner_token:str):
45+
async def _create_channel(session:AsyncSession, member:discord.Member, event_db:model.Event, lock_owner_token:str) -> model.Event:
2646
# 在這個 function 有 exception 就直接 raise 出來
2747
channel:Optional[discord.TextChannel] = None
2848
event_api:Optional[Dict[str, Any]] = None
@@ -31,10 +51,7 @@ async def _create_channel(session:AsyncSession, member:discord.Member, event_db_
3151
log_msg:str = ""
3252

3353
# get guild
34-
bot = await get_bot()
35-
if (guild := bot.get_guild(settings.GUILD_ID)) is None:
36-
logger.critical(f"Guild (id={settings.GUILD_ID}) not found")
37-
raise HTTPException(500, f"Guild (id={settings.GUILD_ID}) not found")
54+
guild = get_guild()
3855

3956
# get category
4057
if (ctf_channel_category := get_category.get_category(guild, settings.CTF_CHANNEL_CATEGORY_ID)) is None:
@@ -43,23 +60,13 @@ async def _create_channel(session:AsyncSession, member:discord.Member, event_db_
4360

4461
try:
4562
async with session.begin():
46-
# get a new event_db
47-
events_db = await crud.read_event(
48-
session,
49-
id=event_db_id,
50-
archived=False, # ensure the event isn't archived
51-
lock_owner_token=lock_owner_token
52-
)
53-
if len(events_db) != 1:
54-
raise RuntimeError(f"Event (id={event_db_id}) not found")
55-
event_db = events_db[0]
5663
ctftime_event = True if event_db.event_id is not None else False
5764

5865
# check channel
5966
if (channel_id := event_db.channel_id) is not None and \
6067
guild.get_channel(channel_id) is not None:
6168
# exists -> no need to create
62-
return
69+
return event_db
6370

6471
if ctftime_event:
6572
events_api = await ctf_api.fetch_ctf_events(event_db.event_id)
@@ -107,49 +114,35 @@ async def _create_channel(session:AsyncSession, member:discord.Member, event_db_
107114
except Exception as e:
108115
logger.error(f"fail to send notification to channel (id={channel.id}): {str(e)}")
109116
# ignore exception
110-
111-
return
112117

118+
return event_db
113119

114-
async def _join_channel(session:AsyncSession, member:discord.Member, event_db_id:int, lock_owner_token:str):
120+
121+
async def _join_channel(session:AsyncSession, member:discord.Member, event_db:model.Event, lock_owner_token:str):
115122
# 在這個 function 有 exception 就直接 raise 出來
116123
# get guild
117-
bot = await get_bot()
118-
if (guild := bot.get_guild(settings.GUILD_ID)) is None:
119-
logger.critical(f"Guild (id={settings.GUILD_ID}) not found")
120-
raise HTTPException(500, f"Guild (id={settings.GUILD_ID}) not found")
124+
guild = get_guild()
121125

122126
joined_channel = False # joined channel in Discord, but not in database
123127
joined = False # joined channel in Discord and database
124128
log_msg:str = ""
125129
try:
126130
async with session.begin():
127-
# get a new event_db
128-
events_db = await crud.read_event(
129-
session,
130-
id=event_db_id,
131-
archived=False, # ensure the Event isn't archived
132-
lock_owner_token=lock_owner_token
133-
)
134-
if len(events_db) != 1:
135-
raise RuntimeError(f"Event (id={event_db_id}) not found")
136-
event_db = events_db[0]
137-
138131
# check channel
139132
if (channel_id := event_db.channel_id) is None or \
140133
(channel := guild.get_channel(channel_id)) is None:
141-
raise RuntimeError(f"TextChannel for Event (id={event_db_id}) not found")
134+
raise RuntimeError(f"TextChannel for Event (id={event_db.id}) not found")
142135

143136
# join channel
144137
await channel.set_permissions(member, view_channel=True)
145138
joined_channel = True
146139

147140
# update database
148141
try:
149-
await crud.join_event(session, event_db_id, member.id, lock_owner_token)
142+
await crud.join_event(session, event_db.id, member.id, lock_owner_token)
150143
except IntegrityError:
151144
# ignore
152-
raise HTTPException(409, f"The user (discord_id={member.id}) has joined the Event (id={event_db_id})")
145+
raise HTTPException(409, f"The user (discord_id={member.id}) has joined the Event (id={event_db.id})")
153146
except Exception:
154147
raise
155148
joined = True
@@ -196,22 +189,14 @@ async def create_and_join_channel(member:discord.Member, event_db_id:int):
196189
lock_owner_token:Optional[str] = None
197190
async with database.with_get_db() as session:
198191
# try to lock event
199-
try:
200-
lock_owner_token = await crud.try_lock_event(session, event_db_id, 120)
201-
except crud.LockedError:
202-
raise HTTPException(423, f"Event (id={event_db_id}) was locked. Try again later.")
203-
except crud.NotFoundError:
204-
raise HTTPException(404, f"Event (id={event_db_id}) not found")
205-
except Exception as e:
206-
logger.error(f"Can't lock Event (id={event_db_id}): {str(e)}")
207-
raise HTTPException(f"Can't lock Event (id={event_db_id}): {str(e)}")
192+
event_db, lock_owner_token = await read_event_one_wrapper(session, event_db_id)
208193

209194
try:
210195
# try to create channel
211-
await _create_channel(session, member, event_db_id, lock_owner_token)
196+
event_db = await _create_channel(session, member, event_db, lock_owner_token)
212197

213198
# join channel
214-
await _join_channel(session, member, event_db_id, lock_owner_token)
199+
await _join_channel(session, member, event_db, lock_owner_token)
215200
except Exception as e:
216201
if isinstance(e, HTTPException):
217202
raise
@@ -239,41 +224,17 @@ async def archive_event(event_db_id:int, reason:str):
239224
event_db_returning = {}
240225

241226
# get guild
242-
bot = await get_bot()
243-
if (guild := bot.get_guild(settings.GUILD_ID)) is None:
244-
logger.critical(f"Guild (id={settings.GUILD_ID}) not found")
245-
raise HTTPException(500, f"Guild (id={settings.GUILD_ID}) not found")
227+
guild = get_guild()
246228

247229
# get archive category
248230
if (archive_category := get_category.get_category(guild, settings.ARCHIVE_CATEGORY_ID)) is None:
249231
logger.critical(f"Archive Category (id={settings.ARCHIVE_CATEGORY_ID}) not found")
250232
raise HTTPException(500, f"Archive Category (id={settings.ARCHIVE_CATEGORY_ID}) not found")
251233

252234
async with database.with_get_db() as session:
253-
# try to lock the Event
254-
try:
255-
lock_owner_token = await crud.try_lock_event(session, event_db_id, 120)
256-
except crud.NotFoundError:
257-
raise HTTPException(404, f"Event (id={event_db_id}) not found")
258-
except crud.LockedError:
259-
raise HTTPException(423, f"Event (id={event_db_id}) was locked. Try again later.")
260-
except Exception as e:
261-
logger.error(f"Can't lock Event (id={event_db_id}): {str(e)}")
262-
raise HTTPException(500, f"Can't lock Event (id={event_db_id})")
263-
235+
event_db, lock_owner_token = await read_event_one_wrapper(session, event_db_id)
264236
try:
265237
async with session.begin():
266-
# get a new event_db
267-
events_db = await crud.read_event(
268-
session=session,
269-
archived=False, # ensure the Event isn't archived
270-
id=event_db_id,
271-
lock_owner_token=lock_owner_token,
272-
)
273-
if len(events_db) != 1:
274-
raise RuntimeError(f"Event (id={event_db_id}) not found")
275-
event_db = events_db[0]
276-
277238
# update database
278239
event_db:model.Event = await crud.update_event(
279240
session=session,
@@ -360,41 +321,17 @@ async def link_event_to_channel(event_db_id:int, channel_id:int):
360321
lock_owner_token = None
361322

362323
# get guild
363-
bot = await get_bot()
364-
if (guild := bot.get_guild(settings.GUILD_ID)) is None:
365-
logger.critical(f"Guild (id={settings.GUILD_ID}) not found")
366-
raise HTTPException(500, f"Guild (id={settings.GUILD_ID}) not found")
324+
guild = get_guild()
367325

368326
# get channel
369327
if (channel := guild.get_channel(channel_id)) is None or \
370328
not isinstance(channel, discord.TextChannel):
371329
raise HTTPException(400, f"Channel (id={channel_id}) not found")
372330

373331
async with database.with_get_db() as session:
374-
# try to lock the Event
375-
try:
376-
lock_owner_token = await crud.try_lock_event(session, event_db_id, 120)
377-
except crud.NotFoundError:
378-
raise HTTPException(404, f"Event (id={event_db_id}) not found")
379-
except crud.LockedError:
380-
raise HTTPException(423, f"Event (id={event_db_id}) was locked. Try again later.")
381-
except Exception as e:
382-
logger.error(f"Can't lock Event (id={event_db_id}): {str(e)}")
383-
raise HTTPException(500, f"Can't lock Event (id={event_db_id})")
384-
332+
event_db, lock_owner_token = await read_event_one_wrapper(session, event_db_id)
385333
try:
386334
async with session.begin():
387-
# get a new event_db
388-
events_db = await crud.read_event(
389-
session=session,
390-
archived=False, # ensure the Event isn't archived
391-
id=event_db_id,
392-
lock_owner_token=lock_owner_token
393-
)
394-
if len(events_db) != 1:
395-
raise RuntimeError(f"Event (id={event_db_id}) not found")
396-
event_db = events_db[0]
397-
398335
# update database
399336
event_db:model.Event = await crud.update_event(
400337
session=session,
@@ -428,10 +365,7 @@ async def create_custom_event(title:str):
428365
:raise HTTPException:
429366
"""
430367
# get guild
431-
bot = await get_bot()
432-
if (guild := bot.get_guild(settings.GUILD_ID)) is None:
433-
logger.critical(f"Guild (id={settings.GUILD_ID}) not found")
434-
raise HTTPException(500, f"Guild (id={settings.GUILD_ID}) not found")
368+
guild = get_guild()
435369

436370
# create the custom event in database
437371
event_db_id = None

0 commit comments

Comments
 (0)