Compare commits

...

2 Commits

Author SHA1 Message Date
95fde663ca ENV Update 2026-03-07 01:19:37 +02:00
98692359db Cleanup Job 2026-03-07 01:16:39 +02:00
6 changed files with 202 additions and 9 deletions

View File

@@ -25,6 +25,10 @@ RUN mkdir -p /app/data && chown -R app:app /app
EXPOSE 8002 EXPOSE 8002
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=8002 ENV PORT=8002
ENV MAX_ACTIVITY_LOG_ENTRIES=400
ENV ADMIN_LOG_BROADCAST_LIMIT=200
ENV STALE_ROOM_CLEANUP_INTERVAL=5m
ENV STALE_ROOM_TTL=30m
USER app USER app

View File

@@ -43,6 +43,10 @@ Enterprise-style Scrum Poker application using Go, Gin, and SSE for real-time ro
- `HOST`: server host/interface to bind (default all interfaces, equivalent to `0.0.0.0`) - `HOST`: server host/interface to bind (default all interfaces, equivalent to `0.0.0.0`)
- `PORT`: server port (default `8002`) - `PORT`: server port (default `8002`)
- `DATA_PATH`: directory for room JSON files (default `./data`) - `DATA_PATH`: directory for room JSON files (default `./data`)
- `MAX_ACTIVITY_LOG_ENTRIES`: max stored activity log entries per room (default `400`)
- `ADMIN_LOG_BROADCAST_LIMIT`: max recent admin log entries sent in SSE payloads (default `200`)
- `STALE_ROOM_CLEANUP_INTERVAL`: cleanup ticker interval for stale rooms as Go duration (default `5m`)
- `STALE_ROOM_TTL`: delete empty rooms when last activity is older than this Go duration (default `30m`)
## Run Locally ## Run Locally
@@ -60,6 +64,16 @@ HOST=localhost PORT=8002 go run ./src
HOST=0.0.0.0 PORT=8002 go run ./src HOST=0.0.0.0 PORT=8002 go run ./src
``` ```
State tuning example:
```bash
MAX_ACTIVITY_LOG_ENTRIES=600 \
ADMIN_LOG_BROADCAST_LIMIT=300 \
STALE_ROOM_CLEANUP_INTERVAL=2m \
STALE_ROOM_TTL=45m \
go run ./src
```
## Docker ## Docker
Build: Build:

View File

@@ -1,11 +1,47 @@
package config package config
import "os" import (
"os"
"strconv"
"time"
)
type Config struct { type Config struct {
Host string Host string
Port string Port string
DataPath string DataPath string
MaxActivityLogEntries int
AdminLogBroadcastLimit int
StaleRoomCleanupInterval time.Duration
StaleRoomTTL time.Duration
}
func envIntOrDefault(name string, fallback int) int {
raw := os.Getenv(name)
if raw == "" {
return fallback
}
parsed, err := strconv.Atoi(raw)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func envDurationOrDefault(name string, fallback time.Duration) time.Duration {
raw := os.Getenv(name)
if raw == "" {
return fallback
}
parsed, err := time.ParseDuration(raw)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
} }
func Load() Config { func Load() Config {
@@ -24,9 +60,18 @@ func Load() Config {
dataPath = "./data" dataPath = "./data"
} }
maxActivityLogEntries := envIntOrDefault("MAX_ACTIVITY_LOG_ENTRIES", 400)
adminLogBroadcastLimit := envIntOrDefault("ADMIN_LOG_BROADCAST_LIMIT", 200)
staleRoomCleanupInterval := envDurationOrDefault("STALE_ROOM_CLEANUP_INTERVAL", 5*time.Minute)
staleRoomTTL := envDurationOrDefault("STALE_ROOM_TTL", 30*time.Minute)
return Config{ return Config{
Host: host, Host: host,
Port: port, Port: port,
DataPath: dataPath, DataPath: dataPath,
MaxActivityLogEntries: maxActivityLogEntries,
AdminLogBroadcastLimit: adminLogBroadcastLimit,
StaleRoomCleanupInterval: staleRoomCleanupInterval,
StaleRoomTTL: staleRoomTTL,
} }
} }

View File

@@ -13,7 +13,12 @@ import (
func main() { func main() {
cfg := config.Load() cfg := config.Load()
manager, err := state.NewManager(cfg.DataPath) manager, err := state.NewManager(cfg.DataPath, state.ManagerOptions{
MaxActivityLogEntries: cfg.MaxActivityLogEntries,
AdminLogBroadcastLimit: cfg.AdminLogBroadcastLimit,
StaleRoomCleanupInterval: cfg.StaleRoomCleanupInterval,
StaleRoomTTL: cfg.StaleRoomTTL,
})
if err != nil { if err != nil {
log.Fatalf("failed to initialize state manager: %v", err) log.Fatalf("failed to initialize state manager: %v", err)
} }

View File

@@ -3,40 +3,138 @@ package state
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"slices" "slices"
"strings" "strings"
"sync" "sync"
"time"
) )
const ( const (
maxActivityLogEntries = 400 defaultMaxActivityLogEntries = 400
adminLogBroadcastLimit = 200 defaultAdminLogBroadcastLimit = 200
defaultStaleRoomCleanupInterval = 5 * time.Minute
defaultStaleRoomTTL = 30 * time.Minute
) )
type Manager struct { type Manager struct {
mu sync.RWMutex mu sync.RWMutex
rooms map[string]*Room rooms map[string]*Room
store *DiskStore store *DiskStore
maxActivityLogEntries int
adminLogBroadcastLimit int
staleRoomCleanupInterval time.Duration
staleRoomTTL time.Duration
} }
func NewManager(dataPath string) (*Manager, error) { type ManagerOptions struct {
MaxActivityLogEntries int
AdminLogBroadcastLimit int
StaleRoomCleanupInterval time.Duration
StaleRoomTTL time.Duration
}
func normalizeManagerOptions(opts ManagerOptions) ManagerOptions {
if opts.MaxActivityLogEntries <= 0 {
opts.MaxActivityLogEntries = defaultMaxActivityLogEntries
}
if opts.AdminLogBroadcastLimit <= 0 {
opts.AdminLogBroadcastLimit = defaultAdminLogBroadcastLimit
}
if opts.StaleRoomCleanupInterval <= 0 {
opts.StaleRoomCleanupInterval = defaultStaleRoomCleanupInterval
}
if opts.StaleRoomTTL <= 0 {
opts.StaleRoomTTL = defaultStaleRoomTTL
}
return opts
}
func NewManager(dataPath string, opts ManagerOptions) (*Manager, error) {
store, err := NewDiskStore(dataPath) store, err := NewDiskStore(dataPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
normalizedOpts := normalizeManagerOptions(opts)
manager := &Manager{ manager := &Manager{
rooms: make(map[string]*Room), rooms: make(map[string]*Room),
store: store, store: store,
maxActivityLogEntries: normalizedOpts.MaxActivityLogEntries,
adminLogBroadcastLimit: normalizedOpts.AdminLogBroadcastLimit,
staleRoomCleanupInterval: normalizedOpts.StaleRoomCleanupInterval,
staleRoomTTL: normalizedOpts.StaleRoomTTL,
} }
if loadErr := manager.loadFromDisk(); loadErr != nil { if loadErr := manager.loadFromDisk(); loadErr != nil {
return nil, loadErr return nil, loadErr
} }
manager.startCleanupLoop()
return manager, nil return manager, nil
} }
func (m *Manager) startCleanupLoop() {
ticker := time.NewTicker(m.staleRoomCleanupInterval)
go func() {
defer ticker.Stop()
for range ticker.C {
m.cleanupStaleRooms(nowUTC())
}
}()
}
func (m *Manager) cleanupStaleRooms(now time.Time) {
m.mu.RLock()
rooms := make([]*Room, 0, len(m.rooms))
for _, room := range m.rooms {
rooms = append(rooms, room)
}
m.mu.RUnlock()
for _, room := range rooms {
room.mu.Lock()
roomID := room.ID
hasConnected := hasConnectedParticipantsLocked(room)
recentlyActive := now.Sub(room.UpdatedAt) < m.staleRoomTTL
hasSubscribers := len(room.subscribers) > 0
room.mu.Unlock()
if hasConnected || recentlyActive || hasSubscribers {
continue
}
m.mu.Lock()
current, ok := m.rooms[roomID]
if !ok || current != room {
m.mu.Unlock()
continue
}
current.mu.Lock()
if hasConnectedParticipantsLocked(current) || now.Sub(current.UpdatedAt) < m.staleRoomTTL || len(current.subscribers) > 0 {
current.mu.Unlock()
m.mu.Unlock()
continue
}
delete(m.rooms, roomID)
current.mu.Unlock()
m.mu.Unlock()
if err := m.store.Delete(roomID); err != nil {
log.Printf("failed to delete stale room %s: %v", roomID, err)
m.mu.Lock()
if _, exists := m.rooms[roomID]; !exists {
m.rooms[roomID] = room
}
m.mu.Unlock()
}
}
}
func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) { func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
roomName := normalizeName(input.RoomName, 80) roomName := normalizeName(input.RoomName, 80)
creatorUsername := normalizeName(input.CreatorUsername, 32) creatorUsername := normalizeName(input.CreatorUsername, 32)
@@ -683,8 +781,8 @@ func (m *Manager) marshalRoomState(room *Room, viewerParticipantID string) ([]by
state.Links.AdminLink = "/room/" + room.ID + "?adminToken=" + room.AdminToken state.Links.AdminLink = "/room/" + room.ID + "?adminToken=" + room.AdminToken
start := 0 start := 0
if len(room.ActivityLog) > adminLogBroadcastLimit { if len(room.ActivityLog) > m.adminLogBroadcastLimit {
start = len(room.ActivityLog) - adminLogBroadcastLimit start = len(room.ActivityLog) - m.adminLogBroadcastLimit
} }
state.AdminLogs = make([]PublicActivityLogEntry, 0, len(room.ActivityLog)-start) state.AdminLogs = make([]PublicActivityLogEntry, 0, len(room.ActivityLog)-start)
for _, item := range room.ActivityLog[start:] { for _, item := range room.ActivityLog[start:] {
@@ -734,8 +832,8 @@ func (m *Manager) appendActivityLogLocked(room *Room, format string, args ...any
Message: fmt.Sprintf(format, args...), Message: fmt.Sprintf(format, args...),
}) })
if len(room.ActivityLog) > maxActivityLogEntries { if len(room.ActivityLog) > m.maxActivityLogEntries {
room.ActivityLog = append([]ActivityLogEntry(nil), room.ActivityLog[len(room.ActivityLog)-maxActivityLogEntries:]...) room.ActivityLog = append([]ActivityLogEntry(nil), room.ActivityLog[len(room.ActivityLog)-m.maxActivityLogEntries:]...)
} }
} }
@@ -744,3 +842,13 @@ func (m *Manager) disconnectParticipantLocked(room *Room, participant *Participa
participant.UpdatedAt = nowUTC() participant.UpdatedAt = nowUTC()
room.UpdatedAt = nowUTC() room.UpdatedAt = nowUTC()
} }
func hasConnectedParticipantsLocked(room *Room) bool {
for _, participant := range room.Participants {
if participant.Connected {
return true
}
}
return false
}

View File

@@ -75,3 +75,20 @@ func (ds *DiskStore) LoadAll() ([]persistedRoom, error) {
return rooms, nil return rooms, nil
} }
func (ds *DiskStore) Delete(roomID string) error {
ds.mu.Lock()
defer ds.mu.Unlock()
finalPath := filepath.Join(ds.dataPath, roomID+".json")
if err := os.Remove(finalPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
tmpPath := finalPath + ".tmp"
if err := os.Remove(tmpPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return nil
}