mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 11:44:16 +00:00
0033 added the column and left the fill to the Jetstream backfill. That is fine for a running system and wrong for replay: a database several releases behind runs every pending migration back-to-back at boot, long before any worker starts. Anything built on top of manifest_key would, on that path, silently operate on NULLs while working perfectly on a system that had been up for a while. Establishing a migration's data precondition out-of-band means replay cannot see it. The runner now supports a Go step per migration version, running inside the same transaction as that migration's SQL, after the DDL it depends on and before the version is recorded. A version is never recorded without its Go half. 0033's step fills manifest_key for every row lacking one. It has to be Go: the value is a truncated sha256 and SQLite has no hash builtin. It pages through the table and writes one UPDATE ... CASE per 500 rows, because a statement per row would be correct and unusably slow against a remote primary. Verified by unregistering the hook: replay then leaves 3 of 3 seeded manifests with NULL keys. With it, all three are filled, from a snapshot of the pre-0009 schema forward. The upserts keep their "OR manifests.manifest_key IS NULL" clause. It is a self-healing net for rows that somehow arrive without a key rather than the mechanism anything depends on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
130 lines
4.3 KiB
Go
130 lines
4.3 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
)
|
|
|
|
// goMigrations holds migration steps that cannot be expressed in SQL, keyed by
|
|
// the migration version they belong to.
|
|
//
|
|
// A hook runs inside the same transaction as its migration's SQL, immediately
|
|
// after it and before the version is recorded, so a version is either fully
|
|
// applied or not applied at all.
|
|
//
|
|
// This exists because a migration's data precondition has to be established
|
|
// inside the migration sequence. Anything satisfied out-of-band (at runtime, by
|
|
// a background worker, by an operator) is invisible to replay: a database
|
|
// upgrading across several releases at once runs every pending migration
|
|
// back-to-back at boot, long before any worker starts, so a later migration that
|
|
// assumed the out-of-band step had happened would silently operate on unprepared
|
|
// data.
|
|
//
|
|
// Keep hooks idempotent. A migration that fails partway is rolled back and
|
|
// retried on the next boot, and a hook may also run against a database where
|
|
// some or all of its work has already happened by other means.
|
|
var goMigrations = map[int]func(*sql.Tx) error{
|
|
33: backfillManifestKeys,
|
|
}
|
|
|
|
// manifestKeyBackfillBatch is how many manifests get their key set per
|
|
// statement. Each row contributes three placeholders, so this stays far below
|
|
// the driver's parameter ceiling while keeping the number of round trips to a
|
|
// remote primary low: one per 500 rows rather than one per row.
|
|
const manifestKeyBackfillBatch = 500
|
|
|
|
// backfillManifestKeys fills manifests.manifest_key for every row that lacks it.
|
|
//
|
|
// The value is a truncated sha256 of (did, repository, digest). SQLite has no
|
|
// hash builtin and go-libsql cannot register one, which is why this is a Go hook
|
|
// rather than an UPDATE in 0033's SQL.
|
|
//
|
|
// On a live database this is usually a no-op or close to it, because the
|
|
// Jetstream backfill fills the column as it re-upserts each manifest. It matters
|
|
// on the path that has no backfill to rely on: a database replaying migrations
|
|
// across releases, where 0033 and anything built on top of it run seconds apart
|
|
// with no worker having started. Without this, a later migration that moves
|
|
// layers and manifest_references onto manifest_key would copy NULLs.
|
|
func backfillManifestKeys(tx *sql.Tx) error {
|
|
type pending struct {
|
|
id int64
|
|
key string
|
|
}
|
|
|
|
var filled int
|
|
for {
|
|
// Read a page, then close the cursor before writing. Updating a table
|
|
// while a SELECT over it is still open is asking for trouble.
|
|
rows, err := tx.Query(`
|
|
SELECT id, did, repository, digest
|
|
FROM manifests
|
|
WHERE manifest_key IS NULL
|
|
LIMIT ?
|
|
`, manifestKeyBackfillBatch)
|
|
if err != nil {
|
|
return fmt.Errorf("select manifests missing a key: %w", err)
|
|
}
|
|
|
|
batch := make([]pending, 0, manifestKeyBackfillBatch)
|
|
for rows.Next() {
|
|
var (
|
|
id int64
|
|
did, repository, digest string
|
|
)
|
|
if err := rows.Scan(&id, &did, &repository, &digest); err != nil {
|
|
rows.Close()
|
|
return fmt.Errorf("scan manifest: %w", err)
|
|
}
|
|
batch = append(batch, pending{id: id, key: ManifestKey(did, repository, digest)})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return fmt.Errorf("iterate manifests: %w", err)
|
|
}
|
|
rows.Close()
|
|
|
|
if len(batch) == 0 {
|
|
break
|
|
}
|
|
|
|
// One statement per page: UPDATE ... SET manifest_key = CASE id WHEN ...
|
|
// END WHERE id IN (...). A statement per row would be correct too, and
|
|
// unusably slow against a remote primary.
|
|
var sb strings.Builder
|
|
args := make([]any, 0, len(batch)*3)
|
|
sb.WriteString("UPDATE manifests SET manifest_key = CASE id")
|
|
for _, p := range batch {
|
|
sb.WriteString(" WHEN ? THEN ?")
|
|
args = append(args, p.id, p.key)
|
|
}
|
|
sb.WriteString(" END WHERE id IN (")
|
|
for i, p := range batch {
|
|
if i > 0 {
|
|
sb.WriteByte(',')
|
|
}
|
|
sb.WriteByte('?')
|
|
args = append(args, p.id)
|
|
}
|
|
sb.WriteByte(')')
|
|
|
|
if _, err := tx.Exec(sb.String(), args...); err != nil {
|
|
return fmt.Errorf("set manifest keys: %w", err)
|
|
}
|
|
filled += len(batch)
|
|
|
|
// A short page means the table is exhausted. Checking this rather than
|
|
// looping again saves a query, and the loop still terminates either way
|
|
// because every row written stops matching the WHERE.
|
|
if len(batch) < manifestKeyBackfillBatch {
|
|
break
|
|
}
|
|
}
|
|
|
|
if filled > 0 {
|
|
slog.Info("Backfilled manifest_key during migration", "manifests", filled)
|
|
}
|
|
return nil
|
|
}
|