diff --git a/Dockerfile b/Dockerfile index 7e55887..a1c6d3a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,10 @@ RUN mkdir -p /app/data && chown -R app:app /app EXPOSE 8002 ENV HOST=0.0.0.0 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 diff --git a/README.md b/README.md index 56c6701..d773c9a 100644 --- a/README.md +++ b/README.md @@ -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`) - `PORT`: server port (default `8002`) - `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 @@ -60,6 +64,16 @@ HOST=localhost 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 Build: diff --git a/src/config/config.go b/src/config/config.go index 0cbdcee..63e47ac 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -1,11 +1,47 @@ package config -import "os" +import ( + "os" + "strconv" + "time" +) type Config struct { Host string Port 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 { @@ -24,9 +60,18 @@ func Load() Config { 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{ Host: host, Port: port, DataPath: dataPath, + MaxActivityLogEntries: maxActivityLogEntries, + AdminLogBroadcastLimit: adminLogBroadcastLimit, + StaleRoomCleanupInterval: staleRoomCleanupInterval, + StaleRoomTTL: staleRoomTTL, } } diff --git a/src/main.go b/src/main.go index b25ed39..8122289 100644 --- a/src/main.go +++ b/src/main.go @@ -13,7 +13,12 @@ import ( func main() { 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 { log.Fatalf("failed to initialize state manager: %v", err) } diff --git a/src/state/manager.go b/src/state/manager.go index c09631b..65286db 100644 --- a/src/state/manager.go +++ b/src/state/manager.go @@ -11,27 +11,61 @@ import ( ) const ( - maxActivityLogEntries = 400 - adminLogBroadcastLimit = 200 - staleRoomCleanupInterval = 5 * time.Minute - staleRoomTTL = 30 * time.Minute + defaultMaxActivityLogEntries = 400 + defaultAdminLogBroadcastLimit = 200 + defaultStaleRoomCleanupInterval = 5 * time.Minute + defaultStaleRoomTTL = 30 * time.Minute ) type Manager struct { mu sync.RWMutex rooms map[string]*Room 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) if err != nil { return nil, err } + normalizedOpts := normalizeManagerOptions(opts) manager := &Manager{ rooms: make(map[string]*Room), store: store, + maxActivityLogEntries: normalizedOpts.MaxActivityLogEntries, + adminLogBroadcastLimit: normalizedOpts.AdminLogBroadcastLimit, + staleRoomCleanupInterval: normalizedOpts.StaleRoomCleanupInterval, + staleRoomTTL: normalizedOpts.StaleRoomTTL, } if loadErr := manager.loadFromDisk(); loadErr != nil { @@ -43,7 +77,7 @@ func NewManager(dataPath string) (*Manager, error) { } func (m *Manager) startCleanupLoop() { - ticker := time.NewTicker(staleRoomCleanupInterval) + ticker := time.NewTicker(m.staleRoomCleanupInterval) go func() { defer ticker.Stop() @@ -65,7 +99,7 @@ func (m *Manager) cleanupStaleRooms(now time.Time) { room.mu.Lock() roomID := room.ID hasConnected := hasConnectedParticipantsLocked(room) - recentlyActive := now.Sub(room.UpdatedAt) < staleRoomTTL + recentlyActive := now.Sub(room.UpdatedAt) < m.staleRoomTTL hasSubscribers := len(room.subscribers) > 0 room.mu.Unlock() @@ -81,7 +115,7 @@ func (m *Manager) cleanupStaleRooms(now time.Time) { } current.mu.Lock() - if hasConnectedParticipantsLocked(current) || now.Sub(current.UpdatedAt) < staleRoomTTL || len(current.subscribers) > 0 { + if hasConnectedParticipantsLocked(current) || now.Sub(current.UpdatedAt) < m.staleRoomTTL || len(current.subscribers) > 0 { current.mu.Unlock() m.mu.Unlock() continue @@ -747,8 +781,8 @@ func (m *Manager) marshalRoomState(room *Room, viewerParticipantID string) ([]by state.Links.AdminLink = "/room/" + room.ID + "?adminToken=" + room.AdminToken start := 0 - if len(room.ActivityLog) > adminLogBroadcastLimit { - start = len(room.ActivityLog) - adminLogBroadcastLimit + if len(room.ActivityLog) > m.adminLogBroadcastLimit { + start = len(room.ActivityLog) - m.adminLogBroadcastLimit } state.AdminLogs = make([]PublicActivityLogEntry, 0, len(room.ActivityLog)-start) for _, item := range room.ActivityLog[start:] { @@ -798,8 +832,8 @@ func (m *Manager) appendActivityLogLocked(room *Room, format string, args ...any Message: fmt.Sprintf(format, args...), }) - if len(room.ActivityLog) > maxActivityLogEntries { - room.ActivityLog = append([]ActivityLogEntry(nil), room.ActivityLog[len(room.ActivityLog)-maxActivityLogEntries:]...) + if len(room.ActivityLog) > m.maxActivityLogEntries { + room.ActivityLog = append([]ActivityLogEntry(nil), room.ActivityLog[len(room.ActivityLog)-m.maxActivityLogEntries:]...) } }