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
19 changes: 0 additions & 19 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
41 changes: 15 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
24 changes: 9 additions & 15 deletions cmd/links/links.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -46,31 +46,25 @@ 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)
}
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)
Expand Down
2 changes: 1 addition & 1 deletion dev/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM golang:1.24-alpine AS builder
FROM golang:1.25-alpine AS builder

WORKDIR /app
COPY go.mod .
Expand Down
5 changes: 2 additions & 3 deletions dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
24 changes: 6 additions & 18 deletions dev/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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: ..
Expand All @@ -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: ..
Expand All @@ -37,3 +22,6 @@ services:
LINKS_ADDR: http://links:8080
depends_on:
- links

volumes:
links_data:
3 changes: 0 additions & 3 deletions dev/psql.sh

This file was deleted.

25 changes: 0 additions & 25 deletions docker_test.sh

This file was deleted.

5 changes: 0 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
24 changes: 0 additions & 24 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -13,46 +10,25 @@ 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=
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=
Expand Down
5 changes: 0 additions & 5 deletions links.sql

This file was deleted.

15 changes: 0 additions & 15 deletions local_test.sh

This file was deleted.

2 changes: 1 addition & 1 deletion pkg/links/bulk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading