{"record":{"id":"aced43467c198934","repo":"gastownhall/beads","slug":"schema-acquire-migration-lock-w-timeout","errorCode":null,"errorMessage":"schema: acquire migration lock: %w: timeout","messagePattern":"schema: acquire migration lock: %w: timeout","errorType":"exception","errorClass":"ErrMigrationLockUnavailable","httpStatus":null,"severity":"error","filePath":"internal/storage/schema/lock.go","lineNumber":358,"sourceCode":"\tif ancestorCount != 1 {\n\t\treturn false\n\t}\n\n\treturn c.consumed.CompareAndSwap(false, true)\n}\n\n// AcquireMigrationLock acquires the named schema migration lock on the pinned\n// connection's current Dolt/MySQL session.\nfunc AcquireMigrationLock(ctx context.Context, conn *sql.Conn, lockName string) error {\n\tvar locked sql.NullInt64\n\tif err := conn.QueryRowContext(ctx, \"SELECT GET_LOCK(?, ?)\", lockName, migrationLockAcquireTimeoutSeconds).Scan(&locked); err != nil {\n\t\treturn fmt.Errorf(\"schema: acquire migration lock: %w: %w\", ErrMigrationLockUnavailable, err)\n\t}\n\tif !locked.Valid {\n\t\treturn fmt.Errorf(\"schema: acquire migration lock: %w: returned NULL\", ErrMigrationLockUnavailable)\n\t}\n\tif locked.Int64 != 1 {\n\t\treturn fmt.Errorf(\"schema: acquire migration lock: %w: timeout\", ErrMigrationLockUnavailable)\n\t}\n\treturn nil\n}\n\n// ReleaseMigrationLock releases the named schema migration lock from the same\n// pinned Dolt/MySQL session used to acquire it.\nfunc ReleaseMigrationLock(conn *sql.Conn, lockName string) error {\n\tcleanupCtx, cancel := context.WithTimeout(context.Background(), migrationLockCleanupTimeout)\n\tdefer cancel()\n\n\tvar released sql.NullInt64\n\tif err := conn.QueryRowContext(cleanupCtx, \"SELECT RELEASE_LOCK(?)\", lockName).Scan(&released); err != nil {\n\t\tdiscardConn(conn)\n\t\treturn fmt.Errorf(\"schema: release migration lock: %w: %w\", ErrMigrationLockRelease, err)\n\t}\n\tif !released.Valid {\n\t\tdiscardConn(conn)\n\t\treturn fmt.Errorf(\"schema: release migration lock: %w: returned NULL\", ErrMigrationLockRelease)","sourceCodeStart":340,"sourceCodeEnd":376,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/schema/lock.go#L340-L376","documentation":"AcquireMigrationLock wraps MySQL/Dolt GET_LOCK: the call returned 0, meaning the named session lock was not acquired within migrationLockAcquireTimeoutSeconds because another session holds it. The library throws this to prevent concurrent schema migrations against the same database. The sentinel ErrMigrationLockUnavailable is wrapped so callers can test with errors.Is.","triggerScenarios":"Calling AcquireMigrationLock (typically via MigrateUpWithLock) when another process/session already holds GET_LOCK on the same lockName, and the wait timeout expires; also when GET_LOCK itself errors (wrapped err) or returns NULL (e.g. on replicas or unsupported backends).","commonSituations":"Two bd processes (or an agent and a human) running migrations concurrently; a crashed process whose session still holds the lock; a long-running migration blocking a second one; a stale connection left holding the lock from a prior failed run.","solutions":["Identify the holder: check for other bd/migration processes on the same database and wait for or stop them.","Reconnect: the lock is session-scoped — killing or recycling the holding connection releases it (on Dolt, restart the server or close the stale session).","Retry the migration after the current holder finishes; the timeout is bounded by migrationLockAcquireTimeoutSeconds.","Verify only one migration runner is configured (e.g. avoid running bd doctor/migrate in parallel cron jobs)."],"exampleFix":"// before: retry immediately and fail\nif err := schema.AcquireMigrationLock(ctx, conn, lockName); err != nil {\n    return err\n}\n// after: detect contention and back off/retry\nif err := schema.AcquireMigrationLock(ctx, conn, lockName); err != nil {\n    if errors.Is(err, schema.ErrMigrationLockUnavailable) {\n        time.Sleep(retryDelay)\n        return retryAcquire(ctx, conn, lockName)\n    }\n    return err\n}","handlingStrategy":"retry","validationCode":"// Check no other holder before migrating\nvar held sql.NullInt64\n_ = conn.QueryRowContext(ctx, \"SELECT IS_FREE_LOCK(?)\", lockName).Scan(&held)\nif held.Valid && held.Int64 != 1 { /* lock busy: defer or wait */ }","typeGuard":null,"tryCatchPattern":"if err := schema.AcquireMigrationLock(ctx, conn, name); err != nil {\n    if errors.Is(err, schema.ErrMigrationLockUnavailable) {\n        // back off and retry; another migration is running\n    }\n    return err\n}","preventionTips":["Run migrations from a single process/leader (use an advisory or scheduler lock).","Never run bd migrate concurrently in cron/CI jobs against the same database.","Retry with backoff on ErrMigrationLockUnavailable instead of failing immediately.","Monitor for stale sessions holding GET_LOCK after crashes."],"tags":["database","locking","migration","dolt"],"backgroundTag":"migration-lock-contention","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}