Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2025-03-20 - Database Connection Pooling
**Learning:** `streamrip/db.py` creates a new `sqlite3.connect` for *every single database operation* (`contains`, `add`, `all`). In bulk operations like resolving a playlist with hundreds of tracks, this introduces significant overhead.
**Action:** Use a single persistent connection per database instance instead of creating a new one on every method call.
71 changes: 46 additions & 25 deletions streamrip/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,21 +68,39 @@ def __init__(self, path: str):

self.path = path

if not os.path.exists(self.path):
# sqlite3.connect creates the file if it does not exist, so we check
# existence beforehand to know if we need to call create() later
exists = os.path.exists(self.path)

# ⚡ Bolt: Cache persistent SQLite connection to avoid recreating it
# on every db check/add. This gives ~10x speedup for database operations
# like downloading a playlist where it does hundreds of ID checks sequentially.
self.conn = sqlite3.connect(self.path, check_same_thread=False)
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check_same_thread=False disables sqlite3's built-in guardrails, but there is no locking/serialization around self.conn access. If this instance is ever used across threads, this can lead to unsafe concurrent access. If cross-thread use isn't required, keep the default (True); if it is required, add a lock around all DB operations (or use one connection per thread).

Suggested change
self.conn = sqlite3.connect(self.path, check_same_thread=False)
self.conn = sqlite3.connect(self.path)

Copilot uses AI. Check for mistakes.

if not exists or not self._table_exists():
self.create()

def __del__(self):
"""Ensure connection is closed on exit."""
if hasattr(self, 'conn') and self.conn:
self.conn.close()
Comment on lines +83 to +86
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relying on __del__ to close the SQLite connection is not reliable (it may never run, and during interpreter shutdown module globals may already be torn down). Consider providing an explicit close() / context-manager API and calling it from the application shutdown path, and keep __del__ as a best-effort fallback only.

Suggested change
def __del__(self):
"""Ensure connection is closed on exit."""
if hasattr(self, 'conn') and self.conn:
self.conn.close()
def close(self) -> None:
"""Close the underlying SQLite connection, if open."""
conn = getattr(self, "conn", None)
if conn is not None:
try:
conn.close()
except Exception:
# Best-effort close; failures are not fatal.
logger.debug("Error while closing database connection", exc_info=True)
finally:
self.conn = None
def __enter__(self):
"""Support use as a context manager."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Ensure the connection is closed when leaving a context manager."""
self.close()
def __del__(self):
"""Best-effort attempt to close the connection on garbage collection."""
try:
self.close()
except Exception:
# Avoid raising during interpreter shutdown
pass

Copilot uses AI. Check for mistakes.
Comment on lines +85 to +86
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repo formatting config enforces double quotes; these single-quoted string literals will be reformatted (and may fail CI if formatting checks run). Please use double quotes for consistency.

Copilot uses AI. Check for mistakes.

def _table_exists(self) -> bool:
command = f"SELECT count(name) FROM sqlite_master WHERE type='table' AND name='{self.name}'"
return bool(self.conn.execute(command).fetchone()[0])

def create(self):
"""Create a database."""
with sqlite3.connect(self.path) as conn:
params = ", ".join(
f"{key} {' '.join(map(str.upper, props))} NOT NULL"
for key, props in self.structure.items()
)
command = f"CREATE TABLE {self.name} ({params})"
params = ", ".join(
f"{key} {' '.join(map(str.upper, props))} NOT NULL"
for key, props in self.structure.items()
)
command = f"CREATE TABLE IF NOT EXISTS {self.name} ({params})"

logger.debug("executing %s", command)
logger.debug("executing %s", command)

conn.execute(command)
self.conn.execute(command)
self.conn.commit()

def keys(self):
"""Get the column names of the table."""
Expand All @@ -101,13 +119,12 @@ def contains(self, **items) -> bool:

items = {k: str(v) for k, v in items.items()}

with sqlite3.connect(self.path) as conn:
conditions = " AND ".join(f"{key}=?" for key in items.keys())
command = f"SELECT EXISTS(SELECT 1 FROM {self.name} WHERE {conditions})"
conditions = " AND ".join(f"{key}=?" for key in items.keys())
command = f"SELECT EXISTS(SELECT 1 FROM {self.name} WHERE {conditions})"

logger.debug("Executing %s", command)
logger.debug("Executing %s", command)

return bool(conn.execute(command, tuple(items.values())).fetchone()[0])
return bool(self.conn.execute(command, tuple(items.values())).fetchone()[0])

def add(self, items: tuple[str]):
"""Add a row to the table.
Expand All @@ -124,12 +141,12 @@ def add(self, items: tuple[str]):
logger.debug("Executing %s", command)
logger.debug("Items to add: %s", items)

with sqlite3.connect(self.path) as conn:
try:
conn.execute(command, tuple(items))
except sqlite3.IntegrityError as e:
# tried to insert an item that was already there
logger.debug(e)
try:
self.conn.execute(command, tuple(items))
self.conn.commit()
except sqlite3.IntegrityError as e:
# tried to insert an item that was already there
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add() catches sqlite3.IntegrityError but does not ROLLBACK the transaction. With a long-lived connection this can leave the connection in an open transaction and hold locks, causing subsequent operations (or other processes) to hit database is locked. Roll back on errors (or wrap DML in with self.conn: so commit/rollback is handled consistently).

Suggested change
# tried to insert an item that was already there
# tried to insert an item that was already there
self.conn.rollback()

Copilot uses AI. Check for mistakes.
logger.debug(e)

def remove(self, **items):
"""Remove items from a table.
Expand All @@ -141,21 +158,25 @@ def remove(self, **items):
conditions = " AND ".join(f"{key}=?" for key in items.keys())
command = f"DELETE FROM {self.name} WHERE {conditions}"

with sqlite3.connect(self.path) as conn:
logger.debug(command)
conn.execute(command, tuple(items.values()))
logger.debug(command)
self.conn.execute(command, tuple(items.values()))
self.conn.commit()

def all(self):
"""Iterate through the rows of the table."""
with sqlite3.connect(self.path) as conn:
return list(conn.execute(f"SELECT * FROM {self.name}"))
return list(self.conn.execute(f"SELECT * FROM {self.name}"))

def reset(self):
"""Delete the database file."""
if hasattr(self, 'conn') and self.conn:
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repo formatting config enforces double quotes; use double quotes here as well (this currently deviates from tool.ruff.format.quote-style = "double").

Suggested change
if hasattr(self, 'conn') and self.conn:
if hasattr(self, "conn") and self.conn:

Copilot uses AI. Check for mistakes.
self.conn.close()
self.conn = None
try:
os.remove(self.path)
except FileNotFoundError:
pass
self.conn = sqlite3.connect(self.path, check_same_thread=False)
self.create()


class Downloads(DatabaseBase):
Expand Down
5 changes: 3 additions & 2 deletions tests/test_deezer.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import os
import pytest
from unittest.mock import Mock, patch

import pytest
from util import arun

from streamrip.client.downloadable import DeezerDownloadable
from streamrip.client.deezer import DeezerClient
from streamrip.config import Config
from streamrip.exceptions import NonStreamableError


@pytest.fixture(scope="session")
def deezer_client():
"""Integration test fixture - requires DEEZER_ARL environment variable"""
Expand Down
Loading