diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d5ca512 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,106 @@ +on: [push, pull_request] + +name: CI + +jobs: + test: + strategy: + matrix: + go-version: [1.14.x, 1.15.x] + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:13 + env: + POSTGRES_USER: glloq_user + POSTGRES_PASSWORD: glloq_password + POSTGRES_DB: glloq + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + mysql: + image: mysql:5.7 + env: + MYSQL_USER: glloq_user + MYSQL_PASSWORD: glloq_password + MYSQL_DATABASE: glloq + MYSQL_ROOT_PASSWORD: root_pwd + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Install Go + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + + - name: Checkout code + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Lint + uses: golangci/golangci-lint-action@v2 + with: + version: v1.31 + + - name: Test + # Quotes for "-coverprofile=cover.out" are required for Windows because of the "." in the argument name. + run: go test -v -covermode=atomic "-coverprofile=profile.cov" ./... + env: + POSTGRES_DSN: postgres://glloq_user:glloq_password@localhost:5432/glloq?sslmode=disable + MYSQL_DSN: mysql://glloq_user:glloq_password@localhost:3306/glloq + + - name: Send coverage + uses: shogo82148/actions-goveralls@v1 + if: matrix.go-version == '1.15.x' + with: + path-to-profile: profile.cov + + goreleaser: + needs: test + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + steps: + - + name: Docker Login + if: success() && startsWith(github.ref, 'refs/tags/v') + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + run: | + echo "${DOCKER_PASSWORD}" | docker login --username "${DOCKER_USERNAME}" --password-stdin + - + name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - + name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.15.x + - + name: Run GoReleaser + uses: goreleaser/goreleaser-action@v2 + with: + version: latest + args: release --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - + name: Clear Docker credentials. + if: always() + run: | + rm -f ${HOME}/.docker/config.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..deea6b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +/dist/ +/glloq +*.lock + +# Created by https://www.toptal.com/developers/gitignore/api/go +# Edit at https://www.toptal.com/developers/gitignore?templates=go + +### Go ### +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +### Go Patch ### +/vendor/ +/Godeps/ + +# End of https://www.toptal.com/developers/gitignore/api/go diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..9875b45 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,34 @@ +before: + hooks: + - go mod download + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + main: ./cmd/main.go + +dockers: + - image_templates: + - "gilbsgilbs/glloq:{{ .Tag }}" + - "gilbsgilbs/glloq:{{ .Major }}" + - "gilbsgilbs/glloq:{{ .Major }}.{{ .Minor }}" + - "gilbsgilbs/glloq:{{ .Major }}.{{ .Minor }}.{{ .Patch }}" + - "gilbsgilbs/glloq:latest" + +archives: + - replacements: + darwin: Darwin + linux: Linux + windows: Windows + 386: i386 + amd64: x86_64 + +checksum: + name_template: 'checksums.txt' + +snapshot: + name_template: "{{ .Tag }}-next" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7d47de9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM scratch +COPY glloq / +ENTRYPOINT ["/glloq"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..96ad5e2 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Glloq + +Glloq is a simple command line utility and gorm library that lets you +take an advisory lock before running some action. This is especially useful +when you want to avoid running database migrations (for example) concurrently. + +## Usage + +### As a CLI + +```bash +# Supported DSNs include PostgreSQL (CockroachDB, ...), MySQL (Maria, ...), Files. +export GLLOQ_DSN=postgres://user:password@postgres:5432/mydb?sslmode=disable + +# This wont run concurrently +glloq run_migrations.sh + +# Override default timeout of 60 seconds to 10 minutes. +export GLLOQ_TIMEOUT=600 + +# You can specify a lock ID (if the back-end supports it) +GLLOQ_KEY=concurrent0 glloq run_migrations_0.sh +GLLOQ_KEY=concurrent1 glloq run_migrations_1.sh +``` + +### As a library + +For detailed usage and examples, refer to [the godoc page]( +https://pkg.go.dev/github.com/gilbsgilbs/glloq). + +```go +import "github.com/gilbsgilbs/glloq" + +func lockWithDSN(dsn string) error { + return glloq.UseLocker( + &postgreslocker.Locker{} + &glloq.Options{ + DSN: dsn, + }, + func() error { + // Run DB migrations or anything. + }, + }) +} + +import "github.com/gilbsgilbs/glloq/postgreslocker" + +func lockWithDB(db *sql.DB) error { + locker := postgreslocker.Locker{} + locker.DB = db + + return locker.WithLock( + context.Background(), + &glloq.Options{}, + func() error { + // ... + }, + ) +} +``` diff --git a/anylocker/any.go b/anylocker/any.go new file mode 100644 index 0000000..747ae36 --- /dev/null +++ b/anylocker/any.go @@ -0,0 +1,56 @@ +package anylocker + +import ( + "context" + + "github.com/gilbsgilbs/glloq" + "github.com/gilbsgilbs/glloq/filelocker" + "github.com/gilbsgilbs/glloq/mysqllocker" + "github.com/gilbsgilbs/glloq/postgreslocker" +) + +// Locker supports locking across multiple backends. +type Locker struct { + locker glloq.Locker +} + +func (l *Locker) newLockerForDSN(dsn string) glloq.Locker { + lockers := []glloq.Locker{ + &postgreslocker.Locker{}, + &mysqllocker.Locker{}, + &filelocker.Locker{}, + } + + for _, locker := range lockers { + if locker.SupportsDSN(dsn) { + return locker + } + } + + return nil +} + +func (l *Locker) SupportsDSN(dsn string) bool { + return l.newLockerForDSN(dsn) != nil +} + +func (l *Locker) Open(ctx context.Context, dsn string) error { + l.locker = l.newLockerForDSN(dsn) + if l.locker == nil { + return glloq.ErrUnsupportedDSN + } + + return l.locker.Open(ctx, dsn) +} + +func (l *Locker) Close() error { + return l.locker.Close() +} + +func (l *Locker) WithLock( + ctx context.Context, + opts *glloq.Options, + fn func() error, +) error { + return l.locker.WithLock(ctx, opts, fn) +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..91f4b96 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/gilbsgilbs/glloq" + "github.com/gilbsgilbs/glloq/anylocker" +) + +func getGlloqOptionsFromEnv(envVars []string) (map[string]string, []string) { + opts := map[string]string{} + rest := []string{} + + for _, envVar := range envVars { + if !strings.HasPrefix(envVar, "GLLOQ_") { + rest = append(rest, envVar) + continue + } + + parts := strings.SplitN(envVar, "=", 2) + key := parts[0] + val := parts[1] + + key = strings.ToLower(strings.TrimPrefix(key, "GLLOQ_")) + opts[key] = val + } + + return opts, rest +} + +func RunGlloq(env []string, args []string) (int, error) { + opts, env := getGlloqOptionsFromEnv(env) + + dsn := opts["dsn"] + if dsn == "" { + return 1, glloq.ErrDSNNotSet + } + + timeoutSeconds, _ := strconv.Atoi(opts["timeout"]) + + lockerOptions := glloq.Options{ + DSN: dsn, + Key: opts["key"], + Timeout: time.Duration(timeoutSeconds) * time.Second, + Params: opts, + } + + if err := glloq.UseLocker( + &anylocker.Locker{}, + &lockerOptions, + func() error { + if len(args) == 0 { + return nil + } + + cmdName := args[0] + cmdArgs := args[1:] + cmd := exec.Command(cmdName, cmdArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = env + + return cmd.Run() + }); err != nil { + if exitError, isExitError := err.(*exec.ExitError); isExitError { + return exitError.ExitCode(), nil + } + + return 1, err + } + + return 0, nil +} + +func main() { + exitCode, err := RunGlloq(os.Environ(), os.Args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + } + os.Exit(exitCode) +} diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 0000000..8abeb6e --- /dev/null +++ b/cmd/main_test.go @@ -0,0 +1,54 @@ +package main_test + +import ( + "testing" + + "github.com/gilbsgilbs/glloq" + cmd "github.com/gilbsgilbs/glloq/cmd" + "github.com/stretchr/testify/assert" +) + +func TestCli(t *testing.T) { + env := []string{ + "GLLOQ_DSN=file://.lock", + "GLLOQ_POLL_DELAY=1", + "GLLOQ_TIMEOUT=5", + } + + t.Run("test DSN not set", func(t *testing.T) { + exitCode, err := cmd.RunGlloq([]string{}, []string{}) + assert.Equal(t, 1, exitCode) + assert.Equal(t, glloq.ErrDSNNotSet, err) + }) + + t.Run("runs", func(t *testing.T) { + exitCode, err := cmd.RunGlloq(env, []string{}) + assert.Equal(t, 0, exitCode) + assert.Nil(t, err) + }) + + t.Run("runs concurrent run", func(t *testing.T) { + go func() { + if _, err := cmd.RunGlloq( + env, + []string{"sleep", "5"}, + ); err != nil { + panic(err) + } + }() + + exitCode, err := cmd.RunGlloq(env, []string{"sleep", "1"}) + + assert.Equal(t, 0, exitCode) + assert.Nil(t, err) + }) + + t.Run("forwards errors", func(t *testing.T) { + cmdArgs := []string{"sh", "-c", "exit 22"} + + exitCode, err := cmd.RunGlloq(env, cmdArgs) + + assert.Equal(t, 22, exitCode) + assert.Nil(t, err) + }) +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..6b8b9ae --- /dev/null +++ b/errors.go @@ -0,0 +1,9 @@ +package glloq + +import "errors" + +var ( + ErrUnsupportedDSN = errors.New("glloq: DSN unknown or unsupported") + ErrDSNNotSet = errors.New("glloq: GLLOQ_DSN environment is not set") + ErrTimeout = errors.New("glloq: timeout") +) diff --git a/filelocker/file.go b/filelocker/file.go new file mode 100644 index 0000000..5cb945e --- /dev/null +++ b/filelocker/file.go @@ -0,0 +1,57 @@ +package filelocker + +import ( + "context" + "log" + "strconv" + "strings" + "time" + + "github.com/gilbsgilbs/glloq" + "github.com/theckman/go-flock" +) + +// Locker supports locking a local file. +type Locker struct { +} + +func (l *Locker) SupportsDSN(dsn string) bool { + return strings.HasPrefix(dsn, "file://") || strings.HasPrefix(dsn, "/") +} + +func (l *Locker) Open(ctx context.Context, dsn string) error { + return nil +} + +func (l *Locker) Close() error { + return nil +} + +func (l *Locker) WithLock( + ctx context.Context, + opts *glloq.Options, + fn func() error, +) error { + dsn := strings.TrimPrefix(opts.DSN, "file://") + + fl := flock.New(dsn) + defer func() { + if err := fl.Unlock(); err != nil { + log.Println("Warning, couldn't unlock file:", err) + } + }() + + pollDelayMilliseconds, err := strconv.Atoi(opts.Params["poll_delay"]) + if err != nil { + pollDelayMilliseconds = 1 + } + + if _, err := fl.TryLockContext( + ctx, + time.Duration(pollDelayMilliseconds)*time.Millisecond, + ); err != nil { + return err + } + + return fn() +} diff --git a/glloq.go b/glloq.go new file mode 100644 index 0000000..e06431c --- /dev/null +++ b/glloq.go @@ -0,0 +1,77 @@ +/* +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// glloq implements advisory locks on various backends. +// +// Checkout https://github.com/gilbsgilbs/glloq for a quick overview. +package glloq + +import ( + "context" + "errors" + "log" + "time" +) + +// Options lock options. +type Options struct { + // DSN is the connection string to the database. + DSN string + + // Key is a lock key. + Key string + + // Timeout defines how long to wait for the backend to be up. + Timeout time.Duration + + // Params are beckend-specific options. + Params map[string]string +} + +// UseLocker waits for a locker to hold the lock then calls fn(). +func UseLocker(locker Locker, opts *Options, fn func() error) error { + if !locker.SupportsDSN(opts.DSN) { + return ErrUnsupportedDSN + } + + timeout := opts.Timeout + if timeout == time.Duration(0) { + timeout = 60 * time.Second + } + + ctx, cancel := context.WithDeadline( + context.Background(), + time.Now().Add(timeout), + ) + defer cancel() + + for { + err := locker.Open(ctx, opts.DSN) + if err == nil { + defer locker.Close() + break + } + + log.Printf("glloq: couldn't open locker (%s). Retrying...\n", err) + select { + case <-ctx.Done(): + return ErrTimeout + case <-time.After(1 * time.Second): + } + } + + err := locker.WithLock(ctx, opts, fn) + if errors.Is(err, context.DeadlineExceeded) { + return ErrTimeout + } + return err +} diff --git a/glloq_test.go b/glloq_test.go new file mode 100644 index 0000000..cf1bb34 --- /dev/null +++ b/glloq_test.go @@ -0,0 +1,327 @@ +package glloq_test + +import ( + "context" + "database/sql" + "errors" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/gilbsgilbs/glloq" + "github.com/gilbsgilbs/glloq/anylocker" + "github.com/gilbsgilbs/glloq/filelocker" + "github.com/gilbsgilbs/glloq/mysqllocker" + "github.com/gilbsgilbs/glloq/postgreslocker" +) + +func TestGlloq(t *testing.T) { + postgresDSN := os.Getenv("POSTGRES_DSN") + if postgresDSN == "" { + postgresDSN = "postgresql://root:root@localhost:5432/glloq?sslmode=disable" + } + + mysqlDSN := os.Getenv("MYSQL_DSN") + if mysqlDSN == "" { + mysqlDSN = "mysql://root:root@localhost:3306/glloq" + } + + testCases := []struct { + name string + dsn string + params map[string]string + }{ + { + name: "File", + dsn: "file://.lock", + }, + { + name: "PostgreSQL", + dsn: postgresDSN, + }, + { + name: "MySQL", + dsn: mysqlDSN, + params: map[string]string{"poll_delay": "1"}, + }, + } + + for _, testCase := range testCases { + dsn := testCase.dsn + t.Run(testCase.name, func(t *testing.T) { + t.Run("test lock function is called", func(t *testing.T) { + called := false + err := glloq.UseLocker( + &anylocker.Locker{}, + &glloq.Options{ + DSN: dsn, + Key: t.Name(), + Params: testCase.params, + }, + func() error { + called = true + return nil + }, + ) + + assert.Nil(t, err) + assert.True(t, called) + }) + + t.Run("test lock function forwards errors", func(t *testing.T) { + myError := errors.New("my custom error") + err := glloq.UseLocker( + &anylocker.Locker{}, + &glloq.Options{ + DSN: dsn, + Key: t.Name(), + Params: testCase.params, + }, + func() error { + return myError + }, + ) + + assert.Equal(t, myError, err) + }) + + t.Run("test concurrent locks", func(t *testing.T) { + oneStarted := false + oneDone := false + oneCh := make(chan bool, 1) + go func() { + if err := glloq.UseLocker( + &anylocker.Locker{}, + &glloq.Options{ + DSN: dsn, + Key: t.Name(), + Params: testCase.params, + }, + func() error { + oneStarted = true + <-oneCh + oneDone = true + return nil + }, + ); err != nil { + panic(err) + } + }() + + time.Sleep(50 * time.Millisecond) + + twoStarted := false + twoDone := false + twoCh := make(chan bool, 1) + go func() { + if err := glloq.UseLocker( + &anylocker.Locker{}, + &glloq.Options{ + DSN: dsn, + Key: t.Name(), + Params: testCase.params, + }, + func() error { + twoStarted = true + <-twoCh + twoDone = true + return nil + }, + ); err != nil { + panic(err) + } + }() + + time.Sleep(50 * time.Millisecond) + + assert.True(t, oneStarted) + assert.False(t, oneDone) + assert.False(t, twoStarted) + assert.False(t, twoDone) + + oneCh <- true + time.Sleep(50 * time.Millisecond) + + assert.True(t, oneDone) + assert.False(t, twoDone) + + twoCh <- true + time.Sleep(50 * time.Millisecond) + + assert.True(t, twoDone) + }) + + t.Run("test timeout", func(t *testing.T) { + ch := make(chan bool, 1) + go func() { + if err := glloq.UseLocker( + &anylocker.Locker{}, + &glloq.Options{ + DSN: dsn, + Key: t.Name(), + Params: testCase.params, + }, + func() error { + <-ch + return nil + }, + ); err != nil { + panic(err) + } + }() + + time.Sleep(50 * time.Millisecond) + + err := glloq.UseLocker( + &anylocker.Locker{}, + &glloq.Options{ + DSN: dsn, + Key: t.Name(), + Timeout: 50 * time.Millisecond, + Params: testCase.params, + }, + func() error { + return nil + }, + ) + assert.Equal(t, glloq.ErrTimeout, err) + + close(ch) + }) + }) + } + + t.Run("test connection timeout", func(t *testing.T) { + called := false + err := glloq.UseLocker( + &anylocker.Locker{}, + &glloq.Options{ + DSN: "postgresql://127.254.254.254:1333", + Timeout: 100 * time.Millisecond, + }, + func() error { + called = true + return nil + }, + ) + + assert.False(t, called) + assert.Equal(t, err, glloq.ErrTimeout) + }) + + t.Run("unsupported DSN", func(t *testing.T) { + err := glloq.UseLocker( + &anylocker.Locker{}, + &glloq.Options{ + DSN: "foobar://barbaz", + }, + func() error { + return nil + }, + ) + assert.Equal(t, glloq.ErrUnsupportedDSN, err) + }) +} + +// The simplest way to use glloq is by using a data source name (DSN). UseLocker will take +// care of opening and closing any connection or socket, will wait for the backend to be +// available and will hold the lock and release it when you're finished. +func Example() { + err := glloq.UseLocker( + &anylocker.Locker{}, + &glloq.Options{ + // This is a connection string to your backend. For SQL-based backends, + // dburl (https://github.com/xo/dburl) is used. + DSN: "postgres://user:password@localhost:5432/db?sslmode=disable", + // DSN: "mysql://user:password@localhost:3006/db", + // DSN: "file://.lock", + + // Maximum time to wait for the backend and the lock. Defaults to 1 minute. + Timeout: 1 * time.Hour, + + // An optional lock key, if supported by the backend. + Key: "someUniqueKey", + + // backend-specific parameters. + Params: map[string]string{}, + }, + func() error { + // You can run any synchronized operation here, such as database migrations. + // It won't run concurrently. + return nil + }, + ) + if err != nil { + panic(err) + } +} + +// The file locker allows you take a lock using a local file. +func Example_fileLocker() { + locker := filelocker.Locker{} + err := locker.WithLock( + context.Background(), + &glloq.Options{ + DSN: "file:///tmp/myAppLockFile.lock", + }, + func() error { + // ... + return nil + }, + ) + if err != nil { + panic(err) + } +} + +func Example_postgresLocker() { + var db *sql.DB + + locker := postgreslocker.Locker{} + locker.DB = db + + err := locker.WithLock( + context.Background(), + &glloq.Options{ + Params: map[string]string{ + // Name of the table that will be created to take locks. + // Defaults to glloq. + "table_name": "my_lock_table", + }, + }, + func() error { + // ... + return nil + }, + ) + if err != nil { + panic(err) + } +} + +func Example_mysqlLocker() { + var db *sql.DB + + locker := mysqllocker.Locker{} + locker.DB = db + + err := locker.WithLock( + context.Background(), + &glloq.Options{ + Params: map[string]string{ + // Name of the table that will be created to take locks. + // Defaults to glloq. + "table_name": "my_lock_table", + }, + }, + func() error { + // ... + return nil + }, + ) + if err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..21ba4e7 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/gilbsgilbs/glloq + +go 1.15 + +require ( + github.com/cockroachdb/cockroach-go/v2 v2.0.8 + github.com/go-sql-driver/mysql v1.5.0 + github.com/lib/pq v1.4.0 + github.com/stretchr/testify v1.5.1 + github.com/theckman/go-flock v0.8.0 + github.com/xo/dburl v0.0.0-20200910011426-652e0d5720a3 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..57d5f29 --- /dev/null +++ b/go.sum @@ -0,0 +1,139 @@ +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/cockroachdb/cockroach-go v2.0.1+incompatible h1:rkk9T7FViadPOz28xQ68o18jBSpyShru0mayVumxqYA= +github.com/cockroachdb/cockroach-go v2.0.1+incompatible/go.mod h1:XGLbWH/ujMcbPbhZq52Nv6UrCghb1yGn//133kEsvDk= +github.com/cockroachdb/cockroach-go/v2 v2.0.8 h1:50C/7ptrrfdxDccCjDU0xsdeBca+S0/AYW4Mo8RyzFE= +github.com/cockroachdb/cockroach-go/v2 v2.0.8/go.mod h1:nkf7rUmgPdawp3EwRjXIumihI2AYg9usGNWbJ2hsJqI= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= +github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= +github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= +github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= +github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= +github.com/jackc/pgtype v1.3.0/go.mod h1:b0JqxHvPmljG+HQ5IsvQ0yqeSi4nGcDTVjFoiLDb0Ik= +github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= +github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= +github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= +github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= +github.com/jackc/pgx/v4 v4.6.0/go.mod h1:vPh43ZzxijXUVJ+t/EmXBtFmbFVO72cuneCT9oAlxAg= +github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jinzhu/gorm v1.9.12/go.mod h1:vhTjlKSJUTWNtcbQtrMBFCxy7eXTzeCAzfL5fBZT/Qs= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= +github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.4.0 h1:TmtCFbH+Aw0AixwyttznSMQDgbR5Yed/Gg6S8Funrhc= +github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v0.0.0-20200419222939-1884f454f8ea/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/theckman/go-flock v0.8.0/go.mod h1:kjuth3y9VJ2aNlkNEO99G/8lp9fMIKaGyBmh84IBheM= +github.com/xo/dburl v0.0.0-20200910011426-652e0d5720a3 h1:WchQs0yWhP3iA3CFE57fmCltE5dx5FkbUZOJZBjEtJ8= +github.com/xo/dburl v0.0.0-20200910011426-652e0d5720a3/go.mod h1:TM8VMBT+LWqC3MBOulZjb8FAthcvZq0t/qvDLwS6skU= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/locker.go b/locker.go new file mode 100644 index 0000000..36c0dc3 --- /dev/null +++ b/locker.go @@ -0,0 +1,58 @@ +package glloq + +import ( + "context" + "database/sql" + + "github.com/xo/dburl" +) + +type Locker interface { + // SupportsDSN returns true if the DSN is supported by the Locker. + SupportsDSN(dsn string) bool + + // Open allows the locker to open a connection to the backend. + Open(ctx context.Context, dsn string) error + + // Close allows the locker to close the connection to the backend + Close() error + + // Holds the lock. Returns ErrTimeout if context is done. + WithLock(ctx context.Context, opts *Options, fn func() error) error +} + +// SQLLocker implements some base locker methods for SQL-based backends. +type SQLLocker struct { + DB *sql.DB +} + +func (l *SQLLocker) DBUrlDriver(dsn string) string { + u, err := dburl.Parse(dsn) + if err != nil { + return "" + } + return u.Driver +} + +func (l *SQLLocker) Open(ctx context.Context, dsn string) error { + db, err := dburl.Open(dsn) + if err != nil { + return err + } + + if err := db.PingContext(ctx); err != nil { + defer db.Close() + return err + } + + l.DB = db + return nil +} + +func (l *SQLLocker) Close() error { + if l.DB != nil { + return l.DB.Close() + } + + return nil +} diff --git a/mysqllocker/mysql.go b/mysqllocker/mysql.go new file mode 100644 index 0000000..f851a15 --- /dev/null +++ b/mysqllocker/mysql.go @@ -0,0 +1,84 @@ +package mysqllocker + +import ( + "context" + "database/sql" + "strings" + + "github.com/gilbsgilbs/glloq" + _ "github.com/go-sql-driver/mysql" +) + +// Locker supports MySQL / MariaDB. +type Locker struct { + glloq.SQLLocker +} + +func (l *Locker) SupportsDSN(dsn string) bool { + return l.DBUrlDriver(dsn) == "mysql" +} + +func (l *Locker) quoteIdentifier(identifier string) string { + // no driver built-in for this? + return "`" + strings.ReplaceAll(identifier, "`", "``") + "`" +} + +func (l *Locker) withTx(db *sql.DB, fn func(tx *sql.Tx) error) error { + tx, err := db.Begin() + if err != nil { + return err + } + err = fn(tx) + commitErr := tx.Commit() + if err != nil { + return err + } + return commitErr +} + +func (l *Locker) WithLock( + ctx context.Context, + opts *glloq.Options, + fn func() error, +) error { + db := l.DB + + tableName := opts.Params["table_name"] + if tableName == "" { + tableName = "glloq" + } + quotedTableName := l.quoteIdentifier(tableName) + + // Can't use an advisory lock because they can't be tight to a transaction. + // So it could leak locks. + if _, err := db.ExecContext( + ctx, + "CREATE TABLE IF NOT EXISTS "+quotedTableName+" ("+ + " id VARCHAR(100) NOT NULL PRIMARY KEY"+ + ");", + ); err != nil { + return err + } + + if _, err := db.ExecContext( + ctx, + "INSERT IGNORE INTO "+quotedTableName+"(id) VALUES (?);", + opts.Key, + ); err != nil { + return err + } + + return l.withTx(db, func(tx *sql.Tx) error { + if _, err := tx.ExecContext( + ctx, + "SELECT * FROM "+quotedTableName+" "+ + "WHERE id = ? "+ + "FOR UPDATE;", + opts.Key, + ); err != nil { + return err + } + + return fn() + }) +} diff --git a/postgreslocker/postgres.go b/postgreslocker/postgres.go new file mode 100644 index 0000000..4239e28 --- /dev/null +++ b/postgreslocker/postgres.go @@ -0,0 +1,77 @@ +package postgreslocker + +import ( + "context" + "database/sql" + + "github.com/cockroachdb/cockroach-go/v2/crdb" + "github.com/gilbsgilbs/glloq" + "github.com/lib/pq" +) + +// Locker supports PostgreSQL (CockroachDB, …). +type Locker struct { + glloq.SQLLocker +} + +func (l *Locker) SupportsDSN(dsn string) bool { + return l.DBUrlDriver(dsn) == "postgres" +} + +func (l *Locker) wrapError(err error) error { + pqErr, isPqErr := err.(*pq.Error) + if isPqErr && pqErr.Code.Name() == "query_canceled" { + return context.DeadlineExceeded + } + + return err +} + +func (l *Locker) WithLock( + ctx context.Context, + opts *glloq.Options, + fn func() error, +) error { + db := l.DB + + tableName := opts.Params["table_name"] + if tableName == "" { + tableName = "glloq" + } + quotedTableName := pq.QuoteIdentifier(tableName) + + // can't use an advisory lock because CockroachDB doesn't support them. + if _, err := db.ExecContext( + ctx, + "CREATE TABLE IF NOT EXISTS "+quotedTableName+"("+ + " key text,"+ + " CONSTRAINT glloq_pk PRIMARY KEY(key)"+ + ");", + ); err != nil { + return l.wrapError(err) + } + + return crdb.ExecuteTx(context.Background(), db, &sql.TxOptions{}, func(tx *sql.Tx) error { + if _, err := tx.ExecContext( + ctx, + "INSERT INTO "+quotedTableName+"(key) "+ + "VALUES ($1) "+ + "ON CONFLICT ON CONSTRAINT glloq_pk DO NOTHING;", + opts.Key, + ); err != nil { + return l.wrapError(err) + } + + if _, err := tx.ExecContext( + ctx, + "SELECT * FROM "+quotedTableName+" "+ + "WHERE key = $1 "+ + "FOR UPDATE;", + opts.Key, + ); err != nil { + return l.wrapError(err) + } + + return fn() + }) +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..f45d8f1 --- /dev/null +++ b/renovate.json @@ -0,0 +1,5 @@ +{ + "extends": [ + "config:base" + ] +}