Cover byte reclamation: one guarded helper, file first, covers row last (#154)
This commit is contained in:
@@ -5,6 +5,8 @@ import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -2029,6 +2031,7 @@ func (s *Store) latestCorrectedAt(t *testing.T, site, seriesID string) int64 {
|
||||
|
||||
// num2 boxes a chapter number for the Bookmark fields that take a pointer.
|
||||
func num2(f float64) *float64 { return &f }
|
||||
|
||||
// --- Cover addressing (ADR-0014): the address is the bytes' SHA-256 ---
|
||||
|
||||
// The address is what makes a re-art visible at all, so the same bytes must
|
||||
@@ -2094,8 +2097,8 @@ func TestReplaceSeriesCover(t *testing.T) {
|
||||
sr.Cover != "https://cdn.asurascans.com/covers/solo-rebrand.webp" {
|
||||
t.Fatalf("series after replacement = %+v, want the new address and source URL", sr)
|
||||
}
|
||||
// The replaced bytes stay served under their old address; nothing reclaims
|
||||
// them in this ticket (the forced-poll wave does).
|
||||
// The replaced bytes stay served under their old address: ReplaceSeriesCover
|
||||
// itself reclaims nothing, reclamation is the caller's separate act (#154).
|
||||
if _, _, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("first-art"))); err != nil || !ok {
|
||||
t.Fatalf("superseded bytes = found %v, err %v, want still served", ok, err)
|
||||
}
|
||||
@@ -2179,3 +2182,161 @@ func TestSetSeriesURLWritesWhereUpsertIgnores(t *testing.T) {
|
||||
t.Fatalf("stored URL = %q, want %q", sr.SeriesURL, repair)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cover byte reclamation (issue #154): one guarded helper, file first ---
|
||||
|
||||
// coverShardPath is the on-disk location of one address's bytes, built the
|
||||
// same way getCoverByAddress reads them.
|
||||
func coverShardPath(t *testing.T, s *Store, address string) string {
|
||||
t.Helper()
|
||||
return filepath.Join(s.coverDir, filepath.FromSlash(coverRelativePath(address)))
|
||||
}
|
||||
|
||||
// ReclaimCover removes a Cover nothing references: the row alone is not the
|
||||
// point — the sharded file must be gone too, because the file is the reclaimed
|
||||
// disk space.
|
||||
func TestReclaimCoverRemovesUnreferencedBytes(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/old.jpg", []byte("old-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
old := CoverAddressForBytes([]byte("old-art"))
|
||||
if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/new.jpg", []byte("new-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("replace cover: %v", err)
|
||||
}
|
||||
|
||||
if err := store.ReclaimCover(old); err != nil {
|
||||
t.Fatalf("ReclaimCover: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, old)); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("sharded path after reclaim = %v, want fs.ErrNotExist", err)
|
||||
}
|
||||
if _, _, ok, err := store.CoverByAddress(old); err != nil || ok {
|
||||
t.Fatalf("covers row after reclaim = found %v err %v, want gone", ok, err)
|
||||
}
|
||||
// The live Cover survives the reclamation of the stranded one.
|
||||
if body, _, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("new-art"))); err != nil || !ok || string(body) != "new-art" {
|
||||
t.Fatalf("new bytes after reclaim = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The guard is the whole design: byte-identical artwork is one covers row by
|
||||
// construction (ADR-0014), so a second Series pointing at the address must
|
||||
// keep the bytes — reclaiming one Series' stranded artwork may not blank
|
||||
// another's.
|
||||
func TestReclaimCoverSparesReferencedAddress(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
const src = "https://cdn.example/covers/shared.jpg"
|
||||
addr := CoverAddressForBytes([]byte("shared-art"))
|
||||
if err := store.SetSeriesCover("asura", "solo", src, []byte("shared-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
|
||||
// One Series pointing at the address is enough for the guard.
|
||||
if err := store.ReclaimCover(addr); err != nil {
|
||||
t.Fatalf("ReclaimCover on a referenced address: %v", err)
|
||||
}
|
||||
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" {
|
||||
t.Fatalf("bytes after no-op = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil {
|
||||
t.Fatalf("sharded file after no-op: %v, want present", err)
|
||||
}
|
||||
|
||||
// A second Series serving identical bytes shares the row by construction.
|
||||
seedForCheck(t, store, "asura:second", "https://asurascans.com/comics/second", 0)
|
||||
if err := store.SetSeriesCover("asura", "second", src, []byte("shared-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("share cover: %v", err)
|
||||
}
|
||||
if err := store.ReclaimCover(addr); err != nil {
|
||||
t.Fatalf("ReclaimCover on a shared address: %v", err)
|
||||
}
|
||||
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" {
|
||||
t.Fatalf("shared bytes after no-op = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil {
|
||||
t.Fatalf("sharded file after shared no-op: %v, want present", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A blank address is the wire value for "no Cover" (ADR-0007), never a
|
||||
// reclaimable one.
|
||||
func TestReclaimCoverBlankAddressIsNoOp(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if err := store.ReclaimCover(""); err != nil {
|
||||
t.Fatalf("ReclaimCover(\"\") = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An interrupted reclamation is the state the file-first order exists for:
|
||||
// the row is the handle, so the unreferenced-covers query finds the torn
|
||||
// Cover and re-running ReclaimCover finishes the job — a missing file is
|
||||
// "already gone", which counts as success.
|
||||
func TestReclaimCoverInterruptedRunIsFindableAndFinishes(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/torn.jpg", []byte("torn-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
torn := CoverAddressForBytes([]byte("torn-art"))
|
||||
if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/new.jpg", []byte("new-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("replace cover: %v", err)
|
||||
}
|
||||
if err := os.Remove(coverShardPath(t, store, torn)); err != nil {
|
||||
t.Fatalf("unlink mid-reclamation: %v", err)
|
||||
}
|
||||
|
||||
var found string
|
||||
err := store.db.QueryRow(`
|
||||
SELECT address FROM covers c
|
||||
WHERE NOT EXISTS (SELECT 1 FROM series s WHERE s.cover_address = c.address)
|
||||
LIMIT 1`).Scan(&found)
|
||||
if err != nil || found != torn {
|
||||
t.Fatalf("unreferenced-covers query = (%q, %v), want the torn row %q", found, err, torn)
|
||||
}
|
||||
|
||||
if err := store.ReclaimCover(torn); err != nil {
|
||||
t.Fatalf("re-run over a missing file: %v", err)
|
||||
}
|
||||
if _, _, ok, err := store.CoverByAddress(torn); err != nil || ok {
|
||||
t.Fatalf("row after re-run = found %v err %v, want gone", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed file removal is the one state that is not self-cleaning: the
|
||||
// covers row must survive so a retry can finish the job, and the store
|
||||
// returns the error rather than logging — each caller logs and carries on,
|
||||
// so the failure has no user-facing surface.
|
||||
func TestReclaimCoverFailedUnlinkKeepsRow(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/stuck.jpg", []byte("stuck-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
stuck := CoverAddressForBytes([]byte("stuck-art"))
|
||||
if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/other.jpg", []byte("other-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("replace cover: %v", err)
|
||||
}
|
||||
// Make the unlink fail: the sharded path becomes a non-empty directory,
|
||||
// which os.Remove refuses.
|
||||
shard := coverShardPath(t, store, stuck)
|
||||
if err := os.Remove(shard); err != nil {
|
||||
t.Fatalf("clear file: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(shard, 0o755); err != nil {
|
||||
t.Fatalf("replace file with dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(shard, "blob"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("fill dir: %v", err)
|
||||
}
|
||||
|
||||
if err := store.ReclaimCover(stuck); err == nil {
|
||||
t.Fatal("ReclaimCover over an unremovable file = nil, want the error")
|
||||
}
|
||||
var one int
|
||||
if err := store.db.QueryRow(`SELECT 1 FROM covers WHERE address = $1`, stuck).Scan(&one); err != nil {
|
||||
t.Fatal("covers row after failed unlink is gone; want it left for a retry")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user