├── .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 | [](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 | 
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 |
--------------------------------------------------------------------------------