Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,20 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Test
run: go test ./...
- name: Test (race detector)
run: go test -race -timeout 5m ./...

concurrency:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: SQLite concurrency stress (repository)
run: go test -race -count=5 -run='Concurrent|PragmasOnEveryPoolConnection' -timeout 5m ./internal/repository/...
- name: SQLite concurrency stress (service)
run: go test -race -count=5 -run='Concurrent' -timeout 5m ./internal/service/...

build:
runs-on: ubuntu-latest
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,45 @@ All settings live in `config.yaml`. Every value can be overridden with environme
| `database.sqlite.path` | `WORDSTORE_DATABASE_SQLITE_PATH` | `./data/word-store.db` | SQLite file path |
| `database.postgres.dsn` | `WORDSTORE_DATABASE_POSTGRES_DSN` | | PostgreSQL connection string |

#### SQLite concurrency

The SQLite backend is configured for safe concurrent reads and writes:

- **WAL journal mode** — multiple readers can run at the same time as one writer.
- **`busy_timeout=5000`** — writers wait up to 5 s on lock contention instead of failing immediately.
- **`BEGIN IMMEDIATE` for every transaction** — prevents busy-snapshot in read-then-write flows (collision resolution, file ops).
- **`synchronous=NORMAL` + `foreign_keys=1`** applied to every pooled connection.
- **Bounded connection pool** sized from CPU count.

SQLite still serializes writers globally — that is a SQLite invariant — but readers run in parallel and write contention is absorbed by the busy timeout. For most MCP workloads this is more than sufficient; reach for PostgreSQL only if you need cross-process writers or a centralized DB.

#### Switching from PostgreSQL to SQLite

Driver selection is a config flip; there is no automatic data migration between backends.

**1. Update config** — either edit `config.yaml`:

```yaml
database:
driver: "sqlite"
sqlite:
path: "./data/word-store.db"
```

…or override via env var (takes precedence over `config.yaml`):

```bash
export WORDSTORE_DATABASE_DRIVER=sqlite
export WORDSTORE_DATABASE_SQLITE_PATH=./data/word-store.db
./watchword
```

The directory in `path` is created on startup, and migrations run on first boot.

**2. Docker** — the default `docker-compose.yml` launches PostgreSQL alongside watchword. To run on SQLite, stop that compose stack (`docker compose down`) and either run the binary directly or use a compose override that drops the `postgres` service, sets `WORDSTORE_DATABASE_DRIVER=sqlite` plus `WORDSTORE_DATABASE_SQLITE_PATH=/data/word-store.db`, and mounts a named volume at `/data` so the DB file survives container restarts.

**3. Migrating data (optional)** — switching driver starts from an empty database. If you need to carry entries across, dump the `entries` table from PostgreSQL (`COPY entries TO STDOUT (FORMAT csv, HEADER)`) and load it into SQLite with `.import`; the schemas are equivalent, but PostgreSQL `timestamptz` columns must be converted to RFC3339 strings for SQLite during the dump.

### Authentication

| Setting | Env var | Default | Description |
Expand Down
72 changes: 63 additions & 9 deletions internal/repository/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"fmt"
"runtime"
"strings"
"time"

"github.com/google/uuid"
Expand All @@ -17,19 +19,71 @@ type SQLiteRepo struct {
db *sql.DB
}

// buildSQLiteDSN turns a database path into a modernc.org/sqlite URI that
// applies the pragmas required for safe concurrent access on every pooled
// connection, and forces BEGIN IMMEDIATE for every transaction so the
// read-then-write flows in the service layer cannot hit busy-snapshot.
//
// - journal_mode=WAL multi-reader / single-writer concurrency
// - busy_timeout=5000 wait up to 5s on lock contention instead of
// failing immediately with SQLITE_BUSY
// - foreign_keys=1 enforce FKs on every connection (the prior
// single Exec only configured one pool member)
// - synchronous=NORMAL safe under WAL, much faster than FULL
// - _txlock=immediate every BeginTx issues BEGIN IMMEDIATE so the
// writer lock is acquired up front
func buildSQLiteDSN(path string) string {
const pragmas = "_pragma=journal_mode(WAL)" +
"&_pragma=busy_timeout(5000)" +
"&_pragma=foreign_keys(1)" +
"&_pragma=synchronous(NORMAL)" +
"&_txlock=immediate"

switch {
case path == ":memory:":
return "file::memory:?" + pragmas
case strings.HasPrefix(path, "file:"):
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
return path + sep + pragmas
default:
return "file:" + path + "?" + pragmas
}
}

// sqlitePoolSize picks a reasonable bounded pool. SQLite serializes writes
// globally, so a deep pool buys nothing for writes — but WAL allows truly
// concurrent reads, so a handful of connections lets readers run in parallel.
func sqlitePoolSize(path string) int {
if path == ":memory:" {
// Each new connection to ":memory:" opens a separate in-memory
// database. Pin to a single shared connection so the schema and
// data stay consistent across queries.
return 1
}
n := runtime.NumCPU() * 2
if n < 4 {
n = 4
}
if n > 16 {
n = 16
}
return n
}

func NewSQLiteRepo(dbPath string) (*SQLiteRepo, error) {
db, err := sql.Open("sqlite", dbPath)
db, err := sql.Open("sqlite", buildSQLiteDSN(dbPath))
if err != nil {
return nil, fmt.Errorf("opening sqlite: %w", err)
}
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, fmt.Errorf("setting WAL mode: %w", err)
}
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
db.Close()
return nil, fmt.Errorf("enabling foreign keys: %w", err)
}

pool := sqlitePoolSize(dbPath)
db.SetMaxOpenConns(pool)
db.SetMaxIdleConns(pool)
db.SetConnMaxIdleTime(5 * time.Minute)

return &SQLiteRepo{db: db}, nil
}

Expand Down
Loading
Loading