munin/internal/search/registry.go

195 lines
6.5 KiB
Go

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
faecher map[string]*fach // Schlüssel: Projekt-ID
// nachDemLesen 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.
nachDemLesen func(projectID string)
}
// fach 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 fach struct {
mu sync.Mutex
idx *Index // nil, solange der Index noch nicht (oder nicht mehr) aufgebaut ist
}
// 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,
faecher: make(map[string]*fach),
}
}
// fachFuer gibt das Fach eines Projekts zurück und legt es bei Bedarf an —
// zunächst leer, aufgebaut wird erst in For.
func (r *Registry) fachFuer(projectID string) *fach {
r.mu.Lock()
defer r.mu.Unlock()
f, ok := r.faecher[projectID]
if !ok {
f = &fach{}
r.faecher[projectID] = f
}
return f
}
// vorhandenesFach gibt das Fach eines Projekts zurück, ohne eines anzulegen.
func (r *Registry) vorhandenesFach(projectID string) (*fach, bool) {
r.mu.Lock()
defer r.mu.Unlock()
f, ok := r.faecher[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.fachFuer(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 {
eintraege, err := r.store.AllEntries(ctx, projectID)
if err != nil {
return fmt.Errorf("Suchindex konnte nicht aufgebaut werden: %w", err)
}
if r.nachDemLesen != nil {
r.nachDemLesen(projectID)
}
for _, e := range eintraege {
idx.Index(e.Slug, e.Title, e.Content, e.Tags)
}
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.vorhandenesFach(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.Index(e.Slug, e.Title, e.Content, e.Tags)
}
}
// 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.faecher, projectID)
}