Skip to content

SQLite write lock held open across blocking embeds → cross-process "database is locked" (semantic.py:101) #97

Description

@emp3thy

Severity: high

Location: better_memory/services/semantic.py:101 (+ reflection.py:1235, :1729)

Merged with the synthesize_next_apply finding — same defect, five call sites.

What breaks. Python's sqlite3 in default isolation mode issues an implicit BEGIN before the first DML (connection.py:29-31); in WAL mode that takes the single writer lock until commit(). These paths open the transaction, then call the blocking SyncEmbedder, then commit:

Path DML Embed Commit
SemanticMemoryService.create semantic.py:91 :101 :102
SemanticMemoryService.update_text :109 :123 :124
create_from_observation SAVEPOINT :185 :203 :210
ReflectionSynthesisService.apply_decision SAVEPOINT reflection.py:1235 :835/:991 (N embeds) :1256
ReflectionService.edit_text (UI) reflection.py:1729 :1738 :1742

Each embed can hold for up to 15 s (sync_embed.py:37, async_bridge.py:59); apply_decision does one per reflection. Every other connection gets PRAGMA busy_timeout=5000 (connection.py:54) — 5 s of patience. This is genuinely cross-process: the MCP server owns one connection (server.py:165), the Flask UI opens its own (ui/app.py:102), both wired to SyncEmbedder(lambda: OllamaEmbedder(timeout=5.0, max_retries=1)) (server.py:188-190, ui/app.py:49). A refuter noted the INSERT at reflection.py:802 precedes the embed at :835, so the lock is provably held across the network call even for N=1. A refuter reproduced the mechanism on the project interpreter (Python 3.12.10 / SQLite 3.49.1) with the project's own PRAGMAs: connection B died with sqlite3.OperationalError: database is locked after 5.5 s.

Failure scenario. synthesize_next_apply with 4-5 new reflections while Ollama loads a model (~4-10 s per embed). The savepoint holds the writer lock for 16-50 s. The management UI's "confirm reflection" POST exceeds its 5000 ms busy_timeout and returns HTTP 500. Same in reverse when the UI's edit_text holds the lock. If a worker exceeds 15 s, run_async_in_worker abandons the daemon thread (async_bridge.py:18-24, 60-63) with the transaction still open.

Fix. Compute the embedding before opening the transaction (or after committing, then write the vector in a second short transaction) so no network call happens inside a write lock.


Filed from an automated adversarial bug hunt (bughunt workflow, run
wf_43511264-124, 87 agents). Each finding was attacked by three independent
refuters from different angles — reproduce, reachability, and library/language
semantics — and survived a majority. Where a refuter corrected or narrowed the
claim, that correction is preserved inline above rather than dropped. Line
numbers reflect the tree at the time of the run; verify before acting.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingbughuntFiled by the automated adversarial bug huntseverity:highbughunt severity

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions