Files
at-container-registry/pkg/appview/db/batch.go
T
Evan JarrettandClaude Opus 5 802cc4ba96 db: add manifest_key and let the existing backfill populate it
First half of replacing manifests.id with a node-independent identity. Nothing
depends on the column yet: id is still the primary key, and layers and
manifest_references still reference it. Getting the column in place and filled
first means the eventual swap operates on data that is already complete and
already proven unique, instead of doing the fill and three table rebuilds in one
step.

The value cannot be computed by the migration. It is a truncated sha256, SQLite
has no hash builtin, and go-libsql exposes no way to register one. So the fill
uses machinery that already exists: the Jetstream backfill re-upserts every
manifest across the protocol on startup, and both upsert paths now write
manifest_key.

That only works because of one extra clause. Both upserts guard their DO UPDATE
with a WHERE that skips rows where nothing changed, which on a backfill re-run is
nearly every row, so they would have skipped the very manifests that need
filling. Adding "OR manifests.manifest_key IS NULL" is what makes an otherwise
no-op pass populate the column. Verified by removing it: the backfill then fills
zero of three manifests instead of three of three.

The index is UNIQUE even though the column is nullable. SQLite treats NULLs as
distinct, so unfilled rows coexist while every filled row is checked. That makes
production data verify the 16-byte truncation rather than us assuming it: if two
manifests ever derived the same key, it fails loudly at insert instead of
silently attaching one manifest's layers to another after the swap.

AppView logs the unfilled count at startup, since there is no single moment at
which this becomes complete and the follow-up migration is only safe at zero.

ManifestKey replaces the old fat "did|repo|digest" map key rather than sitting
beside it; they were always the same question, answered without asking the
database.

The jetstream tests hand-maintained their own CREATE TABLE statements, which is
the drift problem moved into a test: the copy had already fallen behind (it still
had tags.id) and only failed once a query touched the difference. They use
db.InitDB now, with foreign keys switched off to preserve the behavior the
hand-rolled schema had.

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

603 lines
21 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 and returns a map of
// digest → manifest id for the inserted rows (both new and existing). Rows
// are keyed by (did, repository, digest); callers that need the id must
// group their input so that digest is unique per (did, repository) in one
// batch call.
//
// Implementation: one multi-row INSERT per sub-batch, followed by one SELECT
// to fetch ids back (libsql's RETURNING support across replica modes is
// uneven; a second SELECT is reliable and still a single round-trip per
// sub-batch).
func BatchInsertManifests(db DBTX, manifests []Manifest) (map[string]int64, error) {
out := make(map[string]int64, len(manifests))
if len(manifests) == 0 {
return out, 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,
m.DID, m.Repository, m.Digest, m.HoldEndpoint,
m.SchemaVersion, m.MediaType, m.ConfigDigest,
m.ConfigSize, m.ArtifactType,
nullString(m.SubjectDigest),
m.CreatedAt,
ManifestKey(m.DID, m.Repository, m.Digest),
)
}
// The trailing "manifest_key IS NULL" clause on the WHERE is what
// populates rows that predate the column. Without it this upsert skips
// unchanged manifests entirely, so re-running the backfill would never
// fill their key and the column would stay half-empty forever.
query := `
INSERT INTO manifests
(did, repository, digest, hold_endpoint, schema_version, media_type,
config_digest, config_size, artifact_type, subject_digest, created_at,
manifest_key)
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,
manifest_key = excluded.manifest_key
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
OR manifests.manifest_key IS NULL
`
if _, err := db.Exec(query, args...); err != nil {
return nil, fmt.Errorf("batch insert manifests: %w", err)
}
// Fetch ids for this sub-batch by (did, digest) — digests are unique enough
// that matching on (did, digest) avoids needing a three-column IN list.
// repository is included in the row to disambiguate if a user genuinely has
// the same digest across repos.
selectArgs := make([]any, 0, 1+2*len(batch))
// Group by did (caller usually supplies one did per call, but be safe).
didSet := make(map[string]struct{})
for _, m := range batch {
didSet[m.DID] = struct{}{}
}
// Build a per-did IN (?) query; usually exactly one iteration.
for did := range didSet {
digests := make([]string, 0, len(batch))
for _, m := range batch {
if m.DID == did {
digests = append(digests, m.Digest)
}
}
selectArgs = append(selectArgs[:0], did)
for _, d := range digests {
selectArgs = append(selectArgs, d)
}
selectQuery := `
SELECT repository, digest, id FROM manifests
WHERE did = ? AND digest IN (` +
strings.TrimSuffix(strings.Repeat("?,", len(digests)), ",") + `)
`
rows, err := db.Query(selectQuery, selectArgs...)
if err != nil {
return nil, fmt.Errorf("batch select manifest ids: %w", err)
}
for rows.Next() {
var repo, digest string
var id int64
if err := rows.Scan(&repo, &digest, &id); err != nil {
rows.Close()
return nil, fmt.Errorf("scan manifest id: %w", err)
}
// Key format matches what callers use: "did|repo|digest".
out[ManifestKey(did, repo, digest)] = id
}
rows.Close()
}
}
return out, 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.ManifestID, l.Digest, l.Size, l.MediaType, l.LayerIndex, annotationsJSON)
}
query := `
INSERT INTO layers (manifest_id, digest, size, media_type, layer_index, annotations)
VALUES ` + buildPlaceholders(len(batch), cols) + `
ON CONFLICT(manifest_id, 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_id, 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.ManifestID, r.Digest, r.Size, r.MediaType,
r.PlatformArchitecture, r.PlatformOS,
r.PlatformVariant, r.PlatformOSVersion,
r.IsAttestation, r.ReferenceIndex,
)
}
query := `
INSERT INTO manifest_references (manifest_id, 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_id, 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
}