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

618 lines
20 KiB
Go

package deploy
import (
"database/sql"
"errors"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/store"
)
// These tests set BlobGrace negative. The grace exists so that a request which
// has resolved a digest and is about to open it cannot lose the file underneath
// it, and the default hour is far longer than any test wants to wait. Timestamps
// are whole seconds, so a grace of zero would not collect a blob dereferenced in
// the same second either — negative is the only value that means "now".
const collectNow = -time.Minute
// deployReady publishes a finished, inactive deployment holding contents.
func (e *env) deployReady(t *testing.T, contents map[string]string) *store.Deployment {
t.Helper()
dep := e.create(t)
files := manifest(contents)
if _, _, err := e.svc.SetManifest(t.Context(), dep, files); err != nil {
t.Fatalf("SetManifest: %v", err)
}
paths := make([]string, 0, len(contents))
for p := range contents {
paths = append(paths, p)
}
e.upload(t, contents, paths...)
dep, err := e.svc.Finalize(t.Context(), e.p, dep)
if err != nil {
t.Fatalf("Finalize: %v", err)
}
return dep
}
// version publishes a deployment whose single file identifies it, which is
// enough for retention tests: what matters is how many survive and which.
func (e *env) version(t *testing.T, n int) *store.Deployment {
t.Helper()
return e.deployReady(t, map[string]string{"index.html": "v" + strconv.Itoa(n)})
}
func (e *env) activate(t *testing.T, dep *store.Deployment) {
t.Helper()
if _, err := e.svc.Activate(t.Context(), e.p, dep); err != nil {
t.Fatalf("Activate %s: %v", dep.PublicID, err)
}
}
// exec runs a statement the service has no method for. Retention tests need to
// backdate rows, because the alternative is a test that sleeps for an hour.
func (e *env) exec(t *testing.T, query string, args ...any) {
t.Helper()
err := e.db.Tx(t.Context(), func(tx *sql.Tx) error {
_, err := tx.ExecContext(t.Context(), query, args...)
return err
})
if err != nil {
t.Fatalf("%s: %v", query, err)
}
}
// setRetention rewrites the project's policy and re-reads it, because the
// collector reads the row and not the struct the test is holding.
func (e *env) setRetention(t *testing.T, count int, graceS int64) {
t.Helper()
e.exec(t, `UPDATE projects SET retention_count = ?, retention_grace_s = ? WHERE id = ?`,
count, graceS, e.p.ID)
p, err := e.db.ProjectByID(t.Context(), e.p.ID)
if err != nil {
t.Fatal(err)
}
e.p = p
}
// alive reports whether a deployment still has a row and a tree.
func (e *env) alive(t *testing.T, dep *store.Deployment) (row, tree bool) {
t.Helper()
_, err := e.db.DeploymentByPublicID(t.Context(), e.p.ID, dep.PublicID)
switch {
case err == nil:
row = true
case errors.Is(err, store.ErrNotFound):
default:
t.Fatalf("DeploymentByPublicID: %v", err)
}
_, err = os.Stat(DeploymentDir(e.dir, e.p.ID, dep.PublicID))
switch {
case err == nil:
tree = true
case os.IsNotExist(err):
default:
t.Fatalf("stat deployment tree: %v", err)
}
return
}
func (e *env) mustSurvive(t *testing.T, dep *store.Deployment, why string) {
t.Helper()
row, tree := e.alive(t, dep)
if !row || !tree {
t.Errorf("%s (%s) was collected: row=%v tree=%v", why, dep.PublicID, row, tree)
}
}
func (e *env) mustBeGone(t *testing.T, dep *store.Deployment, why string) {
t.Helper()
row, tree := e.alive(t, dep)
if row || tree {
t.Errorf("%s (%s) survived: row=%v tree=%v", why, dep.PublicID, row, tree)
}
}
// blobCount is how many blobs the database knows about, which is the number the
// milestone's manual check watches drop.
func blobCount(t *testing.T, e *env) int64 {
t.Helper()
counts, err := e.db.Counts(t.Context())
if err != nil {
t.Fatal(err)
}
return counts.Blobs
}
func hasContent(t *testing.T, e *env, content string) bool {
t.Helper()
ok, err := e.cas.Has(cas.Sum([]byte(content)))
if err != nil {
t.Fatalf("cas.Has: %v", err)
}
return ok
}
// The headline retention rule from the milestone: deploy repeatedly, keep
// retention_count of them plus whichever one is being served, and watch the
// content of the rest go away.
func TestCollectKeepsRetentionCountPlusTheActiveOne(t *testing.T) {
e := newEnv(t)
e.svc.BlobGrace = collectNow
e.setRetention(t, 10, 0)
var deps []*store.Deployment
for i := 1; i <= 15; i++ {
dep := e.version(t, i)
deps = append(deps, dep)
e.activate(t, dep)
}
// Roll back to the oldest one, so the deployment being served is also the
// one retention would otherwise drop first. Nothing may collect it.
e.activate(t, deps[0])
before := blobCount(t, e)
stats, err := e.svc.Collect(t.Context(), false)
if err != nil {
t.Fatal(err)
}
// 15 deployments, 10 kept by retention plus the active one: 4 collected.
if stats.DeploymentsDeleted != 4 {
t.Errorf("deleted %d deployments, want 4", stats.DeploymentsDeleted)
}
e.mustSurvive(t, deps[0], "the active deployment")
for _, dep := range deps[5:] {
e.mustSurvive(t, dep, "a deployment inside the retention window")
}
for _, dep := range deps[1:5] {
e.mustBeGone(t, dep, "a deployment past the retention window")
}
if after := blobCount(t, e); after != before-4 {
t.Errorf("blob count went from %d to %d, want %d", before, after, before-4)
}
if stats.BlobsDeleted != 4 {
t.Errorf("collected %d blobs, want the 4 the deleted deployments held", stats.BlobsDeleted)
}
if stats.BytesFreed <= 0 {
t.Errorf("BytesFreed = %d, want the size of what was removed", stats.BytesFreed)
}
for i := 2; i <= 5; i++ {
if hasContent(t, e, "v"+strconv.Itoa(i)) {
t.Errorf("content of the collected deployment v%d is still in the CAS", i)
}
}
// What the survivors reference is untouched, which is what makes a rollback
// to any of them still work.
if !hasContent(t, e, "v1") || !hasContent(t, e, "v15") {
t.Error("content a surviving deployment references was collected")
}
// Nothing left to do on a second pass.
stats, err = e.svc.Collect(t.Context(), false)
if err != nil {
t.Fatal(err)
}
if stats.DeploymentsDeleted != 0 || stats.BlobsDeleted != 0 {
t.Errorf("a second pass collected %+v, want nothing", stats)
}
}
// The grace period is measured from when a deployment stopped being served, so
// a rollback that was a mistake can be undone for a while afterwards.
func TestCollectHonoursTheRetentionGrace(t *testing.T) {
e := newEnv(t)
e.svc.BlobGrace = collectNow
e.setRetention(t, 0, 3600)
old := e.version(t, 1)
e.activate(t, old)
current := e.version(t, 2)
e.activate(t, current)
// retention_count is zero, so only the grace is protecting it.
stats, err := e.svc.Collect(t.Context(), false)
if err != nil {
t.Fatal(err)
}
if stats.DeploymentsDeleted != 0 {
t.Errorf("deleted %d deployments inside the grace period, want 0", stats.DeploymentsDeleted)
}
e.mustSurvive(t, old, "a deployment retired seconds ago")
// Backdate the deactivation past the grace and it becomes collectable.
e.exec(t, `UPDATE deployments SET deactivated_at = ? WHERE id = ?`,
time.Now().Add(-2*time.Hour).Unix(), old.ID)
if _, err := e.svc.Collect(t.Context(), false); err != nil {
t.Fatal(err)
}
e.mustBeGone(t, old, "a deployment retired before the grace period")
e.mustSurvive(t, current, "the active deployment")
}
// A deployment that never finished uploading is not retention's business: it
// has no deactivated_at to measure from and someone may still be pushing to it.
func TestCollectLeavesUnfinishedUploadsToTheExpiry(t *testing.T) {
e := newEnv(t)
e.svc.BlobGrace = collectNow
e.setRetention(t, 0, 0)
contents := map[string]string{"index.html": "in progress"}
dep := e.create(t)
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
t.Fatal(err)
}
e.upload(t, contents, "index.html")
if _, err := e.svc.Collect(t.Context(), false); err != nil {
t.Fatal(err)
}
got, err := e.db.DeploymentByPublicID(t.Context(), e.p.ID, dep.PublicID)
if err != nil {
t.Fatalf("an in-progress upload was collected: %v", err)
}
if got.State != store.StateUploading {
t.Errorf("state = %q, want it left alone as %q", got.State, store.StateUploading)
}
// Its content is protected too, by the manifest rows that already reference
// it — which is the whole reason the manifest is written before the upload.
if !hasContent(t, e, "in progress") {
t.Error("the content of an in-progress upload was collected")
}
// Once it is old enough, one pass does the whole job: the expiry fails it
// and drops its manifest, retention finds a failed deployment older than it
// keeps failures for, and the blob pass then reaches what only it
// referenced. That the three run in that order is why it takes one pass and
// not three.
e.exec(t, `UPDATE deployments SET created_at = ? WHERE id = ?`,
time.Now().Add(-48*time.Hour).Unix(), dep.ID)
stats, err := e.svc.Collect(t.Context(), false)
if err != nil {
t.Fatal(err)
}
if stats.DeploymentsDeleted != 1 || stats.BlobsDeleted != 1 {
t.Errorf("collected %+v, want the abandoned upload and its content", stats)
}
if _, err := e.db.DeploymentByPublicID(t.Context(), e.p.ID, dep.PublicID); !errors.Is(err, store.ErrNotFound) {
t.Errorf("the abandoned upload survived: %v", err)
}
if hasContent(t, e, "in progress") {
t.Error("content nothing references any more survived the sweep")
}
}
// A deployment that failed while being finalized keeps its row for a day, so an
// operator looking into a broken CI job can still see that it failed and why.
func TestCollectKeepsRecentFailuresForInspection(t *testing.T) {
e := newEnv(t)
e.svc.BlobGrace = collectNow
e.setRetention(t, 0, 0)
dep := e.create(t)
if err := e.db.MarkDeploymentFailed(t.Context(), dep.ID, "assembly failed"); err != nil {
t.Fatal(err)
}
if _, err := e.svc.Collect(t.Context(), false); err != nil {
t.Fatal(err)
}
got, err := e.db.DeploymentByPublicID(t.Context(), e.p.ID, dep.PublicID)
if err != nil {
t.Fatalf("a deployment that failed moments ago was collected: %v", err)
}
if got.Error != "assembly failed" {
t.Errorf("error = %q, want the reason still readable", got.Error)
}
e.exec(t, `UPDATE deployments SET created_at = ? WHERE id = ?`,
time.Now().Add(-48*time.Hour).Unix(), dep.ID)
if _, err := e.svc.Collect(t.Context(), false); err != nil {
t.Fatal(err)
}
if _, err := e.db.DeploymentByPublicID(t.Context(), e.p.ID, dep.PublicID); !errors.Is(err, store.ErrNotFound) {
t.Errorf("a failed row older than the retention survived: %v", err)
}
}
// Content two deployments share outlives the first of them. This is what makes
// cross-deployment deduplication safe to rely on.
func TestCollectKeepsSharedContent(t *testing.T) {
e := newEnv(t)
e.svc.BlobGrace = collectNow
e.setRetention(t, 0, 0)
shared := "console.log(1)"
old := e.deployReady(t, map[string]string{"index.html": "v1", "app.js": shared})
e.activate(t, old)
current := e.deployReady(t, map[string]string{"index.html": "v2", "app.js": shared})
e.activate(t, current)
if _, err := e.svc.Collect(t.Context(), false); err != nil {
t.Fatal(err)
}
e.mustBeGone(t, old, "the superseded deployment")
if hasContent(t, e, "v1") {
t.Error("content only the collected deployment referenced survived")
}
if !hasContent(t, e, shared) {
t.Fatal("content the active deployment still references was collected")
}
// And it is still readable through the deployment that survived, which is
// the property the assembled tree shares an inode for.
body, err := os.ReadFile(filepath.Join(DeploymentDir(e.dir, e.p.ID, current.PublicID), "app.js"))
if err != nil || string(body) != shared {
t.Errorf("reading shared content from the surviving tree = %q, %v", body, err)
}
}
// The grace is what makes the read path safe without per-request reference
// counting, so it has to actually hold content back.
func TestCollectHoldsRecentlyDereferencedBlobs(t *testing.T) {
e := newEnv(t)
e.setRetention(t, 0, 0) // BlobGrace left at its default hour.
old := e.version(t, 1)
e.activate(t, old)
e.activate(t, e.version(t, 2))
stats, err := e.svc.Collect(t.Context(), false)
if err != nil {
t.Fatal(err)
}
e.mustBeGone(t, old, "the superseded deployment")
if stats.BlobsDeleted != 0 {
t.Errorf("collected %d blobs, want them held by the grace period", stats.BlobsDeleted)
}
if !hasContent(t, e, "v1") {
t.Error("content dereferenced moments ago was removed inside the grace period")
}
}
func TestCollectDryRunChangesNothing(t *testing.T) {
e := newEnv(t)
e.svc.BlobGrace = collectNow
e.setRetention(t, 0, 0)
old := e.version(t, 1)
e.activate(t, old)
current := e.version(t, 2)
e.activate(t, current)
stats, err := e.svc.Collect(t.Context(), true)
if err != nil {
t.Fatal(err)
}
if !stats.DryRun {
t.Error("the report does not say it was a dry run")
}
if stats.DeploymentsDeleted != 1 {
t.Errorf("reported %d deployments, want the 1 that would be deleted", stats.DeploymentsDeleted)
}
e.mustSurvive(t, old, "a deployment a dry run only reported on")
e.mustSurvive(t, current, "the active deployment")
if !hasContent(t, e, "v1") {
t.Error("a dry run removed content")
}
// Blobs the reported deletions would free are not counted: nothing was
// deleted, so they are all still referenced. The number is a floor.
if stats.BlobsDeleted != 0 {
t.Errorf("a dry run reported %d collectable blobs, want 0 while everything is referenced",
stats.BlobsDeleted)
}
// The real pass then does what the dry run said it would.
stats, err = e.svc.Collect(t.Context(), false)
if err != nil {
t.Fatal(err)
}
if stats.DryRun {
t.Error("a real pass reported itself as a dry run")
}
if stats.DeploymentsDeleted != 1 {
t.Errorf("deleted %d deployments, want 1", stats.DeploymentsDeleted)
}
e.mustBeGone(t, old, "the superseded deployment")
}
func TestDeleteRemovesRowAndTree(t *testing.T) {
e := newEnv(t)
e.svc.BlobGrace = collectNow
dep := e.version(t, 1)
current := e.version(t, 2)
e.activate(t, current)
if err := e.svc.Delete(t.Context(), e.p, dep); err != nil {
t.Fatalf("Delete: %v", err)
}
e.mustBeGone(t, dep, "the deleted deployment")
// Deleting it dropped the manifest, so the next collection reaches what only
// it referenced.
if _, err := e.svc.Collect(t.Context(), false); err != nil {
t.Fatal(err)
}
if hasContent(t, e, "v1") {
t.Error("content the deleted deployment held survived collection")
}
if err := e.svc.Delete(t.Context(), e.p, dep); apiCode(err) != api.CodeNotFound {
t.Errorf("deleting it again = %v, want %q", err, api.CodeNotFound)
}
}
// Deleting what a project is serving is a conflict, not a permission problem:
// the client is told to activate something else first.
func TestDeleteRefusesTheActiveDeployment(t *testing.T) {
e := newEnv(t)
dep := e.version(t, 1)
e.activate(t, dep)
err := e.svc.Delete(t.Context(), e.p, dep)
wantCode(t, err, api.CodeDeploymentActive)
e.mustSurvive(t, dep, "the active deployment")
// It becomes deletable the moment something else is being served, which is
// the sequence the error message describes.
e.activate(t, e.version(t, 2))
if err := e.svc.Delete(t.Context(), e.p, dep); err != nil {
t.Fatalf("Delete after activating another deployment: %v", err)
}
e.mustBeGone(t, dep, "the deployment that was superseded and then deleted")
}
func TestRemoveProjectTrees(t *testing.T) {
e := newEnv(t)
dep := e.version(t, 1)
e.activate(t, dep)
other := store.DefaultProject("other")
if err := e.db.CreateProject(t.Context(), other); err != nil {
t.Fatal(err)
}
otherDir := DeploymentDir(e.dir, other.ID, "dpl_0000000000000000")
if err := os.MkdirAll(otherDir, 0o755); err != nil {
t.Fatal(err)
}
if err := e.svc.RemoveProjectTrees(e.p.ID); err != nil {
t.Fatalf("RemoveProjectTrees: %v", err)
}
if _, err := os.Stat(filepath.Join(e.dir, strconv.FormatInt(e.p.ID, 10))); !os.IsNotExist(err) {
t.Errorf("the project's directory survived: %v", err)
}
if _, err := os.Stat(otherDir); err != nil {
t.Errorf("another project's directory was removed: %v", err)
}
// Idempotent: recovery may run it again after a crash partway through.
if err := e.svc.RemoveProjectTrees(e.p.ID); err != nil {
t.Errorf("a second removal: %v", err)
}
}
// Deleting the project takes its deployments with it, and the collector then
// reclaims everything they referenced.
func TestCollectAfterProjectDeletion(t *testing.T) {
e := newEnv(t)
e.svc.BlobGrace = collectNow
dep := e.version(t, 1)
e.activate(t, dep)
if err := e.db.DeleteProject(t.Context(), e.p.ID); err != nil {
t.Fatalf("DeleteProject: %v", err)
}
if err := e.svc.RemoveProjectTrees(e.p.ID); err != nil {
t.Fatalf("RemoveProjectTrees: %v", err)
}
stats, err := e.svc.Collect(t.Context(), false)
if err != nil {
t.Fatal(err)
}
if stats.BlobsDeleted != 1 {
t.Errorf("collected %d blobs, want the 1 the deleted project held", stats.BlobsDeleted)
}
if hasContent(t, e, "v1") {
t.Error("content of a deleted project survived collection")
}
if n := blobCount(t, e); n != 0 {
t.Errorf("%d blob rows left after the project was deleted", n)
}
}
// A deployment claimed by a sweep that was interrupted is finished by the next
// one, whatever its retention would otherwise have said.
func TestCollectResumesAnInterruptedDeletion(t *testing.T) {
e := newEnv(t)
e.svc.BlobGrace = collectNow
e.setRetention(t, 10, 3600) // Generous: retention alone would keep it.
dep := e.version(t, 1)
e.activate(t, e.version(t, 2))
if err := e.db.MarkDeploymentDeleting(t.Context(), dep.ID); err != nil {
t.Fatal(err)
}
stats, err := e.svc.Collect(t.Context(), false)
if err != nil {
t.Fatal(err)
}
if stats.DeploymentsDeleted != 1 {
t.Errorf("deleted %d deployments, want the claimed one", stats.DeploymentsDeleted)
}
e.mustBeGone(t, dep, "a deployment a previous sweep had claimed")
}
// The activation path and the collector both take the project lock, and the
// claim rechecks active = 0 under it. A deployment that becomes active between
// being listed and being claimed is therefore refused rather than deleted.
func TestCollectSkipsADeploymentActivatedUnderIt(t *testing.T) {
e := newEnv(t)
e.svc.BlobGrace = collectNow
e.setRetention(t, 0, 0)
old := e.version(t, 1)
e.activate(t, e.version(t, 2))
// Stand in for the interleaving: retention has decided to drop `old`, and a
// rollback activates it before the claim runs.
e.activate(t, old)
if err := e.svc.claim(t.Context(), e.p, old); !errors.Is(err, store.ErrConflict) {
t.Fatalf("claiming a deployment that became active = %v, want ErrConflict", err)
}
e.mustSurvive(t, old, "a deployment activated during collection")
stats, err := e.svc.Collect(t.Context(), false)
if err != nil {
t.Fatal(err)
}
if stats.DeploymentsDeleted != 1 {
t.Errorf("deleted %d deployments, want only the one that is no longer served", stats.DeploymentsDeleted)
}
e.mustSurvive(t, old, "the deployment the rollback made active")
}
// Collection walks every project, not just the one a request happened to name.
func TestCollectSpansProjects(t *testing.T) {
e := newEnv(t)
e.svc.BlobGrace = collectNow
e.setRetention(t, 0, 0)
first := e.p
firstOld := e.version(t, 1)
e.activate(t, e.version(t, 2))
second := store.DefaultProject("second")
second.RetentionCount = 0
second.RetentionGraceS = 0
if err := e.db.CreateProject(t.Context(), second); err != nil {
t.Fatal(err)
}
e.p = second
secondOld := e.version(t, 3)
e.activate(t, e.version(t, 4))
e.p = first
stats, err := e.svc.Collect(t.Context(), false)
if err != nil {
t.Fatal(err)
}
if stats.DeploymentsDeleted != 2 {
t.Errorf("deleted %d deployments, want one from each project", stats.DeploymentsDeleted)
}
e.mustBeGone(t, firstOld, "the first project's superseded deployment")
e.p = second
e.mustBeGone(t, secondOld, "the second project's superseded deployment")
}