97 lines
2.8 KiB
Go
97 lines
2.8 KiB
Go
// Package retention prunes old CHAT messages (kinds 9/10) so the event store
|
|
// does not grow without bound. Group-management and addressable metadata events
|
|
// (9007/9000/9001/9021/9022, 39000-39002) are NEVER pruned — they define
|
|
// membership and must survive to be replayed on boot.
|
|
package retention
|
|
|
|
import (
|
|
"log"
|
|
"slices"
|
|
"time"
|
|
|
|
"fiatjaf.com/nostr"
|
|
"fiatjaf.com/nostr/eventstore"
|
|
)
|
|
|
|
// Config controls what the retention sweeper deletes.
|
|
type Config struct {
|
|
// MaxAge: chat messages older than this are deleted. Zero disables age pruning.
|
|
MaxAge time.Duration
|
|
// MaxMessages: keep at most this many chat messages (newest kept). Zero
|
|
// disables count pruning.
|
|
MaxMessages int
|
|
// Interval between sweeps.
|
|
Interval time.Duration
|
|
}
|
|
|
|
// chatKinds are the only kinds eligible for retention pruning.
|
|
var chatKinds = []nostr.Kind{
|
|
nostr.KindSimpleGroupChatMessage,
|
|
nostr.KindSimpleGroupThreadedReply,
|
|
}
|
|
|
|
// Sweep runs a single retention pass and returns the number of events deleted.
|
|
// `now` is injected so the logic is deterministically testable.
|
|
func Sweep(store eventstore.Store, cfg Config, now time.Time) int {
|
|
// Collect all chat events (newest-first from the store).
|
|
var chats []nostr.Event
|
|
for evt := range store.QueryEvents(nostr.Filter{Kinds: chatKinds}, 1_000_000) {
|
|
chats = append(chats, evt)
|
|
}
|
|
// Sort newest-first so index >= MaxMessages are the surplus oldest ones.
|
|
slices.SortFunc(chats, nostr.CompareEventReverse)
|
|
|
|
cutoff := nostr.Timestamp(0)
|
|
if cfg.MaxAge > 0 {
|
|
cutoff = nostr.Timestamp(now.Add(-cfg.MaxAge).Unix())
|
|
}
|
|
|
|
deleted := 0
|
|
for i, evt := range chats {
|
|
tooOld := cfg.MaxAge > 0 && evt.CreatedAt < cutoff
|
|
surplus := cfg.MaxMessages > 0 && i >= cfg.MaxMessages
|
|
if tooOld || surplus {
|
|
if err := store.DeleteEvent(evt.ID); err == nil {
|
|
deleted++
|
|
}
|
|
}
|
|
}
|
|
return deleted
|
|
}
|
|
|
|
// Start launches a background sweeper. It is a no-op (returns a no-op stop) when
|
|
// neither limit is configured. The returned stop function halts it.
|
|
func Start(store eventstore.Store, cfg Config) (stop func()) {
|
|
if cfg.MaxAge == 0 && cfg.MaxMessages == 0 {
|
|
log.Println("retention disabled (no RELAY_RETENTION_* limits set)")
|
|
return func() {}
|
|
}
|
|
if cfg.Interval == 0 {
|
|
cfg.Interval = time.Hour
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
// Sweep once at startup, then on the interval.
|
|
if n := Sweep(store, cfg, time.Now()); n > 0 {
|
|
log.Printf("retention: pruned %d old chat message(s)", n)
|
|
}
|
|
ticker := time.NewTicker(cfg.Interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-ticker.C:
|
|
if n := Sweep(store, cfg, time.Now()); n > 0 {
|
|
log.Printf("retention: pruned %d old chat message(s)", n)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
log.Printf("retention enabled (maxAge=%s maxMessages=%d interval=%s)",
|
|
cfg.MaxAge, cfg.MaxMessages, cfg.Interval)
|
|
return func() { close(done) }
|
|
}
|