fix(fts): stop warning "FTS extension unavailable" on the run that installs it
On a machine with no cached LadybugDB extension, the first `gitnexus analyze` logs a WARN GitNexus: FTS extension unavailable; continuing without FTS features. load-only policy (no install attempted); LOAD fts failed: ... and then, in the same run, installs FTS and builds every search index. Nothing was degraded — only the log was wrong, and it sent users chasing a broken install path that does not exist (see the first of the two warn lines in #2184, where only the second one is real). The line comes from `initLbug`'s writable FTS pre-load. That call deliberately never installs (analyze owns extension installation), so on a cold cache it is *expected* to miss; Phase 3 retries moments later with the `auto` policy and succeeds. `ExtensionManager.markUnavailable` had no way to tell that speculative probe from a final answer, so it reported every miss as a user- facing degradation. Adds `quiet` to `ExtensionEnsureOptions`: the outcome is still recorded in capabilities, but it is logged at debug level and does not consume the once-per-(extension, reason) warn budget — so a later real failure with the same reason still warns. Set only on the writable `initLbug` pre-load. The read-only serve/MCP branch keeps `{ policy: 'load-only' }` with no `quiet`: there is no later retry there, so that warning is accurate. Analyze Phase 3, `--repair-fts` and genuinely-offline installs (#2184) are untouched and still report loudly. Verified end-to-end against a temp `HOME` with no `~/.lbdb`: analyze emits no FTS warning, installs `libfts.lbug_extension`, and builds all FTS indexes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
89bbdcf566
commit
91953a3cba
@@ -43,6 +43,18 @@ export interface ExtensionCapability {
|
||||
export interface ExtensionEnsureOptions {
|
||||
policy?: ExtensionInstallPolicy;
|
||||
installTimeoutMs?: number;
|
||||
/**
|
||||
* Speculative probe: log an unavailable outcome at debug level instead of
|
||||
* warn, and do not consume the once-per-(extension, reason) warn budget.
|
||||
*
|
||||
* Set only by callers that do NOT own the extension's lifecycle and know a
|
||||
* later owner will retry with an install-capable policy — currently the
|
||||
* writable `initLbug` pre-load, whose miss on a cold cache is expected and
|
||||
* is fixed moments later by analyze Phase 3. Never set it on a path where
|
||||
* the failure is the final answer (serve/MCP read paths), or a real
|
||||
* degradation goes unreported.
|
||||
*/
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
export interface ExtensionManagerOptions {
|
||||
@@ -230,9 +242,10 @@ export class ExtensionManager {
|
||||
const timeoutMs =
|
||||
opts.installTimeoutMs ?? this.options.installTimeoutMs ?? getExtensionInstallTimeoutMs();
|
||||
const warn = this.options.warn ?? ((msg: string) => logger.warn(msg));
|
||||
const quiet = opts.quiet === true;
|
||||
|
||||
if (policy === 'never') {
|
||||
this.markUnavailable(name, label, 'extension install policy is "never"', warn);
|
||||
this.markUnavailable(name, label, 'extension install policy is "never"', warn, quiet);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -248,6 +261,7 @@ export class ExtensionManager {
|
||||
label,
|
||||
`load-only policy (no install attempted); LOAD ${name} failed: ${loadError}`,
|
||||
warn,
|
||||
quiet,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -267,6 +281,7 @@ export class ExtensionManager {
|
||||
label,
|
||||
`${install.message}; LOAD ${name} had failed: ${loadError}`,
|
||||
warn,
|
||||
quiet,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -282,6 +297,7 @@ export class ExtensionManager {
|
||||
label,
|
||||
`LOAD ${name} failed after successful INSTALL: ${retryError}`,
|
||||
warn,
|
||||
quiet,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -316,6 +332,7 @@ export class ExtensionManager {
|
||||
label: string,
|
||||
reason: string,
|
||||
warn: (message: string) => void,
|
||||
quiet = false,
|
||||
): void {
|
||||
// Classify once here (the single load-failure sink, run per Database not per
|
||||
// request) so the hot per-request warning path does no file I/O (#2383 F3).
|
||||
@@ -325,12 +342,17 @@ export class ExtensionManager {
|
||||
reason,
|
||||
diagnosis: diagnoseExtensionLoad(reason, label),
|
||||
});
|
||||
const message = `GitNexus: ${label} extension unavailable; continuing without ${label} features. ${reason}`;
|
||||
// A quiet probe must not register the dedup key: the owning caller may hit
|
||||
// the identical reason later, and that one is the real degradation report.
|
||||
if (quiet) {
|
||||
logger.debug(message);
|
||||
return;
|
||||
}
|
||||
const key = `${name}:${reason}`;
|
||||
if (this.warnedKeys.has(key)) return;
|
||||
this.warnedKeys.add(key);
|
||||
warn(
|
||||
`GitNexus: ${label} extension unavailable; continuing without ${label} features. ${reason}`,
|
||||
);
|
||||
warn(message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -922,7 +922,13 @@ const doInitLbug = async (dbPath: string, readOnly: boolean = false) => {
|
||||
// FTS powers baseline search, so initialize it with the core DB. Read-only
|
||||
// serve/MCP paths must never run DDL or trigger network INSTALL; analyze owns
|
||||
// schema/index creation and extension installation.
|
||||
await loadFTSExtension(undefined, readOnly ? { policy: 'load-only' } : {});
|
||||
//
|
||||
// `quiet` on the writable branch: on a cold machine this pre-load is EXPECTED
|
||||
// to miss (default policy is load-only, extension not yet on disk) and analyze
|
||||
// Phase 3 installs it moments later in the same run. Warning here reported a
|
||||
// degradation that never happened — the run went on to build every FTS index.
|
||||
// Phase 3 (and the read-only branch) still warn for real failures.
|
||||
await loadFTSExtension(undefined, readOnly ? { policy: 'load-only' } : { quiet: true });
|
||||
|
||||
currentDbPath = dbPath;
|
||||
return { db, conn };
|
||||
|
||||
@@ -258,6 +258,41 @@ describe('ExtensionManager — observability', () => {
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// initLbug's writable FTS pre-load is a speculative probe — on a cold
|
||||
// machine it misses, then analyze Phase 3 installs and every FTS index builds.
|
||||
// The probe must not report a degradation that the same run repairs.
|
||||
it('stays silent on a quiet probe that a later install-capable call repairs', async () => {
|
||||
const installExtension = vi.fn().mockResolvedValue(okInstall);
|
||||
const warn = vi.fn();
|
||||
const manager = new ExtensionManager({ installExtension, warn });
|
||||
const query = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Extension "fts" not found'))
|
||||
.mockRejectedValueOnce(new Error('Extension "fts" not found'))
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await expect(
|
||||
manager.ensure(query, 'fts', 'FTS', { policy: 'load-only', quiet: true }),
|
||||
).resolves.toBe(false);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
|
||||
await expect(manager.ensure(query, 'fts', 'FTS', { policy: 'auto' })).resolves.toBe(true);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
expect(manager.getCapabilities()).toEqual([{ name: 'fts', loaded: true }]);
|
||||
});
|
||||
|
||||
it('still warns on a genuine load-only miss that follows a quiet probe of the same reason', async () => {
|
||||
const warn = vi.fn();
|
||||
const manager = new ExtensionManager({ policy: 'load-only', warn });
|
||||
const query = vi.fn().mockRejectedValue(new Error('Extension "fts" not found'));
|
||||
|
||||
await manager.ensure(query, 'fts', 'FTS', { quiet: true });
|
||||
await manager.ensure(query, 'fts', 'FTS');
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('continuing without FTS features'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExtensionManager — input validation', () => {
|
||||
|
||||
Reference in New Issue
Block a user