Skip to content

Add 'UseMigrationLock' flag to acquire exclusive lock while performing migrations for Postgres#596

Open
zapling wants to merge 4 commits into
amacneil:mainfrom
zapling:main
Open

Add 'UseMigrationLock' flag to acquire exclusive lock while performing migrations for Postgres#596
zapling wants to merge 4 commits into
amacneil:mainfrom
zapling:main

Conversation

@zapling

@zapling zapling commented Nov 18, 2024

Copy link
Copy Markdown

First I'd like to thank you @amacneil for all the hard work, been using this project for years now and it's the best tool for the job.

We currently have a client who are performing migrations from within their code using the dbmate library to do so. They have multiple components that are run as their own deployment in kubernetes, meaning each component is peforming a migration. When we do new deployments of this application, each component starts up and migrations are attempted at the same time, resulting in race conditions.

We have resolved this by adding a new DriverMigrationLock interface, that the Postgres driver implements. It uses an postgres advisory lock within a database transaction to prevent any other dbmate instance from migrating while the lock is active.

The use of the exclusive lock is currently opt-in by using the UseMigrationLock field on the DB struct.

I feel like this might be something more people would like to have, who might be in a similar position.

I have added a few tests for the Postgres driver, I can add more, but unsure what exactly what we should test? It might be nice to have one test for the DB struct, if so, could you point me in the right direction?

@zapling zapling changed the title Add 'UseMigrationLock' flag to aquire exlusive lock while performing migrations for Postgres Add 'UseMigrationLock' flag to acquire exlusive lock while performing migrations for Postgres Nov 18, 2024
@zapling zapling changed the title Add 'UseMigrationLock' flag to acquire exlusive lock while performing migrations for Postgres Add 'UseMigrationLock' flag to acquire exclusive lock while performing migrations for Postgres Nov 19, 2024
@TheCoreMan

Copy link
Copy Markdown

This is exactly what I need right now. Would love to see this get merged @amacneil !

@zapling

zapling commented Mar 26, 2025

Copy link
Copy Markdown
Author

We have been using these changes in production for our client for a couple of months now, no issues so far 👌

@dossy

dossy commented Apr 5, 2025

Copy link
Copy Markdown
Collaborator

As an aside, my proposed support for an --init-sql feature in this thread could provide this same functionality by using --init-sql "select pg_advisory_xact_lock(48372615)".

Just another use case for --init-sql ...

@zapling

zapling commented Apr 5, 2025

Copy link
Copy Markdown
Author

As an aside, my proposed support for an --init-sql feature in this thread could provide this same functionality by using --init-sql "select pg_advisory_xact_lock(48372615)".

Just another use case for --init-sql ...

Usage of pg_advisory_xact_lock is only permitted within a database transaction. There is a session exclusive lock type in postgres that can be used, but then you can run into an issue of dangling locks if the process crashes or for some other reason is unable to release the lock.

This PR addresses this by opening a separate database connection that starts a transaction just to hold the lock, so when the transaction is comitted or rollbacked, the lock is automatically released.

@dossy

dossy commented Apr 5, 2025

Copy link
Copy Markdown
Collaborator

This PR addresses this by opening a separate database connection that starts a transaction just to hold the lock, so when the transaction is comitted or rollbacked, the lock is automatically released.

Ahh, that's clever. Maybe unnecessarily so? Why not use a session-level lock, instead?

[...] Locks can be taken at session level (so that they are held until released or the session ends) [...]

In other words, --init-sql "select pg_advisory_lock(48372615)"? (That is, if such an --init-sql existed: my proposal.)

@zapling

zapling commented Apr 5, 2025

Copy link
Copy Markdown
Author

This PR addresses this by opening a separate database connection that starts a transaction just to hold the lock, so when the transaction is comitted or rollbacked, the lock is automatically released.

Ahh, that's clever. Maybe unnecessarily so? Why not use a session-level lock, instead?

In other words, --init-sql "select pg_advisory_lock(48372615)"? (That is, if such an --init-sql existed: my proposal.)

Our use case is running this from within the application which runs inside a kubernetes pod, if the pod gets killed while the migration is running we get into a broken state where the lock would need to be manually deleted.

@dossy

dossy commented Apr 5, 2025

Copy link
Copy Markdown
Collaborator

Our use case is running this from within the application which runs inside a kubernetes pod, if the pod gets killed while the migration is running we get into a broken state where the lock would need to be manually deleted.

Session-level locks get released when the session ends, so even if the pod is killed, if the migration is still running and the session hasn't been killed with a pg_cancel_backend() or pg_terminate_backend(), then the session-level lock will continue to be held for the duration of the migration being applied, which seems better than having two separate database connections (sessions), one which is holding a transaction-level lock that will be released when the pod is killed, and the other session applying the migration?

In other words, it sounds to me like the session-level lock held in the session applying the actual long-running migrations ensures actual mutual exclusivity so that two executions of dbmate won't apply migrations concurrently, where the implementation in this PR could still result in a long-running migration being started twice, if the first one was terminated and the transaction lock held in a separate session was forcibly released.

@zapling

zapling commented Apr 5, 2025

Copy link
Copy Markdown
Author

Session-level locks get released when the session ends, so even if the pod is killed, if the migration is still running and the session hasn't been killed with a pg_cancel_backend() or pg_terminate_backend(), then the session-level lock will continue to be held for the duration of the migration being applied, which seems better than having two separate database connections (sessions), one which is holding a transaction-level lock that will be released when the pod is killed, and the other session applying the migration?

We did see some issues with a session level lock when testing this initially, hence the usage of the transaction level lock. I do not recall the exact case, could have been some other issue on our end, and maybe a session level lock is actually enough. I might have time to re-test this with a session level lock this coming week.

@psnehanshu

Copy link
Copy Markdown

fwiw, I am using the following function to implement locks until this PR is merged. I am using dbmate as a library

package pgutil

import (
	"context"
	"database/sql"
	"fmt"
)

// AcquireAdvisoryLock acquires a session-level advisory lock on the given key.
// It blocks until the lock is acquired, then returns a function to release the lock.
func AcquireAdvisoryLock(ctx context.Context, db *sql.DB, key int64) (func(), error) {
	// Get a dedicated connection from the pool
	conn, err := db.Conn(ctx)
	if err != nil {
		return nil, fmt.Errorf("failed to get dedicated connection: %w", err)
	}

	// Acquire the advisory lock
	if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock($1)`, key); err != nil {
		_ = conn.Close() // always clean up on failure
		return nil, fmt.Errorf("failed to acquire advisory lock: %w", err)
	}

	fmt.Println("Advisory lock acquired")

	// Unlock function
	unlock := func() {
		defer conn.Close() // releases the connection and any held locks
		if _, err := conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock($1)`, key); err != nil {
			fmt.Printf("Failed to release advisory lock: %v\n", err)
		} else {
			fmt.Println("Advisory lock released successfully")
		}
	}

	return unlock, nil
}

Example

// Attempt to acquire an advisory lock with key 42
unlock, err := AcquireAdvisoryLock(ctx, db, 42)
if err != nil {
	fmt.Printf("Error acquiring advisory lock: %v\n", err)
	return
}
defer unlock()

// Your critical section
fmt.Println("Migrating...")
if err := db.Migrate(); err != nil {
	fmt.Println("Failed to run migrations")
}
fmt.Println("Done.")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants