Skip to content

Commit 92b2f54

Browse files
committed
slackbot: cross-process dedupe via tiny sqlite status\n\n- Add _internal.thread_status with try_acquire/get_status/mark_completed\n- Gate mention handling with DB-backed idempotency key (thread_ts)\n- Post friendly edit-ignored message when in_progress\n- Keep api/core lean; no sprawl
1 parent 06be2f5 commit 92b2f54

2 files changed

Lines changed: 152 additions & 50 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Thread status tracking for Slack events (cross-process safe).
2+
3+
This module encapsulates a tiny SQLite-backed status mechanism to avoid duplicate
4+
processing of the same Slack thread when users edit their original post or when
5+
Slack delivers multiple events. It keeps the rest of the app simple and avoids
6+
sprawling status logic across files.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import Literal
12+
13+
from slackbot.core import Database
14+
15+
Status = Literal["in_progress", "completed"]
16+
17+
18+
_SCHEMA_CREATED = False
19+
20+
21+
async def ensure_schema(db: Database) -> None:
22+
global _SCHEMA_CREATED
23+
if _SCHEMA_CREATED:
24+
return
25+
26+
def _create() -> None:
27+
db.con.execute(
28+
"""
29+
CREATE TABLE IF NOT EXISTS slack_thread_status (
30+
thread_ts TEXT PRIMARY KEY,
31+
status TEXT NOT NULL CHECK (status IN ('in_progress','completed')),
32+
updated_at REAL NOT NULL
33+
);
34+
"""
35+
)
36+
db.con.commit()
37+
38+
await db.loop.run_in_executor(db.executor, _create)
39+
_SCHEMA_CREATED = True
40+
41+
42+
async def try_acquire(db: Database, thread_ts: str) -> bool:
43+
"""Attempt to mark a thread as in_progress; returns True if acquired.
44+
45+
Uses an atomic INSERT OR IGNORE on a PRIMARY KEY to prevent duplicates across
46+
concurrent processes or tasks.
47+
"""
48+
await ensure_schema(db)
49+
50+
def _insert() -> int:
51+
cur = db.con.cursor()
52+
cur.execute(
53+
"""
54+
INSERT OR IGNORE INTO slack_thread_status (thread_ts, status, updated_at)
55+
VALUES (?, 'in_progress', strftime('%s','now'))
56+
""",
57+
(thread_ts,),
58+
)
59+
db.con.commit()
60+
return cur.rowcount
61+
62+
rowcount: int = await db.loop.run_in_executor(db.executor, _insert)
63+
return rowcount == 1
64+
65+
66+
async def get_status(db: Database, thread_ts: str) -> Status | None:
67+
await ensure_schema(db)
68+
69+
def _query() -> Status | None:
70+
cur = db.con.cursor()
71+
cur.execute(
72+
"SELECT status FROM slack_thread_status WHERE thread_ts = ?",
73+
(thread_ts,),
74+
)
75+
row = cur.fetchone()
76+
return row[0] if row else None # type: ignore[return-value]
77+
78+
return await db.loop.run_in_executor(db.executor, _query)
79+
80+
81+
async def mark_completed(db: Database, thread_ts: str) -> None:
82+
await ensure_schema(db)
83+
84+
def _update() -> None:
85+
cur = db.con.cursor()
86+
cur.execute(
87+
"""
88+
UPDATE slack_thread_status
89+
SET status = 'completed', updated_at = strftime('%s','now')
90+
WHERE thread_ts = ?
91+
""",
92+
(thread_ts,),
93+
)
94+
db.con.commit()
95+
96+
await db.loop.run_in_executor(db.executor, _update)

examples/slackbot/src/slackbot/api.py

Lines changed: 56 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@
1717

1818
from slackbot._internal.constants import WORKSPACE_TO_CHANNEL_ID
1919
from slackbot._internal.templates import CHANNEL_REDIRECT_MESSAGE, WELCOME_MESSAGE
20+
from slackbot._internal.thread_status import (
21+
get_status as get_thread_status,
22+
)
23+
from slackbot._internal.thread_status import (
24+
mark_completed as mark_thread_completed,
25+
)
26+
from slackbot._internal.thread_status import (
27+
try_acquire as try_acquire_thread,
28+
)
2029
from slackbot.assets import summarize_thread
2130
from slackbot.core import (
2231
Database,
@@ -40,8 +49,8 @@
4049

4150
logger = get_logger(__name__)
4251

43-
# In-process thread guard to avoid duplicate handling on edits/rapid events
44-
_thread_locks: dict[str, asyncio.Lock] = {}
52+
# Duplicate handling is coordinated via a small SQLite table in
53+
# _internal.thread_status to work across processes.
4554

4655

4756
def get_designated_channel_for_workspace(team_id: str) -> str | None:
@@ -143,11 +152,11 @@ async def handle_message(payload: SlackPayload, db: Database):
143152
# Use the root thread timestamp as our idempotency key
144153
root_ts = thread_ts
145154

146-
# Acquire per-thread lock; if already locked, another handler is running
147-
lock = _thread_locks.setdefault(root_ts, asyncio.Lock())
148-
if lock.locked():
149-
# If this is an edit to the original message, politely ignore
150-
if event.subtype == "message_changed":
155+
# Cross-process acquire; only one handler should proceed
156+
acquired = await try_acquire_thread(db, root_ts)
157+
if not acquired:
158+
status = await get_thread_status(db, root_ts)
159+
if status == "in_progress":
151160
assert event.channel is not None, "No channel found"
152161
await post_slack_message(
153162
message=(
@@ -163,9 +172,8 @@ async def handle_message(payload: SlackPayload, db: Database):
163172
name="IGNORED_EDIT",
164173
data=dict(thread_ts=root_ts),
165174
)
166-
# Otherwise, just skip duplicate events while busy
167175
return Completed(
168-
message="Skipped duplicate event while in progress",
176+
message="Duplicate event after completion",
169177
name="SKIPPED_DUPLICATE",
170178
data=dict(thread_ts=root_ts),
171179
)
@@ -219,49 +227,47 @@ async def handle_message(payload: SlackPayload, db: Database):
219227
bot_id=bot_user_id or "unknown",
220228
)
221229

222-
async with lock:
223-
try:
224-
result = await run_agent(
225-
cleaned_message,
226-
conversation,
227-
user_context,
228-
event.channel,
229-
thread_ts,
230-
) # type: ignore
231-
232-
await db.add_thread_messages(thread_ts, result.new_messages())
233-
conversation.extend(result.new_messages())
234-
assert event.channel is not None, "No channel found"
235-
await task(post_slack_message)(
236-
message=result.output,
237-
channel_id=event.channel,
238-
thread_ts=thread_ts,
239-
)
240-
except Exception as e:
241-
logger.error(f"Error running agent: {e}")
242-
assert event.channel is not None, "No channel found"
243-
await task(post_slack_message)(
244-
message="Sorry, I encountered an error while processing your request. Please try again.",
245-
channel_id=event.channel,
246-
thread_ts=thread_ts,
247-
)
248-
# Still return completed so we don't retry
249-
return Completed(
250-
message="Error during agent execution",
251-
name="ERROR_HANDLED",
252-
data=dict(error=str(e), user_context=user_context),
253-
)
254-
finally:
255-
# Clean up lock map to avoid unbounded growth
256-
# Only pop if this lock is the same instance
257-
current = _thread_locks.get(root_ts)
258-
if current is lock:
259-
_thread_locks.pop(root_ts, None)
260-
230+
try:
231+
result = await run_agent(
232+
cleaned_message,
233+
conversation,
234+
user_context,
235+
event.channel,
236+
thread_ts,
237+
) # type: ignore
238+
239+
await db.add_thread_messages(thread_ts, result.new_messages())
240+
conversation.extend(result.new_messages())
241+
assert event.channel is not None, "No channel found"
242+
await task(post_slack_message)(
243+
message=result.output,
244+
channel_id=event.channel,
245+
thread_ts=thread_ts,
246+
)
247+
except Exception as e:
248+
logger.error(f"Error running agent: {e}")
249+
assert event.channel is not None, "No channel found"
250+
await task(post_slack_message)(
251+
message="Sorry, I encountered an error while processing your request. Please try again.",
252+
channel_id=event.channel,
253+
thread_ts=thread_ts,
254+
)
255+
# Still return completed so we don't retry
261256
return Completed(
262-
message="Responded to mention",
263-
data=dict(user_context=user_context, conversation=conversation),
257+
message="Error during agent execution",
258+
name="ERROR_HANDLED",
259+
data=dict(error=str(e), user_context=user_context),
264260
)
261+
finally:
262+
try:
263+
await mark_thread_completed(db, root_ts)
264+
except Exception:
265+
logger.warning("Failed to mark thread as completed")
266+
267+
return Completed(
268+
message="Responded to mention",
269+
data=dict(user_context=user_context, conversation=conversation),
270+
)
265271

266272
return Completed(message="Skipping non-mention", name="SKIPPED")
267273

0 commit comments

Comments
 (0)