refactor: 30-day defaults, time-based message expiry
All checks were successful
check / check (push) Successful in 5s

- 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
This commit is contained in:
clawbot
2026-03-10 03:47:33 -07:00
parent 5f05b70be7
commit d9dbadaf62
4 changed files with 35 additions and 93 deletions

View File

@@ -249,8 +249,8 @@ Key properties:
- **Ordered**: Queue entries have monotonically increasing IDs. Messages are - **Ordered**: Queue entries have monotonically increasing IDs. Messages are
always delivered in order within a client's queue. always delivered in order within a client's queue.
- **No delivery/read receipts** for channel messages. DM receipts are planned. - **No delivery/read receipts** for channel messages. DM receipts are planned.
- **Queue depth**: Server-configurable via `QUEUE_MAX_AGE`. Default is 48 - **Queue depth**: Server-configurable via `QUEUE_MAX_AGE`. Default is 30
hours. Entries older than this are pruned. days. Entries older than this are pruned.
### Long-Polling ### Long-Polling
@@ -1788,10 +1788,10 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
### Data Lifecycle ### Data Lifecycle
- **Messages**: Rotated per `MAX_HISTORY` — oldest messages beyond the limit - **Messages**: Pruned automatically when older than `MESSAGE_MAX_AGE`
are pruned periodically per target (channel or DM). (default 30 days).
- **Queue entries**: Pruned automatically when older than `QUEUE_MAX_AGE` - **Queue entries**: Pruned automatically when older than `QUEUE_MAX_AGE`
(default 48h). (default 30 days).
- **Channels**: Deleted when the last member leaves (ephemeral). - **Channels**: Deleted when the last member leaves (ephemeral).
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle - **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default 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 | | `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`. | | `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) | | `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. | | `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) | | `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) | | `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` | | `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) - [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
- [x] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE` - [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` - [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
- [ ] **User channel modes** — `+o` (operator), `+v` (voice) - [ ] **User channel modes** — `+o` (operator), `+v` (voice)
- [x] **MODE command** — query channel and user modes (set not yet implemented) - [x] **MODE command** — query channel and user modes (set not yet implemented)

View File

@@ -38,7 +38,7 @@ type Config struct {
MetricsUsername string MetricsUsername string
Port int Port int
SentryDSN string SentryDSN string
MaxHistory int MessageMaxAge int
MaxMessageSize int MaxMessageSize int
QueueMaxAge int QueueMaxAge int
MOTD string MOTD string
@@ -69,9 +69,9 @@ func New(
viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("MAX_HISTORY", "10000") viper.SetDefault("MESSAGE_MAX_AGE", "2592000")
viper.SetDefault("MAX_MESSAGE_SIZE", "4096") viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
viper.SetDefault("QUEUE_MAX_AGE", "172800") viper.SetDefault("QUEUE_MAX_AGE", "2592000")
viper.SetDefault("MOTD", defaultMOTD) viper.SetDefault("MOTD", defaultMOTD)
viper.SetDefault("SERVER_NAME", "") viper.SetDefault("SERVER_NAME", "")
viper.SetDefault("FEDERATION_KEY", "") viper.SetDefault("FEDERATION_KEY", "")
@@ -94,7 +94,7 @@ func New(
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MaxHistory: viper.GetInt("MAX_HISTORY"), MessageMaxAge: viper.GetInt("MESSAGE_MAX_AGE"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
QueueMaxAge: viper.GetInt("QUEUE_MAX_AGE"), QueueMaxAge: viper.GetInt("QUEUE_MAX_AGE"),
MOTD: viper.GetString("MOTD"), MOTD: viper.GetString("MOTD"),

View File

@@ -1118,84 +1118,23 @@ func (database *Database) PruneOldQueueEntries(
return deleted, nil return deleted, nil
} }
// RotateChannelMessages enforces MAX_HISTORY per channel // PruneOldMessages deletes messages older than cutoff and
// by deleting the oldest messages beyond the limit for // returns the number of rows removed.
// each msg_to target. Returns the total number of rows func (database *Database) PruneOldMessages(
// removed.
func (database *Database) RotateChannelMessages(
ctx context.Context, ctx context.Context,
maxHistory int, cutoff time.Time,
) (int64, error) { ) (int64, error) {
if maxHistory <= 0 { res, err := database.conn.ExecContext(ctx,
return 0, nil "DELETE FROM messages WHERE created_at < ?",
} cutoff,
// 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 { if err != nil {
return 0, fmt.Errorf( return 0, fmt.Errorf(
"list targets for rotation: %w", err, "prune old messages: %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() deleted, _ := res.RowsAffected()
totalDeleted += deleted
}
return totalDeleted, nil return deleted, nil
} }

View File

@@ -205,8 +205,7 @@ func (hdlr *Handlers) runCleanup(
} }
// pruneQueuesAndMessages removes old client_queues entries // pruneQueuesAndMessages removes old client_queues entries
// per QUEUE_MAX_AGE, rotates messages per MAX_HISTORY, and // per QUEUE_MAX_AGE and old messages per MESSAGE_MAX_AGE.
// cleans up orphaned messages.
func (hdlr *Handlers) pruneQueuesAndMessages( func (hdlr *Handlers) pruneQueuesAndMessages(
ctx context.Context, ctx context.Context,
) { ) {
@@ -230,18 +229,22 @@ func (hdlr *Handlers) pruneQueuesAndMessages(
} }
} }
maxHistory := hdlr.params.Config.MaxHistory messageMaxAge := hdlr.params.Config.MessageMaxAge
if maxHistory > 0 { if messageMaxAge > 0 {
rotated, err := hdlr.params.Database. msgCutoff := time.Now().Add(
RotateChannelMessages(ctx, maxHistory) -time.Duration(messageMaxAge) * time.Second,
)
pruned, err := hdlr.params.Database.
PruneOldMessages(ctx, msgCutoff)
if err != nil { if err != nil {
hdlr.log.Error( hdlr.log.Error(
"message rotation failed", "error", err, "message pruning failed", "error", err,
) )
} else if rotated > 0 { } else if pruned > 0 {
hdlr.log.Info( hdlr.log.Info(
"rotated old messages", "pruned old messages",
"deleted", rotated, "deleted", pruned,
) )
} }
} }