initial commit
This commit is contained in:
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
.idea/**/aws.xml
|
||||
.idea/**/contentModel.xml
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
cmake-build-*/
|
||||
.idea/**/mongoSettings.xml
|
||||
*.iws
|
||||
out/
|
||||
.idea_modules/
|
||||
atlassian-ide-plugin.xml
|
||||
.idea/replstate.xml
|
||||
.idea/sonarlint/
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
.idea/httpRequests
|
||||
.idea/caches/build_file_checksums.ser
|
||||
*~
|
||||
.fuse_hidden*
|
||||
.directory
|
||||
.Trash-*
|
||||
.nfs*
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
go.work
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
9
.idea/commander.iml
generated
Normal file
9
.idea/commander.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/git_toolbox_blame.xml
generated
Normal file
6
.idea/git_toolbox_blame.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GitToolBoxBlameSettings">
|
||||
<option name="version" value="2" />
|
||||
</component>
|
||||
</project>
|
||||
15
.idea/git_toolbox_prj.xml
generated
Normal file
15
.idea/git_toolbox_prj.xml
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GitToolBoxProjectSettings">
|
||||
<option name="commitMessageIssueKeyValidationOverride">
|
||||
<BoolValueOverride>
|
||||
<option name="enabled" value="true" />
|
||||
</BoolValueOverride>
|
||||
</option>
|
||||
<option name="commitMessageValidationEnabledOverride">
|
||||
<BoolValueOverride>
|
||||
<option name="enabled" value="true" />
|
||||
</BoolValueOverride>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/commander.iml" filepath="$PROJECT_DIR$/.idea/commander.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
117
cache.go
Normal file
117
cache.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package commander
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/vmihailenco/msgpack/v5"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const cacheTTL = 5 * time.Second
|
||||
|
||||
type Cache interface {
|
||||
Set(key string, value []byte, ttl time.Duration) error
|
||||
Get(key string) ([]byte, error)
|
||||
}
|
||||
|
||||
type commandCache struct {
|
||||
tracer trace.Tracer
|
||||
cache Cache
|
||||
ttl time.Duration
|
||||
|
||||
locks sync.Map
|
||||
}
|
||||
|
||||
func newCommandCache(cache Cache, ttl time.Duration) *commandCache {
|
||||
tracer := otel.Tracer(tracerName)
|
||||
|
||||
if ttl <= 0 {
|
||||
ttl = cacheTTL
|
||||
}
|
||||
|
||||
return &commandCache{
|
||||
tracer: tracer,
|
||||
cache: cache,
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *commandCache) CommandCacheGet(ctx context.Context, key string, commands ...Command) bool {
|
||||
ctx, span := c.tracer.Start(ctx, "command_cache.get")
|
||||
defer span.End()
|
||||
|
||||
defer c.locker(ctx, key)()
|
||||
|
||||
res, err := c.cache.Get(key)
|
||||
if err != nil {
|
||||
span.RecordError(fmt.Errorf("cache get: %w", err))
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if len(res) == 0 {
|
||||
span.AddEvent("cache miss")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
span.AddEvent("cache hit")
|
||||
|
||||
if err := c.unmarshal(res, commands); err != nil {
|
||||
span.RecordError(fmt.Errorf("unmarshal commands: %w", err))
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *commandCache) CommandCacheStore(ctx context.Context, key string, commands ...Command) {
|
||||
ctx, span := c.tracer.Start(ctx, "command_cache.store")
|
||||
defer span.End()
|
||||
|
||||
defer c.locker(ctx, key)()
|
||||
|
||||
marshaled, err := c.marshal(commands)
|
||||
if err != nil {
|
||||
span.RecordError(fmt.Errorf("marshal commands: %w", err))
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.cache.Set(key, marshaled, c.ttl); err != nil {
|
||||
span.RecordError(fmt.Errorf("set cache: %w", err))
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *commandCache) locker(ctx context.Context, key string) (unlock func()) {
|
||||
ctx, span := c.tracer.Start(ctx, "locker")
|
||||
defer span.End()
|
||||
|
||||
for {
|
||||
if _, loaded := c.locks.LoadOrStore(key, true); !loaded {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return func() {
|
||||
c.locks.Delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *commandCache) marshal(commands []Command) ([]byte, error) {
|
||||
return msgpack.Marshal(commands)
|
||||
}
|
||||
|
||||
func (c *commandCache) unmarshal(res []byte, commands []Command) error {
|
||||
return msgpack.Unmarshal(res, &commands)
|
||||
}
|
||||
66
examples/basic/commands.go
Normal file
66
examples/basic/commands.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Entity struct {
|
||||
Name string
|
||||
Address string
|
||||
}
|
||||
|
||||
type GetName struct {
|
||||
UID string
|
||||
Sleep time.Duration
|
||||
|
||||
Result Entity
|
||||
}
|
||||
|
||||
func (b *GetName) CorrelationID() string {
|
||||
return b.UID
|
||||
}
|
||||
|
||||
func (b *GetName) Execute(context.Context) error {
|
||||
b.Result = Entity{Name: "Bob"}
|
||||
|
||||
if b.Sleep > 0 {
|
||||
time.Sleep(b.Sleep)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *GetName) Rollback(context.Context) error {
|
||||
b.Result = Entity{}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type GetAddress struct {
|
||||
Input *Entity
|
||||
|
||||
Result Entity
|
||||
}
|
||||
|
||||
func (b *GetAddress) CorrelationID() string {
|
||||
return b.Input.Name
|
||||
}
|
||||
|
||||
func (b *GetAddress) Execute(context.Context) error {
|
||||
b.Result = *b.Input
|
||||
if b.Result.Address != "" {
|
||||
return errors.New("already set")
|
||||
}
|
||||
|
||||
b.Result.Address = "London"
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *GetAddress) Rollback(context.Context) error {
|
||||
b.Result = Entity{}
|
||||
|
||||
return nil
|
||||
}
|
||||
25
examples/basic/go.mod
Normal file
25
examples/basic/go.mod
Normal file
@@ -0,0 +1,25 @@
|
||||
module git.derfenix.pro/fenix/commander/examples/basic
|
||||
|
||||
go 1.22
|
||||
|
||||
replace (
|
||||
git.derfenix.pro/fenix/commander => ../../
|
||||
git.derfenix.pro/fenix/commander/inmemorycache => ../../inmemorycache
|
||||
)
|
||||
|
||||
require (
|
||||
git.derfenix.pro/fenix/commander v0.0.0-00010101000000-000000000000
|
||||
git.derfenix.pro/fenix/commander/inmemorycache v0.0.0-00010101000000-000000000000
|
||||
github.com/google/uuid v1.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
go.opentelemetry.io/otel v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.28.0 // indirect
|
||||
)
|
||||
29
examples/basic/go.sum
Normal file
29
examples/basic/go.sum
Normal file
@@ -0,0 +1,29 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
|
||||
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
|
||||
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
|
||||
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
|
||||
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
|
||||
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
25
examples/basic/service.go
Normal file
25
examples/basic/service.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.derfenix.pro/fenix/commander"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
cmd := commander.New(10)
|
||||
|
||||
c1 := GetName{UID: uuid.NewString()}
|
||||
c2 := GetAddress{Input: &c1.Result}
|
||||
commands := []commander.Command{&c1, &c2}
|
||||
|
||||
if err := cmd.Execute(ctx, "", commands...); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Println(c2.Result)
|
||||
}
|
||||
134
examples/basic/service_test.go
Normal file
134
examples/basic/service_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.derfenix.pro/fenix/commander"
|
||||
"git.derfenix.pro/fenix/commander/inmemorycache"
|
||||
)
|
||||
|
||||
func BenchmarkServiceNoop(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
|
||||
cmd := commander.New(10)
|
||||
|
||||
c1 := GetName{UID: uuid.NewString()}
|
||||
c2 := GetAddress{Input: &c1.Result}
|
||||
commands := []commander.Command{&c1, &c2}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := cmd.Execute(ctx, "", commands...); err != nil {
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkServiceWithDelay(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
|
||||
cmd := commander.New(10)
|
||||
|
||||
c1 := GetName{UID: uuid.NewString(), Sleep: time.Microsecond * 100}
|
||||
c2 := GetAddress{Input: &c1.Result}
|
||||
commands := []commander.Command{&c1, &c2}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := cmd.Execute(ctx, "", commands...); err != nil {
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkServiceWithRollbackNoop(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
|
||||
cmd := commander.New(10)
|
||||
|
||||
c1 := GetName{UID: uuid.NewString()}
|
||||
c2 := GetAddress{Input: &c1.Result}
|
||||
commands := []commander.Command{&c1, &c2}
|
||||
|
||||
commands = append(commands, &GetAddress{Input: &c2.Result})
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := cmd.Execute(ctx, "", commands...); err != nil {
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkServiceWithRollbackWithDelay(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
|
||||
cmd := commander.New(10)
|
||||
|
||||
c1 := GetName{UID: uuid.NewString(), Sleep: time.Microsecond * 100}
|
||||
c2 := GetAddress{Input: &c1.Result}
|
||||
commands := []commander.Command{&c1, &c2}
|
||||
|
||||
commands = append(commands, &GetAddress{Input: &c2.Result})
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := cmd.Execute(ctx, "", commands...); err != nil {
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkServiceWithCacheNoop(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
|
||||
cmd := commander.New(10)
|
||||
|
||||
cmd = cmd.WithCache(inmemorycache.NewInMemoryCache())
|
||||
|
||||
c1 := GetName{UID: uuid.NewString()}
|
||||
c2 := GetAddress{Input: &c1.Result}
|
||||
commands := []commander.Command{&c1, &c2}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := cmd.Execute(ctx, "", commands...); err != nil {
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkServiceWithCacheWithDelay(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
|
||||
cmd := commander.New(10)
|
||||
|
||||
cmd = cmd.WithCache(inmemorycache.NewInMemoryCache())
|
||||
|
||||
c1 := GetName{UID: uuid.NewString(), Sleep: time.Microsecond * 100}
|
||||
c2 := GetAddress{Input: &c1.Result}
|
||||
commands := []commander.Command{&c1, &c2}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := cmd.Execute(ctx, "", commands...); err != nil {
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
}
|
||||
289
executor.go
Normal file
289
executor.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package commander
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const tracerName = "git.derfenix.pro/fenix/commander"
|
||||
|
||||
var ErrNoErrorChannel = errors.New("no error channel provided")
|
||||
|
||||
type Command interface {
|
||||
Execute(ctx context.Context) error
|
||||
Rollback(ctx context.Context) error
|
||||
}
|
||||
|
||||
type CorrelatedCommand interface {
|
||||
Command
|
||||
CorrelationID() string
|
||||
}
|
||||
|
||||
type Eventer interface {
|
||||
EmitEvent(ctx context.Context) error
|
||||
}
|
||||
|
||||
type CommandCache interface {
|
||||
CommandCacheGet(context.Context, string, ...Command) bool
|
||||
CommandCacheStore(context.Context, string, ...Command)
|
||||
}
|
||||
|
||||
func mustNewMetrics() metrics {
|
||||
m, err := newMetrics()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func newMetrics() (metrics, error) {
|
||||
meter := otel.GetMeterProvider().Meter("executor")
|
||||
|
||||
commandsCount, err := meter.Int64Histogram("commands", metric.WithDescription("Count of executed commands (can be rolled back)"))
|
||||
if err != nil {
|
||||
return metrics{}, fmt.Errorf("commands count histogram: %w", err)
|
||||
}
|
||||
|
||||
commandsRollbackCount, err := meter.Int64Histogram("commands.rollback", metric.WithDescription("Count of commands rolled back"))
|
||||
if err != nil {
|
||||
return metrics{}, fmt.Errorf("commands count histogram: %w", err)
|
||||
}
|
||||
|
||||
commandsFailedCount, err := meter.Int64Histogram("commands.failed", metric.WithDescription("Count of failed commands"))
|
||||
if err != nil {
|
||||
return metrics{}, fmt.Errorf("commands failed count histogram: %w", err)
|
||||
}
|
||||
|
||||
commandsRollbackFailedCount, err := meter.Int64Histogram("commands.rollback.failed", metric.WithDescription("Count of commands fail to roll back"))
|
||||
if err != nil {
|
||||
return metrics{}, fmt.Errorf("commands count histogram: %w", err)
|
||||
}
|
||||
|
||||
monitorCommands, err := meter.Int64UpDownCounter("commands.running", metric.WithDescription("Command set in progress"))
|
||||
if err != nil {
|
||||
return metrics{}, fmt.Errorf("monitor commands counter: %w", err)
|
||||
}
|
||||
|
||||
cacheHit, err := meter.Int64Counter("cache.hit", metric.WithDescription("Cache hit"))
|
||||
if err != nil {
|
||||
return metrics{}, fmt.Errorf("cache hit: %w", err)
|
||||
}
|
||||
|
||||
cacheMiss, err := meter.Int64Counter("cache.miss", metric.WithDescription("Cache miss"))
|
||||
if err != nil {
|
||||
return metrics{}, fmt.Errorf("cache miss: %w", err)
|
||||
}
|
||||
|
||||
return metrics{
|
||||
commandsCount: commandsCount,
|
||||
commandsRollbackCount: commandsRollbackCount,
|
||||
commandsFailedCount: commandsFailedCount,
|
||||
commandsRollbackFailedCount: commandsRollbackFailedCount,
|
||||
monitorCommands: monitorCommands,
|
||||
cacheHit: cacheHit,
|
||||
cacheMiss: cacheMiss,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type metrics struct {
|
||||
commandsCount metric.Int64Histogram
|
||||
commandsRollbackCount metric.Int64Histogram
|
||||
commandsFailedCount metric.Int64Histogram
|
||||
commandsRollbackFailedCount metric.Int64Histogram
|
||||
|
||||
monitorCommands metric.Int64UpDownCounter
|
||||
|
||||
cacheHit metric.Int64Counter
|
||||
cacheMiss metric.Int64Counter
|
||||
}
|
||||
|
||||
func New(commandsLimit int) *Commander {
|
||||
tracer := otel.Tracer(tracerName)
|
||||
|
||||
return &Commander{
|
||||
metrics: mustNewMetrics(),
|
||||
tracer: tracer,
|
||||
|
||||
correlationIDBuffer: sync.Pool{New: func() any { return bytes.NewBuffer(nil) }},
|
||||
|
||||
commandsSemaphore: NewSemaphore(commandsLimit),
|
||||
}
|
||||
}
|
||||
|
||||
type Commander struct {
|
||||
metrics
|
||||
tracer trace.Tracer
|
||||
cache CommandCache
|
||||
|
||||
correlationIDBuffer sync.Pool
|
||||
|
||||
commandsSemaphore Semaphore
|
||||
}
|
||||
|
||||
func (c *Commander) WithCache(cache Cache) *Commander {
|
||||
c.cache = newCommandCache(cache, cacheTTL)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Commander) WithCacheTTL(cache Cache, ttl time.Duration) *Commander {
|
||||
c.cache = newCommandCache(cache, ttl)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Commander) ExecuteAsync(ctx context.Context, errCh chan<- error, correlationID string, commands ...Command) {
|
||||
ctx, span := c.tracer.Start(ctx, "executor.execute_async")
|
||||
defer span.End()
|
||||
|
||||
if errCh == nil {
|
||||
span.RecordError(ErrNoErrorChannel)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if errCh != nil {
|
||||
close(errCh)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := c.Execute(ctx, correlationID, commands...); err != nil {
|
||||
if errCh != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Commander) Execute(ctx context.Context, correlationID string, commands ...Command) error {
|
||||
ctx, span := c.tracer.Start(
|
||||
ctx,
|
||||
"executor.execute",
|
||||
trace.WithAttributes(
|
||||
attribute.Int("commands.count", len(commands)),
|
||||
),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
if c.cache != nil && correlationID == "" {
|
||||
correlationID = c.tryGetCorrelationID(commands)
|
||||
}
|
||||
|
||||
shouldCache := c.cache != nil && correlationID != ""
|
||||
|
||||
c.commandsSemaphore.Acquire()
|
||||
c.monitorCommands.Add(ctx, 1)
|
||||
defer func() {
|
||||
c.commandsSemaphore.Release()
|
||||
c.monitorCommands.Add(ctx, -1)
|
||||
}()
|
||||
|
||||
if shouldCache {
|
||||
if c.cache.CommandCacheGet(ctx, correlationID, commands...) {
|
||||
c.cacheHit.Add(ctx, 1)
|
||||
|
||||
return nil
|
||||
} else {
|
||||
c.cacheMiss.Add(ctx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
var actionsCount int64
|
||||
defer func() {
|
||||
if actionsCount > 0 {
|
||||
c.commandsCount.Record(ctx, actionsCount)
|
||||
}
|
||||
}()
|
||||
|
||||
eventEmitters := make([]Eventer, 0, len(commands))
|
||||
|
||||
for idx, command := range commands {
|
||||
if err := command.Execute(ctx); err != nil {
|
||||
c.commandsFailedCount.Record(ctx, 1)
|
||||
|
||||
span.RecordError(
|
||||
err,
|
||||
trace.WithAttributes(attribute.String("step", "execute")),
|
||||
trace.WithAttributes(attribute.String("command", fmt.Sprintf("%T", command))),
|
||||
)
|
||||
span.SetStatus(codes.Error, "failed to execute command")
|
||||
|
||||
if idx > 0 {
|
||||
c.Rollback(ctx, commands[:idx]...)
|
||||
}
|
||||
|
||||
return fmt.Errorf("execute command %v: %w", command, err)
|
||||
}
|
||||
|
||||
if eventer, ok := command.(Eventer); ok {
|
||||
eventEmitters = append(eventEmitters, eventer)
|
||||
}
|
||||
|
||||
actionsCount++
|
||||
}
|
||||
|
||||
for _, emitter := range eventEmitters {
|
||||
if err := emitter.EmitEvent(ctx); err != nil {
|
||||
span.RecordError(
|
||||
err,
|
||||
trace.WithAttributes(attribute.String("step", "send events")),
|
||||
trace.WithAttributes(attribute.String("command", fmt.Sprintf("%T", emitter))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldCache {
|
||||
c.cache.CommandCacheStore(ctx, correlationID, commands...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Commander) tryGetCorrelationID(commands []Command) string {
|
||||
newCorrelationID := c.correlationIDBuffer.Get().(*bytes.Buffer)
|
||||
defer func() {
|
||||
newCorrelationID.Reset()
|
||||
c.correlationIDBuffer.Put(newCorrelationID)
|
||||
}()
|
||||
|
||||
for _, command := range commands {
|
||||
correlationIDer, ok := command.(CorrelatedCommand)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
newCorrelationID.WriteString(correlationIDer.CorrelationID())
|
||||
}
|
||||
|
||||
return newCorrelationID.String()
|
||||
}
|
||||
|
||||
func (c *Commander) Rollback(ctx context.Context, commands ...Command) {
|
||||
ctx, span := c.tracer.Start(ctx, "executor.rollback")
|
||||
defer span.End()
|
||||
|
||||
for _, command := range commands {
|
||||
if err := command.Rollback(ctx); err != nil {
|
||||
c.commandsRollbackFailedCount.Record(ctx, 1)
|
||||
|
||||
span.RecordError(
|
||||
err,
|
||||
trace.WithAttributes(attribute.String("step", "rollback")),
|
||||
trace.WithAttributes(attribute.String("command", fmt.Sprintf("%T", command))),
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
c.commandsRollbackCount.Record(ctx, 1)
|
||||
}
|
||||
}
|
||||
20
go.mod
Normal file
20
go.mod
Normal file
@@ -0,0 +1,20 @@
|
||||
module git.derfenix.pro/fenix/commander
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
go.opentelemetry.io/otel v1.28.0
|
||||
go.opentelemetry.io/otel/metric v1.28.0
|
||||
go.opentelemetry.io/otel/trace v1.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
27
go.sum
Normal file
27
go.sum
Normal file
@@ -0,0 +1,27 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
|
||||
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
|
||||
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
|
||||
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
|
||||
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
|
||||
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
5
inmemorycache/go.mod
Normal file
5
inmemorycache/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module git.derfenix.pro/fenix/commander/inmemorycache
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
2
inmemorycache/go.sum
Normal file
2
inmemorycache/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
30
inmemorycache/inmemorycache.go
Normal file
30
inmemorycache/inmemorycache.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package inmemorycache
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
type InMemoryCache struct {
|
||||
cache *cache.Cache
|
||||
}
|
||||
|
||||
func NewInMemoryCache() *InMemoryCache {
|
||||
return &InMemoryCache{cache: cache.New(time.Minute, 5*time.Minute)}
|
||||
}
|
||||
|
||||
func (i *InMemoryCache) Set(key string, value []byte, ttl time.Duration) error {
|
||||
i.cache.Set(key, value, ttl)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *InMemoryCache) Get(key string) ([]byte, error) {
|
||||
res, ok := i.cache.Get(key)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return res.([]byte), nil
|
||||
}
|
||||
31
semaphore.go
Normal file
31
semaphore.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package commander
|
||||
|
||||
func NewSemaphore(len int) Semaphore {
|
||||
if len == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return make(Semaphore, len)
|
||||
}
|
||||
|
||||
type Semaphore chan struct{}
|
||||
|
||||
func (s Semaphore) Acquire() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s <- struct{}{}
|
||||
}
|
||||
|
||||
func (s Semaphore) Release() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
<-s
|
||||
}
|
||||
|
||||
func (s Semaphore) Close() {
|
||||
close(s)
|
||||
}
|
||||
36
semaphore_test.go
Normal file
36
semaphore_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package commander
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSemaphore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sem := NewSemaphore(2)
|
||||
|
||||
var locked atomic.Uint32
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
go func() {
|
||||
sem.Acquire()
|
||||
locked.Add(1)
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(time.Microsecond * 10)
|
||||
|
||||
require.Equal(t, uint32(2), locked.Load())
|
||||
|
||||
sem.Release()
|
||||
time.Sleep(time.Microsecond)
|
||||
|
||||
require.Equal(t, uint32(3), locked.Load())
|
||||
|
||||
sem.Release()
|
||||
sem.Release()
|
||||
}
|
||||
Reference in New Issue
Block a user