s3lite

package module
v0.14.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 2, 2026 License: Apache-2.0 Imports: 34 Imported by: 0

README

s3lite

A real database for serverless apps that idle to zero — backed by nothing but an object-store bucket.

Built for the developer who just wants to deploy their own thing: managed-grade durability at near-zero cost, with no database to babysit and no "which instance is the writer" incident to debug.

s3lite wraps litestream and a CGO-free SQLite driver so a plain container uses SQLite as a managed database: restore from S3 on startup, replicate to S3 continuously, and — via a lease built on the object store's own atomic conditional write — keep exactly one writer safe across restarts, deploys, and failover with no other moving parts. You get a standard *sql.DB.

What it enables

Stateful services have long forced a choice:

  • stateless app + always-on managed DB — scale the app to zero, but pay for and operate a Postgres/RDS that never sleeps, one network hop away; or
  • always-on stateful app — keep a process or VM up just to hold data and stay correct.

s3lite dissolves that trade-off: the only always-on, durable thing is the bucket — and object storage is cheap, ubiquitous, fully managed, and free when idle. Your app becomes:

  • Scale-to-zero, yet transactional. No state lives in the process — it restores from S3 on wake and replicates on write. Idle costs nothing, but every query hits a local ACID SQLite, not a network round-trip.
  • One binary + a bucket. Nothing to provision. Ship the same container against AWS S3, Scaleway, Cloudflare R2, or MinIO — the bucket is the backing service.
  • Correct under concurrency, with no coordinator. A compare-and-swap lease on lock.json (If-None-Match/If-Match) guarantees exactly one writer, so rolling deploys and failover are safe by construction — no Redis, etcd, or lock service. The object store is the coordinator.

Be clear about what the lease is — and isn't. It buys single-writer safety and zero-downtime handoff: a new instance boots read-only and promotes only once the old one releases. It is not a turnkey read-replica cluster. By default followers serve the snapshot they restored at startup and refresh only when they promote; set FollowerRefreshInterval to periodically catch up incrementally (applying only new LTX) for near-live, bounded-staleness reads (still not continuous live WAL streaming). And nothing routes for you — your app (or a load balancer) must direct writes to the leader and gate them on IsLeader(). Reading from followers scales only if bounded-staleness data is acceptable. See Single writer + read followers.

Together that's a class of app you couldn't cleanly build before — serverless, stateful, and correct at once: deploy-anywhere services that need a genuine transactional store but no always-on infrastructure.

Proof by the hardest case: gitmote is a git remote — concurrent pushes, correctness-critical, protocol-rigid — built entirely on s3lite, idling to zero between pushes. If a git server can run this way, ordinary CRUD apps trivially can. s3lite also runs in production behind pan0.com.

How it works

Every instance runs the same code; the lease decides who writes. The bucket holds the only durable state — a snapshot plus the replicated WAL.

flowchart TD
    S[Cold start] --> O["s3lite.Open()"]
    O --> R["Restore latest DB from S3<br/>(snapshot + WAL)"]
    R --> L{"CAS lease on lock.json<br/>(If-None-Match)"}
    L -->|acquired| W[Writer]
    L -->|held by another| F["Follower (read-only)"]

    W --> WS[Serve reads + writes on the local SQLite file]
    WS --> WR[Stream WAL to S3 + renew lease every TTL/3]
    WR --> WS

    F --> FS[Serve reads from the local copy]
    FS -->|"opt-in refresh tick<br/>(FollowerRefreshInterval)"| FR["Apply only new LTX<br/>(incremental), swap in read-only"]
    FR --> FS
    FS --> FP{Lease freed?}
    FP -->|poll| FS
    FP -->|yes| R

    W -. "Close / SIGTERM" .-> C[Flush WAL, release lease]
    C -. successor promotes at once .-> X((done))

Queries never leave the process — they hit the local file. S3 is touched only to restore on wake, stream the WAL, and arbitrate the lease. A hard kill can lose only the sub-second window since the last WAL sync; a clean Close loses nothing.

How it compares

litestream is the replication engine s3lite embeds; a managed Postgres is what you'd otherwise reach for. The trade:

s3lite litestream alone managed Postgres
What it is SQLite + litestream + a single-writer lease, in-process A WAL-replication sidecar for SQLite A networked RDBMS service
Durable state Your S3 bucket Your S3 bucket The server's managed disk
Cost when idle Zero — the app scales to zero Zero, but you run the sidecar Always-on (or slow serverless-PG cold starts)
Query path Local file, in-process (no network hop) Local file, in-process Network round-trip per query
Single-writer safety Enforced by the lease (handoff, failover) Not enforced — you must guarantee one writer N/A — real multi-writer
Concurrency & size One writer; fits one node (KBs–GBs) One writer; data fits one node High concurrency; large datasets
Read replicas Followers; opt-in near-live reads (bounded-staleness refresh), or snapshot-until-promotion by default None Real, live read replicas
Ops A bucket A bucket + supervise the sidecar process Provision, patch, back up — or pay for managed
Best for Single-writer apps (KBs to several GB) that want trivial deploy + automatic backup — idle-to-zero optional Adding S3 durability to one existing SQLite process Anything needing scale, concurrency, or big data

The only hard limit is the single writer: reach for Postgres when you need genuine multi-writer concurrency or a dataset beyond one node. Size itself is fine — a multi-GB DB just pays a longer cold-start restore, so keep one instance warm if that first-request latency matters.

Usage

db, err := s3lite.Open(ctx, s3lite.Config{
    LocalPath:   "/tmp/db.sqlite3",
    RestoreFrom: "s3://my-bucket/db",
    BackupTo:    "s3://my-bucket/db",
    S3: s3lite.S3Config{
        Region:          os.Getenv("AWS_REGION"),
        Endpoint:        os.Getenv("AWS_ENDPOINT_URL"), // for MinIO/Scaleway/etc.
        AccessKeyID:     os.Getenv("AWS_ACCESS_KEY_ID"),
        SecretAccessKey: os.Getenv("AWS_SECRET_ACCESS_KEY"),
    },
    Migrations: []string{
        `CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT)`,
        `CREATE INDEX IF NOT EXISTS users_email ON users(email)`,
    },
})
if err != nil {
    log.Fatal(err)
}
defer db.Close()

// db embeds *sql.DB — use it directly
rows, err := db.QueryContext(ctx, "SELECT id, email FROM users")

Point RestoreFrom and BackupTo at the same URL — restore what you've been backing up. On first deploy the replica is empty; Open handles that as a no-op and starts with a fresh DB.

Single writer + read followers (leasing)

litestream requires exactly one writer per replica, and s3lite enforces that with a lease whenever it replicates to S3 — an s3:// BackupTo is always leased (litestream's s3.Leaser, stored at <BackupTo path>/lock.json), so N instances run safely as one writer + many read-only followers. There is no uncoordinated "write to a shared WAL without a lease" mode. Config.Role only selects how an instance coordinates:

db, err := s3lite.Open(ctx, s3lite.Config{
    LocalPath: "/tmp/db.sqlite3",
    BackupTo:  "s3://my-bucket/db", // an s3:// replica is always leased
    S3:        s3cfg,
    Role:      s3lite.RoleAuto, // the default: acquire the lease if free, else follow
    Migrations: []string{ /* ... */ },
})
...
if db.IsLeader() {
    // safe to write
}
db.OnPromote(func()      { /* started accepting writes */ })
db.OnDemote(func(err error) { /* stop accepting writes now */ })

Roles:

  • RoleAuto (default) — acquire if free (writer) else follow. The mode a serverless consumer wants: safe rolling deploys (handoff by lease), writer failover, and read scaling, all by construction. With no s3:// replica (an unreplicated or file:// DB) it degrades to the sole writer.
  • RoleWriter — acquire the lease or fail Open with *litestream.LeaseExistsError.
  • RoleFollower — open read-only, never replicate; promote to writer if the lease becomes free.

RoleWriter and RoleFollower demand a lease, so Open fails without an s3:// BackupTo. Without an s3:// replica there is no shared WAL to coordinate on, so the instance is simply the sole writer — leasing is neither needed nor possible.

The holder renews at LeaseTTL/3 (default TTL 30s); a holder that cannot renew stops replicating immediately (before the TTL could let anyone else acquire), so two writers never overlap. Close releases the lease so a successor takes over at once instead of waiting out the TTL.

A follower normally promotes only when the background loop next polls (every LeaseTTL/3). To skip that wait, a consumer can call TryPromote on the write path so a request that arrives during a handoff blocks for the restore and then serves, instead of being refused until the next poll:

if !db.IsLeader() {
    if ok, err := db.TryPromote(ctx); err != nil || !ok {
        http.Error(w, "no writer available", http.StatusServiceUnavailable)
        return
    }
}
// now the writer — safe to write

TryPromote attempts to acquire the lease immediately: it returns true if this instance is (or, after restoring the latest replica, has just become) the writer, and false if the lease is still held by a live writer elsewhere (e.g. after a hard kill, until the old lease expires). It never promotes two writers — acquisition is the same lease CAS the loop uses — and is safe to call concurrently. It is strictly additive: an instance that never calls it is unchanged.

Release-on-idle: YieldLease + OnDemandPromotion

By default the holder is sticky: it renews until it dies, deploys, or Closes, so a live-but-idle holder pins the lease and a peer's write is refused (read-only) until a handoff. For a bursty, migratory writer — write where you are, read everywhere — you can invert that: hold the lease only while actively writing, and let whoever writes next acquire instantly.

db, _ := s3lite.Open(ctx, s3lite.Config{
    // ...
    Role:              s3lite.RoleAuto,
    OnDemandPromotion: true, // followers promote only via TryPromote, not in the background
})

// on the write path:
if !db.IsLeader() {
    if ok, err := db.TryPromote(ctx); err != nil || !ok { /* 503: no writer available */ }
}
// ... serve the write ...

// when the instance goes idle (the consumer decides), hand the lease back:
if err := db.YieldLease(ctx); err != nil && !errors.Is(err, s3lite.ErrNotLeader) {
    // an aborted yield (replica outage) left us still the leader; try again later
}

YieldLease performs a safe voluntary handoff and keeps the instance alive as a read-only follower: it fences the handle, does a final durable sync, stops replication, records a clean-shutdown marker, and only then deletes the lock — so a peer acquiring right after sees every acked write (single-writer is never violated). If the final sync can't complete before the lease deadline the yield aborts atomically: the instance stays the leader with renewals running, or — if it can no longer hold the lease — takes the standard demote path. A successful yield never fires OnDemote (it is a relinquishment, not a lost lease); it returns ErrNotLeader if the instance is not the current holder. A yielded instance that later re-promotes with no interim writer resumes in place (no re-download); one that finds a peer wrote in between restores.

OnDemandPromotion is what makes a yield worthwhile: without it, this instance's own background loop (or an idle peer's) re-acquires the just-freed lock within a tick, so the system ping-pongs restores. With it, a follower promotes only via an explicit TryPromote on your write path, so the lock stays free between bursts. Open's direct acquire is unchanged (bootstrap and crash re-entry still work), and a crashed ex-leader that may hold an unshipped tail keeps recovering eagerly rather than parking it — so on-demand mode never turns a crash into silent data loss. Use the two together; a bare YieldLease without OnDemandPromotion is self-defeating.

Followers serve the snapshot they restored at Open and always refresh on promotion (a follower restores the latest state before it starts writing). To also serve near-live reads, set Config.FollowerRefreshInterval: the follower then periodically catches up to the leader's latest committed state and swaps it in read-only, with staleness bounded by roughly the interval plus the leader's replication lag. The catch-up is incremental — each tick fetches and applies only the LTX committed since the follower's position, not a full snapshot — so a large database on a short interval stays cheap. A tick that finds nothing new does no work, so idle followers are cheap too; register OnRefresh to bust caches when new state lands. Left at zero (the default) a follower serves only its Open snapshot — unchanged behaviour. The refresh replaces the local file underneath the stable handle, so a read that is in flight at the swap may see a rare, retryable error (the connection is dropped and re-dialed); keep the interval modest and retry.

db.Refresh(ctx) is the same catch-up on demand: it pulls the replica's latest state synchronously and reports whether anything advanced, so a request handler (or an interactive shell) can force freshness right before a read instead of living with the interval — and it works with FollowerRefreshInterval unset, since that setting is the background cadence, not a prerequisite. It never takes the writer role (that is TryPromote) and costs two listings on a reused client when the replica has not moved, so calling it per read is affordable:

if _, err := db.Refresh(ctx); err != nil {
    return err // the follower keeps serving its current state
}
row := database.QueryRowContext(ctx, "SELECT ...")

The incremental refresh depends on a small litestream fork (a follow-mode resume fix); see docs/litestream-fork.md.

The embedded *sql.DB is stable for the life of the instance — it is created once and never reassigned, even across promote/demote. Take it once (database := db.DB), pass it to your repositories, and keep using it: connections are transparently re-dialed against the current file in the current mode. A follower's handle is read-only (query_only), so if you serve traffic before promotion, gate write paths on IsLeader; you never need IsLeader or OnPromote merely to keep a handle valid.

On demotion the handle is fenced: new writes, writes on a checked-out *sql.Conn, and the Commit of a transaction begun while leader are all rejected, so a demoted writer cannot persist locally on a lease it no longer holds. Two foot-guns are on you, not the library:

  • Do not call Close from an OnPromote/OnDemote/OnRefresh callback. Those callbacks run on the internal lease-loop goroutine, and Close blocks waiting for that goroutine to exit — calling it from inside a callback deadlocks. (An OnRefresh fired by an explicit Refresh runs on the calling goroutine, but keep the rule.)
  • Do not defeat query_only on a follower (e.g. PRAGMA query_only=0). A follower's local writes never replicate and are silently destroyed by the next restore or refresh.

A consumer that keeps state derived from the database — caches, externally stored blobs, queued deletions — needs to tell two writer-entry outcomes apart: a restore, where the local file was replaced by the replica (a takeover; the previous holder's un-synced tail is gone — see the loss window under Limitations) and must be treated as a possible rewind; versus a resume in place, where the local committed tail was kept (a same-machine restart) and nothing was discarded. Both advance the generation, so Generation() alone conflates them. LastPromoteOutcome() reports which happened:

db.OnPromote(func() {
    if out, _ := db.LastPromoteOutcome(); out.Restored {
        // possible rewind: pause GC / destructive maintenance and reconcile
        // derived state before trusting the metadata again
        reconcile()
    }
})

It reflects the most recent writer entry — valid (ok == true) after a writer Open and after each promotion, false on a follower that has never promoted — and covers both entry paths (a loop/TryPromote promotion and a direct acquire at Open). A first-ever writer entry with no prior local file reads as restored, erring the same conservative direction as the fork guards. It is purely a signal: it changes no restore decision, only lets a consumer skip the reconciliation pass on a plain restart.

Client-side encryption (the bucket never sees plaintext)

Set Config.EncryptionKey and every LTX object s3lite ships is encrypted before it leaves the process, under a key only your app holds. This is for a threat model that includes the bucket operator, or anyone who obtains read credentials — unlike SSE-C / SSE-KMS, which are server-side, so the provider decrypts on request.

db, err := s3lite.Open(ctx, s3lite.Config{
    LocalPath:     "/tmp/db.sqlite3",
    BackupTo:      "s3://my-bucket/db",
    S3:            s3cfg,
    EncryptionKey: key, // exactly 32 bytes, from your secret manager — never in code
})

Reads need the same key. A wrong key fails with s3lite.ErrKeyMismatch ("this bucket is not yours"), a missing one with s3lite.ErrReplicaEncrypted ("you forgot the key") — never data, and never something that looks like a corrupt database. Leave EncryptionKey empty (the default) and nothing changes: no wrapper is installed, and the objects are byte-identical to a build without this feature.

What is and is not hidden

Encrypted: the contents of every LTX object — every page of your database and every WAL frame, including anything compaction rewrites.

Not hidden, and you should decide whether that matters to you:

  • Object names encode the compaction level and TXID range.
  • Object sizes are visible, so database size and write volume are observable.
  • Object timestamps are in the metadata litestream already writes (they are what timestamp-based restore and retention read back).
  • lock.json stays plaintext. It is the lease object, not a replica object — its body is {generation, expires_at, owner}, pure coordination state with no application data, and it is the one object an operator inspects by hand. What it leaks is those three fields. Because litestream's default owner is derived from the hostname, s3lite defaults Owner to an opaque random per-instance id when a key is set, so an encrypted instance does not publish machine names. Set Config.Owner explicitly to override; unencrypted consumers keep the diagnostic hostname.
  • Local state — LocalPath, the .<name>-litestream/ position directory, staging temp files — is out of scope. That rests on your host's disk encryption.
How it works, and what it costs

Each object is header | frames: a 40-byte header (magic, version, frame-size code, a 32-byte random salt) followed by 64 KiB plaintext frames sealed with ChaCha20-Poly1305. Overhead is a 16-byte tag per frame — 0.024%.

Framing rather than one seal per object is not a detail: litestream reopens a dropped stream mid-object at an arbitrary byte offset, so decryption has to be able to start at an interior boundary. Each frame's key is derived HKDF-SHA256(EncryptionKey, salt, "s3lite-ltx-v1" ‖ level ‖ minTXID ‖ maxTXID) — the identity binding is what stops anyone with bucket write access from moving a body between object names — and each frame is authenticated over its index and a final-frame flag, so a reordered, duplicated, or dropped frame is an error rather than a short read.

The per-object random salt matters because the same object name can legitimately be rewritten with different bytes (a retried upload, a re-run compaction), which under an identity-derived nonce would be keystream reuse.

Key rotation is not supported — on purpose

s3lite is a single-key primitive. Every object is sealed under a key derived from its own salt, so rotating in place would mean rewriting the whole replica. If you want cheap rotation, wrap your own data key above s3lite and keep the master key here.

Adopting it on an existing replica

Turning the key on is safe on a replica that already holds plaintext objects: they are detected per object and pass through, so restores keep working while retention ages the plaintext out (the default snapshot retention is 24h — mind bucket versioning and soft-delete, which retain it regardless). Once every live object is encrypted, set RequireEncrypted: true. That closes the downgrade path where someone with bucket write access substitutes a crafted plaintext LTX file for an encrypted one, and it also makes the size bookkeeping exact — during the mixed window a pre-key object's reported size is under-reported by its would-be framing overhead, which slightly weakens litestream's premature-EOF recovery on that object (a truncated read then surfaces as a decode error instead of being retried; it is never silently short).

Encryption depends on the second patch in the litestream fork — the backend needs to accept the LTX timestamp from the caller, because ciphertext cannot be peeked for an LTX header. See docs/litestream-fork.md.

The s3lite shell

go install github.com/atmin/s3lite/cmd/s3lite@latest

s3lite --role=writer   s3://my-bucket/db   # take the lease, stream the WAL
s3lite --role=follower s3://my-bucket/db   # read-only, a fresh pull per statement

A prompt against your bucket, with the library's semantics unchanged: reads come from the local copy, a writer streams to the replica, and --role=writer fails to open while a peer holds the lease. It is packaging, not architecture — a REPL over Open, the returned *sql.DB, and Close. What it does that sqlite3 cannot:

  • Familiar, not compatible. It implements the dot-commands people actually type — .tables .schema .mode .headers .dump .import .help .quit — and claims nothing beyond them. Scripts that need the real shell should keep using sqlite3 against the local file.
  • Freshness per statement, once per pipe. At a prompt, human think-time dominates a pull, so every statement is preceded by a Refresh: what you read is what the replica held when you pressed Enter. Piped input (s3lite … < script.sql) is logically one read of one version — it pulls once, before the first statement. Which contract applies is decided by whether stdin is a terminal.
  • A transaction sees one snapshot. Publishing new state swaps the file underneath the session, so the pull is suppressed between BEGIN and COMMIT/ROLLBACK and resumes on the first statement after. .help says so.
  • The pen is held only while you write. The session opens with OnDemandPromotion, takes the lease on a statement, and YieldLeases it after --idle-yield (30s) of silence at the prompt; the next statement takes it back. A forgotten terminal does not pin the lease. --role=follower never takes it and rejects writes, naming the flag that would not.
  • Ctrl-C is INVARIANTS.md #4 made visible. The first interrupt cancels the statement in flight; one at an idle prompt exits through the same durable, bounded Close a SIGTERMed server gets, and reports what it flushed.
  • A cold open shows progress. Config.OnRestoreProgress drives a stderr bar on a multi-GB first start, and the lifecycle log narrates promote/yield/restore.

The local copy defaults to a stable path under your user cache directory keyed by the replica URL, so reopening the same URL resumes it rather than downloading it again (--local overrides). S3 settings come from --endpoint/--region/ --access-key-id/--secret-access-key, falling back to the AWS variables below; --key-file supplies the client-side encryption key for an encrypted replica. s3lite --help is the full list.

Configuration

The library itself reads no environment variables. Pass S3 settings via S3Config. Empty fields fall through to the AWS SDK's default credential chain (env vars, ~/.aws/config, IAM roles), so on EC2/ECS/Lambda you can leave credentials blank and rely on the instance role. The s3lite shell above is the one part that reads the environment directly: its S3 flags fall back to $AWS_ENDPOINT_URL, $AWS_REGION, $AWS_ACCESS_KEY_ID and $AWS_SECRET_ACCESS_KEY before handing what is left to that same chain.

Limitations

  • Single writer per replica. Enforce it yourself (one instance) or let s3lite enforce it with a lease — see Single writer + read followers.
  • Restore happens on Open — cold starts pay this cost, proportional to DB size (sub-second for small DBs, longer for multi-GB). Keep one instance warm if a large-DB restore would hurt first-request latency. It is logged on Config.Logger as a lifecycle event — "s3lite: restoring from replica" before it starts, "s3lite: restore complete" with the elapsed time and restored size after — on every path that pulls the whole database down (the cold Open and a takeover promotion alike), so an app blocking on Open can surface a "restoring…" state rather than an unexplained pause. For a live percentage rather than a phase, set Config.OnRestoreProgress: it reports bytes fetched against the restore plan's total on those same paths, which is also what tells a stalled restore (the count stops) from a slow one (it keeps moving) — a distinction no deadline can draw without also killing slow-but-healthy restores. It depends on the litestream fork; see docs/litestream-fork.md.
  • Followers serve their Open-time snapshot and only refresh on promotion unless FollowerRefreshInterval is set, which gives bounded-staleness near-live reads by periodically applying only the LTX committed since the follower's position (incremental, but interval-driven — not continuous live WAL tailing). Refresh runs that same catch-up synchronously on demand, so a read that must not be stale can pay for its own freshness.
  • A clean Close is durable: it flushes all committed writes to the replica before returning (bounded by Config.ShutdownSyncTimeout, default 30s). Only a hard crash/kill can lose the sub-second window since litestream's last sync. The writer opens with synchronous=NORMAL by default: WAL mode stays crash-consistent (no corruption), and the un-fsynced WAL tail it can lose on a hard crash is already within that same sub-second replication window — so it costs no real durability while saving an fsync per commit. Applications whose own contract makes a commit mean fsynced-to-disk can set Config.Synchronous: "FULL" (and Config.TxLock: "immediate" to take the write lock at BEGIN) — the pragmas apply to every writer connection.
  • A leased writer that crashes and restarts on the same machine recovers like an unleased one: it resumes its local file in place and keeps the writes it committed after its last sync, instead of restoring the replica over them. It restores (accepting that sub-second window as lost) only when another instance acquired the lease in between — a genuine takeover its local file may have forked from. So the loss window is at risk only on real machine loss or a true failover, not on a plain process restart. This holds however the writer re-enters: whether its lease was still held at reopen (it comes back as a follower and promotes) or had already expired (it re-acquires the lease directly at Open). A plain clean restart likewise resumes in place with no full re-download. See INVARIANTS.md #9.
  • Client-side encryption (Config.EncryptionKey) covers the replica objects only, and is a single-key primitive — no rotation. Object names, sizes and timestamps stay visible, lock.json stays plaintext, and local files rest on host disk encryption. See Client-side encryption.
  • The replica's object format moves forward only: a newer s3lite reads what an older one wrote, but not the reverse. Once a newer build has written to a replica, older builds can no longer restore it — so mixed-version fleets should upgrade readers first. That case is named rather than left to look like damage: it fails with s3lite.ErrReplicaFormatNewer, which an application should surface as "upgrade" (see INVARIANTS.md #12).

Guarantees

The correctness guarantees — single writer per replica, fencing on demotion, durability, follower staleness bounds, handle stability — are written up in INVARIANTS.md, each naming the test that fails if it breaks. Read it if you are deciding whether to trust s3lite with your data.

Contributing

CONTRIBUTING.md is the entry point for working on s3lite — the repo layout, the commit convention, and the gate every change passes. The suites are in docs/testing.md; the litestream fork s3lite pins is a patch ledger in docs/litestream-fork.md.

Documentation

Overview

Package s3lite provides S3-backed embedded SQLite for serverless containers. It wraps litestream for continuous replication and a CGO-free SQLite driver.

Example (Basic)
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"path/filepath"

	"github.com/atmin/s3lite"
)

func main() {
	ctx := context.Background()
	dir, err := os.MkdirTemp("", "s3lite-example-*")
	if err != nil {
		log.Fatal(err)
	}
	defer os.RemoveAll(dir)

	dbPath := filepath.Join(dir, "app.sqlite3")
	replicaURL := "file://" + filepath.Join(dir, "replica")

	db, err := s3lite.Open(ctx, s3lite.Config{
		LocalPath:   dbPath,
		RestoreFrom: replicaURL,
		BackupTo:    replicaURL,
		Migrations: []string{
			`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT)`,
			`CREATE INDEX IF NOT EXISTS users_email ON users(email)`,
		},
	})
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	if _, err := db.ExecContext(ctx, `INSERT INTO users (email) VALUES (?)`, "alice@example.com"); err != nil {
		log.Fatal(err)
	}

	var email string
	if err := db.QueryRowContext(ctx, `SELECT email FROM users LIMIT 1`).Scan(&email); err != nil {
		log.Fatal(err)
	}
	fmt.Println(email)

}
Output:
alice@example.com
Example (S3)

Example_s3 shows the S3 replica URL form. The caller sources S3 settings (from env, config file, secret manager, etc.) and passes them explicitly. Empty S3Config fields fall back to the AWS SDK's default credential chain.

package main

import (
	"context"
	"log"
	"os"

	"github.com/atmin/s3lite"
)

func main() {
	ctx := context.Background()
	db, err := s3lite.Open(ctx, s3lite.Config{
		LocalPath:   "/tmp/app.sqlite3",
		RestoreFrom: "s3://my-bucket/db",
		BackupTo:    "s3://my-bucket/db",
		S3: s3lite.S3Config{
			Region:          os.Getenv("AWS_REGION"),
			Endpoint:        os.Getenv("AWS_ENDPOINT_URL"),
			AccessKeyID:     os.Getenv("AWS_ACCESS_KEY_ID"),
			SecretAccessKey: os.Getenv("AWS_SECRET_ACCESS_KEY"),
		},
		Migrations: []string{
			`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT)`,
		},
	})
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	_ = db // use db as a standard *sql.DB
}

Index

Examples

Constants

View Source
const DefaultLeaseTTL = 30 * time.Second

DefaultLeaseTTL is the lease duration used when Config.LeaseTTL is zero. It matches litestream's s3.DefaultLeaseTTL.

View Source
const DefaultShutdownSyncTimeout = 30 * time.Second

DefaultShutdownSyncTimeout is the flush deadline Close uses when Config.ShutdownSyncTimeout is zero.

Variables

View Source
var ErrClosed = errors.New("s3lite: instance is closed")

ErrClosed is returned by TryPromote when the instance is closing, so a promotion racing Close cannot resurrect a torn-down instance.

View Source
var ErrKeyMismatch = errors.New("s3lite: replica object does not authenticate under the configured EncryptionKey")

ErrKeyMismatch reports that an encrypted object would not authenticate under the configured key — "this bucket is not yours (or its bytes were tampered with)". A wrong key and a modified object are cryptographically indistinguishable, so both report this; either way no plaintext is ever returned.

View Source
var ErrNotLeader = errors.New("s3lite: not the lease holder")

ErrNotLeader is returned by YieldLease when the instance is not the lease holder — a follower, an already-yielded instance (double yield), or an unleased sole writer (which has no lease to yield). Only a live leader can voluntarily hand off.

View Source
var ErrObjectNotEncrypted = errors.New("s3lite: replica holds a plaintext object and RequireEncrypted is set")

ErrObjectNotEncrypted reports that a plaintext object was found on a replica opened with Config.RequireEncrypted. Leave that flag false while a previously-plaintext replica still holds pre-key objects.

View Source
var ErrReplicaEncrypted = errors.New("s3lite: replica is encrypted but no EncryptionKey is configured")

ErrReplicaEncrypted reports that the replica holds s3lite-encrypted objects but this instance has no Config.EncryptionKey — "you forgot the key", not a corrupt database. Restore, follower refresh and Open surface it wrapped.

View Source
var ErrReplicaFormatNewer = errors.New("s3lite: the replica was written by a newer s3lite than this build can read")

ErrReplicaFormatNewer reports a replica object that announces itself as an LTX file and then does not parse as one — "this replica was written by a newer s3lite", not "your database is corrupt". Consumers render it as an instruction to upgrade.

It is a reading of the evidence rather than a guess. The object's frames authenticated under the configured key (or the replica is plaintext and there is nothing to authenticate), so its bytes are exactly the bytes some writer wrote — damage in transit or at rest would have failed the tag first. Bytes that are authentic, say LTX, and still do not parse can only have come from a format this build predates.

Functions

This section is empty.

Types

type Config

type Config struct {
	// LocalPath is the on-disk path for the SQLite file. Required. It may not
	// contain '#', '?' or '%': SQLite reads all three as URI syntax inside a
	// filename, so such a path would not open the file it names, and Open refuses
	// it rather than escaping it (INVARIANTS.md #13).
	LocalPath string

	// RestoreFrom is a replica URL (file:// or s3://) to restore from on startup
	// when LocalPath does not yet exist. Ignored if LocalPath already exists.
	// Safe to set on first deploy — an empty replica is not an error.
	RestoreFrom string

	// BackupTo is a replica URL (file:// or s3://) to replicate to continuously.
	// Omit to run without replication.
	BackupTo string

	// Migrations are SQL strings executed in order on every Open. Each must be
	// idempotent (e.g. CREATE TABLE IF NOT EXISTS) — there is no version table.
	Migrations []string

	// Synchronous sets PRAGMA synchronous on every writer connection: "OFF",
	// "NORMAL", "FULL", or "EXTRA" (case-insensitive). Empty means "NORMAL",
	// the default documented in the README's durability note: WAL keeps the
	// local file crash-consistent either way, and the un-fsynced WAL tail
	// NORMAL can lose on a hard crash is already within the replication
	// window. Set "FULL" when the application's own contract makes a commit
	// mean fsynced-to-local-disk (e.g. a server whose reply promises stable
	// storage) — that closes the local-crash window; the replication window
	// to the bucket remains. Followers are unaffected: their connections are
	// query_only and never write.
	Synchronous string

	// TxLock sets how transactions opened through database/sql (Begin,
	// BeginTx) start on writer connections: "deferred" (SQLite's default),
	// "immediate", or "exclusive" (case-insensitive) — the driver's _txlock
	// DSN parameter. A writer whose every transaction mutates wants
	// "immediate": the write lock is taken at BEGIN, so contention surfaces
	// as a busy_timeout wait up front instead of SQLITE_BUSY at the first
	// write inside the transaction. Followers are unaffected.
	TxLock string

	// S3 configures s3:// replicas. Applies to both RestoreFrom and BackupTo.
	// Empty fields fall back to the AWS SDK's default credential and region chain
	// (env vars, ~/.aws/config, IAM roles). Set Endpoint for MinIO or other
	// S3-compatible providers — path-style addressing is enabled automatically.
	S3 S3Config

	// Logger receives s3lite's own lifecycle events (promote/demote/restore) at
	// their natural levels — including the start and completion of every restore,
	// with its elapsed time, so the cold restore inside Open is observable rather
	// than an unexplained pause. litestream's internal logger is derived from this but
	// gated to WARN+, so its per-interval "replica sync" INFO chatter is dropped
	// while real replication problems still surface. When nil, a WARN+ stderr
	// logger is used. Set to slog.Default() to mirror the host application.
	Logger *slog.Logger

	// OnRestoreProgress, when non-nil, is called while a whole-database restore
	// downloads, with the bytes fetched so far and the total the restore plan will
	// fetch — LTX bytes on the replica, not the size of the restored database. It
	// fires on every path that pulls the whole database down: Open's cold restore,
	// the Open-direct fork guard, and a takeover promotion's rebuild. (A follower's
	// incremental refresh applies deltas to a private follow file instead and
	// reports nothing.) It is a Config field rather than a method because Open's
	// restore runs before the handle exists, and that is the restore that matters
	// most.
	//
	// It answers what the restore log lines cannot. A percentage for a human
	// waiting on a cold multi-GB start — and, because a wedged range read freezes
	// the byte count while a slow link keeps it moving, the difference between a
	// stalled restore and a merely slow one, which no deadline can tell apart
	// without also killing slow-but-healthy restores.
	//
	// The first call reports (0, total) as soon as the plan is known, applied never
	// regresses, and the last call of a successful restore is (total, total). Calls
	// are serialized, so the callback needs no locking of its own — but it runs
	// inline with the download, once per read chunk, so it must be cheap: sample it,
	// do not redraw or log per call. A restore with nothing to fetch (an empty
	// replica) never calls it at all.
	//
	// Depends on the litestream fork — see docs/litestream-fork.md, row 4.
	OnRestoreProgress func(applied, total int64)

	// ShutdownSyncTimeout bounds the final durable flush performed by Close on a
	// replicated DB. Close blocks up to this long waiting for the replica to
	// catch up before it gives up (returning an error) rather than hanging on an
	// unreachable replica. When zero, DefaultShutdownSyncTimeout (30s) is used.
	// Ignored when BackupTo is empty. CloseContext ignores this in favour of the
	// caller-supplied context deadline.
	ShutdownSyncTimeout time.Duration

	// Role selects how an instance coordinates single-writer access. Coordination
	// needs the object store's atomic conditional write, so it applies only to an
	// s3:// BackupTo — where the lease lives at "<BackupTo path>/lock.json" and is
	// mandatory: an s3-replicated DB is always leased, never an uncoordinated
	// writer. Without an s3:// BackupTo (empty, or a file:// replica) there is no
	// shared WAL to coordinate on, so the instance is simply the sole writer and
	// Role is moot.
	//
	//   RoleAuto     — try to acquire; become writer on success, follower on
	//                  contention. The default, and the mode a serverless consumer
	//                  wants. With no s3:// BackupTo it degrades to the sole writer.
	//   RoleWriter   — acquire the lease or fail Open with *litestream.LeaseExistsError.
	//   RoleFollower — never replicate; open read-only and serve the restored
	//                  snapshot; promote to writer if the lease becomes free.
	//
	// RoleWriter and RoleFollower demand a lease, so Open fails if BackupTo is not
	// s3://. litestream requires exactly one writer per replica; the lease enforces
	// that by protocol so N instances run safely as one writer + many followers.
	Role Role

	// LeaseTTL is the lease duration for leased instances. The holder renews at
	// TTL/3; a holder that cannot renew stops replicating before the TTL lets anyone
	// else acquire, so two writers never overlap. When zero, DefaultLeaseTTL (30s)
	// is used. Ignored without an s3:// BackupTo.
	LeaseTTL time.Duration

	// Owner is the lease owner id recorded in the lock file for diagnostics. When
	// empty, litestream generates one (hostname:pid). Ignored without an s3:// BackupTo.
	Owner string

	// FollowerRefreshInterval, when > 0, makes a follower periodically catch up to the
	// leader's latest committed state from BackupTo and serve it read-only, giving
	// near-live reads with staleness bounded by roughly this interval plus the
	// leader's replication lag. The catch-up is incremental — it fetches and applies
	// only the LTX committed since the follower's position, not a full snapshot each
	// tick — so it stays cheap even for a large database on a short interval. When 0
	// (the default) a follower serves the snapshot it restored at Open and refreshes
	// only on promotion — bit-identical to prior behaviour. Ignored without an s3://
	// BackupTo (nothing to follow) and while this instance is the writer. Best-effort:
	// a failed refresh is logged and the follower keeps serving its current state. A
	// refresh that lands new data swaps the local file underneath the stable handle, so
	// an in-flight read spanning the swap may see a rare, retryable error; ticks that
	// find nothing new do no swap.
	FollowerRefreshInterval time.Duration

	// EncryptionKey, when non-empty, encrypts every LTX object written under
	// BackupTo with the caller's 32-byte key, so the bucket operator — or anyone
	// who obtains read credentials — holds only ciphertext. Reads require the same
	// key; a wrong or absent key is a clean typed error (ErrKeyMismatch,
	// ErrReplicaEncrypted), never data and never a corrupt-looking database. Empty
	// (the default) writes plaintext, byte-identical to a build without this
	// feature: no wrapper is installed at all.
	//
	// What it does not hide: object names encode the compaction level and TXID
	// range, object sizes are visible (so database size and write volume are),
	// object timestamps are in the metadata litestream already writes, and
	// lock.json stays plaintext — though with a key set an empty Owner defaults to
	// an opaque random id instead of the hostname. Local state (LocalPath, the
	// position directory, staging temp files) is out of scope and rests on the
	// host's disk encryption. See the README's client-side-encryption section.
	//
	// Key rotation is deliberately not supported: s3lite is a single-key
	// primitive. Each object is sealed under a key derived from its own random
	// salt, so an in-place rotation would be a full rewrite of the replica. A
	// consumer that wants cheap rotation should wrap its own data key above s3lite
	// and keep the master here.
	EncryptionKey []byte

	// RequireEncrypted refuses to read a plaintext object from an encrypted
	// replica. Leave it false while a previously-plaintext replica still holds
	// pre-key objects — they keep restoring while retention ages them out. Set it
	// once every live object is encrypted: that closes the downgrade path where
	// anyone with bucket *write* access substitutes a crafted plaintext LTX file
	// for an encrypted one. Requires EncryptionKey.
	RequireEncrypted bool

	// OnDemandPromotion, when true, makes a follower promote only via an explicit
	// TryPromote (the consumer's write path) rather than greedily in the background. It
	// exists to pair with YieldLease: a bare yield is self-defeating without it, since the
	// yielder's own background loop (or an idle peer's) would re-acquire the just-freed
	// lock within a tick and ping-pong restores. With it set, the lock is held only while a
	// writer is actively writing; between bursts it is free and whoever writes next acquires
	// instantly through TryPromote.
	//
	// Open's direct acquire is unchanged — bootstrap (first-writer schema creation) and an
	// expired-crash re-entry still acquire at Open — at the cost of one harmless
	// acquire-then-idle cycle per boot when the lock is free. The one exception to passivity
	// is eager recovery: an instance that may hold an unshipped local tail (a follower that
	// finds it was a crashed ex-leader here, or one that was just demoted) keeps
	// background-promoting until a writer entry ships or discards that tail, so on-demand
	// mode never turns a crash into silent data loss. A cleanly yielded/closed instance has
	// its tail provably shipped, so it stays passive. Ignored without an s3:// BackupTo.
	OnDemandPromotion bool
}

Config holds the options for Open.

type DB

type DB struct {
	*sql.DB
	// contains filtered or unexported fields
}

DB wraps *sql.DB with optional litestream replication. All *sql.DB methods are available directly via embedding.

The embedded *sql.DB is stable for the whole life of the instance: it is created once and never reassigned, even across lease promote/demote. Callers may take it once (database := db.DB), hand it to repositories, and keep using it — connections are transparently re-dialed against the current local file in the current mode (writable as leader, read-only as follower). Use IsLeader or OnPromote/OnDemote only to make decisions about role; you never need them to keep a handle valid. A follower's handle rejects writes (query_only), so gate write paths on IsLeader if you serve traffic before promotion.

Fencing caveats for a misbehaving consumer:

  • A follower can escape read-only mode with PRAGMA query_only=0 and write to its local file. Such writes never replicate and are destroyed by the next restore/refresh — do not do this.
  • Demote fences the cached handle, including in-flight transactions and writes on a checked-out *sql.Conn. One residual hole is not closed: a *sql.Stmt prepared and then executed on a connection that was already checked out before the demote bypasses the driver-level write fence. Prepared-statement writes should still be gated on IsLeader.

func Open

func Open(ctx context.Context, cfg Config) (*DB, error)

Open opens or creates a SQLite database at cfg.LocalPath.

Without an s3:// BackupTo the instance is the sole writer:

  1. If RestoreFrom is set and LocalPath does not exist, restore from replica.
  2. Start litestream replication if BackupTo is set (a file:// replica).
  3. Open the SQLite connection and apply WAL pragmas.
  4. Run Migrations in order.

With an s3:// BackupTo the instance first coordinates via the lease (see Config.Role): a writer replicates and runs Migrations as above; a follower opens read-only, skips replication and Migrations, and serves the restored snapshot. Open returns *litestream.LeaseExistsError for RoleWriter when the lease is held.

Call Close when done to flush replication, release the lease, and free resources.

func (*DB) Close

func (db *DB) Close() error

Close durably flushes pending replication, stops litestream, and closes the database. On a replicated DB (BackupTo set) it guarantees the replica reflects all committed writes before returning — callers do not need a separate Sync. The flush is bounded by Config.ShutdownSyncTimeout (default 30s): if the replica cannot be reached in time, Close returns an error rather than hanging. With no replica configured it is a fast no-op beyond closing the DB. Returns the first non-nil error encountered.

func (*DB) CloseContext

func (db *DB) CloseContext(ctx context.Context) error

CloseContext is like Close but bounds the final durable flush by ctx instead of Config.ShutdownSyncTimeout. Use it to wire Close into an existing graceful-shutdown deadline. The database handle is always closed even if the flush errors or ctx expires. For a leader it also releases the lease so a successor can take over immediately rather than waiting out the TTL.

It is safe to call Close/CloseContext more than once sequentially: after the first call every later call is a nil no-op. (Concurrent calls from multiple goroutines are not made safe — serialise your shutdown.)

func (*DB) Generation

func (db *DB) Generation() int64

Generation returns the lease generation, or 0 when not holding a lease (including an unleased sole writer). It is unique among concurrent contenders and increases across takeovers only while the lock object survives: an expiry or a forced steal bumps it (1 → 2 → 3 …), but a clean release (Close) deletes the lock, so the next acquirer starts again at 1.

Because of that reset, do not use Generation as a cross-handoff fencing token for external systems: after a clean handoff a consumer sees the sequence go 1 → 2 → 1, which is exactly when a stale-writer fence would need it to keep increasing. What it is good for: telling apart two promotions within one instance's lifetime, and logging/diagnostics.

A durable fencing token would require persisting an epoch next to the replica data (outside lock.json, which litestream's s3.Leaser owns); that is intentionally not built here.

func (*DB) IsLeader

func (db *DB) IsLeader() bool

IsLeader reports whether this instance currently holds write access: it is the lease holder, or the sole writer when unleased (no s3:// BackupTo). Followers return false until they promote. Use it to gate whether the process should accept writes.

func (*DB) LastPromoteOutcome added in v0.7.0

func (db *DB) LastPromoteOutcome() (PromoteOutcome, bool)

LastPromoteOutcome reports how this instance most recently became the writer (see PromoteOutcome). ok is true once this instance has entered the writer role — after a writer Open or a later promotion — and false on a follower that has never promoted. The value is stable until the next writer entry; a demotion leaves it reporting the last one. Read it inside OnPromote, or after Open/TryPromote reports this instance is the writer, to decide whether derived state may have been rewound.

func (*DB) OnDemote

func (db *DB) OnDemote(fn func(error))

OnDemote registers a callback fired after this instance loses the lease and stops replicating (the err explains why). The consumer must stop accepting writes on demotion. It must not call Close — the callback runs on the lease-loop goroutine and Close blocks waiting for that goroutine to exit, so calling Close from here deadlocks — and it should return quickly.

func (*DB) OnPromote

func (db *DB) OnPromote(fn func())

OnPromote registers a callback fired after this instance becomes the writer (lease acquired and replication started). It must not call Close — the callback runs on the lease-loop goroutine and Close blocks waiting for that goroutine to exit, so calling Close from here deadlocks — and it should return quickly. Set it before Open returns control to other goroutines; it is not safe to change concurrently with a role transition.

func (*DB) OnRefresh added in v0.4.0

func (db *DB) OnRefresh(fn func())

OnRefresh registers a callback fired after a follower refresh swaps in newer state from the replica (see Config.FollowerRefreshInterval and Refresh), e.g. to bust caches built on the previous snapshot. It never fires for a writer or when a refresh finds nothing new. It runs on whichever goroutine drove the refresh: the internal lease loop for an interval tick, or the caller's for Refresh. It must not call Close — Close blocks waiting for the lease-loop goroutine to exit, so calling it from a callback that loop is running deadlocks — and it should return quickly. Set it before Open returns control to other goroutines.

func (*DB) Refresh added in v0.13.0

func (db *DB) Refresh(ctx context.Context) (bool, error)

Refresh brings a follower up to the replica's latest committed state now, instead of waiting for the next Config.FollowerRefreshInterval tick, and reports whether the published state actually advanced. It is the synchronous "force freshness before this read" entry point: an interactive consumer can call it per statement and a request handler per request, paying the freshness cost only where it is needed. FollowerRefreshInterval is the background cadence, not a prerequisite — Refresh works with it unset.

It never takes the writer pen: a follower stays a follower (TryPromote promotes). It returns (false, nil) without any work on the writer — including an unleased sole writer, which already holds the latest state — and on a probe that finds the replica unchanged: no fetch, no swap, no OnRefresh. Every refresh that does advance fires OnRefresh and does exactly what an interval tick does, incremental apply included.

Safe from any goroutine and safe to call concurrently with itself, TryPromote and the background loop: they serialise on one lock, so at most one rebuild runs at a time and a promotion winning the race degrades this to the writer no-op. Bound ctx to cap how long the fetch may take; a cancelled or failed Refresh leaves the follower serving its current state. Once Close has begun it returns ErrClosed.

func (*DB) ReplicationStatus added in v0.8.0

func (db *DB) ReplicationStatus() ReplicationStatus

ReplicationStatus reports the metadata replica's health without any network I/O (see ReplicationStatus). Safe to call concurrently and at any role: a follower or an unreplicated sole writer reports Replicating=false with the remaining fields zero.

func (*DB) Sync

func (db *DB) Sync(ctx context.Context) error

Sync blocks until the replica is caught up with the current database state. It is a no-op when there is no replication store (BackupTo unset, or a follower that does not replicate). Calling it concurrently with a demotion may return an error: a role transition can close the store between the snapshot below and SyncAndWait, which correctly reports that the sync did not happen.

func (*DB) TryPromote added in v0.4.0

func (db *DB) TryPromote(ctx context.Context) (bool, error)

TryPromote attempts to acquire the writer lease immediately rather than waiting for the next background poll. It returns true if this instance is (or, after restoring the latest replica, has just become) the writer, and false if the lease is still held by a live writer elsewhere. On a follower it blocks for the restore while promoting; bound ctx to cap that wait.

It never promotes two writers — acquisition is the same lease CAS the background loop uses, so this makes promotion happen sooner, never more often. An unleased sole writer (no s3:// BackupTo) is always the writer, so this returns true without any I/O. Safe to call concurrently. Once Close has begun it returns ErrClosed; callers should stop calling TryPromote when they start shutting down.

func (*DB) YieldLease added in v0.9.0

func (db *DB) YieldLease(ctx context.Context) error

YieldLease voluntarily releases the lease while keeping this instance alive as a read-only follower, so a peer's next write can acquire immediately instead of waiting out the TTL or being refused while a live-but-idle holder pins the lock. It is the release-on-idle handoff: the consumer, which knows its own activity, calls it when it goes quiet; s3lite performs a safe hand-back — fence, final durable sync, stop replication, record a clean-shutdown marker, then delete the lock last — so a successor acquiring right after sees every acked write (INVARIANTS.md #10).

Pair it with Config.OnDemandPromotion: without that, this instance's own background loop (or an idle peer's) re-acquires the just-freed lock within a tick, making the yield self-defeating. With it, the yielded instance stays passive and whoever writes next promotes via TryPromote.

It returns ErrNotLeader if the instance is not the current lease holder (a follower, an already-yielded instance, or an unleased sole writer). If the final sync cannot complete before the lease deadline the yield aborts atomically: the instance stays the leader with renewals running, or — if it can no longer hold the lease — takes the standard demote path (the only case that fires OnDemote). A successful yield never fires OnDemote. Bound ctx to cap how long the final sync may take. Do not call it concurrently with Close.

type PromoteOutcome added in v0.7.0

type PromoteOutcome struct {
	// Restored is true when writer entry replaced the local database with the replica's
	// committed state: a takeover (a successor acquired the lease and could have forked
	// the lineage since this instance's tenure — anything the previous holder acked but
	// had not synced is gone), or a first-ever writer entry with no prior local file.
	// Treat it as a possible rewind: pause destructive maintenance (e.g. garbage
	// collection) and reconcile derived state before trusting the metadata again.
	//
	// It is false only for a provable resume-in-place — self-succession (a crash and
	// restart on this machine with no takeover between) or a clean restart — where the
	// local committed tail was kept intact and nothing was discarded, so no
	// reconciliation is needed.
	Restored bool
	// Generation is the lease generation at that entry, or 0 for an unleased sole writer.
	Generation int64
}

PromoteOutcome reports how this instance entered the writer role, letting a consumer that keeps state derived from the database (caches, externally stored blobs, queued deletions) tell a rewind-bearing takeover from a harmless resume-in-place. Both outcomes carry the same Generation > 1 signal, so Generation alone cannot separate them — that is exactly the gap this type closes.

type ReplicationStatus added in v0.8.0

type ReplicationStatus struct {
	// Replicating is true only for a writer that runs a replication store
	// (BackupTo set). An unreplicated sole writer and every follower report
	// false with the remaining fields zero — neither drives replication.
	Replicating bool
	// LastSyncAt is when the replica last advanced (any successful upload),
	// zero if it never has. Its staleness is the primary health signal.
	LastSyncAt time.Time
	// LocalTXID is the local committed tip litestream replicates from;
	// RemoteTXID is the last position confirmed on the replica. RemoteTXID
	// below LocalTXID is the unreplicated lag.
	LocalTXID  uint64
	RemoteTXID uint64
	// InSync is true when the replica has caught up to the local tip.
	InSync bool
	// LastError is the most recent sync/checkpoint error, empty when the last
	// attempt succeeded. It is the last op's error, not a sticky latch — a
	// later success clears it, so pair it with LastSyncAt staleness to judge a
	// sustained failure.
	LastError string
}

ReplicationStatus is a point-in-time snapshot of the metadata replica's health. It is read from the replication engine's own cached view without any network I/O, so it stays responsive precisely when the bucket is unreachable — which is exactly when a caller asks. LastSyncAt and LastError update on every sync attempt; LocalTXID and RemoteTXID are the last-known local and replica tips, and RemoteTXID is reset to zero after a failed sync, so a stalled replica reads as a large lag rather than a misleading ~0.

type Role

type Role int

Role selects how an instance coordinates single-writer access to an s3:// replica. It has no effect without one — an unreplicated or file:// DB is always the sole writer, since there is no shared WAL to coordinate on.

const (
	// RoleAuto acquires the lease if free (writer) or falls back to follower. The
	// default; with no s3:// BackupTo it is simply the sole writer.
	RoleAuto Role = iota
	// RoleWriter requires acquiring the lease; Open fails if it is already held.
	RoleWriter
	// RoleFollower never replicates; it serves the restored snapshot read-only and
	// promotes itself to writer if the lease becomes available.
	RoleFollower
)

func (Role) String added in v0.2.0

func (r Role) String() string

type S3Config

type S3Config struct {
	Region          string
	Endpoint        string
	AccessKeyID     string
	SecretAccessKey string
}

S3Config holds S3 connection settings. Callers are responsible for sourcing these values (e.g. from environment variables).

Directories

Path Synopsis
cmd
s3lite command
Command s3lite is a sqlite3-familiar shell over an s3lite database: it opens a replica URL, restores the local copy, and reads statements from a prompt or a pipe.
Command s3lite is a sqlite3-familiar shell over an s3lite database: it opens a replica URL, restores the local copy, and reads statements from a prompt or a pipe.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL