forked from cashapp/spirit
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request cashapp#308 from cashapp/303-add-get_lock-to-preve…
…nt-concurrent-table-modifications prevent concurrent table modifications
- Loading branch information
Showing
4 changed files
with
269 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
package dbconn | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"fmt" | ||
"time" | ||
|
||
"github.com/siddontang/loggers" | ||
) | ||
|
||
var ( | ||
// getLockTimeout is the timeout for acquiring the GET_LOCK. We set it to 0 | ||
// because we want to return immediately if the lock is not available | ||
getLockTimeout = 0 * time.Second | ||
refreshInterval = 1 * time.Minute | ||
) | ||
|
||
type MetadataLock struct { | ||
cancel context.CancelFunc | ||
closeCh chan error | ||
refreshInterval time.Duration | ||
} | ||
|
||
func NewMetadataLock(ctx context.Context, dsn string, lockName string, logger loggers.Advanced, optionFns ...func(*MetadataLock)) (*MetadataLock, error) { | ||
if len(lockName) == 0 { | ||
return nil, errors.New("metadata lock name is empty") | ||
} | ||
if len(lockName) > 64 { | ||
return nil, fmt.Errorf("metadata lock name is too long: %d, max length is 64", len(lockName)) | ||
} | ||
|
||
mdl := &MetadataLock{ | ||
refreshInterval: refreshInterval, | ||
} | ||
|
||
// Apply option functions | ||
for _, optionFn := range optionFns { | ||
optionFn(mdl) | ||
} | ||
|
||
// Setup the dedicated connection for this lock | ||
dbConfig := NewDBConfig() | ||
dbConfig.MaxOpenConnections = 1 | ||
dbConn, err := New(dsn, dbConfig) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
// Function to acquire the lock | ||
getLock := func() error { | ||
// https://dev.mysql.com/doc/refman/8.0/en/locking-functions.html#function_get-lock | ||
var answer int | ||
if err := dbConn.QueryRowContext(ctx, "SELECT GET_LOCK(?, ?)", lockName, getLockTimeout.Seconds()).Scan(&answer); err != nil { | ||
return fmt.Errorf("could not acquire metadata lock: %s", err) | ||
} | ||
if answer == 0 { | ||
// 0 means the lock is held by another connection | ||
// TODO: we could lookup the connection that holds the lock and report details about it | ||
return fmt.Errorf("could not acquire metadata lock: %s, lock is held by another connection", lockName) | ||
} else if answer != 1 { | ||
// probably we never get here, but just in case | ||
return fmt.Errorf("could not acquire metadata lock: %s, GET_LOCK returned: %d", lockName, answer) | ||
} | ||
return nil | ||
} | ||
|
||
// Acquire the lock or return an error immediately | ||
logger.Infof("attempting to acquire metadata lock: %s", lockName) | ||
if err = getLock(); err != nil { | ||
return nil, err | ||
} | ||
logger.Infof("acquired metadata lock: %s", lockName) | ||
|
||
// Setup background refresh runner | ||
ctx, mdl.cancel = context.WithCancel(ctx) | ||
mdl.closeCh = make(chan error) | ||
go func() { | ||
ticker := time.NewTicker(mdl.refreshInterval) | ||
defer ticker.Stop() | ||
for { | ||
select { | ||
case <-ctx.Done(): | ||
// Close the dedicated connection to release the lock | ||
logger.Warnf("releasing metadata lock: %s", lockName) | ||
mdl.closeCh <- dbConn.Close() | ||
return | ||
case <-ticker.C: | ||
if err = getLock(); err != nil { | ||
logger.Errorf("could not refresh metadata lock: %s", err) | ||
} | ||
logger.Infof("refreshed metadata lock: %s", lockName) | ||
} | ||
} | ||
}() | ||
|
||
return mdl, nil | ||
} | ||
|
||
func (m *MetadataLock) Close() error { | ||
// Cancel the background refresh runner | ||
m.cancel() | ||
|
||
// Wait for the dedicated connection to be closed and return its error (if any) | ||
return <-m.closeCh | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
package dbconn | ||
|
||
import ( | ||
"context" | ||
"testing" | ||
"time" | ||
|
||
"github.com/cashapp/spirit/pkg/testutils" | ||
"github.com/sirupsen/logrus" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func TestMetadataLock(t *testing.T) { | ||
lockName := "test" | ||
logger := logrus.New() | ||
mdl, err := NewMetadataLock(context.Background(), testutils.DSN(), lockName, logger) | ||
assert.NoError(t, err) | ||
assert.NotNil(t, mdl) | ||
|
||
// Confirm a second lock cannot be acquired | ||
_, err = NewMetadataLock(context.Background(), testutils.DSN(), lockName, logger) | ||
assert.ErrorContains(t, err, "lock is held by another connection") | ||
|
||
// Close the original mdl | ||
assert.NoError(t, mdl.Close()) | ||
|
||
// Confirm a new lock can be acquired | ||
mdl3, err := NewMetadataLock(context.Background(), testutils.DSN(), lockName, logger) | ||
assert.NoError(t, err) | ||
assert.NoError(t, mdl3.Close()) | ||
} | ||
|
||
func TestMetadataLockContextCancel(t *testing.T) { | ||
lockName := "test-cancel" | ||
|
||
logger := logrus.New() | ||
ctx, cancel := context.WithCancel(context.Background()) | ||
mdl, err := NewMetadataLock(ctx, testutils.DSN(), lockName, logger) | ||
assert.NoError(t, err) | ||
assert.NotNil(t, mdl) | ||
|
||
// Cancel the context | ||
cancel() | ||
|
||
// Wait for the lock to be released | ||
<-mdl.closeCh | ||
|
||
// Confirm the lock is released by acquiring a new one | ||
mdl2, err := NewMetadataLock(context.Background(), testutils.DSN(), lockName, logger) | ||
assert.NoError(t, err) | ||
assert.NotNil(t, mdl2) | ||
assert.NoError(t, mdl2.Close()) | ||
} | ||
|
||
func TestMetadataLockRefresh(t *testing.T) { | ||
lockName := "test-refresh" | ||
logger := logrus.New() | ||
mdl, err := NewMetadataLock(context.Background(), testutils.DSN(), lockName, logger, func(mdl *MetadataLock) { | ||
// override the refresh interval for faster testing | ||
mdl.refreshInterval = 2 * time.Second | ||
}) | ||
assert.NoError(t, err) | ||
assert.NotNil(t, mdl) | ||
|
||
// wait for the refresh to happen | ||
time.Sleep(5 * time.Second) | ||
|
||
// Confirm the lock is still held | ||
_, err = NewMetadataLock(context.Background(), testutils.DSN(), lockName, logger) | ||
assert.ErrorContains(t, err, "lock is held by another connection") | ||
|
||
// Close the lock | ||
assert.NoError(t, mdl.Close()) | ||
} | ||
|
||
func TestMetadataLockLength(t *testing.T) { | ||
long := "thisisareallylongtablenamethisisareallylongtablenamethisisareallylongtablename" | ||
empty := "" | ||
|
||
logger := logrus.New() | ||
|
||
_, err := NewMetadataLock(context.Background(), testutils.DSN(), long, logger) | ||
assert.ErrorContains(t, err, "metadata lock name is too long") | ||
|
||
_, err = NewMetadataLock(context.Background(), testutils.DSN(), empty, logger) | ||
assert.ErrorContains(t, err, "metadata lock name is empty") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters