Skip to content

feat(server): add AppContext.ensure_initialized plumbing (#399 phase 1)#400

Merged
memtomem merged 2 commits intomainfrom
feat/lazy-db-init-plumbing
Apr 23, 2026
Merged

feat(server): add AppContext.ensure_initialized plumbing (#399 phase 1)#400
memtomem merged 2 commits intomainfrom
feat/lazy-db-init-plumbing

Conversation

@memtomem
Copy link
Copy Markdown
Owner

Phase 1 of #399AppContext plumbing for the upcoming lazy DB-init refactor.

Summary

  • Move storage / embedder / index_engine / search_pipeline / embedding_broken / llm_provider from direct AppContext fields to read-only properties backed by an internal _components slot.
  • Add async ensure_initialized() with _init_lock for concurrent-first-call serialization.
  • Add AppContext.from_components(...) classmethod for CLI / test bootstraps that already have a Components (e.g. mm watchdog, degraded-mode test fixture).
  • Add async close() for symmetric teardown.
  • Drop the watcher field from AppContext — handler usage was zero; the lifespan keeps a local var.
  • dedup_scanner is now derived from Components.storage + .embedder inside ensure_initialized / from_components rather than passed in by the lifespan.

app_lifespan still calls await ctx.ensure_initialized() eagerly, so behavior is unchanged. Phase 2 (handler migration) and Phase 3 (drop the eager call → DB stays absent until first tool call) are stacked PRs.

Why split it

#399 traces the ~/.memtomem/memtomem.db side-effect on bare MCP handshake to app_lifespan calling create_components before yielding. The fix needs three things to land safely:

  1. A way for handlers to demand-init storage without races (this PR).
  2. Every handler updated to do that demand-init (Phase 2 — ~33 files).
  3. The lifespan dropping its eager init so the new path is actually taken (Phase 3).

Splitting it keeps reviewers focused on the lock semantics + property migration here, rather than reading 30+ mechanical handler diffs at the same time.

Test plan

  • New test_server_app_context.py (9 tests): concurrent-first-call → single factory invocation, idempotent re-call, failure releases lock for retry, from_components short-circuits the factory, pre-init storage access raises, pre-init embedding_broken / llm_provider / dedup_scanner / health_watchdog return None (mirrors the old field defaults).
  • test_server_degraded_mode.py (4 tests): fixture switched to AppContext.from_components.
  • Adjacent suites (test_health_watchdog, test_trust_ux, test_tools_logic, test_server_helpers) — 209 tests pass unchanged; they go through mock AppContext surrogates.
  • Full suite: uv run pytest packages/memtomem/tests -m "not ollama" → 2144 passed.
  • uv run ruff check packages/memtomem/src + uv run ruff format --check clean.

Related

  • Issue Lazy DB init: defer ~/.memtomem/memtomem.db creation until first tool call #399 — full design and acceptance criteria (this PR satisfies the "Phase 1 — Plumbing" item in the staging section).
  • Phase 2 PR (upcoming): handler migration to _get_app_initialized / inline await app.ensure_initialized().
  • Phase 3 PR (upcoming): drop eager ensure_initialized from app_lifespan + move watcher / scheduler / watchdog start into ensure_initialized. That's where the user-visible behavior change (DB stays absent on bare handshake) actually lands.

🤖 Generated with Claude Code

pandas-studio and others added 2 commits April 23, 2026 10:34
Plumbing-only refactor for the lazy DB-init work in #399. Behavior
unchanged: app_lifespan still calls ensure_initialized eagerly, so the
DB is created at the same point in startup as before.

- Hide storage/embedder/index_engine/search_pipeline/embedding_broken/
  llm_provider behind read-only properties backed by AppContext._components
- Add async ensure_initialized() with _init_lock so the future
  per-tool-call init path won't race
- Add AppContext.from_components() classmethod for bootstraps that
  already have a Components (mm watchdog CLI, degraded-mode test fixture)
- Add async close() for symmetric teardown
- Drop the watcher field from AppContext (handler usage was zero;
  lifespan keeps a local var)
- dedup_scanner is now derived from Components.storage + .embedder
  inside ensure_initialized / from_components rather than constructed
  by the lifespan

Phase 2 will migrate the ~33 handler files to call ensure_initialized;
Phase 3 will drop the eager call so the DB stays absent until first
tool call.

Co-Authored-By: Claude <[email protected]>
Three MAJORs + two MINORs from the review on PR #400:

MAJOR: ensure_initialized now tears down create_components's output if a
post-factory step raises. Previously a DedupScanner(storage=..., ...)
failure would leave the already-opened sqlite handle + embedder session
orphaned — no path ever called close_components for them. Explicit
try/except around the post-factory chain closes the leak.

MAJOR: property access before ensure_initialized now raises RuntimeError
via a _require_initialized helper instead of `assert ... is not None`.
asserts are stripped under `python -O` / PYTHONOPTIMIZE=1, at which
point pre-init access would hit a synthesized AttributeError with no
hint about the real cause (ensure_initialized not called). RuntimeError
survives the optimizer and carries an actionable message.

MAJOR: from_components vs ensure_initialized now track ownership via a
private _owns_components flag. close() only calls close_components when
_owns_components is True — i.e. only on the path that actually built
the Components. from_components callers (cli_components context manager,
test fixtures) retain ownership; close() used to double-close their
handles. The flag also resets on close() so post-close property access
still fails via _require_initialized.

MINOR: health_watchdog writer side now goes through a typed
set_health_watchdog method instead of lifespan poking ctx._health_watchdog
directly. Read side unchanged (the health_watchdog property).

MINOR: drop the `**kwargs: Any` escape hatch from `from_components` —
there were no real callers using it; collapsing to the bare components
argument tightens the public surface.

Tests (13 total, +4 new + 1 modernized):

- test_ensure_initialized_closes_components_if_post_factory_step_raises:
  locks in the orphan-cleanup fix via patched close_components + a
  DedupScanner that blows up.
- test_close_after_from_components_does_not_touch_caller_owned:
  verifies close_components is NOT invoked on the from_components path,
  and that the context still drops its component view so stale access
  fails loudly.
- test_close_after_ensure_initialized_closes_components: inverse — the
  path that DID own Components does close them.
- test_set_health_watchdog_exposes_via_property: locks in the
  lifespan-writer / property-reader seam.
- test_storage_access_before_init_raises: switched AssertionError →
  RuntimeError expectation.

Co-Authored-By: Claude <[email protected]>
@memtomem memtomem merged commit 7855042 into main Apr 23, 2026
7 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 23, 2026
@memtomem memtomem deleted the feat/lazy-db-init-plumbing branch April 27, 2026 14:56
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants