feat: lazy per-bundle loading — import individual extension bundles on demand#1063
feat: lazy per-bundle loading — import individual extension bundles on demand#1063
Conversation
…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]>
- 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]>
…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]>
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Adversarial Review
Critical / High
No critical or high severity issues found.
Medium
-
src/domain/models/model.ts:779-781— Successful type load promise cached forever, but lazy entry deleted twice.
Afterloader(key)succeeds, the.then()deletesthis.lazyTypes.delete(key). ButpromoteFromLazy()(called insideloader(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 intypeLoadPromisesforever 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. -
src/domain/models/user_model_loader.ts:680-706—populateCatalogFromDirclassifies files with bothexport const modelandexport const extensionas "extension".
If a source file contains bothmodelMatchandextensionMatch(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,registerLazyFromCatalogonly registers "model" kind entries, so this type would never appear intypes(). Self-heals on mtime change but is wrong for the initial bootstrap. -
src/domain/models/user_model_loader.ts:692-694— Type extraction regextype\s*:\s*["']([^"']+)["']matches the first occurrence oftype:in the file.
If the file has atype:field in a Zod schema, a JSDoc comment, or an import before the model/extension declaration'stype: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. -
src/cli/mod.ts:95-97—ExtensionCatalogStoreis neverclose()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. -
src/domain/models/user_model_loader.ts:486-489— Full import fallback doesn't passskipAlreadyRegistered: true.
InbuildIndex, when the catalog is not populated,this.loadModels(modelsDir, { additionalDirs: ... })is called withoutskipAlreadyRegistered. 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 inresult.failed. The old code path inloadUserModelspassedskipAlreadyRegistered: true. This only affects the first-run bootstrap path (catalog not yet populated) and only when type name collisions exist.
Low
-
src/domain/models/user_model_loader.ts:600-607—importBundleByPathwrites 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., duringrebundleAndUpdateCatalog),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. -
src/infrastructure/persistence/extension_catalog_store.ts:148-154—removeBySourcePrefixusesLIKE ?withsourcePrefix%, which is susceptible to LIKE wildcards in the prefix.
IfsourcePrefixcontains%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.
There was a problem hiding this comment.
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
-
Missing unit tests for
user_model_loader.tsnew methods —buildIndex(),loadSingleType(),findStaleFiles(),rebundleAndUpdateCatalog(), andpopulateCatalogFromRegistry()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. -
create.tsgetModelDefdoesn't callensureTypeLoaded()—src/libswamp/models/create.ts:96wiresgetModelDef: (type) => modelRegistry.get(type)without lazy load support. This works today becauseresolveModelType(called first at line 129) now triggersensureTypeLoaded(). But if someone refactorscreateto skipresolveModelType, the lazy type would silently returnundefined. A comment noting this dependency, or makinggetModelDefasync like inget.ts, would make the contract more robust. -
Regex-based type extraction in
populateCatalogFromDir()— Thetype\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. -
typeLoadPromisesmap never prunes successful entries — After a type loads successfully, its resolved promise stays intypeLoadPromisesforever. This is bounded by the number of types (so not a real leak), but the map could be pruned in the.then()handler alongside thelazyTypes.delete(key)for cleanliness.
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]>
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/s3installed (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:ensureLoaded()callsbuildIndex()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.types()returns both loaded and lazy entries —model type searchworks with zero bundle imports.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 (formodelRegistry.extend()support).Performance Results
Benchmarked with
@swamp/aws/ec2+@swamp/aws/s3(116 bundles):swamp model type search vpcswamp model get my-serverThe 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 ofCatalogStore(data queries) — separate DB file, separate class, no shared state. Schema includes akindcolumn (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.ts—ModelRegistrygains lazy entry support:LazyModelEntrytype — metadata for an unloaded type (type name, bundle path, version)registerLazy()— registers a type as known-but-not-importedensureTypeLoaded(type)— imports a single bundle on demand with per-type promise memoizationpromoteFromLazy()— converts a lazy entry to a fully loaded definitionisLazy()— checks if a type needs loadinghas()— now returns true for both loaded and lazy typestypes()— now returns both loaded and lazy type namessetTypeLoader()— configures the per-type loader callbacksrc/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.src/cli/mod.ts—loadUserModels()now creates anExtensionCatalogStore, sets the type loader on the registry, and callsbuildIndex()instead ofloadModels().src/domain/extensions/extension_auto_resolver.ts—resolveModelType()now callsensureTypeLoaded()before checking the registry, so lazy types are loaded on demand before falling back to auto-resolution.src/libswamp/models/get.ts—getModelDefinModelGetDepsnow supports async return (for lazy loading).createModelGetDepscallsensureTypeLoaded()beforeget().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 viamodelRegistry.extend(), those methods would silently be absent. Mitigation: The catalog tracksextends_typefor extension bundles.loadSingleType()queriesfindExtensionsForType(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
populatedflag 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.dbtriggers 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
ExtensionCatalogStoremust not couple with or affect the data queryCatalogStore. Mitigation: Completely separate database file (.swamp/_extension_catalog.dbvs.swamp/data/_catalog.db), separate class, no shared code paths, no shared imports.Forward-Facing Work
ExtensionCatalogStore. The schema already supports all registry types via thekindcolumn. This is incremental wiring work — the catalog and patterns are in place.extension pull/rm/updatemutation points (currently handled implicitly by the mtime scan, but direct catalog updates would be marginally faster).Test Plan
deno check,deno lint,deno fmtall cleansysteminit/swamp-uatverified against the compiled binary:model/type/search_test.ts— 2/2 passextension/broken_extensions_test.ts— 5/5 passe2e/extension_model_test.ts— 3/3 passe2e/model_lifecycle_test.ts— 3/3 passmodel/type/describe_test.ts— 3/3 passextension/pull_test.ts— 4/4 passmodel/search_test.ts— 3/3 passe2e/error_paths_test.ts— 9/9 passe2e/global_flags_test.ts— 7/7 passadversarial/state_corruption_test.ts— 6/6 passadversarial/concurrency_test.ts— 5/6 pass (1 pre-existing flaky failure, reproduces on current release too)🤖 Generated with Claude Code