From b0ac78b4b1a81efe8e6ccdaee9f5fff139a0ace5 Mon Sep 17 00:00:00 2001 From: jdtw Date: Fri, 31 Jul 2026 22:55:31 -0700 Subject: [PATCH] Remove the Postgres storage backend The managed Postgres cluster this talked to has been destroyed and the service runs on SQLite, so PostgresStore is a code path that can no longer be reached: there is nothing left to connect to. Keeping it was not neutral. Storage selection fell through to Postgres whenever SQLITE_PATH was unset, so a missing variable produced a confusing dial failure at startup instead of naming the actual problem. SQLITE_PATH is now required unless -ephemeral is passed, and the error says so. Removing it also takes pgx and three transitive dependencies out of the module, and lets CI stop provisioning a Postgres service container and seeding a schema purely to exercise unreachable code. The three test wrappers collapse into one. test.sh now provisions its own SQLite file in its scratch directory, so the end-to-end suite needs no database server; docker_test.sh, local_test.sh and sqlite_test.sh are all redundant. The dev compose stack drops its database service and keeps its data in a named volume. Separately, dev/Dockerfile pinned golang:1.24-alpine while go.mod requires 1.25.0. Alpine images set GOTOOLCHAIN=local, so `docker compose up` -- the quickstart the README points at -- has been failing to build since the Go bump. Bumped to match the production Dockerfile. Co-Authored-By: Claude Opus 5 --- .github/workflows/test.yml | 19 ------- README.md | 41 +++++--------- cmd/links/links.go | 24 +++----- dev/Dockerfile | 2 +- dev/README.md | 5 +- dev/docker-compose.yml | 24 ++------ dev/psql.sh | 3 - docker_test.sh | 25 --------- go.mod | 5 -- go.sum | 24 -------- links.sql | 5 -- local_test.sh | 15 ----- pkg/links/bulk_test.go | 2 +- pkg/links/postgres.go | 98 -------------------------------- pkg/links/postgres_test.go | 111 ------------------------------------- pkg/links/sqlite.go | 11 ++-- pkg/links/sqlite_test.go | 5 +- sqlite_test.sh | 20 ------- test.sh | 4 +- 19 files changed, 44 insertions(+), 399 deletions(-) delete mode 100755 dev/psql.sh delete mode 100755 docker_test.sh delete mode 100644 links.sql delete mode 100755 local_test.sh delete mode 100644 pkg/links/postgres.go delete mode 100644 pkg/links/postgres_test.go delete mode 100755 sqlite_test.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ee6650f..cf5220e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,18 +9,6 @@ on: jobs: test: runs-on: ubuntu-latest - services: - postgres: - image: postgres - env: - POSTGRES_PASSWORD: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 steps: - uses: actions/checkout@v2 - name: Set up Go @@ -29,14 +17,7 @@ jobs: go-version: 1.25.x - name: Build run: go build -v ./... - - name: Create Table - run: | - psql postgres://postgres:postgres@localhost:5432/postgres -a -c '\i links.sql' - name: Test - env: - DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres run: go test -v ./... - name: Integration Test - env: - DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres run: ./test.sh diff --git a/README.md b/README.md index dc61d1d..7548a2b 100644 --- a/README.md +++ b/README.md @@ -20,27 +20,25 @@ The server maintains a database of friendly names to URI redirect templates. For ## Storage -The server picks its backing store from the environment, in this order: +Links live in a SQLite database at `SQLITE_PATH`, which the server requires +unless `--ephemeral` is passed for a throwaway in-memory store: | Condition | Store | | --- | --- | | `--ephemeral` | in-memory, discarded on exit | -| `SQLITE_PATH` set | SQLite database at that path | -| otherwise | Postgres at `DATABASE_URL` | +| otherwise | SQLite database at `SQLITE_PATH` | -SQLite keeps the whole link table in a single file, which is enough for this -workload and avoids paying for a managed Postgres instance. The tradeoff is -that the file lives on one volume, so the app is pinned to a single machine -in a single region and there is no replication. Postgres remains supported: -unset `SQLITE_PATH` to switch back. +The whole link table is a single three-column relation, so a file on a +mounted volume serves it comfortably and there is no database server to run. +The tradeoff is that the file lives on one volume, pinning the app to a +single machine in a single region with no replication. -The schema is applied automatically when the SQLite database is opened, so a -freshly provisioned volume needs no manual setup. +The schema is applied automatically when the database is opened, so a freshly +provisioned volume needs no manual setup. ### Backup and restore -The client can dump the whole link database to a file and load it back, -which doubles as the migration path between storage backends: +The client can dump the whole link database to a file and load it back: ``` $ client --export links-backup.json @@ -52,23 +50,14 @@ returns, indented for readability. `--import` posts it back. Both accept `-` for stdout/stdin. Importing is additive and idempotent, so re-running it is safe. -### Migrating Postgres to SQLite - -No database access is needed -- export from the running server, point it at -an empty SQLite file, and import: - -1. `client --export links-backup.json` against the Postgres-backed server. -2. Restart with `SQLITE_PATH` set, which creates and initializes an empty - database file. -3. `client --import links-backup.json`. - -Keep the backup, and keep Postgres around until you're satisfied; unsetting -`SQLITE_PATH` reverts to it with the original data untouched. +Since the database is a single file, a volume snapshot works too -- but an +export is portable, diffable, and does not depend on the host. ### Tests -`./sqlite_test.sh` runs the full suite against SQLite and needs no database -server. `./docker_test.sh` does the same against Postgres in a container. +`go test ./...` covers the packages. `./test.sh` runs the end-to-end suite +against a real server; it provisions its own SQLite file in a scratch +directory, so it needs no database server and leaves nothing behind. ## REST API diff --git a/cmd/links/links.go b/cmd/links/links.go index 1b71534..82d3974 100644 --- a/cmd/links/links.go +++ b/cmd/links/links.go @@ -16,7 +16,7 @@ import ( ) var ( - ephemeral = flag.Bool("ephemeral", false, "If true, don't connect to DATABASE_URL and use in-memory storage") + ephemeral = flag.Bool("ephemeral", false, "If true, ignore SQLITE_PATH and use in-memory storage") ) func main() { @@ -46,16 +46,18 @@ func main() { } log.Printf("loaded keyset:\n%s", keyset) - // Storage precedence: -ephemeral wins, then SQLITE_PATH, then - // DATABASE_URL. Unsetting SQLITE_PATH reverts to Postgres. + // Storage is the SQLite database at SQLITE_PATH, unless -ephemeral asks + // for a throwaway in-memory store. var store links.Store ctx := context.Background() - sqlitePath := os.Getenv("SQLITE_PATH") - switch { - case *ephemeral: + if *ephemeral { log.Printf("Running in ephemeral mode!") store = links.NewMemStore() - case sqlitePath != "": + } else { + sqlitePath := os.Getenv("SQLITE_PATH") + if sqlitePath == "" { + log.Fatal("SQLITE_PATH environment variable must be set (or pass -ephemeral)") + } sqliteStore, err := links.NewSQLiteStore(ctx, sqlitePath) if err != nil { log.Fatalf("links.NewSQLiteStore failed: %v", err) @@ -63,14 +65,6 @@ func main() { log.Printf("Opened SQLite database at %s", sqlitePath) store = sqliteStore defer sqliteStore.Close() - default: - pgStore, err := links.NewPostgresStore(ctx, os.Getenv("DATABASE_URL")) - if err != nil { - log.Fatalf("links.NewPostgresStore failed: %v", err) - } - log.Print("Connected to Postgres") - store = pgStore - defer pgStore.Close() } skew := time.Duration(0) diff --git a/dev/Dockerfile b/dev/Dockerfile index c796538..0b41e5e 100644 --- a/dev/Dockerfile +++ b/dev/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /app COPY go.mod . diff --git a/dev/README.md b/dev/README.md index 1bc1b8c..c283286 100644 --- a/dev/README.md +++ b/dev/README.md @@ -2,11 +2,10 @@ This directory contains a docker compose file config that can be used to test locally end-to-end. Running `docker compose up` will: -- Start postgres. -- Start a links server on port 8080. +- Start a links server on port 8080, backed by a SQLite database on the `links_data` volume. - Start a links web client on port 9090. -Connect directly to the postgres instance using `postgres://postgres:postgres@localhost:15432/postgres`, or use the `psql.sh` script. Note that port is 15432 so that it doesn't conflict with any local postgres installation. +The database persists across restarts in that volume. To start from an empty database, run `docker compose down -v`. Add a link to the server using the frontend at http://localhost:9090. diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 0e8ce01..2a69dff 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -1,18 +1,4 @@ services: - db: - image: postgres - user: postgres - volumes: - - ../links.sql:/docker-entrypoint-initdb.d/links.sql - ports: - - 15432:5432 - environment: - POSTGRES_PASSWORD: postgres - healthcheck: - test: ["CMD-SHELL", "pg_isready"] - interval: 1s - timeout: 5s - retries: 5 links: build: context: .. @@ -21,10 +7,9 @@ services: - 8080:8080 environment: LINKS_KEYSET: ${KEYSET} - DATABASE_URL: postgres://postgres:postgres@db:5432/postgres - depends_on: - db: - condition: service_healthy + SQLITE_PATH: /data/links.db + volumes: + - links_data:/data links-client: build: context: .. @@ -37,3 +22,6 @@ services: LINKS_ADDR: http://links:8080 depends_on: - links + +volumes: + links_data: diff --git a/dev/psql.sh b/dev/psql.sh deleted file mode 100755 index aeae7db..0000000 --- a/dev/psql.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -psql postgres://postgres:postgres@localhost:15432/postgres diff --git a/docker_test.sh b/docker_test.sh deleted file mode 100755 index 56da250..0000000 --- a/docker_test.sh +++ /dev/null @@ -1,25 +0,0 @@ -#! /bin/bash -set -euxo pipefail - -echo "Starting postgres container..." -docker-compose -f dev/docker-compose.yml up -d db - -cleanup() { - exit_status=$? - echo "Cleaning up postgres container..." - docker-compose -f dev/docker-compose.yml down --remove-orphans - exit "${exit_status}" -} -trap cleanup EXIT - -export DATABASE_URL="postgres://postgres:postgres@localhost:15432/postgres?sslmode=disable" - -# Wait for postgres to be ready -until docker-compose -f dev/docker-compose.yml exec db pg_isready; do - echo "Waiting for postgres to start..." - sleep 1 -done - -go test ./... - -./test.sh diff --git a/go.mod b/go.mod index c52a879..66c4687 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.3.1 - github.com/jackc/pgx/v5 v5.10.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e google.golang.org/protobuf v1.36.10 jdtw.dev/token v0.1.6 @@ -14,15 +13,11 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.40.0 // indirect modernc.org/libc v1.74.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index b319994..a73cf84 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,3 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= @@ -13,29 +10,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= -github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -43,16 +25,10 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= jdtw.dev/token v0.1.6 h1:EzvBOo0s+O4cudJqZob1ynhyIjtWfS85Oxfk/b0iqP4= jdtw.dev/token v0.1.6/go.mod h1:qr+zsFbOixxkv7T5Jb7rar/5Gs2yhw27vyNX0Q7pBA4= modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= diff --git a/links.sql b/links.sql deleted file mode 100644 index 46c63f2..0000000 --- a/links.sql +++ /dev/null @@ -1,5 +0,0 @@ -create table if not exists links ( - path text primary key, - link text not null, - segments int not null -); \ No newline at end of file diff --git a/local_test.sh b/local_test.sh deleted file mode 100755 index 6bc64e9..0000000 --- a/local_test.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -set -euxo pipefail - -export DATABASE_URL='postgres://localhost/test' - -cleanup() { - exit_status=$? - psql "${DATABASE_URL}" -c 'drop table if exists links' - exit "${exit_status}" -} -trap cleanup EXIT - -psql "${DATABASE_URL}" -a -c '\i links.sql' -./test.sh -psql "${DATABASE_URL}" -c 'select * from links' \ No newline at end of file diff --git a/pkg/links/bulk_test.go b/pkg/links/bulk_test.go index d34ecfb..6903849 100644 --- a/pkg/links/bulk_test.go +++ b/pkg/links/bulk_test.go @@ -190,7 +190,7 @@ func TestBulkPutRequiresAuth(t *testing.T) { } // Export then import must reproduce the original set exactly -- this is the -// property the Postgres -> SQLite migration relies on. +// property backup and restore relies on. func TestExportImportRoundTrip(t *testing.T) { keyset, priv := tokentest.GenerateKey(t, "test") ctx := context.Background() diff --git a/pkg/links/postgres.go b/pkg/links/postgres.go deleted file mode 100644 index 936977d..0000000 --- a/pkg/links/postgres.go +++ /dev/null @@ -1,98 +0,0 @@ -package links - -import ( - "context" - "errors" - "fmt" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - pb "jdtw.dev/links/proto/links" -) - -const ( - get = "select link, segments from links where path=$1" - put = `insert into links (path, link, segments) values ($1, $2, $3) - on conflict (path) do update set link=excluded.link, segments=excluded.segments - returning (xmax = 0) as inserted` - del = "delete from links where path=$1" - list = "select * from links" -) - -type PostgresStore struct { - db *pgxpool.Pool -} - -var _ Store = &PostgresStore{} - -func (s *PostgresStore) Close() { - if s.db != nil { - s.db.Close() - } -} - -func NewPostgresStore(ctx context.Context, source string) (*PostgresStore, error) { - cfg, err := pgxpool.ParseConfig(source) - if err != nil { - return nil, err - } - s := &PostgresStore{} - s.db, err = pgxpool.NewWithConfig(ctx, cfg) - if err != nil { - return nil, fmt.Errorf("pgxpool.NewWithConfig failed: %w", err) - } - - if err := s.db.Ping(ctx); err != nil { - return nil, fmt.Errorf("db.Ping failed: %v", err) - } - return s, nil -} - -func (s *PostgresStore) Get(ctx context.Context, key string) (*pb.LinkEntry, error) { - var link string - var segments int - if err := s.db.QueryRow(ctx, get, key).Scan(&link, &segments); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - return nil, err - } - return &pb.LinkEntry{ - Link: &pb.Link{Uri: link}, - RequiredPaths: int32(segments), - }, nil -} - -func (s *PostgresStore) Put(ctx context.Context, key string, l *pb.Link) (bool, error) { - var created bool - err := s.db.QueryRow(ctx, put, key, l.Uri, requiredPaths(l)).Scan(&created) - return created, err -} - -func (s *PostgresStore) Delete(ctx context.Context, key string) error { - _, err := s.db.Exec(ctx, del, key) - return err -} - -func (s *PostgresStore) Visit(ctx context.Context, visit func(string, *pb.LinkEntry)) error { - rows, err := s.db.Query(ctx, list) - if err != nil { - return err - } - defer rows.Close() - - for rows.Next() { - var path string - var link string - var segments int - if err := rows.Scan(&path, &link, &segments); err != nil { - return err - } - le := &pb.LinkEntry{ - Link: &pb.Link{Uri: link}, - RequiredPaths: int32(segments), - } - visit(path, le) - } - return rows.Err() -} diff --git a/pkg/links/postgres_test.go b/pkg/links/postgres_test.go deleted file mode 100644 index 7734c85..0000000 --- a/pkg/links/postgres_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package links - -import ( - "context" - "os" - "testing" - - pb "jdtw.dev/links/proto/links" -) - -// newTestPostgresStore connects to the database at DATABASE_URL, skipping -// the test if it isn't set. Run via ./docker_test.sh or CI, both of which -// export it against a real Postgres instance with the links.sql schema -// already applied. -func newTestPostgresStore(t *testing.T) *PostgresStore { - t.Helper() - dbURL := os.Getenv("DATABASE_URL") - if dbURL == "" { - t.Skip("DATABASE_URL not set; skipping PostgresStore tests") - } - s, err := NewPostgresStore(context.Background(), dbURL) - if err != nil { - t.Fatalf("NewPostgresStore failed: %v", err) - } - t.Cleanup(s.Close) - return s -} - -func TestPostgresPutReportsCreatedVsUpdated(t *testing.T) { - s := newTestPostgresStore(t) - ctx := context.Background() - const key = "pgv5migrationtest_createdvsupdated" - t.Cleanup(func() { s.Delete(ctx, key) }) - - created, err := s.Put(ctx, key, &pb.Link{Uri: "http://example.com/first"}) - if err != nil { - t.Fatalf("Put (insert) failed: %v", err) - } - if !created { - t.Errorf("Put (insert) reported created=false, want true") - } - - created, err = s.Put(ctx, key, &pb.Link{Uri: "http://example.com/second"}) - if err != nil { - t.Fatalf("Put (update) failed: %v", err) - } - if created { - t.Errorf("Put (update) reported created=true, want false") - } -} - -func TestPostgresGetMissingKeyReturnsNil(t *testing.T) { - s := newTestPostgresStore(t) - ctx := context.Background() - - le, err := s.Get(ctx, "pgv5migrationtest_doesnotexist") - if err != nil { - t.Fatalf("Get failed: %v", err) - } - if le != nil { - t.Errorf("Get(missing) = %v, want nil", le) - } -} - -func TestPostgresDelete(t *testing.T) { - s := newTestPostgresStore(t) - ctx := context.Background() - const key = "pgv5migrationtest_delete" - - if _, err := s.Put(ctx, key, &pb.Link{Uri: "http://example.com"}); err != nil { - t.Fatalf("Put failed: %v", err) - } - if err := s.Delete(ctx, key); err != nil { - t.Fatalf("Delete failed: %v", err) - } - le, err := s.Get(ctx, key) - if err != nil { - t.Fatalf("Get after delete failed: %v", err) - } - if le != nil { - t.Errorf("Get after delete = %v, want nil", le) - } -} - -func TestPostgresVisit(t *testing.T) { - s := newTestPostgresStore(t) - ctx := context.Background() - const key = "pgv5migrationtest_visit" - const uri = "http://example.com/visit" - t.Cleanup(func() { s.Delete(ctx, key) }) - - if _, err := s.Put(ctx, key, &pb.Link{Uri: uri}); err != nil { - t.Fatalf("Put failed: %v", err) - } - - found := false - err := s.Visit(ctx, func(k string, le *pb.LinkEntry) { - if k == key { - found = true - if le.Link.GetUri() != uri { - t.Errorf("Visit(%s) URI = %q, want %q", key, le.Link.GetUri(), uri) - } - } - }) - if err != nil { - t.Fatalf("Visit failed: %v", err) - } - if !found { - t.Errorf("Visit did not observe key %q", key) - } -} diff --git a/pkg/links/sqlite.go b/pkg/links/sqlite.go index b163585..90a6bd6 100644 --- a/pkg/links/sqlite.go +++ b/pkg/links/sqlite.go @@ -13,7 +13,7 @@ import ( const ( // sqliteSchema is applied on open so that a fresh database file (for // example, a newly provisioned volume) is usable without any manual - // setup. It mirrors links.sql. + // setup. sqliteSchema = `create table if not exists links ( path text primary key, link text not null, @@ -28,10 +28,9 @@ const ( sqliteList = "select path, link, segments from links" ) -// SQLiteStore is a Store backed by a local SQLite database file. It is the -// low-cost alternative to PostgresStore: the link table is small enough that -// a file on a mounted volume serves it fine, at the cost of pinning the app -// to a single machine. +// SQLiteStore is a Store backed by a local SQLite database file. The link +// table is small enough that a file on a mounted volume serves it fine, at +// the cost of pinning the app to a single machine in a single region. type SQLiteStore struct { db *sql.DB } @@ -85,7 +84,7 @@ func (s *SQLiteStore) Get(ctx context.Context, key string) (*pb.LinkEntry, error } // Put upserts the link and reports whether it was created rather than -// updated. SQLite has no equivalent of Postgres' xmax trick, so the existence +// updated. SQLite cannot report that from the upsert itself, so the existence // check and the write share a transaction to keep the answer accurate under // concurrent writers. func (s *SQLiteStore) Put(ctx context.Context, key string, l *pb.Link) (bool, error) { diff --git a/pkg/links/sqlite_test.go b/pkg/links/sqlite_test.go index 72719f9..8936010 100644 --- a/pkg/links/sqlite_test.go +++ b/pkg/links/sqlite_test.go @@ -9,8 +9,7 @@ import ( ) // newTestSQLiteStore opens a store backed by a file in the test's temp -// directory. Unlike the Postgres tests these need no external service, so -// they always run. +// directory. These need no external service, so they always run. func newTestSQLiteStore(t *testing.T) *SQLiteStore { t.Helper() s, err := NewSQLiteStore(context.Background(), filepath.Join(t.TempDir(), "links.db")) @@ -84,7 +83,7 @@ func TestSQLiteDelete(t *testing.T) { } // Deleting a key that was never present should be a no-op, matching the -// Postgres store's behavior. +// in-memory store's behavior. func TestSQLiteDeleteMissingKeyIsNoOp(t *testing.T) { s := newTestSQLiteStore(t) if err := s.Delete(context.Background(), "neverexisted"); err != nil { diff --git a/sqlite_test.sh b/sqlite_test.sh deleted file mode 100755 index 44619bb..0000000 --- a/sqlite_test.sh +++ /dev/null @@ -1,20 +0,0 @@ -#! /bin/bash -# Runs the full integration suite against the SQLite store. Unlike -# docker_test.sh and local_test.sh this needs no database server: the store is -# a file in a scratch directory that is removed on exit. -set -euxo pipefail - -SQLITE_DIR="$(mktemp -d)" -export SQLITE_PATH="${SQLITE_DIR}/links.db" - -cleanup() { - exit_status=$? - echo "Cleaning up ${SQLITE_DIR}..." - rm -rf "${SQLITE_DIR}" - exit "${exit_status}" -} -trap cleanup EXIT - -go test ./... - -./test.sh diff --git a/test.sh b/test.sh index 55de981..28815d2 100755 --- a/test.sh +++ b/test.sh @@ -4,6 +4,9 @@ set -euxo pipefail TEST_DIR="$(pwd)/testdir" export PORT=8080 ADDR="http://localhost:${PORT}" +# The server stores links in a SQLite file under the scratch directory, so +# this suite needs no database server and the cleanup below discards it. +export SQLITE_PATH="${TEST_DIR}/links.db" cleanup() { exit_status=$? @@ -27,7 +30,6 @@ PRIV="${TEST_DIR}/priv.pb" "${TEST_DIR}/tokenpb" dump-keyset "${KEYSET}" export LINKS_KEYSET=$(base64 -i "${KEYSET}") -mkdir "${TEST_DIR}/db" "${TEST_DIR}/links" & until curl -s "${ADDR}"; do