624 lines
19 KiB
Go
624 lines
19 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/iceBear67/simplepages/internal/cas"
|
|
)
|
|
|
|
// blobRow reads a blob's bookkeeping directly, so a test can assert on the
|
|
// columns the collector reads rather than on what a helper reports.
|
|
func blobRow(t *testing.T, db *DB, d cas.Digest) (refcount int64, present bool, lastRef int64) {
|
|
t.Helper()
|
|
err := db.Reader().QueryRow(
|
|
`SELECT refcount, present, last_ref_at FROM blobs WHERE digest = ?`, d.Bytes()).
|
|
Scan(&refcount, &present, &lastRef)
|
|
if err != nil {
|
|
t.Fatalf("blob %s: %v", d, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
func blobExists(t *testing.T, db *DB, d cas.Digest) bool {
|
|
t.Helper()
|
|
var n int
|
|
if err := db.Reader().QueryRow(
|
|
`SELECT count(*) FROM blobs WHERE digest = ?`, d.Bytes()).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n == 1
|
|
}
|
|
|
|
func TestAllDeploymentRefs(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
a := testProject(t, db, "a")
|
|
b := testProject(t, db, "b")
|
|
|
|
want := map[string]int64{}
|
|
for range 3 {
|
|
dep := testDeployment(t, db, a.ID)
|
|
want[dep.PublicID] = a.ID
|
|
}
|
|
dep := testDeployment(t, db, b.ID)
|
|
want[dep.PublicID] = b.ID
|
|
|
|
refs, err := db.AllDeploymentRefs(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(refs) != len(want) {
|
|
t.Fatalf("got %d refs, want %d", len(refs), len(want))
|
|
}
|
|
for _, ref := range refs {
|
|
pid, ok := want[ref.PublicID]
|
|
if !ok {
|
|
t.Errorf("unexpected public id %q", ref.PublicID)
|
|
continue
|
|
}
|
|
if ref.ProjectID != pid {
|
|
t.Errorf("%s belongs to project %d, want %d", ref.PublicID, ref.ProjectID, pid)
|
|
}
|
|
delete(want, ref.PublicID)
|
|
}
|
|
}
|
|
|
|
func TestDeploymentsInStateSpansProjects(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
a := testProject(t, db, "a")
|
|
b := testProject(t, db, "b")
|
|
|
|
// Recovery has to find interrupted deletions wherever they are, so this
|
|
// query is deliberately not project-scoped.
|
|
first := testDeployment(t, db, a.ID)
|
|
second := testDeployment(t, db, b.ID)
|
|
for _, dep := range []*Deployment{first, second} {
|
|
if _, err := db.w.ExecContext(ctx,
|
|
`UPDATE deployments SET state = ? WHERE id = ?`, StateDeleting, dep.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
testDeployment(t, db, a.ID) // still pending; must not be listed
|
|
|
|
got, err := db.DeploymentsInState(ctx, StateDeleting, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("got %d deleting deployments, want 2", len(got))
|
|
}
|
|
if got[0].ID != first.ID || got[1].ID != second.ID {
|
|
t.Errorf("order is %d,%d; want oldest first (%d,%d)",
|
|
got[0].ID, got[1].ID, first.ID, second.ID)
|
|
}
|
|
}
|
|
|
|
// The active deployment is excluded in SQL rather than filtered by the caller,
|
|
// so no retention arithmetic can select the one being served.
|
|
func TestInactiveDeploymentsExcludesTheActiveOne(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
p := testProject(t, db, "demo")
|
|
other := testProject(t, db, "other")
|
|
testDeployment(t, db, other.ID)
|
|
|
|
var deps []*Deployment
|
|
for i := range 3 {
|
|
deps = append(deps, ready(t, db, p.ID, string(rune('a'+i))))
|
|
}
|
|
if err := db.ActivateDeployment(ctx, p.ID, deps[1].ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := db.InactiveDeployments(ctx, p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("got %d inactive deployments, want 2", len(got))
|
|
}
|
|
if got[0].ID != deps[2].ID || got[1].ID != deps[0].ID {
|
|
t.Errorf("order is %d,%d; want newest first (%d,%d)",
|
|
got[0].ID, got[1].ID, deps[2].ID, deps[0].ID)
|
|
}
|
|
}
|
|
|
|
func TestExpireStaleDeployments(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
p := testProject(t, db, "demo")
|
|
|
|
shared := file("shared.js", "shared")
|
|
only := file("only.html", "abandoned")
|
|
|
|
// One upload that stalled, one that is merely young, one that finished.
|
|
stale := testDeployment(t, db, p.ID)
|
|
if _, _, err := db.SetManifest(ctx, stale.ID, []FileRow{shared, only}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
young := testDeployment(t, db, p.ID)
|
|
if _, _, err := db.SetManifest(ctx, young.ID, []FileRow{shared}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
done := ready(t, db, p.ID, "finished")
|
|
|
|
// Age the stalled one past the cutoff. Backdating the row is the only way
|
|
// to test this without the test sleeping.
|
|
if _, err := db.w.ExecContext(ctx,
|
|
`UPDATE deployments SET created_at = ? WHERE id = ?`,
|
|
time.Now().Add(-48*time.Hour).Unix(), stale.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
n, err := db.ExpireStaleDeployments(ctx, time.Now().Add(-24*time.Hour), "abandoned")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("expired %d deployments, want 1", n)
|
|
}
|
|
|
|
got, err := db.DeploymentByPublicID(ctx, p.ID, stale.PublicID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.State != StateFailed {
|
|
t.Errorf("state = %q, want %q", got.State, StateFailed)
|
|
}
|
|
if got.Error != "abandoned" {
|
|
t.Errorf("error = %q, want the reason to be recorded", got.Error)
|
|
}
|
|
|
|
// Dropping the manifest is the point: it is what takes the refcounts back
|
|
// down so the collector can reach the content.
|
|
if rc, _, _ := blobRow(t, db, only.Digest); rc != 0 {
|
|
t.Errorf("refcount of the abandoned file = %d, want 0", rc)
|
|
}
|
|
// Content the surviving upload also names keeps its reference.
|
|
if rc, _, _ := blobRow(t, db, shared.Digest); rc != 1 {
|
|
t.Errorf("refcount of the shared file = %d, want 1 (the young upload still names it)", rc)
|
|
}
|
|
|
|
for _, dep := range []*Deployment{young, done} {
|
|
got, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.State == StateFailed {
|
|
t.Errorf("deployment %s was expired but should not have been", dep.PublicID)
|
|
}
|
|
}
|
|
|
|
// Running it again finds nothing left to do.
|
|
n, err = db.ExpireStaleDeployments(ctx, time.Now().Add(-24*time.Hour), "abandoned")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("a second pass expired %d more, want 0", n)
|
|
}
|
|
}
|
|
|
|
func TestMarkDeploymentDeleting(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
p := testProject(t, db, "demo")
|
|
active := ready(t, db, p.ID, "v1")
|
|
spare := ready(t, db, p.ID, "v2")
|
|
if err := db.ActivateDeployment(ctx, p.ID, active.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The one being served is refused, and by the same statement that would
|
|
// have claimed it — there is no window between the check and the claim.
|
|
if err := db.MarkDeploymentDeleting(ctx, active.ID); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("claiming the active deployment = %v, want ErrConflict", err)
|
|
}
|
|
got, err := db.DeploymentByPublicID(ctx, p.ID, active.PublicID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.State != StateReady {
|
|
t.Errorf("the refused claim changed the state to %q", got.State)
|
|
}
|
|
|
|
if err := db.MarkDeploymentDeleting(ctx, spare.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err = db.DeploymentByPublicID(ctx, p.ID, spare.PublicID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.State != StateDeleting {
|
|
t.Errorf("state = %q, want %q", got.State, StateDeleting)
|
|
}
|
|
|
|
// Re-claiming is fine: a collector that was interrupted after the claim and
|
|
// before the removal has to be able to pick the row up again.
|
|
if err := db.MarkDeploymentDeleting(ctx, spare.ID); err != nil {
|
|
t.Errorf("re-claiming a deployment already being deleted: %v", err)
|
|
}
|
|
|
|
if err := db.MarkDeploymentDeleting(ctx, 99999); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("claiming a missing deployment = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteDeploymentDropsManifestRefsExplicitly(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
p := testProject(t, db, "demo")
|
|
|
|
shared := file("shared.js", "shared")
|
|
only := file("index.html", "gone")
|
|
dep := testDeployment(t, db, p.ID)
|
|
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{shared, only}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keeper := testDeployment(t, db, p.ID)
|
|
if _, _, err := db.SetManifest(ctx, keeper.ID, []FileRow{shared}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := db.DeleteDeployment(ctx, dep.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("the row survived deletion: %v", err)
|
|
}
|
|
// The AFTER DELETE trigger has to have fired. A cascaded delete would not
|
|
// have fired it, which is why the manifest rows are deleted by hand first.
|
|
if rc, _, _ := blobRow(t, db, only.Digest); rc != 0 {
|
|
t.Errorf("refcount = %d after the only reference was deleted, want 0", rc)
|
|
}
|
|
if rc, _, _ := blobRow(t, db, shared.Digest); rc != 1 {
|
|
t.Errorf("refcount of shared content = %d, want 1", rc)
|
|
}
|
|
|
|
if err := db.DeleteDeployment(ctx, dep.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("deleting again = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteDeploymentRefusesTheActiveOne(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
p := testProject(t, db, "demo")
|
|
dep := ready(t, db, p.ID, "v1")
|
|
if err := db.ActivateDeployment(ctx, p.ID, dep.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := db.DeleteDeployment(ctx, dep.ID); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("deleting the active deployment = %v, want ErrConflict", err)
|
|
}
|
|
if _, err := db.ActiveDeployment(ctx, p.ID); err != nil {
|
|
t.Errorf("the project stopped serving anything: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEachPresentBlobAndMarkBlobsAbsent(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
p := testProject(t, db, "demo")
|
|
|
|
here := file("here.txt", "here")
|
|
gone := file("gone.txt", "gone")
|
|
pending := file("pending.txt", "pending")
|
|
dep := testDeployment(t, db, p.ID)
|
|
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{here, gone, pending}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, f := range []FileRow{here, gone} {
|
|
if err := db.MarkBlobPresent(ctx, f.Digest, f.Size); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
if err := db.EachPresentBlob(ctx, func(d cas.Digest) error {
|
|
seen[d.String()] = true
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(seen) != 2 || !seen[here.Digest.String()] || !seen[gone.Digest.String()] {
|
|
t.Fatalf("present blobs = %v, want exactly the two uploaded ones", seen)
|
|
}
|
|
if seen[pending.Digest.String()] {
|
|
t.Error("a blob that was never uploaded was reported as present")
|
|
}
|
|
|
|
if err := db.MarkBlobsAbsent(ctx, []cas.Digest{gone.Digest}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The row stays and keeps its reference: the fix for missing content is to
|
|
// ask for it again, not to forget the deployment needs it.
|
|
rc, present, _ := blobRow(t, db, gone.Digest)
|
|
if present {
|
|
t.Error("the blob is still marked present")
|
|
}
|
|
if rc != 1 {
|
|
t.Errorf("refcount = %d, want the manifest reference to survive", rc)
|
|
}
|
|
if _, present, _ := blobRow(t, db, here.Digest); !present {
|
|
t.Error("an unrelated blob was marked absent")
|
|
}
|
|
|
|
// An empty list is a no-op rather than a statement with no arguments.
|
|
if err := db.MarkBlobsAbsent(ctx, nil); err != nil {
|
|
t.Errorf("MarkBlobsAbsent(nil): %v", err)
|
|
}
|
|
|
|
// The callback's error stops the walk and reaches the caller.
|
|
stop := errors.New("stop")
|
|
if err := db.EachPresentBlob(ctx, func(cas.Digest) error { return stop }); !errors.Is(err, stop) {
|
|
t.Errorf("EachPresentBlob swallowed the callback error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUnreferencedBlobs(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
p := testProject(t, db, "demo")
|
|
|
|
kept := file("kept.txt", "kept")
|
|
dropped := file("dropped.txt", "dropped")
|
|
dep := testDeployment(t, db, p.ID)
|
|
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{kept, dropped}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.MarkBlobPresent(ctx, dropped.Digest, dropped.Size); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Referenced content is never listed, however old.
|
|
got, err := db.UnreferencedBlobs(ctx, time.Now().Add(time.Hour), 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("listed %d referenced blobs, want 0", len(got))
|
|
}
|
|
|
|
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{kept}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Freshly unreferenced content is protected by the cutoff, which is what
|
|
// gives a request that has already resolved the digest time to open it.
|
|
got, err = db.UnreferencedBlobs(ctx, time.Now().Add(-time.Hour), 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("listed %d blobs inside the grace period, want 0", len(got))
|
|
}
|
|
|
|
got, err = db.UnreferencedBlobs(ctx, time.Now().Add(time.Hour), 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("listed %d blobs past the cutoff, want 1", len(got))
|
|
}
|
|
if got[0].Digest != dropped.Digest {
|
|
t.Errorf("listed %s, want the dereferenced %s", got[0].Digest, dropped.Digest)
|
|
}
|
|
if got[0].Size != dropped.Size || !got[0].Present {
|
|
t.Errorf("listed blob = %+v, want the size and presence the collector needs", got[0])
|
|
}
|
|
}
|
|
|
|
func TestDeleteBlobRemovesRowAndContentTogether(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
p := testProject(t, db, "demo")
|
|
|
|
orphan := file("orphan.txt", "orphan")
|
|
held := file("held.txt", "held")
|
|
dep := testDeployment(t, db, p.ID)
|
|
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{orphan, held}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{held}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
removed := 0
|
|
deleted, err := db.DeleteBlob(ctx, orphan.Digest, func() error { removed++; return nil })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !deleted || removed != 1 {
|
|
t.Fatalf("deleted = %v, remove called %d times; want true and once", deleted, removed)
|
|
}
|
|
if blobExists(t, db, orphan.Digest) {
|
|
t.Error("the row survived")
|
|
}
|
|
|
|
// A blob that gained a reference since it was listed is left alone, and the
|
|
// content is not touched — this is the check that keeps a deploy racing the
|
|
// collector from losing its files.
|
|
removed = 0
|
|
deleted, err = db.DeleteBlob(ctx, held.Digest, func() error { removed++; return nil })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if deleted || removed != 0 {
|
|
t.Errorf("a referenced blob was collected: deleted = %v, remove called %d times", deleted, removed)
|
|
}
|
|
if !blobExists(t, db, held.Digest) {
|
|
t.Error("a referenced blob's row was deleted")
|
|
}
|
|
|
|
// Nothing to delete is not an error; the previous sweep already did it.
|
|
deleted, err = db.DeleteBlob(ctx, orphan.Digest, func() error {
|
|
t.Error("remove was called for a row that no longer exists")
|
|
return nil
|
|
})
|
|
if err != nil || deleted {
|
|
t.Errorf("re-deleting = (%v, %v), want (false, nil)", deleted, err)
|
|
}
|
|
}
|
|
|
|
// If the content cannot be removed the row has to survive with it, so the next
|
|
// sweep finds the pair again rather than leaving a file nothing points at.
|
|
func TestDeleteBlobKeepsTheRowWhenRemovalFails(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
p := testProject(t, db, "demo")
|
|
|
|
f := file("orphan.txt", "orphan")
|
|
dep := testDeployment(t, db, p.ID)
|
|
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{f}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := db.SetManifest(ctx, dep.ID, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
boom := errors.New("disk is having a day")
|
|
deleted, err := db.DeleteBlob(ctx, f.Digest, func() error { return boom })
|
|
if !errors.Is(err, boom) {
|
|
t.Fatalf("DeleteBlob = %v, want the removal error", err)
|
|
}
|
|
if deleted {
|
|
t.Error("reported a deletion that was rolled back")
|
|
}
|
|
if !blobExists(t, db, f.Digest) {
|
|
t.Fatal("the row was deleted even though the content was not")
|
|
}
|
|
}
|
|
|
|
func TestFsckFindsAndRepairsDrift(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
p := testProject(t, db, "demo")
|
|
|
|
shared := file("shared.js", "shared")
|
|
one := file("one.html", "one")
|
|
two := file("two.html", "two")
|
|
first := testDeployment(t, db, p.ID)
|
|
if _, _, err := db.SetManifest(ctx, first.ID, []FileRow{shared, one}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second := testDeployment(t, db, p.ID)
|
|
if _, _, err := db.SetManifest(ctx, second.ID, []FileRow{shared, two}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Triggers maintain these counters, so a healthy store never drifts.
|
|
rep, err := db.Fsck(ctx, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rep.Blobs != 3 {
|
|
t.Errorf("examined %d blobs, want 3", rep.Blobs)
|
|
}
|
|
if rep.DriftCount != 0 {
|
|
t.Fatalf("a healthy store reported %d drifting blobs: %+v", rep.DriftCount, rep.Drift)
|
|
}
|
|
|
|
// Injecting drift in both directions. Low is the dangerous one: the
|
|
// collector trusts the counter, so a count that reads low is content it
|
|
// will delete while a deployment still names it.
|
|
if _, err := db.w.ExecContext(ctx,
|
|
`UPDATE blobs SET refcount = 0 WHERE digest = ?`, shared.Digest.Bytes()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.w.ExecContext(ctx,
|
|
`UPDATE blobs SET refcount = 7 WHERE digest = ?`, one.Digest.Bytes()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rep, err = db.Fsck(ctx, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rep.DriftCount != 2 {
|
|
t.Fatalf("found %d drifting blobs, want 2: %+v", rep.DriftCount, rep.Drift)
|
|
}
|
|
if rep.Repaired != 0 {
|
|
t.Errorf("a report-only check repaired %d rows", rep.Repaired)
|
|
}
|
|
found := map[string]Drift{}
|
|
for _, dr := range rep.Drift {
|
|
found[dr.Digest.String()] = dr
|
|
}
|
|
if dr := found[shared.Digest.String()]; dr.Stored != 0 || dr.Actual != 2 {
|
|
t.Errorf("shared drift = %+v, want stored 0 and actual 2", dr)
|
|
}
|
|
if dr := found[one.Digest.String()]; dr.Stored != 7 || dr.Actual != 1 {
|
|
t.Errorf("one.html drift = %+v, want stored 7 and actual 1", dr)
|
|
}
|
|
// Report-only means exactly that.
|
|
if rc, _, _ := blobRow(t, db, shared.Digest); rc != 0 {
|
|
t.Errorf("refcount = %d, want the injected value left alone", rc)
|
|
}
|
|
|
|
rep, err = db.Fsck(ctx, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rep.Repaired != 2 {
|
|
t.Errorf("repaired %d rows, want 2", rep.Repaired)
|
|
}
|
|
if rc, _, _ := blobRow(t, db, shared.Digest); rc != 2 {
|
|
t.Errorf("refcount after repair = %d, want the recounted 2", rc)
|
|
}
|
|
if rc, _, _ := blobRow(t, db, one.Digest); rc != 1 {
|
|
t.Errorf("refcount after repair = %d, want the recounted 1", rc)
|
|
}
|
|
|
|
rep, err = db.Fsck(ctx, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rep.DriftCount != 0 || rep.Repaired != 0 {
|
|
t.Errorf("a repaired store still reports %d drift, %d repaired", rep.DriftCount, rep.Repaired)
|
|
}
|
|
}
|
|
|
|
// The list is for a human to read; the repair is not bounded by it.
|
|
func TestFsckCapsTheReportedDriftButRepairsEverything(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
p := testProject(t, db, "demo")
|
|
|
|
const n = maxReportedDrift + 10
|
|
rows := make([]FileRow, 0, n)
|
|
for i := range n {
|
|
rows = append(rows, file("f"+strconv.Itoa(i)+".txt", "content-"+strconv.Itoa(i)))
|
|
}
|
|
dep := testDeployment(t, db, p.ID)
|
|
if _, _, err := db.SetManifest(ctx, dep.ID, rows); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.w.ExecContext(ctx, `UPDATE blobs SET refcount = 42`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rep, err := db.Fsck(ctx, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rep.DriftCount != n {
|
|
t.Errorf("DriftCount = %d, want the full %d", rep.DriftCount, n)
|
|
}
|
|
if len(rep.Drift) != maxReportedDrift {
|
|
t.Errorf("listed %d drifting blobs, want the report capped at %d", len(rep.Drift), maxReportedDrift)
|
|
}
|
|
if rep.Repaired != n {
|
|
t.Errorf("repaired %d rows, want all %d", rep.Repaired, n)
|
|
}
|
|
if rep, err = db.Fsck(ctx, false); err != nil || rep.DriftCount != 0 {
|
|
t.Errorf("after repair: %d drift, err %v", rep.DriftCount, err)
|
|
}
|
|
}
|