sqlite

package
v0.13.0 Latest Latest
Warning

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

Go to latest
Published: Oct 10, 2026 License: MIT Imports: 24 Imported by: 0

Documentation

Overview

Package sqlite is the thread's second durable backend: every session in one SQLite file on the CGO-free modernc.org/sqlite driver — the store's choice (store/sqlite, Crush's before it), reused so one dependency serves both modules — with WAL and embedded migrations. An Append that returned survives the process dying; whether it also survives a power cut is the fsync policy's choice (see Open: the default fsyncs every commit). It is its own module because the driver would otherwise leak into thread's go.mod (ADR 0011 §1: "own module only if its driver would leak into thread" — it would; thread stays root-and-stdlib only).

A session's bytes are the same lines jsonl writes — the header line, then one entry line per row, the thread wire verbatim (ADR 0011 §2, §6) — so the backends differ in where the lines live, never in what they say: Load here decodes exactly what Load there would, the threadtest table runs the same on both, and the loud rules (the unknown kind and the newer version are ErrNewerFormat, never a skip; anything else undecodable is ErrCorrupt naming the line; salvage or not) are thread's own decode path, not a reimplementation.

The one-writer rule (ADR 0011 §5) is a lock row per session, taken on a session's first write and held until the session is released (the thread.Releaser capability), deleted, or the holder's process exits: a second writer — another Storage in this process, or a process on this machine — fails with thread.ErrLocked. A holder that has died is taken over, so a crashed writer never strands its session: flock's death-release semantics, rebuilt on the database the backend already needs. "Died" is judged on the holder's own host by more than its pid, because pids are reused: the row carries the holder's process token and start time, so a restarted process that wears its predecessor's pid (a container's PID 1) takes its own sessions back, and an unrelated process wearing a dead holder's pid does not keep them locked. A holder on another host is never judged from here: its row stands until it releases or an operator removes it (BreakLock). Readers never lock — Load and List always work. Between Sessions sharing one Storage the same rule is the lease (the thread.Leaser capability): the instance remembers which writer has the row it took.

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrNewerSchema = errors.New("sqlite: database schema is newer than this weft")

ErrNewerSchema is returned by Open when the file's thread_migrations is ahead of this binary's highest migration: a database written by a newer weft fails loudly instead of running nothing and saying nothing — the store's rule (its ErrNewerSchema), verbatim, because the failure is the same failure.

Functions

func BreakLock

func BreakLock(ctx context.Context, st thread.Storage, session string) (err error)

BreakLock removes the writer lock on a session, whoever holds it. It is an operator's action, for the one case the lock cannot decide on its own: a row left by a holder on another host. The lock never judges such a holder dead (acquire's rule), so when that host is gone for good — a container replaced under a new hostname, a database restored from a backup that carried its lock rows — every write to the session fails with thread.ErrLocked until the row is removed. The caller vouches that the holder is gone: nothing here can check it.

st is the Storage this package's Open returned. The next writer takes the lock as on an unheld session. A holder that was alive after all is not left writing beside it: its next write finds the row is no longer its own and fails with thread.ErrLocked, and a Session it serves then answers thread.ErrStale if the session was written in between. The removal is logged at Warn on the storage's logger (thread.OpenLogger), naming the holder it removed.

A session with no lock row is left as it is and BreakLock returns nil; one the database does not hold fails with thread.ErrNotFound. To clear a whole database after a host change, List the sessions and break each.

func Open

func Open(path string, opts ...thread.OpenOption) (thread.Storage, error)

Open opens (creating if needed) the sessions database at path and brings its schema up to date, returning a thread.Storage. ":memory:" works — one private in-process database per Open, for tests and examples, alive for as long as the returned Storage is. Connections carry busy_timeout=30000, foreign_keys=ON, and immediate write transactions (preventing deferred-to-writer upgrade deadlocks; read-only transactions stay deferred and take no write lock), and the handle uses a single working connection so every write is serialized. WAL is not a connection pragma: it is a persistent property of the file, switched once by the first Open (setWAL), so a reader in another process — a watcher, the Inspector — reads concurrently through its own Open.

Durability

Every Append is one committed transaction, so a process that dies — a crash, a SIGKILL — never loses an Append that returned, under either fsync policy. What the policy decides is power loss and kernel crashes:

  • thread.FsyncEveryAppend, the default: synchronous=FULL. Each commit fsyncs the write-ahead log before Append returns — an accepted entry survives losing power, the same promise jsonl makes for its default.
  • thread.FsyncOnFlush: synchronous=NORMAL. A commit is written but not fsynced; the entries since the last sync can be lost to a power cut (the database stays consistent — WAL's guarantee — it only ends earlier). Flush is the sync point: it checkpoints the log, which fsyncs it. SQLite also checkpoints on its own as the log grows.

":memory:" has nothing to sync and ignores both.

path is a file name, taken literally: characters that mean something in a SQLite URI ('?', '#', '%') are part of the name, never options.

The shared open vocabulary (thread/backend.Resolve) applies: Salvage downgrades a malformed line from a load failure to a skip reported in the LoadReport; OpenLogger names where a lock takeover and a removed torn row are reported; the fsync-policy options choose the sync cadence described above; NoLock is accepted and is a no-op: the lock is a row, it needs no platform support, and it stays on. The database file is created 0600 and its directory 0700, matching jsonl's rule (ADR 0011 §5); the -wal and -shm side files are SQLite's own and share the main file's directory.

Example

Open a sessions database, write one turn's worth of entries, read the session back. ":memory:" keeps the example self-contained; a path gives the same answers on disk.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/weftgo/weft"
	"github.com/weftgo/weft/thread"
	"github.com/weftgo/weft/thread/sqlite"
)

func main() {
	st, err := sqlite.Open(":memory:")
	if err != nil {
		fmt.Println(err)
		return
	}
	ctx := context.Background()
	created := time.Date(2026, 9, 29, 9, 0, 0, 0, time.UTC)
	if err := st.Create(ctx, thread.Header{ID: "s_demo", Created: created}); err != nil {
		fmt.Println(err)
		return
	}
	if err := st.Append(ctx, "s_demo",
		thread.MessageEntry{
			ID: "e_1", Created: created.Add(time.Second), Message: weft.User("Where is order 1234?"),
		},
		thread.TurnEntry{
			ID: "e_2", Created: created.Add(2 * time.Second),
			RunID: "s_demo-t1", StopReason: weft.StopEndTurn, Steps: 1,
		},
	); err != nil {
		fmt.Println(err)
		return
	}
	h, entries, report, err := st.Load(ctx, "s_demo")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(h.ID, len(entries), entries[0].(thread.MessageEntry).Message.Text(), report == nil)
}
Output:
s_demo 2 Where is order 1234? true
Example (SecondWriter)

The one-writer rule is per session and crosses processes: a second Storage over the same file — here in the same process, the cheapest way to show it — is refused with thread.ErrLocked until the holder lets go, while Load keeps answering: readers never lock.

package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/weftgo/weft"
	"github.com/weftgo/weft/thread"
	"github.com/weftgo/weft/thread/sqlite"
)

func main() {
	dir, err := os.MkdirTemp("", "weft-sqlite-example")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() { _ = os.RemoveAll(dir) }()
	ctx := context.Background()
	path := filepath.Join(dir, "sessions.db")

	holder, err := sqlite.Open(path)
	if err != nil {
		fmt.Println(err)
		return
	}
	if err := holder.Create(ctx, thread.Header{ID: "s_demo", Created: time.Now().UTC()}); err != nil {
		fmt.Println(err)
		return
	}
	second, err := sqlite.Open(path)
	if err != nil {
		fmt.Println(err)
		return
	}
	err = second.Append(ctx, "s_demo", thread.MessageEntry{
		ID: "e_1", Created: time.Now().UTC(), Message: weft.User("second writer"),
	})
	fmt.Println(errors.Is(err, thread.ErrLocked))
}
Output:
true

Types

This section is empty.

Jump to

Keyboard shortcuts

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