github.com/safing/portbase@v0.19.5/database/migration/migration.go (about)

     1  package migration
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"sort"
     8  	"sync"
     9  	"time"
    10  
    11  	"github.com/hashicorp/go-version"
    12  
    13  	"github.com/safing/portbase/database"
    14  	"github.com/safing/portbase/database/record"
    15  	"github.com/safing/portbase/formats/dsd"
    16  	"github.com/safing/portbase/log"
    17  )
    18  
    19  // MigrateFunc is called when a migration should be applied to the
    20  // database. It receives the current version (from) and the target
    21  // version (to) of the database and a dedicated interface for
    22  // interacting with data stored in the DB.
    23  // A dedicated log.ContextTracer is added to ctx for each migration
    24  // run.
    25  type MigrateFunc func(ctx context.Context, from, to *version.Version, dbInterface *database.Interface) error
    26  
    27  // Migration represents a registered data-migration that should be applied to
    28  // some database. Migrations are stacked on top and executed in order of increasing
    29  // version number (see Version field).
    30  type Migration struct {
    31  	// Description provides a short human-readable description of the
    32  	// migration.
    33  	Description string
    34  	// Version should hold the version of the database/subsystem after
    35  	// the migration has been applied.
    36  	Version string
    37  	// MigrateFuc is executed when the migration should be performed.
    38  	MigrateFunc MigrateFunc
    39  }
    40  
    41  // Registry holds a migration stack.
    42  type Registry struct {
    43  	key string
    44  
    45  	lock       sync.Mutex
    46  	migrations []Migration
    47  }
    48  
    49  // New creates a new migration registry.
    50  // The key should be the name of the database key that is used to store
    51  // the version of the last successfully applied migration.
    52  func New(key string) *Registry {
    53  	return &Registry{
    54  		key: key,
    55  	}
    56  }
    57  
    58  // Add adds one or more migrations to reg.
    59  func (reg *Registry) Add(migrations ...Migration) error {
    60  	reg.lock.Lock()
    61  	defer reg.lock.Unlock()
    62  	for _, m := range migrations {
    63  		if _, err := version.NewSemver(m.Version); err != nil {
    64  			return fmt.Errorf("migration %q: invalid version %s: %w", m.Description, m.Version, err)
    65  		}
    66  		reg.migrations = append(reg.migrations, m)
    67  	}
    68  	return nil
    69  }
    70  
    71  // Migrate migrates the database by executing all registered
    72  // migration in order of increasing version numbers. The error
    73  // returned, if not nil, is always of type *Diagnostics.
    74  func (reg *Registry) Migrate(ctx context.Context) (err error) {
    75  	reg.lock.Lock()
    76  	defer reg.lock.Unlock()
    77  
    78  	start := time.Now()
    79  	log.Infof("migration: migration of %s started", reg.key)
    80  	defer func() {
    81  		if err != nil {
    82  			log.Errorf("migration: migration of %s failed after %s: %s", reg.key, time.Since(start), err)
    83  		} else {
    84  			log.Infof("migration: migration of %s finished after %s", reg.key, time.Since(start))
    85  		}
    86  	}()
    87  
    88  	db := database.NewInterface(&database.Options{
    89  		Local:    true,
    90  		Internal: true,
    91  	})
    92  
    93  	startOfMigration, err := reg.getLatestSuccessfulMigration(db)
    94  	if err != nil {
    95  		return err
    96  	}
    97  
    98  	execPlan, diag, err := reg.getExecutionPlan(startOfMigration)
    99  	if err != nil {
   100  		return err
   101  	}
   102  	if len(execPlan) == 0 {
   103  		return nil
   104  	}
   105  	diag.TargetVersion = execPlan[len(execPlan)-1].Version
   106  
   107  	// finally, apply our migrations
   108  	lastAppliedMigration := startOfMigration
   109  	for _, m := range execPlan {
   110  		target, _ := version.NewSemver(m.Version) // we can safely ignore the error here
   111  
   112  		migrationCtx, tracer := log.AddTracer(ctx)
   113  
   114  		if err := m.MigrateFunc(migrationCtx, lastAppliedMigration, target, db); err != nil {
   115  			diag.Wrapped = err
   116  			diag.FailedMigration = m.Description
   117  			tracer.Errorf("migration: migration for %s failed: %s - %s", reg.key, target.String(), m.Description)
   118  			tracer.Submit()
   119  			return diag
   120  		}
   121  
   122  		lastAppliedMigration = target
   123  		diag.LastSuccessfulMigration = lastAppliedMigration.String()
   124  
   125  		if err := reg.saveLastSuccessfulMigration(db, target); err != nil {
   126  			diag.Message = "failed to persist migration status"
   127  			diag.Wrapped = err
   128  			diag.FailedMigration = m.Description
   129  		}
   130  		tracer.Infof("migration: applied migration for %s: %s - %s", reg.key, target.String(), m.Description)
   131  		tracer.Submit()
   132  	}
   133  
   134  	// all migrations have been applied successfully, we're done here
   135  	return nil
   136  }
   137  
   138  func (reg *Registry) getLatestSuccessfulMigration(db *database.Interface) (*version.Version, error) {
   139  	// find the latest version stored in the database
   140  	rec, err := db.Get(reg.key)
   141  	if errors.Is(err, database.ErrNotFound) {
   142  		return nil, nil
   143  	}
   144  	if err != nil {
   145  		return nil, &Diagnostics{
   146  			Message: "failed to query database for migration status",
   147  			Wrapped: err,
   148  		}
   149  	}
   150  
   151  	// Unwrap the record to get the actual database
   152  	r, ok := rec.(*record.Wrapper)
   153  	if !ok {
   154  		return nil, &Diagnostics{
   155  			Wrapped: errors.New("expected wrapped database record"),
   156  		}
   157  	}
   158  
   159  	sv, err := version.NewSemver(string(r.Data))
   160  	if err != nil {
   161  		return nil, &Diagnostics{
   162  			Message: "failed to parse version stored in migration status record",
   163  			Wrapped: err,
   164  		}
   165  	}
   166  	return sv, nil
   167  }
   168  
   169  func (reg *Registry) saveLastSuccessfulMigration(db *database.Interface, ver *version.Version) error {
   170  	r := &record.Wrapper{
   171  		Data:   []byte(ver.String()),
   172  		Format: dsd.RAW,
   173  	}
   174  	r.SetKey(reg.key)
   175  
   176  	return db.Put(r)
   177  }
   178  
   179  func (reg *Registry) getExecutionPlan(startOfMigration *version.Version) ([]Migration, *Diagnostics, error) {
   180  	// create a look-up map for migrations indexed by their semver created a
   181  	// list of version (sorted by increasing number) that we use as our execution
   182  	// plan.
   183  	lm := make(map[string]Migration)
   184  	versions := make(version.Collection, 0, len(reg.migrations))
   185  	for _, m := range reg.migrations {
   186  		ver, err := version.NewSemver(m.Version)
   187  		if err != nil {
   188  			return nil, nil, &Diagnostics{
   189  				Message:         "failed to parse version of migration",
   190  				Wrapped:         err,
   191  				FailedMigration: m.Description,
   192  			}
   193  		}
   194  		lm[ver.String()] = m // use .String() for a normalized string representation
   195  		versions = append(versions, ver)
   196  	}
   197  	sort.Sort(versions)
   198  
   199  	diag := new(Diagnostics)
   200  	if startOfMigration != nil {
   201  		diag.StartOfMigration = startOfMigration.String()
   202  	}
   203  
   204  	// prepare our diagnostics and the execution plan
   205  	execPlan := make([]Migration, 0, len(versions))
   206  	for _, ver := range versions {
   207  		// skip an migration that has already been applied.
   208  		if startOfMigration != nil && startOfMigration.GreaterThanOrEqual(ver) {
   209  			continue
   210  		}
   211  		m := lm[ver.String()]
   212  		diag.ExecutionPlan = append(diag.ExecutionPlan, DiagnosticStep{
   213  			Description: m.Description,
   214  			Version:     ver.String(),
   215  		})
   216  		execPlan = append(execPlan, m)
   217  	}
   218  
   219  	return execPlan, diag, nil
   220  }