From 2af34a0e21bd5e42d7160bfc9aaafd8e0944c52f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 06:07:20 +0900 Subject: [PATCH 1/2] fix(storage): bound cold Codex log inspection Choose option (a): skip synchronous row aggregates once logs_2.sqlite exceeds 64 MiB, retaining file, schema, and capability inspection. The threshold matches the reporter's measured mitigation: a ~1 GB GROUP BY level took 17.3s, while bounded /api/storage returned in 628ms. A Worker (option b) would make both management endpoints asynchronous and add startup, admission, teardown, and Windows thread-exit cost to a management-only inspector; storage Workers are already serialized specifically around Windows teardown. Bounded queries (option c) cannot make count(*), GROUP BY, or sum() exact with LIMIT, and bun:sqlite provides no interruptible async statement on this request path. Expose metricsSkipped with the threshold so omitted aggregates are distinct from genuine zero values. Keep full metrics for small databases and cover both shapes with a falsified regression test. --- .../storage-workspace/StorageWorkspace.tsx | 4 +++ src/codex/log-guard/inspect.ts | 26 ++++++++++++++++--- tests/codex-log-guard-inspect.test.ts | 21 +++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/gui/src/components/storage-workspace/StorageWorkspace.tsx b/gui/src/components/storage-workspace/StorageWorkspace.tsx index fb5ed7808d..b91cd5d06e 100644 --- a/gui/src/components/storage-workspace/StorageWorkspace.tsx +++ b/gui/src/components/storage-workspace/StorageWorkspace.tsx @@ -73,6 +73,10 @@ export interface CodexLogGuardReport { reclaimableBytes: number; estimatedLogBytes: number | null; }; + metricsSkipped?: null | { + reason: "database_too_large"; + thresholdBytes: number; + }; } export interface StorageReport { diff --git a/src/codex/log-guard/inspect.ts b/src/codex/log-guard/inspect.ts index d2c1719236..eca4402e25 100644 --- a/src/codex/log-guard/inspect.ts +++ b/src/codex/log-guard/inspect.ts @@ -11,6 +11,9 @@ import { const IMMUTABLE_READONLY_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI; const KNOWN_LOG_LEVELS = new Set(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]); +// The issue reporter measured a ~1 GB database taking 17.3s for GROUP BY level +// alone; skipping all row aggregates above 64 MiB reduced /api/storage to 628ms. +const MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES = 64 * 1024 * 1024; interface CurrentLogColumn { name: string; @@ -115,6 +118,10 @@ export interface CodexLogGuardInspection { reclaim: CodexLogGuardCapability; }; metrics: CodexLogGuardMetrics | null; + metricsSkipped: null | { + reason: "database_too_large"; + thresholdBytes: number; + }; } interface ColumnRow { @@ -167,9 +174,8 @@ function fileSize(path: string): number { * repeated stalls without ever serving stale numbers: any write changes the WAL * and invalidates the entry. * - * This bounds the repeat cost, not the first one. A cold inspection of a huge - * database still blocks; moving that work off-thread needs a Worker and is - * tracked separately. + * Memoization bounds repeat cost. The database-size gate below separately bounds + * cold request-thread work by omitting these aggregates for large databases. */ type InspectionCacheEntry = { key: string; @@ -233,6 +239,7 @@ function unavailableInspection(): CodexLogGuardInspection { reclaim: unavailable, }, metrics: null, + metricsSkipped: null, }; } @@ -425,6 +432,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ...common, schema, metrics: null, + metricsSkipped: null, capabilities: { inspection: { state: "supported" }, protection: mutation, @@ -440,6 +448,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ...common, schema, metrics: null, + metricsSkipped: null, capabilities: { inspection: { state: "supported" }, protection: mutation, @@ -458,6 +467,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ...common, schema, metrics: null, + metricsSkipped: null, capabilities: { inspection: { state: "supported" }, protection: mutation, @@ -476,10 +486,17 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ? { state: "compatible" } : { state: "unsupported", reason: "unknown_schema" }; const mutation = capabilityFor(schema); + const metricsSkipped = files.databaseBytes > MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES + ? { + reason: "database_too_large" as const, + thresholdBytes: MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES, + } + : null; return { ...common, schema, - metrics: readMetrics(db, columns), + metrics: metricsSkipped === null ? readMetrics(db, columns) : null, + metricsSkipped, capabilities: { inspection: { state: "supported" }, protection: mutation, @@ -496,6 +513,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ...common, schema, metrics: null, + metricsSkipped: null, capabilities: { inspection: { state: "supported" }, protection: mutation, diff --git a/tests/codex-log-guard-inspect.test.ts b/tests/codex-log-guard-inspect.test.ts index 96deb06672..03ba72c8c7 100644 --- a/tests/codex-log-guard-inspect.test.ts +++ b/tests/codex-log-guard-inspect.test.ts @@ -7,6 +7,7 @@ import { renameSync, rmSync, statSync, + truncateSync, utimesSync, unlinkSync, writeFileSync, @@ -113,6 +114,26 @@ describe("Codex Log Guard inspection", () => { expect(report.metrics?.traceShare).toBe(0.5); expect(report.metrics?.topTargets[0]).toEqual({ target: "TARGET_1", rows: 2 }); expect(report.metrics?.reclaimableBytes).toBeGreaterThanOrEqual(0); + expect(report.metricsSkipped).toBeNull(); + }); + + test("skips row aggregates for a large database without reporting zero metrics", () => { + const root = makeRoot(); + const databasePath = join(root, "logs_2.sqlite"); + createCurrentLogsDb(databasePath); + truncateSync(databasePath, 64 * 1024 * 1024 + 1); + + const report = inspectCodexLogs({ codexHome: root }); + + expect(report.schema).toEqual({ state: "compatible" }); + expect(report.metrics).toBeNull(); + expect(report.metricsSkipped).toEqual({ + reason: "database_too_large", + thresholdBytes: 64 * 1024 * 1024, + }); + expect(report).not.toMatchObject({ + metrics: { totalRows: 0, rowsByLevel: {}, estimatedLogBytes: 0 }, + }); }); test("never exposes feedback bodies, arbitrary levels, target names, or paths", () => { From a6a7f31f7758a8d92769fb470d7ea754bc203157 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 06:12:16 +0900 Subject: [PATCH 2/2] fix(storage): bound the cold Codex log inspection and say when it was skipped Carries the size gate for #2605 and adds the GUI half. The server side skips the row aggregates above 64 MiB, which is what keeps a cold inspection off the proxy thread - the reporter measured GROUP BY level at 17.3s on a ~1 GB database, and the source already admitted the gap in its own comment. The GUI half was missing: with metrics null the row block simply disappeared, so a 1 GB database rendered as one with no rows. That is the exact confusion the server's null-vs-zero distinction exists to prevent, and it is most misleading precisely where the database is largest. The row now states the reason and the threshold, and the file sizes still render because only the aggregates were skipped, not the inspection. String added to all nine locales. Rendered in a real browser against the built stylesheet, not just asserted. Falsified: disabling the skipped-row branch reddens the new render test while the under-threshold case stays green. --- .../storage-workspace/StorageWorkspace.tsx | 16 ++++++++ gui/src/i18n/log-guard-labels.ts | 10 +++++ gui/tests/storage-log-guard.test.tsx | 40 +++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/gui/src/components/storage-workspace/StorageWorkspace.tsx b/gui/src/components/storage-workspace/StorageWorkspace.tsx index b91cd5d06e..46d670a68e 100644 --- a/gui/src/components/storage-workspace/StorageWorkspace.tsx +++ b/gui/src/components/storage-workspace/StorageWorkspace.tsx @@ -209,6 +209,22 @@ function CodexLogGuardPanel({ )} + {/* + Say WHY the rows are missing (#2605). Skipping the aggregates above the size threshold + is what keeps a cold inspection off the proxy thread, but silently dropping the row + block reads as "this database has no rows" — the exact confusion the null-vs-zero + distinction on the server exists to prevent. A user who sees a 1 GB database and no + row count deserves the reason. + */} + {!metrics && report.metricsSkipped && ( +
+
{t("storage.col.rows")}
+
+ {logGuardLabel(locale, "metricsSkippedLarge") + .replace("{threshold}", formatBytes(report.metricsSkipped.thresholdBytes, locale))} +
+
+ )}
sqlite_home
diff --git a/gui/src/i18n/log-guard-labels.ts b/gui/src/i18n/log-guard-labels.ts index de7ed41af5..a0164259e7 100644 --- a/gui/src/i18n/log-guard-labels.ts +++ b/gui/src/i18n/log-guard-labels.ts @@ -4,6 +4,7 @@ export type LogGuardLabelKey = | "inspectionOnly" | "externalSqliteHome" | "inspectionUnavailable" + | "metricsSkippedLarge" | "protection" | "compat" | "quiet" @@ -37,6 +38,7 @@ const LABELS: Record> = { inspectionOnly: 'Inspection only', externalSqliteHome: 'External SQLite storage', inspectionUnavailable: "Diagnostic log inspection is unavailable.", + metricsSkippedLarge: "Row metrics skipped: the database is above {threshold}, and scanning it would stall the proxy.", protection: "Protection", compat: "Compatibility", quiet: "Quiet", @@ -63,6 +65,7 @@ const LABELS: Record> = { inspectionOnly: 'Nur Inspektion', externalSqliteHome: 'Externer SQLite-Speicher', inspectionUnavailable: "Die Diagnoseprotokoll-Inspektion ist nicht verfügbar.", + metricsSkippedLarge: "Zeilenmetriken übersprungen: Die Datenbank ist größer als {threshold}; ein Scan würde den Proxy blockieren.", protection: "Schutz", compat: "Kompatibilität", quiet: "Leise", @@ -89,6 +92,7 @@ const LABELS: Record> = { inspectionOnly: "Inspection uniquement", externalSqliteHome: "Stockage SQLite externe", inspectionUnavailable: "L’inspection des journaux de diagnostic est indisponible.", + metricsSkippedLarge: "Métriques de lignes ignorées : la base dépasse {threshold} et son analyse bloquerait le proxy.", protection: "Protection", compat: "Compatibilité", quiet: "Silencieux", @@ -115,6 +119,7 @@ const LABELS: Record> = { inspectionOnly: '검사 전용', externalSqliteHome: '외부 SQLite 저장소', inspectionUnavailable: "진단 로그 검사를 사용할 수 없습니다.", + metricsSkippedLarge: "행 지표를 건너뛰었습니다. 데이터베이스가 {threshold}보다 커서 스캔하면 프록시가 멈춥니다.", protection: "보호", compat: "호환 모드", quiet: "조용한 모드", @@ -141,6 +146,7 @@ const LABELS: Record> = { inspectionOnly: '仅检查', externalSqliteHome: '外部 SQLite 存储', inspectionUnavailable: "诊断日志检查当前不可用。", + metricsSkippedLarge: "已跳过行指标:数据库超过 {threshold},扫描会阻塞代理。", protection: "保护", compat: "兼容模式", quiet: "静默模式", @@ -167,6 +173,7 @@ const LABELS: Record> = { inspectionOnly: '僅檢查', externalSqliteHome: '外部 SQLite 儲存空間', inspectionUnavailable: "診斷記錄檢查目前無法使用。", + metricsSkippedLarge: "已略過列指標:資料庫超過 {threshold},掃描會阻塞代理。", protection: "保護", compat: "相容模式", quiet: "靜默模式", @@ -193,6 +200,7 @@ const LABELS: Record> = { inspectionOnly: 'Только проверка', externalSqliteHome: 'Внешнее хранилище SQLite', inspectionUnavailable: "Проверка диагностических журналов недоступна.", + metricsSkippedLarge: "Метрики строк пропущены: база больше {threshold}, и её сканирование заблокировало бы прокси.", protection: "Защита", compat: "Совместимость", quiet: "Тихий режим", @@ -219,6 +227,7 @@ const LABELS: Record> = { inspectionOnly: '検査のみ', externalSqliteHome: '外部 SQLite ストレージ', inspectionUnavailable: "診断ログの検査を利用できません。", + metricsSkippedLarge: "行メトリクスをスキップしました。データベースが {threshold} を超えており、走査するとプロキシが停止します。", protection: "保護", compat: "互換モード", quiet: "静音モード", @@ -245,6 +254,7 @@ const LABELS: Record> = { inspectionOnly: 'Yalnızca inceleme', externalSqliteHome: 'Harici SQLite depolaması', inspectionUnavailable: "Tanılama günlüğü incelemesi kullanılamıyor.", + metricsSkippedLarge: "Satır ölçümleri atlandı: veritabanı {threshold} sınırının üzerinde ve taranması proxy’yi kilitler.", protection: "Koruma", compat: "Uyumluluk", quiet: "Sessiz", diff --git a/gui/tests/storage-log-guard.test.tsx b/gui/tests/storage-log-guard.test.tsx index 272f925b14..fb065ce5f9 100644 --- a/gui/tests/storage-log-guard.test.tsx +++ b/gui/tests/storage-log-guard.test.tsx @@ -129,3 +129,43 @@ test("Storage overview does not render arbitrary Log Guard error strings", () => expect(html).not.toContain("/private/state/logs_2.sqlite"); expect(html).not.toContain("failed"); }); + +/** + * A skipped scan must SAY it was skipped (#2605). + * + * Above the size threshold the server returns `metrics: null` so a cold inspection cannot stall + * the proxy thread. Rendering that as an absent row block reads as "this database has no rows" — + * the exact confusion the server's null-vs-zero distinction exists to prevent, and the more + * misleading the larger the database actually is. + */ +test("a skipped large-database scan states the reason instead of rendering no rows", () => { + const large = report(); + large.codexLogs!.files.databaseBytes = 1_468_923_904; + large.codexLogs!.metrics = null; + large.codexLogs!.metricsSkipped = { reason: "database_too_large", thresholdBytes: 67_108_864 }; + + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('data-testid="log-guard-metrics-skipped"'); + expect(html).toContain("Row metrics skipped"); + // The threshold is stated, so the reader can tell why this database crossed it. + expect(html).toContain("64 MiB"); + // The file sizes still render: only the row aggregates were skipped, not the inspection. + expect(html).toContain("1.4 GiB"); + // And it must not silently show a row count it never computed. + expect(html).not.toContain(">400<"); +}); + +test("a database under the threshold still renders full row metrics", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).not.toContain('data-testid="log-guard-metrics-skipped"'); + expect(html).toContain("400"); +});