feat(add solutions reference for challenge 01-concurrent-aggregator)

This commit is contained in:
medunes
2026-01-22 01:14:03 +01:00
committed by Mohamed Younes
parent d44f1a86d9
commit ccd2ec1cce
14 changed files with 769 additions and 5 deletions

32
.github/workflows/codeq.yaml vendored Normal file
View File

@@ -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

76
.github/workflows/tests.yaml vendored Normal file
View File

@@ -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

15
.gitignore vendored
View File

@@ -1 +1,16 @@
dist/
.vscode/
vendor/
pkg/
.idea
test/data
*.exe
*.dll
*.so
*.dylib
*.test
*.out
.glide/
.idea/
*.iml
coverage.txt

View File

@@ -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
}

View File

@@ -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)
})
}
}

View File

@@ -1,3 +0,0 @@
module concurrent-aggregator
go 1.25.0

View File

@@ -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)
}

View File

@@ -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)
}

21
LICENSE Normal file
View File

@@ -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.

17
Makefile Normal file
View File

@@ -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

View File

@@ -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.

14
go.mod Normal file
View File

@@ -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
)

12
go.sum Normal file
View File

@@ -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=