initial public relay build repo
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user