client.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import os
  2. import libsql_client
  3. import psycopg2
  4. from psycopg2.extras import RealDictCursor
  5. from app.core.config import settings
  6. import logging
  7. import json
  8. import time
  9. from contextlib import contextmanager
  10. logger = logging.getLogger(__name__)
  11. class PostgresTransaction:
  12. def __init__(self, conn):
  13. self.conn = conn
  14. self.cursor = conn.cursor(cursor_factory=RealDictCursor)
  15. def execute(self, query, params=None):
  16. # Convert ? to %s for psycopg2
  17. query = query.replace('?', '%s')
  18. self.cursor.execute(query, params)
  19. return self.cursor
  20. def __enter__(self):
  21. return self
  22. def __exit__(self, exc_type, exc_val, exc_tb):
  23. if exc_type:
  24. self.conn.rollback()
  25. else:
  26. self.conn.commit()
  27. self.cursor.close()
  28. class PostgresClient:
  29. def __init__(self, url):
  30. self.url = url
  31. self.conn = psycopg2.connect(url)
  32. self.conn.autocommit = True
  33. def execute(self, query, params=None):
  34. # Convert ? to %s for psycopg2
  35. query = query.replace('?', '%s')
  36. with self.conn.cursor(cursor_factory=RealDictCursor) as cur:
  37. cur.execute(query, params)
  38. # If it's a SELECT or RETURNING, fetch results
  39. if query.strip().upper().startswith("SELECT") or "RETURNING" in query.upper():
  40. return cur.fetchall()
  41. return cur
  42. def transaction(self):
  43. self.conn.autocommit = False
  44. return PostgresTransaction(self.conn)
  45. def close(self):
  46. self.conn.close()
  47. class RetryingTransaction:
  48. """Wrapper for libsql transaction to add retry logic"""
  49. def __init__(self, tx):
  50. self._tx = tx
  51. def execute(self, stmt, args=None):
  52. max_retries = 5
  53. base_delay = 0.1
  54. for attempt in range(max_retries):
  55. try:
  56. return self._tx.execute(stmt, args)
  57. except Exception as e:
  58. error_msg = str(e).lower()
  59. if "database is locked" in error_msg:
  60. if attempt < max_retries - 1:
  61. delay = base_delay * (2 ** attempt)
  62. logger.warning(f"Database locked in transaction, retrying in {delay:.2f}s (attempt {attempt+1}/{max_retries})")
  63. time.sleep(delay)
  64. continue
  65. raise e
  66. def commit(self):
  67. if hasattr(self._tx, 'commit'):
  68. return self._tx.commit()
  69. def __getattr__(self, name):
  70. return getattr(self._tx, name)
  71. class RetryingLibsqlClient:
  72. """Wrapper around libsql_client to add retry logic for locking errors"""
  73. def __init__(self, client):
  74. self._client = client
  75. def execute(self, stmt, args=None):
  76. max_retries = 5
  77. base_delay = 0.1
  78. for attempt in range(max_retries):
  79. try:
  80. return self._client.execute(stmt, args)
  81. except Exception as e:
  82. error_msg = str(e).lower()
  83. if "database is locked" in error_msg:
  84. if attempt < max_retries - 1:
  85. delay = base_delay * (2 ** attempt) # Exponential backoff
  86. logger.warning(f"Database locked, retrying in {delay:.2f}s (attempt {attempt+1}/{max_retries})")
  87. time.sleep(delay)
  88. continue
  89. # If not locked error or retries exhausted, raise
  90. raise e
  91. @contextmanager
  92. def transaction(self):
  93. # We need to wrap the yielded transaction object
  94. # self._client.transaction() returns a context manager itself
  95. with self._client.transaction() as tx:
  96. yield RetryingTransaction(tx)
  97. def close(self):
  98. return self._client.close()
  99. def __getattr__(self, name):
  100. return getattr(self._client, name)
  101. class Database:
  102. def __init__(self):
  103. self.url = settings.DATABASE_URL
  104. self.auth_token = settings.TURSO_AUTH_TOKEN
  105. self.is_postgres = self.url.startswith("postgresql://") or self.url.startswith("postgres://")
  106. self.is_remote = self.url.startswith("libsql://") or self.url.startswith("https://")
  107. def get_connection(self):
  108. if self.is_postgres:
  109. return PostgresClient(self.url)
  110. token = self.auth_token if self.is_remote else None
  111. # Ensure directory exists for local file
  112. if not self.is_remote and self.url.startswith("file:"):
  113. db_path = self.url.replace("file:", "")
  114. db_dir = os.path.dirname(os.path.abspath(db_path))
  115. if db_dir and not os.path.exists(db_dir):
  116. try:
  117. os.makedirs(db_dir, exist_ok=True)
  118. logger.info(f"Created database directory: {db_dir}")
  119. except OSError as e:
  120. logger.warning(f"Failed to create database directory: {e}")
  121. # 使用 create_client_sync 创建连接
  122. # LibSQL client automatically creates the file if it doesn't exist for local file URLs
  123. try:
  124. client = libsql_client.create_client_sync(
  125. url=self.url,
  126. auth_token=token
  127. )
  128. except Exception as e:
  129. logger.error(f"Failed to create database client: {e}")
  130. # Fallback or retry logic could go here, but for now just re-raise
  131. raise e
  132. # --- SQLite WAL 模式与性能优化 ---
  133. if not self.is_remote and not self.is_postgres:
  134. try:
  135. # 启用 WAL 模式:大幅提升并发读写性能
  136. client.execute("PRAGMA journal_mode = WAL")
  137. # 设置同步模式为 NORMAL:在 WAL 模式下既安全又快
  138. client.execute("PRAGMA synchronous = NORMAL")
  139. # 增加缓存大小
  140. client.execute("PRAGMA cache_size = -10000")
  141. # 启用外键约束
  142. client.execute("PRAGMA foreign_keys = ON")
  143. # 设置忙碌超时,防止 database is locked 错误 (增加到 30秒)
  144. client.execute("PRAGMA busy_timeout = 30000")
  145. except Exception as e:
  146. logger.warning(f"Failed to set SQLite PRAGMA: {e}")
  147. # Wrap with retry logic
  148. if not self.is_remote and not self.is_postgres:
  149. return RetryingLibsqlClient(client)
  150. return client
  151. def init_db(self, schema_path="app/db/schema.sql"):
  152. """初始化数据库结构"""
  153. # 如果是 Postgres,跳过 schema.sql,假设使用 Alembic 或 schema_pg.sql
  154. if self.is_postgres:
  155. logger.info("PostgreSQL detected, skipping schema.sql init. Use Alembic or schema_pg.sql.")
  156. return
  157. if not os.path.exists(schema_path):
  158. logger.warning(f"Schema file not found: {schema_path}")
  159. return
  160. conn = self.get_connection()
  161. try:
  162. with open(schema_path, 'r', encoding='utf-8') as f:
  163. script = f.read()
  164. # LibSQL client executescript equivalent: split by ;
  165. # Or use execute for single statement.
  166. # libsql-client-py execute() might not support multiple statements.
  167. # Let's split manually.
  168. statements = [s.strip() for s in script.split(';') if s.strip()]
  169. for stmt in statements:
  170. conn.execute(stmt)
  171. # Existing local databases predate persisted scheduler flags.
  172. # Keep startup migration idempotent because schema.sql only creates
  173. # tables when they do not exist.
  174. if not self.is_remote and not self.is_postgres:
  175. user_columns = fetch_all(conn.execute("PRAGMA table_info(users)"))
  176. if "email" not in {column.name for column in user_columns}:
  177. conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
  178. conn.execute(
  179. "CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique ON users(email)"
  180. )
  181. columns = fetch_all(conn.execute("PRAGMA table_info(forums)"))
  182. if "ablation_flags" not in {column.name for column in columns}:
  183. conn.execute(
  184. "ALTER TABLE forums ADD COLUMN ablation_flags TEXT DEFAULT '{}'"
  185. )
  186. # Older versions accepted whitespace-only persona names.
  187. # Repair them before response validation is applied so an
  188. # upgraded database remains readable.
  189. conn.execute(
  190. """
  191. UPDATE personas
  192. SET name = '未命名智能体 #' || id
  193. WHERE name IS NULL OR TRIM(name) = ''
  194. """
  195. )
  196. logger.info("Database initialized successfully.")
  197. except Exception as e:
  198. logger.error(f"Failed to initialize database: {e}")
  199. finally:
  200. conn.close()
  201. db_manager = Database()
  202. def get_db():
  203. db = db_manager.get_connection()
  204. try:
  205. yield db
  206. finally:
  207. db.close()
  208. # Helper for Row Objects (SQLite returns rows, Postgres returns dicts)
  209. class RowObject:
  210. def __init__(self, data):
  211. self.__dict__.update(data)
  212. def fetch_one(rs):
  213. if rs is None:
  214. return None
  215. # If it's a list (Postgres or cached), return first
  216. if isinstance(rs, list):
  217. return RowObject(rs[0]) if rs else None
  218. # LibSQL ResultSet
  219. if hasattr(rs, 'rows'):
  220. return RowObject(dict(zip(rs.columns, rs.rows[0]))) if rs.rows else None
  221. # Psycopg2 cursor
  222. if hasattr(rs, 'fetchone'):
  223. row = rs.fetchone()
  224. return RowObject(row) if row else None
  225. return None
  226. def fetch_all(rs):
  227. if rs is None:
  228. return []
  229. if isinstance(rs, list):
  230. return [RowObject(r) for r in rs]
  231. if hasattr(rs, 'rows'):
  232. return [RowObject(dict(zip(rs.columns, row))) for row in rs.rows]
  233. if hasattr(rs, 'fetchall'):
  234. return [RowObject(row) for row in rs.fetchall()]
  235. return []
  236. @contextmanager
  237. def db_transaction(db):
  238. """
  239. Unified transaction context manager.
  240. - If `db` is a connection (has `.transaction()`), starts a new transaction.
  241. - If `db` is already a transaction object, reuses it (nested transaction support/no-op).
  242. """
  243. if hasattr(db, 'transaction') and callable(db.transaction):
  244. with db.transaction() as tx:
  245. yield tx
  246. else:
  247. # Assume db is already a transaction object or behaves like one
  248. # For LibSQL/SQLite, nested transactions are not supported directly with SAVEPOINT in this wrapper yet
  249. # So we just yield the existing transaction object.
  250. yield db
  251. def db_execute_commit(db, query, params=None):
  252. """
  253. Helper to execute a query and force commit if applicable.
  254. Useful for one-off write operations to ensure persistence in SQLite WAL mode.
  255. """
  256. if hasattr(db, 'transaction') and callable(db.transaction):
  257. with db.transaction() as tx:
  258. rs = tx.execute(query, params)
  259. # Force commit for SQLite if wrapper doesn't auto-commit on exit (it usually does)
  260. # But let's be safe for our specific issue
  261. if hasattr(tx, 'commit'):
  262. tx.commit()
  263. elif hasattr(db, 'commit'):
  264. db.commit()
  265. return rs
  266. else:
  267. # Already in a transaction, just execute
  268. return db.execute(query, params)