Skip to content

Commit 8e7fe7d

Browse files
committed
Fix solver process reaping and SQLite deadlocks
1 parent 182eb09 commit 8e7fe7d

9 files changed

Lines changed: 42 additions & 28 deletions

File tree

‎app/control/account/backends/local.py‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import asyncio
44
import json
55
import sqlite3
6-
import threading
76
from contextlib import closing, contextmanager
87
from pathlib import Path
98
from typing import Any
109

1110
from app.platform.runtime.clock import now_ms
11+
from utils.sqlite_runtime import sqlite_connection_guard
1212
from ..commands import AccountPatch, AccountUpsert, BulkReplacePoolCommand, ListAccountsQuery
1313
from ..enums import AccountStatus
1414
from ..models import (
@@ -38,10 +38,7 @@ class LocalAccountRepository:
3838
def __init__(self, db_path: Path) -> None:
3939
self._path = Path(db_path)
4040
self._lock = asyncio.Lock()
41-
# Python's bundled SQLite can deadlock inside WAL open/close when many
42-
# worker threads churn short-lived connections concurrently. Keep the
43-
# complete connection lifecycle serial for this repository instance.
44-
self._connect_lock = threading.RLock()
41+
self._connect_lock = sqlite_connection_guard
4542

4643
# ------------------------------------------------------------------
4744
# Internal helpers

‎app/control/account/backends/local_test.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,17 @@ def connect(*_args, **_kwargs):
4444

4545
async def test_connection_lifecycles_are_serialized(self) -> None:
4646
with tempfile.TemporaryDirectory() as temp_dir:
47-
repo = LocalAccountRepository(Path(temp_dir) / "accounts.db")
47+
repos = [
48+
LocalAccountRepository(Path(temp_dir) / "accounts.db"),
49+
LocalAccountRepository(Path(temp_dir) / "other-accounts.db"),
50+
]
4851
active = 0
4952
max_active = 0
5053
guard = threading.Lock()
5154

5255
def hold_connection(_index: int) -> None:
5356
nonlocal active, max_active
54-
with repo._connection():
57+
with repos[_index % len(repos)]._connection():
5558
with guard:
5659
active += 1
5760
max_active = max(max_active, active)

‎app/platform/storage/media_cache.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from app.platform.config.snapshot import get_config
1414
from app.platform.logging.logger import logger
15+
from utils.sqlite_runtime import sqlite_connection_guard
1516

1617
from .media_paths import (
1718
image_files_dir,
@@ -55,7 +56,7 @@ def reconcile(self, media_type: MediaType) -> None:
5556
max_bytes = self._limit_bytes(media_type)
5657
if max_bytes <= 0:
5758
return
58-
with self._guard(media_type), closing(self._connect()) as conn:
59+
with self._guard(media_type), sqlite_connection_guard, closing(self._connect()) as conn:
5960
rows = []
6061
for path in self._iter_files(media_type):
6162
try:
@@ -127,7 +128,7 @@ def _save(
127128
self._write_if_missing(path, raw)
128129
return path
129130

130-
with self._guard(media_type), closing(self._connect()) as conn:
131+
with self._guard(media_type), sqlite_connection_guard, closing(self._connect()) as conn:
131132
if path.exists():
132133
self._upsert_existing_row(conn, media_type, path)
133134
else:
@@ -440,7 +441,7 @@ def _delete_index_row_if_present(self, media_type: MediaType, name: str) -> None
440441
db_path = local_media_cache_db_path()
441442
if not db_path.exists():
442443
return
443-
with closing(self._connect()) as conn:
444+
with sqlite_connection_guard, closing(self._connect()) as conn:
444445
conn.execute(
445446
f"DELETE FROM {_TABLE} WHERE media_type = ? AND name = ?",
446447
(media_type, name),
@@ -451,7 +452,7 @@ def _delete_index_rows_if_present(self, media_type: MediaType) -> None:
451452
db_path = local_media_cache_db_path()
452453
if not db_path.exists():
453454
return
454-
with closing(self._connect()) as conn:
455+
with sqlite_connection_guard, closing(self._connect()) as conn:
455456
conn.execute(f"DELETE FROM {_TABLE} WHERE media_type = ?", (media_type,))
456457
conn.commit()
457458

‎deploy/docker-compose.captcha-solver.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ services:
55
dockerfile: Dockerfile
66
image: chatgpt2api-captcha-solver:local
77
container_name: chatgpt2api-captcha-solver
8+
init: true
89
restart: unless-stopped
910
ports:
1011
- "127.0.0.1:${CAPTCHA_SOLVER_PORT:-8877}:8877"

‎services/backup_service.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from services.config import BASE_DIR, CONFIG_FILE, DATA_DIR, config, load_backup_state, save_backup_state
2222
from services.image_storage_service import IMAGE_INDEX_FILE
2323
from services.image_tags_service import TAGS_FILE
24+
from utils.sqlite_runtime import sqlite_connection_guard
2425

2526

2627
def _utc_now() -> datetime:
@@ -145,7 +146,7 @@ def _sqlite_backup_bytes(source: Path) -> bytes | None:
145146
with tempfile.NamedTemporaryFile(prefix="chatgpt2api-grok-", suffix=".db", delete=False) as handle:
146147
temp_path = Path(handle.name)
147148
source_uri = f"file:{quote(source.resolve().as_posix(), safe='/')}?mode=ro"
148-
with closing(sqlite3.connect(source_uri, uri=True)) as source_db, closing(
149+
with sqlite_connection_guard, closing(sqlite3.connect(source_uri, uri=True)) as source_db, closing(
149150
sqlite3.connect(temp_path)
150151
) as backup_db:
151152
source_db.backup(backup_db)

‎services/openai_agent_identity_store.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from cryptography.fernet import Fernet, InvalidToken
1313

1414
from services.config import DATA_DIR
15+
from utils.sqlite_runtime import sqlite_connection_guard
1516

1617

1718
class AgentIdentityStore:
@@ -82,7 +83,7 @@ def _connect(self) -> sqlite3.Connection:
8283
return connection
8384

8485
def _initialize(self) -> None:
85-
with self._lock, closing(self._connect()) as connection:
86+
with self._lock, sqlite_connection_guard, closing(self._connect()) as connection:
8687
connection.executescript(
8788
"""
8889
CREATE TABLE IF NOT EXISTS agent_identities (
@@ -123,7 +124,7 @@ def save(self, auth_json: dict[str, Any]) -> dict[str, Any]:
123124
if not account_id or not runtime_id or not private_key:
124125
raise ValueError("Agent Identity is incomplete")
125126
now = datetime.now(timezone.utc).isoformat()
126-
with self._lock, closing(self._connect()) as connection:
127+
with self._lock, sqlite_connection_guard, closing(self._connect()) as connection:
127128
connection.execute("BEGIN IMMEDIATE")
128129
connection.execute(
129130
"""
@@ -172,15 +173,15 @@ def get(self, account_id: str) -> dict[str, Any] | None:
172173
key = str(account_id or "").strip()
173174
if not key:
174175
return None
175-
with self._lock, closing(self._connect()) as connection:
176+
with self._lock, sqlite_connection_guard, closing(self._connect()) as connection:
176177
row = connection.execute(
177178
"SELECT * FROM agent_identities WHERE account_id = ?", (key,)
178179
).fetchone()
179180
return self._auth_json(row, self._decrypt(str(row["private_key_ciphertext"]))) if row else None
180181

181182
def list_auth(self, account_ids: list[str] | None = None) -> list[dict[str, Any]]:
182183
targets = {str(item or "").strip() for item in (account_ids or []) if str(item or "").strip()}
183-
with self._lock, closing(self._connect()) as connection:
184+
with self._lock, sqlite_connection_guard, closing(self._connect()) as connection:
184185
rows = connection.execute(
185186
"SELECT * FROM agent_identities ORDER BY updated_at DESC"
186187
).fetchall()
@@ -191,7 +192,7 @@ def list_auth(self, account_ids: list[str] | None = None) -> list[dict[str, Any]
191192
]
192193

193194
def summary(self) -> list[dict[str, str]]:
194-
with self._lock, closing(self._connect()) as connection:
195+
with self._lock, sqlite_connection_guard, closing(self._connect()) as connection:
195196
rows = connection.execute(
196197
"""
197198
SELECT account_id, user_id, email, plan_type, agent_runtime_id,

‎services/openai_survival_service.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from services.config import DATA_DIR
1818
from services.json_file import read_json_object, write_json_file
1919
from utils.log import logger
20+
from utils.sqlite_runtime import sqlite_connection_guard
2021

2122

2223
DEFAULT_CONFIG = {
@@ -106,7 +107,7 @@ def _normalize_config(raw: object) -> dict[str, Any]:
106107

107108
def _initialize_lease_db(self) -> None:
108109
self._data_dir.mkdir(parents=True, exist_ok=True)
109-
with closing(sqlite3.connect(self._lease_path, timeout=15)) as connection:
110+
with sqlite_connection_guard, closing(sqlite3.connect(self._lease_path, timeout=15)) as connection:
110111
connection.execute("PRAGMA busy_timeout=15000")
111112
connection.execute(
112113
"""
@@ -124,7 +125,7 @@ def _initialize_lease_db(self) -> None:
124125

125126
def _claim_run(self, ttl_seconds: int = 3600) -> bool:
126127
now = time.time()
127-
with closing(sqlite3.connect(self._lease_path, timeout=15)) as connection:
128+
with sqlite_connection_guard, closing(sqlite3.connect(self._lease_path, timeout=15)) as connection:
128129
connection.execute("PRAGMA busy_timeout=15000")
129130
connection.execute("BEGIN IMMEDIATE")
130131
connection.execute(
@@ -143,15 +144,15 @@ def _claim_run(self, ttl_seconds: int = 3600) -> bool:
143144
return True
144145

145146
def _release_run(self) -> None:
146-
with closing(sqlite3.connect(self._lease_path, timeout=15)) as connection:
147+
with sqlite_connection_guard, closing(sqlite3.connect(self._lease_path, timeout=15)) as connection:
147148
connection.execute(
148149
"DELETE FROM scheduler_lease WHERE name = 'openai_survival' AND owner = ?",
149150
(self._owner,),
150151
)
151152
connection.commit()
152153

153154
def _renew_run(self, ttl_seconds: int = 3600) -> bool:
154-
with closing(sqlite3.connect(self._lease_path, timeout=15)) as connection:
155+
with sqlite_connection_guard, closing(sqlite3.connect(self._lease_path, timeout=15)) as connection:
155156
cursor = connection.execute(
156157
"""
157158
UPDATE scheduler_lease SET expires_at = ?

‎services/register/mail_provider.py‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from services.config import DATA_DIR
2525
from services.json_file import read_json_file, write_json_file
2626
from services.proxy_service import proxy_settings
27+
from utils.sqlite_runtime import sqlite_connection_guard
2728

2829
DDG_ALIASES_FILE = DATA_DIR / "ddg_aliases.json"
2930
_ddg_aliases_lock = Lock()
@@ -185,7 +186,7 @@ def _load_outlook_token_state() -> dict[str, dict[str, Any]]:
185186
186187
The legacy JSON file is imported once into SQLite on first access.
187188
"""
188-
with closing(_outlook_state_connection()) as connection:
189+
with sqlite_connection_guard, closing(_outlook_state_connection()) as connection:
189190
_ensure_outlook_state_migrated(connection)
190191
rows = connection.execute(
191192
"SELECT email, state, reason, updated_at, lease_token FROM mailbox_states"
@@ -202,7 +203,7 @@ def _load_outlook_token_state() -> dict[str, dict[str, Any]]:
202203

203204

204205
def _save_outlook_token_state(state: dict[str, dict[str, Any]]) -> None:
205-
with closing(_outlook_state_connection()) as connection:
206+
with sqlite_connection_guard, closing(_outlook_state_connection()) as connection:
206207
_ensure_outlook_state_migrated(connection)
207208
connection.execute("BEGIN IMMEDIATE")
208209
connection.execute("DELETE FROM mailbox_states")
@@ -230,7 +231,7 @@ def _claim_outlook_credential(pool: list[dict[str, str]]) -> tuple[dict[str, str
230231
"""Atomically reserve one available credential across worker processes."""
231232
now = datetime.now(timezone.utc)
232233
lease_token = secrets.token_urlsafe(24)
233-
with closing(_outlook_state_connection()) as connection:
234+
with sqlite_connection_guard, closing(_outlook_state_connection()) as connection:
234235
_ensure_outlook_state_migrated(connection)
235236
connection.execute("BEGIN IMMEDIATE")
236237
rows = connection.execute(
@@ -320,7 +321,7 @@ def _set_outlook_token_state(
320321
target = str(address or "").strip().lower()
321322
if not target:
322323
return False
323-
with closing(_outlook_state_connection()) as connection:
324+
with sqlite_connection_guard, closing(_outlook_state_connection()) as connection:
324325
_ensure_outlook_state_migrated(connection)
325326
connection.execute("BEGIN IMMEDIATE")
326327
if lease_token:
@@ -363,7 +364,7 @@ def _release_outlook_token_state(address: str, *, lease_token: str = "") -> bool
363364
target = str(address or "").strip().lower()
364365
if not target:
365366
return False
366-
with closing(_outlook_state_connection()) as connection:
367+
with sqlite_connection_guard, closing(_outlook_state_connection()) as connection:
367368
_ensure_outlook_state_migrated(connection)
368369
connection.execute("BEGIN IMMEDIATE")
369370
if lease_token:
@@ -390,7 +391,7 @@ def clear_outlook_token_states(addresses: list[str] | set[str], states: set[str]
390391
targets.discard("")
391392
if not targets:
392393
return 0
393-
with closing(_outlook_state_connection()) as connection:
394+
with sqlite_connection_guard, closing(_outlook_state_connection()) as connection:
394395
_ensure_outlook_state_migrated(connection)
395396
connection.execute("BEGIN IMMEDIATE")
396397
placeholders = ",".join("?" for _ in targets)
@@ -422,7 +423,7 @@ def reset_outlook_token_pool_state(scope: str = "all") -> int:
422423
target_states = OUTLOOK_BUSY_STATES
423424
else:
424425
target_states = set()
425-
with closing(_outlook_state_connection()) as connection:
426+
with sqlite_connection_guard, closing(_outlook_state_connection()) as connection:
426427
_ensure_outlook_state_migrated(connection)
427428
connection.execute("BEGIN IMMEDIATE")
428429
if target_states:

‎utils/sqlite_runtime.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from __future__ import annotations
2+
3+
import threading
4+
5+
6+
# SQLite keeps a process-wide Unix file-descriptor cache. Serializing complete
7+
# connection lifecycles avoids an open/close mutex deadlock under thread load.
8+
sqlite_connection_guard = threading.RLock()

0 commit comments

Comments
 (0)