initial public relay build repo
ci / build (push) Successful in 2m3s
ci / image (push) Successful in 1m4s

This commit is contained in:
2026-07-24 13:11:06 -04:00
commit 294616642f
24 changed files with 2957 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
// 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) }
}
+116
View File
@@ -0,0 +1,116 @@
package retention
import (
"testing"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/slicestore"
)
func mkStore(t *testing.T) *slicestore.SliceStore {
t.Helper()
s := &slicestore.SliceStore{}
if err := s.Init(); err != nil {
t.Fatalf("init: %v", err)
}
return s
}
func save(t *testing.T, s *slicestore.SliceStore, sk nostr.SecretKey, kind nostr.Kind, createdAt nostr.Timestamp) nostr.ID {
t.Helper()
e := nostr.Event{Kind: kind, CreatedAt: createdAt, Tags: nostr.Tags{{"h", "g"}}}
if kind == nostr.KindSimpleGroupChatMessage {
e.Content = "msg"
}
if err := e.Sign(sk); err != nil {
t.Fatalf("sign: %v", err)
}
if err := s.SaveEvent(e); err != nil {
t.Fatalf("save: %v", err)
}
return e.ID
}
func count(s *slicestore.SliceStore, kinds ...nostr.Kind) int {
n := 0
for range s.QueryEvents(nostr.Filter{Kinds: kinds}, 1_000_000) {
n++
}
return n
}
func TestRetentionPrunesOldChatByAge(t *testing.T) {
s := mkStore(t)
sk := nostr.Generate()
now := time.Unix(1_700_000_000, 0)
old := nostr.Timestamp(now.Add(-48 * time.Hour).Unix())
fresh := nostr.Timestamp(now.Add(-1 * time.Hour).Unix())
save(t, s, sk, nostr.KindSimpleGroupChatMessage, old)
save(t, s, sk, nostr.KindSimpleGroupChatMessage, fresh)
deleted := Sweep(s, Config{MaxAge: 24 * time.Hour}, now)
if deleted != 1 {
t.Fatalf("expected 1 pruned, got %d", deleted)
}
if c := count(s, nostr.KindSimpleGroupChatMessage); c != 1 {
t.Fatalf("expected 1 chat remaining, got %d", c)
}
}
func TestRetentionPrunesSurplusByCount(t *testing.T) {
s := mkStore(t)
sk := nostr.Generate()
now := time.Unix(1_700_000_000, 0)
for i := 0; i < 5; i++ {
save(t, s, sk, nostr.KindSimpleGroupChatMessage, nostr.Timestamp(1_700_000_000+int64(i)))
}
deleted := Sweep(s, Config{MaxMessages: 2}, now)
if deleted != 3 {
t.Fatalf("expected 3 pruned, got %d", deleted)
}
if c := count(s, nostr.KindSimpleGroupChatMessage); c != 2 {
t.Fatalf("expected 2 chats remaining, got %d", c)
}
}
func TestRetentionNeverPrunesManagementEvents(t *testing.T) {
s := mkStore(t)
sk := nostr.Generate()
now := time.Unix(1_700_000_000, 0)
ancient := nostr.Timestamp(now.Add(-1000 * time.Hour).Unix())
// Very old management + metadata events must be preserved.
save(t, s, sk, nostr.KindSimpleGroupCreateGroup, ancient)
save(t, s, sk, nostr.KindSimpleGroupPutUser, ancient)
save(t, s, sk, nostr.KindSimpleGroupJoinRequest, ancient)
save(t, s, sk, nostr.KindSimpleGroupMetadata, ancient)
save(t, s, sk, nostr.KindSimpleGroupAdmins, ancient)
save(t, s, sk, nostr.KindSimpleGroupMembers, ancient)
// An old chat that SHOULD be pruned.
save(t, s, sk, nostr.KindSimpleGroupChatMessage, ancient)
deleted := Sweep(s, Config{MaxAge: time.Hour, MaxMessages: 1}, now)
if deleted != 1 {
t.Fatalf("expected only the 1 chat pruned, got %d", deleted)
}
// All 6 management/metadata events survive.
mgmt := count(s,
nostr.KindSimpleGroupCreateGroup, nostr.KindSimpleGroupPutUser,
nostr.KindSimpleGroupJoinRequest, nostr.KindSimpleGroupMetadata,
nostr.KindSimpleGroupAdmins, nostr.KindSimpleGroupMembers)
if mgmt != 6 {
t.Fatalf("management events must never be pruned; have %d/6", mgmt)
}
}
func TestRetentionDisabledIsNoop(t *testing.T) {
s := mkStore(t)
sk := nostr.Generate()
now := time.Unix(1_700_000_000, 0)
save(t, s, sk, nostr.KindSimpleGroupChatMessage, nostr.Timestamp(1))
if d := Sweep(s, Config{}, now); d != 0 {
t.Fatalf("no limits should prune nothing, got %d", d)
}
}