Skip to content

Commit 093d693

Browse files
authored
feat(graphrag): tenant_id column + scoped reads + backfill (RAN-38) (#30)
1 parent 402297d commit 093d693

11 files changed

Lines changed: 639 additions & 75 deletions

File tree

internal/graphrag/builder.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,13 @@ func New(repo *storage.Repository, vectorIdx *vectordb.Index, tsdbAgg *tsdb.Aggr
238238
// Restore persisted Drain templates so log clustering survives restarts.
239239
// A missing table (fresh install) or transient DB error is non-fatal —
240240
// ingestion will rebuild templates from scratch.
241+
//
242+
// The Drain miner is currently a single shared instance, so we treat its
243+
// learned templates as belonging to DefaultTenantID. The persistence layer
244+
// is already keyed by (tenant_id, id) so a future per-tenant Drain miner
245+
// can load each tenant's slice without colliding cluster IDs.
241246
if repo != nil && repo.DB() != nil {
242-
if tpls, err := LoadDrainTemplates(repo.DB()); err != nil {
247+
if tpls, err := LoadDrainTemplates(repo.DB(), storage.DefaultTenantID); err != nil {
243248
slog.Info("GraphRAG: drain template restore skipped", "reason", err)
244249
} else if len(tpls) > 0 {
245250
g.drain.LoadTemplates(tpls)
@@ -294,7 +299,7 @@ func (g *GraphRAG) Stop() {
294299
// Best-effort final Drain template persistence — losing the most recent
295300
// updates on an unclean shutdown would force rebuilding from scratch.
296301
if g.repo != nil && g.repo.DB() != nil && g.drain != nil {
297-
if err := SaveDrainTemplates(g.repo.DB(), g.drain.Templates()); err != nil {
302+
if err := SaveDrainTemplates(g.repo.DB(), storage.DefaultTenantID, g.drain.Templates()); err != nil {
298303
slog.Warn("GraphRAG: final drain template save failed", "error", err)
299304
}
300305
}

internal/graphrag/drain.go

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"sync"
3434
"time"
3535

36+
"github.com/RandomCodeSpace/otelcontext/internal/storage"
3637
"gorm.io/gorm"
3738
"gorm.io/gorm/clause"
3839
)
@@ -504,20 +505,28 @@ func (d *Drain) Len() int {
504505
// --- Persistence ---
505506

506507
// SaveDrainTemplates upserts the given templates into the drain_templates
507-
// table. Tokens are JSON-encoded. Uses GORM's clause.OnConflict upsert which
508-
// is dialect-aware (ON CONFLICT for SQLite/PostgreSQL, ON DUPLICATE KEY
509-
// UPDATE for MySQL).
510-
func SaveDrainTemplates(db *gorm.DB, templates []Template) error {
508+
// table under the supplied tenant. Tokens are JSON-encoded. Uses GORM's
509+
// clause.OnConflict upsert which is dialect-aware (ON CONFLICT for SQLite/
510+
// PostgreSQL, ON DUPLICATE KEY UPDATE for MySQL).
511+
//
512+
// The conflict target is the composite (tenant_id, id) primary key so the
513+
// same Drain template hash can coexist across tenants — a future per-tenant
514+
// Drain miner can rely on this to keep cluster IDs stable per tenant.
515+
func SaveDrainTemplates(db *gorm.DB, tenant string, templates []Template) error {
511516
if db == nil || len(templates) == 0 {
512517
return nil
513518
}
519+
if tenant == "" {
520+
tenant = storage.DefaultTenantID
521+
}
514522
rows := make([]DrainTemplateRow, 0, len(templates))
515523
for _, t := range templates {
516524
tokensJSON, err := json.Marshal(t.Tokens)
517525
if err != nil {
518526
return fmt.Errorf("marshal drain tokens id=%d: %w", t.ID, err)
519527
}
520528
rows = append(rows, DrainTemplateRow{
529+
TenantID: tenant,
521530
ID: int64(t.ID), //nolint:gosec // intentional bit-reinterpret of FNV-64 hash for DB portability
522531
Tokens: string(tokensJSON),
523532
Count: t.Count,
@@ -527,22 +536,25 @@ func SaveDrainTemplates(db *gorm.DB, templates []Template) error {
527536
})
528537
}
529538
return db.Clauses(clause.OnConflict{
530-
Columns: []clause.Column{{Name: "id"}},
539+
Columns: []clause.Column{{Name: "tenant_id"}, {Name: "id"}},
531540
DoUpdates: clause.AssignmentColumns([]string{
532541
"tokens", "count", "last_seen", "sample",
533542
}),
534543
}).CreateInBatches(&rows, 500).Error
535544
}
536545

537-
// LoadDrainTemplates reads all persisted Drain templates from the DB and
538-
// returns them in a format ready to pass to Drain.LoadTemplates. Returns an
539-
// empty slice (and nil error) if the table is empty.
540-
func LoadDrainTemplates(db *gorm.DB) ([]Template, error) {
546+
// LoadDrainTemplates reads persisted Drain templates for the supplied tenant
547+
// and returns them in a format ready to pass to Drain.LoadTemplates. Returns
548+
// an empty slice (and nil error) if no rows match.
549+
func LoadDrainTemplates(db *gorm.DB, tenant string) ([]Template, error) {
541550
if db == nil {
542551
return nil, nil
543552
}
553+
if tenant == "" {
554+
tenant = storage.DefaultTenantID
555+
}
544556
var rows []DrainTemplateRow
545-
if err := db.Find(&rows).Error; err != nil {
557+
if err := db.Where("tenant_id = ?", tenant).Find(&rows).Error; err != nil {
546558
return nil, err
547559
}
548560
out := make([]Template, 0, len(rows))

internal/graphrag/drain_persist_test.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"testing"
66
"time"
77

8+
"github.com/RandomCodeSpace/otelcontext/internal/storage"
89
"github.com/glebarez/sqlite"
910
"gorm.io/gorm"
1011
)
@@ -50,12 +51,12 @@ func TestDrainPersistence_RoundTrip(t *testing.T) {
5051
}
5152

5253
// Persist.
53-
if err := SaveDrainTemplates(db, d1.Templates()); err != nil {
54+
if err := SaveDrainTemplates(db, storage.DefaultTenantID, d1.Templates()); err != nil {
5455
t.Fatalf("SaveDrainTemplates: %v", err)
5556
}
5657

5758
// Reload.
58-
loaded, err := LoadDrainTemplates(db)
59+
loaded, err := LoadDrainTemplates(db, storage.DefaultTenantID)
5960
if err != nil {
6061
t.Fatalf("LoadDrainTemplates: %v", err)
6162
}
@@ -91,7 +92,7 @@ func TestDrainPersistence_Upsert(t *testing.T) {
9192
for i := 0; i < 10; i++ {
9293
d.Match(fmt.Sprintf("upsert kind %c event", 'a'+byte(i)), t0)
9394
}
94-
if err := SaveDrainTemplates(db, d.Templates()); err != nil {
95+
if err := SaveDrainTemplates(db, storage.DefaultTenantID, d.Templates()); err != nil {
9596
t.Fatalf("first save: %v", err)
9697
}
9798

@@ -108,7 +109,7 @@ func TestDrainPersistence_Upsert(t *testing.T) {
108109
for i := 0; i < 10; i++ {
109110
d.Match(fmt.Sprintf("upsert kind %c event", 'a'+byte(i)), t1)
110111
}
111-
if err := SaveDrainTemplates(db, d.Templates()); err != nil {
112+
if err := SaveDrainTemplates(db, storage.DefaultTenantID, d.Templates()); err != nil {
112113
t.Fatalf("second save: %v", err)
113114
}
114115

@@ -139,7 +140,7 @@ func TestDrainPersistence_Upsert(t *testing.T) {
139140
// returns an empty slice and nil error (fresh-install path).
140141
func TestDrainPersistence_EmptyDB(t *testing.T) {
141142
db := newTestDrainDB(t)
142-
tpls, err := LoadDrainTemplates(db)
143+
tpls, err := LoadDrainTemplates(db, storage.DefaultTenantID)
143144
if err != nil {
144145
t.Fatalf("LoadDrainTemplates on empty table: %v", err)
145146
}
@@ -148,7 +149,7 @@ func TestDrainPersistence_EmptyDB(t *testing.T) {
148149
}
149150

150151
// Calling with nil DB is also safe.
151-
tpls, err = LoadDrainTemplates(nil)
152+
tpls, err = LoadDrainTemplates(nil, storage.DefaultTenantID)
152153
if err != nil {
153154
t.Fatalf("LoadDrainTemplates(nil): %v", err)
154155
}
@@ -157,7 +158,7 @@ func TestDrainPersistence_EmptyDB(t *testing.T) {
157158
}
158159

159160
// Saving an empty slice is a no-op (no error, no rows).
160-
if err := SaveDrainTemplates(db, nil); err != nil {
161+
if err := SaveDrainTemplates(db, storage.DefaultTenantID, nil); err != nil {
161162
t.Fatalf("SaveDrainTemplates(nil): %v", err)
162163
}
163164
var cnt int64

internal/graphrag/investigation.go

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"time"
1111

1212
"github.com/RandomCodeSpace/otelcontext/internal/storage"
13-
"gorm.io/gorm"
1413
)
1514

1615
// investigationCooldown suppresses repeated PersistInvestigation calls with
@@ -43,13 +42,14 @@ func (c *investigationCooldown) allow(key string, now time.Time) bool {
4342
}
4443

4544
// cooldownKey builds a case- and whitespace-insensitive key from the tuple
46-
// (trigger_service, root_service, root_operation). Service names emitted
47-
// from different instrumentations occasionally differ in casing or have
48-
// trailing whitespace; canonicalizing here prevents those variants from
49-
// bypassing the cooldown guard.
50-
func cooldownKey(triggerService, rootService, rootOperation string) string {
45+
// (tenant, trigger_service, root_service, root_operation). Tenant scopes the
46+
// guard so an error in tenant-A doesn't suppress the same error pattern in
47+
// tenant-B. Service names emitted from different instrumentations occasionally
48+
// differ in casing or have trailing whitespace; canonicalizing here prevents
49+
// those variants from bypassing the cooldown guard.
50+
func cooldownKey(tenant, triggerService, rootService, rootOperation string) string {
5151
norm := func(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
52-
return norm(triggerService) + "|" + norm(rootService) + "|" + norm(rootOperation)
52+
return norm(tenant) + "|" + norm(triggerService) + "|" + norm(rootService) + "|" + norm(rootOperation)
5353
}
5454

5555
// prune drops entries older than cutoff to bound map size. Called from
@@ -65,9 +65,14 @@ func (c *investigationCooldown) prune(cutoff time.Time) {
6565
}
6666

6767
// Investigation is a persisted record of an automated error investigation.
68+
//
69+
// TenantID scopes the row to its originating tenant. The composite
70+
// (tenant_id, created_at) index supports the recency-ordered "investigations
71+
// for tenant X" query that GetInvestigations runs on every read.
6872
type Investigation struct {
73+
TenantID string `gorm:"size:64;default:'default';not null;index:idx_investigations_tenant_created,priority:1" json:"tenant_id"`
6974
ID string `gorm:"primaryKey;size:64" json:"id"`
70-
CreatedAt time.Time `json:"created_at"`
75+
CreatedAt time.Time `gorm:"index:idx_investigations_tenant_created,priority:2" json:"created_at"`
7176
Status string `gorm:"size:20" json:"status"` // detected, triaged, resolved
7277
Severity string `gorm:"size:20" json:"severity"` // critical, warning, info
7378
TriggerService string `gorm:"size:255;index" json:"trigger_service"`
@@ -88,20 +93,17 @@ func (Investigation) TableName() string {
8893
return "investigations"
8994
}
9095

91-
// AutoMigrateGraphRAG runs GORM auto-migration for GraphRAG models.
92-
func AutoMigrateGraphRAG(db *gorm.DB) error {
93-
return db.AutoMigrate(&Investigation{}, &GraphSnapshot{}, &DrainTemplateRow{})
94-
}
95-
9696
// PersistInvestigation saves an investigation record from an error chain
9797
// analysis. Tenant is accepted explicitly so the caller (the per-tenant
98-
// anomaly loop) can re-enter ImpactAnalysis on the correct tenant slice. The
99-
// `investigations` table itself does not yet carry a tenant_id column —
100-
// Subtask B (RAN-38) owns that migration.
98+
// anomaly loop) can re-enter ImpactAnalysis on the correct tenant slice and so
99+
// the persisted row carries its originating tenant_id.
101100
func (g *GraphRAG) PersistInvestigation(tenant, triggerService string, chains []ErrorChainResult, anomalies []*AnomalyNode) {
102101
if len(chains) == 0 {
103102
return
104103
}
104+
if tenant == "" {
105+
tenant = storage.DefaultTenantID
106+
}
105107

106108
firstChain := chains[0]
107109
if firstChain.RootCause == nil {
@@ -111,10 +113,12 @@ func (g *GraphRAG) PersistInvestigation(tenant, triggerService string, chains []
111113
now := time.Now()
112114

113115
// Cooldown: suppress repeat investigations for the same
114-
// (trigger_service, root_service, root_operation) inside a sliding window.
115-
// Keys are canonicalized (lower + trim) so "Orders" and "orders " share a
116-
// bucket — otherwise trivial casing differences would bypass the guard.
117-
key := cooldownKey(triggerService, firstChain.RootCause.Service, firstChain.RootCause.Operation)
116+
// (tenant, trigger_service, root_service, root_operation) inside a sliding
117+
// window. Keys are canonicalized (lower + trim) so "Orders" and "orders "
118+
// share a bucket — otherwise trivial casing differences would bypass the
119+
// guard. Tenant scoping prevents an error in one tenant from suppressing
120+
// the same pattern in another.
121+
key := cooldownKey(tenant, triggerService, firstChain.RootCause.Service, firstChain.RootCause.Operation)
118122
if g.invCooldown != nil && !g.invCooldown.allow(key, now) {
119123
return
120124
}
@@ -180,6 +184,7 @@ func (g *GraphRAG) PersistInvestigation(tenant, triggerService string, chains []
180184
}
181185

182186
inv := Investigation{
187+
TenantID: tenant,
183188
ID: id,
184189
CreatedAt: now,
185190
Status: "detected",
@@ -202,23 +207,30 @@ func (g *GraphRAG) PersistInvestigation(tenant, triggerService string, chains []
202207
return
203208
}
204209
if err := g.repo.DB().Create(&inv).Error; err != nil {
205-
slog.Error("Failed to persist investigation", "error", err)
210+
slog.Error("Failed to persist investigation", "tenant", tenant, "error", err)
206211
return
207212
}
208213

209-
slog.Info("Investigation persisted", "id", id, "service", triggerService, "severity", severity)
214+
slog.Info("Investigation persisted", "id", id, "tenant", tenant, "service", triggerService, "severity", severity)
210215
}
211216

212-
// GetInvestigations queries persisted investigations.
213-
func (g *GraphRAG) GetInvestigations(service, severity, status string, limit int) ([]Investigation, error) {
217+
// GetInvestigations queries persisted investigations scoped to the tenant
218+
// carried by ctx. The composite (tenant_id, created_at) index supports the
219+
// recency-ordered scan.
220+
func (g *GraphRAG) GetInvestigations(ctx context.Context, service, severity, status string, limit int) ([]Investigation, error) {
214221
if limit <= 0 {
215222
limit = 20
216223
}
217224
if limit > 100 {
218225
limit = 100
219226
}
220227

221-
db := g.repo.DB().Model(&Investigation{}).Order("created_at DESC").Limit(limit)
228+
tenant := storage.TenantFromContext(ctx)
229+
db := g.repo.DB().
230+
Model(&Investigation{}).
231+
Where("tenant_id = ?", tenant).
232+
Order("created_at DESC").
233+
Limit(limit)
222234
if service != "" {
223235
db = db.Where("trigger_service = ? OR root_service = ?", service, service)
224236
}
@@ -236,10 +248,13 @@ func (g *GraphRAG) GetInvestigations(service, severity, status string, limit int
236248
return investigations, nil
237249
}
238250

239-
// GetInvestigation retrieves a single investigation by ID.
240-
func (g *GraphRAG) GetInvestigation(id string) (*Investigation, error) {
251+
// GetInvestigation retrieves a single investigation by ID, scoped to the
252+
// tenant carried by ctx. Returning ErrRecordNotFound for cross-tenant lookups
253+
// prevents id-guessing from leaking another tenant's row.
254+
func (g *GraphRAG) GetInvestigation(ctx context.Context, id string) (*Investigation, error) {
255+
tenant := storage.TenantFromContext(ctx)
241256
var inv Investigation
242-
if err := g.repo.DB().Where("id = ?", id).First(&inv).Error; err != nil {
257+
if err := g.repo.DB().Where("tenant_id = ? AND id = ?", tenant, id).First(&inv).Error; err != nil {
243258
return nil, err
244259
}
245260
return &inv, nil

internal/graphrag/investigation_cooldown_test.go

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,30 @@ func TestPersistInvestigation_Cooldown(t *testing.T) {
4646
}
4747

4848
// TestCooldownKey_Canonical verifies the key normalizes case and trims
49-
// whitespace so "Orders" / "orders " / "ORDERS" land in the same bucket.
49+
// whitespace so "Orders" / "orders " / "ORDERS" land in the same bucket
50+
// within a tenant.
5051
func TestCooldownKey_Canonical(t *testing.T) {
51-
cases := [][3]string{
52-
{"orders", "orders", "op"},
53-
{"Orders", "ORDERS", "op"},
54-
{" orders ", "orders", " op "},
55-
{"ORDERS", "Orders ", "OP"},
52+
cases := [][4]string{
53+
{"acme", "orders", "orders", "op"},
54+
{"Acme", "Orders", "ORDERS", "op"},
55+
{" acme ", " orders ", "orders", " op "},
56+
{"ACME", "ORDERS", "Orders ", "OP"},
5657
}
57-
want := cooldownKey(cases[0][0], cases[0][1], cases[0][2])
58+
want := cooldownKey(cases[0][0], cases[0][1], cases[0][2], cases[0][3])
5859
for _, c := range cases[1:] {
59-
if got := cooldownKey(c[0], c[1], c[2]); got != want {
60+
if got := cooldownKey(c[0], c[1], c[2], c[3]); got != want {
6061
t.Errorf("cooldownKey%v = %q, want %q", c, got, want)
6162
}
6263
}
6364
}
65+
66+
// TestCooldownKey_TenantIsolated asserts that two tenants emitting the same
67+
// (trigger, root, op) tuple produce distinct cooldown keys, so an error in
68+
// tenant-A doesn't suppress the same pattern in tenant-B.
69+
func TestCooldownKey_TenantIsolated(t *testing.T) {
70+
a := cooldownKey("tenant-a", "orders", "orders", "op")
71+
b := cooldownKey("tenant-b", "orders", "orders", "op")
72+
if a == b {
73+
t.Fatalf("tenant scoping missing: tenant-a and tenant-b share key %q", a)
74+
}
75+
}

0 commit comments

Comments
 (0)