components-contrib/common/component/sql/migrations/postgres/postgres_migrations.go

136 lines
5.3 KiB
Go

/*
Copyright 2021 The Dapr Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package pgmigrations
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5/pgconn"
pginterfaces "github.com/dapr/components-contrib/common/component/postgresql/interfaces"
commonsql "github.com/dapr/components-contrib/common/component/sql"
"github.com/dapr/kit/logger"
)
// Migrations performs migrations for the database schema
type Migrations struct {
DB pginterfaces.PGXPoolConn
Logger logger.Logger
MetadataTableName string
MetadataKey string
}
// Perform the required migrations
func (m Migrations) Perform(ctx context.Context, migrationFns []commonsql.MigrationFn) error {
// Ensure the metadata table exists
// This query uses an "IF NOT EXISTS" so it's safe to be created concurrently
err := m.EnsureMetadataTable(ctx)
if err != nil {
return fmt.Errorf("failed to ensure metadata table exists: %w", err)
}
// Normally, the way to acquire an exclusive lock in PostgreSQL (commonly used for migrations by other frameworks too) is to use advisory locks
// However, advisory locks aren't supported in all PostgreSQL-compatible databases, for example CockroachDB
// So, we're going to write a row in there (not using a transaction, as that causes a table-level lock to be created), ignoring duplicates
const lockKey = "lock"
m.Logger.Debugf("Ensuring lock row '%s' exists in metadata table", lockKey)
queryCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
_, err = m.DB.Exec(queryCtx, fmt.Sprintf("INSERT INTO %s (key, value) VALUES ($1, 'lock') ON CONFLICT (key) DO NOTHING", m.MetadataTableName), lockKey)
cancel()
if err != nil {
return fmt.Errorf("failed to ensure lock row '%s' exists: %w", lockKey, err)
}
// Now, let's use a transaction on a row in the metadata table as a lock
m.Logger.Debug("Starting transaction pre-migration")
queryCtx, cancel = context.WithTimeout(ctx, 15*time.Second)
tx, err := m.DB.Begin(queryCtx)
cancel()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
// Always rollback the transaction at the end to release the lock, since the value doesn't really matter
defer func() {
m.Logger.Debug("Releasing migration lock")
queryCtx, cancel = context.WithTimeout(ctx, 15*time.Second)
rollbackErr := tx.Rollback(queryCtx)
cancel()
if rollbackErr != nil {
// Panicking here, as this forcibly closes the session and thus ensures we are not leaving locks hanging around
m.Logger.Fatalf("Failed to roll back transaction: %v", rollbackErr)
}
}()
// Now, perform a SELECT with FOR UPDATE to lock the row used for locking, and only that row
// We use a long timeout here as this query may block
m.Logger.Debug("Acquiring migration lock")
queryCtx, cancel = context.WithTimeout(ctx, time.Minute)
var lock string
err = tx.QueryRow(queryCtx, fmt.Sprintf("SELECT value FROM %s WHERE key = $1 FOR UPDATE", m.MetadataTableName), lockKey).Scan(&lock)
cancel()
if err != nil {
return fmt.Errorf("failed to acquire migration lock (row-level lock on key '%s'): %w", lockKey, err)
}
m.Logger.Debug("Migration lock acquired")
return commonsql.Migrate(ctx, commonsql.AdaptPgxConn(m.DB), commonsql.MigrationOptions{
Logger: m.Logger,
// Yes, we are using fmt.Sprintf for adding a value in a query.
// This comes from a constant hardcoded at development-time, and cannot be influenced by users. So, no risk of SQL injections here.
GetVersionQuery: fmt.Sprintf(`SELECT value FROM %s WHERE key = '%s'`, m.MetadataTableName, m.MetadataKey),
UpdateVersionQuery: func(version string) (string, any) {
return fmt.Sprintf(`INSERT INTO %s (key, value) VALUES ('%s', $1) ON CONFLICT (key) DO UPDATE SET value = $1`, m.MetadataTableName, m.MetadataKey),
version
},
Migrations: migrationFns,
})
}
func (m Migrations) EnsureMetadataTable(ctx context.Context) (err error) {
m.Logger.Infof("Creating metadata table '%s'", m.MetadataTableName)
// Add an "IF NOT EXISTS" in case another Dapr sidecar is creating the same table at the same time
// In the next step we'll acquire a lock so there won't be issues with concurrency
// Note that this query can fail with error `23505` on constraint `pg_type_typname_nsp_index` if ran in parallel; we will just retry that up to 3 times
for range 3 {
_, err = m.DB.Exec(ctx, fmt.Sprintf(
`CREATE TABLE IF NOT EXISTS %s (
key text NOT NULL PRIMARY KEY,
value text NOT NULL
)`,
m.MetadataTableName,
))
if err == nil {
break
}
// If the error is not a UniqueViolation (23505), abort
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != pgerrcode.UniqueViolation {
return fmt.Errorf("failed to create metadata table: %w", err)
}
// Retry after a delay
time.Sleep(50 * time.Millisecond)
}
if err != nil {
return fmt.Errorf("failed to create metadata table: %w", err)
}
return nil
}