Skip to content

feat: ClickHouse historical search tier - #14682

Draft
danny-avila wants to merge 3 commits into
devfrom
claude/search-track6-clickhouse
Draft

feat: ClickHouse historical search tier#14682
danny-avila wants to merge 3 commits into
devfrom
claude/search-track6-clickhouse

Conversation

@danny-avila

@danny-avila danny-avila commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Adds a ClickHouse-backed historical-search tier: a versioned projection of chat records that serves candidate IDs for older content, so a PostgreSQL search store does not have to hold the entire corpus hot.

Stacked on #14683. This imports the shared scope core (createScope / assertScope / Scope) from @librechat/data-schemas, and its integration spec runs against the chat_search migration — both land there. Merge #14683 first; until then CI here cannot resolve those imports. Verified locally against that branch; results below.

It is additive — PostgreSQL still searches the full corpus, ClickHouse candidates only augment the result set, and PostgreSQL wins deduplication ties. Nothing is wired into a route yet; all code lives under packages/api/src/history/** and is not exported from the package index (a one-line change when a consumer exists). Import as ~/history.

What's here

File Purpose
sql/clickhouse.sql Versioned ReplacingMergeTree, ORDER BY (tenant_id, user_id, kind, record_id), version = projection_version
consumer.ts / frontier.ts / source.ts Outbox consumer: xmin-visibility read, contiguous-prefix watermark, lease-epoch fencing
predicate.ts ClickHouse rendering of the shared Scope
candidates.ts Candidate adapter: text + vector arms, IDs and scores only
guard.ts Fail-closed anti-join contract for the caller
audit.ts ClickHouse-vs-PostgreSQL consistency audit
config.ts Env-only credential loading, no defaults

There is deliberately no chat_search DDL here. That schema is owned by one migration; a second copy is the same drift hazard the shared scope core removes.

Watermark correctness — the bug this exists to prevent

outbox_seq is a bigserial drawn at INSERT time, so sequence order is not commit order. Reading WHERE outbox_seq > W ORDER BY outbox_seq and advancing W to the highest row skips a lower sequence that commits later — that record then becomes permanently invisible to both search arms.

Demonstrated live against PostgreSQL 17, with transaction A holding seq 3 in flight while seq 4 commits:

--- naive read (the bug) ---          --- xmin-visibility read ---
 outbox_seq | record_id                (0 rows)
          4 | m4

The naive read advances W to 4 and loses m3 forever. After A commits, the guarded read returns both, in order.

Two rules, not interchangeable:

  1. xmin visibility — consume only rows whose inserting transaction is no longer in flight. Implemented with wraparound-safe modular comparison rather than the naive spelling: xmin is a 32-bit xid while pg_snapshot_xmin() returns a 64-bit xid8, so comparing decimal renderings degrades to always-true after the first transaction-id wraparound.
  2. Contiguous prefixW advances only across W+1, W+2, … with no missing value, and rows above a gap are withheld from the ClickHouse insert as well, preserving the invariant that no served candidate exceeds W.

Permanent-gap barrier. Aborted transactions burn sequence values permanently, so waiting unconditionally for a gap to fill would stall ingestion forever. On first sight of a gap the consumer persists pg_snapshot_xmax() captured in the same statement that observed it; the gap may be skipped only once pg_snapshot_xmin() reaches that bound — i.e. once every transaction alive at observation time has ended. Verified end to end against a real aborted transaction.

Ordering is ClickHouse-insert-first, watermark-second: a crash between them replays the batch, and ReplacingMergeTree collapses the duplicate.

Tombstones — never a bare TTL

TTL deleted_at + INTERVAL 7 DAY DELETE is a resurrection path and is explicitly forbidden in the DDL comments: in a ReplacingMergeTree, deletion is a higher-version tombstone row, and collapsing happens only at merge time. Expiring the tombstone while an older content-bearing part survives un-merged makes the deleted row visible again.

  • Key-scoped row TTL. key_retire_at derives only from key-stable inputs, and tombstones always carry a never-retire sentinel, so a tombstone can only outlive the content versions it supersedes — asserted as an invariant.
  • OPTIMIZE before cleanup, documented as a four-step procedure with a verification query that must return 0 before any reclamation.
  • A tombstone_is_textless CHECK constraint makes a tombstone carrying text or a vector a write error rather than a review finding.

Scope safety

ClickHouse has no row-level security, so application code is the only fence — not a net over one.

  • The validation half (resolve → normalize → reject → brand) lives once, in data-schemas, shared with the PostgreSQL tier. This module never re-derives it: fetchCandidates takes an already-branded Scope and only gates it with assertScope.
  • predicate.ts renders that value into ClickHouse {name:Type} bindings and owns only the record kind. PostgreSQL renders the same value into $n predicates and RLS session settings. Neither re-derives, so the two cannot drift.
  • createScope normalizes an absent tenant to the base tenant before failing closed — failing on "empty tenant" first would break every non-tenant deployment — then throws on a missing user or on the __SYSTEM__ sentinel, which is a query-time wildcard elsewhere in this codebase and must never become one here.
  • Builders accept nothing but a branded Scope and render the predicate internally. There is no module-level SQL constant with an unfilled scope hole, and no intermediate predicate object to forge — the brand is a module-private symbol, so a structurally identical plain object is rejected.
  • The anti-join lookup gates the same value, so it cannot run wider than the query that produced the candidates.

Leak matrix

{text, vector} × {messages, conversations, shared-links}, three principals sharing a user id across two tenants and a tenant across two users, seeded with identical record ids, bodies, and vectors — only the scope predicate can separate them. 49 assertions against clickhouse-local, plus coverage for absent, forged, and unknown-kind scope.

The three properties that had to survive adopting the shared core are each still asserted here: throw-on-unscoped-construction, brand-substitution rejection, and normalize-tenant-before-failing-closed.

Credentials

config.ts reads every DSN and password from the environment with no fallback default; a missing variable throws at startup. Errors name the variable only, never a value, and describeTarget() (scheme + host, userinfo stripped) is the only connection detail that may be logged.

Verification

Run against this branch combined with #14683 — real shared core, real migration:

Suite Result Backed by
sql.spec.ts 18 pass real clickhouse-local 26.8
leakmatrix.spec.ts 49 pass real clickhouse-local 26.8
consumer.spec.ts 38 pass scripted out-of-order commits
consumer.integration.spec.ts 10 pass real PostgreSQL 17 + pgvector, authoritative migration applied
frontier / guard / audit / config 48 pass unit

163 tests, 0 failures. Typecheck and lint clean. The 10 PostgreSQL integration tests run only when CHAT_SEARCH_TEST_URL is set and the migration file is present; they skip cleanly otherwise.

Running against the authoritative migration rather than a local copy immediately caught two divergences, both fixed here: the consumer read created_at/updated_at where the schema has source_created_at/source_updated_at, and a test fixture used a 2-dimension vector where the column is vector(1024) with NOT NULL model metadata.

Two ClickHouse behaviors the DDL work turned up, both now pinned by tests:

  • Column-wise argMax(nullable_col, version) skips NULLs and returns an older non-null value — which would resurrect a cleared expires_at. Every serving query aggregates a single tuple instead.
  • ReplacingMergeTree collapses duplicate keys within a single insert block, so multi-version tests need separate inserts or they silently prove nothing.

Follow-ups (not blocking)

  • clickhouse-local covers semantics, not performance. Ingest throughput, ANN recall, and the OPTIMIZE FINAL collapse behavior at scale still need a live ClickHouse server.

Versioned ReplacingMergeTree DDL, xmin-fenced outbox consumer with a
contiguous-prefix watermark, additive candidate adapter, fail-closed
anti-join contract, and a CH-vs-PG audit job.
… tier

Splits scope handling into the safety-critical half (resolve, normalize,
reject, brand) and the ClickHouse rendering half, so the shared core in
data-schemas is a drop-in. Adds the ClickHouse leak matrix and env-only,
no-default credential loading.
Copilot AI lite review requested due to automatic review settings August 7, 2026 04:56
@danny-avila danny-avila changed the title feat: ClickHouse historical search tier (track 6) feat: ClickHouse historical search tier Aug 7, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new, self-contained ClickHouse-backed “historical search tier” under packages/api/src/history/**. The tier introduces a ClickHouse projection (fed by a PostgreSQL outbox consumer) and a scoped candidate-fetch API (text + vector) designed to be additive to the existing PostgreSQL search path, with a fail-closed anti-join contract for safety.

Changes:

  • Adds ClickHouse DDL + query builders for scoped candidate retrieval (text and vector arms) using latest-version aggregation semantics.
  • Implements a PostgreSQL outbox consumer with xmin-visibility reads, contiguous-prefix watermark advancement, lease-epoch fencing, and a permanent-gap barrier.
  • Adds scope fencing (branding + per-store predicate rendering), fail-closed anti-join helpers, an audit job, and extensive unit/integration tests (ClickHouse-local + optional PostgreSQL).

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
packages/api/src/history/types.ts History-tier types and narrow ports for PostgreSQL/ClickHouse clients.
packages/api/src/history/sql/outbox.sql Reference PostgreSQL DDL contract for outbox + watermark tables.
packages/api/src/history/sql/clickhouse.sql ClickHouse schema for versioned documents + ingest log, with tombstone/TTL semantics.
packages/api/src/history/sql.spec.ts ClickHouse-local tests validating DDL constraints and serving-query semantics.
packages/api/src/history/source.ts PostgreSQL SQL + adapter for visible outbox reads, watermark I/O, and document+embedding fetches.
packages/api/src/history/scope.ts Provisional scope resolution/branding used to fail closed on unscoped/unsafe input.
packages/api/src/history/predicate.ts ClickHouse predicate rendering from a branded scope + validated kind.
packages/api/src/history/leakmatrix.spec.ts ClickHouse-local leak matrix validating tenant/user isolation across arms/kinds.
packages/api/src/history/index.ts History tier public surface (internal-only for now; not exported from package root).
packages/api/src/history/guard.ts Fail-closed anti-join contract and batch lookup SQL for caller-side safety.
packages/api/src/history/guard.spec.ts Unit tests for anti-join admit/reject behavior.
packages/api/src/history/frontier.ts Pure contiguous-prefix frontier logic + permanent-gap barrier helpers.
packages/api/src/history/frontier.spec.ts Unit tests for frontier and gap-barrier logic.
packages/api/src/history/consumer.ts Outbox consumer: batching, ClickHouse insert-first, watermark advance, gap handling, fencing.
packages/api/src/history/consumer.spec.ts Scripted PG + ClickHouse tests for ordering, batching, fencing, tombstones, embeddings, TTL derivation.
packages/api/src/history/consumer.integration.spec.ts Optional real-PostgreSQL integration tests for xmin visibility and gap behavior.
packages/api/src/history/config.ts Env-only config loading with required credentials and safe loggable target formatting.
packages/api/src/history/config.spec.ts Tests for config fail-closed behavior and credential non-leakage.
packages/api/src/history/candidates.ts Candidate query builders + adapter combining arms with degradations and readiness probing.
packages/api/src/history/candidates.spec.ts Unit tests for arm selection, scoping injection, limits, degradations, and readiness.
packages/api/src/history/audit.ts CH-vs-PG consistency audit (summary + sampled key gap detection).
packages/api/src/history/audit.spec.ts Unit tests for audit cleanliness, gap reporting, sampling, and caps.
Suppressed comments (3)

packages/api/src/history/consumer.ts:267

  • When recording the permanent-gap barrier, the commitWatermark() result is not checked. If this update is fenced, the consumer should stop rather than continuing with a stale view of the watermark/barrier state.
    await commitWatermark(pg, {
      appliedSeq: ctx.appliedSeq,
      appliedVersion: ctx.appliedVersion,
      leaseEpoch: ctx.leaseEpoch,
      gapBarrierSeq: gapAt,

packages/api/src/history/consumer.ts:288

  • When skipping a provably-permanent gap, the watermark update is also fenced but the commitWatermark() result is ignored. If fenced here, the consumer may continue from an incorrect frontier and should fail fast instead.
  await commitWatermark(pg, {
    appliedSeq: resumeAt - BigInt(1),
    appliedVersion: ctx.appliedVersion,
    leaseEpoch: ctx.leaseEpoch,
    gapBarrierSeq: null,

packages/api/src/history/audit.spec.ts:91

  • Avoid as unknown as T double assertions in tests per the repo Type Safety guidance in CLAUDE.md. A single assertion is sufficient here.
      return { json: async <TRow>() => rows as unknown as TRow[] };

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +242 to +250
if (ctx.watermark.gapBarrierSeq !== null) {
await commitWatermark(pg, {
appliedSeq: ctx.appliedSeq,
appliedVersion: ctx.appliedVersion,
leaseEpoch: ctx.leaseEpoch,
gapBarrierSeq: null,
gapBarrierXmax: null,
});
}
Comment on lines +295 to +299
async function buildBatch(
deps: OutboxConsumerDeps,
chunk: readonly OutboxRow[],
_embeddingSpace: string,
graceMs: number,
Comment on lines +177 to +189
const rows: OutboxRow[] = [];
for (let i = 0; i < result.rows.length; i++) {
const row = result.rows[i];
rows.push({
outboxSeq: BigInt(row.outbox_seq),
tenantId: row.tenant_id,
userId: row.user_id,
kind: row.kind as HistoryKind,
recordId: row.record_id,
projectionVersion: BigInt(row.projection_version),
op: row.op === 'tombstone' ? 'tombstone' : 'upsert',
});
}
export type ClickHouseDocumentRow = Readonly<{
tenant_id: string;
user_id: string;
kind: string;
Comment on lines +29 to +31
const rows = this.rowsByQuery.get(arm) ?? [];
return { json: async <TRow>() => rows as unknown as TRow[] };
}
}): Promise<{ json<TRow>(): Promise<TRow[]> }> {
if (params.query === auditClickHouseSummarySql) {
this.appliedVersionSeen = String(params.query_params?.applied_version);
return { json: async <TRow>() => this.summary as unknown as TRow[] };
Comment on lines +104 to +109
const rows = query<Record<string, unknown>>(
params.query,
(params.query_params ?? {}) as Record<string, ClickHouseParam>,
);
return { json: async <TRow>() => rows as unknown as TRow[] };
},
…ration

Deletes the provisional local scope validation half in favour of
@librechat/data-schemas, so resolve/normalize/reject/brand exists once for
both stores. The candidate adapter now gates a branded Scope instead of
re-deriving one from a plain object.

Also drops this module's duplicate chat_search DDL: the integration spec
runs against search/migrations/001_schema.sql, which caught two real
divergences (source_created_at/source_updated_at, and vector(1024) with
NOT NULL model metadata).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants