From bfe730aa38eb53d866afed59591445b0667e763b Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Mon, 16 Mar 2026 21:36:27 +0530 Subject: [PATCH] fix(openclaw): add SQLite resilience for OSS mode initialization (#4337) Co-authored-by: utkarsh240799 Co-authored-by: Claude Opus 4.6 (1M context) --- openclaw/index.ts | 38 +++- openclaw/sqlite-resilience.test.ts | 288 +++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+), 5 deletions(-) create mode 100644 openclaw/sqlite-resilience.test.ts diff --git a/openclaw/index.ts b/openclaw/index.ts index d31d315a2..c49d53b58 100644 --- a/openclaw/index.ts +++ b/openclaw/index.ts @@ -41,6 +41,7 @@ type Mem0Config = { vectorStore?: { provider: string; config: Record }; llm?: { provider: string; config: Record }; historyDbPath?: string; + disableHistory?: boolean; }; // Shared userId: string; @@ -132,7 +133,10 @@ class PlatformProvider implements Mem0Provider { private async ensureClient(): Promise { if (this.client) return; if (this.initPromise) return this.initPromise; - this.initPromise = this._init(); + this.initPromise = this._init().catch((err) => { + this.initPromise = null; + throw err; + }); return this.initPromise; } @@ -225,7 +229,10 @@ class OSSProvider implements Mem0Provider { private async ensureMemory(): Promise { if (this.memory) return; if (this.initPromise) return this.initPromise; - this.initPromise = this._init(); + this.initPromise = this._init().catch((err) => { + this.initPromise = null; + throw err; + }); return this.initPromise; } @@ -246,9 +253,30 @@ class OSSProvider implements Mem0Provider { config.historyDbPath = dbPath; } + if (this.ossConfig?.disableHistory) { + config.disableHistory = true; + } + if (this.customPrompt) config.customPrompt = this.customPrompt; - this.memory = new Memory(config); + try { + this.memory = new Memory(config); + } catch (err) { + // If initialization fails (e.g. native SQLite binding resolution under + // jiti), retry with history disabled — the history DB is the most common + // source of native-binding failures and is not required for core + // memory operations. + if (!config.disableHistory) { + console.warn( + "[mem0] Memory initialization failed, retrying with history disabled:", + err instanceof Error ? err.message : err, + ); + config.disableHistory = true; + this.memory = new Memory(config); + } else { + throw err; + } + } } async add( @@ -521,7 +549,7 @@ function assertAllowedKeys( throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`); } -const mem0ConfigSchema = { +export const mem0ConfigSchema = { parse(value: unknown): Mem0Config { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("openclaw-mem0 config required"); @@ -587,7 +615,7 @@ const mem0ConfigSchema = { // Provider Factory // ============================================================================ -function createProvider( +export function createProvider( cfg: Mem0Config, api: OpenClawPluginApi, ): Mem0Provider { diff --git a/openclaw/sqlite-resilience.test.ts b/openclaw/sqlite-resilience.test.ts new file mode 100644 index 000000000..1b02e0859 --- /dev/null +++ b/openclaw/sqlite-resilience.test.ts @@ -0,0 +1,288 @@ +/** + * Tests for SQLite resilience fixes: + * 1. disableHistory config passthrough + * 2. initPromise poisoning fix (retry after failure) + * 3. Graceful SQLite fallback in OSSProvider + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mem0ConfigSchema, createProvider } from "./index.ts"; + +// --------------------------------------------------------------------------- +// 1. Config: disableHistory passthrough +// --------------------------------------------------------------------------- +describe("mem0ConfigSchema — disableHistory", () => { + const baseConfig = { + mode: "open-source", + oss: { + embedder: { provider: "openai", config: { apiKey: "sk-test" } }, + }, + }; + + it("preserves oss.disableHistory: true through config parsing", () => { + const cfg = mem0ConfigSchema.parse({ + ...baseConfig, + oss: { ...baseConfig.oss, disableHistory: true }, + }); + expect(cfg.oss?.disableHistory).toBe(true); + }); + + it("preserves oss.disableHistory: false through config parsing", () => { + const cfg = mem0ConfigSchema.parse({ + ...baseConfig, + oss: { ...baseConfig.oss, disableHistory: false }, + }); + expect(cfg.oss?.disableHistory).toBe(false); + }); + + it("omits disableHistory when not provided", () => { + const cfg = mem0ConfigSchema.parse(baseConfig); + expect(cfg.oss?.disableHistory).toBeUndefined(); + }); + + it("does not reject unknown keys inside oss object", () => { + // oss sub-object is passed through resolveEnvVarsDeep, not key-checked + expect(() => + mem0ConfigSchema.parse({ + ...baseConfig, + oss: { ...baseConfig.oss, disableHistory: true }, + }), + ).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. OSSProvider: disableHistory flows to Memory constructor +// --------------------------------------------------------------------------- +describe("OSSProvider — disableHistory passthrough to Memory", () => { + let capturedConfig: Record | undefined; + let memoryCallCount: number; + + beforeEach(() => { + capturedConfig = undefined; + memoryCallCount = 0; + + vi.doMock("mem0ai/oss", () => ({ + Memory: class MockMemory { + constructor(config: Record) { + memoryCallCount++; + capturedConfig = { ...config }; + } + async add() { return { results: [] }; } + async search() { return { results: [] }; } + async get() { return {}; } + async getAll() { return []; } + async delete() { } + }, + })); + }); + + it("passes disableHistory: true to Memory when configured", async () => { + const { createProvider } = await import("./index.ts"); + const cfg = mem0ConfigSchema.parse({ + mode: "open-source", + oss: { disableHistory: true }, + }); + const api = { resolvePath: (p: string) => p } as any; + const provider = createProvider(cfg, api); + + // Trigger lazy init by calling search + try { + await provider.search("test", { user_id: "u1" }); + } catch { /* provider may fail on mock, that's ok */ } + + expect(capturedConfig).toBeDefined(); + expect(capturedConfig!.disableHistory).toBe(true); + }); + + it("does not set disableHistory when not configured", async () => { + const { createProvider } = await import("./index.ts"); + const cfg = mem0ConfigSchema.parse({ + mode: "open-source", + oss: {}, + }); + const api = { resolvePath: (p: string) => p } as any; + const provider = createProvider(cfg, api); + + try { + await provider.search("test", { user_id: "u1" }); + } catch { } + + expect(capturedConfig).toBeDefined(); + expect(capturedConfig!.disableHistory).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. OSSProvider: initPromise is cleared on failure (allows retry) +// --------------------------------------------------------------------------- +describe("OSSProvider — initPromise retry after failure", () => { + let callCount: number; + + beforeEach(() => { + callCount = 0; + + vi.doMock("mem0ai/oss", () => ({ + Memory: class MockMemory { + constructor() { + callCount++; + if (callCount === 1) { + throw new Error("SQLITE_CANTOPEN: simulated binding failure"); + } + // Second+ call succeeds + } + async search() { return { results: [] }; } + async get() { return {}; } + async getAll() { return []; } + async add() { return { results: [] }; } + async delete() { } + }, + })); + }); + + it("retries initialization after a transient failure", async () => { + const { createProvider } = await import("./index.ts"); + const cfg = mem0ConfigSchema.parse({ + mode: "open-source", + oss: { disableHistory: true }, + }); + const api = { resolvePath: (p: string) => p } as any; + const provider = createProvider(cfg, api); + + // First call: _init throws, but initPromise is cleared so retry is possible + await expect( + provider.search("test", { user_id: "u1" }), + ).rejects.toThrow("SQLITE_CANTOPEN"); + + // Second call: should retry _init (not return cached rejection) + // callCount === 1 threw, so callCount === 2 should succeed + const results = await provider.search("test", { user_id: "u1" }); + expect(results).toBeDefined(); + expect(callCount).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// 4. OSSProvider: graceful fallback disables history on init failure +// --------------------------------------------------------------------------- +describe("OSSProvider — graceful SQLite fallback", () => { + let capturedConfigs: Record[]; + + beforeEach(() => { + capturedConfigs = []; + + vi.doMock("mem0ai/oss", () => ({ + Memory: class MockMemory { + constructor(config: Record) { + capturedConfigs.push({ ...config }); + if (!config.disableHistory) { + throw new Error("Could not locate the bindings file"); + } + // Succeeds when disableHistory is true + } + async search() { return { results: [] }; } + async get() { return {}; } + async getAll() { return []; } + async add() { return { results: [] }; } + async delete() { } + }, + })); + }); + + it("retries with disableHistory: true when initial construction fails", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { createProvider } = await import("./index.ts"); + const cfg = mem0ConfigSchema.parse({ + mode: "open-source", + oss: {}, + }); + const api = { resolvePath: (p: string) => p } as any; + const provider = createProvider(cfg, api); + + // Should succeed — first attempt fails, fallback with disableHistory succeeds + const results = await provider.search("test", { user_id: "u1" }); + expect(results).toBeDefined(); + + // Memory constructor was called twice + expect(capturedConfigs).toHaveLength(2); + expect(capturedConfigs[0].disableHistory).toBeFalsy(); + expect(capturedConfigs[1].disableHistory).toBe(true); + + // Warning was logged + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("[mem0] Memory initialization failed"), + expect.stringContaining("bindings file"), + ); + warnSpy.mockRestore(); + }); + + it("does not retry when disableHistory is already true", async () => { + vi.doMock("mem0ai/oss", () => ({ + Memory: class MockMemory { + constructor(config: Record) { + // Fail even with disableHistory (e.g. vector store issue) + throw new Error("vector store connection refused"); + } + }, + })); + + const { createProvider } = await import("./index.ts"); + const cfg = mem0ConfigSchema.parse({ + mode: "open-source", + oss: { disableHistory: true }, + }); + const api = { resolvePath: (p: string) => p } as any; + const provider = createProvider(cfg, api); + + // Should throw — no fallback possible when disableHistory was already set + await expect( + provider.search("test", { user_id: "u1" }), + ).rejects.toThrow("vector store connection refused"); + }); +}); + +// --------------------------------------------------------------------------- +// 5. PlatformProvider — initPromise retry after failure +// --------------------------------------------------------------------------- +describe("PlatformProvider — initPromise retry after failure", () => { + let callCount: number; + + beforeEach(() => { + callCount = 0; + + vi.doMock("mem0ai", () => ({ + default: class MockMemoryClient { + constructor() { + callCount++; + if (callCount === 1) { + throw new Error("Network timeout"); + } + } + async search() { return []; } + async get() { return {}; } + async getAll() { return []; } + async add() { return { results: [] }; } + async delete() { } + }, + })); + }); + + it("retries initialization after a transient failure", async () => { + const { createProvider } = await import("./index.ts"); + const cfg = mem0ConfigSchema.parse({ + mode: "platform", + apiKey: "test-api-key", + }); + const api = { resolvePath: (p: string) => p } as any; + const provider = createProvider(cfg, api); + + // First call fails + await expect( + provider.search("test", { user_id: "u1" }), + ).rejects.toThrow("Network timeout"); + + // Second call should retry (not return cached rejection) + const results = await provider.search("test", { user_id: "u1" }); + expect(results).toBeDefined(); + expect(callCount).toBe(2); + }); +});