195 lines
6.5 KiB
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)
|
|
}
|