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

  1. GRANT ALTER on the schema to the application DB user, or run the upgrade once with a privileged account.
  2. Ensure only one migrator runs: acquire an application-level lock (e.g. GET_LOCK or a lock table) around MigrateUp.
  3. Re-run MigrateUp — the flow is idempotent; if the table vanished, bootstrapSQL recreates it including content_hash.
  4. 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

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


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/831a0857149fc471. Report an issue: GitHub.