diff --git a/.github/workflows/codeq.yaml b/.github/workflows/codeq.yaml new file mode 100644 index 0000000..b627e27 --- /dev/null +++ b/.github/workflows/codeq.yaml @@ -0,0 +1,32 @@ +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ "go" ] + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 \ No newline at end of file diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..c4a9200 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,76 @@ +name: Tests + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + check-latest: true + - name: Check Go formatting + run: | + UNFORMATTED_FILES=$(gofmt -l .) + if [ -n "$UNFORMATTED_FILES" ]; then + echo "::error::The following files are not formatted correctly:" + echo "$UNFORMATTED_FILES" + gofmt -d + exit 1 + fi + test: + strategy: + matrix: + os: [ ubuntu-latest, macos-latest ] + go: [ 1.25 ] + include: + - os: ubuntu-latest + go-build: ~/.cache/go-build + - os: macos-latest + go-build: /Users/runner/Library/Caches/go-build + name: ${{ matrix.os }} @ Go ${{ matrix.go }} + runs-on: ${{ matrix.os }} + env: + GO111MODULE: on + GOPROXY: https://proxy.golang.org + steps: + - name: Set up Go ${{ matrix.go }} + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go }} + + - name: Checkout Code + uses: actions/checkout@v6 + with: + ref: ${{ github.ref }} + + - uses: actions/cache@v5 + with: + path: | + ${{ matrix.go-build }} + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Tests + run: | + go run gotest.tools/gotestsum@latest --format=testdox -- -covermode=atomic -coverprofile=coverage.txt ./... + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + flags: ${{ matrix.os }},go-${{ matrix.go }} + token: ${{ secrets.CODECOV_TOKEN }} + slug: medunes/go-kata \ No newline at end of file diff --git a/.gitignore b/.gitignore index 723ef36..32104be 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,16 @@ -.idea \ No newline at end of file +dist/ +.vscode/ +vendor/ +pkg/ +.idea +test/data +*.exe +*.dll +*.so +*.dylib +*.test +*.out +.glide/ +.idea/ +*.iml +coverage.txt diff --git a/01-context-cancellation-concurrency/01-concurrent-aggregator/.gitignore b/01-context-cancellation-concurrency/01-concurrent-aggregator/.gitignore deleted file mode 100644 index 723ef36..0000000 --- a/01-context-cancellation-concurrency/01-concurrent-aggregator/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.idea \ No newline at end of file diff --git a/01-context-cancellation-concurrency/01-concurrent-aggregator/aggregator.go b/01-context-cancellation-concurrency/01-concurrent-aggregator/aggregator.go new file mode 100644 index 0000000..9284b4e --- /dev/null +++ b/01-context-cancellation-concurrency/01-concurrent-aggregator/aggregator.go @@ -0,0 +1,102 @@ +package concurrent_aggregator + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/medunes/go-kata/01-context-cancellation-concurrency/01-concurrent-aggregator/order" + "github.com/medunes/go-kata/01-context-cancellation-concurrency/01-concurrent-aggregator/profile" + + "golang.org/x/sync/errgroup" +) + +type Option func(ua *UserAggregator) + +func WithLogger(l *slog.Logger) Option { + return func(ua *UserAggregator) { + ua.logger = l + } +} + +func WithTimeout(timeout time.Duration) Option { + return func(ua *UserAggregator) { + ua.timeout = timeout + } +} + +type UserAggregator struct { + orderService order.Service + profileService profile.Service + timeout time.Duration + logger *slog.Logger +} + +func NewUserAggregator(os order.Service, ps profile.Service, opts ...Option) *UserAggregator { + ua := &UserAggregator{ + orderService: os, + profileService: ps, + logger: slog.Default(), // avoid nil panics, default writes to stderr + } + for _, opt := range opts { + opt(ua) + } + return ua +} + +type AggregatedProfile struct { + Name string + Cost float64 +} + +func (ua *UserAggregator) Aggregate(ctx context.Context, id int) ([]*AggregatedProfile, error) { + var ( + localCtx context.Context + cancel context.CancelFunc + pr *profile.Profile + or []*order.Order + au []*AggregatedProfile + g *errgroup.Group + ) + if ua.timeout > 0 { + localCtx, cancel = context.WithTimeout(ctx, ua.timeout) + } else { + localCtx, cancel = context.WithCancel(ctx) + } + defer cancel() + ua.logger.Info("starting aggregation", "user_id", id) + g, localCtx = errgroup.WithContext(localCtx) + g.Go(func() error { + var err error + pr, err = ua.profileService.Get(localCtx, id) + if err != nil { + ua.logger.Warn("failed to fetch profile", "user_id", id, "err", err) + return fmt.Errorf("profile fetch failed: %w", err) + } + return nil + }) + g.Go(func() error { + var err error + or, err = ua.orderService.GetAll(localCtx, id) + if err != nil { + ua.logger.Warn("failed to fetch order", "user_id", id, "err", err) + return fmt.Errorf("order fetch failed: %w", err) + } + return nil + + }) + + err := g.Wait() + if err != nil { + ua.logger.Warn("aggregator exited with error", "user_id", id, "err", err) + return nil, err + } + for _, o := range or { + if o.UserId == id { + au = append(au, &AggregatedProfile{pr.Name, o.Cost}) + } + } + ua.logger.Info("aggregation complete successfully", "user_id", id, "count", len(au)) + return au, nil +} diff --git a/01-context-cancellation-concurrency/01-concurrent-aggregator/aggregator_test.go b/01-context-cancellation-concurrency/01-concurrent-aggregator/aggregator_test.go new file mode 100644 index 0000000..456148f --- /dev/null +++ b/01-context-cancellation-concurrency/01-concurrent-aggregator/aggregator_test.go @@ -0,0 +1,447 @@ +package concurrent_aggregator + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "testing" + "time" + + "github.com/medunes/go-kata/01-context-cancellation-concurrency/01-concurrent-aggregator/order" + "github.com/medunes/go-kata/01-context-cancellation-concurrency/01-concurrent-aggregator/profile" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type ProfileServiceMock struct { + simulatedDuration time.Duration + simulateError bool + simulatedProfiles []*profile.Profile +} +type OrderServiceMock struct { + simulatedDuration time.Duration + simulateError bool + simulatedOrders []*order.Order +} + +func (ps *ProfileServiceMock) Get(ctx context.Context, id int) (*profile.Profile, error) { + + fmt.Println("Simulating profile search..") + select { + case <-time.After(ps.simulatedDuration): + if ps.simulateError { + return nil, fmt.Errorf("simulated profile search error") + } + case <-ctx.Done(): + return nil, ctx.Err() + } + for _, p := range ps.simulatedProfiles { + if p.Id == id { + return p, nil + } + } + return nil, nil +} +func (os *OrderServiceMock) GetAll(ctx context.Context, userId int) ([]*order.Order, error) { + fmt.Println("Simulating orders search..") + var emptyOrders []*order.Order + select { + case <-time.After(os.simulatedDuration): + if os.simulateError { + return emptyOrders, fmt.Errorf("simulated orders search error") + } + case <-ctx.Done(): + return emptyOrders, ctx.Err() + } + + var userOrders []*order.Order + for _, o := range os.simulatedOrders { + if o.UserId == userId { + userOrders = append(userOrders, o) + } + } + return userOrders, nil + +} +func TestAggregate(t *testing.T) { + type Input struct { + profileService *ProfileServiceMock + orderService *OrderServiceMock + searchedProfileId int + timeout time.Duration + parentContextDuration time.Duration + } + type Expected struct { + aggregatedProfiles []*AggregatedProfile + err bool + } + type TestCase struct { + name string + input Input + expected Expected + } + basicProfiles := []*profile.Profile{ + {Id: 1, Name: "Alice"}, + {Id: 2, Name: "Bob"}, + {Id: 3, Name: "Charlie"}, + {Id: 4, Name: "Dave"}, + {Id: 5, Name: "Eva"}, + } + //var emptyProfiles []*profile.Profile + //var emptyOrders []*order.Order + var emptyAggregateProfiles []*AggregatedProfile + testCases := []TestCase{ + { + "no errors, profile service in time, order service in time, more than one order", + Input{ + &ProfileServiceMock{ + 10 * time.Millisecond, + false, + basicProfiles, + }, + &OrderServiceMock{ + 20 * time.Millisecond, + false, + []*order.Order{ + {1, 1, 100.0}, + {2, 1, 20.6}, + {3, 3, 30.79}, + }, + }, + 1, + 100 * time.Millisecond, + 0, + }, + Expected{ + []*AggregatedProfile{{"Alice", 100.0}, {"Alice", 20.6}}, + false, + }, + }, + { + "no errors, profile service in time, order service in time, one order", + Input{ + &ProfileServiceMock{ + 10 * time.Millisecond, + false, + basicProfiles, + }, + &OrderServiceMock{ + 20 * time.Millisecond, + false, + []*order.Order{ + {1, 1, 100.0}, + {3, 3, 30.79}, + }, + }, + 1, + 100 * time.Millisecond, + 0, + }, + Expected{ + []*AggregatedProfile{{"Alice", 100.0}}, + false, + }, + }, + { + "no errors, profile service in time, order service in time, no orders", + Input{ + &ProfileServiceMock{ + 10 * time.Millisecond, + false, + basicProfiles, + }, + &OrderServiceMock{ + 20 * time.Millisecond, + false, + []*order.Order{ + {1, 2, 100.0}, + {3, 3, 30.79}, + }, + }, + 1, + 100 * time.Millisecond, + 0, + }, + Expected{ + emptyAggregateProfiles, + false, + }, + }, + { + "no errors, profile service in time, order service timeout, more than one order", + Input{ + &ProfileServiceMock{ + 10 * time.Millisecond, + false, + basicProfiles, + }, + &OrderServiceMock{ + 120 * time.Millisecond, + false, + []*order.Order{ + {1, 1, 100.0}, + {3, 1, 30.79}, + }, + }, + 1, + 100 * time.Millisecond, + 0, + }, + Expected{ + emptyAggregateProfiles, + true, + }, + }, + { + "no errors, profile service in time, order service timeout, one order", + Input{ + &ProfileServiceMock{ + 10 * time.Millisecond, + false, + basicProfiles, + }, + &OrderServiceMock{ + 120 * time.Millisecond, + false, + []*order.Order{ + {1, 1, 100.0}, + {3, 2, 30.79}, + }, + }, + 1, + 100 * time.Millisecond, + 0, + }, + Expected{ + emptyAggregateProfiles, + true, + }, + }, + { + "no errors, profile service in time, order service timeout, no orders", + Input{ + &ProfileServiceMock{ + 10 * time.Millisecond, + false, + basicProfiles, + }, + &OrderServiceMock{ + 120 * time.Millisecond, + false, + []*order.Order{ + {1, 3, 100.0}, + {3, 2, 30.79}, + }, + }, + 1, + 100 * time.Millisecond, + 0, + }, + Expected{ + emptyAggregateProfiles, + true, + }, + }, + { + "no errors, profile service timeout, order service in time, more than one order", + Input{ + &ProfileServiceMock{ + 120 * time.Millisecond, + false, + basicProfiles, + }, + &OrderServiceMock{ + 20 * time.Millisecond, + false, + []*order.Order{ + {1, 1, 100.0}, + {3, 1, 30.79}, + }, + }, + 1, + 100 * time.Millisecond, + 0, + }, + Expected{ + emptyAggregateProfiles, + true, + }, + }, + { + "no service timeout, no errors, more than one order", + Input{ + &ProfileServiceMock{ + 120 * time.Millisecond, + false, + basicProfiles, + }, + &OrderServiceMock{ + 150 * time.Millisecond, + false, + []*order.Order{ + {1, 1, 100.0}, + {3, 1, 30.79}, + }, + }, + 1, + 0 * time.Millisecond, + 0, + }, + Expected{ + []*AggregatedProfile{{"Alice", 100.0}, {"Alice", 30.79}}, + false, + }, + }, + { + "no service timeout, profile error, more than one order", + Input{ + &ProfileServiceMock{ + 120 * time.Millisecond, + true, + basicProfiles, + }, + &OrderServiceMock{ + 150 * time.Millisecond, + false, + []*order.Order{ + {1, 1, 100.0}, + {3, 1, 30.79}, + }, + }, + 1, + 0 * time.Millisecond, + 0, + }, + Expected{ + emptyAggregateProfiles, + true, + }, + }, + { + "no service timeout, order error, more than one order", + Input{ + &ProfileServiceMock{ + 70 * time.Millisecond, + false, + basicProfiles, + }, + &OrderServiceMock{ + 100 * time.Millisecond, + true, + []*order.Order{ + {1, 1, 100.0}, + {3, 1, 30.79}, + }, + }, + 1, + 0 * time.Millisecond, + 0, + }, + Expected{ + emptyAggregateProfiles, + true, + }, + }, + { + "no service timeout, profile error, order error, profile and order take same time", + Input{ + &ProfileServiceMock{ + 120 * time.Millisecond, + true, + basicProfiles, + }, + &OrderServiceMock{ + 120 * time.Millisecond, + true, + []*order.Order{ + {1, 1, 100.0}, + {3, 1, 30.79}, + }, + }, + 1, + 0 * time.Millisecond, + 0, + }, + Expected{ + emptyAggregateProfiles, + true, + }, + }, + { + "order service error propagates correctly", + Input{ + &ProfileServiceMock{10 * time.Millisecond, false, basicProfiles}, + &OrderServiceMock{10 * time.Millisecond, true, nil}, // Error here + 1, + 100 * time.Millisecond, + 0, + }, + Expected{ + nil, + true, + }, + }, + { + "timeout error propagates correctly", + Input{ + &ProfileServiceMock{200 * time.Millisecond, false, basicProfiles}, + &OrderServiceMock{10 * time.Millisecond, false, nil}, + 1, + 50 * time.Millisecond, + 0, + }, + Expected{ + nil, + true, + }, + }, + { + "parent context cancellation propagates correctly", + Input{ + &ProfileServiceMock{200 * time.Millisecond, false, basicProfiles}, + &OrderServiceMock{10 * time.Millisecond, false, nil}, + 1, + 500 * time.Millisecond, + 100 * time.Millisecond, + }, + Expected{ + nil, + true, + }, + }, + } + for _, tc := range testCases { + + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + if tc.input.parentContextDuration > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, tc.input.parentContextDuration) + defer cancel() + } + var buf bytes.Buffer + spyLogger := slog.New(slog.NewJSONHandler(&buf, nil)) + u := NewUserAggregator( + tc.input.orderService, + tc.input.profileService, + WithTimeout(tc.input.timeout), + WithLogger(spyLogger), + ) + aggregatedProfiles, err := u.Aggregate(ctx, tc.input.searchedProfileId) + logOutput := buf.String() + if tc.expected.err { + require.Error(t, err) + assert.Contains(t, logOutput, "error") + + } else { + require.NoError(t, err) + assert.Contains(t, logOutput, "aggregation complete successfully") + assert.Contains(t, logOutput, "user_id") + } + + assert.Equal(t, tc.expected.aggregatedProfiles, aggregatedProfiles) + + }) + } +} diff --git a/01-context-cancellation-concurrency/01-concurrent-aggregator/go.mod b/01-context-cancellation-concurrency/01-concurrent-aggregator/go.mod deleted file mode 100644 index 791d3e7..0000000 --- a/01-context-cancellation-concurrency/01-concurrent-aggregator/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module concurrent-aggregator - -go 1.25.0 diff --git a/01-context-cancellation-concurrency/01-concurrent-aggregator/order/order.go b/01-context-cancellation-concurrency/01-concurrent-aggregator/order/order.go new file mode 100644 index 0000000..6bded0a --- /dev/null +++ b/01-context-cancellation-concurrency/01-concurrent-aggregator/order/order.go @@ -0,0 +1,13 @@ +package order + +import "context" + +type Order struct { + Id int `json:"id"` + UserId int `json:"user_id"` + Cost float64 `json:"cost"` +} + +type Service interface { + GetAll(ctx context.Context, userId int) ([]*Order, error) +} diff --git a/01-context-cancellation-concurrency/01-concurrent-aggregator/profile/profile.go b/01-context-cancellation-concurrency/01-concurrent-aggregator/profile/profile.go new file mode 100644 index 0000000..74d460f --- /dev/null +++ b/01-context-cancellation-concurrency/01-concurrent-aggregator/profile/profile.go @@ -0,0 +1,14 @@ +package profile + +import ( + "context" +) + +type Profile struct { + Id int `json:"id"` + Name string `json:"name"` +} + +type Service interface { + Get(ctx context.Context, id int) (*Profile, error) +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e13c0ed --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 medunes (medunes@protonmail.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..883ce90 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +test: + go run gotest.tools/gotestsum@latest --format=testdox -- -covermode=atomic -coverprofile=coverage.txt ./... +benchmark: + go test -bench=. -benchmem ./... +format: + go fmt ./... +format-check: + @UNFORMATTED_FILES=$$(gofmt -l .); \ + if [ -n "$$UNFORMATTED_FILES" ]; then \ + echo "::error::The following files are not formatted correctly:"; \ + echo "$$UNFORMATTED_FILES"; \ + echo "--- Diff ---"; \ + gofmt -d .; \ + exit 1; \ + fi + +.PHONY: test format benchmark format-check \ No newline at end of file diff --git a/README.md b/README.md index 2f1add7..54a0537 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,14 @@ # 🥋 Go Katas 🥋 +[![Tests](https://github.com/medunes/go-kata/actions/workflows/tests.yaml/badge.svg)](https://github.com/medunes/go-kata/actions/workflows/tests.yaml) +[![CodeQL](https://github.com/MedUnes/go-kata/actions/workflows/codeq.yaml/badge.svg)](https://github.com/MedUnes/go-kata/actions/workflows/codeq.yaml) +[![codecov](https://codecov.io/gh/medunes/go-kata/branch/master/graph/badge.svg)](https://codecov.io/gh/medunes/go-kata) + > "I fear not the man who has practiced 10,000 kicks once, but I fear the man who has practiced one kick 10,000 times." > > (Bruce Lee) + ## What should it be? - Go is simple to learn, but nuanced to master. The difference between "working code" and "idiomatic code" often lies in details such as safety, memory efficiency, and concurrency control. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..525fae0 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/medunes/go-kata + +go 1.25.5 + +require ( + github.com/stretchr/testify v1.11.1 + golang.org/x/sync v0.19.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..71da77f --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +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/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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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=