diff --git a/.github/workflows/pr-workflow.yaml b/.github/workflows/pr-workflow.yaml index 2285c0f0f3..3a307e2eb1 100644 --- a/.github/workflows/pr-workflow.yaml +++ b/.github/workflows/pr-workflow.yaml @@ -32,10 +32,15 @@ jobs: steps: - name: Checkout uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 0 - name: Setup Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: 'go.mod' + - name: Verify immutable PostgreSQL migrations + if: github.event_name == 'pull_request' + run: hack/verify/postgresql-migrations.sh - run: go test -race -v ./... # tools/apitool has its own module so we need to run it explicitly. - name: apitool tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b76edcff8..c51f08ed70 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,6 +86,8 @@ for this purpose. All code changes should be accompanied by tests. We will not merge code that does not have tests, and we will not merge code that causes tests to fail. +Follow the [PostgreSQL schema evolution rules](docs/dev/postgresql-schema-evolution.md) for each application schema change. + ### Root-gated tests Tests that need root (overlay mounts, mknod, `trusted.*` xattrs, ...) call diff --git a/_LICENSES/github.com/mfridman/interpolate/LICENSE.txt b/_LICENSES/github.com/mfridman/interpolate/LICENSE.txt new file mode 100644 index 0000000000..765b73b6c8 --- /dev/null +++ b/_LICENSES/github.com/mfridman/interpolate/LICENSE.txt @@ -0,0 +1,24 @@ +MIT License + +Copyright (c) 2014-2017 Buildkite Pty Ltd +Copyright (c) 2023 Michael Fridman + + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/_LICENSES/github.com/pressly/goose/v3/LICENSE b/_LICENSES/github.com/pressly/goose/v3/LICENSE new file mode 100644 index 0000000000..fd425e0d39 --- /dev/null +++ b/_LICENSES/github.com/pressly/goose/v3/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Original work Copyright (c) 2012 Liam Staskawicz +Modified work Copyright (c) 2016 Vojtech Vitek +Modified work Copyright (c) 2021 Michael Fridman, Vojtech Vitek + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/_LICENSES/github.com/sethvargo/go-retry/LICENSE b/_LICENSES/github.com/sethvargo/go-retry/LICENSE new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/_LICENSES/github.com/sethvargo/go-retry/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/_LICENSES/go.uber.org/multierr/LICENSE.txt b/_LICENSES/go.uber.org/multierr/LICENSE.txt new file mode 100644 index 0000000000..413e30f7ce --- /dev/null +++ b/_LICENSES/go.uber.org/multierr/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2017-2021 Uber Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/cmd/ateapi/internal/store/atepg/atepg.go b/cmd/ateapi/internal/store/atepg/atepg.go index ca06bb43e5..8ffe88667b 100644 --- a/cmd/ateapi/internal/store/atepg/atepg.go +++ b/cmd/ateapi/internal/store/atepg/atepg.go @@ -62,29 +62,36 @@ type Persistence struct { var _ store.Interface = (*Persistence)(nil) -// Connect opens a pgxpool against dsn, verifies connectivity, and applies the -// embedded schema. Startup fails if the database cannot be reached. A second, -// two-connection watch pool (owned by the Persistence, closed by Close) isolates -// outbox polling and maintenance from write traffic. -func Connect(ctx context.Context, dsn string) (*Persistence, error) { +// ErrUnavailable reports that ateapi could not establish the initial +// PostgreSQL connection. Callers can retry this error before startup. +var ErrUnavailable = errors.New("PostgreSQL is unavailable") + +// Connect opens a pgxpool against dsn, creates schema if necessary, and +// applies pending schema migrations. A dedicated watch pool isolates outbox +// polling and maintenance from writes. +func Connect(ctx context.Context, dsn, schema string) (*Persistence, error) { + if schema == "" { + return nil, fmt.Errorf("PostgreSQL schema must not be empty") + } cfg, err := poolConfig(dsn) if err != nil { return nil, err } + cfg.ConnConfig.RuntimeParams["search_path"] = pgx.Identifier{schema}.Sanitize() pool, err := pgxpool.NewWithConfig(ctx, cfg) if err != nil { return nil, fmt.Errorf("opening PostgreSQL pool: %w", err) } if err := pool.Ping(ctx); err != nil { pool.Close() - return nil, fmt.Errorf("pinging PostgreSQL: %w", err) + return nil, fmt.Errorf("%w: pinging PostgreSQL: %w", ErrUnavailable, err) } - - watchCfg, err := poolConfig(dsn) - if err != nil { + if err := createSchema(ctx, pool, schema); err != nil { pool.Close() - return nil, fmt.Errorf("watch pool: %w", err) + return nil, err } + + watchCfg := cfg.Copy() watchCfg.MaxConns = watchPoolMaxConns watchCfg.MinConns = watchPoolMinConns watchPool, err := pgxpool.NewWithConfig(ctx, watchCfg) @@ -103,6 +110,25 @@ func Connect(ctx context.Context, dsn string) (*Persistence, error) { return p, nil } +func createSchema(ctx context.Context, pool *pgxpool.Pool, schema string) error { + tx, err := pool.Begin(ctx) + if err != nil { + return fmt.Errorf("starting PostgreSQL schema transaction: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck // Commit or the returned error decides the outcome. + + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, "agent-substrate:create-schema:"+schema); err != nil { + return fmt.Errorf("locking PostgreSQL schema %q: %w", schema, err) + } + if _, err := tx.Exec(ctx, `CREATE SCHEMA IF NOT EXISTS `+pgx.Identifier{schema}.Sanitize()); err != nil { + return fmt.Errorf("creating PostgreSQL schema %q: %w", schema, err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("committing PostgreSQL schema %q: %w", schema, err) + } + return nil +} + // poolConfig parses dsn into a pool configuration whose TLS material is read // from disk again for every new connection. // @@ -137,7 +163,7 @@ func poolConfig(dsn string) (*pgxpool.Config, error) { return cfg, nil } -// NewPersistence wraps an already-open pool, applying the idempotent schema. +// NewPersistence wraps an already-open pool, applying pending migrations. // Callers that already hold a pool (e.g. tests using testcontainers) use // this directly instead of Connect; outbox watch traffic shares the given pool. func NewPersistence(ctx context.Context, pool *pgxpool.Pool) (*Persistence, error) { @@ -145,7 +171,7 @@ func NewPersistence(ctx context.Context, pool *pgxpool.Pool) (*Persistence, erro } func newPersistence(ctx context.Context, pool, watchPool *pgxpool.Pool) (*Persistence, error) { - if err := applySchema(ctx, pool); err != nil { + if err := applyMigrations(ctx, pool); err != nil { return nil, err } maintenanceCtx, stopMaintenance := context.WithCancel(context.Background()) diff --git a/cmd/ateapi/internal/store/atepg/atepg_test.go b/cmd/ateapi/internal/store/atepg/atepg_test.go index 46db1826ff..bd9705f36e 100644 --- a/cmd/ateapi/internal/store/atepg/atepg_test.go +++ b/cmd/ateapi/internal/store/atepg/atepg_test.go @@ -19,12 +19,15 @@ import ( "errors" "fmt" "os" + "strings" "sync" "testing" + "testing/fstest" "time" "github.com/google/go-cmp/cmp" "github.com/jackc/pgx/v5/pgxpool" + "github.com/pressly/goose/v3" "github.com/testcontainers/testcontainers-go/modules/postgres" "google.golang.org/protobuf/testing/protocmp" @@ -114,6 +117,336 @@ func requirePool(t *testing.T) *pgxpool.Pool { return containerPool } +func TestMigrationsConcurrentStartup(t *testing.T) { + pool := requirePool(t) + ctx := t.Context() + if _, err := pool.Exec(ctx, `DROP SCHEMA IF EXISTS "concurrent-startup" CASCADE`); err != nil { + t.Fatalf("resetting PostgreSQL schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DROP SCHEMA IF EXISTS "concurrent-startup" CASCADE`) + }) + + errs := make(chan error, 2) + for range 2 { + go func() { + p, err := Connect(ctx, containerDSN, "concurrent-startup") + if p != nil { + p.Close() + p.pool.Close() + } + errs <- err + }() + } + for range 2 { + if err := <-errs; err != nil { + t.Fatalf("Connect failed: %v", err) + } + } + + var applied int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM "concurrent-startup".schema_migrations WHERE version_id > 0 AND is_applied`).Scan(&applied); err != nil { + t.Fatalf("reading applied migrations: %v", err) + } + if applied != 1 { + t.Fatalf("applied migration rows = %d, want 1", applied) + } +} + +func TestMigrationsWaitForInProgressMigration(t *testing.T) { + pool := requirePool(t) + ctx := t.Context() + const schema = "migration-lock-wait" + if _, err := pool.Exec(ctx, `DROP SCHEMA IF EXISTS "migration-lock-wait" CASCADE`); err != nil { + t.Fatalf("resetting schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DROP SCHEMA IF EXISTS "migration-lock-wait" CASCADE`) + }) + + if _, err := pool.Exec(ctx, `CREATE SCHEMA "migration-lock-wait"`); err != nil { + t.Fatalf("creating migration schema: %v", err) + } + migrationPool, err := pgxpool.New(ctx, containerDSN+"&search_path="+schema) + if err != nil { + t.Fatalf("opening migration pool: %v", err) + } + t.Cleanup(migrationPool.Close) + lockID, err := migrationLockID(ctx, migrationPool) + if err != nil { + t.Fatalf("generating migration lock ID: %v", err) + } + lockConn, err := pool.Acquire(ctx) + if err != nil { + t.Fatalf("acquiring migration lock connection: %v", err) + } + locked := true + t.Cleanup(func() { + if locked { + _, _ = lockConn.Exec(context.Background(), `SELECT pg_advisory_unlock($1)`, lockID) + } + lockConn.Release() + }) + if _, err := lockConn.Exec(ctx, `SELECT pg_advisory_lock($1)`, lockID); err != nil { + t.Fatalf("locking migrations: %v", err) + } + + result := make(chan error, 1) + go func() { result <- applyMigrations(ctx, migrationPool) }() + select { + case err := <-result: + t.Fatalf("migration returned while its advisory lock was held: %v", err) + case <-time.After(200 * time.Millisecond): + } + if _, err := lockConn.Exec(ctx, `SELECT pg_advisory_unlock($1)`, lockID); err != nil { + t.Fatalf("unlocking migrations: %v", err) + } + locked = false + select { + case err := <-result: + if err != nil { + t.Fatalf("migration failed after the advisory lock was released: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("migration did not finish after the advisory lock was released") + } +} + +func TestConnectUsesConfiguredSchema(t *testing.T) { + pool := requirePool(t) + ctx := t.Context() + const schema = "substrate-test" + if _, err := pool.Exec(ctx, ` + DROP SCHEMA IF EXISTS "substrate-test" CASCADE; + DROP SCHEMA IF EXISTS "substrate-other-test" CASCADE; + CREATE SCHEMA "substrate-other-test"; + CREATE TABLE "substrate-other-test".worker_outbox ( + created_at timestamptz NOT NULL + ) PARTITION BY RANGE (created_at); + CREATE TABLE "substrate-other-test".worker_outbox_p200001010000 + PARTITION OF "substrate-other-test".worker_outbox + FOR VALUES FROM ('2000-01-01 00:00:00+00') TO ('2000-01-01 00:05:00+00'); + CREATE TABLE IF NOT EXISTS public.substrate_schema_test_marker (id integer)`); err != nil { + t.Fatalf("preparing schema test: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), ` + DROP SCHEMA IF EXISTS "substrate-test" CASCADE; + DROP SCHEMA IF EXISTS "substrate-other-test" CASCADE; + DROP TABLE IF EXISTS public.substrate_schema_test_marker`) + }) + + dsn, err := containerPG.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("getting PostgreSQL connection string: %v", err) + } + persistence, err := Connect(ctx, dsn+"&search_path=public", schema) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer persistence.pool.Close() + defer persistence.Close() + + if _, err := persistence.CreateAtespace(ctx, newTestAtespace("schema-test")); err != nil { + t.Fatalf("creating atespace in configured schema: %v", err) + } + + for _, table := range []string{"atespaces", "schema_migrations"} { + var exists bool + if err := persistence.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = $1 AND table_name = $2 + )`, schema, table).Scan(&exists); err != nil { + t.Fatalf("checking %s.%s: %v", schema, table, err) + } + if !exists { + t.Errorf("expected %s.%s to exist", schema, table) + } + } + + var markerExists bool + if err := pool.QueryRow(ctx, `SELECT to_regclass('public.substrate_schema_test_marker') IS NOT NULL`).Scan(&markerExists); err != nil { + t.Fatalf("checking unrelated table: %v", err) + } + if !markerExists { + t.Error("migration removed an unrelated table") + } + + if err := persistence.dropExpiredWorkerOutboxPartitions(ctx, persistence.watchPool, time.Now()); err != nil { + t.Fatalf("dropping expired partitions in the configured schema: %v", err) + } + var unrelatedPartitionExists bool + if err := pool.QueryRow(ctx, `SELECT to_regclass('"substrate-other-test".worker_outbox_p200001010000') IS NOT NULL`).Scan(&unrelatedPartitionExists); err != nil { + t.Fatalf("checking unrelated outbox partition: %v", err) + } + if !unrelatedPartitionExists { + t.Error("outbox maintenance removed a partition from another schema") + } +} + +func TestMigrationSchemaStates(t *testing.T) { + pool := requirePool(t) + ctx := t.Context() + + t.Run("ahead", func(t *testing.T) { + if _, err := pool.Exec(ctx, `DROP SCHEMA IF EXISTS "migration-ahead" CASCADE`); err != nil { + t.Fatalf("resetting schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DROP SCHEMA IF EXISTS "migration-ahead" CASCADE`) + }) + + p, err := Connect(ctx, containerDSN, "migration-ahead") + if err != nil { + t.Fatalf("creating current schema: %v", err) + } + p.Close() + p.pool.Close() + if _, err := pool.Exec(ctx, `INSERT INTO "migration-ahead".schema_migrations (version_id, is_applied) VALUES (2, true)`); err != nil { + t.Fatalf("setting ahead migration state: %v", err) + } + + p, err = Connect(ctx, containerDSN, "migration-ahead") + if err != nil { + t.Fatalf("Connect with an ahead clean schema failed: %v", err) + } + p.Close() + p.pool.Close() + }) + + t.Run("tables without metadata", func(t *testing.T) { + if _, err := pool.Exec(ctx, ` + DROP SCHEMA IF EXISTS "migration-legacy" CASCADE; + CREATE SCHEMA "migration-legacy"; + CREATE TABLE "migration-legacy".atespaces (id integer)`); err != nil { + t.Fatalf("creating legacy schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DROP SCHEMA IF EXISTS "migration-legacy" CASCADE`) + }) + + _, err := Connect(ctx, containerDSN, "migration-legacy") + if err == nil || !strings.Contains(err.Error(), "Substrate tables exist without a migration ledger") { + t.Fatalf("Connect error = %v, want unsupported schema error", err) + } + var metadataExists bool + if err := pool.QueryRow(ctx, `SELECT to_regclass('"migration-legacy".schema_migrations') IS NOT NULL`).Scan(&metadataExists); err != nil { + t.Fatalf("checking migration metadata: %v", err) + } + if metadataExists { + t.Error("Connect created a migration ledger for an unsupported schema") + } + }) +} + +func TestMigrationFailureLeavesCompletedPrefixAndResumes(t *testing.T) { + pool := requirePool(t) + ctx := t.Context() + const schema = "migration-resume" + if _, err := pool.Exec(ctx, `DROP SCHEMA IF EXISTS "migration-resume" CASCADE; CREATE SCHEMA "migration-resume"`); err != nil { + t.Fatalf("resetting schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DROP SCHEMA IF EXISTS "migration-resume" CASCADE`) + }) + + migrationPool, err := pgxpool.New(ctx, containerDSN+"&search_path="+schema) + if err != nil { + t.Fatalf("opening migration pool: %v", err) + } + t.Cleanup(migrationPool.Close) + files := fstest.MapFS{ + "000001_create.sql": {Data: []byte("-- +goose Up\nCREATE TABLE resume_test (id integer PRIMARY KEY);")}, + "000002_add_value.sql": {Data: []byte("-- +goose Up\n" + + "ALTER TABLE resume_test ADD COLUMN value text;")}, + "000003_fail.sql": {Data: []byte("-- +goose Up\n" + + "CREATE TABLE failed_transaction (id integer);\n" + + "ALTER TABLE missing_table ADD COLUMN value text;")}, + } + provider, err := openMigrationProvider(ctx, migrationPool, files) + if err != nil { + t.Fatalf("creating migration provider: %v", err) + } + if _, err := provider.UpTo(ctx, 1); err != nil { + t.Fatalf("applying pre-run migration: %v", err) + } + migrationErr := migrateToLatest(ctx, provider) + if migrationErr == nil { + t.Fatal("migration succeeded, want version 3 failure") + } + var partial *goose.PartialError + if !errors.As(migrationErr, &partial) { + t.Fatalf("migration error = %v, want goose.PartialError", migrationErr) + } + if got := partial.Failed.Source.Version; got != 3 { + t.Fatalf("failed migration version = %d, want 3", got) + } + if len(partial.Applied) != 1 || partial.Applied[0].Source.Version != 2 { + t.Fatalf("migrations completed before failure = %#v, want version 2", partial.Applied) + } + if err := provider.Close(); err != nil { + t.Fatalf("closing failed migration provider: %v", err) + } + + var valueColumnExists bool + if err := migrationPool.QueryRow(ctx, `SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = $1 AND table_name = 'resume_test' AND column_name = 'value' + )`, schema).Scan(&valueColumnExists); err != nil { + t.Fatalf("checking completed migration: %v", err) + } + if !valueColumnExists { + t.Error("migration 2 was not left applied") + } + var failedSQLApplied bool + if err := migrationPool.QueryRow(ctx, `SELECT to_regclass('failed_transaction') IS NOT NULL`).Scan(&failedSQLApplied); err != nil { + t.Fatalf("checking failed migration transaction: %v", err) + } + if failedSQLApplied { + t.Error("SQL from failed migration 3 was committed") + } + if diff := cmp.Diff([]int64{1, 2}, appliedMigrationVersions(t, migrationPool)); diff != "" { + t.Fatalf("applied migration versions after failure (-want +got):\n%s", diff) + } + + files["000003_fail.sql"] = &fstest.MapFile{Data: []byte("-- +goose Up\nCREATE TABLE resumed_migration (id integer);")} + resumedProvider, err := openMigrationProvider(ctx, migrationPool, files) + if err != nil { + t.Fatalf("creating resumed migration provider: %v", err) + } + t.Cleanup(func() { + if err := resumedProvider.Close(); err != nil { + t.Errorf("closing resumed migration provider: %v", err) + } + }) + if err := migrateToLatest(ctx, resumedProvider); err != nil { + t.Fatalf("resuming migrations: %v", err) + } + if diff := cmp.Diff([]int64{1, 2, 3}, appliedMigrationVersions(t, migrationPool)); diff != "" { + t.Fatalf("applied migration versions after resume (-want +got):\n%s", diff) + } + var resumed bool + if err := migrationPool.QueryRow(ctx, `SELECT to_regclass('resumed_migration') IS NOT NULL`).Scan(&resumed); err != nil { + t.Fatalf("checking resumed migration: %v", err) + } + if !resumed { + t.Error("migration 3 was not applied after restart") + } +} + +func appliedMigrationVersions(t *testing.T, pool *pgxpool.Pool) []int64 { + t.Helper() + var versions []int64 + if err := pool.QueryRow(t.Context(), ` + SELECT COALESCE(array_agg(version_id ORDER BY version_id), '{}'::bigint[]) + FROM schema_migrations + WHERE version_id > 0 AND is_applied`).Scan(&versions); err != nil { + t.Fatalf("reading applied migration versions: %v", err) + } + return versions +} + // clearAll truncates every table so the next test starts from an empty store // without paying for a fresh database. Nothing in production mass-deletes // state, so the statement lives here rather than on Persistence. diff --git a/cmd/ateapi/internal/store/atepg/migrations/000001_initial.sql b/cmd/ateapi/internal/store/atepg/migrations/000001_initial.sql new file mode 100644 index 0000000000..a5b485550f --- /dev/null +++ b/cmd/ateapi/internal/store/atepg/migrations/000001_initial.sql @@ -0,0 +1,129 @@ +-- Copyright 2026 Google LLC +-- +-- 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. + +-- +goose Up + +CREATE TABLE atespaces ( + name text PRIMARY KEY, + uid text NOT NULL, + version bigint NOT NULL, + proto bytea NOT NULL +); + +CREATE TABLE actors ( + atespace text NOT NULL + REFERENCES atespaces(name) ON DELETE RESTRICT, + name text NOT NULL, + uid text NOT NULL, + version bigint NOT NULL, + proto bytea NOT NULL, + PRIMARY KEY (atespace, name) +); + +CREATE TABLE actor_egress_policies ( + atespace text NOT NULL, + actor_name text NOT NULL, + uid text NOT NULL, + version bigint NOT NULL, + proto bytea NOT NULL, + PRIMARY KEY (atespace, actor_name), + FOREIGN KEY (atespace, actor_name) + REFERENCES actors(atespace, name) ON DELETE CASCADE +); + +CREATE TABLE actor_templates ( + atespace text NOT NULL + REFERENCES atespaces(name) ON DELETE RESTRICT, + name text NOT NULL, + uid text NOT NULL, + version bigint NOT NULL, + proto bytea NOT NULL, + PRIMARY KEY (atespace, name) +); + +CREATE TABLE actor_snapshots ( + atespace text NOT NULL, + name text NOT NULL, + uid text NOT NULL, + version bigint NOT NULL, + proto bytea NOT NULL, + PRIMARY KEY (atespace, name) +); + +CREATE TABLE actor_snapshot_tags ( + atespace text NOT NULL, + name text NOT NULL, + snapshot_atespace text NOT NULL, + snapshot_name text NOT NULL, + uid text NOT NULL, + version bigint NOT NULL, + proto bytea NOT NULL, + PRIMARY KEY (atespace, name), + CONSTRAINT actor_snapshot_tags_atespace_fk + FOREIGN KEY (atespace) REFERENCES atespaces(name) ON DELETE RESTRICT, + CONSTRAINT actor_snapshot_tags_snapshot_fk + FOREIGN KEY (snapshot_atespace, snapshot_name) + REFERENCES actor_snapshots(atespace, name) ON DELETE RESTRICT +); + +CREATE INDEX actor_snapshot_tags_snapshot_idx + ON actor_snapshot_tags (snapshot_atespace, snapshot_name); + +-- Workers are global-scoped and named by their Kubernetes pod UID, so name +-- alone is the primary key. +CREATE TABLE workers ( + name text PRIMARY KEY, + uid text NOT NULL UNIQUE, + version bigint NOT NULL, + proto bytea NOT NULL +); + +-- Transactional outbox backing WatchWorkers. +-- +-- 1. Ordering (xid): writeAndAppendEvent guarantees exactly one row per tx, +-- ensuring distinct xids so polling batches never split a transaction. +-- 2. Retention (created_at partitions): outboxMaintenance drops expired +-- partitions to avoid VACUUM I/O debt. A DEFAULT partition catches overflow. +-- 3. Durability (UNLOGGED): Skips WAL overhead. Crash recoveries trigger +-- watchers to rebuild from the primary workers table. worker_outbox_trim +-- remains LOGGED to preserve the high-water mark across restarts. +CREATE TABLE worker_outbox ( + xid xid8 NOT NULL DEFAULT pg_current_xact_id(), + -- MUST use clock_timestamp() instead of now(). now() freezes at tx start, + -- causing slow transactions to route into expired partitions. + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + payload bytea NOT NULL +) PARTITION BY RANGE (created_at); + +CREATE INDEX worker_outbox_xid ON worker_outbox (xid); + +CREATE UNLOGGED TABLE worker_outbox_default PARTITION OF worker_outbox DEFAULT + WITH (autovacuum_enabled = off); + +-- Single-row high-water mark of retention: the greatest xid ever discarded +-- from worker_outbox (dropped with an expired partition, or truncated +-- with the DEFAULT partition). Watchers compare it against their cursor to +-- detect exactly that unconsumed rows were discarded out from under them. +CREATE TABLE worker_outbox_trim ( + id boolean PRIMARY KEY DEFAULT true CHECK (id), + xid xid8 NOT NULL +); + +CREATE TABLE leases ( + key text PRIMARY KEY, + token text NOT NULL, + expires_at timestamptz NOT NULL +); + +CREATE INDEX leases_expires_at_idx ON leases (expires_at); diff --git a/cmd/ateapi/internal/store/atepg/migrations_test.go b/cmd/ateapi/internal/store/atepg/migrations_test.go new file mode 100644 index 0000000000..7b24e83b7a --- /dev/null +++ b/cmd/ateapi/internal/store/atepg/migrations_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 Google LLC +// +// 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 atepg + +import ( + "io/fs" + "regexp" + "strings" + "testing" +) + +var transactionControl = regexp.MustCompile(`(?im)^\s*(BEGIN|START\s+TRANSACTION|COMMIT|ROLLBACK)\s*;`) + +func TestMigrationPolicy(t *testing.T) { + err := fs.WalkDir(migrationFiles, "migrations", func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + if !strings.HasSuffix(path, ".sql") { + return nil + } + data, err := fs.ReadFile(migrationFiles, path) + if err != nil { + return err + } + sql := string(data) + upperSQL := strings.ToUpper(sql) + if strings.Count(sql, "-- +goose Up") != 1 { + t.Errorf("%s must contain exactly one Goose Up annotation", path) + } + if strings.Contains(upperSQL, "-- +GOOSE DOWN") { + t.Errorf("%s must not contain a Goose Down migration", path) + } + if strings.Contains(upperSQL, "-- +GOOSE NO TRANSACTION") { + t.Errorf("%s must run in a PostgreSQL transaction", path) + } + if strings.Contains(upperSQL, "IF NOT EXISTS") { + t.Errorf("%s must not contain an IF NOT EXISTS guard", path) + } + if strings.Contains(upperSQL, "-- +GOOSE ENVSUB") { + t.Errorf("%s must not use Goose environment substitution", path) + } + if transactionControl.MatchString(sql) { + t.Errorf("%s must let Goose control the PostgreSQL transaction", path) + } + return nil + }) + if err != nil { + t.Fatalf("check PostgreSQL migrations: %v", err) + } +} diff --git a/cmd/ateapi/internal/store/atepg/outbox.go b/cmd/ateapi/internal/store/atepg/outbox.go index aa9b981272..535b007623 100644 --- a/cmd/ateapi/internal/store/atepg/outbox.go +++ b/cmd/ateapi/internal/store/atepg/outbox.go @@ -351,10 +351,11 @@ func (p *Persistence) tryCreateWorkerOutboxPartitions(ctx context.Context, trunc // entire range is older than retention. func (p *Persistence) dropExpiredWorkerOutboxPartitions(ctx context.Context, q querier, now time.Time) error { rows, err := q.Query(ctx, ` - SELECT c.relname FROM pg_inherits i - JOIN pg_class c ON c.oid = i.inhrelid + SELECT child.relname FROM pg_inherits i + JOIN pg_class child ON child.oid = i.inhrelid JOIN pg_class parent ON parent.oid = i.inhparent - WHERE parent.relname = 'worker_outbox'`) + WHERE i.inhparent = 'worker_outbox'::regclass + AND child.relnamespace = parent.relnamespace`) if err != nil { return fmt.Errorf("listing outbox partitions: %w", err) } diff --git a/cmd/ateapi/internal/store/atepg/outbox_test.go b/cmd/ateapi/internal/store/atepg/outbox_test.go index 083f1468e9..24a84d8896 100644 --- a/cmd/ateapi/internal/store/atepg/outbox_test.go +++ b/cmd/ateapi/internal/store/atepg/outbox_test.go @@ -42,7 +42,7 @@ func TestConnect_DedicatedWatchPool(t *testing.T) { requirePool(t) // ensures the container is up and containerDSN is set ctx := context.Background() - p, err := Connect(ctx, containerDSN) + p, err := Connect(ctx, containerDSN, "public") if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -850,7 +850,7 @@ func TestWatchWorkers_ClosesAfterPersistentPollFailure(t *testing.T) { // Connect so the watcher has its own pool: killing it simulates a // persistent outage without touching the shared container pool. - p, err := Connect(ctx, containerDSN) + p, err := Connect(ctx, containerDSN, "public") if err != nil { t.Fatalf("Connect failed: %v", err) } diff --git a/cmd/ateapi/internal/store/atepg/schema.go b/cmd/ateapi/internal/store/atepg/schema.go index 5c301b1cdf..0ca7ac80a9 100644 --- a/cmd/ateapi/internal/store/atepg/schema.go +++ b/cmd/ateapi/internal/store/atepg/schema.go @@ -16,161 +16,163 @@ package atepg import ( "context" + "embed" + "errors" "fmt" + "io/fs" + "log/slog" + "time" "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + "github.com/pressly/goose/v3/lock" ) -// schema is atepg's idempotent embedded schema. -// -// Resource fields are projected into columns only when PostgreSQL needs them -// for identity, relationships, queries, ordering, or atomic concurrency -// checks. All other resource state remains authoritative in the opaque proto. -const schema = ` -CREATE TABLE IF NOT EXISTS atespaces ( - name text PRIMARY KEY, - uid text NOT NULL, - version bigint NOT NULL, - proto bytea NOT NULL -); - -CREATE TABLE IF NOT EXISTS actors ( - atespace text NOT NULL - REFERENCES atespaces(name) ON DELETE RESTRICT, - name text NOT NULL, - uid text NOT NULL, - version bigint NOT NULL, - proto bytea NOT NULL, - PRIMARY KEY (atespace, name) -); - -CREATE TABLE IF NOT EXISTS actor_egress_policies ( - atespace text NOT NULL, - actor_name text NOT NULL, - uid text NOT NULL, - version bigint NOT NULL, - proto bytea NOT NULL, - PRIMARY KEY (atespace, actor_name), - FOREIGN KEY (atespace, actor_name) - REFERENCES actors(atespace, name) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS actor_templates ( - atespace text NOT NULL - REFERENCES atespaces(name) ON DELETE RESTRICT, - name text NOT NULL, - uid text NOT NULL, - version bigint NOT NULL, - proto bytea NOT NULL, - PRIMARY KEY (atespace, name) -); - -CREATE TABLE IF NOT EXISTS actor_snapshots ( - atespace text NOT NULL, - name text NOT NULL, - uid text NOT NULL, - version bigint NOT NULL, - proto bytea NOT NULL, - PRIMARY KEY (atespace, name) -); - -CREATE TABLE IF NOT EXISTS actor_snapshot_tags ( - atespace text NOT NULL, - name text NOT NULL, - snapshot_atespace text NOT NULL, - snapshot_name text NOT NULL, - uid text NOT NULL, - version bigint NOT NULL, - proto bytea NOT NULL, - PRIMARY KEY (atespace, name), - CONSTRAINT actor_snapshot_tags_atespace_fk - FOREIGN KEY (atespace) REFERENCES atespaces(name) ON DELETE RESTRICT, - CONSTRAINT actor_snapshot_tags_snapshot_fk - FOREIGN KEY (snapshot_atespace, snapshot_name) - REFERENCES actor_snapshots(atespace, name) ON DELETE RESTRICT -); - -CREATE INDEX IF NOT EXISTS actor_snapshot_tags_snapshot_idx - ON actor_snapshot_tags (snapshot_atespace, snapshot_name); - --- Workers are global-scoped and named by their Kubernetes pod UID, so name --- alone is the primary key. -CREATE TABLE IF NOT EXISTS workers ( - name text PRIMARY KEY, - uid text NOT NULL UNIQUE, - version bigint NOT NULL, - proto bytea NOT NULL -); - --- Transactional outbox backing WatchWorkers. --- --- 1. Ordering (xid): writeAndAppendEvent guarantees exactly one row per tx, --- ensuring distinct xids so polling batches never split a transaction. --- 2. Retention (created_at partitions): outboxMaintenance drops expired --- partitions to avoid VACUUM I/O debt. A DEFAULT partition catches overflow. --- 3. Durability (UNLOGGED): Skips WAL overhead. Crash recoveries trigger --- watchers to rebuild from the primary workers table. worker_outbox_trim --- remains LOGGED to preserve the high-water mark across restarts. -CREATE TABLE IF NOT EXISTS worker_outbox ( - xid xid8 NOT NULL DEFAULT pg_current_xact_id(), - -- MUST use clock_timestamp() instead of now(). now() freezes at tx start, - -- causing slow transactions to route into expired partitions. - created_at timestamptz NOT NULL DEFAULT clock_timestamp(), - payload bytea NOT NULL -) PARTITION BY RANGE (created_at); - -CREATE INDEX IF NOT EXISTS worker_outbox_xid ON worker_outbox (xid); - -CREATE UNLOGGED TABLE IF NOT EXISTS worker_outbox_default PARTITION OF worker_outbox DEFAULT WITH (autovacuum_enabled = off); - --- Single-row high-water mark of retention: the greatest xid ever discarded --- from worker_outbox (dropped with an expired partition, or truncated --- with the DEFAULT partition). Watchers compare it against their cursor to --- detect exactly that unconsumed rows were discarded out from under them. -CREATE TABLE IF NOT EXISTS worker_outbox_trim ( - id boolean PRIMARY KEY DEFAULT true CHECK (id), - xid xid8 NOT NULL -); - -CREATE TABLE IF NOT EXISTS leases ( - key text PRIMARY KEY, - token text NOT NULL, - expires_at timestamptz NOT NULL -); +const ( + migrationTableName = "schema_migrations" + migrationLockName = "agent-substrate:atepg:migrations" +) -CREATE INDEX IF NOT EXISTS leases_expires_at_idx ON leases (expires_at); -` +//go:embed migrations/*.sql +var migrationFiles embed.FS -// applySchema idempotently creates atepg's tables. -func applySchema(ctx context.Context, pool *pgxpool.Pool) error { - tx, err := pool.Begin(ctx) - if err != nil { - return fmt.Errorf("beginning atepg schema transaction: %w", err) +func applyMigrations(ctx context.Context, pool *pgxpool.Pool) error { + if err := ctx.Err(); err != nil { + return err } - defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed // The schema needs PostgreSQL 13+ (xid8, pg_current_xact_id, - // pg_current_snapshot); fail with a clear message rather than an + // pg_current_snapshot). Report a clear error before PostgreSQL reports an // opaque DDL or function error. var version int - if err := tx.QueryRow(ctx, `SELECT current_setting('server_version_num')::int`).Scan(&version); err != nil { - return fmt.Errorf("reading PostgreSQL version: %w", err) + if err := pool.QueryRow(ctx, `SELECT current_setting('server_version_num')::int`).Scan(&version); err != nil { + return fmt.Errorf("get PostgreSQL version: %w", err) } if version < 130000 { - return fmt.Errorf("atepg requires PostgreSQL 13 or newer (xid8 and pg_current_snapshot); server_version_num is %d", version) + return fmt.Errorf("atepg requires PostgreSQL 13 or newer for xid8 and pg_current_snapshot. server_version_num is %d", version) + } + if err := rejectUnversionedSubstrateSchema(ctx, pool); err != nil { + return err + } + + migrations, err := fs.Sub(migrationFiles, "migrations") + if err != nil { + return fmt.Errorf("open embedded PostgreSQL migrations: %w", err) + } + provider, err := openMigrationProvider(ctx, pool, migrations) + if err != nil { + return err } + return errors.Join(migrateToLatest(ctx, provider), provider.Close()) +} - // Multiple ateapi replicas can start against an empty database together. - // PostgreSQL's IF NOT EXISTS does not eliminate every concurrent-DDL race, - // so serialize schema application with a transaction-scoped advisory lock. - if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('agent-substrate-atepg-schema'))`); err != nil { - return fmt.Errorf("locking atepg schema: %w", err) +func openMigrationProvider(ctx context.Context, pool *pgxpool.Pool, migrations fs.FS) (*goose.Provider, error) { + lockID, err := migrationLockID(ctx, pool) + if err != nil { + return nil, err + } + locker, err := lock.NewPostgresSessionLocker( + lock.WithLockID(lockID), + lock.WithLockTimeout(1, 300), + ) + if err != nil { + return nil, fmt.Errorf("create PostgreSQL migration locker: %w", err) } - if _, err := tx.Exec(ctx, schema); err != nil { - return fmt.Errorf("applying atepg schema: %w", err) + db := stdlib.OpenDBFromPool(pool) + provider, err := goose.NewProvider( + goose.DialectPostgres, + db, + migrations, + goose.WithTableName(migrationTableName), + goose.WithSessionLocker(locker), + ) + if err != nil { + _ = db.Close() + return nil, fmt.Errorf("create PostgreSQL migration provider: %w", err) } - if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("committing atepg schema: %w", err) + return provider, nil +} + +func migrationLockID(ctx context.Context, pool *pgxpool.Pool) (int64, error) { + var lockID int64 + if err := pool.QueryRow(ctx, `SELECT hashtextextended($1 || ':' || current_schema(), 0)`, migrationLockName).Scan(&lockID); err != nil { + return 0, fmt.Errorf("get PostgreSQL migration lock ID: %w", err) + } + return lockID, nil +} + +// rejectUnversionedSubstrateSchema stops Goose before it creates a migration +// ledger in a database from a pre-migration ateapi version. +// The list contains all tables in the pre-migration schema. Goose creates the +// migration ledger before it creates a later table. +func rejectUnversionedSubstrateSchema(ctx context.Context, pool *pgxpool.Pool) error { + var hasMetadata, hasSubstrateTables bool + err := pool.QueryRow(ctx, ` + SELECT + to_regclass('schema_migrations') IS NOT NULL, + EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name IN ( + 'atespaces', 'actors', 'actor_templates', + 'actor_snapshots', 'actor_snapshot_tags', 'workers', + 'worker_outbox', 'worker_outbox_default', + 'worker_outbox_trim', 'leases' + ) + )`).Scan(&hasMetadata, &hasSubstrateTables) + if err != nil { + return fmt.Errorf("check PostgreSQL migration ledger: %w", err) + } + if hasSubstrateTables && !hasMetadata { + return errors.New("unsupported PostgreSQL schema: Substrate tables exist without a migration ledger") + } + return nil +} + +func migrateToLatest(ctx context.Context, provider *goose.Provider) (migrationErr error) { + started := time.Now() + current, latest, err := provider.GetVersions(ctx) + if err != nil { + return fmt.Errorf("get PostgreSQL migration versions: %w", err) + } + starting := current + + applied := 0 + defer func() { + attributes := []any{ + slog.Int64("starting_version", starting), + slog.Int64("current_version", current), + slog.Int64("latest_version", latest), + slog.Int("applied_migrations", applied), + slog.Duration("duration", time.Since(started)), + } + if migrationErr != nil { + attributes = append(attributes, slog.Any("err", migrationErr)) + slog.ErrorContext(ctx, "PostgreSQL migrations failed", attributes...) + return + } + slog.InfoContext(ctx, "PostgreSQL migrations ready", attributes...) + }() + + results, err := provider.Up(ctx) + if err != nil { + var partial *goose.PartialError + if errors.As(err, &partial) { + applied = len(partial.Applied) + } + applyErr := fmt.Errorf("apply PostgreSQL migrations: %w", err) + failedCurrent, _, versionErr := provider.GetVersions(ctx) + if versionErr != nil { + return errors.Join(applyErr, fmt.Errorf("get PostgreSQL migration versions after a failure: %w", versionErr)) + } + current = failedCurrent + return applyErr + } + applied = len(results) + current, _, err = provider.GetVersions(ctx) + if err != nil { + return fmt.Errorf("get PostgreSQL migration versions after migration: %w", err) } return nil } diff --git a/cmd/ateapi/main.go b/cmd/ateapi/main.go index a59fb3589f..46ea9fe147 100644 --- a/cmd/ateapi/main.go +++ b/cmd/ateapi/main.go @@ -18,6 +18,7 @@ import ( "context" "crypto/tls" "crypto/x509" + "errors" "fmt" "log/slog" "net" @@ -65,6 +66,7 @@ var ( authenticationConfigFile = pflag.String("authentication-config", "", "YAML file configuring trusted JWT providers.") postgresConnectionString = pflag.String("postgres-connection-string", "", "PostgreSQL connection string (libpq DSN or URI).") + postgresSchema = pflag.String("postgres-schema", "public", "PostgreSQL schema for Substrate tables. This overrides a search_path connection parameter.") actorIDJWTPoolFile = pflag.String("actor-id-jwt-pool", "", "The file that contains the serialized JWT authority pool for signing actor JWTs") egressGatewayAddress = pflag.String("egress-gateway-address", "", "Address of the egress PEP. Empty disables tunneled egress.") @@ -283,6 +285,7 @@ func loadFlagsFromEnv() { env string }{ {postgresConnectionString, "ATE_API_POSTGRES_CONNECTION_STRING"}, + {postgresSchema, "ATE_API_POSTGRES_SCHEMA"}, } for _, o := range overrides { if *o.flag == "@env" { @@ -297,6 +300,7 @@ func logFlagValues(ctx context.Context) { slog.String("grpc-server-cred-bundle", *grpcServerCredBundle), slog.String("authentication-config", *authenticationConfigFile), slog.String("postgres-connection-string", *postgresConnectionString), + slog.String("postgres-schema", *postgresSchema), slog.String("actor-id-jwt-pool", *actorIDJWTPoolFile), slog.String("actor-id-ca-pool", *actorIDCAPoolFile), slog.String("pod-identity-ca-certs", *podIdentityCACerts), @@ -330,10 +334,13 @@ var ( func connectPostgresWithRetries(ctx context.Context) (*atepg.Persistence, error) { var connectErr error for attempt := 1; attempt <= postgresConnectTries; attempt++ { - persistence, err := atepg.Connect(ctx, *postgresConnectionString) + persistence, err := atepg.Connect(ctx, *postgresConnectionString, *postgresSchema) if err == nil { return persistence, nil } + if !errors.Is(err, atepg.ErrUnavailable) { + return nil, err + } connectErr = err slog.WarnContext(ctx, "Failed to connect to PostgreSQL, retrying...", slog.Int("attempt", attempt), slog.Any("err", err)) if attempt == postgresConnectTries { diff --git a/docs/dev/postgresql-schema-evolution.md b/docs/dev/postgresql-schema-evolution.md new file mode 100644 index 0000000000..19a77b3b5a --- /dev/null +++ b/docs/dev/postgresql-schema-evolution.md @@ -0,0 +1,70 @@ +# PostgreSQL schema evolution + +`ateapi` applies PostgreSQL migrations before it becomes ready. During a rolling update, the previous binary continues to serve requests while the new binary changes the schema. + +Goose commits one migration at a time. A failed migration run can leave any completed migration prefix in place. These rules keep the previous binary safe with every migration prefix. + +## Compatibility contract + +- Keep every migration prefix compatible with reads and writes from the previous binary. +- Treat every migration file boundary as a durable database state. +- Do not depend on a later migration to make an earlier prefix compatible. +- Let the new binary migrate the schema from the previous release. +- Make the new binary wait for all pending migrations before readiness. +- Use explicit column lists in `SELECT` and `INSERT` statements. + +## Schema change rules + +Prefer additive changes. Add a table, column, or index before the new binary uses it. + +Add a column as nullable or give it a compatible database default. Do not make previous binary inserts fail because they omit a new column. + +Do not remove or rename a table or column that the previous binary uses. Do not change its type or meaning in an incompatible way. + +Do not tighten a constraint while the previous binary can write a value that violates it. Do not remove a default that the previous binary requires. + +If two schema structures hold the same data, keep them consistent while both binaries can write. Backfill existing rows before the new binary requires the new structure. + +Do not run a large data backfill during startup. Propose a separate migration process before you add such a change. + +## Expand and contract + +Use an expand and contract sequence for a schema replacement or removal: + +1. In release N, add the new structure and keep the old structure. +2. In release N+1, stop using the old structure. +3. In release N+2, remove the old structure. + +This sequence keeps the previous binary compatible during a rollout and a temporary binary rollback. A binary rollback does not roll back the schema. + +## Migration file rules + +Store migration files in `cmd/ateapi/internal/store/atepg/migrations`. + +- Use the next sequential `NNNNNN_name.sql` filename. +- Add exactly one `-- +goose Up` annotation. +- Use SQL migrations only. +- Do not add down migrations. +- Do not use `NO TRANSACTION` or `ENVSUB`. +- Do not add SQL transaction control statements. +- Do not use `IF NOT EXISTS` for a schema change. +- Keep each startup migration short. + +Before the first stable v1 release, developers can change or squash migration files. Recreate a development database after its migration history changes. + +After the first stable v1 release, do not change or delete a released migration file. Add a new migration to correct it. + +Do not edit the `schema_migrations` ledger manually. + +## Before you submit + +1. Identify the previous binary reads and writes that use the changed objects. +2. Check those operations against every new migration prefix. +3. Use expand and contract when one prefix would break an operation. +4. Add or update a test for the schema behavior. +5. Run the migration verifier and PostgreSQL store tests. + +```sh +hack/verify/postgresql-migrations.sh +go test ./cmd/ateapi/internal/store/atepg +``` diff --git a/go.mod b/go.mod index dcc980bfd5..19e71b3854 100644 --- a/go.mod +++ b/go.mod @@ -20,17 +20,18 @@ require ( github.com/envoyproxy/go-control-plane v0.14.0 github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260812071801-353463cc7248 github.com/fsnotify/fsnotify v1.9.0 - github.com/go-logr/logr v1.4.3 + github.com/go-logr/logr v1.4.4 github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.21.7 github.com/google/nftables v0.3.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/jackc/pgx/v5 v5.10.0 - github.com/klauspost/compress v1.19.0 + github.com/klauspost/compress v1.19.1 github.com/myzhan/boomer v1.6.1-0.20250711115830-8a2c7ce4c7b1 github.com/opencontainers/runtime-spec v1.3.0 github.com/pelletier/go-toml/v2 v2.4.0 + github.com/pressly/goose/v3 v3.27.3 github.com/prometheus/client_golang v1.24.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -154,10 +155,11 @@ require ( github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.23 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/socket v0.5.0 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.3.0 // indirect github.com/moby/moby/api v1.55.0 // indirect @@ -188,6 +190,7 @@ require ( github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.21.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/sethvargo/go-retry v0.4.0 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect @@ -203,6 +206,7 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.54.0 // indirect diff --git a/go.sum b/go.sum index a6b594c84f..2704d922ec 100644 --- a/go.sum +++ b/go.sum @@ -131,6 +131,8 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= @@ -160,8 +162,8 @@ github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= @@ -247,8 +249,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= -github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -263,8 +265,8 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -273,6 +275,8 @@ github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0 github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.3.0 h1:nos4BtzzUIqB406BgQnWGMI4qib9BZ8XUHU+ucv/n1c= @@ -309,6 +313,8 @@ github.com/myzhan/gomq v0.0.0-20220926014711-4eea0d4a1e75 h1:bzEwvuO0LYXUHgpOnO6 github.com/myzhan/gomq v0.0.0-20220926014711-4eea0d4a1e75/go.mod h1:hLGyy3ZMq1KVGxDM54SdHMBl08IyiKex9KWjslj0qyQ= github.com/myzhan/gomq/zmtp v0.0.0-20220926014711-4eea0d4a1e75 h1:yVyx8Vc/WCldQjQWQx8FXQSwKDawpEJCeAZIBXPSAk0= github.com/myzhan/gomq/zmtp v0.0.0-20220926014711-4eea0d4a1e75/go.mod h1:+nAGtxfeMKPOBqMEAAozdV5x38YsnxJr5WDerNKtznQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= @@ -338,6 +344,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA= +github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY= github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -348,6 +356,8 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -355,6 +365,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= +github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= @@ -456,7 +468,6 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= @@ -514,6 +525,14 @@ k8s.io/streaming v0.37.0-rc.0 h1:zDBjrKCnSLQyznohS8DyT1vmiD1s7XEcj5B9kyfN1WQ= k8s.io/streaming v0.37.0-rc.0/go.mod h1:APlJR26ZWRcVy5bIEj0QRrKUXROtBHPcxl2NT7EAzPU= k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs= k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws= +modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= diff --git a/hack/install-ate.sh b/hack/install-ate.sh index e4b5c5dd62..95f0f1f50f 100755 --- a/hack/install-ate.sh +++ b/hack/install-ate.sh @@ -101,6 +101,11 @@ function usage() { echo " --create-api-server-env-vars Create ate-api-server env vars" echo " --create-api-authentication-config Create the default ate-api-server authentication config" echo "" + echo "PostgreSQL configuration:" + echo "" + echo " ATE_API_POSTGRES_CONNECTION_STRING Use an external PostgreSQL instance and skip the bundled instance" + echo " ATE_API_POSTGRES_SCHEMA Select the Substrate schema (default: public)" + echo "" echo "Benchmarks (see benchmarking/README.md for details and customization):" echo "" echo " --deploy-benchmarks Deploy workloads + locust load test stack" @@ -223,6 +228,10 @@ default_postgres_connection_string() { echo "postgresql://postgres@postgres.ate-system.svc:5432/atepg?sslmode=verify-full&sslrootcert=/run/servicedns.podcert.ate.dev/trust-bundle.pem&sslcert=/run/podidentity.podcert.ate.dev/credential-bundle.pem&sslkey=/run/podidentity.podcert.ate.dev/credential-bundle.pem" } +use_bundled_postgres() { + [[ -z "${ATE_API_POSTGRES_CONNECTION_STRING:-}" ]] +} + # --- Versioned dataplane rendering --- # # The atelet DaemonSet is keyed by substrate version (name suffix + nodeSelector on @@ -377,6 +386,15 @@ apply_otel_config() { fi } +apply_postgres() { + if [[ "${ATE_INSTALL_KIND:-false}" == "true" ]]; then + kubectl kustomize manifests/ate-install/kind/postgres \ + --load-restrictor LoadRestrictionsNone | run_kubectl apply -f - + else + run_kubectl apply -f manifests/ate-install/postgres.yaml + fi +} + # --otlp-endpoint sends all control plane telemetry to a different collector for # the duration of a measurement. One patch is sufficient: each component reads # this ConfigMap through envFrom, and ate-controller copies the values to the @@ -530,6 +548,7 @@ create_api_server_env_vars() { | run_kubectl apply -f - local postgres_connection_string="${ATE_API_POSTGRES_CONNECTION_STRING:-}" + local postgres_schema="${ATE_API_POSTGRES_SCHEMA:-public}" if [[ -z "${postgres_connection_string}" ]]; then postgres_connection_string="$(default_postgres_connection_string)" fi @@ -538,6 +557,7 @@ create_api_server_env_vars() { run_kubectl create configmap -n ate-system ate-api-server-envvars \ --from-literal=ATE_API_POSTGRES_CONNECTION_STRING="${postgres_connection_string}" \ + --from-literal=ATE_API_POSTGRES_SCHEMA="${postgres_schema}" \ --dry-run=client -o yaml \ | run_kubectl apply -f - } @@ -667,6 +687,10 @@ deploy_ate_system() { fi fi + if use_bundled_postgres; then + apply_postgres + fi + local manifests="" manifests="$(render_ate_system_manifests)" echo "${manifests}" | run_kubectl apply -f - @@ -678,7 +702,9 @@ deploy_ate_system() { apply_atenet_egress log_step "Waiting for ATE system components to be ready..." - run_kubectl rollout status statefulset/postgres -n ate-system --timeout="$(rollout_timeout)" + if use_bundled_postgres; then + run_kubectl rollout status statefulset/postgres -n ate-system --timeout="$(rollout_timeout)" + fi run_kubectl rollout status deployment/ate-api-server -n ate-system --timeout="$(rollout_timeout)" run_kubectl rollout status deployment/ate-controller -n ate-system --timeout="$(rollout_timeout)" run_kubectl rollout status deployment/atenet-router -n ate-system --timeout="$(rollout_timeout)" diff --git a/hack/verify/postgresql-migrations.sh b/hack/verify/postgresql-migrations.sh new file mode 100755 index 0000000000..4894cf0e61 --- /dev/null +++ b/hack/verify/postgresql-migrations.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +# Copyright 2026 Google LLC +# +# 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. + +set -o errexit -o nounset -o pipefail + +ROOT="$(git rev-parse --show-toplevel)" +cd "${ROOT}" + +export LC_ALL=C + +MIGRATIONS_DIR="cmd/ateapi/internal/store/atepg/migrations" + +if (( $# > 1 )); then + echo "Usage: $0 [released-ref]" >&2 + exit 2 +fi + +shopt -s nullglob +migrations=("${MIGRATIONS_DIR}"/*.sql) +if (( ${#migrations[@]} == 0 )); then + echo "Add at least one PostgreSQL migration." >&2 + exit 1 +fi + +expected=1 +for migration in "${migrations[@]}"; do + name="${migration##*/}" + if [[ ! "${name}" =~ ^([0-9]{6})_[a-z0-9_]+\.sql$ ]]; then + echo "Migration file ${name} must match NNNNNN_name.sql." >&2 + exit 1 + fi + version=$((10#${BASH_REMATCH[1]})) + if (( version != expected )); then + printf 'Expected PostgreSQL migration version %06d, but found %s.\n' "${expected}" "${name}" >&2 + exit 1 + fi + if ! grep -Fxq -- '-- +goose Up' "${migration}"; then + echo "Migration file ${name} must contain a Goose Up annotation." >&2 + exit 1 + fi + if grep -Fiq -- '-- +goose Down' "${migration}"; then + echo "Migration file ${name} must not contain a Goose Down migration." >&2 + exit 1 + fi + if grep -Fiq -- '-- +goose no transaction' "${migration}"; then + echo "Migration file ${name} must run in a PostgreSQL transaction." >&2 + exit 1 + fi + if grep -Fiq -- 'IF NOT EXISTS' "${migration}"; then + echo "Migration file ${name} must not contain an IF NOT EXISTS guard." >&2 + exit 1 + fi + if grep -Fiq -- '-- +goose ENVSUB' "${migration}"; then + echo "Migration file ${name} must not use Goose environment substitution." >&2 + exit 1 + fi + if grep -Eiq -- '^[[:space:]]*(BEGIN|START[[:space:]]+TRANSACTION|COMMIT|ROLLBACK)[[:space:]]*;' "${migration}"; then + echo "Migration file ${name} must let Goose control the PostgreSQL transaction." >&2 + exit 1 + fi + expected=$((expected + 1)) +done + +released_ref="${1:-}" +if [[ -z "${released_ref}" ]]; then + while read -r tag; do + if [[ ! "${tag}" =~ ^v[1-9][0-9]*\.[0-9]+\.[0-9]+$ ]]; then + continue + fi + if [[ "$(git ls-tree -r --name-only "${tag}" -- "${MIGRATIONS_DIR}")" == *".sql"* ]]; then + released_ref="${tag}" + break + fi + done < <(git tag --merged HEAD --sort=-version:refname) +fi + +if [[ -n "${released_ref}" ]] && ! git diff --quiet --no-renames --diff-filter=MD "${released_ref}" HEAD -- "${MIGRATIONS_DIR}"; then + echo "Do not change or delete released PostgreSQL migrations." >&2 + git diff --name-only --no-renames --diff-filter=MD "${released_ref}" HEAD -- "${MIGRATIONS_DIR}" >&2 + exit 1 +fi diff --git a/manifests/ate-install/ate-api-server.yaml b/manifests/ate-install/ate-api-server.yaml index cb4030a985..610b229201 100644 --- a/manifests/ate-install/ate-api-server.yaml +++ b/manifests/ate-install/ate-api-server.yaml @@ -90,6 +90,7 @@ spec: - "--grpc-server-cred-bundle=/run/servicedns.podcert.ate.dev/credential-bundle.pem" - --authentication-config=/etc/ateapi/authentication/authentication.yaml - --postgres-connection-string=@env + - --postgres-schema=@env - --actor-id-jwt-pool=/run/actor-id-jwt-pool/pool.json - --actor-id-ca-pool=/run/actor-id-ca-pool/pool.json - --atelet-client-cred-bundle=/run/podidentity.podcert.ate.dev/credential-bundle.pem diff --git a/manifests/ate-install/base/kustomization.yaml b/manifests/ate-install/base/kustomization.yaml index fc70376ee0..2a9b83a969 100644 --- a/manifests/ate-install/base/kustomization.yaml +++ b/manifests/ate-install/base/kustomization.yaml @@ -24,6 +24,5 @@ resources: - ../atelet.yaml - ../atenet-dns.yaml - ../atenet-router.yaml - - ../postgres.yaml - ../pod-certificate-controller.yaml - ../ate-otel-config.yaml diff --git a/manifests/ate-install/kind/kustomization.yaml b/manifests/ate-install/kind/kustomization.yaml index f68364ded1..1fabcdec4d 100644 --- a/manifests/ate-install/kind/kustomization.yaml +++ b/manifests/ate-install/kind/kustomization.yaml @@ -27,7 +27,6 @@ resources: - ./atelet - ../atenet-dns.yaml - ../atenet-router.yaml - - ./postgres - ../pod-certificate-controller.yaml - ate-otel-config.yaml - rustfs.yaml @@ -55,4 +54,3 @@ patches: env: - name: OTEL_TRACES_SAMPLER value: parentbased_always_on - diff --git a/vendor/github.com/go-logr/logr/context_noslog.go b/vendor/github.com/go-logr/logr/context_noslog.go index f012f9a18e..0a3d1a125e 100644 --- a/vendor/github.com/go-logr/logr/context_noslog.go +++ b/vendor/github.com/go-logr/logr/context_noslog.go @@ -1,5 +1,4 @@ //go:build !go1.21 -// +build !go1.21 /* Copyright 2019 The logr Authors. diff --git a/vendor/github.com/go-logr/logr/context_slog.go b/vendor/github.com/go-logr/logr/context_slog.go index 065ef0b828..c69eb01ba1 100644 --- a/vendor/github.com/go-logr/logr/context_slog.go +++ b/vendor/github.com/go-logr/logr/context_slog.go @@ -1,5 +1,4 @@ //go:build go1.21 -// +build go1.21 /* Copyright 2019 The logr Authors. diff --git a/vendor/github.com/go-logr/logr/funcr/funcr.go b/vendor/github.com/go-logr/logr/funcr/funcr.go index b22c57d713..7f4996e9e4 100644 --- a/vendor/github.com/go-logr/logr/funcr/funcr.go +++ b/vendor/github.com/go-logr/logr/funcr/funcr.go @@ -426,7 +426,7 @@ func (f Formatter) colon() byte { } func (f Formatter) pretty(value any) string { - return f.prettyWithFlags(value, 0, 0) + return f.prettyWithFlags(value, 0, 0, 0, nil) } const ( @@ -434,7 +434,13 @@ const ( ) // TODO: This is not fast. Most of the overhead goes here. -func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string { +// value: The value to render +// flags: Bitmask of flags (see above) +// depth: The current depth of nested structs, slices, arrays, and maps +// ptrDepth: The current depth of including pointer dereferences +// ptrMap: A map of pointers already seen, to avoid infinite recursion (usually +// nil unless ptrDepth is large) +func (f Formatter) prettyWithFlags(value any, flags uint32, depth int, ptrDepth int, ptrMap map[uintptr]bool) string { if depth > f.opts.MaxLogDepth { return `""` } @@ -504,7 +510,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string { // arbitrary keys might need escaping buf.WriteString(prettyString(k)) buf.WriteByte(f.colon()) - buf.WriteString(f.prettyWithFlags(v[i+1], 0, depth+1)) + buf.WriteString(f.prettyWithFlags(v[i+1], 0, depth+1, ptrDepth+1, ptrMap)) } if flags&flagRawStruct == 0 { buf.WriteByte('}') @@ -576,7 +582,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string { } printComma = true // if we got here, we are rendering a field if fld.Anonymous && fld.Type.Kind() == reflect.Struct && name == "" { - buf.WriteString(f.prettyWithFlags(v.Field(i).Interface(), flags|flagRawStruct, depth+1)) + buf.WriteString(f.prettyWithFlags(v.Field(i).Interface(), flags|flagRawStruct, depth+1, ptrDepth+1, ptrMap)) continue } if name == "" { @@ -585,7 +591,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string { // field names can't contain characters which need escaping buf.WriteString(f.quoted(name, false)) buf.WriteByte(f.colon()) - buf.WriteString(f.prettyWithFlags(v.Field(i).Interface(), 0, depth+1)) + buf.WriteString(f.prettyWithFlags(v.Field(i).Interface(), 0, depth+1, ptrDepth+1, ptrMap)) } if flags&flagRawStruct == 0 { buf.WriteByte('}') @@ -612,7 +618,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string { buf.WriteByte(f.comma()) } e := v.Index(i) - buf.WriteString(f.prettyWithFlags(e.Interface(), 0, depth+1)) + buf.WriteString(f.prettyWithFlags(e.Interface(), 0, depth+1, ptrDepth+1, ptrMap)) } buf.WriteByte(']') return buf.String() @@ -637,7 +643,8 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string { keystr = prettyString(keystr) } else { // prettyWithFlags will produce already-escaped values - keystr = f.prettyWithFlags(it.Key().Interface(), 0, depth+1) + // key depth is unrelated to overall depth + keystr = f.prettyWithFlags(it.Key().Interface(), 0, 0, ptrDepth, ptrMap) if t.Key().Kind() != reflect.String { // JSON only does string keys. Unlike Go's standard JSON, we'll // convert just about anything to a string. @@ -646,16 +653,34 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string { } buf.WriteString(keystr) buf.WriteByte(f.colon()) - buf.WriteString(f.prettyWithFlags(it.Value().Interface(), 0, depth+1)) + buf.WriteString(f.prettyWithFlags(it.Value().Interface(), 0, depth+1, ptrDepth+1, ptrMap)) i++ } buf.WriteByte('}') return buf.String() - case reflect.Ptr, reflect.Interface: + case reflect.Pointer, reflect.Interface: if v.IsNil() { return "null" } - return f.prettyWithFlags(v.Elem().Interface(), 0, depth) + // Special case: recursive pointers. For normal use we do not want to + // count pointer dereferences as depth, but if we see the same pointer + // again we have a recursion and need to stop. After a large number of + // pointer dereferences we will start tracking pointers to avoid the + // perf hit of doing it in the normal path. + // + // This should not happen accidentally (e.g. json decoding should never + // do this) but we can handle it gracefully. + if ptrMap != nil && ptrMap[uintptr(v.Pointer())] { + depth = f.opts.MaxLogDepth + 1 // force a depth error + } + const maxDepthFactor = 4 // arbitrary, but we want it large enough to not false-alert + if ptrDepth > f.opts.MaxLogDepth*maxDepthFactor && ptrMap == nil { + ptrMap = map[uintptr]bool{} + } + if ptrMap != nil { + ptrMap[(uintptr)(v.Pointer())] = true + } + return f.prettyWithFlags(v.Elem().Interface(), 0, depth, ptrDepth+1, ptrMap) } return fmt.Sprintf(`""`, t.Kind().String()) } @@ -697,7 +722,7 @@ func isEmpty(v reflect.Value) bool { return v.Float() == 0 case reflect.Complex64, reflect.Complex128: return v.Complex() == 0 - case reflect.Interface, reflect.Ptr: + case reflect.Interface, reflect.Pointer: return v.IsNil() } return false diff --git a/vendor/github.com/go-logr/logr/funcr/slogsink.go b/vendor/github.com/go-logr/logr/funcr/slogsink.go index 7bd84761e2..8b519c91e1 100644 --- a/vendor/github.com/go-logr/logr/funcr/slogsink.go +++ b/vendor/github.com/go-logr/logr/funcr/slogsink.go @@ -1,5 +1,4 @@ //go:build go1.21 -// +build go1.21 /* Copyright 2023 The logr Authors. @@ -33,7 +32,7 @@ const extraSlogSinkDepth = 3 // 2 for slog, 1 for SlogSink func (l fnlogger) Handle(_ context.Context, record slog.Record) error { kvList := make([]any, 0, 2*record.NumAttrs()) record.Attrs(func(attr slog.Attr) bool { - kvList = attrToKVs(attr, kvList) + kvList = attrToKVs(attr, kvList, l.opts.MaxLogDepth) return true }) @@ -49,7 +48,7 @@ func (l fnlogger) Handle(_ context.Context, record slog.Record) error { func (l fnlogger) WithAttrs(attrs []slog.Attr) logr.SlogSink { kvList := make([]any, 0, 2*len(attrs)) for _, attr := range attrs { - kvList = attrToKVs(attr, kvList) + kvList = attrToKVs(attr, kvList, l.opts.MaxLogDepth) } l.AddValues(kvList) return &l @@ -61,14 +60,25 @@ func (l fnlogger) WithGroup(name string) logr.SlogSink { } // attrToKVs appends a slog.Attr to a logr-style kvList. It handle slog Groups -// and other details of slog. -func attrToKVs(attr slog.Attr, kvList []any) []any { +// and other details of slog. maxDepth bounds recursion into nested groups so a +// deeply-nested slog.Group cannot exhaust the stack; it is decremented per group +// level and starts at the Formatter's MaxLogDepth (past which the formatter would +// truncate the rendering anyway). +func attrToKVs(attr slog.Attr, kvList []any, maxDepth int) []any { attrVal := attr.Value.Resolve() if attrVal.Kind() == slog.KindGroup { + if maxDepth <= 0 { + // Nesting is too deep to build without risking a stack overflow. + // Stop here; the formatter truncates below MaxLogDepth regardless. + if attr.Key != "" { + kvList = append(kvList, attr.Key, "") + } + return kvList + } groupVal := attrVal.Group() grpKVs := make([]any, 0, 2*len(groupVal)) for _, attr := range groupVal { - grpKVs = attrToKVs(attr, grpKVs) + grpKVs = attrToKVs(attr, grpKVs, maxDepth-1) } if attr.Key == "" { // slog says we have to inline these diff --git a/vendor/github.com/go-logr/logr/sloghandler.go b/vendor/github.com/go-logr/logr/sloghandler.go index 82d1ba4948..befaf5510f 100644 --- a/vendor/github.com/go-logr/logr/sloghandler.go +++ b/vendor/github.com/go-logr/logr/sloghandler.go @@ -1,5 +1,4 @@ //go:build go1.21 -// +build go1.21 /* Copyright 2023 The logr Authors. diff --git a/vendor/github.com/go-logr/logr/slogr.go b/vendor/github.com/go-logr/logr/slogr.go index 28a83d0243..bfe80eb8d6 100644 --- a/vendor/github.com/go-logr/logr/slogr.go +++ b/vendor/github.com/go-logr/logr/slogr.go @@ -1,5 +1,4 @@ //go:build go1.21 -// +build go1.21 /* Copyright 2023 The logr Authors. diff --git a/vendor/github.com/go-logr/logr/slogsink.go b/vendor/github.com/go-logr/logr/slogsink.go index 4060fcbc2b..ab76ea99fe 100644 --- a/vendor/github.com/go-logr/logr/slogsink.go +++ b/vendor/github.com/go-logr/logr/slogsink.go @@ -1,5 +1,4 @@ //go:build go1.21 -// +build go1.21 /* Copyright 2023 The logr Authors. diff --git a/vendor/github.com/jackc/pgx/v5/stdlib/sql.go b/vendor/github.com/jackc/pgx/v5/stdlib/sql.go new file mode 100644 index 0000000000..576d3c60f8 --- /dev/null +++ b/vendor/github.com/jackc/pgx/v5/stdlib/sql.go @@ -0,0 +1,909 @@ +// Package stdlib is the compatibility layer from pgx to database/sql. +// +// A database/sql connection can be established through sql.Open. +// +// db, err := sql.Open("pgx", "postgres://pgx_md5:secret@localhost:5432/pgx_test?sslmode=disable") +// if err != nil { +// return err +// } +// +// Or from a keyword/value string. +// +// db, err := sql.Open("pgx", "user=postgres password=secret host=localhost port=5432 dbname=pgx_test sslmode=disable") +// if err != nil { +// return err +// } +// +// Or from a *pgxpool.Pool. +// +// pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) +// if err != nil { +// return err +// } +// +// db := stdlib.OpenDBFromPool(pool) +// +// Or a pgx.ConnConfig can be used to set configuration not accessible via connection string. In this case the +// pgx.ConnConfig must first be registered with the driver. This registration returns a connection string which is used +// with sql.Open. +// +// connConfig, _ := pgx.ParseConfig(os.Getenv("DATABASE_URL")) +// connConfig.Tracer = &tracelog.TraceLog{Logger: myLogger, LogLevel: tracelog.LogLevelInfo} +// connStr := stdlib.RegisterConnConfig(connConfig) +// db, _ := sql.Open("pgx", connStr) +// +// pgx uses standard PostgreSQL positional parameters in queries. e.g. $1, $2. It does not support named parameters. +// +// db.QueryRow("select * from users where id=$1", userID) +// +// (*sql.Conn) Raw() can be used to get a *pgx.Conn from the standard database/sql.DB connection pool. This allows +// operations that use pgx specific functionality. +// +// // Given db is a *sql.DB +// conn, err := db.Conn(context.Background()) +// if err != nil { +// // handle error from acquiring connection from DB pool +// } +// +// err = conn.Raw(func(driverConn any) error { +// conn := driverConn.(*stdlib.Conn).Conn() // conn is a *pgx.Conn +// // Do pgx specific stuff with conn +// conn.CopyFrom(...) +// return nil +// }) +// if err != nil { +// // handle error that occurred while using *pgx.Conn +// } +// +// # PostgreSQL Specific Data Types +// +// The pgtype package provides support for PostgreSQL specific types. *pgtype.Map.SQLScanner is an adapter that makes +// these types usable as a sql.Scanner. +// +// m := pgtype.NewMap() +// var a []int64 +// err := db.QueryRow("select '{1,2,3}'::bigint[]").Scan(m.SQLScanner(&a)) +package stdlib + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "math" + "math/rand/v2" + "reflect" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Only intrinsic types should be binary format with database/sql. +var databaseSQLResultFormats pgx.QueryResultFormatsByOID + +var pgxDriver *Driver + +func init() { + pgxDriver = &Driver{ + configs: make(map[string]*pgx.ConnConfig), + } + + // if pgx driver was already registered by different pgx major version then we + // skip registration under the default name. + if !slices.Contains(sql.Drivers(), "pgx") { + sql.Register("pgx", pgxDriver) + } + sql.Register("pgx/v5", pgxDriver) + + databaseSQLResultFormats = pgx.QueryResultFormatsByOID{ + pgtype.BoolOID: 1, + pgtype.ByteaOID: 1, + pgtype.CIDOID: 1, + pgtype.DateOID: 1, + pgtype.Float4OID: 1, + pgtype.Float8OID: 1, + pgtype.Int2OID: 1, + pgtype.Int4OID: 1, + pgtype.Int8OID: 1, + pgtype.OIDOID: 1, + pgtype.TimestampOID: 1, + pgtype.TimestamptzOID: 1, + pgtype.XIDOID: 1, + } +} + +// OptionOpenDB options for configuring the driver when opening a new db pool. +type OptionOpenDB func(*connector) + +// ShouldPingParams are passed to OptionShouldPing to decide whether to ping before reusing a connection. +type ShouldPingParams struct { + // Conn is the underlying pgx connection. + Conn *pgx.Conn + // IdleDuration is how long it has been since ResetSession last ran. + IdleDuration time.Duration +} + +// OptionShouldPing controls whether stdlib should issue a liveness ping before reusing a connection. +// If the function returns true, stdlib will ping. +// If it returns false, stdlib will skip the ping. +// If not provided, default is ping only when IdleDuration > 1s. +func OptionShouldPing(f func(context.Context, ShouldPingParams) bool) OptionOpenDB { + return func(dc *connector) { dc.ShouldPing = f } +} + +// OptionBeforeConnect provides a callback for before connect. It is passed a shallow copy of the ConnConfig that will +// be used to connect, so only its immediate members should be modified. Used only if db is opened with *pgx.ConnConfig. +func OptionBeforeConnect(bc func(context.Context, *pgx.ConnConfig) error) OptionOpenDB { + return func(dc *connector) { + dc.BeforeConnect = bc + } +} + +// OptionAfterConnect provides a callback for after connect. Used only if db is opened with *pgx.ConnConfig. +func OptionAfterConnect(ac func(context.Context, *pgx.Conn) error) OptionOpenDB { + return func(dc *connector) { + dc.AfterConnect = ac + } +} + +// OptionResetSession provides a callback that can be used to add custom logic prior to executing a query on the +// connection if the connection has been used before. +// If ResetSessionFunc returns ErrBadConn error the connection will be discarded. +func OptionResetSession(rs func(context.Context, *pgx.Conn) error) OptionOpenDB { + return func(dc *connector) { + dc.ResetSession = rs + } +} + +// RandomizeHostOrderFunc is a BeforeConnect hook that randomizes the host order in the provided connConfig, so that a +// new host becomes primary each time. This is useful to distribute connections for multi-master databases like +// CockroachDB. If you use this you likely should set https://golang.org/pkg/database/sql/#DB.SetConnMaxLifetime as well +// to ensure that connections are periodically rebalanced across your nodes. +func RandomizeHostOrderFunc(ctx context.Context, connConfig *pgx.ConnConfig) error { + if len(connConfig.Fallbacks) == 0 { + return nil + } + + newFallbacks := append([]*pgconn.FallbackConfig{{ + Host: connConfig.Host, + Port: connConfig.Port, + TLSConfig: connConfig.TLSConfig, + }}, connConfig.Fallbacks...) + + rand.Shuffle(len(newFallbacks), func(i, j int) { + newFallbacks[i], newFallbacks[j] = newFallbacks[j], newFallbacks[i] + }) + + // Use the one that sorted last as the primary and keep the rest as the fallbacks + newPrimary := newFallbacks[len(newFallbacks)-1] + connConfig.Host = newPrimary.Host + connConfig.Port = newPrimary.Port + connConfig.TLSConfig = newPrimary.TLSConfig + connConfig.Fallbacks = newFallbacks[:len(newFallbacks)-1] + return nil +} + +func GetConnector(config pgx.ConnConfig, opts ...OptionOpenDB) driver.Connector { + c := connector{ + ConnConfig: config, + BeforeConnect: func(context.Context, *pgx.ConnConfig) error { return nil }, // noop before connect by default + AfterConnect: func(context.Context, *pgx.Conn) error { return nil }, // noop after connect by default + ResetSession: func(context.Context, *pgx.Conn) error { return nil }, // noop reset session by default + driver: pgxDriver, + } + + for _, opt := range opts { + opt(&c) + } + return c +} + +// GetPoolConnector creates a new driver.Connector from the given *pgxpool.Pool. By using this be sure to set the +// maximum idle connections of the *sql.DB created with this connector to zero since they must be managed from the +// *pgxpool.Pool. This is required to avoid acquiring all the connections from the pgxpool and starving any direct +// users of the pgxpool. +func GetPoolConnector(pool *pgxpool.Pool, opts ...OptionOpenDB) driver.Connector { + c := connector{ + pool: pool, + ResetSession: func(context.Context, *pgx.Conn) error { return nil }, // noop reset session by default + driver: pgxDriver, + } + + for _, opt := range opts { + opt(&c) + } + + return c +} + +func OpenDB(config pgx.ConnConfig, opts ...OptionOpenDB) *sql.DB { + c := GetConnector(config, opts...) + return sql.OpenDB(c) +} + +// OpenDBFromPool creates a new *sql.DB from the given *pgxpool.Pool. Note that this method automatically sets the +// maximum number of idle connections in *sql.DB to zero, since they must be managed from the *pgxpool.Pool. This is +// required to avoid acquiring all the connections from the pgxpool and starving any direct users of the pgxpool. Note +// that closing the returned *sql.DB will not close the *pgxpool.Pool. +func OpenDBFromPool(pool *pgxpool.Pool, opts ...OptionOpenDB) *sql.DB { + c := GetPoolConnector(pool, opts...) + db := sql.OpenDB(c) + db.SetMaxIdleConns(0) + return db +} + +type connector struct { + pgx.ConnConfig + pool *pgxpool.Pool + BeforeConnect func(context.Context, *pgx.ConnConfig) error // function to call before creation of every new connection + AfterConnect func(context.Context, *pgx.Conn) error // function to call after creation of every new connection + ResetSession func(context.Context, *pgx.Conn) error // function is called before a connection is reused + ShouldPing func(context.Context, ShouldPingParams) bool // function to decide if stdlib should ping before reusing a connection + driver *Driver +} + +// Connect implement driver.Connector interface +func (c connector) Connect(ctx context.Context) (driver.Conn, error) { + var ( + connConfig pgx.ConnConfig + conn *pgx.Conn + close func(context.Context) error + err error + ) + + if c.pool == nil { + // Create a shallow copy of the config, so that BeforeConnect can safely modify it + connConfig = c.ConnConfig + + if err = c.BeforeConnect(ctx, &connConfig); err != nil { + return nil, err + } + + if conn, err = pgx.ConnectConfig(ctx, &connConfig); err != nil { + return nil, err + } + + if err = c.AfterConnect(ctx, conn); err != nil { + return nil, err + } + + close = conn.Close + } else { + var pconn *pgxpool.Conn + + pconn, err = c.pool.Acquire(ctx) + if err != nil { + return nil, err + } + + conn = pconn.Conn() + + close = func(_ context.Context) error { + pconn.Release() + return nil + } + } + + return &Conn{ + conn: conn, + close: close, + driver: c.driver, + connConfig: connConfig, + resetSessionFunc: c.ResetSession, + shouldPing: c.ShouldPing, + psRefCounts: make(map[*pgconn.StatementDescription]int), + }, nil +} + +// Driver implement driver.Connector interface +func (c connector) Driver() driver.Driver { + return c.driver +} + +// GetDefaultDriver returns the driver initialized in the init function +// and used when the pgx driver is registered. +func GetDefaultDriver() driver.Driver { + return pgxDriver +} + +type Driver struct { + configMutex sync.Mutex + configs map[string]*pgx.ConnConfig + sequence int +} + +func (d *Driver) Open(name string) (driver.Conn, error) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) // Ensure eventual timeout + defer cancel() + + connector, err := d.OpenConnector(name) + if err != nil { + return nil, err + } + return connector.Connect(ctx) +} + +func (d *Driver) OpenConnector(name string) (driver.Connector, error) { + return &driverConnector{driver: d, name: name}, nil +} + +func (d *Driver) registerConnConfig(c *pgx.ConnConfig) string { + d.configMutex.Lock() + connStr := fmt.Sprintf("registeredConnConfig%d", d.sequence) + d.sequence++ + d.configs[connStr] = c + d.configMutex.Unlock() + return connStr +} + +func (d *Driver) unregisterConnConfig(connStr string) { + d.configMutex.Lock() + delete(d.configs, connStr) + d.configMutex.Unlock() +} + +type driverConnector struct { + driver *Driver + name string +} + +func (dc *driverConnector) Connect(ctx context.Context) (driver.Conn, error) { + var connConfig *pgx.ConnConfig + + dc.driver.configMutex.Lock() + connConfig = dc.driver.configs[dc.name] + dc.driver.configMutex.Unlock() + + if connConfig == nil { + var err error + connConfig, err = pgx.ParseConfig(dc.name) + if err != nil { + return nil, err + } + } + + conn, err := pgx.ConnectConfig(ctx, connConfig) + if err != nil { + return nil, err + } + + c := &Conn{ + conn: conn, + close: conn.Close, + driver: dc.driver, + connConfig: *connConfig, + resetSessionFunc: func(context.Context, *pgx.Conn) error { return nil }, + psRefCounts: make(map[*pgconn.StatementDescription]int), + } + + return c, nil +} + +func (dc *driverConnector) Driver() driver.Driver { + return dc.driver +} + +// RegisterConnConfig registers a ConnConfig and returns the connection string to use with Open. +func RegisterConnConfig(c *pgx.ConnConfig) string { + return pgxDriver.registerConnConfig(c) +} + +// UnregisterConnConfig removes the ConnConfig registration for connStr. +func UnregisterConnConfig(connStr string) { + pgxDriver.unregisterConnConfig(connStr) +} + +type Conn struct { + conn *pgx.Conn + close func(context.Context) error + driver *Driver + connConfig pgx.ConnConfig + resetSessionFunc func(context.Context, *pgx.Conn) error // Function is called before a connection is reused + shouldPing func(context.Context, ShouldPingParams) bool // Function to decide if stdlib should ping before reusing a connection + lastResetSessionTime time.Time + + // psRefCounts contains reference counts for prepared statements. Prepare uses the underlying pgx logic to generate + // deterministic statement names from the statement text. If this query has already been prepared then the existing + // *pgconn.StatementDescription will be returned. However, this means that if Close is called on the returned Stmt + // then the underlying prepared statement will be closed even when the underlying prepared statement is still in use + // by another database/sql Stmt. To prevent this psRefCounts keeps track of how many database/sql statements are using + // the same underlying statement and only closes the underlying statement when the reference count reaches 0. + psRefCounts map[*pgconn.StatementDescription]int +} + +// Conn returns the underlying *pgx.Conn +func (c *Conn) Conn() *pgx.Conn { + return c.conn +} + +func (c *Conn) Prepare(query string) (driver.Stmt, error) { + return c.PrepareContext(context.Background(), query) +} + +func (c *Conn) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) { + if c.conn.IsClosed() { + return nil, driver.ErrBadConn + } + + sd, err := c.conn.Prepare(ctx, query, query) + if err != nil { + return nil, err + } + c.psRefCounts[sd]++ + + return &Stmt{sd: sd, conn: c}, nil +} + +func (c *Conn) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + return c.close(ctx) +} + +func (c *Conn) Begin() (driver.Tx, error) { + return c.BeginTx(context.Background(), driver.TxOptions{}) +} + +func (c *Conn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) { + if c.conn.IsClosed() { + return nil, driver.ErrBadConn + } + + var pgxOpts pgx.TxOptions + switch sql.IsolationLevel(opts.Isolation) { + case sql.LevelDefault: + case sql.LevelReadUncommitted: + pgxOpts.IsoLevel = pgx.ReadUncommitted + case sql.LevelReadCommitted: + pgxOpts.IsoLevel = pgx.ReadCommitted + case sql.LevelRepeatableRead, sql.LevelSnapshot: + pgxOpts.IsoLevel = pgx.RepeatableRead + case sql.LevelSerializable: + pgxOpts.IsoLevel = pgx.Serializable + default: + return nil, fmt.Errorf("unsupported isolation: %v", opts.Isolation) + } + + if opts.ReadOnly { + pgxOpts.AccessMode = pgx.ReadOnly + } + + tx, err := c.conn.BeginTx(ctx, pgxOpts) + if err != nil { + return nil, err + } + + return wrapTx{ctx: ctx, tx: tx}, nil +} + +func (c *Conn) ExecContext(ctx context.Context, query string, argsV []driver.NamedValue) (driver.Result, error) { + if c.conn.IsClosed() { + return nil, driver.ErrBadConn + } + + args := make([]any, len(argsV)) + convertNamedArguments(args, argsV) + + commandTag, err := c.conn.Exec(ctx, query, args...) + // if we got a network error before we had a chance to send the query, retry + if err != nil { + if pgconn.SafeToRetry(err) { + return nil, driver.ErrBadConn + } + } + return driver.RowsAffected(commandTag.RowsAffected()), err +} + +func (c *Conn) QueryContext(ctx context.Context, query string, argsV []driver.NamedValue) (driver.Rows, error) { + if c.conn.IsClosed() { + return nil, driver.ErrBadConn + } + + args := make([]any, 1+len(argsV)) + args[0] = databaseSQLResultFormats + convertNamedArguments(args[1:], argsV) + + rows, err := c.conn.Query(ctx, query, args...) + if err != nil { + if pgconn.SafeToRetry(err) { + return nil, driver.ErrBadConn + } + return nil, err + } + + // Preload first row because otherwise we won't know what columns are available when database/sql asks. + more := rows.Next() + if err = rows.Err(); err != nil { + rows.Close() + return nil, err + } + return &Rows{conn: c, rows: rows, skipNext: true, skipNextMore: more}, nil +} + +func (c *Conn) Ping(ctx context.Context) error { + if c.conn.IsClosed() { + return driver.ErrBadConn + } + + err := c.conn.Ping(ctx) + if err != nil { + // A Ping failure implies some sort of fatal state. The connection is almost certainly already closed by the + // failure, but manually close it just to be sure. + c.Close() + return driver.ErrBadConn + } + + return nil +} + +func (c *Conn) CheckNamedValue(*driver.NamedValue) error { + // Underlying pgx supports sql.Scanner and driver.Valuer interfaces natively. So everything can be passed through directly. + return nil +} + +func (c *Conn) ResetSession(ctx context.Context) error { + if c.conn.IsClosed() { + return driver.ErrBadConn + } + + // Discard connection if it has an open transaction. This can happen if the + // application did not properly commit or rollback a transaction. + if c.conn.PgConn().TxStatus() != 'I' { + return driver.ErrBadConn + } + + now := time.Now() + idle := now.Sub(c.lastResetSessionTime) + + doPing := idle > time.Second // default behavior: ping only if idle > 1s + + if c.shouldPing != nil { + doPing = c.shouldPing(ctx, ShouldPingParams{ + Conn: c.conn, + IdleDuration: idle, + }) + } + + if doPing { + if err := c.conn.PgConn().Ping(ctx); err != nil { + return driver.ErrBadConn + } + } + + c.lastResetSessionTime = now + + return c.resetSessionFunc(ctx, c.conn) +} + +type Stmt struct { + sd *pgconn.StatementDescription + conn *Conn +} + +func (s *Stmt) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + refCount := s.conn.psRefCounts[s.sd] + if refCount == 1 { + delete(s.conn.psRefCounts, s.sd) + } else { + s.conn.psRefCounts[s.sd]-- + return nil + } + + return s.conn.conn.Deallocate(ctx, s.sd.SQL) +} + +func (s *Stmt) NumInput() int { + return len(s.sd.ParamOIDs) +} + +func (s *Stmt) Exec(argsV []driver.Value) (driver.Result, error) { + return nil, errors.New("Stmt.Exec deprecated and not implemented") +} + +func (s *Stmt) ExecContext(ctx context.Context, argsV []driver.NamedValue) (driver.Result, error) { + return s.conn.ExecContext(ctx, s.sd.SQL, argsV) +} + +func (s *Stmt) Query(argsV []driver.Value) (driver.Rows, error) { + return nil, errors.New("Stmt.Query deprecated and not implemented") +} + +func (s *Stmt) QueryContext(ctx context.Context, argsV []driver.NamedValue) (driver.Rows, error) { + return s.conn.QueryContext(ctx, s.sd.SQL, argsV) +} + +type rowValueFunc func(src []byte) (driver.Value, error) + +type Rows struct { + conn *Conn + rows pgx.Rows + valueFuncs []rowValueFunc + skipNext bool + skipNextMore bool + + columnNames []string +} + +func (r *Rows) Columns() []string { + if r.columnNames == nil { + fields := r.rows.FieldDescriptions() + r.columnNames = make([]string, len(fields)) + for i, fd := range fields { + r.columnNames[i] = fd.Name + } + } + + return r.columnNames +} + +// ColumnTypeDatabaseTypeName returns the database system type name. If the name is unknown the OID is returned. +func (r *Rows) ColumnTypeDatabaseTypeName(index int) string { + if dt, ok := r.conn.conn.TypeMap().TypeForOID(r.rows.FieldDescriptions()[index].DataTypeOID); ok { + return strings.ToUpper(dt.Name) + } + + return strconv.FormatInt(int64(r.rows.FieldDescriptions()[index].DataTypeOID), 10) +} + +const varHeaderSize = 4 + +// ColumnTypeLength returns the length of the column type if the column is a +// variable length type. If the column is not a variable length type ok +// should return false. +func (r *Rows) ColumnTypeLength(index int) (int64, bool) { + fd := r.rows.FieldDescriptions()[index] + + switch fd.DataTypeOID { + case pgtype.TextOID, pgtype.ByteaOID: + return math.MaxInt64, true + case pgtype.VarcharOID, pgtype.BPCharOID: + return int64(fd.TypeModifier - varHeaderSize), true + case pgtype.VarbitOID: + return int64(fd.TypeModifier), true + default: + return 0, false + } +} + +// ColumnTypePrecisionScale should return the precision and scale for decimal +// types. If not applicable, ok should be false. +func (r *Rows) ColumnTypePrecisionScale(index int) (precision, scale int64, ok bool) { + fd := r.rows.FieldDescriptions()[index] + + switch fd.DataTypeOID { + case pgtype.NumericOID: + mod := fd.TypeModifier - varHeaderSize + precision = int64((mod >> 16) & 0xffff) + scale = int64(mod & 0xffff) + return precision, scale, true + default: + return 0, 0, false + } +} + +// ColumnTypeScanType returns the value type that can be used to scan types into. +func (r *Rows) ColumnTypeScanType(index int) reflect.Type { + fd := r.rows.FieldDescriptions()[index] + + switch fd.DataTypeOID { + case pgtype.Float8OID: + return reflect.TypeFor[float64]() + case pgtype.Float4OID: + return reflect.TypeFor[float32]() + case pgtype.Int8OID: + return reflect.TypeFor[int64]() + case pgtype.Int4OID: + return reflect.TypeFor[int32]() + case pgtype.Int2OID: + return reflect.TypeFor[int16]() + case pgtype.BoolOID: + return reflect.TypeFor[bool]() + case pgtype.NumericOID: + return reflect.TypeFor[float64]() + case pgtype.DateOID, pgtype.TimestampOID, pgtype.TimestamptzOID: + return reflect.TypeFor[time.Time]() + case pgtype.ByteaOID: + return reflect.TypeFor[[]byte]() + default: + return reflect.TypeFor[string]() + } +} + +func (r *Rows) Close() error { + r.rows.Close() + return r.rows.Err() +} + +func (r *Rows) Next(dest []driver.Value) error { + m := r.conn.conn.TypeMap() + fieldDescriptions := r.rows.FieldDescriptions() + + if r.valueFuncs == nil { + r.valueFuncs = make([]rowValueFunc, len(fieldDescriptions)) + + for i, fd := range fieldDescriptions { + dataTypeOID := fd.DataTypeOID + format := fd.Format + + switch fd.DataTypeOID { + case pgtype.BoolOID: + var d bool + scanPlan := m.PlanScan(dataTypeOID, format, &d) + r.valueFuncs[i] = func(src []byte) (driver.Value, error) { + err := scanPlan.Scan(src, &d) + return d, err + } + case pgtype.ByteaOID: + var d []byte + scanPlan := m.PlanScan(dataTypeOID, format, &d) + r.valueFuncs[i] = func(src []byte) (driver.Value, error) { + err := scanPlan.Scan(src, &d) + return d, err + } + case pgtype.CIDOID, pgtype.OIDOID, pgtype.XIDOID: + var d pgtype.Uint32 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + r.valueFuncs[i] = func(src []byte) (driver.Value, error) { + err := scanPlan.Scan(src, &d) + if err != nil { + return nil, err + } + return d.Value() + } + case pgtype.DateOID: + var d pgtype.Date + scanPlan := m.PlanScan(dataTypeOID, format, &d) + r.valueFuncs[i] = func(src []byte) (driver.Value, error) { + err := scanPlan.Scan(src, &d) + if err != nil { + return nil, err + } + return d.Value() + } + case pgtype.Float4OID: + var d float32 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + r.valueFuncs[i] = func(src []byte) (driver.Value, error) { + err := scanPlan.Scan(src, &d) + return float64(d), err + } + case pgtype.Float8OID: + var d float64 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + r.valueFuncs[i] = func(src []byte) (driver.Value, error) { + err := scanPlan.Scan(src, &d) + return d, err + } + case pgtype.Int2OID: + var d int16 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + r.valueFuncs[i] = func(src []byte) (driver.Value, error) { + err := scanPlan.Scan(src, &d) + return int64(d), err + } + case pgtype.Int4OID: + var d int32 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + r.valueFuncs[i] = func(src []byte) (driver.Value, error) { + err := scanPlan.Scan(src, &d) + return int64(d), err + } + case pgtype.Int8OID: + var d int64 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + r.valueFuncs[i] = func(src []byte) (driver.Value, error) { + err := scanPlan.Scan(src, &d) + return d, err + } + case pgtype.JSONOID, pgtype.JSONBOID: + var d []byte + scanPlan := m.PlanScan(dataTypeOID, format, &d) + r.valueFuncs[i] = func(src []byte) (driver.Value, error) { + err := scanPlan.Scan(src, &d) + if err != nil { + return nil, err + } + return d, nil + } + case pgtype.TimestampOID: + var d pgtype.Timestamp + scanPlan := m.PlanScan(dataTypeOID, format, &d) + r.valueFuncs[i] = func(src []byte) (driver.Value, error) { + err := scanPlan.Scan(src, &d) + if err != nil { + return nil, err + } + return d.Value() + } + case pgtype.TimestamptzOID: + var d pgtype.Timestamptz + scanPlan := m.PlanScan(dataTypeOID, format, &d) + r.valueFuncs[i] = func(src []byte) (driver.Value, error) { + err := scanPlan.Scan(src, &d) + if err != nil { + return nil, err + } + return d.Value() + } + case pgtype.XMLOID: + var d []byte + scanPlan := m.PlanScan(dataTypeOID, format, &d) + r.valueFuncs[i] = func(src []byte) (driver.Value, error) { + err := scanPlan.Scan(src, &d) + if err != nil { + return nil, err + } + return d, nil + } + default: + var d string + scanPlan := m.PlanScan(dataTypeOID, format, &d) + r.valueFuncs[i] = func(src []byte) (driver.Value, error) { + err := scanPlan.Scan(src, &d) + return d, err + } + } + } + } + + var more bool + if r.skipNext { + more = r.skipNextMore + r.skipNext = false + } else { + more = r.rows.Next() + } + + if !more { + if r.rows.Err() == nil { + return io.EOF + } else { + return r.rows.Err() + } + } + + for i, rv := range r.rows.RawValues() { + if rv != nil { + var err error + dest[i], err = r.valueFuncs[i](rv) + if err != nil { + return fmt.Errorf("convert field %d failed: %w", i, err) + } + } else { + dest[i] = nil + } + } + + return nil +} + +func convertNamedArguments(args []any, argsV []driver.NamedValue) { + for i, v := range argsV { + if v.Value != nil { + args[i] = v.Value.(any) + } else { + args[i] = nil + } + } +} + +type wrapTx struct { + ctx context.Context + tx pgx.Tx +} + +func (wtx wrapTx) Commit() error { return wtx.tx.Commit(wtx.ctx) } + +func (wtx wrapTx) Rollback() error { return wtx.tx.Rollback(wtx.ctx) } diff --git a/vendor/github.com/klauspost/compress/README.md b/vendor/github.com/klauspost/compress/README.md index fb023f2cf2..0e9f170d01 100644 --- a/vendor/github.com/klauspost/compress/README.md +++ b/vendor/github.com/klauspost/compress/README.md @@ -27,6 +27,18 @@ Use the links above for more information on each. # changelog +* Jul 1st, 2026 [1.19.0](https://github.com/klauspost/compress/releases/tag/v1.19.0) + * zstd: Add true concurrent stream encodingin https://github.com/klauspost/compress/pull/1136 + * zstd: arm64 decoder asm by @lizthegrey in https://github.com/klauspost/compress/pull/1160 + * flate: Add inflate checkpoints in https://github.com/klauspost/compress/pull/1154 + * zstd: avoid unused BuildDict encoder allocation by @snissn in https://github.com/klauspost/compress/pull/1147 + * snappy/s2: Limit length of varint in `decodedLen` by @eustas in https://github.com/klauspost/compress/pull/1148 + * gzhttp: match qvalue parameter case-insensitively (RFC 7231) by @z9z in https://github.com/klauspost/compress/pull/1149 + * zip: add NameDecoder callback for legacy encoding rewrite by @SAY-5 in https://github.com/klauspost/compress/pull/1150 + * huff0: Allow building tables from histogram in https://github.com/klauspost/compress/pull/1155 + * huff0: Allow building table from oversized histogram in https://github.com/klauspost/compress/pull/1156 + * s2sx: Clean symlink targets in https://github.com/klauspost/compress/pull/1163 + * Feb 9th, 2026 [1.18.4](https://github.com/klauspost/compress/releases/tag/v1.18.4) * gzhttp: Add zstandard to server handler wrapper https://github.com/klauspost/compress/pull/1121 * zstd: Add ResetWithOptions to encoder/decoder https://github.com/klauspost/compress/pull/1122 diff --git a/vendor/github.com/klauspost/compress/flate/inflate_gen.go b/vendor/github.com/klauspost/compress/flate/inflate_gen.go index 2b2f993f75..e80f196560 100644 --- a/vendor/github.com/klauspost/compress/flate/inflate_gen.go +++ b/vendor/github.com/klauspost/compress/flate/inflate_gen.go @@ -10,6 +10,15 @@ import ( "strings" ) +func peekBufio(fr *bufio.Reader) ([]byte, error) { + if fr.Buffered() == 0 { + if _, err := fr.Peek(1); err != nil && fr.Buffered() == 0 { + return nil, err + } + } + return fr.Peek(fr.Buffered()) +} + // Decode a single Huffman block from f. // hl and hd are the Huffman states for the lit/length values // and the distance values, respectively. If hd == nil, using the @@ -527,6 +536,8 @@ func (f *decompressor) huffmanBufioReader() { // but is smart enough to keep local variables in registers, so use nb and b, // inline call to moreBits and reassign b,nb back to f on return. fnb, fb, dict := f.nb, f.b, &f.dict + pbuf, _ := fr.Peek(fr.Buffered()) + pos := 0 switch f.stepState { case stateInit: @@ -548,12 +559,19 @@ readLiteral: n := uint(f.hl.maxRead) for { for fnb < n { - c, err := fr.ReadByte() - if err != nil { - f.b, f.nb = fb, fnb - f.err = noEOF(err) - return + if pos >= len(pbuf) { + fr.Discard(pos) + var err error + pbuf, err = peekBufio(fr) + pos = 0 + if len(pbuf) == 0 { + f.b, f.nb = fb, fnb + f.err = noEOF(err) + return + } } + c := pbuf[pos] + pos++ f.roffset++ fb |= uint32(c) << (fnb & regSizeMaskUint32) fnb += 8 @@ -566,6 +584,7 @@ readLiteral: } if n <= fnb { if n == 0 { + fr.Discard(pos) f.b, f.nb = fb, fnb if debugDecode { fmt.Println("huffsym: n==0") @@ -586,6 +605,7 @@ readLiteral: case v < 256: dict.writeByte(byte(v)) if dict.availWrite() == 0 { + fr.Discard(pos) f.toRead = dict.readFlush() f.step = huffmanBufioReader f.stepState = stateInit @@ -594,6 +614,7 @@ readLiteral: } goto readLiteral case v == 256: + fr.Discard(pos) f.b, f.nb = fb, fnb f.finishBlock() return @@ -605,15 +626,22 @@ readLiteral: length = int(val.length) + 3 n := uint(val.extra) for fnb < n { - c, err := fr.ReadByte() - if err != nil { - f.b, f.nb = fb, fnb - if debugDecode { - fmt.Println("morebits n>0:", err) + if pos >= len(pbuf) { + fr.Discard(pos) + var err error + pbuf, err = peekBufio(fr) + pos = 0 + if len(pbuf) == 0 { + f.b, f.nb = fb, fnb + if debugDecode { + fmt.Println("morebits n>0:", err) + } + f.err = err + return } - f.err = err - return } + c := pbuf[pos] + pos++ f.roffset++ fb |= uint32(c) << (fnb & regSizeMaskUint32) fnb += 8 @@ -622,6 +650,7 @@ readLiteral: fb >>= n & regSizeMaskUint32 fnb -= n default: + fr.Discard(pos) if debugDecode { fmt.Println(v, ">= maxNumLit") } @@ -633,15 +662,22 @@ readLiteral: var dist uint32 if f.hd == nil { for fnb < 5 { - c, err := fr.ReadByte() - if err != nil { - f.b, f.nb = fb, fnb - if debugDecode { - fmt.Println("morebits f.nb<5:", err) + if pos >= len(pbuf) { + fr.Discard(pos) + var err error + pbuf, err = peekBufio(fr) + pos = 0 + if len(pbuf) == 0 { + f.b, f.nb = fb, fnb + if debugDecode { + fmt.Println("morebits f.nb<5:", err) + } + f.err = err + return } - f.err = err - return } + c := pbuf[pos] + pos++ f.roffset++ fb |= uint32(c) << (fnb & regSizeMaskUint32) fnb += 8 @@ -660,12 +696,19 @@ readLiteral: // inline call to moreBits and reassign b,nb back to f on return. for { for fnb < n { - c, err := fr.ReadByte() - if err != nil { - f.b, f.nb = fb, fnb - f.err = noEOF(err) - return + if pos >= len(pbuf) { + fr.Discard(pos) + var err error + pbuf, err = peekBufio(fr) + pos = 0 + if len(pbuf) == 0 { + f.b, f.nb = fb, fnb + f.err = noEOF(err) + return + } } + c := pbuf[pos] + pos++ f.roffset++ fb |= uint32(c) << (fnb & regSizeMaskUint32) fnb += 8 @@ -678,6 +721,7 @@ readLiteral: } if n <= fnb { if n == 0 { + fr.Discard(pos) f.b, f.nb = fb, fnb if debugDecode { fmt.Println("huffsym: n==0") @@ -701,15 +745,22 @@ readLiteral: // have 1 bit in bottom of dist, need nb more. extra := (dist & 1) << (nb & regSizeMaskUint32) for fnb < nb { - c, err := fr.ReadByte() - if err != nil { - f.b, f.nb = fb, fnb - if debugDecode { - fmt.Println("morebits f.nb= len(pbuf) { + fr.Discard(pos) + var err error + pbuf, err = peekBufio(fr) + pos = 0 + if len(pbuf) == 0 { + f.b, f.nb = fb, fnb + if debugDecode { + fmt.Println("morebits f.nb uint32(dict.histSize()) { + fr.Discard(pos) f.b, f.nb = fb, fnb if debugDecode { fmt.Println("dist > dict.histSize():", dist, dict.histSize()) @@ -752,6 +805,7 @@ copyHistory: f.copyLen -= cnt if dict.availWrite() == 0 || f.copyLen > 0 { + fr.Discard(pos) f.toRead = dict.readFlush() f.step = huffmanBufioReader // We need to continue this work f.stepState = stateDict diff --git a/vendor/github.com/klauspost/compress/zstd/fse_decoder_arm64.s b/vendor/github.com/klauspost/compress/zstd/fse_decoder_arm64.s index 661761fb21..77ee3913f0 100644 --- a/vendor/github.com/klauspost/compress/zstd/fse_decoder_arm64.s +++ b/vendor/github.com/klauspost/compress/zstd/fse_decoder_arm64.s @@ -28,7 +28,10 @@ TEXT ·buildDtable_asm(SB), $0-24 init_main_loop: ADD R8<<1, R1, R15 MOVH (R15), R9 - CMN $1, R9 + AND $0xffff, R9, R15 + MOVD $-1, R16 + AND $0xffff, R16, R16 + CMP R16, R15 BNE do_not_update_high_threshold ADD R7<<3, R5, R15 MOVB R8, 1(R15) @@ -126,9 +129,13 @@ build_table_main_table: RET build_table_check1_ok: - TST R1, R1 + AND $0xff, R1, R15 + AND $0xff, R1, R16 + TST R16, R15 BNE build_table_check2_ok - CMP R6, R7 + AND $0xffff, R7, R15 + AND $0xffff, R6, R16 + CMP R16, R15 BNE build_table_check2_ok MOVD ctx+8(FP), R0 MOVD R7, 24(R0) diff --git a/vendor/github.com/klauspost/compress/zstd/seqdec_arm64.s b/vendor/github.com/klauspost/compress/zstd/seqdec_arm64.s index 75b19d732a..a468e5fc2c 100644 --- a/vendor/github.com/klauspost/compress/zstd/seqdec_arm64.s +++ b/vendor/github.com/klauspost/compress/zstd/seqdec_arm64.s @@ -670,9 +670,9 @@ error_overread: MOVD R16, ret+24(FP) RET -// skipped sequenceDecs_decode_bmi2 (BMI2 not available on arm64) +// skipped sequenceDecs_decode_bmi2 (generic twin preferred on arm64) -// skipped sequenceDecs_decode_56_bmi2 (BMI2 not available on arm64) +// skipped sequenceDecs_decode_56_bmi2 (generic twin preferred on arm64) // func sequenceDecs_executeSimple_amd64(ctx *executeAsmContext) bool // Requires: SSE @@ -1966,7 +1966,7 @@ error_not_enough_space: MOVD R16, ret+24(FP) RET -// skipped sequenceDecs_decodeSync_bmi2 (BMI2 not available on arm64) +// skipped sequenceDecs_decodeSync_bmi2 (generic twin preferred on arm64) // func sequenceDecs_decodeSync_safe_amd64(s *sequenceDecs, br *bitReader, ctx *decodeSyncAsmContext) int // Requires: CMOV, SSE @@ -2702,4 +2702,4 @@ error_not_enough_space: MOVD R16, ret+24(FP) RET -// skipped sequenceDecs_decodeSync_safe_bmi2 (BMI2 not available on arm64) +// skipped sequenceDecs_decodeSync_safe_bmi2 (generic twin preferred on arm64) diff --git a/vendor/github.com/klauspost/compress/zstd/snappy.go b/vendor/github.com/klauspost/compress/zstd/snappy.go index 336c288930..36c56f36d7 100644 --- a/vendor/github.com/klauspost/compress/zstd/snappy.go +++ b/vendor/github.com/klauspost/compress/zstd/snappy.go @@ -334,9 +334,10 @@ func decodeSnappy(blk *blockEnc, src []byte) error { return errUnsupportedLiteralLength } - //if length > snappyMaxBlockSize-d || uint32(length) > len(src)-s { - // return ErrSnappyCorrupt - //} + if length > len(src)-s { + println("length > len(src)-s", length, len(src)-s) + return ErrSnappyCorrupt + } blk.literals = append(blk.literals, src[s:s+length]...) //println(length, "litLen") diff --git a/vendor/github.com/mattn/go-isatty/isatty_others.go b/vendor/github.com/mattn/go-isatty/isatty_others.go index 7402e0618a..569113eac5 100644 --- a/vendor/github.com/mattn/go-isatty/isatty_others.go +++ b/vendor/github.com/mattn/go-isatty/isatty_others.go @@ -1,5 +1,5 @@ -//go:build (appengine || js || nacl || tinygo || wasm) && !windows -// +build appengine js nacl tinygo wasm +//go:build (appengine || js || nacl || tinygo || wasm || wasip1 || wasip2 || haiku) && !windows +// +build appengine js nacl tinygo wasm wasip1 wasip2 haiku // +build !windows package isatty diff --git a/vendor/github.com/mattn/go-isatty/isatty_windows.go b/vendor/github.com/mattn/go-isatty/isatty_windows.go index 8e3c99171b..5f29c11dd2 100644 --- a/vendor/github.com/mattn/go-isatty/isatty_windows.go +++ b/vendor/github.com/mattn/go-isatty/isatty_windows.go @@ -31,6 +31,10 @@ func init() { if procGetFileInformationByHandleEx.Find() != nil { procGetFileInformationByHandleEx = nil } + // Check if NtQueryObject is available. + if procNtQueryObject.Find() != nil { + procNtQueryObject = nil + } } // IsTerminal return true if the file descriptor is terminal. @@ -43,6 +47,7 @@ func IsTerminal(fd uintptr) bool { // Check pipe name is used for cygwin/msys2 pty. // Cygwin/MSYS2 PTY has a name like: // \{cygwin,msys}-XXXXXXXXXXXXXXXX-ptyN-{from,to}-master +// On Windows 7 a trailing suffix (e.g. "-nat") may be appended. func isCygwinPipeName(name string) bool { token := strings.Split(name, "-") if len(token) < 5 { @@ -72,13 +77,19 @@ func isCygwinPipeName(name string) bool { return false } + for _, t := range token[5:] { + if t == "" { + return false + } + } + return true } -// getFileNameByHandle use the undocomented ntdll NtQueryObject to get file full name from file handler +// getFileNameByHandle use the undocumented ntdll NtQueryObject to get file full name from file handler // since GetFileInformationByHandleEx is not available under windows Vista and still some old fashion // guys are using Windows XP, this is a workaround for those guys, it will also work on system from -// Windows vista to 10 +// Windows Vista to 10 // see https://stackoverflow.com/a/18792477 for details func getFileNameByHandle(fd uintptr) (string, error) { if procNtQueryObject == nil { diff --git a/vendor/github.com/mfridman/interpolate/LICENSE.txt b/vendor/github.com/mfridman/interpolate/LICENSE.txt new file mode 100644 index 0000000000..765b73b6c8 --- /dev/null +++ b/vendor/github.com/mfridman/interpolate/LICENSE.txt @@ -0,0 +1,24 @@ +MIT License + +Copyright (c) 2014-2017 Buildkite Pty Ltd +Copyright (c) 2023 Michael Fridman + + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/mfridman/interpolate/README.md b/vendor/github.com/mfridman/interpolate/README.md new file mode 100644 index 0000000000..48db562a15 --- /dev/null +++ b/vendor/github.com/mfridman/interpolate/README.md @@ -0,0 +1,85 @@ +# Interpolate + +[![Build Status](https://github.com/mfridman/interpolate/actions/workflows/ci.yaml/badge.svg)](https://github.com/mfridman/interpolate/actions/workflows/ci.yaml) +[![Go Reference](https://pkg.go.dev/badge/github.com/mfridman/interpolate.svg)](https://pkg.go.dev/github.com/mfridman/interpolate) +[![Go Report Card](https://goreportcard.com/badge/github.com/mfridman/interpolate)](https://goreportcard.com/report/github.com/mfridman/interpolate) + +A Go library for parameter expansion (like `${NAME}` or `$NAME`) in strings from environment +variables. An implementation of [POSIX Parameter +Expansion](http://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_06_02), +plus some other basic operations that you'd expect in a shell scripting environment [like +bash](https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html). + +## Installation + +``` +go get github.com/mfridman/interpolate@latest +``` + +## Usage + +```go +package main + +import ( + "github.com/mfridman/interpolate" + "fmt" +) + +func main() { + env := interpolate.NewSliceEnv([]string{ + "NAME=James", + }) + + output, _ := interpolate.Interpolate(env, "Hello... ${NAME} welcome to the ${ANOTHER_VAR:-🏖}") + + fmt.Println(output) + // Output: Hello... James welcome to the 🏖 +} +``` + +## Supported Expansions + +- `${parameter}` or `$parameter` + + - **Use value.** If parameter is set, then it shall be substituted; otherwise, it will be blank + +- `${parameter:-[word]}` + + - **Use default values.** If parameter is unset or null, the expansion of word (or an empty string + if word is omitted) shall be substituted; otherwise, the value of parameter shall be + substituted. + +- `${parameter-[word]}` + + - **Use default values when not set.** If parameter is unset, the expansion of word (or an empty + string if word is omitted) shall be substituted; otherwise, the value of parameter shall be + substituted. + +- `${parameter:[offset]}` + + - **Use the substring of parameter after offset.** A negative offset must be separated from the + colon with a space, and will select from the end of the string. If the value is out of bounds, + an empty string will be substituted. + +- `${parameter:[offset]:[length]}` + + - **Use the substring of parameter after offset of given length.** A negative offset must be + separated from the colon with a space, and will select from the end of the string. If the offset + is out of bounds, an empty string will be substituted. If the length is greater than the length + then the entire string will be returned. + +- `${parameter:?[word]}` + - **Indicate Error if Null or Unset.** If parameter is unset or null, the expansion of word (or a + message indicating it is unset if word is omitted) shall be returned as an error. + +## Prior work + +This repository is a fork of [buildkite/interpolate](https://github.com/buildkite/interpolate). I'd +like to thank the authors of that library for their work. I've forked it to make some changes that I +needed for my own use cases, and to make it easier to maintain. I've also added some tests and +documentation. + +## License + +Licensed under MIT license, in `LICENSE`. diff --git a/vendor/github.com/mfridman/interpolate/env.go b/vendor/github.com/mfridman/interpolate/env.go new file mode 100644 index 0000000000..d149e869fc --- /dev/null +++ b/vendor/github.com/mfridman/interpolate/env.go @@ -0,0 +1,53 @@ +package interpolate + +import ( + "runtime" + "strings" +) + +// Env is an interface for getting environment variables by name and returning a boolean indicating +// whether the variable was found. +type Env interface { + Get(key string) (string, bool) +} + +// NewSliceEnv creates an Env from a slice of environment variables in the form "key=value". +// +// This can be used with [os.Environ] to create an Env. +func NewSliceEnv(env []string) Env { + envMap := mapEnv{} + for _, l := range env { + parts := strings.SplitN(l, "=", 2) + if len(parts) == 2 { + envMap[normalizeKeyName(parts[0])] = parts[1] + } + } + return envMap +} + +// NewMapEnv creates an Env from a map of environment variables. +func NewMapEnv(env map[string]string) Env { + envMap := mapEnv{} + for k, v := range env { + envMap[normalizeKeyName(k)] = v + } + return envMap +} + +type mapEnv map[string]string + +func (m mapEnv) Get(key string) (string, bool) { + if m == nil { + return "", false + } + val, ok := m[normalizeKeyName(key)] + return val, ok +} + +// Windows isn't case sensitive for env +func normalizeKeyName(key string) string { + if runtime.GOOS == "windows" { + return strings.ToUpper(key) + } + return key +} diff --git a/vendor/github.com/mfridman/interpolate/interpolate.go b/vendor/github.com/mfridman/interpolate/interpolate.go new file mode 100644 index 0000000000..b9478a8553 --- /dev/null +++ b/vendor/github.com/mfridman/interpolate/interpolate.go @@ -0,0 +1,213 @@ +package interpolate + +import ( + "bytes" + "fmt" +) + +// Interpolate takes a set of environment and interpolates it into the provided string using shell +// script expansions +func Interpolate(env Env, str string) (string, error) { + if env == nil { + env = NewSliceEnv(nil) + } + expr, err := NewParser(str).Parse() + if err != nil { + return "", err + } + return expr.Expand(env) +} + +// Indentifiers parses the identifiers from any expansions in the provided string +func Identifiers(str string) ([]string, error) { + expr, err := NewParser(str).Parse() + if err != nil { + return nil, err + } + return expr.Identifiers(), nil +} + +// An expansion is something that takes in ENV and returns a string or an error +type Expansion interface { + Expand(env Env) (string, error) + Identifiers() []string +} + +// VariableExpansion represents either $VAR or ${VAR}, our simplest expansion +type VariableExpansion struct { + Identifier string +} + +func (e VariableExpansion) Identifiers() []string { + return []string{e.Identifier} +} + +func (e VariableExpansion) Expand(env Env) (string, error) { + val, _ := env.Get(e.Identifier) + return val, nil +} + +// EmptyValueExpansion returns either the value of an env, or a default value if it's unset or null +type EmptyValueExpansion struct { + Identifier string + Content Expression +} + +func (e EmptyValueExpansion) Identifiers() []string { + return append([]string{e.Identifier}, e.Content.Identifiers()...) +} + +func (e EmptyValueExpansion) Expand(env Env) (string, error) { + val, _ := env.Get(e.Identifier) + if val == "" { + return e.Content.Expand(env) + } + return val, nil +} + +// UnsetValueExpansion returns either the value of an env, or a default value if it's unset +type UnsetValueExpansion struct { + Identifier string + Content Expression +} + +func (e UnsetValueExpansion) Identifiers() []string { + return []string{e.Identifier} +} + +func (e UnsetValueExpansion) Expand(env Env) (string, error) { + val, ok := env.Get(e.Identifier) + if !ok { + return e.Content.Expand(env) + } + return val, nil +} + +// SubstringExpansion returns a substring (or slice) of the env +type SubstringExpansion struct { + Identifier string + Offset int + Length int + HasLength bool +} + +func (e SubstringExpansion) Identifiers() []string { + return []string{e.Identifier} +} + +func (e SubstringExpansion) Expand(env Env) (string, error) { + val, _ := env.Get(e.Identifier) + + from := e.Offset + + // Negative offsets = from end + if from < 0 { + from += len(val) + } + + // Still negative = too far from end? Truncate to start. + if from < 0 { + from = 0 + } + + // Beyond end? Truncate to end. + if from > len(val) { + from = len(val) + } + + if !e.HasLength { + return val[from:], nil + } + + to := e.Length + + if to >= 0 { + // Positive length = from offset + to += from + } else { + // Negative length = from end + to += len(val) + + // Too far? Truncate to offset. + if to < from { + to = from + } + } + + // Beyond end? Truncate to end. + if to > len(val) { + to = len(val) + } + + return val[from:to], nil +} + +// RequiredExpansion returns an env value, or an error if it is unset +type RequiredExpansion struct { + Identifier string + Message Expression +} + +func (e RequiredExpansion) Identifiers() []string { + return []string{e.Identifier} +} + +func (e RequiredExpansion) Expand(env Env) (string, error) { + val, ok := env.Get(e.Identifier) + if !ok { + msg, err := e.Message.Expand(env) + if err != nil { + return "", err + } + if msg == "" { + msg = "not set" + } + return "", fmt.Errorf("$%s: %s", e.Identifier, msg) + } + return val, nil +} + +// Expression is a collection of either Text or Expansions +type Expression []ExpressionItem + +func (e Expression) Identifiers() []string { + identifiers := []string{} + for _, item := range e { + if item.Expansion != nil { + identifiers = append(identifiers, item.Expansion.Identifiers()...) + } + } + return identifiers +} + +func (e Expression) Expand(env Env) (string, error) { + buf := &bytes.Buffer{} + + for _, item := range e { + if item.Expansion != nil { + result, err := item.Expansion.Expand(env) + if err != nil { + return "", err + } + _, _ = buf.WriteString(result) + } else { + _, _ = buf.WriteString(item.Text) + } + } + + return buf.String(), nil +} + +// ExpressionItem models either an Expansion or Text. Either/Or, never both. +type ExpressionItem struct { + Text string + // -- or -- + Expansion Expansion +} + +func (i ExpressionItem) String() string { + if i.Expansion != nil { + return fmt.Sprintf("%#v", i.Expansion) + } + return fmt.Sprintf("%q", i.Text) +} diff --git a/vendor/github.com/mfridman/interpolate/parser.go b/vendor/github.com/mfridman/interpolate/parser.go new file mode 100644 index 0000000000..936608e7a0 --- /dev/null +++ b/vendor/github.com/mfridman/interpolate/parser.go @@ -0,0 +1,281 @@ +package interpolate + +import ( + "fmt" + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +// This is a recursive descent parser for our grammar. Because it can contain nested expressions +// like ${LLAMAS:-${ROCK:-true}} we can't use regular expressions. The simplest possible alternative +// is a recursive parser like this. It parses a chunk and then calls a function to parse that +// further and so on and so forth. It results in a tree of objects that represent the things we've +// parsed (an AST). This means that the logic for how expansions work lives in those objects, and +// the logic for how we go from plain text to parsed objects lives here. +// +// To keep things simple, we do our "lexing" or "scanning" just as a few functions at the end of the +// file rather than as a dedicated lexer that emits tokens. This matches the simplicity of the +// format we are parsing relatively well +// +// Below is an EBNF grammar for the language. The parser was built by basically turning this into +// functions and structs named the same reading the string bite by bite (peekRune and nextRune) + +/* +EscapedBackslash = "\\" EscapedDollar = ( "\$" | "$$") Identifier = letter { letters | +digit | "_" } Expansion = "$" ( Identifier | Brace ) Brace = "{" Identifier [ +Identifier BraceOperation ] "}" Text = { EscapedBackslash | EscapedDollar | all characters except +"$" } Expression = { Text | Expansion } EmptyValue = ":-" { Expression } UnsetValue = +"-" { Expression } Substring = ":" number [ ":" number ] Required = "?" { Expression } +Operation = EmptyValue | UnsetValue | Substring | Required +*/ + +const ( + eof = -1 +) + +// Parser takes a string and parses out a tree of structs that represent text and Expansions +type Parser struct { + input string // the string we are scanning + pos int // the current position +} + +// NewParser returns a new instance of a Parser +func NewParser(str string) *Parser { + return &Parser{ + input: str, + pos: 0, + } +} + +// Parse expansions out of the internal text and return them as a tree of Expressions +func (p *Parser) Parse() (Expression, error) { + return p.parseExpression() +} + +func (p *Parser) parseExpression(stop ...rune) (Expression, error) { + var expr Expression + var stopStr = string(stop) + + for { + c := p.peekRune() + if c == eof || strings.ContainsRune(stopStr, c) { + break + } + + // check for our escaped characters first, as we assume nothing subsequently is escaped + if strings.HasPrefix(p.input[p.pos:], `\\`) { + p.pos += 2 + expr = append(expr, ExpressionItem{Text: `\\`}) + continue + } else if strings.HasPrefix(p.input[p.pos:], `\$`) || strings.HasPrefix(p.input[p.pos:], `$$`) { + p.pos += 2 + expr = append(expr, ExpressionItem{Text: `$`}) + continue + } + + // Ignore bash shell expansions + if strings.HasPrefix(p.input[p.pos:], `$(`) { + p.pos += 2 + expr = append(expr, ExpressionItem{Text: `$(`}) + continue + } + + // If we run into a dollar sign and it's not the last char, it's an expansion + if c == '$' && p.pos < (len(p.input)-1) { + expansion, err := p.parseExpansion() + if err != nil { + return nil, err + } + expr = append(expr, ExpressionItem{Expansion: expansion}) + continue + } + + // nibble a character, otherwise if it's a \ or a $ we can loop + c = p.nextRune() + + // Scan as much as we can into text + text := p.scanUntil(func(r rune) bool { + return (r == '$' || r == '\\' || strings.ContainsRune(stopStr, r)) + }) + + expr = append(expr, ExpressionItem{Text: string(c) + text}) + } + + return expr, nil +} + +func (p *Parser) parseExpansion() (Expansion, error) { + if c := p.nextRune(); c != '$' { + return nil, fmt.Errorf("Expected expansion to start with $, got %c", c) + } + + // if we have an open brace, this is a brace expansion + if c := p.peekRune(); c == '{' { + return p.parseBraceExpansion() + } + + identifier, err := p.scanIdentifier() + if err != nil { + return nil, err + } + + return VariableExpansion{Identifier: identifier}, nil +} + +func (p *Parser) parseBraceExpansion() (Expansion, error) { + if c := p.nextRune(); c != '{' { + return nil, fmt.Errorf("Expected brace expansion to start with {, got %c", c) + } + + identifier, err := p.scanIdentifier() + if err != nil { + return nil, err + } + + if c := p.peekRune(); c == '}' { + _ = p.nextRune() + return VariableExpansion{Identifier: identifier}, nil + } + + var operator string + var exp Expansion + + // Parse an operator, some trickery is needed to handle : vs :- + if op1 := p.nextRune(); op1 == ':' { + if op2 := p.peekRune(); op2 == '-' { + _ = p.nextRune() + operator = ":-" + } else { + operator = ":" + } + } else if op1 == '?' || op1 == '-' { + operator = string(op1) + } else { + return nil, fmt.Errorf("Expected an operator, got %c", op1) + } + + switch operator { + case `:-`: + exp, err = p.parseEmptyValueExpansion(identifier) + if err != nil { + return nil, err + } + case `-`: + exp, err = p.parseUnsetValueExpansion(identifier) + if err != nil { + return nil, err + } + case `:`: + exp, err = p.parseSubstringExpansion(identifier) + if err != nil { + return nil, err + } + case `?`: + exp, err = p.parseRequiredExpansion(identifier) + if err != nil { + return nil, err + } + } + + if c := p.nextRune(); c != '}' { + return nil, fmt.Errorf("Expected brace expansion to end with }, got %c", c) + } + + return exp, nil +} + +func (p *Parser) parseEmptyValueExpansion(identifier string) (Expansion, error) { + // parse an expression (text and expansions) up until the end of the brace + expr, err := p.parseExpression('}') + if err != nil { + return nil, err + } + + return EmptyValueExpansion{Identifier: identifier, Content: expr}, nil +} + +func (p *Parser) parseUnsetValueExpansion(identifier string) (Expansion, error) { + expr, err := p.parseExpression('}') + if err != nil { + return nil, err + } + + return UnsetValueExpansion{Identifier: identifier, Content: expr}, nil +} + +func (p *Parser) parseSubstringExpansion(identifier string) (Expansion, error) { + offset := p.scanUntil(func(r rune) bool { + return r == ':' || r == '}' + }) + + offsetInt, err := strconv.Atoi(strings.TrimSpace(offset)) + if err != nil { + return nil, fmt.Errorf("Unable to parse offset: %v", err) + } + + if c := p.peekRune(); c == '}' { + return SubstringExpansion{Identifier: identifier, Offset: offsetInt}, nil + } + + _ = p.nextRune() + length := p.scanUntil(func(r rune) bool { + return r == '}' + }) + + lengthInt, err := strconv.Atoi(strings.TrimSpace(length)) + if err != nil { + return nil, fmt.Errorf("Unable to parse length: %v", err) + } + + return SubstringExpansion{Identifier: identifier, Offset: offsetInt, Length: lengthInt, HasLength: true}, nil +} + +func (p *Parser) parseRequiredExpansion(identifier string) (Expansion, error) { + expr, err := p.parseExpression('}') + if err != nil { + return nil, err + } + + return RequiredExpansion{Identifier: identifier, Message: expr}, nil +} + +func (p *Parser) scanUntil(f func(rune) bool) string { + start := p.pos + for int(p.pos) < len(p.input) { + c, size := utf8.DecodeRuneInString(p.input[p.pos:]) + if c == utf8.RuneError || f(c) { + break + } + p.pos += size + } + return p.input[start:p.pos] +} + +func (p *Parser) scanIdentifier() (string, error) { + if c := p.peekRune(); !unicode.IsLetter(c) { + return "", fmt.Errorf("Expected identifier to start with a letter, got %c", c) + } + var notIdentifierChar = func(r rune) bool { + return (!unicode.IsLetter(r) && !unicode.IsNumber(r) && r != '_') + } + return p.scanUntil(notIdentifierChar), nil +} + +func (p *Parser) nextRune() rune { + if int(p.pos) >= len(p.input) { + return eof + } + c, size := utf8.DecodeRuneInString(p.input[p.pos:]) + p.pos += size + return c +} + +func (p *Parser) peekRune() rune { + if int(p.pos) >= len(p.input) { + return eof + } + c, _ := utf8.DecodeRuneInString(p.input[p.pos:]) + return c +} diff --git a/vendor/github.com/pressly/goose/v3/.gitignore b/vendor/github.com/pressly/goose/v3/.gitignore new file mode 100644 index 0000000000..f7073309c4 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/.gitignore @@ -0,0 +1,22 @@ +.idea +.vscode +.DS_Store +*.swp +*.test + +# Files output by tests +/bin + +# Coverage files +coverage.out +coverage.html + +# Local testing +.envrc +*.FAIL + +dist/ +release_notes.txt + +go.work +go.work.sum diff --git a/vendor/github.com/pressly/goose/v3/.golangci.yaml b/vendor/github.com/pressly/goose/v3/.golangci.yaml new file mode 100644 index 0000000000..93913f30eb --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/.golangci.yaml @@ -0,0 +1,31 @@ +version: "2" +linters: + default: none + enable: + - errcheck + - govet + - ineffassign + - misspell + - staticcheck + - testifylint + - unused + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/vendor/github.com/pressly/goose/v3/.goreleaser.yaml b/vendor/github.com/pressly/goose/v3/.goreleaser.yaml new file mode 100644 index 0000000000..db8a84bf70 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/.goreleaser.yaml @@ -0,0 +1,37 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# +# See https://goreleaser.com/customization/ for more information. +version: 2 +project_name: goose + +before: + hooks: + - go mod tidy +builds: + - env: + - CGO_ENABLED=0 + binary: goose + main: ./cmd/goose + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + ldflags: + # The v prefix is stripped by goreleaser, so we need to add it back. + # https://goreleaser.com/customization/templates/#fnref:version-prefix + - "-s -w -X main.version=v{{ .Version }}" + +archives: + - formats: + - binary + name_template: >- + {{ .ProjectName }}_{{- tolower .Os }}_{{- if eq .Arch "amd64" }}x86_64{{- else }}{{ .Arch }}{{ end }} +checksum: + name_template: "checksums.txt" +snapshot: + version_template: "{{ incpatch .Version }}-next" +changelog: + use: github-native diff --git a/vendor/github.com/pressly/goose/v3/CHANGELOG.md b/vendor/github.com/pressly/goose/v3/CHANGELOG.md new file mode 100644 index 0000000000..315fe5acef --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/CHANGELOG.md @@ -0,0 +1,357 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [v3.27.3] - 2026-07-22 + +### Changed + +- Various dependency upgrades + +## [v3.27.2] - 2026-06-30 + +### Changed + +- Upgrade `golang.org/x/crypto` and `golang.org/x/net` to address security advisories (CVE fixes) +- Various dependency upgrades + +## [v3.27.1] - 2026-04-24 + +### Changed + +- Bump minimum Go version to 1.25.7 +- Various dependency upgrades + +## [v3.27.0] - 2026-02-22 + +### Added + +- Preliminary Spanner dialect support (#966) + +### Changed + +- **Minimum Go version is now 1.25** +- SQL migration templates no longer include `StatementBegin` and `StatementEnd` annotations. These + are only needed for complex statements containing semicolons (e.g., stored procedures). See + [docs](https://pressly.github.io/goose/documentation/annotations/#complex-statements) for details. +- Various dependency upgrades + +## [v3.26.0] - 2025-10-03 + +- Add `*slog.Logger` support to goose provider via option `WithSlog` (#989) +- Add convenience `WithTableName` provider option (#985) +- Minor bug fixes and dependency upgrades +- Add general purpose `Locker` interface to support DB locking with a table-based Postgres + implementation via `lock.NewPostgresTableLocker` (#993 for more details) + - Unlike `SessionLocker`, this uses the `*sql.DB` connection pool + - Add `WithLocker` option to goose provider + +## [v3.25.0] - 2025-08-24 + +- Upgrade go deps (#976) +- Remove references/tests for vertica and add deprecation warnings (#978) +- Add Aurora DSQL as a new database dialect to goose `Provider` (#971) +- Add DDL isolation support for Aurora DSQL compatibility (#970) +- Update Apply to respect no versioning option (#950) +- Expose dialect `Querier` (#939) + +## [v3.24.3] + +- Add `GOOSE_TABLE` environment variable -- lower priority than `-table` flag, but higher than the + default table name. (#932) +- Dependency updates + +## [v3.24.2] + +- Add `TableExists` table existence check for the mysql dialect (#895) +- Upgrade **minimum Go version to 1.23** +- Various dependency updates + +## [v3.24.1] + +- Fix regression (`v3.23.1` and `v3.24.0`) in postgres migration table existence check for + non-default schema. (#882, #883, #884). + +## [v3.24.0] + +- Add support for loading environment variables from `.env` files, enabled by default. + - The default file name is `.env`, but can be changed with the `-env=` flag. + - To disable this feature, set `-env=none`. + +## [v3.23.1] + +- Store implementations can **optionally** implement the `TableExists` method to provide optimized + table existence checks (#860) + - Default postgres Store implementation updated to use `pg_tables` system catalog, more to follow + - Backward compatible change - existing implementations will continue to work without modification + +```go +TableExists(ctx context.Context, db database.DBTxConn) (bool, error) +``` + +## [v3.23.0] + +- Add `WithLogger` to `NewProvider` to allow custom loggers (#833) +- Update Provider `WithVerbose` behavior to log all SQL statements (#851) +- Upgrade dependencies and rebuild binaries with latest Go version (`go1.23.3`) + +## [v3.22.1] + +- Upgrade dependencies and rebuild binaries with latest Go version (`go1.23.1`) + +## [v3.22.0] + +- Minimum Go version is now 1.21 +- Add Unwrap to PartialError (#815) +- Allow flags anywhere on the CLI (#814) + +`goose` uses the default Go `flag` parsing library, which means flags **must** be defined before the +first positional argument. We've updated this behavior to allow flags to be defined anywhere. For +more details, see [blog post](https://mfridman.com/blog/2024/allowing-flags-anywhere-on-the-cli/). + +- Update `WithDisableGlobalRegistry` behavior (#783). When set, this will ignore globally-registered + migrationse entirely instead of the previous behavior of raising an error. Specifically, the + following check is removed: + +```go +if len(global) > 0 { + return nil, errors.New("global registry disabled, but provider has registered go migrations") +} +``` + +This enables creating isolated goose provider(s) in legacy environments where global migrations may +be registered. Without updating this behavior, it would be impossible to use +`WithDisableGlobalRegistry` in combination with provider-scoped `WithGoMigrations`. + +- Postgres, updated schema to use identity instead of serial and make `tstamp` not nullable (#556) + +```diff +- id serial NOT NULL, ++ id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + +- tstamp timestamp NULL default now(), ++ tstamp timestamp NOT NULL DEFAULT now() +``` + +- MySQL, updated schema to not use SERIAL alias (#816) + +```diff +- id serial NOT NULL, ++ id bigint(20) unsigned NOT NULL AUTO_INCREMENT, +``` + +## [v3.21.1] + +- Add `GetVersions` method to `goose.Provider`, returns the current (max db) version and the latest + (max filesystem) version. (#756) +- Clarify `GetLatestVersion` method MUST return `ErrVersionNotFound` if no latest migration is + found. Previously it was returning a -1 and nil error, which was inconsistent with the rest of the + API surface. + +- Add `GetLatestVersion` implementations to all existing dialects. This is an optimization to avoid + loading all migrations when only the latest version is needed. This uses the `max` function in SQL + to get the latest version_id irrespective of the order of applied migrations. + - Refactor existing portions of the code to use the new `GetLatestVersion` method. + +## [v3.21.0] + +- Retracted. Broken release, please use v3.21.1 instead. + +## [v3.20.0] + +- Expand the `Store` interface by adding a `GetLatestVersion` method and make the interface public. +- Add a (non-blocking) method to check if there are pending migrations to the `goose.Provider` + (#751): + +```go +func (p *Provider) HasPending(context.Context) (bool, error) {} +``` + +The underlying implementation **does not respect the `SessionLocker`** (if one is enabled) and can +be used to check for pending migrations without blocking or being blocked by other operations. + +- The methods `.Up`, `.UpByOne`, and `.UpTo` from `goose.Provider` will invoke `.HasPending` before + acquiring a lock with `SessionLocker` (if enabled). This addresses an edge case in + Kubernetes-style deployments where newer pods with long-running migrations prevent older pods - + which have all known migrations applied - from starting up due to an advisory lock. For more + details, refer to https://github.com/pressly/goose/pull/507#discussion_r1266498077 and #751. +- Move integration tests to `./internal/testing` and make it a separate Go module. This will allow + us to have a cleaner top-level go.mod file and avoid imports unrelated to the goose project. See + [integration/README.md](https://github.com/pressly/goose/blob/d0641b5bfb3bd5d38d95fe7a63d7ddf2d282234d/internal/testing/integration/README.md) + for more details. This shouldn't affect users of the goose library. + +## [v3.19.2] - 2024-03-13 + +- Remove duckdb support. The driver uses Cgo and we've decided to remove it until we can find a + better solution. If you were using duckdb with goose, please let us know by opening an issue. + +## [v3.19.1] - 2024-03-11 + +- Fix selecting dialect for `redshift` +- Add `GOOSE_MIGRATION_DIR` documentation +- Bump github.com/opencontainers/runc to `v1.1.12` (security fix) +- Update CI tests for go1.22 +- Make goose annotations case-insensitive + - All `-- +goose` annotations are now case-insensitive. This means that `-- +goose Up` and `-- ++goose up` are now equivalent. This change was made to improve the user experience and to make the + annotations more consistent. + +## [v3.19.0] - 2024-03-11 + +- Use [v3.19.1] instead. This was tagged but not released and does not contain release binaries. + +## [v3.18.0] - 2024-01-31 + +- Add environment variable substitution for SQL migrations. (#604) + + - This feature is **disabled by default**, and can be enabled by adding an annotation to the + migration file: + + ```sql + -- +goose ENVSUB ON + ``` + + - When enabled, goose will attempt to substitute environment variables in the SQL migration + queries until the end of the file, or until the annotation `-- +goose ENVSUB OFF` is found. For + example, if the environment variable `REGION` is set to `us_east_1`, the following SQL migration + will be substituted to `SELECT * FROM regions WHERE name = 'us_east_1';` + + ```sql + -- +goose ENVSUB ON + -- +goose Up + SELECT * FROM regions WHERE name = '${REGION}'; + ``` + +- Add native [Turso](https://turso.tech/) support with libsql driver. (#658) + +- Fixed query for list migrations in YDB (#684) + +## [v3.17.0] - 2023-12-15 + +- Standardised the MIT license (#647) +- Improve provider `Apply()` errors, add `ErrNotApplied` when attempting to rollback a migration + that has not been previously applied. (#660) +- Add `WithDisableGlobalRegistry` option to `NewProvider` to disable the global registry. (#645) +- Add `-timeout` flag to CLI to set the maximum allowed duration for queries to run. Default remains + no timeout. (#627) +- Add optional logging in `Provider` when `WithVerbose` option is supplied. (#668) + +⚠️ Potential Breaking Change ⚠️ + +- Update `goose create` to use UTC time instead of local time. (#242) + +## [v3.16.0] - 2023-11-12 + +- Added YDB support. (#592) +- Fix sqlserver query to ensure DB version. (#601) +- Allow setting / resetting the global Go migration registry. (#602) + - `SetGlobalMigrations` and `ResetGlobalMigrations` functions have been added. + - Introduce `NewGoMigration` for constructing Go migrations. +- Add initial implementation of `goose.NewProvider`. + +🎉 Read more about this new feature here: + +https://pressly.github.io/goose/blog/2023/goose-provider/ + +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 +- ... and more! + +## [v3.15.1] - 2023-10-10 + +- Fix regression that prevented registering Go migrations that didn't have the corresponding files + available in the filesystem. (#588) + - If Go migrations have been registered globally, but there are no .go files in the filesystem, + **always include** them. + - If Go migrations have been registered, and there are .go files in the filesystem, **only + include** those migrations. This was the original motivation behind #553. + - If there are .go files in the filesystem but not registered, **raise an error**. This is to + prevent accidentally adding valid looking Go migration files without explicitly registering + them. + +## [v3.15.0] - 2023-08-12 + +- Fix `sqlparser` to avoid skipping the last statement when it's not terminated with a semicolon + within a StatementBegin/End block. (#580) +- Add `**go1.21**` to the CI matrix. +- Bump minimum version of module in go.mod to `go1.19`. +- Fix version output when installing pre-built binaries (#585). + +## [v3.14.0] - 2023-07-26 + +- Filter registered Go migrations from the global map with corresponding .go files from the + filesystem. + - The code previously assumed all .go migrations would be in the same folder, so this should not + be a breaking change. + - See #553 for more details +- Improve output log message for applied up migrations. #562 +- Fix an issue where `AddMigrationNoTxContext` was registering the wrong source because it skipped + too many frames. #572 +- Improve binary version output when using go install. + +## [v3.13.4] - 2023-07-07 + +- Fix pre-built binary versioning and make small improvements to GoReleaser config. +- Fix an edge case in the `sqlparser` where the last up statement may be ignored if it's + unterminated with a semicolon and followed by a `-- +goose Down` annotation. +- Trim `Logger` interface to `Printf` and `Fatalf` methods only. Projects that have previously + implemented the `Logger` interface should not be affected, and can remove unused methods. + +## [v3.13.1] - 2023-07-03 + +- Add pre-built binaries with GoReleaser and update the build process. + +## [v3.13.0] - 2023-06-29 + +- Add a changelog to the project, based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +- Update go.mod and retract all `v3.12.X` tags. They were accidentally pushed and contain a + reference to the wrong Go module. +- Fix `up` and `up -allowing-missing` behavior. +- Fix empty version in log output. +- Add new `context.Context`-aware functions and methods, for both sql and go migrations. +- Return error when no migration files found or dir is not a directory. + +[Unreleased]: https://github.com/pressly/goose/compare/v3.27.3...HEAD +[v3.27.3]: https://github.com/pressly/goose/compare/v3.27.2...v3.27.3 +[v3.27.2]: https://github.com/pressly/goose/compare/v3.27.1...v3.27.2 +[v3.27.1]: https://github.com/pressly/goose/compare/v3.27.0...v3.27.1 +[v3.27.0]: https://github.com/pressly/goose/compare/v3.26.0...v3.27.0 +[v3.26.0]: https://github.com/pressly/goose/compare/v3.25.0...v3.26.0 +[v3.25.0]: https://github.com/pressly/goose/compare/v3.24.3...v3.25.0 +[v3.24.3]: https://github.com/pressly/goose/compare/v3.24.2...v3.24.3 +[v3.24.2]: https://github.com/pressly/goose/compare/v3.24.1...v3.24.2 +[v3.24.1]: https://github.com/pressly/goose/compare/v3.24.0...v3.24.1 +[v3.24.0]: https://github.com/pressly/goose/compare/v3.23.1...v3.24.0 +[v3.23.1]: https://github.com/pressly/goose/compare/v3.23.0...v3.23.1 +[v3.23.0]: https://github.com/pressly/goose/compare/v3.22.1...v3.23.0 +[v3.22.1]: https://github.com/pressly/goose/compare/v3.22.0...v3.22.1 +[v3.22.0]: https://github.com/pressly/goose/compare/v3.21.1...v3.22.0 +[v3.21.1]: https://github.com/pressly/goose/compare/v3.20.0...v3.21.1 +[v3.21.0]: https://github.com/pressly/goose/compare/v3.20.0...v3.21.0 +[v3.20.0]: https://github.com/pressly/goose/compare/v3.19.2...v3.20.0 +[v3.19.2]: https://github.com/pressly/goose/compare/v3.19.1...v3.19.2 +[v3.19.1]: https://github.com/pressly/goose/compare/v3.19.0...v3.19.1 +[v3.19.0]: https://github.com/pressly/goose/compare/v3.18.0...v3.19.0 +[v3.18.0]: https://github.com/pressly/goose/compare/v3.17.0...v3.18.0 +[v3.17.0]: https://github.com/pressly/goose/compare/v3.16.0...v3.17.0 +[v3.16.0]: https://github.com/pressly/goose/compare/v3.15.1...v3.16.0 +[v3.15.1]: https://github.com/pressly/goose/compare/v3.15.0...v3.15.1 +[v3.15.0]: https://github.com/pressly/goose/compare/v3.14.0...v3.15.0 +[v3.14.0]: https://github.com/pressly/goose/compare/v3.13.4...v3.14.0 +[v3.13.4]: https://github.com/pressly/goose/compare/v3.13.1...v3.13.4 +[v3.13.1]: https://github.com/pressly/goose/compare/v3.13.0...v3.13.1 +[v3.13.0]: https://github.com/pressly/goose/releases/tag/v3.13.0 diff --git a/vendor/github.com/pressly/goose/v3/LICENSE b/vendor/github.com/pressly/goose/v3/LICENSE new file mode 100644 index 0000000000..fd425e0d39 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Original work Copyright (c) 2012 Liam Staskawicz +Modified work Copyright (c) 2016 Vojtech Vitek +Modified work Copyright (c) 2021 Michael Fridman, Vojtech Vitek + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/pressly/goose/v3/Makefile b/vendor/github.com/pressly/goose/v3/Makefile new file mode 100644 index 0000000000..99c429ff19 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/Makefile @@ -0,0 +1,150 @@ +GO_TEST_FLAGS ?= -race -count=1 -v -timeout=5m -json + +# These are the default values for the test database. They can be overridden +DB_USER ?= dbuser +DB_PASSWORD ?= password1 +DB_NAME ?= testdb +DB_POSTGRES_PORT ?= 5433 +DB_MYSQL_PORT ?= 3307 +DB_CLICKHOUSE_PORT ?= 9001 +DB_YDB_PORT ?= 2136 +DB_TURSO_PORT ?= 8080 +DB_STARROCKS_PORT ?= 9030 + +list-build-tags: + @echo "Available build tags:" + @echo "$$(rg -o --trim 'no_[a-zA-Z0-9_]+' ./cmd/goose \ + --no-line-number --no-filename | sort | uniq | \ + xargs -n 4 | column -t | sed 's/^/ /')" + +.PHONY: dist +dist: + @mkdir -p ./bin + @rm -f ./bin/* + GOOS=darwin GOARCH=amd64 go build -o ./bin/goose-darwin64 ./cmd/goose + GOOS=linux GOARCH=amd64 go build -o ./bin/goose-linux64 ./cmd/goose + GOOS=linux GOARCH=386 go build -o ./bin/goose-linux386 ./cmd/goose + GOOS=windows GOARCH=amd64 go build -o ./bin/goose-windows64.exe ./cmd/goose + GOOS=windows GOARCH=386 go build -o ./bin/goose-windows386.exe ./cmd/goose + +.PHONY: clean +clean: + @find . -type f -name '*.FAIL' -delete + +.PHONY: lint +lint: tools + @golangci-lint run ./... --fix + +.PHONY: tools +tools: + @go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest + @go install github.com/mfridman/tparse@main + +test-packages: + go test $(GO_TEST_FLAGS) $$(go list ./... | grep -v -e /bin -e /cmd -e /examples) |\ + tparse --follow -sort=elapsed -trimpath=auto -all + +test-packages-short: + go test -test.short $(GO_TEST_FLAGS) $$(go list ./... | grep -v -e /bin -e /cmd -e /examples) |\ + tparse --follow -sort=elapsed + +coverage-short: + go test ./ -test.short $(GO_TEST_FLAGS) -cover -coverprofile=coverage.out | tparse --follow -sort=elapsed + go tool cover -html=coverage.out + +coverage: + go test ./ $(GO_TEST_FLAGS) -cover -coverprofile=coverage.out | tparse --follow -sort=elapsed + go tool cover -html=coverage.out + +test-lock-coverage: + go test ./internal/testing/integration/locking ./lock/internal/... -cover -coverpkg=./lock/internal/... -coverprofile=coverage.out + go tool cover -html=coverage.out -o coverage.html + @echo "Lock package coverage: $$(go tool cover -func=coverage.out | tail -1 | awk '{print $$3}')" + open coverage.html + +# +# Integration-related targets +# +add-gowork: + @[ -f go.work ] || go work init + @[ -f go.work.sum ] || go work use -r . + +remove-gowork: + rm -rf go.work go.work.sum + +upgrade-integration-deps: + cd ./internal/testing && go get -u ./... && go mod tidy + +test-postgres-long: add-gowork test-postgres + go test $(GO_TEST_FLAGS) ./internal/testing/integration -run='(TestPostgresProviderLocking|TestPostgresSessionLocker)' |\ + tparse --follow -sort=elapsed + +test-postgres: add-gowork + go test $(GO_TEST_FLAGS) ./internal/testing/integration -run="^TestPostgres$$" | tparse --follow -sort=elapsed + +test-spanner: add-gowork + go test $(GO_TEST_FLAGS) ./internal/testing/integration -run='TestSpanner' | tparse --follow -sort=elapsed + +test-clickhouse: add-gowork + go test $(GO_TEST_FLAGS) ./internal/testing/integration -run='(TestClickhouse|TestClickhouseRemote)' |\ + tparse --follow -sort=elapsed + +test-mysql: add-gowork + go test $(GO_TEST_FLAGS) ./internal/testing/integration -run='TestMySQL' | tparse --follow -sort=elapsed + +test-turso: add-gowork + go test $(GO_TEST_FLAGS) ./internal/testing/integration -run='TestTurso' | tparse --follow -sort=elapsed + +test-ydb: add-gowork + go test $(GO_TEST_FLAGS) ./internal/testing/integration -run='TestYDB' | tparse --follow -sort=elapsed + +test-starrocks: add-gowork + go test $(GO_TEST_FLAGS) ./internal/testing/integration -run='TestStarrocks' | tparse --follow -sort=elapsed + +test-integration: add-gowork + go test $(GO_TEST_FLAGS) ./internal/testing/integration/... | tparse --follow -sort=elapsed -trimpath=auto -all + +# +# Docker-related targets +# + +docker-cleanup: + docker stop -t=0 $$(docker ps --filter="label=goose_test" -aq) + +docker-postgres: + docker run --rm -d \ + -e POSTGRES_USER=$(DB_USER) \ + -e POSTGRES_PASSWORD=$(DB_PASSWORD) \ + -e POSTGRES_DB=$(DB_NAME) \ + -p $(DB_POSTGRES_PORT):5432 \ + -l goose_test \ + postgres:14-alpine -c log_statement=all + echo "postgres://$(DB_USER):$(DB_PASSWORD)@localhost:$(DB_POSTGRES_PORT)/$(DB_NAME)?sslmode=disable" + +docker-mysql: + docker run --rm -d \ + -e MYSQL_ROOT_PASSWORD=rootpassword1 \ + -e MYSQL_DATABASE=$(DB_NAME) \ + -e MYSQL_USER=$(DB_USER) \ + -e MYSQL_PASSWORD=$(DB_PASSWORD) \ + -p $(DB_MYSQL_PORT):3306 \ + -l goose_test \ + mysql:8.0.31 + echo "mysql://$(DB_USER):$(DB_PASSWORD)@localhost:$(DB_MYSQL_PORT)/$(DB_NAME)?parseTime=true" + +docker-clickhouse: + docker run --rm -d \ + -e CLICKHOUSE_DB=$(DB_NAME) \ + -e CLICKHOUSE_USER=$(DB_USER) \ + -e CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 \ + -e CLICKHOUSE_PASSWORD=$(DB_PASSWORD) \ + -p $(DB_CLICKHOUSE_PORT):9000/tcp \ + -l goose_test \ + clickhouse/clickhouse-server:23-alpine + echo "clickhouse://$(DB_USER):$(DB_PASSWORD)@localhost:$(DB_CLICKHOUSE_PORT)/$(DB_NAME)" + +docker-turso: + docker run --rm -d \ + -p $(DB_TURSO_PORT):8080 \ + -l goose_test \ + ghcr.io/tursodatabase/libsql-server:v0.22.10 diff --git a/vendor/github.com/pressly/goose/v3/README.md b/vendor/github.com/pressly/goose/v3/README.md new file mode 100644 index 0000000000..a022d8c8d4 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/README.md @@ -0,0 +1,500 @@ +# goose + + + +[![Goose +CI](https://github.com/pressly/goose/actions/workflows/ci.yaml/badge.svg)](https://github.com/pressly/goose/actions/workflows/ci.yaml) +[![Go +Reference](https://pkg.go.dev/badge/github.com/pressly/goose/v3.svg)](https://pkg.go.dev/github.com/pressly/goose/v3) +[![Go Report +Card](https://goreportcard.com/badge/github.com/pressly/goose/v3)](https://goreportcard.com/report/github.com/pressly/goose/v3) + +Goose is a database migration tool. Both a CLI and a library. + +Manage your **database schema** by creating incremental SQL changes or Go functions. + +#### Features + +- Works against multiple databases: + - Postgres, MySQL, MariaDB, Spanner, SQLite, + YDB, ClickHouse, MSSQL, Vertica, and more. +- Supports Go migrations written as plain functions. +- Supports [embedded](https://pkg.go.dev/embed/) migrations. +- Out-of-order migrations. +- Seeding data. +- Environment variable substitution in SQL migrations. +- ... and more. + +# Install + +```shell +go install github.com/pressly/goose/v3/cmd/goose@latest +``` + +This will install the `goose` binary to your `$GOPATH/bin` directory. + +Binary too big? Build a lite version by excluding the drivers you don't need: + +```shell +go build -tags='no_postgres no_mysql no_sqlite3 no_ydb' -o goose ./cmd/goose + +# Available build tags: +# no_clickhouse no_libsql no_mssql no_mysql +# no_postgres no_sqlite3 no_vertica no_ydb +``` + +For macOS users `goose` is available as a [Homebrew +Formulae](https://formulae.brew.sh/formula/goose#default): + +```shell +brew install goose +``` + +See [installation documentation](https://pressly.github.io/goose/installation/) for more details. + +# Usage + +
+Click to show goose help output + +``` +Usage: goose DRIVER DBSTRING [OPTIONS] COMMAND + +or + +Set environment key +GOOSE_DRIVER=DRIVER +GOOSE_DBSTRING=DBSTRING +GOOSE_MIGRATION_DIR=MIGRATION_DIR + +Usage: goose [OPTIONS] COMMAND + +Drivers: + postgres + mysql + sqlite3 + spanner + mssql + redshift + tidb + clickhouse + ydb + starrocks + turso + +Examples: + goose sqlite3 ./foo.db status + goose sqlite3 ./foo.db create init sql + goose sqlite3 ./foo.db create add_some_column sql + goose sqlite3 ./foo.db create fetch_user_data go + goose sqlite3 ./foo.db up + + goose postgres "user=postgres dbname=postgres sslmode=disable" status + goose mysql "user:password@/dbname?parseTime=true" status + goose spanner "projects/project/instances/instance/databases/database" status + goose redshift "postgres://user:password@qwerty.us-east-1.redshift.amazonaws.com:5439/db" status + goose tidb "user:password@/dbname?parseTime=true" status + goose mssql "sqlserver://user:password@hostname:1433?database=master" status + goose clickhouse "tcp://127.0.0.1:9000" status + goose ydb "grpcs://localhost:2135/local?go_query_mode=scripting&go_fake_tx=scripting&go_query_bind=declare,numeric" status + goose starrocks "user:password@/dbname?parseTime=true&interpolateParams=true" status + + GOOSE_DRIVER=sqlite3 GOOSE_DBSTRING=./foo.db goose status + GOOSE_DRIVER=sqlite3 GOOSE_DBSTRING=./foo.db goose create init sql + GOOSE_DRIVER=postgres GOOSE_DBSTRING="user=postgres dbname=postgres sslmode=disable" goose status + GOOSE_DRIVER=mysql GOOSE_DBSTRING="user:password@/dbname" goose status + GOOSE_DRIVER=redshift GOOSE_DBSTRING="postgres://user:password@qwerty.us-east-1.redshift.amazonaws.com:5439/db" goose status + GOOSE_DRIVER=clickhouse GOOSE_DBSTRING="clickhouse://user:password@qwerty.clickhouse.cloud:9440/dbname?secure=true&skip_verify=false" goose status + +Options: + + -allow-missing + applies missing (out-of-order) migrations + -certfile string + file path to root CA's certificates in pem format (only support on mysql) + -dir string + directory with migration files (default ".", can be set via the GOOSE_MIGRATION_DIR env variable). + -h print help + -no-color + disable color output (NO_COLOR env variable supported) + -no-versioning + apply migration commands with no versioning, in file order, from directory pointed to + -s use sequential numbering for new migrations + -ssl-cert string + file path to SSL certificates in pem format (only support on mysql) + -ssl-key string + file path to SSL key in pem format (only support on mysql) + -table string + migrations table name (default "goose_db_version"). If you use a schema that is not `public`, you should set `schemaname.goose_db_version` when running commands. + -timeout duration + maximum allowed duration for queries to run; e.g., 1h13m + -v enable verbose mode + -version + print version + +Commands: + up Migrate the DB to the most recent version available + up-by-one Migrate the DB up by 1 + up-to VERSION Migrate the DB to a specific VERSION + down Roll back the version by 1 + down-to VERSION Roll back to a specific VERSION + redo Re-run the latest migration + reset Roll back all migrations + status Dump the migration status for the current DB + version Print the current version of the database + create NAME [sql|go] Creates new migration file with the current timestamp + fix Apply sequential ordering to migrations + validate Check migration files without running them +``` + +
+ +Commonly used commands: + +[create](#create) •  [up](#up) •  [up-to](#up-to) •  [down](#down) •  [down-to](#down-to) •  [status](#status) •  [version](#version) + +## create + +Create a new SQL migration. + + $ goose create add_some_column sql + $ Created new file: 20170506082420_add_some_column.sql + + $ goose -s create add_some_column sql + $ Created new file: 00001_add_some_column.sql + +Edit the newly created file to define the behavior of your migration. + +You can also create a Go migration, if you then invoke it with [your own goose +binary](#go-migrations): + + $ goose create fetch_user_data go + $ Created new file: 20170506082421_fetch_user_data.go + +## up + +Apply all available migrations. + + $ goose up + $ OK 001_basics.sql + $ OK 002_next.sql + $ OK 003_and_again.go + +## up-to + +Migrate up to a specific version. + + $ goose up-to 20170506082420 + $ OK 20170506082420_create_table.sql + +## up-by-one + +Migrate up a single migration from the current version + + $ goose up-by-one + $ OK 20170614145246_change_type.sql + +## down + +Roll back a single migration from the current version. + + $ goose down + $ OK 003_and_again.go + +## down-to + +Roll back migrations to a specific version. + + $ goose down-to 20170506082527 + $ OK 20170506082527_alter_column.sql + +Or, roll back all migrations (careful!): + + $ goose down-to 0 + +## status + +Print the status of all migrations: + + $ goose status + $ Applied At Migration + $ ======================================= + $ Sun Jan 6 11:25:03 2013 -- 001_basics.sql + $ Sun Jan 6 11:25:03 2013 -- 002_next.sql + $ Pending -- 003_and_again.go + +Note: for MySQL and MariaDB (use the `mysql` driver) [parseTime flag](https://github.com/go-sql-driver/mysql#parsetime) must be enabled. + +Note: for MySQL and MariaDB +[`multiStatements`](https://github.com/go-sql-driver/mysql?tab=readme-ov-file#multistatements) must +be enabled. This is required when writing multiple queries separated by ';' characters in a single +sql file. + +## version + +Print the current version of the database: + + $ goose version + $ goose: version 002 + +# Environment Variables + +If you prefer to use environment variables, instead of passing the driver and database string as +arguments, you can set the following environment variables: + +**1. Via environment variables:** + +```shell +export GOOSE_DRIVER=DRIVER +export GOOSE_DBSTRING=DBSTRING +export GOOSE_MIGRATION_DIR=MIGRATION_DIR +export GOOSE_TABLE=TABLENAME +``` + +**2. Via `.env` files with corresponding variables. `.env` file example**: + +```env +GOOSE_DRIVER=postgres +GOOSE_DBSTRING=postgres://admin:admin@localhost:5432/admin_db +GOOSE_MIGRATION_DIR=./migrations +GOOSE_TABLE=custom.goose_migrations +``` + +Loading from `.env` files is enabled by default. To disable this feature, set the `-env=none` flag. +If you want to load from a specific file, set the `-env` flag to the file path. + +For more details about environment variables, see the [official documentation on environment +variables](https://pressly.github.io/goose/documentation/environment-variables/). + +# Migrations + +goose supports migrations written in SQL or in Go. + +## SQL Migrations + +A sample SQL migration looks like: + +```sql +-- +goose Up +CREATE TABLE post ( + id int NOT NULL, + title text, + body text, + PRIMARY KEY(id) +); + +-- +goose Down +DROP TABLE post; +``` + +Each migration file must have exactly one `-- +goose Up` annotation. The `-- +goose Down` annotation +is optional. If the file has both annotations, then the `-- +goose Up` annotation **must** come +first. + +Notice the annotations in the comments. Any statements following `-- +goose Up` will be executed as +part of a forward migration, and any statements following `-- +goose Down` will be executed as part +of a rollback. + +By default, all migrations are run within a transaction. Some statements like `CREATE DATABASE`, +however, cannot be run within a transaction. You may optionally add `-- +goose NO TRANSACTION` to +the top of your migration file in order to skip transactions within that specific migration file. +Both Up and Down migrations within this file will be run without transactions. + +By default, SQL statements are delimited by semicolons - in fact, query statements must end with a +semicolon to be properly recognized by goose. + +By default, all migrations are run on the public schema. If you want to use a different schema, +specify the schema name using the table option like `-table='schemaname.goose_db_version`. + +More complex statements (PL/pgSQL) that have semicolons within them must be annotated with `-- ++goose StatementBegin` and `-- +goose StatementEnd` to be properly recognized. For example: + +```sql +-- +goose Up +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION histories_partition_creation( DATE, DATE ) +returns void AS $$ +DECLARE + create_query text; +BEGIN + FOR create_query IN SELECT + 'CREATE TABLE IF NOT EXISTS histories_' + || TO_CHAR( d, 'YYYY_MM' ) + || ' ( CHECK( created_at >= timestamp ''' + || TO_CHAR( d, 'YYYY-MM-DD 00:00:00' ) + || ''' AND created_at < timestamp ''' + || TO_CHAR( d + INTERVAL '1 month', 'YYYY-MM-DD 00:00:00' ) + || ''' ) ) inherits ( histories );' + FROM generate_series( $1, $2, '1 month' ) AS d + LOOP + EXECUTE create_query; + END LOOP; -- LOOP END +END; -- FUNCTION END +$$ +language plpgsql; +-- +goose StatementEnd +``` + +Goose supports environment variable substitution in SQL migrations through annotations. To enable +this feature, use the `-- +goose ENVSUB ON` annotation before the queries where you want +substitution applied. It stays active until the `-- +goose ENVSUB OFF` annotation is encountered. +You can use these annotations multiple times within a file. + +This feature is disabled by default for backward compatibility with existing scripts. + +For `PL/pgSQL` functions or other statements where substitution is not desired, wrap the annotations +explicitly around the relevant parts. For example, to exclude escaping the `$$` characters: + +```sql +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION test_func() +RETURNS void AS $$ +-- +goose ENVSUB ON +BEGIN + RAISE NOTICE '${SOME_ENV_VAR}'; +END; +-- +goose ENVSUB OFF +$$ LANGUAGE plpgsql; +-- +goose StatementEnd +``` + +
+Supported expansions (click here to expand): + +- `${VAR}` or $VAR - expands to the value of the environment variable `VAR` +- `${VAR:-default}` - expands to the value of the environment variable `VAR`, or `default` if `VAR` + is unset or null +- `${VAR-default}` - expands to the value of the environment variable `VAR`, or `default` if `VAR` + is unset +- `${VAR?err_msg}` - expands to the value of the environment variable `VAR`, or prints `err_msg` and + error if `VAR` unset +- ~~`${VAR:?err_msg}` - expands to the value of the environment variable `VAR`, or prints `err_msg` + and error if `VAR` unset or null.~~ **THIS IS NOT SUPPORTED** + +See +[mfridman/interpolate](https://github.com/mfridman/interpolate?tab=readme-ov-file#supported-expansions) +for more details on supported expansions. + +
+ +## Embedded sql migrations + +Go 1.16 introduced new feature: [compile-time embedding](https://pkg.go.dev/embed/) files into +binary and corresponding [filesystem abstraction](https://pkg.go.dev/io/fs/). + +This feature can be used only for applying existing migrations. Modifying operations such as `fix` +and `create` will continue to operate on OS filesystem even if using embedded files. This is +expected behaviour because `io/fs` interfaces allows read-only access. + +Make sure to configure the correct SQL dialect, see [dialect.go](./dialect.go) for supported SQL +dialects. + +Example usage, assuming that SQL migrations are placed in the `migrations` directory: + +```go +package main + +import ( + "database/sql" + "embed" + + "github.com/pressly/goose/v3" +) + +//go:embed migrations/*.sql +var embedMigrations embed.FS + +func main() { + var db *sql.DB + // setup database + + goose.SetBaseFS(embedMigrations) + + if err := goose.SetDialect("postgres"); err != nil { + panic(err) + } + + if err := goose.Up(db, "migrations"); err != nil { + panic(err) + } + + // run app +} +``` + +Note that we pass `"migrations"` as directory argument in `Up` because embedding saves directory +structure. + +## Go Migrations + +1. Create your own goose binary, see [example](./examples/go-migrations) +2. Import `github.com/pressly/goose` +3. Register your migration functions +4. Include your `migrations` package into Go build: in `main.go`, `import _ "github.com/me/myapp/migrations"` +5. Run goose command, ie. `goose.Up(db *sql.DB, dir string)` + +A [sample Go migration 00002_users_add_email.go file](./examples/go-migrations/00002_rename_root.go) +looks like: + +```go +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(Up, Down) +} + +func Up(tx *sql.Tx) error { + _, err := tx.Exec("UPDATE users SET username='admin' WHERE username='root';") + if err != nil { + return err + } + return nil +} + +func Down(tx *sql.Tx) error { + _, err := tx.Exec("UPDATE users SET username='root' WHERE username='admin';") + if err != nil { + return err + } + return nil +} +``` + +Note that Go migration files must begin with a numeric value, followed by an underscore, and must +not end with `*_test.go`. + +# Hybrid Versioning + +Please, read the [versioning +problem](https://github.com/pressly/goose/issues/63#issuecomment-428681694) first. + +By default, if you attempt to apply missing (out-of-order) migrations `goose` will raise an error. +However, If you want to apply these missing migrations pass goose the `-allow-missing` flag, or if +using as a library supply the functional option `goose.WithAllowMissing()` to Up, UpTo or UpByOne. + +However, we strongly recommend adopting a hybrid versioning approach, using both timestamps and +sequential numbers. Migrations created during the development process are timestamped and sequential +versions are ran on production. We believe this method will prevent the problem of conflicting +versions when writing software in a team environment. + +To help you adopt this approach, `create` will use the current timestamp as the migration version. +When you're ready to deploy your migrations in a production environment, we also provide a helpful +`fix` command to convert your migrations into sequential order, while preserving the timestamp +ordering. We recommend running `fix` in the CI pipeline, and only when the migrations are ready for +production. + +## Credit + +The gopher mascot was designed by [Renée French](https://reneefrench.blogspot.com/) / [CC +3.0.](https://creativecommons.org/licenses/by/3.0/) For more info check out the [Go +Blog](https://go.dev/blog/gopher). Adapted by Ellen. + +## License + +Licensed under [MIT License](./LICENSE) diff --git a/vendor/github.com/pressly/goose/v3/create.go b/vendor/github.com/pressly/goose/v3/create.go new file mode 100644 index 0000000000..a26eae510c --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/create.go @@ -0,0 +1,116 @@ +package goose + +import ( + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "text/template" + "time" +) + +type tmplVars struct { + Version string + CamelName string +} + +var ( + sequential = false +) + +// SetSequential set whether to use sequential versioning instead of timestamp based versioning +func SetSequential(s bool) { + sequential = s +} + +// Create writes a new blank migration file. +func CreateWithTemplate(db *sql.DB, dir string, tmpl *template.Template, name, migrationType string) error { + version := time.Now().UTC().Format(timestampFormat) + + if sequential { + // always use DirFS here because it's modifying operation + migrations, err := collectMigrationsFS(osFS{}, dir, minVersion, maxVersion, registeredGoMigrations) + if err != nil && !errors.Is(err, ErrNoMigrationFiles) { + return err + } + + vMigrations, err := migrations.versioned() + if err != nil { + return err + } + + if last, err := vMigrations.Last(); err == nil { + version = fmt.Sprintf(seqVersionTemplate, last.Version+1) + } else { + version = fmt.Sprintf(seqVersionTemplate, int64(1)) + } + } + + filename := fmt.Sprintf("%v_%v.%v", version, snakeCase(name), migrationType) + + if tmpl == nil { + if migrationType == "go" { + tmpl = goSQLMigrationTemplate + } else { + tmpl = sqlMigrationTemplate + } + } + + path := filepath.Join(dir, filename) + if _, err := os.Stat(path); !os.IsNotExist(err) { + return fmt.Errorf("failed to create migration file: %w", err) + } + + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create migration file: %w", err) + } + defer f.Close() + + vars := tmplVars{ + Version: version, + CamelName: camelCase(name), + } + if err := tmpl.Execute(f, vars); err != nil { + return fmt.Errorf("failed to execute tmpl: %w", err) + } + + log.Printf("Created new file: %s", f.Name()) + return nil +} + +// Create writes a new blank migration file. +func Create(db *sql.DB, dir, name, migrationType string) error { + return CreateWithTemplate(db, dir, nil, name, migrationType) +} + +var sqlMigrationTemplate = template.Must(template.New("goose.sql-migration").Parse(`-- +goose Up +SELECT 'up SQL query'; + +-- +goose Down +SELECT 'down SQL query'; +`)) + +var goSQLMigrationTemplate = template.Must(template.New("goose.go-migration").Parse(`package migrations + +import ( + "context" + "database/sql" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(up{{.CamelName}}, down{{.CamelName}}) +} + +func up{{.CamelName}}(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is applied. + return nil +} + +func down{{.CamelName}}(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} +`)) diff --git a/vendor/github.com/pressly/goose/v3/database/dialect/querier.go b/vendor/github.com/pressly/goose/v3/database/dialect/querier.go new file mode 100644 index 0000000000..0cedcadaf7 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/database/dialect/querier.go @@ -0,0 +1,25 @@ +package dialect + +// Querier is the interface that wraps the basic methods to create a dialect specific query. +// +// It is intended tio be using with [database.NewStoreFromQuerier] to create a new [database.Store] +// implementation based on a custom querier. +type Querier interface { + // CreateTable returns the SQL query string to create the db version table. + CreateTable(tableName string) string + // InsertVersion returns the SQL query string to insert a new version into the db version table. + InsertVersion(tableName string) string + // DeleteVersion returns the SQL query string to delete a version from the db version table. + DeleteVersion(tableName string) string + // GetMigrationByVersion returns the SQL query string to get a single migration by version. + // + // The query should return the timestamp and is_applied columns. + GetMigrationByVersion(tableName string) string + // ListMigrations returns the SQL query string to list all migrations in descending order by id. + // + // The query should return the version_id and is_applied columns. + ListMigrations(tableName string) string + // GetLatestVersion returns the SQL query string to get the last version_id from the db version + // table. Returns a nullable int64 value. + GetLatestVersion(tableName string) string +} diff --git a/vendor/github.com/pressly/goose/v3/database/dialect/querier_extended.go b/vendor/github.com/pressly/goose/v3/database/dialect/querier_extended.go new file mode 100644 index 0000000000..7ab11510d1 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/database/dialect/querier_extended.go @@ -0,0 +1,22 @@ +package dialect + +// QuerierExtender extends the [Querier] interface with optional database-specific optimizations. +// While not required, implementing these methods can improve performance. +// +// IMPORTANT: This interface may be expanded in future versions. Implementors must be prepared to +// update their implementations when new methods are added. +// +// Example compile-time check: +// +// var _ QuerierExtender = (*CustomQuerierExtended)(nil) +// +// In short, it's exported to allow implementors to have a compile-time check that they are +// implementing the interface correctly. +type QuerierExtender interface { + Querier + + // TableExists returns a database-specific SQL query to check if a table exists. For example, + // implementations might query system catalogs like pg_tables or sqlite_master. Return empty + // string if not supported. + TableExists(tableName string) string +} diff --git a/vendor/github.com/pressly/goose/v3/database/dialects.go b/vendor/github.com/pressly/goose/v3/database/dialects.go new file mode 100644 index 0000000000..f239171db2 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/database/dialects.go @@ -0,0 +1,214 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/pressly/goose/v3/database/dialect" + "github.com/pressly/goose/v3/internal/dialects" +) + +// Dialect is the type of database dialect. +type Dialect string + +const ( + DialectCustom Dialect = "" + DialectClickHouse Dialect = "clickhouse" + DialectAuroraDSQL Dialect = "dsql" + DialectMSSQL Dialect = "mssql" + DialectMySQL Dialect = "mysql" + DialectPostgres Dialect = "postgres" + DialectRedshift Dialect = "redshift" + DialectSQLite3 Dialect = "sqlite3" + DialectSpanner Dialect = "spanner" + DialectStarrocks Dialect = "starrocks" + DialectTiDB Dialect = "tidb" + DialectTurso Dialect = "turso" + DialectYdB Dialect = "ydb" + + // DEPRECATED: Vertica support is deprecated and will be removed in a future release. + DialectVertica Dialect = "vertica" +) + +// NewStore returns a new [Store] implementation for the given dialect. +func NewStore(d Dialect, tableName string) (Store, error) { + if d == DialectCustom { + return nil, errors.New("custom dialect is not supported") + } + lookup := map[Dialect]dialect.Querier{ + DialectClickHouse: dialects.NewClickhouse(), + DialectAuroraDSQL: dialects.NewAuroraDSQL(), + DialectMSSQL: dialects.NewSqlserver(), + DialectMySQL: dialects.NewMysql(), + DialectPostgres: dialects.NewPostgres(), + DialectRedshift: dialects.NewRedshift(), + DialectSQLite3: dialects.NewSqlite3(), + DialectSpanner: dialects.NewSpanner(), + DialectStarrocks: dialects.NewStarrocks(), + DialectTiDB: dialects.NewTidb(), + DialectTurso: dialects.NewTurso(), + DialectVertica: dialects.NewVertica(), + DialectYdB: dialects.NewYDB(), + } + querier, ok := lookup[d] + if !ok { + return nil, fmt.Errorf("unknown dialect: %q", d) + } + return NewStoreFromQuerier(tableName, querier) +} + +// NewStoreFromQuerier returns a new [Store] implementation for the given querier. +// +// Most of the time you should use [NewStore] instead of this function, as it will automatically +// create a dialect-specific querier for you. This function is useful if you want to use a custom +// querier that is not part of the standard dialects. +func NewStoreFromQuerier(tableName string, querier dialect.Querier) (Store, error) { + if tableName == "" { + return nil, errors.New("table name must not be empty") + } + if querier == nil { + return nil, errors.New("querier must not be nil") + } + return &store{ + tableName: tableName, + querier: newQueryController(querier), + }, nil +} + +type store struct { + tableName string + querier *queryController +} + +var _ Store = (*store)(nil) + +func (s *store) Tablename() string { + return s.tableName +} + +func (s *store) CreateVersionTable(ctx context.Context, db DBTxConn) error { + q := s.querier.CreateTable(s.tableName) + if _, err := db.ExecContext(ctx, q); err != nil { + return fmt.Errorf("failed to create version table %q: %w", s.tableName, err) + } + return nil +} + +func (s *store) Insert(ctx context.Context, db DBTxConn, req InsertRequest) error { + q := s.querier.InsertVersion(s.tableName) + if _, err := db.ExecContext(ctx, q, req.Version, true); err != nil { + return fmt.Errorf("failed to insert version %d: %w", req.Version, err) + } + return nil +} + +func (s *store) Delete(ctx context.Context, db DBTxConn, version int64) error { + q := s.querier.DeleteVersion(s.tableName) + if _, err := db.ExecContext(ctx, q, version); err != nil { + return fmt.Errorf("failed to delete version %d: %w", version, err) + } + return nil +} + +func (s *store) GetMigration( + ctx context.Context, + db DBTxConn, + version int64, +) (*GetMigrationResult, error) { + q := s.querier.GetMigrationByVersion(s.tableName) + var result GetMigrationResult + if err := db.QueryRowContext(ctx, q, version).Scan( + &result.Timestamp, + &result.IsApplied, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: %d", ErrVersionNotFound, version) + } + return nil, fmt.Errorf("failed to get migration %d: %w", version, err) + } + return &result, nil +} + +func (s *store) GetLatestVersion(ctx context.Context, db DBTxConn) (int64, error) { + q := s.querier.GetLatestVersion(s.tableName) + var version sql.NullInt64 + if err := db.QueryRowContext(ctx, q).Scan(&version); err != nil { + return -1, fmt.Errorf("failed to get latest version: %w", err) + } + if !version.Valid { + return -1, fmt.Errorf("latest %w", ErrVersionNotFound) + } + return version.Int64, nil +} + +func (s *store) ListMigrations( + ctx context.Context, + db DBTxConn, +) ([]*ListMigrationsResult, error) { + q := s.querier.ListMigrations(s.tableName) + rows, err := db.QueryContext(ctx, q) + if err != nil { + return nil, fmt.Errorf("failed to list migrations: %w", err) + } + defer rows.Close() + + var migrations []*ListMigrationsResult + for rows.Next() { + var result ListMigrationsResult + if err := rows.Scan(&result.Version, &result.IsApplied); err != nil { + return nil, fmt.Errorf("failed to scan list migrations result: %w", err) + } + migrations = append(migrations, &result) + } + if err := rows.Err(); err != nil { + return nil, err + } + return migrations, nil +} + +// +// +// +// Additional methods that are not part of the core Store interface, but are extended by the +// [controller.StoreController] type. +// +// +// + +func (s *store) TableExists(ctx context.Context, db DBTxConn) (bool, error) { + q := s.querier.TableExists(s.tableName) + if q == "" { + return false, errors.ErrUnsupported + } + var exists bool + // Note, we do not pass the table name as an argument to the query, as the query should be + // pre-defined by the dialect. + if err := db.QueryRowContext(ctx, q).Scan(&exists); err != nil { + return false, fmt.Errorf("failed to check if table exists: %w", err) + } + return exists, nil +} + +var _ dialect.Querier = (*queryController)(nil) + +type queryController struct{ dialect.Querier } + +// newQueryController returns a new QueryController that wraps the given Querier. +func newQueryController(querier dialect.Querier) *queryController { + return &queryController{Querier: querier} +} + +// Optional methods + +// TableExists returns the SQL query string to check if the version table exists. If the Querier +// does not implement this method, it will return an empty string. +// +// Returns a boolean value. +func (c *queryController) TableExists(tableName string) string { + if t, ok := c.Querier.(interface{ TableExists(string) string }); ok { + return t.TableExists(tableName) + } + return "" +} diff --git a/vendor/github.com/pressly/goose/v3/database/doc.go b/vendor/github.com/pressly/goose/v3/database/doc.go new file mode 100644 index 0000000000..4748c72d89 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/database/doc.go @@ -0,0 +1,14 @@ +// Package database defines a generic [Store] interface for goose to use when interacting with the +// database. It is meant to be generic and not tied to any specific database technology. +// +// At a high level, a [Store] is responsible for: +// - Creating a version table +// - Inserting and deleting a version +// - Getting a specific version +// - Listing all applied versions +// +// Use the [NewStore] function to create a [Store] for one of the supported dialects. +// +// For more advanced use cases, it's possible to implement a custom [Store] for a database that +// goose does not support. +package database diff --git a/vendor/github.com/pressly/goose/v3/database/sql_extended.go b/vendor/github.com/pressly/goose/v3/database/sql_extended.go new file mode 100644 index 0000000000..8eaa9399a2 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/database/sql_extended.go @@ -0,0 +1,23 @@ +package database + +import ( + "context" + "database/sql" +) + +// DBTxConn is a thin interface for common methods that is satisfied by *sql.DB, *sql.Tx and +// *sql.Conn. +// +// There is a long outstanding issue to formalize a std lib interface, but alas. See: +// https://github.com/golang/go/issues/14468 +type DBTxConn interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +var ( + _ DBTxConn = (*sql.DB)(nil) + _ DBTxConn = (*sql.Tx)(nil) + _ DBTxConn = (*sql.Conn)(nil) +) diff --git a/vendor/github.com/pressly/goose/v3/database/store.go b/vendor/github.com/pressly/goose/v3/database/store.go new file mode 100644 index 0000000000..ff0e714a84 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/database/store.go @@ -0,0 +1,66 @@ +package database + +import ( + "context" + "errors" + "time" +) + +var ( + // ErrVersionNotFound must be returned by [GetMigration] or [GetLatestVersion] when a migration + // does not exist. + ErrVersionNotFound = errors.New("version not found") + + // ErrNotImplemented must be returned by methods that are not implemented. + ErrNotImplemented = errors.New("not implemented") +) + +// Store is an interface that defines methods for tracking and managing migrations. It is used by +// the goose package to interact with a database. By defining a Store interface, multiple +// implementations can be created to support different databases without reimplementing the +// migration logic. +// +// This package provides several dialects that implement the Store interface. While most users won't +// need to create their own Store, if you need to support a database that isn't currently supported, +// you can implement your own! +type Store interface { + // Tablename is the name of the version table. This table is used to record applied migrations + // and must not be an empty string. + Tablename() string + // CreateVersionTable creates the version table, which is used to track migrations. + CreateVersionTable(ctx context.Context, db DBTxConn) error + // Insert a version id into the version table. + Insert(ctx context.Context, db DBTxConn, req InsertRequest) error + // Delete a version id from the version table. + Delete(ctx context.Context, db DBTxConn, version int64) error + // GetMigration retrieves a single migration by version id. If the query succeeds, but the + // version is not found, this method must return [ErrVersionNotFound]. + GetMigration(ctx context.Context, db DBTxConn, version int64) (*GetMigrationResult, error) + // GetLatestVersion retrieves the last applied migration version. If no migrations exist, this + // method must return [ErrVersionNotFound]. + GetLatestVersion(ctx context.Context, db DBTxConn) (int64, error) + // ListMigrations retrieves all migrations sorted in descending order by id or timestamp. If + // there are no migrations, return empty slice with no error. Typically this method will return + // at least one migration, because the initial version (0) is always inserted into the version + // table when it is created. + ListMigrations(ctx context.Context, db DBTxConn) ([]*ListMigrationsResult, error) +} + +type InsertRequest struct { + Version int64 + + // TODO(mf): in the future, we maybe want to expand this struct so implementors can store + // additional information. See the following issues for more information: + // - https://github.com/pressly/goose/issues/422 + // - https://github.com/pressly/goose/issues/288 +} + +type GetMigrationResult struct { + Timestamp time.Time + IsApplied bool +} + +type ListMigrationsResult struct { + Version int64 + IsApplied bool +} diff --git a/vendor/github.com/pressly/goose/v3/database/store_extended.go b/vendor/github.com/pressly/goose/v3/database/store_extended.go new file mode 100644 index 0000000000..e3aae4d9a8 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/database/store_extended.go @@ -0,0 +1,33 @@ +package database + +import "context" + +// StoreExtender is an extension of the Store interface that provides optional optimizations and +// database-specific features. While not required by the core goose package, implementing these +// methods can improve performance and functionality for specific databases. +// +// IMPORTANT: This interface may be expanded in future versions. Implementors MUST be prepared to +// update their implementations when new methods are added, either by implementing the new +// functionality or returning [errors.ErrUnsupported]. +// +// The goose package handles these extended capabilities through a [controller.StoreController], +// which automatically uses optimized methods when available while falling back to default behavior +// when they're not implemented. +// +// Example usage to verify implementation: +// +// var _ StoreExtender = (*CustomStoreExtended)(nil) +// +// In short, it's exported to allows implementors to have a compile-time check that they are +// implementing the interface correctly. +type StoreExtender interface { + Store + + // TableExists checks if the migrations table exists in the database. Implementing this method + // allows goose to optimize table existence checks by using database-specific system catalogs + // (e.g., pg_tables for PostgreSQL, sqlite_master for SQLite) instead of generic SQL queries. + // + // Return [errors.ErrUnsupported] if the database does not provide an efficient way to check + // table existence. + TableExists(ctx context.Context, db DBTxConn) (bool, error) +} diff --git a/vendor/github.com/pressly/goose/v3/db.go b/vendor/github.com/pressly/goose/v3/db.go new file mode 100644 index 0000000000..a8606f7c32 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/db.go @@ -0,0 +1,48 @@ +package goose + +import ( + "database/sql" + "fmt" +) + +// OpenDBWithDriver creates a connection to a database, and modifies goose internals to be +// compatible with the supplied driver by calling SetDialect. +func OpenDBWithDriver(driver string, dbstring string) (*sql.DB, error) { + if err := SetDialect(driver); err != nil { + return nil, err + } + + // The Go ecosystem has added more and more drivers over the years. As a result, there's no + // longer a one-to-one match between the driver name and the dialect name. For instance, there's + // no "redshift" driver, but that's the internal dialect name within goose. Hence, we need to + // convert the dialect name to a supported driver name. This conversion is a best-effort + // attempt, as we can't support both lib/pq and pgx, which some users might have. + // + // We recommend users to create a [NewProvider] with the desired dialect, open a connection + // using their preferred driver, and provide the *sql.DB to goose. This approach removes the + // need for mapping dialects to drivers, rendering this function unnecessary. + + switch driver { + case "mssql": + driver = "sqlserver" + case "tidb": + driver = "mysql" + case "spanner": + driver = "spanner" + case "turso": + driver = "libsql" + case "sqlite3": + driver = "sqlite" + case "postgres", "redshift": + driver = "pgx" + case "starrocks": + driver = "mysql" + } + + switch driver { + case "postgres", "pgx", "sqlite3", "sqlite", "spanner", "mysql", "sqlserver", "clickhouse", "vertica", "azuresql", "ydb", "libsql", "starrocks": + return sql.Open(driver, dbstring) + default: + return nil, fmt.Errorf("unsupported driver %s", driver) + } +} diff --git a/vendor/github.com/pressly/goose/v3/dialect.go b/vendor/github.com/pressly/goose/v3/dialect.go new file mode 100644 index 0000000000..b011e858c7 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/dialect.go @@ -0,0 +1,74 @@ +package goose + +import ( + "fmt" + + "github.com/pressly/goose/v3/database" + "github.com/pressly/goose/v3/internal/legacystore" +) + +// Dialect is the type of database dialect. It is an alias for [database.Dialect]. +type Dialect = database.Dialect + +const ( + DialectCustom Dialect = database.DialectCustom + DialectClickHouse Dialect = database.DialectClickHouse + DialectMSSQL Dialect = database.DialectMSSQL + DialectMySQL Dialect = database.DialectMySQL + DialectPostgres Dialect = database.DialectPostgres + DialectRedshift Dialect = database.DialectRedshift + DialectSQLite3 Dialect = database.DialectSQLite3 + DialectSpanner Dialect = database.DialectSpanner + DialectStarrocks Dialect = database.DialectStarrocks + DialectTiDB Dialect = database.DialectTiDB + DialectTurso Dialect = database.DialectTurso + DialectYdB Dialect = database.DialectYdB + + // Dialects only available to the [Provider]. + DialectAuroraDSQL Dialect = database.DialectAuroraDSQL + + // DEPRECATED: Vertica support is deprecated and will be removed in a future release. + DialectVertica Dialect = database.DialectVertica +) + +func init() { + store, _ = legacystore.NewStore(DialectPostgres) +} + +var store legacystore.Store + +// SetDialect sets the dialect to use for the goose package. +func SetDialect(s string) error { + var d Dialect + switch s { + case "postgres", "pgx": + d = DialectPostgres + case "mysql": + d = DialectMySQL + case "sqlite3", "sqlite": + d = DialectSQLite3 + case "spanner": + d = DialectSpanner + case "mssql", "azuresql", "sqlserver": + d = DialectMSSQL + case "redshift": + d = DialectRedshift + case "tidb": + d = DialectTiDB + case "clickhouse": + d = DialectClickHouse + case "vertica": + d = DialectVertica + case "ydb": + d = DialectYdB + case "turso": + d = DialectTurso + case "starrocks": + d = DialectStarrocks + default: + return fmt.Errorf("%q: unknown dialect", s) + } + var err error + store, err = legacystore.NewStore(d) + return err +} diff --git a/vendor/github.com/pressly/goose/v3/down.go b/vendor/github.com/pressly/goose/v3/down.go new file mode 100644 index 0000000000..29e7daaeeb --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/down.go @@ -0,0 +1,107 @@ +package goose + +import ( + "context" + "database/sql" + "fmt" +) + +// Down rolls back a single migration from the current version. +func Down(db *sql.DB, dir string, opts ...OptionsFunc) error { + ctx := context.Background() + return DownContext(ctx, db, dir, opts...) +} + +// DownContext rolls back a single migration from the current version. +func DownContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { + option := &options{} + for _, f := range opts { + f(option) + } + migrations, err := CollectMigrations(dir, minVersion, maxVersion) + if err != nil { + return err + } + if option.noVersioning { + if len(migrations) == 0 { + return nil + } + currentVersion := migrations[len(migrations)-1].Version + // Migrate only the latest migration down. + return downToNoVersioning(ctx, db, migrations, currentVersion-1) + } + currentVersion, err := GetDBVersionContext(ctx, db) + if err != nil { + return err + } + current, err := migrations.Current(currentVersion) + if err != nil { + return fmt.Errorf("migration %v: %w", currentVersion, err) + } + return current.DownContext(ctx, db) +} + +// DownTo rolls back migrations to a specific version. +func DownTo(db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { + ctx := context.Background() + return DownToContext(ctx, db, dir, version, opts...) +} + +// DownToContext rolls back migrations to a specific version. +func DownToContext(ctx context.Context, db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { + option := &options{} + for _, f := range opts { + f(option) + } + migrations, err := CollectMigrations(dir, minVersion, maxVersion) + if err != nil { + return err + } + if option.noVersioning { + return downToNoVersioning(ctx, db, migrations, version) + } + + for { + currentVersion, err := GetDBVersionContext(ctx, db) + if err != nil { + return err + } + + if currentVersion == 0 { + log.Printf("goose: no migrations to run. current version: %d", currentVersion) + return nil + } + current, err := migrations.Current(currentVersion) + if err != nil { + log.Printf("goose: migration file not found for current version (%d), error: %s", currentVersion, err) + return err + } + + if current.Version <= version { + log.Printf("goose: no migrations to run. current version: %d", currentVersion) + return nil + } + + if err = current.DownContext(ctx, db); err != nil { + return err + } + } +} + +// downToNoVersioning applies down migrations down to, but not including, the +// target version. +func downToNoVersioning(ctx context.Context, db *sql.DB, migrations Migrations, version int64) error { + var finalVersion int64 + for i := len(migrations) - 1; i >= 0; i-- { + if version >= migrations[i].Version { + finalVersion = migrations[i].Version + break + } + migrations[i].noVersioning = true + if err := migrations[i].DownContext(ctx, db); err != nil { + return err + } + } + log.Printf("goose: down to current file version: %d", finalVersion) + return nil +} diff --git a/vendor/github.com/pressly/goose/v3/fix.go b/vendor/github.com/pressly/goose/v3/fix.go new file mode 100644 index 0000000000..a498358ee1 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/fix.go @@ -0,0 +1,54 @@ +package goose + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const seqVersionTemplate = "%05v" + +func Fix(dir string) error { + // always use osFS here because it's modifying operation + migrations, err := collectMigrationsFS(osFS{}, dir, minVersion, maxVersion, registeredGoMigrations) + if err != nil { + return err + } + + // split into timestamped and versioned migrations + tsMigrations, err := migrations.timestamped() + if err != nil { + return err + } + + vMigrations, err := migrations.versioned() + if err != nil { + return err + } + // Initial version. + version := int64(1) + if last, err := vMigrations.Last(); err == nil { + version = last.Version + 1 + } + + // fix filenames by replacing timestamps with sequential versions + for _, tsm := range tsMigrations { + oldPath := tsm.Source + newPath := strings.Replace( + oldPath, + fmt.Sprintf("%d", tsm.Version), + fmt.Sprintf(seqVersionTemplate, version), + 1, + ) + + if err := os.Rename(oldPath, newPath); err != nil { + return err + } + + log.Printf("RENAMED %s => %s", filepath.Base(oldPath), filepath.Base(newPath)) + version++ + } + + return nil +} diff --git a/vendor/github.com/pressly/goose/v3/globals.go b/vendor/github.com/pressly/goose/v3/globals.go new file mode 100644 index 0000000000..535f0ff27b --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/globals.go @@ -0,0 +1,104 @@ +package goose + +import ( + "errors" + "fmt" + "path/filepath" +) + +var ( + registeredGoMigrations = make(map[int64]*Migration) +) + +// ResetGlobalMigrations resets the global Go migrations registry. +// +// Not safe for concurrent use. +func ResetGlobalMigrations() { + registeredGoMigrations = make(map[int64]*Migration) +} + +// SetGlobalMigrations registers Go migrations globally. It returns an error if a migration with the +// same version has already been registered. Go migrations must be constructed using the +// [NewGoMigration] function. +// +// Not safe for concurrent use. +func SetGlobalMigrations(migrations ...*Migration) error { + for _, m := range migrations { + if _, ok := registeredGoMigrations[m.Version]; ok { + return fmt.Errorf("go migration with version %d already registered", m.Version) + } + if err := checkGoMigration(m); err != nil { + return fmt.Errorf("invalid go migration: %w", err) + } + registeredGoMigrations[m.Version] = m + } + return nil +} + +func checkGoMigration(m *Migration) error { + if !m.construct { + return errors.New("must use NewGoMigration to construct migrations") + } + if !m.Registered { + return errors.New("must be registered") + } + if m.Type != TypeGo { + return fmt.Errorf("type must be %q", TypeGo) + } + if m.Version < 1 { + return errors.New("version must be greater than zero") + } + if m.Source != "" { + if filepath.Ext(m.Source) != ".go" { + return fmt.Errorf("source must have .go extension: %q", m.Source) + } + // If the source is set, expect it to be a path with a numeric component that matches the + // version. This field is not intended to be used for descriptive purposes. + version, err := NumericComponent(m.Source) + if err != nil { + return fmt.Errorf("invalid source: %w", err) + } + if version != m.Version { + return fmt.Errorf("version:%d does not match numeric component in source %q", m.Version, m.Source) + } + } + if err := checkGoFunc(m.goUp); err != nil { + return fmt.Errorf("up function: %w", err) + } + if err := checkGoFunc(m.goDown); err != nil { + return fmt.Errorf("down function: %w", err) + } + if m.UpFnContext != nil && m.UpFnNoTxContext != nil { + return errors.New("must specify exactly one of UpFnContext or UpFnNoTxContext") + } + if m.UpFn != nil && m.UpFnNoTx != nil { + return errors.New("must specify exactly one of UpFn or UpFnNoTx") + } + if m.DownFnContext != nil && m.DownFnNoTxContext != nil { + return errors.New("must specify exactly one of DownFnContext or DownFnNoTxContext") + } + if m.DownFn != nil && m.DownFnNoTx != nil { + return errors.New("must specify exactly one of DownFn or DownFnNoTx") + } + return nil +} + +func checkGoFunc(f *GoFunc) error { + if f.RunTx != nil && f.RunDB != nil { + return errors.New("must specify exactly one of RunTx or RunDB") + } + switch f.Mode { + case TransactionEnabled, TransactionDisabled: + // No functions, but mode is set. This is not an error. It means the user wants to + // record a version with the given mode but not run any functions. + default: + return fmt.Errorf("invalid mode: %d", f.Mode) + } + if f.RunDB != nil && f.Mode != TransactionDisabled { + return fmt.Errorf("transaction mode must be disabled or unspecified when RunDB is set") + } + if f.RunTx != nil && f.Mode != TransactionEnabled { + return fmt.Errorf("transaction mode must be enabled or unspecified when RunTx is set") + } + return nil +} diff --git a/vendor/github.com/pressly/goose/v3/goose.go b/vendor/github.com/pressly/goose/v3/goose.go new file mode 100644 index 0000000000..233fb39375 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/goose.go @@ -0,0 +1,141 @@ +package goose + +import ( + "context" + "database/sql" + "fmt" + "io/fs" + "strconv" +) + +// Deprecated: VERSION will no longer be supported in the next major release. +const VERSION = "v3.18.0" + +var ( + minVersion = int64(0) + maxVersion = int64((1 << 63) - 1) + timestampFormat = "20060102150405" + verbose = false + noColor = false + + // base fs to lookup migrations + baseFS fs.FS = osFS{} +) + +// SetVerbose set the goose verbosity mode +func SetVerbose(v bool) { + verbose = v +} + +// SetBaseFS sets a base FS to discover migrations. It can be used with 'embed' package. +// Calling with 'nil' argument leads to default behaviour: discovering migrations from os filesystem. +// Note that modifying operations like Create will use os filesystem anyway. +func SetBaseFS(fsys fs.FS) { + if fsys == nil { + fsys = osFS{} + } + + baseFS = fsys +} + +// Run runs a goose command. +// +// Deprecated: Use RunContext. +func Run(command string, db *sql.DB, dir string, args ...string) error { + ctx := context.Background() + return RunContext(ctx, command, db, dir, args...) +} + +// RunContext runs a goose command. +func RunContext(ctx context.Context, command string, db *sql.DB, dir string, args ...string) error { + return run(ctx, command, db, dir, args) +} + +// RunWithOptions runs a goose command with options. +// +// Deprecated: Use RunWithOptionsContext. +func RunWithOptions(command string, db *sql.DB, dir string, args []string, options ...OptionsFunc) error { + ctx := context.Background() + return RunWithOptionsContext(ctx, command, db, dir, args, options...) +} + +// RunWithOptionsContext runs a goose command with options. +func RunWithOptionsContext(ctx context.Context, command string, db *sql.DB, dir string, args []string, options ...OptionsFunc) error { + return run(ctx, command, db, dir, args, options...) +} + +func run(ctx context.Context, command string, db *sql.DB, dir string, args []string, options ...OptionsFunc) error { + switch command { + case "up": + if err := UpContext(ctx, db, dir, options...); err != nil { + return err + } + case "up-by-one": + if err := UpByOneContext(ctx, db, dir, options...); err != nil { + return err + } + case "up-to": + if len(args) == 0 { + return fmt.Errorf("up-to must be of form: goose DRIVER DBSTRING [OPTIONS] up-to VERSION") + } + + version, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("version must be a number (got '%s')", args[0]) + } + if err := UpToContext(ctx, db, dir, version, options...); err != nil { + return err + } + case "create": + if len(args) == 0 { + return fmt.Errorf("create must be of form: goose DRIVER DBSTRING [OPTIONS] create NAME [go|sql]") + } + + migrationType := "go" + if len(args) == 2 { + migrationType = args[1] + } + if err := Create(db, dir, args[0], migrationType); err != nil { + return err + } + case "down": + if err := DownContext(ctx, db, dir, options...); err != nil { + return err + } + case "down-to": + if len(args) == 0 { + return fmt.Errorf("down-to must be of form: goose DRIVER DBSTRING [OPTIONS] down-to VERSION") + } + + version, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("version must be a number (got '%s')", args[0]) + } + if err := DownToContext(ctx, db, dir, version, options...); err != nil { + return err + } + case "fix": + if err := Fix(dir); err != nil { + return err + } + case "redo": + if err := RedoContext(ctx, db, dir, options...); err != nil { + return err + } + case "reset": + if err := ResetContext(ctx, db, dir, options...); err != nil { + return err + } + case "status": + if err := StatusContext(ctx, db, dir, options...); err != nil { + return err + } + case "version": + if err := VersionContext(ctx, db, dir, options...); err != nil { + return err + } + default: + return fmt.Errorf("%q: no such command", command) + } + return nil +} diff --git a/vendor/github.com/pressly/goose/v3/helpers.go b/vendor/github.com/pressly/goose/v3/helpers.go new file mode 100644 index 0000000000..4c7d762dcc --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/helpers.go @@ -0,0 +1,84 @@ +package goose + +import ( + "bytes" + "strings" + "unicode" + "unicode/utf8" +) + +type camelSnakeStateMachine int + +const ( // _$$_This is some text, OK?! + idle camelSnakeStateMachine = iota // 0 ↑ ↑ ↑ + firstAlphaNum // 1 ↑ ↑ ↑ ↑ ↑ + alphaNum // 2 ↑↑↑ ↑ ↑↑↑ ↑↑↑ ↑ + delimiter // 3 ↑ ↑ ↑ ↑ ↑ +) + +func (s camelSnakeStateMachine) next(r rune) camelSnakeStateMachine { + switch s { + case idle: + if isAlphaNum(r) { + return firstAlphaNum + } + case firstAlphaNum: + if isAlphaNum(r) { + return alphaNum + } + return delimiter + case alphaNum: + if !isAlphaNum(r) { + return delimiter + } + case delimiter: + if isAlphaNum(r) { + return firstAlphaNum + } + return idle + } + return s +} + +func camelCase(str string) string { + var b strings.Builder + + stateMachine := idle + for i := 0; i < len(str); { + r, size := utf8.DecodeRuneInString(str[i:]) + i += size + stateMachine = stateMachine.next(r) + switch stateMachine { + case firstAlphaNum: + b.WriteRune(unicode.ToUpper(r)) + case alphaNum: + b.WriteRune(unicode.ToLower(r)) + } + } + return b.String() +} + +func snakeCase(str string) string { + var b bytes.Buffer + + stateMachine := idle + for i := 0; i < len(str); { + r, size := utf8.DecodeRuneInString(str[i:]) + i += size + stateMachine = stateMachine.next(r) + switch stateMachine { + case firstAlphaNum, alphaNum: + b.WriteRune(unicode.ToLower(r)) + case delimiter: + b.WriteByte('_') + } + } + if stateMachine == idle { + return string(bytes.TrimSuffix(b.Bytes(), []byte{'_'})) + } + return b.String() +} + +func isAlphaNum(r rune) bool { + return unicode.IsLetter(r) || unicode.IsNumber(r) +} diff --git a/vendor/github.com/pressly/goose/v3/install.sh b/vendor/github.com/pressly/goose/v3/install.sh new file mode 100644 index 0000000000..de82bc3a63 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/install.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Adapted from the Deno installer: Copyright 2019 the Deno authors. All rights reserved. MIT license. +# Ref: https://github.com/denoland/deno_install +# TODO(everyone): Keep this script simple and easily auditable. + +# TODO(mf): this should work on Linux and macOS. Not intended for Windows. + +set -e + +os=$(uname -s | tr '[:upper:]' '[:lower:]') +arch=$(uname -m) + +if [ "$arch" = "aarch64" ]; then + arch="arm64" +fi + +if [ $# -eq 0 ]; then + goose_uri="https://github.com/pressly/goose/releases/latest/download/goose_${os}_${arch}" +else + goose_uri="https://github.com/pressly/goose/releases/download/${1}/goose_${os}_${arch}" +fi + +goose_install="${GOOSE_INSTALL:-/usr/local}" +bin_dir="${goose_install}/bin" +exe="${bin_dir}/goose" + +if [ ! -d "${bin_dir}" ]; then + mkdir -p "${bin_dir}" +fi + +curl --silent --show-error --location --fail --location --output "${exe}" "$goose_uri" +chmod +x "${exe}" + +echo "Goose was installed successfully to ${exe}" +if command -v goose >/dev/null; then + echo "Run 'goose --help' to get started" +fi diff --git a/vendor/github.com/pressly/goose/v3/internal/controller/store.go b/vendor/github.com/pressly/goose/v3/internal/controller/store.go new file mode 100644 index 0000000000..1d26e55b20 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/controller/store.go @@ -0,0 +1,37 @@ +package controller + +import ( + "context" + "errors" + + "github.com/pressly/goose/v3/database" +) + +// A StoreController is used by the goose package to interact with a database. This type is a +// wrapper around the Store interface, but can be extended to include additional (optional) methods +// that are not part of the core Store interface. +type StoreController struct{ database.Store } + +var _ database.StoreExtender = (*StoreController)(nil) + +// NewStoreController returns a new StoreController that wraps the given Store. +// +// If the Store implements the following optional methods, the StoreController will call them as +// appropriate: +// +// - TableExists(context.Context, DBTxConn) (bool, error) +// +// If the Store does not implement a method, it will either return a [errors.ErrUnsupported] error +// or fall back to the default behavior. +func NewStoreController(store database.Store) *StoreController { + return &StoreController{store} +} + +func (c *StoreController) TableExists(ctx context.Context, db database.DBTxConn) (bool, error) { + if t, ok := c.Store.(interface { + TableExists(ctx context.Context, db database.DBTxConn) (bool, error) + }); ok { + return t.TableExists(ctx, db) + } + return false, errors.ErrUnsupported +} diff --git a/vendor/github.com/pressly/goose/v3/internal/dialects/clickhouse.go b/vendor/github.com/pressly/goose/v3/internal/dialects/clickhouse.go new file mode 100644 index 0000000000..7b676b9777 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/dialects/clickhouse.go @@ -0,0 +1,53 @@ +package dialects + +import ( + "fmt" + + "github.com/pressly/goose/v3/database/dialect" +) + +// NewClickhouse returns a new [dialect.Querier] for Clickhouse dialect. +func NewClickhouse() dialect.Querier { + return &clickhouse{} +} + +type clickhouse struct{} + +var _ dialect.Querier = (*clickhouse)(nil) + +func (c *clickhouse) CreateTable(tableName string) string { + q := `CREATE TABLE IF NOT EXISTS %s ( + version_id Int64, + is_applied UInt8, + date Date default now(), + tstamp DateTime default now() + ) + ENGINE = MergeTree() + ORDER BY (date)` + return fmt.Sprintf(q, tableName) +} + +func (c *clickhouse) InsertVersion(tableName string) string { + q := `INSERT INTO %s (version_id, is_applied) VALUES ($1, $2)` + return fmt.Sprintf(q, tableName) +} + +func (c *clickhouse) DeleteVersion(tableName string) string { + q := `ALTER TABLE %s DELETE WHERE version_id = $1 SETTINGS mutations_sync = 2` + return fmt.Sprintf(q, tableName) +} + +func (c *clickhouse) GetMigrationByVersion(tableName string) string { + q := `SELECT tstamp, is_applied FROM %s WHERE version_id = $1 ORDER BY tstamp DESC LIMIT 1` + return fmt.Sprintf(q, tableName) +} + +func (c *clickhouse) ListMigrations(tableName string) string { + q := `SELECT version_id, is_applied FROM %s ORDER BY version_id DESC` + return fmt.Sprintf(q, tableName) +} + +func (c *clickhouse) GetLatestVersion(tableName string) string { + q := `SELECT max(version_id) FROM %s` + return fmt.Sprintf(q, tableName) +} diff --git a/vendor/github.com/pressly/goose/v3/internal/dialects/dsql.go b/vendor/github.com/pressly/goose/v3/internal/dialects/dsql.go new file mode 100644 index 0000000000..306ceec04c --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/dialects/dsql.go @@ -0,0 +1,66 @@ +package dialects + +import ( + "fmt" + + "github.com/pressly/goose/v3/database/dialect" +) + +// NewAuroraDSQL returns a new [dialect.Querier] for Aurora DSQL dialect. +func NewAuroraDSQL() dialect.QuerierExtender { + return &dsql{} +} + +type dsql struct{} + +var _ dialect.QuerierExtender = (*dsql)(nil) + +func (d *dsql) CreateTable(tableName string) string { + q := `CREATE TABLE %s ( + id integer PRIMARY KEY, + version_id bigint NOT NULL, + is_applied boolean NOT NULL, + tstamp timestamp NOT NULL DEFAULT now() + )` + return fmt.Sprintf(q, tableName) +} + +func (d *dsql) InsertVersion(tableName string) string { + q := `INSERT INTO %s (id, version_id, is_applied) + VALUES ( + COALESCE((SELECT MAX(id) FROM %s), 0) + 1, + $1, + $2 + )` + return fmt.Sprintf(q, tableName, tableName) +} + +func (d *dsql) DeleteVersion(tableName string) string { + q := `DELETE FROM %s WHERE version_id=$1` + return fmt.Sprintf(q, tableName) +} + +func (d *dsql) GetMigrationByVersion(tableName string) string { + q := `SELECT tstamp, is_applied FROM %s WHERE version_id=$1 ORDER BY tstamp DESC LIMIT 1` + return fmt.Sprintf(q, tableName) +} + +func (d *dsql) ListMigrations(tableName string) string { + q := `SELECT version_id, is_applied from %s ORDER BY id DESC` + return fmt.Sprintf(q, tableName) +} + +func (d *dsql) GetLatestVersion(tableName string) string { + q := `SELECT max(version_id) FROM %s` + return fmt.Sprintf(q, tableName) +} + +func (d *dsql) TableExists(tableName string) string { + schemaName, tableName := parseTableIdentifier(tableName) + if schemaName != "" { + q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE schemaname = '%s' AND tablename = '%s' )` + return fmt.Sprintf(q, schemaName, tableName) + } + q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE (current_schema() IS NULL OR schemaname = current_schema()) AND tablename = '%s' )` + return fmt.Sprintf(q, tableName) +} diff --git a/vendor/github.com/pressly/goose/v3/internal/dialects/mysql.go b/vendor/github.com/pressly/goose/v3/internal/dialects/mysql.go new file mode 100644 index 0000000000..9341589523 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/dialects/mysql.go @@ -0,0 +1,62 @@ +package dialects + +import ( + "fmt" + + "github.com/pressly/goose/v3/database/dialect" +) + +// NewMysql returns a new [dialect.Querier] for MySQL dialect. +func NewMysql() dialect.QuerierExtender { + return &mysql{} +} + +type mysql struct{} + +var _ dialect.QuerierExtender = (*mysql)(nil) + +func (m *mysql) CreateTable(tableName string) string { + q := `CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + version_id bigint NOT NULL, + is_applied boolean NOT NULL, + tstamp timestamp NULL default now(), + PRIMARY KEY(id) + )` + return fmt.Sprintf(q, tableName) +} + +func (m *mysql) InsertVersion(tableName string) string { + q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` + return fmt.Sprintf(q, tableName) +} + +func (m *mysql) DeleteVersion(tableName string) string { + q := `DELETE FROM %s WHERE version_id=?` + return fmt.Sprintf(q, tableName) +} + +func (m *mysql) GetMigrationByVersion(tableName string) string { + q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` + return fmt.Sprintf(q, tableName) +} + +func (m *mysql) ListMigrations(tableName string) string { + q := `SELECT version_id, is_applied from %s ORDER BY id DESC` + return fmt.Sprintf(q, tableName) +} + +func (m *mysql) GetLatestVersion(tableName string) string { + q := `SELECT MAX(version_id) FROM %s` + return fmt.Sprintf(q, tableName) +} + +func (m *mysql) TableExists(tableName string) string { + schemaName, tableName := parseTableIdentifier(tableName) + if schemaName != "" { + q := `SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = '%s' AND table_name = '%s' )` + return fmt.Sprintf(q, schemaName, tableName) + } + q := `SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE (database() IS NULL OR table_schema = database()) AND table_name = '%s' )` + return fmt.Sprintf(q, tableName) +} diff --git a/vendor/github.com/pressly/goose/v3/internal/dialects/postgres.go b/vendor/github.com/pressly/goose/v3/internal/dialects/postgres.go new file mode 100644 index 0000000000..b2c7327cda --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/dialects/postgres.go @@ -0,0 +1,70 @@ +package dialects + +import ( + "fmt" + "strings" + + "github.com/pressly/goose/v3/database/dialect" +) + +// NewPostgres returns a new [dialect.Querier] for PostgreSQL dialect. +func NewPostgres() dialect.QuerierExtender { + return &postgres{} +} + +type postgres struct{} + +var _ dialect.QuerierExtender = (*postgres)(nil) + +func (p *postgres) CreateTable(tableName string) string { + q := `CREATE TABLE %s ( + id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + version_id bigint NOT NULL, + is_applied boolean NOT NULL, + tstamp timestamp NOT NULL DEFAULT now() + )` + return fmt.Sprintf(q, tableName) +} + +func (p *postgres) InsertVersion(tableName string) string { + q := `INSERT INTO %s (version_id, is_applied) VALUES ($1, $2)` + return fmt.Sprintf(q, tableName) +} + +func (p *postgres) DeleteVersion(tableName string) string { + q := `DELETE FROM %s WHERE version_id=$1` + return fmt.Sprintf(q, tableName) +} + +func (p *postgres) GetMigrationByVersion(tableName string) string { + q := `SELECT tstamp, is_applied FROM %s WHERE version_id=$1 ORDER BY tstamp DESC LIMIT 1` + return fmt.Sprintf(q, tableName) +} + +func (p *postgres) ListMigrations(tableName string) string { + q := `SELECT version_id, is_applied from %s ORDER BY id DESC` + return fmt.Sprintf(q, tableName) +} + +func (p *postgres) GetLatestVersion(tableName string) string { + q := `SELECT max(version_id) FROM %s` + return fmt.Sprintf(q, tableName) +} + +func (p *postgres) TableExists(tableName string) string { + schemaName, tableName := parseTableIdentifier(tableName) + if schemaName != "" { + q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE schemaname = '%s' AND tablename = '%s' )` + return fmt.Sprintf(q, schemaName, tableName) + } + q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE (current_schema() IS NULL OR schemaname = current_schema()) AND tablename = '%s' )` + return fmt.Sprintf(q, tableName) +} + +func parseTableIdentifier(name string) (schema, table string) { + schema, table, found := strings.Cut(name, ".") + if !found { + return "", name + } + return schema, table +} diff --git a/vendor/github.com/pressly/goose/v3/internal/dialects/redshift.go b/vendor/github.com/pressly/goose/v3/internal/dialects/redshift.go new file mode 100644 index 0000000000..9b7dcf6a65 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/dialects/redshift.go @@ -0,0 +1,52 @@ +package dialects + +import ( + "fmt" + + "github.com/pressly/goose/v3/database/dialect" +) + +// Redshift returns a new [dialect.Querier] for Redshift dialect. +func NewRedshift() dialect.Querier { + return &redshift{} +} + +type redshift struct{} + +var _ dialect.Querier = (*redshift)(nil) + +func (r *redshift) CreateTable(tableName string) string { + q := `CREATE TABLE %s ( + id integer NOT NULL identity(1, 1), + version_id bigint NOT NULL, + is_applied boolean NOT NULL, + tstamp timestamp NULL default sysdate, + PRIMARY KEY(id) + )` + return fmt.Sprintf(q, tableName) +} + +func (r *redshift) InsertVersion(tableName string) string { + q := `INSERT INTO %s (version_id, is_applied) VALUES ($1, $2)` + return fmt.Sprintf(q, tableName) +} + +func (r *redshift) DeleteVersion(tableName string) string { + q := `DELETE FROM %s WHERE version_id=$1` + return fmt.Sprintf(q, tableName) +} + +func (r *redshift) GetMigrationByVersion(tableName string) string { + q := `SELECT tstamp, is_applied FROM %s WHERE version_id=$1 ORDER BY tstamp DESC LIMIT 1` + return fmt.Sprintf(q, tableName) +} + +func (r *redshift) ListMigrations(tableName string) string { + q := `SELECT version_id, is_applied from %s ORDER BY id DESC` + return fmt.Sprintf(q, tableName) +} + +func (r *redshift) GetLatestVersion(tableName string) string { + q := `SELECT max(version_id) FROM %s` + return fmt.Sprintf(q, tableName) +} diff --git a/vendor/github.com/pressly/goose/v3/internal/dialects/spanner.go b/vendor/github.com/pressly/goose/v3/internal/dialects/spanner.go new file mode 100644 index 0000000000..a20885a0c3 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/dialects/spanner.go @@ -0,0 +1,50 @@ +package dialects + +import ( + "fmt" + + "github.com/pressly/goose/v3/database/dialect" +) + +// NewSpanner returns a [dialect.Querier] for Spanner dialect. +func NewSpanner() dialect.Querier { + return &spanner{} +} + +type spanner struct{} + +var _ dialect.Querier = (*spanner)(nil) + +func (s *spanner) CreateTable(tableName string) string { + q := `CREATE TABLE %s ( + version_id INT64 NOT NULL, + is_applied BOOL NOT NULL, + tstamp TIMESTAMP DEFAULT (CURRENT_TIMESTAMP()), + ) PRIMARY KEY(version_id)` + return fmt.Sprintf(q, tableName) +} + +func (s *spanner) InsertVersion(tableName string) string { + q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` + return fmt.Sprintf(q, tableName) +} + +func (s *spanner) DeleteVersion(tableName string) string { + q := `DELETE FROM %s WHERE version_id=?` + return fmt.Sprintf(q, tableName) +} + +func (s *spanner) GetMigrationByVersion(tableName string) string { + q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` + return fmt.Sprintf(q, tableName) +} + +func (s *spanner) ListMigrations(tableName string) string { + q := `SELECT version_id, is_applied from %s ORDER BY version_id DESC` + return fmt.Sprintf(q, tableName) +} + +func (s *spanner) GetLatestVersion(tableName string) string { + q := `SELECT MAX(version_id) FROM %s` + return fmt.Sprintf(q, tableName) +} diff --git a/vendor/github.com/pressly/goose/v3/internal/dialects/sqlite3.go b/vendor/github.com/pressly/goose/v3/internal/dialects/sqlite3.go new file mode 100644 index 0000000000..8cd03ec965 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/dialects/sqlite3.go @@ -0,0 +1,51 @@ +package dialects + +import ( + "fmt" + + "github.com/pressly/goose/v3/database/dialect" +) + +// NewSqlite3 returns a [dialect.Querier] for SQLite3 dialect. +func NewSqlite3() dialect.Querier { + return &sqlite3{} +} + +type sqlite3 struct{} + +var _ dialect.Querier = (*sqlite3)(nil) + +func (s *sqlite3) CreateTable(tableName string) string { + q := `CREATE TABLE %s ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL, + is_applied INTEGER NOT NULL, + tstamp TIMESTAMP DEFAULT (datetime('now')) + )` + return fmt.Sprintf(q, tableName) +} + +func (s *sqlite3) InsertVersion(tableName string) string { + q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` + return fmt.Sprintf(q, tableName) +} + +func (s *sqlite3) DeleteVersion(tableName string) string { + q := `DELETE FROM %s WHERE version_id=?` + return fmt.Sprintf(q, tableName) +} + +func (s *sqlite3) GetMigrationByVersion(tableName string) string { + q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` + return fmt.Sprintf(q, tableName) +} + +func (s *sqlite3) ListMigrations(tableName string) string { + q := `SELECT version_id, is_applied from %s ORDER BY id DESC` + return fmt.Sprintf(q, tableName) +} + +func (s *sqlite3) GetLatestVersion(tableName string) string { + q := `SELECT MAX(version_id) FROM %s` + return fmt.Sprintf(q, tableName) +} diff --git a/vendor/github.com/pressly/goose/v3/internal/dialects/sqlserver.go b/vendor/github.com/pressly/goose/v3/internal/dialects/sqlserver.go new file mode 100644 index 0000000000..c9bb0d435b --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/dialects/sqlserver.go @@ -0,0 +1,51 @@ +package dialects + +import ( + "fmt" + + "github.com/pressly/goose/v3/database/dialect" +) + +// NewSqlserver returns a [dialect.Querier] for SQL Server dialect. +func NewSqlserver() dialect.Querier { + return &sqlserver{} +} + +type sqlserver struct{} + +var _ dialect.Querier = (*sqlserver)(nil) + +func (s *sqlserver) CreateTable(tableName string) string { + q := `CREATE TABLE %s ( + id INT NOT NULL IDENTITY(1,1) PRIMARY KEY, + version_id BIGINT NOT NULL, + is_applied BIT NOT NULL, + tstamp DATETIME NULL DEFAULT CURRENT_TIMESTAMP + )` + return fmt.Sprintf(q, tableName) +} + +func (s *sqlserver) InsertVersion(tableName string) string { + q := `INSERT INTO %s (version_id, is_applied) VALUES (@p1, @p2)` + return fmt.Sprintf(q, tableName) +} + +func (s *sqlserver) DeleteVersion(tableName string) string { + q := `DELETE FROM %s WHERE version_id=@p1` + return fmt.Sprintf(q, tableName) +} + +func (s *sqlserver) GetMigrationByVersion(tableName string) string { + q := `SELECT TOP 1 tstamp, is_applied FROM %s WHERE version_id=@p1 ORDER BY tstamp DESC` + return fmt.Sprintf(q, tableName) +} + +func (s *sqlserver) ListMigrations(tableName string) string { + q := `SELECT version_id, is_applied FROM %s ORDER BY id DESC` + return fmt.Sprintf(q, tableName) +} + +func (s *sqlserver) GetLatestVersion(tableName string) string { + q := `SELECT MAX(version_id) FROM %s` + return fmt.Sprintf(q, tableName) +} diff --git a/vendor/github.com/pressly/goose/v3/internal/dialects/starrocks.go b/vendor/github.com/pressly/goose/v3/internal/dialects/starrocks.go new file mode 100644 index 0000000000..1f1a20c9c8 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/dialects/starrocks.go @@ -0,0 +1,54 @@ +package dialects + +import ( + "fmt" + + "github.com/pressly/goose/v3/database/dialect" +) + +// NewStarrocks returns a [dialect.Querier] for StarRocks dialect. +func NewStarrocks() dialect.Querier { + return &starrocks{} +} + +type starrocks struct{} + +var _ dialect.Querier = (*starrocks)(nil) + +func (m *starrocks) CreateTable(tableName string) string { + q := `CREATE TABLE IF NOT EXISTS %s ( + id bigint NOT NULL AUTO_INCREMENT, + version_id bigint NOT NULL, + is_applied boolean NOT NULL, + tstamp datetime NULL default CURRENT_TIMESTAMP + ) + PRIMARY KEY (id) + DISTRIBUTED BY HASH (id) + ORDER BY (id,version_id)` + return fmt.Sprintf(q, tableName) +} + +func (m *starrocks) InsertVersion(tableName string) string { + q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` + return fmt.Sprintf(q, tableName) +} + +func (m *starrocks) DeleteVersion(tableName string) string { + q := `DELETE FROM %s WHERE version_id=?` + return fmt.Sprintf(q, tableName) +} + +func (m *starrocks) GetMigrationByVersion(tableName string) string { + q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` + return fmt.Sprintf(q, tableName) +} + +func (m *starrocks) ListMigrations(tableName string) string { + q := `SELECT version_id, is_applied from %s ORDER BY id DESC` + return fmt.Sprintf(q, tableName) +} + +func (m *starrocks) GetLatestVersion(tableName string) string { + q := `SELECT MAX(version_id) FROM %s` + return fmt.Sprintf(q, tableName) +} diff --git a/vendor/github.com/pressly/goose/v3/internal/dialects/tidb.go b/vendor/github.com/pressly/goose/v3/internal/dialects/tidb.go new file mode 100644 index 0000000000..f5b58f2b7a --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/dialects/tidb.go @@ -0,0 +1,52 @@ +package dialects + +import ( + "fmt" + + "github.com/pressly/goose/v3/database/dialect" +) + +// NewTidb returns a [dialect.Querier] for TiDB dialect. +func NewTidb() dialect.Querier { + return &Tidb{} +} + +type Tidb struct{} + +var _ dialect.Querier = (*Tidb)(nil) + +func (t *Tidb) CreateTable(tableName string) string { + q := `CREATE TABLE %s ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT UNIQUE, + version_id bigint NOT NULL, + is_applied boolean NOT NULL, + tstamp timestamp NULL default now(), + PRIMARY KEY(id) + )` + return fmt.Sprintf(q, tableName) +} + +func (t *Tidb) InsertVersion(tableName string) string { + q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` + return fmt.Sprintf(q, tableName) +} + +func (t *Tidb) DeleteVersion(tableName string) string { + q := `DELETE FROM %s WHERE version_id=?` + return fmt.Sprintf(q, tableName) +} + +func (t *Tidb) GetMigrationByVersion(tableName string) string { + q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` + return fmt.Sprintf(q, tableName) +} + +func (t *Tidb) ListMigrations(tableName string) string { + q := `SELECT version_id, is_applied from %s ORDER BY id DESC` + return fmt.Sprintf(q, tableName) +} + +func (t *Tidb) GetLatestVersion(tableName string) string { + q := `SELECT MAX(version_id) FROM %s` + return fmt.Sprintf(q, tableName) +} diff --git a/vendor/github.com/pressly/goose/v3/internal/dialects/turso.go b/vendor/github.com/pressly/goose/v3/internal/dialects/turso.go new file mode 100644 index 0000000000..8698e7df8f --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/dialects/turso.go @@ -0,0 +1,14 @@ +package dialects + +import "github.com/pressly/goose/v3/database/dialect" + +// NewTurso returns a [dialect.Querier] for Turso dialect. +func NewTurso() dialect.Querier { + return &turso{} +} + +type turso struct { + sqlite3 +} + +var _ dialect.Querier = (*turso)(nil) diff --git a/vendor/github.com/pressly/goose/v3/internal/dialects/vertica.go b/vendor/github.com/pressly/goose/v3/internal/dialects/vertica.go new file mode 100644 index 0000000000..ac7973e841 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/dialects/vertica.go @@ -0,0 +1,54 @@ +package dialects + +import ( + "fmt" + + "github.com/pressly/goose/v3/database/dialect" +) + +// NewVertica returns a new [dialect.Querier] for Vertica dialect. +// +// DEPRECATED: Vertica support is deprecated and will be removed in a future release. +func NewVertica() dialect.Querier { + return &vertica{} +} + +type vertica struct{} + +var _ dialect.Querier = (*vertica)(nil) + +func (v *vertica) CreateTable(tableName string) string { + q := `CREATE TABLE %s ( + id identity(1,1) NOT NULL, + version_id bigint NOT NULL, + is_applied boolean NOT NULL, + tstamp timestamp NULL default now(), + PRIMARY KEY(id) + )` + return fmt.Sprintf(q, tableName) +} + +func (v *vertica) InsertVersion(tableName string) string { + q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` + return fmt.Sprintf(q, tableName) +} + +func (v *vertica) DeleteVersion(tableName string) string { + q := `DELETE FROM %s WHERE version_id=?` + return fmt.Sprintf(q, tableName) +} + +func (v *vertica) GetMigrationByVersion(tableName string) string { + q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` + return fmt.Sprintf(q, tableName) +} + +func (v *vertica) ListMigrations(tableName string) string { + q := `SELECT version_id, is_applied from %s ORDER BY id DESC` + return fmt.Sprintf(q, tableName) +} + +func (v *vertica) GetLatestVersion(tableName string) string { + q := `SELECT MAX(version_id) FROM %s` + return fmt.Sprintf(q, tableName) +} diff --git a/vendor/github.com/pressly/goose/v3/internal/dialects/ydb.go b/vendor/github.com/pressly/goose/v3/internal/dialects/ydb.go new file mode 100644 index 0000000000..1ddebaebda --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/dialects/ydb.go @@ -0,0 +1,72 @@ +package dialects + +import ( + "fmt" + + "github.com/pressly/goose/v3/database/dialect" +) + +// NewYDB returns a new [dialect.Querier] for Vertica dialect. +func NewYDB() dialect.Querier { + return &ydb{} +} + +type ydb struct{} + +var _ dialect.Querier = (*ydb)(nil) + +func formatYDBTableName(tableName string) string { + return fmt.Sprintf("`%s`", tableName) +} + +func (c *ydb) CreateTable(tableName string) string { + formatedYDBTableName := formatYDBTableName(tableName) + q := `CREATE TABLE %s ( + version_id Uint64, + is_applied Bool, + tstamp Timestamp, + + PRIMARY KEY(version_id) + )` + return fmt.Sprintf(q, formatedYDBTableName) +} + +func (c *ydb) InsertVersion(tableName string) string { + formatedYDBTableName := formatYDBTableName(tableName) + q := `INSERT INTO %s ( + version_id, + is_applied, + tstamp + ) VALUES ( + CAST($1 AS Uint64), + $2, + CurrentUtcTimestamp() + )` + return fmt.Sprintf(q, formatedYDBTableName) +} + +func (c *ydb) DeleteVersion(tableName string) string { + formatedYDBTableName := formatYDBTableName(tableName) + q := `DELETE FROM %s WHERE version_id = $1` + return fmt.Sprintf(q, formatedYDBTableName) +} + +func (c *ydb) GetMigrationByVersion(tableName string) string { + formatedYDBTableName := formatYDBTableName(tableName) + q := `SELECT tstamp, is_applied FROM %s WHERE version_id = $1 ORDER BY tstamp DESC LIMIT 1` + return fmt.Sprintf(q, formatedYDBTableName) +} + +func (c *ydb) ListMigrations(tableName string) string { + formatedYDBTableName := formatYDBTableName(tableName) + q := ` + SELECT version_id, is_applied, tstamp AS __discard_column_tstamp + FROM %s ORDER BY __discard_column_tstamp DESC` + return fmt.Sprintf(q, formatedYDBTableName) +} + +func (c *ydb) GetLatestVersion(tableName string) string { + formatedYDBTableName := formatYDBTableName(tableName) + q := `SELECT MAX(version_id) FROM %s` + return fmt.Sprintf(q, formatedYDBTableName) +} diff --git a/vendor/github.com/pressly/goose/v3/internal/gooseutil/resolve.go b/vendor/github.com/pressly/goose/v3/internal/gooseutil/resolve.go new file mode 100644 index 0000000000..edc1a67e06 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/gooseutil/resolve.go @@ -0,0 +1,118 @@ +// 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" + "slices" + "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. + slices.Sort(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. + slices.Sort(out) + + return out, nil +} + +func newMissingError( + missing []int64, + dbMaxVersion int64, + target int64, +) error { + slices.Sort(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, + ) +} diff --git a/vendor/github.com/pressly/goose/v3/internal/legacystore/legacystore.go b/vendor/github.com/pressly/goose/v3/internal/legacystore/legacystore.go new file mode 100644 index 0000000000..c134a0e0a6 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/legacystore/legacystore.go @@ -0,0 +1,173 @@ +package legacystore + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/pressly/goose/v3/database" + "github.com/pressly/goose/v3/database/dialect" + "github.com/pressly/goose/v3/internal/dialects" +) + +// Store is the interface that wraps the basic methods for a database dialect. +// +// A dialect is a set of SQL statements that are specific to a database. +// +// By defining a store interface, we can support multiple databases +// with a single codebase. +// +// The underlying implementation does not modify the error. It is the callers +// responsibility to assert for the correct error, such as sql.ErrNoRows. +type Store interface { + // CreateVersionTable creates the version table within a transaction. + // This table is used to store goose migrations. + CreateVersionTable(ctx context.Context, tx *sql.Tx, tableName string) error + + // InsertVersion inserts a version id into the version table within a transaction. + InsertVersion(ctx context.Context, tx *sql.Tx, tableName string, version int64) error + // InsertVersionNoTx inserts a version id into the version table without a transaction. + InsertVersionNoTx(ctx context.Context, db *sql.DB, tableName string, version int64) error + + // DeleteVersion deletes a version id from the version table within a transaction. + DeleteVersion(ctx context.Context, tx *sql.Tx, tableName string, version int64) error + // DeleteVersionNoTx deletes a version id from the version table without a transaction. + DeleteVersionNoTx(ctx context.Context, db *sql.DB, tableName string, version int64) error + + // GetMigrationRow retrieves a single migration by version id. + // + // Returns the raw sql error if the query fails. It is the callers responsibility + // to assert for the correct error, such as sql.ErrNoRows. + GetMigration(ctx context.Context, db *sql.DB, tableName string, version int64) (*GetMigrationResult, error) + + // ListMigrations retrieves all migrations sorted in descending order by id. + // + // If there are no migrations, an empty slice is returned with no error. + ListMigrations(ctx context.Context, db *sql.DB, tableName string) ([]*ListMigrationsResult, error) +} + +// NewStore returns a new Store for the given dialect. +func NewStore(d database.Dialect) (Store, error) { + var querier dialect.Querier + switch d { + case database.DialectPostgres: + querier = dialects.NewPostgres() + case database.DialectMySQL: + querier = dialects.NewMysql() + case database.DialectSQLite3: + querier = dialects.NewSqlite3() + case database.DialectSpanner: + querier = dialects.NewSpanner() + case database.DialectMSSQL: + querier = dialects.NewSqlserver() + case database.DialectRedshift: + querier = dialects.NewRedshift() + case database.DialectTiDB: + querier = dialects.NewTidb() + case database.DialectClickHouse: + querier = dialects.NewClickhouse() + case database.DialectVertica: + querier = dialects.NewVertica() + case database.DialectYdB: + querier = dialects.NewYDB() + case database.DialectTurso: + querier = dialects.NewTurso() + case database.DialectStarrocks: + querier = dialects.NewStarrocks() + default: + return nil, fmt.Errorf("unknown querier dialect: %v", d) + } + return &store{querier: querier}, nil +} + +type GetMigrationResult struct { + IsApplied bool + Timestamp time.Time +} + +type ListMigrationsResult struct { + VersionID int64 + IsApplied bool +} + +type store struct { + querier dialect.Querier +} + +var _ Store = (*store)(nil) + +func (s *store) CreateVersionTable(ctx context.Context, tx *sql.Tx, tableName string) error { + q := s.querier.CreateTable(tableName) + _, err := tx.ExecContext(ctx, q) + return err +} + +func (s *store) InsertVersion(ctx context.Context, tx *sql.Tx, tableName string, version int64) error { + q := s.querier.InsertVersion(tableName) + _, err := tx.ExecContext(ctx, q, version, true) + return err +} + +func (s *store) InsertVersionNoTx(ctx context.Context, db *sql.DB, tableName string, version int64) error { + q := s.querier.InsertVersion(tableName) + _, err := db.ExecContext(ctx, q, version, true) + return err +} + +func (s *store) DeleteVersion(ctx context.Context, tx *sql.Tx, tableName string, version int64) error { + q := s.querier.DeleteVersion(tableName) + _, err := tx.ExecContext(ctx, q, version) + return err +} + +func (s *store) DeleteVersionNoTx(ctx context.Context, db *sql.DB, tableName string, version int64) error { + q := s.querier.DeleteVersion(tableName) + _, err := db.ExecContext(ctx, q, version) + return err +} + +func (s *store) GetMigration( + ctx context.Context, + db *sql.DB, + tableName string, + version int64, +) (*GetMigrationResult, error) { + q := s.querier.GetMigrationByVersion(tableName) + var timestamp time.Time + var isApplied bool + err := db.QueryRowContext(ctx, q, version).Scan(×tamp, &isApplied) + if err != nil { + return nil, err + } + return &GetMigrationResult{ + IsApplied: isApplied, + Timestamp: timestamp, + }, nil +} + +func (s *store) ListMigrations(ctx context.Context, db *sql.DB, tableName string) ([]*ListMigrationsResult, error) { + q := s.querier.ListMigrations(tableName) + rows, err := db.QueryContext(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + + var migrations []*ListMigrationsResult + for rows.Next() { + var version int64 + var isApplied bool + if err := rows.Scan(&version, &isApplied); err != nil { + return nil, err + } + migrations = append(migrations, &ListMigrationsResult{ + VersionID: version, + IsApplied: isApplied, + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + return migrations, nil +} diff --git a/vendor/github.com/pressly/goose/v3/internal/sqlparser/parse.go b/vendor/github.com/pressly/goose/v3/internal/sqlparser/parse.go new file mode 100644 index 0000000000..b42fdde145 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/sqlparser/parse.go @@ -0,0 +1,59 @@ +package sqlparser + +import ( + "fmt" + "io/fs" + + "go.uber.org/multierr" + "golang.org/x/sync/errgroup" +) + +type ParsedSQL struct { + UseTx bool + Up, Down []string +} + +func ParseAllFromFS(fsys fs.FS, filename string, debug bool) (*ParsedSQL, error) { + parsedSQL := new(ParsedSQL) + // TODO(mf): parse is called twice, once for up and once for down. This is inefficient. It + // should be possible to parse both directions in one pass. Also, UseTx is set once (but + // returned twice), which is unnecessary and potentially error-prone if the two calls to + // parseSQL disagree based on direction. + var g errgroup.Group + g.Go(func() error { + up, useTx, err := parse(fsys, filename, DirectionUp, debug) + if err != nil { + return err + } + parsedSQL.Up = up + parsedSQL.UseTx = useTx + return nil + }) + g.Go(func() error { + down, _, err := parse(fsys, filename, DirectionDown, debug) + if err != nil { + return err + } + parsedSQL.Down = down + return nil + }) + if err := g.Wait(); err != nil { + return nil, err + } + return parsedSQL, nil +} + +func parse(fsys fs.FS, filename string, direction Direction, debug bool) (_ []string, _ bool, retErr error) { + r, err := fsys.Open(filename) + if err != nil { + return nil, false, err + } + defer func() { + retErr = multierr.Append(retErr, r.Close()) + }() + stmts, useTx, err := ParseSQLMigration(r, direction, debug) + if err != nil { + return nil, false, fmt.Errorf("failed to parse %s: %w", filename, err) + } + return stmts, useTx, nil +} diff --git a/vendor/github.com/pressly/goose/v3/internal/sqlparser/parser.go b/vendor/github.com/pressly/goose/v3/internal/sqlparser/parser.go new file mode 100644 index 0000000000..5afc07fd46 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/internal/sqlparser/parser.go @@ -0,0 +1,394 @@ +package sqlparser + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "log" + "os" + "strings" + "sync" + + "github.com/mfridman/interpolate" +) + +type Direction string + +const ( + DirectionUp Direction = "up" + DirectionDown Direction = "down" +) + +func FromBool(b bool) Direction { + if b { + return DirectionUp + } + return DirectionDown +} + +func (d Direction) String() string { + return string(d) +} + +func (d Direction) ToBool() bool { + return d == DirectionUp +} + +type parserState int + +const ( + start parserState = iota // 0 + gooseUp // 1 + gooseStatementBeginUp // 2 + gooseStatementEndUp // 3 + gooseDown // 4 + gooseStatementBeginDown // 5 + gooseStatementEndDown // 6 +) + +type stateMachine struct { + state parserState + verbose bool +} + +func newStateMachine(begin parserState, verbose bool) *stateMachine { + return &stateMachine{ + state: begin, + verbose: verbose, + } +} + +func (s *stateMachine) get() parserState { + return s.state +} + +func (s *stateMachine) set(new parserState) { + s.print("set %d => %d", s.state, new) + s.state = new +} + +const ( + grayColor = "\033[90m" + resetColor = "\033[00m" +) + +func (s *stateMachine) print(msg string, args ...any) { + msg = "StateMachine: " + msg + if s.verbose { + log.Printf(grayColor+msg+resetColor, args...) + } +} + +const scanBufSize = 4 * 1024 * 1024 + +var bufferPool = sync.Pool{ + New: func() any { + buf := make([]byte, scanBufSize) + return &buf + }, +} + +// Split given SQL script into individual statements and return +// SQL statements for given direction (up=true, down=false). +// +// The base case is to simply split on semicolons, as these +// naturally terminate a statement. +// +// However, more complex cases like pl/pgsql can have semicolons +// within a statement. For these cases, we provide the explicit annotations +// 'StatementBegin' and 'StatementEnd' to allow the script to +// tell us to ignore semicolons. +func ParseSQLMigration(r io.Reader, direction Direction, debug bool) (stmts []string, useTx bool, err error) { + scanBufPtr := bufferPool.Get().(*[]byte) + scanBuf := *scanBufPtr + defer bufferPool.Put(scanBufPtr) + + scanner := bufio.NewScanner(r) + scanner.Buffer(scanBuf, scanBufSize) + + stateMachine := newStateMachine(start, debug) + useTx = true + useEnvsub := false + + var buf bytes.Buffer + for scanner.Scan() { + line := scanner.Text() + if debug { + log.Println(line) + } + if stateMachine.get() == start && strings.TrimSpace(line) == "" { + continue + } + + // Check for annotations. + // All annotations must be in format: "-- +goose [annotation]" + if strings.HasPrefix(strings.TrimSpace(line), "--") && strings.Contains(line, "+goose") { + var cmd annotation + + cmd, err = extractAnnotation(line) + if err != nil { + return nil, false, fmt.Errorf("failed to parse annotation line %q: %w", line, err) + } + + switch cmd { + case annotationUp: + switch stateMachine.get() { + case start: + stateMachine.set(gooseUp) + default: + return nil, false, fmt.Errorf("duplicate '-- +goose Up' annotations; stateMachine=%d, see https://github.com/pressly/goose#sql-migrations", stateMachine.state) + } + continue + + case annotationDown: + switch stateMachine.get() { + case gooseUp, gooseStatementEndUp: + // If we hit a down annotation, but the buffer is not empty, we have an unfinished SQL query from a + // previous up annotation. This is an error, because we expect the SQL query to be terminated by a semicolon + // and the buffer to have been reset. + if bufferRemaining := strings.TrimSpace(buf.String()); len(bufferRemaining) > 0 { + return nil, false, missingSemicolonError(stateMachine.state, direction, bufferRemaining) + } + stateMachine.set(gooseDown) + default: + return nil, false, fmt.Errorf("must start with '-- +goose Up' annotation, stateMachine=%d, see https://github.com/pressly/goose#sql-migrations", stateMachine.state) + } + continue + + case annotationStatementBegin: + switch stateMachine.get() { + case gooseUp, gooseStatementEndUp: + stateMachine.set(gooseStatementBeginUp) + case gooseDown, gooseStatementEndDown: + stateMachine.set(gooseStatementBeginDown) + default: + return nil, false, fmt.Errorf("'-- +goose StatementBegin' must be defined after '-- +goose Up' or '-- +goose Down' annotation, stateMachine=%d, see https://github.com/pressly/goose#sql-migrations", stateMachine.state) + } + continue + + case annotationStatementEnd: + switch stateMachine.get() { + case gooseStatementBeginUp: + stateMachine.set(gooseStatementEndUp) + case gooseStatementBeginDown: + stateMachine.set(gooseStatementEndDown) + default: + return nil, false, errors.New("'-- +goose StatementEnd' must be defined after '-- +goose StatementBegin', see https://github.com/pressly/goose#sql-migrations") + } + + case annotationNoTransaction: + useTx = false + continue + + case annotationEnvsubOn: + useEnvsub = true + continue + + case annotationEnvsubOff: + useEnvsub = false + continue + + default: + return nil, false, fmt.Errorf("unknown annotation: %q", cmd) + } + } + // Once we've started parsing a statement the buffer is no longer empty, + // we keep all comments up until the end of the statement (the buffer will be reset). + // All other comments in the file are ignored. + if buf.Len() == 0 { + // This check ensures leading comments and empty lines prior to a statement are ignored. + if strings.HasPrefix(strings.TrimSpace(line), "--") || line == "" { + stateMachine.print("ignore comment") + continue + } + } + switch stateMachine.get() { + case gooseStatementEndDown, gooseStatementEndUp: + // Do not include the "+goose StatementEnd" annotation in the final statement. + default: + if useEnvsub { + expanded, err := interpolate.Interpolate(&envWrapper{}, line) + if err != nil { + return nil, false, fmt.Errorf("variable substitution failed: %w:\n%s", err, line) + } + line = expanded + } + // Write SQL line to a buffer. + if _, err := buf.WriteString(line + "\n"); err != nil { + return nil, false, fmt.Errorf("failed to write to buf: %w", err) + } + } + // Read SQL body one by line, if we're in the right direction. + // + // 1) basic query with semicolon; 2) psql statement + // + // Export statement once we hit end of statement. + switch stateMachine.get() { + case gooseUp, gooseStatementBeginUp, gooseStatementEndUp: + if direction == DirectionDown { + buf.Reset() + stateMachine.print("ignore down") + continue + } + case gooseDown, gooseStatementBeginDown, gooseStatementEndDown: + if direction == DirectionUp { + buf.Reset() + stateMachine.print("ignore up") + continue + } + default: + return nil, false, fmt.Errorf("failed to parse migration: unexpected state %d on line %q, see https://github.com/pressly/goose#sql-migrations", stateMachine.state, line) + } + + switch stateMachine.get() { + case gooseUp: + if endsWithSemicolon(line) { + stmts = append(stmts, cleanupStatement(buf.String())) + buf.Reset() + stateMachine.print("store simple Up query") + } + case gooseDown: + if endsWithSemicolon(line) { + stmts = append(stmts, cleanupStatement(buf.String())) + buf.Reset() + stateMachine.print("store simple Down query") + } + case gooseStatementEndUp: + stmts = append(stmts, cleanupStatement(buf.String())) + buf.Reset() + stateMachine.print("store Up statement") + stateMachine.set(gooseUp) + case gooseStatementEndDown: + stmts = append(stmts, cleanupStatement(buf.String())) + buf.Reset() + stateMachine.print("store Down statement") + stateMachine.set(gooseDown) + } + } + if err := scanner.Err(); err != nil { + return nil, false, fmt.Errorf("failed to scan migration: %w", err) + } + // EOF + + switch stateMachine.get() { + case start: + return nil, false, errors.New("failed to parse migration: must start with '-- +goose Up' annotation, see https://github.com/pressly/goose#sql-migrations") + case gooseStatementBeginUp, gooseStatementBeginDown: + return nil, false, errors.New("failed to parse migration: missing '-- +goose StatementEnd' annotation") + } + + if bufferRemaining := strings.TrimSpace(buf.String()); len(bufferRemaining) > 0 { + return nil, false, missingSemicolonError(stateMachine.state, direction, bufferRemaining) + } + + return stmts, useTx, nil +} + +type annotation string + +const ( + annotationUp annotation = "Up" + annotationDown annotation = "Down" + annotationStatementBegin annotation = "StatementBegin" + annotationStatementEnd annotation = "StatementEnd" + annotationNoTransaction annotation = "NO TRANSACTION" + annotationEnvsubOn annotation = "ENVSUB ON" + annotationEnvsubOff annotation = "ENVSUB OFF" +) + +var supportedAnnotations = map[annotation]struct{}{ + annotationUp: {}, + annotationDown: {}, + annotationStatementBegin: {}, + annotationStatementEnd: {}, + annotationNoTransaction: {}, + annotationEnvsubOn: {}, + annotationEnvsubOff: {}, +} + +var ( + errEmptyAnnotation = errors.New("empty annotation") + errInvalidAnnotation = errors.New("invalid annotation") +) + +// extractAnnotation extracts the annotation from the line. +// All annotations must be in format: "-- +goose [annotation]" +// Allowed annotations: Up, Down, StatementBegin, StatementEnd, NO TRANSACTION, ENVSUB ON, ENVSUB OFF +func extractAnnotation(line string) (annotation, error) { + // If line contains leading whitespace - return error. + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + return "", fmt.Errorf("%q contains leading whitespace: %w", line, errInvalidAnnotation) + } + + // Extract the annotation from the line, by removing the leading "--" + cmd := strings.ReplaceAll(line, "--", "") + + // Extract the annotation from the line, by removing the leading "+goose" + cmd = strings.Replace(cmd, "+goose", "", 1) + + if strings.Contains(cmd, "+goose") { + return "", fmt.Errorf("%q contains multiple '+goose' annotations: %w", cmd, errInvalidAnnotation) + } + + // Remove leading and trailing whitespace from the annotation command. + cmd = strings.TrimSpace(cmd) + + if cmd == "" { + return "", errEmptyAnnotation + } + + a := annotation(cmd) + + for s := range supportedAnnotations { + if strings.EqualFold(string(s), string(a)) { + return s, nil + } + } + + return "", fmt.Errorf("%q not supported: %w", cmd, errInvalidAnnotation) +} + +func missingSemicolonError(state parserState, direction Direction, s string) error { + return fmt.Errorf("failed to parse migration: state %d, direction: %v: unexpected unfinished SQL query: %q: missing semicolon?", + state, + direction, + s, + ) +} + +type envWrapper struct{} + +var _ interpolate.Env = (*envWrapper)(nil) + +func (e *envWrapper) Get(key string) (string, bool) { + return os.LookupEnv(key) +} + +func cleanupStatement(input string) string { + return strings.TrimSpace(input) +} + +// Checks the line to see if the line has a statement-ending semicolon +// or if the line contains a double-dash comment. +func endsWithSemicolon(line string) bool { + scanBufPtr := bufferPool.Get().(*[]byte) + scanBuf := *scanBufPtr + defer bufferPool.Put(scanBufPtr) + + prev := "" + scanner := bufio.NewScanner(strings.NewReader(line)) + scanner.Buffer(scanBuf, scanBufSize) + scanner.Split(bufio.ScanWords) + + for scanner.Scan() { + word := scanner.Text() + if strings.HasPrefix(word, "--") { + break + } + prev = word + } + + return strings.HasSuffix(prev, ";") +} diff --git a/vendor/github.com/pressly/goose/v3/lock/internal/store/postgres.go b/vendor/github.com/pressly/goose/v3/lock/internal/store/postgres.go new file mode 100644 index 0000000000..387386f09d --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/lock/internal/store/postgres.go @@ -0,0 +1,282 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "go.uber.org/multierr" +) + +// NewPostgres creates a new Postgres-based [LockStore]. +func NewPostgres(tableName string) (LockStore, error) { + if tableName == "" { + return nil, errors.New("table name must not be empty") + } + return &postgresStore{ + tableName: tableName, + }, nil +} + +var _ LockStore = (*postgresStore)(nil) + +type postgresStore struct { + tableName string +} + +func (s *postgresStore) TableExists( + ctx context.Context, + db *sql.DB, +) (bool, error) { + var query string + schemaName, tableName := parseTableIdentifier(s.tableName) + if schemaName != "" { + q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE schemaname = '%s' AND tablename = '%s' )` + query = fmt.Sprintf(q, schemaName, tableName) + } else { + q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE (current_schema() IS NULL OR schemaname = current_schema()) AND tablename = '%s' )` + query = fmt.Sprintf(q, tableName) + } + + var exists bool + if err := db.QueryRowContext(ctx, query).Scan( + &exists, + ); err != nil { + return false, fmt.Errorf("table exists: %w", err) + } + return exists, nil +} + +func (s *postgresStore) CreateLockTable( + ctx context.Context, + db *sql.DB, +) error { + exists, err := s.TableExists(ctx, db) + if err != nil { + return fmt.Errorf("check lock table existence: %w", err) + } + if exists { + return nil + } + + query := fmt.Sprintf(`CREATE TABLE %s ( + lock_id bigint NOT NULL PRIMARY KEY, + locked boolean NOT NULL DEFAULT false, + locked_at timestamptz NULL, + locked_by text NULL, + lease_expires_at timestamptz NULL, + updated_at timestamptz NULL + )`, s.tableName) + if _, err := db.ExecContext(ctx, query); err != nil { + // Double-check if another process created it concurrently + if exists, checkErr := s.TableExists(ctx, db); checkErr == nil && exists { + // Another process created it, that's fine! + return nil + } + return fmt.Errorf("create lock table %q: %w", s.tableName, err) + } + return nil +} + +func (s *postgresStore) AcquireLock( + ctx context.Context, + db *sql.DB, + lockID int64, + lockedBy string, + leaseDuration time.Duration, +) (*AcquireLockResult, error) { + query := fmt.Sprintf(`INSERT INTO %s (lock_id, locked, locked_at, locked_by, lease_expires_at, updated_at) + VALUES ($1, true, now(), $2, now() + $3::interval, now()) + ON CONFLICT (lock_id) DO UPDATE SET + locked = true, + locked_at = now(), + locked_by = $2, + lease_expires_at = now() + $3::interval, + updated_at = now() + WHERE %s.locked = false OR %s.lease_expires_at < now() + RETURNING locked_by, lease_expires_at`, s.tableName, s.tableName, s.tableName) + + // Convert duration to PostgreSQL interval format + leaseDurationStr := formatDurationAsInterval(leaseDuration) + + var returnedLockedBy string + var leaseExpiresAt time.Time + err := db.QueryRowContext(ctx, query, + lockID, + lockedBy, + leaseDurationStr, + ).Scan( + &returnedLockedBy, + &leaseExpiresAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // TODO(mf): should we return a special error type here? + return nil, fmt.Errorf("acquire lock %d: already held by another instance", lockID) + } + return nil, fmt.Errorf("acquire lock %d: %w", lockID, err) + } + + // Verify we got the lock by checking the returned locked_by matches our instance ID + if returnedLockedBy != lockedBy { + return nil, fmt.Errorf("acquire lock %d: acquired by %s instead of %s", lockID, returnedLockedBy, lockedBy) + } + + return &AcquireLockResult{ + LockedBy: returnedLockedBy, + LeaseExpiresAt: leaseExpiresAt, + }, nil +} + +func (s *postgresStore) ReleaseLock( + ctx context.Context, + db *sql.DB, + lockID int64, + lockedBy string, +) (*ReleaseLockResult, error) { + // Release lock only if it's held by the current instance + query := fmt.Sprintf(`UPDATE %s SET + locked = false, + locked_at = NULL, + locked_by = NULL, + lease_expires_at = NULL, + updated_at = now() + WHERE lock_id = $1 AND locked_by = $2 + RETURNING lock_id`, s.tableName) + + var returnedLockID int64 + err := db.QueryRowContext(ctx, query, + lockID, + lockedBy, + ).Scan( + &returnedLockID, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // TODO(mf): should we return a special error type here? + return nil, fmt.Errorf("release lock %d: not held by this instance", lockID) + } + return nil, fmt.Errorf("release lock %d: %w", lockID, err) + } + + // Verify the correct lock was released + if returnedLockID != lockID { + return nil, fmt.Errorf("release lock %d: returned lock ID %d does not match", lockID, returnedLockID) + } + + return &ReleaseLockResult{ + LockID: returnedLockID, + }, nil +} + +func (s *postgresStore) UpdateLease( + ctx context.Context, + db *sql.DB, + lockID int64, + lockedBy string, + leaseDuration time.Duration, +) (*UpdateLeaseResult, error) { + // Update lease expiration time for heartbeat, only if we own the lock + query := fmt.Sprintf(`UPDATE %s SET + lease_expires_at = now() + $1::interval, + updated_at = now() + WHERE lock_id = $2 AND locked_by = $3 AND locked = true + RETURNING lease_expires_at`, s.tableName) + + // Convert duration to PostgreSQL interval format + intervalStr := formatDurationAsInterval(leaseDuration) + + var leaseExpiresAt time.Time + err := db.QueryRowContext(ctx, query, + intervalStr, + lockID, + lockedBy, + ).Scan( + &leaseExpiresAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("failed to update lease for lock %d: not held by this instance", lockID) + } + return nil, fmt.Errorf("failed to update lease for lock %d: %w", lockID, err) + } + + return &UpdateLeaseResult{ + LeaseExpiresAt: leaseExpiresAt, + }, nil +} + +func (s *postgresStore) CheckLockStatus( + ctx context.Context, + db *sql.DB, + lockID int64, +) (*LockStatus, error) { + query := fmt.Sprintf(`SELECT locked, locked_by, lease_expires_at, updated_at FROM %s WHERE lock_id = $1`, s.tableName) + var status LockStatus + + err := db.QueryRowContext(ctx, query, + lockID, + ).Scan( + &status.Locked, + &status.LockedBy, + &status.LeaseExpiresAt, + &status.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("lock %d not found", lockID) + } + return nil, fmt.Errorf("check lock status for %d: %w", lockID, err) + } + + return &status, nil +} + +func (s *postgresStore) CleanupStaleLocks(ctx context.Context, db *sql.DB) (_ []int64, retErr error) { + query := fmt.Sprintf(`UPDATE %s SET + locked = false, + locked_at = NULL, + locked_by = NULL, + lease_expires_at = NULL, + updated_at = now() + WHERE locked = true AND lease_expires_at < now() + RETURNING lock_id`, s.tableName) + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("cleanup stale locks: %w", err) + } + defer func() { + retErr = multierr.Append(retErr, rows.Close()) + }() + + var cleanedLocks []int64 + for rows.Next() { + var lockID int64 + if err := rows.Scan(&lockID); err != nil { + return nil, fmt.Errorf("scan cleaned lock ID: %w", err) + } + cleanedLocks = append(cleanedLocks, lockID) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate over cleaned locks: %w", err) + } + + return cleanedLocks, nil +} + +// formatDurationAsInterval converts a time.Duration to PostgreSQL interval format +func formatDurationAsInterval(d time.Duration) string { + return fmt.Sprintf("%d seconds", int(d.Seconds())) +} + +func parseTableIdentifier(name string) (schema, table string) { + schema, table, found := strings.Cut(name, ".") + if !found { + return "", name + } + return schema, table +} diff --git a/vendor/github.com/pressly/goose/v3/lock/internal/store/store.go b/vendor/github.com/pressly/goose/v3/lock/internal/store/store.go new file mode 100644 index 0000000000..b1d58de300 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/lock/internal/store/store.go @@ -0,0 +1,51 @@ +package store + +import ( + "context" + "database/sql" + "time" +) + +// LockStore defines the interface for storing and managing database locks. +type LockStore interface { + // CreateLockTable creates the lock table if it doesn't exist. Implementations should ensure + // that this operation is idempotent. + CreateLockTable(ctx context.Context, db *sql.DB) error + // TableExists checks if the lock table exists. + TableExists(ctx context.Context, db *sql.DB) (bool, error) + // AcquireLock attempts to acquire a lock for the given lockID. + AcquireLock(ctx context.Context, db *sql.DB, lockID int64, lockedBy string, leaseDuration time.Duration) (*AcquireLockResult, error) + // ReleaseLock releases a lock held by the current instance. + ReleaseLock(ctx context.Context, db *sql.DB, lockID int64, lockedBy string) (*ReleaseLockResult, error) + // UpdateLease updates the lease expiration time for a lock (heartbeat). + UpdateLease(ctx context.Context, db *sql.DB, lockID int64, lockedBy string, leaseDuration time.Duration) (*UpdateLeaseResult, error) + // CheckLockStatus checks the current status of a lock. + CheckLockStatus(ctx context.Context, db *sql.DB, lockID int64) (*LockStatus, error) + // CleanupStaleLocks removes any locks that have expired using server time. Returns the list of + // lock IDs that were cleaned up, if any. + CleanupStaleLocks(ctx context.Context, db *sql.DB) ([]int64, error) +} + +// LockStatus represents the current status of a lock. +type LockStatus struct { + Locked bool + LockedBy *string + LeaseExpiresAt *time.Time + UpdatedAt *time.Time +} + +// AcquireLockResult contains the result of a lock acquisition attempt. +type AcquireLockResult struct { + LockedBy string + LeaseExpiresAt time.Time +} + +// ReleaseLockResult contains the result of a lock release. +type ReleaseLockResult struct { + LockID int64 +} + +// UpdateLeaseResult contains the result of a lease update. +type UpdateLeaseResult struct { + LeaseExpiresAt time.Time +} diff --git a/vendor/github.com/pressly/goose/v3/lock/internal/table/config.go b/vendor/github.com/pressly/goose/v3/lock/internal/table/config.go new file mode 100644 index 0000000000..bde5b51b3a --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/lock/internal/table/config.go @@ -0,0 +1,28 @@ +package table + +import ( + "log/slog" + "time" +) + +// Config holds configuration for table locker. +type Config struct { + TableName string + LockID int64 + LeaseDuration time.Duration + HeartbeatInterval time.Duration + LockTimeout ProbeConfig + UnlockTimeout ProbeConfig + + // Optional logger for lock operations + Logger *slog.Logger + + // Optional custom retry policy for database errors + RetryPolicy RetryPolicyFunc +} + +// ProbeConfig holds retry configuration. +type ProbeConfig struct { + IntervalDuration time.Duration + FailureThreshold uint64 +} diff --git a/vendor/github.com/pressly/goose/v3/lock/internal/table/locker.go b/vendor/github.com/pressly/goose/v3/lock/internal/table/locker.go new file mode 100644 index 0000000000..2f16256896 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/lock/internal/table/locker.go @@ -0,0 +1,237 @@ +package table + +import ( + "cmp" + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "log/slog" + "os" + "sync" + "time" + + "github.com/pressly/goose/v3/lock/internal/store" + "github.com/sethvargo/go-retry" +) + +// RetryPolicyFunc inspects an error and returns whether the caller should retry the operation. This +// allows for database-specific error handling without hardcoding driver-specific logic. +type RetryPolicyFunc func(err error) bool + +// Locker implements table-based locking for databases. This implementation is safe for concurrent +// use by multiple goroutines. +type Locker struct { + store store.LockStore + tableName string + lockID int64 + instanceID string + leaseDuration time.Duration + heartbeatInterval time.Duration + retryLock retry.Backoff + retryUnlock retry.Backoff + logger *slog.Logger + retryPolicy RetryPolicyFunc + + // Application-level coordination + mu sync.Mutex + + // Heartbeat management + heartbeatCancel context.CancelFunc + heartbeatDone chan struct{} +} + +// New creates a new table-based locker. +func New(lockStore store.LockStore, cfg Config) *Locker { + // Generate instance identifier + hostname, _ := os.Hostname() + hostname = cmp.Or(hostname, "unknown-hostname") + instanceID := fmt.Sprintf("%s-%d-%s", hostname, os.Getpid(), randomHex(4)) + + logger := cfg.Logger + if logger == nil { + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + } + + return &Locker{ + store: lockStore, + tableName: cfg.TableName, + lockID: cfg.LockID, + instanceID: instanceID, + leaseDuration: cfg.LeaseDuration, + heartbeatInterval: cfg.HeartbeatInterval, + logger: logger, + retryPolicy: cfg.RetryPolicy, + retryLock: retry.WithMaxRetries( + cfg.LockTimeout.FailureThreshold, + // Add +/- 25% jitter to reduce thundering herd + retry.WithJitterPercent(25, retry.NewConstant(cfg.LockTimeout.IntervalDuration)), + ), + retryUnlock: retry.WithMaxRetries( + cfg.UnlockTimeout.FailureThreshold, + // Add +/- 25% jitter to reduce thundering herd + retry.WithJitterPercent(25, retry.NewConstant(cfg.UnlockTimeout.IntervalDuration)), + ), + } +} + +// Lock acquires the database lock. This method is safe for concurrent use - the mutex is held until +// Unlock() is called. Only one goroutine can hold the lock at a time across the entire lifecycle. +func (l *Locker) Lock(ctx context.Context, db *sql.DB) error { + l.mu.Lock() + // NOTE: mutex is NOT defer unlocked here, it remains held until Unlock() is called explicitly + // or a specific error occurs below! + + // Ensure the lock table exists + if err := l.store.CreateLockTable(ctx, db); err != nil { + l.mu.Unlock() + return fmt.Errorf("ensure lock table exists: %w", err) + } + + err := retry.Do(ctx, l.retryLock, func(ctx context.Context) error { + _, err := l.store.AcquireLock(ctx, db, l.lockID, l.instanceID, l.leaseDuration) + if err != nil { + // Clean up any stale locks before retrying + if _, cleanupErr := l.store.CleanupStaleLocks(ctx, db); cleanupErr != nil { + l.logger.WarnContext(ctx, "failed to cleanup stale locks", + slog.Int64("lock_table", l.lockID), + slog.Any("error", cleanupErr), + ) + // Continue with retry, cleanup failure shouldn't block acquisition attempts + } + if l.shouldRetry(err) { + return retry.RetryableError(fmt.Errorf("acquire retryable lock: %w", err)) + } + return fmt.Errorf("acquire lock: %w", err) + } + return nil + }) + if err != nil { + l.mu.Unlock() + l.logger.WarnContext(ctx, "failed to acquire lock after retries", + slog.Int64("lock_id", l.lockID), + slog.String("instance_id", l.instanceID), + slog.Any("error", err), + ) + return fmt.Errorf("acquire lock %d after retries: %w", l.lockID, err) + } + + l.logger.DebugContext(ctx, "successfully acquired lock", + slog.Int64("lock_id", l.lockID), + slog.String("instance_id", l.instanceID), + slog.Duration("lease_duration", l.leaseDuration), + ) + // Start heartbeat to maintain the lease + l.startHeartbeat(ctx, db) + + // Mutex remains held - will be released in Unlock() + return nil +} + +// Unlock releases the database lock. This method must be called exactly once after a successful +// Lock() call. +func (l *Locker) Unlock(ctx context.Context, db *sql.DB) error { + // NOTE: The mutex was acquired in Lock() and is still held + defer l.mu.Unlock() + + // Use a context that can't be cancelled to ensure we always attempt to unlock even if the + // caller's context is cancelled. The call can control the retry behavior via the configured + // timeouts. + ctx = context.WithoutCancel(ctx) + + // Stop heartbeat first + l.stopHeartbeat() + + err := retry.Do(ctx, l.retryUnlock, func(ctx context.Context) error { + _, err := l.store.ReleaseLock(ctx, db, l.lockID, l.instanceID) + if err != nil { + if l.shouldRetry(err) { + return retry.RetryableError(fmt.Errorf("release retryable lock: %w", err)) + } + return fmt.Errorf("release lock: %w", err) + } + return nil + }) + if err != nil { + l.logger.WarnContext(ctx, "failed to release lock", + slog.Int64("lock_id", l.lockID), + slog.String("instance_id", l.instanceID), + slog.Any("error", err), + ) + return err + } + + l.logger.DebugContext(ctx, "successfully released lock", + slog.Int64("lock_id", l.lockID), + slog.String("instance_id", l.instanceID), + ) + return nil +} + +// startHeartbeat starts the heartbeat goroutine (called from within Lock with mutex held) +func (l *Locker) startHeartbeat(parentCtx context.Context, db *sql.DB) { + // If there's already a heartbeat running, stop it first + l.stopHeartbeat() + + // Create a new context for the heartbeat + ctx, cancel := context.WithCancel(parentCtx) + l.heartbeatCancel = cancel + l.heartbeatDone = make(chan struct{}) + + go func() { + defer close(l.heartbeatDone) + ticker := time.NewTicker(l.heartbeatInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + result, err := l.store.UpdateLease(ctx, db, l.lockID, l.instanceID, l.leaseDuration) + if err != nil { + // TODO(mf): should we add a retry policy here? + l.logger.WarnContext(ctx, "heartbeat failed to update lease", + slog.Int64("lock_id", l.lockID), + slog.String("instance_id", l.instanceID), + slog.Any("error", err), + ) + continue + } + l.logger.DebugContext(ctx, "heartbeat updated lease", + slog.Int64("lock_id", l.lockID), + slog.String("instance_id", l.instanceID), + slog.Time("lease_expires_at", result.LeaseExpiresAt), + ) + } + } + }() +} + +// stopHeartbeat stops the heartbeat goroutine (called from within Unlock with mutex held). +func (l *Locker) stopHeartbeat() { + if l.heartbeatCancel != nil { + l.heartbeatCancel() + <-l.heartbeatDone + l.heartbeatCancel = nil + l.heartbeatDone = nil + } +} + +// shouldRetry determines whether an error is retryable based on the configured retry policy. If no +// retry policy is configured, it defaults to always retrying. +func (l *Locker) shouldRetry(err error) bool { + if l.retryPolicy != nil { + return l.retryPolicy(err) + } + return true +} + +func randomHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%0*x", n*2, time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} diff --git a/vendor/github.com/pressly/goose/v3/lock/locker.go b/vendor/github.com/pressly/goose/v3/lock/locker.go new file mode 100644 index 0000000000..a5fa480c0b --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/lock/locker.go @@ -0,0 +1,32 @@ +// Package lock defines the Locker interface and implements the locking logic. +package lock + +import ( + "context" + "database/sql" + "errors" +) + +var ( + // ErrLockNotImplemented is returned when the database does not support locking. + ErrLockNotImplemented = errors.New("lock not implemented") + // ErrUnlockNotImplemented is returned when the database does not support unlocking. + ErrUnlockNotImplemented = errors.New("unlock not implemented") +) + +// SessionLocker is the interface to lock and unlock the database for the duration of a session. The +// session is defined as the duration of a single connection and both methods must be called on the +// same connection. +type SessionLocker interface { + SessionLock(ctx context.Context, conn *sql.Conn) error + SessionUnlock(ctx context.Context, conn *sql.Conn) error +} + +// Locker is the interface to lock and unlock the database. +// +// Unlike [SessionLocker], the Lock and Unlock methods are called on a [*sql.DB] and do not require +// the same connection to be used for both methods. +type Locker interface { + Lock(ctx context.Context, db *sql.DB) error + Unlock(ctx context.Context, db *sql.DB) error +} diff --git a/vendor/github.com/pressly/goose/v3/lock/postgres.go b/vendor/github.com/pressly/goose/v3/lock/postgres.go new file mode 100644 index 0000000000..830d59842a --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/lock/postgres.go @@ -0,0 +1,165 @@ +package lock + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/pressly/goose/v3/lock/internal/store" + "github.com/pressly/goose/v3/lock/internal/table" + "github.com/sethvargo/go-retry" +) + +// NewPostgresTableLocker returns a Locker that uses PostgreSQL table-based locking. It manages a +// single lock row and keeps the lock alive automatically. +// +// Default behavior: +// +// - Lease (30s): How long the lock is valid if heartbeat stops +// - Heartbeat (5s): How often the lock gets refreshed to keep it alive +// - If the process dies, others can take the lock after lease expires +// +// Defaults: +// +// Table: "goose_lock" +// Lock ID: 4097083626 (crc64 of "goose") +// Lock retry: 5s intervals, 5min timeout +// Unlock retry: 2s intervals, 1min timeout +// +// Lock and Unlock both retry on failure. Lock stays alive automatically until released. All +// defaults can be overridden with options. +func NewPostgresTableLocker(options ...TableLockerOption) (Locker, error) { + config := table.Config{ + TableName: DefaultLockTableName, + LockID: DefaultLockID, + LeaseDuration: 30 * time.Second, + HeartbeatInterval: 5 * time.Second, + LockTimeout: table.ProbeConfig{ + IntervalDuration: 5 * time.Second, + FailureThreshold: 60, // 5 minutes total + }, + UnlockTimeout: table.ProbeConfig{ + IntervalDuration: 2 * time.Second, + FailureThreshold: 30, // 1 minute total + }, + } + for _, opt := range options { + if err := opt.apply(&config); err != nil { + return nil, err + } + } + lockStore, err := store.NewPostgres(config.TableName) + if err != nil { + return nil, fmt.Errorf("create lock store: %w", err) + } + return table.New(lockStore, config), nil +} + +// NewPostgresSessionLocker returns a SessionLocker that utilizes PostgreSQL's exclusive +// session-level advisory lock mechanism. +// +// This function creates a SessionLocker that can be used to acquire and release a lock for +// synchronization purposes. The lock acquisition is retried until it is successfully acquired or +// until the failure threshold is reached. The default lock duration is set to 5 minutes, and the +// default unlock duration is set to 1 minute. +// +// If you have long running migrations, you may want to increase the lock duration. +// +// See [SessionLockerOption] for options that can be used to configure the SessionLocker. +func NewPostgresSessionLocker(opts ...SessionLockerOption) (SessionLocker, error) { + cfg := sessionLockerConfig{ + lockID: DefaultLockID, + lockProbe: probe{ + intervalDuration: 5 * time.Second, + failureThreshold: 60, + }, + unlockProbe: probe{ + intervalDuration: 2 * time.Second, + failureThreshold: 30, + }, + } + for _, opt := range opts { + if err := opt.apply(&cfg); err != nil { + return nil, err + } + } + return &postgresSessionLocker{ + lockID: cfg.lockID, + retryLock: retry.WithMaxRetries( + cfg.lockProbe.failureThreshold, + retry.NewConstant(cfg.lockProbe.intervalDuration), + ), + retryUnlock: retry.WithMaxRetries( + cfg.unlockProbe.failureThreshold, + retry.NewConstant(cfg.unlockProbe.intervalDuration), + ), + }, nil +} + +type postgresSessionLocker struct { + lockID int64 + retryLock retry.Backoff + retryUnlock retry.Backoff +} + +var _ SessionLocker = (*postgresSessionLocker)(nil) + +func (l *postgresSessionLocker) SessionLock(ctx context.Context, conn *sql.Conn) error { + return retry.Do(ctx, l.retryLock, func(ctx context.Context) error { + row := conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", l.lockID) + var locked bool + if err := row.Scan(&locked); err != nil { + return fmt.Errorf("failed to execute pg_try_advisory_lock: %w", err) + } + if locked { + // A session-level advisory lock was acquired. + return nil + } + // A session-level advisory lock could not be acquired. This is likely because another + // process has already acquired the lock. We will continue retrying until the lock is + // acquired or the maximum number of retries is reached. + return retry.RetryableError(errors.New("failed to acquire lock")) + }) +} + +func (l *postgresSessionLocker) SessionUnlock(ctx context.Context, conn *sql.Conn) error { + return retry.Do(ctx, l.retryUnlock, func(ctx context.Context) error { + var unlocked bool + row := conn.QueryRowContext(ctx, "SELECT pg_advisory_unlock($1)", l.lockID) + if err := row.Scan(&unlocked); err != nil { + return fmt.Errorf("failed to execute pg_advisory_unlock: %w", err) + } + if unlocked { + // A session-level advisory lock was released. + return nil + } + /* + docs(md): provide users with some documentation on how they can unlock the session + manually. + + This is probably not an issue for 99.99% of users since pg_advisory_unlock_all() will + release all session level advisory locks held by the current session. It is implicitly + invoked at session end, even if the client disconnects ungracefully. + + Here is output from a session that has a lock held: + + SELECT pid, granted, ((classid::bigint << 32) | objid::bigint) AS goose_lock_id FROM + pg_locks WHERE locktype = 'advisory'; + + | pid | granted | goose_lock_id | + |-----|---------|---------------------| + | 191 | t | 4097083626 | + + A forceful way to unlock the session is to terminate the backend with SIGTERM: + + SELECT pg_terminate_backend(191); + + Subsequent commands on the same connection will fail with: + + Query 1 ERROR: FATAL: terminating connection due to administrator command + */ + return retry.RetryableError(errors.New("failed to unlock session")) + }) +} diff --git a/vendor/github.com/pressly/goose/v3/lock/session_locker_options.go b/vendor/github.com/pressly/goose/v3/lock/session_locker_options.go new file mode 100644 index 0000000000..083e404e88 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/lock/session_locker_options.go @@ -0,0 +1,98 @@ +package lock + +import ( + "errors" + "time" +) + +const ( + // DefaultLockID is the id used to lock the database for migrations. It is a crc64 hash of the + // string "goose". This is used to ensure that the lock is unique to goose. + // + // crc32.Checksum([]byte("goose"), crc32.MakeTable(crc32.IEEE)) + DefaultLockID int64 = 4097083626 +) + +// SessionLockerOption is used to configure a SessionLocker. +type SessionLockerOption interface { + apply(*sessionLockerConfig) error +} + +// WithLockID sets the lock ID to use when locking the database. +// +// If WithLockID is not called, the DefaultLockID is used. +func WithLockID(lockID int64) SessionLockerOption { + return sessionLockerConfigFunc(func(c *sessionLockerConfig) error { + c.lockID = lockID + return nil + }) +} + +// WithLockTimeout sets the max duration to wait for the lock to be acquired. The total duration +// will be the period times the failure threshold. +// +// By default, the lock timeout is 300s (5min), where the lock is retried every 5 seconds (period) +// up to 60 times (failure threshold). +// +// The minimum period is 1 second, and the minimum failure threshold is 1. +func WithLockTimeout(period, failureThreshold uint64) SessionLockerOption { + return sessionLockerConfigFunc(func(c *sessionLockerConfig) error { + if period < 1 { + return errors.New("period must be greater than 0, minimum is 1") + } + if failureThreshold < 1 { + return errors.New("failure threshold must be greater than 0, minimum is 1") + } + c.lockProbe = probe{ + intervalDuration: time.Duration(period) * time.Second, + failureThreshold: failureThreshold, + } + return nil + }) +} + +// WithUnlockTimeout sets the max duration to wait for the lock to be released. The total duration +// will be the period times the failure threshold. +// +// By default, the lock timeout is 60s, where the lock is retried every 2 seconds (period) up to 30 +// times (failure threshold). +// +// The minimum period is 1 second, and the minimum failure threshold is 1. +func WithUnlockTimeout(period, failureThreshold uint64) SessionLockerOption { + return sessionLockerConfigFunc(func(c *sessionLockerConfig) error { + if period < 1 { + return errors.New("period must be greater than 0, minimum is 1") + } + if failureThreshold < 1 { + return errors.New("failure threshold must be greater than 0, minimum is 1") + } + c.unlockProbe = probe{ + intervalDuration: time.Duration(period) * time.Second, + failureThreshold: failureThreshold, + } + return nil + }) +} + +type sessionLockerConfig struct { + lockID int64 + lockProbe probe + unlockProbe probe +} + +// probe is used to configure how often and how many times to retry a lock or unlock operation. The +// total timeout will be the period times the failure threshold. +type probe struct { + // How often (in seconds) to perform the probe. + intervalDuration time.Duration + // Number of times to retry the probe. + failureThreshold uint64 +} + +var _ SessionLockerOption = (sessionLockerConfigFunc)(nil) + +type sessionLockerConfigFunc func(*sessionLockerConfig) error + +func (f sessionLockerConfigFunc) apply(cfg *sessionLockerConfig) error { + return f(cfg) +} diff --git a/vendor/github.com/pressly/goose/v3/lock/table_locker_options.go b/vendor/github.com/pressly/goose/v3/lock/table_locker_options.go new file mode 100644 index 0000000000..970c98719a --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/lock/table_locker_options.go @@ -0,0 +1,136 @@ +package lock + +import ( + "errors" + "fmt" + "log/slog" + "time" + + "github.com/pressly/goose/v3/lock/internal/table" +) + +const ( + // DefaultLockTableName is the default name of the lock table. + DefaultLockTableName = "goose_lock" +) + +// TableLockerOption is used to configure a table-based locker. +type TableLockerOption interface { + apply(*table.Config) error +} + +// WithTableName sets the name of the lock table. +func WithTableName(tableName string) TableLockerOption { + return tableLockerConfigFunc(func(c *table.Config) error { + if tableName == "" { + return errors.New("lock table name must not be empty") + } + c.TableName = tableName + return nil + }) +} + +// WithTableLockID sets the lock ID to use for this locker instance. Different lock IDs allow for +// multiple independent locks in the same table. +func WithTableLockID(lockID int64) TableLockerOption { + return tableLockerConfigFunc(func(c *table.Config) error { + if lockID <= 0 { + return fmt.Errorf("lock ID must be greater than zero: %d", lockID) + } + c.LockID = lockID + return nil + }) +} + +// WithTableLeaseDuration sets how long a lock lease lasts. The lock will expire after this duration +// if not renewed by heartbeat. +func WithTableLeaseDuration(duration time.Duration) TableLockerOption { + return tableLockerConfigFunc(func(c *table.Config) error { + if duration <= 0 { + return errors.New("lease duration must be positive") + } + c.LeaseDuration = duration + return nil + }) +} + +// WithTableHeartbeatInterval sets how often to send heartbeat updates to renew the lease. This +// should be significantly smaller than the lease duration. +func WithTableHeartbeatInterval(interval time.Duration) TableLockerOption { + return tableLockerConfigFunc(func(c *table.Config) error { + if interval <= 0 { + return errors.New("heartbeat interval must be positive") + } + c.HeartbeatInterval = interval + return nil + }) +} + +// WithTableLockTimeout configures how long to retry acquiring a lock and how often to retry. +func WithTableLockTimeout(intervalDuration time.Duration, failureThreshold uint64) TableLockerOption { + return tableLockerConfigFunc(func(c *table.Config) error { + if intervalDuration <= 0 { + return errors.New("lock timeout interval duration must be positive") + } + if failureThreshold == 0 { + return errors.New("lock timeout failure threshold must be positive") + } + c.LockTimeout = table.ProbeConfig{ + IntervalDuration: intervalDuration, + FailureThreshold: failureThreshold, + } + return nil + }) +} + +// WithTableUnlockTimeout configures how long to retry releasing a lock and how often to retry. +func WithTableUnlockTimeout(intervalDuration time.Duration, failureThreshold uint64) TableLockerOption { + return tableLockerConfigFunc(func(c *table.Config) error { + if intervalDuration <= 0 { + return errors.New("unlock timeout interval duration must be positive") + } + if failureThreshold == 0 { + return errors.New("unlock timeout failure threshold must be positive") + } + c.UnlockTimeout = table.ProbeConfig{ + IntervalDuration: intervalDuration, + FailureThreshold: failureThreshold, + } + return nil + }) +} + +// WithTableLogger sets an optional logger for lock operations. If not provided, lock operations +// will use a default logger that only logs errors to stderr. +func WithTableLogger(logger *slog.Logger) TableLockerOption { + return tableLockerConfigFunc(func(c *table.Config) error { + c.Logger = logger + return nil + }) +} + +// WithTableRetryPolicy sets an optional callback to classify database errors during table lock +// operations. +// +// The provided function is invoked whenever a database operation fails. This includes Lock(), +// Unlock(), and heartbeat/lease update operations. +// +// If the function returns true, the operation is retried according to the configured retry/backoff +// policy. +// +// If it returns false, the operation fails immediately, bypassing any retries. +// +// This allows clients to implement custom logic for transient errors, driver-specific errors, or +// application-specific failure handling. +func WithTableRetryPolicy(retryPolicy func(error) bool) TableLockerOption { + return tableLockerConfigFunc(func(c *table.Config) error { + c.RetryPolicy = retryPolicy + return nil + }) +} + +type tableLockerConfigFunc func(*table.Config) error + +func (f tableLockerConfigFunc) apply(cfg *table.Config) error { + return f(cfg) +} diff --git a/vendor/github.com/pressly/goose/v3/log.go b/vendor/github.com/pressly/goose/v3/log.go new file mode 100644 index 0000000000..4596b4a01c --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/log.go @@ -0,0 +1,36 @@ +package goose + +import ( + std "log" +) + +var log Logger = &stdLogger{} + +// Logger is standard logger interface +type Logger interface { + Fatalf(format string, v ...any) + Printf(format string, v ...any) +} + +// SetLogger sets the logger for package output +func SetLogger(l Logger) { + log = l +} + +// stdLogger is a default logger that outputs to a stdlib's log.std logger. +type stdLogger struct{} + +func (*stdLogger) Fatalf(format string, v ...any) { std.Fatalf(format, v...) } +func (*stdLogger) Printf(format string, v ...any) { std.Printf(format, v...) } + +// NopLogger returns a logger that discards all logged output. +func NopLogger() Logger { + return &nopLogger{} +} + +type nopLogger struct{} + +var _ Logger = (*nopLogger)(nil) + +func (*nopLogger) Fatalf(format string, v ...any) {} +func (*nopLogger) Printf(format string, v ...any) {} diff --git a/vendor/github.com/pressly/goose/v3/migrate.go b/vendor/github.com/pressly/goose/v3/migrate.go new file mode 100644 index 0000000000..b60c61ae77 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/migrate.go @@ -0,0 +1,379 @@ +package goose + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io/fs" + "math" + "path" + "sort" + "strings" + "time" + + "go.uber.org/multierr" +) + +var ( + // ErrNoMigrationFiles when no migration files have been found. + ErrNoMigrationFiles = errors.New("no migration files found") + // ErrNoCurrentVersion when a current migration version is not found. + ErrNoCurrentVersion = errors.New("no current version found") + // ErrNoNextVersion when the next migration version is not found. + ErrNoNextVersion = errors.New("no next version found") + // MaxVersion is the maximum allowed version. + MaxVersion int64 = math.MaxInt64 +) + +// Migrations slice. +type Migrations []*Migration + +// helpers so we can use pkg sort +func (ms Migrations) Len() int { return len(ms) } +func (ms Migrations) Swap(i, j int) { ms[i], ms[j] = ms[j], ms[i] } +func (ms Migrations) Less(i, j int) bool { + if ms[i].Version == ms[j].Version { + panic(fmt.Sprintf("goose: duplicate version %v detected:\n%v\n%v", ms[i].Version, ms[i].Source, ms[j].Source)) + } + return ms[i].Version < ms[j].Version +} + +// Current gets the current migration. +func (ms Migrations) Current(current int64) (*Migration, error) { + for i, migration := range ms { + if migration.Version == current { + return ms[i], nil + } + } + + return nil, ErrNoCurrentVersion +} + +// Next gets the next migration. +func (ms Migrations) Next(current int64) (*Migration, error) { + for i, migration := range ms { + if migration.Version > current { + return ms[i], nil + } + } + + return nil, ErrNoNextVersion +} + +// Previous : Get the previous migration. +func (ms Migrations) Previous(current int64) (*Migration, error) { + for i := len(ms) - 1; i >= 0; i-- { + if ms[i].Version < current { + return ms[i], nil + } + } + + return nil, ErrNoNextVersion +} + +// Last gets the last migration. +func (ms Migrations) Last() (*Migration, error) { + if len(ms) == 0 { + return nil, ErrNoNextVersion + } + + return ms[len(ms)-1], nil +} + +// Versioned gets versioned migrations. +func (ms Migrations) versioned() (Migrations, error) { + var migrations Migrations + + // assume that the user will never have more than 19700101000000 migrations + for _, m := range ms { + // parse version as timestamp + versionTime, err := time.Parse(timestampFormat, fmt.Sprintf("%d", m.Version)) + + if versionTime.Before(time.Unix(0, 0)) || err != nil { + migrations = append(migrations, m) + } + } + + return migrations, nil +} + +// Timestamped gets the timestamped migrations. +func (ms Migrations) timestamped() (Migrations, error) { + var migrations Migrations + + // assume that the user will never have more than 19700101000000 migrations + for _, m := range ms { + // parse version as timestamp + versionTime, err := time.Parse(timestampFormat, fmt.Sprintf("%d", m.Version)) + if err != nil { + // probably not a timestamp + continue + } + + if versionTime.After(time.Unix(0, 0)) { + migrations = append(migrations, m) + } + } + return migrations, nil +} + +func (ms Migrations) String() string { + var str strings.Builder + for _, m := range ms { + fmt.Fprintln(&str, m) + } + return str.String() +} + +func collectMigrationsFS( + fsys fs.FS, + dirpath string, + current, target int64, + registered map[int64]*Migration, +) (Migrations, error) { + if _, err := fs.Stat(fsys, dirpath); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("%s directory does not exist", dirpath) + } + return nil, err + } + var migrations Migrations + // SQL migration files. + sqlMigrationFiles, err := fs.Glob(fsys, path.Join(dirpath, "*.sql")) + if err != nil { + return nil, err + } + for _, file := range sqlMigrationFiles { + v, err := NumericComponent(file) + if err != nil { + return nil, fmt.Errorf("could not parse SQL migration file %q: %w", file, err) + } + if versionFilter(v, current, target) { + migrations = append(migrations, &Migration{ + Version: v, + Next: -1, + Previous: -1, + Source: file, + }) + } + } + // Go migration files. + goMigrations, err := collectGoMigrations(fsys, dirpath, registered, current, target) + if err != nil { + return nil, err + } + migrations = append(migrations, goMigrations...) + if len(migrations) == 0 { + return nil, ErrNoMigrationFiles + } + return sortAndConnectMigrations(migrations), nil +} + +// CollectMigrations returns all the valid looking migration scripts in the +// migrations folder and go func registry, and key them by version. +func CollectMigrations(dirpath string, current, target int64) (Migrations, error) { + return collectMigrationsFS(baseFS, dirpath, current, target, registeredGoMigrations) +} + +func sortAndConnectMigrations(migrations Migrations) Migrations { + sort.Sort(migrations) + + // now that we're sorted in the appropriate direction, + // populate next and previous for each migration + for i, m := range migrations { + prev := int64(-1) + if i > 0 { + prev = migrations[i-1].Version + migrations[i-1].Next = m.Version + } + migrations[i].Previous = prev + } + + return migrations +} + +func versionFilter(v, current, target int64) bool { + if target > current { + return v > current && v <= target + } + if target < current { + return v <= current && v > target + } + return false +} + +// EnsureDBVersion retrieves the current version for this DB. +// Create and initialize the DB version table if it doesn't exist. +func EnsureDBVersion(db *sql.DB) (int64, error) { + ctx := context.Background() + return EnsureDBVersionContext(ctx, db) +} + +// EnsureDBVersionContext retrieves the current version for this DB. +// Create and initialize the DB version table if it doesn't exist. +func EnsureDBVersionContext(ctx context.Context, db *sql.DB) (int64, error) { + dbMigrations, err := store.ListMigrations(ctx, db, TableName()) + if err != nil { + createErr := createVersionTable(ctx, db) + if createErr != nil { + return 0, multierr.Append(err, createErr) + } + return 0, nil + } + // The most recent record for each migration specifies + // whether it has been applied or rolled back. + // The first version we find that has been applied is the current version. + // + // TODO(mf): for historic reasons, we continue to use the is_applied column, + // but at some point we need to deprecate this logic and ideally remove + // this column. + // + // For context, see: + // https://github.com/pressly/goose/pull/131#pullrequestreview-178409168 + // + // The dbMigrations list is expected to be ordered by descending ID. But + // in the future we should be able to query the last record only. + skipLookup := make(map[int64]struct{}) + for _, m := range dbMigrations { + // Have we already marked this version to be skipped? + if _, ok := skipLookup[m.VersionID]; ok { + continue + } + // If version has been applied we are done. + if m.IsApplied { + return m.VersionID, nil + } + // Latest version of migration has not been applied. + skipLookup[m.VersionID] = struct{}{} + } + return 0, ErrNoNextVersion +} + +// createVersionTable creates the db version table and inserts the +// initial 0 value into it. +func createVersionTable(ctx context.Context, db *sql.DB) error { + txn, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + if err := store.CreateVersionTable(ctx, txn, TableName()); err != nil { + _ = txn.Rollback() + return err + } + if err := store.InsertVersion(ctx, txn, TableName(), 0); err != nil { + _ = txn.Rollback() + return err + } + return txn.Commit() +} + +// GetDBVersion is an alias for EnsureDBVersion, but returns -1 in error. +func GetDBVersion(db *sql.DB) (int64, error) { + ctx := context.Background() + return GetDBVersionContext(ctx, db) +} + +// GetDBVersionContext is an alias for EnsureDBVersion, but returns -1 in error. +func GetDBVersionContext(ctx context.Context, db *sql.DB) (int64, error) { + version, err := EnsureDBVersionContext(ctx, db) + if err != nil { + return -1, err + } + + return version, nil +} + +// collectGoMigrations collects Go migrations from the filesystem and merges them with registered +// migrations. +// +// If Go migrations have been registered globally, with [goose.AddNamedMigration...], but there are +// no corresponding .go files in the filesystem, add them to the migrations slice. +// +// If Go migrations have been registered, and there are .go files in the filesystem dirpath, ONLY +// include those in the migrations slices. +// +// Lastly, if there are .go files in the filesystem but they have not been registered, raise an +// error. This is to prevent users from accidentally adding valid looking Go files to the migrations +// folder without registering them. +func collectGoMigrations( + fsys fs.FS, + dirpath string, + registeredGoMigrations map[int64]*Migration, + current, target int64, +) (Migrations, error) { + // Sanity check registered migrations have the correct version prefix. + for _, m := range registeredGoMigrations { + if _, err := NumericComponent(m.Source); err != nil { + return nil, fmt.Errorf("could not parse go migration file %s: %w", m.Source, err) + } + } + goFiles, err := fs.Glob(fsys, path.Join(dirpath, "*.go")) + if err != nil { + return nil, err + } + // If there are no Go files in the filesystem and no registered Go migrations, return early. + if len(goFiles) == 0 && len(registeredGoMigrations) == 0 { + return nil, nil + } + type source struct { + fullpath string + version int64 + } + // Find all Go files that have a version prefix and are within the requested range. + var sources []source + for _, fullpath := range goFiles { + v, err := NumericComponent(fullpath) + if err != nil { + continue // Skip any files that don't have version prefix. + } + if strings.HasSuffix(fullpath, "_test.go") { + continue // Skip Go test files. + } + if versionFilter(v, current, target) { + sources = append(sources, source{ + fullpath: fullpath, + version: v, + }) + } + } + var ( + migrations Migrations + ) + if len(sources) > 0 { + for _, s := range sources { + migration, ok := registeredGoMigrations[s.version] + if ok { + migrations = append(migrations, migration) + } else { + // TODO(mf): something that bothers me about this implementation is it will be + // lazily evaluated and the error will only be raised if the user tries to run the + // migration. It would be better to raise an error much earlier in the process. + migrations = append(migrations, &Migration{ + Version: s.version, + Next: -1, + Previous: -1, + Source: s.fullpath, + Registered: false, + }) + } + } + } else { + // Some users may register Go migrations manually via AddNamedMigration_ functions but not + // provide the corresponding .go files in the filesystem. In this case, we include them + // wholesale in the migrations slice. + // + // This is a valid use case because users may want to build a custom binary that only embeds + // the SQL migration files and some other mechanism for registering Go migrations. + for _, migration := range registeredGoMigrations { + v, err := NumericComponent(migration.Source) + if err != nil { + return nil, fmt.Errorf("could not parse go migration file %s: %w", migration.Source, err) + } + if versionFilter(v, current, target) { + migrations = append(migrations, migration) + } + } + } + return migrations, nil +} diff --git a/vendor/github.com/pressly/goose/v3/migration.go b/vendor/github.com/pressly/goose/v3/migration.go new file mode 100644 index 0000000000..7dca831f5a --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/migration.go @@ -0,0 +1,398 @@ +package goose + +import ( + "context" + "database/sql" + "errors" + "fmt" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/pressly/goose/v3/internal/sqlparser" +) + +// NewGoMigration creates a new Go migration. +// +// Both up and down functions may be nil, in which case the migration will be recorded in the +// versions table but no functions will be run. This is useful for recording (up) or deleting (down) +// a version without running any functions. See [GoFunc] for more details. +func NewGoMigration(version int64, up, down *GoFunc) *Migration { + m := &Migration{ + Type: TypeGo, + Registered: true, + Version: version, + Next: -1, Previous: -1, + goUp: &GoFunc{Mode: TransactionEnabled}, + goDown: &GoFunc{Mode: TransactionEnabled}, + construct: true, + } + updateMode := func(f *GoFunc) *GoFunc { + // infer mode from function + if f.Mode == 0 { + if f.RunTx != nil && f.RunDB == nil { + f.Mode = TransactionEnabled + } + if f.RunTx == nil && f.RunDB != nil { + f.Mode = TransactionDisabled + } + // Always default to TransactionEnabled if both functions are nil. This is the most + // common use case. + if f.RunDB == nil && f.RunTx == nil { + f.Mode = TransactionEnabled + } + } + return f + } + // To maintain backwards compatibility, we set ALL legacy functions. In a future major version, + // we will remove these fields in favor of [GoFunc]. + // + // Note, this function does not do any validation. Validation is lazily done when the migration + // is registered. + if up != nil { + m.goUp = updateMode(up) + + if up.RunDB != nil { + m.UpFnNoTxContext = up.RunDB // func(context.Context, *sql.DB) error + m.UpFnNoTx = withoutContext(up.RunDB) // func(*sql.DB) error + } + if up.RunTx != nil { + m.UseTx = true + m.UpFnContext = up.RunTx // func(context.Context, *sql.Tx) error + m.UpFn = withoutContext(up.RunTx) // func(*sql.Tx) error + } + } + if down != nil { + m.goDown = updateMode(down) + + if down.RunDB != nil { + m.DownFnNoTxContext = down.RunDB // func(context.Context, *sql.DB) error + m.DownFnNoTx = withoutContext(down.RunDB) // func(*sql.DB) error + } + if down.RunTx != nil { + m.UseTx = true + m.DownFnContext = down.RunTx // func(context.Context, *sql.Tx) error + m.DownFn = withoutContext(down.RunTx) // func(*sql.Tx) error + } + } + return m +} + +// Migration struct represents either a SQL or Go migration. +// +// Avoid constructing migrations manually, use [NewGoMigration] function. +type Migration struct { + Type MigrationType + Version int64 + // Source is the path to the .sql script or .go file. It may be empty for Go migrations that + // have been registered globally and don't have a source file. + Source string + + UpFnContext, DownFnContext GoMigrationContext + UpFnNoTxContext, DownFnNoTxContext GoMigrationNoTxContext + + // These fields will be removed in a future major version. They are here for backwards + // compatibility and are an implementation detail. + Registered bool + UseTx bool + Next int64 // next version, or -1 if none + Previous int64 // previous version, -1 if none + + // We still save the non-context versions in the struct in case someone is using them. Goose + // does not use these internally anymore in favor of the context-aware versions. These fields + // will be removed in a future major version. + + UpFn GoMigration // Deprecated: use UpFnContext instead. + DownFn GoMigration // Deprecated: use DownFnContext instead. + UpFnNoTx GoMigrationNoTx // Deprecated: use UpFnNoTxContext instead. + DownFnNoTx GoMigrationNoTx // Deprecated: use DownFnNoTxContext instead. + + noVersioning bool + + // These fields are used internally by goose and users are not expected to set them. Instead, + // use [NewGoMigration] to create a new go migration. + construct bool + goUp, goDown *GoFunc + + sql sqlMigration +} + +type sqlMigration struct { + // The Parsed field is used to track whether the SQL migration has been parsed. It serves as an + // optimization to avoid parsing migrations that may never be needed. Typically, migrations are + // incremental, and users often run only the most recent ones, making parsing of prior + // migrations unnecessary in most cases. + Parsed bool + + // Parsed must be set to true before the following fields are used. + UseTx bool + Up []string + Down []string +} + +// GoFunc represents a Go migration function. +type GoFunc struct { + // Exactly one of these must be set, or both must be nil. + RunTx func(ctx context.Context, tx *sql.Tx) error + // -- OR -- + RunDB func(ctx context.Context, db *sql.DB) error + + // Mode is the transaction mode for the migration. When one of the run functions is set, the + // mode will be inferred from the function and the field is ignored. Users do not need to set + // this field when supplying a run function. + // + // If both run functions are nil, the mode defaults to TransactionEnabled. The use case for nil + // functions is to record a version in the version table without invoking a Go migration + // function. + // + // The only time this field is required is if BOTH run functions are nil AND you want to + // override the default transaction mode. + Mode TransactionMode +} + +// TransactionMode represents the possible transaction modes for a migration. +type TransactionMode int + +const ( + TransactionEnabled TransactionMode = iota + 1 + TransactionDisabled +) + +func (m TransactionMode) String() string { + switch m { + case TransactionEnabled: + return "transaction_enabled" + case TransactionDisabled: + return "transaction_disabled" + default: + return fmt.Sprintf("unknown transaction mode (%d)", m) + } +} + +// MigrationRecord struct. +// +// Deprecated: unused and will be removed in a future major version. +type MigrationRecord struct { + VersionID int64 + TStamp time.Time + IsApplied bool // was this a result of up() or down() +} + +func (m *Migration) String() string { + return fmt.Sprint(m.Source) +} + +// Up runs an up migration. +func (m *Migration) Up(db *sql.DB) error { + ctx := context.Background() + return m.UpContext(ctx, db) +} + +// UpContext runs an up migration. +func (m *Migration) UpContext(ctx context.Context, db *sql.DB) error { + if err := m.run(ctx, db, true); err != nil { + return err + } + return nil +} + +// Down runs a down migration. +func (m *Migration) Down(db *sql.DB) error { + ctx := context.Background() + return m.DownContext(ctx, db) +} + +// DownContext runs a down migration. +func (m *Migration) DownContext(ctx context.Context, db *sql.DB) error { + if err := m.run(ctx, db, false); err != nil { + return err + } + return nil +} + +func (m *Migration) run(ctx context.Context, db *sql.DB, direction bool) error { + switch filepath.Ext(m.Source) { + case ".sql": + f, err := baseFS.Open(m.Source) + if err != nil { + return fmt.Errorf("ERROR %v: failed to open SQL migration file: %w", filepath.Base(m.Source), err) + } + defer f.Close() + + statements, useTx, err := sqlparser.ParseSQLMigration(f, sqlparser.FromBool(direction), verbose) + if err != nil { + return fmt.Errorf("ERROR %v: failed to parse SQL migration file: %w", filepath.Base(m.Source), err) + } + + start := time.Now() + if err := runSQLMigration(ctx, db, statements, useTx, m.Version, direction, m.noVersioning); err != nil { + return fmt.Errorf("ERROR %v: failed to run SQL migration: %w", filepath.Base(m.Source), err) + } + finish := truncateDuration(time.Since(start)) + + if len(statements) > 0 { + log.Printf("OK %s (%s)", filepath.Base(m.Source), finish) + } else { + log.Printf("EMPTY %s (%s)", filepath.Base(m.Source), finish) + } + + case ".go": + if !m.Registered { + return fmt.Errorf("ERROR %v: failed to run Go migration: Go functions must be registered and built into a custom binary (see https://github.com/pressly/goose/tree/master/examples/go-migrations)", m.Source) + } + start := time.Now() + var empty bool + if m.UseTx { + // Run go-based migration inside a tx. + fn := m.DownFnContext + if direction { + fn = m.UpFnContext + } + empty = (fn == nil) + if err := runGoMigration( + ctx, + db, + fn, + m.Version, + direction, + !m.noVersioning, + ); err != nil { + return fmt.Errorf("ERROR go migration: %q: %w", filepath.Base(m.Source), err) + } + } else { + // Run go-based migration outside a tx. + fn := m.DownFnNoTxContext + if direction { + fn = m.UpFnNoTxContext + } + empty = (fn == nil) + if err := runGoMigrationNoTx( + ctx, + db, + fn, + m.Version, + direction, + !m.noVersioning, + ); err != nil { + return fmt.Errorf("ERROR go migration no tx: %q: %w", filepath.Base(m.Source), err) + } + } + finish := truncateDuration(time.Since(start)) + if !empty { + log.Printf("OK %s (%s)", filepath.Base(m.Source), finish) + } else { + log.Printf("EMPTY %s (%s)", filepath.Base(m.Source), finish) + } + } + return nil +} + +func runGoMigrationNoTx( + ctx context.Context, + db *sql.DB, + fn GoMigrationNoTxContext, + version int64, + direction bool, + recordVersion bool, +) error { + if fn != nil { + // Run go migration function. + if err := fn(ctx, db); err != nil { + return fmt.Errorf("failed to run go migration: %w", err) + } + } + if recordVersion { + return insertOrDeleteVersionNoTx(ctx, db, version, direction) + } + return nil +} + +func runGoMigration( + ctx context.Context, + db *sql.DB, + fn GoMigrationContext, + version int64, + direction bool, + recordVersion bool, +) error { + if fn == nil && !recordVersion { + return nil + } + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + if fn != nil { + // Run go migration function. + if err := fn(ctx, tx); err != nil { + _ = tx.Rollback() + return fmt.Errorf("failed to run go migration: %w", err) + } + } + if recordVersion { + if err := insertOrDeleteVersion(ctx, tx, version, direction); err != nil { + _ = tx.Rollback() + return fmt.Errorf("failed to update version: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + return nil +} + +func insertOrDeleteVersion(ctx context.Context, tx *sql.Tx, version int64, direction bool) error { + if direction { + return store.InsertVersion(ctx, tx, TableName(), version) + } + return store.DeleteVersion(ctx, tx, TableName(), version) +} + +func insertOrDeleteVersionNoTx(ctx context.Context, db *sql.DB, version int64, direction bool) error { + if direction { + return store.InsertVersionNoTx(ctx, db, TableName(), version) + } + return store.DeleteVersionNoTx(ctx, db, TableName(), version) +} + +// NumericComponent parses the version from the migration file name. +// +// XXX_descriptivename.ext where XXX specifies the version number and ext specifies the type of +// migration, either .sql or .go. +func NumericComponent(filename string) (int64, error) { + base := filepath.Base(filename) + if ext := filepath.Ext(base); ext != ".go" && ext != ".sql" { + return 0, errors.New("migration file does not have .sql or .go file extension") + } + before, _, ok := strings.Cut(base, "_") + if !ok { + return 0, errors.New("no filename separator '_' found") + } + n, err := strconv.ParseInt(before, 10, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse version from migration file: %s: %w", base, err) + } + if n < 1 { + return 0, errors.New("migration version must be greater than zero") + } + return n, nil +} + +func truncateDuration(d time.Duration) time.Duration { + for _, v := range []time.Duration{ + time.Second, + time.Millisecond, + time.Microsecond, + } { + if d > v { + return d.Round(v / time.Duration(100)) + } + } + return d +} + +// ref returns a string that identifies the migration. This is used for logging and error messages. +func (m *Migration) ref() string { + return fmt.Sprintf("(type:%s,version:%d)", m.Type, m.Version) +} diff --git a/vendor/github.com/pressly/goose/v3/migration_sql.go b/vendor/github.com/pressly/goose/v3/migration_sql.go new file mode 100644 index 0000000000..1fcb06dc4c --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/migration_sql.go @@ -0,0 +1,115 @@ +package goose + +import ( + "context" + "database/sql" + "fmt" + "regexp" +) + +// Run a migration specified in raw SQL. +// +// Sections of the script can be annotated with a special comment, +// starting with "-- +goose" to specify whether the section should +// be applied during an Up or Down migration +// +// All statements following an Up or Down annotation are grouped together +// until another direction annotation is found. +func runSQLMigration( + ctx context.Context, + db *sql.DB, + statements []string, + useTx bool, + v int64, + direction bool, + noVersioning bool, +) error { + if useTx { + // TRANSACTION. + + verboseInfo("Begin transaction") + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + + for _, query := range statements { + verboseInfo("Executing statement: %s\n", clearStatement(query)) + if _, err := tx.ExecContext(ctx, query); err != nil { + verboseInfo("Rollback transaction") + _ = tx.Rollback() + return fmt.Errorf("failed to execute SQL query %q: %w", clearStatement(query), err) + } + } + + if !noVersioning { + if direction { + if err := store.InsertVersion(ctx, tx, TableName(), v); err != nil { + verboseInfo("Rollback transaction") + _ = tx.Rollback() + return fmt.Errorf("failed to insert new goose version: %w", err) + } + } else { + if err := store.DeleteVersion(ctx, tx, TableName(), v); err != nil { + verboseInfo("Rollback transaction") + _ = tx.Rollback() + return fmt.Errorf("failed to delete goose version: %w", err) + } + } + } + + verboseInfo("Commit transaction") + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil + } + + // NO TRANSACTION. + for _, query := range statements { + verboseInfo("Executing statement: %s", clearStatement(query)) + if _, err := db.ExecContext(ctx, query); err != nil { + return fmt.Errorf("failed to execute SQL query %q: %w", clearStatement(query), err) + } + } + if !noVersioning { + if direction { + if err := store.InsertVersionNoTx(ctx, db, TableName(), v); err != nil { + return fmt.Errorf("failed to insert new goose version: %w", err) + } + } else { + if err := store.DeleteVersionNoTx(ctx, db, TableName(), v); err != nil { + return fmt.Errorf("failed to delete goose version: %w", err) + } + } + } + + return nil +} + +const ( + grayColor = "\033[90m" + resetColor = "\033[00m" +) + +func verboseInfo(s string, args ...any) { + if verbose { + if noColor { + log.Printf(s, args...) + } else { + log.Printf(grayColor+s+resetColor, args...) + } + } +} + +var ( + matchSQLComments = regexp.MustCompile(`(?m)^--.*$[\r\n]*`) + matchEmptyEOL = regexp.MustCompile(`(?m)^$[\r\n]*`) // TODO: Duplicate +) + +func clearStatement(s string) string { + s = matchSQLComments.ReplaceAllString(s, ``) + return matchEmptyEOL.ReplaceAllString(s, ``) +} diff --git a/vendor/github.com/pressly/goose/v3/osfs.go b/vendor/github.com/pressly/goose/v3/osfs.go new file mode 100644 index 0000000000..420f95ff9a --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/osfs.go @@ -0,0 +1,28 @@ +package goose + +import ( + "io/fs" + "os" + "path/filepath" +) + +// osFS wraps functions working with os filesystem to implement fs.FS interfaces. +type osFS struct{} + +func (osFS) Open(name string) (fs.File, error) { return os.Open(filepath.FromSlash(name)) } + +func (osFS) ReadDir(name string) ([]fs.DirEntry, error) { return os.ReadDir(filepath.FromSlash(name)) } + +func (osFS) Stat(name string) (fs.FileInfo, error) { return os.Stat(filepath.FromSlash(name)) } + +func (osFS) ReadFile(name string) ([]byte, error) { return os.ReadFile(filepath.FromSlash(name)) } + +func (osFS) Glob(pattern string) ([]string, error) { return filepath.Glob(filepath.FromSlash(pattern)) } + +type noopFS struct{} + +var _ fs.FS = noopFS{} + +func (f noopFS) Open(name string) (fs.File, error) { + return nil, os.ErrNotExist +} diff --git a/vendor/github.com/pressly/goose/v3/provider.go b/vendor/github.com/pressly/goose/v3/provider.go new file mode 100644 index 0000000000..e65505c566 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/provider.go @@ -0,0 +1,669 @@ +package goose + +import ( + "cmp" + "context" + "database/sql" + "errors" + "fmt" + "io/fs" + "log/slog" + "maps" + "math" + "strconv" + "strings" + "sync" + + "github.com/pressly/goose/v3/database" + "github.com/pressly/goose/v3/internal/controller" + "github.com/pressly/goose/v3/internal/gooseutil" + "github.com/pressly/goose/v3/internal/sqlparser" + "go.uber.org/multierr" +) + +// Provider is a goose migration provider. +type Provider struct { + // mu protects all accesses to the provider and must be held when calling operations on the + // database. + mu sync.Mutex + + db *sql.DB + store *controller.StoreController + versionTableOnce sync.Once + + fsys fs.FS + cfg config + + // migrations are ordered by version in ascending order. This list will never be empty and + // contains all migrations known to the provider. + migrations []*Migration +} + +// NewProvider returns a new goose provider. +// +// The caller is responsible for matching the database dialect with the database/sql driver. For +// example, if the database dialect is "postgres", the database/sql driver could be +// github.com/lib/pq or github.com/jackc/pgx. Each dialect has a corresponding [database.Dialect] +// constant backed by a default [database.Store] implementation. For more advanced use cases, such +// as using a custom table name or supplying a custom store implementation, see [WithStore]. +// +// fsys is the filesystem used to read migration files, but may be nil. Most users will want to use +// [os.DirFS], os.DirFS("path/to/migrations"), to read migrations from the local filesystem. +// However, it is possible to use a different "filesystem", such as [embed.FS] or filter out +// migrations using [fs.Sub]. +// +// See [ProviderOption] for more information on configuring the provider. +// +// Unless otherwise specified, all methods on Provider are safe for concurrent use. +func NewProvider(dialect Dialect, db *sql.DB, fsys fs.FS, opts ...ProviderOption) (*Provider, error) { + if db == nil { + return nil, errors.New("db must not be nil") + } + if fsys == nil { + fsys = noopFS{} + } + cfg := config{ + registered: make(map[int64]*Migration), + excludePaths: make(map[string]bool), + excludeVersions: make(map[int64]bool), + } + for _, opt := range opts { + if err := opt.apply(&cfg); err != nil { + return nil, err + } + } + // Allow users to specify a custom store implementation, but only if they don't specify a + // dialect. If they specify a dialect, we'll use the default store implementation. + if dialect == DialectCustom && cfg.store == nil { + return nil, errors.New("custom store must be supplied when using a custom dialect, make sure to pass WithStore option") + } + if dialect != DialectCustom && cfg.store != nil { + return nil, errors.New("custom store must not be specified when using one of the default dialects, use DialectCustom instead") + } + // Allow table name to be set only if store is not set. + if cfg.tableName != "" && cfg.store != nil { + return nil, errors.New("WithTableName cannot be used with WithStore; set the table name directly on your custom store") + } + + // Set default logger if neither was provided + if cfg.slogger == nil && cfg.logger == nil { + cfg.logger = &stdLogger{} + } + var store database.Store + if dialect != "" { + var err error + store, err = database.NewStore(dialect, cmp.Or(cfg.tableName, DefaultTablename)) + if err != nil { + return nil, err + } + } else { + store = cfg.store + } + if store.Tablename() == "" { + return nil, errors.New("invalid store implementation: table name must not be empty") + } + return newProvider(db, store, fsys, cfg, registeredGoMigrations /* global */) +} + +func newProvider( + db *sql.DB, + store database.Store, + fsys fs.FS, + cfg config, + global map[int64]*Migration, +) (*Provider, error) { + // Collect migrations from the filesystem and merge with registered migrations. + // + // Note, we don't parse SQL migrations here. They are parsed lazily when required. + + // feat(mf): we could add a flag to parse SQL migrations eagerly. This would allow us to return + // an error if there are any SQL parsing errors. This adds a bit overhead to startup though, so + // we should make it optional. + filesystemSources, err := collectFilesystemSources(fsys, false, cfg.excludePaths, cfg.excludeVersions) + if err != nil { + return nil, err + } + versionToGoMigration := make(map[int64]*Migration) + // Add user-registered Go migrations from the provider. + maps.Copy(versionToGoMigration, cfg.registered) + // Skip adding global Go migrations if explicitly disabled. + if cfg.disableGlobalRegistry { + // TODO(mf): let's add a warn-level log here to inform users if len(global) > 0. Would like + // to add this once we're on go1.21 and leverage the new slog package. + } else { + for version, m := range global { + if _, ok := versionToGoMigration[version]; ok { + return nil, fmt.Errorf("global go migration conflicts with provider-registered go migration with version %d", version) + } + versionToGoMigration[version] = m + } + } + // At this point we have all registered unique Go migrations (if any). We need to merge them + // with SQL migrations from the filesystem. + migrations, err := merge(filesystemSources, versionToGoMigration) + if err != nil { + return nil, err + } + if len(migrations) == 0 { + return nil, ErrNoMigrations + } + return &Provider{ + db: db, + fsys: fsys, + cfg: cfg, + store: controller.NewStoreController(store), + migrations: migrations, + }, nil +} + +// Status returns the status of all migrations, merging the list of migrations from the database and +// filesystem. The returned items are ordered by version, in ascending order. +func (p *Provider) Status(ctx context.Context) ([]*MigrationStatus, error) { + return p.status(ctx) +} + +// HasPending returns true if there are pending migrations to apply, otherwise, it returns false. If +// out-of-order migrations are disabled, yet some are detected, this method returns an error. +// +// Note, this method will not use a SessionLocker or Locker if one is configured. This allows +// callers to check for pending migrations without blocking or being blocked by other operations. +func (p *Provider) HasPending(ctx context.Context) (bool, error) { + return p.hasPending(ctx) +} + +// GetVersions returns the max database version and the target version to migrate to. +// +// Note, this method will not use a SessionLocker or Locker if one is configured. This allows +// callers to check for versions without blocking or being blocked by other operations. +func (p *Provider) GetVersions(ctx context.Context) (current, target int64, err error) { + return p.getVersions(ctx) +} + +// GetDBVersion returns the highest version recorded in the database, regardless of the order in +// which migrations were applied. For example, if migrations were applied out of order (1,4,2,3), +// this method returns 4. If no migrations have been applied, it returns 0. +func (p *Provider) GetDBVersion(ctx context.Context) (int64, error) { + if p.cfg.disableVersioning { + return -1, errors.New("getting database version not supported when versioning is disabled") + } + return p.getDBMaxVersion(ctx, nil) +} + +// ListSources returns a list of all migration sources known to the provider, sorted in ascending +// order by version. The path field may be empty for manually registered migrations, such as Go +// migrations registered using the [WithGoMigrations] option. +func (p *Provider) ListSources() []*Source { + sources := make([]*Source, 0, len(p.migrations)) + for _, m := range p.migrations { + sources = append(sources, &Source{ + Type: m.Type, + Path: m.Source, + Version: m.Version, + }) + } + return sources +} + +// Ping attempts to ping the database to verify a connection is available. +func (p *Provider) Ping(ctx context.Context) error { + return p.db.PingContext(ctx) +} + +// Close closes the database connection initially supplied to the provider. +func (p *Provider) Close() error { + return p.db.Close() +} + +// ApplyVersion applies exactly one migration for the specified version. If there is no migration +// available for the specified version, this method returns [ErrVersionNotFound]. If the migration +// has already been applied, this method returns [ErrAlreadyApplied]. +// +// The direction parameter determines the migration direction: true for up migration and false for +// down migration. +func (p *Provider) ApplyVersion(ctx context.Context, version int64, direction bool) (*MigrationResult, error) { + res, err := p.apply(ctx, version, direction) + if err != nil { + return nil, err + } + // This should never happen, we must return exactly one result. + if len(res) != 1 { + versions := make([]string, 0, len(res)) + for _, r := range res { + versions = append(versions, strconv.FormatInt(r.Source.Version, 10)) + } + return nil, fmt.Errorf( + "unexpected number of migrations applied running apply, expecting exactly one result: %v", + strings.Join(versions, ","), + ) + } + return res[0], nil +} + +// Up applies all pending migrations. If there are no new migrations to apply, this method returns +// empty list and nil error. +func (p *Provider) Up(ctx context.Context) ([]*MigrationResult, error) { + hasPending, err := p.HasPending(ctx) + if err != nil { + return nil, err + } + if !hasPending { + return nil, nil + } + return p.up(ctx, false, math.MaxInt64) +} + +// UpByOne applies the next pending migration. If there is no next migration to apply, this method +// returns [ErrNoNextVersion]. +func (p *Provider) UpByOne(ctx context.Context) (*MigrationResult, error) { + hasPending, err := p.HasPending(ctx) + if err != nil { + return nil, err + } + if !hasPending { + return nil, ErrNoNextVersion + } + res, err := p.up(ctx, true, math.MaxInt64) + if err != nil { + return nil, err + } + if len(res) == 0 { + return nil, ErrNoNextVersion + } + // This should never happen, we must return exactly one result. + if len(res) != 1 { + versions := make([]string, 0, len(res)) + for _, r := range res { + versions = append(versions, strconv.FormatInt(r.Source.Version, 10)) + } + return nil, fmt.Errorf( + "unexpected number of migrations applied running up-by-one, expecting exactly one result: %v", + strings.Join(versions, ","), + ) + } + return res[0], nil +} + +// UpTo applies all pending migrations up to, and including, the specified version. If there are no +// migrations to apply, this method returns empty list and nil error. +// +// For example, if there are three new migrations (9,10,11) and the current database version is 8 +// with a requested version of 10, only versions 9,10 will be applied. +func (p *Provider) UpTo(ctx context.Context, version int64) ([]*MigrationResult, error) { + hasPending, err := p.HasPending(ctx) + if err != nil { + return nil, err + } + if !hasPending { + return nil, nil + } + return p.up(ctx, false, version) +} + +// Down rolls back the most recently applied migration. If there are no migrations to rollback, this +// method returns [ErrNoNextVersion]. +// +// Note, migrations are rolled back in the order they were applied. And not in the reverse order of +// the migration version. This only applies in scenarios where migrations are allowed to be applied +// out of order. +func (p *Provider) Down(ctx context.Context) (*MigrationResult, error) { + res, err := p.down(ctx, true, 0) + if err != nil { + return nil, err + } + if len(res) == 0 { + return nil, ErrNoNextVersion + } + // This should never happen, we must return exactly one result. + if len(res) != 1 { + versions := make([]string, 0, len(res)) + for _, r := range res { + versions = append(versions, strconv.FormatInt(r.Source.Version, 10)) + } + return nil, fmt.Errorf( + "unexpected number of migrations applied running down, expecting exactly one result: %v", + strings.Join(versions, ","), + ) + } + return res[0], nil +} + +// DownTo rolls back all migrations down to, but not including, the specified version. +// +// For example, if the current database version is 11,10,9... and the requested version is 9, only +// migrations 11, 10 will be rolled back. +// +// Note, migrations are rolled back in the order they were applied. And not in the reverse order of +// the migration version. This only applies in scenarios where migrations are allowed to be applied +// out of order. +func (p *Provider) DownTo(ctx context.Context, version int64) ([]*MigrationResult, error) { + if version < 0 { + return nil, fmt.Errorf("invalid version: must be a valid number or zero: %d", version) + } + return p.down(ctx, false, version) +} + +// *** Internal methods *** + +func (p *Provider) up( + ctx context.Context, + byOne bool, + version int64, +) (_ []*MigrationResult, retErr error) { + if version < 1 { + return nil, errInvalidVersion + } + conn, cleanup, err := p.initialize(ctx, true) + if err != nil { + return nil, fmt.Errorf("failed to initialize: %w", err) + } + defer func() { + retErr = multierr.Append(retErr, cleanup()) + }() + + if len(p.migrations) == 0 { + return nil, nil + } + var apply []*Migration + if p.cfg.disableVersioning { + if byOne { + return nil, errors.New("up-by-one not supported when versioning is disabled") + } + apply = p.migrations + } else { + // optimize(mf): Listing all migrations from the database isn't great. + // + // The ideal implementation would be to query for the current max version and then apply + // migrations greater than that version. However, a nice property of the current + // implementation is that we can make stronger guarantees about unapplied migrations. + // + // In cases where users do not use out-of-order migrations, we want to surface an error if + // there are older unapplied migrations. See https://github.com/pressly/goose/issues/761 for + // more details. + // + // And in cases where users do use out-of-order migrations, we need to build a list of older + // migrations that need to be applied, so we need to query for all migrations anyways. + dbMigrations, err := p.store.ListMigrations(ctx, conn) + if err != nil { + return nil, err + } + if len(dbMigrations) == 0 { + return nil, errMissingZeroVersion + } + versions, err := gooseutil.UpVersions( + getVersionsFromMigrations(p.migrations), // fsys versions + getVersionsFromListMigrations(dbMigrations), // db versions + version, + p.cfg.allowMissing, + ) + if err != nil { + return nil, err + } + for _, v := range versions { + m, err := p.getMigration(v) + if err != nil { + return nil, err + } + apply = append(apply, m) + } + } + return p.runMigrations(ctx, conn, apply, sqlparser.DirectionUp, byOne) +} + +func (p *Provider) down( + ctx context.Context, + byOne bool, + version int64, +) (_ []*MigrationResult, retErr error) { + conn, cleanup, err := p.initialize(ctx, true) + if err != nil { + return nil, fmt.Errorf("failed to initialize: %w", err) + } + defer func() { + retErr = multierr.Append(retErr, cleanup()) + }() + + if len(p.migrations) == 0 { + return nil, nil + } + if p.cfg.disableVersioning { + var downMigrations []*Migration + if byOne { + last := p.migrations[len(p.migrations)-1] + downMigrations = []*Migration{last} + } else { + downMigrations = p.migrations + } + return p.runMigrations(ctx, conn, downMigrations, sqlparser.DirectionDown, byOne) + } + dbMigrations, err := p.store.ListMigrations(ctx, conn) + if err != nil { + return nil, err + } + if len(dbMigrations) == 0 { + return nil, errMissingZeroVersion + } + // We never migrate the zero version down. + if dbMigrations[0].Version == 0 { + p.logf(ctx, + "no migrations to run, current version: 0", + "no migrations to run", + slog.Int64("version", 0), + ) + return nil, nil + } + var apply []*Migration + for _, dbMigration := range dbMigrations { + if dbMigration.Version <= version { + break + } + m, err := p.getMigration(dbMigration.Version) + if err != nil { + return nil, err + } + apply = append(apply, m) + } + return p.runMigrations(ctx, conn, apply, sqlparser.DirectionDown, byOne) +} + +func (p *Provider) apply( + ctx context.Context, + version int64, + direction bool, +) (_ []*MigrationResult, retErr error) { + if version < 1 { + return nil, errInvalidVersion + } + m, err := p.getMigration(version) + if err != nil { + return nil, err + } + conn, cleanup, err := p.initialize(ctx, true) + if err != nil { + return nil, fmt.Errorf("failed to initialize: %w", err) + } + defer func() { + retErr = multierr.Append(retErr, cleanup()) + }() + + d := sqlparser.DirectionDown + if direction { + d = sqlparser.DirectionUp + } + + if p.cfg.disableVersioning { + // If versioning is disabled, we simply run the migration. + return p.runMigrations(ctx, conn, []*Migration{m}, d, true) + } + + result, err := p.store.GetMigration(ctx, conn, version) + if err != nil && !errors.Is(err, database.ErrVersionNotFound) { + return nil, err + } + // There are a few states here: + // 1. direction is up + // a. migration is applied, this is an error (ErrAlreadyApplied) + // b. migration is not applied, apply it + if direction && result != nil { + return nil, fmt.Errorf("version %d: %w", version, ErrAlreadyApplied) + } + // 2. direction is down + // a. migration is applied, rollback + // b. migration is not applied, this is an error (ErrNotApplied) + if !direction && result == nil { + return nil, fmt.Errorf("version %d: %w", version, ErrNotApplied) + } + return p.runMigrations(ctx, conn, []*Migration{m}, d, true) +} + +func (p *Provider) getVersions(ctx context.Context) (current, target int64, retErr error) { + conn, cleanup, err := p.initialize(ctx, false) + if err != nil { + return -1, -1, fmt.Errorf("failed to initialize: %w", err) + } + defer func() { + retErr = multierr.Append(retErr, cleanup()) + }() + + target = p.migrations[len(p.migrations)-1].Version + + // If versioning is disabled, we always have pending migrations and the target version is the + // last migration. + if p.cfg.disableVersioning { + return -1, target, nil + } + + current, err = p.store.GetLatestVersion(ctx, conn) + if err != nil { + if errors.Is(err, database.ErrVersionNotFound) { + return -1, target, errMissingZeroVersion + } + return -1, target, err + } + return current, target, nil +} + +func (p *Provider) hasPending(ctx context.Context) (_ bool, retErr error) { + conn, cleanup, err := p.initialize(ctx, false) + if err != nil { + return false, fmt.Errorf("failed to initialize: %w", err) + } + defer func() { + retErr = multierr.Append(retErr, cleanup()) + }() + + // If versioning is disabled, we always have pending migrations. + if p.cfg.disableVersioning { + return true, nil + } + + // List all migrations from the database. Careful, optimizations here can lead to subtle bugs. + // We have 2 important cases to consider: + // + // 1. Users have enabled out-of-order migrations, in which case we need to check if any + // migrations are missing and report that there are pending migrations. Do not surface an + // error because this is a valid state. + // + // 2. Users have disabled out-of-order migrations (default), in which case we need to check if all + // migrations have been applied. We cannot check for the highest applied version because we lose the + // ability to surface an error if an out-of-order migration was introduced. It would be silently + // ignored and the user would not know that they have unapplied migrations. + // + // Maybe we could consider adding a flag to the provider such as IgnoreMissing, which would + // allow silently ignoring missing migrations. This would be useful for users that have built + // checks that prevent missing migrations from being introduced. + + dbMigrations, err := p.store.ListMigrations(ctx, conn) + if err != nil { + return false, err + } + apply, err := gooseutil.UpVersions( + getVersionsFromMigrations(p.migrations), // fsys versions + getVersionsFromListMigrations(dbMigrations), // db versions + math.MaxInt64, + p.cfg.allowMissing, + ) + if err != nil { + return false, err + } + return len(apply) > 0, nil +} + +func getVersionsFromMigrations(in []*Migration) []int64 { + out := make([]int64, 0, len(in)) + for _, m := range in { + out = append(out, m.Version) + } + return out + +} + +func getVersionsFromListMigrations(in []*database.ListMigrationsResult) []int64 { + out := make([]int64, 0, len(in)) + for _, m := range in { + out = append(out, m.Version) + } + return out + +} + +func (p *Provider) status(ctx context.Context) (_ []*MigrationStatus, retErr error) { + conn, cleanup, err := p.initialize(ctx, true) + if err != nil { + return nil, fmt.Errorf("failed to initialize: %w", err) + } + defer func() { + retErr = multierr.Append(retErr, cleanup()) + }() + + status := make([]*MigrationStatus, 0, len(p.migrations)) + for _, m := range p.migrations { + migrationStatus := &MigrationStatus{ + Source: &Source{ + Type: m.Type, + Path: m.Source, + Version: m.Version, + }, + State: StatePending, + } + // If versioning is disabled, we can't check the database for applied migrations, so we + // assume all migrations are pending. + if !p.cfg.disableVersioning { + dbResult, err := p.store.GetMigration(ctx, conn, m.Version) + if err != nil && !errors.Is(err, database.ErrVersionNotFound) { + return nil, err + } + if dbResult != nil { + migrationStatus.State = StateApplied + migrationStatus.AppliedAt = dbResult.Timestamp + } + } + status = append(status, migrationStatus) + } + + return status, nil +} + +// getDBMaxVersion returns the highest version recorded in the database, regardless of the order in +// which migrations were applied. conn may be nil, in which case a connection is initialized. +func (p *Provider) getDBMaxVersion(ctx context.Context, conn *sql.Conn) (_ int64, retErr error) { + if conn == nil { + var cleanup func() error + var err error + conn, cleanup, err = p.initialize(ctx, true) + if err != nil { + return 0, err + } + defer func() { + retErr = multierr.Append(retErr, cleanup()) + }() + } + + latest, err := p.store.GetLatestVersion(ctx, conn) + if err != nil { + if errors.Is(err, database.ErrVersionNotFound) { + return 0, errMissingZeroVersion + } + return -1, err + } + return latest, nil +} diff --git a/vendor/github.com/pressly/goose/v3/provider_collect.go b/vendor/github.com/pressly/goose/v3/provider_collect.go new file mode 100644 index 0000000000..12ecb7fa3c --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/provider_collect.go @@ -0,0 +1,196 @@ +package goose + +import ( + "errors" + "fmt" + "io/fs" + "path/filepath" + "sort" + "strings" +) + +// fileSources represents a collection of migration files on the filesystem. +type fileSources struct { + sqlSources []Source + goSources []Source +} + +// collectFilesystemSources scans the file system for migration files that have a numeric prefix +// (greater than one) followed by an underscore and a file extension of either .go or .sql. fsys may +// be nil, in which case an empty fileSources is returned. +// +// If strict is true, then any error parsing the numeric component of the filename will result in an +// error. The file is skipped otherwise. +// +// This function DOES NOT parse SQL migrations or merge registered Go migrations. It only collects +// migration sources from the filesystem. +func collectFilesystemSources( + fsys fs.FS, + strict bool, + excludePaths map[string]bool, + excludeVersions map[int64]bool, +) (*fileSources, error) { + if fsys == nil { + return new(fileSources), nil + } + sources := new(fileSources) + versionToBaseLookup := make(map[int64]string) // map[version]filepath.Base(fullpath) + for _, pattern := range []string{ + "*.sql", + "*.go", + } { + files, err := fs.Glob(fsys, pattern) + if err != nil { + return nil, fmt.Errorf("failed to glob pattern %q: %w", pattern, err) + } + for _, fullpath := range files { + base := filepath.Base(fullpath) + if strings.HasSuffix(base, "_test.go") { + continue + } + if excludePaths[base] { + // TODO(mf): log this? + continue + } + // If the filename has a valid looking version of the form: NUMBER_.{sql,go}, then use + // that as the version. Otherwise, ignore it. This allows users to have arbitrary + // filenames, but still have versioned migrations within the same directory. For + // example, a user could have a helpers.go file which contains unexported helper + // functions for migrations. + version, err := NumericComponent(base) + if err != nil { + if strict { + return nil, fmt.Errorf("failed to parse numeric component from %q: %w", base, err) + } + continue + } + if excludeVersions[version] { + // TODO: log this? + continue + } + // Ensure there are no duplicate versions. + if existing, ok := versionToBaseLookup[version]; ok { + return nil, fmt.Errorf("found duplicate migration version %d:\n\texisting:%v\n\tcurrent:%v", + version, + existing, + base, + ) + } + switch filepath.Ext(base) { + case ".sql": + sources.sqlSources = append(sources.sqlSources, Source{ + Type: TypeSQL, + Path: fullpath, + Version: version, + }) + case ".go": + sources.goSources = append(sources.goSources, Source{ + Type: TypeGo, + Path: fullpath, + Version: version, + }) + default: + // Should never happen since we already filtered out all other file types. + return nil, fmt.Errorf("invalid file extension: %q", base) + } + // Add the version to the lookup map. + versionToBaseLookup[version] = base + } + } + return sources, nil +} + +func newSQLMigration(source Source) *Migration { + return &Migration{ + Type: source.Type, + Version: source.Version, + Source: source.Path, + construct: true, + Next: -1, Previous: -1, + sql: sqlMigration{ + Parsed: false, // SQL migrations are parsed lazily. + }, + } +} + +func merge(sources *fileSources, registered map[int64]*Migration) ([]*Migration, error) { + var migrations []*Migration + migrationLookup := make(map[int64]*Migration) + // Add all SQL migrations to the list of migrations. + for _, source := range sources.sqlSources { + m := newSQLMigration(source) + migrations = append(migrations, m) + migrationLookup[source.Version] = m + } + // If there are no Go files in the filesystem and no registered Go migrations, return early. + if len(sources.goSources) == 0 && len(registered) == 0 { + return migrations, nil + } + // Return an error if the given sources contain a versioned Go migration that has not been + // registered. This is a sanity check to ensure users didn't accidentally create a valid looking + // Go migration file on disk and forget to register it. + // + // This is almost always a user error. + var unregistered []string + for _, s := range sources.goSources { + m, ok := registered[s.Version] + if !ok { + unregistered = append(unregistered, s.Path) + } else { + // Populate the source path for registered Go migrations that have a corresponding file + // on disk. + m.Source = s.Path + } + } + if len(unregistered) > 0 { + return nil, unregisteredError(unregistered) + } + // Add all registered Go migrations to the list of migrations, checking for duplicate versions. + // + // Important, users can register Go migrations manually via goose.Add_ functions. These + // migrations may not have a corresponding file on disk. Which is fine! We include them + // wholesale as part of migrations. This allows users to build a custom binary that only embeds + // the SQL migration files. + for version, r := range registered { + // Ensure there are no duplicate versions. + if existing, ok := migrationLookup[version]; ok { + fullpath := r.Source + if fullpath == "" { + fullpath = "no source path" + } + return nil, fmt.Errorf("found duplicate migration version %d:\n\texisting:%v\n\tcurrent:%v", + version, + existing.Source, + fullpath, + ) + } + migrations = append(migrations, r) + migrationLookup[version] = r + } + // Sort migrations by version in ascending order. + sort.Slice(migrations, func(i, j int) bool { + return migrations[i].Version < migrations[j].Version + }) + return migrations, nil +} + +func unregisteredError(unregistered []string) error { + const ( + hintURL = "https://github.com/pressly/goose/tree/master/examples/go-migrations" + ) + f := "file" + if len(unregistered) > 1 { + f += "s" + } + var b strings.Builder + + fmt.Fprintf(&b, "error: detected %d unregistered Go %s:\n", len(unregistered), f) + for _, name := range unregistered { + b.WriteString("\t" + name + "\n") + } + hint := fmt.Sprintf("hint: go functions must be registered and built into a custom binary see:\n%s", hintURL) + b.WriteString(hint) + b.WriteString("\n") + + return errors.New(b.String()) +} diff --git a/vendor/github.com/pressly/goose/v3/provider_errors.go b/vendor/github.com/pressly/goose/v3/provider_errors.go new file mode 100644 index 0000000000..718bcbec84 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/provider_errors.go @@ -0,0 +1,48 @@ +package goose + +import ( + "errors" + "fmt" +) + +var ( + // ErrVersionNotFound is returned when a specific migration version is not located. This can + // occur if a .sql file or a Go migration function for the specified version is missing. + ErrVersionNotFound = errors.New("version not found") + + // ErrNoMigrations is returned by [NewProvider] when no migrations are found. + ErrNoMigrations = errors.New("no migrations found") + + // ErrAlreadyApplied indicates that the migration cannot be applied because it has already been + // executed. This error is returned by [Provider.Apply]. + ErrAlreadyApplied = errors.New("migration already applied") + + // ErrNotApplied indicates that the rollback cannot be performed because the migration has not + // yet been applied. This error is returned by [Provider.Apply]. + ErrNotApplied = errors.New("migration not applied") + + // errInvalidVersion is returned when a migration version is invalid. + errInvalidVersion = errors.New("version must be greater than 0") +) + +// PartialError is returned when a migration fails, but some migrations already got applied. +type PartialError struct { + // Applied are migrations that were applied successfully before the error occurred. May be + // empty. + Applied []*MigrationResult + // Failed contains the result of the migration that failed. Cannot be nil. + Failed *MigrationResult + // Err is the error that occurred while running the migration and caused the failure. + Err error +} + +func (e *PartialError) Error() string { + return fmt.Sprintf( + "partial migration error (type:%s,version:%d): %v", + e.Failed.Source.Type, e.Failed.Source.Version, e.Err, + ) +} + +func (e *PartialError) Unwrap() error { + return e.Err +} diff --git a/vendor/github.com/pressly/goose/v3/provider_options.go b/vendor/github.com/pressly/goose/v3/provider_options.go new file mode 100644 index 0000000000..e58aa654c8 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/provider_options.go @@ -0,0 +1,280 @@ +package goose + +import ( + "errors" + "fmt" + "log/slog" + + "github.com/pressly/goose/v3/database" + "github.com/pressly/goose/v3/lock" +) + +const ( + // DefaultTablename is the default name of the database table used to track history of applied + // migrations. + DefaultTablename = "goose_db_version" +) + +// ProviderOption is a configuration option for a goose goose. +type ProviderOption interface { + apply(*config) error +} + +// WithStore configures the provider with a custom [database.Store], allowing users to bring their +// own implementation of the store interface. When this option is used, the dialect parameter of +// [NewProvider] must be set to [DialectCustom]. +// +// This option cannot be used together with [WithTableName], since the table name is set on the +// store. +// +// By default, the provider uses the [database.NewStore] function to create a store backed by one of +// the officially supported dialects. +func WithStore(store database.Store) ProviderOption { + return configFunc(func(c *config) error { + if c.store != nil { + return fmt.Errorf("store already set: %T", c.store) + } + if store == nil { + return errors.New("store must not be nil") + } + if store.Tablename() == "" { + return errors.New("store implementation must set the table name") + } + c.store = store + return nil + }) +} + +// WithTableName sets the name of the database table used to track history of applied migrations. +// This option cannot be used together with [WithStore], since the table name is set on the store. +// +// Default is "goose_db_version". +func WithTableName(name string) ProviderOption { + return configFunc(func(c *config) error { + if name == "" { + return errors.New("table name must not be empty") + } + c.tableName = name + return nil + }) +} + +// WithVerbose enables verbose logging. +func WithVerbose(b bool) ProviderOption { + return configFunc(func(c *config) error { + c.verbose = b + return nil + }) +} + +// WithSessionLocker enables locking using the provided SessionLocker. +// +// If WithSessionLocker is not called, locking is disabled. Must not be used together with +// [WithLocker]. +func WithSessionLocker(locker lock.SessionLocker) ProviderOption { + return configFunc(func(c *config) error { + if c.lockEnabled { + return errors.New("lock already enabled") + } + if c.sessionLocker != nil { + return errors.New("session locker already set") + } + if c.locker != nil { + return errors.New("locker already set; cannot use both SessionLocker and Locker") + } + if locker == nil { + return errors.New("session locker must not be nil") + } + c.lockEnabled = true + c.sessionLocker = locker + return nil + }) +} + +// WithLocker enables locking using the provided Locker. +// +// If WithLocker is not called, locking is disabled. Must not be used together with +// [WithSessionLocker]. +func WithLocker(locker lock.Locker) ProviderOption { + return configFunc(func(c *config) error { + if c.lockEnabled { + return errors.New("lock already enabled") + } + if c.locker != nil { + return errors.New("locker already set") + } + if c.sessionLocker != nil { + return errors.New("session locker already set; cannot use both SessionLocker and Locker") + } + if locker == nil { + return errors.New("locker must not be nil") + } + c.lockEnabled = true + c.locker = locker + return nil + }) +} + +// WithExcludeNames excludes the given file name from the list of migrations. If called multiple +// times, the list of excludes is merged. +func WithExcludeNames(excludes []string) ProviderOption { + return configFunc(func(c *config) error { + for _, name := range excludes { + if _, ok := c.excludePaths[name]; ok { + return fmt.Errorf("duplicate exclude file name: %s", name) + } + c.excludePaths[name] = true + } + return nil + }) +} + +// WithExcludeVersions excludes the given versions from the list of migrations. If called multiple +// times, the list of excludes is merged. +func WithExcludeVersions(versions []int64) ProviderOption { + return configFunc(func(c *config) error { + for _, version := range versions { + if version < 1 { + return errInvalidVersion + } + if _, ok := c.excludeVersions[version]; ok { + return fmt.Errorf("duplicate excludes version: %d", version) + } + c.excludeVersions[version] = true + } + return nil + }) +} + +// WithGoMigrations registers Go migrations with the provider. If a Go migration with the same +// version has already been registered, an error will be returned. +// +// Go migrations must be constructed using the [NewGoMigration] function. +func WithGoMigrations(migrations ...*Migration) ProviderOption { + return configFunc(func(c *config) error { + for _, m := range migrations { + if _, ok := c.registered[m.Version]; ok { + return fmt.Errorf("go migration with version %d already registered", m.Version) + } + if err := checkGoMigration(m); err != nil { + return fmt.Errorf("invalid go migration: %w", err) + } + c.registered[m.Version] = m + } + return nil + }) +} + +// WithDisableGlobalRegistry prevents the provider from registering Go migrations from the global +// registry. By default, goose will register all Go migrations including those registered globally. +func WithDisableGlobalRegistry(b bool) ProviderOption { + return configFunc(func(c *config) error { + c.disableGlobalRegistry = b + return nil + }) +} + +// WithAllowOutofOrder allows the provider to apply missing (out-of-order) migrations. By default, +// goose will raise an error if it encounters a missing migration. +// +// For example: migrations 1,3 are applied and then version 2,6 are introduced. If this option is +// true, then goose will apply 2 (missing) and 6 (new) instead of raising an error. The final order +// of applied migrations will be: 1,3,2,6. Out-of-order migrations are always applied first, +// followed by new migrations. +func WithAllowOutofOrder(b bool) ProviderOption { + return configFunc(func(c *config) error { + c.allowMissing = b + return nil + }) +} + +// WithDisableVersioning disables versioning. Disabling versioning allows applying migrations +// without tracking the versions in the database schema table. Useful for tests, seeding a database +// or running ad-hoc queries. By default, goose will track all versions in the database schema +// table. +func WithDisableVersioning(b bool) ProviderOption { + return configFunc(func(c *config) error { + c.disableVersioning = b + return nil + }) +} + +// WithLogger will set a custom Logger, which will override the default logger. Cannot be used +// together with [WithSlog]. +func WithLogger(l Logger) ProviderOption { + return configFunc(func(c *config) error { + if l == nil { + return errors.New("logger must not be nil") + } + if c.slogger != nil { + return errors.New("cannot use both WithLogger and WithSlog") + } + c.logger = l + return nil + }) +} + +// WithSlog will set a custom [*slog.Logger] for structured logging. This enables rich structured +// logging with attributes like source, direction, duration, etc. Cannot be used together with +// [WithLogger]. +// +// Example: +// +// logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) +// p, err := goose.NewProvider("postgres", db, fs, goose.WithSlog(logger)) +func WithSlog(logger *slog.Logger) ProviderOption { + return configFunc(func(c *config) error { + if logger == nil { + return errors.New("slog logger must not be nil") + } + if c.logger != nil { + return errors.New("cannot use both WithLogger and WithSlog") + } + c.slogger = logger + return nil + }) +} + +// WithIsolateDDL executes DDL operations separately from DML operations. This is useful for +// databases like AWS Aurora DSQL that don't support mixing DDL and DML within the same transaction. +func WithIsolateDDL(b bool) ProviderOption { + return configFunc(func(c *config) error { + c.isolateDDL = b + return nil + }) +} + +type config struct { + tableName string + store database.Store + + verbose bool + excludePaths map[string]bool + excludeVersions map[int64]bool + + // Go migrations registered by the user. These will be merged/resolved against the globally + // registered migrations. + registered map[int64]*Migration + + // Locking options + lockEnabled bool + sessionLocker lock.SessionLocker + locker lock.Locker + + // Feature + disableVersioning bool + allowMissing bool + disableGlobalRegistry bool + isolateDDL bool + + // Only a single logger can be set, they are mutually exclusive. If neither is set, a default + // [Logger] will be set to maintain backward compatibility in /v3. + logger Logger + slogger *slog.Logger +} + +type configFunc func(*config) error + +func (f configFunc) apply(cfg *config) error { + return f(cfg) +} diff --git a/vendor/github.com/pressly/goose/v3/provider_run.go b/vendor/github.com/pressly/goose/v3/provider_run.go new file mode 100644 index 0000000000..57ba6e94c5 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/provider_run.go @@ -0,0 +1,535 @@ +package goose + +import ( + "cmp" + "context" + "database/sql" + "errors" + "fmt" + "io/fs" + "log/slog" + "path/filepath" + "runtime/debug" + "slices" + "time" + + "github.com/pressly/goose/v3/database" + "github.com/pressly/goose/v3/internal/sqlparser" + "github.com/sethvargo/go-retry" + "go.uber.org/multierr" +) + +var ( + errMissingZeroVersion = errors.New("missing zero version migration") +) + +func (p *Provider) prepareMigration(fsys fs.FS, m *Migration, direction bool) error { + switch m.Type { + case TypeGo: + if m.goUp.Mode == 0 { + return errors.New("go up migration mode is not set") + } + if m.goDown.Mode == 0 { + return errors.New("go down migration mode is not set") + } + var useTx bool + if direction { + useTx = m.goUp.Mode == TransactionEnabled + } else { + useTx = m.goDown.Mode == TransactionEnabled + } + // bug(mf): this is a potential deadlock scenario. We're running Go migrations with *sql.DB, + // but are locking the database with *sql.Conn. If the caller sets max open connections to + // 1, then this will deadlock because the Go migration will try to acquire a connection from + // the pool, but the pool is exhausted because the lock is held. + // + // A potential solution is to expose a third Go register function *sql.Conn. Or continue to + // use *sql.DB and document that the user SHOULD NOT SET max open connections to 1. This is + // a bit of an edge case. For now, we guard against this scenario by checking the max open + // connections and returning an error. + if p.cfg.lockEnabled && p.cfg.sessionLocker != nil && p.db.Stats().MaxOpenConnections == 1 { + if !useTx { + return errors.New("potential deadlock detected: cannot run Go migration without a transaction when max open connections set to 1") + } + } + return nil + case TypeSQL: + if m.sql.Parsed { + return nil + } + parsed, err := sqlparser.ParseAllFromFS(fsys, m.Source, false) + if err != nil { + return err + } + m.sql.Parsed = true + m.sql.UseTx = parsed.UseTx + m.sql.Up, m.sql.Down = parsed.Up, parsed.Down + return nil + } + return fmt.Errorf("invalid migration type: %+v", m) +} + +func (p *Provider) logf(ctx context.Context, legacyMsg string, slogMsg string, attrs ...slog.Attr) { + if !p.cfg.verbose { + return + } + if p.cfg.slogger != nil { + // Sort attributes by key for consistent ordering + slices.SortFunc(attrs, func(a, b slog.Attr) int { + return cmp.Compare(a.Key, b.Key) + }) + // Use slog with structured attributes + args := make([]any, 0, len(attrs)+1) + // Add the logger=goose identifier + args = append(args, slog.String("logger", "goose")) + for _, attr := range attrs { + args = append(args, attr) + } + p.cfg.slogger.InfoContext(ctx, slogMsg, args...) + } else if p.cfg.logger != nil { + p.cfg.logger.Printf("goose: %s", legacyMsg) + } +} + +// runMigrations runs migrations sequentially in the given direction. If the migrations list is +// empty, return nil without error. +func (p *Provider) runMigrations( + ctx context.Context, + conn *sql.Conn, + migrations []*Migration, + direction sqlparser.Direction, + byOne bool, +) ([]*MigrationResult, error) { + if len(migrations) == 0 { + if !p.cfg.disableVersioning { + // No need to print this message if versioning is disabled because there are no + // migrations being tracked in the goose version table. + maxVersion, err := p.getDBMaxVersion(ctx, conn) + if err != nil { + return nil, err + } + p.logf(ctx, + fmt.Sprintf("no migrations to run, current version: %d", maxVersion), + "no migrations to run", + slog.Int64("current_version", maxVersion), + ) + } + return nil, nil + } + apply := migrations + if byOne { + apply = migrations[:1] + } + + // SQL migrations are lazily parsed in both directions. This is done before attempting to run + // any migrations to catch errors early and prevent leaving the database in an incomplete state. + + for _, m := range apply { + if err := p.prepareMigration(p.fsys, m, direction.ToBool()); err != nil { + return nil, fmt.Errorf("failed to prepare migration %s: %w", m.ref(), err) + } + } + + // feat(mf): If we decide to add support for advisory locks at the transaction level, this may + // be a good place to acquire the lock. However, we need to be sure that ALL migrations are safe + // to run in a transaction. + + // feat(mf): this is where we can (optionally) group multiple migrations to be run in a single + // transaction. The default is to apply each migration sequentially on its own. See the + // following issues for more details: + // - https://github.com/pressly/goose/issues/485 + // - https://github.com/pressly/goose/issues/222 + // + // Be careful, we can't use a single transaction for all migrations because some may be marked + // as not using a transaction. + + var results []*MigrationResult + for _, m := range apply { + result := &MigrationResult{ + Source: &Source{ + Type: m.Type, + Path: m.Source, + Version: m.Version, + }, + Direction: direction.String(), + Empty: isEmpty(m, direction.ToBool()), + } + start := time.Now() + if err := p.runIndividually(ctx, conn, m, direction.ToBool()); err != nil { + // TODO(mf): we should also return the pending migrations here, the remaining items in + // the apply slice. + result.Error = err + result.Duration = time.Since(start) + return nil, &PartialError{ + Applied: results, + Failed: result, + Err: err, + } + } + result.Duration = time.Since(start) + results = append(results, result) + // Log the result of the migration. + var state string + if result.Empty { + state = "empty" + } else { + state = "applied" + } + p.logf(ctx, + result.String(), + "migration completed", + slog.String("source", filepath.Base(result.Source.Path)), + slog.String("direction", result.Direction), + slog.Float64("duration_seconds", result.Duration.Seconds()), + slog.String("state", state), + slog.Int64("version", result.Source.Version), + slog.String("type", string(result.Source.Type)), + ) + } + if !p.cfg.disableVersioning && !byOne { + maxVersion, err := p.getDBMaxVersion(ctx, conn) + if err != nil { + return nil, err + } + p.logf(ctx, + fmt.Sprintf("successfully migrated database, current version: %d", maxVersion), + "successfully migrated database", + slog.Int64("current_version", maxVersion), + ) + } + return results, nil +} + +func (p *Provider) runIndividually( + ctx context.Context, + conn *sql.Conn, + m *Migration, + direction bool, +) error { + useTx, err := useTx(m, direction) + if err != nil { + return err + } + if useTx && !p.cfg.isolateDDL { + return beginTx(ctx, conn, func(tx *sql.Tx) error { + if err := p.runMigration(ctx, tx, m, direction); err != nil { + return err + } + return p.maybeInsertOrDelete(ctx, tx, m.Version, direction) + }) + } + switch m.Type { + case TypeGo: + // Note, we are using *sql.DB instead of *sql.Conn because it's the Go migration contract. + // This may be a deadlock scenario if max open connections is set to 1 AND a lock is + // acquired on the database. In this case, the migration will block forever unable to + // acquire a connection from the pool. + // + // For now, we guard against this scenario by checking the max open connections and + // returning an error in the prepareMigration function. + if err := p.runMigration(ctx, p.db, m, direction); err != nil { + return err + } + return p.maybeInsertOrDelete(ctx, p.db, m.Version, direction) + case TypeSQL: + if err := p.runMigration(ctx, conn, m, direction); err != nil { + return err + } + return p.maybeInsertOrDelete(ctx, conn, m.Version, direction) + } + return fmt.Errorf("failed to run individual migration: neither sql or go: %v", m) +} + +func (p *Provider) maybeInsertOrDelete( + ctx context.Context, + db database.DBTxConn, + version int64, + direction bool, +) error { + // If versioning is disabled, we don't need to insert or delete the migration version. + if p.cfg.disableVersioning { + return nil + } + if direction { + return p.store.Insert(ctx, db, database.InsertRequest{Version: version}) + } + return p.store.Delete(ctx, db, version) +} + +// beginTx begins a transaction and runs the given function. If the function returns an error, the +// transaction is rolled back. Otherwise, the transaction is committed. +func beginTx(ctx context.Context, conn *sql.Conn, fn func(tx *sql.Tx) error) (retErr error) { + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { + if retErr != nil { + retErr = multierr.Append(retErr, tx.Rollback()) + } + }() + if err := fn(tx); err != nil { + return err + } + return tx.Commit() +} + +func (p *Provider) initialize(ctx context.Context, useLocker bool) (*sql.Conn, func() error, error) { + p.mu.Lock() + conn, err := p.db.Conn(ctx) + if err != nil { + p.mu.Unlock() + return nil, nil, err + } + // cleanup is a function that cleans up the connection, and optionally, the lock. + cleanup := func() error { + p.mu.Unlock() + return conn.Close() + } + + // Handle locking if enabled and requested + if useLocker && p.cfg.lockEnabled { + // Session locker (connection-based locking) + if p.cfg.sessionLocker != nil { + l := p.cfg.sessionLocker + if err := l.SessionLock(ctx, conn); err != nil { + return nil, nil, multierr.Append(err, cleanup()) + } + // A lock was acquired, so we need to unlock the session when we're done. This is done + // by returning a cleanup function that unlocks the session and closes the connection. + cleanup = func() error { + p.mu.Unlock() + // Use a detached context to unlock the session. This is because the context passed + // to SessionLock may have been canceled, and we don't want to cancel the unlock. + return multierr.Append( + l.SessionUnlock(context.WithoutCancel(ctx), conn), + conn.Close(), + ) + } + } + // General locker (db-based locking) + if p.cfg.locker != nil { + l := p.cfg.locker + if err := l.Lock(ctx, p.db); err != nil { + return nil, nil, multierr.Append(err, cleanup()) + } + // A lock was acquired, so we need to unlock when we're done. + cleanup = func() error { + p.mu.Unlock() + // Use a detached context to unlock. This is because the context passed to Lock may + // have been canceled, and we don't want to cancel the unlock. + return multierr.Append( + l.Unlock(context.WithoutCancel(ctx), p.db), + conn.Close(), + ) + } + } + } + // If versioning is enabled, ensure the version table exists. For ad-hoc migrations, we don't + // need the version table because no versions are being tracked. + if !p.cfg.disableVersioning { + if err := p.ensureVersionTable(ctx, conn); err != nil { + return nil, nil, multierr.Append(err, cleanup()) + } + } + return conn, cleanup, nil +} + +func (p *Provider) ensureVersionTable( + ctx context.Context, + conn *sql.Conn, +) (retErr error) { + // There are 2 optimizations here: + // - 1. We create the version table once per Provider instance. + // - 2. We retry the operation a few times in case the table is being created concurrently. + // + // Regarding item 2, certain goose operations, like HasPending, don't respect a SessionLocker. + // So, when goose is run for the first time in a multi-instance environment, it's possible that + // multiple instances will try to create the version table at the same time. This is why we + // retry this operation a few times. Best case, the table is created by one instance and all the + // other instances see that change immediately. Worst case, all instances try to create the + // table at the same time, but only one will succeed and the others will retry. + p.versionTableOnce.Do(func() { + retErr = p.tryEnsureVersionTable(ctx, conn) + }) + return retErr +} + +func (p *Provider) tryEnsureVersionTable(ctx context.Context, conn *sql.Conn) error { + b := retry.NewConstant(1 * time.Second) + b = retry.WithMaxRetries(3, b) + return retry.Do(ctx, b, func(ctx context.Context) error { + exists, err := p.store.TableExists(ctx, conn) + if err == nil && exists { + return nil + } else if err != nil && errors.Is(err, errors.ErrUnsupported) { + // Fallback strategy for checking table existence: + // + // When direct table existence checks aren't supported, we attempt to query the initial + // migration (version 0). This approach has two implications: + // + // 1. If the table exists, the query succeeds and confirms existence + // 2. If the table doesn't exist, the query fails and generates an error log + // + // Note: This check must occur outside any transaction, as a failed query would + // otherwise cause the entire transaction to roll back. The error logs generated by this + // approach are expected and can be safely ignored. + if res, err := p.store.GetMigration(ctx, conn, 0); err == nil && res != nil { + return nil + } + // Fallthrough to create the table. + } else if err != nil { + return fmt.Errorf("check if version table exists: %w", err) + } + + if p.cfg.isolateDDL { + // If isolation is enabled, we create the version table separately to ensure subsequent + // DML operations are not mixed with DDL. + if err := p.store.CreateVersionTable(ctx, conn); err != nil { + return retry.RetryableError(fmt.Errorf("create version table: %w", err)) + } + if err := p.store.Insert(ctx, conn, database.InsertRequest{Version: 0}); err != nil { + return retry.RetryableError(fmt.Errorf("insert zero version: %w", err)) + } + } else { + // If DDL isolation is not enabled, we can create the version table and insert the zero + // version in a single transaction. + if err := beginTx(ctx, conn, func(tx *sql.Tx) error { + if err := p.store.CreateVersionTable(ctx, tx); err != nil { + return err + } + return p.store.Insert(ctx, tx, database.InsertRequest{Version: 0}) + }); err != nil { + // Mark the error as retryable so we can try again. It's possible that another + // instance is creating the table at the same time and the checks above will succeed + // on the next iteration. + return retry.RetryableError(fmt.Errorf("create version table: %w", err)) + } + } + + return nil + }) +} + +// getMigration returns the migration for the given version. If no migration is found, then +// ErrVersionNotFound is returned. +func (p *Provider) getMigration(version int64) (*Migration, error) { + for _, m := range p.migrations { + if m.Version == version { + return m, nil + } + } + return nil, ErrVersionNotFound +} + +// useTx is a helper function that returns true if the migration should be run in a transaction. It +// must only be called after the migration has been parsed and initialized. +func useTx(m *Migration, direction bool) (bool, error) { + switch m.Type { + case TypeGo: + if m.goUp.Mode == 0 || m.goDown.Mode == 0 { + return false, fmt.Errorf("go migrations must have a mode set") + } + if direction { + return m.goUp.Mode == TransactionEnabled, nil + } + return m.goDown.Mode == TransactionEnabled, nil + case TypeSQL: + if !m.sql.Parsed { + return false, fmt.Errorf("sql migrations must be parsed") + } + return m.sql.UseTx, nil + } + return false, fmt.Errorf("use tx: invalid migration type: %q", m.Type) +} + +// isEmpty is a helper function that returns true if the migration has no functions or no statements +// to execute. It must only be called after the migration has been parsed and initialized. +func isEmpty(m *Migration, direction bool) bool { + switch m.Type { + case TypeGo: + if direction { + return m.goUp.RunTx == nil && m.goUp.RunDB == nil + } + return m.goDown.RunTx == nil && m.goDown.RunDB == nil + case TypeSQL: + if direction { + return len(m.sql.Up) == 0 + } + return len(m.sql.Down) == 0 + } + return true +} + +// runMigration is a helper function that runs the migration in the given direction. It must only be +// called after the migration has been parsed and initialized. +func (p *Provider) runMigration(ctx context.Context, db database.DBTxConn, m *Migration, direction bool) error { + switch m.Type { + case TypeGo: + return p.runGo(ctx, db, m, direction) + case TypeSQL: + return p.runSQL(ctx, db, m, direction) + } + return fmt.Errorf("invalid migration type: %q", m.Type) +} + +// runGo is a helper function that runs the given Go functions in the given direction. It must only +// be called after the migration has been initialized. +func (p *Provider) runGo(ctx context.Context, db database.DBTxConn, m *Migration, direction bool) (retErr error) { + defer func() { + if r := recover(); r != nil { + retErr = fmt.Errorf("panic: %v\n%s", r, debug.Stack()) + } + }() + + switch db := db.(type) { + case *sql.Conn: + return fmt.Errorf("go migrations are not supported with *sql.Conn") + case *sql.DB: + if direction && m.goUp.RunDB != nil { + return m.goUp.RunDB(ctx, db) + } + if !direction && m.goDown.RunDB != nil { + return m.goDown.RunDB(ctx, db) + } + return nil + case *sql.Tx: + if direction && m.goUp.RunTx != nil { + return m.goUp.RunTx(ctx, db) + } + if !direction && m.goDown.RunTx != nil { + return m.goDown.RunTx(ctx, db) + } + return nil + } + return fmt.Errorf("invalid database connection type: %T", db) +} + +// runSQL is a helper function that runs the given SQL statements in the given direction. It must +// only be called after the migration has been parsed. +func (p *Provider) runSQL(ctx context.Context, db database.DBTxConn, m *Migration, direction bool) error { + if !m.sql.Parsed { + return fmt.Errorf("sql migrations must be parsed") + } + var statements []string + if direction { + statements = m.sql.Up + } else { + statements = m.sql.Down + } + for _, stmt := range statements { + p.logf(ctx, + fmt.Sprintf("Executing statement: %s", stmt), + "executing statement", + slog.String("statement", stmt), + slog.String("source", filepath.Base(m.Source)), + slog.Int64("version", m.Version), + slog.String("type", string(m.Type)), + slog.String("direction", string(sqlparser.FromBool(direction))), + ) + if _, err := db.ExecContext(ctx, stmt); err != nil { + return err + } + } + return nil +} diff --git a/vendor/github.com/pressly/goose/v3/provider_types.go b/vendor/github.com/pressly/goose/v3/provider_types.go new file mode 100644 index 0000000000..3b9ab97f91 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/provider_types.go @@ -0,0 +1,91 @@ +package goose + +import ( + "fmt" + "path/filepath" + "time" +) + +// MigrationType is the type of migration. +type MigrationType string + +const ( + TypeGo MigrationType = "go" + TypeSQL MigrationType = "sql" +) + +// Source represents a single migration source. +// +// The Path field may be empty if the migration was registered manually. This is typically the case +// for Go migrations registered using the [WithGoMigration] option. +type Source struct { + Type MigrationType + Path string + Version int64 +} + +// MigrationResult is the result of a single migration operation. +type MigrationResult struct { + Source *Source + Duration time.Duration + Direction string + // Empty indicates no action was taken during the migration, but it was still versioned. For + // SQL, it means no statements; for Go, it's a nil function. + Empty bool + // Error is only set if the migration failed. + Error error +} + +// String returns a string representation of the migration result. +// +// Example down: +// +// EMPTY down 00006_posts_view-copy.sql (607.83µs) +// OK down 00005_posts_view.sql (646.25µs) +// +// Example up: +// +// OK up 00005_posts_view.sql (727.5µs) +// EMPTY up 00006_posts_view-copy.sql (378.33µs) +func (m *MigrationResult) String() string { + var format string + if m.Direction == "up" { + format = "%-5s %-2s %s (%s)" + } else { + format = "%-5s %-4s %s (%s)" + } + var state string + if m.Empty { + state = "EMPTY" + } else { + state = "OK" + } + return fmt.Sprintf(format, + state, + m.Direction, + filepath.Base(m.Source.Path), + truncateDuration(m.Duration), + ) +} + +// State represents the state of a migration. +type State string + +const ( + // StatePending is a migration that exists on the filesystem, but not in the database. + StatePending State = "pending" + // StateApplied is a migration that has been applied to the database and exists on the + // filesystem. + StateApplied State = "applied" + + // TODO(mf): we could also add a third state for untracked migrations. This would be useful for + // migrations that were manually applied to the database, but not versioned. Or the Source was + // deleted, but the migration still exists in the database. StateUntracked State = "untracked" +) + +// MigrationStatus represents the status of a single migration. +type MigrationStatus struct { + Source *Source + State State + AppliedAt time.Time +} diff --git a/vendor/github.com/pressly/goose/v3/redo.go b/vendor/github.com/pressly/goose/v3/redo.go new file mode 100644 index 0000000000..ed3ff677fd --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/redo.go @@ -0,0 +1,51 @@ +package goose + +import ( + "context" + "database/sql" +) + +// Redo rolls back the most recently applied migration, then runs it again. +func Redo(db *sql.DB, dir string, opts ...OptionsFunc) error { + ctx := context.Background() + return RedoContext(ctx, db, dir, opts...) +} + +// RedoContext rolls back the most recently applied migration, then runs it again. +func RedoContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { + option := &options{} + for _, f := range opts { + f(option) + } + migrations, err := CollectMigrations(dir, minVersion, maxVersion) + if err != nil { + return err + } + var ( + currentVersion int64 + ) + if option.noVersioning { + if len(migrations) == 0 { + return nil + } + currentVersion = migrations[len(migrations)-1].Version + } else { + if currentVersion, err = GetDBVersionContext(ctx, db); err != nil { + return err + } + } + + current, err := migrations.Current(currentVersion) + if err != nil { + return err + } + current.noVersioning = option.noVersioning + + if err := current.DownContext(ctx, db); err != nil { + return err + } + if err := current.UpContext(ctx, db); err != nil { + return err + } + return nil +} diff --git a/vendor/github.com/pressly/goose/v3/register.go b/vendor/github.com/pressly/goose/v3/register.go new file mode 100644 index 0000000000..524228c570 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/register.go @@ -0,0 +1,133 @@ +package goose + +import ( + "context" + "database/sql" + "fmt" + "runtime" +) + +// GoMigrationContext is a Go migration func that is run within a transaction and receives a +// context. +type GoMigrationContext func(ctx context.Context, tx *sql.Tx) error + +// AddMigrationContext adds Go migrations. +func AddMigrationContext(up, down GoMigrationContext) { + _, filename, _, _ := runtime.Caller(1) + AddNamedMigrationContext(filename, up, down) +} + +// AddNamedMigrationContext adds named Go migrations. +func AddNamedMigrationContext(filename string, up, down GoMigrationContext) { + if err := register( + filename, + true, + &GoFunc{RunTx: up, Mode: TransactionEnabled}, + &GoFunc{RunTx: down, Mode: TransactionEnabled}, + ); err != nil { + panic(err) + } +} + +// GoMigrationNoTxContext is a Go migration func that is run outside a transaction and receives a +// context. +type GoMigrationNoTxContext func(ctx context.Context, db *sql.DB) error + +// AddMigrationNoTxContext adds Go migrations that will be run outside transaction. +func AddMigrationNoTxContext(up, down GoMigrationNoTxContext) { + _, filename, _, _ := runtime.Caller(1) + AddNamedMigrationNoTxContext(filename, up, down) +} + +// AddNamedMigrationNoTxContext adds named Go migrations that will be run outside transaction. +func AddNamedMigrationNoTxContext(filename string, up, down GoMigrationNoTxContext) { + if err := register( + filename, + false, + &GoFunc{RunDB: up, Mode: TransactionDisabled}, + &GoFunc{RunDB: down, Mode: TransactionDisabled}, + ); err != nil { + panic(err) + } +} + +func register(filename string, useTx bool, up, down *GoFunc) error { + v, _ := NumericComponent(filename) + if existing, ok := registeredGoMigrations[v]; ok { + return fmt.Errorf("failed to add migration %q: version %d conflicts with %q", + filename, + v, + existing.Source, + ) + } + // Add to global as a registered migration. + m := NewGoMigration(v, up, down) + m.Source = filename + // We explicitly set transaction to maintain existing behavior. Both up and down may be nil, but + // we know based on the register function what the user is requesting. + m.UseTx = useTx + registeredGoMigrations[v] = m + return nil +} + +// withContext changes the signature of a function that receives one argument to receive a context +// and the argument. +func withContext[T any](fn func(T) error) func(context.Context, T) error { + if fn == nil { + return nil + } + return func(ctx context.Context, t T) error { + return fn(t) + } +} + +// withoutContext changes the signature of a function that receives a context and one argument to +// receive only the argument. When called the passed context is always context.Background(). +func withoutContext[T any](fn func(context.Context, T) error) func(T) error { + if fn == nil { + return nil + } + return func(t T) error { + return fn(context.Background(), t) + } +} + +// GoMigration is a Go migration func that is run within a transaction. +// +// Deprecated: Use GoMigrationContext. +type GoMigration func(tx *sql.Tx) error + +// GoMigrationNoTx is a Go migration func that is run outside a transaction. +// +// Deprecated: Use GoMigrationNoTxContext. +type GoMigrationNoTx func(db *sql.DB) error + +// AddMigration adds Go migrations. +// +// Deprecated: Use AddMigrationContext. +func AddMigration(up, down GoMigration) { + _, filename, _, _ := runtime.Caller(1) + AddNamedMigrationContext(filename, withContext(up), withContext(down)) +} + +// AddNamedMigration adds named Go migrations. +// +// Deprecated: Use AddNamedMigrationContext. +func AddNamedMigration(filename string, up, down GoMigration) { + AddNamedMigrationContext(filename, withContext(up), withContext(down)) +} + +// AddMigrationNoTx adds Go migrations that will be run outside transaction. +// +// Deprecated: Use AddMigrationNoTxContext. +func AddMigrationNoTx(up, down GoMigrationNoTx) { + _, filename, _, _ := runtime.Caller(1) + AddNamedMigrationNoTxContext(filename, withContext(up), withContext(down)) +} + +// AddNamedMigrationNoTx adds named Go migrations that will be run outside transaction. +// +// Deprecated: Use AddNamedMigrationNoTxContext. +func AddNamedMigrationNoTx(filename string, up, down GoMigrationNoTx) { + AddNamedMigrationNoTxContext(filename, withContext(up), withContext(down)) +} diff --git a/vendor/github.com/pressly/goose/v3/reset.go b/vendor/github.com/pressly/goose/v3/reset.go new file mode 100644 index 0000000000..274c539d4a --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/reset.go @@ -0,0 +1,64 @@ +package goose + +import ( + "context" + "database/sql" + "fmt" + "sort" +) + +// Reset rolls back all migrations +func Reset(db *sql.DB, dir string, opts ...OptionsFunc) error { + ctx := context.Background() + return ResetContext(ctx, db, dir, opts...) +} + +// ResetContext rolls back all migrations +func ResetContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { + option := &options{} + for _, f := range opts { + f(option) + } + migrations, err := CollectMigrations(dir, minVersion, maxVersion) + if err != nil { + return fmt.Errorf("failed to collect migrations: %w", err) + } + if option.noVersioning { + return DownToContext(ctx, db, dir, minVersion, opts...) + } + + statuses, err := dbMigrationsStatus(ctx, db) + if err != nil { + return fmt.Errorf("failed to get status of migrations: %w", err) + } + sort.Sort(sort.Reverse(migrations)) + + for _, migration := range migrations { + if !statuses[migration.Version] { + continue + } + if err = migration.DownContext(ctx, db); err != nil { + return fmt.Errorf("failed to db-down: %w", err) + } + } + + return nil +} + +func dbMigrationsStatus(ctx context.Context, db *sql.DB) (map[int64]bool, error) { + dbMigrations, err := store.ListMigrations(ctx, db, TableName()) + if err != nil { + return nil, err + } + // The most recent record for each migration specifies + // whether it has been applied or rolled back. + results := make(map[int64]bool) + + for _, m := range dbMigrations { + if _, ok := results[m.VersionID]; ok { + continue + } + results[m.VersionID] = m.IsApplied + } + return results, nil +} diff --git a/vendor/github.com/pressly/goose/v3/status.go b/vendor/github.com/pressly/goose/v3/status.go new file mode 100644 index 0000000000..df8524c62d --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/status.go @@ -0,0 +1,64 @@ +package goose + +import ( + "context" + "database/sql" + "errors" + "fmt" + "path/filepath" + "time" +) + +// Status prints the status of all migrations. +func Status(db *sql.DB, dir string, opts ...OptionsFunc) error { + ctx := context.Background() + return StatusContext(ctx, db, dir, opts...) +} + +// StatusContext prints the status of all migrations. +func StatusContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { + option := &options{} + for _, f := range opts { + f(option) + } + migrations, err := CollectMigrations(dir, minVersion, maxVersion) + if err != nil { + return fmt.Errorf("failed to collect migrations: %w", err) + } + if option.noVersioning { + log.Printf(" Applied At Migration") + log.Printf(" =======================================") + for _, current := range migrations { + log.Printf(" %-24s -- %v", "no versioning", filepath.Base(current.Source)) + } + return nil + } + + // must ensure that the version table exists if we're running on a pristine DB + if _, err := EnsureDBVersionContext(ctx, db); err != nil { + return fmt.Errorf("failed to ensure DB version: %w", err) + } + + log.Printf(" Applied At Migration") + log.Printf(" =======================================") + for _, migration := range migrations { + if err := printMigrationStatus(ctx, db, migration.Version, filepath.Base(migration.Source)); err != nil { + return fmt.Errorf("failed to print status: %w", err) + } + } + + return nil +} + +func printMigrationStatus(ctx context.Context, db *sql.DB, version int64, script string) error { + m, err := store.GetMigration(ctx, db, TableName(), version) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("failed to query the latest migration: %w", err) + } + appliedAt := "Pending" + if m != nil && m.IsApplied { + appliedAt = m.Timestamp.Format(time.ANSIC) + } + log.Printf(" %-24s -- %v", appliedAt, script) + return nil +} diff --git a/vendor/github.com/pressly/goose/v3/up.go b/vendor/github.com/pressly/goose/v3/up.go new file mode 100644 index 0000000000..0caa967eb7 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/up.go @@ -0,0 +1,221 @@ +package goose + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strings" +) + +type options struct { + allowMissing bool + applyUpByOne bool + noVersioning bool +} + +type OptionsFunc func(o *options) + +func WithAllowMissing() OptionsFunc { + return func(o *options) { o.allowMissing = true } +} + +func WithNoVersioning() OptionsFunc { + return func(o *options) { o.noVersioning = true } +} + +func WithNoColor(b bool) OptionsFunc { + return func(o *options) { noColor = b } +} + +func withApplyUpByOne() OptionsFunc { + return func(o *options) { o.applyUpByOne = true } +} + +// UpTo migrates up to a specific version. +func UpTo(db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { + ctx := context.Background() + return UpToContext(ctx, db, dir, version, opts...) +} + +func UpToContext(ctx context.Context, db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { + option := &options{} + for _, f := range opts { + f(option) + } + foundMigrations, err := CollectMigrations(dir, minVersion, version) + if err != nil { + return err + } + + if option.noVersioning { + if len(foundMigrations) == 0 { + return nil + } + if option.applyUpByOne { + // For up-by-one this means keep re-applying the first + // migration over and over. + version = foundMigrations[0].Version + } + return upToNoVersioning(ctx, db, foundMigrations, version) + } + + if _, err := EnsureDBVersionContext(ctx, db); err != nil { + return err + } + dbMigrations, err := listAllDBVersions(ctx, db) + if err != nil { + return err + } + dbMaxVersion := dbMigrations[len(dbMigrations)-1].Version + // lookupAppliedInDB is a map of all applied migrations in the database. + lookupAppliedInDB := make(map[int64]bool) + for _, m := range dbMigrations { + lookupAppliedInDB[m.Version] = true + } + + missingMigrations := findMissingMigrations(dbMigrations, foundMigrations, dbMaxVersion) + + // feature(mf): It is very possible someone may want to apply ONLY new migrations + // and skip missing migrations altogether. At the moment this is not supported, + // but leaving this comment because that's where that logic will be handled. + if len(missingMigrations) > 0 && !option.allowMissing { + var collected []string + for _, m := range missingMigrations { + output := fmt.Sprintf("version %d: %s", m.Version, m.Source) + collected = append(collected, output) + } + return fmt.Errorf("error: found %d missing migrations before current version %d:\n\t%s", + len(missingMigrations), dbMaxVersion, strings.Join(collected, "\n\t")) + } + var migrationsToApply Migrations + if option.allowMissing { + migrationsToApply = missingMigrations + } + // filter all migrations with a version greater than the supplied version (min) and less than or + // equal to the requested version (max). Note, we do not need to filter out missing migrations + // because we are only appending "new" migrations that have a higher version than the current + // database max version, which inevitably means they are not "missing". + for _, m := range foundMigrations { + if lookupAppliedInDB[m.Version] { + continue + } + if m.Version > dbMaxVersion && m.Version <= version { + migrationsToApply = append(migrationsToApply, m) + } + } + + var current int64 + for _, m := range migrationsToApply { + if err := m.UpContext(ctx, db); err != nil { + return err + } + if option.applyUpByOne { + return nil + } + current = m.Version + } + + if len(migrationsToApply) == 0 { + current, err = GetDBVersionContext(ctx, db) + if err != nil { + return err + } + + log.Printf("goose: no migrations to run. current version: %d", current) + } else { + log.Printf("goose: successfully migrated database to version: %d", current) + } + + // At this point there are no more migrations to apply. But we need to maintain + // the following behaviour: + // UpByOne returns an error to signifying there are no more migrations. + // Up and UpTo return nil + + if option.applyUpByOne { + return ErrNoNextVersion + } + + return nil +} + +// upToNoVersioning applies up migrations up to, and including, the +// target version. +func upToNoVersioning(ctx context.Context, db *sql.DB, migrations Migrations, version int64) error { + var finalVersion int64 + for _, current := range migrations { + if current.Version > version { + break + } + current.noVersioning = true + if err := current.UpContext(ctx, db); err != nil { + return err + } + finalVersion = current.Version + } + log.Printf("goose: up to current file version: %d", finalVersion) + return nil +} + +// Up applies all available migrations. +func Up(db *sql.DB, dir string, opts ...OptionsFunc) error { + ctx := context.Background() + return UpContext(ctx, db, dir, opts...) +} + +// UpContext applies all available migrations. +func UpContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { + return UpToContext(ctx, db, dir, maxVersion, opts...) +} + +// UpByOne migrates up by a single version. +func UpByOne(db *sql.DB, dir string, opts ...OptionsFunc) error { + ctx := context.Background() + return UpByOneContext(ctx, db, dir, opts...) +} + +// UpByOneContext migrates up by a single version. +func UpByOneContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { + opts = append(opts, withApplyUpByOne()) + return UpToContext(ctx, db, dir, maxVersion, opts...) +} + +// listAllDBVersions returns a list of all migrations, ordered ascending. +func listAllDBVersions(ctx context.Context, db *sql.DB) (Migrations, error) { + dbMigrations, err := store.ListMigrations(ctx, db, TableName()) + if err != nil { + return nil, err + } + all := make(Migrations, 0, len(dbMigrations)) + for _, m := range dbMigrations { + all = append(all, &Migration{ + Version: m.VersionID, + }) + } + // ListMigrations returns migrations in descending order by id. + // But we want to return them in ascending order by version_id, so we re-sort. + sort.SliceStable(all, func(i, j int) bool { + return all[i].Version < all[j].Version + }) + return all, nil +} + +// findMissingMigrations migrations returns all missing migrations. +// A migrations is considered missing if it has a version less than the +// current known max version. +func findMissingMigrations(knownMigrations, newMigrations Migrations, dbMaxVersion int64) Migrations { + existing := make(map[int64]bool) + for _, known := range knownMigrations { + existing[known.Version] = true + } + var missing Migrations + for _, new := range newMigrations { + if !existing[new.Version] && new.Version < dbMaxVersion { + missing = append(missing, new) + } + } + sort.SliceStable(missing, func(i, j int) bool { + return missing[i].Version < missing[j].Version + }) + return missing +} diff --git a/vendor/github.com/pressly/goose/v3/version.go b/vendor/github.com/pressly/goose/v3/version.go new file mode 100644 index 0000000000..03a5e71fc3 --- /dev/null +++ b/vendor/github.com/pressly/goose/v3/version.go @@ -0,0 +1,52 @@ +package goose + +import ( + "context" + "database/sql" + "fmt" +) + +// Version prints the current version of the database. +func Version(db *sql.DB, dir string, opts ...OptionsFunc) error { + ctx := context.Background() + return VersionContext(ctx, db, dir, opts...) +} + +// VersionContext prints the current version of the database. +func VersionContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { + option := &options{} + for _, f := range opts { + f(option) + } + if option.noVersioning { + var current int64 + migrations, err := CollectMigrations(dir, minVersion, maxVersion) + if err != nil { + return fmt.Errorf("failed to collect migrations: %w", err) + } + if len(migrations) > 0 { + current = migrations[len(migrations)-1].Version + } + log.Printf("goose: file version %v", current) + return nil + } + + current, err := GetDBVersionContext(ctx, db) + if err != nil { + return err + } + log.Printf("goose: version %v", current) + return nil +} + +var tableName = "goose_db_version" + +// TableName returns goose db version table name +func TableName() string { + return tableName +} + +// SetTableName set goose db version table name +func SetTableName(n string) { + tableName = n +} diff --git a/vendor/github.com/sethvargo/go-retry/LICENSE b/vendor/github.com/sethvargo/go-retry/LICENSE new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/vendor/github.com/sethvargo/go-retry/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/vendor/github.com/sethvargo/go-retry/README.md b/vendor/github.com/sethvargo/go-retry/README.md new file mode 100644 index 0000000000..3464965a8b --- /dev/null +++ b/vendor/github.com/sethvargo/go-retry/README.md @@ -0,0 +1,188 @@ +# Retry + +[![Test](https://github.com/sethvargo/go-retry/actions/workflows/test.yml/badge.svg)](https://github.com/sethvargo/go-retry/actions/workflows/test.yml)[![GoDoc](https://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](https://pkg.go.dev/mod/github.com/sethvargo/go-retry) + +Retry is a Go library for facilitating retry logic and backoff. It's highly +extensible with full control over how and when retries occur. You can also write +your own custom backoff functions by implementing the Backoff interface. + +## Features + +- **Extensible** - Inspired by Go's built-in HTTP package, this Go backoff and + retry library is extensible via middleware. You can write custom backoff + functions or use a provided filter. + +- **Independent** - No external dependencies besides the Go standard library, + meaning it won't bloat your project. + +- **Concurrent** - Unless otherwise specified, everything is safe for concurrent + use. + +- **Context-aware** - Use native Go contexts to control cancellation. + +## Usage + +Here is an example use for connecting to a database using Go's `database/sql` +package: + +```golang +package main + +import ( + "context" + "database/sql" + "log" + "time" + + "github.com/sethvargo/go-retry" +) + +func main() { + db, err := sql.Open("mysql", "...") + if err != nil { + log.Fatal(err) + } + + ctx := context.Background() + if err := retry.Fibonacci(ctx, 1*time.Second, func(ctx context.Context) error { + if err := db.PingContext(ctx); err != nil { + // This marks the error as retryable + return retry.RetryableError(err) + } + return nil + }); err != nil { + log.Fatal(err) + } +} +``` + +## Backoffs + +In addition to your own custom algorithms, there are built-in algorithms for +backoff in the library. + +### Constant + +A very rudimentary backoff, just returns a constant value. Here is an example: + +```text +1s -> 1s -> 1s -> 1s -> 1s -> 1s +``` + +Usage: + +```golang +NewConstant(1 * time.Second) +``` + +### Exponential + +Arguably the most common backoff, the next value is double the previous value. +Here is an example: + +```text +1s -> 2s -> 4s -> 8s -> 16s -> 32s -> 64s +``` + +Usage: + +```golang +NewExponential(1 * time.Second) +``` + +### Fibonacci + +The Fibonacci backoff uses the Fibonacci sequence to calculate the backoff. The +next value is the sum of the current value and the previous value. This means +retires happen quickly at first, but then gradually take slower, ideal for +network-type issues. Here is an example: + +```text +1s -> 1s -> 2s -> 3s -> 5s -> 8s -> 13s +``` + +Usage: + +```golang +NewFibonacci(1 * time.Second) +``` + +## Modifiers (Middleware) + +The built-in backoff algorithms never terminate and have no caps or limits - you +control their behavior with middleware. There's built-in middleware, but you can +also write custom middleware. + +### Jitter + +To reduce the chances of a thundering herd, add random jitter to the returned +value. + +```golang +b := NewFibonacci(1 * time.Second) + +// Return the next value, +/- 500ms +b = WithJitter(500*time.Millisecond, b) + +// Return the next value, +/- 5% of the result +b = WithJitterPercent(5, b) + +// Return a random value in [0, next value) +b = WithFullJitter(b) +``` + +### MaxRetries + +To terminate a retry, specify the maximum number of _retries_. Note this +is _retries_, not _attempts_. Attempts is retries + 1. + +```golang +b := NewFibonacci(1 * time.Second) + +// Stop after 4 retries, when the 5th attempt has failed. In this example, the worst case elapsed +// time would be 1s + 1s + 2s + 3s = 7s. +b = WithMaxRetries(4, b) +``` + +### CappedDuration + +To ensure an individual calculated duration never exceeds a value, use a cap: + +```golang +b := NewFibonacci(1 * time.Second) + +// Ensure the maximum value is 2s. In this example, the sleep values would be +// 1s, 1s, 2s, 2s, 2s, 2s... +b = WithCappedDuration(2 * time.Second, b) +``` + +### WithMaxDuration + +For a best-effort limit on the total execution time, specify a max duration: + +```golang +b := NewFibonacci(1 * time.Second) + +// Ensure the maximum total retry time is 5s. +b = WithMaxDuration(5 * time.Second, b) +``` + +## Benchmarks + +Here are benchmarks against some other popular Go backoff and retry libraries. +You can run these benchmarks yourself via the `benchmark/` folder. Commas and +spacing fixed for clarity. + +```text +Benchmark/cenkalti-7 13,052,668 87.3 ns/op +Benchmark/lestrrat-7 902,044 1,355 ns/op +Benchmark/sethvargo-7 203,914,245 5.73 ns/op +``` + +## Notes and Caveats + +- Randomization uses `math/rand/v2` (non-cryptographic, automatically seeded), + not `crypto/rand`. +- Ordering of addition of multiple modifiers will make a difference. + For example; ensure you add `CappedDuration` before `WithMaxDuration`, otherwise it may early out too early. + Another example is you could add `Jitter` before or after capping depending on your desired outcome. diff --git a/vendor/github.com/sethvargo/go-retry/backoff.go b/vendor/github.com/sethvargo/go-retry/backoff.go new file mode 100644 index 0000000000..47f747536f --- /dev/null +++ b/vendor/github.com/sethvargo/go-retry/backoff.go @@ -0,0 +1,156 @@ +package retry + +import ( + "math/rand/v2" + "sync" + "time" +) + +// Backoff is an interface that backs off. +type Backoff interface { + // Next returns the time duration to wait and whether to stop. + Next() (next time.Duration, stop bool) +} + +var _ Backoff = (BackoffFunc)(nil) + +// BackoffFunc is a backoff expressed as a function. +type BackoffFunc func() (time.Duration, bool) + +// Next implements Backoff. +func (b BackoffFunc) Next() (time.Duration, bool) { + return b() +} + +// WithJitter wraps a backoff function and adds the specified jitter. j can be +// interpreted as "+/- j". For example, if j were 5 seconds and the backoff +// returned 20s, the value could be between 15 and 25 seconds. The value can +// never be less than 0. +func WithJitter(j time.Duration, next Backoff) Backoff { + return BackoffFunc(func() (time.Duration, bool) { + val, stop := next.Next() + if stop { + return 0, true + } + + if j <= 0 { + return val, false + } + + diff := time.Duration(rand.Int64N(int64(j)*2) - int64(j)) + val = max(val+diff, 0) + return val, false + }) +} + +// WithJitterPercent wraps a backoff function and adds the specified jitter +// percentage. j can be interpreted as "+/- j%". For example, if j were 5 and +// the backoff returned 20s, the value could be between 19 and 21 seconds. The +// value can never be less than 0 or greater than 100. +func WithJitterPercent(j uint64, next Backoff) Backoff { + return BackoffFunc(func() (time.Duration, bool) { + val, stop := next.Next() + if stop { + return 0, true + } + + if j <= 0 { + return val, false + } + + // Get a value between -j and j, the convert to a percentage + top := rand.Int64N(int64(j)*2) - int64(j) + pct := 1 - float64(top)/100.0 + + val = max(time.Duration(float64(val)*pct), 0) + return val, false + }) +} + +// WithFullJitter wraps a backoff function and returns a random value between +// zero (inclusive) and the wrapped backoff's returned value (exclusive). For +// example, if the backoff returned 20s, the value could be anywhere in [0, 20s). +// +// Unlike WithJitter, which applies "+/- j" around the value, full jitter +// spreads the wait across the entire range, which spreads out retrying clients +// more effectively. See +// https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/. +func WithFullJitter(next Backoff) Backoff { + return BackoffFunc(func() (time.Duration, bool) { + val, stop := next.Next() + if stop { + return 0, true + } + + if val <= 0 { + return val, false + } + + return time.Duration(rand.Int64N(int64(val))), false + }) +} + +// WithMaxRetries executes the backoff function up until the maximum attempts. +func WithMaxRetries(max uint64, next Backoff) Backoff { + var l sync.Mutex + var attempt uint64 + + return BackoffFunc(func() (time.Duration, bool) { + l.Lock() + defer l.Unlock() + + if attempt >= max { + return 0, true + } + attempt++ + + val, stop := next.Next() + if stop { + return 0, true + } + + return val, false + }) +} + +// WithCappedDuration sets a maximum on the duration returned from the next +// backoff. This is NOT a total backoff time, but rather a cap on the maximum +// value a backoff can return. Without another middleware, the backoff will +// continue infinitely. +func WithCappedDuration(cap time.Duration, next Backoff) Backoff { + return BackoffFunc(func() (time.Duration, bool) { + val, stop := next.Next() + if stop { + return 0, true + } + + if val <= 0 || val > cap { + val = cap + } + return val, false + }) +} + +// WithMaxDuration sets a maximum on the total amount of time a backoff should +// execute. It's best-effort, and should not be used to guarantee an exact +// amount of time. +func WithMaxDuration(timeout time.Duration, next Backoff) Backoff { + start := time.Now() + + return BackoffFunc(func() (time.Duration, bool) { + diff := timeout - time.Since(start) + if diff <= 0 { + return 0, true + } + + val, stop := next.Next() + if stop { + return 0, true + } + + if val <= 0 || val > diff { + val = diff + } + return val, false + }) +} diff --git a/vendor/github.com/sethvargo/go-retry/backoff_constant.go b/vendor/github.com/sethvargo/go-retry/backoff_constant.go new file mode 100644 index 0000000000..e25ebf4bb1 --- /dev/null +++ b/vendor/github.com/sethvargo/go-retry/backoff_constant.go @@ -0,0 +1,25 @@ +package retry + +import ( + "context" + "time" +) + +// Constant is a wrapper around Retry that uses a constant backoff. It panics if +// the given base is less than zero. +func Constant(ctx context.Context, t time.Duration, f RetryFunc) error { + return Do(ctx, NewConstant(t), f) +} + +// NewConstant creates a new constant backoff using the value t. The wait time +// is the provided constant value. It panics if the given base is less than +// or equal to zero. +func NewConstant(t time.Duration) Backoff { + if t <= 0 { + panic("t must be greater than 0") + } + + return BackoffFunc(func() (time.Duration, bool) { + return t, false + }) +} diff --git a/vendor/github.com/sethvargo/go-retry/backoff_exponential.go b/vendor/github.com/sethvargo/go-retry/backoff_exponential.go new file mode 100644 index 0000000000..4fc2788aa1 --- /dev/null +++ b/vendor/github.com/sethvargo/go-retry/backoff_exponential.go @@ -0,0 +1,51 @@ +package retry + +import ( + "context" + "math" + "sync/atomic" + "time" +) + +type exponentialBackoff struct { + base time.Duration + attempt atomic.Uint64 +} + +// Exponential is a wrapper around Retry that uses an exponential backoff. See +// NewExponential. +func Exponential(ctx context.Context, base time.Duration, f RetryFunc) error { + return Do(ctx, NewExponential(base), f) +} + +// NewExponential creates a new exponential backoff using the starting value of +// base and doubling on each failure (1, 2, 4, 8, 16, 32, 64...), up to max. +// +// Once it overflows, the function constantly returns the maximum time.Duration +// for a 64-bit integer. +// +// It panics if the given base is less than zero. +func NewExponential(base time.Duration) Backoff { + if base <= 0 { + panic("base must be greater than 0") + } + + return &exponentialBackoff{ + base: base, + } +} + +// Next implements Backoff. It is safe for concurrent use. +func (b *exponentialBackoff) Next() (time.Duration, bool) { + for { + attempt := b.attempt.Load() + next := b.base << attempt + if next <= 0 { + return math.MaxInt64, false + } + + if b.attempt.CompareAndSwap(attempt, attempt+1) { + return next, false + } + } +} diff --git a/vendor/github.com/sethvargo/go-retry/backoff_fibonacci.go b/vendor/github.com/sethvargo/go-retry/backoff_fibonacci.go new file mode 100644 index 0000000000..747bf40858 --- /dev/null +++ b/vendor/github.com/sethvargo/go-retry/backoff_fibonacci.go @@ -0,0 +1,54 @@ +package retry + +import ( + "context" + "math" + "sync/atomic" + "time" +) + +type state [2]time.Duration + +type fibonacciBackoff struct { + state atomic.Pointer[state] +} + +// Fibonacci is a wrapper around Retry that uses a Fibonacci backoff. See +// NewFibonacci. +func Fibonacci(ctx context.Context, base time.Duration, f RetryFunc) error { + return Do(ctx, NewFibonacci(base), f) +} + +// NewFibonacci creates a new Fibonacci backoff using the starting value of +// base. The wait time is the sum of the previous two wait times on each failed +// attempt (1, 1, 2, 3, 5, 8, 13...). +// +// Once it overflows, the function constantly returns the maximum time.Duration +// for a 64-bit integer. +// +// It panics if the given base is less than zero. +func NewFibonacci(base time.Duration) Backoff { + if base <= 0 { + panic("base must be greater than 0") + } + + b := new(fibonacciBackoff) + b.state.Store(&state{0, base}) + return b +} + +// Next implements Backoff. It is safe for concurrent use. +func (b *fibonacciBackoff) Next() (time.Duration, bool) { + for { + curr := b.state.Load() + next := curr[0] + curr[1] + + if next <= 0 { + return math.MaxInt64, false + } + + if b.state.CompareAndSwap(curr, &state{curr[1], next}) { + return next, false + } + } +} diff --git a/vendor/github.com/sethvargo/go-retry/retry.go b/vendor/github.com/sethvargo/go-retry/retry.go new file mode 100644 index 0000000000..46453a9f0e --- /dev/null +++ b/vendor/github.com/sethvargo/go-retry/retry.go @@ -0,0 +1,95 @@ +// Package retry provides helpers for retrying. +// +// This package defines flexible interfaces for retrying Go functions that may +// be flakey or eventually consistent. It abstracts the "backoff" (how long to +// wait between tries) and "retry" (execute the function again) mechanisms for +// maximum flexibility. Furthermore, everything is an interface, so you can +// define your own implementations. +// +// The package is modeled after Go's built-in HTTP package, making it easy to +// customize the built-in backoff with your own custom logic. Additionally, +// callers specify which errors are retryable by wrapping them. This is helpful +// with complex operations where only certain results should retry. +package retry + +import ( + "context" + "errors" + "time" +) + +// RetryFunc is a function passed to [Do]. +type RetryFunc func(ctx context.Context) error + +// RetryFuncValue is a function passed to [Do] which returns a value. +type RetryFuncValue[T any] func(ctx context.Context) (T, error) + +type retryableError struct { + err error +} + +// RetryableError marks an error as retryable. +func RetryableError(err error) error { + if err == nil { + return nil + } + return &retryableError{err} +} + +// Unwrap implements error wrapping. +func (e *retryableError) Unwrap() error { + return e.err +} + +// Error returns the error string. +func (e *retryableError) Error() string { + if e.err == nil { + return "retryable: " + } + return "retryable: " + e.err.Error() +} + +func DoValue[T any](ctx context.Context, b Backoff, f RetryFuncValue[T]) (T, error) { + var nilT T + + for { + // Return immediately if ctx is canceled + if err := context.Cause(ctx); err != nil { + return nilT, err + } + + v, err := f(ctx) + if err == nil { + return v, nil + } + + // Not retryable + var rerr *retryableError + if !errors.As(err, &rerr) { + return nilT, err + } + + next, stop := b.Next() + if stop { + return nilT, rerr.Unwrap() + } + + // Wait until next attempt or until the context expires. Any error will + // be caught at the top of the loop. + t := time.NewTimer(next) + select { + case <-ctx.Done(): + t.Stop() + case <-t.C: + } + } +} + +// Do wraps a function with a backoff to retry. The provided context is the same +// context passed to the [RetryFunc]. +func Do(ctx context.Context, b Backoff, f RetryFunc) error { + _, err := DoValue(ctx, b, func(ctx context.Context) (*struct{}, error) { + return nil, f(ctx) + }) + return err +} diff --git a/vendor/go.uber.org/multierr/.codecov.yml b/vendor/go.uber.org/multierr/.codecov.yml new file mode 100644 index 0000000000..6d4d1be7b5 --- /dev/null +++ b/vendor/go.uber.org/multierr/.codecov.yml @@ -0,0 +1,15 @@ +coverage: + range: 80..100 + round: down + precision: 2 + + status: + project: # measuring the overall project coverage + default: # context, you can create multiple ones with custom titles + enabled: yes # must be yes|true to enable this status + target: 100 # specify the target coverage for each commit status + # option: "auto" (must increase from parent commit or pull request base) + # option: "X%" a static target percentage to hit + if_not_found: success # if parent is not found report status as success, error, or failure + if_ci_failed: error # if ci fails report status as success, error, or failure + diff --git a/vendor/go.uber.org/multierr/.gitignore b/vendor/go.uber.org/multierr/.gitignore new file mode 100644 index 0000000000..b9a05e3da0 --- /dev/null +++ b/vendor/go.uber.org/multierr/.gitignore @@ -0,0 +1,4 @@ +/vendor +cover.html +cover.out +/bin diff --git a/vendor/go.uber.org/multierr/CHANGELOG.md b/vendor/go.uber.org/multierr/CHANGELOG.md new file mode 100644 index 0000000000..f8177b978c --- /dev/null +++ b/vendor/go.uber.org/multierr/CHANGELOG.md @@ -0,0 +1,95 @@ +Releases +======== + +v1.11.0 (2023-03-28) +==================== +- `Errors` now supports any error that implements multiple-error + interface. +- Add `Every` function to allow checking if all errors in the chain + satisfies `errors.Is` against the target error. + +v1.10.0 (2023-03-08) +==================== + +- Comply with Go 1.20's multiple-error interface. +- Drop Go 1.18 support. + Per the support policy, only Go 1.19 and 1.20 are supported now. +- Drop all non-test external dependencies. + +v1.9.0 (2022-12-12) +=================== + +- Add `AppendFunc` that allow passsing functions to similar to + `AppendInvoke`. + +- Bump up yaml.v3 dependency to 3.0.1. + +v1.8.0 (2022-02-28) +=================== + +- `Combine`: perform zero allocations when there are no errors. + + +v1.7.0 (2021-05-06) +=================== + +- Add `AppendInvoke` to append into errors from `defer` blocks. + + +v1.6.0 (2020-09-14) +=================== + +- Actually drop library dependency on development-time tooling. + + +v1.5.0 (2020-02-24) +=================== + +- Drop library dependency on development-time tooling. + + +v1.4.0 (2019-11-04) +=================== + +- Add `AppendInto` function to more ergonomically build errors inside a + loop. + + +v1.3.0 (2019-10-29) +=================== + +- Switch to Go modules. + + +v1.2.0 (2019-09-26) +=================== + +- Support extracting and matching against wrapped errors with `errors.As` + and `errors.Is`. + + +v1.1.0 (2017-06-30) +=================== + +- Added an `Errors(error) []error` function to extract the underlying list of + errors for a multierr error. + + +v1.0.0 (2017-05-31) +=================== + +No changes since v0.2.0. This release is committing to making no breaking +changes to the current API in the 1.X series. + + +v0.2.0 (2017-04-11) +=================== + +- Repeatedly appending to the same error is now faster due to fewer + allocations. + + +v0.1.0 (2017-31-03) +=================== + +- Initial release diff --git a/vendor/go.uber.org/multierr/LICENSE.txt b/vendor/go.uber.org/multierr/LICENSE.txt new file mode 100644 index 0000000000..413e30f7ce --- /dev/null +++ b/vendor/go.uber.org/multierr/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2017-2021 Uber Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/go.uber.org/multierr/Makefile b/vendor/go.uber.org/multierr/Makefile new file mode 100644 index 0000000000..dcb6fe723c --- /dev/null +++ b/vendor/go.uber.org/multierr/Makefile @@ -0,0 +1,38 @@ +# Directory to put `go install`ed binaries in. +export GOBIN ?= $(shell pwd)/bin + +GO_FILES := $(shell \ + find . '(' -path '*/.*' -o -path './vendor' ')' -prune \ + -o -name '*.go' -print | cut -b3-) + +.PHONY: build +build: + go build ./... + +.PHONY: test +test: + go test -race ./... + +.PHONY: gofmt +gofmt: + $(eval FMT_LOG := $(shell mktemp -t gofmt.XXXXX)) + @gofmt -e -s -l $(GO_FILES) > $(FMT_LOG) || true + @[ ! -s "$(FMT_LOG)" ] || (echo "gofmt failed:" | cat - $(FMT_LOG) && false) + +.PHONY: golint +golint: + @cd tools && go install golang.org/x/lint/golint + @$(GOBIN)/golint ./... + +.PHONY: staticcheck +staticcheck: + @cd tools && go install honnef.co/go/tools/cmd/staticcheck + @$(GOBIN)/staticcheck ./... + +.PHONY: lint +lint: gofmt golint staticcheck + +.PHONY: cover +cover: + go test -race -coverprofile=cover.out -coverpkg=./... -v ./... + go tool cover -html=cover.out -o cover.html diff --git a/vendor/go.uber.org/multierr/README.md b/vendor/go.uber.org/multierr/README.md new file mode 100644 index 0000000000..5ab6ac40f4 --- /dev/null +++ b/vendor/go.uber.org/multierr/README.md @@ -0,0 +1,43 @@ +# multierr [![GoDoc][doc-img]][doc] [![Build Status][ci-img]][ci] [![Coverage Status][cov-img]][cov] + +`multierr` allows combining one or more Go `error`s together. + +## Features + +- **Idiomatic**: + multierr follows best practices in Go, and keeps your code idiomatic. + - It keeps the underlying error type hidden, + allowing you to deal in `error` values exclusively. + - It provides APIs to safely append into an error from a `defer` statement. +- **Performant**: + multierr is optimized for performance: + - It avoids allocations where possible. + - It utilizes slice resizing semantics to optimize common cases + like appending into the same error object from a loop. +- **Interoperable**: + multierr interoperates with the Go standard library's error APIs seamlessly: + - The `errors.Is` and `errors.As` functions *just work*. +- **Lightweight**: + multierr comes with virtually no dependencies. + +## Installation + +```bash +go get -u go.uber.org/multierr@latest +``` + +## Status + +Stable: No breaking changes will be made before 2.0. + +------------------------------------------------------------------------------- + +Released under the [MIT License]. + +[MIT License]: LICENSE.txt +[doc-img]: https://pkg.go.dev/badge/go.uber.org/multierr +[doc]: https://pkg.go.dev/go.uber.org/multierr +[ci-img]: https://github.com/uber-go/multierr/actions/workflows/go.yml/badge.svg +[cov-img]: https://codecov.io/gh/uber-go/multierr/branch/master/graph/badge.svg +[ci]: https://github.com/uber-go/multierr/actions/workflows/go.yml +[cov]: https://codecov.io/gh/uber-go/multierr diff --git a/vendor/go.uber.org/multierr/error.go b/vendor/go.uber.org/multierr/error.go new file mode 100644 index 0000000000..3a828b2dff --- /dev/null +++ b/vendor/go.uber.org/multierr/error.go @@ -0,0 +1,646 @@ +// Copyright (c) 2017-2023 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Package multierr allows combining one or more errors together. +// +// # Overview +// +// Errors can be combined with the use of the Combine function. +// +// multierr.Combine( +// reader.Close(), +// writer.Close(), +// conn.Close(), +// ) +// +// If only two errors are being combined, the Append function may be used +// instead. +// +// err = multierr.Append(reader.Close(), writer.Close()) +// +// The underlying list of errors for a returned error object may be retrieved +// with the Errors function. +// +// errors := multierr.Errors(err) +// if len(errors) > 0 { +// fmt.Println("The following errors occurred:", errors) +// } +// +// # Appending from a loop +// +// You sometimes need to append into an error from a loop. +// +// var err error +// for _, item := range items { +// err = multierr.Append(err, process(item)) +// } +// +// Cases like this may require knowledge of whether an individual instance +// failed. This usually requires introduction of a new variable. +// +// var err error +// for _, item := range items { +// if perr := process(item); perr != nil { +// log.Warn("skipping item", item) +// err = multierr.Append(err, perr) +// } +// } +// +// multierr includes AppendInto to simplify cases like this. +// +// var err error +// for _, item := range items { +// if multierr.AppendInto(&err, process(item)) { +// log.Warn("skipping item", item) +// } +// } +// +// This will append the error into the err variable, and return true if that +// individual error was non-nil. +// +// See [AppendInto] for more information. +// +// # Deferred Functions +// +// Go makes it possible to modify the return value of a function in a defer +// block if the function was using named returns. This makes it possible to +// record resource cleanup failures from deferred blocks. +// +// func sendRequest(req Request) (err error) { +// conn, err := openConnection() +// if err != nil { +// return err +// } +// defer func() { +// err = multierr.Append(err, conn.Close()) +// }() +// // ... +// } +// +// multierr provides the Invoker type and AppendInvoke function to make cases +// like the above simpler and obviate the need for a closure. The following is +// roughly equivalent to the example above. +// +// func sendRequest(req Request) (err error) { +// conn, err := openConnection() +// if err != nil { +// return err +// } +// defer multierr.AppendInvoke(&err, multierr.Close(conn)) +// // ... +// } +// +// See [AppendInvoke] and [Invoker] for more information. +// +// NOTE: If you're modifying an error from inside a defer, you MUST use a named +// return value for that function. +// +// # Advanced Usage +// +// Errors returned by Combine and Append MAY implement the following +// interface. +// +// type errorGroup interface { +// // Returns a slice containing the underlying list of errors. +// // +// // This slice MUST NOT be modified by the caller. +// Errors() []error +// } +// +// Note that if you need access to list of errors behind a multierr error, you +// should prefer using the Errors function. That said, if you need cheap +// read-only access to the underlying errors slice, you can attempt to cast +// the error to this interface. You MUST handle the failure case gracefully +// because errors returned by Combine and Append are not guaranteed to +// implement this interface. +// +// var errors []error +// group, ok := err.(errorGroup) +// if ok { +// errors = group.Errors() +// } else { +// errors = []error{err} +// } +package multierr // import "go.uber.org/multierr" + +import ( + "bytes" + "errors" + "fmt" + "io" + "strings" + "sync" + "sync/atomic" +) + +var ( + // Separator for single-line error messages. + _singlelineSeparator = []byte("; ") + + // Prefix for multi-line messages + _multilinePrefix = []byte("the following errors occurred:") + + // Prefix for the first and following lines of an item in a list of + // multi-line error messages. + // + // For example, if a single item is: + // + // foo + // bar + // + // It will become, + // + // - foo + // bar + _multilineSeparator = []byte("\n - ") + _multilineIndent = []byte(" ") +) + +// _bufferPool is a pool of bytes.Buffers. +var _bufferPool = sync.Pool{ + New: func() interface{} { + return &bytes.Buffer{} + }, +} + +type errorGroup interface { + Errors() []error +} + +// Errors returns a slice containing zero or more errors that the supplied +// error is composed of. If the error is nil, a nil slice is returned. +// +// err := multierr.Append(r.Close(), w.Close()) +// errors := multierr.Errors(err) +// +// If the error is not composed of other errors, the returned slice contains +// just the error that was passed in. +// +// Callers of this function are free to modify the returned slice. +func Errors(err error) []error { + return extractErrors(err) +} + +// multiError is an error that holds one or more errors. +// +// An instance of this is guaranteed to be non-empty and flattened. That is, +// none of the errors inside multiError are other multiErrors. +// +// multiError formats to a semi-colon delimited list of error messages with +// %v and with a more readable multi-line format with %+v. +type multiError struct { + copyNeeded atomic.Bool + errors []error +} + +// Errors returns the list of underlying errors. +// +// This slice MUST NOT be modified. +func (merr *multiError) Errors() []error { + if merr == nil { + return nil + } + return merr.errors +} + +func (merr *multiError) Error() string { + if merr == nil { + return "" + } + + buff := _bufferPool.Get().(*bytes.Buffer) + buff.Reset() + + merr.writeSingleline(buff) + + result := buff.String() + _bufferPool.Put(buff) + return result +} + +// Every compares every error in the given err against the given target error +// using [errors.Is], and returns true only if every comparison returned true. +func Every(err error, target error) bool { + for _, e := range extractErrors(err) { + if !errors.Is(e, target) { + return false + } + } + return true +} + +func (merr *multiError) Format(f fmt.State, c rune) { + if c == 'v' && f.Flag('+') { + merr.writeMultiline(f) + } else { + merr.writeSingleline(f) + } +} + +func (merr *multiError) writeSingleline(w io.Writer) { + first := true + for _, item := range merr.errors { + if first { + first = false + } else { + w.Write(_singlelineSeparator) + } + io.WriteString(w, item.Error()) + } +} + +func (merr *multiError) writeMultiline(w io.Writer) { + w.Write(_multilinePrefix) + for _, item := range merr.errors { + w.Write(_multilineSeparator) + writePrefixLine(w, _multilineIndent, fmt.Sprintf("%+v", item)) + } +} + +// Writes s to the writer with the given prefix added before each line after +// the first. +func writePrefixLine(w io.Writer, prefix []byte, s string) { + first := true + for len(s) > 0 { + if first { + first = false + } else { + w.Write(prefix) + } + + idx := strings.IndexByte(s, '\n') + if idx < 0 { + idx = len(s) - 1 + } + + io.WriteString(w, s[:idx+1]) + s = s[idx+1:] + } +} + +type inspectResult struct { + // Number of top-level non-nil errors + Count int + + // Total number of errors including multiErrors + Capacity int + + // Index of the first non-nil error in the list. Value is meaningless if + // Count is zero. + FirstErrorIdx int + + // Whether the list contains at least one multiError + ContainsMultiError bool +} + +// Inspects the given slice of errors so that we can efficiently allocate +// space for it. +func inspect(errors []error) (res inspectResult) { + first := true + for i, err := range errors { + if err == nil { + continue + } + + res.Count++ + if first { + first = false + res.FirstErrorIdx = i + } + + if merr, ok := err.(*multiError); ok { + res.Capacity += len(merr.errors) + res.ContainsMultiError = true + } else { + res.Capacity++ + } + } + return +} + +// fromSlice converts the given list of errors into a single error. +func fromSlice(errors []error) error { + // Don't pay to inspect small slices. + switch len(errors) { + case 0: + return nil + case 1: + return errors[0] + } + + res := inspect(errors) + switch res.Count { + case 0: + return nil + case 1: + // only one non-nil entry + return errors[res.FirstErrorIdx] + case len(errors): + if !res.ContainsMultiError { + // Error list is flat. Make a copy of it + // Otherwise "errors" escapes to the heap + // unconditionally for all other cases. + // This lets us optimize for the "no errors" case. + out := append(([]error)(nil), errors...) + return &multiError{errors: out} + } + } + + nonNilErrs := make([]error, 0, res.Capacity) + for _, err := range errors[res.FirstErrorIdx:] { + if err == nil { + continue + } + + if nested, ok := err.(*multiError); ok { + nonNilErrs = append(nonNilErrs, nested.errors...) + } else { + nonNilErrs = append(nonNilErrs, err) + } + } + + return &multiError{errors: nonNilErrs} +} + +// Combine combines the passed errors into a single error. +// +// If zero arguments were passed or if all items are nil, a nil error is +// returned. +// +// Combine(nil, nil) // == nil +// +// If only a single error was passed, it is returned as-is. +// +// Combine(err) // == err +// +// Combine skips over nil arguments so this function may be used to combine +// together errors from operations that fail independently of each other. +// +// multierr.Combine( +// reader.Close(), +// writer.Close(), +// pipe.Close(), +// ) +// +// If any of the passed errors is a multierr error, it will be flattened along +// with the other errors. +// +// multierr.Combine(multierr.Combine(err1, err2), err3) +// // is the same as +// multierr.Combine(err1, err2, err3) +// +// The returned error formats into a readable multi-line error message if +// formatted with %+v. +// +// fmt.Sprintf("%+v", multierr.Combine(err1, err2)) +func Combine(errors ...error) error { + return fromSlice(errors) +} + +// Append appends the given errors together. Either value may be nil. +// +// This function is a specialization of Combine for the common case where +// there are only two errors. +// +// err = multierr.Append(reader.Close(), writer.Close()) +// +// The following pattern may also be used to record failure of deferred +// operations without losing information about the original error. +// +// func doSomething(..) (err error) { +// f := acquireResource() +// defer func() { +// err = multierr.Append(err, f.Close()) +// }() +// +// Note that the variable MUST be a named return to append an error to it from +// the defer statement. See also [AppendInvoke]. +func Append(left error, right error) error { + switch { + case left == nil: + return right + case right == nil: + return left + } + + if _, ok := right.(*multiError); !ok { + if l, ok := left.(*multiError); ok && !l.copyNeeded.Swap(true) { + // Common case where the error on the left is constantly being + // appended to. + errs := append(l.errors, right) + return &multiError{errors: errs} + } else if !ok { + // Both errors are single errors. + return &multiError{errors: []error{left, right}} + } + } + + // Either right or both, left and right, are multiErrors. Rely on usual + // expensive logic. + errors := [2]error{left, right} + return fromSlice(errors[0:]) +} + +// AppendInto appends an error into the destination of an error pointer and +// returns whether the error being appended was non-nil. +// +// var err error +// multierr.AppendInto(&err, r.Close()) +// multierr.AppendInto(&err, w.Close()) +// +// The above is equivalent to, +// +// err := multierr.Append(r.Close(), w.Close()) +// +// As AppendInto reports whether the provided error was non-nil, it may be +// used to build a multierr error in a loop more ergonomically. For example: +// +// var err error +// for line := range lines { +// var item Item +// if multierr.AppendInto(&err, parse(line, &item)) { +// continue +// } +// items = append(items, item) +// } +// +// Compare this with a version that relies solely on Append: +// +// var err error +// for line := range lines { +// var item Item +// if parseErr := parse(line, &item); parseErr != nil { +// err = multierr.Append(err, parseErr) +// continue +// } +// items = append(items, item) +// } +func AppendInto(into *error, err error) (errored bool) { + if into == nil { + // We panic if 'into' is nil. This is not documented above + // because suggesting that the pointer must be non-nil may + // confuse users into thinking that the error that it points + // to must be non-nil. + panic("misuse of multierr.AppendInto: into pointer must not be nil") + } + + if err == nil { + return false + } + *into = Append(*into, err) + return true +} + +// Invoker is an operation that may fail with an error. Use it with +// AppendInvoke to append the result of calling the function into an error. +// This allows you to conveniently defer capture of failing operations. +// +// See also, [Close] and [Invoke]. +type Invoker interface { + Invoke() error +} + +// Invoke wraps a function which may fail with an error to match the Invoker +// interface. Use it to supply functions matching this signature to +// AppendInvoke. +// +// For example, +// +// func processReader(r io.Reader) (err error) { +// scanner := bufio.NewScanner(r) +// defer multierr.AppendInvoke(&err, multierr.Invoke(scanner.Err)) +// for scanner.Scan() { +// // ... +// } +// // ... +// } +// +// In this example, the following line will construct the Invoker right away, +// but defer the invocation of scanner.Err() until the function returns. +// +// defer multierr.AppendInvoke(&err, multierr.Invoke(scanner.Err)) +// +// Note that the error you're appending to from the defer statement MUST be a +// named return. +type Invoke func() error + +// Invoke calls the supplied function and returns its result. +func (i Invoke) Invoke() error { return i() } + +// Close builds an Invoker that closes the provided io.Closer. Use it with +// AppendInvoke to close io.Closers and append their results into an error. +// +// For example, +// +// func processFile(path string) (err error) { +// f, err := os.Open(path) +// if err != nil { +// return err +// } +// defer multierr.AppendInvoke(&err, multierr.Close(f)) +// return processReader(f) +// } +// +// In this example, multierr.Close will construct the Invoker right away, but +// defer the invocation of f.Close until the function returns. +// +// defer multierr.AppendInvoke(&err, multierr.Close(f)) +// +// Note that the error you're appending to from the defer statement MUST be a +// named return. +func Close(closer io.Closer) Invoker { + return Invoke(closer.Close) +} + +// AppendInvoke appends the result of calling the given Invoker into the +// provided error pointer. Use it with named returns to safely defer +// invocation of fallible operations until a function returns, and capture the +// resulting errors. +// +// func doSomething(...) (err error) { +// // ... +// f, err := openFile(..) +// if err != nil { +// return err +// } +// +// // multierr will call f.Close() when this function returns and +// // if the operation fails, its append its error into the +// // returned error. +// defer multierr.AppendInvoke(&err, multierr.Close(f)) +// +// scanner := bufio.NewScanner(f) +// // Similarly, this scheduled scanner.Err to be called and +// // inspected when the function returns and append its error +// // into the returned error. +// defer multierr.AppendInvoke(&err, multierr.Invoke(scanner.Err)) +// +// // ... +// } +// +// NOTE: If used with a defer, the error variable MUST be a named return. +// +// Without defer, AppendInvoke behaves exactly like AppendInto. +// +// err := // ... +// multierr.AppendInvoke(&err, mutltierr.Invoke(foo)) +// +// // ...is roughly equivalent to... +// +// err := // ... +// multierr.AppendInto(&err, foo()) +// +// The advantage of the indirection introduced by Invoker is to make it easy +// to defer the invocation of a function. Without this indirection, the +// invoked function will be evaluated at the time of the defer block rather +// than when the function returns. +// +// // BAD: This is likely not what the caller intended. This will evaluate +// // foo() right away and append its result into the error when the +// // function returns. +// defer multierr.AppendInto(&err, foo()) +// +// // GOOD: This will defer invocation of foo unutil the function returns. +// defer multierr.AppendInvoke(&err, multierr.Invoke(foo)) +// +// multierr provides a few Invoker implementations out of the box for +// convenience. See [Invoker] for more information. +func AppendInvoke(into *error, invoker Invoker) { + AppendInto(into, invoker.Invoke()) +} + +// AppendFunc is a shorthand for [AppendInvoke]. +// It allows using function or method value directly +// without having to wrap it into an [Invoker] interface. +// +// func doSomething(...) (err error) { +// w, err := startWorker(...) +// if err != nil { +// return err +// } +// +// // multierr will call w.Stop() when this function returns and +// // if the operation fails, it appends its error into the +// // returned error. +// defer multierr.AppendFunc(&err, w.Stop) +// } +func AppendFunc(into *error, fn func() error) { + AppendInvoke(into, Invoke(fn)) +} diff --git a/vendor/go.uber.org/multierr/error_post_go120.go b/vendor/go.uber.org/multierr/error_post_go120.go new file mode 100644 index 0000000000..a173f9c251 --- /dev/null +++ b/vendor/go.uber.org/multierr/error_post_go120.go @@ -0,0 +1,48 @@ +// Copyright (c) 2017-2023 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +//go:build go1.20 +// +build go1.20 + +package multierr + +// Unwrap returns a list of errors wrapped by this multierr. +func (merr *multiError) Unwrap() []error { + return merr.Errors() +} + +type multipleErrors interface { + Unwrap() []error +} + +func extractErrors(err error) []error { + if err == nil { + return nil + } + + // check if the given err is an Unwrapable error that + // implements multipleErrors interface. + eg, ok := err.(multipleErrors) + if !ok { + return []error{err} + } + + return append(([]error)(nil), eg.Unwrap()...) +} diff --git a/vendor/go.uber.org/multierr/error_pre_go120.go b/vendor/go.uber.org/multierr/error_pre_go120.go new file mode 100644 index 0000000000..93872a3fcd --- /dev/null +++ b/vendor/go.uber.org/multierr/error_pre_go120.go @@ -0,0 +1,79 @@ +// Copyright (c) 2017-2023 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +//go:build !go1.20 +// +build !go1.20 + +package multierr + +import "errors" + +// Versions of Go before 1.20 did not support the Unwrap() []error method. +// This provides a similar behavior by implementing the Is(..) and As(..) +// methods. +// See the errors.Join proposal for details: +// https://github.com/golang/go/issues/53435 + +// As attempts to find the first error in the error list that matches the type +// of the value that target points to. +// +// This function allows errors.As to traverse the values stored on the +// multierr error. +func (merr *multiError) As(target interface{}) bool { + for _, err := range merr.Errors() { + if errors.As(err, target) { + return true + } + } + return false +} + +// Is attempts to match the provided error against errors in the error list. +// +// This function allows errors.Is to traverse the values stored on the +// multierr error. +func (merr *multiError) Is(target error) bool { + for _, err := range merr.Errors() { + if errors.Is(err, target) { + return true + } + } + return false +} + +func extractErrors(err error) []error { + if err == nil { + return nil + } + + // Note that we're casting to multiError, not errorGroup. Our contract is + // that returned errors MAY implement errorGroup. Errors, however, only + // has special behavior for multierr-specific error objects. + // + // This behavior can be expanded in the future but I think it's prudent to + // start with as little as possible in terms of contract and possibility + // of misuse. + eg, ok := err.(*multiError) + if !ok { + return []error{err} + } + + return append(([]error)(nil), eg.Errors()...) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 6680094177..bbc6971bf3 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -421,7 +421,7 @@ github.com/go-errors/errors github.com/go-jose/go-jose/v4 github.com/go-jose/go-jose/v4/cipher github.com/go-jose/go-jose/v4/json -# github.com/go-logr/logr v1.4.3 +# github.com/go-logr/logr v1.4.4 ## explicit; go 1.18 github.com/go-logr/logr github.com/go-logr/logr/funcr @@ -605,6 +605,7 @@ github.com/jackc/pgx/v5/pgconn/internal/bgreader github.com/jackc/pgx/v5/pgproto3 github.com/jackc/pgx/v5/pgtype github.com/jackc/pgx/v5/pgxpool +github.com/jackc/pgx/v5/stdlib # github.com/jackc/puddle/v2 v2.2.2 ## explicit; go 1.19 github.com/jackc/puddle/v2 @@ -612,7 +613,7 @@ github.com/jackc/puddle/v2/internal/genstack # github.com/json-iterator/go v1.1.12 ## explicit; go 1.12 github.com/json-iterator/go -# github.com/klauspost/compress v1.19.0 +# github.com/klauspost/compress v1.19.1 ## explicit; go 1.24 github.com/klauspost/compress github.com/klauspost/compress/flate @@ -633,8 +634,8 @@ github.com/magiconair/properties # github.com/mattn/go-colorable v0.1.14 ## explicit; go 1.18 github.com/mattn/go-colorable -# github.com/mattn/go-isatty v0.0.20 -## explicit; go 1.15 +# github.com/mattn/go-isatty v0.0.23 +## explicit; go 1.20 github.com/mattn/go-isatty # github.com/mattn/go-runewidth v0.0.19 ## explicit; go 1.20 @@ -647,6 +648,9 @@ github.com/mdlayher/netlink/nltest # github.com/mdlayher/socket v0.5.0 ## explicit; go 1.21 github.com/mdlayher/socket +# github.com/mfridman/interpolate v0.0.2 +## explicit; go 1.19 +github.com/mfridman/interpolate # github.com/moby/docker-image-spec v1.3.1 ## explicit; go 1.18 github.com/moby/docker-image-spec/specs-go/v1 @@ -775,6 +779,19 @@ github.com/pmezard/go-difflib/difflib # github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 ## explicit; go 1.14 github.com/power-devops/perfstat +# github.com/pressly/goose/v3 v3.27.3 +## explicit; go 1.25.7 +github.com/pressly/goose/v3 +github.com/pressly/goose/v3/database +github.com/pressly/goose/v3/database/dialect +github.com/pressly/goose/v3/internal/controller +github.com/pressly/goose/v3/internal/dialects +github.com/pressly/goose/v3/internal/gooseutil +github.com/pressly/goose/v3/internal/legacystore +github.com/pressly/goose/v3/internal/sqlparser +github.com/pressly/goose/v3/lock +github.com/pressly/goose/v3/lock/internal/store +github.com/pressly/goose/v3/lock/internal/table # github.com/prometheus/client_golang v1.24.0 ## explicit; go 1.25.0 github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil @@ -803,6 +820,9 @@ github.com/prometheus/procfs/internal/util # github.com/rivo/uniseg v0.2.0 ## explicit; go 1.12 github.com/rivo/uniseg +# github.com/sethvargo/go-retry v0.4.0 +## explicit; go 1.25 +github.com/sethvargo/go-retry # github.com/shirou/gopsutil v3.21.11+incompatible ## explicit github.com/shirou/gopsutil/cpu @@ -994,6 +1014,9 @@ go.opentelemetry.io/proto/otlp/common/v1 go.opentelemetry.io/proto/otlp/metrics/v1 go.opentelemetry.io/proto/otlp/resource/v1 go.opentelemetry.io/proto/otlp/trace/v1 +# go.uber.org/multierr v1.11.0 +## explicit; go 1.19 +go.uber.org/multierr # go.yaml.in/yaml/v2 v2.4.4 ## explicit; go 1.15 go.yaml.in/yaml/v2