Skip to content

Commit

Permalink
Introduce cache chain for the http handler (#12)
Browse files Browse the repository at this point in the history
* Introduce cache chain for the http handler

* Fix e2e test

* Replace map[string]models.GeoIPRepository in Chain type by Cache type

* Fix random failures in TestNewChecks() unit test due to random iteration over maps

* Fix e2e failing tests by running e2e-curl after e2e-go
  • Loading branch information
lescactus authored Oct 18, 2022
1 parent 0cd23f7 commit 17e4c85
Show file tree
Hide file tree
Showing 24 changed files with 661 additions and 202 deletions.
2 changes: 1 addition & 1 deletion .e2e/gotest/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func TestE2E(t *testing.T) {
name: "invalid path - " + *baseUrl + "/rest/v1/bla",
url: "" + *baseUrl + "/rest/v1/bla",
method: "GET",
want: []byte(`{"status":"error","msg":"the provided IP is not a valid IPv4 address"}`),
want: []byte(`{"status":"error","msg":"the provided ip is not a valid ipv4 address"}`),
code: http.StatusBadRequest,
},
{
Expand Down
8 changes: 8 additions & 0 deletions .e2e/gotest/skaffold/skaffold-e2e.curl.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
apiVersion: skaffold/v2beta28
kind: Config
metadata:
name: e2e
deploy:
kubectl:
manifests:
- .e2e/curl/e2e.yaml
3 changes: 0 additions & 3 deletions .e2e/gotest/skaffold/skaffold-e2e.withmetrics.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ build:
dateTime:
format: 2006-01-02_15-04-05.999_MST
deploy:
kubectl:
manifests:
- .e2e/curl/e2e.yaml
kustomize:
paths:
- .e2e/gotest/k8s/jobs/withmetrics
3 changes: 0 additions & 3 deletions .e2e/gotest/skaffold/skaffold-e2e.withoutmetrics.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ build:
dateTime:
format: 2006-01-02_15-04-05.999_MST
deploy:
kubectl:
manifests:
- .e2e/curl/e2e.yaml
kustomize:
paths:
- .e2e/gotest/k8s/jobs/withoutmetrics
3 changes: 0 additions & 3 deletions .e2e/gotest/skaffold/skaffold-e2e.withoutpprof.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ build:
dateTime:
format: 2006-01-02_15-04-05.999_MST
deploy:
kubectl:
manifests:
- .e2e/curl/e2e.yaml
kustomize:
paths:
- .e2e/gotest/k8s/jobs/withoutpprof
3 changes: 0 additions & 3 deletions .e2e/gotest/skaffold/skaffold-e2e.withpprof.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ build:
dateTime:
format: 2006-01-02_15-04-05.999_MST
deploy:
kubectl:
manifests:
- .e2e/curl/e2e.yaml
kustomize:
paths:
- .e2e/gotest/k8s/jobs/withpprof
3 changes: 0 additions & 3 deletions .e2e/gotest/skaffold/skaffold-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ build:
dateTime:
format: 2006-01-02_15-04-05.999_MST
deploy:
kubectl:
manifests:
- .e2e/curl/e2e.yaml
kustomize:
paths:
- .e2e/gotest/k8s/jobs/e2e
18 changes: 13 additions & 5 deletions .github/actions/e2e/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,25 @@ inputs:
runs:
using: "composite"
steps:
- name: e2e
- name: e2e-go
uses: hiberbee/github-action-skaffold@1.18.0
with:
command: run
filename: ${{ inputs.filename }}

- name: e2e wait
- name: e2e-go wait
shell: bash
run: |
kubectl wait --timeout=60s --for=condition=Complete -l app=e2e job
kubectl wait --timeout=60s --for=condition=Complete -l app=e2e-go job
run: kubectl wait --timeout=60s --for=condition=Complete -l app=e2e-go job

- name: e2e-curl
uses: hiberbee/github-action-skaffold@1.18.0
with:
command: run
filename: .e2e/gotest/skaffold/skaffold-e2e.curl.yaml

- name: e2e-curl wait
shell: bash
run: kubectl wait --timeout=60s --for=condition=Complete -l app=e2e job

- name: Debug
shell: bash
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/k8s/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ runs:
with:
command: run

- name: Set log level to trace
- name: Set log level to trace
shell: bash
run: |
kubectl set env deploy/geolocation-go LOGGER_LOG_LEVEL=trace
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
build:
strategy:
matrix:
version: [1.15, 1.16, 1.17, 1.18]
version: [1.15, 1.16, 1.17, 1.18, 1.19]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
Expand All @@ -30,7 +30,7 @@ jobs:
test:
strategy:
matrix:
version: [1.15, 1.16, 1.17, 1.18]
version: [1.15, 1.16, 1.17, 1.18, 1.19]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
Expand All @@ -47,7 +47,7 @@ jobs:
race-condition:
strategy:
matrix:
version: [1.15, 1.16, 1.17, 1.18]
version: [1.15, 1.16, 1.17, 1.18, 1.19]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ RUN CGO_ENABLED=0 go build -o main
FROM gcr.io/distroless/static

COPY --from=build-env /go/src/main /app
CMD ["/app"]
CMD ["/app"]
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@ To retrieve the country code and country name of the given IP address, `geolocat
+------------------------+ | *
+-------------+ (1) | | | * Update in-memory cache
| | GET /rest/v1/{ip} | | | *
| +------------------------------>| | | (3) *
| +------------------------------>| | | (3) v
| Client | | geolocation-go | +--------------> Redis lookup (optional)
| | (5) | | | ^
| |<------------------------------+ | | *
+-------------+ 200 - OK | | | * Update Redis cache
+------------------------+ | *
| *
| (4) *
| (4) v
+--------------> http://ip-api.com/json/{ip} lookup (optional)
```

Expand Down Expand Up @@ -90,10 +90,8 @@ To retrieve the country code and country name of the given IP address, `geolocat
* `REDIS_KEY_TTL` (default `24h`). TTL of a redis key: Time before the key saved in redis will expire.

* `GEOLOCATION_API` (default value `ip-api`). Define which geolocation API to use to retrieve geo IP information. Available options are:

* [`ip-api`](https://ip-api.com/)

* [`ipbase`](https://ipbase.com/)
* [`ip-api`](https://ip-api.com/)
* [`ipbase`](https://ipbase.com/)

* `IP_API_BASE_URL` (default value: `http://ip-api.com/json/`). Base URL for the [`ip-api`](https://ip-api.com/) API. Note that https is not available with the free plan.

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/justinas/alice v1.2.0
github.com/prometheus/client_golang v1.12.2
github.com/prometheus/common v0.34.0 // indirect
github.com/rs/xid v1.3.0
github.com/rs/zerolog v1.26.1
github.com/slok/go-http-metrics v0.10.0
github.com/spf13/viper v1.12.0
Expand Down
164 changes: 164 additions & 0 deletions internal/chain/chain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package chain

import (
"context"
"errors"
"sync"

"github.com/lescactus/geolocation-go/internal/models"
"github.com/rs/zerolog"
"github.com/rs/zerolog/hlog"
)

// Cache is a models.GeoIPRepository and is used within a
// Chain.
type Cache struct {
name string
repository models.GeoIPRepository
}

// Chain acts as a list of GeoIPRepository.
// It's goal is to provide a cache to read and write models.GeoIP
// in a chained way: when requesting a read, the first GeoIPRepository
// will be queried and in case of cache miss, the second GeoIPRepository will be queried,
// then in case of cache miss, the third GeoIPRepository will be queried and so on...
// When one of the cache is a hit, all the other caches will be updated if necessary.
//
//
// Typically, the chain would be composed of several caches stores, starting from the fastest to
// the slowest.
//
// Ex:
//
// 1. In memory cache - very fast
// |
// V
// 2. Redis - fast but slower than in memory cache
// |
// V
// 3. MySQL - fast but slower than redis
//
type Chain struct {
caches []Cache
l *zerolog.Logger
}

// New will return a new empty chain.
// It is then up to the caller to use the Add()
// method to add GeoIPRepository to the chain.
func New(l *zerolog.Logger) *Chain {
return &Chain{
l: l,
caches: make([]Cache, 0),
}
}

// Add will add a models.GeoIPRepository to the chain.
// Return an error if the GeoIPRepository is already present.
func (c *Chain) Add(name string, g models.GeoIPRepository) error {
if g == nil {
return errors.New("error: GeoIPRepository cannot be nil")
}

if len(c.caches) == 0 {
c.caches = append(c.caches, Cache{
name: name,
repository: g,
})
return nil
}

for _, cache := range c.caches {
if cache.name == name {
return errors.New("error: GeoIPRepository already present in chain")
}
}

c.caches = append(c.caches, Cache{name: name, repository: g})

return nil
}

// Get will attempt to retrieve the *models.GeoIP corresponding to the given ip.
//
// It will lookup each GeoIPRepository in the chain and return the first *models.GeoIP
// found.
// Each GeoIPRepository which doesn't have the *models.GeoIP will be updated accordingly.
//
// If no *models.GeoIP is found, an error will be returned.
func (c *Chain) Get(ctx context.Context, ip string) (*models.GeoIP, error) {
var g *models.GeoIP
var err error
var cacheMiss []string

req_id := reqIDFromContext(ctx)

for _, cache := range c.caches {
c.l.Trace().Str("req_id", req_id).Msgf("looking for %s in %s database from cache chain", ip, cache.name)

g, err = cache.repository.Get(ctx, ip)
if err != nil {
c.l.Debug().Str("req_id", req_id).Err(err).Msgf("cache miss from %s database", cache.name)
cacheMiss = append(cacheMiss, cache.name)
} else {
c.l.Debug().Str("req_id", req_id).Msgf("cache hit from %s database", cache.name)

// Update all caches with g if needed
if len(cacheMiss) > 0 {
go c.SaveInAllCaches(ctx, g)
}
return g, nil
}
}

return nil, errors.New("couldn't find entry in cache chain")
}

// Statuses will call each GeoIPRepository Status() function concurrently
// and return errors if any.
func (c *Chain) Statuses(ctx context.Context) map[string]error {
var wg sync.WaitGroup

errors := make(map[string]error)

for _, cache := range c.caches {
c := make(chan error, 1)
wg.Add(1)
go cache.repository.Status(ctx, &wg, c)

errors[cache.name] = <-c
}
wg.Wait()

return errors
}

// SaveInAllCaches will save geoip asynchronousely in all the caches from the chain.
func (c *Chain) SaveInAllCaches(ctx context.Context, geoip *models.GeoIP) {
req_id := reqIDFromContext(ctx)

var wg sync.WaitGroup
wg.Add(len(c.caches))

for _, cache := range c.caches {
go func(cache Cache) {
defer wg.Done()

c.l.Debug().Str("req_id", req_id).Msgf("updating cache %s with entry %s", cache.name, geoip.IP)
if err := cache.repository.Save(ctx, geoip); err != nil {
c.l.Error().Str("req_id", req_id).Msgf("fail to cache in %s database: %s", cache.name, err.Error())
} else {
c.l.Trace().Str("req_id", req_id).Msgf("cache %s updated with entry %s", cache.name, geoip.IP)
}
}(cache)
}

wg.Wait()
}

// reqIDFromContext extracts and returns the request id from
// the given context.
func reqIDFromContext(ctx context.Context) string {
req_id, _ := hlog.IDFromCtx(ctx)
return req_id.String()
}
Loading

0 comments on commit 17e4c85

Please sign in to comment.