Skip to content

Commit

Permalink
Update HasPending logic to account for out-of-order errors (#762)
Browse files Browse the repository at this point in the history
  • Loading branch information
mfridman authored May 3, 2024
1 parent 7e6240d commit 8c8def4
Show file tree
Hide file tree
Showing 8 changed files with 429 additions and 199 deletions.
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/mfridman/interpolate v0.0.2
github.com/microsoft/go-mssqldb v1.7.0
github.com/sethvargo/go-retry v0.2.4
github.com/stretchr/testify v1.8.4
github.com/tursodatabase/libsql-client-go v0.0.0-20240411070317-a1138d155304
github.com/vertica/vertica-sql-go v1.3.3
github.com/ydb-platform/ydb-go-sdk/v3 v3.55.1
Expand All @@ -23,6 +24,7 @@ require (
github.com/ClickHouse/ch-go v0.58.2 // indirect
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elastic/go-sysinfo v1.11.2 // indirect
github.com/elastic/go-windows v1.0.1 // indirect
Expand All @@ -46,6 +48,7 @@ require (
github.com/paulmach/orb v0.10.0 // indirect
github.com/pierrec/lz4/v4 v4.1.18 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.0 // indirect
Expand Down
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tursodatabase/libsql-client-go v0.0.0-20240411070317-a1138d155304 h1:Y6cw8yjWCEJDy5Bll7HjTinkgTQU55AXiKSEe29SpgA=
github.com/tursodatabase/libsql-client-go v0.0.0-20240411070317-a1138d155304/go.mod h1:2Fu26tjM011BLeR5+jwTfs6DX/fNMEWV/3CBZvggrA4=
Expand Down
124 changes: 124 additions & 0 deletions internal/gooseutil/resolve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Package gooseutil provides utility functions we want to keep internal to the package. It's
// intended to be a collection of well-tested helper functions.
package gooseutil

import (
"fmt"
"math"
"sort"
"strconv"
"strings"
)

// UpVersions returns a list of migrations to apply based on the versions in the filesystem and the
// versions in the database. The target version can be used to specify a target version. In most
// cases this will be math.MaxInt64.
//
// The allowMissing flag can be used to allow missing migrations as part of the list of migrations
// to apply. Otherwise, an error will be returned if there are missing migrations in the database.
func UpVersions(
fsysVersions []int64,
dbVersions []int64,
target int64,
allowMissing bool,
) ([]int64, error) {
// Sort the list of versions in the filesystem. This should already be sorted, but we do this
// just in case.
sortAscending(fsysVersions)

// dbAppliedVersions is a map of all applied migrations in the database.
dbAppliedVersions := make(map[int64]bool, len(dbVersions))
var dbMaxVersion int64
for _, v := range dbVersions {
dbAppliedVersions[v] = true
if v > dbMaxVersion {
dbMaxVersion = v
}
}

// Get a list of migrations that are missing from the database. A missing migration is one that
// has a version less than the max version in the database and has not been applied.
//
// In most cases the target version is math.MaxInt64, but it can be used to specify a target
// version. In which case we respect the target version and only surface migrations up to and
// including that target.
var missing []int64
for _, v := range fsysVersions {
if dbAppliedVersions[v] {
continue
}
if v < dbMaxVersion && v <= target {
missing = append(missing, v)
}
}

// feat(mf): It is very possible someone may want to apply ONLY new migrations and skip missing
// migrations entirely. At the moment this is not supported, but leaving this comment because
// that's where that logic would be handled.
//
// For example, if database has 1,4 already applied and 2,3,5 are new, we would apply only 5 and
// skip 2,3. Not sure if this is a common use case, but it's possible someone may want to do
// this.
if len(missing) > 0 && !allowMissing {
return nil, newMissingError(missing, dbMaxVersion, target)
}

var out []int64

// 1. Add missing migrations to the list of migrations to apply, if any.
out = append(out, missing...)

// 2. Add new migrations to the list of migrations to apply, if any.
for _, v := range fsysVersions {
if dbAppliedVersions[v] {
continue
}
if v > dbMaxVersion && v <= target {
out = append(out, v)
}
}
// 3. Sort the list of migrations to apply.
sortAscending(out)

return out, nil
}

func newMissingError(
missing []int64,
dbMaxVersion int64,
target int64,
) error {
sortAscending(missing)

collected := make([]string, 0, len(missing))
for _, v := range missing {
collected = append(collected, strconv.FormatInt(v, 10))
}

msg := "migration"
if len(collected) > 1 {
msg += "s"
}

var versionsMsg string
if len(collected) > 1 {
versionsMsg = "versions " + strings.Join(collected, ",")
} else {
versionsMsg = "version " + collected[0]
}

desiredMsg := fmt.Sprintf("database version (%d)", dbMaxVersion)
if target != math.MaxInt64 {
desiredMsg += fmt.Sprintf(", with target version (%d)", target)
}

return fmt.Errorf("detected %d missing (out-of-order) %s lower than %s: %s",
len(missing), msg, desiredMsg, versionsMsg,
)
}

func sortAscending(versions []int64) {
sort.Slice(versions, func(i, j int) bool {
return versions[i] < versions[j]
})
}
204 changes: 204 additions & 0 deletions internal/gooseutil/resolve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package gooseutil

import (
"math"
"testing"

"github.com/stretchr/testify/require"
)

func TestResolveVersions(t *testing.T) {
t.Run("not_allow_missing", func(t *testing.T) {
// Nothing to apply nil
got, err := UpVersions(nil, nil, math.MaxInt64, false)
require.NoError(t, err)
require.Equal(t, 0, len(got))
// Nothing to apply empty
got, err = UpVersions([]int64{}, []int64{}, math.MaxInt64, false)
require.NoError(t, err)
require.Equal(t, 0, len(got))

// Nothing new
got, err = UpVersions([]int64{1, 2, 3}, []int64{1, 2, 3}, math.MaxInt64, false)
require.NoError(t, err)
require.Equal(t, 0, len(got))

// All new
got, err = UpVersions([]int64{1, 2, 3}, []int64{}, math.MaxInt64, false)
require.NoError(t, err)
require.Equal(t, 3, len(got))
require.Equal(t, int64(1), got[0])
require.Equal(t, int64(2), got[1])
require.Equal(t, int64(3), got[2])

// Squashed, no new
got, err = UpVersions([]int64{3}, []int64{3}, math.MaxInt64, false)
require.NoError(t, err)
require.Equal(t, 0, len(got))
// Squashed, 1 new
got, err = UpVersions([]int64{3, 4}, []int64{3}, math.MaxInt64, false)
require.NoError(t, err)
require.Equal(t, 1, len(got))
require.Equal(t, int64(4), got[0])

// Some new with target
got, err = UpVersions([]int64{1, 2, 3, 4, 5}, []int64{1, 2}, 4, false)
require.NoError(t, err)
require.Equal(t, 2, len(got))
require.Equal(t, int64(3), got[0])
require.Equal(t, int64(4), got[1]) // up to and including target
// Some new with zero target
got, err = UpVersions([]int64{1, 2, 3, 4, 5}, []int64{1, 2}, 0, false)
require.NoError(t, err)
require.Equal(t, 0, len(got))

// Error: one missing migrations with max target
_, err = UpVersions([]int64{1, 2, 3, 4}, []int64{1 /* 2*/, 3}, math.MaxInt64, false)
require.Error(t, err)
require.Equal(t,
"detected 1 missing (out-of-order) migration lower than database version (3): version 2",
err.Error(),
)

// Error: multiple missing migrations with max target
_, err = UpVersions([]int64{1, 2, 3, 4, 5}, []int64{ /* 1 */ 2 /* 3 */, 4, 5}, math.MaxInt64, false)
require.Error(t, err)
require.Equal(t,
"detected 2 missing (out-of-order) migrations lower than database version (5): versions 1,3",
err.Error(),
)

t.Run("target_lower_than_max", func(t *testing.T) {

// These tests are a bit of an edge case but an important one worth documenting. There
// can be missing migrations above and/or below the target version which itself can be
// lower than the max db version. For example, migrations 1,2,3,4 in the filesystem, and
// migrations 1,2,4 applied to the database and the user requested target 2. Technically
// there are no missing migrations based on the target version since 1,2 have been
// applied, but there is 1 missing migration (3) based on the max db version. Should
// this return an error, or report no pending migrations?
//
// We've taken the stance that this SHOULD respect the target version and surface an
// error if there are missing migrations below the target version. This is because the
// user has explicitly requested a target version and we should respect that.

got, err = UpVersions([]int64{1, 2, 3}, []int64{1 /* 2 */, 3}, 1, false)
require.NoError(t, err)
require.Equal(t, 0, len(got))
got, err = UpVersions([]int64{1, 2, 3}, []int64{1 /* 2 */, 3}, 2, false)
require.Error(t, err)
require.Equal(t,
"detected 1 missing (out-of-order) migration lower than database version (3), with target version (2): version 2",
err.Error(),
)
got, err = UpVersions([]int64{1, 2, 3}, []int64{1 /* 2 */, 3}, 3, false)
require.Error(t, err)
require.Equal(t,
"detected 1 missing (out-of-order) migration lower than database version (3), with target version (3): version 2",
err.Error(),
)

_, err = UpVersions([]int64{1, 2, 3, 4, 5, 6}, []int64{1 /* 2 */, 3, 4 /* 5*/, 6}, 4, false)
require.Error(t, err)
require.Equal(t,
"detected 1 missing (out-of-order) migration lower than database version (6), with target version (4): version 2",
err.Error(),
)
_, err = UpVersions([]int64{1, 2, 3, 4, 5, 6}, []int64{1 /* 2 */, 3, 4 /* 5*/, 6}, 6, false)
require.Error(t, err)
require.Equal(t,
"detected 2 missing (out-of-order) migrations lower than database version (6), with target version (6): versions 2,5",
err.Error(),
)
})
})

t.Run("allow_missing", func(t *testing.T) {
// Nothing to apply nil
got, err := UpVersions(nil, nil, math.MaxInt64, true)
require.NoError(t, err)
require.Equal(t, 0, len(got))
// Nothing to apply empty
got, err = UpVersions([]int64{}, []int64{}, math.MaxInt64, true)
require.NoError(t, err)
require.Equal(t, 0, len(got))

// Nothing new
got, err = UpVersions([]int64{1, 2, 3}, []int64{1, 2, 3}, math.MaxInt64, true)
require.NoError(t, err)
require.Equal(t, 0, len(got))

// All new
got, err = UpVersions([]int64{1, 2, 3}, []int64{}, math.MaxInt64, true)
require.NoError(t, err)
require.Equal(t, 3, len(got))
require.Equal(t, int64(1), got[0])
require.Equal(t, int64(2), got[1])
require.Equal(t, int64(3), got[2])

// Squashed, no new
got, err = UpVersions([]int64{3}, []int64{3}, math.MaxInt64, true)
require.NoError(t, err)
require.Equal(t, 0, len(got))
// Squashed, 1 new
got, err = UpVersions([]int64{3, 4}, []int64{3}, math.MaxInt64, true)
require.NoError(t, err)
require.Equal(t, 1, len(got))
require.Equal(t, int64(4), got[0])

// Some new with target
got, err = UpVersions([]int64{1, 2, 3, 4, 5}, []int64{1, 2}, 4, true)
require.NoError(t, err)
require.Equal(t, 2, len(got))
require.Equal(t, int64(3), got[0])
require.Equal(t, int64(4), got[1]) // up to and including target
// Some new with zero target
got, err = UpVersions([]int64{1, 2, 3, 4, 5}, []int64{1, 2}, 0, true)
require.NoError(t, err)
require.Equal(t, 0, len(got))

// No error: one missing
got, err = UpVersions([]int64{1, 2, 3}, []int64{1 /* 2*/, 3}, math.MaxInt64, true)
require.NoError(t, err)
require.Equal(t, 1, len(got))
require.Equal(t, int64(2), got[0]) // missing

// No error: multiple missing and new with max target
got, err = UpVersions([]int64{1, 2, 3, 4, 5}, []int64{ /* 1 */ 2 /* 3 */, 4}, math.MaxInt64, true)
require.NoError(t, err)
require.Equal(t, 3, len(got))
require.Equal(t, int64(1), got[0]) // missing
require.Equal(t, int64(3), got[1]) // missing
require.Equal(t, int64(5), got[2])

t.Run("target_lower_than_max", func(t *testing.T) {
got, err = UpVersions([]int64{1, 2, 3}, []int64{1 /* 2 */, 3}, 1, true)
require.NoError(t, err)
require.Equal(t, 0, len(got))
got, err = UpVersions([]int64{1, 2, 3}, []int64{1 /* 2 */, 3}, 2, true)
require.NoError(t, err)
require.Equal(t, 1, len(got))
require.Equal(t, int64(2), got[0]) // missing
got, err = UpVersions([]int64{1, 2, 3}, []int64{1 /* 2 */, 3}, 3, true)
require.NoError(t, err)
require.Equal(t, 1, len(got))
require.Equal(t, int64(2), got[0]) // missing

got, err = UpVersions([]int64{1, 2, 3, 4, 5, 6}, []int64{1 /* 2 */, 3, 4 /* 5*/, 6}, 4, true)
require.NoError(t, err)
require.Equal(t, 1, len(got))
require.Equal(t, int64(2), got[0]) // missing
got, err = UpVersions([]int64{1, 2, 3, 4, 5, 6}, []int64{1 /* 2 */, 3, 4 /* 5*/, 6}, 6, true)
require.NoError(t, err)
require.Equal(t, 2, len(got))
require.Equal(t, int64(2), got[0]) // missing
require.Equal(t, int64(5), got[1]) // missing
})
})

t.Run("sort_ascending", func(t *testing.T) {
got := []int64{5, 3, 4, 2, 1}
sortAscending(got)
require.Equal(t, []int64{1, 2, 3, 4, 5}, got)
})
}
Loading

0 comments on commit 8c8def4

Please sign in to comment.