├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── build_and_test.yaml │ └── release.yaml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── admin.go ├── admin_test.go ├── cmd └── spool │ ├── main.go │ └── main_test.go ├── config.go ├── docker-compose.yml ├── error.go ├── filter.go ├── filter_test.go ├── go.mod ├── go.sum ├── internal └── db │ ├── embed.go │ └── schema.sql ├── main_test.go ├── model ├── spooldatabase.yo.go ├── spooldatabase_extend.go └── yo_db.yo.go ├── pool.go ├── pool_test.go └── testdata ├── schema1.sql └── schema2.sql /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # See the CODEOWNERS file documentation: 2 | # https://blog.github.com/2017-07-06-introducing-code-owners 3 | 4 | * @zoncoen @vvakame @sinmetal 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/build_and_test.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | name: build and test 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | services: 11 | emulator: 12 | image: gcr.io/cloud-spanner-emulator/emulator:1.5.29 13 | ports: 14 | - 9010:9010 15 | - 9020:9020 16 | steps: 17 | - uses: actions/checkout@v4.2.2 18 | - uses: actions/setup-go@v5.3.0 19 | with: 20 | go-version-file: ./go.mod 21 | - run: go version 22 | - run: make lint 23 | - run: make setup-emulator 24 | - run: | 25 | mkdir -p .bin 26 | go build -o .bin/spool ./cmd/spool 27 | - run: make test 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | goreleaser: 10 | name: Release pre-build binary by goreleaser 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | packages: write 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4.2.2 18 | with: 19 | fetch-depth: 0 20 | - name: Set up Go 21 | uses: actions/setup-go@v5.3.0 22 | with: 23 | go-version-file: ./go.mod 24 | - name: Run GoReleaser 25 | uses: goreleaser/goreleaser-action@v6.2.1 26 | with: 27 | args: release --clean 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | .idea/ 3 | .bin 4 | dist/ 5 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | concurrency: 4 3 | deadline: 1m 4 | issues-exit-code: 1 5 | tests: true 6 | issues: 7 | exclude-dirs: 8 | - model 9 | exclude-files: 10 | - assets.go 11 | linters: 12 | enable: 13 | - bodyclose 14 | - errcheck 15 | - gosec 16 | - govet 17 | - nilerr 18 | - noctx 19 | - nolintlint 20 | - revive 21 | - staticcheck 22 | - unused 23 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 2 10 | 11 | before: 12 | hooks: 13 | - go mod tidy 14 | 15 | builds: 16 | - main: ./cmd/spool/ 17 | env: 18 | - CGO_ENABLED=0 19 | goos: 20 | - linux 21 | - windows 22 | - darwin 23 | ldflags: 24 | - -s -w -X github.com/cloudspannerecosystem/spool/cmd/spool.versionStr={{.Version}} 25 | ignore: 26 | - goos: darwin 27 | goarch: "386" 28 | - goos: windows 29 | goarch: arm64 30 | 31 | archives: 32 | - formats: [tar.gz] 33 | # this name template makes the OS and Arch compatible with the results of `uname`. 34 | name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 35 | # use zip for windows archives 36 | format_overrides: 37 | - goos: windows 38 | formats: [zip] 39 | 40 | checksum: 41 | name_template: 'checksums.txt' 42 | 43 | changelog: 44 | sort: asc 45 | filters: 46 | exclude: 47 | - "^docs:" 48 | - "^test:" 49 | 50 | release: 51 | footer: >- 52 | 53 | --- 54 | 55 | Released by [GoReleaser](https://github.com/goreleaser/goreleaser). 56 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Google Open Source Community Guidelines 2 | 3 | At Google, we recognize and celebrate the creativity and collaboration of open 4 | source contributors and the diversity of skills, experiences, cultures, and 5 | opinions they bring to the projects and communities they participate in. 6 | 7 | Every one of Google's open source projects and communities are inclusive 8 | environments, based on treating all individuals respectfully, regardless of 9 | gender identity and expression, sexual orientation, disabilities, 10 | neurodiversity, physical appearance, body size, ethnicity, nationality, race, 11 | age, religion, or similar personal characteristic. 12 | 13 | We value diverse opinions, but we value respectful behavior more. 14 | 15 | Respectful behavior includes: 16 | 17 | * Being considerate, kind, constructive, and helpful. 18 | * Not engaging in demeaning, discriminatory, harassing, hateful, sexualized, or 19 | physically threatening behavior, speech, and imagery. 20 | * Not engaging in unwanted physical contact. 21 | 22 | Some Google open source projects [may adopt][] an explicit project code of 23 | conduct, which may have additional detailed expectations for participants. Most 24 | of those projects will use our [modified Contributor Covenant][]. 25 | 26 | [may adopt]: https://opensource.google/docs/releasing/preparing/#conduct 27 | [modified Contributor Covenant]: https://opensource.google/docs/releasing/template/CODE_OF_CONDUCT/ 28 | 29 | ## Resolve peacefully 30 | 31 | We do not believe that all conflict is necessarily bad; healthy debate and 32 | disagreement often yields positive results. However, it is never okay to be 33 | disrespectful. 34 | 35 | If you see someone behaving disrespectfully, you are encouraged to address the 36 | behavior directly with those involved. Many issues can be resolved quickly and 37 | easily, and this gives people more control over the outcome of their dispute. 38 | If you are unable to resolve the matter for any reason, or if the behavior is 39 | threatening or harassing, report it. We are dedicated to providing an 40 | environment where participants feel welcome and safe. 41 | 42 | ## Reporting problems 43 | 44 | Some Google open source projects may adopt a project-specific code of conduct. 45 | In those cases, a Google employee will be identified as the Project Steward, 46 | who will receive and handle reports of code of conduct violations. In the event 47 | that a project hasn’t identified a Project Steward, you can report problems by 48 | emailing opensource@google.com. 49 | 50 | We will investigate every complaint, but you may not receive a direct response. 51 | We will use our discretion in determining when and how to follow up on reported 52 | incidents, which may range from not taking action to permanent expulsion from 53 | the project and project-sponsored spaces. We will notify the accused of the 54 | report and provide them an opportunity to discuss it before any action is 55 | taken. The identity of the reporter will be omitted from the details of the 56 | report supplied to the accused. In potentially harmful situations, such as 57 | ongoing harassment or threats to anyone's safety, we may take action without 58 | notice. 59 | 60 | *This document was adapted from the [IndieWeb Code of Conduct][] and can also 61 | be found at .* 62 | 63 | [IndieWeb Code of Conduct]: https://indieweb.org/code-of-conduct 64 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution; 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | This project follows [Google's Open Source Community 28 | Guidelines](https://opensource.google/conduct/). 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Kenta Mori 4 | Copyright (c) 2020 Google LLC 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export SPANNER_PROJECT_ID ?= spool-test-project 2 | export SPANNER_INSTANCE_ID ?= spool-test-instance 3 | export SPOOL_SPANNER_DATABASE_ID ?= spool-test-database 4 | 5 | export SPANNER_EMULATOR_HOST ?= localhost:9010 6 | export SPANNER_EMULATOR_HOST_REST ?= localhost:9020 7 | 8 | YO_BIN := go tool yo 9 | LINT_BIN := go tool golangci-lint 10 | WRENCH_BIN := go tool wrench 11 | 12 | BIN_DIR := .bin 13 | 14 | .PHONY: clean 15 | clean: 16 | rm -rf ${BIN_DIR} 17 | 18 | .PHONY: gen 19 | gen: gen_model 20 | 21 | .PHONY: gen_model 22 | gen_model: 23 | rm -f ./model/*.yo.go 24 | ${YO_BIN} $(SPANNER_PROJECT_ID) $(SPANNER_INSTANCE_ID) $(SPOOL_SPANNER_DATABASE_ID) --out ./model/ 25 | 26 | .PHONY: lint 27 | lint: 28 | ${LINT_BIN} run 29 | 30 | .PHONY: test 31 | test: 32 | go test -v -race -p=1 `go list ./...` 33 | 34 | .PHONY: setup-emulator 35 | setup-emulator: 36 | curl -s "${SPANNER_EMULATOR_HOST_REST}/v1/projects/${SPANNER_PROJECT_ID}/instances" --data '{"instanceId": "'${SPANNER_INSTANCE_ID}'"}' 37 | 38 | .PHONY: create_db 39 | create_db: 40 | ${WRENCH_BIN} create --project $(SPANNER_PROJECT_ID) --instance $(SPANNER_INSTANCE_ID) --database $(SPOOL_SPANNER_DATABASE_ID) --directory db/ 41 | 42 | .PHONY: drop_db 43 | drop_db: 44 | ${WRENCH_BIN} drop --project $(SPANNER_PROJECT_ID) --instance $(SPANNER_INSTANCE_ID) --database $(SPOOL_SPANNER_DATABASE_ID) 45 | 46 | .PHONY: reset_db 47 | reset_db: 48 | make drop_db 49 | make create_db 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spool 2 | 3 | [![build and test](https://github.com/cloudspannerecosystem/spool/actions/workflows/build_and_test.yaml/badge.svg)](https://github.com/cloudspannerecosystem/spool/actions/workflows/build_and_test.yaml) 4 | 5 | A CLI tool to manage [Cloud Spanner](https://cloud.google.com/spanner) databases for testing. 6 | 7 | ![spool](https://user-images.githubusercontent.com/2238852/68204102-a0764580-000a-11ea-879b-1acaf1c699c8.gif) 8 | 9 | Please feel free to report issues and send pull requests, but note that this 10 | application is not officially supported as part of the Cloud Spanner product. 11 | 12 | ## Motivation 13 | 14 | When the development of spool started, the [Cloud Spanner 15 | Emulator](https://cloud.google.com/spanner/docs/emulator) wasn't available yet. 16 | When using Cloud Spanner instances for continuous integration tests, it is 17 | inefficient to create a new test database on every run. 18 | This tool lets you reuse test databases in CI tests. 19 | 20 | ## Installation 21 | 22 | ```shell 23 | $ go get -u github.com/cloudspannerecosystem/spool/cmd/spool 24 | ``` 25 | 26 | ## Setup 27 | 28 | Spool requires a database for metadata to manage databases. 29 | The following command sets up the database. 30 | 31 | ```shell 32 | $ spool --project=${PROJECT} --instance=${INSTANCE} --database=${SPOOL_DATABASE} setup 33 | ``` 34 | 35 | ## Usage 36 | 37 | ```shell 38 | usage: spool [] [ ...] 39 | 40 | A CLI tool to manage Cloud Spanner databases for testing. 41 | 42 | Flags: 43 | --help Show context-sensitive help (also try --help-long and --help-man). 44 | -p, --project=PROJECT Set GCP project ID. (use $SPANNER_PROJECT_ID or $GOOGLE_CLOUD_PROJECT as default value) 45 | -i, --instance=INSTANCE Set Cloud Spanner instance name. (use $SPANNER_INSTANCE_ID as default value) 46 | -d, --database=DATABASE Set Cloud Spanner database name. (use $SPOOL_SPANNER_DATABASE_ID as default value) 47 | -s, --schema=SCHEMA Set schema file path. 48 | 49 | Commands: 50 | help [...] 51 | Show help. 52 | 53 | setup 54 | Setup the database for spool metadata. 55 | 56 | create --db-name-prefix=DB-NAME-PREFIX [] 57 | Add new databases to the pool. 58 | 59 | get 60 | Get a idle database from the pool. 61 | 62 | get-or-create --db-name-prefix=DB-NAME-PREFIX 63 | Get or create a idle database from the pool. 64 | 65 | list [] 66 | Print databases. 67 | 68 | put 69 | Return the database to the pool. 70 | 71 | clean [] 72 | Drop all idle databases. 73 | ``` 74 | 75 | ## Sample CircleCI configuration 76 | 77 | ```yaml 78 | version: 2 79 | 80 | jobs: 81 | build: 82 | docker: 83 | - image: golang:1.13-stretch 84 | environment: 85 | PROJECT: project 86 | INSTANCE: instance 87 | SPOOL_DATABASE: spool 88 | PATH_TO_SCHEMA_FILE: path/to/schema.sql 89 | DATABASE_PREFIX: spool 90 | working_directory: /go/src/github.com/user/repo 91 | steps: 92 | - checkout 93 | - run: 94 | name: set GitHub token 95 | command: | 96 | rm -f ~/.gitconfig 97 | echo "machine github.com login ${GITHUB_TOKEN}" > ~/.netrc 98 | - run: 99 | name: install spool 100 | command: go get -u github.com/cloudspannerecosystem/spool/cmd/spool 101 | - run: 102 | name: get database for testing 103 | command: | 104 | DATABASE=$(spool --project=${PROJECT} --instance=${INSTANCE} --database=${SPOOL_DATABASE} --schema=${PATH_TO_SCHEMA_FILE} get-or-create --db-name-prefix=${DATABASE_PREFIX}) 105 | echo "export DATABASE=${DATABASE}" >> ${BASH_ENV} 106 | - run: 107 | name: run tests 108 | command: echo "run your tests with /projects/${PROJECT}/instances/${INSTANCE}/databases/${DATABASE}" 109 | - run: 110 | name: release database 111 | when: always 112 | command: spool --project=${PROJECT} --instance=${INSTANCE} --database=${SPOOL_DATABASE} --schema=${PATH_TO_SCHEMA_FILE} put ${DATABASE} 113 | 114 | cleanup-old-test-db: 115 | docker: 116 | - image: golang:1.13-stretch 117 | environment: 118 | PROJECT: project 119 | INSTANCE: instance 120 | SPOOL_DATABASE: spool 121 | PATH_TO_SCHEMA_FILE: path/to/schema.sql 122 | working_directory: /go/src/github.com/user/repo 123 | steps: 124 | - checkout 125 | - run: 126 | name: set GitHub token 127 | command: | 128 | rm -f ~/.gitconfig 129 | echo "machine github.com login ${GITHUB_TOKEN}" > ~/.netrc 130 | - run: 131 | name: install spool 132 | command: go get -u github.com/cloudspannerecosystem/spool/cmd/spool 133 | - run: 134 | name: cleanup databases 135 | command: spool --project=${PROJECT} --instance=${INSTANCE} --database=${SPOOL_DATABASE} --schema=${PATH_TO_SCHEMA_FILE} clean --all --force --ignore-used-within-days=7 136 | 137 | workflows: 138 | version: 2 139 | build-workflow: 140 | jobs: 141 | - build: 142 | context: org-global 143 | cleanup-workflow: 144 | triggers: 145 | - schedule: 146 | cron: '0 9 * * *' 147 | filters: 148 | branches: 149 | only: master 150 | jobs: 151 | - cleanup-old-test-db: 152 | context: org-global 153 | ``` 154 | 155 | ## How to development 156 | 157 | Setup environment (do it once) 158 | 159 | ```shell 160 | $ export CLOUDSDK_ACTIVE_CONFIG_NAME=spool-test-config 161 | $ gcloud config configurations create --no-activate $CLOUDSDK_ACTIVE_CONFIG_NAME 162 | $ gcloud config set auth/disable_credentials true 163 | $ gcloud config set project spool-test 164 | $ gcloud config set api_endpoint_overrides/spanner http://localhost:9020/ 165 | ``` 166 | 167 | Setup environment (do this before execute testing) 168 | 169 | ```shell 170 | $ docker compose up -d --build --force-recreate 171 | $ export CLOUDSDK_ACTIVE_CONFIG_NAME=spool-test-config 172 | $ make setup-emulator 173 | $ make test 174 | $ docker compose down 175 | ``` 176 | -------------------------------------------------------------------------------- /admin.go: -------------------------------------------------------------------------------- 1 | package spool 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "cloud.google.com/go/spanner" 8 | admin "cloud.google.com/go/spanner/admin/database/apiv1" 9 | "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" 10 | "github.com/cloudspannerecosystem/spool/internal/db" 11 | "github.com/cloudspannerecosystem/spool/model" 12 | "google.golang.org/grpc/codes" 13 | "google.golang.org/grpc/status" 14 | ) 15 | 16 | // Setup creates a new spool metadata database. 17 | func Setup(ctx context.Context, conf *Config) error { 18 | adminClient, err := admin.NewDatabaseAdminClient(ctx, conf.ClientOptions()...) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | _, err = adminClient.GetDatabase(ctx, &databasepb.GetDatabaseRequest{ 24 | Name: conf.Database(), 25 | }) 26 | if st, ok := status.FromError(err); ok && st.Code() == codes.NotFound { 27 | // Database does not exist. Create a new one. 28 | op, err := adminClient.CreateDatabase(ctx, &databasepb.CreateDatabaseRequest{ 29 | Parent: conf.Instance(), 30 | CreateStatement: fmt.Sprintf("CREATE DATABASE `%s`", conf.DatabaseID()), 31 | ExtraStatements: ddlToStatements(db.SpoolSchema), 32 | }) 33 | if err != nil { 34 | return err 35 | } 36 | if _, err := op.Wait(ctx); err != nil { 37 | return err 38 | } 39 | } else if err != nil { 40 | return err 41 | } else { 42 | // Database already exists. Try to update schema. 43 | // Considerations when the database is created using terraform, etc. 44 | op, err := adminClient.UpdateDatabaseDdl(ctx, &databasepb.UpdateDatabaseDdlRequest{ 45 | Database: conf.Database(), 46 | Statements: ddlToStatements(db.SpoolSchema), 47 | }) 48 | if err != nil { 49 | return err 50 | } 51 | if err := op.Wait(ctx); err != nil { 52 | return err 53 | } 54 | } 55 | 56 | return nil 57 | } 58 | 59 | // ListAll gets all databases from the pool. 60 | func ListAll(ctx context.Context, conf *Config) ([]*model.SpoolDatabase, error) { 61 | client, err := spanner.NewClient(ctx, conf.Database(), conf.ClientOptions()...) 62 | if err != nil { 63 | return nil, err 64 | } 65 | return model.FindAllSpoolDatabases(ctx, client.ReadOnlyTransaction()) 66 | } 67 | 68 | // CleanAll removes all idle databases. 69 | func CleanAll(ctx context.Context, conf *Config, filters ...func(sdb *model.SpoolDatabase) bool) error { 70 | client, err := spanner.NewClient(ctx, conf.Database(), conf.ClientOptions()...) 71 | if err != nil { 72 | return err 73 | } 74 | return clean(ctx, client, conf, func(ctx context.Context, txn *spanner.ReadWriteTransaction) ([]*model.SpoolDatabase, error) { 75 | sdbs, err := model.FindAllSpoolDatabases(ctx, txn) 76 | if err != nil { 77 | return nil, err 78 | } 79 | return filter(sdbs, filters...), nil 80 | }) 81 | } 82 | 83 | func clean(ctx context.Context, client *spanner.Client, conf *Config, find func(ctx context.Context, txn *spanner.ReadWriteTransaction) ([]*model.SpoolDatabase, error)) error { 84 | var dropErr error 85 | if _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error { 86 | sdbs, err := find(ctx, txn) 87 | if err != nil { 88 | return err 89 | } 90 | ms := []*spanner.Mutation{} 91 | for _, sdb := range sdbs { 92 | dropErr = dropDatabase(ctx, conf.WithDatabaseID(sdb.DatabaseName)) 93 | if dropErr != nil { 94 | break 95 | } 96 | ms = append(ms, sdb.Delete(ctx)) 97 | } 98 | if len(ms) > 0 { 99 | if err := txn.BufferWrite(ms); err != nil { 100 | return err 101 | } 102 | } 103 | return nil 104 | }); err != nil { 105 | return err 106 | } 107 | if dropErr != nil { 108 | return dropErr 109 | } 110 | return nil 111 | } 112 | 113 | func dropDatabase(ctx context.Context, conf *Config) error { 114 | adminClient, err := admin.NewDatabaseAdminClient(ctx, conf.ClientOptions()...) 115 | if err != nil { 116 | return err 117 | } 118 | return adminClient.DropDatabase(ctx, &databasepb.DropDatabaseRequest{ 119 | Database: conf.Database(), 120 | }) 121 | } 122 | -------------------------------------------------------------------------------- /admin_test.go: -------------------------------------------------------------------------------- 1 | package spool 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "cloud.google.com/go/spanner" 9 | admin "cloud.google.com/go/spanner/admin/database/apiv1" 10 | "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" 11 | "github.com/cloudspannerecosystem/spool/model" 12 | ) 13 | 14 | func connect(ctx context.Context, t *testing.T, conf *Config) (*spanner.Client, func()) { 15 | t.Helper() 16 | client, err := spanner.NewClient(ctx, conf.Database(), conf.ClientOptions()...) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | t.Cleanup(client.Close) 21 | return client, func() { 22 | if err := clean(ctx, client, conf, 23 | func(ctx context.Context, txn *spanner.ReadWriteTransaction) ([]*model.SpoolDatabase, error) { 24 | return model.FindAllSpoolDatabases(ctx, txn) 25 | }, 26 | ); err != nil { 27 | t.Fatal(err) 28 | } 29 | } 30 | } 31 | 32 | func TestSetup(t *testing.T) { 33 | t.Parallel() 34 | 35 | cfg := ConfigTestDatabase(t) 36 | 37 | ctx := context.Background() 38 | 39 | adminClient, err := admin.NewDatabaseAdminClient(ctx, cfg.ClientOptions()...) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | op, err := adminClient.CreateDatabase(ctx, &databasepb.CreateDatabaseRequest{ 45 | Parent: cfg.Instance(), 46 | CreateStatement: fmt.Sprintf("CREATE DATABASE `%s`", cfg.DatabaseID()), 47 | }) 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | if _, err := op.Wait(ctx); err != nil { 52 | t.Fatal(err) 53 | } 54 | 55 | if err := Setup(ctx, cfg); err != nil { 56 | t.Fatal(err) 57 | } 58 | } 59 | 60 | func TestListAll(t *testing.T) { 61 | t.Parallel() 62 | 63 | cfg := SetupTestDatabase(t) 64 | 65 | ctx := context.Background() 66 | client, truncate := connect(ctx, t, cfg) 67 | t.Cleanup(truncate) 68 | sdb1 := &model.SpoolDatabase{ 69 | DatabaseName: "zoncoen-spool-test-1", 70 | Checksum: "checksum-1", 71 | State: StateIdle.Int64(), 72 | CreatedAt: spanner.CommitTimestamp, 73 | UpdatedAt: spanner.CommitTimestamp, 74 | } 75 | sdb2 := &model.SpoolDatabase{ 76 | DatabaseName: "zoncoen-spool-test-2", 77 | Checksum: "checksum-2", 78 | State: StateIdle.Int64(), 79 | CreatedAt: spanner.CommitTimestamp, 80 | UpdatedAt: spanner.CommitTimestamp, 81 | } 82 | if _, err := client.Apply(ctx, []*spanner.Mutation{sdb1.Insert(ctx), sdb2.Insert(ctx)}); err != nil { 83 | t.Fatalf("failed to setup fixture: %s", err) 84 | } 85 | 86 | sdbs, err := ListAll(ctx, cfg) 87 | if err != nil { 88 | t.Fatal(err) 89 | } 90 | if len(sdbs) != 2 { 91 | t.Errorf("failed to find all: found %d", len(sdbs)) 92 | } 93 | } 94 | 95 | func TestCleanAll(t *testing.T) { 96 | t.Parallel() 97 | 98 | cfg := SetupTestDatabase(t) 99 | 100 | ctx := context.Background() 101 | client, truncate := connect(ctx, t, cfg) 102 | t.Cleanup(truncate) 103 | sdb1 := &model.SpoolDatabase{ 104 | DatabaseName: "zoncoen-spool-test-1", 105 | Checksum: "checksum-1", 106 | State: StateIdle.Int64(), 107 | CreatedAt: spanner.CommitTimestamp, 108 | UpdatedAt: spanner.CommitTimestamp, 109 | } 110 | sdb2 := &model.SpoolDatabase{ 111 | DatabaseName: "zoncoen-spool-test-2", 112 | Checksum: "checksum-2", 113 | State: StateIdle.Int64(), 114 | CreatedAt: spanner.CommitTimestamp, 115 | UpdatedAt: spanner.CommitTimestamp, 116 | } 117 | if _, err := client.Apply(ctx, []*spanner.Mutation{sdb1.Insert(ctx), sdb2.Insert(ctx)}); err != nil { 118 | t.Fatalf("failed to setup fixture: %s", err) 119 | } 120 | 121 | if err := CleanAll(ctx, cfg); err != nil { 122 | t.Fatal(err) 123 | } 124 | sdbs, err := model.FindAllSpoolDatabases(ctx, client.Single()) 125 | if err != nil { 126 | t.Error(err) 127 | } 128 | if len(sdbs) != 0 { 129 | t.Error("failed to clean all") 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /cmd/spool/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "runtime/debug" 10 | "text/tabwriter" 11 | "time" 12 | 13 | "github.com/alecthomas/kingpin" 14 | "github.com/cloudspannerecosystem/spool" 15 | "github.com/cloudspannerecosystem/spool/model" 16 | ) 17 | 18 | const ( 19 | envProjectID = "SPANNER_PROJECT_ID" 20 | envGoogleCloudProjectID = "GOOGLE_CLOUD_PROJECT" 21 | envInstanceID = "SPANNER_INSTANCE_ID" 22 | envDatabaseID = "SPOOL_SPANNER_DATABASE_ID" 23 | ) 24 | 25 | var ( 26 | versionStr string 27 | ) 28 | 29 | var ( 30 | app = kingpin.New("spool", "A CLI tool to manage Cloud Spanner databases for testing.").Version(versionInfo()) 31 | projectID = app.Flag("project", "Set GCP project ID. (use $SPANNER_PROJECT_ID or $GOOGLE_CLOUD_PROJECT as default value)").Short('p').String() 32 | instanceID = app.Flag("instance", "Set Cloud Spanner instance name. (use $SPANNER_INSTANCE_ID as default value)").Short('i').String() 33 | databaseID = app.Flag("database", "Set Cloud Spanner database name. (use $SPOOL_SPANNER_DATABASE_ID as default value)").Short('d').String() 34 | schemaFile = app.Flag("schema", "Set schema file path.").Short('s').File() 35 | 36 | setup = app.Command("setup", "Setup the database for spool metadata.") 37 | 38 | create = app.Command("create", "Add new databases to the pool.") 39 | createDatabaseNamePrefix = create.Flag("db-name-prefix", "Set new database name prefix.").Required().String() 40 | createDatabaseNum = create.Flag("num", "Set the number of new databases.").Default("1").Int() 41 | 42 | get = app.Command("get", "Get a idle database from the pool.") 43 | 44 | getOrCreate = app.Command("get-or-create", "Get or create a idle database from the pool.") 45 | getOrCreateDatabaseNamePrefix = getOrCreate.Flag("db-name-prefix", "Set new database name prefix.").Required().String() 46 | 47 | list = app.Command("list", "Print databases.") 48 | listAll = list.Flag("all", "Print databases. (without checksum filtering)").Default("false").Bool() 49 | 50 | put = app.Command("put", "Return the database to the pool.") 51 | putDatabaseName = put.Arg("database", "database name").Required().String() 52 | 53 | clean = app.Command("clean", "Drop all idle databases.") 54 | cleanAll = clean.Flag("all", "Drop all idle databases. (without checksum filtering)").Default("false").Bool() 55 | cleanIgnoreUsedWithinDays = clean.Flag("ignore-used-within-days", "Ignore databases which used within n days.").Int64() 56 | cleanForce = clean.Flag("force", "Drop all databases. (include busy databases)").Default("false").Bool() 57 | ) 58 | 59 | func main() { 60 | ctx := context.Background() 61 | cmd := kingpin.MustParse(app.Parse(os.Args[1:])) 62 | if err := loadEnvVarsIfNeeded(); err != nil { 63 | kingpin.Fatalf("%s, try --help", err) 64 | } 65 | 66 | config := spool.NewConfig(*projectID, *instanceID, *databaseID) 67 | 68 | switch cmd { 69 | case setup.FullCommand(): 70 | kingpin.FatalIfError(spool.Setup(ctx, config), "failed to setup") 71 | case create.FullCommand(): 72 | pool := newPool(ctx, config) 73 | for range *createDatabaseNum { 74 | _, err := pool.Create(ctx, *createDatabaseNamePrefix) 75 | kingpin.FatalIfError(err, "failed to create database") 76 | } 77 | case get.FullCommand(): 78 | pool := newPool(ctx, config) 79 | sdb, err := pool.Get(ctx) 80 | kingpin.FatalIfError(err, "failed to get database") 81 | fmt.Print(sdb.DatabaseName) 82 | case getOrCreate.FullCommand(): 83 | pool := newPool(ctx, config) 84 | sdb, err := pool.GetOrCreate(ctx, *getOrCreateDatabaseNamePrefix) 85 | kingpin.FatalIfError(err, "failed to get or create database") 86 | fmt.Print(sdb.DatabaseName) 87 | case list.FullCommand(): 88 | var sdbs []*model.SpoolDatabase 89 | var err error 90 | if *listAll { 91 | sdbs, err = spool.ListAll(ctx, config) 92 | } else { 93 | pool := newPool(ctx, config) 94 | sdbs, err = pool.List(ctx) 95 | } 96 | kingpin.FatalIfError(err, "failed to get databases") 97 | w := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', 0) 98 | for _, sdb := range sdbs { 99 | _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", sdb.DatabaseName, sdb.Checksum, spool.State(sdb.State), sdb.CreatedAt.In(time.Local), sdb.UpdatedAt.In(time.Local)) 100 | kingpin.FatalIfError(err, "failed to print databases") 101 | } 102 | if err := w.Flush(); err != nil { 103 | kingpin.FatalIfError(err, "failed to print databases") 104 | } 105 | case put.FullCommand(): 106 | pool := newPool(ctx, config) 107 | err := pool.Put(ctx, *putDatabaseName) 108 | kingpin.FatalIfError(err, "failed to put database") 109 | case clean.FullCommand(): 110 | filters := []func(*model.SpoolDatabase) bool{} 111 | if cleanIgnoreUsedWithinDays != nil { 112 | filters = append(filters, spool.FilterNotUsedWithin(time.Duration(*cleanIgnoreUsedWithinDays)*24*time.Hour)) 113 | } 114 | if !*cleanForce { 115 | filters = append(filters, spool.FilterState(spool.StateIdle)) 116 | } 117 | var err error 118 | if *cleanAll { 119 | err = spool.CleanAll(ctx, config, filters...) 120 | } else { 121 | pool := newPool(ctx, config) 122 | err = pool.Clean(ctx, filters...) 123 | } 124 | kingpin.FatalIfError(err, "failed to clean database") 125 | } 126 | } 127 | 128 | func loadEnvVarsIfNeeded() error { 129 | if projectID == nil || *projectID == "" { 130 | if v, ok := os.LookupEnv(envProjectID); ok { 131 | projectID = &v 132 | } else if v, ok := os.LookupEnv(envGoogleCloudProjectID); ok { 133 | projectID = &v 134 | } else { 135 | return errors.New("required flag --project not provided") 136 | } 137 | } 138 | if instanceID == nil || *instanceID == "" { 139 | if v, ok := os.LookupEnv(envInstanceID); ok { 140 | instanceID = &v 141 | } else { 142 | return errors.New("required flag --instance not provided") 143 | } 144 | } 145 | if databaseID == nil || *databaseID == "" { 146 | if v, ok := os.LookupEnv(envDatabaseID); ok { 147 | databaseID = &v 148 | } else { 149 | return errors.New("required flag --database not provided") 150 | } 151 | } 152 | return nil 153 | } 154 | 155 | func newPool(ctx context.Context, config *spool.Config) *spool.Pool { 156 | if *schemaFile == nil { 157 | kingpin.Fatalf("required flag --schema not provided, try --help") 158 | } 159 | ddl, err := io.ReadAll(*schemaFile) 160 | kingpin.FatalIfError(err, "failed to read schema file") 161 | pool, err := spool.NewPool(ctx, config, ddl) 162 | kingpin.FatalIfError(err, "") 163 | return pool 164 | } 165 | 166 | func versionInfo() string { 167 | if versionStr != "" { 168 | return versionStr 169 | } 170 | 171 | // For those who "go install" yo 172 | info, ok := debug.ReadBuildInfo() 173 | if !ok { 174 | return "unknown" 175 | } 176 | return info.Main.Version 177 | } 178 | -------------------------------------------------------------------------------- /cmd/spool/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestLoadEnvVarsIfNeeded(t *testing.T) { 9 | tests := map[string]struct { 10 | setup func() 11 | envVars map[string]string 12 | assert func(t *testing.T) 13 | fail bool 14 | }{ 15 | "from env vars": { 16 | setup: func() { 17 | projectID = ptr("") 18 | instanceID = ptr("") 19 | databaseID = ptr("") 20 | }, 21 | envVars: map[string]string{ 22 | envGoogleCloudProjectID: "projectID-from-google-cloud-env", 23 | envInstanceID: "instanceID-from-env", 24 | envDatabaseID: "databaseID-from-env", 25 | }, 26 | assert: func(t *testing.T) { 27 | if expected, got := "projectID-from-google-cloud-env", *projectID; expected != got { 28 | t.Errorf("expected projectID %s but got %s", expected, got) 29 | } 30 | if expected, got := "instanceID-from-env", *instanceID; expected != got { 31 | t.Errorf("expected instanceID %s but got %s", expected, got) 32 | } 33 | if expected, got := "databaseID-from-env", *databaseID; expected != got { 34 | t.Errorf("expected databaseID %s but got %s", expected, got) 35 | } 36 | }, 37 | }, 38 | "from env vars (SPANNER_PROJECT_ID overrides GOOGLE_CLOUD_PROJECT)": { 39 | setup: func() { 40 | projectID = ptr("") 41 | instanceID = ptr("") 42 | databaseID = ptr("") 43 | }, 44 | envVars: map[string]string{ 45 | envProjectID: "projectID-from-env", 46 | envGoogleCloudProjectID: "projectID-from-google-cloud-env", 47 | envInstanceID: "instanceID-from-env", 48 | envDatabaseID: "databaseID-from-env", 49 | }, 50 | assert: func(t *testing.T) { 51 | if expected, got := "projectID-from-env", *projectID; expected != got { 52 | t.Errorf("expected projectID %s but got %s", expected, got) 53 | } 54 | if expected, got := "instanceID-from-env", *instanceID; expected != got { 55 | t.Errorf("expected instanceID %s but got %s", expected, got) 56 | } 57 | if expected, got := "databaseID-from-env", *databaseID; expected != got { 58 | t.Errorf("expected databaseID %s but got %s", expected, got) 59 | } 60 | }, 61 | }, 62 | "from flags": { 63 | setup: func() { 64 | projectID = ptr("projectID") 65 | instanceID = ptr("instanceID") 66 | databaseID = ptr("databaseID") 67 | }, 68 | envVars: map[string]string{ 69 | envProjectID: "projectID-from-env", 70 | envGoogleCloudProjectID: "projectID-from-google-cloud-env", 71 | envInstanceID: "instanceID-from-env", 72 | envDatabaseID: "databaseID-from-env", 73 | }, 74 | assert: func(t *testing.T) { 75 | if expected, got := "projectID", *projectID; expected != got { 76 | t.Errorf("expected projectID %s but got %s", expected, got) 77 | } 78 | if expected, got := "instanceID", *instanceID; expected != got { 79 | t.Errorf("expected instanceID %s but got %s", expected, got) 80 | } 81 | if expected, got := "databaseID", *databaseID; expected != got { 82 | t.Errorf("expected databaseID %s but got %s", expected, got) 83 | } 84 | }, 85 | }, 86 | "projectID is required": { 87 | setup: func() { 88 | projectID = ptr("") 89 | instanceID = ptr("instanceID") 90 | databaseID = ptr("databaseID") 91 | }, 92 | fail: true, 93 | }, 94 | "instanceID is required": { 95 | setup: func() { 96 | projectID = ptr("projectID") 97 | instanceID = ptr("") 98 | databaseID = ptr("databaseID") 99 | }, 100 | fail: true, 101 | }, 102 | "databaseID is required": { 103 | setup: func() { 104 | projectID = ptr("projectID") 105 | instanceID = ptr("instanceID") 106 | databaseID = ptr("") 107 | }, 108 | fail: true, 109 | }, 110 | } 111 | for name, test := range tests { 112 | t.Run(name, func(t *testing.T) { 113 | test.setup() 114 | os.Clearenv() 115 | for k, v := range test.envVars { 116 | t.Setenv(k, v) 117 | } 118 | err := loadEnvVarsIfNeeded() 119 | if test.fail { 120 | if err == nil { 121 | t.Fatal("expected error but no error") 122 | } 123 | } else { 124 | if err != nil { 125 | t.Fatalf("unexpected error: %s", err) 126 | } 127 | if projectID == nil || instanceID == nil || databaseID == nil { 128 | t.Fatalf("all flags are required: projectID=%v, instanceID=%v, databaseID=%v", projectID, instanceID, databaseID) 129 | } 130 | test.assert(t) 131 | } 132 | }) 133 | } 134 | } 135 | 136 | func ptr(s string) *string { 137 | return &s 138 | } 139 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package spool 2 | 3 | import ( 4 | "fmt" 5 | 6 | "google.golang.org/api/option" 7 | ) 8 | 9 | type Config struct { 10 | projectID string 11 | instanceID string 12 | databaseID string 13 | opts []option.ClientOption 14 | } 15 | 16 | func NewConfig(projectID, instanceID, databaseID string, opts ...option.ClientOption) *Config { 17 | return &Config{ 18 | projectID: projectID, 19 | instanceID: instanceID, 20 | databaseID: databaseID, 21 | opts: opts, 22 | } 23 | } 24 | 25 | func (c *Config) ProjectID() string { 26 | return c.projectID 27 | } 28 | 29 | func (c *Config) InstanceID() string { 30 | return c.instanceID 31 | } 32 | 33 | func (c *Config) DatabaseID() string { 34 | return c.databaseID 35 | } 36 | 37 | func (c *Config) Instance() string { 38 | return fmt.Sprintf("projects/%s/instances/%s", c.projectID, c.instanceID) 39 | } 40 | 41 | func (c *Config) Database() string { 42 | return fmt.Sprintf("projects/%s/instances/%s/databases/%s", c.projectID, c.instanceID, c.databaseID) 43 | } 44 | 45 | func (c *Config) ClientOptions() []option.ClientOption { 46 | return c.opts 47 | } 48 | 49 | func (c *Config) WithDatabaseID(databaseID string) *Config { 50 | return NewConfig(c.projectID, c.instanceID, databaseID, c.ClientOptions()...) 51 | } 52 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | spanner-emulator: 3 | image: gcr.io/cloud-spanner-emulator/emulator:1.5.29 4 | # docker run gcr.io/cloud-spanner-emulator/emulator ./gateway_main --help 5 | # docker run gcr.io/cloud-spanner-emulator/emulator ./emulator_main --helpfull 6 | entrypoint: 7 | - './gateway_main' 8 | - '--hostname' 9 | - '0.0.0.0' 10 | - '--log_requests' # requires -copy_emulator_stdout 11 | - '-copy_emulator_stderr' 12 | - '-copy_emulator_stdout' 13 | ports: 14 | - 9010:9010 15 | - 9020:9020 16 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package spool 2 | 3 | import "errors" 4 | 5 | type yoError interface { 6 | NotFound() bool 7 | } 8 | 9 | func isErrNotFound(err error) bool { 10 | var yErr yoError 11 | if errors.As(err, &yErr) { 12 | return yErr.NotFound() 13 | } 14 | return false 15 | } 16 | -------------------------------------------------------------------------------- /filter.go: -------------------------------------------------------------------------------- 1 | package spool 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/cloudspannerecosystem/spool/model" 7 | ) 8 | 9 | // FilterNotUsedWithin returns a function which reports whether sdb is not used within d. 10 | func FilterNotUsedWithin(d time.Duration) func(sdb *model.SpoolDatabase) bool { 11 | return func(sdb *model.SpoolDatabase) bool { 12 | return !sdb.UpdatedAt.Add(d).After(time.Now()) 13 | } 14 | } 15 | 16 | // FilterState returns a function which reports whether sdb.State is state. 17 | func FilterState(state State) func(sdb *model.SpoolDatabase) bool { 18 | return func(sdb *model.SpoolDatabase) bool { 19 | return sdb.State == state.Int64() 20 | } 21 | } 22 | 23 | func filter(sdbs []*model.SpoolDatabase, filters ...func(sdb *model.SpoolDatabase) bool) []*model.SpoolDatabase { 24 | res := make([]*model.SpoolDatabase, 0, len(sdbs)) 25 | for _, sdb := range sdbs { 26 | var skip bool 27 | for _, filter := range filters { 28 | if !filter(sdb) { 29 | skip = true 30 | break 31 | } 32 | } 33 | if !skip { 34 | res = append(res, sdb) 35 | } 36 | } 37 | return res 38 | } 39 | -------------------------------------------------------------------------------- /filter_test.go: -------------------------------------------------------------------------------- 1 | package spool 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/cloudspannerecosystem/spool/model" 8 | ) 9 | 10 | func TestFilterNotUsedWithin(t *testing.T) { 11 | t.Parallel() 12 | 13 | now := time.Now() 14 | hour := time.Hour 15 | sdbs := []*model.SpoolDatabase{ 16 | { 17 | UpdatedAt: now.Add(-hour), 18 | }, 19 | { 20 | UpdatedAt: now.Add(-hour).Add(time.Second), 21 | }, 22 | } 23 | filtered := filter(sdbs, FilterNotUsedWithin(hour)) 24 | if len(filtered) != 1 { 25 | t.Errorf("expected 1 but got %d", len(filtered)) 26 | } else { 27 | if !filtered[0].UpdatedAt.Equal(sdbs[0].UpdatedAt) { 28 | t.Errorf("expected %s but got %s", sdbs[0].UpdatedAt, filtered[0].UpdatedAt) 29 | } 30 | } 31 | } 32 | 33 | func TestFilterState(t *testing.T) { 34 | t.Parallel() 35 | 36 | sdbs := []*model.SpoolDatabase{ 37 | { 38 | State: StateIdle.Int64(), 39 | }, 40 | { 41 | State: StateBusy.Int64(), 42 | }, 43 | } 44 | state := StateIdle 45 | filtered := filter(sdbs, FilterState(state)) 46 | if len(filtered) != 1 { 47 | t.Errorf("expected 1 but got %d", len(filtered)) 48 | } else { 49 | if filtered[0].State != state.Int64() { 50 | t.Errorf("expected %s but got %s", state, State(filtered[0].State)) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cloudspannerecosystem/spool 2 | 3 | go 1.23.1 4 | 5 | toolchain go1.24.1 6 | 7 | tool ( 8 | github.com/cloudspannerecosystem/wrench 9 | github.com/golangci/golangci-lint/cmd/golangci-lint 10 | go.mercari.io/yo 11 | ) 12 | 13 | require ( 14 | cloud.google.com/go/spanner v1.81.1 15 | github.com/alecthomas/kingpin v2.2.6+incompatible 16 | google.golang.org/api v0.232.0 17 | google.golang.org/grpc v1.72.0 18 | ) 19 | 20 | require ( 21 | 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 22 | 4d63.com/gochecknoglobals v0.2.2 // indirect 23 | cel.dev/expr v0.20.0 // indirect 24 | cloud.google.com/go v0.121.0 // indirect 25 | cloud.google.com/go/auth v0.16.1 // indirect 26 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 27 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 28 | cloud.google.com/go/iam v1.5.2 // indirect 29 | cloud.google.com/go/longrunning v0.6.7 // indirect 30 | cloud.google.com/go/monitoring v1.24.2 // indirect 31 | github.com/4meepo/tagalign v1.4.2 // indirect 32 | github.com/Abirdcfly/dupword v0.1.3 // indirect 33 | github.com/Antonboom/errname v1.0.0 // indirect 34 | github.com/Antonboom/nilnil v1.0.1 // indirect 35 | github.com/Antonboom/testifylint v1.5.2 // indirect 36 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect 37 | github.com/Crocmagnon/fatcontext v0.7.1 // indirect 38 | github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect 39 | github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect 40 | github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.2 // indirect 41 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect 42 | github.com/Masterminds/semver/v3 v3.3.0 // indirect 43 | github.com/OpenPeeDeeP/depguard/v2 v2.2.0 // indirect 44 | github.com/alecthomas/go-check-sumtype v0.3.1 // indirect 45 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect 46 | github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect 47 | github.com/alexkohler/nakedret/v2 v2.0.5 // indirect 48 | github.com/alexkohler/prealloc v1.0.0 // indirect 49 | github.com/alingse/asasalint v0.0.11 // indirect 50 | github.com/alingse/nilnesserr v0.1.2 // indirect 51 | github.com/apstndb/gsqlutils v0.0.0-20241220021154-62754cd04acc // indirect 52 | github.com/ashanbrown/forbidigo v1.6.0 // indirect 53 | github.com/ashanbrown/makezero v1.2.0 // indirect 54 | github.com/beorn7/perks v1.0.1 // indirect 55 | github.com/bkielbasa/cyclop v1.2.3 // indirect 56 | github.com/blizzy78/varnamelen v0.8.0 // indirect 57 | github.com/bombsimon/wsl/v4 v4.5.0 // indirect 58 | github.com/breml/bidichk v0.3.2 // indirect 59 | github.com/breml/errchkjson v0.4.0 // indirect 60 | github.com/butuzov/ireturn v0.3.1 // indirect 61 | github.com/butuzov/mirror v1.3.0 // indirect 62 | github.com/catenacyber/perfsprint v0.8.2 // indirect 63 | github.com/ccojocar/zxcvbn-go v1.0.2 // indirect 64 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 65 | github.com/charithe/durationcheck v0.0.10 // indirect 66 | github.com/chavacava/garif v0.1.0 // indirect 67 | github.com/ckaznocha/intrange v0.3.0 // indirect 68 | github.com/cloudspannerecosystem/memefish v0.4.0 // indirect 69 | github.com/cloudspannerecosystem/wrench v1.11.3 // indirect 70 | github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect 71 | github.com/curioswitch/go-reassign v0.3.0 // indirect 72 | github.com/daixiang0/gci v0.13.5 // indirect 73 | github.com/davecgh/go-spew v1.1.1 // indirect 74 | github.com/denis-tingaikin/go-header v0.5.0 // indirect 75 | github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect 76 | github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect 77 | github.com/ettle/strcase v0.2.0 // indirect 78 | github.com/fatih/color v1.18.0 // indirect 79 | github.com/fatih/structtag v1.2.0 // indirect 80 | github.com/felixge/httpsnoop v1.0.4 // indirect 81 | github.com/firefart/nonamedreturns v1.0.5 // indirect 82 | github.com/fsnotify/fsnotify v1.5.4 // indirect 83 | github.com/fzipp/gocyclo v0.6.0 // indirect 84 | github.com/gedex/inflector v0.0.0-20170307190818-16278e9db813 // indirect 85 | github.com/ghostiam/protogetter v0.3.9 // indirect 86 | github.com/go-critic/go-critic v0.12.0 // indirect 87 | github.com/go-jose/go-jose/v4 v4.0.4 // indirect 88 | github.com/go-logr/logr v1.4.2 // indirect 89 | github.com/go-logr/stdr v1.2.2 // indirect 90 | github.com/go-toolsmith/astcast v1.1.0 // indirect 91 | github.com/go-toolsmith/astcopy v1.1.0 // indirect 92 | github.com/go-toolsmith/astequal v1.2.0 // indirect 93 | github.com/go-toolsmith/astfmt v1.1.0 // indirect 94 | github.com/go-toolsmith/astp v1.1.0 // indirect 95 | github.com/go-toolsmith/strparse v1.1.0 // indirect 96 | github.com/go-toolsmith/typep v1.1.0 // indirect 97 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 98 | github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect 99 | github.com/gobwas/glob v0.2.3 // indirect 100 | github.com/gofrs/flock v0.12.1 // indirect 101 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 102 | github.com/golang/protobuf v1.5.4 // indirect 103 | github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a // indirect 104 | github.com/golangci/go-printf-func-name v0.1.0 // indirect 105 | github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect 106 | github.com/golangci/golangci-lint v1.64.6 // indirect 107 | github.com/golangci/misspell v0.6.0 // indirect 108 | github.com/golangci/plugin-module-register v0.1.1 // indirect 109 | github.com/golangci/revgrep v0.8.0 // indirect 110 | github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect 111 | github.com/google/go-cmp v0.7.0 // indirect 112 | github.com/google/s2a-go v0.1.9 // indirect 113 | github.com/google/uuid v1.6.0 // indirect 114 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 115 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 116 | github.com/gordonklaus/ineffassign v0.1.0 // indirect 117 | github.com/gostaticanalysis/analysisutil v0.7.1 // indirect 118 | github.com/gostaticanalysis/comment v1.5.0 // indirect 119 | github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect 120 | github.com/gostaticanalysis/nilerr v0.1.1 // indirect 121 | github.com/hashicorp/errwrap v1.1.0 // indirect 122 | github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect 123 | github.com/hashicorp/go-multierror v1.1.1 // indirect 124 | github.com/hashicorp/go-version v1.7.0 // indirect 125 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 126 | github.com/hashicorp/hcl v1.0.0 // indirect 127 | github.com/hexops/gotextdiff v1.0.3 // indirect 128 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 129 | github.com/jessevdk/go-assets v0.0.0-20160921144138-4f4301a06e15 // indirect 130 | github.com/jgautheron/goconst v1.7.1 // indirect 131 | github.com/jingyugao/rowserrcheck v1.1.1 // indirect 132 | github.com/jinzhu/inflection v1.0.0 // indirect 133 | github.com/jjti/go-spancheck v0.6.4 // indirect 134 | github.com/julz/importas v0.2.0 // indirect 135 | github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect 136 | github.com/kenshaw/snaker v0.2.0 // indirect 137 | github.com/kisielk/errcheck v1.9.0 // indirect 138 | github.com/kkHAIKE/contextcheck v1.1.6 // indirect 139 | github.com/kulti/thelper v0.6.3 // indirect 140 | github.com/kunwardeep/paralleltest v1.0.10 // indirect 141 | github.com/lasiar/canonicalheader v1.1.2 // indirect 142 | github.com/ldez/exptostd v0.4.2 // indirect 143 | github.com/ldez/gomoddirectives v0.6.1 // indirect 144 | github.com/ldez/grignotin v0.9.0 // indirect 145 | github.com/ldez/tagliatelle v0.7.1 // indirect 146 | github.com/ldez/usetesting v0.4.2 // indirect 147 | github.com/leonklingele/grouper v1.1.2 // indirect 148 | github.com/macabu/inamedparam v0.1.3 // indirect 149 | github.com/magiconair/properties v1.8.6 // indirect 150 | github.com/maratori/testableexamples v1.0.0 // indirect 151 | github.com/maratori/testpackage v1.1.1 // indirect 152 | github.com/matoous/godox v1.1.0 // indirect 153 | github.com/mattn/go-colorable v0.1.14 // indirect 154 | github.com/mattn/go-isatty v0.0.20 // indirect 155 | github.com/mattn/go-runewidth v0.0.16 // indirect 156 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect 157 | github.com/mgechev/revive v1.7.0 // indirect 158 | github.com/mitchellh/go-homedir v1.1.0 // indirect 159 | github.com/mitchellh/mapstructure v1.5.0 // indirect 160 | github.com/moricho/tparallel v0.3.2 // indirect 161 | github.com/nakabonne/nestif v0.3.1 // indirect 162 | github.com/nishanths/exhaustive v0.12.0 // indirect 163 | github.com/nishanths/predeclared v0.2.2 // indirect 164 | github.com/nunnatsa/ginkgolinter v0.19.1 // indirect 165 | github.com/olekukonko/tablewriter v0.0.5 // indirect 166 | github.com/pelletier/go-toml v1.9.5 // indirect 167 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 168 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect 169 | github.com/pmezard/go-difflib v1.0.0 // indirect 170 | github.com/polyfloyd/go-errorlint v1.7.1 // indirect 171 | github.com/prometheus/client_golang v1.12.1 // indirect 172 | github.com/prometheus/client_model v0.6.1 // indirect 173 | github.com/prometheus/common v0.32.1 // indirect 174 | github.com/prometheus/procfs v0.7.3 // indirect 175 | github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 // indirect 176 | github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect 177 | github.com/quasilyte/gogrep v0.5.0 // indirect 178 | github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect 179 | github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect 180 | github.com/raeperd/recvcheck v0.2.0 // indirect 181 | github.com/rivo/uniseg v0.4.7 // indirect 182 | github.com/rogpeppe/go-internal v1.14.1 // indirect 183 | github.com/ryancurrah/gomodguard v1.3.5 // indirect 184 | github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect 185 | github.com/samber/lo v1.47.0 // indirect 186 | github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect 187 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect 188 | github.com/sashamelentyev/interfacebloat v1.1.0 // indirect 189 | github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect 190 | github.com/securego/gosec/v2 v2.22.1 // indirect 191 | github.com/sirupsen/logrus v1.9.3 // indirect 192 | github.com/sivchari/containedctx v1.0.3 // indirect 193 | github.com/sivchari/tenv v1.12.1 // indirect 194 | github.com/sonatard/noctx v0.1.0 // indirect 195 | github.com/sourcegraph/go-diff v0.7.0 // indirect 196 | github.com/spf13/afero v1.12.0 // indirect 197 | github.com/spf13/cast v1.5.0 // indirect 198 | github.com/spf13/cobra v1.9.1 // indirect 199 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 200 | github.com/spf13/pflag v1.0.6 // indirect 201 | github.com/spf13/viper v1.12.0 // indirect 202 | github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect 203 | github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect 204 | github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect 205 | github.com/stretchr/objx v0.5.2 // indirect 206 | github.com/stretchr/testify v1.10.0 // indirect 207 | github.com/subosito/gotenv v1.4.1 // indirect 208 | github.com/tdakkota/asciicheck v0.4.1 // indirect 209 | github.com/tetafro/godot v1.5.0 // indirect 210 | github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3 // indirect 211 | github.com/timonwong/loggercheck v0.10.1 // indirect 212 | github.com/tomarrell/wrapcheck/v2 v2.10.0 // indirect 213 | github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect 214 | github.com/ultraware/funlen v0.2.0 // indirect 215 | github.com/ultraware/whitespace v0.2.0 // indirect 216 | github.com/uudashr/gocognit v1.2.0 // indirect 217 | github.com/uudashr/iface v1.3.1 // indirect 218 | github.com/xen0n/gosmopolitan v1.2.2 // indirect 219 | github.com/yagipy/maintidx v1.0.0 // indirect 220 | github.com/yeya24/promlinter v0.3.0 // indirect 221 | github.com/ykadowak/zerologlint v0.1.5 // indirect 222 | github.com/zeebo/errs v1.4.0 // indirect 223 | gitlab.com/bosi/decorder v0.4.2 // indirect 224 | go-simpler.org/musttag v0.13.0 // indirect 225 | go-simpler.org/sloglint v0.9.0 // indirect 226 | go.mercari.io/yo v0.6.1 // indirect 227 | go.opencensus.io v0.24.0 // indirect 228 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 229 | go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect 230 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect 231 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect 232 | go.opentelemetry.io/otel v1.35.0 // indirect 233 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 234 | go.opentelemetry.io/otel/sdk v1.35.0 // indirect 235 | go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect 236 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 237 | go.uber.org/atomic v1.7.0 // indirect 238 | go.uber.org/automaxprocs v1.6.0 // indirect 239 | go.uber.org/multierr v1.6.0 // indirect 240 | go.uber.org/zap v1.24.0 // indirect 241 | golang.org/x/crypto v0.37.0 // indirect 242 | golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect 243 | golang.org/x/mod v0.23.0 // indirect 244 | golang.org/x/net v0.39.0 // indirect 245 | golang.org/x/oauth2 v0.30.0 // indirect 246 | golang.org/x/sync v0.14.0 // indirect 247 | golang.org/x/sys v0.32.0 // indirect 248 | golang.org/x/text v0.24.0 // indirect 249 | golang.org/x/time v0.11.0 // indirect 250 | golang.org/x/tools v0.30.0 // indirect 251 | google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect 252 | google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect 253 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 // indirect 254 | google.golang.org/protobuf v1.36.6 // indirect 255 | gopkg.in/ini.v1 v1.67.0 // indirect 256 | gopkg.in/yaml.v2 v2.4.0 // indirect 257 | gopkg.in/yaml.v3 v3.0.1 // indirect 258 | honnef.co/go/tools v0.6.0 // indirect 259 | mvdan.cc/gofumpt v0.7.0 // indirect 260 | mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect 261 | spheric.cloud/xiter v0.0.0-20240904151420-c999f37a46b2 // indirect 262 | ) 263 | -------------------------------------------------------------------------------- /internal/db/embed.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import _ "embed" 4 | 5 | //go:embed schema.sql 6 | var SpoolSchema []byte 7 | -------------------------------------------------------------------------------- /internal/db/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE SpoolDatabases ( 2 | DatabaseName STRING(MAX) NOT NULL, 3 | Checksum STRING(MAX) NOT NULL, 4 | State INT64 NOT NULL, 5 | CreatedAt TIMESTAMP NOT NULL OPTIONS ( 6 | allow_commit_timestamp = true 7 | ), 8 | UpdatedAt TIMESTAMP NOT NULL OPTIONS ( 9 | allow_commit_timestamp = true 10 | ), 11 | ) PRIMARY KEY(DatabaseName); 12 | 13 | CREATE INDEX SpoolDatabasesByChecksumAndState ON SpoolDatabases(Checksum, State); 14 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package spool 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | _ "embed" 7 | "fmt" 8 | "os" 9 | "testing" 10 | ) 11 | 12 | var ( 13 | //go:embed testdata/schema1.sql 14 | ddl1 []byte 15 | //go:embed testdata/schema2.sql 16 | ddl2 []byte 17 | ) 18 | 19 | func spoolSpannerDatabaseNamePrefix() string { 20 | s := os.Getenv("SPOOL_SPANNER_DATABASE_NAME_PREFIX") 21 | if s == "" { 22 | s = "spool-test" 23 | } 24 | return s 25 | } 26 | 27 | func ConfigTestDatabase(t *testing.T) *Config { 28 | t.Helper() 29 | 30 | emulatorHost := os.Getenv("SPANNER_EMULATOR_HOST") 31 | if emulatorHost == "" { 32 | t.Fatal("SPANNER_EMULATOR_HOST environment variable is not set") 33 | } 34 | 35 | b := make([]byte, 10) 36 | _, err := rand.Read(b) 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | 41 | cfg := NewConfig( 42 | os.Getenv("SPANNER_PROJECT_ID"), 43 | os.Getenv("SPANNER_INSTANCE_ID"), 44 | fmt.Sprintf("test-%x", b), 45 | ) 46 | 47 | return cfg 48 | } 49 | 50 | func SetupTestDatabase(t *testing.T) *Config { 51 | t.Helper() 52 | 53 | ctx := context.Background() 54 | cfg := ConfigTestDatabase(t) 55 | 56 | err := Setup(ctx, cfg) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | return cfg 62 | } 63 | -------------------------------------------------------------------------------- /model/spooldatabase.yo.go: -------------------------------------------------------------------------------- 1 | // Code generated by yo. DO NOT EDIT. 2 | // Package model contains the types. 3 | package model 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "time" 9 | 10 | "cloud.google.com/go/spanner" 11 | "google.golang.org/api/iterator" 12 | "google.golang.org/grpc/codes" 13 | ) 14 | 15 | // SpoolDatabase represents a row from 'SpoolDatabases'. 16 | type SpoolDatabase struct { 17 | DatabaseName string `spanner:"DatabaseName" json:"DatabaseName"` // DatabaseName 18 | Checksum string `spanner:"Checksum" json:"Checksum"` // Checksum 19 | State int64 `spanner:"State" json:"State"` // State 20 | CreatedAt time.Time `spanner:"CreatedAt" json:"CreatedAt"` // CreatedAt 21 | UpdatedAt time.Time `spanner:"UpdatedAt" json:"UpdatedAt"` // UpdatedAt 22 | } 23 | 24 | func SpoolDatabasePrimaryKeys() []string { 25 | return []string{ 26 | "DatabaseName", 27 | } 28 | } 29 | 30 | func SpoolDatabaseColumns() []string { 31 | return []string{ 32 | "DatabaseName", 33 | "Checksum", 34 | "State", 35 | "CreatedAt", 36 | "UpdatedAt", 37 | } 38 | } 39 | 40 | func (sd *SpoolDatabase) columnsToPtrs(cols []string, customPtrs map[string]interface{}) ([]interface{}, error) { 41 | ret := make([]interface{}, 0, len(cols)) 42 | for _, col := range cols { 43 | if val, ok := customPtrs[col]; ok { 44 | ret = append(ret, val) 45 | continue 46 | } 47 | 48 | switch col { 49 | case "DatabaseName": 50 | ret = append(ret, &sd.DatabaseName) 51 | case "Checksum": 52 | ret = append(ret, &sd.Checksum) 53 | case "State": 54 | ret = append(ret, &sd.State) 55 | case "CreatedAt": 56 | ret = append(ret, &sd.CreatedAt) 57 | case "UpdatedAt": 58 | ret = append(ret, &sd.UpdatedAt) 59 | default: 60 | return nil, fmt.Errorf("unknown column: %s", col) 61 | } 62 | } 63 | return ret, nil 64 | } 65 | 66 | func (sd *SpoolDatabase) columnsToValues(cols []string) ([]interface{}, error) { 67 | ret := make([]interface{}, 0, len(cols)) 68 | for _, col := range cols { 69 | switch col { 70 | case "DatabaseName": 71 | ret = append(ret, sd.DatabaseName) 72 | case "Checksum": 73 | ret = append(ret, sd.Checksum) 74 | case "State": 75 | ret = append(ret, sd.State) 76 | case "CreatedAt": 77 | ret = append(ret, sd.CreatedAt) 78 | case "UpdatedAt": 79 | ret = append(ret, sd.UpdatedAt) 80 | default: 81 | return nil, fmt.Errorf("unknown column: %s", col) 82 | } 83 | } 84 | 85 | return ret, nil 86 | } 87 | 88 | // newSpoolDatabase_Decoder returns a decoder which reads a row from *spanner.Row 89 | // into SpoolDatabase. The decoder is not goroutine-safe. Don't use it concurrently. 90 | func newSpoolDatabase_Decoder(cols []string) func(*spanner.Row) (*SpoolDatabase, error) { 91 | customPtrs := map[string]interface{}{} 92 | 93 | return func(row *spanner.Row) (*SpoolDatabase, error) { 94 | var sd SpoolDatabase 95 | ptrs, err := sd.columnsToPtrs(cols, customPtrs) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | if err := row.Columns(ptrs...); err != nil { 101 | return nil, err 102 | } 103 | 104 | return &sd, nil 105 | } 106 | } 107 | 108 | // Insert returns a Mutation to insert a row into a table. If the row already 109 | // exists, the write or transaction fails. 110 | func (sd *SpoolDatabase) Insert(ctx context.Context) *spanner.Mutation { 111 | return spanner.Insert("SpoolDatabases", SpoolDatabaseColumns(), []interface{}{ 112 | sd.DatabaseName, sd.Checksum, sd.State, sd.CreatedAt, sd.UpdatedAt, 113 | }) 114 | } 115 | 116 | // Update returns a Mutation to update a row in a table. If the row does not 117 | // already exist, the write or transaction fails. 118 | func (sd *SpoolDatabase) Update(ctx context.Context) *spanner.Mutation { 119 | return spanner.Update("SpoolDatabases", SpoolDatabaseColumns(), []interface{}{ 120 | sd.DatabaseName, sd.Checksum, sd.State, sd.CreatedAt, sd.UpdatedAt, 121 | }) 122 | } 123 | 124 | // InsertOrUpdate returns a Mutation to insert a row into a table. If the row 125 | // already exists, it updates it instead. Any column values not explicitly 126 | // written are preserved. 127 | func (sd *SpoolDatabase) InsertOrUpdate(ctx context.Context) *spanner.Mutation { 128 | return spanner.InsertOrUpdate("SpoolDatabases", SpoolDatabaseColumns(), []interface{}{ 129 | sd.DatabaseName, sd.Checksum, sd.State, sd.CreatedAt, sd.UpdatedAt, 130 | }) 131 | } 132 | 133 | // UpdateColumns returns a Mutation to update specified columns of a row in a table. 134 | func (sd *SpoolDatabase) UpdateColumns(ctx context.Context, cols ...string) (*spanner.Mutation, error) { 135 | // add primary keys to columns to update by primary keys 136 | colsWithPKeys := append(cols, SpoolDatabasePrimaryKeys()...) 137 | 138 | values, err := sd.columnsToValues(colsWithPKeys) 139 | if err != nil { 140 | return nil, newErrorWithCode(codes.InvalidArgument, "SpoolDatabase.UpdateColumns", "SpoolDatabases", err) 141 | } 142 | 143 | return spanner.Update("SpoolDatabases", colsWithPKeys, values), nil 144 | } 145 | 146 | // FindSpoolDatabase gets a SpoolDatabase by primary key 147 | func FindSpoolDatabase(ctx context.Context, db YORODB, databaseName string) (*SpoolDatabase, error) { 148 | key := spanner.Key{databaseName} 149 | row, err := db.ReadRow(ctx, "SpoolDatabases", key, SpoolDatabaseColumns()) 150 | if err != nil { 151 | return nil, newError("FindSpoolDatabase", "SpoolDatabases", err) 152 | } 153 | 154 | decoder := newSpoolDatabase_Decoder(SpoolDatabaseColumns()) 155 | sd, err := decoder(row) 156 | if err != nil { 157 | return nil, newErrorWithCode(codes.Internal, "FindSpoolDatabase", "SpoolDatabases", err) 158 | } 159 | 160 | return sd, nil 161 | } 162 | 163 | // Delete deletes the SpoolDatabase from the database. 164 | func (sd *SpoolDatabase) Delete(ctx context.Context) *spanner.Mutation { 165 | values, _ := sd.columnsToValues(SpoolDatabasePrimaryKeys()) 166 | return spanner.Delete("SpoolDatabases", spanner.Key(values)) 167 | } 168 | 169 | // FindSpoolDatabasesByChecksumState retrieves multiple rows from 'SpoolDatabases' as a slice of SpoolDatabase. 170 | // 171 | // Generated from index 'SpoolDatabasesByChecksumAndState'. 172 | func FindSpoolDatabasesByChecksumState(ctx context.Context, db YORODB, checksum string, state int64) ([]*SpoolDatabase, error) { 173 | const sqlstr = `SELECT ` + 174 | `DatabaseName, Checksum, State, CreatedAt, UpdatedAt ` + 175 | `FROM SpoolDatabases@{FORCE_INDEX=SpoolDatabasesByChecksumAndState} ` + 176 | `WHERE Checksum = @param0 AND State = @param1` 177 | 178 | stmt := spanner.NewStatement(sqlstr) 179 | stmt.Params["param0"] = checksum 180 | stmt.Params["param1"] = state 181 | 182 | decoder := newSpoolDatabase_Decoder(SpoolDatabaseColumns()) 183 | 184 | // run query 185 | YOLog(ctx, sqlstr, checksum, state) 186 | iter := db.Query(ctx, stmt) 187 | defer iter.Stop() 188 | 189 | // load results 190 | res := []*SpoolDatabase{} 191 | for { 192 | row, err := iter.Next() 193 | if err != nil { 194 | if err == iterator.Done { 195 | break 196 | } 197 | return nil, newError("FindSpoolDatabasesByChecksumState", "SpoolDatabases", err) 198 | } 199 | 200 | sd, err := decoder(row) 201 | if err != nil { 202 | return nil, newErrorWithCode(codes.Internal, "FindSpoolDatabasesByChecksumState", "SpoolDatabases", err) 203 | } 204 | 205 | res = append(res, sd) 206 | } 207 | 208 | return res, nil 209 | } 210 | -------------------------------------------------------------------------------- /model/spooldatabase_extend.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/spanner" 7 | "google.golang.org/api/iterator" 8 | "google.golang.org/grpc/codes" 9 | ) 10 | 11 | func (sdb *SpoolDatabase) ChangeState(state int64) { 12 | sdb.State = state 13 | sdb.UpdatedAt = spanner.CommitTimestamp 14 | } 15 | 16 | // FindAllSpoolDatabases finds all SpoolDatabases. 17 | func FindAllSpoolDatabases(ctx context.Context, db YORODB) ([]*SpoolDatabase, error) { 18 | const sqlstr = `SELECT ` + 19 | `* ` + 20 | `FROM SpoolDatabases` 21 | 22 | stmt := spanner.NewStatement(sqlstr) 23 | customPtrs := make(map[string]interface{}, 0) 24 | 25 | // run query 26 | YOLog(ctx, sqlstr) 27 | iter := db.Query(ctx, stmt) 28 | defer iter.Stop() 29 | 30 | // load results 31 | res := []*SpoolDatabase{} 32 | for { 33 | row, err := iter.Next() 34 | if err != nil { 35 | if err == iterator.Done { 36 | break 37 | } 38 | return nil, newError("FindSpoolDatabasesByChecksumState", "SpoolDatabases", err) 39 | } 40 | 41 | var sd SpoolDatabase 42 | ptrs, err := sd.columnsToPtrs(SpoolDatabaseColumns(), customPtrs) 43 | if err != nil { 44 | return nil, newError("FindSpoolDatabasesByChecksumState", "SpoolDatabases", err) 45 | } 46 | 47 | if err := row.Columns(ptrs...); err != nil { 48 | return nil, newErrorWithCode(codes.Internal, "FindSpoolDatabasesByChecksumState", "SpoolDatabases", err) 49 | } 50 | 51 | res = append(res, &sd) 52 | } 53 | 54 | return res, nil 55 | } 56 | 57 | // FindSpoolDatabasesByChecksum finds a SpoolDatabase by Checksum. 58 | func FindSpoolDatabasesByChecksum(ctx context.Context, db YORODB, checksum string) ([]*SpoolDatabase, error) { 59 | const sqlstr = `SELECT ` + 60 | `*` + 61 | `FROM SpoolDatabases@{FORCE_INDEX=SpoolDatabasesByChecksumAndState} ` + 62 | `WHERE Checksum = @param0` 63 | 64 | stmt := spanner.NewStatement(sqlstr) 65 | stmt.Params["param0"] = checksum 66 | customPtrs := make(map[string]interface{}, 0) 67 | 68 | // run query 69 | YOLog(ctx, sqlstr, checksum) 70 | iter := db.Query(ctx, stmt) 71 | defer iter.Stop() 72 | 73 | // load results 74 | res := []*SpoolDatabase{} 75 | for { 76 | row, err := iter.Next() 77 | if err != nil { 78 | if err == iterator.Done { 79 | break 80 | } 81 | return nil, newError("FindSpoolDatabasesByChecksumState", "SpoolDatabases", err) 82 | } 83 | 84 | var sd SpoolDatabase 85 | ptrs, err := sd.columnsToPtrs(SpoolDatabaseColumns(), customPtrs) 86 | if err != nil { 87 | return nil, newError("FindSpoolDatabasesByChecksumState", "SpoolDatabases", err) 88 | } 89 | 90 | if err := row.Columns(ptrs...); err != nil { 91 | return nil, newErrorWithCode(codes.Internal, "FindSpoolDatabasesByChecksumState", "SpoolDatabases", err) 92 | } 93 | 94 | res = append(res, &sd) 95 | } 96 | 97 | return res, nil 98 | } 99 | 100 | // FindSpoolDatabaseByChecksumState finds a SpoolDatabase by Checksum and State. 101 | func FindSpoolDatabaseByChecksumState(ctx context.Context, db YORODB, checksum string, state int64) (*SpoolDatabase, error) { 102 | const sqlstr = `SELECT ` + 103 | `*` + 104 | `FROM SpoolDatabases@{FORCE_INDEX=SpoolDatabasesByChecksumAndState} ` + 105 | `WHERE Checksum = @param0 AND State = @param1 Limit 1` 106 | 107 | stmt := spanner.NewStatement(sqlstr) 108 | stmt.Params["param0"] = checksum 109 | stmt.Params["param1"] = state 110 | customPtrs := make(map[string]interface{}, 0) 111 | 112 | // run query 113 | YOLog(ctx, sqlstr, checksum, state) 114 | var sd SpoolDatabase 115 | ptrs, err := sd.columnsToPtrs(SpoolDatabaseColumns(), customPtrs) 116 | if err != nil { 117 | return nil, newError("FindSpoolDatabasesByChecksumState", "SpoolDatabases", err) 118 | } 119 | 120 | iter := db.Query(ctx, stmt) 121 | defer iter.Stop() 122 | 123 | row, err := iter.Next() 124 | if err != nil { 125 | if err == iterator.Done { 126 | return nil, newErrorWithCode(codes.NotFound, "FindSpoolDatabasesByChecksumState", "SpoolDatabases", err) 127 | } 128 | return nil, newError("FindSpoolDatabasesByChecksumState", "SpoolDatabases", err) 129 | } 130 | 131 | if err := row.Columns(ptrs...); err != nil { 132 | return nil, newErrorWithCode(codes.Internal, "FindSpoolDatabasesByChecksumState", "SpoolDatabases", err) 133 | } 134 | 135 | return &sd, nil 136 | } 137 | -------------------------------------------------------------------------------- /model/yo_db.yo.go: -------------------------------------------------------------------------------- 1 | // Code generated by yo. DO NOT EDIT. 2 | // Package model contains the types. 3 | package model 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "cloud.google.com/go/spanner" 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | ) 13 | 14 | // YODB is the common interface for database operations. 15 | type YODB interface { 16 | YORODB 17 | } 18 | 19 | // YORODB is the common interface for database operations. 20 | type YORODB interface { 21 | ReadRow(ctx context.Context, table string, key spanner.Key, columns []string) (*spanner.Row, error) 22 | Read(ctx context.Context, table string, keys spanner.KeySet, columns []string) *spanner.RowIterator 23 | ReadUsingIndex(ctx context.Context, table, index string, keys spanner.KeySet, columns []string) (ri *spanner.RowIterator) 24 | Query(ctx context.Context, statement spanner.Statement) *spanner.RowIterator 25 | } 26 | 27 | // YOLog provides the log func used by generated queries. 28 | var YOLog = func(context.Context, string, ...interface{}) {} 29 | 30 | func newError(method, table string, err error) error { 31 | code := spanner.ErrCode(err) 32 | return newErrorWithCode(code, method, table, err) 33 | } 34 | 35 | func newErrorWithCode(code codes.Code, method, table string, err error) error { 36 | return &yoError{ 37 | method: method, 38 | table: table, 39 | err: err, 40 | code: code, 41 | } 42 | } 43 | 44 | type yoError struct { 45 | err error 46 | method string 47 | table string 48 | code codes.Code 49 | } 50 | 51 | func (e yoError) Error() string { 52 | return fmt.Sprintf("yo error in %s(%s): %v", e.method, e.table, e.err) 53 | } 54 | 55 | func (e yoError) DBTableName() string { 56 | return e.table 57 | } 58 | 59 | func (e yoError) GRPCStatus() *status.Status { 60 | return status.New(e.code, e.Error()) 61 | } 62 | 63 | func (e yoError) Timeout() bool { return e.code == codes.DeadlineExceeded } 64 | func (e yoError) Temporary() bool { return e.code == codes.DeadlineExceeded } 65 | func (e yoError) NotFound() bool { return e.code == codes.NotFound } 66 | -------------------------------------------------------------------------------- /pool.go: -------------------------------------------------------------------------------- 1 | package spool 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/sha256" 7 | "fmt" 8 | "strings" 9 | "time" 10 | 11 | "cloud.google.com/go/spanner" 12 | admin "cloud.google.com/go/spanner/admin/database/apiv1" 13 | "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" 14 | "github.com/cloudspannerecosystem/spool/model" 15 | ) 16 | 17 | // State represents a state of the database. 18 | type State int64 19 | 20 | const ( 21 | // StateIdle represents a idle state. 22 | StateIdle State = iota 23 | // StateBusy represents a busy state. 24 | StateBusy 25 | ) 26 | 27 | // Int64 returns s as int64. 28 | func (s State) Int64() int64 { 29 | return int64(s) 30 | } 31 | 32 | // String returns a string representing the state. 33 | func (s State) String() string { 34 | switch s { 35 | case StateIdle: 36 | return "idle" 37 | case StateBusy: 38 | return "busy" 39 | } 40 | return "unknown" 41 | } 42 | 43 | // Pool represents a Spanner database pool. 44 | type Pool struct { 45 | client *spanner.Client 46 | adminClient *admin.DatabaseAdminClient 47 | conf *Config 48 | ddlStatements []string 49 | checksum string 50 | } 51 | 52 | // NewPool creates a new Pool. 53 | func NewPool(ctx context.Context, conf *Config, ddl []byte) (*Pool, error) { 54 | client, err := spanner.NewClient(ctx, conf.Database(), conf.ClientOptions()...) 55 | if err != nil { 56 | return nil, err 57 | } 58 | adminClient, err := admin.NewDatabaseAdminClient(ctx, conf.ClientOptions()...) 59 | if err != nil { 60 | return nil, err 61 | } 62 | pool := &Pool{ 63 | client: client, 64 | adminClient: adminClient, 65 | conf: conf, 66 | ddlStatements: ddlToStatements(ddl), 67 | checksum: checksum(ddl), 68 | } 69 | return pool, nil 70 | } 71 | 72 | func ddlToStatements(ddl []byte) []string { 73 | ddls := bytes.Split(ddl, []byte(";")) 74 | ddlStatements := make([]string, 0, len(ddls)) 75 | for _, s := range ddls { 76 | if stmt := strings.TrimSpace(string(s)); stmt != "" { 77 | ddlStatements = append(ddlStatements, stmt) 78 | } 79 | } 80 | return ddlStatements 81 | } 82 | 83 | func checksum(ddl []byte) string { 84 | return fmt.Sprintf("%x", sha256.Sum256(ddl)) 85 | } 86 | 87 | // Create creates a new database and adds to the pool. 88 | func (p *Pool) Create(ctx context.Context, dbNamePrefix string) (*model.SpoolDatabase, error) { 89 | dbName := fmt.Sprintf("%s-%d", dbNamePrefix, time.Now().Unix()) 90 | sdb := &model.SpoolDatabase{ 91 | DatabaseName: dbName, 92 | Checksum: p.checksum, 93 | State: StateIdle.Int64(), 94 | CreatedAt: spanner.CommitTimestamp, 95 | UpdatedAt: spanner.CommitTimestamp, 96 | } 97 | return p.create(ctx, sdb) 98 | } 99 | 100 | func (p *Pool) create(ctx context.Context, sdb *model.SpoolDatabase) (*model.SpoolDatabase, error) { 101 | op, err := p.adminClient.CreateDatabase(ctx, &databasepb.CreateDatabaseRequest{ 102 | Parent: p.conf.Instance(), 103 | CreateStatement: fmt.Sprintf("CREATE DATABASE `%s`", sdb.DatabaseName), 104 | ExtraStatements: p.ddlStatements, 105 | }) 106 | if err != nil { 107 | return nil, err 108 | } 109 | if _, err := op.Wait(ctx); err != nil { 110 | return nil, err 111 | } 112 | ts, err := p.client.Apply(ctx, []*spanner.Mutation{sdb.Insert(ctx)}) 113 | if err != nil { 114 | _ = dropDatabase(ctx, p.conf.WithDatabaseID(sdb.DatabaseName)) 115 | return nil, err 116 | } 117 | sdb.CreatedAt = ts 118 | sdb.UpdatedAt = ts 119 | return sdb, nil 120 | } 121 | 122 | // Get gets a idle database from the pool. 123 | func (p *Pool) Get(ctx context.Context) (*model.SpoolDatabase, error) { 124 | var sdb *model.SpoolDatabase 125 | if _, err := p.client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error { 126 | var err error 127 | sdb, err = model.FindSpoolDatabaseByChecksumState(ctx, txn, p.checksum, StateIdle.Int64()) 128 | if err != nil { 129 | return err 130 | } 131 | sdb.ChangeState(StateBusy.Int64()) 132 | if err := txn.BufferWrite([]*spanner.Mutation{sdb.Update(ctx)}); err != nil { 133 | return err 134 | } 135 | return nil 136 | }); err != nil { 137 | return nil, err 138 | } 139 | return sdb, nil 140 | } 141 | 142 | // GetOrCreate gets a idle database or creates a new database. 143 | func (p *Pool) GetOrCreate(ctx context.Context, dbNamePrefix string) (*model.SpoolDatabase, error) { 144 | sdb, err := p.Get(ctx) 145 | if err == nil { 146 | return sdb, nil 147 | } 148 | if !isErrNotFound(err) { 149 | return nil, err 150 | } 151 | dbName := fmt.Sprintf("%s-%d", dbNamePrefix, time.Now().Unix()) 152 | sdb = &model.SpoolDatabase{ 153 | DatabaseName: dbName, 154 | Checksum: p.checksum, 155 | State: StateBusy.Int64(), 156 | CreatedAt: spanner.CommitTimestamp, 157 | UpdatedAt: spanner.CommitTimestamp, 158 | } 159 | return p.create(ctx, sdb) 160 | } 161 | 162 | // List gets all databases from the pool. 163 | func (p *Pool) List(ctx context.Context) ([]*model.SpoolDatabase, error) { 164 | return model.FindSpoolDatabasesByChecksum(ctx, p.client.ReadOnlyTransaction(), p.checksum) 165 | } 166 | 167 | // Put adds a database to the pool. 168 | func (p *Pool) Put(ctx context.Context, dbName string) error { 169 | if _, err := p.client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error { 170 | sdb, err := model.FindSpoolDatabase(ctx, txn, dbName) 171 | if err != nil { 172 | return err 173 | } 174 | sdb.ChangeState(StateIdle.Int64()) 175 | if err := txn.BufferWrite([]*spanner.Mutation{sdb.Update(ctx)}); err != nil { 176 | return err 177 | } 178 | return nil 179 | }); err != nil { 180 | return err 181 | } 182 | return nil 183 | } 184 | 185 | // Clean removes all idle databases. 186 | func (p *Pool) Clean(ctx context.Context, filters ...func(sdb *model.SpoolDatabase) bool) error { 187 | return clean(ctx, p.client, p.conf, func(ctx context.Context, txn *spanner.ReadWriteTransaction) ([]*model.SpoolDatabase, error) { 188 | sdbs, err := model.FindSpoolDatabasesByChecksumState(ctx, txn, p.checksum, StateIdle.Int64()) 189 | if err != nil { 190 | return nil, err 191 | } 192 | return filter(sdbs, filters...), nil 193 | }) 194 | } 195 | -------------------------------------------------------------------------------- /pool_test.go: -------------------------------------------------------------------------------- 1 | package spool 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "cloud.google.com/go/spanner" 8 | "github.com/cloudspannerecosystem/spool/model" 9 | ) 10 | 11 | func newPool(ctx context.Context, t *testing.T, cfg *Config, ddl []byte) *Pool { 12 | t.Helper() 13 | pool, err := NewPool(ctx, cfg, ddl) 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | return pool 18 | } 19 | 20 | func TestPool_Create(t *testing.T) { 21 | t.Parallel() 22 | 23 | cfg := SetupTestDatabase(t) 24 | 25 | ctx := context.Background() 26 | client, truncate := connect(ctx, t, cfg) 27 | t.Cleanup(truncate) 28 | 29 | pool := newPool(ctx, t, cfg, ddl1) 30 | sdb, err := pool.Create(ctx, spoolSpannerDatabaseNamePrefix()) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | if _, err := model.FindSpoolDatabase(ctx, client.Single(), sdb.DatabaseName); err != nil { 35 | t.Error(err) 36 | } 37 | } 38 | 39 | func TestPool_Get(t *testing.T) { 40 | t.Parallel() 41 | 42 | cfg := SetupTestDatabase(t) 43 | 44 | ctx := context.Background() 45 | client, truncate := connect(ctx, t, cfg) 46 | t.Cleanup(truncate) 47 | 48 | pool := newPool(ctx, t, cfg, ddl1) 49 | sdb := &model.SpoolDatabase{ 50 | DatabaseName: "zoncoen-spool-test", 51 | Checksum: checksum(ddl1), 52 | State: StateIdle.Int64(), 53 | CreatedAt: spanner.CommitTimestamp, 54 | UpdatedAt: spanner.CommitTimestamp, 55 | } 56 | if _, err := client.Apply(ctx, []*spanner.Mutation{sdb.Insert(ctx)}); err != nil { 57 | t.Fatalf("failed to setup fixture: %s", err) 58 | } 59 | 60 | t.Run("not found (no database for the schema)", func(t *testing.T) { 61 | pool2 := newPool(ctx, t, cfg, ddl2) 62 | if _, err := pool2.Get(ctx); err != nil { 63 | if !isErrNotFound(err) { 64 | t.Fatal(err) 65 | } 66 | } else { 67 | t.Fatal("should not get another schema database") 68 | } 69 | }) 70 | t.Run("found", func(t *testing.T) { 71 | if _, err := pool.Get(ctx); err != nil { 72 | t.Fatal(err) 73 | } 74 | }) 75 | t.Run("not found (already used)", func(t *testing.T) { 76 | if _, err := pool.Get(ctx); err != nil { 77 | if !isErrNotFound(err) { 78 | t.Fatal(err) 79 | } 80 | } else { 81 | t.Fatal("should not get busy database") 82 | } 83 | }) 84 | } 85 | 86 | func TestPool_GetOrCreate(t *testing.T) { 87 | // t.Parallel() // this test can't be parallel because it uses time.Now().Unix() 88 | 89 | cfg := SetupTestDatabase(t) 90 | 91 | ctx := context.Background() 92 | client, truncate := connect(ctx, t, cfg) 93 | t.Cleanup(truncate) 94 | 95 | pool := newPool(ctx, t, cfg, ddl1) 96 | sdb := &model.SpoolDatabase{ 97 | DatabaseName: "zoncoen-spool-test", 98 | Checksum: checksum(ddl1), 99 | State: StateIdle.Int64(), 100 | CreatedAt: spanner.CommitTimestamp, 101 | UpdatedAt: spanner.CommitTimestamp, 102 | } 103 | if _, err := client.Apply(ctx, []*spanner.Mutation{sdb.Insert(ctx)}); err != nil { 104 | t.Fatalf("failed to setup fixture: %s", err) 105 | } 106 | 107 | t.Run("get", func(t *testing.T) { 108 | got, err := pool.GetOrCreate(ctx, spoolSpannerDatabaseNamePrefix()) 109 | if err != nil { 110 | t.Fatal(err) 111 | } 112 | if got.DatabaseName != sdb.DatabaseName { 113 | t.Errorf("expected %s but got %s", sdb.DatabaseName, got.DatabaseName) 114 | } 115 | }) 116 | t.Run("create", func(t *testing.T) { 117 | got, err := pool.GetOrCreate(ctx, spoolSpannerDatabaseNamePrefix()) 118 | if err != nil { 119 | t.Fatal(err) 120 | } 121 | if got.DatabaseName == sdb.DatabaseName { 122 | t.Error("should not get busy database") 123 | } 124 | }) 125 | } 126 | 127 | func TestPool_List(t *testing.T) { 128 | t.Parallel() 129 | 130 | cfg := SetupTestDatabase(t) 131 | 132 | ctx := context.Background() 133 | client, truncate := connect(ctx, t, cfg) 134 | t.Cleanup(truncate) 135 | 136 | pool := newPool(ctx, t, cfg, ddl1) 137 | sdb1 := &model.SpoolDatabase{ 138 | DatabaseName: "zoncoen-spool-test-1", 139 | Checksum: checksum(ddl1), 140 | State: StateIdle.Int64(), 141 | CreatedAt: spanner.CommitTimestamp, 142 | UpdatedAt: spanner.CommitTimestamp, 143 | } 144 | sdb2 := &model.SpoolDatabase{ 145 | DatabaseName: "zoncoen-spool-test-2", 146 | Checksum: checksum(ddl2), 147 | State: StateIdle.Int64(), 148 | CreatedAt: spanner.CommitTimestamp, 149 | UpdatedAt: spanner.CommitTimestamp, 150 | } 151 | if _, err := client.Apply(ctx, []*spanner.Mutation{sdb1.Insert(ctx), sdb2.Insert(ctx)}); err != nil { 152 | t.Fatalf("failed to setup fixture: %s", err) 153 | } 154 | 155 | sdbs, err := pool.List(ctx) 156 | if err != nil { 157 | t.Fatal(err) 158 | } 159 | if len(sdbs) != 1 { 160 | t.Fatalf("expected 1 but gut %d", len(sdbs)) 161 | } 162 | if sdbs[0].DatabaseName != sdb1.DatabaseName { 163 | t.Errorf("expected %s but got %s", sdb1.DatabaseName, sdbs[0].DatabaseName) 164 | } 165 | } 166 | 167 | func TestPool_Put(t *testing.T) { 168 | t.Parallel() 169 | 170 | cfg := SetupTestDatabase(t) 171 | 172 | ctx := context.Background() 173 | client, truncate := connect(ctx, t, cfg) 174 | t.Cleanup(truncate) 175 | 176 | pool := newPool(ctx, t, cfg, ddl1) 177 | sdb := &model.SpoolDatabase{ 178 | DatabaseName: "zoncoen-spool-test", 179 | Checksum: checksum(ddl1), 180 | State: StateBusy.Int64(), 181 | CreatedAt: spanner.CommitTimestamp, 182 | UpdatedAt: spanner.CommitTimestamp, 183 | } 184 | if _, err := client.Apply(ctx, []*spanner.Mutation{sdb.Insert(ctx)}); err != nil { 185 | t.Fatalf("failed to setup fixture: %s", err) 186 | } 187 | 188 | if err := pool.Put(ctx, sdb.DatabaseName); err != nil { 189 | t.Fatal(err) 190 | } 191 | got, err := model.FindSpoolDatabase(ctx, client.Single(), sdb.DatabaseName) 192 | if err != nil { 193 | t.Error(err) 194 | } 195 | if state := State(got.State); state != StateIdle { 196 | t.Errorf("expected %s but got %s", StateIdle, state) 197 | } 198 | } 199 | 200 | func TestPool_Clean(t *testing.T) { 201 | t.Parallel() 202 | 203 | cfg := SetupTestDatabase(t) 204 | 205 | ctx := context.Background() 206 | client, truncate := connect(ctx, t, cfg) 207 | t.Cleanup(truncate) 208 | 209 | pool := newPool(ctx, t, cfg, ddl1) 210 | sdb1 := &model.SpoolDatabase{ 211 | DatabaseName: "zoncoen-spool-test-1", 212 | Checksum: checksum(ddl1), 213 | State: StateIdle.Int64(), 214 | CreatedAt: spanner.CommitTimestamp, 215 | UpdatedAt: spanner.CommitTimestamp, 216 | } 217 | sdb2 := &model.SpoolDatabase{ 218 | DatabaseName: "zoncoen-spool-test-2", 219 | Checksum: checksum(ddl2), 220 | State: StateIdle.Int64(), 221 | CreatedAt: spanner.CommitTimestamp, 222 | UpdatedAt: spanner.CommitTimestamp, 223 | } 224 | if _, err := client.Apply(ctx, []*spanner.Mutation{sdb1.Insert(ctx), sdb2.Insert(ctx)}); err != nil { 225 | t.Fatalf("failed to setup fixture: %s", err) 226 | } 227 | 228 | if err := pool.Clean(ctx); err != nil { 229 | t.Fatal(err) 230 | } 231 | t.Run("should be deleted", func(t *testing.T) { 232 | if _, err := model.FindSpoolDatabase(ctx, client.Single(), sdb1.DatabaseName); err != nil { 233 | if !isErrNotFound(err) { 234 | t.Fatal(err) 235 | } 236 | } else { 237 | t.Fatal("should be deleted") 238 | } 239 | }) 240 | t.Run("another schema database should not be deleted", func(t *testing.T) { 241 | if _, err := model.FindSpoolDatabase(ctx, client.Single(), sdb2.DatabaseName); err != nil { 242 | t.Fatal(err) 243 | } 244 | }) 245 | } 246 | -------------------------------------------------------------------------------- /testdata/schema1.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE Books ( 2 | ISBN STRING(MAX) NOT NULL, 3 | ) PRIMARY KEY(ISBN); 4 | -------------------------------------------------------------------------------- /testdata/schema2.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE Books ( 2 | ISBN STRING(MAX) NOT NULL, 3 | Title STRING(MAX) NOT NULL, 4 | ) PRIMARY KEY(ISBN); 5 | 6 | --------------------------------------------------------------------------------