fix: evict stale project engine delegate on delete to prevent assert self._db crash
Deleting a project closed its store but left the delegate cached in _project_engine_delegates; re-creating a same-name project then reused the zombie engine and crashed in get_session. delete() now closes and evicts via _close_project_engine_store (also covers non-active deletes), the delegate cache self-heals when a cached store is closed, and _engine_for_project reopens a closed store for the non-evictable root engine case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+15
-2
@@ -11599,6 +11599,12 @@ class OPCEngine:
|
|||||||
return "company"
|
return "company"
|
||||||
return "task"
|
return "task"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_delegate_usable(delegate: "OPCEngine") -> bool:
|
||||||
|
"""A cached delegate is only reusable while its store connection is open."""
|
||||||
|
store = getattr(delegate, "store", None)
|
||||||
|
return bool(store is None or getattr(store, "is_ready", True))
|
||||||
|
|
||||||
async def _get_project_delegate(self, project_id: str) -> OPCEngine:
|
async def _get_project_delegate(self, project_id: str) -> OPCEngine:
|
||||||
"""Return an initialized engine dedicated to ``project_id``.
|
"""Return an initialized engine dedicated to ``project_id``.
|
||||||
|
|
||||||
@@ -11611,14 +11617,21 @@ class OPCEngine:
|
|||||||
if normalized_project_id == current_project_id:
|
if normalized_project_id == current_project_id:
|
||||||
return self
|
return self
|
||||||
existing = self._project_engine_delegates.get(normalized_project_id)
|
existing = self._project_engine_delegates.get(normalized_project_id)
|
||||||
if existing is not None:
|
if existing is not None and self._is_delegate_usable(existing):
|
||||||
return existing
|
return existing
|
||||||
if self._project_delegate_lock is None:
|
if self._project_delegate_lock is None:
|
||||||
self._project_delegate_lock = asyncio.Lock()
|
self._project_delegate_lock = asyncio.Lock()
|
||||||
async with self._project_delegate_lock:
|
async with self._project_delegate_lock:
|
||||||
existing = self._project_engine_delegates.get(normalized_project_id)
|
existing = self._project_engine_delegates.get(normalized_project_id)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
if self._is_delegate_usable(existing):
|
||||||
|
return existing
|
||||||
|
# Store was closed (e.g. project deleted then re-created with
|
||||||
|
# the same id) — drop the stale delegate and build a fresh one.
|
||||||
|
self._project_engine_delegates.pop(normalized_project_id, None)
|
||||||
|
logger.warning(
|
||||||
|
f"Discarding stale project delegate for '{normalized_project_id}' (store closed)"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
delegate_config = copy.deepcopy(self.config)
|
delegate_config = copy.deepcopy(self.config)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -240,14 +240,13 @@ class ProjectService:
|
|||||||
deleted_channels = int(await delete_chat(project_id) or 0)
|
deleted_channels = int(await delete_chat(project_id) or 0)
|
||||||
logger.info(f"Deleted {deleted_channels} channels for project '{project_id}'")
|
logger.info(f"Deleted {deleted_channels} channels for project '{project_id}'")
|
||||||
|
|
||||||
|
# Close every engine bound to this project AND evict its delegate from
|
||||||
|
# the root engine's cache; a stale delegate would otherwise be reused
|
||||||
|
# (with a closed store) if a project with the same id is re-created.
|
||||||
|
await self._close_project_engine_store(project_id)
|
||||||
|
|
||||||
projects_dir = self.context.project_dir(project_id)
|
projects_dir = self.context.project_dir(project_id)
|
||||||
if projects_dir.is_dir():
|
if projects_dir.is_dir():
|
||||||
active_engine = self.context.engine
|
|
||||||
if was_active and getattr(active_engine, "store", None):
|
|
||||||
try:
|
|
||||||
await active_engine.store.close()
|
|
||||||
except Exception:
|
|
||||||
logger.opt(exception=True).debug("Failed to close active project store before delete")
|
|
||||||
shutil.rmtree(str(projects_dir), ignore_errors=True)
|
shutil.rmtree(str(projects_dir), ignore_errors=True)
|
||||||
|
|
||||||
workplace = self.context.project_workplace(project_id)
|
workplace = self.context.project_workplace(project_id)
|
||||||
|
|||||||
@@ -763,6 +763,15 @@ class WSHandler:
|
|||||||
)
|
)
|
||||||
maybe_engine = delegate_getter(normalized)
|
maybe_engine = delegate_getter(normalized)
|
||||||
engine = await maybe_engine if inspect.isawaitable(maybe_engine) else maybe_engine
|
engine = await maybe_engine if inspect.isawaitable(maybe_engine) else maybe_engine
|
||||||
|
# Self-heal a closed store (project deleted then re-created while this
|
||||||
|
# engine instance stayed bound to it — e.g. the root engine, which can
|
||||||
|
# never be evicted from its own delegate cache).
|
||||||
|
store = getattr(engine, "store", None)
|
||||||
|
if store is not None and not getattr(store, "is_ready", True):
|
||||||
|
ensure_ready = getattr(store, "ensure_ready", None)
|
||||||
|
if callable(ensure_ready):
|
||||||
|
logger.warning(f"Reopening closed store for project '{normalized}'")
|
||||||
|
await ensure_ready()
|
||||||
self._wire_engine_callbacks(engine)
|
self._wire_engine_callbacks(engine)
|
||||||
return engine
|
return engine
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user