From d57d5babf01eca44492d9f7e89f1b935fbf52dc9 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 10 Mar 2026 03:20:21 -0700 Subject: [PATCH 1/5] feat: implement queue pruning and message rotation Enforce QUEUE_MAX_AGE and MAX_HISTORY config values that previously existed but were not applied. The existing cleanup loop now also: - Prunes client_queues entries older than QUEUE_MAX_AGE (default 48h) - Rotates messages per target (channel/DM) beyond MAX_HISTORY (default 10000) - Removes orphaned messages no longer referenced by any client queue closes #40 --- README.md | 17 ++--- internal/config/config.go | 3 + internal/db/queries.go | 125 ++++++++++++++++++++++++++++++++++ internal/handlers/handlers.go | 59 ++++++++++++++++ 4 files changed, 196 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2d061a3..85a1895 100644 --- a/README.md +++ b/README.md @@ -1788,10 +1788,11 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison). ### Data Lifecycle -- **Messages**: Stored indefinitely in the current implementation. Rotation - per `MAX_HISTORY` is planned. -- **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is - planned. +- **Messages**: Rotated per `MAX_HISTORY` — oldest messages beyond the limit + are pruned periodically per target (channel or DM). Orphaned messages (no + longer referenced by any client queue) are also removed. +- **Queue entries**: Pruned automatically when older than `QUEUE_MAX_AGE` + (default 48h). - **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 +1813,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 channel before rotation (planned) | +| `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 (planned). | +| `QUEUE_MAX_AGE` | int | `172800` | Maximum age of client queue entries in seconds (48h). 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` | @@ -2228,8 +2229,8 @@ GET /api/v1/challenge ### Post-MVP (Planned) - [ ] **Hashcash proof-of-work** for session creation (abuse prevention) -- [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE` -- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel +- [x] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE` +- [x] **Message rotation** — enforce `MAX_HISTORY` per target - [ ] **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 468ca75..393e98e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,6 +40,7 @@ type Config struct { SentryDSN string MaxHistory int MaxMessageSize int + QueueMaxAge int MOTD string ServerName string FederationKey string @@ -70,6 +71,7 @@ func New( viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("MAX_HISTORY", "10000") viper.SetDefault("MAX_MESSAGE_SIZE", "4096") + viper.SetDefault("QUEUE_MAX_AGE", "172800") viper.SetDefault("MOTD", defaultMOTD) viper.SetDefault("SERVER_NAME", "") viper.SetDefault("FEDERATION_KEY", "") @@ -94,6 +96,7 @@ func New( MetricsPassword: viper.GetString("METRICS_PASSWORD"), MaxHistory: viper.GetInt("MAX_HISTORY"), MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), + QueueMaxAge: viper.GetInt("QUEUE_MAX_AGE"), MOTD: viper.GetString("MOTD"), ServerName: viper.GetString("SERVER_NAME"), FederationKey: viper.GetString("FEDERATION_KEY"), diff --git a/internal/db/queries.go b/internal/db/queries.go index 6ffff23..a4a4c26 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1096,3 +1096,128 @@ func (database *Database) GetSessionCreatedAt( return createdAt, nil } + +// PruneOldQueueEntries deletes client_queues rows older +// than cutoff and returns the number of rows removed. +func (database *Database) PruneOldQueueEntries( + ctx context.Context, + cutoff time.Time, +) (int64, error) { + res, err := database.conn.ExecContext(ctx, + "DELETE FROM client_queues WHERE created_at < ?", + cutoff, + ) + if err != nil { + return 0, fmt.Errorf( + "prune old queue entries: %w", err, + ) + } + + deleted, _ := res.RowsAffected() + + return deleted, nil +} + +// PruneOrphanedMessages deletes messages that are no +// longer referenced by any client_queues row and returns +// the number of rows removed. +func (database *Database) PruneOrphanedMessages( + ctx context.Context, +) (int64, error) { + res, err := database.conn.ExecContext(ctx, + `DELETE FROM messages WHERE id NOT IN + (SELECT DISTINCT message_id + FROM client_queues)`, + ) + if err != nil { + return 0, fmt.Errorf( + "prune orphaned messages: %w", err, + ) + } + + deleted, _ := res.RowsAffected() + + 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( + ctx context.Context, + maxHistory int, +) (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, + ) + if err != nil { + return 0, fmt.Errorf( + "list targets for rotation: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + 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 +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 72ef994..4876284 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -200,4 +200,63 @@ func (hdlr *Handlers) runCleanup( "deleted", deleted, ) } + + hdlr.pruneQueuesAndMessages(ctx) +} + +// pruneQueuesAndMessages removes old client_queues entries +// per QUEUE_MAX_AGE, rotates messages per MAX_HISTORY, and +// cleans up orphaned messages. +func (hdlr *Handlers) pruneQueuesAndMessages( + ctx context.Context, +) { + queueMaxAge := hdlr.params.Config.QueueMaxAge + if queueMaxAge > 0 { + queueCutoff := time.Now().Add( + -time.Duration(queueMaxAge) * time.Second, + ) + + pruned, err := hdlr.params.Database. + PruneOldQueueEntries(ctx, queueCutoff) + if err != nil { + hdlr.log.Error( + "queue pruning failed", "error", err, + ) + } else if pruned > 0 { + hdlr.log.Info( + "pruned old queue entries", + "deleted", pruned, + ) + } + } + + maxHistory := hdlr.params.Config.MaxHistory + if maxHistory > 0 { + rotated, err := hdlr.params.Database. + RotateChannelMessages(ctx, maxHistory) + if err != nil { + hdlr.log.Error( + "message rotation failed", "error", err, + ) + } else if rotated > 0 { + hdlr.log.Info( + "rotated old messages", + "deleted", rotated, + ) + } + } + + orphaned, err := hdlr.params.Database. + PruneOrphanedMessages(ctx) + if err != nil { + hdlr.log.Error( + "orphan message cleanup failed", + "error", err, + ) + } else if orphaned > 0 { + hdlr.log.Info( + "pruned orphaned messages", + "deleted", orphaned, + ) + } } -- 2.49.1 From 291be0c701bd877c08babb1de5fdce0940cd9c4f Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Mar 2026 03:26:40 -0700 Subject: [PATCH 2/5] fix: remove PruneOrphanedMessages to preserve history within MAX_HISTORY PruneOrphanedMessages deleted messages that lost their client_queues references after PruneOldQueueEntries ran, even when those messages were within the MAX_HISTORY limit. This made MAX_HISTORY meaningless for low-traffic channels. RotateChannelMessages already caps messages per target. Queue pruning handles client_queues growth. Orphan cleanup is redundant. Closes https://git.eeqj.de/sneak/chat/issues/40 --- README.md | 3 +-- internal/db/queries.go | 22 ---------------------- internal/handlers/handlers.go | 14 -------------- 3 files changed, 1 insertion(+), 38 deletions(-) diff --git a/README.md b/README.md index 85a1895..04c8747 100644 --- a/README.md +++ b/README.md @@ -1789,8 +1789,7 @@ 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). Orphaned messages (no - longer referenced by any client queue) are also removed. + are pruned periodically per target (channel or DM). - **Queue entries**: Pruned automatically when older than `QUEUE_MAX_AGE` (default 48h). - **Channels**: Deleted when the last member leaves (ephemeral). diff --git a/internal/db/queries.go b/internal/db/queries.go index a4a4c26..2974649 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1118,28 +1118,6 @@ func (database *Database) PruneOldQueueEntries( return deleted, nil } -// PruneOrphanedMessages deletes messages that are no -// longer referenced by any client_queues row and returns -// the number of rows removed. -func (database *Database) PruneOrphanedMessages( - ctx context.Context, -) (int64, error) { - res, err := database.conn.ExecContext(ctx, - `DELETE FROM messages WHERE id NOT IN - (SELECT DISTINCT message_id - FROM client_queues)`, - ) - if err != nil { - return 0, fmt.Errorf( - "prune orphaned messages: %w", err, - ) - } - - deleted, _ := res.RowsAffected() - - 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 diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 4876284..aa817db 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -245,18 +245,4 @@ func (hdlr *Handlers) pruneQueuesAndMessages( ) } } - - orphaned, err := hdlr.params.Database. - PruneOrphanedMessages(ctx) - if err != nil { - hdlr.log.Error( - "orphan message cleanup failed", - "error", err, - ) - } else if orphaned > 0 { - hdlr.log.Info( - "pruned orphaned messages", - "deleted", orphaned, - ) - } } -- 2.49.1 From 8d7a9915875a3b2863eb4c4a748712a7acbb55d4 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Mar 2026 03:47:40 -0700 Subject: [PATCH 3/5] refactor: 30-day defaults for all expiry settings - QUEUE_MAX_AGE: 48h -> 30 days (per-client queue entry expiry) - MESSAGE_MAX_AGE: replaces count-based MAX_HISTORY with time-based 30-day message expiry - SESSION_IDLE_TIMEOUT: 24h -> 30 days All expiry is now time-based (30 days) as requested. Closes https://git.eeqj.de/sneak/chat/issues/40 --- README.md | 22 +++++----- internal/config/config.go | 10 ++--- internal/db/queries.go | 81 +++++------------------------------ internal/handlers/handlers.go | 25 ++++++----- 4 files changed, 40 insertions(+), 98 deletions(-) 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, ) } } -- 2.49.1 From 6f3c0b01b01b2af3defc9c108d808ee2a878fb24 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Mar 2026 04:05:18 -0700 Subject: [PATCH 4/5] refactor: use Go duration strings for QUEUE_MAX_AGE and MESSAGE_MAX_AGE Change config from integer seconds to Go duration strings (e.g. '720h') parsed via time.ParseDuration, consistent with SESSION_IDLE_TIMEOUT. Default remains 30 days (720h). --- README.md | 4 ++-- internal/config/config.go | 12 +++++------ internal/handlers/handlers.go | 40 ++++++++++++++++++++++++++++------- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 1ff5192..42bac72 100644 --- a/README.md +++ b/README.md @@ -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) | -| `MESSAGE_MAX_AGE` | int | `2592000` | Maximum age of messages in seconds (30 days). Messages older than this are pruned. | +| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | | `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. | +| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. | | `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` | diff --git a/internal/config/config.go b/internal/config/config.go index bd3b7b7..855b55a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,9 +38,9 @@ type Config struct { MetricsUsername string Port int SentryDSN string - MessageMaxAge int + MessageMaxAge string MaxMessageSize int - QueueMaxAge int + QueueMaxAge string MOTD string ServerName string FederationKey string @@ -69,9 +69,9 @@ func New( viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_PASSWORD", "") - viper.SetDefault("MESSAGE_MAX_AGE", "2592000") + viper.SetDefault("MESSAGE_MAX_AGE", "720h") viper.SetDefault("MAX_MESSAGE_SIZE", "4096") - viper.SetDefault("QUEUE_MAX_AGE", "2592000") + viper.SetDefault("QUEUE_MAX_AGE", "720h") viper.SetDefault("MOTD", defaultMOTD) viper.SetDefault("SERVER_NAME", "") viper.SetDefault("FEDERATION_KEY", "") @@ -94,9 +94,9 @@ func New( MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsPassword: viper.GetString("METRICS_PASSWORD"), - MessageMaxAge: viper.GetInt("MESSAGE_MAX_AGE"), + MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"), MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), - QueueMaxAge: viper.GetInt("QUEUE_MAX_AGE"), + QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"), MOTD: viper.GetString("MOTD"), ServerName: viper.GetString("SERVER_NAME"), FederationKey: viper.GetString("FEDERATION_KEY"), diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 7ee70d5..66d6824 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -204,16 +204,39 @@ func (hdlr *Handlers) runCleanup( hdlr.pruneQueuesAndMessages(ctx) } +// parseDurationConfig parses a Go duration string, +// returning zero on empty input and logging on error. +func (hdlr *Handlers) parseDurationConfig( + name, raw string, +) time.Duration { + if raw == "" { + return 0 + } + + dur, err := time.ParseDuration(raw) + if err != nil { + hdlr.log.Error( + "invalid duration config, skipping", + "name", name, "value", raw, "error", err, + ) + + return 0 + } + + return dur +} + // pruneQueuesAndMessages removes old client_queues entries // per QUEUE_MAX_AGE and prunes messages per MESSAGE_MAX_AGE. func (hdlr *Handlers) pruneQueuesAndMessages( ctx context.Context, ) { - queueMaxAge := hdlr.params.Config.QueueMaxAge + queueMaxAge := hdlr.parseDurationConfig( + "QUEUE_MAX_AGE", + hdlr.params.Config.QueueMaxAge, + ) if queueMaxAge > 0 { - queueCutoff := time.Now().Add( - -time.Duration(queueMaxAge) * time.Second, - ) + queueCutoff := time.Now().Add(-queueMaxAge) pruned, err := hdlr.params.Database. PruneOldQueueEntries(ctx, queueCutoff) @@ -229,11 +252,12 @@ func (hdlr *Handlers) pruneQueuesAndMessages( } } - messageMaxAge := hdlr.params.Config.MessageMaxAge + messageMaxAge := hdlr.parseDurationConfig( + "MESSAGE_MAX_AGE", + hdlr.params.Config.MessageMaxAge, + ) if messageMaxAge > 0 { - msgCutoff := time.Now().Add( - -time.Duration(messageMaxAge) * time.Second, - ) + msgCutoff := time.Now().Add(-messageMaxAge) pruned, err := hdlr.params.Database. PruneOldMessages(ctx, msgCutoff) -- 2.49.1 From e0eb38289e0726338f38e033a554adf7b8c74310 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Mar 2026 07:27:29 -0700 Subject: [PATCH 5/5] fix: use 'client output queue' terminology consistently Replace ambiguous 'queue' with 'client output queue' throughout documentation, code comments, log messages, and error strings added in the queue pruning PR. --- README.md | 12 ++++++------ internal/db/queries.go | 6 +++--- internal/handlers/handlers.go | 9 +++++---- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 42bac72..c9cb666 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 30 - days. Entries older than this are pruned. +- **Client output queue depth**: Server-configurable via `QUEUE_MAX_AGE`. + Default is 30 days. Entries older than this are pruned. ### Long-Polling @@ -1790,8 +1790,8 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison). - **Messages**: Pruned automatically when older than `MESSAGE_MAX_AGE` (default 30 days). -- **Queue entries**: Pruned automatically when older than `QUEUE_MAX_AGE` - (default 30 days). +- **Client output queue entries**: Pruned automatically when older than + `QUEUE_MAX_AGE` (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 @@ -1814,7 +1814,7 @@ directory is also loaded automatically via | `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) | | `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | | `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` | string | `720h` | Maximum age of client queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. | +| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. | | `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` | @@ -2228,7 +2228,7 @@ GET /api/v1/challenge ### Post-MVP (Planned) - [ ] **Hashcash proof-of-work** for session creation (abuse prevention) -- [x] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE` +- [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE` - [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) diff --git a/internal/db/queries.go b/internal/db/queries.go index 74e88dd..50686d3 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1110,8 +1110,8 @@ func (database *Database) GetSessionCreatedAt( return createdAt, nil } -// PruneOldQueueEntries deletes client_queues rows older -// than cutoff and returns the number of rows removed. +// PruneOldQueueEntries deletes client output queue entries +// older than cutoff and returns the number of rows removed. func (database *Database) PruneOldQueueEntries( ctx context.Context, cutoff time.Time, @@ -1122,7 +1122,7 @@ func (database *Database) PruneOldQueueEntries( ) if err != nil { return 0, fmt.Errorf( - "prune old queue entries: %w", err, + "prune old client output queue entries: %w", err, ) } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 66d6824..6d9b7cd 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -226,8 +226,9 @@ func (hdlr *Handlers) parseDurationConfig( return dur } -// pruneQueuesAndMessages removes old client_queues entries -// per QUEUE_MAX_AGE and prunes messages per MESSAGE_MAX_AGE. +// pruneQueuesAndMessages removes old client output queue +// entries per QUEUE_MAX_AGE and old messages per +// MESSAGE_MAX_AGE. func (hdlr *Handlers) pruneQueuesAndMessages( ctx context.Context, ) { @@ -242,11 +243,11 @@ func (hdlr *Handlers) pruneQueuesAndMessages( PruneOldQueueEntries(ctx, queueCutoff) if err != nil { hdlr.log.Error( - "queue pruning failed", "error", err, + "client output queue pruning failed", "error", err, ) } else if pruned > 0 { hdlr.log.Info( - "pruned old queue entries", + "pruned old client output queue entries", "deleted", pruned, ) } -- 2.49.1