Adding a goose provider
Introduction
In this post, we'll explore the new Provider
feature recently added to the core goose package. If
you're new to goose, it's a tool for handling database migrations, available as a standalone CLI
tool and a package that can be used in Go applications.
Requires version v3.16.0 and above.
Adding a provider to your application is easy, here's a quick example:
provider, err := goose.NewProvider(
goose.DialectPostgres, // (1)!
db, // (2)!
os.DirFS("migrations"), // (3)!
)
results, err := provider.Up(ctx) // (4)!
-
The first argument is the dialect. It is the type of database technology you're using. In this case, we're using Postgres. But goose also supports:
clickhouse, mssql, mysql, postgres, redshift, sqlite3, tidb, vertica, ydb,
-
The second argument is the database connection. You can use any database driver you want, as long as it implements the
database/sql
interface.A popular choice for Postgres is pgx/v5
-
The last argument may be
nil
. Why? Because goose also supports the ability to register Go functions as migrations.However, in most cases, you'll be using SQL migrations and reading them from disk. In this case, you'll use os.DirFS or embed them into your binary and use embed.FS.
-
The last step is to invoke one of the migration methods. In this case, we're running the
Up
method, which will run all the migrations that haven't been run yet. Here's a list of all the methods:(p *Provider) ApplyVersion (p *Provider) Close (p *Provider) Down (p *Provider) DownTo (p *Provider) GetDBVersion (p *Provider) ListSources (p *Provider) Ping (p *Provider) Status (p *Provider) Up (p *Provider) UpByOne (p *Provider) UpTo
All functionality is scoped to the Provider
instance. This means that you can create multiple of
them, each with their own configuration.
Here's some options you can pass to the NewProvider
method:
Option | Description |
---|---|
WithGoMigrations | Register Go functions as migrations directly within the provider |
WithSessionLocker | Lock database to prevent concurrent migrations |
WithStore | Bring your own store implementation |
WithExclude | Exclude migrations by name or version |
WithAllowOutofOrder | Allow migrations to be run out of order |
WithDisableVersioning | Disable versioning, useful for testing and seeding data |
... and more!
Backwards compatibility
Although we're adding a new feature, we're not removing any existing functionality in the /v3
package and the Provider
is fully backwards compatible. This means that you can continue to use
goose as you always have and migrate at your own pace. Do note, however, that the Provider
will be
the recommended way to use goose and we'll be focusing our efforts on it going forward.
For all the limitations mentioned below, the goose package was (and still is) a great tool and we're
grateful for all the contributions and feedback from the community. We hope that the Provider
will
compliment the existing functionality and make goose even better.
Motivation
The motivation behind the Provider
was simple - to reduce global state and make goose easier to
consume as an imported package.
Here's a quick summary:
- Avoid global state
- Make
Provider
safe to use concurrently - Unlock (no pun intended) new features, such as database locking
- Make logging configurable
- Better error handling with proper return values
- Double down on Go migrations
Global state
Some of the functionality mentioned above was not possible, or if it was, would lead to an awkward API. The reason is because goose used global state to store the configuration. This meant that you could only have one configuration at a time, which was fine for the CLI, but not ideal as a package.
As an aside, if you're interested in the topic, Peter Bourgon had a nice post about global state
tl;dr: magic is bad; global state is magic → no package level vars; no func init
As time went on, goose became more popular and people started using it in more complex ways. For example, they wanted to run parallel tests or run migrations against multiple databases. This made the package unsafe to use, because global state would be overwritten by concurrent calls.
By using the Provider
, we can scope all functionality to the instance and make it safe to use
concurrently. This means that you can create multiple providers, each with their own configuration.
Database locking
Another limitation is all commands, such as goose.Up
and goose.Down
, would take *sql.DB
as the
database connection. This made it challenging to implement more advanced features, such as database
locking.
We'll save the details for another post, but the gist is that for most databases you need to use a
*sql.Conn
to lock the database, and, most importantly, use the same connection to unlock it.
Some users got clever and worked around this limitation by using a wrapper that handled locking or even more exotic solutions, such as setting the max number of connections to 1.
But this was not ideal.
With the Provider
, you can now pass in a SessionLocker
option which can be used to lock the
database. The only implementation supported right now is for Postgres, but we plan to add support
for other databases as requested. Here's a quick example:
sessionLocker, err := lock.NewPostgresSessionLocker(
// Timeout after 30min. Try every 15s up to 120 times.
lock.WithLockTimeout(15, 120),
)
provider, err := goose.NewProvider(
goose.DialectPostgres,
db,
os.DirFS("migrations"),
goose.WithSessionLocker(sessionLocker), // Use session-level advisory lock.
)
Kudos to @roblaszczak for the idea to support a custom locker interface.
If you've been following Add locking mechanism to prevent parallel execution issue, there's a bit to unpack. We'll save the details for another post.
Logging
There was too much logging from within the package. This made it difficult to integrate goose into existing applications, because it would pollute the logs. We still want to have some logging, but we want to make it configurable.
Error handling and return values
This is a big one. There was no way to get the results of a migration because all success and failure states were logged from within the package.
With the provider, all methods return a well-defined type, which includes the results of the
migration and any errors that occurred. For example, here's the Up
method:
func (p *Provider) Up(ctx context.Context) ([]*MigrationResult, error) {
Notice that it returns a slice of *MigrationResult
and an error.
By having a well-defined return value, we can also improve the CLI output and control the formatting
of the results (such as adding JSON support), as opposed to logging them directly in the goose
package through the Logger
.
Lastly, we can handle errors in a more graceful way. Due to the nature of migrations, it's possible that a migration may fail part way through, and we want to know exactly what state we're in. What was the last migration that was run? What was the error? What was the migration that failed?
This is now possible because all provider methods return a PartialError
.
Go migrations
Previously, all Go migrations had to be registered globally. This meant that you could only have one set of Go migrations per application.
Now, you can register Go migrations directly with the provider, which means you can have multiple providers, each with their own set of Go migrations. Here's a quick example:
register := []*goose.Migration{
goose.NewGoMigration(
1,
&goose.GoFunc{RunTx: newTxFn("CREATE TABLE users (id INTEGER)")},
&goose.GoFunc{RunTx: newTxFn("DROP TABLE users")},
),
}
provider, err := goose.NewProvider(goose.DialectSQLite3, db, nil,
goose.WithGoMigrations(register...),
)
The goose provider will automatically register Go migrations and return any conflicts that may occur. This means that you can mix and match SQL and Go migrations, as long as they don't conflict with each other by having the same version.
Conclusion
The Provider
is a solid foundation that we can build upon and add new features. We're excited to
see how people will use it and what new ideas they'll come up with
If you have any questions or feedback, feel free to reach out on Twitter @_mfridman or file an issue on pressly/goose.
ps. It's also an example of how to use functional options in Go, despite their controversial nature. I personally like them, but I can see why some people don't. Here's a great talk by @jub0bs on the topic:
Acknowledgments
This feature would not have been possible without all the contributions from the community. A special thanks to everyone who opened an issue, submitted a PR, or helped with the design.
Special thanks to @oliverpool for pitching ideas and working
through the design around the fs.FS
interface. I'm quite happy with how it turned out and I think
it's a great example of how to use the new fs.FS
interface.