Skip to content

Commit 68db364

Browse files
committed
feat: added healthchecking service daemon process and added additional tests.
1 parent a54ad80 commit 68db364

File tree

12 files changed

+396
-56
lines changed

12 files changed

+396
-56
lines changed

.vscode/launch.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
// Use IntelliSense to learn about possible attributes.
3+
// Hover to view descriptions of existing attributes.
4+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5+
"version": "0.2.0",
6+
"configurations": [
7+
{
8+
"name": "Launch test function",
9+
"type": "go",
10+
"request": "launch",
11+
"mode": "test",
12+
"program": "${workspaceFolder}",
13+
"args": [
14+
"-test.run",
15+
"MyTestFunction"
16+
]
17+
},
18+
{
19+
"name": "Launch Package",
20+
"type": "go",
21+
"request": "launch",
22+
"mode": "auto",
23+
"program": "${fileDirname}"
24+
}
25+
]
26+
}

bin/air

-7.84 MB
Binary file not shown.

internal/handlers/helpers_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package handlers
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/syntaxsdev/mercury/models"
10+
)
11+
12+
func TestWriteHttp(t *testing.T) {
13+
recorder := httptest.NewRecorder()
14+
data := map[string]interface{}{"data": map[string]string{"name": "testing"}}
15+
16+
err := WriteHttp(recorder, http.StatusOK, "success", data)
17+
if err != nil {
18+
t.Fatalf("Expected no error, got %v", err)
19+
}
20+
21+
// Check status code
22+
if recorder.Code != http.StatusOK {
23+
t.Errorf("Expected status code %d, got %d", http.StatusOK, recorder.Code)
24+
}
25+
26+
// Check response body
27+
var response models.Response
28+
29+
if err = json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
30+
t.Error("Could not convert to Response type")
31+
}
32+
if dataMap, ok := response.Data.(map[string]interface{}); ok {
33+
if name, exists := dataMap["name"]; exists && name != "testing" {
34+
t.Errorf("Expected %v, got %v", response.Data, data)
35+
}
36+
}
37+
}

internal/handlers/strategy.go

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package handlers
22

33
import (
44
"encoding/json"
5-
"log"
65
"net/http"
76

87
"github.com/syntaxsdev/mercury/internal/services"
@@ -19,50 +18,26 @@ func GetAllStrategies(w http.ResponseWriter, r *http.Request, f *services.Factor
1918
}
2019

2120
if payload != nil {
21+
var results map[string]interface{}
22+
2223
err := f.MongoService.First("strategies", bson.M(payload), &results)
2324
if err == nil && results == nil {
24-
WriteHttp(w, http.StatusNotFound, "Nothing was found with the supplied filter", nil)
25+
WriteHttp(w, http.StatusNotFound, "No results were returned with the supplied filter", nil)
2526
return
2627
} else if err == nil {
27-
log.Println("Here")
2828
WriteHttp(w, http.StatusOK, "Successfully fetched all strategies by filter", results)
29+
return
2930
}
30-
log.Println("Here", err)
3131
}
3232
err := f.MongoService.All("strategies", bson.M{}, &results)
3333
if err != nil {
3434
http.Error(w, "Could not retrieve strategies.", http.StatusInternalServerError)
3535
return
3636
}
37-
code := http.StatusOK
38-
WriteHttp(w, code, "Successfully fetched all strategies", results)
37+
WriteHttp(w, http.StatusOK, "Successfully fetched all strategies", results)
3938

4039
}
4140

42-
// // Get Strategy By ID
43-
// func GetStrategyByID(w http.ResponseWriter, r *http.Request, f *services.Factory) {
44-
// var result interface{}
45-
// id := chi.URLParam(r, "id")
46-
// port, err := strconv.Atoi(id)
47-
// if err != nil {
48-
// WriteHttp(w, http.StatusBadRequest, "Invalid", err)
49-
// }
50-
// // objectID, err := primitive.ObjectIDFromHex(id)
51-
// // if err != nil {
52-
// // WriteHttp(w, http.StatusBadRequest, "Invalid ID", err)
53-
// // return
54-
// // }
55-
56-
// err = f.MongoService.First("strategies", bson.M{"port": port}, &result)
57-
// if err != nil {
58-
// WriteHttp(w, http.StatusBadRequest, "Could not retrieve strategy by ID.", nil)
59-
// log.Println(err)
60-
// return
61-
// }
62-
// WriteHttp(w, http.StatusOK, "Successfully fetched strategy", result)
63-
64-
// }
65-
6641
// Create A Strategy
6742
func CreateStrategy(w http.ResponseWriter, r *http.Request, f *services.Factory) {
6843
var newStrategy models.Strategy
@@ -73,11 +48,8 @@ func CreateStrategy(w http.ResponseWriter, r *http.Request, f *services.Factory)
7348
res, err := f.MongoService.Insert("strategies", newStrategy)
7449
if err != nil {
7550
http.Error(w, "Could not insert strategy", http.StatusInternalServerError)
51+
return
7652
}
7753

7854
WriteHttp(w, http.StatusCreated, "Strategy successfully created", res)
79-
// code := http.StatusCreated
80-
// w.Header().Set("Content-Type", "application/json")
81-
// w.WriteHeader(http.StatusCreated)
82-
// json.NewEncoder(w).Encode(models.Response{Message: "Strategy successfully created", Code: code, Data: res})
8355
}

internal/services/mongo.go

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package services
22

33
import (
44
"context"
5-
"log"
65
"time"
76

87
"go.mongodb.org/mongo-driver/v2/mongo"
@@ -30,10 +29,8 @@ func (s *MongoService) First(collection string, filter interface{}, result inter
3029
coll := s.Client.Database(s.DatabaseName).Collection(collection)
3130
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
3231
defer cancel()
33-
log.Println(filter)
34-
log.Println(collection)
32+
3533
err := coll.FindOne(ctx, filter).Decode(result)
36-
log.Println(result)
3734
if err == mongo.ErrNoDocuments {
3835
if resultPtr, ok := result.(*interface{}); ok {
3936
*resultPtr = nil
@@ -44,7 +41,7 @@ func (s *MongoService) First(collection string, filter interface{}, result inter
4441

4542
}
4643

47-
func (s *MongoService) All(collection string, filter interface{}, result *[]interface{}) error {
44+
func (s *MongoService) All(collection string, filter interface{}, result interface{}) error {
4845
coll := s.Client.Database(s.DatabaseName).Collection(collection)
4946
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
5047
defer cancel()
@@ -55,18 +52,10 @@ func (s *MongoService) All(collection string, filter interface{}, result *[]inte
5552
}
5653
defer cursor.Close(ctx)
5754

58-
for cursor.Next(ctx) {
59-
var doc interface{}
60-
if err := cursor.Decode(&doc); err != nil {
61-
return err
62-
}
63-
*result = append(*result, doc)
64-
}
65-
66-
if err := cursor.Err(); err != nil {
55+
// Use the cursor to decode directly into the provided result slice
56+
if err := cursor.All(ctx, result); err != nil {
6757
return err
68-
6958
}
70-
return nil
7159

60+
return nil
7261
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package monitoring
2+
3+
import (
4+
"log"
5+
"net/http"
6+
"sync"
7+
"time"
8+
9+
"github.com/syntaxsdev/mercury/internal/services"
10+
"github.com/syntaxsdev/mercury/models"
11+
)
12+
13+
// HealthData tracks data of each Strategies health
14+
type HealthData struct {
15+
mu sync.Mutex
16+
Healthy bool
17+
LastCheck time.Time
18+
FailureCount int
19+
}
20+
21+
// Update updates the health check result
22+
func (h *HealthData) Update(isHealthy bool) {
23+
h.mu.Lock()
24+
defer h.mu.Unlock()
25+
h.Healthy = isHealthy
26+
h.LastCheck = time.Now()
27+
if !isHealthy {
28+
h.FailureCount++
29+
return
30+
}
31+
h.FailureCount = 0
32+
}
33+
34+
// HealthChecker
35+
type HealthChecker struct {
36+
client *http.Client
37+
mu sync.Mutex
38+
healthData map[string]*HealthData
39+
}
40+
41+
// Initialize a HealthChecker Manager
42+
func NewHealthChecker(client *http.Client) *HealthChecker {
43+
if client == nil {
44+
client = &http.Client{}
45+
}
46+
return &HealthChecker{
47+
healthData: make(map[string]*HealthData),
48+
client: client,
49+
}
50+
}
51+
52+
func (h *HealthChecker) Check(data *HealthData, s *models.Strategy) {
53+
if s.Options.Active && s.HasHealthCheck() {
54+
url, _ := s.HealthCheckUrl()
55+
resp, err := h.client.Get(url)
56+
if err != nil || (resp.StatusCode < 200 || resp.StatusCode > 203) {
57+
data.Update(false)
58+
} else {
59+
data.Update(true)
60+
}
61+
}
62+
// Max 5 unhealthy checks
63+
if data.FailureCount == 5 {
64+
s.Options.Active = false
65+
}
66+
}
67+
68+
func (h *HealthChecker) BackgroundProcess(s *services.StrategyService) {
69+
defaultWait := 10
70+
for {
71+
strats, err := s.GetAllStrategies()
72+
if err != nil {
73+
log.Println("ERROR: Cannot get strategies")
74+
continue
75+
}
76+
77+
// Delay the wait if there is no strategies at the moment
78+
if len(strats) == 0 {
79+
defaultWait = 20
80+
continue
81+
}
82+
defaultWait = 10
83+
84+
for _, strat := range strats {
85+
h.mu.Lock()
86+
if _, exists := h.healthData[strat.Name]; !exists {
87+
h.healthData[strat.Name] = &HealthData{}
88+
}
89+
h.mu.Unlock()
90+
val := h.healthData[strat.Name]
91+
go h.Check(val, &strat)
92+
}
93+
time.Sleep(time.Duration(defaultWait) * time.Second)
94+
}
95+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package monitoring
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"net/url"
7+
"testing"
8+
9+
"github.com/syntaxsdev/mercury/models"
10+
)
11+
12+
func TestHealthCheck(t *testing.T) {
13+
// Mock HTTP Server
14+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+
// Simulate healthy check
16+
w.WriteHeader(http.StatusOK)
17+
}))
18+
defer server.Close()
19+
20+
// Mock Client
21+
mockClient := &http.Client{
22+
Transport: &http.Transport{
23+
Proxy: func(req *http.Request) (*url.URL, error) {
24+
return url.Parse(server.URL)
25+
},
26+
},
27+
}
28+
healthChecker := NewHealthChecker(mockClient)
29+
30+
s := models.Strategy{
31+
Name: "Test",
32+
Host: "http://localhost",
33+
Port: 8445,
34+
Options: models.StrategyOptions{Active: true},
35+
HealthCheck: models.HealthCheckOptions{},
36+
}
37+
s.SetDefaults()
38+
39+
t.Run("CheckStrategyHealth", func(t *testing.T) {
40+
data := HealthData{}
41+
healthChecker.Check(&data, &s)
42+
43+
// Asserts
44+
if data.FailureCount != 0 {
45+
t.Errorf("Expected 0, got %v", data.FailureCount)
46+
}
47+
if !data.Healthy {
48+
t.Errorf("Expected true got %v", data.Healthy)
49+
}
50+
})
51+
52+
t.Run("CheckUnhealthyStrategy", func(t *testing.T) {
53+
server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
54+
w.WriteHeader(http.StatusInternalServerError)
55+
})
56+
data := HealthData{}
57+
healthChecker.Check(&data, &s)
58+
59+
// Asserts
60+
if data.FailureCount != 1 {
61+
t.Errorf("Expected 1, got %v", data.FailureCount)
62+
}
63+
if data.Healthy {
64+
t.Errorf("Expected false got %v", data.Healthy)
65+
}
66+
})
67+
68+
t.Run("CheckClosedServer", func(t *testing.T) {
69+
// Close the server to simulate unavailable resource
70+
server.Close()
71+
72+
data := HealthData{}
73+
healthChecker.Check(&data, &s)
74+
75+
// Asserts
76+
if data.FailureCount != 1 {
77+
t.Errorf("Expected FailureCount 1, got %v", data.FailureCount)
78+
}
79+
if data.Healthy {
80+
t.Errorf("Expected Healthy false got %v", data.Healthy)
81+
}
82+
})
83+
}

0 commit comments

Comments
 (0)