go-wiki/handler/cache.go
2026-07-14 20:46:19 +02:00

166 lines
6 KiB
Go

// Dieses File implementiert einen In-Memory-Cache für Wiki-Seiten.
//
// # Warum ein Cache?
//
// Ohne Cache liest der Server bei jedem Request jede .md-Datei vom Dateisystem
// und parst das Frontmatter neu. Bei list_pages bedeutet das: alle Dateien öffnen,
// YAML parsen, schließen — für jede Anfrage. Mit vielen Seiten wird das spürbar.
//
// Der Cache löst das: beim Serverstart werden alle Seiten einmal geladen und im
// Arbeitsspeicher gehalten. Danach sind Metadaten (Titel, Tags, Hidden) sofort
// verfügbar ohne Dateisystem-Zugriff.
//
// # Nebenläufigkeit (Concurrency)
//
// Go-Server bearbeiten mehrere HTTP-Requests gleichzeitig in sogenannten Goroutinen
// (leichtgewichtige Threads). Das bedeutet: zwei Requests könnten gleichzeitig den
// Cache lesen — das ist kein Problem. Aber wenn gleichzeitig einer liest und einer
// schreibt, kann es zu Datenverlust oder Abstürzen kommen (Race Condition).
//
// sync.RWMutex (Read-Write-Mutex) löst das:
// - Lesen (RLock): Mehrere Goroutinen dürfen gleichzeitig lesen
// - Schreiben (Lock): Nur eine Goroutine darf schreiben, alle anderen warten
package handler
import (
"os"
"path/filepath"
"strings"
"sync"
"wiki/render"
)
// CachedEntry ist ein einzelner Eintrag im Cache — eine geladene Seite mit ihrem URL-Pfad.
type CachedEntry struct {
URLPath string // z.B. "/projekte/notizen"
Page *render.Page // die geladene Seite mit Frontmatter
}
// PageCache hält alle Wiki-Seiten im Arbeitsspeicher.
type PageCache struct {
mu sync.RWMutex // schützt entries vor gleichzeitigem Lesen+Schreiben
entries map[string]*render.Page // key: URL-Pfad wie "/projekte/notizen"
docsRoot string // Pfad zum docs-Ordner, wird für Pfadumrechnung gebraucht
SearchIndex *SearchIndex // BM25-Volltextindex — wird parallel zum Cache aktuell gehalten
}
// NewPageCache erstellt einen neuen Cache und befüllt ihn sofort mit allen
// vorhandenen .md-Dateien im docsRoot-Ordner.
// Gleichzeitig wird der BM25-Suchindex initial aufgebaut.
func NewPageCache(docsRoot string) (*PageCache, error) {
c := &PageCache{
entries: make(map[string]*render.Page),
docsRoot: docsRoot,
SearchIndex: NewSearchIndex(),
}
// Beim Start einmal alle Seiten laden — befüllt Cache und Suchindex gemeinsam.
if err := c.build(); err != nil {
return nil, err
}
return c, nil
}
// build geht rekursiv durch den docs-Ordner und lädt alle .md-Dateien in den Cache.
// Gleichzeitig werden alle Seiten in den BM25-Suchindex aufgenommen.
// Wird nur einmal beim Start aufgerufen — danach werden Einträge einzeln aktualisiert.
func (c *PageCache) build() error {
return filepath.Walk(c.docsRoot, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".md") {
return nil
}
page, err := render.LoadPage(path)
if err != nil {
return nil // fehlerhafte Datei überspringen, nicht abbrechen
}
urlPath := c.FilePathToURLPath(path)
// Kein Lock nötig hier, weil build() nur einmal vor dem ersten Request läuft
c.entries[urlPath] = page
// Seite in den Suchindex aufnehmen — Hidden-Seiten werden mitindexiert,
// aber search_pages filtert sie beim Anzeigen heraus.
c.SearchIndex.Index(urlPath, page.Title, page.RawBody, page.Tags)
return nil
})
}
// Get liest eine Seite aus dem Cache anhand ihres URL-Pfads.
// Gibt (nil, false) zurück wenn die Seite nicht im Cache ist.
func (c *PageCache) Get(urlPath string) (*render.Page, bool) {
// RLock = Lesesperre: andere Goroutinen dürfen gleichzeitig lesen
c.mu.RLock()
defer c.mu.RUnlock() // Sperre am Funktionsende freigeben — defer läuft immer, auch bei return
page, ok := c.entries[urlPath]
return page, ok
}
// Set fügt eine Seite in den Cache ein oder überschreibt einen bestehenden Eintrag.
// Aktualisiert gleichzeitig den BM25-Suchindex.
// Wird nach einem erfolgreichen Upload aufgerufen.
func (c *PageCache) Set(urlPath string, page *render.Page) {
// Lock = exklusive Schreibsperre: alle anderen Goroutinen warten bis wir fertig sind
c.mu.Lock()
defer c.mu.Unlock()
c.entries[urlPath] = page
// Suchindex synchron aktualisieren — Index() ersetzt automatisch einen bestehenden Eintrag
c.SearchIndex.Index(urlPath, page.Title, page.RawBody, page.Tags)
}
// Delete entfernt eine Seite aus dem Cache und aus dem BM25-Suchindex.
// Wird nach einem erfolgreichen Delete aufgerufen.
func (c *PageCache) Delete(urlPath string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, urlPath)
c.SearchIndex.Remove(urlPath)
}
// All gibt eine Momentaufnahme aller Cache-Einträge zurück.
// Wir erstellen eine Kopie der Liste damit der Aufrufer sie ohne Sperre lesen kann.
// (Würde der Aufrufer direkt auf c.entries zugreifen, müsste er die Sperre halten.)
func (c *PageCache) All() []CachedEntry {
c.mu.RLock()
defer c.mu.RUnlock()
// make mit Kapazität = aktuelle Anzahl Einträge — vermeidet Speicher-Neuallokierungen
result := make([]CachedEntry, 0, len(c.entries))
for urlPath, page := range c.entries {
result = append(result, CachedEntry{URLPath: urlPath, Page: page})
}
return result
}
// FilePathToURLPath wandelt einen Dateisystem-Pfad in einen URL-Pfad um.
//
// data/docs/projekte/notizen.md → /projekte/notizen
// data/docs/index.md → /
func (c *PageCache) FilePathToURLPath(filePath string) string {
rel := strings.TrimPrefix(filePath, c.docsRoot)
rel = strings.TrimSuffix(rel, ".md")
if rel == "/index" {
return "/"
}
return rel
}
// URLPathToFilePath wandelt einen URL-Pfad in einen Dateisystem-Pfad um.
//
// /projekte/notizen → data/docs/projekte/notizen.md
// / → data/docs/index.md
func (c *PageCache) URLPathToFilePath(urlPath string) string {
if urlPath == "/" {
return filepath.Join(c.docsRoot, "index.md")
}
clean := strings.TrimPrefix(urlPath, "/")
// Falls der Pfad schon auf .md endet (z.B. vom MCP-Client), nicht doppelt anhängen.
clean = strings.TrimSuffix(clean, ".md")
return filepath.Join(c.docsRoot, clean+".md")
}