Skip to content

Commit

Permalink
no more redis!
Browse files Browse the repository at this point in the history
Replace by an in-memory KV store instead that way we have an http
downstream service

Signed-off-by: Charly Molter <[email protected]>
  • Loading branch information
lahabana committed Nov 14, 2024
1 parent 42ed492 commit 1f51e89
Show file tree
Hide file tree
Showing 16 changed files with 710 additions and 218 deletions.
24 changes: 18 additions & 6 deletions ERRORS.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
# Errors
# Error Codes

The list of error details that can be returned by our app.
This document provides a list of error codes used in the application.

## REDIS-FAILURE
## INVALID-JSON

Redis failed in some way
Invalid JSON

## DEMOAPP-FAILURE
## INTERNAL-ERROR

Demo-app failed
A complex error occured

## KV-NOT-FOUND

Couldn't find a kv entry

## KV-DISABLED

You can't use KV or KVUrl is not set. Are you talking to the right service?

## KV-CONFLICT

A conflict when update a key with compare and swap
8 changes: 4 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ OAPI_CODEGEN = $(PROJECT_DIR)/bin/installs/oapi-codegen/$(OAPI_CODEGEN_VERSION)/
oapi-codegen.download: | mise ## Download oapi-codegen locally if necessary.
$(MISE) install -y -q oapi-codegen@$(OAPI_CODEGEN_VERSION)

app/internal/api/gen.go: openapi.yaml | oapi-codegen.download
$(OAPI_CODEGEN) --config openapi-config.yaml $<
pkg/api/gen.go: openapi-config.yaml openapi.yaml | oapi-codegen.download
$(OAPI_CODEGEN) --config openapi-config.yaml openapi.yaml

.PHONY: clean
clean:
@rm -rf app/internal/api/gen.go
@rm -rf pkg/api/gen.go
@rm -rf dist/
@rm -rf bin/

Expand Down Expand Up @@ -63,7 +63,7 @@ build: | goreleaser.download
$(GORELEASER) release --snapshot --clean

.PHONY: generate
generate: app/internal/api/gen.go
generate: pkg/api/gen.go
go generate ./...

.PHONY: test
Expand Down
15 changes: 7 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ Kuma is a CNCF Sandbox project.

## Introduction

The application consists of two services:
The application consists of the same app instantiated differently to simulate 2 services:

- A `demo-app` service that presents a web application that allows us to increment a numeric counter
- A `redis` service that stores the counter
- A `kv` service which simulates a database.


```mermaid
Expand All @@ -29,16 +29,16 @@ browser
subgraph kuma-mesh
edge-gateway
demo-app(demo-app :5050)
redis(redis :6379)
kv(kv :5050)
end
edge-gateway --> demo-app
demo-app --> redis
demo-app --> kv
browser --> edge-gateway
```

The `demo-app` service presents a browser interface that listens on port `5050`. When it starts, it expects to find a `zone` key in Redis that specifies the name of the datacenter (or cluster) that the current `redis` instance belongs to. This name is then displayed in the `demo-app` GUI.
The `demo-app` service presents a browser interface that listens on port `5050`.

The `zone` key is purely static and arbitrary, but by having different `zone` values across different `redis` instances, we know at any given time from which Redis instance we are fetching/incrementing our counter when we route across a distributed environment across many zones, clusters and clouds.
You can set the zone key on the kv `curl -v -XPOST -d '{"value":"zone-1"}' localhost:5050/api/key-value/zone -H 'content-type: application/json'` where `localhost:5050` is your kv service.

## Run the application

Expand All @@ -48,8 +48,7 @@ Follow the [getting-started](https://kuma.io/docs/latest/quickstart/kubernetes-d

We can configure the following environment variables when running `demo-app`:

* `REDIS_HOST`: Determines the hostname to use when connecting to Redis. Default is `127.0.0.1`.
* `REDIS_PORT`: Determines the port to use when connecting to Redis. Default is `6379`.
* `KV_URL`: The address at which to contact the service.
* `APP_VERSION`: Lets you change the version number displayed in the main page of `demo-app`. Default is `1.0`.
* `APP_COLOR`: Lets you change background color of the `demo-app` main page. Default is `#efefef`.

Expand Down
160 changes: 160 additions & 0 deletions app/internal/base/counter_api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package base

import (
"bytes"
"context"
"encoding/json"
"github.com/kumahq/kuma-counter-demo/pkg/api"
"net/http"
"strconv"
)

// This API uses 2 entry in the KV COUNTER and ZONE
const (
COUNTER_KEY = "counter"
ZONE_KEY = "zone"
)

func (s *ServerImpl) DeleteCounter(w http.ResponseWriter, r *http.Request) {
if s.guardKvApi(w, r, true) {
return
}
ctx := r.Context()
req, _ := http.NewRequestWithContext(ctx, http.MethodDelete, s.kvUrl+"/api/key-value/"+COUNTER_KEY, nil)
res, err := http.DefaultClient.Do(req)
if err != nil {
Error(w, r, http.StatusInternalServerError, api.INTERNAL_ERROR, err, "failed sending request")
return
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotFound {
Error(w, r, res.StatusCode, api.KV_NOT_FOUND, err, "Key not found")
return
}
zone, err := s.getKey(ctx, ZONE_KEY)
if err != nil {
Error(w, r, http.StatusInternalServerError, api.INTERNAL_ERROR, err, "failed to retrieve zone")
return
}

response := api.DeleteCounterResponse{
Counter: 0,
Zone: zone,
}

writeResponse(w, r, http.StatusOK, response, nil)
}

func (s *ServerImpl) getKey(ctx context.Context, key string) (string, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, s.kvUrl+"/api/key-value/"+key, nil)
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotFound {
return "", nil
}
zoneResponse := api.KVGetResponse{}
err = json.NewDecoder(res.Body).Decode(&zoneResponse)
if err != nil {
return "", err
}
return zoneResponse.Value, nil
}

func (s *ServerImpl) GetCounter(w http.ResponseWriter, r *http.Request) {
if s.guardKvApi(w, r, true) {
return
}
ctx := r.Context()
counter, err := s.getKey(ctx, COUNTER_KEY)
if err != nil {
Error(w, r, http.StatusInternalServerError, api.INTERNAL_ERROR, err, "failed to retrieve zone")
return
}
zone, err := s.getKey(ctx, ZONE_KEY)
if err != nil {
Error(w, r, http.StatusInternalServerError, api.INTERNAL_ERROR, err, "failed to retrieve zone")
return
}
c := 0
if counter != "" {
c, _ = strconv.Atoi(counter)
}

response := api.GetCounterResponse{
Counter: c,
Zone: zone,
}

writeResponse(w, r, http.StatusOK, response, nil)
}

func (s *ServerImpl) PostCounter(w http.ResponseWriter, r *http.Request) {
if s.guardKvApi(w, r, true) {
return
}
ctx := r.Context()
zone, err := s.getKey(ctx, ZONE_KEY)
if err != nil {
Error(w, r, http.StatusInternalServerError, api.INTERNAL_ERROR, err, "failed to retrieve zone")
return
}
for i := 0; i < 5; i++ {
if s.tryIncrementCounter(w, r, zone) {
return
}
}
Error(w, r, http.StatusConflict, api.KV_CONFLICT, nil, "out of retries without success")

}

func (s *ServerImpl) tryIncrementCounter(w http.ResponseWriter, r *http.Request, zone string) bool {
ctx := r.Context()
counter, err := s.getKey(ctx, COUNTER_KEY)
if err != nil {
s.logger.InfoContext(ctx, "failed to retrieve counter", "error", err)
Error(w, r, http.StatusInternalServerError, api.INTERNAL_ERROR, err, "failed to retrieve zone")
return true
}
c := 0
if counter != "" {
c, _ = strconv.Atoi(counter)
}

// Now let's update
b, _ := json.Marshal(api.KVPostRequest{Value: strconv.Itoa(c + 1), Expect: &counter})
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, s.kvUrl+"/api/key-value/"+COUNTER_KEY, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
Error(w, r, http.StatusInternalServerError, api.INTERNAL_ERROR, err, "failed sending request")
return true
}
switch res.StatusCode {
case http.StatusConflict:
return false
case http.StatusNotFound:
Error(w, r, res.StatusCode, api.KV_NOT_FOUND, nil, "counter key not found")
return true
case http.StatusOK:
counterResponse := api.KVPostResponse{}
err = json.NewDecoder(res.Body).Decode(&counterResponse)
if err != nil {
Error(w, r, http.StatusInternalServerError, api.INTERNAL_ERROR, nil, "failed to parse counter response")
return true
}
c, _ := strconv.Atoi(counterResponse.Value)
response := api.PostCounterResponse{
Counter: c,
Zone: zone,
}
writeResponse(w, r, http.StatusOK, response, nil)
return true
default:
Error(w, r, res.StatusCode, api.INTERNAL_ERROR, nil, "failed sending request")
return true
}

}
118 changes: 19 additions & 99 deletions app/internal/base/handler.go
Original file line number Diff line number Diff line change
@@ -1,120 +1,40 @@
package base

import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/kumahq/kuma-counter-demo/app/internal/api"
"github.com/redis/go-redis/v9"
"github.com/kumahq/kuma-counter-demo/pkg/api"
"go.opentelemetry.io/otel/trace"
"log/slog"
"net/http"
"strconv"
"sync"
)

const (
COUNTER_KEY = "counter"
ZONE_KEY = "zone"
)

type ServerImpl struct {
redisClient *redis.Client
color string
version string
func Error(w http.ResponseWriter, r *http.Request, statusCode int, errorType api.ErrorType, err error, format string, args ...any) {
s := errorType.QualifiedType()
span := trace.SpanFromContext(r.Context())
writeResponse(w, r, statusCode, api.Error{Type: &s, Status: statusCode, Instance: span.SpanContext().TraceID().String(), Title: fmt.Sprintf(format, args...)}, err)
}

var (
DemoAppErrorType = "https://github.com/kumahq/kuma-counter-demo/blob/master/ERRORS.md#DEMOAPP-FAILURE"
)

func redisError(ctx context.Context, statusCode int, title string) api.Error {
redisErrorType := "https://github.com/kumahq/kuma-counter-demo/blob/master/ERRORS.md#REDIS-FAILURE"
span := trace.SpanFromContext(ctx)
return api.Error{Type: &redisErrorType, Status: statusCode, Instance: span.SpanContext().TraceID().String(), Title: title}
type ServerImpl struct {
logger *slog.Logger
kvUrl string
kv map[string]api.KV
color string
version string
sync.Mutex
}

func NewServerImpl(client *redis.Client, version string, color string) api.ServerInterface {
func NewServerImpl(logger *slog.Logger, kvUrl string, version string, color string) api.ServerInterface {
return &ServerImpl{
redisClient: client,
version: version,
color: color,
}

}

func (s *ServerImpl) DeleteCounter(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := s.redisClient.Del(ctx, COUNTER_KEY).Err(); err != nil {
writeResponse(w, r, http.StatusInternalServerError, redisError(ctx, http.StatusInternalServerError, "failed to delete counter"), err)
return
}

zone, err := s.redisClient.Get(ctx, ZONE_KEY).Result()
if errors.Is(err, redis.Nil) {
zone = ""
} else if err != nil {
writeResponse(w, r, http.StatusInternalServerError, redisError(ctx, http.StatusInternalServerError, "failed to retrieve zone"), err)
return
}

response := api.DeleteCounterResponse{
Counter: 0,
Zone: zone,
}

writeResponse(w, r, http.StatusOK, response, nil)
}

func (s *ServerImpl) GetCounter(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
counter, err := s.redisClient.Get(ctx, COUNTER_KEY).Result()
if errors.Is(err, redis.Nil) {
counter = "0"
} else if err != nil {
writeResponse(w, r, http.StatusInternalServerError, redisError(ctx, http.StatusInternalServerError, "failed to retrieve counter"), err)
return
}

zone, err := s.redisClient.Get(ctx, ZONE_KEY).Result()
if errors.Is(err, redis.Nil) {
zone = ""
} else if err != nil {
writeResponse(w, r, http.StatusInternalServerError, redisError(ctx, http.StatusInternalServerError, "failed to retrieve zone"), err)
return
}
c, _ := strconv.Atoi(counter)

response := api.GetCounterResponse{
Counter: c,
Zone: zone,
}

writeResponse(w, r, http.StatusOK, response, nil)
}

func (s *ServerImpl) PostCounter(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
counter, err := s.redisClient.Incr(ctx, COUNTER_KEY).Result()
if err != nil {
writeResponse(w, r, http.StatusInternalServerError, redisError(ctx, http.StatusInternalServerError, "failed to increment counter"), err)
return
}

zone, err := s.redisClient.Get(ctx, ZONE_KEY).Result()
if errors.Is(redis.Nil, err) {
zone = ""
} else if err != nil {
writeResponse(w, r, http.StatusInternalServerError, redisError(ctx, http.StatusInternalServerError, "failed to retrieve zone"), err)
return
}

response := api.PostCounterResponse{
Counter: int(counter),
Zone: zone,
logger: logger,
kv: map[string]api.KV{},
kvUrl: kvUrl,
version: version,
color: color,
}

writeResponse(w, r, http.StatusOK, response, nil)
}

func (s *ServerImpl) GetVersion(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading

0 comments on commit 1f51e89

Please sign in to comment.