Summary
The entities service's workspace_cleanup controller permanently reports unhealthy (and its cleanup loop stops processing pending workspace deletions) whenever the process-global SQLAlchemy async engine singleton is first initialized on a different event loop than the controller's private loop and the connection pool is subsequently exhausted by concurrent load. The controller's connection checkout then waits on an asyncio.Queue bound to the other loop and raises:
RuntimeError: <Queue ... maxsize=5> is bound to a different event loop
Because controllers.healthy aggregates this flag into /status, one stuck background controller falsely degrades the whole deployment's health signal. In a full regression run this single flag caused 25/198 agent test cases to error at setup (health gate refused entry) while every API those tests exercise was healthy.
The failure is a startup race: whether a deployment is affected depends on which loop touches the DB singleton first, so identical builds can come up healthy or permanently degraded.
Source analysis
Three sites interact:
- Engine singleton ignores loop affinity —
services/core/entities/src/nmp/core/entities/app/repository/__init__.py:
_async_engine: AsyncEngine | None = None
async def initialize_async_engine(config: EntitiesConfig) -> None:
global _async_engine, _async_session_maker
if _async_engine is not None:
return # Already initialized ← no check WHICH loop created it
-
The uvicorn lifespan initializes the same singleton on the server loop — services/core/entities/src/nmp/core/entities/service.py (await initialize_async_engine(cfg) inside the startup/retry path). Controllers run as daemon threads in the same process (packages/nmp_platform_runner/src/nmp/platform_runner/server.py).
-
The controller thread believes it owns the pool — services/core/entities/src/nmp/core/entities/controllers/main.py:
# Create a single event loop that will be shared for DB init and the cleanup controller,
# so SQLAlchemy's async pool is bound to the same loop that later runs queries.
loop = asyncio.new_event_loop()
loop.run_until_complete(initialize_async_engine(entities_config)) # ← NO-OP if the server won
The comment documents the intended invariant, but the singleton's early return silently breaks it whenever the server lifespan initializes first. workspace_cleanup.step() then drives queries via run_until_complete on the controller loop against a pool whose waiter Queue belongs to the uvicorn loop, and its exception handler pins _is_healthy = False.
Note: while the pool has idle connections the cross-loop checkout happens to succeed (no Queue wait), which is why lightly loaded deployments usually look fine. The defect surfaces exactly under pool contention.
Reproduction (standalone, deterministic — no running platform needed)
Using the product modules in a venv:
- Loop A (simulating the uvicorn lifespan):
initialize_async_engine(cfg), check out all pool_size + max_overflow connections and hold them (simulating concurrent API load), and leave one waiter queued so the pool Queue's futures bind to loop A.
- Loop B (simulating the controller thread): call
initialize_async_engine(cfg) again (returns early — singleton reused), then run one repository query via loop_b.run_until_complete(...) exactly like workspace_cleanup.step() does.
Result:
RuntimeError: <Queue at 0x... maxsize=5> is bound to a different event loop
Observed identically in production logs under concurrent regression load (three suites), with the pool queue showing _getters[28] tasks=172977 and /status reporting:
{"healthy": false, "status": {"job_scheduler": true, "job_reconciler": true,
"models_controller": true, "workspace_cleanup": false}}
After a clean restart (controller loop wins the race) the same suite passes with workspace_cleanup: true throughout — confirming the race dependence.
Expected behavior
- Background controllers keep a valid DB path regardless of initialization order.
workspace_cleanup stays healthy under concurrent API load.
- A single stuck background janitor should not flip the deployment-wide
controllers.healthy signal that external monitors gate on.
Suggested fix directions
- Make
initialize_async_engine loop-aware: record the owning loop and either create per-loop engines/session-makers or raise on foreign-loop reuse so the misconfiguration is visible at startup; or
- Run the entities controller's DB work on the loop that owns the engine (
asyncio.run_coroutine_threadsafe onto the server loop), which is what the controller comment already intends; and
- Consider reporting per-controller health separately from the hard aggregate.
Environment
- main @
0c4dc810cc519ed2b266152e79b4378f7f07de7f (also reproduced from source at that revision)
- Single
nemo services run process, SQLite entities DB, AsyncAdaptedQueuePool size=5 max_overflow=10
- Internal tracking: NVBUG 6588975
Summary
The entities service's
workspace_cleanupcontroller permanently reports unhealthy (and its cleanup loop stops processing pending workspace deletions) whenever the process-global SQLAlchemy async engine singleton is first initialized on a different event loop than the controller's private loop and the connection pool is subsequently exhausted by concurrent load. The controller's connection checkout then waits on anasyncio.Queuebound to the other loop and raises:Because
controllers.healthyaggregates this flag into/status, one stuck background controller falsely degrades the whole deployment's health signal. In a full regression run this single flag caused 25/198 agent test cases to error at setup (health gate refused entry) while every API those tests exercise was healthy.The failure is a startup race: whether a deployment is affected depends on which loop touches the DB singleton first, so identical builds can come up healthy or permanently degraded.
Source analysis
Three sites interact:
services/core/entities/src/nmp/core/entities/app/repository/__init__.py:The uvicorn lifespan initializes the same singleton on the server loop —
services/core/entities/src/nmp/core/entities/service.py(await initialize_async_engine(cfg)inside the startup/retry path). Controllers run as daemon threads in the same process (packages/nmp_platform_runner/src/nmp/platform_runner/server.py).The controller thread believes it owns the pool —
services/core/entities/src/nmp/core/entities/controllers/main.py:The comment documents the intended invariant, but the singleton's early return silently breaks it whenever the server lifespan initializes first.
workspace_cleanup.step()then drives queries viarun_until_completeon the controller loop against a pool whose waiter Queue belongs to the uvicorn loop, and its exception handler pins_is_healthy = False.Note: while the pool has idle connections the cross-loop checkout happens to succeed (no Queue wait), which is why lightly loaded deployments usually look fine. The defect surfaces exactly under pool contention.
Reproduction (standalone, deterministic — no running platform needed)
Using the product modules in a venv:
initialize_async_engine(cfg), check out allpool_size + max_overflowconnections and hold them (simulating concurrent API load), and leave one waiter queued so the pool Queue's futures bind to loop A.initialize_async_engine(cfg)again (returns early — singleton reused), then run one repository query vialoop_b.run_until_complete(...)exactly likeworkspace_cleanup.step()does.Result:
Observed identically in production logs under concurrent regression load (three suites), with the pool queue showing
_getters[28] tasks=172977and/statusreporting:{"healthy": false, "status": {"job_scheduler": true, "job_reconciler": true, "models_controller": true, "workspace_cleanup": false}}After a clean restart (controller loop wins the race) the same suite passes with
workspace_cleanup: truethroughout — confirming the race dependence.Expected behavior
workspace_cleanupstays healthy under concurrent API load.controllers.healthysignal that external monitors gate on.Suggested fix directions
initialize_async_engineloop-aware: record the owning loop and either create per-loop engines/session-makers or raise on foreign-loop reuse so the misconfiguration is visible at startup; orasyncio.run_coroutine_threadsafeonto the server loop), which is what the controller comment already intends; andEnvironment
0c4dc810cc519ed2b266152e79b4378f7f07de7f(also reproduced from source at that revision)nemo services runprocess, SQLite entities DB,AsyncAdaptedQueuePool size=5 max_overflow=10