Coverage for src / beautyspot / db.py: 74%

415 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-03-10 23:54 +0900

1# src/beautyspot/db.py 

2 

3import sqlite3 

4import os 

5import logging 

6import queue 

7import threading 

8import time 

9from collections.abc import Iterator 

10from contextlib import contextmanager 

11import dataclasses 

12from dataclasses import dataclass 

13from datetime import datetime, timezone 

14from pathlib import Path 

15from abc import ABC, abstractmethod 

16from typing import Optional, TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable 

17import weakref 

18from beautyspot.types import TaskRecord 

19 

20 

21@runtime_checkable 

22class TaskDBCore(Protocol): 

23 """ 

24 Core interface for task metadata storage required during execution. 

25 """ 

26 

27 def init_schema(self) -> None: ... 

28 

29 def get( 

30 self, cache_key: str, *, include_expired: bool = False 

31 ) -> Optional[TaskRecord]: ... 

32 

33 def save( 

34 self, 

35 cache_key: str, 

36 func_name: str, 

37 func_identifier: Optional[str], 

38 input_id: str, 

39 version: Optional[str], 

40 result_type: str, 

41 content_type: Optional[str], 

42 result_value: Optional[str] = None, 

43 result_data: Optional[bytes] = None, 

44 expires_at: Optional[datetime] = None, 

45 ) -> None: ... 

46 

47 def delete(self, cache_key: str) -> bool: ... 

48 

49 

50@runtime_checkable 

51class Flushable(Protocol): 

52 """Protocol for backends that support flushing pending writes.""" 

53 

54 def flush(self, timeout: Optional[float] = None) -> bool: ... 

55 

56 

57@runtime_checkable 

58class Shutdownable(Protocol): 

59 """Protocol for backends that require graceful shutdown.""" 

60 

61 def shutdown(self, wait: bool = True) -> None: ... 

62 

63 

64@runtime_checkable 

65class Maintenable(Protocol): 

66 """ 

67 Extended interface for maintenance tasks (GC, CLI, Dashboard). 

68 """ 

69 

70 def delete_expired(self) -> int: ... 

71 

72 def prune(self, older_than: datetime, func_name: Optional[str] = None) -> int: ... 

73 

74 def get_outdated_tasks( 

75 self, older_than: datetime, func_name: Optional[str] = None 

76 ) -> list[tuple[str, str, str]]: ... 

77 

78 def get_blob_refs(self) -> Optional[set[str]]: ... 

79 

80 def delete_all(self, func_name: Optional[str] = None) -> int: ... 

81 

82 def get_keys_start_with(self, prefix: str) -> list[str]: ... 

83 

84 def get_history(self, limit: int = 1000) -> "pd.DataFrame": ... 

85 

86 

87@runtime_checkable 

88class TaskDBMaintenable(TaskDBCore, Maintenable, Protocol): 

89 ... 

90 

91 

92 

93class _ReadConnWrapper: 

94 def __init__(self, conn: sqlite3.Connection): 

95 self.conn = conn 

96 self.lock = threading.RLock() 

97 self._closed = False 

98 self._shutdown_requested = False 

99 

100 def close(self, wait: bool = True): 

101 """ 

102 Args: 

103 wait: True の場合はロック解放を待機。 

104 False (シャットダウン時) の場合は即座に試行し、他が使用中ならスキップする。 

105 """ 

106 # wait=False の場合は blocking=False になり、取得できなければ直ちに False を返す 

107 if not self.lock.acquire(blocking=wait): 

108 # 誰かがクエリ実行中なので、強制クローズによるクラッシュを防ぐために諦める。 

109 # 代わりにシャットダウン要求フラグを立てて、クエリ完了後に自身でクローズさせる。 

110 if not wait: 110 ↛ 112line 110 didn't jump to line 112 because the condition on line 110 was always true

111 self._shutdown_requested = True 

112 return 

113 

114 try: 

115 if not self._closed: 

116 try: 

117 self.conn.close() 

118 except Exception: 

119 pass 

120 self._closed = True 

121 finally: 

122 self.lock.release() 

123 

124 def __del__(self): 

125 self.close() 

126 

127 

128if TYPE_CHECKING: 

129 import pandas as pd 

130 

131logger = logging.getLogger(__name__) 

132logger.addHandler(logging.NullHandler()) 

133 

134 

135def _ensure_utc_isoformat(dt: Optional[datetime]) -> Optional[str]: 

136 """datetime を UTC 保証の ISO 8601 文字列に変換する。None はそのまま返す。""" 

137 if dt is None: 

138 return None 

139 if dt.tzinfo is None: 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true

140 dt = dt.replace(tzinfo=timezone.utc) 

141 else: 

142 dt = dt.astimezone(timezone.utc) 

143 return dt.isoformat(" ") 

144 

145 

146@dataclass 

147class _WriteTask: 

148 fn: Callable[[sqlite3.Connection], Any] 

149 event: threading.Event 

150 result: Any = None 

151 error: Exception | BaseException | None = None 

152 state: str = "PENDING" # "PENDING", "RUNNING", "DONE", "CANCELLED" 

153 _state_lock: threading.Lock = dataclasses.field(default_factory=threading.Lock) 

154 

155 def try_cancel(self) -> bool: 

156 """PENDING 状態のタスクをキャンセルする。成功時 True。""" 

157 with self._state_lock: 

158 if self.state == "PENDING": 

159 self.state = "CANCELLED" 

160 return True 

161 return False 

162 

163 def try_start(self) -> bool: 

164 """PENDING → RUNNING に遷移する。CANCELLED なら False を返す。""" 

165 with self._state_lock: 

166 if self.state == "CANCELLED": 

167 return False 

168 self.state = "RUNNING" 

169 return True 

170 

171 def mark_done(self) -> None: 

172 """RUNNING → DONE に遷移する。""" 

173 with self._state_lock: 

174 if self.state != "CANCELLED": 174 ↛ exitline 174 didn't jump to the function exit

175 self.state = "DONE" 

176 

177 

178_STOP = object() 

179 

180 

181class TaskDBBase(ABC): 

182 """ 

183 Abstract base class providing default no-op implementations for maintenance methods. 

184 Actual backends should implement TaskDBCore and optionally TaskDBMaintenance. 

185 """ 

186 

187 @abstractmethod 

188 def init_schema(self): 

189 pass 

190 

191 @abstractmethod 

192 def get( 

193 self, cache_key: str, *, include_expired: bool = False 

194 ) -> Optional[TaskRecord]: 

195 pass 

196 

197 @abstractmethod 

198 def save( 

199 self, 

200 cache_key: str, 

201 func_name: str, 

202 func_identifier: Optional[str], 

203 input_id: str, 

204 version: Optional[str], 

205 result_type: str, 

206 content_type: Optional[str], 

207 result_value: Optional[str] = None, 

208 result_data: Optional[bytes] = None, 

209 expires_at: Optional[datetime] = None, 

210 ): 

211 pass 

212 

213 @abstractmethod 

214 def delete(self, cache_key: str) -> bool: 

215 pass 

216 

217 # --- Maintenance Methods (Default implementations) --- 

218 def delete_expired(self) -> int: 

219 """Delete tasks that have passed their expiration time.""" 

220 return 0 

221 

222 def prune(self, older_than: datetime, func_name: Optional[str] = None) -> int: 

223 """Delete tasks older than the specified datetime.""" 

224 return 0 

225 

226 def get_outdated_tasks( 

227 self, older_than: datetime, func_name: Optional[str] = None 

228 ) -> list[tuple[str, str, str]]: 

229 """Retrieve tasks older than the specified datetime (Preview for prune).""" 

230 return [] 

231 

232 def get_blob_refs(self) -> Optional[set[str]]: 

233 """Retrieve all 'result_value' entries that point to external storage.""" 

234 return None 

235 

236 def delete_all(self, func_name: Optional[str] = None) -> int: 

237 """Delete all tasks, optionally filtered by function name.""" 

238 return 0 

239 

240 def get_keys_start_with(self, prefix: str) -> list[str]: 

241 """Retrieve cache keys that start with the given prefix.""" 

242 return [] 

243 

244 def get_history(self, limit: int = 1000) -> "pd.DataFrame": 

245 """Get task history. Returns an empty DataFrame by default.""" 

246 try: 

247 import pandas as pd 

248 

249 return pd.DataFrame() 

250 except ImportError: 

251 raise ImportError("Pandas is required for this feature.") 

252 

253 

254class SQLiteTaskDB(TaskDBCore, Flushable, Shutdownable, Maintenable): 

255 """ 

256 Default implementation using SQLite. 

257 """ 

258 

259 def __init__(self, db_path: str | Path | None = None, timeout: float = 30.0): 

260 self.db_path = ( 

261 Path(db_path).resolve() if db_path else Path(f".beautyspot/{hash(self)}.db") 

262 ) 

263 self._ensure_cache_dir(self.db_path.parent) 

264 self.timeout = timeout 

265 self._local = threading.local() 

266 self._write_queue: queue.Queue[object] = queue.Queue() 

267 self._shutdown_lock = threading.Lock() 

268 self._shutdown_requested = False 

269 self._writer_ready = threading.Event() 

270 self._writer_error: Exception | None = None 

271 # 読み取り専用スレッドローカル接続を追跡し、 

272 # shutdown() 時に一括クローズする。WAL チェックポイントの妨げを防ぐ。 

273 self._read_wrappers = weakref.WeakSet() 

274 self._read_conns_lock = threading.Lock() 

275 self._writer_thread = threading.Thread( 

276 target=self._writer_loop, daemon=True, name="BeautySpot-SQLiteWriter" 

277 ) 

278 self._writer_thread.start() 

279 self._writer_ready.wait() 

280 if self._writer_error: 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true

281 raise self._writer_error 

282 

283 @staticmethod 

284 def _ensure_cache_dir(directory: Path) -> None: 

285 """ 

286 データベース格納用の親ディレクトリを作成し、.gitignore を配置する。 

287 """ 

288 directory.mkdir(parents=True, exist_ok=True) 

289 gitignore_path = directory / ".gitignore" 

290 if not gitignore_path.exists(): 

291 try: 

292 gitignore_path.write_text("*\n") 

293 except OSError as e: 

294 logging.warning(f"Failed to create .gitignore in {directory}: {e}") 

295 

296 @contextmanager 

297 def _read_connect(self) -> Iterator[sqlite3.Connection]: 

298 """ 

299 Thread-safe connection context manager for read-only operations. 

300 Uses a thread-local pool to reuse connections and reduce overhead. 

301 PRAGMA query_only = ON により、誤った書き込みを接続レベルで防止する。 

302 

303 新規接続を _read_wrappers に登録し、 

304 shutdown() 時の一括クローズで WAL チェックポイント妨害を防ぐ。 

305 また、_ReadConnWrapper によってスレッド終了時に接続がクローズされ、メモリリークを防止。 

306 """ 

307 if self._shutdown_requested: 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true

308 raise RuntimeError("SQLiteTaskDB is shutting down.") 

309 

310 wrapper = getattr(self._local, "read_conn_wrapper", None) 

311 if wrapper is None or wrapper._closed: 

312 # シャットダウン後に新しい接続がリークするのを防ぐため再チェック。 

313 # 最初のチェック通過後に別スレッドが shutdown() を呼び出し、 

314 # 全ラッパーをクローズした場合にここに到達する。 

315 if self._shutdown_requested: 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true

316 raise RuntimeError("SQLiteTaskDB is shutting down.") 

317 conn = sqlite3.connect( 

318 self.db_path, timeout=self.timeout, check_same_thread=False 

319 ) 

320 try: 

321 conn.execute("PRAGMA query_only = ON;") 

322 except Exception: 

323 conn.close() 

324 raise 

325 wrapper = _ReadConnWrapper(conn) 

326 with self._read_conns_lock: 

327 # ロック内で再度チェックし、shutdown() による _read_wrappers.clear() と 

328 # 新規追加の間の競合を完全に排除する。 

329 if self._shutdown_requested: 329 ↛ 330line 329 didn't jump to line 330 because the condition on line 329 was never true

330 conn.close() 

331 raise RuntimeError("SQLiteTaskDB is shutting down.") 

332 self._read_wrappers.add(wrapper) 

333 self._local.read_conn_wrapper = wrapper 

334 

335 with wrapper.lock: 

336 if wrapper._closed: 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true

337 raise RuntimeError("Database connection was closed") 

338 try: 

339 yield wrapper.conn 

340 except sqlite3.Error: 

341 # 接続が壊れた場合等のリカバリ (BUG-3) 

342 # 現在の接続を破棄し、次回のアクセス時に新しく作り直す 

343 wrapper.close() 

344 with self._read_conns_lock: 

345 self._read_wrappers.discard(wrapper) 

346 self._local.read_conn_wrapper = None 

347 raise 

348 

349 # クエリ実行中にシャットダウン要求があった場合、自身でクローズする 

350 if getattr(wrapper, "_shutdown_requested", False): 350 ↛ 351line 350 didn't jump to line 351 because the condition on line 350 was never true

351 wrapper.close() 

352 with self._read_conns_lock: 

353 self._read_wrappers.discard(wrapper) 

354 self._local.read_conn_wrapper = None 

355 

356 def _writer_loop(self) -> None: 

357 conn: sqlite3.Connection | None = None 

358 try: 

359 conn = sqlite3.connect(self.db_path, timeout=self.timeout) 

360 conn.execute("PRAGMA journal_mode=WAL;") 

361 except Exception as e: 

362 self._writer_error = e 

363 self._writer_ready.set() 

364 return 

365 

366 self._writer_ready.set() 

367 try: 

368 while True: 

369 task = self._write_queue.get() 

370 if task is _STOP: 

371 self._write_queue.task_done() 

372 break 

373 assert isinstance(task, _WriteTask) 

374 if not task.try_start(): 

375 # CANCELLED 状態 — スキップ 

376 task.event.set() 

377 self._write_queue.task_done() 

378 continue 

379 

380 try: 

381 task.result = task.fn(conn) 

382 conn.commit() 

383 except BaseException as e: 

384 conn.rollback() 

385 task.error = e 

386 finally: 

387 task.mark_done() 

388 task.event.set() 

389 self._write_queue.task_done() 

390 finally: 

391 if conn is not None: 391 ↛ exitline 391 didn't return from function '_writer_loop' because the condition on line 391 was always true

392 conn.close() 

393 

394 def _enqueue_write(self, fn: Callable[[sqlite3.Connection], Any]) -> Any: 

395 self._writer_ready.wait() 

396 if self._writer_error: 396 ↛ 397line 396 didn't jump to line 397 because the condition on line 396 was never true

397 raise RuntimeError( 

398 "SQLite writer thread failed to start." 

399 ) from self._writer_error 

400 

401 with self._shutdown_lock: 

402 if self._shutdown_requested: 402 ↛ 403line 402 didn't jump to line 403 because the condition on line 402 was never true

403 raise RuntimeError("SQLiteTaskDB is shutting down.") 

404 if not self._writer_thread.is_alive(): 404 ↛ 405line 404 didn't jump to line 405 because the condition on line 404 was never true

405 raise RuntimeError("SQLite writer thread is not running.") 

406 task = _WriteTask(fn=fn, event=threading.Event()) 

407 self._write_queue.put(task) 

408 

409 start = time.monotonic() 

410 _warned_running = False 

411 while not task.event.wait(timeout=0.5): 

412 if not self._writer_thread.is_alive(): 412 ↛ 413line 412 didn't jump to line 413 because the condition on line 412 was never true

413 raise RuntimeError("SQLite writer thread stopped unexpectedly.") 

414 if self._shutdown_requested: 414 ↛ 415line 414 didn't jump to line 415 because the condition on line 414 was never true

415 raise RuntimeError("SQLiteTaskDB is shutting down.") 

416 elapsed = time.monotonic() - start 

417 if elapsed > self.timeout: 417 ↛ 411line 417 didn't jump to line 411 because the condition on line 417 was always true

418 if task.try_cancel(): 

419 # PENDING(未着手)のタスクはキャンセル可能 

420 raise TimeoutError( 

421 f"SQLite write did not start within {self.timeout}s and was cancelled." 

422 ) 

423 elif not _warned_running: 423 ↛ 411line 423 didn't jump to line 411 because the condition on line 423 was always true

424 # RUNNING(実行中)のタスクはキャンセル不可。 

425 # 旧実装では RUNNING でも TimeoutError を送出していたが、 

426 # DB への書き込みは継続されるため呼び出し元との整合性が取れなかった。 

427 # 修正後は完了まで待ち続け、警告ログのみ出力する。 

428 logger.warning( 

429 f"SQLite write has been running for over {self.timeout}s. " 

430 "The operation cannot be cancelled and will continue until completion." 

431 ) 

432 _warned_running = True 

433 # RUNNING 状態: 完了まで待ち続ける(TimeoutError は送出しない) 

434 if task.error: 434 ↛ 435line 434 didn't jump to line 435 because the condition on line 434 was never true

435 raise task.error 

436 return task.result 

437 

438 def shutdown(self, wait: bool = True) -> None: 

439 with self._shutdown_lock: 

440 if self._shutdown_requested: 440 ↛ 441line 440 didn't jump to line 441 because the condition on line 440 was never true

441 return 

442 self._shutdown_requested = True 

443 

444 if not self._writer_thread.is_alive(): 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true

445 logger.error( 

446 "SQLite writer thread is not running; pending writes may be lost." 

447 ) 

448 return 

449 

450 if wait: 

451 self._write_queue.join() 

452 self._write_queue.put(_STOP) 

453 if wait: 

454 self._writer_thread.join() 

455 

456 # 全スレッドのread-only接続を一括クローズ。 

457 # スレッドローカル接続が開いたままだと WAL チェックポイントを妨げるため。 

458 with self._read_conns_lock: 

459 for wrapper in self._read_wrappers: 

460 try: 

461 wrapper.close(wait=False) 

462 except Exception: 

463 pass 

464 self._read_wrappers.clear() 

465 

466 def init_schema(self): 

467 # スキーマ初期化および全マイグレーションを Writer Thread の接続で実行する。 

468 # _connect() による別コネクション経由の DDL は、Writer Thread が保持する 

469 # WAL ライターロックと競合するリスクがあるため、_enqueue_write に委譲する。 

470 def _op(conn: sqlite3.Connection) -> None: 

471 conn.execute("PRAGMA journal_mode=WAL;") 

472 conn.execute(""" 

473 CREATE TABLE IF NOT EXISTS tasks ( 

474 cache_key TEXT PRIMARY KEY, 

475 func_name TEXT, 

476 func_identifier TEXT, 

477 input_id TEXT, 

478 result_type TEXT, 

479 result_value TEXT, 

480 result_data BLOB, 

481 content_type TEXT, 

482 version TEXT, 

483 expires_at TIMESTAMP, 

484 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 

485 ) 

486 """) 

487 

488 # Auto Migration 

489 cursor = conn.execute("PRAGMA table_info(tasks)") 

490 columns = [row[1] for row in cursor.fetchall()] 

491 

492 if "content_type" not in columns: 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true

493 try: 

494 conn.execute("ALTER TABLE tasks ADD COLUMN content_type TEXT;") 

495 except sqlite3.OperationalError as e: 

496 if "duplicate column name" not in str(e).lower(): 

497 raise 

498 pass 

499 if "version" not in columns: 499 ↛ 500line 499 didn't jump to line 500 because the condition on line 499 was never true

500 try: 

501 conn.execute("ALTER TABLE tasks ADD COLUMN version TEXT;") 

502 except sqlite3.OperationalError as e: 

503 if "duplicate column name" not in str(e).lower(): 

504 raise 

505 pass 

506 if "result_data" not in columns: 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true

507 try: 

508 conn.execute("ALTER TABLE tasks ADD COLUMN result_data BLOB;") 

509 except sqlite3.OperationalError as e: 

510 if "duplicate column name" not in str(e).lower(): 

511 raise 

512 pass 

513 

514 if "func_identifier" not in columns: 514 ↛ 515line 514 didn't jump to line 515 because the condition on line 514 was never true

515 try: 

516 conn.execute("ALTER TABLE tasks ADD COLUMN func_identifier TEXT;") 

517 except sqlite3.OperationalError as e: 

518 if "duplicate column name" not in str(e).lower(): 

519 raise 

520 pass 

521 conn.execute( 

522 "CREATE INDEX IF NOT EXISTS idx_func_identifier ON tasks(func_identifier);" 

523 ) 

524 

525 if "expires_at" not in columns: 

526 try: 

527 conn.execute("ALTER TABLE tasks ADD COLUMN expires_at TIMESTAMP;") 

528 except sqlite3.OperationalError as e: 

529 if "duplicate column name" not in str(e).lower(): 

530 raise 

531 pass 

532 conn.execute( 

533 "CREATE INDEX IF NOT EXISTS idx_expires_at ON tasks(expires_at);" 

534 ) 

535 

536 self._enqueue_write(_op) 

537 

538 def get( 

539 self, cache_key: str, *, include_expired: bool = False 

540 ) -> Optional[TaskRecord]: 

541 with self._read_connect() as conn: 

542 # [MOD] Include expires_at in query 

543 row = conn.execute( 

544 "SELECT result_type, result_value, result_data, expires_at FROM tasks WHERE cache_key=?", 

545 (cache_key,), 

546 ).fetchone() 

547 

548 if row: 

549 r_type, r_val, r_data, exp_str = row 

550 

551 # [ADD] Lazy Expiration Check (skip when include_expired=True) 

552 if exp_str and not include_expired: 

553 try: 

554 # SQLite returns timestamps as strings usually 

555 # Replace space with T for compatibility with Python <= 3.10 

556 expires_at = datetime.fromisoformat(exp_str.replace(" ", "T")) 

557 # Naive datetimes stored before timezone support are treated as UTC 

558 if expires_at.tzinfo is None: 558 ↛ 559line 558 didn't jump to line 559 because the condition on line 558 was never true

559 expires_at = expires_at.replace(tzinfo=timezone.utc) 

560 if expires_at < datetime.now(timezone.utc): 

561 # Expired -> Treat as Cache Miss 

562 # (Physical deletion is deferred to `beautyspot gc`) 

563 return None 

564 except (ValueError, TypeError): 

565 pass # Ignore parsing errors, treat as valid 

566 

567 return TaskRecord( 

568 result_type=r_type, 

569 result_value=r_val, 

570 result_data=r_data, 

571 expires_at=exp_str, 

572 ) 

573 return None 

574 

575 def save( 

576 self, 

577 cache_key: str, 

578 func_name: str, 

579 func_identifier: Optional[str], 

580 input_id: str, 

581 version: Optional[str], 

582 result_type: str, 

583 content_type: Optional[str], 

584 result_value: Optional[str] = None, 

585 result_data: Optional[bytes] = None, 

586 expires_at: Optional[datetime] = None, # [ADD] Added argument 

587 ): 

588 def _op(conn: sqlite3.Connection) -> None: 

589 effective_identifier = func_identifier or func_name 

590 # updated_at を明示的に設定し、expires_at と同じ形式 

591 # (_ensure_utc_isoformat) で統一する。 

592 # SQLite の DEFAULT CURRENT_TIMESTAMP は秒精度でフォーマットが異なるため、 

593 # prune/get_outdated_tasks との比較精度を揃える。 

594 now_str = _ensure_utc_isoformat(datetime.now(timezone.utc)) 

595 conn.execute( 

596 """ 

597 INSERT OR REPLACE INTO tasks 

598 (cache_key, func_name, func_identifier, input_id, version, result_type, content_type, result_value, result_data, expires_at, updated_at) 

599 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 

600 """, 

601 ( 

602 cache_key, 

603 func_name, 

604 effective_identifier, 

605 input_id, 

606 version, 

607 result_type, 

608 content_type, 

609 result_value, 

610 result_data, 

611 _ensure_utc_isoformat(expires_at), 

612 now_str, 

613 ), 

614 ) 

615 

616 self._enqueue_write(_op) 

617 

618 def get_history(self, limit: int = 1000) -> "pd.DataFrame": 

619 try: 

620 import pandas as pd 

621 except ImportError as e: 

622 raise ImportError("Pandas is required for this feature.") from e 

623 

624 if not os.path.exists(self.db_path): 624 ↛ 625line 624 didn't jump to line 625 because the condition on line 624 was never true

625 return pd.DataFrame() 

626 

627 with self._read_connect() as conn: 

628 query = """ 

629 SELECT 

630 cache_key, func_name, func_identifier, input_id, version, result_type, 

631 content_type, result_value, result_data, updated_at, expires_at 

632 FROM tasks 

633 ORDER BY updated_at DESC LIMIT ? 

634 """ 

635 return pd.read_sql_query(query, conn, params=[limit]) 

636 

637 def delete(self, cache_key: str) -> bool: 

638 def _op(conn: sqlite3.Connection) -> bool: 

639 cursor = conn.execute("DELETE FROM tasks WHERE cache_key=?", (cache_key,)) 

640 return cursor.rowcount > 0 

641 

642 return bool(self._enqueue_write(_op)) 

643 

644 def delete_all(self, func_name: Optional[str] = None) -> int: 

645 def _op(conn: sqlite3.Connection) -> int: 

646 if func_name: 

647 cursor = conn.execute( 

648 "DELETE FROM tasks WHERE func_name = ? OR func_identifier = ?", 

649 (func_name, func_name), 

650 ) 

651 else: 

652 cursor = conn.execute("DELETE FROM tasks") 

653 return cursor.rowcount 

654 

655 return int(self._enqueue_write(_op)) 

656 

657 def prune(self, older_than: datetime, func_name: Optional[str] = None) -> int: 

658 cutoff_str = _ensure_utc_isoformat(older_than) 

659 

660 def _op(conn: sqlite3.Connection) -> int: 

661 if func_name: 

662 cursor = conn.execute( 

663 "DELETE FROM tasks WHERE updated_at < ? AND (func_name = ? OR func_identifier = ?)", 

664 (cutoff_str, func_name, func_name), 

665 ) 

666 else: 

667 cursor = conn.execute( 

668 "DELETE FROM tasks WHERE updated_at < ?", 

669 (cutoff_str,), 

670 ) 

671 return cursor.rowcount 

672 

673 return int(self._enqueue_write(_op)) 

674 

675 def get_outdated_tasks( 

676 self, older_than: datetime, func_name: Optional[str] = None 

677 ) -> list[tuple[str, str, str]]: 

678 cutoff_str = _ensure_utc_isoformat(older_than) 

679 if not os.path.exists(self.db_path): 679 ↛ 680line 679 didn't jump to line 680 because the condition on line 679 was never true

680 return [] 

681 

682 with self._read_connect() as conn: 

683 if func_name: 

684 cursor = conn.execute( 

685 "SELECT cache_key, COALESCE(func_identifier, func_name), updated_at FROM tasks " 

686 "WHERE updated_at < ? AND (func_name = ? OR func_identifier = ?)", 

687 (cutoff_str, func_name, func_name), 

688 ) 

689 else: 

690 cursor = conn.execute( 

691 "SELECT cache_key, COALESCE(func_identifier, func_name), updated_at FROM tasks WHERE updated_at < ?", 

692 (cutoff_str,), 

693 ) 

694 return [(row[0], row[1], str(row[2])) for row in cursor.fetchall()] 

695 

696 def delete_expired(self) -> int: 

697 if not os.path.exists(self.db_path): 697 ↛ 698line 697 didn't jump to line 698 because the condition on line 697 was never true

698 return 0 

699 

700 # save() と同じ _ensure_utc_isoformat を使い、フォーマットを統一する 

701 now_str = _ensure_utc_isoformat(datetime.now(timezone.utc)) 

702 

703 def _op(conn: sqlite3.Connection) -> int: 

704 cursor = conn.execute( 

705 "DELETE FROM tasks WHERE expires_at IS NOT NULL AND expires_at < ?", 

706 (now_str,), 

707 ) 

708 return cursor.rowcount 

709 

710 return int(self._enqueue_write(_op)) 

711 

712 def get_blob_refs(self) -> Optional[set[str]]: 

713 if not os.path.exists(self.db_path): 713 ↛ 714line 713 didn't jump to line 714 because the condition on line 713 was never true

714 return set() 

715 

716 with self._read_connect() as conn: 

717 cursor = conn.execute( 

718 "SELECT result_value FROM tasks WHERE result_type = 'FILE' AND result_value IS NOT NULL" 

719 ) 

720 # Return full location strings for precise matching 

721 return {row[0] for row in cursor.fetchall() if row[0]} 

722 

723 def get_keys_start_with(self, prefix: str) -> list[str]: 

724 if not os.path.exists(self.db_path): 724 ↛ 725line 724 didn't jump to line 725 because the condition on line 724 was never true

725 return [] 

726 

727 with self._read_connect() as conn: 

728 # LIKE ワイルドカード文字をエスケープしてプレフィックス検索 

729 escaped = ( 

730 prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") 

731 ) 

732 cursor = conn.execute( 

733 "SELECT cache_key FROM tasks WHERE cache_key LIKE ? ESCAPE '\\' LIMIT 50", 

734 (f"{escaped}%",), 

735 ) 

736 return [row[0] for row in cursor.fetchall()] 

737 

738 @staticmethod 

739 def count_tasks(db_path: Path, timeout: float = 5.0) -> int: 

740 """ 

741 Writer Thread を起動せずに tasks テーブルの件数を返す軽量ユーティリティ。 

742 CLI の一覧表示など、読み込みのみを目的とした用途向け。 

743 エラー時は -1 を返す。 

744 """ 

745 try: 

746 conn = sqlite3.connect(str(db_path), timeout=timeout) 

747 try: 

748 # 読み取り専用ユーティリティに journal_mode=WAL 設定は不要。 

749 # query_only=ON との組み合わせで動作が曖昧になる可能性もあるため削除。 

750 conn.execute("PRAGMA query_only = ON;") 

751 cursor = conn.execute("SELECT COUNT(*) FROM tasks") 

752 result = cursor.fetchone() 

753 return result[0] if result else 0 

754 finally: 

755 conn.close() 

756 except Exception: 

757 return -1 

758 

759 def flush(self, timeout: Optional[float] = None) -> bool: 

760 """ 

761 キューに溜まっているすべての書き込み操作が完了するまで待機します。 

762 

763 No-op(何もしない)タスクをキューの末尾に挿入し、そのタスクが処理されるまで 

764 待機することで、先行するすべてのタスクの完了を保証します。 

765 

766 Args: 

767 timeout: 待機する最大秒数。タイムアウトした場合は False を返します。 

768 None の場合は無期限に待機しますが、ライタースレッドの 

769 死活監視ループにより永久ハングは防止されます。 

770 """ 

771 self._writer_ready.wait() 

772 

773 # キューをフラッシュするためのダミータスク 

774 def _noop_op(conn: sqlite3.Connection) -> None: 

775 pass 

776 

777 task = _WriteTask(fn=_noop_op, event=threading.Event()) 

778 

779 # shutdown との TOCTOU を防ぐため、ロック内でチェックと put を原子的に行う 

780 with self._shutdown_lock: 

781 if self._shutdown_requested or not self._writer_thread.is_alive(): 781 ↛ 782line 781 didn't jump to line 782 because the condition on line 781 was never true

782 return False 

783 self._write_queue.put(task) 

784 

785 # ライタースレッドの死活を定期確認しながら待機する。 

786 # timeout=None で event.wait() を直接呼ぶとスレッド死亡時に永久ハングするため、 

787 # ポーリングループで代替する。 

788 _POLL = 0.5 

789 deadline = (time.monotonic() + timeout) if timeout is not None else None 

790 

791 while True: 

792 remaining = ( 

793 max(0.0, deadline - time.monotonic()) if deadline is not None else None 

794 ) 

795 wait_time = _POLL if remaining is None else min(_POLL, remaining) 

796 

797 if task.event.wait(timeout=wait_time): 

798 return True 

799 

800 if not self._writer_thread.is_alive(): 800 ↛ 801line 800 didn't jump to line 801 because the condition on line 800 was never true

801 logger.error( 

802 "SQLite writer thread died unexpectedly while waiting for flush." 

803 ) 

804 return False 

805 

806 if deadline is not None and time.monotonic() >= deadline: 

807 return False