Add a durable backend: serverless, cross-machine memory on object storage - #7
Open
ShawnChen-Sirius wants to merge 6 commits into
Open
Add a durable backend: serverless, cross-machine memory on object storage#7ShawnChen-Sirius wants to merge 6 commits into
ShawnChen-Sirius wants to merge 6 commits into
Conversation
…e object A third storage backend alongside LocalBackend (ephemeral, in-process) and ClickHouseBackend (needs a server): DurableBackend keeps the memory database in a Durable Analytical Object (chdb.durable), checkpointed to object storage you own (S3/GCS/Azure/local), single-writer, portable across hosts. Same query/execute/vector_search/close contract; selected via CLICKMEM_BACKEND=durable + CLICKMEM_DURABLE_URL + CLICKMEM_DURABLE_ID. Bootstrap runs WAL-free (idempotent schema, captured in the checkpoint base); data writes go through the WAL and flush for RPO~0; close checkpoints then releases. Validated (local FS + MinIO): bootstrap, persist, restore-across-restart, WAL replay, cosine vector_search. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
utc_now_sql() now returns a Python-evaluated DateTime64 literal instead of now64(): the durable backend replays logged statements after a crash, and a server-evaluated now64() would drift updated_at on replay — the ReplacingMergeTree version column that decides dedup. All timestamp writes go through this one helper; local and clickhouse backends are unaffected. DurableBackend: accept force= for crash takeover and wrap LeaseError with what to do (wait out the ~60s lease or force once the old process is confirmed dead). Docstring: local backend is machine-bound, not ephemeral. Test: crash without close → lease blocks reopen with the hint → force takeover replays the WAL with identical timestamps.
Every recall writes an audit event and every Stop hook lands the full transcript; on the durable backend each of those forced a WAL segment upload, putting object-storage latency on hot paths. Add execute_lazy to the Backend protocol: applied to the local replica immediately, durability rides the next belief write / close / checkpoint. Local and server backends alias it to execute. The durable backend force-flushes once deferred audit data exceeds 1 MiB so the loss window stays bounded. events.write and raw.append use it; belief writes (remember/edit/forget/ pin/resolve) still flush at return. A crash loses at most the audit tail since the last flush, never a belief. Test covers: no per-write upload, piggyback flush, bounded loss after crash takeover.
A long-running service never used to checkpoint, leaving an ever-longer replay chain; and a quiet session could hold deferred audit writes in memory for hours. All maintenance now rides normal operations (no timer thread, same pattern as lease renewal): - checkpoint when the WAL passes 64 segments or ~32 MiB of statements, so reopen cost stays bounded for write-heavy and transcript-heavy sessions alike - ship the deferred audit tail after 60s, checked on every query and by the server's background worker (maybe_flush, no-op on local/server backends), so a crash loses at most ~a minute of audit rows - close folds only when the WAL passed 8 segments; below that it just flushes — replay of a few segments is cheaper than uploading a fresh base every restart (and stops minting orphaned bases) - serialize object access with a lock: the background worker and request handlers share this backend across threads
Add a memory-store selection layer so a user can see what stores exist, switch between them, and create new ones — without editing config by hand or one store per project. - clickmem/stores.py: per-user selection record in ~/.clickmem/stores.json (never in the repo, so different people can keep different memories for the same project); flat store-id rules with a reserved '_' prefix; the durable directory _catalog/catalog.json (conditional-write upsert, drop on contention — it is a navigation hint, not the source of truth). - config: durable_id / ch_database / db_path fall back to the selection record when the env var is unset; env always wins. - durable backend: refresh its own directory entry on open and close (open = timestamp+writer+embedding dims; close = cheap counts for list). - CLI 'store list|use|new' and MCP clickmem_store(op=...), named after the existing project/blacklist command shapes. 151 passed (+5 store tests); durable smoke + growth-control scenarios green.
…document durable backend - test_stores: skip the two durable-path tests when chdb.durable isn't importable (it's merged to chDB but not in a released wheel yet), so CI on the current chdb release is green. - test_cli: import tomli as a fallback for tomllib on Python 3.10 (stdlib only from 3.11); add tomli to the dev extra under a version marker. This was failing collection on the 3.10 lane independent of this feature. - README + reference: document the durable (object-storage) backend, the CLICKMEM_DURABLE_URL/ID variables, and the store list/use/new commands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
|
@auxten please review this PR and trigger the ci workflow. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a third memory backend,
durable: the clickmem database lives in object storage you own (S3 / GCS / Azure Blob / any S3-compatible / a local folder), so memory persists and moves across machines without running a server. Built onchdb.durable(chdb-io/chdb#616).Proposal and discussion: #6
Why
Fills the gap between
local(persistent but one machine) andclickhouse(portable but needs a server): durable, portable, and serverless — for agents that restart on a fresh host (Lambda, CI, sandboxes).Kept true to clickmem
~/.clickmem/stores.json(not in the repo). Newstore list|use|new(CLI) andclickmem_store(MCP), shaped like the existingproject/blacklistcommands.Compatibility
Uses
chdb.durable, which is merged into chDB but not yet in a published release, so this backend stays inactive until a chdb release includes it. Thelocalandclickhousebackends are unchanged.Tests
151 pass (+5 for store selection). The durable backend has a standalone scenario script — bootstrap, persist, restore, log replay, vector search, crash recovery with the single-writer lock, lazy bookkeeping with bounded loss, and storage compaction — verified locally and against MinIO.
Design document
Full design (click to expand)
ClickMem Durable Memory Backend (on chdb.durable)
1. Goal
ClickMem memories currently live in one of two places: the local disk (the local backend — gone when you switch machines), or a ClickHouse server (the clickhouse backend — someone has to run a server). This adds a third option: memories persist in object storage the user owns (S3 / GCS / Azure Blob / a local folder). No server, and the same memory works across machines, clouds, and throwaway environments like Lambda.
2. What chdb.durable is and how it works
chdb.durable is a subpackage merged into the chDB main repo (chdb-io/chdb PR #616, merged 2026-07-23). It provides a persistent, addressable embedded analytical object; clickmem is one consumer of it.
Storage layout — one memory store = one directory in object storage:
Three core flows:
ns.open(id): read head.json. Missing → create a fresh empty store. Present → download the base, restore it into a local chDB database, then replay the incremental log in order — state is back to where it was. All queries afterwards hit this local replica; no network involved.execute + flush: the write runs locally and its SQL is buffered; flush packs the buffer into one log file, uploads it, and updates head.json. When flush returns, the data is in object storage — a crash right after loses nothing.Preventing two processes from corrupting the same store (single-writer lease): head.json records the current holder and an expiry (60s by default; an active writer renews automatically). Every head.json update uses the object store's conditional write — the overwrite only succeeds if the file's version hasn't changed since it was read. So even if a stale process is still alive, once someone else takes over, the stale process's next commit is rejected by the storage layer itself. Crash recovery: wait out the 60s lease, or pass
force=Trueonce you are sure the old process is dead.Two rules for statements in the incremental log (violating either corrupts crash recovery):
now()/rand(): replay would evaluate them again and produce different values.Growth control
Logs and files must not grow without bound. Four gates, one principle: maintenance always rides normal operations — no dedicated timer thread (the lease already renews the same way, inside writes):
Close is graded: folding is only worth a full-snapshot upload if the log passed 8 segments; below that, just upload the remaining log — this also stops minting an orphaned snapshot on every service restart.
Not done yet: garbage collection. Superseded snapshots and already-folded log segments stay in object storage (a known chdb.durable TODO). Bounded impact: open only downloads the files head.json references, so garbage never affects speed or correctness — it only costs storage (snapshots are hundreds of KB; ~tens of MB per year). A real fix needs a "list files" primitive in the storage abstraction plus delayed deletion after folding — tracked as a chdb.durable follow-up.
3. Architecture
Division of labor: clickmem decides what counts as memory, how recall works, and how conflicts are resolved; chdb.durable decides how state survives process death; the object store's IAM decides who can touch which bucket. chdb.durable does no cataloging, no auth, no memory semantics.
4. Store ids: recorded per user locally; the directory is for exceptions
Everyday path (no directory involved): the store id is recorded in the user's own
~/.clickmem/config (the active id, plus an optional project → id map). Every start reads the local record and opens directly. Nothing is written into the repository or git — different people can maintain different memories for the same project under their own ids. A team that wants one shared id has each member runstore use <id>once, or distributes the existingCLICKMEM_DURABLE_IDenvironment variable. This matches clickmem's existing pattern: project_id is likewise derived at runtime from the git remote; no config file ever lands in the repo.Creating (
store new): explicitly creates a fresh empty store and switches to it. Default name = a readable project/user slug, sanitized to the id character rules — ids may not contain/\...(so one id's storage prefix can never contain another's);acme/foobecomesacme-foo. If the directory already has that name, prompt to reuse or rename;--idoverrides entirely. Readable names, no random hashes — users must recognize a store in a list.Switching machines / stores (
store list+store use): on a new machine, after losing the local record, or to switch stores,store listreads the directory and shows "id ↔ description ↔ source repo ↔ last used"; the user picks,store use <id>records the choice locally.Why no automatic selection: auto-routing to the wrong store injects one project's memories into another's context — exactly the contamination clickmem exists to prevent. The system's job is to provide enough information (local record + directory); the choice belongs to the user. Same principle as clickmem's write side: memory is never created by accident, and never loaded by accident.
Store id and project_id are different layers: project_id (e.g.
auxten/clickmem) is a column inside the memories table — a recall filter; the store id says which database (all tables) lives where. One store holds any number of projects. Do not create one store per project — cross-project recall only works inside a single store, and global memories would have nowhere to live. Recommended: one store per person (or per agent).5. The directory:
_catalog.jsonOne small JSON file per storage location:
[{id, description, source, last_opened, count}]. It only serves the exception path in §4.6. API additions: one
storegroup, named after existing conventionsNaming follows clickmem's own patterns — CLI is "noun + short verb" (precedents:
project link,service install,dashboard open); MCP is one tool per noun with anopparameter (precedents:clickmem_project(op=link/list),clickmem_blacklist(op=add/remove/list)):store list/store use <id>/store new [--id NAME]clickmem_store(op="list"|"use"|"new", id=...)No durable prefix, no durable special-casing — "list my memory stores, switch, create" makes sense for all three backends:
_catalog.jsonEvery existing API (remember/recall/…) is untouched — they depend only on the Backend protocol, so the backend swap is transparent.
7. Teams and multi-tenancy
8. Measured performance (2026-07-27, chdb 4.2.1, 256-dim embedding workload)
Takeaway: recall has zero network cost; writes are infrequent and deliberate, and tens of milliseconds disappear inside an LLM turn; checkpoint needs no async machinery (durability comes from flush — checkpoint is just folding).