From d9dbadaf62a198b3cc70ec94577261d07378bc97 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Mar 2026 03:47:33 -0700 Subject: [PATCH] refactor: 30-day defaults, time-based message expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change QUEUE_MAX_AGE default from 48h to 30 days (2592000s) - Replace count-based MAX_HISTORY with time-based MESSAGE_MAX_AGE (default 30 days) — messages older than the configured age are pruned instead of keeping a fixed count per target - Update README to reflect new defaults and time-based expiry --- README.md | 16 +++---- internal/config/config.go | 8 ++-- internal/db/queries.go | 81 +++++------------------------------ internal/handlers/handlers.go | 23 +++++----- 4 files changed, 35 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 04c8747..54ae639 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,10 +1788,10 @@ 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 @@ -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 | +| `MESSAGE_MAX_AGE` | int | `2592000` | Maximum age of messages in seconds (30 days). Messages older than this are pruned. | | `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. | +| `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` | @@ -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 expiry** — delete 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..d0e3068 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,9 +69,9 @@ 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", "") @@ -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..8861c26 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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 old 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, ) } }