diff --git a/README.md b/README.md index 04c8747..1ff5192 100644 --- a/README.md +++ b/README.md @@ -249,8 +249,8 @@ Key properties: - **Ordered**: Queue entries have monotonically increasing IDs. Messages are always delivered in order within a client's queue. - **No delivery/read receipts** for channel messages. DM receipts are planned. -- **Queue depth**: Server-configurable via `QUEUE_MAX_AGE`. Default is 48 - hours. Entries older than this are pruned. +- **Queue depth**: Server-configurable via `QUEUE_MAX_AGE`. Default is 30 + days. Entries older than this are pruned. ### Long-Polling @@ -1788,14 +1788,14 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison). ### Data Lifecycle -- **Messages**: Rotated per `MAX_HISTORY` — oldest messages beyond the limit - are pruned periodically per target (channel or DM). +- **Messages**: Pruned automatically when older than `MESSAGE_MAX_AGE` + (default 30 days). - **Queue entries**: Pruned automatically when older than `QUEUE_MAX_AGE` - (default 48h). + (default 30 days). - **Channels**: Deleted when the last member leaves (ephemeral). - **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default - 24h) — the server runs a background cleanup loop that parts idle users + 30 days) — the server runs a background cleanup loop that parts idle users from all channels, broadcasts QUIT, and releases their nicks. --- @@ -1812,9 +1812,9 @@ directory is also loaded automatically via | `PORT` | int | `8080` | HTTP listen port | | `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. | | `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) | -| `MAX_HISTORY` | int | `10000` | Maximum messages retained per target (channel or DM) before rotation | -| `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. | -| `QUEUE_MAX_AGE` | int | `172800` | Maximum age of client queue entries in seconds (48h). Entries older than this are pruned. | +| `MESSAGE_MAX_AGE` | int | `2592000` | Maximum age of messages in seconds (30 days). Messages older than this are pruned. | +| `SESSION_IDLE_TIMEOUT` | string | `720h` | Session idle timeout as a Go duration string (e.g. `720h`, `24h`). Sessions with no activity for this long are expired and the nick is released. Default is 30 days. | +| `QUEUE_MAX_AGE` | int | `2592000` | Maximum age of client queue entries in seconds (30 days). Entries older than this are pruned. | | `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) | | `LONG_POLL_TIMEOUT`| int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) | | `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` | @@ -1833,7 +1833,7 @@ SERVER_NAME=My NeoIRC Server MOTD=Welcome! Be excellent to each other. DEBUG=false DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL -SESSION_IDLE_TIMEOUT=24h +SESSION_IDLE_TIMEOUT=720h ``` --- @@ -2229,7 +2229,7 @@ GET /api/v1/challenge - [ ] **Hashcash proof-of-work** for session creation (abuse prevention) - [x] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE` -- [x] **Message rotation** — enforce `MAX_HISTORY` per target +- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE` - [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n` - [ ] **User channel modes** — `+o` (operator), `+v` (voice) - [x] **MODE command** — query channel and user modes (set not yet implemented) diff --git a/internal/config/config.go b/internal/config/config.go index 393e98e..bd3b7b7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,7 +38,7 @@ type Config struct { MetricsUsername string Port int SentryDSN string - MaxHistory int + MessageMaxAge int MaxMessageSize int QueueMaxAge int MOTD string @@ -69,13 +69,13 @@ func New( viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_PASSWORD", "") - viper.SetDefault("MAX_HISTORY", "10000") + viper.SetDefault("MESSAGE_MAX_AGE", "2592000") viper.SetDefault("MAX_MESSAGE_SIZE", "4096") - viper.SetDefault("QUEUE_MAX_AGE", "172800") + viper.SetDefault("QUEUE_MAX_AGE", "2592000") viper.SetDefault("MOTD", defaultMOTD) viper.SetDefault("SERVER_NAME", "") viper.SetDefault("FEDERATION_KEY", "") - viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h") + viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h") err := viper.ReadInConfig() if err != nil { @@ -94,7 +94,7 @@ func New( MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsPassword: viper.GetString("METRICS_PASSWORD"), - MaxHistory: viper.GetInt("MAX_HISTORY"), + MessageMaxAge: viper.GetInt("MESSAGE_MAX_AGE"), MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), QueueMaxAge: viper.GetInt("QUEUE_MAX_AGE"), MOTD: viper.GetString("MOTD"), diff --git a/internal/db/queries.go b/internal/db/queries.go index 2974649..b6a8e0a 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1118,84 +1118,23 @@ func (database *Database) PruneOldQueueEntries( return deleted, nil } -// RotateChannelMessages enforces MAX_HISTORY per channel -// by deleting the oldest messages beyond the limit for -// each msg_to target. Returns the total number of rows -// removed. -func (database *Database) RotateChannelMessages( +// PruneOldMessages deletes messages older than cutoff and +// returns the number of rows removed. +func (database *Database) PruneOldMessages( ctx context.Context, - maxHistory int, + cutoff time.Time, ) (int64, error) { - if maxHistory <= 0 { - return 0, nil - } - - // Find distinct targets that have messages. - rows, err := database.conn.QueryContext(ctx, - `SELECT msg_to, COUNT(*) AS cnt - FROM messages - WHERE msg_to != '' - GROUP BY msg_to - HAVING cnt > ?`, - maxHistory, + res, err := database.conn.ExecContext(ctx, + "DELETE FROM messages WHERE created_at < ?", + cutoff, ) if err != nil { return 0, fmt.Errorf( - "list targets for rotation: %w", err, + "prune old messages: %w", err, ) } - defer func() { _ = rows.Close() }() + deleted, _ := res.RowsAffected() - type targetCount struct { - target string - count int64 - } - - var targets []targetCount - - for rows.Next() { - var entry targetCount - - err = rows.Scan(&entry.target, &entry.count) - if err != nil { - return 0, fmt.Errorf( - "scan target count: %w", err, - ) - } - - targets = append(targets, entry) - } - - err = rows.Err() - if err != nil { - return 0, fmt.Errorf("rows error: %w", err) - } - - var totalDeleted int64 - - for _, entry := range targets { - res, delErr := database.conn.ExecContext(ctx, - `DELETE FROM messages - WHERE msg_to = ? - AND id NOT IN ( - SELECT id FROM messages - WHERE msg_to = ? - ORDER BY id DESC - LIMIT ? - )`, - entry.target, entry.target, maxHistory, - ) - if delErr != nil { - return totalDeleted, fmt.Errorf( - "rotate messages for %s: %w", - entry.target, delErr, - ) - } - - deleted, _ := res.RowsAffected() - totalDeleted += deleted - } - - return totalDeleted, nil + return deleted, nil } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index aa817db..7ee70d5 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -31,7 +31,7 @@ type Params struct { Healthcheck *healthcheck.Healthcheck } -const defaultIdleTimeout = 24 * time.Hour +const defaultIdleTimeout = 30 * 24 * time.Hour // Handlers manages HTTP request handling. type Handlers struct { @@ -205,8 +205,7 @@ func (hdlr *Handlers) runCleanup( } // pruneQueuesAndMessages removes old client_queues entries -// per QUEUE_MAX_AGE, rotates messages per MAX_HISTORY, and -// cleans up orphaned messages. +// per QUEUE_MAX_AGE and prunes messages per MESSAGE_MAX_AGE. func (hdlr *Handlers) pruneQueuesAndMessages( ctx context.Context, ) { @@ -230,18 +229,22 @@ func (hdlr *Handlers) pruneQueuesAndMessages( } } - maxHistory := hdlr.params.Config.MaxHistory - if maxHistory > 0 { - rotated, err := hdlr.params.Database. - RotateChannelMessages(ctx, maxHistory) + messageMaxAge := hdlr.params.Config.MessageMaxAge + if messageMaxAge > 0 { + msgCutoff := time.Now().Add( + -time.Duration(messageMaxAge) * time.Second, + ) + + pruned, err := hdlr.params.Database. + PruneOldMessages(ctx, msgCutoff) if err != nil { hdlr.log.Error( - "message rotation failed", "error", err, + "message pruning failed", "error", err, ) - } else if rotated > 0 { + } else if pruned > 0 { hdlr.log.Info( - "rotated old messages", - "deleted", rotated, + "pruned old messages", + "deleted", pruned, ) } }