Skip to content

feat: lazy per-bundle loading — import individual extension bundles on demand#1063

Merged
stack72 merged 3 commits intomainfrom
feat/lazy-per-bundle-loading
Apr 2, 2026
Merged

feat: lazy per-bundle loading — import individual extension bundles on demand#1063
stack72 merged 3 commits intomainfrom
feat/lazy-per-bundle-loading

Conversation

@stack72
Copy link
Copy Markdown
Contributor

@stack72 stack72 commented Apr 2, 2026

Fixes: #1053

Summary

This PR adds lazy per-bundle loading to the model registry so individual extension bundles are imported on demand rather than all at once. This is Phase 2 of the lazy loading optimization started in #1050.

The Problem

After #1050 (lazy per-registry loading), commands that need model extensions still load all model bundles at once via modelRegistry.ensureLoaded(). With @swamp/aws/ec2 + @swamp/aws/s3 installed (116 bundles), every model command takes 3-6 seconds because it imports all 116 bundles even when only 1 type is needed. This scales linearly — every new extension installed makes every command slower.

The Solution

An ExtensionCatalogStore (SQLite database at .swamp/_extension_catalog.db) indexes all known extension types without importing any JavaScript. The loading flow becomes:

  1. On startup, ensureLoaded() calls buildIndex() which reads the catalog and does a lightweight mtime scan (directory walk + stat calls, no imports). Types are registered as lazy entries — the registry knows they exist but hasn't imported their bundles.
  2. types() returns both loaded and lazy entries — model type search works with zero bundle imports.
  3. When a specific type is needed (e.g. model get, model create), ensureTypeLoaded(type) queries the catalog for the bundle path, imports just that one bundle, and also imports any extension bundles that target the base type (for modelRegistry.extend() support).
  4. First run after install (catalog doesn't exist): falls back to the existing full-import path, then populates the catalog. One-time cost, same speed as before.

Performance Results

Benchmarked with @swamp/aws/ec2 + @swamp/aws/s3 (116 bundles):

Command Before After Speedup
swamp model type search vpc ~4s ~0.7s 5.7x faster
swamp model get my-server ~3.2s ~0.7s 4.6x faster
First run (builds catalog) ~6.3s One-time cost

The key insight: these numbers stay constant regardless of how many extensions are installed. Before this change, installing more extensions made every command proportionally slower.

What Changed

New Files

  • src/infrastructure/persistence/extension_catalog_store.ts — SQLite-backed catalog for extension type metadata. Completely independent of CatalogStore (data queries) — separate DB file, separate class, no shared state. Schema includes a kind column (model, extension, vault, driver, datastore, report) so the same store supports all registry types.
  • src/infrastructure/persistence/extension_catalog_store_test.ts — 14 tests including concurrent-open test (busy_timeout before WAL pragma, per fix: set SQLite busy_timeout before WAL pragma to prevent concurrent locking errors #1057).

Modified Files

  • src/domain/models/model.tsModelRegistry gains lazy entry support:

    • LazyModelEntry type — metadata for an unloaded type (type name, bundle path, version)
    • registerLazy() — registers a type as known-but-not-imported
    • ensureTypeLoaded(type) — imports a single bundle on demand with per-type promise memoization
    • promoteFromLazy() — converts a lazy entry to a fully loaded definition
    • isLazy() — checks if a type needs loading
    • has() — now returns true for both loaded and lazy types
    • types() — now returns both loaded and lazy type names
    • setTypeLoader() — configures the per-type loader callback
  • src/domain/models/model_test.ts — 11 new tests covering lazy entry registration, promotion, type enumeration, ensureTypeLoaded with concurrent callers, and no-op behavior for already-loaded/unknown types.

  • src/domain/models/user_model_loader.ts — Two new loading modes:

    • buildIndex() — discovers files, checks mtimes against catalog, rebundles only changed files, registers lazy entries from catalog. Falls back to full import when catalog isn't populated.
    • loadSingleType() — imports one bundle + its extensions from catalog paths.
    • Supporting methods for mtime-based staleness detection, catalog population from registry, and direct bundle-by-path import.
  • src/cli/mod.tsloadUserModels() now creates an ExtensionCatalogStore, sets the type loader on the registry, and calls buildIndex() instead of loadModels().

  • src/domain/extensions/extension_auto_resolver.tsresolveModelType() now calls ensureTypeLoaded() before checking the registry, so lazy types are loaded on demand before falling back to auto-resolution.

  • src/libswamp/models/get.tsgetModelDef in ModelGetDeps now supports async return (for lazy loading). createModelGetDeps calls ensureTypeLoaded() before get().

  • design/extension.md — New "Lazy Per-Bundle Loading" section documenting the architecture, self-healing behavior, and roadmap.

Risks Discussed During Planning

Extension methods going missing

If ensureTypeLoaded("base/type") loads the base bundle but misses an extension bundle that adds methods via modelRegistry.extend(), those methods would silently be absent. Mitigation: The catalog tracks extends_type for extension bundles. loadSingleType() queries findExtensionsForType(baseType) and imports all matching extensions after the base type.

Stale catalog data

If a user edits extension source files, the catalog could serve stale metadata. Mitigation: buildIndex() always runs a directory scan + mtime comparison on every command. Changed files are detected, rebundled, and the catalog is updated. Deleted files are removed from the catalog. No manual intervention needed.

First-run migration

Users upgrading have cached bundles but no catalog. Mitigation: When the catalog's populated flag is false, buildIndex() falls back to the existing full-import path (same speed as before), then populates the catalog from the loaded registry. Self-healing: deleting _extension_catalog.db triggers the same rebuild.

Concurrent access

Multiple swamp processes hitting the same catalog. Mitigation: SQLite WAL mode + busy_timeout=5000 (set before WAL pragma, per #1057 fix). Concurrent-open test verifies two handles can write simultaneously without "database is locked" errors.

Independence from data catalog

The ExtensionCatalogStore must not couple with or affect the data query CatalogStore. Mitigation: Completely separate database file (.swamp/_extension_catalog.db vs .swamp/data/_catalog.db), separate class, no shared code paths, no shared imports.

Forward-Facing Work

  • Lazy per-bundle loading for vault, driver, datastore, and report registries #1062 — Wire the remaining registries (vault, driver, datastore, report) through the same ExtensionCatalogStore. The schema already supports all registry types via the kind column. This is incremental wiring work — the catalog and patterns are in place.
  • Explicit write-through at extension pull/rm/update mutation points (currently handled implicitly by the mtime scan, but direct catalog updates would be marginally faster).

Test Plan

  • 4038 unit tests pass (including 11 new ModelRegistry lazy entry tests and 14 new ExtensionCatalogStore tests)
  • deno check, deno lint, deno fmt all clean
  • UAT tests from systeminit/swamp-uat verified against the compiled binary:
    • model/type/search_test.ts — 2/2 pass
    • extension/broken_extensions_test.ts — 5/5 pass
    • e2e/extension_model_test.ts — 3/3 pass
    • e2e/model_lifecycle_test.ts — 3/3 pass
    • model/type/describe_test.ts — 3/3 pass
    • extension/pull_test.ts — 4/4 pass
    • model/search_test.ts — 3/3 pass
    • e2e/error_paths_test.ts — 9/9 pass
    • e2e/global_flags_test.ts — 7/7 pass
    • adversarial/state_corruption_test.ts — 6/6 pass
    • adversarial/concurrency_test.ts — 5/6 pass (1 pre-existing flaky failure, reproduces on current release too)
  • Performance benchmarked with 116 bundles (@swamp/aws/ec2 + @swamp/aws/s3), results consistent across multiple runs

🤖 Generated with Claude Code

…n demand

Add an ExtensionCatalogStore (SQLite at .swamp/_extension_catalog.db) that
indexes all known extension types. Commands read the catalog instead of
importing every bundle, then load individual bundles on demand when a
specific type is needed.

Closes #1053

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
github-actions[bot]

This comment was marked as outdated.

github-actions[bot]

This comment was marked as outdated.

github-actions[bot]

This comment was marked as outdated.

- Fix silent error swallowing in importAndExtendBundle: log warnings
  for extension processing failures instead of discarding LoadResult
- Fix permanently cached rejected promises in ensureTypeLoaded: clear
  the promise cache on failure so subsequent calls retry instead of
  permanently failing (e.g. transient I/O errors)
- Add retry-after-failure test for ensureTypeLoaded
- Add clarifying comments on regex-based type extraction (best-effort
  bootstrap only, corrected on subsequent mtime scan)
- Add comment on registerLazyFromCatalog explaining why only "model"
  kind entries are registered (extensions augment base types, not
  standalone)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
github-actions[bot]

This comment was marked as outdated.

github-actions[bot]

This comment was marked as outdated.

…per type

The previous PK (type_normalized, kind) caused silent data loss when
multiple extension files targeted the same base type — the second
extension would overwrite the first via INSERT OR REPLACE.

Changed PK to source_path since each source file is unique. Added
index on (type_normalized, kind) for query performance. Added test
verifying two extensions targeting the same base type both survive.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLI UX Review

Blocking

None.

Suggestions

None.

Verdict

PASS — This PR is a transparent internal optimization (lazy per-bundle loading). No CLI flags, help text, error messages, or output formats were changed. The performance improvement (5–6x faster for commands like model type search and model get) is user-visible in a positive way, but requires no UX review. The first run one-time cost is handled silently with no user-facing prompts or messages added.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial Review

Critical / High

No critical or high severity issues found.

Medium

  1. src/domain/models/model.ts:779-781 — Successful type load promise cached forever, but lazy entry deleted twice.
    After loader(key) succeeds, the .then() deletes this.lazyTypes.delete(key). But promoteFromLazy() (called inside loader(key)) also deletes the lazy entry at line 797. This is a no-op double-delete (Map.delete on missing key is safe), so no bug, but the .then() callback is dead code for the success path. More importantly, the promise remains in typeLoadPromises forever after success — it's only cleaned up on rejection. This means the map grows monotonically for the lifetime of the process. With 116 bundles this is ~116 entries of resolved promises, which is negligible, but it's a latent leak pattern.

  2. src/domain/models/user_model_loader.ts:680-706populateCatalogFromDir classifies files with both export const model and export const extension as "extension".
    If a source file contains both modelMatch and extensionMatch (rare but valid — a file that exports both), extensionMatch ? "extension" : "model" at line 706 picks "extension", which means the model definition is lost from the catalog. On subsequent runs, registerLazyFromCatalog only registers "model" kind entries, so this type would never appear in types(). Self-heals on mtime change but is wrong for the initial bootstrap.

  3. src/domain/models/user_model_loader.ts:692-694 — Type extraction regex type\s*:\s*["']([^"']+)["'] matches the first occurrence of type: in the file.
    If the file has a type: field in a Zod schema, a JSDoc comment, or an import before the model/extension declaration's type: field, the regex captures the wrong string. The comment at line 687-691 acknowledges this, and the self-healing on mtime change mitigates it, so this is a documentation-acknowledged trade-off rather than a hidden bug.

  4. src/cli/mod.ts:95-97ExtensionCatalogStore is never close()d.
    The comment says "The catalog stays open for the process lifetime so the type loader can query it when ensureTypeLoaded() is called later." This is intentional per the comment, and SQLite WAL handles this gracefully on process exit. However, on abnormal exit (SIGKILL, OOM), the WAL file and shared-memory file may persist on disk. This is harmless (SQLite recovers on next open) but worth noting.

  5. src/domain/models/user_model_loader.ts:486-489 — Full import fallback doesn't pass skipAlreadyRegistered: true.
    In buildIndex, when the catalog is not populated, this.loadModels(modelsDir, { additionalDirs: ... }) is called without skipAlreadyRegistered. This means if a built-in type and a user extension type have the same name, the user extension will fail with "already registered" and appear in result.failed. The old code path in loadUserModels passed skipAlreadyRegistered: true. This only affects the first-run bootstrap path (catalog not yet populated) and only when type name collisions exist.

Low

  1. src/domain/models/user_model_loader.ts:600-607importBundleByPath writes back to the bundle file then imports it, but Deno's module cache may serve a stale version.
    If the bundle was previously imported in the same process (e.g., during rebundleAndUpdateCatalog), import(url) may return the cached module even after the file on disk changed. This only matters if the same bundle is imported twice in one process with different content, which shouldn't happen in normal operation.

  2. src/infrastructure/persistence/extension_catalog_store.ts:148-154removeBySourcePrefix uses LIKE ? with sourcePrefix%, which is susceptible to LIKE wildcards in the prefix.
    If sourcePrefix contains % or _ characters (unlikely in filesystem paths but theoretically possible), they would be interpreted as wildcards. The input comes from internal code (not user input), so this is theoretical.

Verdict

PASS — The code is well-structured with solid concurrency handling (promise memoization, retry on failure, WAL + busy_timeout). The self-healing catalog design is robust. The medium findings are edge cases in uncommon paths (first-run bootstrap, files with dual exports) that don't affect the happy path. The skipAlreadyRegistered omission in the fallback path (medium #5) is the most actionable item — it's a behavioral regression from the old code, but only on the first-run bootstrap when type collisions exist.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Well-designed lazy loading implementation. The architecture is clean: ExtensionCatalogStore lives in infrastructure (correct DDD placement), LazyModelEntry is a value object in the domain, and the ModelRegistry gains lazy entry semantics without breaking existing contracts. The per-type promise memoization with retry-on-failure in ensureTypeLoaded() is a nice touch.

No blocking issues found.

Suggestions

  1. Missing unit tests for user_model_loader.ts new methodsbuildIndex(), loadSingleType(), findStaleFiles(), rebundleAndUpdateCatalog(), and populateCatalogFromRegistry() add 489 lines of complex logic with no direct unit test coverage. The existing loader test file has 44 tests but none cover the new paths. UAT/integration tests provide end-to-end coverage, but unit tests for the mtime staleness detection and catalog population logic would catch regressions earlier. Consider adding tests in a follow-up.

  2. create.ts getModelDef doesn't call ensureTypeLoaded()src/libswamp/models/create.ts:96 wires getModelDef: (type) => modelRegistry.get(type) without lazy load support. This works today because resolveModelType (called first at line 129) now triggers ensureTypeLoaded(). But if someone refactors create to skip resolveModelType, the lazy type would silently return undefined. A comment noting this dependency, or making getModelDef async like in get.ts, would make the contract more robust.

  3. Regex-based type extraction in populateCatalogFromDir() — The type\s*:\s*["']([^"']+)["'] regex (line ~778 in diff) can match inside comments or string literals. The self-healing documentation is good, but a comment in-code noting the regex limitations and that subsequent runs correct via proper bundle import would help future readers.

  4. typeLoadPromises map never prunes successful entries — After a type loads successfully, its resolved promise stays in typeLoadPromises forever. This is bounded by the number of types (so not a real leak), but the map could be pruned in the .then() handler alongside the lazyTypes.delete(key) for cleanliness.

@stack72 stack72 merged commit 686365a into main Apr 2, 2026
10 checks passed
@stack72 stack72 deleted the feat/lazy-per-bundle-loading branch April 2, 2026 19:25
keeb added a commit that referenced this pull request Apr 11, 2026
validateModelPathReference called modelRegistry.get() without first
awaiting ensureTypeLoaded(), so cross-model CEL expressions referencing
lazy-registered types failed with a misleading "Unknown model type"
error even though the type was registered and worked at execution time.

Missed call site from PR #1063 (lazy per-bundle loading) — the execution
path was wired up but the validation path was not. ensureTypeLoaded is a
no-op on already-loaded types, so the perf win from lazy loading is
preserved.

Closes swamp-club #89.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Lazy per-bundle loading — import individual extension bundles on demand

1 participant