Files
at-container-registry/pkg/appview/db/batch.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

550 lines
19 KiB
Go

package db
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
)
// BatchSize is the maximum number of rows included in a single multi-row INSERT.
// Kept well under SQLite's default SQLITE_MAX_VARIABLE_NUMBER (32766) and any
// remote libsql parameter ceiling — at 11 columns this is 1100 placeholders.
const BatchSize = 100
// buildPlaceholders returns a comma-separated list of `rows` groups of the form
// `(?,?,?)`, each group containing `cols` placeholders. Used to construct the
// VALUES clause of multi-row INSERT statements.
func buildPlaceholders(rows, cols int) string {
if rows <= 0 || cols <= 0 {
return ""
}
group := "(" + strings.Repeat("?,", cols-1) + "?)"
var sb strings.Builder
sb.Grow((len(group) + 1) * rows)
for i := range rows {
if i > 0 {
sb.WriteByte(',')
}
sb.WriteString(group)
}
return sb.String()
}
// chunk returns the half-open range [start, end) for the i-th chunk of size
// BatchSize within a slice of length n.
func chunk(n, i int) (start, end int) {
start = i * BatchSize
end = min(start+BatchSize, n)
return start, end
}
// BatchInsertManifests upserts a batch of manifests.
//
// It used to return a map of digest to manifest id, because callers needed the
// rowid the database had just allocated in order to insert layers. They compute
// ManifestKey themselves now, so the follow-up SELECT and its per-DID IN list
// are gone: one round trip per sub-batch instead of two, and no dependency on
// reading back what we just wrote.
func BatchInsertManifests(db DBTX, manifests []Manifest) error {
if len(manifests) == 0 {
return nil
}
for i := 0; i*BatchSize < len(manifests); i++ {
start, end := chunk(len(manifests), i)
batch := manifests[start:end]
const cols = 12
args := make([]any, 0, len(batch)*cols)
for _, m := range batch {
args = append(args,
ManifestKey(m.DID, m.Repository, m.Digest),
m.DID, m.Repository, m.Digest, m.HoldEndpoint,
m.SchemaVersion, m.MediaType, m.ConfigDigest,
m.ConfigSize, m.ArtifactType,
nullString(m.SubjectDigest),
m.CreatedAt,
)
}
query := `
INSERT INTO manifests
(manifest_key, did, repository, digest, hold_endpoint, schema_version,
media_type, config_digest, config_size, artifact_type, subject_digest,
created_at)
VALUES ` + buildPlaceholders(len(batch), cols) + `
ON CONFLICT(did, repository, digest) DO UPDATE SET
hold_endpoint = excluded.hold_endpoint,
schema_version = excluded.schema_version,
media_type = excluded.media_type,
config_digest = excluded.config_digest,
config_size = excluded.config_size,
artifact_type = excluded.artifact_type,
subject_digest = excluded.subject_digest
WHERE excluded.hold_endpoint != manifests.hold_endpoint
OR excluded.schema_version != manifests.schema_version
OR excluded.media_type != manifests.media_type
OR excluded.config_digest IS NOT manifests.config_digest
OR excluded.config_size IS NOT manifests.config_size
OR excluded.artifact_type != manifests.artifact_type
OR excluded.subject_digest IS NOT manifests.subject_digest
`
if _, err := db.Exec(query, args...); err != nil {
return fmt.Errorf("batch insert manifests: %w", err)
}
}
return nil
}
// ManifestKey derives a manifest's node-independent identity from its natural
// key, (did, repository, digest).
//
// This is both the value stored in manifests.manifest_key and the lookup key for
// BatchInsertManifests' result map, which are the same concept: "which manifest
// is this" answered without asking the database.
//
// Deterministic on purpose. A rowid is allocated by whichever node performs the
// insert, so it is only a stable identity while every write funnels through a
// single writer. Any node can compute this one, before the write, without a
// round trip.
//
// Truncated to 16 bytes: 128 bits puts the birthday bound at 2^64, which is far
// past the number of manifests that could ever exist here. The column carries a
// UNIQUE index anyway, so real data checks the assumption rather than trusting
// it.
func ManifestKey(did, repository, digest string) string {
h := sha256.New()
h.Write([]byte(did))
h.Write([]byte{0})
h.Write([]byte(repository))
h.Write([]byte{0})
h.Write([]byte(digest))
return hex.EncodeToString(h.Sum(nil)[:16])
}
// BatchInsertLayers inserts a batch of layers, skipping any that already exist.
// Layers are immutable, so ON CONFLICT DO NOTHING matches the single-row
// InsertLayer semantics.
func BatchInsertLayers(db DBTX, layers []Layer) error {
if len(layers) == 0 {
return nil
}
for i := 0; i*BatchSize < len(layers); i++ {
start, end := chunk(len(layers), i)
batch := layers[start:end]
const cols = 6
args := make([]any, 0, len(batch)*cols)
for _, l := range batch {
var annotationsJSON any
if len(l.Annotations) > 0 {
b, err := json.Marshal(l.Annotations)
if err != nil {
return fmt.Errorf("marshal layer annotations: %w", err)
}
s := string(b)
annotationsJSON = &s
}
args = append(args, l.ManifestKey, l.Digest, l.Size, l.MediaType, l.LayerIndex, annotationsJSON)
}
query := `
INSERT INTO layers (manifest_key, digest, size, media_type, layer_index, annotations)
VALUES ` + buildPlaceholders(len(batch), cols) + `
ON CONFLICT(manifest_key, layer_index) DO NOTHING
`
if _, err := db.Exec(query, args...); err != nil {
return fmt.Errorf("batch insert layers: %w", err)
}
}
return nil
}
// BatchInsertManifestReferences inserts a batch of manifest references.
// The table has PRIMARY KEY(manifest_key, reference_index); duplicates skip.
func BatchInsertManifestReferences(db DBTX, refs []ManifestReference) error {
if len(refs) == 0 {
return nil
}
for i := 0; i*BatchSize < len(refs); i++ {
start, end := chunk(len(refs), i)
batch := refs[start:end]
const cols = 10
args := make([]any, 0, len(batch)*cols)
for _, r := range batch {
args = append(args,
r.ManifestKey, r.Digest, r.Size, r.MediaType,
r.PlatformArchitecture, r.PlatformOS,
r.PlatformVariant, r.PlatformOSVersion,
r.IsAttestation, r.ReferenceIndex,
)
}
query := `
INSERT INTO manifest_references (manifest_key, digest, size, media_type,
platform_architecture, platform_os,
platform_variant, platform_os_version,
is_attestation, reference_index)
VALUES ` + buildPlaceholders(len(batch), cols) + `
ON CONFLICT(manifest_key, reference_index) DO NOTHING
`
if _, err := db.Exec(query, args...); err != nil {
return fmt.Errorf("batch insert manifest references: %w", err)
}
}
return nil
}
// BatchUpsertTags upserts a batch of tag records, matching UpsertTag semantics.
func BatchUpsertTags(db DBTX, tags []Tag) error {
if len(tags) == 0 {
return nil
}
for i := 0; i*BatchSize < len(tags); i++ {
start, end := chunk(len(tags), i)
batch := tags[start:end]
const cols = 5
args := make([]any, 0, len(batch)*cols)
for _, t := range batch {
args = append(args, t.DID, t.Repository, t.Tag, t.Digest, t.CreatedAt)
}
query := `
INSERT INTO tags (did, repository, tag, digest, created_at)
VALUES ` + buildPlaceholders(len(batch), cols) + `
ON CONFLICT(did, repository, tag) DO UPDATE SET
digest = excluded.digest,
created_at = excluded.created_at
WHERE excluded.digest != tags.digest
OR excluded.created_at != tags.created_at
`
if _, err := db.Exec(query, args...); err != nil {
return fmt.Errorf("batch upsert tags: %w", err)
}
}
return nil
}
// StarInput is a struct projection of the UpsertStar argument list for use with BatchUpsertStars.
type StarInput struct {
StarrerDID string
OwnerDID string
Repository string
CreatedAt time.Time
}
// BatchUpsertStars upserts a batch of stars. Stars are immutable.
func BatchUpsertStars(db DBTX, stars []StarInput) error {
if len(stars) == 0 {
return nil
}
for i := 0; i*BatchSize < len(stars); i++ {
start, end := chunk(len(stars), i)
batch := stars[start:end]
const cols = 4
args := make([]any, 0, len(batch)*cols)
for _, s := range batch {
args = append(args, s.StarrerDID, s.OwnerDID, s.Repository, s.CreatedAt)
}
query := `
INSERT INTO stars (starrer_did, owner_did, repository, created_at)
VALUES ` + buildPlaceholders(len(batch), cols) + `
ON CONFLICT(starrer_did, owner_did, repository) DO NOTHING
`
if _, err := db.Exec(query, args...); err != nil {
return fmt.Errorf("batch upsert stars: %w", err)
}
}
return nil
}
// BatchUpsertRepoPages upserts a batch of repo page records.
func BatchUpsertRepoPages(db DBTX, pages []RepoPage) error {
if len(pages) == 0 {
return nil
}
for i := 0; i*BatchSize < len(pages); i++ {
start, end := chunk(len(pages), i)
batch := pages[start:end]
const cols = 7
args := make([]any, 0, len(batch)*cols)
for _, p := range batch {
args = append(args,
p.DID, p.Repository, p.Description, p.AvatarCID,
p.UserEdited, p.CreatedAt, p.UpdatedAt,
)
}
query := `
INSERT INTO repo_pages (did, repository, description, avatar_cid, user_edited, created_at, updated_at)
VALUES ` + buildPlaceholders(len(batch), cols) + `
ON CONFLICT(did, repository) DO UPDATE SET
description = excluded.description,
avatar_cid = excluded.avatar_cid,
user_edited = excluded.user_edited,
updated_at = excluded.updated_at
WHERE excluded.description IS NOT repo_pages.description
OR excluded.avatar_cid IS NOT repo_pages.avatar_cid
OR excluded.user_edited IS NOT repo_pages.user_edited
`
if _, err := db.Exec(query, args...); err != nil {
return fmt.Errorf("batch upsert repo pages: %w", err)
}
}
return nil
}
// BatchUpsertDailyStats upserts a batch of daily stats rows.
func BatchUpsertDailyStats(db DBTX, stats []DailyStats) error {
if len(stats) == 0 {
return nil
}
for i := 0; i*BatchSize < len(stats); i++ {
start, end := chunk(len(stats), i)
batch := stats[start:end]
const cols = 5
args := make([]any, 0, len(batch)*cols)
for _, s := range batch {
args = append(args, s.DID, s.Repository, s.Date, s.PullCount, s.PushCount)
}
query := `
INSERT INTO repository_stats_daily (did, repository, date, pull_count, push_count)
VALUES ` + buildPlaceholders(len(batch), cols) + `
ON CONFLICT(did, repository, date) DO UPDATE SET
pull_count = excluded.pull_count,
push_count = excluded.push_count
WHERE excluded.pull_count != repository_stats_daily.pull_count
OR excluded.push_count != repository_stats_daily.push_count
`
if _, err := db.Exec(query, args...); err != nil {
return fmt.Errorf("batch upsert daily stats: %w", err)
}
}
return nil
}
// BatchUpsertRepositoryStats upserts aggregated repository stats.
func BatchUpsertRepositoryStats(db DBTX, stats []RepositoryStats) error {
if len(stats) == 0 {
return nil
}
for i := 0; i*BatchSize < len(stats); i++ {
start, end := chunk(len(stats), i)
batch := stats[start:end]
const cols = 6
args := make([]any, 0, len(batch)*cols)
for _, s := range batch {
args = append(args,
s.DID, s.Repository, s.PullCount, s.LastPull, s.PushCount, s.LastPush,
)
}
query := `
INSERT INTO repository_stats (did, repository, pull_count, last_pull, push_count, last_push)
VALUES ` + buildPlaceholders(len(batch), cols) + `
ON CONFLICT(did, repository) DO UPDATE SET
pull_count = excluded.pull_count,
last_pull = excluded.last_pull,
push_count = excluded.push_count,
last_push = excluded.last_push
WHERE excluded.pull_count != repository_stats.pull_count
OR excluded.last_pull IS NOT repository_stats.last_pull
OR excluded.push_count != repository_stats.push_count
OR excluded.last_push IS NOT repository_stats.last_push
`
if _, err := db.Exec(query, args...); err != nil {
return fmt.Errorf("batch upsert repository stats: %w", err)
}
}
return nil
}
// BatchUpsertCaptainRecords upserts a batch of captain records.
func BatchUpsertCaptainRecords(db DBTX, records []HoldCaptainRecord) error {
if len(records) == 0 {
return nil
}
for i := 0; i*BatchSize < len(records); i++ {
start, end := chunk(len(records), i)
batch := records[start:end]
const cols = 8
args := make([]any, 0, len(batch)*cols)
for _, r := range batch {
args = append(args,
r.HoldDID, r.OwnerDID, r.Public, r.AllowAllCrew,
nullString(r.DeployedAt),
nullString(r.Region),
nullString(r.Successor),
r.UpdatedAt,
)
}
query := `
INSERT INTO hold_captain_records (
hold_did, owner_did, public, allow_all_crew,
deployed_at, region, successor, updated_at
) VALUES ` + buildPlaceholders(len(batch), cols) + `
ON CONFLICT(hold_did) DO UPDATE SET
owner_did = excluded.owner_did,
public = excluded.public,
allow_all_crew = excluded.allow_all_crew,
deployed_at = excluded.deployed_at,
region = excluded.region,
successor = excluded.successor,
updated_at = excluded.updated_at
WHERE excluded.owner_did != hold_captain_records.owner_did
OR excluded.public != hold_captain_records.public
OR excluded.allow_all_crew != hold_captain_records.allow_all_crew
OR excluded.deployed_at IS NOT hold_captain_records.deployed_at
OR excluded.region IS NOT hold_captain_records.region
OR excluded.successor IS NOT hold_captain_records.successor
`
if _, err := db.Exec(query, args...); err != nil {
return fmt.Errorf("batch upsert captain records: %w", err)
}
}
return nil
}
// BatchUpsertCrewMembers upserts a batch of crew members.
func BatchUpsertCrewMembers(db DBTX, members []CrewMember) error {
if len(members) == 0 {
return nil
}
for i := 0; i*BatchSize < len(members); i++ {
start, end := chunk(len(members), i)
batch := members[start:end]
// updated_at uses CURRENT_TIMESTAMP literal, so it's not a placeholder.
const cols = 7
args := make([]any, 0, len(batch)*cols)
for _, m := range batch {
args = append(args,
m.HoldDID, m.MemberDID, m.Rkey,
nullString(m.Role),
nullString(m.Permissions),
nullString(m.Tier),
nullString(m.AddedAt),
)
}
// Replace each group with `(?,?,?,?,?,?,?,CURRENT_TIMESTAMP)` — we build it
// manually because buildPlaceholders only handles uniform placeholders.
group := "(" + strings.Repeat("?,", cols) + "CURRENT_TIMESTAMP)"
var sb strings.Builder
sb.Grow((len(group) + 1) * len(batch))
for i := range batch {
if i > 0 {
sb.WriteByte(',')
}
sb.WriteString(group)
}
query := `
INSERT INTO hold_crew_members (
hold_did, member_did, rkey, role, permissions, tier, added_at, updated_at
) VALUES ` + sb.String() + `
ON CONFLICT(hold_did, member_did) DO UPDATE SET
rkey = excluded.rkey,
role = excluded.role,
permissions = excluded.permissions,
tier = excluded.tier,
added_at = excluded.added_at,
updated_at = CURRENT_TIMESTAMP
WHERE excluded.rkey != hold_crew_members.rkey
OR excluded.role IS NOT hold_crew_members.role
OR excluded.permissions IS NOT hold_crew_members.permissions
OR excluded.tier IS NOT hold_crew_members.tier
OR excluded.added_at IS NOT hold_crew_members.added_at
`
if _, err := db.Exec(query, args...); err != nil {
return fmt.Errorf("batch upsert crew members: %w", err)
}
}
return nil
}
// AnnotationRow represents a single key/value annotation for a repository,
// used by BatchUpsertRepositoryAnnotations.
type AnnotationRow struct {
DID string
Repository string
Key string
Value string
}
// BatchUpsertRepositoryAnnotations upserts annotation rows and deletes any
// stale keys for each (did, repository) represented in the input. The caller
// is responsible for pre-filtering: rows should represent only repositories
// whose newest manifest has at least one non-empty annotation, matching the
// single-row UpsertRepositoryAnnotations semantics.
func BatchUpsertRepositoryAnnotations(db DBTX, rows []AnnotationRow) error {
if len(rows) == 0 {
return nil
}
// Group rows by (did, repository) so we can delete stale keys per repo.
type repoKey struct{ did, repo string }
keysByRepo := make(map[repoKey][]string)
for _, r := range rows {
k := repoKey{r.DID, r.Repository}
keysByRepo[k] = append(keysByRepo[k], r.Key)
}
// Delete stale keys per repository in one statement each. We could batch
// further with OR chains, but DELETE is cheap and each repo has few keys.
for k, keys := range keysByRepo {
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(keys)), ",")
args := make([]any, 0, 2+len(keys))
args = append(args, k.did, k.repo)
for _, key := range keys {
args = append(args, key)
}
if _, err := db.Exec(`
DELETE FROM repository_annotations
WHERE did = ? AND repository = ? AND key NOT IN (`+placeholders+`)
`, args...); err != nil {
return fmt.Errorf("batch delete stale annotations: %w", err)
}
}
// Upsert all annotation rows in sub-batches.
now := time.Now()
for i := 0; i*BatchSize < len(rows); i++ {
start, end := chunk(len(rows), i)
batch := rows[start:end]
const cols = 5
args := make([]any, 0, len(batch)*cols)
for _, r := range batch {
args = append(args, r.DID, r.Repository, r.Key, r.Value, now)
}
query := `
INSERT INTO repository_annotations (did, repository, key, value, updated_at)
VALUES ` + buildPlaceholders(len(batch), cols) + `
ON CONFLICT(did, repository, key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at
WHERE excluded.value != repository_annotations.value
`
if _, err := db.Exec(query, args...); err != nil {
return fmt.Errorf("batch upsert annotations: %w", err)
}
}
return nil
}