ClickHouse
Durable Loom state lives in ClickHouse, except document bytes in an optional
S3-compatible bucket and, under the Postgres memory authority, the Postgres tables: live
memory, knowledge state and the records beside them. The bucket is used with
LOOM_DOCUMENT_OBJECT_STORE=s3. ClickHouse holds projections of the live memory and
knowledge state Postgres holds; storage authority
lists every Postgres table. Loom's ClickHouse databases are loom and loom_telemetry.
The canonical ClickHouse schema is schemas/schema.sql,
dumped from a server with every migration applied; the Postgres schema is the journal in
schemas/pg_migrations/. The generated schema reference lists
every ClickHouse table with its engine, keys, TTL and indexes. This page covers the
table families, how Loom uses engines, indexes and views, and how migrations run.
Goals and non-goals
| Goals | Non-goals |
|---|---|
| One engine for every plane (ClickHouse authority), so a read never joins across stores. | Cross-table transactions. A primary row lands first; derived rows follow. |
Every row names its tenant, writer (actor) and content (content_sha256). |
Transaction-time history on every table. The version mirror covers loom.memory. |
| Mutable state as append-only versions: a write is an INSERT, a read is deterministic. | In-place updates on the request path. Updates are new versions or batched background folds. |
| Nothing that holds memory content expires unless its writer or the operator asked for it. | Default retention on facts, documents, conversations or the graph. |
| Every table classified for erasure. | A compliance guarantee. |
Table families
"Written by" names the code or worker that inserts rows. Retention is in Partitioning and TTL.
| Family | Main tables | Written by |
|---|---|---|
| Memory facts | memory, memory_versions, shared, working_memory |
memory.set, memory.bulk_set, lifecycle ops, working_memory.*; memory_versions_mv fills the history |
| Memory embeddings | memory_embeddings, memory_reinforcement, memory_term_freq, memory_doc_stats |
The same write paths (build_embedding_row); embedding_worker fills stubs; usage_fold; refreshable views for BM25 |
| Evidence and chunks | memory_evidence, memory_chunks, memory_chunk_sources, memory_belief_sources, session_summaries, derived_facts |
set_from_messages projections, consolidation |
| Records and taxonomy | memory_records, memory_record_versions, memory_record_fields, memory_record_conflicts, memory_schemas, memory_schema_fields, categories, memory_categories |
memory.record.*, namespace.*, category assignment |
| Write-path state | memory_extraction_queue, memory_projection_failures, memory_drops, blacklist, blacklist_version, conflict_allowlist, learnings_queue, consolidation_runs, memory_ask_precedents |
Deferred extraction, fail-open projections, blacklist.*, conflicts.resolve, memory.consolidate, memory.ask |
| Knowledge | documents, document_blobs (uploaded originals), chunks, chunk_supersessions, chunk_term_freq |
knowledge.add; chunk_term_freq_mv, chunk_context_term_freq_mv |
| Conversations | conversations, conversation_threads, conversation_embeddings |
conversation.*; conversation_titler |
| Entity graph | entity_edges, entity_degree, entity_triples, entity_edge_memories, entity_edge_rows, entity_triple_memories, entity_triple_rows, entity_vectors, entity_summaries, entity_canonical_map, entity_communities, operator_edges |
The memory.set after-write projection, entity_extractor, entity_communities; entity_degree_mv |
| Code index | code_files, code_chunks, code_symbols, code_edges, code_pr_history and the other code_* tables |
code_ingest, the code_scip_* workers, code_pr_history, code_embed_recovery, code_summary, code_graph_stats |
| Tracker | qa_sources, qa_item_embeddings, qa_link_candidates, qa_test_daily, qa_rollup_days, qa_fail_hours |
tracker.source.add, tracker_embed, tracker_links, tracker_test_daily |
| Runs, tasks, briefs | agent_runs, agent_turn_usage, session_outcomes_by_user, procedure_runs, tasks, task_events, task_leases, agent_run_claims, task_inboxes, session_briefs, context_blobs and neighbours |
run.*, task.* / inbox.*, session.brief_*, context.offload, reflection_distill |
| Namespaces, auth, governance | namespaces, namespace_grants, namespace_policies, namespace_types, namespace_snapshots, namespace_calibration_*, service_accounts, user_state, revoked_tokens, signing_keys, auth_attempts, runtime_overrides, oauth_state, idempotency, policy_decisions and neighbours |
namespace.*, grants.*, sign-up and claim flows, recalibration, the idempotency cache |
| Telemetry, audit, cost | events, agent_events, agent_cost_rollup_daily, audit_log, audit_checkpoints, search_trace_log, memory_exposures, memory_usage_events, memory_usage_totals, memory_served_daily, code_search_trace, worker_status and neighbours |
The events stream, the op ledger, observability/audit.py, audit_verifier, the supervisor |
| Service levels | loom_telemetry.sli_minute (its own database: telemetry Loom derives from its tables lives outside loom) |
sli_minute_refresh, once a minute over the agent_events request rows with an SLO class |
| Erasure | erasure_requests, erasure_reversals |
erasure.request, erasure_recovery, memory.restore |
| Provider caches | embedding_cache, llm_response_cache |
The embedder and LLM client wrappers |
Why the SLI rollup has its own database. loom holds what Loom serves; derived
operational telemetry stays outside it, so a dashboard or alert user gets SELECT ON
loom_telemetry.* without any grant on the database that holds memory, and the rollup can
be paused (SYSTEM STOP VIEW / SYSTEM START VIEW on its refresh), truncated and
refolded, or given its own retention without touching loom. A refold is SYSTEM STOP
VIEW loom_telemetry.sli_minute_refresh, TRUNCATE TABLE loom_telemetry.sli_minute (its
sentinel goes with it), then SYSTEM START VIEW; stopping first matters, since a refresh
that commits after the truncate writes a new sentinel and the refold never starts. It
reads the last 31 days of the ledger only, an hour per refresh, so it takes about 12 hours
and loom.sli.rollup_lag_s shows how far it is; a deployment that wants the longer
history backs loom_telemetry up too. Do not drop the view: migration 045 is journaled as
applied, so nothing recreates it. The cost is one more database: the setup SQL grants the
migrating user CURRENT GRANTS ON loom_telemetry.*, and preflight names that grant when
it is missing.
State outside ClickHouse. Under the Postgres memory authority, Postgres holds the
tables storage authority lists. Uploaded document
originals are rows of document_blobs, addressed per namespace by SHA-256
(server/knowledge/store/blobs.py); with LOOM_DOCUMENT_OBJECT_STORE=s3 each row is
the catalog entry of an object in an S3-compatible bucket, which holds the bytes
(knowledge). The token signing
keys (signing_keys), admin runtime overrides of rate limits, latency budgets and live
settings (runtime_overrides), sign-in attempt counts (auth_attempts), graph-walk
marks (graph_projections: the projections in flight and each namespace's last graph
write), each namespace's last write (namespace_write_keys) and the supervisor lease
are in ClickHouse too, so a new Loom pointed at this ClickHouse (and its Postgres, under
the Postgres memory authority), and the document bucket while rows name objects in it,
gets every feature back. On the filesystem: the code plane's git mirrors under
var/code_repos, a cache of the remotes ClickHouse records that a host without them
re-clones. The root secret LOOM_JWT_SECRET, which seals the signing keys and stored
credentials, is in the env. Process caches and file locks are listed in
system overview.
Conventions every table follows
-
Identity prefix. Tenant rows lead the sort key with the identity tuple, and every read filters on it, so the primary index prunes other tenants' granules first. A per-namespace erasure, snapshot or retention override is one key range, not a scan.
Column Meaning Default orgTenant boundary; required — namespaceUnit of sharing and grants; required — agentWriter agent; required on memory rows — user_idEnd user the row is about '-'session_idSession the row came from '-''-'means "not applicable" and is stored;'*'is a read-time wildcard, never stored. Tables that are not per agent (code index, tasks, namespace config) key on(org, namespace, …)or(org, inbox, …). Memory tables addrun_id. The server stampsactorandsource_classon memory writes (system overview).
LowCardinality(String)fororg,namespace,agent, model names, statuses and event kinds.- Append, then collapse. Mutable state is a
ReplacingMergeTree; writers insert a new row with a higher version and reads useFINAL. Mutations stay off the request path. - Waited inserts. A write that a later read depends on is sent with
wait_for_async_insert = 1, so it is written when the insert returns: committed (async_insert = 0, the client's session default) or batched (batched inserts). Fire-and-forget telemetry is sent withasync_insert = 1, wait_for_async_insert = 0. - Times are UTC.
DateTimecolumns carry no zone of their own, so the server client and the migration runner pinsession_timezone = 'UTC'and bind aware params in UTC: a server whose<timezone>is something else reads and writes the same instants. - Retries never duplicate. Every insert carries an
insert_deduplication_tokenfrom the batch and a per-batch nonce (insert_dedup_token), so a retry after a dropped socket lands the batch once. An async insert is deduplicated by that token only, never by content, so two identical rows in one flush window both land. A caller that passes its own token names the row's identity instead, so two writers of one row land it once:conversation.save_messagewrites a new transcript row this way.
Batched inserts
A committed insert makes one part, so a table written on every request makes parts at
the request rate, and merges fall behind ("too many parts"). The rows written on every
request or by every process on a short interval are batched inserts, sent with
async_insert = 1, wait_for_async_insert = 1 (server/core/ch_writes.py). They fall in
two groups:
- Switched by
ch_batched_inserts(batched_insert_settings):audit_log, theconversationsturns ofconversation.appendandconversation.compact's summary turn, the turnconversation.save_messagerewrites when deduplication dropped its first write, theconversation_embeddingsrows (conversation.append,conversation.save_message,conversation.compact; under the width rule below), theagent_eventsflush,memory_webhook_outbox,memory_evidenceandmemory_belief_sources.memory_webhook_attemptsstays committed: one worker writes it twice a pass, one insert after the other, so batching would save no parts. - Waited async inserts either way (
async_insert_settings):auth_attempts, the policy witness'sagent_eventsandmemory_exposuresrows,memory_projection_failures, the graph Undo markers, thenamespace_write_marksflush, the usage-event flush, the carry-acknowledgement publish and withdraw, every waited entity-graph insert (memory.set,memory.bulk_set;graph_insert_settingswithwait=True), and a follow plan's successor inserts (entity_triple_memories,entity_triple_rows,entity_triples).
ClickHouse collects the concurrent inserts into one table in one part per partition and
answers each once those parts are written. On return the row is as durable and as
visible as a committed insert's, and a caller's next insert lands in a later part, so
the order of one caller's writes holds. The buffer is ClickHouse's, so a Loom process
that stops loses nothing it acknowledged. A flush that fails as a whole fails every
insert waiting on it; a retry of any of them lands once, under its own token. Each
insert waits between ch_batched_insert_wait_min_ms (writes are sparse) and
ch_batched_insert_wait_max_ms (under load) for others to join it, and holds its pooled
connection for the wait. The insert sends async_insert_use_adaptive_busy_timeout = 1
so these bounds hold whatever the server profile sets; with it off, every flush waits
the full maximum. Under steady load a table gets about 1000 /
ch_batched_insert_wait_max_ms parts a second per ClickHouse node, insert shape
(ClickHouse keeps one buffer per column list and settings) and partition written: more
when a buffer fills before the wait ends, at most one per insert when writes are sparse.
The insert also sends async_insert_max_query_number = 10000, so at high rates the
window, not ClickHouse's default of 450 statements, decides the flush.
Two windows: the usage-event flusher and shutdown drain and the write-mark flush wait up
to ch_batched_insert_background_wait_max_ms (200 ms, ClickHouse's own default), so they
collect more rows per part. Every other batched or waited async insert waits at most
ch_batched_insert_wait_max_ms (50 ms), the agent_events timer flush included
(flush_now waits for a flush in flight), and so does a usage-event flush a request
makes because the buffer is full. The two windows are two insert shapes, so they fill
separate buffers.
ClickHouse's own target is about one insert, one part, a second per table, with 1,000
rows or more per insert. These windows stay well short of it; they cut the part rate
from one per insert to the rate above. Batching trades latency for parts: each waited
insert adds up to its window, and holds its pooled connection meanwhile. A sustained
R_request request-path and R_background background batched inserts a second therefore
hold at least R_request × ch_batched_insert_wait_max_ms / 1000 + R_background ×
ch_batched_insert_background_wait_max_ms / 1000 connections: 100 conversation.append
a second, two batched inserts each, hold at least 100 × 2 × 50 / 1000 = 10. That is a
lower bound: a waiting insert holds its connection for the window and then the flush
and part write, which queue up and grow under load. A memory.set waits on six or
more: the audit row, the outbox row (webhook namespaces), the entity_edges and
entity_edge_memories inserts, the entity_triples and entity_triple_memories
inserts, plus carry_acks on a carried write; memory.set_from_messages waits on
about ten. The entity-graph inserts of both are waited async inserts, so
ch_batched_inserts = false leaves them holding connections: a process taking mostly
memory or bulk writes sizes its pool for them either way, or lowers
ch_batched_insert_wait_max_ms. One process taking 200 writes a second, two batched
inserts each (bench/write_rate.py --target client), refused 3,367 of 12,000 writes
with 503 OVERLOADED at ch_pool_maxsize = 20, the formula's bound, and none at 40 or
64; that is one run on one host, so read it as an order of magnitude. Size
ch_pool_maxsize at three times the bound or more, plus the process's other
statements: the default 64 holds three times the bound for about 300 request-path
batched inserts a second, with about 20 connections left for other statements. A
process above that rate either raises ch_pool_maxsize or sets
ch_batched_inserts = false; lowering ch_batched_insert_wait_max_ms also frees
connections (troubleshooting).
ch_batched_inserts (live, on by default) switches batching off for the first group
above: off, those inserts are committed, one part each. The second group waits on the
same bounds either way, and fire-and-forget inserts are unchanged. Turn it off
(LOOM_CH_BATCHED_INSERTS=false) on a process whose write rate exhausts the pool as
above and whose pool cannot grow, or on a deployment with little traffic where latency
matters more than parts: with writes too sparse to share a part, each insert still waits
ch_batched_insert_wait_min_ms.
ClickHouse keys the async buffer by table, statement shape, settings and user, so one
flush can hold rows from several orgs, and one row that fails (a vector of the wrong
width, code 190) fails the flush for every caller in it. An embedding row is batched only
when its coded width equals embedding_dim, and a conversation_embeddings row whose
shared flush failed with code 190 is sent again, committed; the other batched tables
carry no coded vector.
These tables stay committed:
- Version rows of
memory,memory_embeddings,shared,session_summariesandentity_summaries: their fencedINSERT … SELECT FROM input()(rolling upgrades) cannot be buffered. Under the Postgres authority thememoryandmemory_embeddingsrows of committed writes are batched by Loom instead: each process sends every commit queued since its last flush in one committed insert per table, at most 1000 /pg_publish_interval_msa second, a part for each partition its rows span (publication). entity_canonical_map: it is partitioned byorg, so a shared flush that spans more thanmax_partitions_per_insert_blockorgs would fail every insert waiting on it.- Rows with a
Quantized-coded vector (entity_vectors,memory_chunks): the codec refuses a vector of another width when the part is written (code 190), so one mis-sized row would fail every other insert in its flush. - A
conversation_embeddingsrow that could be mis-sized (embedding_insert_settings): the row is batched when the column is plain, or when it is coded at the served model'sembedding_dimand the vector has that width (an empty vector is padded to it). Without a reading of the column, or while the width the column was coded at differs fromembedding_dim(a model change before its rebuild, or a rebuild this process has not read yet), the row is committed. A batched row whose flush another process's stale-width row failed is sent again, committed. - A write whose token names the row's identity (
conversation.save_message): a replicated table keeps the tokens of committed and of buffered inserts apart, so every writer of one identity sends it the same way.
Engines and why
| Engine | Used for | Why |
|---|---|---|
ReplacingMergeTree(version) |
Mutable rows: memory, memory_embeddings, shared, working_memory, the code_* index, tasks, config |
An update is an INSERT of a whole row; FINAL keeps the newest per key. No locks. |
ReplacingMergeTree(created_at) or (updated_at) |
Rows written once per key: memory_evidence, memory_chunks, caches, idempotency, namespaces |
The same collapse, versioned by the row's own timestamp. |
ReplacingMergeTree with no version |
memory_versions, code_symbol_ids, code_symbol_names |
version or the full identity is in the sort key, so only exact re-inserts collapse. |
MergeTree |
Append-only logs: events, agent_events, audit_log, conversations, task_events, entity_edges, traces |
Rows are facts about a moment. |
SummingMergeTree |
Counters: entity_degree, memory_usage_totals, memory_served_daily, code_unit_signal |
A counter is an INSERT of a delta, never a read-modify-write. |
AggregatingMergeTree |
Non-additive rollups: memory_term_freq, chunk_term_freq, code_term_freq, agent_cost_rollup_daily, memory_exposure_breadth_hourly, query_nodes, namespace_write_marks, subject_last_seen, loom_telemetry.sli_minute |
Partial aggregate states (a distinct count cannot be summed), finished with -Merge. |
KeeperMap |
task_leases, agent_run_claims, oauth_signin_state and the migration lock loom._migration_lock (also the workers_supervisor lease, the claims and the compiler-tier scip_entry:* keys) |
Linearizable compare-and-swap across processes. |
One keeper_map_path_prefix per deployment. The KeeperMap paths are fixed in the
migrations (/loom/migration_lock, /loom/instances, /loom/task_leases,
/loom/agent_run_claims, /loom/graph_projections, /loom/namespace_write_keys), and
ClickHouse puts them under the server's keeper_map_path_prefix. Two deployments whose
ClickHouse servers share a Keeper and a prefix share these keys: one workers lease, one
migration lock and every claim between them. Give each deployment its own prefix.
Why ReplacingMergeTree for mutable state. ALTER … UPDATE rewrites whole parts
asynchronously with no read-your-write guarantee. Lightweight UPDATE writes patch parts
that every later read must apply, and switches off the quantized-codes read
(vector storage). A version row is one INSERT, visible to the next
FINAL read, with its history in the mirror. The price is the write rule below and the
FINAL cost, which the identity prefix keeps to one tenant's granules.
On the memory tables version is a wall-clock nanosecond stamp
(server/memory/store/rows.py, next_version), so two replicas' versions compare, at the cost
of bounded clock skew. Configuration tables version on updated_at, created_at or
version.
The FINAL write rule
An update, tombstone, restore, touch, lock or promotion is an INSERT of a complete row
with a higher version. A version row must carry every persisted column. ClickHouse
fills an omitted column with its DDL default, and under FINAL that default replaces the
real value for every reader. Use the builders in server/memory/store/rows.py:
| Builder | Table | Behaviour |
|---|---|---|
build_memory_version_row(existing, **changes) |
memory |
Copies every column in MEMORY_PERSISTED_COLS, applies the changes, keeps the stored value bytes. |
build_revision_row(existing, new) |
memory |
A new value over the live row: carries usage, lineage, identity, lock and expiry; resets REVISION_RESETS; keeps the attestation. |
build_first_version_row(new, now=…) |
memory |
Completes a first write with the DDL defaults (FIRST_VERSION_DEFAULTS), so every first version passes the insert funnel's check. |
insert_memory_versions(rows) (inserts.py) |
memory |
The only loom.memory insert: refuses a row missing a persisted column or naming an unknown one, then inserts committed. |
build_embedding_row(**columns) |
memory_embeddings |
Full row from EMBEDDING_PERSISTED_COLS defaults; an unknown column raises; fills content_excerpt_lemma and a fresh version. |
build_session_summary_row, build_entity_summary_row |
session_summaries, entity_summaries |
The same pattern. |
A new column goes into the builder's column set and every projection that feeds it, in
the same change. MATERIALIZED columns (content_excerpt_lc, content_excerpt_stem,
value_preview, value_len) must not appear in an insert. Other versioned tables
(namespaces, code_summaries) read the full row before re-inserting it.
Two query rules keep FINAL correct:
- Filter identity columns in
PREWHEREor beforeFINAL; keep row-state predicates (tombstoned_at,status, expiry, validity) inWHEREafterFINAL. A state predicate before the collapse drops the tombstone and brings back the version under it. - Never alias an expression with the name of a column it filters on
(
SELECT toString(memory_id) AS memory_id … WHERE memory_id IN …). TheWHEREresolves to the alias and silently matches nothing.tests/unit/test_sql_alias_shadow.pyfails the build on this pattern.
Content hashes
content_sha256 on loom.memory is the SHA-256 of the value's canonical JSON. It is used
for:
- Optimistic concurrency.
memory.setandmemory.update_by_idacceptif_match_sha256; a mismatch answers 409PRECONDITION_FAILED. The hash ridesmemory.get,memory.get_by_idandmemory.setresponses. - Attestation binding.
verified_sha256records the bytes someone attested. A revision keepsverified_byandverified_at, but the hashes no longer match, so readers derive "stale" from the pair. - Audit. Every write that creates or changes memory leaves one hash-only
audit_logrow (key, id, hash, actor, source class, verdict; never the value).
A code chunk's id is a UUIDv5 over its path, scope and content hash, so an unchanged chunk re-ingests to the same row.
Sort keys
After the identity prefix, a sort key serves the table's dominant read:
| Table | ORDER BY |
Read it serves |
|---|---|---|
memory |
(org, namespace, agent, user_id, session_id, memory_key) |
Point reads by key; the collapse unit is one key per identity. |
memory_embeddings |
(org, namespace, agent, user_id, session_id, scope, embedding_model, memory_key) |
Vector legs for one identity and one model; two models' vectors are separate rows. |
memory_versions |
(org, namespace, agent, user_id, session_id, memory_key, version) |
History of one key; version in the key keeps every row. |
code_chunks |
(org, namespace, embedding_model, path, chunk_id) |
All chunks of a path, for re-ingest diffs and file reads. |
code_edges |
(org, namespace, tier, to_symbol_id, edge_kind, from_symbol_id, path, line) |
"Who calls X" is a key range. Projections by_from and by_path serve callees and per-file reads. |
code_symbols |
(org, namespace, tier, qualified_name, symbol_id) |
Definition by qualified name; code_symbol_names_mv and code_symbol_ids_mv re-key it. |
entity_edges |
(org, namespace, entity_a, entity_b) |
Neighbours of an entity. |
task_events |
(org, inbox, seq) |
An inbox's events in order. |
Sort and partition keys are CREATE-only. A migration that changes one declares
-- loom: rebuild-if-empty <table> above the full CREATE TABLE. The runner swaps an
empty table for its twin and refuses a table with rows, naming
scripts/ops/table_rebuild.py (twin, copy, EXCHANGE TABLES) (server/core/schema/table_rebuild.py).
Partitioning and TTL
Most time-series tables are PARTITION BY toYYYYMM(<time column>). Tenant directory tables
(namespaces, namespace_grants, service_accounts, user_state, …) are partitioned by
org; working_memory and shared by (org, namespace). Small tables read whole
(code_pr_history, code_pr_reviews) have no partition key.
code_chunks is partitioned by (org, namespace), so each repository index has its own
parts, the text-index posting lists cover one namespace, and a retired index leaves by
DROP PARTITION. The key is declared in the baseline's CREATE.
No table deletes memory content by default. Facts, embeddings, documents, chunks,
conversations and the graph have no TTL. They leave only by an explicit delete, a
writer-set expiry (ttl_seconds, applied by memory_expiry_sweep per the row's
on_expire) or erasure. Tables with a schema TTL hold telemetry, caches or runner state:
| Window | Tables |
|---|---|
| 60 minutes | code_search_memo |
| 3 days | memory_webhook_outbox, memory_webhook_attempts |
| 7 days | llm_response_cache, code_scip_parts |
| 30 days | events, embedding_cache, memory_drops, memory_extraction_queue, memory_ask_precedents, worker_status, code_scip_batches |
| 90 days | erasure_requests, oauth_state, policy_decisions |
| 180 days | search_trace_log, memory_exposures, memory_exposure_breadth_hourly, consolidation_runs, code_search_trace, code_search_outcome |
| 400 days | loom_telemetry.sli_minute |
| 5 years | erasure_reversals |
| other | idempotency (at expires_at), revoked_tokens (10 days past token expiry), context_blobs (1 day past expires_at) |
Operators can add retention to audit_log, agent_events, memory, memory_embeddings,
conversations, chunks and documents with LOOM_RETENTION_<TABLE>_DAYS, and tiering
with LOOM_TIERING_<TABLE>_WARM_DAYS plus LOOM_TIERING_WARM_VOLUME_NAME. Per-namespace
retention_days or warm_days overrides become a multiIf in the same TTL expression
(server/storage/retention_overrides.py). The worker leader applies them at boot and
retention_reconcile keeps them in step, skipping unchanged tables because each
MODIFY TTL is a mutation. When the last window on a table is cleared, the same pass
issues ALTER TABLE … REMOVE TTL; none of these tables has a schema TTL. Every window
defaults to 0 (off).
Merges and skip indexes
A part count that keeps rising almost always means merges are failing. A skip index a
merge cannot build fails every merge of that table until inserts get TOO_MANY_PARTS.
Loom keeps such indexes out of merges with exclude_materialize_skip_indexes_on_merge
(the text indexes content_text and context_text on chunks): inserted parts still
build the index, merged parts do without it. The clickhouse_parts check of ops.health
(_clickhouse_parts_check) reads system.part_log for failed MergeParts events in the
last quarter hour and reports the error code and this repair; the exception's text
stays in system.part_log. For the same reason
memory-content tables carry no rows TTL. The runbook is in
troubleshooting.
memory_embeddings also names embedding_hnsw_idx in that setting, an index no migration
declares. The setting does nothing unless an operator adds a vector index of that name by
hand, and then keeps merges from rebuilding its graph, so no migration resets it.
The write → derive loop
A write inserts its primary rows and returns. Everything else is derived afterwards, by one of three mechanisms:
- Insert-time materialized views run inside the source INSERT:
memory_versions_mvcopies everymemoryrow intomemory_versions, andmemory_usage_totals_mvandentity_degree_mvfold their source rows into totals.memory_served_dailytakes no view: an agent read inserts its counts there directly, apart frommemory_usage_events, whose totals the usage fold turns into ranking, so a served read never moves the reinforcement signal. - Refreshable materialized views (
REFRESH EVERY 1 MINUTE APPEND) append rows newer than their high-water mark: BM25 statistics, the daily cost rollup, hourly exposure breadth, last seen per subject, the request SLIs per minute. The SLI rollup's mark isagent_events.ingested_at, when ClickHouse took the row, so a delayed flush is not skipped. - Workers poll for rows that need work and write new versions: stub embeddings
(
embedding_worker,code_embed_recovery), entities (entity_extractor), usage folds (usage_fold), deferred extraction (extraction_queue,observe_extract), failed projections (projection_repair), orphaned vectors (orphan_vector_reconcile), expiry (memory_expiry_sweep) and TTLs (retention_reconcile).
There is no queue service. A queue is a table whose rows carry their state:
memory_extraction_queue rows move pending → running → done | failed (or held until a
model-call hold ends) as versions; an embedding row with embedding_dim = 0 is a stub
waiting for a vector; a memory_projection_failures row is a projection to replay. Read
paths filter unfinished rows out.
flowchart LR
W["memory.set / bulk_set"] --> M[(memory)]
W --> E[(memory_embeddings)]
M -- "memory_versions_mv" --> MV[(memory_versions)]
E -- "refresh 1 min" --> BM[(memory_term_freq<br/>memory_doc_stats)]
W --> AU[(audit_log)]
E -. "stub dim=0" .-> EW[embedding_worker]
EW --> E
W -. "after-write task" .-> G[(entity_edges<br/>entity_triples)]
G -- "entity_degree_mv" --> DEG[(entity_degree)]
U[(memory_usage_events)] -- "memory_usage_totals_mv" --> UT[(memory_usage_totals)]
UT --> UF[usage_fold]
UF --> R[(memory_reinforcement)]
UF --> M
Why derive. A memory.set that awaited its graph projection, BM25 update and audit
fan-out would pay each round trip and fail when any failed. The response path keeps only
what the caller's next read needs: the memory row, its memory_embeddings row, the
same-key supersession and the audit receipt. Projections are fail-open
(memory/app/after_write.py), and a read of the same namespace in the same process waits
for the graph projection (after_write.settle). A derived table lags its source by
seconds; the BM25 legs merge that lag themselves.
An embedding row may land under the refresh watermark when its version is more than 5 s
old once it is in (half the views' 10 s lag). A long embed or judge, or slow inserts, do
that. Its own write folds such a row into the BM25 tables after the response
(after_write.index_late_rows), so a slow write does not also wait on two more inserts.
The memory_versions mirror
Merges discard superseded memory rows, so the RMT table is not a history.
memory_versions_mv copies every inserted memory row into memory_versions, which keeps
each version. memory.history, memory.redact, namespace.rollback and the usage fold
read it; the erasure saga deletes from it. A new memory column goes into
memory_versions and the view's SELECT in the same migration;
tests/unit/test_memory_versions_schema.py compares the column lists.
Materialized views
| View | Kind | From → to |
|---|---|---|
memory_versions_mv |
insert-time | memory → memory_versions |
memory_record_versions_mv |
insert-time | memory_records → memory_record_versions |
memory_usage_totals_mv |
insert-time | memory_usage_events → memory_usage_totals |
entity_degree_mv |
insert-time | entity_edges → entity_degree |
session_brief_revisions_mv |
insert-time | session_briefs → session_brief_revisions |
chunk_term_freq_mv, chunk_context_term_freq_mv, code_term_freq_mv, code_doc_stats_mv, code_symbol_ids_mv, code_symbol_names_mv |
insert-time | Chunk and code-index statistics and lookups |
memory_term_freq_refresh, memory_doc_stats_refresh |
REFRESH EVERY 1 MINUTE APPEND |
memory_embeddings → BM25 statistics |
agent_cost_rollup_refresh |
REFRESH EVERY 1 MINUTE APPEND |
agent_events → agent_cost_rollup_daily |
memory_exposure_breadth_hourly_refresh |
REFRESH EVERY 1 MINUTE APPEND |
memory_exposures → memory_exposure_breadth_hourly |
subject_last_seen_refresh |
REFRESH EVERY 1 MINUTE APPEND |
agent_events → subject_last_seen |
loom_telemetry.sli_minute_refresh |
REFRESH EVERY 1 MINUTE APPEND |
agent_events → loom_telemetry.sli_minute |
Insert-time or refreshable. An insert-time view writes one part per source insert. That
suits a low-rate source or a target read back in the same request (memory_versions). A
high-rate source uses a refreshable view that appends one part a minute. The BM25 rollups
keep their mark in a sentinel row (org = '', namespace = '', term = '') carrying
max(version) and read only rows at least ten seconds old. They lag a write by about a
minute, and memory.search merges the newer source rows (LOOM_MEMORY_SEARCH_BM25_LIVE_TAIL,
on), so BM25 scores are exact.
The events stream. loom.events carries budget violations only:
server/observability/events_stream.py queues them and writes one committed insert per
flush. Every other telemetry writer (audit rows, exposures, search traces, query nodes,
code-search outcomes) inserts into its own table directly; no view fans events out. The
op ledger (agent_events) is queued the same way, and each flush is a batched insert
(server/observability/events.py), so the flushes of every process share parts. A failed
flush never fails a request; a crash loses the queue.
Vector storage
- The vector columns of
CODED_TABLESareArray(BFloat16), coded withQuantizedor plain depending on the install (the Quantized codes bullet below says which). Loom's migrations code no other vector column; a table named inLOOM_CODE_VECTOR_TABLESkeeps the layout and width it was created with.entity_vectors.entity_embeddingkeepsLZ4because its vectors repeat. Coded, it keeps theLZ4afterQuantizedonly whereallow_suspicious_codecscan be on; where it is held at 0, as on a ClickHouse Cloud service by default, the column is stored without it. How to add the chain back is in recommended settings. Query vectors bind asArray(Float32). A memory carries up to two vectors (value and key), so at 3072 dimensions up to 12 KiB of vectors. - No table has a vector-similarity (HNSW) index. Search is a
cosineDistancescan over the rows the identity predicates leave: exact, or codes first and rescored where codes-first reads (below) apply. The bytes a read scans therefore grow with namespace size, not corpus size. Why there is no vector index has the measurements. - Quantized codes. On ClickHouse 26.8+, a fresh install creates the vector columns of
CODED_TABLES(server/core/schema/vector_codec_ddl.py) withCODEC(Quantized('rabitq', <dim>, 0))whereenable_quantized_codecis on, or Loom's ClickHouse user may turn it on. The runner rewrites theCREATEat apply time (Gated ClickHouse settings). On a server that holdsenable_quantized_codecat 0, a fresh install creates the plain layout, andscripts/ops/vector_codec_rebuild.pyapplies the codec once the setting is on. ClickHouse Cloud holds the setting read-only at its default 0 until the operator enables it for the service with a settings-profile override; it then reads 1, still read-only, and coded tables work. An existing table moves with the same tool. - Codes-first reads. When a column is coded and its table has no patch parts, reads
send
vector_search_use_quantized_codes = 1(LOOM_VECTOR_SEARCH_QUANTIZED_CODES): a pass over the codes keepsLIMIT × LOOM_VECTOR_SEARCH_QUANTIZED_FETCH_MULTIPLIERcandidates and rescores them at full precision. Coding and patch parts are re-probed everyLOOM_VECTOR_CODES_STATE_PROBE_SECONDS; a failed probe reads as "not patch-free". A replica that rejects the setting has it dropped and the read retried. The memory fact leg has its own switch,LOOM_MEMORY_SEARCH_FACT_CODES_FIRST. Itsautofollows its own patch-parts probe (everyLOOM_MEMORY_EMBEDDINGS_PATCH_PARTS_PROBE_SECONDS). Bothonandautoread as off whileLOOM_VECTOR_SEARCH_QUANTIZED_CODESis false or the replica does not know the setting. Its over-fetch isLOOM_MEMORY_SEARCH_FACT_CODES_FETCH_MULTIPLIER. - Fixed-length placeholders. Rows awaiting an embedding store zero vectors with
embedding_dim = 0(server/core/vectors/placeholders.py). Every vector leg filtersembedding_dim > 0. - No in-place updates on vector tables. Write, fold, reject and tombstone paths write
new versions; reinforcement lives in
memory_reinforcement. A lightweightUPDATEonmemory_embeddingswould leave a patch part, and the patch-parts probe keeps codes-first reads off for the whole table until it merges. - One model per row.
embedding_modelis in the sort key, so a search compares only its own model's vectors.loom reembedregenerates vectors after a model change (--in-databaseusesaiEmbedon 26.7+).
Query shape. ClickHouse pushes a top-k into the codes-first scan only for the plan
Limit → Sorting → Expression → [Filter] → ReadFromMergeTree with one ascending sort key
that is a bare distance call. ORDER BY 1 - cosineDistance(…) DESC, a second sort key,
LIMIT n BY, a window function or a JOIN fall back to a full scan silently. Vector
queries order by a bare distance alias and compute similarity in Python. The chunk leg
reads candidates with no FINAL or JOIN, then looks them up by primary key under
FINAL. tests/unit/test_vector_search_query_shape.py guards the shape.
Why there is no vector index
A vector_similarity index is one HNSW graph per part. It does not fit Loom's reads, for
four reasons. Unless a paragraph says otherwise, the figures come from ClickHouse 26.9
with 1M synthetic 1536-dimension vectors in tables shaped like memory_embeddings (its
sort key, ReplacingMergeTree, index_granularity = 256, one merged part per
partition), an index built as
vector_similarity('hnsw', 'cosineDistance', 1536, 'bf16', 16, 128), and 30 queries per
filter; the command is in bench/vector_index_vs_exact.py.
A tenant read never uses it. ClickHouse searches the graph only when the primary-key
condition keeps every granule of the part. Every Loom vector read filters on
(org, namespace), the prefix of every vector table's sort key. In every vector table
but code_chunks a part holds many tenants (memory_embeddings is partitioned by
month), so the read takes the tenant's granules and sorts them exactly: no index search
runs (ProfileEvents['USearchSearchCount'] = 0) and the read costs what the plain table
costs.
MiB read and p50 per query, every read under FINAL:
| Tenant share | Rows | Exact scan | With the index | Codes-first, LIMIT 60 |
|---|---|---|---|---|
| 50% | 500k | 1,478 MiB, 141 ms | 1,478 MiB, 140 ms | 134 MiB, 55 ms |
| 10% | 100k | 295 MiB, 491 ms | 295 MiB, 476 ms | 49 MiB, 44 ms |
| 1% | 10k | 30 MiB, 57 ms | 30 MiB, 55 ms | 21 MiB, 18 ms |
| 0.1% | 1k | 3.2 MiB, 12 ms | 3.2 MiB, 13 ms | 3.4 MiB, 9 ms |
Compare the bytes: the 10% tenant's read was slower than the 50% tenant's in both runs,
so the milliseconds do not rank by size. The vector_search_filter_strategy,
vector_search_index_fetch_multiplier and hnsw_candidate_list_size_for_search
settings change nothing here: the index is never opened. The codes-first column reads
the fact leg's 60 rows at codes multiplier 1 (the leg's default is 3); recall@10 of those
60 rows against the exact top 10 was 0.95-1.00.
Where it does run, a selective filter loses recall. With a predicate outside the sort
key the graph runs first and the predicate drops its candidates afterwards. recall@10
against the exact answer, by vector_search_index_fetch_multiplier:
| Rows the filter admits | x1 | x10 | x100 |
|---|---|---|---|
| all | 0.783 | 0.783 | 0.783 |
| 10% | 0.200 | 0.820 | 0.933 |
| 1% | 0.080 | 0.163 | 0.723 |
The unfiltered row is this graph's own recall on this data at
hnsw_candidate_list_size_for_search = 256 (0.847 at 1024). A partition per tenant
makes the index run for tenant reads: the 50% tenant's read under FINAL then takes
76 ms instead of 141 ms, at recall@10 0.757, and the 10% and 1% tenants get 0.747 and
0.843. code_chunks is partitioned by (org, namespace), so an index there would run
and read this way; the recall loss and the stale versions below are what rule it out.
For memory_embeddings, a partition per namespace is not a layout a table with
thousands of namespaces can hold.
Under FINAL it serves superseded versions. The graph returns rows of every version
before FINAL collapses them, so a key's newest version or tombstone loses to an older
row. bench/ann_final_staleness.py (30k keys, 3 versions each, 18 parts, 128
dimensions, 5 queries) on 26.9: recall@10 0.38 with 6.2 of 10 rows stale, against 1.00
and none for the prefilter arm, which leaves the index unused. Its reread arm, which
takes candidate keys from the graph and reads them again under FINAL with skip indexes
off, removes the stale rows (recall 0.90-0.98) and takes twice as long as prefilter
(37-39 ms against 20 ms), so the index then costs more than the scan it replaces.
It doubles storage and slows writes. The graph took 2.27 GiB beside 2.25 GiB of table
data. Copying the 1M rows into the indexed table took 135-166 s over two runs, against
17-51 s into a coded table without an index. With
exclude_materialize_skip_indexes_on_merge naming the index, a merged part carries no
graph at all; without it every merge rebuilds the graph. A graph also has to stay
in the vector similarity index cache, or a read loads it from disk. On this server
(vector_similarity_index_cache_size 5 GiB, SLRU), once the partitioned table's
graphs had been loaded, all 30 reads of the single-part graph missed the cache
(VectorSimilarityIndexCacheMisses), reloaded it and took 4.8 s instead of 9 ms.
The codes-first read is what cuts a large namespace's scan instead: at 100k rows and
more it reads 6-11x fewer bytes than the exact scan, inside the tenant's granules and
under FINAL. An index becomes worth revisiting when ClickHouse searches it within the
primary-key ranges a read selects and resolves versions under FINAL.
Text indexes and lexical search
| Table | Index | Indexed expression | Tokenizer |
|---|---|---|---|
chunks |
content_text |
content_lc |
splitByNonAlpha |
chunks |
context_text |
context_lc |
splitByNonAlpha |
code_chunks |
idx_code_chunks_content_ng |
lower(content) |
ngrams(3) |
code_chunks |
idx_code_chunks_lexemes |
lexemes |
splitByNonAlpha |
All four use GRANULARITY 100000000: one granule per part, a per-part posting list.
memory_embeddings and memory_evidence carry no text index: hasAnyTokens and
hasAllTokens run on their lowercase columns after the key prefix narrows the read.
memory_embeddings also has content_excerpt_stem (MATERIALIZED, stem(…, 'en')) and
content_excerpt_lemma, filled by build_embedding_row with the query side's lemmatizer,
so no server-side dictionary is needed. The lexical legs match lowercase, stem and lemma
forms; bm25_hybrid computes Okapi BM25 in SQL from the statistics tables.
The code_chunks 3-gram index answers substring grep: it narrows granules and the exact
position() or match() predicate decides. The query must carry a
lower(content) LIKE '%…%' guard; a literal under three characters cannot use it.
Skip indexes prune the rest: bloom_filter on tags, entities, files, concepts,
memory_id, content_sha256; ngrambf_v1 on memory_key and code symbol names; minmax
on valid_to, invalid_at, version; set on status and memory_type.
Deletes and mutations
Loom deletes memory by writing a tombstone version (tombstoned_at set, every other column
carried). It is visible to the next FINAL read, keeps prior versions in
memory_versions and leaves memory.restore a window. Physical removal is for erasure
with purge=true, runner state, and the supersession rows knowledge.restore removes.
knowledge.restoreruns a lightweightDELETE FROM loom.chunk_supersessionsfor the rows one delete wrote: the document'ssource_uri, the ingestions of its revision and thesuperseded_atthe delete stamped on its row (server/knowledge/store/restore.py). The chunks then read as live throughLIVE_CHUNK_SQL, on builds N-1 and N alike. No materialized view writes the table, and a row another retirement wrote stays. When it takes itself back under a purge that may cover the document, it also deletes the oneloom.documentsrow it wrote (by itsversion), so the purge leaves no copy of the content.
- Counter folds on
loom.memory(the usage fold'sevidence_count/recall_count) use batched lightweightUPDATE: one statement per chunk of targets, with per-row values as parallel arrays throughtransform()(server/memory/workers/usage_fold.py). On ClickHouse Cloud each statement is a patch part and a Keeper entry, so cost is per statement. A pass is skipped while the table has too many unfinished mutations. Only "lightweight update unsupported" errors fall back toALTER TABLE … UPDATE; a transient error is retried next pass. - Erasure purges remove rows. On
erasure/store/mutations.py::LIGHTWEIGHT_PURGE_TABLES(vector, chunk, summary and evidence tables) a purge uses lightweightDELETE FROM, which writes only the row mask: rows vanish from every read at once and bytes leave at the next merge. Narrow tables keepALTER TABLE … DELETE. The saga polls each mutation by an embedded tag, and a residue census checks the result. - A refused knowledge write's own chunks: under the Postgres authority, a write
refused because an erasure removed the document it was built from deletes the chunk
rows it inserted (lightweight
DELETE FROM loom.chunksby source and version,chunks.delete_versions), so its text and author do not outlive the erasure. - Remaining
ALTER … UPDATE/DELETEcall sites are listed intests/unit/test_clickhouse_mutation_inventory.py, which fails on a new one until it is classified there. - Extraction retries (
memory.extraction_retry_mine) update failed queue rows in place, asynchronously. The update targets the exact identity and version observed, with the caller's namespace, principal and read scope. Copying the transcript into a new version could restore it after a concurrent erasure; this mutation changes no payload or version and cannot create a row. The request returns when the mutation is accepted, and a retry asked again while its mutation is unfinished insystem.mutationssubmits none. - Provider lock recovery takes over one
loom._migration_lockKeeper key with a conditionalUPDATEunder strict mode, once the key expired and the previous owner's participant counts as stopped (retired, or no heartbeat for itsstale_after_sand no query tagged with its id insystem.processes).loom provider-lock cleardeletes the key the same way. Both are Keeper operations, not MergeTree content mutations; an elapsed TTL alone never permits provider-lock takeover. - Run id claims (
loom.agent_run_claims, KeeperMap) live only inside onerun.begin: claim, read the run row, write it, drop the claim. A later begin that takes the free claim reads the row and goes to a fresh id.run.completeand the erasure saga'sagent_runs.deletestep also drop claims, for a key a process that died mid-begin left behind. All are Keeper key drops, not MergeTree mutations. - Sign-in state (
loom.oauth_signin_state, KeeperMap) holds each code, device grant, finished IdP round trip and session's current refresh token. A value changes only by a strict-mode compare-and-set: anINSERTfor a new key, otherwise anALTER … UPDATEconditioned on the value the writer read. Keys carry their credential's expiry, and a sweep every five minutes drops the expired ones with oneALTER … DELETE; all are Keeper key operations, not MergeTree mutations. - Grant change and account claims are leases (
server.core.leases): keys ofloom._migration_lock. A grant change holds the grant's claim from its read to its write, so a concurrent change of the same grant waits for it. Its reads are sequentially consistent, and it is cut off after 20 s (each statement after 10 s), well before its 60 s claim may be taken over. A repository maintainer's change across branches (a revoke's fan-out, a sync's mirrors) first holds one claim per (repository, subject),repo:<owner/name>, then its branches' claims in namespace order, and writes the branches in one insert inside that bound. A change that may remove an admin first holds the namespace's admins claim (the key with no subject) and counts the admins under it. A change that rewrites an account's directory row (loom.namespaces), from any plane, holds that account's claim the same way, before any admins claim.task_claims,oauth_claimsandgrant_claimsexist in the schema and no code path writes or reads them. - Graph projections and graph writes (
loom.graph_projections, KeeperMap where the server has a Keeper): a projection holds one of 32 slot keys of its namespace and its end drops it (a begin whose insert failed uncertainly leaves its slot to go stale); a begin that finds a slot older than 15 s (its process died) drops that one first; a graph write raises the namespace's write key with a version-checked UPDATE, one in flight per namespace per process. All are Keeper key operations. On a server without a Keeper the table is a MergeTree and every begin, end and write is a row under a 1-day TTL. - Dead lease and slot keys. The leader's hourly
keeper_sweepworker deletes theloom._migration_lockkeys (leases and claims) an hour past their expiry and theloom.graph_projectionsslot keys a day old: keys whose holder died before its own delete. One strict-mode Keeper delete per table; a key written meanwhile fails it and the next sweep retries. - Cell probe nonce.
loom cell preparewrites a nonce under thecell-probekey ofloom._migration_lockfor the router to read back through the cell's probe listener, then drops that key while it still holds that nonce, as a lease is released: one strict-mode Keeper delete. - Namespace write keys (
loom.namespace_write_keys, KeeperMap, only where the server has a Keeper): a write to a memory plane overwrites its namespace's key in the request tail. One Keeper write, no part, no mutation.
Classification for erasure. server/erasure/store/census.py puts every table in
schemas/schema.sql in exactly one list, and tests/unit/test_every_table_is_classified.py
fails on a table in none:
| List | Holds |
|---|---|
_CENSUS |
One Substrate(...) per table that can hold content in scope: live predicate, user predicate, owner step. |
STEP_ONLY_TABLES |
Tables a saga step clears but the census does not count, with the reason. |
NO_MEMORY_CONTENT |
Every other table, with the reason it holds no memory content. |
The saga is described in memory architecture.
Query attribution
Every statement carries a log_comment setting naming its origin, so system.query_log
groups by operation and joins Loom's ledger. It is a setting, not query text, so plans and
query hashes are unaffected.
| Origin | log_comment |
|---|---|
| HTTP or MCP request | <op> <request_id> (for example memory.search 5f…) |
| Worker pass | worker.<name> |
loom command |
cli.<command>[.<subcommand>] (not loom serve) |
| Anything else | untagged:<module>.<function> of the caller |
Request statements also send
query_id = "<request_id>:<n>:<nonce>". n counts the request's statements and the
nonce is drawn once per request, so two requests that carry the same caller id never
send the same query_id. A carried insert prefixes it with loom-carry/<process>/.
LOOM_CH_LOG_COMMENT_STATEMENT_DIGEST=true appends a
statement-shape hash and the first 200 characters; a statement that carries a secret, or
one run as the analyst, gets the hash alone. A new background loop calls
server.core.ch.set_query_tag(...); work scheduled from a request keeps the request's tag.
-- ClickHouse work per operation over the last hour
SELECT splitByChar(' ', log_comment)[1] AS op,
count() AS queries,
sum(read_bytes) AS read_bytes,
sum(query_duration_ms) AS ms
FROM system.query_log
WHERE event_time > now() - INTERVAL 1 HOUR
AND type = 'QueryFinish'
AND current_database = 'loom'
GROUP BY op
ORDER BY read_bytes DESC;
On ClickHouse Cloud, use clusterAllReplicas('default', system.query_log).
Statement classes, deadlines and traffic classes
Each statement's class comes from its leading keyword (server/core/ch.py, statement_class):
| Class | Statements | Deadline setting | Default |
|---|---|---|---|
read |
SELECT, WITH and anything not listed below |
LOOM_CH_TIMEOUT_READ_S |
30 s |
write |
INSERT, lightweight UPDATE |
LOOM_CH_TIMEOUT_WRITE_S |
60 s |
ddl |
ALTER, CREATE, DROP, OPTIMIZE, SYSTEM, EXCHANGE, DELETE FROM, … |
LOOM_CH_TIMEOUT_DDL_S |
300 s |
The deadline is applied as max_execution_time and as a client-side timeout, so the pool
slot is released even when the server never answers. statement_timeout(seconds) raises
it for one block. A statement that hit its deadline is not re-run; only transient network
errors get the one reconnect-retry. A statement that gets no pool slot within
LOOM_CH_POOL_WAIT_MS fails with 503 OVERLOADED (LOOM_CH_POOL_MAXSIZE slots per
process).
Reads also carry per-traffic-class settings from LOOM_CH_SETTINGS_BY_TRAFFIC_CLASS
(production, interactive, bench, background): priority, max_threads, and for
background work a filesystem-cache bypass. They apply only to SELECT/WITH and never
change the rows returned.
Schema guard. A server built from a branch can be migrations ahead of the database.
A statement naming a table a pending migration would
create is refused in the client with SchemaPendingError; the ledger row says which table
the request went without. The pending set is re-read at most once a minute.
Read-your-writes
On a single self-hosted server a committed or batched insert is visible to the next read. On
ClickHouse Cloud replicas may lag and the HTTP pool spreads requests over replicas, so Loom
adds select_sequential_consistency = 1 where it matters:
- Point reads after the caller's own write (working memory, a document, a memory by
key, a profile section) run inside
own_write_reads(). - Searches of a namespace written recently (within
LOOM_CH_READ_YOUR_WRITES_WINDOW_S, 20 s) run undersequential_reads(); an empty pool re-runs the search that way (LOOM_CH_READ_YOUR_WRITES). A write by this process is known in-process; one by another process throughloom.namespace_write_keys, one Keeper key per namespace that a write sets, stamped by the server's clock, before it answers.
The scope is a context variable, so spawned tasks inherit it.
What sequential consistency covers. On SharedMergeTree (ClickHouse Cloud) a
sequentially consistent read sees every insert acknowledged before it began. On a
self-hosted ReplicatedMergeTree it waits only for inserts written with insert_quorum
and insert_quorum_parallel = 0, which ClickHouse refuses for async inserts (and which
serializes every insert into a table), so a read on one replica can still miss a row
another replica acknowledged a moment ago. Guarantees built on it include the sign-in
attempt recounts, the single signing-key retire, task.create's idempotency key (a
retry finds the task the first call stored), the single code.coverage.ready event, the
document bucket's walk and user erasure, a knowledge delete's read after a restore and,
under the Postgres authority, three more: the read that drops a knowledge head
ClickHouse no longer holds, the erasure's read of the document ids it refuses afterwards
(storage authority) and the originals
sweep's ClickHouse read after its read of the unpublished commits
(knowledge). They hold
exactly on Cloud and a single server. A self-hosted replicated cluster must send every
Loom process to one replica; loom doctor's replica_consistency line warns while
loom.auth_attempts, loom.signing_keys, loom.tasks, loom.task_events,
loom.document_blobs, loom.blob_uploads or loom.documents has more than one.
Keeper reads. The Keeper keys that carry state between replicas (the namespace write
keys, graph projections and writes, claims and leases) are read through each replica's own
Keeper session. A Keeper node answers reads from its local state, which can trail the
leader for a moment, so a key one replica just wrote can be missing from another's read.
Writes, and the version checks of compare-and-swap, always go through the leader. For
read-after-write across replicas on a self-hosted Keeper ensemble, set
<coordination_settings><quorum_reads>true</quorum_reads></coordination_settings> in
keeper_server: every read then goes through the leader too, at a round trip each. A
single Keeper node, as in deploy/clickhouse-config/keeper.xml, needs nothing.
Migrations
- Files are
schemas/migrations/NNN_<name>.sql, applied in numeric order byserver/core/schema/migrate.py. A merged migration is never edited or deleted: the ledger checksums every file. - The ledger is
loom._migrations(filename, SHA-256checksum,applied_at,applied_by). A recorded file is skipped; a changed hash is reported as drift, and--strictmakes drift or a ledger row with no file exit 1. - A file headed
-- loom: baseline covers Nreplaces every migration numbered N or lower that the repository no longer carries. A fresh database applies it; a database that already holds a superseded file records it without running. - The server applies pending migrations at boot (
LOOM_BOOT_MIGRATE). Set it tofalsefor a branch server against a shared database. Every process serialises on the KeeperMap lockloom._migration_lock; a server without a Keeper is refused. - Every
ALTERis sent withalter_sync, a mutating one also withmutations_sync, where the user may change them. After a file the runner pollssystem.mutations; a failed mutation, or one still running afterLOOM_MIGRATE_MUTATION_WAIT_S, fails the file. -- loom: tolerate-error <code>lets a statement that only some servers accept fail with that code (for exampleMODIFY SETTING index_granularityon OSSReplacingMergeTree, code 472).- Fresh-install rewrites (vector codec,
code_chunkspartition key) happen in the runner, so the files stay byte-identical. schemas/schema.sqlis regenerated byscripts/ops/dump_schema.py; CI checks it.
loom migrate # apply pending migrations (also: make migrate)
loom migrate --status # applied and pending, no changes
loom migrate --dry-run # what would apply, with hashes and drift
loom migrate --dry-run --strict # exit 1 on drift or ledger-only rows
loom migrate --target 008 # apply through one migration only
make schema-dump # regenerate schemas/schema.sql after a migration change
make schema-check # verify schema.sql matches the migrations
Writing a migration is covered in migrations and adding a table.
Gated ClickHouse settings
Some DDL needs a session setting that ClickHouse gates. The runner sends each flag only with
the statements that need it, because some servers reject an unrelated statement that
carries it (server/core/schema/migrate.py, _stmt_experimental_settings):
| Setting | Sent with |
|---|---|
allow_experimental_nlp_functions |
Statements calling stem( or lemmatize(, and any ALTER on memory_embeddings. Dropped where read-only, as on ClickHouse Cloud, which runs stem() without it. |
allow_experimental_vector_similarity_index |
Statements naming a vector_similarity / HNSW index. |
allow_experimental_statistics |
Statements with STATISTICS. |
enable_quantized_codec, allow_suspicious_codecs |
A coded CREATE on 26.8+, where the user may set it to 1 (below); allow_suspicious_codecs only when a codec is chained after Quantized. |
The two codec settings decide the vector layout, so the runner reads their value,
readonly, min, max and disallowed_values from system.settings before it sends
either (CodecSettings in server/core/schema/vector_codec_ddl.py). A setting is held
when this user may not set it to 1: readonly = 1 (a <readonly/> constraint), or a
<max>0</max> or <disallowed>1</disallowed> constraint, which system.settings shows
with readonly = 0. A held setting is never sent: at 1 it needs no change, and at 0 the
server refuses it (code 452).
State in system.settings |
enable_quantized_codec |
allow_suspicious_codecs |
|---|---|---|
| settable | vector tables coded; sent with each coded CREATE |
entity_vectors keeps its LZ4 chain; sent with that CREATE |
| held at 1 | vector tables coded; not sent | entity_vectors keeps its LZ4 chain; not sent |
| held at 0, or absent | vector tables plain | entity_vectors coded with Quantized alone, since the server refuses the chain without it (code 36) |
The provider rebuild's candidate table and dev/restore_loom.py follow the same table,
but refuse a coded table where the codec cannot be on instead of creating it plain: the
restore before it writes anything, the provider rebuild before the candidate's CREATE
(an owned stale candidate is dropped first). On 26.7 the provider rebuild takes
allow_experimental_codecs in place of enable_quantized_codec.
scripts/ops/vector_codec_rebuild.py refuses to run where the codec cannot be on, and
refuses --chain where allow_suspicious_codecs cannot be; run with --chain LZ4 once it
can, it adds the chain back.
schemas/schema.sql is a reference dump, not an install path (its header says to install
with loom migrate): applied by hand with clickhouse-client --multiquery, it needs these
flags set in the session first.
Cloud and self-hosted differences
Supported server versions are in ClickHouse requirements.
| Area | Self-hosted (single node) | ClickHouse Cloud |
|---|---|---|
| Connection | HTTP on 8123 | TLS on 8443 (LOOM_CH_PORT=8443 or LOOM_CH_SECURE=true). Keep LOOM_CH_KEEPALIVE_TIMEOUT below the load balancer's idle cutoff. |
| Read-your-writes | A waited insert is visible to the next read. | Replicas may lag; see Read-your-writes. |
| Rolling upgrades | One version. | Replicas can differ for a while; setting support is probed per replica, and a rejected setting is dropped and the read retried. |
| NLP functions | stem() available. |
allow_experimental_nlp_functions is read-only; migrations run without it. The lemma column is filled by the application on both. |
| Codecs | Quantized needs 26.8+; both codec settings are writable. |
Read-only: enable_quantized_codec at its default 0 until the operator enables it (plain install), then 1 (coded); allow_suspicious_codecs at 0 until enabled for Loom's user (then the LZ4 chain on a fresh install or a rebuild). |
MODIFY SETTING index_granularity |
Rejected by OSS ReplacingMergeTree (code 472); tolerated. |
Accepted. |
| Lightweight updates | Supported on recent versions; ALTER UPDATE fallback otherwise. |
Each UPDATE is a patch part plus a Keeper entry; statements are batched and back off while mutations are pending. |
OPTIMIZE … FINAL |
Returns after the merge. | Returns before the merge completes; code compaction polls system.merges on every replica. |
| Filesystem cache | No effect on a local disk. | The client sends enable_filesystem_cache = 1 on every session. |
| Keeper | Required: the bundled stack runs an embedded one (deploy/clickhouse-config/keeper.xml). |
Keeper is available. |
| Durability | A single node has no replication or backups unless you add them. | Replication and backups are managed by the service. |
Before 26.8, a vector-search read with a WHERE clause can record wrong entries in the
query condition cache, and a later plain SELECT with the same condition can return fewer
rows. When any replica is older, every Loom session turns the cache off when it connects
(server/core/ch.py, condition_cache_unsafe).
Invariants
- Every tenant read filters on the identity prefix of the sort key; the exceptions are
rows addressed by a global id (
erasure_requests,oauth_state) and tables with no tenant columns (caches,idempotency,revoked_tokens,worker_status). - A
ReplacingMergeTreeversion row carries every persisted column; row-state predicates run afterFINAL. - No expression is aliased with the name of a column its
WHEREfilters on. - Vector statements order by a bare ascending distance with one sort key and no
JOIN, window orLIMIT BY; every vector leg filtersembedding_dim > 0. - Vector tables are written as new versions.
- Every statement carries a
log_comment, a class deadline and a pool slot. memory_versionsreceives everymemoryrow, and the column lists match.- No table that holds memory content has a schema TTL.
- Every
loom.*table is in_CENSUS,STEP_ONLY_TABLESorNO_MEMORY_CONTENT. - A derived row is never the only copy of a write: the source row lands first and the derivation can be replayed.
- A merged migration file is never edited.
Source entry points
server/core/ch.py:set_query_tag,statement_class,statement_settings,sequential_reads,own_write_reads;server/core/ch_writes.py:batched_insert_settings,async_insert_settings,committed_insert_settings,dedup_settings,insert_dedup_token;server/core/ch_schema_guard.py:pending_tables.server/core/schema/journal.py: the journal as data (file order, baselines, statements, the objects each file creates or drops).server/core/schema/migrate.py,server/core/schema/migrate_lease.py: the runner, ledger, locks and gated settings.server/core/schema/vector_codec_ddl.py,server/core/schema/table_rebuild.py: apply-time rewrites and rebuilds.server/core/vectors/codes_state.py,patch_parts.py: vector read policy.server/memory/store/rows.py,inserts.py: the version-row builders.server/erasure/store/census.py:_CENSUS,STEP_ONLY_TABLES,NO_MEMORY_CONTENT;server/erasure/store/mutations.py:LIGHTWEIGHT_PURGE_TABLES.server/memory/app/after_write.py: fail-open projections.server/observability/events_stream.py,server/observability/audit.py: telemetry and audit rows.server/storage/retention_overrides.py: TTL composition;server/knowledge/store/blobs.py: document originals.server/insights/app/ops.py(_clickhouse_parts_check): the parts and merges check.scripts/ops/dump_schema.py,table_rebuild.py,vector_codec_rebuild.py: operator tools.
Related
- Schema reference: every table, generated.
- Memory architecture: the memory plane's write and read paths.
- Code index architecture: the
code_*tables. - Migrations: writing and testing a migration.
- Troubleshooting: too many parts, slow inserts.
- Performance tuning: traffic classes, vector knobs.