Skip to content

feat(backup): SQLite Online Backup API, auto-backup, icm export/import - #431

Open
kzzalews wants to merge 1 commit into
rtk-ai:developfrom
kzzalews:feature/backup-api-and-export
Open

feat(backup): SQLite Online Backup API, auto-backup, icm export/import#431
kzzalews wants to merge 1 commit into
rtk-ai:developfrom
kzzalews:feature/backup-api-and-export

Conversation

@kzzalews

@kzzalews kzzalews commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🛡️ Fixes #313 — Database durability for multi-agent setups

Closes #313 — SQLite store corruption under concurrent writes.

ICM is designed to be the shared brain of multiple AI agents writing concurrently to the same memories.db. The existing std::fs::copy backup, the lack of preventive backups, and the absence of a SQLite-independent recovery format left that brain unprotected. This PR closes all three gaps.


🔧 What's changed

A · SQLite Online Backup API — safe backups under concurrent writers

Problem: backup_db() used std::fs::copy + separate copies of -wal/-shm sidecars. Two syscalls = possible inconsistent snapshot while another agent is committing.

Solution: replaced with rusqlite::Connection::backup() (sqlite3_backup_init/step/finish), which locks pages one at a time, retries pages dirtied by concurrent writers, and produces a fully-checkpointed copy without needing sidecars.

New command:

icm backup               # timestamped sibling: memories.db.backup-YYYYMMDD-HHMMSS
icm backup --output /safe/path/backup.db

Backup files are created with mode 0o600 on Unix (non-fatal warn on FAT32/NFS).


B · Preventive auto-backup + BackupConfig

Problem: backup existed only inside icm repair — i.e., after corruption had already occurred.

Solution: open_store_with_backup() runs a background backup on every writable store open, subject to a configurable interval.

Race condition eliminated. In thundering-herd scenarios (50 agents starting at once), the previous read→decide→write sequence let all of them trigger a backup simultaneously. A single atomic SQL upsert now acts as a distributed mutex — exactly one process wins:

INSERT INTO icm_metadata (key, value) VALUES ('last_backup_at', ?1)
ON CONFLICT(key) DO UPDATE SET value = ?1
WHERE value IS NULL OR julianday(?1) - julianday(value) >= ?2

Modelled on the existing maybe_auto_decay() pattern already in the codebase.

After a failed backup the slot resets to the Unix epoch (1970-01-01T00:00:00+00:00) instead of chrono::DateTime::MIN_UTC (year −262143, outside SQLite julianday()'s 0000–9999 range, which returns NULL and permanently blocks retries).

Configuration (config.toml or ICM_CONFIG):

[store.backup]
# Automatic backups are ENABLED by default.
# To disable, uncomment:
# enabled = false
interval_days = 7      # backup if last one is older than this
keep_backups  = 5      # oldest rotated out; 0 = accumulate indefinitely

C · icm export / icm import --from-export — portable JSONL snapshot

Problem: no SQLite-independent recovery format; if .recover also fails, all data is lost.

Solution: a streaming JSONL snapshot that any tool can read.

icm export                           # JSONL to stdout
icm export --output snapshot.jsonl   # to file
icm export --format json             # single JSON array

Each line is a self-contained JSON object:

{"type":"header","icm_export_version":1,"exported_at":"2026-08-18T...","counts":{"memories":42,...}}
{"type":"memory","id":"01JX...","topic":"arch","summary":"We chose SQLite..."}
{"type":"fact","id":"01JX...","entity":"db","key":"host","value":"prod-1"}
{"type":"feedback","id":"01JX...","topic":"api",...}

Restore (idempotent — existing records are silently skipped):

icm import --from-export snapshot.jsonl
icm import --from-export -            # stdin (pipeable)
icm import --from-export snap.jsonl --dry-run

The previous icm import-from-export command is retained as a hidden deprecated alias for backwards compatibility; users see a migration hint at runtime.


🧪 Tests added

Suite Tests
claim_backup_slot first call wins · second within interval skips · interval=0 wins again · epoch reset retries
rotate_backups removes oldest · ignores unrelated files · keep=0 no-op · no-op within limit
export/import roundtrip idempotency · cmd_import_from_export idempotency
list_all_facts returns only active (not superseded) facts

📋 Files changed

File What
Cargo.toml add backup feature to rusqlite
config/default.toml [store.backup] section with documented defaults
crates/icm-cli/src/config.rs BackupConfig, extended StoreConfig
crates/icm-cli/src/main.rs backup_db, open_store_with_backup, rotate_backups, cmd_backup, cmd_export, cmd_import_from_export, icm import --from-export
crates/icm-store/src/store.rs backup_to, claim_backup_slot, list_all (no LIMIT), list_all_facts, get/set_metadata_str
crates/icm-store/src/backend.rs dispatch for new methods

Cargo.lock intentionally excluded — please regenerate.


✅ Checklist


🤖 How this PR was built

This PR was developed using a mixture-of-experts agentic workflow inside Kiro:

Role Models used
Implementation Claude Opus 5 (high thinking)
Code review (correctness, edge cases, security) Claude Opus 5 · Claude Sonnet 4.6
Code review (performance, semantics, UX) GPT-5.6 Sol · GPT-5.6 Terra
Code review (cross-platform, upstream compat) GLM 5
Review consolidation Claude Opus 4.8
Bug fixes & CI Claude Opus 5 (high thinking)
Orchestration Claude Sonnet 4.6

The implementation went through 8 rounds of multi-model review (up to 10 independent agents per round), each producing a consolidated report before fixes were applied. Known bugs caught this way include the MIN_UTC/julianday() incompatibility, the thundering-herd race in auto-backup, O(n²) feedback dedup, and silent --project ignore.

@kzzalews
kzzalews force-pushed the feature/backup-api-and-export branch from 335cc72 to 98c791c Compare August 18, 2026 09:44
Fixes rtk-ai#313 — three durability gaps for multi-agent setups:

## A. SQLite Online Backup API (replaces std::fs::copy)

backup_db() previously copied the database file with std::fs::copy plus
separate copies of the -wal/-shm sidecars — two syscalls that could
capture an inconsistent snapshot under active WAL writes. Replaced with
rusqlite::Connection::backup() (sqlite3_backup_init/step/finish), which
locks pages one at a time, retries pages dirtied by concurrent writers,
and produces a fully-checkpointed destination without needing sidecars.

New command: icm backup [--output PATH]
New Unix permission: backup files created with mode 0o600 (non-fatal
warn on FAT32 / NFS where chmod is unavailable).

## B. Preventive auto-backup + BackupConfig

Auto-backup (default: enabled, interval 7 days, keep 5 files) runs on
every writable store open via open_store_with_backup(). Race condition
in multi-agent thundering-herd scenarios (N agents starting at once)
eliminated by claim_backup_slot(): a single atomic SQL upsert
(INSERT … ON CONFLICT DO UPDATE … WHERE julianday(now)-julianday(value) >= N)
guarantees exactly one process performs the backup; others see changed==0
and skip. Modelled on the existing maybe_auto_decay() pattern.

After a failed backup the slot is reset to the Unix epoch
(1970-01-01T00:00:00+00:00) instead of chrono::DateTime::MIN_UTC
(year -262143, outside SQLite julianday()'s 0000–9999 range, which
would return NULL and permanently block retries).

backup.keep_backups = 0 disables rotation (backups accumulate
indefinitely); a once-per-process OnceLock warning is emitted when this
is set with backup enabled.

Configuration ([store.backup] in config.toml):
  enabled       = true   # opt-out with enabled = false
  interval_days = 7
  keep_backups  = 5      # 0 = accumulate indefinitely (warn)

## C. Portable JSONL snapshot: icm export / icm import --from-export

icm export [--output PATH] [--format jsonl|json]
  Dumps memories, facts, and feedback to a SQLite-independent JSONL
  snapshot. Each line is a self-contained JSON object with a "type"
  discriminator (header / memory / fact / feedback). Sessions and
  transcripts are intentionally excluded (transient, high-volume).

icm import --from-export <PATH|->
  Restores a snapshot produced by icm export. Idempotent: records
  whose ID already exists are silently skipped. Supports stdin (-).
  Mutually exclusive with <PATH>, --format, and --project (snapshot
  topics are restored verbatim from the file).

Idempotency fixes applied during review:
  KRIT-1: fact import compares value (not ID — set_fact always
          generates a new ULID, so ID comparison never matched).
  KRIT-2: memory import checks stored_id == expected_id after
          store.store() to detect summary_hash dedup.
  KRIT-3: feedback import uses a pre-built HashSet<id> (O(n) instead
          of O(n²) list_feedback per record).

The previous icm import-from-export command is retained as a hidden,
deprecated alias (eprintln! migration hint emitted at runtime).
Integrated into icm import via --from-export flag; --project conflicts
with --from-export because snapshot topics are restored verbatim.

## Additional fixes

- list_all() LIMIT 10000 removed (silent data loss on export of large DBs)
- rotate_backups uses exact prefix {stem}.backup- (KRIT-4: prevents
  accidental deletion of unrelated files)
- rotate_backups logs tracing::warn! on removal failures (D5)
- cmd_export uses .expect(msg) instead of .unwrap() (6 occurrences)
- dry-run output clarified: "file totals — existing records not checked"
- keep_backups = 0 documented in default.toml with disk-space warning
- default.toml [store.backup] updated: backup is ENABLED by default

## Tests added

- claim_backup_slot_first_call_wins
- claim_backup_slot_second_call_within_interval_skips
- claim_backup_slot_after_interval_wins_again
- claim_backup_slot_epoch_reset_allows_immediate_retry
- list_all_facts_returns_only_active
- rotate_backups_removes_oldest_files
- rotate_backups_does_not_remove_unrelated_files
- rotate_backups_keep_zero_does_nothing
- rotate_backups_noop_when_within_limit
- export_import_roundtrip_is_idempotent
- cmd_import_from_export_is_idempotent
@kzzalews
kzzalews force-pushed the feature/backup-api-and-export branch from 98c791c to dfd185e Compare August 18, 2026 09:48
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.

1 participant