Sediment

SQLite-compatible database for Elixir on the Turso engine, with optional S3-backed durability.

Status: experimental. Expect rough edges; see Naming and the disclaimer below.

Sediment is an Elixir driver for Turso, the SQLite-compatible database engine written in Rust.

sediment is to Turso what exqlite is to SQLite: a NIF (built with Rustler over turso_core 0.8.1) plus a DBConnection implementation. The public API mirrors exqlite's module by module, so code written against exqlite ports over by renaming modules:

exqlite sediment
Exqlite Sediment
Exqlite.Sqlite3 Sediment.Engine
Exqlite.Sqlite3NIF Sediment.Native
Exqlite.Connection Sediment.Connection
Exqlite.Query Sediment.Query
Exqlite.Result Sediment.Result
Exqlite.Error Sediment.Error
Exqlite.Stream Sediment.Stream
Exqlite.Pragma Sediment.Pragma
Exqlite.TypeExtension Sediment.TypeExtension
Exqlite.Basic Sediment.Basic

It also exposes Turso-only features: MVCC with BEGIN CONCURRENT, page encryption, vector search, full-text search, change data capture and S3-backed durability.

If you are looking for the Ecto adapter, see ecto_sediment, the Sediment equivalent of ecto_sqlite3.

New to it? Start with the getting started guide.

Naming

You might expect a library like this to be called turso_ex or ecto_turso. Elixir integrations are often named after what they wrap, and that makes them easy to find. We deliberately didn't do that.

We love Turso. The Turso database engine is the foundation of this library. It's an impressive, open-source (MIT) reimplementation of SQLite in Rust, and everything Sediment does stands on that work. But "Turso" is the name of a company and its products, and we don't want to ride on the coattails of their good name:

So the name is our own. Sediment describes how the S3 layer works: committed changes settle into the bucket in layers, as log segments that get compacted into snapshots, and together they make up the solid ground your data rests on.

"Turso" appears in these docs only to describe what Sediment is built on: the engine, its compatibility and its behaviour. See also the disclaimer below: Sediment isn't affiliated with Turso.

Not affiliated with Turso. Sediment is an independent, community-maintained Elixir library built on the open-source Turso database engine (turso_core, MIT). "Turso" is a trademark of its respective owner and is used here only to describe compatibility. This project is not endorsed by, sponsored by, or otherwise connected with Turso or its company.

Provided under the MIT License, without warranty of any kind. The underlying engine is pre-1.0; keep independent backups of any data you care about.

Installation

defp deps do
[
{:sediment, "~> 0.1"}
]
end

With Ecto, depend on the adapter instead; it brings sediment with it:

{:ecto_sediment, "~> 0.1"}

Elixir 1.18 or later; the package is tested on Elixir 1.18 with OTP 27 and Elixir 1.20 with OTP 29, on Linux.

The NIF comes precompiled (via RustlerPrecompiled) for these targets, so no Rust toolchain is needed:

OS Targets
Linux, glibc 2.28+ x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu
Linux, musl (Alpine) x86_64-unknown-linux-musl, aarch64-unknown-linux-musl
macOS aarch64-apple-darwin, x86_64-apple-darwin
Windows x86_64-pc-windows-msvc

The download is checked against the checksums in the Hex package. glibc 2.28 covers Debian 10+, Ubuntu 20.04+, RHEL/Rocky/Alma 8+ and Amazon Linux 2023, but not Amazon Linux 2 (glibc 2.26). On Alpine the NIF links libgcc_s: install libgcc if your image doesn't have it. The test suites run on Linux only; the other targets are built in the release, not tested.

To build from source instead (any other target, or by policy), set SEDIMENT_BUILD=1 when compiling and install a Rust toolchain (1.91 or later). A git or path dependency on sediment always builds from source: the precompiled NIFs are only used from the Hex package, which carries their checksums.

Caveats

Configuration

Runtime configuration

config :sediment,
default_chunk_size: 100,
type_extensions: [MyApp.TypeExtension]

Compile-time configuration

Full-text search pulls in tantivy and adds a few minutes to the first build. It is on by default; to build without it:

config :sediment, Sediment.Native, default_features: false

Usage

The Sediment.Engine module works like Exqlite.Sqlite3:

# We'll just keep it in memory right now
{:ok, conn} = Sediment.Engine.open(":memory:")
# Create the table
:ok = Sediment.Engine.execute(conn, "create table test (id integer primary key, stuff text)")
# Prepare a statement
{:ok, statement} = Sediment.Engine.prepare(conn, "insert into test (stuff) values (?1)")
:ok = Sediment.Engine.bind(statement, ["Hello world"])
# Step is used to run statements
:done = Sediment.Engine.step(conn, statement)
# Prepare a select statement
{:ok, statement} = Sediment.Engine.prepare(conn, "select id, stuff from test")
# Get the results
{:row, [1, "Hello world"]} = Sediment.Engine.step(conn, statement)
# No more results
:done = Sediment.Engine.step(conn, statement)
# Release the statement.
:ok = Sediment.Engine.release(conn, statement)

With DBConnection (pooling, transactions, streams):

{:ok, conn} = Sediment.start_link(database: "app.db", journal_mode: :wal)
Sediment.query!(conn, "create table users (id integer primary key, name text)")
Sediment.query!(conn, "insert into users (name) values (?)", ["Alice"])
{:ok, %Sediment.Result{rows: [[1, "Alice"]]}} =
Sediment.query(conn, "select id, name from users")
Sediment.transaction(conn, fn conn ->
Sediment.query!(conn, "update users set name = ? where id = ?", ["Bob", 1])
end)

See Sediment.Connection.connect/1 for all connection options.

Turso features

See the Turso extensions guide for details and examples.

Telemetry

Sediment.Telemetry lists the events: spans around preparing and executing statements ([:sediment, :prepare | :query, ...]), pooled connection disconnects with their reason (S3 fencing, a contained turso_core panic, a closed handle), and spans for the S3 operations. For connect and disconnect counts, start the pool with a DBConnection.TelemetryListener in :connection_listeners.

Differences from exqlite

API parity

Every public function of exqlite, per module ("unsupported" functions exist and return {:error, :not_supported}; "no" means the function isn't provided; Exqlite.Sqlite3NIF and Exqlite.Flags are internal modules whose design differs). The exqlite API parity guide lists each function with the reason for every difference.

exqlite module sediment Functions present Unsupported or absent
Exqlite Sediment 27/27 none
Exqlite.Basic Sediment.Basic 8/8 disable_load_extension/1 (unsupported), enable_load_extension/1 (unsupported), load_extension/2 (unsupported)
Exqlite.Connection Sediment.Connection 15/15 none
Exqlite.Error Sediment.Error 1/1 none
Exqlite.Flags none 0/2 put_file_open_flags/1 (internal), put_file_open_flags/2 (internal)
Exqlite.Pragma Sediment.Pragma 12/12 none
Exqlite.Query Sediment.Query 1/1 none
Exqlite.Result Sediment.Result 1/1 none
Exqlite.Sqlite3 Sediment.Engine 36/36 enable_load_extension/2 (unsupported), set_authorizer/2 (unsupported), set_log_hook/1 (unsupported), set_update_hook/2 (unsupported)
Exqlite.Sqlite3NIF Sediment.Native 20/33 bind_blob/3 (no), bind_float/3 (no), bind_integer/3 (no), bind_null/2 (no), bind_text/3 (no), enable_load_extension/2 (no), erlang_allocator_enabled/0 (no), errmsg/1 (no), errstr/1 (no), load_nif/0 (no), set_authorizer/2 (no), set_log_hook/1 (no), set_update_hook/2 (no)

Turso is SQLite compatible at the file format and SQL level, but it is a different engine. Where Turso cannot behave like SQLite, sediment says so instead of faking it.

Not supported

exqlite sediment
Sqlite3.set_update_hook/2 returns {:error, :not_supported}; use Sediment.CDC
Sqlite3.set_authorizer/2 returns {:error, :not_supported}
Sqlite3.set_log_hook/1 returns {:error, :not_supported}
Sqlite3.enable_load_extension/2 returns {:error, :not_supported}
Basic.load_extension/2 returns {:error, :not_supported}
Sqlite3NIF.errmsg/1, errstr/1 not provided; errors are returned directly
connect options :load_extensions, :authorizer (non-empty) connect/1 fails with an error
connect option :key (SQLCipher) connect/1 fails; use :encryption
fts3, fts4, fts5 virtual tables no such module; use Turso FTS indexes
Numeric named parameters (:42) parse error; use ?NNN or named parameters with letters

Behaves differently

Performance and testing

bench/ compares sediment with exqlite; see bench/RESULTS.md. Through a DBConnection pool (and so Ecto) queries and single-row inserts are faster than exqlite's; through the low-level API reads are faster and writes 1.2-2x slower (turso_core's insert path plus two native calls per statement); concurrent writers in MVCC mode commit about twice as many transactions per second.

mix test runs the unit and integration suites. Tagged suites: --only s3 (needs an S3 server such as SeaweedFS on 127.0.0.1:8333), --only slow_test (cancellation and a longer fuzz run; the stress, timeout, model and fuzz tests also run in the default suite), --only soak (a mixed five-minute workload, SOAK_SECONDS to change it, that checks memory, NIF resources and scheduler latency stay bounded), and --only torture (S3 crash torture, 30 minutes, TORTURE_MINUTES to change it; see the S3 guide). .github/workflows/ci.yml runs mix ci, --only s3 and --only slow_test on the oldest and newest supported Elixir/OTP versions, plus cargo fmt, cargo clippy and cargo test, against a SeaweedFS service container; the soak and torture suites run locally (they take minutes to half an hour).

Under the hood

Each Sediment.Engine.open/2 creates a Turso connection guarded by a mutex that is held for the duration of every call, like exqlite's per-connection mutex. Turso's statement stepping is asynchronous; the NIF drives Turso's IO loop until a row or completion is available, on a dirty IO scheduler. Busy handler backoff is waited out in short slices so cancel/1 can abort a statement that is waiting for a lock.

turso_core compiles expressions and runs trigger programs recursively, which can need more stack than a dirty scheduler thread has (about 320 KB): a 50-term expression or a chain of 100 triggers would overflow it and crash the VM. So every call into turso_core runs on a 16 MB stack that each dirty IO scheduler thread maps once, with a guard page, and reuses. It is committed lazily, so it costs virtual address space rather than memory.

Acknowledgements

License

MIT, see LICENSE. It keeps exqlite's copyright notice for the ported code.

Cargo.lock lists webpki-root-certs (CDLA-Permissive-2.0), which may trip license allow-lists that only cover code licenses. It is a wasm32-only dependency of rustls-platform-verifier and is not built into the NIF on any supported target.