Skip to content

fix(history): give each thread its own SQLite connection (fixes #39)#81

Open
calvinlow18 wants to merge 1 commit into
open-webui:mainfrom
calvinlow18:fix/sqlite-thread-affinity
Open

fix(history): give each thread its own SQLite connection (fixes #39)#81
calvinlow18 wants to merge 1 commit into
open-webui:mainfrom
calvinlow18:fix/sqlite-thread-affinity

Conversation

@calvinlow18

Copy link
Copy Markdown

Fixes #39.

The bug

SyncHistory caches a single sqlite3.Connection in self._conn, created lazily by whichever thread calls _get_conn() first. The daemon runs every sync through asyncio.to_thread(), so the next source lands on a different pool thread — and sqlite3 (with its default check_same_thread=True) refuses it:

SQLite objects created in a thread can only be used in that same thread.

The first source to finish wins the connection and succeeds; every other source raises. That matches #39's report exactly (4 of 5 sources failing), and it is connector-agnostic — #39 hit it with Google Drive, I hit it with outline: on 4 sources.

Why it's worse than it looks

SyncHistory.log() is called after run_sync() has already uploaded, and _run_entry_locked() catches the exception in its broad except Exception — which overwrites the just-set success state:

_scheduler_state[source] = { "status": status, "files_added": result.added, ... }  # success
...
await asyncio.to_thread(_history.log, ...)   # <-- raises here
except Exception as e:
    _scheduler_state[source] = {"status": "error", "error": str(e)}   # success is discarded

So the documents do reach the knowledge base, while /health, the dashboard and the history all report error for a sync that actually worked. Anything monitoring the daemon is permanently red, and the counts (files_added, etc.) are lost.

The fix

Hold the connection in a threading.local() so each thread gets its own. Also open with WAL and a busy timeout, so the now-genuinely-concurrent writers wait on the brief write lock instead of raising database is locked.

One file, no API change.

Reproduction

Against main, driving SyncHistory the way the daemon does:

h = SyncHistory(db_path=db)
async def log(i):
    await asyncio.to_thread(h.log, source=f"src{i}", kb_id=f"kb{i}",
                            status="success", started_at=0.0, files_added=1)
await asyncio.gather(*(log(i) for i in range(5)), return_exceptions=True)

Before: 4 of 5 raise ProgrammingError: SQLite objects created in a thread..., and the follow-up query() raises too.
After: sources logged OK: 5/5, rows in history: 5.

Also re-checked the single-threaded CLI path — log, query (plain / errors_only / kb_id), last_sync, clear, and a double close() — all unchanged.

The daemon runs every sync through asyncio.to_thread(), so SyncHistory's single
cached connection gets handed to whichever pool thread runs the next source.
sqlite3 refuses it:

    SQLite objects created in a thread can only be used in that same thread.

The first source to finish wins the connection and succeeds; every other source
raises. Because SyncHistory.log() is called after the upload completes, the
documents do reach the knowledge base -- but the sync is recorded and reported as
an error, so the daemon's /health, its dashboard and its history all show failures
for syncs that actually worked.

Hold the connection in a threading.local() instead, so each thread gets its own.
Also open with WAL and a busy timeout, so the now-concurrent writers wait on the
brief write lock rather than raising 'database is locked'.

Fixes open-webui#39
@tjbck

tjbck commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Should be addressed!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SQLite threading error in daemon mode with multiple sources

2 participants