Compare commits
3 Commits
e4e555cac3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e850d3b00 | |||
| 95fde663ca | |||
| 98692359db |
@@ -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
|
||||
|
||||
|
||||
109
README.md
109
README.md
@@ -40,9 +40,100 @@ Enterprise-style Scrum Poker application using Go, Gin, and SSE for real-time ro
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `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`)
|
||||
### Quick Reference
|
||||
|
||||
- `HOST` (default: `0.0.0.0`): network interface/address the server binds to.
|
||||
- `PORT` (default: `8002`): HTTP port the server listens on.
|
||||
- `DATA_PATH` (default: `./data`): folder where room JSON files are stored.
|
||||
- `MAX_ACTIVITY_LOG_ENTRIES` (default: `400`): max activity entries retained per room.
|
||||
- `ADMIN_LOG_BROADCAST_LIMIT` (default: `200`): max recent log entries sent to admins over SSE.
|
||||
- `STALE_ROOM_CLEANUP_INTERVAL` (default: `5m`): how often stale-room cleanup runs.
|
||||
- `STALE_ROOM_TTL` (default: `30m`): empty rooms older than this are deleted.
|
||||
|
||||
### Variable Guide With Use Cases
|
||||
|
||||
`HOST`
|
||||
- What it controls: where the web server is reachable from.
|
||||
- Use case: local-only dev on one machine.
|
||||
```bash
|
||||
HOST=localhost PORT=8002 go run ./src
|
||||
```
|
||||
- Use case: allow access from LAN/Docker/k8s ingress.
|
||||
```bash
|
||||
HOST=0.0.0.0 PORT=8002 go run ./src
|
||||
```
|
||||
|
||||
`PORT`
|
||||
- What it controls: server port.
|
||||
- Use case: avoid conflicts with another app already on `8002`.
|
||||
```bash
|
||||
PORT=9000 go run ./src
|
||||
```
|
||||
- Use case: common reverse-proxy port mapping.
|
||||
```bash
|
||||
HOST=0.0.0.0 PORT=8080 go run ./src
|
||||
```
|
||||
|
||||
`DATA_PATH`
|
||||
- What it controls: disk location for persisted rooms.
|
||||
- Use case: keep data in a dedicated local directory.
|
||||
```bash
|
||||
DATA_PATH=./tmp/rooms go run ./src
|
||||
```
|
||||
- Use case: persistent mount in containers.
|
||||
```bash
|
||||
docker run --rm -p 8002:8002 -e DATA_PATH=/app/data -v $(pwd)/data:/app/data scrum-solitare
|
||||
```
|
||||
|
||||
`MAX_ACTIVITY_LOG_ENTRIES`
|
||||
- What it controls: per-room in-memory/disk activity history cap.
|
||||
- Use case: retain more history for auditing/debugging.
|
||||
```bash
|
||||
MAX_ACTIVITY_LOG_ENTRIES=1000 go run ./src
|
||||
```
|
||||
- Use case: reduce memory/storage in lightweight deployments.
|
||||
```bash
|
||||
MAX_ACTIVITY_LOG_ENTRIES=100 go run ./src
|
||||
```
|
||||
|
||||
`ADMIN_LOG_BROADCAST_LIMIT`
|
||||
- What it controls: maximum recent activity entries included in admin SSE payloads.
|
||||
- Use case: richer admin timeline in active rooms.
|
||||
```bash
|
||||
ADMIN_LOG_BROADCAST_LIMIT=400 go run ./src
|
||||
```
|
||||
- Use case: smaller SSE payloads for lower bandwidth.
|
||||
```bash
|
||||
ADMIN_LOG_BROADCAST_LIMIT=50 go run ./src
|
||||
```
|
||||
|
||||
`STALE_ROOM_CLEANUP_INTERVAL`
|
||||
- What it controls: scheduler frequency for checking stale empty rooms.
|
||||
- Use case: aggressive cleanup in ephemeral environments.
|
||||
```bash
|
||||
STALE_ROOM_CLEANUP_INTERVAL=1m go run ./src
|
||||
```
|
||||
- Use case: reduce cleanup churn in stable environments.
|
||||
```bash
|
||||
STALE_ROOM_CLEANUP_INTERVAL=15m go run ./src
|
||||
```
|
||||
|
||||
`STALE_ROOM_TTL`
|
||||
- What it controls: time since last room activity before an empty room is deleted.
|
||||
- Use case: auto-prune quickly for temporary team rooms.
|
||||
```bash
|
||||
STALE_ROOM_TTL=10m go run ./src
|
||||
```
|
||||
- Use case: keep empty rooms around longer between sessions.
|
||||
```bash
|
||||
STALE_ROOM_TTL=2h go run ./src
|
||||
```
|
||||
|
||||
### Duration Format Notes
|
||||
|
||||
`STALE_ROOM_CLEANUP_INTERVAL` and `STALE_ROOM_TTL` use Go duration syntax:
|
||||
- valid examples: `30s`, `5m`, `45m`, `2h`
|
||||
- invalid examples: `5` (missing unit), `five minutes`
|
||||
|
||||
## Run Locally
|
||||
|
||||
@@ -53,13 +144,23 @@ go run ./src
|
||||
|
||||
Open `http://localhost:8002`.
|
||||
|
||||
To bind explicitly:
|
||||
Host binding examples:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -3,40 +3,138 @@ package state
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxActivityLogEntries = 400
|
||||
adminLogBroadcastLimit = 200
|
||||
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 {
|
||||
return nil, loadErr
|
||||
}
|
||||
manager.startCleanupLoop()
|
||||
|
||||
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) {
|
||||
roomName := normalizeName(input.RoomName, 80)
|
||||
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
|
||||
|
||||
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:] {
|
||||
@@ -734,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:]...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,3 +842,13 @@ func (m *Manager) disconnectParticipantLocked(room *Room, participant *Participa
|
||||
participant.UpdatedAt = nowUTC()
|
||||
room.UpdatedAt = nowUTC()
|
||||
}
|
||||
|
||||
func hasConnectedParticipantsLocked(room *Room) bool {
|
||||
for _, participant := range room.Participants {
|
||||
if participant.Connected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -75,3 +75,20 @@ func (ds *DiskStore) LoadAll() ([]persistedRoom, error) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user