gastownhall/beads · error
adding %s.content_hash: %w
Error message
adding %s.content_hash: %w
What it means
ensureContentHashColumn upgrades an older cursor table by running `ALTER TABLE <cursor> ADD COLUMN content_hash CHAR(64)`. If the ALTER fails, the error is wrapped as `adding <cursor>.content_hash: <cause>`. This is part of an idempotent bootstrap upgrade (beads#4259), so failures here block migration bookkeeping from recording content hashes.
Source
Thrown at internal/storage/schema/schema.go:1153
// table that predates it (gastownhall/beads#4259 reporter fix No.2: record a
// per-migration content hash so two clones at the same MAX(version) but with
// divergent migration content are detectable). Fresh tables already have it via
// bootstrapSQL; this idempotently upgrades older databases without a numbered
// migration. Already-applied rows keep a NULL hash — their migration content is
// not re-read. It reports whether it actually added the column, so MigrateUp can
// treat that ALTER as committable schema work even when no numbered migration or
// backfill ran.
func (m migrationSource) ensureContentHashColumn(ctx context.Context, db DBConn) (bool, error) {
has, err := m.hasContentHashColumn(ctx, db)
if err != nil {
return false, err
}
if has {
return false, nil
}
//nolint:gosec // G201: m.cursorTable is a hardcoded constant.
if _, err := db.ExecContext(ctx, "ALTER TABLE "+m.cursorTable+" ADD COLUMN content_hash CHAR(64)"); err != nil {
return false, fmt.Errorf("adding %s.content_hash: %w", m.cursorTable, err)
}
return true, nil
}
func checkNoDuplicateVersions(files []migrationFile) {
seen := make(map[int]string, len(files))
for _, m := range files {
if prior, ok := seen[m.version]; ok {
panic(fmt.Sprintf(
"schema: duplicate migration version %d: %q and %q — renumber one before commit",
m.version, prior, m.name,
))
}
seen[m.version] = m.name
}
}
func (m migrationSource) list() []migrationFile {View on GitHub (pinned to 71377f2769)
Solutions
- GRANT ALTER on the schema to the application DB user, or run the upgrade once with a privileged account.
- Ensure only one migrator runs: acquire an application-level lock (e.g. GET_LOCK or a lock table) around MigrateUp.
- Re-run MigrateUp — the flow is idempotent; if the table vanished, bootstrapSQL recreates it including content_hash.
- Check server disk space and DDL error details in the wrapped cause; on read-only replicas, point migrations at the primary.
Example fix
// before
if _, err := db.ExecContext(ctx, "ALTER TABLE "+m.cursorTable+" ADD COLUMN content_hash CHAR(64)"); err != nil {
return false, fmt.Errorf("adding %s.content_hash: %w", m.cursorTable, err)
}
// after
if _, err := db.ExecContext(ctx, "ALTER TABLE "+m.cursorTable+" ADD COLUMN IF NOT EXISTS content_hash CHAR(64)"); err != nil {
if dberrors.IsDuplicateColumn(err) {
return false, nil // concurrent migrator already added it
}
return false, fmt.Errorf("adding %s.content_hash: %w", m.cursorTable, err)
} Defensive patterns
Strategy: retry
Validate before calling
// verify ALTER privilege and that the cursor table exists before bootstrap
var priv int
err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.schema_privileges WHERE grantee LIKE CURRENT_USER() AND privilege_type = 'ALTER'").Scan(&priv)
if err == nil && priv == 0 {
return fmt.Errorf("DB user lacks ALTER privilege needed for content_hash upgrade")
} Type guard
func isDuplicateColumnErr(err error) bool {
var mysqlErr *mysql.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1060
} Try / catch
err := MigrateUp(ctx, db)
if err != nil && strings.Contains(err.Error(), "adding ") && strings.Contains(err.Error(), "content_hash") {
if isDuplicateColumnErr(err) || isLockWaitErr(err) {
time.Sleep(time.Second)
err = MigrateUp(ctx, db) // idempotent: probe re-checks before ALTER
}
} Prevention
- Grant the app user ALTER on the schema, or run the one-time upgrade as an admin.
- Use a migration lock so only one process upgrades the cursor table.
- Re-run MigrateUp after failure — the ensure step is idempotent.
- Keep the server's disk from filling; ALTERs fail on full disks.
When it happens
Trigger: The ALTER runs only when SHOW COLUMNS confirmed the column is absent; it fails on insufficient privileges (ALTER denied), the table being dropped between probe and ALTER, a concurrent writer holding metadata locks, or disk-full/online-DDL limits on the server.
Common situations: Restricted DB user without ALTER privilege on an ops-managed database; two processes migrating concurrently racing the ALTER; managed MySQL (non-Dolt) with online DDL restrictions; read-only replica receiving writes.
Related errors
- finalizing dependencies.id for migration 0053: %w
- dropping dependencies' existing primary key for migration 00
- adding issues.%s for migration 0053: %w
- adding wisp_dependencies.%s for migration 0053: %w
- creating %s: %w
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/831a0857149fc471.
Report an issue: GitHub.