Skip to content

Commit adc45e4

Browse files
committed
Close the review findings on the :gogo: backfill (#89)
Fixes from the adversarial review of the ported PR #90: - Duplicate-registration race: the one-shot existing-rows snapshot could not close the window where JoinBigchat and the backfill process the same user concurrently, and JoinBigchat had no dedupe at all. Both paths now go through append_row_if_absent, which serializes the check-and-append behind a process-wide lock (the app runs as a single gunicorn worker). As a bonus this also absorbs duplicate Slack event deliveries. - A reactor who left the channel (or was deactivated) made the ephemeral fan-out crash mid-loop, dropping the remaining users' info messages and reporting the whole — already successful — request as failed. Ephemeral sends are now per-user best-effort. - Any other backfill error after the sheet was created also turned into the global "try again" error, and retrying the mention cannot succeed (same-name worksheet). The backfill now warns in the thread instead. - get_emoji only caught SlackApiError; socket-level OSErrors (which this repo already retries in get_replies) crashed the handler. It now uses the same 3-attempt retry and degrades to None. - N cold member lookups in one backfill each re-read the whole members sheet; _fetch_members now has a short (60s) TTL cache. Test gaps demonstrated by mutation testing are filled: multi-user batches, the MemberLackInfo branch, the link-before-reactions ordering, worksheet_id pinning, and a real-SlackClient contract test so the original PR's kwarg-mismatch bug class cannot silently return. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iELKuaH7CvNy1GZRd7ptg
1 parent 55e00ff commit adc45e4

9 files changed

Lines changed: 303 additions & 59 deletions

File tree

src/handler/bigchat/create_bigchat_sheet.py

Lines changed: 59 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
import logging
2+
3+
from slack_sdk.errors import SlackApiError
4+
15
from handler.bigchat.join_bigchat import build_registration_info_message
26
from handler.bigchat.mention_handler import MentionHandler
37
from implementation.member_finder import MemberLackInfo, MemberNotFound
48
from util.bigchat_event import parse_sheet_name
59
from util.utils import strip_multiline
610

11+
logger = logging.getLogger(__name__)
12+
713

814
class CreateBigchatSheet(MentionHandler):
915
def __init__(self, event, slack_client, gs_client, member_manager, target_emoji):
@@ -55,8 +61,13 @@ def _register_early_reacted_users(self, worksheet_id):
5561
reaction 은 시트 링크 메시지를 올린 '뒤에' 읽는다. 링크가 올라간 이후의
5662
reaction 은 JoinBigchat(reaction_added)이 정상 처리하므로, 이 순서면 시트
5763
생성 전후 어느 시점에 눌린 reaction 도 두 경로 중 한쪽에는 반드시 잡힌다.
58-
두 경로가 거의 동시에 같은 사람을 처리하는 좁은 구간은 시트의 기존 행과
59-
대조해 중복 등록을 막는다.
64+
두 경로가 거의 동시에 같은 사람을 처리해도 append_row_if_absent 가
65+
중복 등록을 막는다.
66+
67+
여기 도달했다면 시트 생성과 링크 안내는 이미 성공했으므로, 일괄 등록이
68+
실패해도 전체 요청을 실패 처리하면 안 된다 — 전역 에러 핸들러의
69+
'다시 시도해줘' 안내를 따라 멘션을 다시 보내면 같은 이름의 시트를 또
70+
만들려다 실패한다. 대신 스레드에 경고만 남긴다.
6071
"""
6172
# 모집글(스레드 부모)에 달린 reaction 을 읽어야 한다. 스레드 없이 채널에
6273
# 바로 멘션한 경우엔 멘션 글 자체가 모집글이다.
@@ -67,40 +78,53 @@ def _register_early_reacted_users(self, worksheet_id):
6778
if reaction is None:
6879
return
6980

70-
existing_rows = self.gs_client.get_values(worksheet_id)
71-
registered = []
72-
for user in reaction.users:
73-
try:
74-
member = self.member_manager.find(user)
75-
except MemberNotFound:
76-
self.slack_client.send_message(
77-
msg=f"<@{user}>, 네 정보를 찾지 못했어. 운영진에게 연락해줘!",
78-
ts=self.ts,
79-
)
80-
except MemberLackInfo:
81-
self.slack_client.send_message(
82-
msg=f"<@{user}>, 네 정보에 누락된 값이 있어. 운영진에게 연락해줘!",
83-
ts=self.ts,
84-
)
85-
else:
86-
row = member.transform_for_spreadsheet()
87-
if row in existing_rows:
88-
continue
89-
self.gs_client.append_row(worksheet_id, row)
90-
registered.append((user, member))
81+
try:
82+
registered = []
83+
for user in reaction.users:
84+
try:
85+
member = self.member_manager.find(user)
86+
except MemberNotFound:
87+
self.slack_client.send_message(
88+
msg=f"<@{user}>, 네 정보를 찾지 못했어. 운영진에게 연락해줘!",
89+
ts=self.ts,
90+
)
91+
except MemberLackInfo:
92+
self.slack_client.send_message(
93+
msg=f"<@{user}>, 네 정보에 누락된 값이 있어. 운영진에게 연락해줘!",
94+
ts=self.ts,
95+
)
96+
else:
97+
if self.gs_client.append_row_if_absent(
98+
worksheet_id, member.transform_for_spreadsheet()
99+
):
100+
registered.append((user, member))
91101

92-
if not registered:
93-
return
102+
if not registered:
103+
return
94104

95-
mentions = " ".join(f"<@{user}>" for user, _ in registered)
96-
self.slack_client.send_message(
97-
msg=f"{mentions} 시트가 만들어지기 전에 :{self.target_emoji}:를 눌러줬구나! 지금 등록 완료했어!",
98-
ts=self.ts,
99-
)
100-
for user, member in registered:
101-
self.slack_client.send_message_only_visible_to_user(
102-
msg=build_registration_info_message(user, member),
103-
channel=self.channel,
105+
mentions = " ".join(f"<@{user}>" for user, _ in registered)
106+
self.slack_client.send_message(
107+
msg=f"{mentions} 시트가 만들어지기 전에 :{self.target_emoji}:를 눌러줬구나! 지금 등록 완료했어!",
108+
ts=self.ts,
109+
)
110+
for user, member in registered:
111+
try:
112+
self.slack_client.send_message_only_visible_to_user(
113+
msg=build_registration_info_message(user, member),
114+
channel=self.channel,
115+
ts=self.ts,
116+
user_id=user,
117+
)
118+
except SlackApiError as ex:
119+
# 채널을 떠났거나 비활성화된 반응자에게는 ephemeral 을 못 보낸다.
120+
# 등록 자체는 이미 끝났으므로 나머지 인원 안내를 계속한다.
121+
logger.warning(
122+
f"Failed to send registration info to {user}: {ex}"
123+
)
124+
except Exception:
125+
logger.exception("Failed to backfill early reactions")
126+
self.slack_client.send_message(
127+
msg=f"미리 눌린 :{self.target_emoji}: 자동 등록을 처리하다 문제가 생겨서 멈췄어. "
128+
"등록되지 않은 사람이 있을 수 있으니 시트를 확인해줘!",
104129
ts=self.ts,
105-
user_id=user,
106130
)

src/handler/bigchat/join_bigchat.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,9 @@ def run(self):
144144
return False
145145

146146
try:
147-
self.gs_client.append_row(worksheet_id, member.transform_for_spreadsheet())
147+
added = self.gs_client.append_row_if_absent(
148+
worksheet_id, member.transform_for_spreadsheet()
149+
)
148150
except WorksheetNotFound:
149151
self.slack_client.send_message(
150152
msg=f"<@{self.user}>, 이 빅챗의 신청 시트를 찾을 수 없어. 이미 마감되었거나 삭제된 것 같아. "
@@ -153,6 +155,13 @@ def run(self):
153155
)
154156
return False
155157

158+
if not added:
159+
# 시트 생성 직후의 일괄 등록(#89)과 동시에 처리됐거나 이벤트가 중복 전달된 경우
160+
self.slack_client.send_message(
161+
msg=f"<@{self.user}>, 이미 등록되어 있어!", ts=self.ts
162+
)
163+
return True
164+
156165
self.slack_client.send_message(msg=f"<@{self.user}>, 등록 완료!", ts=self.ts)
157166
msg = build_registration_info_message(self.user, member)
158167
self.slack_client.send_message_only_visible_to_user(

src/implementation/google_spreadsheet_client.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import threading
23
from datetime import datetime
34
from typing import List, Optional
45

@@ -10,6 +11,10 @@
1011
from config.env_config import envs
1112
from util.utils import with_retry
1213

14+
# append_row_if_absent 의 확인-후-추가를 원자적으로 만드는 프로세스 전역 락.
15+
# 이 앱은 단일 프로세스(gunicorn --workers 1)로 떠 있어서 프로세스 락으로 충분하다.
16+
_APPEND_IF_ABSENT_LOCK = threading.Lock()
17+
1318

1419
class GoogleSpreadsheetClient:
1520
def __init__(self):
@@ -74,6 +79,18 @@ def append_row(
7479

7580
worksheet.append_row(_values)
7681

82+
def append_row_if_absent(self, worksheet_id: int, values: List[str]) -> bool:
83+
"""같은 행이 이미 있으면 추가하지 않는다. 실제로 추가했으면 True.
84+
85+
빅챗 등록은 JoinBigchat(reaction_added)과 시트 생성 직후의 일괄 등록(#89)이
86+
거의 동시에 같은 사람을 처리할 수 있어서, 확인과 추가를 락으로 직렬화한다.
87+
"""
88+
with _APPEND_IF_ABSENT_LOCK:
89+
if list(values) in self.get_values(worksheet_id):
90+
return False
91+
self.append_row(worksheet_id, values)
92+
return True
93+
7794
@with_retry(non_retryable_exceptions=(WorksheetNotFound,))
7895
def get_values(self, worksheet_id: int, cell_range=None) -> List[List[str]]:
7996
worksheet = self._get_worksheet(worksheet_id)

src/implementation/member_finder.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ def find(self, slack_unique_id: str) -> Member:
5353
self.logger.debug(member)
5454
return member
5555

56+
@ttl_cache(maxsize=1, ttl=60) # not thread-safe
57+
# 시트 생성 직후의 일괄 등록(#89)이 N명을 연달아 조회해도 멤버 시트 읽기는 1회면 된다.
58+
# ttl 이 길면 방금 멤버 시트에 추가된 사람이 그만큼 늦게 조회되므로 60초로 짧게 잡았다.
5659
def _fetch_members(self) -> Dict[str, Member]:
5760
member_info_cols = "J:O" # 열 순서: user_id, kor_name, eng_name, email, phone, school_name or company_name
5861
raw_members = self.gs_client.get_values(

src/implementation/slack_client.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,19 @@ def get_emoji(self, channel: str, ts: str, emoji_name: str) -> Optional[Reaction
139139
140140
해당 반응이 없거나 메시지를 읽지 못하면 None을 반환한다.
141141
"""
142-
try:
143-
response = self.web_client.reactions_get(
144-
channel=channel, timestamp=ts, full=True
145-
)
146-
except SlackApiError as ex:
147-
logger.warning(f"Failed to get reactions: {ex}")
148-
return None
142+
for attempt in range(3):
143+
try:
144+
response = self.web_client.reactions_get(
145+
channel=channel, timestamp=ts, full=True
146+
)
147+
break
148+
except SlackApiError as ex:
149+
logger.warning(f"Failed to get reactions: {ex}")
150+
return None
151+
except OSError as ex:
152+
if attempt >= 2:
153+
logger.warning(f"Failed to get reactions: {ex}")
154+
return None
149155

150156
message = response.get("message") or {}
151157
for reaction in message.get("reactions", []):

0 commit comments

Comments
 (0)