Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,27 @@ CLI: `ocx route policy list`, `ocx route policy show <id>`, `ocx route policy dr

CLI: `ocx logs explain <request-id>`, `ocx logs rebuild-index`, `ocx logs index-status`.

### 인덱스 검사와 복구

기존 기록 인덱스를 처음 열면 읽기 전용 워커에서 무결성을 검사합니다.
기록 조회는 이 초기 검사를 기다리며, 프록시의 요청 처리 스레드를 막지 않습니다.
동기식 라우팅 조회는 검사 중에 busy를 반환하므로 라우팅 프로필의
`unknownEvidence.health` 정책이 적용됩니다. 작은 인덱스를 인라인으로 재구축한 경우에는
성공 직후 사용할 수 있으며, 워커에서 재구축한 인덱스는 게시 전에 검사를 마칩니다.

검사나 재구축에 성공한 뒤 최소 5분이 지나면 다음 조회가 백그라운드 무결성 검사 하나를
시작합니다. 이미 검증된 인덱스는 검사 중에도 조회할 수 있습니다. 일반 갱신은 스키마,
원본 파일 식별자와 바이트 오프셋을 확인하지만 전체 동기식 검사를 반복하지 않으며,
조회가 없는 동안에는 타이머를 실행하지 않습니다. 데이터베이스 잠금이나 워커 실행 실패
같은 일시적인 검사 오류는 인덱스를 삭제하지 않고, 30초가 지난 뒤의 조회에서 다시 시도합니다.
초기 검증을 마치지 못한 인덱스는 그동안 사용할 수 없습니다.

손상이 탐지되면 `usage.jsonl`에서 자동으로 재구축하며, 영향을 받은 비동기 기록·분석
조회는 복구 후 한 번 재시도합니다. 인덱스를 닫거나 재구축할 때에는 이전 검사 결과를
폐기하고 검사 워커가 파일을 해제한 뒤 인덱스를 교체합니다. 검사와 복구는 원본 기록을
다시 쓰지 않습니다. 초기 검사에는 인덱스 크기에 비례하는 시간이 걸리며,
이후 백그라운드 검사는 인덱스를 사용하는 동안에만 실행됩니다.

## 마이그레이션

`routingProfiles`는 선택적 추가 설정입니다. 기존 설정 파일과 이전 `usage.jsonl` 행은 그대로 읽힙니다. 인덱스는 일회용이며 삭제 시 다음 쿼리에서 `usage.jsonl`로 자동 재구축됩니다. 자동 튜닝은 없습니다.
23 changes: 23 additions & 0 deletions docs-site/src/content/docs/reference/configuration/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,29 @@ CLI: `ocx logs history`, `ocx logs get <request-id>`,
`ocx logs explain <request-id>`, `ocx logs rebuild-index`,
`ocx logs index-status`, `ocx route policy list | show | dry-run | evaluate`.

### Index validation and recovery

An existing history index is checked in a read-only worker when first opened.
History queries wait for that initial check without blocking the proxy's serving
thread. Synchronous routing reads report busy during validation, so routing
profiles apply their `unknownEvidence.health` policy. A successful small inline
rebuild is usable immediately; a worker rebuild is checked before publication.

After a successful check or rebuild, the next read at least five minutes later
starts one background integrity check. Reads from the verified index continue
during that check. Normal refreshes retain schema, source-identity and offset
validation without a full synchronous scan, and an idle index starts no timer.
Temporary check failures, such as a busy database or unavailable worker, leave
the index intact and retry on a subsequent read after a 30-second backoff.
An index that has not completed initial validation remains unavailable meanwhile.

Detected corruption triggers automatic reconstruction from `usage.jsonl`, with
one retry for an affected asynchronous history or analytics read. Closing or
rebuilding the index discards old check results and waits for the scanner to
release the file before replacing it. Validation and recovery never rewrite the
canonical ledger. The initial check still takes time proportional to index size;
later background checks run only when the index is used.

## Migration

`routingProfiles` is optional and additive: existing config files load
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1495,7 +1495,7 @@
"repo-hygiene.test.ts": "ci-workflows",
"request-decompress.test.ts": "usage",
"request-evidence.test.ts": "usage",
"request-history-index.test.ts": "usage",
"request-history-index.test.ts": "usage", "request-history-integrity.test.ts": "usage",
"request-log-attempt-identity.test.ts": "usage",
"request-log-conversation.test.ts": "usage",
"request-log-estimate-cap.test.ts": "usage",
Expand Down
8 changes: 3 additions & 5 deletions src/routing/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import { normalizePersistedUsageRow, type PersistedUsageEntry } from "../usage/log";
import { computeEntryCost } from "../usage/summary";
import { openRequestHistoryIndex, requestHistoryDb } from "./history/indexer";
import { withRequestHistoryRead } from "./history/indexer";

export const ANALYTICS_MAX_ROWS = 50_000;
/** Default row cap for the management API (full cap remains available via `limit`). */
Expand Down Expand Up @@ -168,8 +168,6 @@ export async function computeRoutingAnalytics(
filters: RoutingAnalyticsFilters,
options: { maxRows?: number } = {},
): Promise<RoutingAnalyticsResult> {
await openRequestHistoryIndex();
const handle = requestHistoryDb();
const maxRows = Math.min(
Math.max(1, Math.trunc(options.maxRows ?? ANALYTICS_MAX_ROWS)),
ANALYTICS_MAX_ROWS,
Expand All @@ -189,15 +187,15 @@ export async function computeRoutingAnalytics(
if (filters.to !== undefined) add("timestamp <= ?", filters.to);
const whereSql = where.length > 0 ? ` WHERE ${where.join(" AND ")}` : "";

const rows = handle.query(
const rows = await withRequestHistoryRead(handle => handle.query(
`SELECT provider, model, api_key_id AS apiKeyId, profile_id AS profileId,
profile_revision AS profileRevision, status,
duration_ms AS durationMs, first_output_ms AS firstOutputMs,
close_reason AS closeReason, terminal_status AS terminalStatus,
usage_status AS usageStatus, usage_json AS usageJson,
attempt_count AS attemptCount, fallback, row_json AS rowJson
FROM requests${whereSql} ORDER BY timestamp DESC LIMIT ?`,
).all(...values, maxRows + 1) as ScannedRow[];
).all(...values, maxRows + 1) as ScannedRow[]);

const scanned = rows.slice(0, maxRows);
const historyTruncated = rows.length > maxRows;
Expand Down
8 changes: 5 additions & 3 deletions src/routing/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
*/

import type { OcxConfig } from "../types";
import { openRequestHistoryIndexSync, requestHistoryDb } from "./history/indexer";
import { openRequestHistoryIndexSync, reportRequestHistoryCorruption, requestHistoryDb } from "./history/indexer";
import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
import {
getCodexAccountCooldownUntil,
Expand Down Expand Up @@ -251,9 +251,10 @@ function computeHistoricalHealthEvidence(
input: Pick<HealthEvidenceInput, "provider" | "model" | "accountRef">,
now: number,
): HistoricalHealthEvidence {
let handle: ReturnType<typeof requestHistoryDb> | undefined;
try {
openRequestHistoryIndexSync();
const handle = requestHistoryDb();
handle = requestHistoryDb();
const where: string[] = ["provider = ?", "model = ?", "timestamp >= ?"];
const values: Array<string | number> = [input.provider, input.model, now - HEALTH_WINDOW_MS];
if (input.accountRef) {
Expand Down Expand Up @@ -341,7 +342,8 @@ function computeHistoricalHealthEvidence(
out.recencyWeight = decayWeight(samples[0]!.timestamp, now);
}
return out;
} catch {
} catch (error) {
if (handle) reportRequestHistoryCorruption(error, handle);
/* index unreadable: evidence stays unknown */
return {};
}
Expand Down
6 changes: 6 additions & 0 deletions src/routing/history/build-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ export function buildIndexFile(buildPath: string, reason: string, revision: Usag
expected: revision,
endOffset: Number(revision.size),
});
// Validate before publication while still off the serving thread. The
// published handle can then be trusted until its periodic worker check.
const check = build.query("PRAGMA quick_check").get() as { quick_check?: string } | undefined;
if (check?.quick_check !== "ok") {
throw Object.assign(new Error("rebuilt history index failed integrity check"), { code: "SQLITE_CORRUPT" });
}
// The serving thread opens the published file in WAL mode; switching here
// keeps that first open from rewriting the database header.
build.exec("PRAGMA journal_mode = WAL");
Expand Down
Loading
Loading