From 30e322d9c691eaef60cea8dc560e14f8b599d475 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Sun, 15 Jun 2025 09:51:25 +0300 Subject: [PATCH] Feat: graceful shutdown --- handlers/actions.go | 3 +- handlers/actions_test.go | 107 +++++++++++++++++++++++++++++++++++++++ handlers/handlers.go | 2 + main.go | 30 +++++++++-- pkg/cache/impl.go | 26 +++++----- todos.md | 3 ++ 6 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 handlers/actions_test.go diff --git a/handlers/actions.go b/handlers/actions.go index 9019c12..9be7068 100644 --- a/handlers/actions.go +++ b/handlers/actions.go @@ -70,10 +70,11 @@ func getStateByCtx(ctx context.Context) (*models.UserState, error) { } func saveFullInfo(fi *models.FullInfo) error { - // INFO: unfortunately working no transactions; so case are possible where first object is updated but the second is not + // INFO: no transactions; so case is possible where first object is updated but the second is not if err := saveState(fi.State.Username, fi.State); err != nil { return err } + log.Debug("saved user state", "state", fi.State) if err := saveRoom(fi.Room); err != nil { return err } diff --git a/handlers/actions_test.go b/handlers/actions_test.go new file mode 100644 index 0000000..47f413c --- /dev/null +++ b/handlers/actions_test.go @@ -0,0 +1,107 @@ +package handlers + +import ( + "encoding/json" + "gralias/models" + "os" + "testing" +) + +func TestSaveState(t *testing.T) { + // Create test state + state := &models.UserState{ + Username: "testuser", + RoomID: "testroom", + Team: models.UserTeamBlue, + Role: models.UserRoleMime, + } + + // Save state + err := saveState(state.Username, state) + if err != nil { + t.Fatalf("saveState failed: %v", err) + } + + // Load state + loadedState, err := loadState(state.Username) + if err != nil { + t.Fatalf("loadState failed: %v", err) + } + + // Verify loaded state matches original + if loadedState.Username != state.Username { + t.Errorf("Username mismatch: got %s, want %s", loadedState.Username, state.Username) + } + if loadedState.RoomID != state.RoomID { + t.Errorf("RoomID mismatch: got %s, want %s", loadedState.RoomID, state.RoomID) + } + if loadedState.Team != state.Team { + t.Errorf("Team mismatch: got %s, want %s", loadedState.Team, state.Team) + } + if loadedState.Role != state.Role { + t.Errorf("Role mismatch: got %s, want %s", loadedState.Role, state.Role) + } + + // Test JSON serialization/deserialization + data, err := json.Marshal(state) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + testMap := make(map[string][]byte) + testMap["testkey"] = data + + // Create a temporary file + tmpFile, err := os.CreateTemp("", "test_store_*.json") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + tmpFileName := tmpFile.Name() + // defer os.Remove(tmpFileName) + + // Write testMap to the temp file + fileData, err := json.Marshal(testMap) + if err != nil { + t.Fatalf("failed to marshal testMap: %v", err) + } + if err := os.WriteFile(tmpFileName, fileData, 0644); err != nil { + t.Fatalf("failed to write to temp file: %v", err) + } + + // Read the temp file + readData, err := os.ReadFile(tmpFileName) + if err != nil { + t.Fatalf("failed to read temp file: %v", err) + } + + // Unmarshal the data + var testMapRead map[string][]byte + if err := json.Unmarshal(readData, &testMapRead); err != nil { + t.Fatalf("failed to unmarshal testMap: %v", err) + } + + // Get the state bytes from the map + stateBytes, ok := testMapRead["testkey"] + if !ok { + t.Fatalf("key 'testkey' not found in testMapRead") + } + + // Unmarshal the state bytes + stateRead := &models.UserState{} + if err := json.Unmarshal(stateBytes, stateRead); err != nil { + t.Fatalf("failed to unmarshal state: %v", err) + } + + // Compare the state + if stateRead.Username != state.Username { + t.Errorf("Username mismatch from file: got %s, want %s", stateRead.Username, state.Username) + } + if stateRead.RoomID != state.RoomID { + t.Errorf("RoomID mismatch from file: got %s, want %s", stateRead.RoomID, state.RoomID) + } + if stateRead.Team != state.Team { + t.Errorf("Team mismatch from file: got %s, want %s", stateRead.Team, state.Team) + } + if stateRead.Role != state.Role { + t.Errorf("Role mismatch from file: got %s, want %s", stateRead.Role, state.Role) + } +} diff --git a/handlers/handlers.go b/handlers/handlers.go index ead27f6..119d60c 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -9,6 +9,7 @@ import ( "log/slog" "net/http" "os" + "time" ) var ( @@ -26,6 +27,7 @@ func init() { memcache = cache.MemCache cfg = config.LoadConfigOrDefault("") Notifier = broker.Notifier + cache.MemCache.StartBackupRoutine(15 * time.Second) // Reduced backup interval } func HandlePing(w http.ResponseWriter, r *http.Request) { diff --git a/main.go b/main.go index 40da06c..b21ae34 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,15 @@ package main import ( + "context" "gralias/config" "gralias/handlers" + "gralias/pkg/cache" "log/slog" "net/http" + "os" + "os/signal" + "syscall" "time" ) @@ -14,7 +19,7 @@ func init() { cfg = config.LoadConfigOrDefault("") } -func ListenToRequests(port string) error { +func ListenToRequests(port string) *http.Server { mux := http.NewServeMux() server := &http.Server{ Handler: handlers.LogRequests(handlers.GetSession(mux)), @@ -47,12 +52,27 @@ func ListenToRequests(port string) error { // sse mux.Handle("GET /sub/sse", handlers.Notifier) slog.Info("Listening", "addr", port) - return server.ListenAndServe() + return server } func main() { - err := ListenToRequests(cfg.ServerConfig.Port) - if err != nil { - panic(err) + // Setup graceful shutdown + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + + server := ListenToRequests(cfg.ServerConfig.Port) + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + panic(err) + } + }() + + <-stop + slog.Info("Shutting down server...") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + slog.Error("server shutdown failed", "error", err) } + cache.MemCache.BackupNow() } diff --git a/pkg/cache/impl.go b/pkg/cache/impl.go index 04cf8f0..056d90e 100644 --- a/pkg/cache/impl.go +++ b/pkg/cache/impl.go @@ -102,6 +102,19 @@ func (mc *MemoryCache) RemoveKey(key string) { mc.lock.RUnlock() } +func (mc *MemoryCache) BackupNow() { + data := mc.GetAll() + jsonString, err := json.Marshal(data) + if err != nil { + slog.Warn("immediate backup failed to marshal", "err", err) + return + } + err = os.WriteFile(storeFileName, jsonString, os.ModePerm) + if err != nil { + slog.Warn("immediate backup failed to write", "err", err) + } +} + func (mc *MemoryCache) StartExpiryRoutine(n time.Duration) { ticker := time.NewTicker(n) go func() { @@ -127,18 +140,7 @@ func (mc *MemoryCache) StartBackupRoutine(n time.Duration) { go func() { for { <-ticker.C - // get all - data := mc.GetAll() - jsonString, err := json.Marshal(data) - if err != nil { - slog.Warn("failed to marshal", "err", err) - continue - } - err = os.WriteFile(storeFileName, jsonString, os.ModePerm) - if err != nil { - slog.Warn("failed to write", "err", err) - continue - } + mc.BackupNow() } }() } diff --git a/todos.md b/todos.md index 1fa1a4e..3d6586c 100644 --- a/todos.md +++ b/todos.md @@ -11,6 +11,8 @@ - show in backlog (and with that in prompt to llm) how many cards are left to open, also additional comment: if guess was right; - hide clue input for mime when it's not their turn; - needs resend to llm btn; + +- better styles and fluff; +- common auth system between sites; #### sse points - clue sse update; @@ -32,3 +34,4 @@ - gameover to backlog; - remove verbs from word file; - invite link gets cutoff; +- mime rejoined the room: does not see colors; state save in store.json has empty role and team