Persist kagane covers in Postgres (#49)
Closes #43 Persist kagane cover bytes in a dedicated Postgres covers table keyed by image ID. The web handler reads storage before the browser, writes validated fetches through, and no longer keeps an in-process cover cache. Added migration, store persistence tests including reopen, handler coverage for stored/miss/rejected paths, and corrected repository guidance. Verification: - go test ./... - CGO_ENABLED=0 go build ./... Reviewed-on: #49 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #49.
This commit is contained in:
@@ -5,14 +5,13 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CoverFetcher retrieves one kagane cover by image id. Satisfied by
|
||||
// latest.BrowserFetcher, and nil when BROWSER_WS_URL is unset — which leaves
|
||||
// kagane covers exactly as unavailable as they were before this endpoint
|
||||
// existed, rather than hanging a request on a fetcher that cannot run.
|
||||
// latest.BrowserFetcher. It is nil when BROWSER_WS_URL is unset; uncached
|
||||
// covers are then unavailable, while covers already stored by the backend
|
||||
// remain available without a browser.
|
||||
type CoverFetcher interface {
|
||||
Image(ctx context.Context, imageID string) (body []byte, contentType string, err error)
|
||||
}
|
||||
@@ -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,29 +46,30 @@ 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)
|
||||
body, contentType, ok, err := h.store.GetKaganeCover(id)
|
||||
if err != nil {
|
||||
log.Printf("read kagane cover %s: %v", id, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if v, ok := h.coverCache.get(id); ok {
|
||||
writeCover(w, v)
|
||||
if ok {
|
||||
writeCover(w, body, contentType)
|
||||
return
|
||||
}
|
||||
if h.covers == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), coverTimeout)
|
||||
defer cancel()
|
||||
body, contentType, err := h.covers.Image(ctx, id)
|
||||
body, contentType, err = h.covers.Image(ctx, id)
|
||||
if err != nil {
|
||||
log.Printf("kagane cover %s: %v", id, err)
|
||||
http.NotFound(w, r)
|
||||
@@ -114,16 +80,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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user