Persist kagane covers in Postgres

This commit is contained in:
2026-08-09 06:43:28 +07:00
parent cd3a7e3d01
commit e1a0cb7fc3
6 changed files with 127 additions and 67 deletions
+17 -51
View File
@@ -5,7 +5,6 @@ import (
"log"
"net/http"
"regexp"
"sync"
"time"
)
@@ -39,40 +38,6 @@ var coverTypes = map[string]bool{
// holding a connection open.
const coverTimeout = 20 * time.Second
// coverCacheMax caps the in-memory cover cache. Covers are immutable per image
// id and a library holds tens of series, so this is a ceiling that is never
// reached in practice; reaching it clears the map rather than evicting by age.
//
// ponytail: flush-on-full, not LRU. Swap it for an LRU if a library ever grows
// past this and the flush starts costing refetches.
const coverCacheMax = 500
type cachedCover struct {
body []byte
contentType string
}
type coverCache struct {
mu sync.Mutex
m map[string]cachedCover
}
func (c *coverCache) get(id string) (cachedCover, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.m[id]
return v, ok
}
func (c *coverCache) put(id string, v cachedCover) {
c.mu.Lock()
defer c.mu.Unlock()
if c.m == nil || len(c.m) >= coverCacheMax {
c.m = make(map[string]cachedCover, coverCacheMax)
}
c.m[id] = v
}
// kaganeCover serves a kagane cover from the backend's own origin.
//
// kagane answers image requests with a Cloudflare challenge and
@@ -81,23 +46,22 @@ func (c *coverCache) put(id string, v cachedCover) {
// (verified 2026-08-08). Fetching it through the headless browser that already
// clears the challenge, and re-serving it here, is what puts the bytes on an
// origin the page may load from.
//
// ponytail: covers are fetched on first view, one browser navigation at a time
// behind the fetcher's mutex, so a first load of a large kagane library
// trickles in over a few seconds. The cache makes it a one-off. Prefetching
// during the poll cycle is the upgrade if that ever grates.
func (h *Handler) kaganeCover(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !coverIDRe.MatchString(id) {
http.NotFound(w, r)
return
}
if h.covers == nil {
http.NotFound(w, r)
if body, contentType, ok, err := h.store.GetKaganeCover(id); err != nil {
log.Printf("read kagane cover %s: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
} else if ok {
writeCover(w, body, contentType)
return
}
if v, ok := h.coverCache.get(id); ok {
writeCover(w, v)
if h.covers == nil {
http.NotFound(w, r)
return
}
@@ -114,16 +78,18 @@ func (h *Handler) kaganeCover(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
v := cachedCover{body: body, contentType: contentType}
h.coverCache.put(id, v)
writeCover(w, v)
if err := h.store.PutKaganeCover(id, body, contentType); err != nil {
log.Printf("persist kagane cover %s: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeCover(w, body, contentType)
}
// writeCover sends the bytes with a long cache life: an image id names one
// immutable rendering, so a client that has it never needs to ask again.
func writeCover(w http.ResponseWriter, v cachedCover) {
w.Header().Set("Content-Type", v.contentType)
func writeCover(w http.ResponseWriter, body []byte, contentType string) {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "private, max-age=604800, immutable")
w.Write(v.body)
w.Write(body)
}