Skip to content

Commit 4841c57

Browse files
committed
fix: harden sqlite lock retry handling
1 parent b4fe90d commit 4841c57

2 files changed

Lines changed: 125 additions & 6 deletions

File tree

astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -521,10 +521,25 @@ async def _save_to_history(
521521
BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}
522522
decoded_blocked = [base64.b64decode(b).decode("utf-8") for b in BLOCKED]
523523

524+
PROVIDER_STATS_SQLITE_LOCK_RETRY_ATTEMPTS = 3
525+
PROVIDER_STATS_SQLITE_LOCK_RETRY_BASE_DELAY = 0.2
526+
527+
528+
def _iter_exception_messages(exc: BaseException):
529+
yield str(exc)
530+
orig = getattr(exc, "orig", None)
531+
if orig is not None and orig is not exc:
532+
yield str(orig)
533+
yield from (str(arg) for arg in getattr(orig, "args", ()) if arg)
534+
yield from (str(arg) for arg in getattr(exc, "args", ()) if arg)
535+
524536

525537
def _is_sqlite_database_locked_error(exc: Exception) -> bool:
526-
return (
527-
isinstance(exc, OperationalError) and "database is locked" in str(exc).lower()
538+
if not isinstance(exc, OperationalError):
539+
return False
540+
return any(
541+
"database" in message.lower() and "locked" in message.lower()
542+
for message in _iter_exception_messages(exc)
528543
)
529544

530545

@@ -557,7 +572,7 @@ async def _record_internal_agent_stats(
557572
else:
558573
status = "completed"
559574

560-
for attempt in range(3):
575+
for attempt in range(PROVIDER_STATS_SQLITE_LOCK_RETRY_ATTEMPTS):
561576
try:
562577
await db_helper.insert_provider_stat(
563578
umo=event.unified_msg_origin,
@@ -569,9 +584,19 @@ async def _record_internal_agent_stats(
569584
agent_type="internal",
570585
)
571586
return
572-
except Exception as e:
573-
if _is_sqlite_database_locked_error(e) and attempt < 2:
574-
await asyncio.sleep(0.2 * (2**attempt))
587+
except OperationalError as e:
588+
if (
589+
_is_sqlite_database_locked_error(e)
590+
and attempt < PROVIDER_STATS_SQLITE_LOCK_RETRY_ATTEMPTS - 1
591+
):
592+
await asyncio.sleep(
593+
PROVIDER_STATS_SQLITE_LOCK_RETRY_BASE_DELAY * (2**attempt)
594+
)
575595
continue
576596
logger.warning("Persist provider stats failed: %s", e, exc_info=True)
577597
return
598+
except asyncio.CancelledError:
599+
raise
600+
except Exception as e:
601+
logger.warning("Persist provider stats failed: %s", e, exc_info=True)
602+
return

tests/unit/test_provider_stats.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,97 @@ async def no_sleep(delay: float) -> None:
112112
)
113113

114114
assert attempts == 2
115+
116+
117+
@pytest.mark.asyncio
118+
async def test_record_internal_agent_stats_retries_database_table_lock(
119+
monkeypatch: pytest.MonkeyPatch,
120+
):
121+
attempts = 0
122+
123+
class LockedOnceDb:
124+
async def insert_provider_stat(self, **kwargs):
125+
nonlocal attempts
126+
attempts += 1
127+
if attempts == 1:
128+
raise OperationalError(
129+
"insert into provider_stats",
130+
{},
131+
Exception("database table is locked"),
132+
)
133+
return SimpleNamespace(**kwargs)
134+
135+
monkeypatch.setattr(internal, "db_helper", LockedOnceDb())
136+
137+
async def no_sleep(delay: float) -> None:
138+
return None
139+
140+
monkeypatch.setattr(internal.asyncio, "sleep", no_sleep)
141+
142+
event = SimpleNamespace(unified_msg_origin="webchat:FriendMessage:session-42")
143+
provider = SimpleNamespace(
144+
provider_config={"id": "provider-1"},
145+
meta=lambda: SimpleNamespace(id="provider-1", type="openai"),
146+
get_model=lambda: "gpt-4.1",
147+
)
148+
agent_runner = SimpleNamespace(
149+
provider=provider,
150+
stats=AgentStats(),
151+
was_aborted=lambda: False,
152+
)
153+
154+
await internal._record_internal_agent_stats(
155+
event,
156+
ProviderRequest(conversation=SimpleNamespace(cid="conv-123")),
157+
agent_runner,
158+
SimpleNamespace(role="assistant"),
159+
)
160+
161+
assert attempts == 2
162+
163+
164+
@pytest.mark.asyncio
165+
async def test_record_internal_agent_stats_does_not_retry_other_operational_errors(
166+
monkeypatch: pytest.MonkeyPatch,
167+
):
168+
attempts = 0
169+
warnings = []
170+
171+
class FailingDb:
172+
async def insert_provider_stat(self, **kwargs):
173+
nonlocal attempts
174+
attempts += 1
175+
raise OperationalError(
176+
"insert into provider_stats",
177+
{},
178+
Exception("no such table: provider_stats"),
179+
)
180+
181+
monkeypatch.setattr(internal, "db_helper", FailingDb())
182+
monkeypatch.setattr(
183+
internal.logger,
184+
"warning",
185+
lambda *args, **kwargs: warnings.append((args, kwargs)),
186+
)
187+
188+
event = SimpleNamespace(unified_msg_origin="webchat:FriendMessage:session-42")
189+
provider = SimpleNamespace(
190+
provider_config={"id": "provider-1"},
191+
meta=lambda: SimpleNamespace(id="provider-1", type="openai"),
192+
get_model=lambda: "gpt-4.1",
193+
)
194+
agent_runner = SimpleNamespace(
195+
provider=provider,
196+
stats=AgentStats(),
197+
was_aborted=lambda: False,
198+
)
199+
200+
await internal._record_internal_agent_stats(
201+
event,
202+
ProviderRequest(conversation=SimpleNamespace(cid="conv-123")),
203+
agent_runner,
204+
SimpleNamespace(role="assistant"),
205+
)
206+
207+
assert attempts == 1
208+
assert len(warnings) == 1

0 commit comments

Comments
 (0)