Files
go-cart-actor/pkg/profile/email_index.go
T
2026-07-01 10:40:28 +02:00

127 lines
3.6 KiB
Go

package profile
import (
"bytes"
"io"
"context"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"google.golang.org/protobuf/proto"
)
// emailIndexEntry captures the email preferences snapshot for a profile.
type emailIndexEntry struct {
ID uint64
EmailPreferences *EmailPreferences
}
// ProfileEmailIndex is a thread-safe in-memory map from email address to
// profile ID and email preferences snapshot. It is populated at startup by
// scanning the profile storage directory, and updated when SetProfile
// mutations change the email or preferences.
type ProfileEmailIndex struct {
mu sync.RWMutex
byID map[uint64]string // profileID → email (for cleanup on email change)
idx map[string]emailIndexEntry // email → profile info
}
// NewProfileEmailIndex returns an empty index.
func NewProfileEmailIndex() *ProfileEmailIndex {
return &ProfileEmailIndex{
byID: make(map[uint64]string),
idx: make(map[string]emailIndexEntry),
}
}
// Set records or updates the email→profile mapping. When email is empty the
// entry is removed (profile was anonymized).
func (ix *ProfileEmailIndex) Set(profileID uint64, email string, prefs *EmailPreferences) {
ix.mu.Lock()
defer ix.mu.Unlock()
// Remove any previous email for this profile.
if oldEmail, ok := ix.byID[profileID]; ok {
delete(ix.idx, oldEmail)
}
if strings.TrimSpace(email) == "" {
delete(ix.byID, profileID)
return
}
email = strings.ToLower(strings.TrimSpace(email))
ix.byID[profileID] = email
ix.idx[email] = emailIndexEntry{
ID: profileID,
EmailPreferences: prefs,
}
}
// Lookup returns the email preferences for the given email. The second return
// value is false when the email is not found.
func (ix *ProfileEmailIndex) Lookup(email string) (emailIndexEntry, bool) {
ix.mu.RLock()
defer ix.mu.RUnlock()
entry, ok := ix.idx[strings.ToLower(strings.TrimSpace(email))]
return entry, ok
}
// BuildEmailIndex populates the index by scanning the profile storage
// directory and replaying each event log into a fresh ProfileGrain. It must
// be called once at service startup after the mutation registry and storage
// are initialised.
//
// dir — the same path passed to actor.NewDiskStorage (e.g. "data/profiles").
// replayer — an *actor.StateStorage for reading event log files.
// reg — the profile mutation registry.
// ix — the (empty) index to populate.
func BuildEmailIndex(dir string, replayer interface {
Load(r io.Reader, onMessage func(msg proto.Message, timeStamp time.Time)) error
}, reg actor.MutationRegistry, ix *ProfileEmailIndex) {
entries, err := os.ReadDir(dir)
if err != nil {
log.Printf("email index: cannot read profile dir %s: %v — starting with empty index", dir, err)
return
}
var count int
ctx := context.Background()
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".events.log") {
continue
}
idStr := strings.TrimSuffix(entry.Name(), ".events.log")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
continue
}
p := NewProfileGrain(id, time.Now())
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
log.Printf("email index: read %s: %v", entry.Name(), err)
continue
}
if err := replayer.Load(bytes.NewReader(data), func(msg proto.Message, _ time.Time) { //nolint:govet
_, _ = reg.Apply(ctx, p, msg)
}); err != nil {
log.Printf("email index: replay %s: %v", entry.Name(), err)
continue
}
if p.Email != "" {
ix.Set(p.Id, p.Email, p.EmailPreferences)
count++
}
}
log.Printf("email index: built %d entries from %s", count, dir)
}