package search import ( "context" "errors" "fmt" "log" "sync" "munin/internal/store" ) // Registry hält je Projekt einen Suchindex. // // # Warum getrennte Indizes statt einem großen? // // Ein gemeinsamer Index mit Projektfilter wäre möglich, hätte aber zwei Nachteile: // Die BM25-Werte (Dokumentanzahl, Durchschnittslänge, Seltenheit eines Begriffs) // würden über Projektgrenzen hinweg berechnet — ein Begriff, der in Projekt A // häufig ist, wäre dann auch in Projekt B abgewertet. Und ein Programmierfehler // beim Filtern würde Inhalte quer über Projekte hinweg sichtbar machen. // // Getrennte Indizes machen die Abschottung strukturell statt zu einer Frage der // richtigen WHERE-Klausel. type Registry struct { store *store.Store // mu schützt nur die Zuordnung Projekt → Fach und wird immer nur kurz // gehalten. Die eigentliche Arbeit (Aufbau, Aktualisierung) läuft unter // der Sperre des jeweiligen Fachs. mu sync.Mutex slots map[string]*slot // Schlüssel: Projekt-ID // afterRead wird nur von Tests gesetzt: Es läuft beim Aufbau zwischen // dem Lesen der Einträge und dem Befüllen des Index. So lässt sich ein // langsamer Aufbau nachstellen. afterRead func(projectID string) } // slot hält den Index eines einzelnen Projekts. // // # Warum eine Sperre je Projekt? // // Früher gab es nur eine Sperre für alle Projekte des Workspace. Der erste // Zugriff auf ein großes Projekt nach einem Neustart liest alle Einträge ein // (etwa 170 ms je 10.000 Einträge) — und so lange mussten auch alle anderen // Projekte desselben Workspace warten, selbst wenn ihr Index längst fertig war. // // Mit einer Sperre je Projekt wartet nur, wer auf genau dieses Projekt // zugreift. Das muss er auch: Ohne fertigen Index gibt es nichts zu suchen. type slot struct { mu sync.Mutex idx *Index // nil, solange der Index noch nicht (oder nicht mehr) aufgebaut ist } // curatedBoost bevorzugt kuratierte Einträge in der Suche: Ihr Wert wird mit // diesem Faktor malgenommen. // // Kuratiert heißt: Ein Mensch hat den Inhalt geprüft. Bei sonst ähnlicher // Relevanz soll ein Agent zuerst das geprüfte Wissen sehen. Der Faktor ist // bewusst klein — ein kuratierter Eintrag, der nur am Rande passt, soll einen // genau passenden ungeprüften nicht verdrängen. const curatedBoost = 1.2 // boostFor gibt den Suchfaktor eines Eintrags zurück. func boostFor(e store.Entry) float64 { if e.Curated { return curatedBoost } return 1 } // NewRegistry erstellt eine leere Registry. Die Indizes entstehen beim ersten // Zugriff auf ein Projekt (siehe For). func NewRegistry(s *store.Store) *Registry { return &Registry{ store: s, slots: make(map[string]*slot), } } // slotFor gibt das Fach eines Projekts zurück und legt es bei Bedarf an — // zunächst leer, aufgebaut wird erst in For. func (r *Registry) slotFor(projectID string) *slot { r.mu.Lock() defer r.mu.Unlock() f, ok := r.slots[projectID] if !ok { f = &slot{} r.slots[projectID] = f } return f } // existingSlot gibt das Fach eines Projekts zurück, ohne eines anzulegen. func (r *Registry) existingSlot(projectID string) (*slot, bool) { r.mu.Lock() defer r.mu.Unlock() f, ok := r.slots[projectID] return f, ok } // For gibt den Index eines Projekts zurück und baut ihn beim ersten Zugriff auf. // // Der verzögerte Aufbau hat einen praktischen Grund: Beim Serverstart alle // Projekte einzulesen würde bei vielen Projekten den Start ausbremsen, obwohl // die meisten davon in dieser Laufzeit vielleicht nie angefragt werden. So zahlt // nur der erste Zugriff je Projekt. // // Greifen mehrere gleichzeitig auf ein Projekt ohne Index zu, baut ihn nur der // erste auf. Die anderen warten an der Sperre des Fachs und bekommen danach // den fertigen Index. func (r *Registry) For(ctx context.Context, projectID string) (*Index, error) { f := r.slotFor(projectID) f.mu.Lock() defer f.mu.Unlock() if f.idx != nil { return f.idx, nil } idx := NewIndex() if err := r.fill(ctx, idx, projectID); err != nil { // Fach bleibt leer, der nächste Zugriff versucht es erneut. return nil, err } f.idx = idx return idx, nil } // fill liest alle Einträge eines Projekts und nimmt sie in den Index auf. func (r *Registry) fill(ctx context.Context, idx *Index, projectID string) error { entries, err := r.store.AllEntries(ctx, projectID) if err != nil { return fmt.Errorf("Suchindex konnte nicht aufgebaut werden: %w", err) } if r.afterRead != nil { r.afterRead(projectID) } for _, e := range entries { idx.IndexWithBoost(e.Slug, e.Title, e.Content, e.Tags, boostFor(e)) } return nil } // Refresh bringt einen Eintrag im Index auf den Stand der Datenbank — nach // jedem Schreiben, Löschen oder Wiederherstellen aufrufen, nachdem die // Änderung gespeichert ist. // // Existiert für das Projekt noch kein Index, passiert nichts — er wird beim // ersten Zugriff ohnehin vollständig aufgebaut. // // # Warum aus der Datenbank lesen statt den geschriebenen Stand übernehmen? // // Speichern und Index aktualisieren sind zwei getrennte Schritte. Schreiben // zwei Agenten gleichzeitig denselben Eintrag, können sie sich dazwischen // überholen: Fassung 3 ist gespeichert, aber die Meldung für Fassung 2 kommt // als letzte beim Index an. Früher übernahm der Index den mitgegebenen Stand // und hielt dann bis zum Neustart Fassung 2. Genauso konnte ein gerade // gelöschter Eintrag durch eine verspätete Meldung wieder auftauchen. // // Hier wird stattdessen unter der Sperre nachgelesen, was jetzt gespeichert // ist. Der letzte Aufruf liest damit immer den neuesten Stand, egal in welcher // Reihenfolge die Aufrufe ankommen. Das kostet eine kleine Leseabfrage je // Schreibvorgang. func (r *Registry) Refresh(ctx context.Context, projectID, slug string) { f, ok := r.existingSlot(projectID) if !ok { return } // Läuft gerade der Aufbau dieses Projekts, wartet Refresh hier, bis er // fertig ist. Das ist wichtig: Der Aufbau hat die Einträge womöglich vor // dieser Änderung gelesen. Erst danach nachzulesen holt sie sicher nach. f.mu.Lock() defer f.mu.Unlock() if f.idx == nil { return } // WithoutCancel: Die Änderung ist schon gespeichert. Bricht der Client die // Verbindung jetzt ab, muss der Index trotzdem nachziehen — sonst würde // der Abbruch als Lesefehler gelten und den ganzen Index verwerfen. e, err := r.store.EntryBySlug(context.WithoutCancel(ctx), projectID, slug) switch { case errors.Is(err, store.ErrNotFound): // Gelöscht oder im Papierkorb: gehört nicht in die Suche. f.idx.Remove(slug) case err != nil: // Der aktuelle Stand ist unbekannt. Statt zu raten, den ganzen Index // des Projekts verwerfen — der nächste Zugriff baut ihn aus der // Datenbank neu auf. log.Printf("Suchindex: %q in Projekt %s nicht lesbar, Index wird neu aufgebaut: %v", slug, projectID, err) f.idx = nil default: f.idx.IndexWithBoost(e.Slug, e.Title, e.Content, e.Tags, boostFor(e)) } } // Forget wirft den Index eines Projekts weg — nach dem Löschen des Projekts. func (r *Registry) Forget(projectID string) { r.mu.Lock() defer r.mu.Unlock() delete(r.slots, projectID) }