diff --git a/.golangci.yml b/.golangci.yml index 53d71d2..bc3b2dc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,5 @@ -version: "2" - linters: - default: none + disable-all: true enable: - errcheck # Unchecked errors - gosimple # Simplification suggestions diff --git a/go.mod b/go.mod index 1c39193..427fc85 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,26 @@ module github.com/syst3mctl/crashctl -go 1.23 +go 1.23.0 require github.com/spf13/cobra v1.10.2 require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgraph-io/badger/v4 v4.9.1 // indirect + github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/spf13/pflag v1.0.9 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.7 // indirect ) diff --git a/go.sum b/go.sum index 1a17bb8..033137c 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,23 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dgraph-io/badger/v4 v4.9.1 h1:DocZXZkg5JJHJPtUErA0ibyHxOVUDVoXLSCV6t8NC8w= +github.com/dgraph-io/badger/v4 v4.9.1/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0= +github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= +github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= @@ -9,5 +26,19 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/storage/badger.go b/internal/storage/badger.go new file mode 100644 index 0000000..549eaea --- /dev/null +++ b/internal/storage/badger.go @@ -0,0 +1,711 @@ +package storage + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sort" + "strings" + "time" + + badger "github.com/dgraph-io/badger/v4" + "github.com/dgraph-io/badger/v4/options" + "github.com/oklog/ulid/v2" + + "github.com/syst3mctl/crashctl/internal/domain" +) + +// Key-schema prefixes. Colons are used as delimiters; K8s namespaces and +// project IDs never contain colons so the schema is unambiguous. +const ( + pfxEvent = "e:" // e:{projectID}:{ts20}:{eventID} + pfxEventLookup = "ei:" // ei:{projectID}:{eventID} → ts20 + pfxGroup = "g:" // g:{projectID}:{groupID} + pfxFingerprint = "f:" // f:{projectID}:{fingerprint} → groupID + pfxCrash = "c:" // c:{namespace}:{ts20}:{crashID} + pfxCrashLookup = "ci:" // ci:{crashID} → namespace:ts20 + pfxProject = "p:" // p:{projectID} + pfxDSN = "d:" // d:{dsnKey} → projectID +) + +// BadgerStore is a production-grade Store backed by BadgerDB v4. +// It is safe for concurrent use. +type BadgerStore struct { + db *badger.DB + stopGC chan struct{} +} + +// Open opens (or creates) a BadgerDB database at path and starts the +// background GC goroutine. Caller must call Close when done. +func Open(path string) (*BadgerStore, error) { + opts := badger.DefaultOptions(path) + opts.Compression = options.ZSTD + opts.Logger = nil // slog is used at the application level + opts.SyncWrites = false + + db, err := badger.Open(opts) + if err != nil { + return nil, fmt.Errorf("open badger at %s: %w", path, err) + } + + s := &BadgerStore{db: db, stopGC: make(chan struct{})} + go s.startGC() + return s, nil +} + +// Close stops the GC goroutine and closes the underlying database. +func (s *BadgerStore) Close() error { + close(s.stopGC) + return s.db.Close() +} + +// startGC runs BadgerDB value-log GC every 5 minutes. +func (s *BadgerStore) startGC() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := s.db.RunValueLogGC(0.5); err != nil && err != badger.ErrNoRewrite { + slog.Warn("badger GC", "err", err) + } + case <-s.stopGC: + return + } + } +} + +// --------------------------------------------------------------------------- +// Key builders +// --------------------------------------------------------------------------- + +// ts20 formats a Unix nanosecond timestamp as a zero-padded 20-digit string +// so that lexicographic order equals chronological order. +func ts20(t time.Time) string { + return fmt.Sprintf("%020d", t.UnixNano()) +} + +func eventKey(projectID string, t time.Time, id ulid.ULID) []byte { + return []byte(pfxEvent + projectID + ":" + ts20(t) + ":" + id.String()) +} + +func eventLookupKey(projectID string, id ulid.ULID) []byte { + return []byte(pfxEventLookup + projectID + ":" + id.String()) +} + +func groupKey(projectID string, id ulid.ULID) []byte { + return []byte(pfxGroup + projectID + ":" + id.String()) +} + +func fpKey(projectID, fingerprint string) []byte { + return []byte(pfxFingerprint + projectID + ":" + fingerprint) +} + +func crashKey(namespace string, t time.Time, id ulid.ULID) []byte { + return []byte(pfxCrash + namespace + ":" + ts20(t) + ":" + id.String()) +} + +func crashLookupKey(id ulid.ULID) []byte { + return []byte(pfxCrashLookup + id.String()) +} + +func projectKey(id string) []byte { + return []byte(pfxProject + id) +} + +func dsnIndexKey(dsnKey string) []byte { + return []byte(pfxDSN + dsnKey) +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// reverseIter executes fn for every key that starts with prefix, iterating +// from newest to oldest (reverse lexicographic order). +func reverseIter(txn *badger.Txn, prefix string, fn func(item *badger.Item) error) error { + pfx := []byte(prefix) + opts := badger.DefaultIteratorOptions + opts.Reverse = true + it := txn.NewIterator(opts) + defer it.Close() + + // Seek to just past the last possible key for this prefix. + seekKey := append([]byte(prefix), 0xFF) + for it.Seek(seekKey); it.ValidForPrefix(pfx); it.Next() { + if err := fn(it.Item()); err != nil { + return err + } + } + return nil +} + +// getValue reads and JSON-decodes the value of item into dst. +func getValue(item *badger.Item, dst any) error { + return item.Value(func(val []byte) error { + return json.Unmarshal(val, dst) + }) +} + +// marshal JSON-encodes v, wrapping any error with context. +func marshal(v any) ([]byte, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("marshal %T: %w", v, err) + } + return data, nil +} + +// --------------------------------------------------------------------------- +// Event operations +// --------------------------------------------------------------------------- + +// SaveEvent persists the event under its time-ordered primary key and writes a +// secondary lookup entry so GetEvent can resolve it by ID alone. +func (s *BadgerStore) SaveEvent(ctx context.Context, event *domain.Event) error { + data, err := marshal(event) + if err != nil { + return fmt.Errorf("save event %s: %w", event.ID, err) + } + return s.db.Update(func(txn *badger.Txn) error { + if err := txn.Set(eventKey(event.ProjectID, event.Timestamp, event.ID), data); err != nil { + return fmt.Errorf("save event %s primary: %w", event.ID, err) + } + if err := txn.Set(eventLookupKey(event.ProjectID, event.ID), []byte(ts20(event.Timestamp))); err != nil { + return fmt.Errorf("save event %s lookup: %w", event.ID, err) + } + return nil + }) +} + +// GetEvent retrieves a single event by project and ID. +func (s *BadgerStore) GetEvent(ctx context.Context, projectID string, id ulid.ULID) (*domain.Event, error) { + var event domain.Event + err := s.db.View(func(txn *badger.Txn) error { + // Resolve timestamp from secondary index. + lkItem, err := txn.Get(eventLookupKey(projectID, id)) + if err == badger.ErrKeyNotFound { + return fmt.Errorf("get event %s: %w", id, ErrNotFound) + } + if err != nil { + return fmt.Errorf("get event %s lookup: %w", id, err) + } + ts20val, err := lkItem.ValueCopy(nil) + if err != nil { + return fmt.Errorf("get event %s lookup value: %w", id, err) + } + var tsNano int64 + if _, err = fmt.Sscanf(string(ts20val), "%d", &tsNano); err != nil { + return fmt.Errorf("get event %s parse ts: %w", id, err) + } + + // Retrieve the primary record. + t := time.Unix(0, tsNano) + item, err := txn.Get(eventKey(projectID, t, id)) + if err == badger.ErrKeyNotFound { + return fmt.Errorf("get event %s: %w", id, ErrNotFound) + } + if err != nil { + return fmt.Errorf("get event %s: %w", id, err) + } + return getValue(item, &event) + }) + if err != nil { + return nil, err + } + return &event, nil +} + +// ListEvents returns events newest-first, optionally filtered and paginated. +func (s *BadgerStore) ListEvents(ctx context.Context, opts ListEventsOpts) ([]*domain.Event, error) { + prefix := pfxEvent + if opts.ProjectID != "" { + prefix = pfxEvent + opts.ProjectID + ":" + } + + var results []*domain.Event + err := s.db.View(func(txn *badger.Txn) error { + return reverseIter(txn, prefix, func(item *badger.Item) error { + var e domain.Event + if err := getValue(item, &e); err != nil { + return fmt.Errorf("list events decode: %w", err) + } + if opts.Level != nil && e.Level != *opts.Level { + return nil + } + results = append(results, &e) + return nil + }) + }) + if err != nil { + return nil, err + } + // Reverse iteration already yields newest-first for a single-project prefix. + // If no project filter, keys are ordered by projectID then time, so sort + // globally by timestamp descending. + if opts.ProjectID == "" { + sort.Slice(results, func(i, j int) bool { + return results[i].Timestamp.After(results[j].Timestamp) + }) + } + return applyPage(results, opts.Offset, opts.Limit), nil +} + +// --------------------------------------------------------------------------- +// ErrorGroup operations +// --------------------------------------------------------------------------- + +// SaveGroup persists an ErrorGroup and updates the fingerprint index. +func (s *BadgerStore) SaveGroup(ctx context.Context, group *domain.ErrorGroup) error { + data, err := marshal(group) + if err != nil { + return fmt.Errorf("save group %s: %w", group.ID, err) + } + return s.db.Update(func(txn *badger.Txn) error { + if err := txn.Set(groupKey(group.ProjectID, group.ID), data); err != nil { + return fmt.Errorf("save group %s primary: %w", group.ID, err) + } + if err := txn.Set(fpKey(group.ProjectID, group.Fingerprint), []byte(group.ID.String())); err != nil { + return fmt.Errorf("save group %s fingerprint: %w", group.ID, err) + } + return nil + }) +} + +// GetGroup retrieves an ErrorGroup by project and ID. +func (s *BadgerStore) GetGroup(ctx context.Context, projectID string, id ulid.ULID) (*domain.ErrorGroup, error) { + var g domain.ErrorGroup + err := s.db.View(func(txn *badger.Txn) error { + item, err := txn.Get(groupKey(projectID, id)) + if err == badger.ErrKeyNotFound { + return fmt.Errorf("get group %s: %w", id, ErrNotFound) + } + if err != nil { + return fmt.Errorf("get group %s: %w", id, err) + } + return getValue(item, &g) + }) + if err != nil { + return nil, err + } + return &g, nil +} + +// GetGroupByFingerprint resolves an ErrorGroup by project and fingerprint hash. +func (s *BadgerStore) GetGroupByFingerprint(ctx context.Context, projectID, fingerprint string) (*domain.ErrorGroup, error) { + var g domain.ErrorGroup + err := s.db.View(func(txn *badger.Txn) error { + // Resolve group ID from fingerprint index. + fpItem, err := txn.Get(fpKey(projectID, fingerprint)) + if err == badger.ErrKeyNotFound { + return fmt.Errorf("get group by fingerprint %q: %w", fingerprint, ErrNotFound) + } + if err != nil { + return fmt.Errorf("get group by fingerprint %q: %w", fingerprint, err) + } + gidBytes, err := fpItem.ValueCopy(nil) + if err != nil { + return fmt.Errorf("get group by fingerprint %q value: %w", fingerprint, err) + } + gid, err := ulid.ParseStrict(string(gidBytes)) + if err != nil { + return fmt.Errorf("get group by fingerprint %q parse id: %w", fingerprint, err) + } + + item, err := txn.Get(groupKey(projectID, gid)) + if err == badger.ErrKeyNotFound { + return fmt.Errorf("get group by fingerprint %q: %w", fingerprint, ErrNotFound) + } + if err != nil { + return fmt.Errorf("get group by fingerprint %q: %w", fingerprint, err) + } + return getValue(item, &g) + }) + if err != nil { + return nil, err + } + return &g, nil +} + +// ListGroups returns groups for a project, sorted and paginated. +// Because sorting is by arbitrary fields (count, first/last seen) BadgerDB +// cannot serve the order from key layout alone — results are loaded into +// memory and sorted there. +func (s *BadgerStore) ListGroups(ctx context.Context, opts ListGroupsOpts) ([]*domain.ErrorGroup, error) { + prefix := pfxGroup + if opts.ProjectID != "" { + prefix = pfxGroup + opts.ProjectID + ":" + } + + var results []*domain.ErrorGroup + err := s.db.View(func(txn *badger.Txn) error { + return reverseIter(txn, prefix, func(item *badger.Item) error { + var g domain.ErrorGroup + if err := getValue(item, &g); err != nil { + return fmt.Errorf("list groups decode: %w", err) + } + if opts.Status != nil && g.Status != *opts.Status { + return nil + } + results = append(results, &g) + return nil + }) + }) + if err != nil { + return nil, err + } + + switch opts.SortBy { + case GroupSortFirstSeen: + sort.Slice(results, func(i, j int) bool { + return results[i].FirstSeen.After(results[j].FirstSeen) + }) + case GroupSortCount: + sort.Slice(results, func(i, j int) bool { + return results[i].Count > results[j].Count + }) + default: // GroupSortLastSeen or empty + sort.Slice(results, func(i, j int) bool { + return results[i].LastSeen.After(results[j].LastSeen) + }) + } + return applyPage(results, opts.Offset, opts.Limit), nil +} + +// IncrementGroupCount atomically increments the event count for a group and +// updates its LastSeen timestamp and LastEvent reference. +func (s *BadgerStore) IncrementGroupCount(ctx context.Context, projectID string, id ulid.ULID, lastSeen time.Time, lastEventID ulid.ULID) error { + key := groupKey(projectID, id) + return s.db.Update(func(txn *badger.Txn) error { + item, err := txn.Get(key) + if err == badger.ErrKeyNotFound { + return fmt.Errorf("increment group count %s: %w", id, ErrNotFound) + } + if err != nil { + return fmt.Errorf("increment group count %s: get: %w", id, err) + } + + var g domain.ErrorGroup + if err := getValue(item, &g); err != nil { + return fmt.Errorf("increment group count %s: decode: %w", id, err) + } + + g.Count++ + g.LastSeen = lastSeen + g.LastEvent = lastEventID + + data, err := marshal(&g) + if err != nil { + return fmt.Errorf("increment group count %s: %w", id, err) + } + return txn.Set(key, data) + }) +} + +// UpdateGroupStatus changes the lifecycle status of an ErrorGroup. +func (s *BadgerStore) UpdateGroupStatus(ctx context.Context, projectID string, id ulid.ULID, status domain.GroupStatus) error { + key := groupKey(projectID, id) + return s.db.Update(func(txn *badger.Txn) error { + item, err := txn.Get(key) + if err == badger.ErrKeyNotFound { + return fmt.Errorf("update group status %s: %w", id, ErrNotFound) + } + if err != nil { + return fmt.Errorf("update group status %s: get: %w", id, err) + } + + var g domain.ErrorGroup + if err := getValue(item, &g); err != nil { + return fmt.Errorf("update group status %s: decode: %w", id, err) + } + g.Status = status + + data, err := marshal(&g) + if err != nil { + return fmt.Errorf("update group status %s: %w", id, err) + } + return txn.Set(key, data) + }) +} + +// --------------------------------------------------------------------------- +// PodCrash operations +// --------------------------------------------------------------------------- + +// SavePodCrash persists a PodCrash under its time-ordered primary key and +// writes a secondary lookup entry for GetPodCrash. +func (s *BadgerStore) SavePodCrash(ctx context.Context, crash *domain.PodCrash) error { + data, err := marshal(crash) + if err != nil { + return fmt.Errorf("save pod crash %s: %w", crash.ID, err) + } + lookup := crash.Namespace + ":" + ts20(crash.Timestamp) + return s.db.Update(func(txn *badger.Txn) error { + if err := txn.Set(crashKey(crash.Namespace, crash.Timestamp, crash.ID), data); err != nil { + return fmt.Errorf("save pod crash %s primary: %w", crash.ID, err) + } + if err := txn.Set(crashLookupKey(crash.ID), []byte(lookup)); err != nil { + return fmt.Errorf("save pod crash %s lookup: %w", crash.ID, err) + } + return nil + }) +} + +// GetPodCrash retrieves a PodCrash by ID. +func (s *BadgerStore) GetPodCrash(ctx context.Context, id ulid.ULID) (*domain.PodCrash, error) { + var crash domain.PodCrash + err := s.db.View(func(txn *badger.Txn) error { + lkItem, err := txn.Get(crashLookupKey(id)) + if err == badger.ErrKeyNotFound { + return fmt.Errorf("get pod crash %s: %w", id, ErrNotFound) + } + if err != nil { + return fmt.Errorf("get pod crash %s lookup: %w", id, err) + } + val, err := lkItem.ValueCopy(nil) + if err != nil { + return fmt.Errorf("get pod crash %s lookup value: %w", id, err) + } + + // val = "{namespace}:{ts20}" + sep := strings.LastIndex(string(val), ":") + if sep < 0 { + return fmt.Errorf("get pod crash %s: malformed lookup value", id) + } + namespace := string(val[:sep]) + ts20val := string(val[sep+1:]) + var tsNano int64 + if _, err = fmt.Sscanf(ts20val, "%d", &tsNano); err != nil { + return fmt.Errorf("get pod crash %s parse ts: %w", id, err) + } + t := time.Unix(0, tsNano) + + item, err := txn.Get(crashKey(namespace, t, id)) + if err == badger.ErrKeyNotFound { + return fmt.Errorf("get pod crash %s: %w", id, ErrNotFound) + } + if err != nil { + return fmt.Errorf("get pod crash %s: %w", id, err) + } + return getValue(item, &crash) + }) + if err != nil { + return nil, err + } + return &crash, nil +} + +// ListPodCrashes returns crashes newest-first, optionally filtered by +// namespace and paginated. +func (s *BadgerStore) ListPodCrashes(ctx context.Context, opts ListCrashesOpts) ([]*domain.PodCrash, error) { + prefix := pfxCrash + if opts.Namespace != "" { + prefix = pfxCrash + opts.Namespace + ":" + } + + var results []*domain.PodCrash + err := s.db.View(func(txn *badger.Txn) error { + return reverseIter(txn, prefix, func(item *badger.Item) error { + var c domain.PodCrash + if err := getValue(item, &c); err != nil { + return fmt.Errorf("list pod crashes decode: %w", err) + } + results = append(results, &c) + return nil + }) + }) + if err != nil { + return nil, err + } + // When scanning across all namespaces, keys are ordered per-namespace so + // we must sort globally by timestamp. + if opts.Namespace == "" { + sort.Slice(results, func(i, j int) bool { + return results[i].Timestamp.After(results[j].Timestamp) + }) + } + return applyPage(results, opts.Offset, opts.Limit), nil +} + +// --------------------------------------------------------------------------- +// Project operations +// --------------------------------------------------------------------------- + +// SaveProject persists a Project and updates the DSN key index. +func (s *BadgerStore) SaveProject(ctx context.Context, project *domain.Project) error { + data, err := marshal(project) + if err != nil { + return fmt.Errorf("save project %s: %w", project.ID, err) + } + return s.db.Update(func(txn *badger.Txn) error { + if err := txn.Set(projectKey(project.ID), data); err != nil { + return fmt.Errorf("save project %s primary: %w", project.ID, err) + } + if err := txn.Set(dsnIndexKey(project.DSNKey), []byte(project.ID)); err != nil { + return fmt.Errorf("save project %s dsn index: %w", project.ID, err) + } + return nil + }) +} + +// GetProject retrieves a Project by its ID. +func (s *BadgerStore) GetProject(ctx context.Context, id string) (*domain.Project, error) { + var p domain.Project + err := s.db.View(func(txn *badger.Txn) error { + item, err := txn.Get(projectKey(id)) + if err == badger.ErrKeyNotFound { + return fmt.Errorf("get project %s: %w", id, ErrNotFound) + } + if err != nil { + return fmt.Errorf("get project %s: %w", id, err) + } + return getValue(item, &p) + }) + if err != nil { + return nil, err + } + return &p, nil +} + +// GetProjectByDSNKey looks up a Project by its DSN key. +func (s *BadgerStore) GetProjectByDSNKey(ctx context.Context, dsnKey string) (*domain.Project, error) { + var p domain.Project + err := s.db.View(func(txn *badger.Txn) error { + idxItem, err := txn.Get(dsnIndexKey(dsnKey)) + if err == badger.ErrKeyNotFound { + return fmt.Errorf("get project by dsn key: %w", ErrNotFound) + } + if err != nil { + return fmt.Errorf("get project by dsn key: %w", err) + } + idBytes, err := idxItem.ValueCopy(nil) + if err != nil { + return fmt.Errorf("get project by dsn key value: %w", err) + } + + item, err := txn.Get(projectKey(string(idBytes))) + if err == badger.ErrKeyNotFound { + return fmt.Errorf("get project by dsn key: %w", ErrNotFound) + } + if err != nil { + return fmt.Errorf("get project by dsn key: %w", err) + } + return getValue(item, &p) + }) + if err != nil { + return nil, err + } + return &p, nil +} + +// --------------------------------------------------------------------------- +// Maintenance +// --------------------------------------------------------------------------- + +// DeleteEventsOlderThan removes all events (and their lookup entries) with a +// Timestamp before cutoff. Key timestamps are parsed directly without JSON +// decoding for efficiency. Returns the number of deleted records. +func (s *BadgerStore) DeleteEventsOlderThan(ctx context.Context, cutoff time.Time) (int, error) { + type toDelete struct { + primary []byte + lookup []byte + } + cutoffNano := cutoff.UnixNano() + + // Collect keys to delete in a read transaction first. + var stale []toDelete + if err := s.db.View(func(txn *badger.Txn) error { + opts := badger.DefaultIteratorOptions + opts.PrefetchValues = false // keys only for efficiency + it := txn.NewIterator(opts) + defer it.Close() + + pfx := []byte(pfxEvent) + for it.Seek(pfx); it.ValidForPrefix(pfx); it.Next() { + key := it.Item().KeyCopy(nil) + projectID, tsNano, eventIDStr, ok := parseEventKey(key) + if !ok || tsNano >= cutoffNano { + continue + } + id, err := ulid.ParseStrict(eventIDStr) + if err != nil { + continue + } + stale = append(stale, toDelete{ + primary: key, + lookup: eventLookupKey(projectID, id), + }) + } + return nil + }); err != nil { + return 0, fmt.Errorf("delete events older than: scan: %w", err) + } + + if len(stale) == 0 { + return 0, nil + } + + // Delete in batches via WriteBatch for efficiency. + wb := s.db.NewWriteBatch() + defer wb.Cancel() + for _, d := range stale { + if err := wb.Delete(d.primary); err != nil { + return 0, fmt.Errorf("delete events older than: delete primary: %w", err) + } + if err := wb.Delete(d.lookup); err != nil { + return 0, fmt.Errorf("delete events older than: delete lookup: %w", err) + } + } + if err := wb.Flush(); err != nil { + return 0, fmt.Errorf("delete events older than: flush: %w", err) + } + return len(stale), nil +} + +// parseEventKey extracts the projectID, timestamp (nanoseconds), and event ID +// string from a key of the form "e:{projectID}:{ts20}:{eventID}". +// The ts20 segment is always exactly 20 characters. +func parseEventKey(key []byte) (projectID string, tsNano int64, eventID string, ok bool) { + // Strip "e:" prefix. + s := string(key) + if len(s) < 3 || s[:2] != pfxEvent { + return + } + s = s[2:] + + // projectID ends at the first ':'. + sep1 := strings.Index(s, ":") + if sep1 < 0 { + return + } + projectID = s[:sep1] + s = s[sep1+1:] + + // ts20 is the next 20 bytes, followed by ':'. + if len(s) < 22 { // 20 digits + ':' + at least 1 char for eventID + return + } + if _, err := fmt.Sscanf(s[:20], "%d", &tsNano); err != nil { + return + } + if s[20] != ':' { + return + } + eventID = s[21:] + ok = true + return +} + +// applyPage applies offset and limit pagination to a slice. +// limit=0 means no upper bound. +func applyPage[T any](s []T, offset, limit int) []T { + if offset >= len(s) { + return nil + } + s = s[offset:] + if limit > 0 && limit < len(s) { + s = s[:limit] + } + return s +} diff --git a/internal/storage/badger_test.go b/internal/storage/badger_test.go new file mode 100644 index 0000000..f268bd4 --- /dev/null +++ b/internal/storage/badger_test.go @@ -0,0 +1,668 @@ +package storage + +import ( + "errors" + "testing" + "time" + + "github.com/syst3mctl/crashctl/internal/domain" +) + +// package-level level/status shorthands used across Badger tests. +var ( + lvlError = domain.LevelError + lvlInfo = domain.LevelInfo + stOpen = domain.GroupStatusOpen + stResolved = domain.GroupStatusResolved + stIgnored = domain.GroupStatusIgnored +) + +// openTestStore opens a BadgerStore in a temp directory that is +// automatically cleaned up when the test ends. +func openTestStore(t *testing.T) *BadgerStore { + t.Helper() + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { + if err := s.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) + return s +} + +// --------------------------------------------------------------------------- +// Event tests +// --------------------------------------------------------------------------- + +func TestBadger_SaveAndGetEvent(t *testing.T) { + s := openTestStore(t) + e := makeEvent(t, "proj1", domain.LevelError, time.Now()) + if err := s.SaveEvent(ctx, e); err != nil { + t.Fatalf("SaveEvent: %v", err) + } + got, err := s.GetEvent(ctx, "proj1", e.ID) + if err != nil { + t.Fatalf("GetEvent: %v", err) + } + if got.ID != e.ID { + t.Errorf("got ID %v, want %v", got.ID, e.ID) + } + if got.ProjectID != e.ProjectID { + t.Errorf("got ProjectID %q, want %q", got.ProjectID, e.ProjectID) + } +} + +func TestBadger_GetEvent_NotFound(t *testing.T) { + s := openTestStore(t) + + t.Run("missing id", func(t *testing.T) { + _, err := s.GetEvent(ctx, "proj1", newULID(t)) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } + }) + + t.Run("wrong project", func(t *testing.T) { + e := makeEvent(t, "proj1", domain.LevelError, time.Now()) + _ = s.SaveEvent(ctx, e) + _, err := s.GetEvent(ctx, "other-project", e.ID) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound for wrong project, got %v", err) + } + }) +} + +func TestBadger_Event_RoundTrip(t *testing.T) { + s := openTestStore(t) + e := makeEvent(t, "p", domain.LevelError, time.Now().Truncate(time.Millisecond)) + e.Message = "database connection refused" + e.Service = "payments-api" + e.Tags = map[string]string{"region": "us-east-1"} + _ = s.SaveEvent(ctx, e) + + got, err := s.GetEvent(ctx, "p", e.ID) + if err != nil { + t.Fatalf("GetEvent: %v", err) + } + if got.Message != e.Message { + t.Errorf("Message: got %q, want %q", got.Message, e.Message) + } + if got.Service != e.Service { + t.Errorf("Service: got %q, want %q", got.Service, e.Service) + } + if got.Tags["region"] != "us-east-1" { + t.Errorf("Tags[region]: got %q", got.Tags["region"]) + } +} + +func TestBadger_ListEvents_FilterByProject(t *testing.T) { + s := openTestStore(t) + now := time.Now() + _ = s.SaveEvent(ctx, makeEvent(t, "proj1", domain.LevelError, now)) + _ = s.SaveEvent(ctx, makeEvent(t, "proj2", domain.LevelError, now)) + _ = s.SaveEvent(ctx, makeEvent(t, "proj1", domain.LevelInfo, now.Add(time.Second))) + + got, err := s.ListEvents(ctx, ListEventsOpts{ProjectID: "proj1"}) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(got) != 2 { + t.Errorf("got %d events, want 2", len(got)) + } +} + +func TestBadger_ListEvents_FilterByLevel(t *testing.T) { + s := openTestStore(t) + now := time.Now() + _ = s.SaveEvent(ctx, makeEvent(t, "p", domain.LevelError, now)) + _ = s.SaveEvent(ctx, makeEvent(t, "p", domain.LevelInfo, now)) + _ = s.SaveEvent(ctx, makeEvent(t, "p", domain.LevelError, now.Add(time.Second))) + + got, err := s.ListEvents(ctx, ListEventsOpts{ProjectID: "p", Level: &lvlError}) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(got) != 2 { + t.Errorf("got %d events, want 2", len(got)) + } + for _, e := range got { + if e.Level != domain.LevelError { + t.Errorf("unexpected level %v", e.Level) + } + } +} + +func TestBadger_ListEvents_OrderedNewestFirst(t *testing.T) { + s := openTestStore(t) + base := time.Now() + for i := 0; i < 5; i++ { + _ = s.SaveEvent(ctx, makeEvent(t, "p", domain.LevelInfo, base.Add(time.Duration(i)*time.Second))) + } + + got, _ := s.ListEvents(ctx, ListEventsOpts{ProjectID: "p"}) + for i := 1; i < len(got); i++ { + if got[i].Timestamp.After(got[i-1].Timestamp) { + t.Errorf("not in descending order at index %d", i) + } + } +} + +func TestBadger_ListEvents_LimitAndOffset(t *testing.T) { + s := openTestStore(t) + base := time.Now() + for i := 0; i < 5; i++ { + _ = s.SaveEvent(ctx, makeEvent(t, "p", domain.LevelInfo, base.Add(time.Duration(i)*time.Second))) + } + + tests := []struct{ limit, offset, want int }{ + {2, 0, 2}, + {2, 3, 2}, + {10, 0, 5}, + {0, 0, 5}, + {2, 10, 0}, + } + for _, tc := range tests { + got, _ := s.ListEvents(ctx, ListEventsOpts{ProjectID: "p", Limit: tc.limit, Offset: tc.offset}) + if len(got) != tc.want { + t.Errorf("limit=%d offset=%d: got %d, want %d", tc.limit, tc.offset, len(got), tc.want) + } + } +} + +// --------------------------------------------------------------------------- +// ErrorGroup tests +// --------------------------------------------------------------------------- + +func TestBadger_SaveAndGetGroup(t *testing.T) { + s := openTestStore(t) + g := makeGroup(t, "proj1", "fp1", domain.GroupStatusOpen, time.Now(), 1) + _ = s.SaveGroup(ctx, g) + + got, err := s.GetGroup(ctx, "proj1", g.ID) + if err != nil { + t.Fatalf("GetGroup: %v", err) + } + if got.Fingerprint != "fp1" { + t.Errorf("got fingerprint %q, want %q", got.Fingerprint, "fp1") + } +} + +func TestBadger_GetGroup_NotFound(t *testing.T) { + s := openTestStore(t) + + t.Run("missing id", func(t *testing.T) { + _, err := s.GetGroup(ctx, "proj1", newULID(t)) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } + }) + + t.Run("wrong project", func(t *testing.T) { + g := makeGroup(t, "proj1", "fp1", domain.GroupStatusOpen, time.Now(), 1) + _ = s.SaveGroup(ctx, g) + _, err := s.GetGroup(ctx, "other", g.ID) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound for wrong project, got %v", err) + } + }) +} + +func TestBadger_GetGroupByFingerprint(t *testing.T) { + s := openTestStore(t) + g := makeGroup(t, "proj1", "sha256abc", domain.GroupStatusOpen, time.Now(), 1) + _ = s.SaveGroup(ctx, g) + + got, err := s.GetGroupByFingerprint(ctx, "proj1", "sha256abc") + if err != nil { + t.Fatalf("GetGroupByFingerprint: %v", err) + } + if got.ID != g.ID { + t.Errorf("got group %v, want %v", got.ID, g.ID) + } +} + +func TestBadger_GetGroupByFingerprint_NotFound(t *testing.T) { + s := openTestStore(t) + + t.Run("no groups", func(t *testing.T) { + _, err := s.GetGroupByFingerprint(ctx, "proj1", "nope") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } + }) + + t.Run("fingerprint in different project", func(t *testing.T) { + g := makeGroup(t, "proj1", "fp1", domain.GroupStatusOpen, time.Now(), 1) + _ = s.SaveGroup(ctx, g) + _, err := s.GetGroupByFingerprint(ctx, "proj2", "fp1") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound for different project, got %v", err) + } + }) +} + +func TestBadger_ListGroups_FilterByStatus(t *testing.T) { + s := openTestStore(t) + now := time.Now() + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp1", domain.GroupStatusOpen, now, 1)) + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp2", domain.GroupStatusResolved, now, 1)) + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp3", domain.GroupStatusOpen, now, 1)) + + got, _ := s.ListGroups(ctx, ListGroupsOpts{ProjectID: "p", Status: &stOpen}) + if len(got) != 2 { + t.Errorf("got %d groups, want 2", len(got)) + } + for _, g := range got { + if g.Status != domain.GroupStatusOpen { + t.Errorf("unexpected status %v", g.Status) + } + } +} + +func TestBadger_ListGroups_SortByCount(t *testing.T) { + s := openTestStore(t) + now := time.Now() + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp1", domain.GroupStatusOpen, now, 10)) + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp2", domain.GroupStatusOpen, now, 50)) + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp3", domain.GroupStatusOpen, now, 5)) + + got, _ := s.ListGroups(ctx, ListGroupsOpts{ProjectID: "p", SortBy: GroupSortCount}) + if len(got) != 3 { + t.Fatalf("got %d groups, want 3", len(got)) + } + if got[0].Count != 50 || got[1].Count != 10 || got[2].Count != 5 { + t.Errorf("wrong sort order: %v %v %v", got[0].Count, got[1].Count, got[2].Count) + } +} + +func TestBadger_ListGroups_SortByLastSeen(t *testing.T) { + s := openTestStore(t) + base := time.Now() + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp1", domain.GroupStatusOpen, base.Add(1*time.Hour), 1)) + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp2", domain.GroupStatusOpen, base.Add(3*time.Hour), 1)) + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp3", domain.GroupStatusOpen, base.Add(2*time.Hour), 1)) + + got, _ := s.ListGroups(ctx, ListGroupsOpts{ProjectID: "p", SortBy: GroupSortLastSeen}) + if len(got) != 3 { + t.Fatalf("got %d groups, want 3", len(got)) + } + if !got[0].LastSeen.After(got[1].LastSeen) || !got[1].LastSeen.After(got[2].LastSeen) { + t.Error("not sorted by last_seen descending") + } +} + +func TestBadger_ListGroups_SortByFirstSeen(t *testing.T) { + s := openTestStore(t) + base := time.Now() + g1 := makeGroup(t, "p", "fp1", domain.GroupStatusOpen, base, 1) + g1.FirstSeen = base.Add(1 * time.Hour) + g2 := makeGroup(t, "p", "fp2", domain.GroupStatusOpen, base, 1) + g2.FirstSeen = base.Add(3 * time.Hour) + g3 := makeGroup(t, "p", "fp3", domain.GroupStatusOpen, base, 1) + g3.FirstSeen = base.Add(2 * time.Hour) + _ = s.SaveGroup(ctx, g1) + _ = s.SaveGroup(ctx, g2) + _ = s.SaveGroup(ctx, g3) + + got, _ := s.ListGroups(ctx, ListGroupsOpts{ProjectID: "p", SortBy: GroupSortFirstSeen}) + if len(got) != 3 { + t.Fatalf("got %d groups, want 3", len(got)) + } + if !got[0].FirstSeen.After(got[1].FirstSeen) || !got[1].FirstSeen.After(got[2].FirstSeen) { + t.Error("not sorted by first_seen descending") + } +} + +func TestBadger_ListGroups_LimitAndOffset(t *testing.T) { + s := openTestStore(t) + now := time.Now() + for i := 0; i < 5; i++ { + _ = s.SaveGroup(ctx, makeGroup(t, "p", string(rune('a'+i)), domain.GroupStatusOpen, now, 1)) + } + + tests := []struct{ limit, offset, want int }{ + {2, 0, 2}, + {2, 3, 2}, + {0, 0, 5}, + {10, 0, 5}, + {3, 4, 1}, + } + for _, tc := range tests { + got, _ := s.ListGroups(ctx, ListGroupsOpts{ProjectID: "p", Limit: tc.limit, Offset: tc.offset}) + if len(got) != tc.want { + t.Errorf("limit=%d offset=%d: got %d, want %d", tc.limit, tc.offset, len(got), tc.want) + } + } +} + +func TestBadger_IncrementGroupCount(t *testing.T) { + s := openTestStore(t) + g := makeGroup(t, "proj1", "fp1", domain.GroupStatusOpen, time.Now(), 1) + _ = s.SaveGroup(ctx, g) + + newSeen := time.Now().Add(time.Minute).Truncate(time.Millisecond) + lastEvt := newULID(t) + if err := s.IncrementGroupCount(ctx, "proj1", g.ID, newSeen, lastEvt); err != nil { + t.Fatalf("IncrementGroupCount: %v", err) + } + + got, _ := s.GetGroup(ctx, "proj1", g.ID) + if got.Count != 2 { + t.Errorf("got count %d, want 2", got.Count) + } + if !got.LastSeen.Equal(newSeen) { + t.Errorf("got LastSeen %v, want %v", got.LastSeen, newSeen) + } + if got.LastEvent != lastEvt { + t.Errorf("got LastEvent %v, want %v", got.LastEvent, lastEvt) + } +} + +func TestBadger_IncrementGroupCount_NotFound(t *testing.T) { + s := openTestStore(t) + err := s.IncrementGroupCount(ctx, "proj1", newULID(t), time.Now(), newULID(t)) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestBadger_UpdateGroupStatus(t *testing.T) { + s := openTestStore(t) + g := makeGroup(t, "proj1", "fp1", domain.GroupStatusOpen, time.Now(), 1) + _ = s.SaveGroup(ctx, g) + + if err := s.UpdateGroupStatus(ctx, "proj1", g.ID, stResolved); err != nil { + t.Fatalf("UpdateGroupStatus: %v", err) + } + got, _ := s.GetGroup(ctx, "proj1", g.ID) + if got.Status != domain.GroupStatusResolved { + t.Errorf("got status %v, want resolved", got.Status) + } +} + +func TestBadger_UpdateGroupStatus_NotFound(t *testing.T) { + s := openTestStore(t) + err := s.UpdateGroupStatus(ctx, "proj1", newULID(t), stIgnored) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// PodCrash tests +// --------------------------------------------------------------------------- + +func TestBadger_SaveAndGetPodCrash(t *testing.T) { + s := openTestStore(t) + c := makeCrash(t, "prod", time.Now()) + _ = s.SavePodCrash(ctx, c) + + got, err := s.GetPodCrash(ctx, c.ID) + if err != nil { + t.Fatalf("GetPodCrash: %v", err) + } + if got.ID != c.ID { + t.Errorf("got ID %v, want %v", got.ID, c.ID) + } +} + +func TestBadger_GetPodCrash_NotFound(t *testing.T) { + s := openTestStore(t) + _, err := s.GetPodCrash(ctx, newULID(t)) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestBadger_SavePodCrash_PreservesLinkedGroup(t *testing.T) { + s := openTestStore(t) + c := makeCrash(t, "prod", time.Now()) + gid := newULID(t) + c.LinkedGroup = &gid + _ = s.SavePodCrash(ctx, c) + + got, _ := s.GetPodCrash(ctx, c.ID) + if got.LinkedGroup == nil || *got.LinkedGroup != gid { + t.Error("LinkedGroup not preserved correctly") + } +} + +func TestBadger_ListPodCrashes_FilterByNamespace(t *testing.T) { + s := openTestStore(t) + now := time.Now() + _ = s.SavePodCrash(ctx, makeCrash(t, "prod", now)) + _ = s.SavePodCrash(ctx, makeCrash(t, "staging", now)) + _ = s.SavePodCrash(ctx, makeCrash(t, "prod", now.Add(time.Second))) + + got, _ := s.ListPodCrashes(ctx, ListCrashesOpts{Namespace: "prod"}) + if len(got) != 2 { + t.Errorf("got %d crashes, want 2", len(got)) + } + for _, c := range got { + if c.Namespace != "prod" { + t.Errorf("unexpected namespace %q", c.Namespace) + } + } +} + +func TestBadger_ListPodCrashes_OrderedNewestFirst(t *testing.T) { + s := openTestStore(t) + base := time.Now() + for i := 0; i < 4; i++ { + _ = s.SavePodCrash(ctx, makeCrash(t, "ns", base.Add(time.Duration(i)*time.Second))) + } + + got, _ := s.ListPodCrashes(ctx, ListCrashesOpts{Namespace: "ns"}) + for i := 1; i < len(got); i++ { + if got[i].Timestamp.After(got[i-1].Timestamp) { + t.Errorf("not in descending order at index %d", i) + } + } +} + +func TestBadger_ListPodCrashes_LimitAndOffset(t *testing.T) { + s := openTestStore(t) + base := time.Now() + for i := 0; i < 5; i++ { + _ = s.SavePodCrash(ctx, makeCrash(t, "ns", base.Add(time.Duration(i)*time.Second))) + } + + tests := []struct{ limit, offset, want int }{ + {2, 0, 2}, + {0, 0, 5}, + {3, 3, 2}, + {5, 10, 0}, + } + for _, tc := range tests { + got, _ := s.ListPodCrashes(ctx, ListCrashesOpts{Namespace: "ns", Limit: tc.limit, Offset: tc.offset}) + if len(got) != tc.want { + t.Errorf("limit=%d offset=%d: got %d, want %d", tc.limit, tc.offset, len(got), tc.want) + } + } +} + +// --------------------------------------------------------------------------- +// Project tests +// --------------------------------------------------------------------------- + +func TestBadger_SaveAndGetProject(t *testing.T) { + s := openTestStore(t) + p := makeProject(t, "proj1", "dsn-abc") + _ = s.SaveProject(ctx, p) + + got, err := s.GetProject(ctx, "proj1") + if err != nil { + t.Fatalf("GetProject: %v", err) + } + if got.Name != p.Name { + t.Errorf("got Name %q, want %q", got.Name, p.Name) + } +} + +func TestBadger_GetProject_NotFound(t *testing.T) { + s := openTestStore(t) + _, err := s.GetProject(ctx, "nope") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestBadger_GetProjectByDSNKey(t *testing.T) { + s := openTestStore(t) + p := makeProject(t, "proj1", "dsn-secret") + _ = s.SaveProject(ctx, p) + + got, err := s.GetProjectByDSNKey(ctx, "dsn-secret") + if err != nil { + t.Fatalf("GetProjectByDSNKey: %v", err) + } + if got.ID != "proj1" { + t.Errorf("got ID %q, want %q", got.ID, "proj1") + } +} + +func TestBadger_GetProjectByDSNKey_NotFound(t *testing.T) { + s := openTestStore(t) + + t.Run("no projects", func(t *testing.T) { + _, err := s.GetProjectByDSNKey(ctx, "nope") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } + }) + + t.Run("different key", func(t *testing.T) { + _ = s.SaveProject(ctx, makeProject(t, "p1", "key-a")) + _, err := s.GetProjectByDSNKey(ctx, "key-b") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } + }) +} + +// --------------------------------------------------------------------------- +// DeleteEventsOlderThan tests +// --------------------------------------------------------------------------- + +func TestBadger_DeleteEventsOlderThan(t *testing.T) { + s := openTestStore(t) + now := time.Now() + old1 := makeEvent(t, "p", domain.LevelInfo, now.Add(-2*time.Hour)) + old2 := makeEvent(t, "p", domain.LevelInfo, now.Add(-3*time.Hour)) + recent := makeEvent(t, "p", domain.LevelInfo, now.Add(-30*time.Minute)) + for _, e := range []*domain.Event{old1, old2, recent} { + _ = s.SaveEvent(ctx, e) + } + + deleted, err := s.DeleteEventsOlderThan(ctx, now.Add(-time.Hour)) + if err != nil { + t.Fatalf("DeleteEventsOlderThan: %v", err) + } + if deleted != 2 { + t.Errorf("deleted %d, want 2", deleted) + } + + remaining, _ := s.ListEvents(ctx, ListEventsOpts{ProjectID: "p"}) + if len(remaining) != 1 { + t.Errorf("got %d remaining events, want 1", len(remaining)) + } + if remaining[0].ID != recent.ID { + t.Error("wrong event retained after deletion") + } + + // Verify the lookup key is also deleted. + _, err = s.GetEvent(ctx, "p", old1.ID) + if !errors.Is(err, ErrNotFound) { + t.Errorf("old1 lookup should be deleted, got %v", err) + } +} + +func TestBadger_DeleteEventsOlderThan_NoneMatch(t *testing.T) { + s := openTestStore(t) + _ = s.SaveEvent(ctx, makeEvent(t, "p", domain.LevelInfo, time.Now())) + + deleted, err := s.DeleteEventsOlderThan(ctx, time.Now().Add(-time.Hour)) + if err != nil { + t.Fatalf("DeleteEventsOlderThan: %v", err) + } + if deleted != 0 { + t.Errorf("deleted %d, want 0", deleted) + } +} + +func TestBadger_DeleteEventsOlderThan_EmptyStore(t *testing.T) { + s := openTestStore(t) + deleted, err := s.DeleteEventsOlderThan(ctx, time.Now()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if deleted != 0 { + t.Errorf("deleted %d from empty store, want 0", deleted) + } +} + +// --------------------------------------------------------------------------- +// Persistence — data must survive close and reopen. +// --------------------------------------------------------------------------- + +func TestBadger_DataSurvivesReopen(t *testing.T) { + dir := t.TempDir() + + s1, err := Open(dir) + if err != nil { + t.Fatalf("Open: %v", err) + } + p := makeProject(t, "proj1", "dsn-key") + g := makeGroup(t, "proj1", "fp-reopen", domain.GroupStatusOpen, time.Now(), 3) + e := makeEvent(t, "proj1", domain.LevelError, time.Now()) + _ = s1.SaveProject(ctx, p) + _ = s1.SaveGroup(ctx, g) + _ = s1.SaveEvent(ctx, e) + if err := s1.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + s2, err := Open(dir) + if err != nil { + t.Fatalf("reopen: %v", err) + } + defer s2.Close() + + if _, err := s2.GetProject(ctx, "proj1"); err != nil { + t.Errorf("project not found after reopen: %v", err) + } + if _, err := s2.GetGroupByFingerprint(ctx, "proj1", "fp-reopen"); err != nil { + t.Errorf("group not found after reopen: %v", err) + } + if _, err := s2.GetEvent(ctx, "proj1", e.ID); err != nil { + t.Errorf("event not found after reopen: %v", err) + } +} + +// --------------------------------------------------------------------------- +// parseEventKey unit test +// --------------------------------------------------------------------------- + +func TestParseEventKey(t *testing.T) { + projectID := "my-project" + ts := time.Unix(0, 1700000000000000000) + id := newULID(t) + key := eventKey(projectID, ts, id) + + gotProj, gotNano, gotEvt, ok := parseEventKey(key) + if !ok { + t.Fatal("parseEventKey returned ok=false") + } + if gotProj != projectID { + t.Errorf("project: got %q, want %q", gotProj, projectID) + } + if gotNano != ts.UnixNano() { + t.Errorf("nano: got %d, want %d", gotNano, ts.UnixNano()) + } + if gotEvt != id.String() { + t.Errorf("eventID: got %q, want %q", gotEvt, id.String()) + } +} diff --git a/internal/storage/memstore.go b/internal/storage/memstore.go new file mode 100644 index 0000000..e50aa52 --- /dev/null +++ b/internal/storage/memstore.go @@ -0,0 +1,359 @@ +package storage + +import ( + "context" + "fmt" + "sort" + "sync" + "time" + + "github.com/oklog/ulid/v2" + + "github.com/syst3mctl/crashctl/internal/domain" +) + +// MemStore is an in-memory implementation of Store backed by maps and a +// sync.RWMutex. It is intended exclusively for unit tests — never production. +type MemStore struct { + mu sync.RWMutex + + events map[string]*domain.Event // key: eventID + groups map[string]*domain.ErrorGroup // key: groupID + fingerprints map[string]string // key: "projectID:fingerprint" → groupID + crashes map[string]*domain.PodCrash // key: crashID + projects map[string]*domain.Project // key: projectID + dsnIndex map[string]string // key: dsnKey → projectID +} + +// NewMemStore returns an empty, ready-to-use MemStore. +func NewMemStore() *MemStore { + return &MemStore{ + events: make(map[string]*domain.Event), + groups: make(map[string]*domain.ErrorGroup), + fingerprints: make(map[string]string), + crashes: make(map[string]*domain.PodCrash), + projects: make(map[string]*domain.Project), + dsnIndex: make(map[string]string), + } +} + +// --- helpers ---------------------------------------------------------------- + +func copyEvent(e *domain.Event) *domain.Event { + if e == nil { + return nil + } + cp := *e + if e.StackTrace != nil { + cp.StackTrace = make([]domain.Frame, len(e.StackTrace)) + for i, f := range e.StackTrace { + cf := f + if f.Source != nil { + cf.Source = make([]domain.SourceLine, len(f.Source)) + copy(cf.Source, f.Source) + } + cp.StackTrace[i] = cf + } + } + if e.ErrorChain != nil { + cp.ErrorChain = make([]domain.ChainedError, len(e.ErrorChain)) + copy(cp.ErrorChain, e.ErrorChain) + } + if e.Tags != nil { + cp.Tags = make(map[string]string, len(e.Tags)) + for k, v := range e.Tags { + cp.Tags[k] = v + } + } + if e.Context != nil { + cp.Context = make(map[string]any, len(e.Context)) + for k, v := range e.Context { + cp.Context[k] = v + } + } + return &cp +} + +func copyGroup(g *domain.ErrorGroup) *domain.ErrorGroup { + if g == nil { + return nil + } + cp := *g + return &cp +} + +func copyCrash(c *domain.PodCrash) *domain.PodCrash { + if c == nil { + return nil + } + cp := *c + if c.LinkedGroup != nil { + id := *c.LinkedGroup + cp.LinkedGroup = &id + } + return &cp +} + +func copyProject(p *domain.Project) *domain.Project { + if p == nil { + return nil + } + cp := *p + return &cp +} + +func fingerprintKey(projectID, fingerprint string) string { + return projectID + ":" + fingerprint +} + +// --- Event operations ------------------------------------------------------- + +func (m *MemStore) SaveEvent(ctx context.Context, event *domain.Event) error { + if event == nil { + return fmt.Errorf("save event: event is nil") + } + m.mu.Lock() + defer m.mu.Unlock() + m.events[event.ID.String()] = copyEvent(event) + return nil +} + +func (m *MemStore) GetEvent(ctx context.Context, projectID string, id ulid.ULID) (*domain.Event, error) { + m.mu.RLock() + defer m.mu.RUnlock() + e, ok := m.events[id.String()] + if !ok || e.ProjectID != projectID { + return nil, fmt.Errorf("get event %s: %w", id, ErrNotFound) + } + return copyEvent(e), nil +} + +func (m *MemStore) ListEvents(ctx context.Context, opts ListEventsOpts) ([]*domain.Event, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var matched []*domain.Event + for _, e := range m.events { + if opts.ProjectID != "" && e.ProjectID != opts.ProjectID { + continue + } + if opts.Level != nil && e.Level != *opts.Level { + continue + } + matched = append(matched, e) + } + + // Sort by timestamp descending (newest first). + sort.Slice(matched, func(i, j int) bool { + return matched[i].Timestamp.After(matched[j].Timestamp) + }) + + matched = applyPage(matched, opts.Offset, opts.Limit) + + out := make([]*domain.Event, len(matched)) + for i, e := range matched { + out[i] = copyEvent(e) + } + return out, nil +} + +// --- ErrorGroup operations -------------------------------------------------- + +func (m *MemStore) SaveGroup(ctx context.Context, group *domain.ErrorGroup) error { + if group == nil { + return fmt.Errorf("save group: group is nil") + } + m.mu.Lock() + defer m.mu.Unlock() + m.groups[group.ID.String()] = copyGroup(group) + m.fingerprints[fingerprintKey(group.ProjectID, group.Fingerprint)] = group.ID.String() + return nil +} + +func (m *MemStore) GetGroup(ctx context.Context, projectID string, id ulid.ULID) (*domain.ErrorGroup, error) { + m.mu.RLock() + defer m.mu.RUnlock() + g, ok := m.groups[id.String()] + if !ok || g.ProjectID != projectID { + return nil, fmt.Errorf("get group %s: %w", id, ErrNotFound) + } + return copyGroup(g), nil +} + +func (m *MemStore) GetGroupByFingerprint(ctx context.Context, projectID, fingerprint string) (*domain.ErrorGroup, error) { + m.mu.RLock() + defer m.mu.RUnlock() + gid, ok := m.fingerprints[fingerprintKey(projectID, fingerprint)] + if !ok { + return nil, fmt.Errorf("get group by fingerprint %q: %w", fingerprint, ErrNotFound) + } + g, ok := m.groups[gid] + if !ok { + return nil, fmt.Errorf("get group by fingerprint %q: %w", fingerprint, ErrNotFound) + } + return copyGroup(g), nil +} + +func (m *MemStore) ListGroups(ctx context.Context, opts ListGroupsOpts) ([]*domain.ErrorGroup, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var matched []*domain.ErrorGroup + for _, g := range m.groups { + if opts.ProjectID != "" && g.ProjectID != opts.ProjectID { + continue + } + if opts.Status != nil && g.Status != *opts.Status { + continue + } + matched = append(matched, g) + } + + switch opts.SortBy { + case GroupSortFirstSeen: + sort.Slice(matched, func(i, j int) bool { + return matched[i].FirstSeen.After(matched[j].FirstSeen) + }) + case GroupSortCount: + sort.Slice(matched, func(i, j int) bool { + return matched[i].Count > matched[j].Count + }) + default: // GroupSortLastSeen or empty + sort.Slice(matched, func(i, j int) bool { + return matched[i].LastSeen.After(matched[j].LastSeen) + }) + } + + matched = applyPage(matched, opts.Offset, opts.Limit) + + out := make([]*domain.ErrorGroup, len(matched)) + for i, g := range matched { + out[i] = copyGroup(g) + } + return out, nil +} + +func (m *MemStore) IncrementGroupCount(ctx context.Context, projectID string, id ulid.ULID, lastSeen time.Time, lastEventID ulid.ULID) error { + m.mu.Lock() + defer m.mu.Unlock() + g, ok := m.groups[id.String()] + if !ok || g.ProjectID != projectID { + return fmt.Errorf("increment group count %s: %w", id, ErrNotFound) + } + g.Count++ + g.LastSeen = lastSeen + g.LastEvent = lastEventID + return nil +} + +func (m *MemStore) UpdateGroupStatus(ctx context.Context, projectID string, id ulid.ULID, status domain.GroupStatus) error { + m.mu.Lock() + defer m.mu.Unlock() + g, ok := m.groups[id.String()] + if !ok || g.ProjectID != projectID { + return fmt.Errorf("update group status %s: %w", id, ErrNotFound) + } + g.Status = status + return nil +} + +// --- PodCrash operations ---------------------------------------------------- + +func (m *MemStore) SavePodCrash(ctx context.Context, crash *domain.PodCrash) error { + if crash == nil { + return fmt.Errorf("save pod crash: crash is nil") + } + m.mu.Lock() + defer m.mu.Unlock() + m.crashes[crash.ID.String()] = copyCrash(crash) + return nil +} + +func (m *MemStore) GetPodCrash(ctx context.Context, id ulid.ULID) (*domain.PodCrash, error) { + m.mu.RLock() + defer m.mu.RUnlock() + c, ok := m.crashes[id.String()] + if !ok { + return nil, fmt.Errorf("get pod crash %s: %w", id, ErrNotFound) + } + return copyCrash(c), nil +} + +func (m *MemStore) ListPodCrashes(ctx context.Context, opts ListCrashesOpts) ([]*domain.PodCrash, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var matched []*domain.PodCrash + for _, c := range m.crashes { + if opts.Namespace != "" && c.Namespace != opts.Namespace { + continue + } + matched = append(matched, c) + } + + // Sort by timestamp descending (newest first). + sort.Slice(matched, func(i, j int) bool { + return matched[i].Timestamp.After(matched[j].Timestamp) + }) + + matched = applyPage(matched, opts.Offset, opts.Limit) + + out := make([]*domain.PodCrash, len(matched)) + for i, c := range matched { + out[i] = copyCrash(c) + } + return out, nil +} + +// --- Project operations ----------------------------------------------------- + +func (m *MemStore) SaveProject(ctx context.Context, project *domain.Project) error { + if project == nil { + return fmt.Errorf("save project: project is nil") + } + m.mu.Lock() + defer m.mu.Unlock() + m.projects[project.ID] = copyProject(project) + m.dsnIndex[project.DSNKey] = project.ID + return nil +} + +func (m *MemStore) GetProject(ctx context.Context, id string) (*domain.Project, error) { + m.mu.RLock() + defer m.mu.RUnlock() + p, ok := m.projects[id] + if !ok { + return nil, fmt.Errorf("get project %s: %w", id, ErrNotFound) + } + return copyProject(p), nil +} + +func (m *MemStore) GetProjectByDSNKey(ctx context.Context, dsnKey string) (*domain.Project, error) { + m.mu.RLock() + defer m.mu.RUnlock() + pid, ok := m.dsnIndex[dsnKey] + if !ok { + return nil, fmt.Errorf("get project by dsn key: %w", ErrNotFound) + } + p, ok := m.projects[pid] + if !ok { + return nil, fmt.Errorf("get project by dsn key: %w", ErrNotFound) + } + return copyProject(p), nil +} + +// --- Maintenance ------------------------------------------------------------ + +func (m *MemStore) DeleteEventsOlderThan(ctx context.Context, cutoff time.Time) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + deleted := 0 + for id, e := range m.events { + if e.Timestamp.Before(cutoff) { + delete(m.events, id) + deleted++ + } + } + return deleted, nil +} + diff --git a/internal/storage/memstore_test.go b/internal/storage/memstore_test.go new file mode 100644 index 0000000..bcc6497 --- /dev/null +++ b/internal/storage/memstore_test.go @@ -0,0 +1,652 @@ +package storage + +import ( + "context" + "crypto/rand" + "errors" + "testing" + "time" + + "github.com/oklog/ulid/v2" + + "github.com/syst3mctl/crashctl/internal/domain" +) + +// newULID generates a unique ULID for test fixtures using crypto/rand entropy. +func newULID(t *testing.T) ulid.ULID { + t.Helper() + return ulid.MustNew(ulid.Now(), rand.Reader) +} + +// makeEvent returns a minimal valid Event for testing. +func makeEvent(t *testing.T, projectID string, level domain.Level, ts time.Time) *domain.Event { + t.Helper() + return &domain.Event{ + ID: newULID(t), + ProjectID: projectID, + Timestamp: ts, + Level: level, + Message: "test error", + } +} + +// makeGroup returns a minimal valid ErrorGroup for testing. +func makeGroup(t *testing.T, projectID, fingerprint string, status domain.GroupStatus, lastSeen time.Time, count int64) *domain.ErrorGroup { + t.Helper() + return &domain.ErrorGroup{ + ID: newULID(t), + ProjectID: projectID, + Fingerprint: fingerprint, + Title: "test group", + Level: domain.LevelError, + FirstSeen: lastSeen.Add(-time.Hour), + LastSeen: lastSeen, + Count: count, + Status: status, + } +} + +// makeCrash returns a minimal valid PodCrash for testing. +func makeCrash(t *testing.T, namespace string, ts time.Time) *domain.PodCrash { + t.Helper() + return &domain.PodCrash{ + ID: newULID(t), + Timestamp: ts, + Namespace: namespace, + PodName: "my-pod", + Container: "app", + CrashType: domain.CrashTypeOOMKill, + } +} + +// makeProject returns a valid Project for testing. +func makeProject(t *testing.T, id, dsnKey string) *domain.Project { + t.Helper() + return &domain.Project{ + ID: id, + Name: "test-project", + DSNKey: dsnKey, + CreatedAt: time.Now(), + } +} + +var ctx = context.Background() + +// --------------------------------------------------------------------------- +// Event tests +// --------------------------------------------------------------------------- + +func TestMemStore_SaveAndGetEvent(t *testing.T) { + s := NewMemStore() + e := makeEvent(t, "proj1", domain.LevelError, time.Now()) + + if err := s.SaveEvent(ctx, e); err != nil { + t.Fatalf("SaveEvent: %v", err) + } + + got, err := s.GetEvent(ctx, "proj1", e.ID) + if err != nil { + t.Fatalf("GetEvent: %v", err) + } + if got.ID != e.ID { + t.Errorf("got ID %v, want %v", got.ID, e.ID) + } + if got.ProjectID != e.ProjectID { + t.Errorf("got ProjectID %q, want %q", got.ProjectID, e.ProjectID) + } +} + +func TestMemStore_GetEvent_NotFound(t *testing.T) { + s := NewMemStore() + + t.Run("missing id", func(t *testing.T) { + _, err := s.GetEvent(ctx, "proj1", newULID(t)) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } + }) + + t.Run("wrong project", func(t *testing.T) { + e := makeEvent(t, "proj1", domain.LevelError, time.Now()) + _ = s.SaveEvent(ctx, e) + _, err := s.GetEvent(ctx, "other-project", e.ID) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound for wrong project, got %v", err) + } + }) +} + +func TestMemStore_GetEvent_ReturnsCopy(t *testing.T) { + s := NewMemStore() + e := makeEvent(t, "proj1", domain.LevelError, time.Now()) + e.Tags = map[string]string{"env": "test"} + _ = s.SaveEvent(ctx, e) + + got, _ := s.GetEvent(ctx, "proj1", e.ID) + got.Tags["env"] = "mutated" + + got2, _ := s.GetEvent(ctx, "proj1", e.ID) + if got2.Tags["env"] != "test" { + t.Error("GetEvent returned a reference instead of a copy") + } +} + +func TestMemStore_ListEvents_FilterByProject(t *testing.T) { + s := NewMemStore() + now := time.Now() + e1 := makeEvent(t, "proj1", domain.LevelError, now) + e2 := makeEvent(t, "proj2", domain.LevelError, now) + e3 := makeEvent(t, "proj1", domain.LevelInfo, now.Add(time.Second)) + for _, e := range []*domain.Event{e1, e2, e3} { + _ = s.SaveEvent(ctx, e) + } + + got, err := s.ListEvents(ctx, ListEventsOpts{ProjectID: "proj1"}) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(got) != 2 { + t.Errorf("got %d events, want 2", len(got)) + } +} + +func TestMemStore_ListEvents_FilterByLevel(t *testing.T) { + s := NewMemStore() + now := time.Now() + errLevel := domain.LevelError + _ = s.SaveEvent(ctx, makeEvent(t, "proj1", domain.LevelError, now)) + _ = s.SaveEvent(ctx, makeEvent(t, "proj1", domain.LevelInfo, now)) + _ = s.SaveEvent(ctx, makeEvent(t, "proj1", domain.LevelError, now.Add(time.Second))) + + got, err := s.ListEvents(ctx, ListEventsOpts{ProjectID: "proj1", Level: &errLevel}) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(got) != 2 { + t.Errorf("got %d events, want 2", len(got)) + } + for _, e := range got { + if e.Level != domain.LevelError { + t.Errorf("unexpected level %v in filtered results", e.Level) + } + } +} + +func TestMemStore_ListEvents_OrderedNewestFirst(t *testing.T) { + s := NewMemStore() + base := time.Now() + for i := 0; i < 5; i++ { + _ = s.SaveEvent(ctx, makeEvent(t, "p", domain.LevelInfo, base.Add(time.Duration(i)*time.Second))) + } + + got, _ := s.ListEvents(ctx, ListEventsOpts{ProjectID: "p"}) + for i := 1; i < len(got); i++ { + if got[i].Timestamp.After(got[i-1].Timestamp) { + t.Errorf("results not in descending order at index %d", i) + } + } +} + +func TestMemStore_ListEvents_LimitAndOffset(t *testing.T) { + s := NewMemStore() + base := time.Now() + for i := 0; i < 5; i++ { + _ = s.SaveEvent(ctx, makeEvent(t, "p", domain.LevelInfo, base.Add(time.Duration(i)*time.Second))) + } + + tests := []struct { + limit, offset, wantLen int + }{ + {2, 0, 2}, + {2, 3, 2}, + {10, 0, 5}, + {0, 0, 5}, // limit=0 means no cap + {2, 10, 0}, + } + for _, tc := range tests { + got, _ := s.ListEvents(ctx, ListEventsOpts{ProjectID: "p", Limit: tc.limit, Offset: tc.offset}) + if len(got) != tc.wantLen { + t.Errorf("limit=%d offset=%d: got %d, want %d", tc.limit, tc.offset, len(got), tc.wantLen) + } + } +} + +// --------------------------------------------------------------------------- +// ErrorGroup tests +// --------------------------------------------------------------------------- + +func TestMemStore_SaveAndGetGroup(t *testing.T) { + s := NewMemStore() + g := makeGroup(t, "proj1", "fp1", domain.GroupStatusOpen, time.Now(), 1) + _ = s.SaveGroup(ctx, g) + + got, err := s.GetGroup(ctx, "proj1", g.ID) + if err != nil { + t.Fatalf("GetGroup: %v", err) + } + if got.Fingerprint != "fp1" { + t.Errorf("got fingerprint %q, want %q", got.Fingerprint, "fp1") + } +} + +func TestMemStore_GetGroup_NotFound(t *testing.T) { + s := NewMemStore() + + t.Run("missing id", func(t *testing.T) { + _, err := s.GetGroup(ctx, "proj1", newULID(t)) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } + }) + + t.Run("wrong project", func(t *testing.T) { + g := makeGroup(t, "proj1", "fp1", domain.GroupStatusOpen, time.Now(), 1) + _ = s.SaveGroup(ctx, g) + _, err := s.GetGroup(ctx, "other", g.ID) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound for wrong project, got %v", err) + } + }) +} + +func TestMemStore_GetGroupByFingerprint(t *testing.T) { + s := NewMemStore() + g := makeGroup(t, "proj1", "sha256abc", domain.GroupStatusOpen, time.Now(), 1) + _ = s.SaveGroup(ctx, g) + + got, err := s.GetGroupByFingerprint(ctx, "proj1", "sha256abc") + if err != nil { + t.Fatalf("GetGroupByFingerprint: %v", err) + } + if got.ID != g.ID { + t.Errorf("got group %v, want %v", got.ID, g.ID) + } +} + +func TestMemStore_GetGroupByFingerprint_NotFound(t *testing.T) { + s := NewMemStore() + + t.Run("no groups", func(t *testing.T) { + _, err := s.GetGroupByFingerprint(ctx, "proj1", "nope") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } + }) + + t.Run("fingerprint exists in different project", func(t *testing.T) { + g := makeGroup(t, "proj1", "fp1", domain.GroupStatusOpen, time.Now(), 1) + _ = s.SaveGroup(ctx, g) + _, err := s.GetGroupByFingerprint(ctx, "proj2", "fp1") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound for different project, got %v", err) + } + }) +} + +func TestMemStore_ListGroups_FilterByStatus(t *testing.T) { + s := NewMemStore() + now := time.Now() + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp1", domain.GroupStatusOpen, now, 1)) + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp2", domain.GroupStatusResolved, now, 1)) + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp3", domain.GroupStatusOpen, now, 1)) + + open := domain.GroupStatusOpen + got, _ := s.ListGroups(ctx, ListGroupsOpts{ProjectID: "p", Status: &open}) + if len(got) != 2 { + t.Errorf("got %d groups, want 2", len(got)) + } + for _, g := range got { + if g.Status != domain.GroupStatusOpen { + t.Errorf("unexpected status %v", g.Status) + } + } +} + +func TestMemStore_ListGroups_SortByCount(t *testing.T) { + s := NewMemStore() + now := time.Now() + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp1", domain.GroupStatusOpen, now, 10)) + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp2", domain.GroupStatusOpen, now, 50)) + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp3", domain.GroupStatusOpen, now, 5)) + + got, _ := s.ListGroups(ctx, ListGroupsOpts{ProjectID: "p", SortBy: GroupSortCount}) + if got[0].Count != 50 || got[1].Count != 10 || got[2].Count != 5 { + t.Errorf("wrong sort order by count: %v %v %v", got[0].Count, got[1].Count, got[2].Count) + } +} + +func TestMemStore_ListGroups_SortByLastSeen(t *testing.T) { + s := NewMemStore() + base := time.Now() + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp1", domain.GroupStatusOpen, base.Add(1*time.Hour), 1)) + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp2", domain.GroupStatusOpen, base.Add(3*time.Hour), 1)) + _ = s.SaveGroup(ctx, makeGroup(t, "p", "fp3", domain.GroupStatusOpen, base.Add(2*time.Hour), 1)) + + got, _ := s.ListGroups(ctx, ListGroupsOpts{ProjectID: "p", SortBy: GroupSortLastSeen}) + if !got[0].LastSeen.After(got[1].LastSeen) || !got[1].LastSeen.After(got[2].LastSeen) { + t.Error("results not sorted by last_seen descending") + } +} + +func TestMemStore_ListGroups_SortByFirstSeen(t *testing.T) { + s := NewMemStore() + base := time.Now() + g1 := makeGroup(t, "p", "fp1", domain.GroupStatusOpen, base, 1) + g1.FirstSeen = base.Add(1 * time.Hour) + g2 := makeGroup(t, "p", "fp2", domain.GroupStatusOpen, base, 1) + g2.FirstSeen = base.Add(3 * time.Hour) + g3 := makeGroup(t, "p", "fp3", domain.GroupStatusOpen, base, 1) + g3.FirstSeen = base.Add(2 * time.Hour) + for _, g := range []*domain.ErrorGroup{g1, g2, g3} { + _ = s.SaveGroup(ctx, g) + } + + got, _ := s.ListGroups(ctx, ListGroupsOpts{ProjectID: "p", SortBy: GroupSortFirstSeen}) + if !got[0].FirstSeen.After(got[1].FirstSeen) || !got[1].FirstSeen.After(got[2].FirstSeen) { + t.Error("results not sorted by first_seen descending") + } +} + +func TestMemStore_ListGroups_LimitAndOffset(t *testing.T) { + s := NewMemStore() + now := time.Now() + for i := 0; i < 5; i++ { + _ = s.SaveGroup(ctx, makeGroup(t, "p", string(rune('a'+i)), domain.GroupStatusOpen, now, 1)) + } + + tests := []struct { + limit, offset, want int + }{ + {2, 0, 2}, + {2, 3, 2}, + {0, 0, 5}, + {10, 0, 5}, + {3, 4, 1}, + } + for _, tc := range tests { + got, _ := s.ListGroups(ctx, ListGroupsOpts{ProjectID: "p", Limit: tc.limit, Offset: tc.offset}) + if len(got) != tc.want { + t.Errorf("limit=%d offset=%d: got %d, want %d", tc.limit, tc.offset, len(got), tc.want) + } + } +} + +func TestMemStore_IncrementGroupCount(t *testing.T) { + s := NewMemStore() + g := makeGroup(t, "proj1", "fp1", domain.GroupStatusOpen, time.Now(), 1) + _ = s.SaveGroup(ctx, g) + + newSeen := time.Now().Add(time.Minute) + lastEvt := newULID(t) + if err := s.IncrementGroupCount(ctx, "proj1", g.ID, newSeen, lastEvt); err != nil { + t.Fatalf("IncrementGroupCount: %v", err) + } + + got, _ := s.GetGroup(ctx, "proj1", g.ID) + if got.Count != 2 { + t.Errorf("got count %d, want 2", got.Count) + } + if !got.LastSeen.Equal(newSeen) { + t.Errorf("got LastSeen %v, want %v", got.LastSeen, newSeen) + } + if got.LastEvent != lastEvt { + t.Errorf("got LastEvent %v, want %v", got.LastEvent, lastEvt) + } +} + +func TestMemStore_IncrementGroupCount_NotFound(t *testing.T) { + s := NewMemStore() + err := s.IncrementGroupCount(ctx, "proj1", newULID(t), time.Now(), newULID(t)) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestMemStore_UpdateGroupStatus(t *testing.T) { + s := NewMemStore() + g := makeGroup(t, "proj1", "fp1", domain.GroupStatusOpen, time.Now(), 1) + _ = s.SaveGroup(ctx, g) + + if err := s.UpdateGroupStatus(ctx, "proj1", g.ID, domain.GroupStatusResolved); err != nil { + t.Fatalf("UpdateGroupStatus: %v", err) + } + got, _ := s.GetGroup(ctx, "proj1", g.ID) + if got.Status != domain.GroupStatusResolved { + t.Errorf("got status %v, want resolved", got.Status) + } +} + +func TestMemStore_UpdateGroupStatus_NotFound(t *testing.T) { + s := NewMemStore() + err := s.UpdateGroupStatus(ctx, "proj1", newULID(t), domain.GroupStatusIgnored) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// PodCrash tests +// --------------------------------------------------------------------------- + +func TestMemStore_SaveAndGetPodCrash(t *testing.T) { + s := NewMemStore() + c := makeCrash(t, "prod", time.Now()) + _ = s.SavePodCrash(ctx, c) + + got, err := s.GetPodCrash(ctx, c.ID) + if err != nil { + t.Fatalf("GetPodCrash: %v", err) + } + if got.ID != c.ID { + t.Errorf("got ID %v, want %v", got.ID, c.ID) + } +} + +func TestMemStore_GetPodCrash_NotFound(t *testing.T) { + s := NewMemStore() + _, err := s.GetPodCrash(ctx, newULID(t)) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestMemStore_SavePodCrash_PreservesLinkedGroup(t *testing.T) { + s := NewMemStore() + c := makeCrash(t, "prod", time.Now()) + gid := newULID(t) + c.LinkedGroup = &gid + _ = s.SavePodCrash(ctx, c) + + got, _ := s.GetPodCrash(ctx, c.ID) + if got.LinkedGroup == nil || *got.LinkedGroup != gid { + t.Error("LinkedGroup not preserved correctly") + } +} + +func TestMemStore_GetPodCrash_ReturnsCopy(t *testing.T) { + s := NewMemStore() + c := makeCrash(t, "prod", time.Now()) + gid := newULID(t) + c.LinkedGroup = &gid + _ = s.SavePodCrash(ctx, c) + + got, _ := s.GetPodCrash(ctx, c.ID) + newID := newULID(t) + got.LinkedGroup = &newID + + got2, _ := s.GetPodCrash(ctx, c.ID) + if *got2.LinkedGroup != gid { + t.Error("GetPodCrash returned a reference instead of a copy") + } +} + +func TestMemStore_ListPodCrashes_FilterByNamespace(t *testing.T) { + s := NewMemStore() + now := time.Now() + _ = s.SavePodCrash(ctx, makeCrash(t, "prod", now)) + _ = s.SavePodCrash(ctx, makeCrash(t, "staging", now)) + _ = s.SavePodCrash(ctx, makeCrash(t, "prod", now.Add(time.Second))) + + got, _ := s.ListPodCrashes(ctx, ListCrashesOpts{Namespace: "prod"}) + if len(got) != 2 { + t.Errorf("got %d crashes, want 2", len(got)) + } + for _, c := range got { + if c.Namespace != "prod" { + t.Errorf("unexpected namespace %q", c.Namespace) + } + } +} + +func TestMemStore_ListPodCrashes_OrderedNewestFirst(t *testing.T) { + s := NewMemStore() + base := time.Now() + for i := 0; i < 4; i++ { + _ = s.SavePodCrash(ctx, makeCrash(t, "ns", base.Add(time.Duration(i)*time.Second))) + } + + got, _ := s.ListPodCrashes(ctx, ListCrashesOpts{}) + for i := 1; i < len(got); i++ { + if got[i].Timestamp.After(got[i-1].Timestamp) { + t.Errorf("results not in descending order at index %d", i) + } + } +} + +func TestMemStore_ListPodCrashes_LimitAndOffset(t *testing.T) { + s := NewMemStore() + base := time.Now() + for i := 0; i < 5; i++ { + _ = s.SavePodCrash(ctx, makeCrash(t, "ns", base.Add(time.Duration(i)*time.Second))) + } + + tests := []struct { + limit, offset, want int + }{ + {2, 0, 2}, + {0, 0, 5}, + {3, 3, 2}, + {5, 10, 0}, + } + for _, tc := range tests { + got, _ := s.ListPodCrashes(ctx, ListCrashesOpts{Limit: tc.limit, Offset: tc.offset}) + if len(got) != tc.want { + t.Errorf("limit=%d offset=%d: got %d, want %d", tc.limit, tc.offset, len(got), tc.want) + } + } +} + +// --------------------------------------------------------------------------- +// Project tests +// --------------------------------------------------------------------------- + +func TestMemStore_SaveAndGetProject(t *testing.T) { + s := NewMemStore() + p := makeProject(t, "proj1", "dsn-abc") + _ = s.SaveProject(ctx, p) + + got, err := s.GetProject(ctx, "proj1") + if err != nil { + t.Fatalf("GetProject: %v", err) + } + if got.Name != p.Name { + t.Errorf("got Name %q, want %q", got.Name, p.Name) + } +} + +func TestMemStore_GetProject_NotFound(t *testing.T) { + s := NewMemStore() + _, err := s.GetProject(ctx, "nope") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestMemStore_GetProjectByDSNKey(t *testing.T) { + s := NewMemStore() + p := makeProject(t, "proj1", "dsn-secret") + _ = s.SaveProject(ctx, p) + + got, err := s.GetProjectByDSNKey(ctx, "dsn-secret") + if err != nil { + t.Fatalf("GetProjectByDSNKey: %v", err) + } + if got.ID != "proj1" { + t.Errorf("got ID %q, want %q", got.ID, "proj1") + } +} + +func TestMemStore_GetProjectByDSNKey_NotFound(t *testing.T) { + s := NewMemStore() + + t.Run("no projects", func(t *testing.T) { + _, err := s.GetProjectByDSNKey(ctx, "nope") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } + }) + + t.Run("different key", func(t *testing.T) { + _ = s.SaveProject(ctx, makeProject(t, "p1", "key-a")) + _, err := s.GetProjectByDSNKey(ctx, "key-b") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } + }) +} + +// --------------------------------------------------------------------------- +// DeleteEventsOlderThan tests +// --------------------------------------------------------------------------- + +func TestMemStore_DeleteEventsOlderThan(t *testing.T) { + s := NewMemStore() + now := time.Now() + old1 := makeEvent(t, "p", domain.LevelInfo, now.Add(-2*time.Hour)) + old2 := makeEvent(t, "p", domain.LevelInfo, now.Add(-3*time.Hour)) + recent := makeEvent(t, "p", domain.LevelInfo, now.Add(-30*time.Minute)) + for _, e := range []*domain.Event{old1, old2, recent} { + _ = s.SaveEvent(ctx, e) + } + + cutoff := now.Add(-time.Hour) + deleted, err := s.DeleteEventsOlderThan(ctx, cutoff) + if err != nil { + t.Fatalf("DeleteEventsOlderThan: %v", err) + } + if deleted != 2 { + t.Errorf("deleted %d, want 2", deleted) + } + + remaining, _ := s.ListEvents(ctx, ListEventsOpts{ProjectID: "p"}) + if len(remaining) != 1 { + t.Errorf("got %d remaining events, want 1", len(remaining)) + } + if remaining[0].ID != recent.ID { + t.Error("wrong event retained after deletion") + } +} + +func TestMemStore_DeleteEventsOlderThan_NoneMatch(t *testing.T) { + s := NewMemStore() + _ = s.SaveEvent(ctx, makeEvent(t, "p", domain.LevelInfo, time.Now())) + + deleted, err := s.DeleteEventsOlderThan(ctx, time.Now().Add(-time.Hour)) + if err != nil { + t.Fatalf("DeleteEventsOlderThan: %v", err) + } + if deleted != 0 { + t.Errorf("deleted %d, want 0", deleted) + } +} + +func TestMemStore_DeleteEventsOlderThan_EmptyStore(t *testing.T) { + s := NewMemStore() + deleted, err := s.DeleteEventsOlderThan(ctx, time.Now()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if deleted != 0 { + t.Errorf("deleted %d from empty store, want 0", deleted) + } +} diff --git a/internal/storage/store.go b/internal/storage/store.go new file mode 100644 index 0000000..6c90d20 --- /dev/null +++ b/internal/storage/store.go @@ -0,0 +1,113 @@ +package storage + +import ( + "context" + "errors" + "time" + + "github.com/oklog/ulid/v2" + + "github.com/syst3mctl/crashctl/internal/domain" +) + +// ErrNotFound is returned when a requested resource does not exist. +var ErrNotFound = errors.New("not found") + +// GroupSortField controls the ordering of ListGroups results. +type GroupSortField string + +const ( + GroupSortLastSeen GroupSortField = "last_seen" + GroupSortFirstSeen GroupSortField = "first_seen" + GroupSortCount GroupSortField = "count" +) + +// ListEventsOpts filters and paginates ListEvents results. +type ListEventsOpts struct { + ProjectID string + Level *domain.Level + Limit int + Offset int +} + +// ListGroupsOpts filters and paginates ListGroups results. +type ListGroupsOpts struct { + ProjectID string + Status *domain.GroupStatus + SortBy GroupSortField + Limit int + Offset int +} + +// ListCrashesOpts filters and paginates ListPodCrashes results. +type ListCrashesOpts struct { + Namespace string + Limit int + Offset int +} + +// Store is the persistence interface for all crashctl entities. +// All implementations must be safe for concurrent use. +type Store interface { + // Event operations + + // SaveEvent persists a new event. The event must have a valid ID and ProjectID. + SaveEvent(ctx context.Context, event *domain.Event) error + + // GetEvent retrieves a single event by ID within a project. + GetEvent(ctx context.Context, projectID string, id ulid.ULID) (*domain.Event, error) + + // ListEvents returns a paginated, optionally filtered slice of events. + ListEvents(ctx context.Context, opts ListEventsOpts) ([]*domain.Event, error) + + // ErrorGroup operations + + // SaveGroup persists a new or updated ErrorGroup. + SaveGroup(ctx context.Context, group *domain.ErrorGroup) error + + // GetGroup retrieves an ErrorGroup by its ID within a project. + GetGroup(ctx context.Context, projectID string, id ulid.ULID) (*domain.ErrorGroup, error) + + // GetGroupByFingerprint looks up the ErrorGroup matching the given fingerprint + // in the specified project. Returns ErrNotFound if no group exists yet. + GetGroupByFingerprint(ctx context.Context, projectID, fingerprint string) (*domain.ErrorGroup, error) + + // ListGroups returns a paginated, optionally filtered and sorted slice of groups. + ListGroups(ctx context.Context, opts ListGroupsOpts) ([]*domain.ErrorGroup, error) + + // IncrementGroupCount atomically increments the event count for a group and + // updates its LastSeen timestamp and LastEvent reference. + IncrementGroupCount(ctx context.Context, projectID string, id ulid.ULID, lastSeen time.Time, lastEventID ulid.ULID) error + + // UpdateGroupStatus changes the status of an ErrorGroup. + UpdateGroupStatus(ctx context.Context, projectID string, id ulid.ULID, status domain.GroupStatus) error + + // PodCrash operations + + // SavePodCrash persists a new PodCrash record. + SavePodCrash(ctx context.Context, crash *domain.PodCrash) error + + // GetPodCrash retrieves a single PodCrash by ID. + GetPodCrash(ctx context.Context, id ulid.ULID) (*domain.PodCrash, error) + + // ListPodCrashes returns a paginated, optionally namespace-filtered slice of crashes. + ListPodCrashes(ctx context.Context, opts ListCrashesOpts) ([]*domain.PodCrash, error) + + // Project operations + + // SaveProject persists a new or updated Project. + SaveProject(ctx context.Context, project *domain.Project) error + + // GetProject retrieves a Project by its ID. + GetProject(ctx context.Context, id string) (*domain.Project, error) + + // GetProjectByDSNKey looks up a Project by its DSN key. + // Returns ErrNotFound if no project has the given key. + GetProjectByDSNKey(ctx context.Context, dsnKey string) (*domain.Project, error) + + // Maintenance + + // DeleteEventsOlderThan removes all events with a timestamp before the given + // cutoff and returns the number of deleted records. + DeleteEventsOlderThan(ctx context.Context, cutoff time.Time) (int, error) +}