Skip to content

Commit 5219232

Browse files
authored
Fix cache-miss stalls and move cache cleanup out of request path [minor] (#35)
1 parent c6cbd05 commit 5219232

16 files changed

Lines changed: 855 additions & 148 deletions

File tree

Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@ COPY . .
140140
RUN --mount=type=cache,target=/go/pkg/mod \
141141
--mount=type=cache,target=/root/.cache/go-build \
142142
CGO_ENABLED=1 go build -trimpath -ldflags='-s -w' -o /out/triplet ./cmd/triplet \
143-
&& CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/triplet-healthcheck ./cmd/triplet-healthcheck
143+
&& CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/triplet-healthcheck ./cmd/triplet-healthcheck \
144+
&& CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/triplet-cache-cleanup ./cmd/triplet-cache-cleanup
144145

145146
FROM base AS test-runner
146147
WORKDIR /app
@@ -223,6 +224,7 @@ COPY --chown=triplet:triplet deploy/compose/images/ /var/lib/triplet/testdata/im
223224

224225
COPY --from=build /out/triplet /usr/local/bin/triplet
225226
COPY --from=build /out/triplet-healthcheck /usr/local/bin/triplet-healthcheck
227+
COPY --from=build /out/triplet-cache-cleanup /usr/local/bin/triplet-cache-cleanup
226228
COPY config.example.yaml /etc/triplet/config.yaml
227229
RUN ldd /usr/local/bin/triplet >/dev/null
228230

cmd/triplet-cache-cleanup/main.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Command triplet-cache-cleanup performs explicit filesystem cache cleanup.
2+
package main
3+
4+
import (
5+
"context"
6+
"flag"
7+
"fmt"
8+
"io"
9+
"os"
10+
11+
"github.com/libops/triplet/internal/cache"
12+
"github.com/libops/triplet/internal/config"
13+
)
14+
15+
type namedReport struct {
16+
name string
17+
maxConfig string
18+
report cache.CleanupReport
19+
}
20+
21+
func main() {
22+
configPath := flag.String("config", "config.yaml", "path to the YAML config file")
23+
timeout := flag.Duration("timeout", 0, "optional cleanup timeout")
24+
flag.Parse()
25+
26+
cfg, err := config.Load(*configPath)
27+
if err != nil {
28+
_, _ = fmt.Fprintf(os.Stderr, "config: %v\n", err)
29+
os.Exit(2)
30+
}
31+
32+
ctx := context.Background()
33+
if *timeout > 0 {
34+
var cancel context.CancelFunc
35+
ctx, cancel = context.WithTimeout(ctx, *timeout)
36+
defer cancel()
37+
}
38+
39+
reports, err := cleanupCaches(ctx, cfg)
40+
if err != nil {
41+
_, _ = fmt.Fprintf(os.Stderr, "cache cleanup: %v\n", err)
42+
os.Exit(1)
43+
}
44+
if len(reports) == 0 {
45+
_, _ = fmt.Fprintln(os.Stdout, "no filesystem cache roots configured")
46+
return
47+
}
48+
49+
overMax := false
50+
for _, r := range reports {
51+
printReport(os.Stdout, r)
52+
if r.report.OverMaxBytes {
53+
overMax = true
54+
_, _ = fmt.Fprintf(os.Stderr, "%s cache remains over %s: bytes=%d max_bytes=%d\n", r.name, r.maxConfig, r.report.Bytes, r.report.MaxBytes)
55+
}
56+
}
57+
if overMax {
58+
os.Exit(1)
59+
}
60+
}
61+
62+
func cleanupCaches(ctx context.Context, cfg *config.Config) ([]namedReport, error) {
63+
var reports []namedReport
64+
if cfg.Cache.Root != "" {
65+
store, err := cache.NewPayloadFileStoreWithMaxAge(cfg.Cache.Root, int64(cfg.Cache.MaxBytes), cfg.Cache.MaxAge)
66+
if err != nil {
67+
return nil, fmt.Errorf("derivative cache: %w", err)
68+
}
69+
report, err := store.Cleanup(ctx)
70+
if err != nil {
71+
return nil, fmt.Errorf("derivative cache: %w", err)
72+
}
73+
reports = append(reports, namedReport{
74+
name: "derivative",
75+
maxConfig: "cache.max_bytes",
76+
report: report,
77+
})
78+
}
79+
if cfg.Cache.SourceRoot != "" {
80+
store, err := cache.NewFileStore(cfg.Cache.SourceRoot, int64(cfg.Cache.SourceMaxBytes))
81+
if err != nil {
82+
return nil, fmt.Errorf("source cache: %w", err)
83+
}
84+
report, err := store.Cleanup(ctx)
85+
if err != nil {
86+
return nil, fmt.Errorf("source cache: %w", err)
87+
}
88+
reports = append(reports, namedReport{
89+
name: "source",
90+
maxConfig: "cache.source_max_bytes",
91+
report: report,
92+
})
93+
}
94+
return reports, nil
95+
}
96+
97+
func printReport(out io.Writer, r namedReport) {
98+
_, _ = fmt.Fprintf(out,
99+
"%s cache root=%s scanned=%d removed=%d expired_removed=%d removed_bytes=%d bytes=%d max_bytes=%d over_max=%t\n",
100+
r.name,
101+
r.report.Root,
102+
r.report.Scanned,
103+
r.report.Removed,
104+
r.report.ExpiredRemoved,
105+
r.report.RemovedBytes,
106+
r.report.Bytes,
107+
r.report.MaxBytes,
108+
r.report.OverMaxBytes,
109+
)
110+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/libops/triplet/internal/cache"
13+
"github.com/libops/triplet/internal/config"
14+
)
15+
16+
func TestCleanupCachesRemovesExpiredDerivativeAndReportsOversize(t *testing.T) {
17+
derivRoot := t.TempDir()
18+
sourceRoot := t.TempDir()
19+
20+
derivStore, err := cache.NewPayloadFileStoreWithMaxAge(derivRoot, 0, time.Hour)
21+
if err != nil {
22+
t.Fatal(err)
23+
}
24+
if err := derivStore.Put(context.Background(), "old", "image/jpeg", strings.NewReader("old")); err != nil {
25+
t.Fatal(err)
26+
}
27+
oldFiles := payloadFiles(t, derivRoot)
28+
if len(oldFiles) != 1 {
29+
t.Fatalf("payload files after old put = %d, want 1", len(oldFiles))
30+
}
31+
oldTime := time.Now().Add(-2 * time.Hour)
32+
if err := os.Chtimes(oldFiles[0], oldTime, oldTime); err != nil {
33+
t.Fatal(err)
34+
}
35+
if err := derivStore.Put(context.Background(), "new", "image/jpeg", strings.NewReader("new")); err != nil {
36+
t.Fatal(err)
37+
}
38+
39+
sourceStore, err := cache.NewFileStore(sourceRoot, 0)
40+
if err != nil {
41+
t.Fatal(err)
42+
}
43+
if err := sourceStore.Put(context.Background(), "source", "image/tiff", strings.NewReader("source")); err != nil {
44+
t.Fatal(err)
45+
}
46+
47+
reports, err := cleanupCaches(context.Background(), &config.Config{
48+
Cache: config.Cache{
49+
Root: derivRoot,
50+
MaxAge: time.Hour,
51+
SourceRoot: sourceRoot,
52+
SourceMaxBytes: 1,
53+
},
54+
})
55+
if err != nil {
56+
t.Fatal(err)
57+
}
58+
59+
derivReport := reportByName(t, reports, "derivative")
60+
if derivReport.ExpiredRemoved != 1 {
61+
t.Fatalf("expired removed = %d, want 1", derivReport.ExpiredRemoved)
62+
}
63+
if derivReport.Removed != 1 {
64+
t.Fatalf("derivative removed = %d, want 1", derivReport.Removed)
65+
}
66+
if got := len(payloadFiles(t, derivRoot)); got != 1 {
67+
t.Fatalf("derivative payload files = %d, want 1", got)
68+
}
69+
70+
sourceReport := reportByName(t, reports, "source")
71+
if !sourceReport.OverMaxBytes {
72+
t.Fatal("expected source cache to report over max bytes")
73+
}
74+
if sourceReport.Bytes != int64(len("source")) {
75+
t.Fatalf("source bytes = %d, want %d", sourceReport.Bytes, len("source"))
76+
}
77+
}
78+
79+
func TestCleanupCachesSkipsUnconfiguredRoots(t *testing.T) {
80+
reports, err := cleanupCaches(context.Background(), &config.Config{})
81+
if err != nil {
82+
t.Fatal(err)
83+
}
84+
if len(reports) != 0 {
85+
t.Fatalf("reports = %d, want 0", len(reports))
86+
}
87+
}
88+
89+
func reportByName(t *testing.T, reports []namedReport, name string) cache.CleanupReport {
90+
t.Helper()
91+
for _, report := range reports {
92+
if report.name == name {
93+
return report.report
94+
}
95+
}
96+
t.Fatalf("missing %s report", name)
97+
return cache.CleanupReport{}
98+
}
99+
100+
func payloadFiles(t *testing.T, root string) []string {
101+
t.Helper()
102+
var out []string
103+
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
104+
if err != nil {
105+
return err
106+
}
107+
if d.IsDir() || filepath.Ext(path) == ".meta" || strings.HasPrefix(d.Name(), ".tmp-") {
108+
return nil
109+
}
110+
out = append(out, path)
111+
return nil
112+
})
113+
if err != nil && !errors.Is(err, os.ErrNotExist) {
114+
t.Fatal(err)
115+
}
116+
return out
117+
}

config.example.yaml

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -185,18 +185,19 @@ cache:
185185
root: /var/lib/triplet/cache
186186
# Best-effort aggregate size target for all cached derivative payload files
187187
# under cache.root. This controls retained cache footprint over time, not the
188-
# size of any single generated response. A write may temporarily exceed this
189-
# target before eviction runs, and metadata sidecar files are not counted.
190-
# 0 disables size-based eviction.
188+
# size of any single generated response. The server does not prune for size
189+
# in the request path; run triplet-cache-cleanup periodically to report when
190+
# this target is exceeded. Metadata sidecar files are not counted. 0 disables
191+
# size reporting.
191192
max_bytes: 500GiB
192-
# Optional age limit for derivative entries. Expired entries are removed on
193-
# read and opportunistically during writes. 0 disables age-based eviction.
193+
# Optional age limit for derivative entries. Expired entries miss on read and
194+
# are removed by triplet-cache-cleanup. 0 disables age-based cleanup.
194195
max_age: 720h
195196
# Optional filesystem source cache for fetched source bytes (primarily HTTP
196197
# identifiers).
197198
# source_root: /var/lib/triplet/source-cache
198-
# Best-effort eviction target for the source cache. 0 disables size-based
199-
# eviction.
199+
# Best-effort reporting target for the source cache. The cleanup command
200+
# reports when this target is exceeded. 0 disables size reporting.
200201
source_max_bytes: 1GiB
201202
# When non-zero, stale source-cache hits are served immediately while a
202203
# background refresh fetches a fresh copy for later requests.

docs/caching.md

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,35 @@ cache:
1919
max_age: 720h
2020
```
2121
22-
`max_bytes` is a best-effort filesystem eviction target. `max_age` is an
23-
optional age limit for derivative entries. Failed transforms and HTTP error
24-
responses are not stored.
22+
`max_bytes` is a best-effort filesystem size target. `max_age` is an optional
23+
age limit for derivative entries. Failed transforms and HTTP error responses
24+
are not stored.
2525

2626
`cache.max_bytes` is the approximate total retained size of derivative payload
2727
files under `cache.root`. It is different from
2828
`iiif.image.max_derivative_bytes`, which limits one generated response before it
29-
can be returned or cached. A cache write can temporarily exceed `cache.max_bytes`
30-
before eviction runs. When size eviction runs, Triplet removes the oldest
31-
derivative payload files first based on payload file modification time; reads
32-
do not refresh cache age.
29+
can be returned or cached. Cache writes do not walk or prune the cache tree in
30+
the server request path.
3331

3432
`cache.max_age` is based on the derivative payload file modification time, not
3533
when it was last requested. When a cached derivative is older than `max_age`,
36-
Triplet removes it and treats the request as a cache miss. Expired entries are
37-
also removed opportunistically when new entries are written. Set `max_age: 0`
38-
or omit it to keep derivative files until size eviction, manual deletion,
39-
invalidation, or cache-key changes make them unused.
34+
Triplet treats the request as a cache miss. Set `max_age: 0` or omit it to keep
35+
derivative files until manual deletion, invalidation, or cache-key changes make
36+
them unused.
37+
38+
Run `triplet-cache-cleanup` periodically from cron, systemd timers, Kubernetes
39+
CronJobs, or a similar scheduler:
40+
41+
```sh
42+
triplet-cache-cleanup -config /etc/triplet/config.yaml
43+
```
44+
45+
The cleanup command reads the same YAML configuration as the server. It removes
46+
derivative cache entries older than `cache.max_age`, then measures the remaining
47+
derivative cache size. It also measures the source cache when `cache.source_root`
48+
is configured. If a cache remains above `cache.max_bytes` or
49+
`cache.source_max_bytes`, the command reports that condition and exits non-zero;
50+
it does not delete live entries solely to satisfy a size target.
4051

4152
### Derivative invalidation
4253

@@ -153,7 +164,7 @@ derivative and source caches.
153164

154165
| Layer | Configuration | What is cached | Invalidation / freshness |
155166
|---|---|---|---|
156-
| Derivative cache | `cache.root`; optional `cache.max_bytes`, `cache.max_age`, `iiif.image.cache_invalidation_token` | Encoded IIIF image responses, keyed by identifier, source version, invalidation marker, region, size, rotation, quality, and format. | A changed source version produces a new key. The protected invalidation route bumps the per-identifier invalidation marker. `cache.max_bytes` is a best-effort aggregate cache budget; `cache.max_age` removes derivative entries older than the configured duration. `iiif.image.max_derivative_bytes` is the per-response size limit before return/cache. Failed transforms and HTTP error responses are not stored. |
167+
| Derivative cache | `cache.root`; optional `cache.max_bytes`, `cache.max_age`, `iiif.image.cache_invalidation_token` | Encoded IIIF image responses, keyed by identifier, source version, invalidation marker, region, size, rotation, quality, and format. | A changed source version produces a new key. The protected invalidation route bumps the per-identifier invalidation marker. `cache.max_bytes` is a best-effort aggregate cache budget reported by `triplet-cache-cleanup`; `cache.max_age` is enforced on reads and by `triplet-cache-cleanup`. `iiif.image.max_derivative_bytes` is the per-response size limit before return/cache. Failed transforms and HTTP error responses are not stored. |
157168
| HTTP source cache | `cache.source_root`; optional `cache.source_max_bytes`, `cache.source_stale_after` | Original source bytes fetched through the HTTP source backend. | Keys are source identifiers. When `source_stale_after` is set, stale hits are served immediately and refreshed in the background. Upstream 4xx/5xx responses are not stored. |
158169
| HTTP metadata cache | `sources.http.metadata_cache_ttl` | Successful remote source metadata lookups for URL identifiers. | In-memory only. While fresh, derivative cache checks can avoid upstream metadata requests. This can serve stale derivatives until the TTL expires. |
159170
| `info.json` dimension cache | `iiif.image.info_dimension_cache` | Source dimensions used to build Image API `info.json`. | In-memory only. Entries are keyed by identifier plus source size/modtime metadata, so source changes with updated metadata miss the cache. |

0 commit comments

Comments
 (0)