Backup and restore

In shortWhat 10xGraph persists, how to back up the Postgres tables that hold threads and state, and how to restore or roll back a running deployment safely.

  • 4 min read
  • 7 sections
  • Updated
  • v0.9.2
  • Markdown

What is durable and what is not

Only one of the two storage layers holds anything you cannot lose.

Layer Contents Durable Back up
Postgres Threads, state, messages, tool-execution ledger, schema version Yes Yes
Redis Hot cache of recent state, default TTL 24h No No
Vector store (Qdrant, Mem0) Long-term memories Yes, in that system Yes, with that system’s own tooling
Media store Uploaded files Depends on backend Yes, if local disk

Redis is a cache in front of Postgres, not a second source of truth. Losing it costs latency on the next read, nothing else. Never restore Redis from a snapshot alongside an older Postgres: a cache holding newer versions than the database is exactly the state the version guard exists to reject.

Tables

With the default public schema, PgCheckpointer owns:

Table Holds
threads Thread id, name, owner user_id, timestamps, metadata
states Serialized graph state, with a version column used for optimistic concurrency
messages Conversation history
tool_executions Idempotency ledger keyed by (thread_id, origin_message_id:tool_call_id)
schema_version One row per applied schema version

Current schema version is 3. If you pass a custom schema= to PgCheckpointer, the tables are schema-qualified and your backup must target that schema.


Back up

Routine

Terminal
pg_dump "$DATABASE_URL" \
  --format=custom \
  --file="agentflow-$(date +%Y%m%d-%H%M).dump"

Use --format=custom rather than plain SQL: it restores in parallel and lets you restore a single table.

To back up only what 10xGraph owns from a shared database:

Terminal
pg_dump "$DATABASE_URL" --format=custom \
  --table=threads --table=states --table=messages \
  --table=tool_executions --table=schema_version \
  --file=agentflow-tables.dump

Verify the dump is readable before you trust it. A backup you have never restored is a hypothesis:

Terminal
pg_restore --list agentflow-tables.dump | head

Before an upgrade

Always take a dump before a release that changes the schema, because migrations apply automatically on first startup of the upgraded server:

Terminal
pg_dump "$DATABASE_URL" > agentflow-pre-1.0.sql

See upgrade to 1.0.

Automate it

Whatever your platform offers is usually better than a cron job on one box: managed Postgres point-in-time recovery, a scheduled snapshot, or a sidecar CronJob on Kubernetes. What matters is that you know your recovery point objective and have tested a restore against it.


Restore

Restoring under live traffic corrupts state, because running workers hold state versions that the restored database has never seen.

Terminal
# 1. Stop traffic. Scale to zero rather than restoring under load.
kubectl scale deployment/my-agent --replicas=0

# 2. Flush the cache so it cannot serve newer versions than the database.
redis-cli -u "$REDIS_URL" FLUSHDB

# 3. Restore.
pg_restore --clean --if-exists --no-owner \
  --dbname "$DATABASE_URL" agentflow-tables.dump

# 4. Confirm the schema version matches what the code expects.
psql "$DATABASE_URL" -c "SELECT * FROM schema_version ORDER BY version DESC LIMIT 1;"

# 5. Bring one replica back and smoke-test before scaling up.
kubectl scale deployment/my-agent --replicas=1

Restoring one thread

Full restores are rarely what you want. To recover a single conversation, restore the dump into a scratch database and copy the rows across:

Terminal
createdb agentflow_scratch
pg_restore --no-owner --dbname agentflow_scratch agentflow-tables.dump
SQL
-- From the scratch database, export one thread.
\copy (SELECT * FROM threads  WHERE thread_id = 'thr_123') TO 'thread.csv'  CSV
\copy (SELECT * FROM states   WHERE thread_id = 'thr_123') TO 'states.csv'  CSV
\copy (SELECT * FROM messages WHERE thread_id = 'thr_123') TO 'messages.csv' CSV

Import into production only when no run is active on that thread_id, and keep the version column intact: rewriting it defeats the concurrency guard.


Retention and deletion

Threads carry an owner user_id, which is what makes per-user deletion possible. To honour a deletion request, remove the user’s rows from all four tables in dependency order:

SQL
BEGIN;
DELETE FROM tool_executions WHERE thread_id IN (SELECT thread_id FROM threads WHERE user_id = $1);
DELETE FROM messages        WHERE thread_id IN (SELECT thread_id FROM threads WHERE user_id = $1);
DELETE FROM states          WHERE thread_id IN (SELECT thread_id FROM threads WHERE user_id = $1);
DELETE FROM threads         WHERE user_id = $1;
COMMIT;

Then invalidate the cache for those threads, and delete the user’s memories from the vector store and any uploaded files from the media store. Those live outside Postgres and are not covered by the transaction above.


Test your restore

Once a quarter, or before any release you are nervous about:

  1. Restore the latest dump into a scratch database.
  2. Point a staging server at it.
  3. Open an existing thread and continue the conversation.
  4. Confirm the reply includes context from before the restore.

If step 4 fails, your backup covers the tables but not the history, which usually means messages were excluded from a table-scoped dump.

Last updated for v0.9.2Edit this page on GitHubReport an issue