gastownhall/beads · warning · ErrMigrationLockUnavailable

schema: acquire migration lock: %w: returned NULL

Error message

schema: acquire migration lock: %w: returned NULL

What it means

GET_LOCK returned NULL instead of 0/1, which MySQL/Dolt uses to signal an error condition (e.g. the lock name was NULL/invalid or an out-of-memory/server error). The library wraps ErrMigrationLockUnavailable so callers treat it like any other transient lock-acquisition failure.

Source

Thrown at internal/storage/schema/lock.go:355

	).Scan(&ancestorCount); err != nil {
		return false
	}
	if ancestorCount != 1 {
		return false
	}

	return c.consumed.CompareAndSwap(false, true)
}

// AcquireMigrationLock acquires the named schema migration lock on the pinned
// connection's current Dolt/MySQL session.
func AcquireMigrationLock(ctx context.Context, conn *sql.Conn, lockName string) error {
	var locked sql.NullInt64
	if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, ?)", lockName, migrationLockAcquireTimeoutSeconds).Scan(&locked); err != nil {
		return fmt.Errorf("schema: acquire migration lock: %w: %w", ErrMigrationLockUnavailable, err)
	}
	if !locked.Valid {
		return fmt.Errorf("schema: acquire migration lock: %w: returned NULL", ErrMigrationLockUnavailable)
	}
	if locked.Int64 != 1 {
		return fmt.Errorf("schema: acquire migration lock: %w: timeout", ErrMigrationLockUnavailable)
	}
	return nil
}

// ReleaseMigrationLock releases the named schema migration lock from the same
// pinned Dolt/MySQL session used to acquire it.
func ReleaseMigrationLock(conn *sql.Conn, lockName string) error {
	cleanupCtx, cancel := context.WithTimeout(context.Background(), migrationLockCleanupTimeout)
	defer cancel()

	var released sql.NullInt64
	if err := conn.QueryRowContext(cleanupCtx, "SELECT RELEASE_LOCK(?)", lockName).Scan(&released); err != nil {
		discardConn(conn)
		return fmt.Errorf("schema: release migration lock: %w: %w", ErrMigrationLockRelease, err)
	}

View on GitHub (pinned to 71377f2769)

Solutions

  1. Use schema.MigrationLockName(databaseName) to build the lock name instead of a hand-rolled string.
  2. Verify databaseName passed to MigrateUpWithLock is non-empty.
  3. Check server logs for the underlying GET_LOCK error condition.
  4. Retry via IsMigrationLockError — the sentinel marks it retryable.

Example fix

// before
schema.AcquireMigrationLock(ctx, conn, db) // raw db name as lock name
// after
lockName := schema.MigrationLockName(db)
err := schema.AcquireMigrationLock(ctx, conn, lockName)
Defensive patterns

Strategy: validation

Validate before calling

if db == "" { return errors.New("database name required for migration lock") }
lockName := schema.MigrationLockName(db) // guarantees valid, ≤64-byte name
if len(lockName) == 0 || len(lockName) > 64 { return fmt.Errorf("bad lock name %q", lockName) }

Type guard

func isLockUnavailable(err error) bool { return errors.Is(err, schema.ErrMigrationLockUnavailable) }

Try / catch

if err := schema.AcquireMigrationLock(ctx, conn, lockName); err != nil {
    if schema.IsMigrationLockError(err) { /* retry with backoff or abort gracefully */ }
    return err
}

Prevention

When it happens

Trigger: AcquireMigrationLock called with an empty or invalid lockName, or a server-side error during GET_LOCK makes it return NULL. MigrationLockName normally guarantees a valid 64-char-max name, so NULL usually indicates a server problem or a hand-built name.

Common situations: Passing an empty database name into a custom lock name; non-ASCII/oversized lock names from custom callers; Dolt server-side anomalies reproducing MySQL NULL semantics.

Related errors


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