A run-time loadable extension for SQLite that adds multi-master replication via conflict-free replicated data types (CRDTs). This is a fork of vlcn-io/cr-sqlite, maintained by Fly.io and used as the replication engine for Corrosion, a distributed SQLite database.
This fork diverges from upstream cr-sqlite v0.15.0 and introduces significant, breaking changes to the change bookkeeping model. The current version is 0.17.0. Databases created with cr-sqlite < 0.17.0 are not supported and must be migrated.
The core CRDT approach (history-free, last-write-wins per column, causal length sets) remains the same. The differences are in how changes are tracked, timestamped, and replicated.
Upstream cr-sqlite maintains a single global db_version counter. This fork introduces a crsql_db_versions table that tracks the latest db_version seen from each site (actor):
CREATE TABLE crsql_db_versions (site_id BLOB NOT NULL PRIMARY KEY, db_version INTEGER NOT NULL);Unlike upstream cr-sqlite, which provides some built-in peer tracking via crsql_tracked_peers, this fork expects the application to handle all bookkeeping — gap detection, seq tracking, and buffering — outside the extension. The extension only tracks the latest db_version seen per site in crsql_db_versions. Corrosion implements this bookkeeping with:
__corro_bookkeeping_gaps— tracks missing db_version ranges per peer__corro_seq_bookkeeping— tracks which seq ranges have been received within each db_version__corro_buffered_changes— buffers partial transactions (individual changes from a db_version whose seqs haven't all arrived yet) until the full transaction can be applied
New SQL functions:
crsql_peek_next_db_version()— Returns the next db_version without incrementing it. Used to inspect what the next version will be before writes happen.crsql_set_db_version(site_id, db_version)— Sets the db_version for a specific remote site. Used when applying changes from a peer to record how far we've synced from that peer, even if no changes won the merge.
The crsql_next_db_version() function no longer accepts a merging_version argument (breaking change). The db_version is now committed to storage immediately when computed, rather than only at transaction commit.
db_versions are monotonic per peer, and site_id ordinal 0 is always the local node. The clock table uses PRIMARY KEY (key, col_name), so when a newer write updates the same column of the same row, it replaces the old clock entry — the old db_version and seq for that column are gone.
This means that when querying crsql_changes WHERE db_version = X AND site_id = Y, some seqs may be missing even though the version was fully written. For example, if db_version X originally produced seqs 0-10 for site_id Y, but a newer write superseded the value at (X, 5), querying for db_version X will return seqs 0-4 and 6-10 — seq 5 is gone because it got supersceded by a newer write (the extension is history-free, so old clock entries are not retained).
Applications must treat a version as complete even when some seqs are missing, since those seqs were superseded by newer writes. In the extreme case, all seqs in a db_version can be missing — the version is effectively empty because every column it touched has since been overwritten. Corrosion handles this by sending metadata indicating whether a given version is fully complete (no missing seqs due to network fragmentation).
Every change record now carries a timestamp. A ts TEXT NOT NULL DEFAULT '0' column has been added to all __crsql_clock tables and to the crsql_changes virtual table. The timestamp is set per-transaction via a new SQL function:
SELECT crsql_set_ts('1719878400000');
-- subsequent writes in this transaction will record this timestamp
INSERT INTO foo VALUES (1, 'bar');The timestamp is stored as a string representation of a u64. The value itself is an NTP64 timestamp — in Corrosion, this is the physical-time component extracted from a Hybrid Logical Clock (uhlc::HLC), which combines wall-clock time with a logical counter to maintain causal ordering while staying close to real time. This enables time-based retention policies — the Corrosion reaper uses ts to garbage-collect tombstones (delete sentinel rows) older than a configurable retention period.
The current timestamp for a transaction can be read with crsql_get_ts().
This fork introduces several in-memory caches to avoid repeated lookups during merges and local writes. All caches are scoped to a transaction and cleared on commit/rollback (including savepoint rollback via xRollbackTo).
- Site ordinal cache: A
BTreeMap<site_id, ordinal>onExtDataavoids repeated lookups tocrsql_site_idduring merge operations. Triggers on thecrsql_site_idtable keep this cache in sync when rows are inserted, updated, or deleted directly — a newcrsql_update_site_id(site_id, ordinal)function updates theBTreeMapin-memory (it does not write to persistent storage; thecrsql_site_idtable is the source of truth). This allows external tooling (e.g., Corrosion'scorro-admin) to manipulate site IDs directly in the table without reloading the extension. - Causal length cache: Causal lengths (cl) are cached per-transaction in a
BTreeMapon eachTableInfo, with a configurable max size (currently 1500 entries). This avoids repeated lookups to the clock table for the sentinel row during merges and local writes. - Last db versions map: A
BTreeMap<site_id, db_version>onExtDatatracks the highest db_version inserted per site during merge transactions, avoiding redundant writes tocrsql_db_versions.
Proper SQLite commit and rollback hooks are registered to manage the cache lifecycle. On commit, pendingDbVersion becomes dbVersion. On rollback, all caches are cleared.
The __crsql_clock table schema is now:
CREATE TABLE "table_name__crsql_clock" (
key INTEGER NOT NULL,
col_name TEXT NOT NULL,
col_version INTEGER NOT NULL,
db_version INTEGER NOT NULL,
site_id INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL,
ts TEXT NOT NULL DEFAULT '0',
PRIMARY KEY (key, col_name)
) WITHOUT ROWID, STRICT;The db_version index has changed from (db_version) to (site_id, db_version) to optimize per-site change queries.
Local writes (insert/update/delete triggers) have been significantly reworked:
- Insert: Uses a new
mark_locally_insertedfunction that tries anUPDATEon clock rows first, then falls back toINSERTonly for columns where the update was a no-op (detected viasqlite3_changes64()). A combo-insert fast path batches all column inserts into a single statement when none of the updates hit existing rows. - Update: Uses
peek_next_db_version()to avoid incrementing the version if nothing actually changed. Only callsnext_db_version()(which writes to storage) if at least one column value differed. - Delete: The
mark_locally_deletedstatement now returns the new causal length viaRETURNING, which is cached in the cl cache. - PK change: When a primary key changes during an update, non-sentinel clock rows are moved from the old key to the new key via
UPDATE OR REPLACE, preserving their col_version (so they can override values at downstream nodes).
set_winner_clocknow takes aninsert_tsparameter and binds it to the clock table.zero_clocks_on_resurrectno longer setsdb_versionduring resurrect (only zeroescol_version).merge_sentinel_only_insertnow accepts and bindsremote_ts.- After any merge (win or lose),
insert_db_versionis called to updatecrsql_db_versionsfor the remote site. - The merge code is restructured to handle all three cases (sentinel-only, resurrect, normal) in a unified path.
| Function | Description |
|---|---|
crsql_set_ts(ts) |
Set the timestamp for the current transaction (string u64) |
crsql_get_ts() |
Get the current transaction's timestamp |
crsql_peek_next_db_version() |
Peek at the next db_version without incrementing |
crsql_set_db_version(site_id, db_version) |
Set the db_version for a specific site |
crsql_set_debug(enabled) |
Enable/disable debug logging |
crsql_version() |
Return the cr-sqlite version integer (re-enabled; was commented out upstream) |
- Debug logging: A
crsql_set_debug(1)function enableslibc_print-based debug output. - ASAN support: Added
make asantarget with proper Rust sanitizer flags. - Config lifetime fix:
crsql_config_setnow properly manages the statement lifetime to prevent use-after-free of returned values. crsql_changesschema: Thecrsql_changesvirtual table now includes atscolumn (column index 9).
-- load the extension
.load crsqlite
.mode qbox
-- create tables as normal
CREATE TABLE foo (a PRIMARY KEY NOT NULL, b);
CREATE TABLE baz (a PRIMARY KEY NOT NULL, b, c, d);
-- upgrade tables to be CRRs (conflict-free replicated relations)
SELECT crsql_as_crr('foo');
SELECT crsql_as_crr('baz');
-- optionally set a timestamp for this transaction
SELECT crsql_set_ts('1719878400000');
-- insert data as normal
INSERT INTO foo (a, b) VALUES (1, 2);
INSERT INTO baz (a, b, c, d) VALUES ('a', 'woo', 'doo', 'daa');
-- fetch changes (note the ts column)
SELECT "table", "pk", "cid", "val", "col_version", "db_version", "site_id", "cl", "seq", "ts"
FROM crsql_changes
WHERE db_version > 0 AND site_id = crsql_site_id();
-- apply changes from a peer
INSERT INTO crsql_changes
("table", "pk", "cid", "val", "col_version", "db_version", "site_id", "cl", "seq", "ts")
VALUES
('foo', x'010905', 'b', 'thing', 5, 5, X'7096E2D505314699A59C95FABA14ABB5', 1, 0, '1719878400000');
-- tear down before closing the connection
SELECT crsql_finalize();SELECT crsql_begin_alter('table_name');
-- 1 or more alterations
ALTER TABLE table_name ...;
SELECT crsql_commit_alter('table_name');Corrosion is a distributed, eventually-consistent SQLite database. It embeds cr-sqlite as a loadable extension (with pre-built binaries bundled for darwin-aarch64, linux-x86_64, and linux-aarch64) and builds a full clustering layer on top:
- Actor identity: Corrosion uses
crsql_site_id()as the actor ID in its cluster. Thecrsql_site_idtable (with ordinals) maps to Corrosion's actor tracking. - Change extraction: Corrosion queries
crsql_changesfiltered bydb_versionandsite_idto extract changesets for broadcast to peers. It usescrsql_peek_next_db_version()to determine the version before writes are committed, then queriesMAX(seq)andMAX(ts)to track the last sequence and timestamp per version. - Change application: Remote changesets are inserted into
crsql_changesin bulk (viaunnestfor batch inserts). Corrosion usescrsql_rows_impacted()to verify that merges actually affected rows. - Timestamps: Corrosion uses a Hybrid Logical Clock (HLC) and calls
crsql_set_ts()before each transaction to stamp changes with an NTP64 timestamp. This enables the reaper to garbage-collect tombstones based on age. - Per-site version tracking: Corrosion uses the
crsql_db_versionstable to track the latestdb_versionreceived from each peer. When processing incomplete or buffered changes, it callscrsql_set_db_version(site_id, version)to advance the tracked version even when no changes won the merge. - Schema management: Corrosion uses
crsql_as_crr()to register tables andcrsql_begin_alter()/crsql_commit_alter()for schema migrations. When migrating from older cr-sqlite versions that lacked the(site_id, db_version)index on clock tables, it createscorro_{table}__crsql_clock_site_id_dbvindexes. - Buffered changes & gap tracking: Corrosion handles two kinds of gaps. Missing db_versions from a peer are tracked in
__corro_bookkeeping_gaps. Partial transactions (a db_version split across multiple messages with missing seqs) are buffered in__corro_buffered_changes(same schema ascrsql_changes), with received seq ranges tracked in__corro_seq_bookkeeping. Once all seqs for a db_version are received, the changes are applied tocrsql_changesin bulk. - Reaper: A background reaper process uses the
tscolumn to find tombstones (delete sentinel rows wherecol_name = -1 AND col_version % 2 = 0 AND ts < cutoff) and cleans them up along with orphaned entries in__crsql_clockand__crsql_pks. - Config: Corrosion enables
crsql_config_set('merge-equal-values', 1)to optimize merges where values are equal. - Migration: Corrosion includes a
crsqlite_v0_17_migrationthat adds thetscolumn to existing clock tables and recreates indexes.
You'll need Rust (nightly toolchain required).
rustup toolchain install nightly
git clone --recurse-submodules git@github.com:superfly/cr-sqlite.git
cd cr-sqlite/core
make loadableThis creates a shared library at dist/crsqlite.[so|dylib|dll].
Note: loading the extension should be the first operation after opening a connection. The extension must be loaded on every connection.
cd core
make sqlite3Creates dist/sqlite3 with cr-sqlite statically linked and pre-loaded.
C tests:
cd core
make testPython integration tests:
cd core
make loadable
cd ../py/correctness
./install-and-test.shA Rust-based benchmarking tool is in tools/:
cd tools
cargo run -- ../core/dist/crsqlite| Function | Description |
|---|---|
crsql_as_crr('table') |
Upgrade a table to a conflict-free replicated relation |
crsql_as_table('table') |
Alias for crsql_as_crr |
crsql_site_id() |
Return this database's 16-byte site ID |
crsql_db_version() |
Return the current db_version |
crsql_next_db_version() |
Return and persist the next db_version |
crsql_peek_next_db_version() |
Peek at the next db_version without persisting |
crsql_set_db_version(site_id, version) |
Set the db_version for a specific site |
crsql_set_ts(ts) |
Set the timestamp for the current transaction |
crsql_get_ts() |
Get the current transaction's timestamp |
crsql_rows_impacted() |
Return rows impacted by the last merge insert |
crsql_finalize() |
Tear down the extension (call before closing connection) |
crsql_version() |
Return the cr-sqlite version integer |
crsql_config_set(name, value) |
Set a configuration option |
crsql_set_debug(enabled) |
Enable/disable debug logging |
| Function | Description |
|---|---|
crsql_begin_alter('table') |
Begin an alter session on a CRR table |
crsql_commit_alter('table') |
Commit alterations to a CRR table |
crsql_changes— Query and apply changesets. Columns:table, pk, cid, val, col_version, db_version, site_id, cl, seq, tscrsql_site_id— Maps site IDs to ordinals (ordinal 0 is the local site)
{table}__crsql_clock— Per-column clock metadata (col_version, db_version, site_id, seq, ts){table}__crsql_pks— Maps primary key values to integer keys for clock table lookups
crsql_db_versions— Per-site db_version trackingcrsql_master— Extension metadata and config key-value storecrsql_tracked_peers— Peer tracking table (site_id, version, seq, tag, event). Created by the extension but bookkeeping is expected to be handled by the application.
CR-SQLite uses history-free CRDTs based on causal length sets. Each table upgraded to a CRR gets:
- Clock tables (
__crsql_clock) that track per-column version metadata (col_version, db_version, site_id, seq, ts) - PK lookaside tables (
__crsql_pks) that map composite primary keys to integer keys for efficient clock table joins - Triggers (insert, update, delete) that automatically record changes to the clock tables
Merging works by comparing column versions and causal lengths. For each incoming change:
- If the incoming
col_versionis greater than the local one, the change wins - If versions are equal, the column values are compared
- If values are equal, the
site_idis used as a tiebreaker - Deletes are tracked via sentinel rows (tombstones) with even
col_versionvalues; the causal length determines whether a delete or a create wins
The crsql_changes virtual table provides a unified view across all CRR clock tables, allowing you to extract and apply changesets without knowing the underlying schema.
- Towards a General Database Management System of Conflict-Free Replicated Relations
- Conflict-Free Replicated Relations for Multi-Synchronous Database Management at Edge
- Merkle-CRDTs
- Time, Clocks, and the Ordering of Events in a Distributed System
- Replicated abstract data types: Building blocks for collaborative applications
- CRDTs for Brrr