Merge branch 'main' into allspice/workflow-link

This commit is contained in:
william-allspice 2024-08-29 10:10:51 -05:00 committed by GitHub
commit 0dcfd792e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 3796 additions and 583 deletions

View File

@ -27,20 +27,20 @@ func DefaultLocker() Locker {
// Lock tries to acquire a lock for the given key, it uses the default locker.
// Read the documentation of Locker.Lock for more information about the behavior.
func Lock(ctx context.Context, key string) (context.Context, ReleaseFunc, error) {
func Lock(ctx context.Context, key string) (ReleaseFunc, error) {
return DefaultLocker().Lock(ctx, key)
}
// TryLock tries to acquire a lock for the given key, it uses the default locker.
// Read the documentation of Locker.TryLock for more information about the behavior.
func TryLock(ctx context.Context, key string) (bool, context.Context, ReleaseFunc, error) {
func TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error) {
return DefaultLocker().TryLock(ctx, key)
}
// LockAndDo tries to acquire a lock for the given key and then calls the given function.
// It uses the default locker, and it will return an error if failed to acquire the lock.
func LockAndDo(ctx context.Context, key string, f func(context.Context) error) error {
ctx, release, err := Lock(ctx, key)
release, err := Lock(ctx, key)
if err != nil {
return err
}
@ -52,7 +52,7 @@ func LockAndDo(ctx context.Context, key string, f func(context.Context) error) e
// TryLockAndDo tries to acquire a lock for the given key and then calls the given function.
// It uses the default locker, and it will return false if failed to acquire the lock.
func TryLockAndDo(ctx context.Context, key string, f func(context.Context) error) (bool, error) {
ok, ctx, release, err := TryLock(ctx, key)
ok, release, err := TryLock(ctx, key)
if err != nil {
return false, err
}

View File

@ -5,56 +5,34 @@ package globallock
import (
"context"
"fmt"
)
type Locker interface {
// Lock tries to acquire a lock for the given key, it blocks until the lock is acquired or the context is canceled.
//
// Lock returns a new context which should be used in the following code.
// The new context will be canceled when the lock is released or lost - yes, it's possible to lose a lock.
// For example, it lost the connection to the redis server while holding the lock.
// If it fails to acquire the lock, the returned context will be the same as the input context.
//
// Lock returns a ReleaseFunc to release the lock, it cannot be nil.
// It's always safe to call this function even if it fails to acquire the lock, and it will do nothing in that case.
// And it's also safe to call it multiple times, but it will only release the lock once.
// That's why it's called ReleaseFunc, not UnlockFunc.
// But be aware that it's not safe to not call it at all; it could lead to a memory leak.
// So a recommended pattern is to use defer to call it:
// ctx, release, err := locker.Lock(ctx, "key")
// release, err := locker.Lock(ctx, "key")
// if err != nil {
// return err
// }
// defer release()
// The ReleaseFunc will return the original context which was used to acquire the lock.
// It's useful when you want to continue to do something after releasing the lock.
// At that time, the ctx will be canceled, and you can use the returned context by the ReleaseFunc to continue:
// ctx, release, err := locker.Lock(ctx, "key")
// if err != nil {
// return err
// }
// defer release()
// doSomething(ctx)
// ctx = release()
// doSomethingElse(ctx)
// Please ignore it and use `defer release()` instead if you don't need this, to avoid forgetting to release the lock.
//
// Lock returns an error if failed to acquire the lock.
// Be aware that even the context is not canceled, it's still possible to fail to acquire the lock.
// For example, redis is down, or it reached the maximum number of tries.
Lock(ctx context.Context, key string) (context.Context, ReleaseFunc, error)
Lock(ctx context.Context, key string) (ReleaseFunc, error)
// TryLock tries to acquire a lock for the given key, it returns immediately.
// It follows the same pattern as Lock, but it doesn't block.
// And if it fails to acquire the lock because it's already locked, not other reasons like redis is down,
// it will return false without any error.
TryLock(ctx context.Context, key string) (bool, context.Context, ReleaseFunc, error)
TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error)
}
// ReleaseFunc is a function that releases a lock.
// It returns the original context which was used to acquire the lock.
type ReleaseFunc func() context.Context
// ErrLockReleased is used as context cause when a lock is released
var ErrLockReleased = fmt.Errorf("lock released")
type ReleaseFunc func()

View File

@ -47,27 +47,24 @@ func TestLocker(t *testing.T) {
func testLocker(t *testing.T, locker Locker) {
t.Run("lock", func(t *testing.T) {
parentCtx := context.Background()
ctx, release, err := locker.Lock(parentCtx, "test")
release, err := locker.Lock(parentCtx, "test")
defer release()
assert.NotEqual(t, parentCtx, ctx) // new context should be returned
assert.NoError(t, err)
func() {
parentCtx, cancel := context.WithTimeout(context.Background(), time.Second)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
ctx, release, err := locker.Lock(parentCtx, "test")
release, err := locker.Lock(ctx, "test")
defer release()
assert.Error(t, err)
assert.Equal(t, parentCtx, ctx) // should return the same context
}()
release()
assert.Error(t, ctx.Err())
func() {
_, release, err := locker.Lock(context.Background(), "test")
release, err := locker.Lock(context.Background(), "test")
defer release()
assert.NoError(t, err)
@ -76,29 +73,26 @@ func testLocker(t *testing.T, locker Locker) {
t.Run("try lock", func(t *testing.T) {
parentCtx := context.Background()
ok, ctx, release, err := locker.TryLock(parentCtx, "test")
ok, release, err := locker.TryLock(parentCtx, "test")
defer release()
assert.True(t, ok)
assert.NotEqual(t, parentCtx, ctx) // new context should be returned
assert.NoError(t, err)
func() {
parentCtx, cancel := context.WithTimeout(context.Background(), time.Second)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
ok, ctx, release, err := locker.TryLock(parentCtx, "test")
ok, release, err := locker.TryLock(ctx, "test")
defer release()
assert.False(t, ok)
assert.NoError(t, err)
assert.Equal(t, parentCtx, ctx) // should return the same context
}()
release()
assert.Error(t, ctx.Err())
func() {
ok, _, release, _ := locker.TryLock(context.Background(), "test")
ok, release, _ := locker.TryLock(context.Background(), "test")
defer release()
assert.True(t, ok)
@ -107,7 +101,7 @@ func testLocker(t *testing.T, locker Locker) {
t.Run("wait and acquired", func(t *testing.T) {
ctx := context.Background()
_, release, err := locker.Lock(ctx, "test")
release, err := locker.Lock(ctx, "test")
require.NoError(t, err)
wg := &sync.WaitGroup{}
@ -115,7 +109,7 @@ func testLocker(t *testing.T, locker Locker) {
go func() {
defer wg.Done()
started := time.Now()
_, release, err := locker.Lock(context.Background(), "test") // should be blocked for seconds
release, err := locker.Lock(context.Background(), "test") // should be blocked for seconds
defer release()
assert.Greater(t, time.Since(started), time.Second)
assert.NoError(t, err)
@ -127,34 +121,15 @@ func testLocker(t *testing.T, locker Locker) {
wg.Wait()
})
t.Run("continue after release", func(t *testing.T) {
ctx := context.Background()
ctxBeforeLock := ctx
ctx, release, err := locker.Lock(ctx, "test")
require.NoError(t, err)
assert.NoError(t, ctx.Err())
assert.NotEqual(t, ctxBeforeLock, ctx)
ctxBeforeRelease := ctx
ctx = release()
assert.NoError(t, ctx.Err())
assert.Error(t, ctxBeforeRelease.Err())
// so it can continue with ctx to do more work
})
t.Run("multiple release", func(t *testing.T) {
ctx := context.Background()
_, release1, err := locker.Lock(ctx, "test")
release1, err := locker.Lock(ctx, "test")
require.NoError(t, err)
release1()
_, release2, err := locker.Lock(ctx, "test")
release2, err := locker.Lock(ctx, "test")
defer release2()
require.NoError(t, err)
@ -163,7 +138,7 @@ func testLocker(t *testing.T, locker Locker) {
// and it shouldn't affect the other lock
release1()
ok, _, release3, err := locker.TryLock(ctx, "test")
ok, release3, err := locker.TryLock(ctx, "test")
defer release3()
require.NoError(t, err)
// It should be able to acquire the lock;
@ -184,28 +159,23 @@ func testRedisLocker(t *testing.T, locker *redisLocker) {
// Otherwise, it will affect other tests.
t.Run("close", func(t *testing.T) {
assert.NoError(t, locker.Close())
_, _, err := locker.Lock(context.Background(), "test")
_, err := locker.Lock(context.Background(), "test")
assert.Error(t, err)
})
}()
t.Run("failed extend", func(t *testing.T) {
ctx, release, err := locker.Lock(context.Background(), "test")
release, err := locker.Lock(context.Background(), "test")
defer release()
require.NoError(t, err)
// It simulates that there are some problems with extending like network issues or redis server down.
v, ok := locker.mutexM.Load("test")
require.True(t, ok)
m := v.(*redisMutex)
_, _ = m.mutex.Unlock() // release it to make it impossible to extend
m := v.(*redsync.Mutex)
_, _ = m.Unlock() // release it to make it impossible to extend
select {
case <-time.After(redisLockExpiry + time.Second):
t.Errorf("lock should be expired")
case <-ctx.Done():
var errTaken *redsync.ErrTaken
assert.ErrorAs(t, context.Cause(ctx), &errTaken)
}
// In current design, callers can't know the lock can't be extended.
// Just keep this case to improve the test coverage.
})
}

View File

@ -19,18 +19,13 @@ func NewMemoryLocker() Locker {
return &memoryLocker{}
}
func (l *memoryLocker) Lock(ctx context.Context, key string) (context.Context, ReleaseFunc, error) {
originalCtx := ctx
func (l *memoryLocker) Lock(ctx context.Context, key string) (ReleaseFunc, error) {
if l.tryLock(key) {
ctx, cancel := context.WithCancelCause(ctx)
releaseOnce := sync.Once{}
return ctx, func() context.Context {
return func() {
releaseOnce.Do(func() {
l.locks.Delete(key)
cancel(ErrLockReleased)
})
return originalCtx
}, nil
}
@ -39,39 +34,31 @@ func (l *memoryLocker) Lock(ctx context.Context, key string) (context.Context, R
for {
select {
case <-ctx.Done():
return ctx, func() context.Context { return originalCtx }, ctx.Err()
return func() {}, ctx.Err()
case <-ticker.C:
if l.tryLock(key) {
ctx, cancel := context.WithCancelCause(ctx)
releaseOnce := sync.Once{}
return ctx, func() context.Context {
return func() {
releaseOnce.Do(func() {
l.locks.Delete(key)
cancel(ErrLockReleased)
})
return originalCtx
}, nil
}
}
}
}
func (l *memoryLocker) TryLock(ctx context.Context, key string) (bool, context.Context, ReleaseFunc, error) {
originalCtx := ctx
func (l *memoryLocker) TryLock(_ context.Context, key string) (bool, ReleaseFunc, error) {
if l.tryLock(key) {
ctx, cancel := context.WithCancelCause(ctx)
releaseOnce := sync.Once{}
return true, ctx, func() context.Context {
return true, func() {
releaseOnce.Do(func() {
cancel(ErrLockReleased)
l.locks.Delete(key)
})
return originalCtx
}, nil
}
return false, ctx, func() context.Context { return originalCtx }, nil
return false, func() {}, nil
}
func (l *memoryLocker) tryLock(key string) bool {

View File

@ -48,21 +48,21 @@ func NewRedisLocker(connection string) Locker {
return l
}
func (l *redisLocker) Lock(ctx context.Context, key string) (context.Context, ReleaseFunc, error) {
func (l *redisLocker) Lock(ctx context.Context, key string) (ReleaseFunc, error) {
return l.lock(ctx, key, 0)
}
func (l *redisLocker) TryLock(ctx context.Context, key string) (bool, context.Context, ReleaseFunc, error) {
ctx, f, err := l.lock(ctx, key, 1)
func (l *redisLocker) TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error) {
f, err := l.lock(ctx, key, 1)
var (
errTaken *redsync.ErrTaken
errNodeTaken *redsync.ErrNodeTaken
)
if errors.As(err, &errTaken) || errors.As(err, &errNodeTaken) {
return false, ctx, f, nil
return false, f, nil
}
return err == nil, ctx, f, err
return err == nil, f, err
}
// Close closes the locker.
@ -76,18 +76,11 @@ func (l *redisLocker) Close() error {
return nil
}
type redisMutex struct {
mutex *redsync.Mutex
cancel context.CancelCauseFunc
}
func (l *redisLocker) lock(ctx context.Context, key string, tries int) (context.Context, ReleaseFunc, error) {
func (l *redisLocker) lock(ctx context.Context, key string, tries int) (ReleaseFunc, error) {
if l.closed.Load() {
return ctx, func() context.Context { return ctx }, fmt.Errorf("locker is closed")
return func() {}, fmt.Errorf("locker is closed")
}
originalCtx := ctx
options := []redsync.Option{
redsync.WithExpiry(redisLockExpiry),
}
@ -96,18 +89,13 @@ func (l *redisLocker) lock(ctx context.Context, key string, tries int) (context.
}
mutex := l.rs.NewMutex(redisLockKeyPrefix+key, options...)
if err := mutex.LockContext(ctx); err != nil {
return ctx, func() context.Context { return originalCtx }, err
return func() {}, err
}
ctx, cancel := context.WithCancelCause(ctx)
l.mutexM.Store(key, &redisMutex{
mutex: mutex,
cancel: cancel,
})
l.mutexM.Store(key, mutex)
releaseOnce := sync.Once{}
return ctx, func() context.Context {
return func() {
releaseOnce.Do(func() {
l.mutexM.Delete(key)
@ -115,10 +103,7 @@ func (l *redisLocker) lock(ctx context.Context, key string, tries int) (context.
// if it failed to unlock, it will be released automatically after the lock expires.
// Do not call mutex.UnlockContext(ctx) here, or it will fail to release when ctx has timed out.
_, _ = mutex.Unlock()
cancel(ErrLockReleased)
})
return originalCtx
}, nil
}
@ -128,16 +113,15 @@ func (l *redisLocker) startExtend() {
return
}
toExtend := make([]*redisMutex, 0)
toExtend := make([]*redsync.Mutex, 0)
l.mutexM.Range(func(_, value any) bool {
m := value.(*redisMutex)
m := value.(*redsync.Mutex)
// Extend the lock if it is not expired.
// Although the mutex will be removed from the map before it is released,
// it still can be expired because of a failed extension.
// If it happens, the cancel function should have been called,
// so it does not need to be extended anymore.
if time.Now().After(m.mutex.Until()) {
// If it happens, it does not need to be extended anymore.
if time.Now().After(m.Until()) {
return true
}
@ -145,9 +129,8 @@ func (l *redisLocker) startExtend() {
return true
})
for _, v := range toExtend {
if ok, err := v.mutex.Extend(); !ok {
v.cancel(err)
}
// If it failed to extend, it will be released automatically after the lock expires.
_, _ = v.Extend()
}
time.AfterFunc(redisLockExpiry/2, l.startExtend)

4130
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,12 +17,12 @@
"add-asset-webpack-plugin": "3.0.0",
"ansi_up": "6.0.2",
"asciinema-player": "3.8.0",
"chart.js": "4.4.3",
"chart.js": "4.4.4",
"chartjs-adapter-dayjs-4": "1.0.4",
"chartjs-plugin-zoom": "2.0.1",
"clippie": "4.1.3",
"css-loader": "7.1.2",
"dayjs": "1.11.12",
"dayjs": "1.11.13",
"dropzone": "6.0.0-beta.2",
"easymde": "2.18.0",
"esbuild-loader": "4.2.2",
@ -34,17 +34,17 @@
"katex": "0.16.11",
"license-checker-webpack-plugin": "0.2.1",
"mermaid": "11.0.2",
"mini-css-extract-plugin": "2.9.0",
"mini-css-extract-plugin": "2.9.1",
"minimatch": "10.0.1",
"monaco-editor": "0.50.0",
"monaco-editor": "0.51.0",
"monaco-editor-webpack-plugin": "7.1.0",
"pdfobject": "2.3.0",
"postcss": "8.4.40",
"postcss": "8.4.41",
"postcss-loader": "8.1.1",
"postcss-nesting": "12.1.5",
"postcss-nesting": "13.0.0",
"sortablejs": "1.15.2",
"swagger-ui-dist": "5.17.14",
"tailwindcss": "3.4.7",
"tailwindcss": "3.4.10",
"temporal-polyfill": "0.2.5",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
@ -54,20 +54,20 @@
"typescript": "5.5.4",
"uint8-to-base64": "0.2.0",
"vanilla-colorful": "0.7.2",
"vue": "3.4.35",
"vue": "3.4.38",
"vue-bar-graph": "2.1.0",
"vue-chartjs": "5.3.1",
"vue-loader": "17.4.2",
"webpack": "5.93.0",
"webpack": "5.94.0",
"webpack-cli": "5.1.4",
"wrap-ansi": "9.0.0"
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "4.3.0",
"@playwright/test": "1.45.3",
"@eslint-community/eslint-plugin-eslint-comments": "4.4.0",
"@playwright/test": "1.46.1",
"@stoplight/spectral-cli": "6.11.1",
"@stylistic/eslint-plugin-js": "2.6.1",
"@stylistic/stylelint-plugin": "3.0.0",
"@stylistic/eslint-plugin-js": "2.6.5",
"@stylistic/stylelint-plugin": "3.0.1",
"@types/dropzone": "5.7.8",
"@types/jquery": "3.5.30",
"@types/katex": "0.16.7",
@ -78,11 +78,11 @@
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/toastify-js": "1.12.3",
"@typescript-eslint/eslint-plugin": "8.0.0",
"@typescript-eslint/parser": "8.0.0",
"@typescript-eslint/eslint-plugin": "8.3.0",
"@typescript-eslint/parser": "8.3.0",
"@vitejs/plugin-vue": "5.1.2",
"eslint": "8.57.0",
"eslint-import-resolver-typescript": "3.6.1",
"eslint-import-resolver-typescript": "3.6.3",
"eslint-plugin-array-func": "4.0.0",
"eslint-plugin-deprecation": "3.0.0",
"eslint-plugin-github": "5.0.1",
@ -91,14 +91,14 @@
"eslint-plugin-no-use-extend-native": "0.5.0",
"eslint-plugin-playwright": "1.6.2",
"eslint-plugin-regexp": "2.6.0",
"eslint-plugin-sonarjs": "1.0.4",
"eslint-plugin-sonarjs": "2.0.1",
"eslint-plugin-unicorn": "55.0.0",
"eslint-plugin-vitest": "0.4.1",
"eslint-plugin-vitest-globals": "1.5.0",
"eslint-plugin-vue": "9.27.0",
"eslint-plugin-vue-scoped-css": "2.8.1",
"eslint-plugin-wc": "2.1.0",
"happy-dom": "14.12.3",
"eslint-plugin-wc": "2.1.1",
"happy-dom": "15.3.1",
"markdownlint-cli": "0.41.0",
"nolyfill": "1.0.39",
"postcss-html": "1.7.0",
@ -107,8 +107,8 @@
"stylelint-declaration-strict-value": "1.10.6",
"stylelint-value-no-unknown-custom-properties": "6.0.1",
"svgo": "3.3.2",
"type-fest": "4.23.0",
"updates": "16.3.7",
"type-fest": "4.26.0",
"updates": "16.4.0",
"vite-string-plugin": "1.3.4",
"vitest": "2.0.5"
},
@ -131,6 +131,10 @@
"object.values": "npm:@nolyfill/object.values@^1",
"safe-regex-test": "npm:@nolyfill/safe-regex-test@^1",
"string.prototype.includes": "npm:@nolyfill/string.prototype.includes@^1",
"is-core-module": "npm:@nolyfill/is-core-module@^1"
"is-core-module": "npm:@nolyfill/is-core-module@^1",
"array.prototype.findlast": "npm:@nolyfill/array.prototype.findlast@^1",
"array.prototype.tosorted": "npm:@nolyfill/array.prototype.tosorted@^1",
"string.prototype.matchall": "npm:@nolyfill/string.prototype.matchall@^1",
"string.prototype.repeat": "npm:@nolyfill/string.prototype.repeat@^1"
}
}

10
poetry.lock generated
View File

@ -42,13 +42,13 @@ six = ">=1.13.0"
[[package]]
name = "djlint"
version = "1.34.1"
version = "1.34.2"
description = "HTML Template Linter and Formatter"
optional = false
python-versions = ">=3.8.0,<4.0.0"
python-versions = "<4.0.0,>=3.8.0"
files = [
{file = "djlint-1.34.1-py3-none-any.whl", hash = "sha256:96ff1c464fb6f061130ebc88663a2ea524d7ec51f4b56221a2b3f0320a3cfce8"},
{file = "djlint-1.34.1.tar.gz", hash = "sha256:db93fa008d19eaadb0454edf1704931d14469d48508daba2df9941111f408346"},
{file = "djlint-1.34.2-py3-none-any.whl", hash = "sha256:4825389e395eb77371857c77f547fa5ebd1a644b1bc4fe9fed19d49a2786b9e5"},
{file = "djlint-1.34.2.tar.gz", hash = "sha256:db9b2e59203a452b83532499bc243c749279090b905cc1f657973f78e7a31ddd"},
]
[package.dependencies]
@ -357,4 +357,4 @@ dev = ["doc8", "flake8", "flake8-import-order", "rstcheck[sphinx]", "sphinx"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "cd2ff218e9f27a464dfbc8ec2387824a90f4360e04c3f2e58cc375796b7df33a"
content-hash = "af89bce0c442463621b6e536f9b94c31e188e1662c2caa84372c0858a2ee7d5c"

View File

@ -5,7 +5,7 @@ package-mode = false
python = "^3.10"
[tool.poetry.group.dev.dependencies]
djlint = "1.34.1"
djlint = "1.34.2"
yamllint = "1.35.1"
[tool.djlint]

View File

@ -883,10 +883,15 @@ func registerRoutes(m *web.Router) {
m.Post("/teams/{team}/action/repo/{action}", org.TeamsRepoAction)
}, context.OrgAssignment(true, false, true))
// require admin permission
m.Group("/{org}", func() {
m.Get("/teams/-/search", org.SearchTeam)
}, context.OrgAssignment(true, false, false, true))
// require owner permission
m.Group("/{org}", func() {
m.Get("/teams/new", org.NewTeam)
m.Post("/teams/new", web.Bind(forms.CreateTeamForm{}), org.NewTeamPost)
m.Get("/teams/-/search", org.SearchTeam)
m.Get("/teams/{team}/edit", org.EditTeam)
m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost)
m.Post("/teams/{team}/delete", org.DeleteTeam)