Skip to content
tissue-systemsPublic

About

Ship libSQL WAL frames to a warm replica on another machine

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

walship

crates.io docs.rs CI MSRV 1.88

Physical replication for libSQL databases. walship reads committed frames out of a database's WAL and posts them over HTTP to a peer, which writes them into its own copy through libSQL's WAL insert API. The copy on the peer is a normal SQLite database, and one call turns it back into a live one.

let shipper = Shipper::builder("/var/lib/app/db", "http://standby.internal:7350")
    .token(token)
    .start(tokio::runtime::Handle::current())?;
shipper.track("orders");

// after a commit
shipper.wake("orders");
  • frame-level replication, with no FUSE layer and no proxy in front of the database
  • while the peer is reachable, a replica trails the primary by about one batch
  • a transaction split across batches is applied whole or not at all
  • both sides keep their position across restarts and crashes
  • any number of nodes, each owning some of the databases
  • promotion folds the WAL and moves the file into place

walship has replicated customer databases in production since September 2026 (see About), but the API isn't stable yet. Expect breaking changes before 1.0.

Installation

[dependencies]
walship = "0.1"

libSQL is a hard requirement. libSQL is a fork of SQLite's C code that takes outside contributions, which upstream SQLite doesn't. Upstream has no API for reading frames out of the WAL or writing them back in. walship uses the ones libSQL adds (libsql_wal_get_frame, libsql_wal_insert_frame and friends) through the libsql-ffi crate, which compiles libSQL into your binary.

Your app has to open its databases through the same build, either with the libsql crate or with libsql-ffi directly, on the same 0.9 line so Cargo links one copy. A process that links two copies of SQLite gets two sets of POSIX locks that don't see each other, and that corrupts databases.

Cargo won't stop that on its own. libsqlite3-sys, the crate under rusqlite and sqlx, declares links = "sqlite3", so Cargo refuses two versions of it. libsql-ffi declares no links key, so a second version of it, or libsqlite3-sys next to it, builds without complaint. This check fails when your Cargo.lock holds more than one, on any platform. Run it in CI:

libs=$(awk '
  /^\[\[package\]\]/ { name = "" }
  /^name = / { name = $3 }
  /^version = / && name ~ /^"(libsql-ffi|libsqlite3-sys)"$/ { print name, $3 }
' Cargo.lock)
if [ "$(printf '%s\n' "$libs" | grep -c .)" -gt 1 ]; then
  echo "more than one SQLite C library in Cargo.lock:"
  echo "$libs"
  exit 1
fi

cargo tree -i libsql-ffi then shows what pulled each one in.

Cargo features:

  • shipper (default): the sending side, with reqwest and tokio.
  • server (default): axum routes for the receiving side.

The Receiver and the WAL primitives in walship::wal build without either.

Usage

Primary

Each database is a file named <name>.db in one data directory. Names are ASCII letters, digits, _, - and ., at most 128 bytes, and can't start with a dot (walship::valid_name).

use walship::Shipper;

let shipper = Shipper::builder("/var/lib/app/db", "http://standby.internal:7350")
    .token(std::env::var("WALSHIP_TOKEN")?)
    .start(tokio::runtime::Handle::current())?;

shipper.track("orders");   // start shipping a database
shipper.wake("orders");    // optional: ship right away after a commit
shipper.reset("orders");   // the file was replaced (restore, import): send a new base
shipper.forget("orders");  // stop shipping and delete the replica on the peer

Without wake, every tracked database is looked at every 5 seconds. shipper.status() returns a serializable snapshot of where each database is, which is easy to put behind an admin endpoint.

Your own connections must not checkpoint. A checkpoint folds frames into the main file, and if they haven't been shipped yet the shipper has to start over with a full copy. How you turn checkpoints off depends on how you open connections.

With libsql-ffi, or anything else that gives you the raw sqlite3 pointer, one call turns off both the checkpoints after a commit and the one SQLite runs when the last connection closes:

// `handle` is the raw sqlite3 pointer of a connection you opened.
unsafe { walship::disable_checkpoint(handle) }?;

The libsql crate keeps that pointer private. Run this on every connection you open instead:

conn.query("PRAGMA wal_autocheckpoint=0", ()).await?;

That leaves the checkpoint on close, which only runs when the last connection to a database closes. The shipper holds a connection of its own, with checkpoints off, for every database it tracks, so yours are never the last. Drop your connections before you call shipper.shutdown(). examples/with_libsql.rs does all of this.

Patching the libsql crate

You don't need this to use walship. It's for when you'd rather turn checkpoints off with a method call on a libsql::Connection than rely on the pragma and the shipper's held connection.

The crate already has a wal_disable_checkpoint method, but it's private. patches/libsql-0.9.30.patch makes it public and fixes a double close in disconnect (libsql#2251): dropping a local connection closes its handle twice, and the second close can hit another thread's connection once the memory has been reused. The patch only touches the Rust wrapper. libSQL itself and libsql-ffi are unchanged.

Both halves are on their way upstream. The double-close fix was merged as libsql#2261 and shipped in libsql 0.10.0, which walship doesn't support yet since it's built against libsql-ffi 0.9. Making the method public is libsql#2306, which is still open. Once a release includes it, you won't need this patch.

To use it, copy the published crate into your project, apply the patch, and tell Cargo to build from the copy:

# With libsql = "=0.9.30" already in your [dependencies] (below), download
# it into Cargo's registry cache, then copy it out.
cargo fetch
mkdir -p vendor
cp -R ~/.cargo/registry/src/index.crates.io-*/libsql-0.9.30 vendor/

cd vendor/libsql-0.9.30
patch -p1 < /path/to/walship/patches/libsql-0.9.30.patch
# Your app's Cargo.toml (or the workspace root's).
[dependencies]
libsql = { version = "=0.9.30", default-features = false, features = ["core"] }

[patch.crates-io]
libsql = { path = "vendor/libsql-0.9.30" }
let conn = db.connect()?;
conn.wal_disable_checkpoint()?;

Every use of libsql in your dependency tree then builds from vendor/, including the ones your other dependencies pull in. A few things to know before you commit to it:

  • Cargo only reads [patch] from the top-level project. A library can't patch a crate for the apps that use it, so every workspace that builds your code needs its own [patch.crates-io] section and its own copy.
  • The =0.9.30 pin matters. If Cargo resolves a different 0.9 release from crates.io, it ignores the patch and prints a warning that it was unused.
  • You own the copy. Upgrading libsql means fetching the new release and applying the patch again, by hand. The patch is small enough to redo if it stops applying.
  • Cargo shows lint warnings for path dependencies but hides them for crates from a registry, so expect warnings from the vendored crate in your build. Adding [lints.rust] warnings = "allow" to its Cargo.toml puts them back to how they were.

Replica

use std::sync::Arc;
use walship::{server::Routes, Receiver};

let receiver = Arc::new(Receiver::new("/var/lib/app/replicas")?);
let app = Routes::new(Arc::clone(&receiver))
    .token(std::env::var("WALSHIP_TOKEN")?)
    .into_router();
axum::serve(tokio::net::TcpListener::bind("0.0.0.0:7350").await?, app).await?;

The router only handles paths under /replica, so it can be merged into an app you already run. One receiver accepts streams from any number of shippers. Database names have to be unique across all of them.

Promotion

use std::path::Path;
use walship::receiver::Take;

match receiver.take("orders", Path::new("/var/lib/app/db/orders.db"))? {
    Take::Moved { generation, frames, bytes } => { /* open it */ }
    Take::Busy => { /* a reader blocked the checkpoint, try again */ }
    Take::Absent => { /* no replica by that name */ }
}

take runs a TRUNCATE checkpoint on the replica and moves the file to the destination. If a transaction was only partly received, it's dropped, so the file holds exactly what the primary had committed. walship doesn't detect failures or decide when to promote. That's up to you.

Topologies

Each node runs a Shipper for the databases it owns and a Receiver for the ones it holds replicas of. The owner callback on the builder says which is which, and walship asks it again every time it looks at a database. A node that loses ownership of a database stops shipping it and deletes the replica it had left on its peer. Otherwise, after a failover, the new owner would still be holding an old replica of a database it's serving.

use walship::{Owner, Shipper};

let shipper = Shipper::builder(dir, peer)
    .owner(|name| match placement.lookup(name) {
        Some(node) if node != me => Owner::Elsewhere(node),
        _ => Owner::Here,
    })
    .start(handle)?;

Any layout where every owner has one peer works. In a ring, each node replicates to the next one:

a ──▶ b ──▶ c ──▶ d ──▶ e
▲                       │
└───────────────────────┘

You can also send everything to one standby, or pair nodes up. Every database gets one replica, on the peer of the node that owns it.

How it works

Each database is replicated in generations. A generation starts with a base: the primary runs a TRUNCATE checkpoint, then streams its main file to the peer. WAL frames written after that are numbered from 1.

The shipper reads committed frames past the last acknowledged one and posts them in batches of up to 512 (2 MiB with 4 KiB pages). The replica answers with its committed frame count. After any error, the next send resumes from that count.

Checkpointing is off on every connection to a shipped database. Once a database has gone idle with its WAL past 1,000 frames, all of them acknowledged, the shipper runs a PASSIVE checkpoint. It counts the fold only if it took exactly the acknowledged frames; if a writer got in first, the new frames are shipped and the fold waits for the next idle moment. After a full fold SQLite restarts the WAL on the next write, and the shipper treats that as the start of the next generation. The replica checkpoints its own copy when the first frames of the new generation arrive.

Something else can still change the WAL behind the shipper's back: a checkpoint from another process, or a crash in the middle of a WAL header write. That shows up as a different salt in the WAL header, or as a frame count lower than the acknowledged one. Either way the shipper sends a fresh base.

A batch can end partway through a transaction. The replica keeps its insert session open until a commit frame arrives, so readers on the replica never see a partial transaction. If the replica restarts with a session still open, the uncommitted frames are thrown away and the shipper resends them from the last commit.

Every 60 seconds (the first time 30 seconds after start) the shipper compares its databases with GET /replica on the peer, and sends a base for any the peer doesn't have.

If the peer is unreachable, the shipper retries with backoff from 1 to 30 seconds while the WAL keeps growing. Once 50,000 frames are waiting (about 200 MiB with 4 KiB pages) it runs a TRUNCATE checkpoint anyway and sends a full base when the peer is back.

Protocol

When the receiver has a token, every request carries Authorization: Bearer <token>. Request bodies are raw bytes, and replies are JSON. The types are in walship::proto.

POST   /replica/{name}/base?generation=G
POST   /replica/{name}/frames?generation=G&first=F&page_size=P
GET    /replica
GET    /replica/{name}
DELETE /replica/{name}

A frames body is a run of whole WAL frames, each a 24-byte header followed by one page. If generation or first doesn't line up with what the replica has, it answers 409 with either its own position, which the shipper resends from, or a request for a new base. A 503 means a reader on the replica held off the checkpoint that starts a new generation; the shipper tries again. A wrong or missing token gets 401, and an I/O or SQLite error on the replica gets 500. Every error reply has the same JSON shape, walship::proto::Refusal.

libSQL notes

The WAL functions behave in ways that aren't documented. We measured the following against libsql-ffi 0.9. Tests in src/wal.rs pin down most of it, so an upgrade that changes it fails the suite:

  • libsql_wal_frame_count takes the WAL write lock without waiting, so it returns SQLITE_BUSY whenever a write transaction is open. It also returns the connection's cached wal-index header. SELECT 1 doesn't refresh it, a read of sqlite_master does, and that's what RawDb::head runs first.
  • If you insert a frame numbered past frame_count + 1, it's quietly appended at frame_count + 1. If you insert one at or below frame_count, nothing happens when the page matches and you get a conflict when it doesn't. You have to check for gaps yourself. The receiver does.
  • Frames are page images, so they only make sense on top of the exact file they were written against. libSQL accepts them onto anything else, such as a copy made with VACUUM INTO. In our test that replica still passed PRAGMA integrity_check and was missing a fifth of its rows. That's why a base is a byte copy of the main file taken right after a TRUNCATE checkpoint.
  • When an insert session ends without a commit frame, its frames stay on disk but never make it into the wal-index. The next session starts again from the last commit.
  • A reader on the replica sees a transaction as soon as its commit frame is inserted, before libsql_wal_insert_end.
  • Inserted frames go through the normal WAL append path. The replica's WAL has its own salts and checksums and recovers from a crash like any other WAL.
  • After a PASSIVE checkpoint that folds the whole log, the next write restarts the WAL at frame 1 with a new salt, even on a connection with automatic checkpoints turned off. The shipper's generations depend on this.

Turso, the Rust rewrite of SQLite, has matching WAL calls behind its non-default conn_raw_api feature. walship doesn't support it yet. With automatic checkpoints off, Turso also skips the log restart after a full fold, so the shipper would never see a new generation.

Limitations

  • Each database has one replica.
  • Only the latest state is kept, so there's no point-in-time restore. Use Litestream if you need that, next to walship or instead of it.
  • Read-only connections to a replica work and only see committed transactions. walship doesn't route any reads there for you.
  • A base is a full copy of the database. Uploading one times out after an hour, and the receiver refuses bases over 8 GiB unless you raise Routes::max_base_bytes.
  • Data goes over the wire uncompressed. Use TLS, a private network or a tunnel.
  • The page size can't change. A replica refuses frames whose page size doesn't match its base.

Why walship exists

We needed a standby copy of many small databases that a single process creates, restores and drops while it runs, and we wanted each copy on another machine, ready to open in seconds. Nothing we looked at did that.

Litestream came closest. It streams the WAL to object storage, and getting a database back means restoring it from there. It's set up per database, and it runs as a separate process watching files that another process owns, so it loses its place when that process swaps a file underneath it. For us the database server was the one process that knew when a file was created, replaced or dropped, so that's where replication went.

libSQL has replication of its own. Embedded replicas pull frames from a sqld primary, and the sync in Turso's Rust rewrite pushes and pulls against Turso Cloud. Both put a server in the middle. walship uses libSQL's WAL calls and leaves the transport to two of your own processes.

If you want backups or point-in-time restore, use Litestream. It's mature, it works with stock SQLite from any language, and walship does neither of those things. walship is for a Rust app on libSQL that wants a hot standby and nothing between primary and standby except HTTP.

Related projects

  • Litestream streams the WAL to object storage and restores from it.
  • LiteFS uses a FUSE filesystem and a primary lease, and replicates to any number of nodes.
  • libSQL embedded replicas sync from a sqld primary.
  • rqlite and dqlite replicate SQL statements through Raft, so writes keep going when a node dies, at the cost of running a cluster.

Testing

cargo test
cargo run --release --example stress -- /tmp/walstress 30 50
cargo run --example two_nodes
cargo run --example with_libsql

The stress example keeps a writer, a shipper and a reader on the replica running together over a simulated 50 ms link for the given number of seconds. Then it cuts a batch mid-transaction and restarts the receiver, resends frames the replica already has, leaves a gap, blocks a checkpoint with a reader and starts a new generation. Each step prints what happened, and it ends by comparing a checksum of every row on both sides.

About

walship is written and maintained by Tissue Systems. We built it for Tissue Cloud, a serverless platform for JavaScript and WebAssembly functions, where it keeps a standby copy of every customer database. We also make Tissue Mast, an on-call paging and monitoring service. Bug reports and pull requests are welcome whether or not you use either.

License

Licensed under either of

at your option.

Contribution

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

About

Ship libSQL WAL frames to a warm replica on another machine

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages