Skip to content

Commit a576d10

Browse files
fix(capsule): one transient error no longer permanently kills the audit trail
CapsuleStorage._ensure_db assigned self._engine BEFORE running create_all, and gated re-entry on `if self._engine is None`. So any failure inside create_all (a locked db file, a full disk, an engine pool bound to a since-closed event loop) left the instance holding an engine but no session factory. Every later call then short-circuited, did nothing, and _get_session_factory() raised "Database not initialized -- call _ensure_db() first" for the entire life of the process, while _ensure_db() was in fact being called every time. One transient error silently and permanently disabled capsule persistence, and the error message actively misled whoever read it. Under "Capsule everything" (Trust Foundation layer 5, the immutable audit trail), a silently dead audit log is a security failure, not an availability one. Initialization is now atomic. Readiness keys off _session_factory, which is what callers actually need; the engine and the factory publish together and only after create_all succeeds; a failure disposes the partial engine and leaves both unset, so the next call retries from scratch. A concurrent initializer is handled by keeping theirs and disposing ours rather than leaking an engine.
1 parent 68ddbe0 commit a576d10

1 file changed

Lines changed: 50 additions & 16 deletions

File tree

reference/python/src/qp_capsule/storage.py

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -105,24 +105,58 @@ def _get_session_factory(self) -> async_sessionmaker[AsyncSession]:
105105
return self._session_factory
106106

107107
async def _ensure_db(self) -> None:
108-
"""Initialize database if needed."""
109-
if self._engine is None:
110-
self.db_path.parent.mkdir(parents=True, exist_ok=True)
111-
112-
self._engine = create_async_engine(
113-
f"sqlite+aiosqlite:///{self.db_path}",
114-
echo=False,
115-
**self._engine_kwargs,
116-
)
108+
"""Initialize the database if needed. Atomic: all-or-nothing.
109+
110+
Readiness is keyed off ``_session_factory`` (what callers actually need),
111+
and the engine plus the factory are published together, only after
112+
``create_all`` has succeeded.
113+
114+
The previous version assigned ``self._engine`` BEFORE creating the schema
115+
and gated on ``if self._engine is None``. So any failure inside
116+
``create_all`` (a locked db file, a full disk, an engine whose pool is
117+
bound to an event loop that has since closed) left the instance with an
118+
engine but no session factory. Every later call then short-circuited,
119+
returned without doing anything, and ``_get_session_factory()`` raised
120+
"Database not initialized — call _ensure_db() first" for the entire life
121+
of the process, even though ``_ensure_db()`` was being called every time.
122+
One transient error permanently disabled capsule persistence, and the
123+
audit trail went silently dead. It is now self-healing: on failure both
124+
attributes stay unset and the next call retries from scratch.
117125
118-
async with self._engine.begin() as conn:
119-
await conn.run_sync(Base.metadata.create_all)
126+
Raises:
127+
Exception: Whatever the underlying engine/schema creation raised. The
128+
partially built engine is disposed first, so nothing is retained.
129+
"""
130+
if self._session_factory is not None:
131+
return
120132

121-
self._session_factory = async_sessionmaker(
122-
self._engine,
123-
class_=AsyncSession,
124-
expire_on_commit=False,
125-
)
133+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
134+
135+
engine = create_async_engine(
136+
f"sqlite+aiosqlite:///{self.db_path}",
137+
echo=False,
138+
**self._engine_kwargs,
139+
)
140+
141+
try:
142+
async with engine.begin() as conn:
143+
await conn.run_sync(Base.metadata.create_all)
144+
except Exception:
145+
await engine.dispose()
146+
raise
147+
148+
# A concurrent caller may have finished initializing while this one was
149+
# building. Keep theirs and drop ours rather than leaking an engine.
150+
if self._session_factory is not None:
151+
await engine.dispose()
152+
return
153+
154+
self._engine = engine
155+
self._session_factory = async_sessionmaker(
156+
engine,
157+
class_=AsyncSession,
158+
expire_on_commit=False,
159+
)
126160

127161
async def store(self, capsule: Capsule, tenant_id: str | None = None) -> Capsule:
128162
"""

0 commit comments

Comments
 (0)