Files
pages/internal/deploy/atomicity_test.go
T
2026-08-15 07:13:00 +00:00

455 lines
14 KiB
Go

package deploy
import (
"bytes"
"context"
"database/sql"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"sync"
"testing"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/site"
"github.com/iceBear67/simplepages/internal/store"
"github.com/iceBear67/simplepages/internal/webroot"
)
// These are the tests the whole program exists for.
//
// The failure they are written against is the one rsync has: during a
// deployment a visitor sees the new HTML with the old JavaScript, or an asset
// that is not there yet. Everything else in this repository — the immutable
// snapshot, the single pointer store, the database-before-memory ordering — is
// a means to making that state unobservable, and unobservable is a claim about
// concurrent behaviour that only a concurrent test can support.
//
// What is actually asserted, and why it is the strongest true statement:
//
// - Every single response is internally consistent. big.bin is half a
// megabyte of one repeated byte, so it spans many writes and a switch
// landing mid-body would show up as a seam. Every byte of it equal to the
// same version is the claim "no request ever saw a half-updated site".
// - A reader's successive responses never go backwards. Requests within one
// reader are strictly ordered — the next is not sent until the previous has
// been read to completion — so the version it observes may only rise.
//
// It would be tempting to also demand that three separate GETs issued around
// the same time report the same version. That is not a property this or any
// design has: they are three requests, an activation may legitimately land
// between any two of them, and asserting otherwise would be asserting that the
// switch never happens. The per-response guarantee above is what "atomic
// deployment" means.
const (
// Large enough that a response spans many socket writes, so a switch has
// somewhere to land mid-body.
stormFileSize = 512 << 10
// The in-flight tests need the server to still be blocked writing when the
// test does something underneath it, which means comfortably more than a
// loopback socket will buffer for a client that has stopped reading.
inflightFileSize = 8 << 20
)
// switchEnv is an env with the serving layer attached: a registry, a webroot
// and an HTTP server, which is the only configuration in which the switch is
// observable from the outside.
type switchEnv struct {
*env
reg *site.Registry
wrDir string
srv *httptest.Server
cl *http.Client
}
func newSwitchEnv(t *testing.T) *switchEnv {
t.Helper()
e := newEnv(t)
log := slog.New(slog.DiscardHandler)
reg := site.NewRegistry()
wrDir := t.TempDir()
wr, err := webroot.Open(wrDir, e.dir)
if err != nil {
t.Fatalf("webroot.Open: %v", err)
}
e.svc.Sites = reg
e.svc.Webroot = wr
reg.Put(e.p)
srv := httptest.NewServer(&site.Handler{Registry: reg, CAS: e.cas, Log: log})
t.Cleanup(srv.Close)
// The default transport keeps two idle connections per host, which would
// turn 64 readers into a connection churn benchmark instead of a switching
// one.
cl := &http.Client{Transport: &http.Transport{MaxIdleConns: 512, MaxIdleConnsPerHost: 512}}
t.Cleanup(cl.CloseIdleConnections)
return &switchEnv{env: e, reg: reg, wrDir: wrDir, srv: srv, cl: cl}
}
// versionFiles is deployment n: three small files that name their version, and
// one large one filled with the single byte n so that any part of it identifies
// the whole.
func versionFiles(n, size int) map[string]string {
v := strconv.Itoa(n)
return map[string]string{
"marker.txt": v,
"a.txt": v,
"b.txt": v,
"big.bin": string(bytes.Repeat([]byte{byte(n)}, size)),
}
}
// publish takes version n all the way to ready without activating it.
func (e *switchEnv) publish(t *testing.T, n, size int) *store.Deployment {
t.Helper()
contents := versionFiles(n, size)
dep := e.create(t)
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
t.Fatalf("SetManifest v%d: %v", n, err)
}
names := make([]string, 0, len(contents))
for p := range contents {
names = append(names, p)
}
e.upload(t, contents, names...)
dep, err := e.svc.Finalize(t.Context(), e.p, dep)
if err != nil {
t.Fatalf("Finalize v%d: %v", n, err)
}
return dep
}
func (e *switchEnv) activate(t *testing.T, dep *store.Deployment) *store.Deployment {
t.Helper()
out, err := e.svc.Activate(t.Context(), e.p, dep)
if err != nil {
t.Fatalf("Activate %s: %v", dep.PublicID, err)
}
return out
}
// get fetches one file from the served site. It returns errors rather than
// failing the test, because most of its callers are goroutines.
func (e *switchEnv) get(name string) ([]byte, error) {
resp, err := e.cl.Get(e.srv.URL + "/~demo/" + name)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("GET %s: status %d", name, resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// bigVersion is the assertion that matters: it reports which version a big.bin
// body came from, and fails if the body is not entirely from one version.
func bigVersion(b []byte, size int) (int, error) {
if len(b) != size {
return 0, fmt.Errorf("big.bin is %d bytes, want %d", len(b), size)
}
first := b[0]
for i, c := range b {
if c != first {
return 0, fmt.Errorf(
"big.bin mixes two deployments: byte 0 is from version %d but byte %d is from version %d",
first, i, c)
}
}
return int(first), nil
}
func smallVersion(b []byte) (int, error) {
n, err := strconv.Atoi(string(b))
if err != nil {
return 0, fmt.Errorf("file body %q is not a version number: %v", b, err)
}
return n, nil
}
func TestActivationAtomicity(t *testing.T) {
const versions = 50
const readers = 64
e := newSwitchEnv(t)
deps := make([]*store.Deployment, versions)
valid := make(map[string]bool, versions)
for i := range deps {
deps[i] = e.publish(t, i+1, stormFileSize)
valid[deps[i].PublicID] = true
}
e.activate(t, deps[0])
ctx, stop := context.WithCancel(t.Context())
var wg sync.WaitGroup
// Anti-vacuity: if every reader only ever saw the last version the
// invariants above hold trivially and prove nothing.
var mu sync.Mutex
lowest, highest := versions+1, 0
record := func(n int) {
mu.Lock()
defer mu.Unlock()
lowest = min(lowest, n)
highest = max(highest, n)
}
for range readers {
wg.Add(1)
go func() {
defer wg.Done()
seen := 0
for ctx.Err() == nil {
for _, name := range []string{"a.txt", "b.txt", "big.bin"} {
body, err := e.get(name)
if err != nil {
if ctx.Err() == nil {
t.Errorf("%v", err)
}
return
}
var n int
if name == "big.bin" {
n, err = bigVersion(body, stormFileSize)
} else {
n, err = smallVersion(body)
}
if err != nil {
t.Errorf("%s: %v", name, err)
return
}
if n < seen {
t.Errorf("%s reported version %d after version %d had already been "+
"served to this reader: the switch was observed running backwards",
name, n, seen)
return
}
seen = n
record(n)
}
}
}()
}
// The symlink is not what serves the site, but an external reader — a
// reverse proxy, a backup job — follows it, and it must never be missing or
// dangling while the switch runs.
link := filepath.Join(e.wrDir, "~demo")
wg.Add(1)
go func() {
defer wg.Done()
for ctx.Err() == nil {
target, err := os.Readlink(link)
if err != nil {
t.Errorf("$WEBROOT/~demo: %v", err)
return
}
// Stat follows the link, so a dangling one fails here.
fi, err := os.Stat(target)
if err != nil {
t.Errorf("$WEBROOT/~demo -> %s: %v", target, err)
return
}
if !fi.IsDir() {
t.Errorf("$WEBROOT/~demo -> %s is not a directory", target)
return
}
if !valid[filepath.Base(target)] {
t.Errorf("$WEBROOT/~demo -> %s, which is not a deployment of this project", target)
return
}
}
}()
for _, dep := range deps[1:] {
if _, err := e.svc.Activate(t.Context(), e.p, dep); err != nil {
t.Errorf("Activate %s: %v", dep.PublicID, err)
break
}
}
stop()
wg.Wait()
if t.Failed() {
return
}
if lowest >= versions {
t.Fatalf("every observation was of version %d or later: the readers never "+
"overlapped the switching, so this test proved nothing", lowest)
}
if lowest == highest {
t.Fatalf("every observation was of version %d: no switch was observed", lowest)
}
for _, name := range []string{"marker.txt", "a.txt", "b.txt"} {
body, err := e.get(name)
if err != nil {
t.Fatalf("%v", err)
}
if got, err := smallVersion(body); err != nil || got != versions {
t.Errorf("after the storm %s = %q (%v), want version %d", name, body, err, versions)
}
}
}
// openBig starts a request for big.bin and reads only its first kilobyte, so
// the response is still open and the server is still blocked writing it.
func (e *switchEnv) openBig(t *testing.T) (*http.Response, []byte) {
t.Helper()
resp, err := e.cl.Get(e.srv.URL + "/~demo/big.bin")
if err != nil {
t.Fatalf("GET big.bin: %v", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
t.Fatalf("GET big.bin: status %d", resp.StatusCode)
}
head := make([]byte, 1024)
if _, err := io.ReadFull(resp.Body, head); err != nil {
resp.Body.Close()
t.Fatalf("reading the start of big.bin: %v", err)
}
return resp, head
}
// drain finishes a response opened by openBig and reports which version the
// whole body came from.
func drain(t *testing.T, resp *http.Response, head []byte) int {
t.Helper()
defer resp.Body.Close()
rest, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("reading the rest of big.bin: %v", err)
}
n, err := bigVersion(append(head, rest...), inflightFileSize)
if err != nil {
t.Fatalf("%v", err)
}
return n
}
func TestAnInFlightRequestKeepsReadingTheDeploymentItStartedOn(t *testing.T) {
e := newSwitchEnv(t)
v1 := e.publish(t, 1, inflightFileSize)
v2 := e.publish(t, 2, inflightFileSize)
e.activate(t, v1)
resp, head := e.openBig(t)
e.activate(t, v2)
// This is the property a symlink rename cannot give you: the switch has
// already happened, and this response is still the one it started as.
if n := drain(t, resp, head); n != 1 {
t.Errorf("a request that started before the switch finished on version %d, want 1", n)
}
body, err := e.get("marker.txt")
if err != nil {
t.Fatalf("%v", err)
}
if n, _ := smallVersion(body); n != 2 {
t.Errorf("a request that started after the switch got version %d, want 2", n)
}
}
func TestAnInFlightRequestSurvivesTheContentBeingCollected(t *testing.T) {
e := newSwitchEnv(t)
v1 := e.publish(t, 1, inflightFileSize)
v2 := e.publish(t, 2, inflightFileSize)
e.activate(t, v1)
resp, head := e.openBig(t)
e.activate(t, v2)
// Everything a collector could possibly remove, with no grace period at
// all: the assembled tree and the content it was hardlinked from. Both,
// because removing only one of them leaves the inode alive through the
// other and the test would prove nothing.
if err := os.RemoveAll(DeploymentDir(e.dir, e.p.ID, v1.PublicID)); err != nil {
t.Fatalf("removing the old tree: %v", err)
}
for _, c := range versionFiles(1, inflightFileSize) {
if err := e.cas.Remove(cas.Sum([]byte(c))); err != nil {
t.Fatalf("removing old content: %v", err)
}
}
// The handler is holding an open descriptor, and POSIX keeps the inode
// alive until it closes. The grace period in the collector exists so this
// never has to be relied on, but relying on it has to work.
if n := drain(t, resp, head); n != 1 {
t.Errorf("a request whose content was deleted under it finished on version %d, want 1", n)
}
if _, err := e.get("marker.txt"); err != nil {
t.Errorf("the live deployment stopped serving after the old one was collected: %v", err)
}
}
func TestAFailedActivationChangesNothing(t *testing.T) {
e := newSwitchEnv(t)
v1 := e.publish(t, 1, 64)
v2 := e.publish(t, 2, 64)
e.activate(t, v1)
link := filepath.Join(e.wrDir, "~demo")
before, err := os.Readlink(link)
if err != nil {
t.Fatalf("$WEBROOT/~demo: %v", err)
}
// Make v2's manifest unreadable, which fails Activate inside index() —
// before the transaction, before the pointer store, before the symlink. The
// bogus blobs row exists only to satisfy the foreign key; the point is the
// one-byte digest, which cas.FromBytes refuses.
err = e.db.Tx(t.Context(), func(tx *sql.Tx) error {
if _, err := tx.ExecContext(t.Context(), `
INSERT INTO blobs (digest, size, present, created_at, last_ref_at)
VALUES (x'00', 1, 1, 0, 0) ON CONFLICT(digest) DO NOTHING`); err != nil {
return err
}
_, err := tx.ExecContext(t.Context(),
`UPDATE deployment_files SET digest = x'00' WHERE deployment_id = ?`, v2.ID)
return err
})
if err != nil {
t.Fatalf("corrupting the manifest: %v", err)
}
if _, err := e.svc.Activate(t.Context(), e.p, v2); err == nil {
t.Fatal("Activate succeeded on a deployment whose manifest cannot be read")
}
// In memory.
body, err := e.get("marker.txt")
if err != nil {
t.Fatalf("%v", err)
}
if n, _ := smallVersion(body); n != 1 {
t.Errorf("after the failed activation the site serves version %d, want 1", n)
}
// In the database, which is what a restart would come back to.
active, err := e.db.ActiveDeployment(t.Context(), e.p.ID)
if err != nil {
t.Fatalf("ActiveDeployment: %v", err)
}
if active.PublicID != v1.PublicID {
t.Errorf("the database says %s is active, want %s", active.PublicID, v1.PublicID)
}
// And on disk.
after, err := os.Readlink(link)
if err != nil {
t.Fatalf("$WEBROOT/~demo: %v", err)
}
if after != before {
t.Errorf("$WEBROOT/~demo moved to %s, want it left at %s", after, before)
}
}