Files
at-container-registry/pkg/appview/db/cascade_delete_test.go
T
Evan JarrettandClaude Opus 5 454a6bad3d db: key manifests by manifest_key and drop the rowid
Completes the swap 0033 set up. layers and manifest_references move onto
manifest_key and manifests.id is gone, which removes the last node-allocated
identifier in the AppView schema.

Statement order in 0034 is load-bearing. With foreign keys on, DROP TABLE
performs an implicit DELETE FROM, so dropping manifests while layers still holds
an ON DELETE CASCADE reference deletes every layer row. Migration 0009 did
exactly that; it went unnoticed because the Jetstream backfill rebuilds layers
from PDS records, so the damage healed itself. PRAGMA foreign_keys is no help:
it is a no-op inside a transaction and migrations run in one. So the new
children are built pointing at manifests_new, the old children are dropped
first, and only then is the old manifests table dropped, by which point nothing
references it. Verified both behaviors before relying on them.

manifest_key is declared NOT NULL as well as PRIMARY KEY, because in SQLite a
PRIMARY KEY column still accepts NULL unless it is INTEGER PRIMARY KEY. That
constraint immediately caught four test helpers inserting manifests without one.

Five queries used MAX(id) as "the newest manifest in this repo", which I had
previously reported as absent after grepping only for ORDER BY. A derived key
has no ordering, so recency now comes from created_at with manifest_key as a
deterministic tiebreak. This is a real behavior change, and a fix: the two
disagree whenever a manifest is indexed out of order, which the backfill does
routinely, and created_at is the push time these queries always wanted. Both
directions are tested, including that ties resolve the same way every run.

InsertManifest and BatchInsertManifests no longer read anything back. The key is
derived from (did, repository, digest), so the writer knows it before the
statement runs: the select-back, its per-DID IN list, and the "manifest missing
id after batch insert" branch all go away, along with the UNIQUE-conflict
fallback that existed only to recover a rowid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:44:41 -05:00

251 lines
6.9 KiB
Go

package db
import (
"database/sql"
"errors"
"testing"
"time"
)
// seedCascadeFixture inserts a user and a single manifest. Returns the
// manifest's row id so callers can attach references (for the multi-arch case).
func seedCascadeFixture(t *testing.T, db *sql.DB, didStr, repo, digest string) string {
t.Helper()
user := &User{
DID: didStr,
Handle: "tester.example.com",
PDSEndpoint: "https://test.pds.example.com",
LastSeen: time.Now(),
}
if err := UpsertUser(db, user); err != nil {
t.Fatalf("UpsertUser: %v", err)
}
id, err := InsertManifest(db, &Manifest{
DID: didStr,
Repository: repo,
Digest: digest,
HoldEndpoint: "did:web:hold.example.com",
SchemaVersion: 2,
MediaType: "application/vnd.oci.image.manifest.v1+json",
CreatedAt: time.Now(),
})
if err != nil {
t.Fatalf("InsertManifest: %v", err)
}
return id
}
func TestGetTagDigest_ReturnsDigestForKnownTag(t *testing.T) {
db, err := InitDB(":memory:", LibsqlConfig{})
if err != nil {
t.Fatalf("InitDB: %v", err)
}
defer db.Close()
const did = "did:plc:tagdigest"
const repo = "myapp"
const digest = "sha256:aaa"
seedCascadeFixture(t, db, did, repo, digest)
if err := UpsertTag(db, &Tag{
DID: did,
Repository: repo,
Tag: "latest",
Digest: digest,
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("UpsertTag: %v", err)
}
got, err := GetTagDigest(db, did, repo, "latest")
if err != nil {
t.Fatalf("GetTagDigest: %v", err)
}
if got != digest {
t.Errorf("digest mismatch: got %q want %q", got, digest)
}
}
func TestGetTagDigest_UnknownTagReturnsErrNoRows(t *testing.T) {
db, err := InitDB(":memory:", LibsqlConfig{})
if err != nil {
t.Fatalf("InitDB: %v", err)
}
defer db.Close()
seedCascadeFixture(t, db, "did:plc:tagdigest2", "myapp", "sha256:bbb")
_, err = GetTagDigest(db, "did:plc:tagdigest2", "myapp", "does-not-exist")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("expected sql.ErrNoRows for unknown tag, got %v", err)
}
}
// TestShouldCascadeDeleteManifest_LastTagAndNoParent: the common case —
// digest has no remaining tags and is not referenced by any manifest list.
// Cascade should fire.
func TestShouldCascadeDeleteManifest_LastTagAndNoParent(t *testing.T) {
db, err := InitDB(":memory:", LibsqlConfig{})
if err != nil {
t.Fatalf("InitDB: %v", err)
}
defer db.Close()
const did = "did:plc:cascade1"
const repo = "myapp"
const digest = "sha256:lonely"
seedCascadeFixture(t, db, did, repo, digest)
// No tags pointing to this digest, no manifest_references entries.
ok, err := ShouldCascadeDeleteManifest(db, did, digest)
if err != nil {
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
}
if !ok {
t.Error("expected cascade=true when manifest is untagged and unreferenced")
}
}
// TestShouldCascadeDeleteManifest_RemainingTagBlocks: another tag still
// points to this digest → keep the manifest alive.
func TestShouldCascadeDeleteManifest_RemainingTagBlocks(t *testing.T) {
db, err := InitDB(":memory:", LibsqlConfig{})
if err != nil {
t.Fatalf("InitDB: %v", err)
}
defer db.Close()
const did = "did:plc:cascade2"
const repo = "myapp"
const digest = "sha256:shared"
seedCascadeFixture(t, db, did, repo, digest)
if err := UpsertTag(db, &Tag{
DID: did,
Repository: repo,
Tag: "v1",
Digest: digest,
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("UpsertTag: %v", err)
}
ok, err := ShouldCascadeDeleteManifest(db, did, digest)
if err != nil {
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
}
if ok {
t.Error("expected cascade=false when another tag still points to the digest")
}
}
// TestShouldCascadeDeleteManifest_TagInOtherRepoBlocks: the same digest is
// tagged in a *different* repository of the same user. The io.atcr.manifest
// record is keyed by digest alone, so one record backs both repos — cascading
// here would delete the record out from under the other repo and purge the
// shared layers on the hold, breaking an image the user never touched.
func TestShouldCascadeDeleteManifest_TagInOtherRepoBlocks(t *testing.T) {
db, err := InitDB(":memory:", LibsqlConfig{})
if err != nil {
t.Fatalf("InitDB: %v", err)
}
defer db.Close()
const did = "did:plc:cascade4"
const deletingRepo = "myapp"
const otherRepo = "myapp-mirror"
const digest = "sha256:sharedacrossrepos"
// Same content pushed to two repositories.
seedCascadeFixture(t, db, did, deletingRepo, digest)
if _, err := InsertManifest(db, &Manifest{
DID: did,
Repository: otherRepo,
Digest: digest,
HoldEndpoint: "did:web:hold.example.com",
SchemaVersion: 2,
MediaType: "application/vnd.oci.image.manifest.v1+json",
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("InsertManifest(otherRepo): %v", err)
}
// Only the other repo still carries a tag. The deleting repo has none.
if err := UpsertTag(db, &Tag{
DID: did,
Repository: otherRepo,
Tag: "v1",
Digest: digest,
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("UpsertTag: %v", err)
}
ok, err := ShouldCascadeDeleteManifest(db, did, digest)
if err != nil {
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
}
if ok {
t.Error("expected cascade=false when the digest is still tagged in another repository")
}
}
// TestShouldCascadeDeleteManifest_MultiArchChildBlocks: the digest is a child
// of a manifest list (multi-arch parent). Even with no tags, deleting it
// would orphan the parent's reference, so we must NOT cascade.
func TestShouldCascadeDeleteManifest_MultiArchChildBlocks(t *testing.T) {
db, err := InitDB(":memory:", LibsqlConfig{})
if err != nil {
t.Fatalf("InitDB: %v", err)
}
defer db.Close()
const did = "did:plc:cascade3"
const repo = "myapp"
const childDigest = "sha256:amd64child"
const parentDigest = "sha256:multiarchparent"
// Insert the child manifest fixture.
seedCascadeFixture(t, db, did, repo, childDigest)
// Insert a separate parent (manifest list) and attach a manifest_reference
// from parent → child.
parentID, err := InsertManifest(db, &Manifest{
DID: did,
Repository: repo,
Digest: parentDigest,
HoldEndpoint: "did:web:hold.example.com",
SchemaVersion: 2,
MediaType: "application/vnd.oci.image.index.v1+json",
CreatedAt: time.Now(),
})
if err != nil {
t.Fatalf("InsertManifest(parent): %v", err)
}
if err := InsertManifestReference(db, &ManifestReference{
ManifestKey: parentID,
Digest: childDigest,
Size: 1234,
MediaType: "application/vnd.oci.image.manifest.v1+json",
PlatformArchitecture: "amd64",
PlatformOS: "linux",
ReferenceIndex: 0,
}); err != nil {
t.Fatalf("InsertManifestReference: %v", err)
}
ok, err := ShouldCascadeDeleteManifest(db, did, childDigest)
if err != nil {
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
}
if ok {
t.Error("expected cascade=false when digest is a child of a manifest list")
}
}