fix: lazy extension loading — only load registries when accessed#1050
fix: lazy extension loading — only load registries when accessed#1050
Conversation
Replace eager all-or-nothing extension loading with lazy per-registry loading. Each registry (models, vaults, drivers, datastores, reports) now has setLoader()/ensureLoaded() methods. runCli() configures loaders at startup but does not invoke them. Extensions load on first access when a command's dep factory calls registry.ensureLoaded(). Commands that don't access registries (help, search, list, delete, evaluate, data operations) load nothing and complete in <1s instead of 4-8s. Commands that need specific registries only load those types. Closes #1044 Closes #1046 Co-authored-by: Zac Stevens <[email protected]>
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
None.
Verdict
PASS — This PR is a pure internal performance refactoring with no user-facing UX changes. Help text, flag names, error messages, log-mode output, and JSON-mode output are all unchanged. The only observable effect for users is that commands that don't need extensions (e.g. swamp model help, swamp vault search) are now ~6-7x faster. The rename of the internal commandNeedsExtensions → commandNeedsLoaderSetup correctly reflects the new semantics and is not user-visible.
There was a problem hiding this comment.
Code Review
Well-executed performance optimization. The lazy loading pattern is clean — configuring loaders at startup and deferring actual loading to ensureLoaded() in dep factories is the right approach. The benchmark results (7x speedup for commands that don't need extensions) speak for themselves.
DDD Assessment
The setLoader() / ensureLoaded() addition to domain registries is a reasonable trade-off. The registries don't depend on infrastructure — they accept a loader function from the CLI layer (dependency inversion), keeping the domain pure. The lazy-loading concern is expressed as a capability of the registry, not as knowledge of how loading works.
Blocking Issues
None.
Suggestions
-
Duplicated
ensureLoaded/setLoaderboilerplate — The identical 15-line pattern is copy-pasted across all 5 registries (ModelRegistry,VaultTypeRegistry,DriverTypeRegistry,DatastoreTypeRegistry,ReportRegistry). Consider extracting to a sharedLazyLoadablemixin or utility class in a follow-up. -
Missing unit tests for
setLoader/ensureLoaded— This is new core functionality with interesting edge cases (concurrent callers, loader rejection, no-op when no loader set). At least one registry should have tests covering: (a) basic load-on-first-call, (b) concurrentensureLoaded()deduplication, (c) no-op when no loader configured. The existing 3940 tests validate this indirectly through integration, but targeted unit tests would prevent regressions. -
report_describe.tscould usecreateReportDescribeDeps— The PR addscreateReportDescribeDeps()to libswamp and exports it frommod.ts, butreport_describe.tsstill manually constructs deps and callsensureLoaded()inline. Using the dep factory would be more consistent with the pattern used by other commands (e.g.,model_get.tsusescreateModelGetDeps). -
Rejected loader promise is permanently cached — If the loader throws (e.g., transient I/O error),
extensionLoadPromisecaches the rejection and all subsequentensureLoaded()calls re-throw. For a CLI tool that exits on error this is fine, but theservemodule keeps running — a transient failure on the first WebSocket connection would poison the registry for all subsequent connections. Worth documenting or adding a reset-on-failure path in a follow-up.
There was a problem hiding this comment.
Adversarial Review
Critical / High
-
src/cli/mod.ts:593-718— Deferred extension-loading warnings are silently droppedThe
deferredWarningsarray is created at line 593 and the lazy-loader closures (lines 596-610) capture it by reference. However, the warning-emission loop runs insideglobalActionat line 718, which executes before any command.action()handler. SinceensureLoaded()is only called from.action()handlers (or dep factories invoked by them), the loaders haven't run yet when warnings are emitted — so the array is empty (or only containscheckForMissingPulledExtensionswarnings).Breaking scenario: A user has a corrupt or version-mismatched extension installed. Previously, they saw a warning like
Failed to load user model @swamp/aws/ec2: .... Now, the loader runs inside the command action, pushes the warning intodeferredWarnings, but the emission loop at line 718 already finished. The warning is silently swallowed, and the user gets a confusing downstream error (e.g., "unknown model type") with no hint about the real cause.Suggested fix: Either (a) emit warnings after
ensureLoaded()calls (e.g., in dep factories or a post-load hook), or (b) haveensureLoaded()log warnings directly via the logger (since logging IS initialized by the time.action()runs), bypassing the deferred-warning mechanism entirely. -
src/cli/repo_context.ts:78— Custom extension datastore types not found duringrequireInitializedReporesolveCustomProvidercallsdatastoreTypeRegistry.get(config.type)without anyensureLoaded()call. This function is invoked fromrequireInitializedRepo(line 275), which runs in ~59 command handlers before anyensureLoaded()call in the command body.Before this PR, all extensions were eagerly loaded, so custom datastore types were already registered. After this PR, lazy loading means the type won't be found.
Breaking scenario: User has
@swamp/aws/s3(or another extension datastore) configured in.swamp.yaml. They runswamp model search.requireInitializedRepocallsresolveCustomProvider, which callsdatastoreTypeRegistry.get("@swamp/aws/s3")→ returnsundefined→ throwsUserError: Datastore type "@swamp/aws/s3" is not registered or has no provider.Note: This may be partially mitigated by the auto-resolver in
resolveDatastoreConfig(line 251-253) for@-prefixed types, butresolveCustomProviderat line 78 is called separately (e.g., at lines 461, 566, 763) and does not go through auto-resolution. If any of these paths are hit beforeensureLoaded(), it breaks.Suggested fix: Add
await datastoreTypeRegistry.ensureLoaded()inrequireInitializedRepobefore theresolveCustomProvidercall, or add it directly inresolveCustomProvider.
Medium
-
src/cli/completion_types.ts:134— Shell completions for model types won't include extension typesModelTypeType.complete()callsmodelRegistry.types()withoutensureLoaded(). Shell completion is async-capable via Cliffy, but extensions won't be loaded. This meansswamp model create <TAB>won't suggest extension model types. Lower severity since it's a completions UX issue, not a correctness issue. -
All 5 registries
ensureLoaded()— Rejected loader promise is permanently cachedIf the loader throws (e.g., network error during extension loading),
extensionLoadPromiseis set to the rejected promise butextensionsLoadedstays false. Every subsequentensureLoaded()call re-awaits the same rejected promise. For a CLI (single invocation per process), this is fine. But inservemode, where connections create deps per-WebSocket (line 964-973 in connection.ts), a transient failure on the first connection permanently poisons the registry for all future connections until the server restarts.Suggested fix: On rejection, clear
extensionLoadPromiseso the next call retries:this.extensionLoadPromise = loader().then(() => { this.extensionsLoaded = true; }).catch((err) => { this.extensionLoadPromise = null; throw err; });
Low
- Duplicated
ensureLoaded()pattern across 5 registries — ThesetLoader/ensureLoadedimplementation is copy-pasted identically inModelRegistry,VaultTypeRegistry,DriverTypeRegistry,DatastoreTypeRegistry, andReportRegistry. Not a correctness issue, but a maintenance concern — a fix to the caching logic (e.g., the rejection fix above) would need to be applied in 5 places.
Verdict
FAIL — The deferred-warnings bug (#1) means extension loading failures are silently swallowed, which will cause confusing downstream errors. The requireInitializedRepo regression (#2) breaks custom extension datastores. Both are fixable without changing the overall lazy-loading architecture.
Two issues found by adversarial review: 1. Deferred extension-loading warnings were silently dropped: the deferredWarnings emission loop in globalAction ran before any ensureLoaded() call, so loader warnings were never emitted. Fix: loaders now log warnings directly via the logger (logging IS initialized by the time ensureLoaded() runs inside .action() handlers). 2. Custom extension datastore types not found during requireInitializedRepo: resolveCustomProvider called datastoreTypeRegistry.get() without ensureLoaded(), so extension datastore types (e.g., @swamp/aws/s3) would fail with "not registered" errors. Fix: resolveCustomProvider is now async and calls datastoreTypeRegistry.ensureLoaded() before lookup. Cascading async changes to createModelLock and createDatastoreLock.
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
None.
Verdict
PASS — This PR is a pure internal performance optimization. No user-facing interface changes: no new flags, no changed help text, no modified output formats, no altered error messages. The only UX change is a significant speedup (6-7x faster for commands that don't need extensions, e.g. swamp model help drops from 4.8s to 0.65s). Warning message format for failed extension loads is preserved — same text, now emitted directly via logger.warn inside command action handlers rather than through the deferred system, which is correct since logging is initialized by the time those run.
There was a problem hiding this comment.
Code Review
Well-structured PR that cleanly replaces eager all-or-nothing extension loading with lazy per-registry loading. The setLoader()/ensureLoaded() pattern is sound — configuring loaders at startup and deferring actual loading to command action handlers is a good architectural choice. The concurrent-caller caching via the shared promise is correct, and the loaders' internal try/catch blocks prevent rejected-promise caching issues in practice.
DDD alignment is good: registries remain domain-layer singletons, the lazy loading infrastructure is configured by the CLI orchestration layer, and application services (dep factories in libswamp) trigger loading as needed. The libswamp import boundary is respected — CLI commands import from src/libswamp/mod.ts only.
Blocking Issues
None.
Suggestions
-
Duplicate lazy-loading boilerplate across 5 registries: The
setLoader/ensureLoaded/extensionLoader/extensionLoadPromise/extensionsLoadedpattern is copy-pasted identically acrossModelRegistry,VaultTypeRegistry,DriverTypeRegistry,DatastoreTypeRegistry, andReportRegistry. Consider extracting this into a shared mixin or base class to reduce duplication and ensure consistent behavior if the pattern needs to evolve. Not blocking since the current code is correct. -
No unit tests for
setLoader/ensureLoaded: The new lazy-loading mechanism has subtle concurrency semantics (promise caching, no-op when no loader is set, idempotency). While the existing 3940 tests provide integration coverage, dedicated unit tests for the registry lazy-loading behavior would be valuable — especially for edge cases like concurrent callers and the "no loader configured" path. -
Unused
createReportSearchDepsandcreateReportDescribeDepsfactories: These are defined insrc/libswamp/reports/search.tsandsrc/libswamp/reports/describe.ts, exported fromsrc/libswamp/mod.ts, but not imported by any consumer. The CLI commands (report_search.ts,report_describe.ts) build their deps inline instead. If these are intended for the serve layer or future use, a comment noting that would help; otherwise they're dead code. -
Rejected-promise caching in
ensureLoaded(): If a loader's promise rejects, the rejection is permanently cached inextensionLoadPromise— all subsequentensureLoaded()calls will re-throw the same error with no retry path. This is mitigated today because all loaders wrap their bodies in try/catch, but it's a latent risk if a future loader doesn't follow this pattern. Consider resettingextensionLoadPromiseon rejection.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
All registries
ensureLoaded()— rejected promise cached permanently (src/domain/models/model.ts:692-698, and identical pattern in all 5 registries):
Ifloader()rejects,extensionLoadPromisecaches the rejected promise whileextensionsLoadedremainsfalse. Every subsequentensureLoaded()call re-awaits the same rejected promise — the registry is permanently broken for the rest of the process with no retry path.Breaking example: A transient filesystem error (e.g., permission denied on the pulled extensions directory) during first
ensureLoaded()call would cause every subsequent command handler that callsensureLoaded()to throw. In the serve command, this means the first WebSocket connection's transient failure permanently poisons all future connections.Mitigating factor: The
loadUser*functions all have broadtry { ... } catch { }blocks that swallow all errors, so the loader should never reject in practice. But if someone later adds error propagation in a loader, this will silently become a permanent-failure bug.Suggested fix: Clear
extensionLoadPromiseon rejection so the next caller retries:this.extensionLoadPromise = loader().then(() => { this.extensionsLoaded = true; }).catch((err) => { this.extensionLoadPromise = null; throw err; });
Low
-
datastoreTypeRegistrynot in servePromise.allblocks (src/serve/deps.ts:53-58,src/serve/deps.ts:78-83):
createWorkflowRunDepsandcreateModelMethodRunDepseagerly load 4 of 5 registries (model,vault,driver,report) but notdatastoreTypeRegistry. Datastore loading only happens lazily throughresolveCustomProvider()inrepo_context.ts. This works correctly since custom datastore resolution gates itself, but it's an inconsistency — a workflow step that needs a custom datastore type pays the loading cost at lock-acquisition time rather than at deps-creation time, which could produce surprising latency during execution. -
deferredWarningsarray declared but never populated by extension loaders (src/cli/mod.ts:571):
ThedeferredWarningsarray is still declared and passed toemitDeferredWarnings()later, but all 5loadUser*functions now log directly vialogger.warninstead of pushing to this array. The array is only populated bycheckForMissingPulledExtensions. This is harmless dead-ish code but could confuse future readers about the warning flow.
Verdict
PASS — The lazy loading pattern is mechanically correct. All registry accesses are properly guarded by ensureLoaded() calls, the promise caching handles concurrent callers correctly, and the sync-to-async function signature changes are consistently propagated to all callers. The medium finding about cached rejected promises is a defensive concern that doesn't bite in the current codebase due to the broad try/catch in all loaders.
PR #1050 converted type registries to lazy loading but missed ensureLoaded() calls in three commands and tab completion, causing user-defined extensions to not be discovered. - model_method_describe: add modelRegistry.ensureLoaded() before createModelMethodDescribeDeps - type_describe: add modelRegistry.ensureLoaded() before createTypeDescribeDeps - vault_type_search: add vaultTypeRegistry.ensureLoaded() before constructing deps - completion_types: make ModelTypeType.complete() async with ensureLoaded() so extension types appear in tab completion Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…nfig resolution resolveDatastoreConfig() calls resolveDatastoreType() which checks the datastore type registry. After PR #1050's lazy loading change, the registry is empty at this point because ensureLoaded() hasn't been called yet. This causes the auto-resolver to fire unnecessarily, writing progress JSON ({"event":"auto_resolve","status":"searching","type":"@swamp/..."}) to stdout — corrupting --json output for any command that uses an extension-based datastore (S3, GCS). Add ensureLoaded() calls before auto-resolve checks in all three code paths within resolveDatastoreConfig() and parseDatastoreEnvVar(), so the registry is populated from disk before the auto-resolver is consulted. Fixes datastore UAT failures: status_test.ts:14, sync_test.ts:19, sync_test.ts:96 on both S3 and GCS backends.
Summary
Replaces eager all-or-nothing extension loading with lazy per-registry loading.
Previously, every repo-aware command (
swamp model,swamp vault,swamp workflow,etc.) loaded all 5 extension types (models, vaults, drivers, datastores, reports)
at startup — even for help display or commands that only read YAML files.
With
@swamp/aws/ec2installed (~100 model types, 179 bundles, 206MB), this added~5 seconds to every command invocation.
Approach
Each registry (
ModelRegistry,VaultTypeRegistry,DriverTypeRegistry,DatastoreTypeRegistry,ReportRegistry) now hassetLoader()andensureLoaded()methods:
runCli()configures loaders on registries at startup but does not invoke themawait registry.ensureLoaded()beforeaccessing a registry
ensureLoaded()loads on first call, caches the promise for concurrent callers,and is a no-op on subsequent calls
Design trade-offs
No static mapping — we considered a static command-to-extension-type mapping
but rejected it because it creates a second source of truth that drifts from the
code. Instead, the code itself declares what it needs via
ensureLoaded()calls.Removed enumeration guards — we initially added throwing guards on
registry.types()/registry.getAll()to catch missingensureLoaded()calls.However,
modelRegistry.types()is used pervasively in the domain layer forYAML file discovery (
YamlOutputRepository.findAllGlobal(),findDefinitionByIdGlobal()in model_lookup.ts). These paths work correctlywith only built-in types, so throwing was too aggressive. The real protection is
at the dep factory level where
ensureLoaded()is called.Dep factories become async — shared libswamp dep factories like
createModelGetDeps()now returnPromise<T>since they callawait registry.ensureLoaded(). All callers are already in async action handlersso this is a mechanical change. The serve command creates deps per-WebSocket
connection (already lazy) — first connection pays loading cost, subsequent
connections are instant.
Phase 2 follow-up — commands that DO need model extensions (like
model type search,model get,model create) still load all model bundles.Phase 2 (lazy per-bundle loading with a type index) would address this by
importing individual bundles on demand.
Benchmark results
Tested with
@swamp/aws/ec2extension installed (~100 model types):swamp model(help)swamp model searchswamp vault searchswamp model type searchCommands that don't need extensions go from ~4.4s to ~0.65s. Commands that need
extensions have identical performance — they load exactly what they need, when they
need it.
Test Plan
deno check,deno lint,deno fmtall pass@swamp/aws/ec2extensionFixes #1044
Fixes #1046
🤖 Generated with Claude Code