Compare commits
2
Commits
e06192b7a5
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f9d624d74 | ||
|
|
a19cca2f34 |
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks loadtest
|
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks loadtest bench
|
||||||
|
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
LDFLAGS := -X main.Version=$(VERSION)
|
LDFLAGS := -X main.Version=$(VERSION)
|
||||||
@@ -64,6 +64,12 @@ docker-smoke:
|
|||||||
loadtest:
|
loadtest:
|
||||||
@script/loadtest
|
@script/loadtest
|
||||||
|
|
||||||
|
# Serve cache hits in parallel through the image handler and report hits per
|
||||||
|
# second (needs Go on the host after `script/bootstrap --cgo`, not Docker; a
|
||||||
|
# benchmark, not part of check)
|
||||||
|
bench:
|
||||||
|
@script/bench
|
||||||
|
|
||||||
# Build Docker image as `make docker` does, and also tag it pixa:$(VERSION)
|
# Build Docker image as `make docker` does, and also tag it pixa:$(VERSION)
|
||||||
docker-versioned:
|
docker-versioned:
|
||||||
@script/docker
|
@script/docker
|
||||||
|
|||||||
@@ -76,8 +76,7 @@ database and the disk cache:
|
|||||||
pixa runs as (uid 65532) and sets its mode to `750`; outside it, that user
|
pixa runs as (uid 65532) and sets its mode to `750`; outside it, that user
|
||||||
must be able to write the directory.
|
must be able to write the directory.
|
||||||
- `cache_max_bytes` limits the source and transformed images together. The
|
- `cache_max_bytes` limits the source and transformed images together. The
|
||||||
database, the metadata files, the `.meta` file beside each transformed image
|
database and files still being written come on top, and eviction runs in the
|
||||||
and files still being written come on top, and eviction runs in the
|
|
||||||
background, so the cache can pass the limit for a while: leave room on the
|
background, so the cache can pass the limit for a while: leave room on the
|
||||||
volume beyond it.
|
volume beyond it.
|
||||||
- Set `cache_max_bytes` for a lasting deployment. Its default, worked out each
|
- Set `cache_max_bytes` for a lasting deployment. Its default, worked out each
|
||||||
@@ -91,11 +90,12 @@ which answers 200 whenever pixa is running, in maintenance mode too (see
|
|||||||
|
|
||||||
On SIGTERM or SIGINT pixa stops accepting connections, gives the requests in
|
On SIGTERM or SIGINT pixa stops accepting connections, gives the requests in
|
||||||
progress and the images being processed 5 seconds to finish, then writes to the
|
progress and the images being processed 5 seconds to finish, then writes to the
|
||||||
database the counts of cache hits, misses, fetches and conversions that requests
|
database the counts of cache hits, misses, fetches and conversions and the times
|
||||||
could not write by their deadline, and exits: with 0, or with 1 when images were
|
cached images were last used that are not written yet (see Storage), and exits:
|
||||||
still being processed after those 5 seconds, some of those counts could not be
|
with 0, or with 1 when images were still being processed after those 5 seconds,
|
||||||
written, or another part of pixa failed to stop. A request not finished by then
|
some of those counts or times could not be written, or another part of pixa
|
||||||
is cut off. `docker stop` waits 10 seconds before it kills the container.
|
failed to stop. A request not finished by then is cut off. `docker stop` waits
|
||||||
|
10 seconds before it kills the container.
|
||||||
|
|
||||||
Outside Docker, pixa needs libvips (the image has 8.16) and libheif to run, as
|
Outside Docker, pixa needs libvips (the image has 8.16) and libheif to run, as
|
||||||
it uses libvips through CGO. pixad does not start unless libvips has its JPEG XL
|
it uses libvips through CGO. pixad does not start unless libvips has its JPEG XL
|
||||||
@@ -177,38 +177,46 @@ allowlisted source hosts for open access.
|
|||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
|
|
||||||
- **Source content**:
|
- **Source images**:
|
||||||
`<state_dir>/cache/sources/<ab>/<cd>/<sha256 of source content>`
|
`<state_dir>/cache/sources/<ab>/<cd>/<sha256 of host, path and query>`, one
|
||||||
- **Source metadata**:
|
file for each source URL
|
||||||
`<state_dir>/cache/metadata/<hostname>/<sha256 of path and query>.json` (host,
|
|
||||||
path and query, content hash, upstream status and headers, fetch time)
|
|
||||||
- **Database**: `<state_dir>/state.sqlite3` (SQLite)
|
- **Database**: `<state_dir>/state.sqlite3` (SQLite)
|
||||||
- **Transformed images**:
|
- **Transformed images**:
|
||||||
`<state_dir>/cache/variants/<ab>/<cd>/<sha256 of host, path, query, size, format, quality and fit>`,
|
`<state_dir>/cache/variants/<ab>/<cd>/<sha256 of host, path, query, size, format, quality and fit>`
|
||||||
each with a `.meta` file beside it holding its content type
|
|
||||||
|
|
||||||
`<ab>` and `<cd>` are the first and second pairs of characters of the file's
|
`<ab>` and `<cd>` are the first and second pairs of characters of the file's
|
||||||
name.
|
name.
|
||||||
|
|
||||||
Multiple source paths may reference the same content blob; the database tracks
|
Each source image and each transformed image has one row in the database,
|
||||||
references rather than using filesystem refcounting.
|
holding its size and when it was last used. Two source URLs that serve the same
|
||||||
|
bytes are stored twice, once under each URL. A source image is stored by writing
|
||||||
|
its file and then its row; when the row cannot be written, the file is removed
|
||||||
|
again.
|
||||||
|
|
||||||
Source and transformed image files, and the `.meta` file beside each transformed
|
Source and transformed image files are written under a temporary name, synced to
|
||||||
image, are written under a temporary name, synced to disk, and only then renamed
|
disk, and only then renamed into place, so a crash leaves no file under its own
|
||||||
into place, so a crash leaves no file under its own name with part of its data.
|
name with part of its data. When pixa starts, and every five minutes after, it
|
||||||
When pixa starts, and every five minutes after, it removes each cached source or
|
removes each cached source or transformed image whose file is empty, or not the
|
||||||
transformed image whose file is empty, or not the size the database records for
|
size the database records for it, with a warning naming the file; the next
|
||||||
it, with a warning naming the file; the next request for it fetches the source
|
request for it fetches the source or converts the image again. Looking for those
|
||||||
or converts the image again. Looking for those files reads the database once for
|
files reads the database once for every cached file, so pixa skips each
|
||||||
every cached file, so pixa skips each five-minute look that would start less
|
five-minute look that would start less than ten times as long after the last
|
||||||
than ten times as long after the last look ended as that look took. After a look
|
look ended as that look took. After a look that took one minute, the next one
|
||||||
that took one minute, the next one starts 10 to 15 minutes after that look
|
starts 10 to 15 minutes after that look ended.
|
||||||
ended.
|
|
||||||
|
|
||||||
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
|
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
|
||||||
Test). Toward it, pixa keeps in memory the content types of the 10,000
|
Test). Toward it, a cache hit opens the transformed image's file and nothing
|
||||||
transformed images most recently cached or served, so a cache hit on one of them
|
else, and makes no database call. The content type comes from the file's first
|
||||||
reads only the image file from disk and not the metadata file stored beside it.
|
bytes, which are those of the format pixa wrote it in; a file whose first bytes
|
||||||
|
are not those of a format pixa writes is not served, and the image is converted
|
||||||
|
again. The time the image was last served, which eviction orders by, and the hit
|
||||||
|
count are handed to a background writer without waiting for it, as are the
|
||||||
|
counts of misses, fetches and conversions, and the time a miss last used a
|
||||||
|
cached source image. Once a second that writer writes the waiting times in one
|
||||||
|
transaction and the counts in one statement. While 10,000 times of transformed
|
||||||
|
images, or 10,000 of source images, wait to be written, a further one is
|
||||||
|
dropped, and that image only looks less recently used to eviction.
|
||||||
|
`script/bench` measures how many hits pixa serves per second (see Load Test).
|
||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
@@ -846,6 +854,9 @@ provide:
|
|||||||
- `script/docker-smoke` — build the image, start it, wait for it to be healthy
|
- `script/docker-smoke` — build the image, start it, wait for it to be healthy
|
||||||
- `script/loadtest` — measure pixad's throughput, latency and peak memory; a
|
- `script/loadtest` — measure pixad's throughput, latency and peak memory; a
|
||||||
benchmark, not part of `script/check` (see Load Test)
|
benchmark, not part of `script/check` (see Load Test)
|
||||||
|
- `script/bench` — run the Go benchmark that serves cache hits in parallel
|
||||||
|
through the image handler, with Go on the host after `script/bootstrap --cgo`;
|
||||||
|
a benchmark, not part of `script/check` (see Load Test)
|
||||||
- `script/cibuild` — CI entrypoint: run `script/bootstrap` (without `--cgo`),
|
- `script/cibuild` — CI entrypoint: run `script/bootstrap` (without `--cgo`),
|
||||||
then `script/check`, then build the image as `script/docker` does
|
then `script/check`, then build the image as `script/docker` does
|
||||||
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
||||||
@@ -926,6 +937,17 @@ The numbers depend on the machine and on whatever else runs on it. The first
|
|||||||
measurement, made on a shared machine with few clients, is in `TODO.md`; it says
|
measurement, made on a shared machine with few clients, is in `TODO.md`; it says
|
||||||
nothing about the target.
|
nothing about the target.
|
||||||
|
|
||||||
|
`script/bench` (or `make bench`) measures cache hits alone, without the network
|
||||||
|
and with no pixad process: a Go benchmark in `internal/handlers` serves one
|
||||||
|
cached image through the `/v1/image/` handler from as many goroutines at once as
|
||||||
|
the machine has CPUs, and reports the hits served per second as `hits/s`. Each
|
||||||
|
run starts with an empty cache and an empty database, a SQLite file of its own,
|
||||||
|
and puts the image in the cache before it measures. The benchmark fails if
|
||||||
|
serving the hits ran any statement on the database. Like the load test it is not
|
||||||
|
part of `script/check`. It runs Go on the host, as the load test does, and
|
||||||
|
compiles pixa there, so it needs the C compiler and libraries that
|
||||||
|
`script/bootstrap --cgo` installs, but not Docker.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
See [TODO.md](TODO.md) for the full prioritized task list.
|
See [TODO.md](TODO.md) for the full prioritized task list.
|
||||||
|
|||||||
@@ -30,6 +30,31 @@ P2: security: per-origin rate limiting
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-21 a cache hit makes no database call (closes #309): `Service.Get`
|
||||||
|
opens the variant file with `Cache.GetVariant` and nothing else, with no
|
||||||
|
existence check first. The content type comes from the file's first bytes; a
|
||||||
|
file whose first bytes are not those of an output format, SVG included, is a
|
||||||
|
miss, and the variant is made again. A hit hands its access time to a channel
|
||||||
|
that holds up to 10,000 and drops the rest, and its hit count to the pending
|
||||||
|
counts, without waiting; every count is now a pending count. A miss that finds
|
||||||
|
a cached source hands that source's access time to a second such channel. The
|
||||||
|
goroutine `StartPendingCountWrites` starts writes, once a second, the access
|
||||||
|
times in one transaction and the counts in one `UPDATE`;
|
||||||
|
`StopPendingCountWrites` writes what is left once at shutdown and logs
|
||||||
|
separately the access times and the counts it could not write.
|
||||||
|
`checkNegativeCache` no longer deletes an expired row; `CleanExpired` in the
|
||||||
|
eviction goroutine does. The `.meta` files, `VariantMeta`, `LoadWithMeta`,
|
||||||
|
`variantContentTypeFromSidecar`, `metaCache`, `Lookup` and `golang-lru` are
|
||||||
|
gone. `make bench` (`script/bench`) runs `BenchmarkHandleImage_CacheHits` with
|
||||||
|
Go on the host after `script/bootstrap --cgo`, outside `make check`.
|
||||||
|
- 2026-10-21 a source is stored the way a variant is (closes #308): one
|
||||||
|
`source_content` row and one file per source URL, the file named by the
|
||||||
|
source's `SourceKey`, the SHA-256 of its host, path and query made as the
|
||||||
|
variant key is, under `cache/sources/`. A store writes the file, then the row,
|
||||||
|
and removes the file when the row cannot be written; an eviction deletes the
|
||||||
|
row, then the file, with no lock and no transaction. `source_metadata`, the
|
||||||
|
JSON files under `cache/metadata/`, `MetadataStorage` and the content lock are
|
||||||
|
gone. Two source URLs that serve the same bytes now store them twice.
|
||||||
- 2026-10-21 one image request type instead of five (closes #307): the new
|
- 2026-10-21 one image request type instead of five (closes #307): the new
|
||||||
package `internal/imagerequest`, which imports no other package of pixa, holds
|
package `internal/imagerequest`, which imports no other package of pixa, holds
|
||||||
`imagerequest.Request` with its two parts, `Source` (host, path and query) and
|
`imagerequest.Request` with its two parts, `Source` (host, path and query) and
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ require (
|
|||||||
github.com/go-chi/httprate v0.16.0
|
github.com/go-chi/httprate v0.16.0
|
||||||
github.com/gorilla/csrf v1.7.3
|
github.com/gorilla/csrf v1.7.3
|
||||||
github.com/gorilla/securecookie v1.1.2
|
github.com/gorilla/securecookie v1.1.2
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
|||||||
@@ -228,8 +228,6 @@ github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09
|
|||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
||||||
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
||||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.ExecContext(ctx, `INSERT INTO source_content
|
_, err = db.ExecContext(ctx, `INSERT INTO source_content
|
||||||
(content_hash, content_type, size_bytes) VALUES (?, 'image/png', 1)`, key)
|
(source_key, source_host, source_path, content_type, size_bytes)
|
||||||
|
VALUES (?, 'example.com', '/image.png', 'image/png', 1)`, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("storing a source: %w", err)
|
return fmt.Errorf("storing a source: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
|
|||||||
|
|
||||||
// Verify that the application tables created by 001.sql exist.
|
// Verify that the application tables created by 001.sql exist.
|
||||||
tables := []string{
|
tables := []string{
|
||||||
"source_content", "source_metadata", "output_content",
|
"source_content", "output_content",
|
||||||
"request_cache", "negative_cache", "cache_stats",
|
"request_cache", "negative_cache", "cache_stats",
|
||||||
}
|
}
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
-- Migration 001: Initial schema
|
-- Migration 001: Initial schema
|
||||||
-- Creates all tables for the pixa caching image proxy
|
-- Creates all tables for the pixa caching image proxy
|
||||||
|
|
||||||
-- Source content blobs
|
-- Source images, one row per source URL
|
||||||
-- Files stored at: cache/sources/<ab>/<cd>/<sha256>
|
-- Files stored at: cache/sources/<ab>/<cd>/<source_key>, where source_key
|
||||||
|
-- is the SHA-256 of the source's host, path and query
|
||||||
CREATE TABLE IF NOT EXISTS source_content (
|
CREATE TABLE IF NOT EXISTS source_content (
|
||||||
content_hash TEXT PRIMARY KEY,
|
source_key TEXT PRIMARY KEY,
|
||||||
|
source_host TEXT NOT NULL,
|
||||||
|
source_path TEXT NOT NULL,
|
||||||
|
source_query TEXT NOT NULL DEFAULT '',
|
||||||
content_type TEXT NOT NULL,
|
content_type TEXT NOT NULL,
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -13,35 +17,13 @@ CREATE TABLE IF NOT EXISTS source_content (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
||||||
ON source_content(last_accessed_at);
|
ON source_content(last_accessed_at);
|
||||||
|
|
||||||
-- Source URL metadata - maps URLs to content hashes
|
|
||||||
-- JSON stored at: cache/metadata/<hostname>/<path_hash>.json
|
|
||||||
CREATE TABLE IF NOT EXISTS source_metadata (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
source_host TEXT NOT NULL,
|
|
||||||
source_path TEXT NOT NULL,
|
|
||||||
source_query TEXT NOT NULL DEFAULT '',
|
|
||||||
path_hash TEXT NOT NULL,
|
|
||||||
content_hash TEXT,
|
|
||||||
status_code INTEGER NOT NULL,
|
|
||||||
content_type TEXT,
|
|
||||||
response_headers TEXT,
|
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at DATETIME,
|
|
||||||
etag TEXT,
|
|
||||||
last_modified TEXT,
|
|
||||||
UNIQUE(source_host, source_path, source_query),
|
|
||||||
FOREIGN KEY (content_hash) REFERENCES source_content(content_hash)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_host ON source_metadata(source_host);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_path_hash ON source_metadata(path_hash);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_expires ON source_metadata(expires_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_meta_content_hash ON source_metadata(content_hash);
|
|
||||||
|
|
||||||
-- Processed variant blobs
|
-- Processed variant blobs
|
||||||
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key> (plus a .meta
|
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key>. Tracked here
|
||||||
-- sidecar with the content type). Tracked here (like source content
|
-- (like source content blobs above) so total cache usage is known
|
||||||
-- blobs above) so total cache usage is known without a directory scan,
|
-- without a directory scan, and so LRU eviction has a timestamp to order
|
||||||
-- and so LRU eviction has a timestamp to order on.
|
-- on. A cache hit does not read this table: it takes the content type
|
||||||
|
-- from the file's first bytes, and its last_accessed_at is written
|
||||||
|
-- later, in a batch.
|
||||||
CREATE TABLE IF NOT EXISTS variant_content (
|
CREATE TABLE IF NOT EXISTS variant_content (
|
||||||
cache_key TEXT PRIMARY KEY,
|
cache_key TEXT PRIMARY KEY,
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countingHost is the allowlisted host whose photoPath the server of
|
||||||
|
// newCountingDatabaseServer serves.
|
||||||
|
const countingHost = "counting.example.com"
|
||||||
|
|
||||||
|
// countingConnector opens connections to the SQLite database file name with
|
||||||
|
// sqliteDriver, each one counting in statements the statements run on it.
|
||||||
|
type countingConnector struct {
|
||||||
|
sqliteDriver driver.Driver
|
||||||
|
name string
|
||||||
|
statements *atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingConnector) Connect(context.Context) (driver.Conn, error) {
|
||||||
|
conn, err := c.sqliteDriver.Open(c.name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return countingConn{Conn: conn, statements: c.statements}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingConnector) Driver() driver.Driver {
|
||||||
|
return c.sqliteDriver
|
||||||
|
}
|
||||||
|
|
||||||
|
// countingConn counts the statements run on its connection. It has only the
|
||||||
|
// methods of driver.Conn, so database/sql prepares every query and every
|
||||||
|
// other statement with Prepare, which counts it.
|
||||||
|
type countingConn struct {
|
||||||
|
driver.Conn
|
||||||
|
|
||||||
|
statements *atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingConn) Prepare(query string) (driver.Stmt, error) {
|
||||||
|
c.statements.Add(1)
|
||||||
|
|
||||||
|
return c.Conn.Prepare(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCountingDatabase returns a database with pixa's schema, in a file of
|
||||||
|
// its own and with one connection, and the count of the statements run on
|
||||||
|
// it once the schema is in place.
|
||||||
|
func newCountingDatabase(tb testing.TB) (*sql.DB, *atomic.Int64) {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(tb.TempDir(), "state.sqlite3")
|
||||||
|
|
||||||
|
schemaDB, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("opening the database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = schemaDB.Close() }()
|
||||||
|
|
||||||
|
err = database.ApplyMigrations(tb.Context(), schemaDB, nil)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("applying the migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statements := new(atomic.Int64)
|
||||||
|
|
||||||
|
db := sql.OpenDB(countingConnector{
|
||||||
|
sqliteDriver: schemaDB.Driver(),
|
||||||
|
name: path,
|
||||||
|
statements: statements,
|
||||||
|
})
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
tb.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
return db, statements
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCountingDatabaseServer returns a router for both image routes whose
|
||||||
|
// image cache uses newCountingDatabase, the count of the statements run on
|
||||||
|
// that database, and a URL on each route for a different image of
|
||||||
|
// photoPath on countingHost. Nothing writes the cache's pending counts and
|
||||||
|
// access times in the background, so every statement counted comes from a
|
||||||
|
// request.
|
||||||
|
func newCountingDatabaseServer(
|
||||||
|
tb testing.TB,
|
||||||
|
) (http.Handler, *atomic.Int64, []string) {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
db, statements := newCountingDatabase(tb)
|
||||||
|
|
||||||
|
var photo bytes.Buffer
|
||||||
|
|
||||||
|
err := jpeg.Encode(&photo, image.NewRGBA(image.Rect(0, 0, 100, 100)), nil)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("encoding the photo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
|
||||||
|
StateDir: tb.TempDir(),
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("imgcache.NewCache() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
|
Cache: cache,
|
||||||
|
Fetcher: newMockFetcher(fstest.MapFS{
|
||||||
|
countingHost + photoPath: &fstest.MapFile{Data: photo.Bytes()},
|
||||||
|
}),
|
||||||
|
SigningKey: testSigningKey,
|
||||||
|
Allowlist: []string{countingHost},
|
||||||
|
Logger: slog.New(slog.DiscardHandler),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("imgcache.NewService() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := encGen.Generate(&imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{Host: countingHost, Path: photoPath},
|
||||||
|
Transform: imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 40, Height: 40},
|
||||||
|
Format: imagerequest.FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("encGen.Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
imgSvc: svc,
|
||||||
|
encGen: encGen,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/image/*", h.HandleImage())
|
||||||
|
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
||||||
|
|
||||||
|
return r, statements, []string{
|
||||||
|
"/v1/image/" + countingHost + photoPath + "/50x50.jpeg",
|
||||||
|
"/v1/e/" + token + "/img.jpg",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveImage sends srv a GET for target and returns the response, failing
|
||||||
|
// the test unless it is a 200 with an image.
|
||||||
|
func serveImage(
|
||||||
|
tb testing.TB, srv http.Handler, target string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec,
|
||||||
|
httptest.NewRequestWithContext(tb.Context(), http.MethodGet, target, nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.Len() == 0 {
|
||||||
|
tb.Errorf("GET %s: %d with %d bytes, want 200 with an image",
|
||||||
|
target, rec.Code, rec.Body.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageRoutes_CacheHitsMakeNoDatabaseCall serves an image on each image
|
||||||
|
// route once, which makes and caches it, then serves it again from the cache.
|
||||||
|
// Serving those cache hits must run no statement on the database.
|
||||||
|
func TestImageRoutes_CacheHitsMakeNoDatabaseCall(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv, statements, targets := newCountingDatabaseServer(t)
|
||||||
|
|
||||||
|
for _, target := range targets {
|
||||||
|
before := statements.Load()
|
||||||
|
|
||||||
|
serveImage(t, srv, target)
|
||||||
|
|
||||||
|
afterMiss := statements.Load()
|
||||||
|
t.Logf("GET %s, a miss: %d statements", target, afterMiss-before)
|
||||||
|
|
||||||
|
if afterMiss == before {
|
||||||
|
t.Fatalf("GET %s, a miss: no statement counted, want some", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
rec := serveImage(t, srv, target)
|
||||||
|
|
||||||
|
cacheStatus := rec.Header().Get("X-Pixa-Cache")
|
||||||
|
if cacheStatus != string(imgcache.CacheHit) {
|
||||||
|
t.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
|
||||||
|
target, cacheStatus, imgcache.CacheHit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hitStatements := statements.Load() - afterMiss; hitStatements != 0 {
|
||||||
|
t.Errorf("GET %s: 3 cache hits ran %d statements, want 0",
|
||||||
|
target, hitStatements)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkHandleImage_CacheHits serves one cached image through the
|
||||||
|
// /v1/image/ route from b.RunParallel's goroutines, and reports the cache
|
||||||
|
// hits served per second. It fails if serving them ran any statement on the
|
||||||
|
// database. script/bench runs it.
|
||||||
|
func BenchmarkHandleImage_CacheHits(b *testing.B) {
|
||||||
|
srv, statements, targets := newCountingDatabaseServer(b)
|
||||||
|
target := targets[0]
|
||||||
|
|
||||||
|
serveImage(b, srv, target)
|
||||||
|
|
||||||
|
before := statements.Load()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
|
||||||
|
b.RunParallel(func(pb *testing.PB) {
|
||||||
|
for pb.Next() {
|
||||||
|
rec := serveImage(b, srv, target)
|
||||||
|
|
||||||
|
cacheStatus := rec.Header().Get("X-Pixa-Cache")
|
||||||
|
if cacheStatus != string(imgcache.CacheHit) {
|
||||||
|
b.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
|
||||||
|
target, cacheStatus, imgcache.CacheHit)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "hits/s")
|
||||||
|
|
||||||
|
if hitStatements := statements.Load() - before; hitStatements != 0 {
|
||||||
|
b.Fatalf("%d cache hits ran %d statements, want 0", b.N, hitStatements)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,8 +76,8 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
|||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
return s.initImageService()
|
return s.initImageService()
|
||||||
},
|
},
|
||||||
// The pending counts are written here, before the database's stop
|
// The pending counts and access times are written here, before the
|
||||||
// hook closes it.
|
// database's stop hook closes it.
|
||||||
OnStop: func(ctx context.Context) error {
|
OnStop: func(ctx context.Context) error {
|
||||||
if s.imgCache == nil {
|
if s.imgCache == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -128,7 +128,8 @@ func (s *Handlers) initImageService() error {
|
|||||||
// rows, which is all it does when the disk cache is disabled.
|
// rows, which is all it does when the disk cache is disabled.
|
||||||
cache.StartEviction(imgcache.DefaultEvictionInterval)
|
cache.StartEviction(imgcache.DefaultEvictionInterval)
|
||||||
|
|
||||||
// Writes the counts requests could not write by their deadline
|
// Writes the counts and the access times of cache hits that requests
|
||||||
|
// hand over, so that no request waits for the database to record them
|
||||||
cache.StartPendingCountWrites()
|
cache.StartPendingCountWrites()
|
||||||
|
|
||||||
// Create the fetcher config
|
// Create the fetcher config
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Package imagerequest holds the image request type, its parts, its checks,
|
// Package imagerequest holds the image request type, its parts, its checks,
|
||||||
// the parsing of the /v1/image/ path and the variant key. It imports no other
|
// the parsing of the /v1/image/ path, the variant key and the source key. It
|
||||||
// package of this repo, so every package that handles an image request can
|
// imports no other package of this repo, so every package that handles an
|
||||||
// import it.
|
// image request can import it.
|
||||||
package imagerequest
|
package imagerequest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SourceKey is a SHA256 hash identifying a cached source image (hex-encoded).
|
||||||
|
type SourceKey string
|
||||||
|
|
||||||
|
// Key returns the key the source is cached under, made as CacheKey makes a
|
||||||
|
// variant key.
|
||||||
|
// Format: sha256(host:path:query)
|
||||||
|
// The path and query go through escapePercentAndColon, so that a ":" in
|
||||||
|
// either cannot be taken for the one between them.
|
||||||
|
func (s Source) Key() SourceKey {
|
||||||
|
data := fmt.Sprintf("%s:%s:%s",
|
||||||
|
s.Host,
|
||||||
|
escapePercentAndColon(s.Path),
|
||||||
|
escapePercentAndColon(s.Query),
|
||||||
|
)
|
||||||
|
h := sha256.Sum256([]byte(data))
|
||||||
|
|
||||||
|
return SourceKey(hex.EncodeToString(h[:]))
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestSourceKey_DiffersForEachSource checks that each source gets a key of
|
||||||
|
// 64 hex characters, the same each time, and that no two of these sources
|
||||||
|
// share one, also when they differ only by a ":" or "%" in the path or query.
|
||||||
|
func TestSourceKey_DiffersForEachSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sources := []Source{
|
||||||
|
{Host: testHostCDN, Path: testPathCat},
|
||||||
|
{Host: testHostCDN, Path: testPathCat, Query: "v=2"},
|
||||||
|
{Host: "other.example.com", Path: testPathCat},
|
||||||
|
{Host: testHostCDN, Path: "/c:d"},
|
||||||
|
{Host: testHostCDN, Path: "/c", Query: "d:"},
|
||||||
|
{Host: testHostCDN, Path: "/c%3Ad"},
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[SourceKey]Source)
|
||||||
|
|
||||||
|
for _, source := range sources {
|
||||||
|
key := source.Key()
|
||||||
|
t.Logf("Key() of %+v = %s", source, key)
|
||||||
|
|
||||||
|
if len(key) != 64 {
|
||||||
|
t.Errorf("Key() of %+v length = %d, want 64", source, len(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
if again := source.Key(); again != key {
|
||||||
|
t.Errorf("Key() of %+v not deterministic: %s vs %s", source, key, again)
|
||||||
|
}
|
||||||
|
|
||||||
|
if earlier, found := seen[key]; found {
|
||||||
|
t.Errorf("Key() gave %+v and %+v the same key", earlier, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[key] = source
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
-367
@@ -2,10 +2,7 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -15,7 +12,6 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
lru "github.com/hashicorp/golang-lru/v2"
|
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
@@ -26,12 +22,11 @@ var (
|
|||||||
ErrNegativeCache = errors.New("negative cache hit")
|
ErrNegativeCache = errors.New("negative cache hit")
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTP status code for successful fetch.
|
// maxPendingAccessTimes is how many access times of variants served from
|
||||||
const httpStatusOK = 200
|
// the cache, and how many of cached sources a miss used, may each wait to be
|
||||||
|
// written, at about 80 bytes each. An access time handed over while that
|
||||||
// metaCacheSize is how many variants' content types metaCache holds. A
|
// many of its kind wait is dropped.
|
||||||
// variant not among them is served as before, reading its .meta file.
|
const maxPendingAccessTimes = 10000
|
||||||
const metaCacheSize = 10000
|
|
||||||
|
|
||||||
// cleanExpiredBatchSize is the most expired negative cache rows one
|
// cleanExpiredBatchSize is the most expired negative cache rows one
|
||||||
// statement of CleanExpired deletes. Each batch is a statement of its
|
// statement of CleanExpired deletes. Each batch is a statement of its
|
||||||
@@ -69,12 +64,11 @@ type CacheConfig struct {
|
|||||||
|
|
||||||
// Cache implements the caching layer for the image proxy.
|
// Cache implements the caching layer for the image proxy.
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
srcContent *ContentStorage // source images by content hash
|
srcContent *ContentStorage // source images by source key
|
||||||
variants *VariantStorage // processed variants by cache key
|
variants *VariantStorage // processed variants by cache key
|
||||||
srcMetadata *MetadataStorage // source metadata by host/path
|
config CacheConfig
|
||||||
config CacheConfig
|
log *slog.Logger
|
||||||
log *slog.Logger
|
|
||||||
|
|
||||||
// disabled means the disk cache is turned off entirely: lookups
|
// disabled means the disk cache is turned off entirely: lookups
|
||||||
// always miss, stores are no-ops, and no eviction runs.
|
// always miss, stores are no-ops, and no eviction runs.
|
||||||
@@ -89,19 +83,23 @@ type Cache struct {
|
|||||||
evictionCancel context.CancelFunc
|
evictionCancel context.CancelFunc
|
||||||
|
|
||||||
// The pending counts: hits, misses, upstream fetches and transforms
|
// The pending counts: hits, misses, upstream fetches and transforms
|
||||||
// whose write to the database missed its request's deadline, kept
|
// that requests counted, kept here until they are written by the
|
||||||
// here until they are written by the goroutine that
|
// goroutine that StartPendingCountWrites starts, so that no request
|
||||||
// StartPendingCountWrites starts. As for eviction, the channels are
|
// waits for the database to count it. pendingVariantAccessTimes holds
|
||||||
// created in NewCache: pendingCountsAdded wakes that goroutine, and
|
// the variants served from the cache, and pendingSourceAccessTimes the
|
||||||
// pendingCountsDone is closed when it returns. pendingCountsCancel,
|
// cached sources a miss used, whose access time is still to be written
|
||||||
// set by StartPendingCountWrites, cancels its context, which tells it
|
// by that goroutine. As for eviction, the channels are created in
|
||||||
// to finish.
|
// NewCache, so that requests can hand over access times before that
|
||||||
|
// goroutine starts; pendingCountsDone is closed when it returns.
|
||||||
|
// pendingCountsCancel, set by StartPendingCountWrites, cancels its
|
||||||
|
// context, which tells it to finish.
|
||||||
pendingHits atomic.Int64
|
pendingHits atomic.Int64
|
||||||
pendingMisses atomic.Int64
|
pendingMisses atomic.Int64
|
||||||
pendingUpstreamFetches atomic.Int64
|
pendingUpstreamFetches atomic.Int64
|
||||||
pendingUpstreamFetchBytes atomic.Int64
|
pendingUpstreamFetchBytes atomic.Int64
|
||||||
pendingTransforms atomic.Int64
|
pendingTransforms atomic.Int64
|
||||||
pendingCountsAdded chan struct{}
|
pendingVariantAccessTimes chan imagerequest.VariantKey
|
||||||
|
pendingSourceAccessTimes chan imagerequest.SourceKey
|
||||||
pendingCountsDone chan struct{}
|
pendingCountsDone chan struct{}
|
||||||
pendingCountsCancel context.CancelFunc
|
pendingCountsCancel context.CancelFunc
|
||||||
|
|
||||||
@@ -109,24 +107,6 @@ type Cache struct {
|
|||||||
// readHitAndMissCounts to read them
|
// readHitAndMissCounts to read them
|
||||||
pendingCountsWriteMutex sync.Mutex
|
pendingCountsWriteMutex sync.Mutex
|
||||||
|
|
||||||
// metaCache holds the content types of the variants most recently
|
|
||||||
// stored or served, so a hit does not read the variant's .meta file.
|
|
||||||
// It never stands in for the variant file, which is always opened.
|
|
||||||
metaCache *lru.Cache[imagerequest.VariantKey, string]
|
|
||||||
|
|
||||||
// contentLocks serializes StoreSource and evictSourceBlob per
|
|
||||||
// content hash, closing the race window between an eviction's row
|
|
||||||
// deletion and its file unlink against a concurrent store of
|
|
||||||
// identical content.
|
|
||||||
contentLocks *contentLock
|
|
||||||
|
|
||||||
// evictSourceBlobTestHook, when set, is invoked by evictSourceBlob
|
|
||||||
// after its row-deletion transaction commits and before the
|
|
||||||
// content file is unlinked. It exists solely so tests can
|
|
||||||
// deterministically pause inside that window to exercise
|
|
||||||
// concurrent stores against it; production code leaves it nil.
|
|
||||||
evictSourceBlobTestHook func(ContentHash)
|
|
||||||
|
|
||||||
// reconciliationPageSize is the most rows one read of a content table
|
// reconciliationPageSize is the most rows one read of a content table
|
||||||
// returns in the reconciliation pass and in Stats. newCache sets it to
|
// returns in the reconciliation pass and in Stats. newCache sets it to
|
||||||
// defaultReconciliationPageSize; tests set it smaller.
|
// defaultReconciliationPageSize; tests set it smaller.
|
||||||
@@ -154,11 +134,6 @@ func newCache(
|
|||||||
log = slog.Default()
|
log = slog.Default()
|
||||||
}
|
}
|
||||||
|
|
||||||
metaCache, err := lru.New[imagerequest.VariantKey, string](metaCacheSize)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create variant content type cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := &Cache{
|
c := &Cache{
|
||||||
db: db,
|
db: db,
|
||||||
config: config,
|
config: config,
|
||||||
@@ -166,11 +141,10 @@ func newCache(
|
|||||||
disabled: config.DisableDiskCache,
|
disabled: config.DisableDiskCache,
|
||||||
evictionPressure: make(chan struct{}, 1),
|
evictionPressure: make(chan struct{}, 1),
|
||||||
evictionDone: make(chan struct{}),
|
evictionDone: make(chan struct{}),
|
||||||
metaCache: metaCache,
|
|
||||||
contentLocks: newContentLock(),
|
|
||||||
|
|
||||||
pendingCountsAdded: make(chan struct{}, 1),
|
pendingVariantAccessTimes: make(chan imagerequest.VariantKey, maxPendingAccessTimes),
|
||||||
pendingCountsDone: make(chan struct{}),
|
pendingSourceAccessTimes: make(chan imagerequest.SourceKey, maxPendingAccessTimes),
|
||||||
|
pendingCountsDone: make(chan struct{}),
|
||||||
|
|
||||||
reconciliationPageSize: defaultReconciliationPageSize,
|
reconciliationPageSize: defaultReconciliationPageSize,
|
||||||
}
|
}
|
||||||
@@ -187,22 +161,14 @@ func newCache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
variants, err := NewVariantStorage(
|
variants, err := NewVariantStorage(
|
||||||
filepath.Join(config.StateDir, "cache", "variants"), log,
|
filepath.Join(config.StateDir, "cache", "variants"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srcMetadata, err := NewMetadataStorage(
|
|
||||||
filepath.Join(config.StateDir, "cache", "metadata"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.srcContent = srcContent
|
c.srcContent = srcContent
|
||||||
c.variants = variants
|
c.variants = variants
|
||||||
c.srcMetadata = srcMetadata
|
|
||||||
|
|
||||||
if config.UseDefaultMaxBytes {
|
if config.UseDefaultMaxBytes {
|
||||||
limit, err := c.computeDefaultMaxBytes(context.Background(), probe)
|
limit, err := c.computeDefaultMaxBytes(context.Background(), probe)
|
||||||
@@ -221,45 +187,11 @@ func newCache(
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupResult contains the result of a cache lookup.
|
// GetVariant opens a cached variant and returns a reader, its size and its
|
||||||
type LookupResult struct {
|
// content type, read from the variant file's first bytes. It opens that one
|
||||||
Hit bool
|
// file and makes no database call. A variant not in the cache, or any
|
||||||
CacheKey imagerequest.VariantKey
|
// variant of a disabled cache, is ErrNotFound; a variant file whose first
|
||||||
ContentType string
|
// bytes are not those of an output format is another error.
|
||||||
SizeBytes int64
|
|
||||||
CacheStatus CacheStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookup checks if a processed variant exists on disk. Hits touch the
|
|
||||||
// variant's LRU timestamp; a disabled cache always misses.
|
|
||||||
func (c *Cache) Lookup(
|
|
||||||
ctx context.Context, req *imagerequest.Request,
|
|
||||||
) (*LookupResult, error) {
|
|
||||||
cacheKey := imagerequest.CacheKey(req)
|
|
||||||
|
|
||||||
// Check variant storage directly - no DB needed for cache hits
|
|
||||||
if !c.disabled && c.variants.Exists(cacheKey) {
|
|
||||||
c.touchVariant(ctx, cacheKey)
|
|
||||||
|
|
||||||
return &LookupResult{
|
|
||||||
Hit: true,
|
|
||||||
CacheKey: cacheKey,
|
|
||||||
CacheStatus: CacheHit,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &LookupResult{
|
|
||||||
Hit: false,
|
|
||||||
CacheKey: cacheKey,
|
|
||||||
CacheStatus: CacheMiss,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetVariant returns a reader, size, and content type for a cached
|
|
||||||
// variant. The content type comes from metaCache, or else from the
|
|
||||||
// variant's .meta file and is then kept in metaCache. A variant with
|
|
||||||
// no .meta file is served as application/octet-stream, which is not
|
|
||||||
// kept.
|
|
||||||
func (c *Cache) GetVariant(
|
func (c *Cache) GetVariant(
|
||||||
cacheKey imagerequest.VariantKey,
|
cacheKey imagerequest.VariantKey,
|
||||||
) (io.ReadCloser, int64, string, error) {
|
) (io.ReadCloser, int64, string, error) {
|
||||||
@@ -267,104 +199,62 @@ func (c *Cache) GetVariant(
|
|||||||
return nil, 0, "", ErrNotFound
|
return nil, 0, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
contentType, known := c.metaCache.Get(cacheKey)
|
return c.variants.LoadWithContentType(cacheKey)
|
||||||
if !known {
|
|
||||||
return c.loadVariantWithMeta(cacheKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, size, err := c.variants.LoadWithSize(cacheKey)
|
|
||||||
if err != nil {
|
|
||||||
// The file is gone, e.g. deleted outside pixa
|
|
||||||
c.metaCache.Remove(cacheKey)
|
|
||||||
|
|
||||||
return nil, 0, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return reader, size, contentType, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreSource stores fetched source content and metadata. On a
|
// StoreSource stores fetched source content: it writes the file named by
|
||||||
// disabled cache it is a no-op returning an empty hash.
|
// the source's key, then the source's row. When the row cannot be written
|
||||||
|
// the file is removed and the store fails, as a file with no row is never
|
||||||
|
// served or evicted; if that removal fails too, the next reconciliation
|
||||||
|
// pass removes the file. On a disabled cache it is a no-op returning an
|
||||||
|
// empty key.
|
||||||
func (c *Cache) StoreSource(
|
func (c *Cache) StoreSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *imagerequest.Request,
|
req *imagerequest.Request,
|
||||||
content io.Reader,
|
content io.Reader,
|
||||||
result *httpfetcher.FetchResult,
|
result *httpfetcher.FetchResult,
|
||||||
) (ContentHash, error) {
|
) (imagerequest.SourceKey, error) {
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash the content ourselves (rather than via srcContent.Store,
|
|
||||||
// which would hash internally) so the content hash is known before
|
|
||||||
// any file or database work happens: that lets the entire store be
|
|
||||||
// serialized, per hash, against a concurrent eviction of the same
|
|
||||||
// content below.
|
|
||||||
data, err := io.ReadAll(content)
|
data, err := io.ReadAll(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read source content: %w", err)
|
return "", fmt.Errorf("failed to read source content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sum := sha256.Sum256(data)
|
key := req.Source.Key()
|
||||||
contentHash := ContentHash(hex.EncodeToString(sum[:]))
|
|
||||||
|
|
||||||
// Hold the content hash's lock for the whole store operation. A
|
size, err := c.srcContent.Store(key, data)
|
||||||
// concurrent eviction of this exact hash (the real SHA-256 dedup
|
|
||||||
// case: a different source path whose bytes hash identically)
|
|
||||||
// deletes the accounting rows and unlinks the file inside the same
|
|
||||||
// lock, so the two can never interleave: either this store
|
|
||||||
// completes first (and a subsequent eviction removes it together
|
|
||||||
// with its rows and file, correctly), or eviction completes first
|
|
||||||
// (and this store finds the file already gone and recreates it
|
|
||||||
// fresh) — never a fresh row left pointing at a file eviction is
|
|
||||||
// mid-unlink on.
|
|
||||||
unlock := c.contentLocks.Lock(string(contentHash))
|
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
size, err := c.srcContent.StoreHashed(contentHash, data)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to store source content: %w", err)
|
return "", fmt.Errorf("failed to store source content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in database
|
|
||||||
pathHash := HashPath(req.Source.Path + "?" + req.Source.Query)
|
|
||||||
|
|
||||||
headersJSON, err := json.Marshal(result.Headers)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to marshal response headers: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
INSERT INTO source_content (content_hash, content_type, size_bytes)
|
INSERT INTO source_content
|
||||||
VALUES (?, ?, ?)
|
(source_key, source_host, source_path, source_query,
|
||||||
ON CONFLICT(content_hash) DO NOTHING
|
content_type, size_bytes)
|
||||||
`, contentHash, result.ContentType, size)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(source_key) DO UPDATE SET
|
||||||
|
content_type = excluded.content_type,
|
||||||
|
size_bytes = excluded.size_bytes,
|
||||||
|
fetched_at = CURRENT_TIMESTAMP,
|
||||||
|
last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
`, string(key), req.Source.Host, req.Source.Path, req.Source.Query,
|
||||||
|
result.ContentType, size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
deleteErr := c.srcContent.Delete(key)
|
||||||
|
if deleteErr != nil {
|
||||||
|
c.log.Warn("failed to remove source file whose row was not written",
|
||||||
|
"source_key", key, "error", deleteErr)
|
||||||
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("failed to insert source content: %w", err)
|
return "", fmt.Errorf("failed to insert source content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
|
||||||
INSERT INTO source_metadata
|
|
||||||
(source_host, source_path, source_query, path_hash,
|
|
||||||
content_hash, status_code, content_type, response_headers)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
|
|
||||||
content_hash = excluded.content_hash,
|
|
||||||
status_code = excluded.status_code,
|
|
||||||
content_type = excluded.content_type,
|
|
||||||
response_headers = excluded.response_headers,
|
|
||||||
fetched_at = CURRENT_TIMESTAMP
|
|
||||||
`, req.Source.Host, req.Source.Path, req.Source.Query, pathHash,
|
|
||||||
contentHash, httpStatusOK, result.ContentType, string(headersJSON))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to insert source metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.writeMetadataSidecar(req, pathHash, contentHash, result)
|
|
||||||
|
|
||||||
c.notifyWritePressure()
|
c.notifyWritePressure()
|
||||||
|
|
||||||
return contentHash, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreVariant stores a processed variant by its cache key and records
|
// StoreVariant stores a processed variant by its cache key and records
|
||||||
@@ -379,13 +269,11 @@ func (c *Cache) StoreVariant(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
size, err := c.variants.Store(cacheKey, content, contentType)
|
size, err := c.variants.Store(cacheKey, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
c.metaCache.Add(cacheKey, contentType)
|
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
@@ -405,22 +293,24 @@ func (c *Cache) StoreVariant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LookupSource checks if we have cached source content for a request.
|
// LookupSource checks if we have cached source content for a request.
|
||||||
// Returns the content hash and content type if found, or empty values
|
// Returns the source key and content type if found, or empty values
|
||||||
// if not. Hits touch the blob's LRU timestamp; a disabled cache always
|
// if not; a row whose file is missing is not found. A source found has
|
||||||
// reports no cached source.
|
// its access time handed to the background writer without waiting (see
|
||||||
|
// touchSourceContent); a disabled cache always reports no cached source.
|
||||||
func (c *Cache) LookupSource(
|
func (c *Cache) LookupSource(
|
||||||
ctx context.Context, req *imagerequest.Request,
|
ctx context.Context, req *imagerequest.Request,
|
||||||
) (ContentHash, string, error) {
|
) (imagerequest.SourceKey, string, error) {
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return "", "", nil
|
return "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var hashStr, contentType string
|
key := req.Source.Key()
|
||||||
|
|
||||||
|
var contentType string
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx, `
|
||||||
SELECT content_hash, content_type FROM source_metadata
|
SELECT content_type FROM source_content WHERE source_key = ?
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
`, string(key)).Scan(&contentType)
|
||||||
`, req.Source.Host, req.Source.Path, req.Source.Query).Scan(&hashStr, &contentType)
|
|
||||||
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return "", "", nil
|
return "", "", nil
|
||||||
@@ -430,16 +320,14 @@ func (c *Cache) LookupSource(
|
|||||||
return "", "", fmt.Errorf("failed to lookup source: %w", err)
|
return "", "", fmt.Errorf("failed to lookup source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
contentHash := ContentHash(hashStr)
|
|
||||||
|
|
||||||
// Verify the content file exists
|
// Verify the content file exists
|
||||||
if !c.srcContent.Exists(contentHash) {
|
if !c.srcContent.Exists(key) {
|
||||||
return "", "", nil
|
return "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
c.touchSourceContent(ctx, contentHash)
|
c.touchSourceContent(key)
|
||||||
|
|
||||||
return contentHash, contentType, nil
|
return key, contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreNegative stores a negative cache entry for a failed fetch.
|
// StoreNegative stores a negative cache entry for a failed fetch.
|
||||||
@@ -466,33 +354,16 @@ func (c *Cache) StoreNegative(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSourceMetadataID returns the source metadata ID for a request.
|
// GetSourceContent returns a reader for cached source content by its key,
|
||||||
func (c *Cache) GetSourceMetadataID(
|
|
||||||
ctx context.Context, req *imagerequest.Request,
|
|
||||||
) (int64, error) {
|
|
||||||
var id int64
|
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
|
||||||
SELECT id FROM source_metadata
|
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
|
||||||
`, req.Source.Host, req.Source.Path, req.Source.Query).Scan(&id)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to get source metadata ID: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSourceContent returns a reader for cached source content by its hash,
|
|
||||||
// and the content's size in bytes.
|
// and the content's size in bytes.
|
||||||
func (c *Cache) GetSourceContent(
|
func (c *Cache) GetSourceContent(
|
||||||
contentHash ContentHash,
|
key imagerequest.SourceKey,
|
||||||
) (io.ReadCloser, int64, error) {
|
) (io.ReadCloser, int64, error) {
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return nil, 0, ErrNotFound
|
return nil, 0, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.srcContent.LoadWithSize(contentHash)
|
return c.srcContent.LoadWithSize(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanExpired removes the negative cache rows past their expiry, one
|
// CleanExpired removes the negative cache rows past their expiry, one
|
||||||
@@ -555,92 +426,33 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
|||||||
|
|
||||||
// IncrementStats counts a cache hit or miss, and an upstream fetch that read
|
// IncrementStats counts a cache hit or miss, and an upstream fetch that read
|
||||||
// fetchBytes bytes, as IncrementUpstreamFetch does. Like the other Increment
|
// fetchBytes bytes, as IncrementUpstreamFetch does. Like the other Increment
|
||||||
// methods, it writes to the database with ctx's deadline but not its
|
// methods, it only adds to the pending counts, which the goroutine
|
||||||
// cancellation, so an ended request is still counted but does not wait for
|
// StartPendingCountWrites starts writes to the database, so it never waits
|
||||||
// the database past its deadline; a count not written by then becomes a
|
// for the database or fails.
|
||||||
// pending count (see StartPendingCountWrites).
|
func (c *Cache) IncrementStats(hit bool, fetchBytes int64) {
|
||||||
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
|
||||||
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
var err error
|
|
||||||
|
|
||||||
pendingCount := &c.pendingMisses
|
|
||||||
|
|
||||||
if hit {
|
if hit {
|
||||||
pendingCount = &c.pendingHits
|
c.pendingHits.Add(1)
|
||||||
|
|
||||||
_, err = c.db.ExecContext(countCtx, `
|
|
||||||
UPDATE cache_stats
|
|
||||||
SET hit_count = hit_count + 1,
|
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = 1
|
|
||||||
`)
|
|
||||||
} else {
|
} else {
|
||||||
_, err = c.db.ExecContext(countCtx, `
|
c.pendingMisses.Add(1)
|
||||||
UPDATE cache_stats
|
|
||||||
SET miss_count = miss_count + 1,
|
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = 1
|
|
||||||
`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
c.IncrementUpstreamFetch(fetchBytes)
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
|
||||||
c.addPendingCount(pendingCount, 1)
|
|
||||||
case err != nil:
|
|
||||||
c.log.Warn("failed to count cache hit or miss", "hit", hit, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.IncrementUpstreamFetch(ctx, fetchBytes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementUpstreamFetch counts one upstream fetch that read fetchBytes bytes.
|
// IncrementUpstreamFetch counts one upstream fetch that read fetchBytes bytes.
|
||||||
// A fetch that read no bytes is not counted.
|
// A fetch that read no bytes is not counted.
|
||||||
func (c *Cache) IncrementUpstreamFetch(ctx context.Context, fetchBytes int64) {
|
func (c *Cache) IncrementUpstreamFetch(fetchBytes int64) {
|
||||||
if fetchBytes <= 0 {
|
if fetchBytes <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
c.pendingUpstreamFetches.Add(1)
|
||||||
defer cancel()
|
c.pendingUpstreamFetchBytes.Add(fetchBytes)
|
||||||
|
|
||||||
_, err := c.db.ExecContext(countCtx, `
|
|
||||||
UPDATE cache_stats
|
|
||||||
SET upstream_fetch_count = upstream_fetch_count + 1,
|
|
||||||
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = 1
|
|
||||||
`, fetchBytes)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
|
||||||
c.addPendingCount(&c.pendingUpstreamFetches, 1)
|
|
||||||
c.addPendingCount(&c.pendingUpstreamFetchBytes, fetchBytes)
|
|
||||||
case err != nil:
|
|
||||||
c.log.Warn("failed to count upstream fetch",
|
|
||||||
"fetch_bytes", fetchBytes, "error", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementTransformCount counts one image transcoded by the image processor.
|
// IncrementTransformCount counts one image transcoded by the image processor.
|
||||||
func (c *Cache) IncrementTransformCount(ctx context.Context) {
|
func (c *Cache) IncrementTransformCount() {
|
||||||
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
c.pendingTransforms.Add(1)
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, err := c.db.ExecContext(countCtx, `
|
|
||||||
UPDATE cache_stats
|
|
||||||
SET transform_count = transform_count + 1,
|
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = 1
|
|
||||||
`)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
|
||||||
c.addPendingCount(&c.pendingTransforms, 1)
|
|
||||||
case err != nil:
|
|
||||||
c.log.Warn("failed to count transform", "error", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// readHitAndMissCounts sets the hit and miss counts of stats to those of the
|
// readHitAndMissCounts sets the hit and miss counts of stats to those of the
|
||||||
@@ -662,88 +474,33 @@ func (c *Cache) readHitAndMissCounts(ctx context.Context, stats *CacheStats) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadVariantWithMeta is GetVariant for a variant metaCache does not
|
// touchVariant hands the access time of a variant served from the cache to
|
||||||
// hold: it reads the content type from the variant's .meta file and
|
// the goroutine StartPendingCountWrites starts, which writes it in its next
|
||||||
// keeps it in metaCache, unless a StoreVariant has put one there
|
// batch. It never waits: while maxPendingAccessTimes access times of
|
||||||
// meanwhile, as the store's is newer. A read that finds no .meta file,
|
// variants wait to be written, this one is dropped, and the variant only
|
||||||
// as one can when the store's .meta write failed or an eviction removed
|
// looks colder to eviction.
|
||||||
// the file after the read opened the variant, serves
|
func (c *Cache) touchVariant(cacheKey imagerequest.VariantKey) {
|
||||||
// application/octet-stream and keeps nothing, so metaCache only ever
|
select {
|
||||||
// holds a type read from a .meta file or passed to StoreVariant.
|
case c.pendingVariantAccessTimes <- cacheKey:
|
||||||
func (c *Cache) loadVariantWithMeta(
|
default:
|
||||||
cacheKey imagerequest.VariantKey,
|
|
||||||
) (io.ReadCloser, int64, string, error) {
|
|
||||||
reader, size, contentType, err := c.variants.LoadWithMeta(cacheKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if contentType == "" {
|
|
||||||
return reader, size, fallbackContentType, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
c.metaCache.ContainsOrAdd(cacheKey, contentType)
|
|
||||||
|
|
||||||
return reader, size, contentType, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeMetadataSidecar writes the JSON metadata sidecar of a stored source.
|
|
||||||
// A failure is logged and is otherwise non-fatal; the metadata is in the
|
|
||||||
// database.
|
|
||||||
func (c *Cache) writeMetadataSidecar(
|
|
||||||
req *imagerequest.Request,
|
|
||||||
pathHash PathHash,
|
|
||||||
contentHash ContentHash,
|
|
||||||
result *httpfetcher.FetchResult,
|
|
||||||
) {
|
|
||||||
meta := &SourceMetadata{
|
|
||||||
Host: req.Source.Host,
|
|
||||||
Path: req.Source.Path,
|
|
||||||
Query: req.Source.Query,
|
|
||||||
ContentHash: string(contentHash),
|
|
||||||
StatusCode: result.StatusCode,
|
|
||||||
ContentType: result.ContentType,
|
|
||||||
ContentLength: result.ContentLength,
|
|
||||||
ResponseHeaders: result.Headers,
|
|
||||||
FetchedAt: time.Now().UTC().Unix(),
|
|
||||||
FetchDurationMs: result.FetchDurationMs,
|
|
||||||
RemoteAddr: result.RemoteAddr,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := c.srcMetadata.Store(req.Source.Host, pathHash, meta)
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to write metadata sidecar",
|
|
||||||
"host", req.Source.Host, "path_hash", pathHash, "error", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// touchVariant updates the LRU timestamp of a variant, best-effort:
|
// touchSourceContent hands the access time of a cached source a miss used to
|
||||||
// a failed touch only makes the entry look colder to eviction.
|
// the goroutine StartPendingCountWrites starts, which writes it in its next
|
||||||
func (c *Cache) touchVariant(ctx context.Context, cacheKey imagerequest.VariantKey) {
|
// batch. It never waits: while maxPendingAccessTimes access times of sources
|
||||||
_, err := c.db.ExecContext(ctx, `
|
// wait to be written, this one is dropped, and the source only looks colder
|
||||||
UPDATE variant_content SET last_accessed_at = CURRENT_TIMESTAMP
|
// to eviction.
|
||||||
WHERE cache_key = ?
|
func (c *Cache) touchSourceContent(key imagerequest.SourceKey) {
|
||||||
`, string(cacheKey))
|
select {
|
||||||
if err != nil {
|
case c.pendingSourceAccessTimes <- key:
|
||||||
c.log.Debug("failed to touch variant LRU timestamp",
|
default:
|
||||||
"cache_key", cacheKey, "error", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// touchSourceContent updates the LRU timestamp of a source content
|
// checkNegativeCache checks if a request is in the negative cache. It only
|
||||||
// blob, best-effort: a failed touch only makes the blob look colder.
|
// reads: a row past its expiry is not a hit, and the eviction goroutine
|
||||||
func (c *Cache) touchSourceContent(ctx context.Context, contentHash ContentHash) {
|
// deletes it with the other expired rows (see CleanExpired).
|
||||||
_, err := c.db.ExecContext(ctx, `
|
|
||||||
UPDATE source_content SET last_accessed_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE content_hash = ?
|
|
||||||
`, string(contentHash))
|
|
||||||
if err != nil {
|
|
||||||
c.log.Debug("failed to touch source content LRU timestamp",
|
|
||||||
"content_hash", contentHash, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkNegativeCache checks if a request is in the negative cache.
|
|
||||||
func (c *Cache) checkNegativeCache(
|
func (c *Cache) checkNegativeCache(
|
||||||
ctx context.Context, req *imagerequest.Request,
|
ctx context.Context, req *imagerequest.Request,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
@@ -762,22 +519,9 @@ func (c *Cache) checkNegativeCache(
|
|||||||
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if expired
|
expired := time.Now().After(expiresAt)
|
||||||
if time.Now().After(expiresAt) {
|
|
||||||
// Clean up expired entry
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
|
||||||
DELETE FROM negative_cache
|
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
|
||||||
`, req.Source.Host, req.Source.Path, req.Source.Query)
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to delete expired negative cache entry",
|
|
||||||
"host", req.Source.Host, "path", req.Source.Path, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
return !expired, nil
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanExpiredBatch deletes up to cleanExpiredBatchSize negative cache
|
// cleanExpiredBatch deletes up to cleanExpiredBatchSize negative cache
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetVariant_ContentTypeFromFirstBytes stores an image in each output
|
||||||
|
// format as a variant, recorded with the content type text/plain, and checks
|
||||||
|
// that GetVariant serves it whole with the content type of its format, read
|
||||||
|
// from the file.
|
||||||
|
func TestGetVariant_ContentTypeFromFirstBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
processor := imageprocessor.New(imageprocessor.Params{})
|
||||||
|
source := generateTestJPEG(t, 40, 30, color.RGBA{200, 40, 40, 255})
|
||||||
|
|
||||||
|
for _, format := range []imagerequest.Format{
|
||||||
|
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
|
||||||
|
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
||||||
|
} {
|
||||||
|
result, err := processor.Process(t.Context(), bytes.NewReader(source),
|
||||||
|
&imagerequest.Transform{
|
||||||
|
Size: imagerequest.Size{Width: 20, Height: 15},
|
||||||
|
Format: format,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: imagerequest.FitCover,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("making a %s image: %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
image, err := io.ReadAll(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the %s image: %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := imagerequest.VariantKey("aabbccdd10" + string(format))
|
||||||
|
|
||||||
|
err = cache.StoreVariant(t.Context(), key, bytes.NewReader(image), "text/plain")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreVariant(%s) error = %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, size, contentType, err := cache.GetVariant(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetVariant(%s) error = %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
served, err := io.ReadAll(reader)
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the %s variant: %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("%s: %d bytes served as %s", format, size, contentType)
|
||||||
|
|
||||||
|
if want := imageprocessor.FormatToMIME(format); contentType != want {
|
||||||
|
t.Errorf("%s variant content type = %q, want %q", format, contentType, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != int64(len(image)) || !bytes.Equal(served, image) {
|
||||||
|
t.Errorf("%s variant served %d bytes, size %d, want the %d bytes stored",
|
||||||
|
format, len(served), size, len(image))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetVariant_RefusesFileOfNoOutputFormat checks that GetVariant refuses
|
||||||
|
// a variant file whose first bytes are not those of an output format, with
|
||||||
|
// an error other than ErrNotFound, and that a variant with no file, or any
|
||||||
|
// variant of a disabled cache, is ErrNotFound.
|
||||||
|
func TestGetVariant_RefusesFileOfNoOutputFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
jpegImage := generateTestJPEG(t, 10, 10, color.RGBA{0, 0, 255, 255})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content []byte
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"text", []byte("this is text, not an image"), magic.ErrUnknownFormat},
|
||||||
|
{"SVG", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
||||||
|
errNotAnOutputFormat},
|
||||||
|
{"JPEG cut to 10 bytes", jpegImage[:10], io.EOF},
|
||||||
|
{"empty", nil, io.EOF},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tt := range tests {
|
||||||
|
key := imagerequest.VariantKey(fmt.Sprintf("aabbccdd20%02d", i))
|
||||||
|
|
||||||
|
_, err := cache.variants.Store(key, bytes.NewReader(tt.content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: storing the variant file: %v", tt.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, _, contentType, err := cache.GetVariant(key)
|
||||||
|
t.Logf("%s: GetVariant() error = %v", tt.name, err)
|
||||||
|
|
||||||
|
if reader != nil || contentType != "" || !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Errorf("%s: GetVariant() = reader %v, content type %q, error %v; "+
|
||||||
|
"want no reader and %v", tt.name, reader, contentType, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, _, _, err := cache.GetVariant(testVariantKeyFour)
|
||||||
|
if reader != nil || !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("GetVariant() of a variant with no file = reader %v, error %v; "+
|
||||||
|
"want no reader and %v", reader, err, ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled, _ := newEvictionTestCache(t, 0)
|
||||||
|
|
||||||
|
reader, _, _, err = disabled.GetVariant(testVariantKeyOne)
|
||||||
|
if reader != nil || !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("GetVariant() on a disabled cache = reader %v, error %v; "+
|
||||||
|
"want no reader and %v", reader, err, ErrNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_MakesAgainVariantOfNoOutputFormat replaces a cached
|
||||||
|
// variant's file with text, and checks that the next request is a miss
|
||||||
|
// answered with the variant made again, and the one after a hit.
|
||||||
|
func TestService_Get_MakesAgainVariantOfNoOutputFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
|
||||||
|
want, _ := getImage(t, svc, req)
|
||||||
|
|
||||||
|
path := svc.cache.variants.keyToPath(imagerequest.CacheKey(req))
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte("this is text, not an image"), StorageFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replacing the variant file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, status := getImage(t, svc, req)
|
||||||
|
t.Logf("after the replacement: %d bytes, %s", len(got), status)
|
||||||
|
|
||||||
|
if status != CacheMiss || !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("after the replacement: Get() = %d bytes, %s; want the %d bytes "+
|
||||||
|
"made again, %s", len(got), status, len(want), CacheMiss)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, status = getImage(t, svc, req)
|
||||||
|
|
||||||
|
if status != CacheHit || !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("next request: Get() = %d bytes, %s; want the %d bytes, %s",
|
||||||
|
len(got), status, len(want), CacheHit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_HitWaitsForNoDatabaseWrite holds the database's one
|
||||||
|
// connection while a cached variant is requested. The hit must be answered
|
||||||
|
// while the connection is still held, and its access time and hit count
|
||||||
|
// must reach the database once it is free.
|
||||||
|
func TestService_Get_HitWaitsForNoDatabaseWrite(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||||
|
key := imagerequest.CacheKey(req)
|
||||||
|
|
||||||
|
getImage(t, svc, req)
|
||||||
|
waitForPendingCountsWritten(t, svc.cache)
|
||||||
|
|
||||||
|
anHourAgo := time.Now().Add(-time.Hour)
|
||||||
|
setVariantLastAccessed(t, svc.cache, key, anHourAgo)
|
||||||
|
|
||||||
|
want := readCacheStatsCounters(t, svc.cache)
|
||||||
|
want.hitCount++
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, svc.cache)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
results := startGet(t.Context(), svc, req)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-results:
|
||||||
|
if got.err != nil {
|
||||||
|
t.Fatalf("Get() of the cached variant error = %v", got.err)
|
||||||
|
}
|
||||||
|
case <-time.After(pendingCountsWait):
|
||||||
|
t.Fatal("the cache hit waited for the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase()
|
||||||
|
|
||||||
|
waitForCounters(t, svc.cache, want)
|
||||||
|
|
||||||
|
deadline := time.Now().Add(pendingCountsWait)
|
||||||
|
|
||||||
|
for countRows(t, svc.cache, `
|
||||||
|
SELECT COUNT(*) FROM variant_content
|
||||||
|
WHERE cache_key = ? AND last_accessed_at > ?
|
||||||
|
`, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat)) == 0 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the hit's access time was not written")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTouchVariantAndTouchSourceContent_DropAccessTimesPastTheLimit hands one
|
||||||
|
// more access time of a variant, and one more of a source, than may wait to
|
||||||
|
// be written. touchVariant and touchSourceContent must return each time
|
||||||
|
// without waiting, and the last of each must be dropped.
|
||||||
|
func TestTouchVariantAndTouchSourceContent_DropAccessTimesPastTheLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
for range maxPendingAccessTimes + 1 {
|
||||||
|
cache.touchVariant(testVariantKeyOne)
|
||||||
|
cache.touchSourceContent("aabbccdd3001")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(pendingCountsWait):
|
||||||
|
t.Fatal("touchVariant or touchSourceContent waited once the access " +
|
||||||
|
"times reached the limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
if waiting := len(cache.pendingVariantAccessTimes); waiting != maxPendingAccessTimes {
|
||||||
|
t.Errorf("%d access times of variants wait to be written, want %d",
|
||||||
|
waiting, maxPendingAccessTimes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if waiting := len(cache.pendingSourceAccessTimes); waiting != maxPendingAccessTimes {
|
||||||
|
t.Errorf("%d access times of sources wait to be written, want %d",
|
||||||
|
waiting, maxPendingAccessTimes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopPendingCountWrites_WritesPendingAccessTimes hands the access time
|
||||||
|
// of one of two variants, three times, then stops the goroutine that writes
|
||||||
|
// them, which never started. The stop must write that access time and
|
||||||
|
// leave the other variant's.
|
||||||
|
func TestStopPendingCountWrites_WritesPendingAccessTimes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
anHourAgo := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
for _, key := range []imagerequest.VariantKey{testVariantKeyOne, testVariantKeyTwo} {
|
||||||
|
storeEvictionTestVariant(t, cache, key, []byte("variant bytes"))
|
||||||
|
setVariantLastAccessed(t, cache, key, anHourAgo)
|
||||||
|
}
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
cache.touchVariant(testVariantKeyOne)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cache.StopPendingCountWrites(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StopPendingCountWrites() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, wantTouched := range map[imagerequest.VariantKey]int{
|
||||||
|
testVariantKeyOne: 1, testVariantKeyTwo: 0,
|
||||||
|
} {
|
||||||
|
touched := countRows(t, cache, `
|
||||||
|
SELECT COUNT(*) FROM variant_content
|
||||||
|
WHERE cache_key = ? AND last_accessed_at > ?
|
||||||
|
`, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat))
|
||||||
|
if touched != wantTouched {
|
||||||
|
t.Errorf("variant %s: %d rows with a new access time, want %d",
|
||||||
|
key, touched, wantTouched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if waiting := len(cache.pendingVariantAccessTimes); waiting != 0 {
|
||||||
|
t.Errorf("%d access times still wait to be written, want 0", waiting)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package imgcache
|
package imgcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"os"
|
"os"
|
||||||
@@ -9,8 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) *sql.DB {
|
func setupTestDB(t *testing.T) *sql.DB {
|
||||||
@@ -24,26 +21,14 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
// Create schema
|
// Create schema
|
||||||
schema := `
|
schema := `
|
||||||
CREATE TABLE source_content (
|
CREATE TABLE source_content (
|
||||||
content_hash TEXT PRIMARY KEY,
|
source_key TEXT PRIMARY KEY,
|
||||||
content_type TEXT NOT NULL,
|
|
||||||
size_bytes INTEGER NOT NULL,
|
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
CREATE TABLE source_metadata (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
source_host TEXT NOT NULL,
|
source_host TEXT NOT NULL,
|
||||||
source_path TEXT NOT NULL,
|
source_path TEXT NOT NULL,
|
||||||
source_query TEXT NOT NULL DEFAULT '',
|
source_query TEXT NOT NULL DEFAULT '',
|
||||||
path_hash TEXT NOT NULL,
|
content_type TEXT NOT NULL,
|
||||||
content_hash TEXT,
|
size_bytes INTEGER NOT NULL,
|
||||||
status_code INTEGER NOT NULL,
|
|
||||||
content_type TEXT,
|
|
||||||
response_headers TEXT,
|
|
||||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
expires_at DATETIME,
|
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
etag TEXT,
|
|
||||||
last_modified TEXT,
|
|
||||||
UNIQUE(source_host, source_path, source_query)
|
|
||||||
);
|
);
|
||||||
CREATE TABLE output_content (
|
CREATE TABLE output_content (
|
||||||
content_hash TEXT PRIMARY KEY,
|
content_hash TEXT PRIMARY KEY,
|
||||||
@@ -112,347 +97,6 @@ func setupTestCache(t *testing.T) *Cache {
|
|||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_LookupMiss(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache := setupTestCache(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
req := &imagerequest.Request{
|
|
||||||
Source: imagerequest.Source{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: testPathCat,
|
|
||||||
},
|
|
||||||
Transform: imagerequest.Transform{
|
|
||||||
Size: imagerequest.Size{Width: 800, Height: 600},
|
|
||||||
Format: imagerequest.FormatWebP,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: imagerequest.FitCover,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Hit {
|
|
||||||
t.Error("Lookup() hit = true, want false for cache miss")
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.CacheStatus != CacheMiss {
|
|
||||||
t.Errorf("Lookup() status = %v, want %v", result.CacheStatus, CacheMiss)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_StoreAndLookup(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache := setupTestCache(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
req := &imagerequest.Request{
|
|
||||||
Source: imagerequest.Source{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: testPathCat,
|
|
||||||
},
|
|
||||||
Transform: imagerequest.Transform{
|
|
||||||
Size: imagerequest.Size{Width: 800, Height: 600},
|
|
||||||
Format: imagerequest.FormatWebP,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: imagerequest.FitCover,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store source content
|
|
||||||
sourceContent := []byte("fake jpeg data")
|
|
||||||
fetchResult := &httpfetcher.FetchResult{
|
|
||||||
ContentType: testContentTypeJPEG,
|
|
||||||
Headers: map[string][]string{testHeaderContentType: {testContentTypeJPEG}},
|
|
||||||
}
|
|
||||||
|
|
||||||
contentHash, err := cache.StoreSource(
|
|
||||||
ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreSource() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if contentHash == "" {
|
|
||||||
t.Error("StoreSource() returned empty hash")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store variant
|
|
||||||
cacheKey := imagerequest.CacheKey(req)
|
|
||||||
outputContent := []byte("fake webp data")
|
|
||||||
|
|
||||||
err = cache.StoreVariant(
|
|
||||||
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now lookup should hit
|
|
||||||
result, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.Hit {
|
|
||||||
t.Error("Lookup() hit = false, want true after store")
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.CacheStatus != CacheHit {
|
|
||||||
t.Errorf("Lookup() status = %v, want %v", result.CacheStatus, CacheHit)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.CacheKey == "" {
|
|
||||||
t.Error("Lookup() returned empty cache key")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_NegativeCache(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache := setupTestCache(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
req := &imagerequest.Request{
|
|
||||||
Source: imagerequest.Source{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: "/photos/notfound.jpg",
|
|
||||||
},
|
|
||||||
Transform: imagerequest.Transform{
|
|
||||||
Size: imagerequest.Size{Width: 800, Height: 600},
|
|
||||||
Format: imagerequest.FormatWebP,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store negative cache entry
|
|
||||||
err := cache.StoreNegative(ctx, req, 404, "not found")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreNegative() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookup should return miss (negative cache is checked at service level)
|
|
||||||
result, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Hit {
|
|
||||||
t.Error("Lookup() hit = true, want false for negative cache entry")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_NegativeCacheExpiry(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Very short negative TTL for testing
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
|
||||||
StateDir: tmpDir,
|
|
||||||
NegativeTTL: 1 * time.Millisecond,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create cache: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
req := &imagerequest.Request{
|
|
||||||
Source: imagerequest.Source{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: "/photos/expired.jpg",
|
|
||||||
},
|
|
||||||
Transform: imagerequest.Transform{
|
|
||||||
Size: imagerequest.Size{Width: 800, Height: 600},
|
|
||||||
Format: imagerequest.FormatWebP,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store negative cache entry
|
|
||||||
err = cache.StoreNegative(ctx, req, 404, "not found")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreNegative() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for expiry
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
|
|
||||||
// Lookup should return miss after expiry
|
|
||||||
result, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() error = %v, want nil after expiry", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Hit {
|
|
||||||
t.Error("Lookup() hit = true, want false after negative cache expiry")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_VariantLookup(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache := setupTestCache(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
req := &imagerequest.Request{
|
|
||||||
Source: imagerequest.Source{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: "/photos/variant.jpg",
|
|
||||||
},
|
|
||||||
Transform: imagerequest.Transform{
|
|
||||||
Size: imagerequest.Size{Width: 800, Height: 600},
|
|
||||||
Format: imagerequest.FormatWebP,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: imagerequest.FitCover,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store variant
|
|
||||||
cacheKey := imagerequest.CacheKey(req)
|
|
||||||
outputContent := []byte("output data")
|
|
||||||
|
|
||||||
err := cache.StoreVariant(
|
|
||||||
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// First lookup
|
|
||||||
result1, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() first error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result1.Hit {
|
|
||||||
t.Error("Lookup() first hit = false")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second lookup should also hit (from disk)
|
|
||||||
result2, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() second error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result2.Hit {
|
|
||||||
t.Error("Lookup() second hit = false")
|
|
||||||
}
|
|
||||||
|
|
||||||
if result1.CacheKey != result2.CacheKey {
|
|
||||||
t.Error("Lookup() returned different cache keys")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache := setupTestCache(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
req := &imagerequest.Request{
|
|
||||||
Source: imagerequest.Source{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: "/photos/variantct.jpg",
|
|
||||||
},
|
|
||||||
Transform: imagerequest.Transform{
|
|
||||||
Size: imagerequest.Size{Width: 800, Height: 600},
|
|
||||||
Format: imagerequest.FormatWebP,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: imagerequest.FitCover,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store variant
|
|
||||||
cacheKey := imagerequest.CacheKey(req)
|
|
||||||
outputContent := []byte("output webp data")
|
|
||||||
|
|
||||||
err := cache.StoreVariant(
|
|
||||||
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookup
|
|
||||||
result, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.Hit {
|
|
||||||
t.Fatal("Lookup() hit = false, want true")
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetVariant should return content type
|
|
||||||
reader, size, contentType, err := cache.GetVariant(result.CacheKey)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetVariant() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = reader.Close() }()
|
|
||||||
|
|
||||||
if contentType != "image/webp" {
|
|
||||||
t.Errorf("GetVariant() ContentType = %q, want %q", contentType, "image/webp")
|
|
||||||
}
|
|
||||||
|
|
||||||
if size != int64(len(outputContent)) {
|
|
||||||
t.Errorf("GetVariant() size = %d, want %d", size, len(outputContent))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_GetVariant(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache := setupTestCache(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
req := &imagerequest.Request{
|
|
||||||
Source: imagerequest.Source{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: "/photos/output.jpg",
|
|
||||||
},
|
|
||||||
Transform: imagerequest.Transform{
|
|
||||||
Size: imagerequest.Size{Width: 800, Height: 600},
|
|
||||||
Format: imagerequest.FormatWebP,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: imagerequest.FitCover,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store variant
|
|
||||||
cacheKey := imagerequest.CacheKey(req)
|
|
||||||
outputContent := []byte("the actual output content")
|
|
||||||
|
|
||||||
err := cache.StoreVariant(
|
|
||||||
t.Context(), cacheKey, bytes.NewReader(outputContent), "image/webp")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookup to get cache key
|
|
||||||
result, err := cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get variant content
|
|
||||||
reader, _, _, err := cache.GetVariant(result.CacheKey)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetVariant() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = reader.Close() }()
|
|
||||||
|
|
||||||
buf := make([]byte, 100)
|
|
||||||
n, _ := reader.Read(buf)
|
|
||||||
|
|
||||||
if !bytes.Equal(buf[:n], outputContent) {
|
|
||||||
t.Errorf("GetVariant() content = %q, want %q", buf[:n], outputContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_Stats(t *testing.T) {
|
func TestCache_Stats(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -460,9 +104,9 @@ func TestCache_Stats(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Increment some stats
|
// Increment some stats
|
||||||
cache.IncrementStats(ctx, true, 0)
|
cache.IncrementStats(true, 0)
|
||||||
cache.IncrementStats(ctx, true, 0)
|
cache.IncrementStats(true, 0)
|
||||||
cache.IncrementStats(ctx, false, 1024)
|
cache.IncrementStats(false, 1024)
|
||||||
|
|
||||||
stats, err := cache.Stats(ctx)
|
stats, err := cache.Stats(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -547,7 +191,6 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
|||||||
dirs := []string{
|
dirs := []string{
|
||||||
"cache/sources",
|
"cache/sources",
|
||||||
"cache/variants",
|
"cache/variants",
|
||||||
"cache/metadata",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ func TestEvictionReadsTheUsageTotal(t *testing.T) {
|
|||||||
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
|
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
|
||||||
bytes.Repeat([]byte{0x75}, 200))
|
bytes.Repeat([]byte{0x75}, 200))
|
||||||
|
|
||||||
// pixa never changes a source's size, but a statement that does must
|
// Storing a source URL again changes its size, so the total must
|
||||||
// change the total too.
|
// follow a statement that changes it.
|
||||||
_, err := cache.db.ExecContext(ctx,
|
_, err := cache.db.ExecContext(ctx,
|
||||||
`UPDATE source_content SET size_bytes = 900 WHERE content_hash = ?`,
|
`UPDATE source_content SET size_bytes = 900 WHERE source_key = ?`,
|
||||||
string(kept))
|
string(kept))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to change the source's size: %v", err)
|
t.Fatalf("failed to change the source's size: %v", err)
|
||||||
@@ -155,8 +155,9 @@ func TestUsageTotalNotCorrectedFromAnOlderSum(t *testing.T) {
|
|||||||
// pass. No read the pass makes of a content table, to check its rows or
|
// pass. No read the pass makes of a content table, to check its rows or
|
||||||
// to sum them, may cover more than two rows, so a request's query waits
|
// to sum them, may cover more than two rows, so a request's query waits
|
||||||
// for one page at most. Between them the reads must still cover every
|
// for one page at most. Between them the reads must still cover every
|
||||||
// row of both tables twice, once to check it and once to sum it, and the
|
// variant row twice, once to check it and once to sum it, and every
|
||||||
// sum must put a wrong total right.
|
// source row once, as source rows are read only to sum them, and the sum
|
||||||
|
// must put a wrong total right.
|
||||||
func TestReconciliationReadsAPageAtATime(t *testing.T) {
|
func TestReconciliationReadsAPageAtATime(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -207,9 +208,10 @@ func TestReconciliationReadsAPageAtATime(t *testing.T) {
|
|||||||
coveredRows += rows
|
coveredRows += rows
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ten rows, each read once to check it and once to sum it.
|
// Five variant rows, each read once to check it and once to sum it,
|
||||||
if coveredRows != 20 {
|
// and five source rows, read only to sum them.
|
||||||
t.Errorf("the reads covered %d rows in all, want 20", coveredRows)
|
if coveredRows != 15 {
|
||||||
|
t.Errorf("the reads covered %d rows in all, want 15", coveredRows)
|
||||||
}
|
}
|
||||||
|
|
||||||
usage, err := cache.UsageBytes(ctx)
|
usage, err := cache.UsageBytes(ctx)
|
||||||
|
|||||||
@@ -193,6 +193,8 @@ func TestService_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
|
|||||||
|
|
||||||
want := cacheStatsCounters{0, requests, 1, int64(len(photo)), 1}
|
want := cacheStatsCounters{0, requests, 1, int64(len(photo)), 1}
|
||||||
|
|
||||||
|
waitForPendingCountsWritten(t, svc.cache)
|
||||||
|
|
||||||
if got := readCacheStatsCounters(t, svc.cache); got != want {
|
if got := readCacheStatsCounters(t, svc.cache); got != want {
|
||||||
t.Errorf("counters = %+v, want %+v", got, want)
|
t.Errorf("counters = %+v, want %+v", got, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import "sync"
|
|
||||||
|
|
||||||
// contentLock provides per-key mutual exclusion for content-hash keyed
|
|
||||||
// operations. StoreSource and evictSourceBlob each hold a content
|
|
||||||
// hash's lock for the full duration of their file-plus-accounting-row
|
|
||||||
// work, so a store and an eviction racing on identical content bytes
|
|
||||||
// (the real SHA-256 content-addressed dedup case, not a contrived one)
|
|
||||||
// can never interleave: the unlink of an evicted blob's file can never
|
|
||||||
// race the creation of a fresh database row for a concurrently
|
|
||||||
// re-stored copy of the same content. Entries are removed once no
|
|
||||||
// goroutine holds or is waiting for them, so a long-running process
|
|
||||||
// does not accumulate memory proportional to the number of distinct
|
|
||||||
// content hashes it has ever seen.
|
|
||||||
type contentLock struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
entries map[string]*contentLockEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
// contentLockEntry is one key's exclusion lock plus a count of
|
|
||||||
// goroutines currently holding or waiting to acquire it, used to know
|
|
||||||
// when it is safe to remove the entry from the map.
|
|
||||||
type contentLockEntry struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
count int
|
|
||||||
}
|
|
||||||
|
|
||||||
// newContentLock creates an empty contentLock.
|
|
||||||
func newContentLock() *contentLock {
|
|
||||||
return &contentLock{entries: make(map[string]*contentLockEntry)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock acquires exclusive access for key, blocking until it is
|
|
||||||
// available, and returns a function that releases it. The caller must
|
|
||||||
// invoke the returned function exactly once to release the lock.
|
|
||||||
func (c *contentLock) Lock(key string) func() {
|
|
||||||
c.mu.Lock()
|
|
||||||
|
|
||||||
entry, ok := c.entries[key]
|
|
||||||
if !ok {
|
|
||||||
entry = &contentLockEntry{}
|
|
||||||
c.entries[key] = entry
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.count++
|
|
||||||
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
entry.mu.Lock()
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
entry.mu.Unlock()
|
|
||||||
|
|
||||||
c.mu.Lock()
|
|
||||||
|
|
||||||
entry.count--
|
|
||||||
if entry.count == 0 {
|
|
||||||
delete(c.entries, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestContentLockExcludesSameKey verifies that two goroutines locking
|
|
||||||
// the same key never run their critical sections concurrently.
|
|
||||||
func TestContentLockExcludesSameKey(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
lock := newContentLock()
|
|
||||||
|
|
||||||
var (
|
|
||||||
active atomic.Int32
|
|
||||||
maxSeen int32
|
|
||||||
wg sync.WaitGroup
|
|
||||||
)
|
|
||||||
|
|
||||||
const goroutines = 20
|
|
||||||
|
|
||||||
wg.Add(goroutines)
|
|
||||||
|
|
||||||
for range goroutines {
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
unlock := lock.Lock("same-key")
|
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
n := active.Add(1)
|
|
||||||
|
|
||||||
for {
|
|
||||||
seen := atomic.LoadInt32(&maxSeen)
|
|
||||||
if n <= seen || atomic.CompareAndSwapInt32(&maxSeen, seen, n) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
|
|
||||||
active.Add(-1)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if maxSeen != 1 {
|
|
||||||
t.Errorf("max concurrent holders of the same key = %d, want 1", maxSeen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestContentLockAllowsDifferentKeys verifies that locking distinct
|
|
||||||
// keys does not serialize unrelated work: all goroutines must be able
|
|
||||||
// to enter their critical sections at once, proven by every one of
|
|
||||||
// them reaching the rendezvous point before any is allowed to
|
|
||||||
// proceed.
|
|
||||||
func TestContentLockAllowsDifferentKeys(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
lock := newContentLock()
|
|
||||||
|
|
||||||
const goroutines = 20
|
|
||||||
|
|
||||||
var (
|
|
||||||
wg sync.WaitGroup
|
|
||||||
inside atomic.Int32
|
|
||||||
reached = make(chan struct{}, goroutines)
|
|
||||||
)
|
|
||||||
|
|
||||||
wg.Add(goroutines)
|
|
||||||
|
|
||||||
release := make(chan struct{})
|
|
||||||
|
|
||||||
for i := range goroutines {
|
|
||||||
key := string(rune('a' + i))
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
unlock := lock.Lock(key)
|
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
inside.Add(1)
|
|
||||||
|
|
||||||
reached <- struct{}{}
|
|
||||||
|
|
||||||
<-release
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every goroutine must reach the rendezvous point (i.e. acquire its
|
|
||||||
// own key's lock) without needing any other to release first. If
|
|
||||||
// keys were incorrectly serialized onto one underlying lock, only
|
|
||||||
// one would get here and this would time out.
|
|
||||||
for i := range goroutines {
|
|
||||||
select {
|
|
||||||
case <-reached:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatalf("only %d/%d goroutines locking distinct keys made progress; "+
|
|
||||||
"keys may be incorrectly serialized", i, goroutines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := inside.Load(); n != goroutines {
|
|
||||||
t.Errorf("goroutines inside their critical section = %d, want %d",
|
|
||||||
n, goroutines)
|
|
||||||
}
|
|
||||||
|
|
||||||
close(release)
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestContentLockRemovesEntryAfterUnlock verifies that the internal
|
|
||||||
// entries map does not grow without bound: once no goroutine holds or
|
|
||||||
// awaits a key, its entry is removed.
|
|
||||||
func TestContentLockRemovesEntryAfterUnlock(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
lock := newContentLock()
|
|
||||||
|
|
||||||
unlock := lock.Lock("k")
|
|
||||||
|
|
||||||
lock.mu.Lock()
|
|
||||||
if _, ok := lock.entries["k"]; !ok {
|
|
||||||
lock.mu.Unlock()
|
|
||||||
t.Fatal("entry missing while lock is held")
|
|
||||||
}
|
|
||||||
lock.mu.Unlock()
|
|
||||||
|
|
||||||
unlock()
|
|
||||||
|
|
||||||
lock.mu.Lock()
|
|
||||||
defer lock.mu.Unlock()
|
|
||||||
|
|
||||||
if _, ok := lock.entries["k"]; ok {
|
|
||||||
t.Error("entry for key still present after the last holder unlocked")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+80
-273
@@ -3,7 +3,6 @@ package imgcache
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -52,12 +51,9 @@ const sqliteTimestampLayout = "2006-01-02 15:04:05"
|
|||||||
// writes (".tmp-*" patterns in the storage layer).
|
// writes (".tmp-*" patterns in the storage layer).
|
||||||
const tempFilePrefix = ".tmp-"
|
const tempFilePrefix = ".tmp-"
|
||||||
|
|
||||||
// variantMetaSuffix is the sidecar suffix VariantStorage writes next
|
// fallbackContentType is the content type reconciliation records for a
|
||||||
// to each variant file.
|
// variant file it adopts whose first bytes are not those of an output
|
||||||
const variantMetaSuffix = ".meta"
|
// format. Such a file is never served.
|
||||||
|
|
||||||
// fallbackContentType is the content type given to a variant file that
|
|
||||||
// has no readable .meta sidecar, when it is served or reconciled.
|
|
||||||
const fallbackContentType = "application/octet-stream"
|
const fallbackContentType = "application/octet-stream"
|
||||||
|
|
||||||
// UsageBytes returns the total number of bytes of cache content
|
// UsageBytes returns the total number of bytes of cache content
|
||||||
@@ -84,11 +80,11 @@ func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
|||||||
|
|
||||||
// evictionCandidate is one LRU eviction victim candidate: either a
|
// evictionCandidate is one LRU eviction victim candidate: either a
|
||||||
// processed variant (isVariant true, identified by cacheKey) or a
|
// processed variant (isVariant true, identified by cacheKey) or a
|
||||||
// source content blob (identified by contentHash).
|
// source content blob (identified by sourceKey).
|
||||||
type evictionCandidate struct {
|
type evictionCandidate struct {
|
||||||
isVariant bool
|
isVariant bool
|
||||||
cacheKey imagerequest.VariantKey
|
cacheKey imagerequest.VariantKey
|
||||||
contentHash ContentHash
|
sourceKey imagerequest.SourceKey
|
||||||
sizeBytes int64
|
sizeBytes int64
|
||||||
lastAccessedAt string
|
lastAccessedAt string
|
||||||
}
|
}
|
||||||
@@ -160,7 +156,7 @@ func (c *Cache) evictBatch(ctx context.Context, excessBytes int64) (int64, error
|
|||||||
|
|
||||||
c.log.Warn("failed to evict cache entry",
|
c.log.Warn("failed to evict cache entry",
|
||||||
"cache_key", candidate.cacheKey,
|
"cache_key", candidate.cacheKey,
|
||||||
"content_hash", candidate.contentHash,
|
"source_key", candidate.sourceKey,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -179,7 +175,7 @@ func (c *Cache) evictCandidate(ctx context.Context, candidate evictionCandidate)
|
|||||||
return c.evictVariant(ctx, candidate.cacheKey)
|
return c.evictVariant(ctx, candidate.cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.evictSourceBlob(ctx, candidate.contentHash)
|
return c.evictSourceBlob(ctx, candidate.sourceKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// evictionCandidates returns up to evictionBatchSize variants and
|
// evictionCandidates returns up to evictionBatchSize variants and
|
||||||
@@ -259,9 +255,9 @@ func (c *Cache) variantCandidates(ctx context.Context) ([]evictionCandidate, err
|
|||||||
// SQLite reads the rows in order from that column's index instead of
|
// SQLite reads the rows in order from that column's index instead of
|
||||||
// sorting the whole table.
|
// sorting the whole table.
|
||||||
const sourceCandidatesQuery = `
|
const sourceCandidatesQuery = `
|
||||||
SELECT content_hash, size_bytes, last_accessed_at
|
SELECT source_key, size_bytes, last_accessed_at
|
||||||
FROM source_content
|
FROM source_content
|
||||||
ORDER BY last_accessed_at ASC, content_hash ASC
|
ORDER BY last_accessed_at ASC, source_key ASC
|
||||||
LIMIT ?`
|
LIMIT ?`
|
||||||
|
|
||||||
// sourceCandidates returns the least recently used source blobs.
|
// sourceCandidates returns the least recently used source blobs.
|
||||||
@@ -278,14 +274,14 @@ func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, erro
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var candidate evictionCandidate
|
var candidate evictionCandidate
|
||||||
|
|
||||||
var hash string
|
var key string
|
||||||
|
|
||||||
err := rows.Scan(&hash, &candidate.sizeBytes, &candidate.lastAccessedAt)
|
err := rows.Scan(&key, &candidate.sizeBytes, &candidate.lastAccessedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan source candidate: %w", err)
|
return nil, fmt.Errorf("failed to scan source candidate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate.contentHash = ContentHash(hash)
|
candidate.sourceKey = imagerequest.SourceKey(key)
|
||||||
candidates = append(candidates, candidate)
|
candidates = append(candidates, candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,10 +294,7 @@ func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// evictVariant removes one variant: accounting row first, then the
|
// evictVariant removes one variant: accounting row first, then the
|
||||||
// content and .meta files, so the database never references a deleted
|
// file, so the database never references a deleted file.
|
||||||
// file. The metaCache entry goes before the files; a GetVariant that
|
|
||||||
// read them just before may put it back, and the next GetVariant then
|
|
||||||
// fails to open the file and removes it again.
|
|
||||||
func (c *Cache) evictVariant(
|
func (c *Cache) evictVariant(
|
||||||
ctx context.Context, cacheKey imagerequest.VariantKey,
|
ctx context.Context, cacheKey imagerequest.VariantKey,
|
||||||
) error {
|
) error {
|
||||||
@@ -311,8 +304,6 @@ func (c *Cache) evictVariant(
|
|||||||
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.metaCache.Remove(cacheKey)
|
|
||||||
|
|
||||||
err = c.variants.Delete(cacheKey)
|
err = c.variants.Delete(cacheKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -321,117 +312,18 @@ func (c *Cache) evictVariant(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceReference identifies one source_metadata row's JSON sidecar.
|
// evictSourceBlob removes one source: its row first, then its file, as
|
||||||
type sourceReference struct {
|
// evictVariant does for a variant. It holds no lock: a store of the same
|
||||||
host string
|
// source in between can leave the source's row with no file, which
|
||||||
pathHash PathHash
|
// LookupSource treats as a miss.
|
||||||
}
|
func (c *Cache) evictSourceBlob(ctx context.Context, key imagerequest.SourceKey) error {
|
||||||
|
_, err := c.db.ExecContext(ctx,
|
||||||
// evictSourceBlob removes one source content blob. All source_metadata
|
`DELETE FROM source_content WHERE source_key = ?`, string(key))
|
||||||
// rows referencing the blob are deleted together with its
|
|
||||||
// source_content row in a single transaction BEFORE the file is
|
|
||||||
// unlinked: a blob referenced by multiple source paths is only ever
|
|
||||||
// removed together with all of its references, and database rows never
|
|
||||||
// point at deleted files. The JSON metadata sidecars for the removed
|
|
||||||
// rows are deleted afterwards.
|
|
||||||
//
|
|
||||||
// The whole operation holds the content hash's lock (the same one
|
|
||||||
// StoreSource holds for its full store), so a concurrent store of
|
|
||||||
// identical content bytes can never observe the file gone but a row
|
|
||||||
// still present, or insert a fresh row between this transaction's
|
|
||||||
// commit and the file unlink below: it either runs entirely before
|
|
||||||
// this eviction starts, or is blocked until this eviction (row
|
|
||||||
// deletion and unlink together) has fully completed.
|
|
||||||
func (c *Cache) evictSourceBlob(ctx context.Context, contentHash ContentHash) error {
|
|
||||||
unlock := c.contentLocks.Lock(string(contentHash))
|
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
references, err := c.sourceReferences(ctx, contentHash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := c.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to begin eviction transaction: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = tx.Rollback() }()
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx,
|
|
||||||
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete source metadata rows: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx,
|
|
||||||
`DELETE FROM source_content WHERE content_hash = ?`, string(contentHash))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete source content row: %w", err)
|
return fmt.Errorf("failed to delete source content row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
return c.srcContent.Delete(key)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to commit eviction transaction: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.evictSourceBlobTestHook != nil {
|
|
||||||
c.evictSourceBlobTestHook(contentHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only after the rows are gone may the files be removed.
|
|
||||||
for _, reference := range references {
|
|
||||||
err := c.srcMetadata.Delete(reference.host, reference.pathHash)
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to delete metadata sidecar",
|
|
||||||
"host", reference.host, "path_hash", reference.pathHash, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = c.srcContent.Delete(contentHash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sourceReferences lists the metadata sidecar locations of every
|
|
||||||
// source_metadata row referencing the given blob.
|
|
||||||
func (c *Cache) sourceReferences(
|
|
||||||
ctx context.Context, contentHash ContentHash,
|
|
||||||
) ([]sourceReference, error) {
|
|
||||||
rows, err := c.db.QueryContext(ctx, `
|
|
||||||
SELECT source_host, path_hash FROM source_metadata WHERE content_hash = ?
|
|
||||||
`, string(contentHash))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to query source references: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var references []sourceReference
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var reference sourceReference
|
|
||||||
|
|
||||||
var pathHash string
|
|
||||||
|
|
||||||
err := rows.Scan(&reference.host, &pathHash)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to scan source reference: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reference.pathHash = PathHash(pathHash)
|
|
||||||
references = append(references, reference)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = rows.Err()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("source reference iteration failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return references, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// notifyWritePressure wakes the background evictor after a store, so
|
// notifyWritePressure wakes the background evictor after a store, so
|
||||||
@@ -586,12 +478,12 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
|
|||||||
// as long after the last pass ended as that pass took, off the request
|
// as long after the last pass ended as that pass took, off the request
|
||||||
// hot path: it adopts variant files that predate the accounting table
|
// hot path: it adopts variant files that predate the accounting table
|
||||||
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
||||||
// insert under transient DB contention), drops accounting rows whose
|
// insert under transient DB contention), drops variant accounting rows
|
||||||
// files are missing, removes source blob files the database does not
|
// whose files are missing, removes source blob files the database does
|
||||||
// know (and rows whose files are gone), removes source and variant files
|
// not know, removes source and variant files that are empty or not the
|
||||||
// that are empty or not the size their rows record, sweeps stale temp
|
// size their rows record, sweeps stale temp files left behind by crashed
|
||||||
// files left behind by crashed writes, and last checks the total cache
|
// writes, and last checks the total cache usage against the tables. It
|
||||||
// usage against the tables. It reads the tables a page at a time.
|
// reads the tables a page at a time.
|
||||||
// Running it periodically, not just once, bounds how long such drift can
|
// Running it periodically, not just once, bounds how long such drift can
|
||||||
// accumulate unaccounted for on a long-running process to about one
|
// accumulate unaccounted for on a long-running process to about one
|
||||||
// eviction interval plus ten times the last pass's time.
|
// eviction interval plus ten times the last pass's time.
|
||||||
@@ -617,20 +509,19 @@ func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.reconcileSourceRows(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.reconcileUsageTotal(ctx)
|
return c.reconcileUsageTotal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileVariantFiles walks the variant storage directory, checking
|
// reconcileFiles walks baseDir, one of the cache's storage directories,
|
||||||
// each variant file against its accounting row (reconcileVariantFile)
|
// sweeping stale temp files and passing each other file, with its name, to
|
||||||
// and sweeping stale temp files.
|
// reconcileFile. Once ctx is cancelled it stops at the next file and
|
||||||
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
// returns ctx's error.
|
||||||
|
func (c *Cache) reconcileFiles(
|
||||||
|
ctx context.Context, baseDir string,
|
||||||
|
reconcileFile func(path string, entry fs.DirEntry, name string) error,
|
||||||
|
) error {
|
||||||
return filepath.WalkDir(
|
return filepath.WalkDir(
|
||||||
c.variants.baseDir,
|
baseDir,
|
||||||
func(path string, entry fs.DirEntry, err error) error {
|
func(path string, entry fs.DirEntry, err error) error {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -648,15 +539,21 @@ func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasSuffix(name, variantMetaSuffix) {
|
return reconcileFile(path, entry, name)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.reconcileVariantFile(ctx, path, entry, imagerequest.VariantKey(name))
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reconcileVariantFiles walks the variant storage directory, checking
|
||||||
|
// each variant file against its accounting row (reconcileVariantFile)
|
||||||
|
// and sweeping stale temp files.
|
||||||
|
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
||||||
|
return c.reconcileFiles(ctx, c.variants.baseDir,
|
||||||
|
func(path string, entry fs.DirEntry, name string) error {
|
||||||
|
return c.reconcileVariantFile(ctx, path, entry, imagerequest.VariantKey(name))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// reconcileVariantFile checks one variant file against its accounting
|
// reconcileVariantFile checks one variant file against its accounting
|
||||||
// row. A file without a row is adopted into the accounting, unless it is
|
// row. A file without a row is adopted into the accounting, unless it is
|
||||||
// empty. A file that is empty, or not the size its row records, as a
|
// empty. A file that is empty, or not the size its row records, as a
|
||||||
@@ -678,7 +575,7 @@ func (c *Cache) reconcileVariantFile(
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, sql.ErrNoRows) && info.Size() > 0:
|
case errors.Is(err, sql.ErrNoRows) && info.Size() > 0:
|
||||||
return c.adoptVariantFile(ctx, path, info, cacheKey)
|
return c.adoptVariantFile(ctx, info, cacheKey)
|
||||||
case err != nil && !errors.Is(err, sql.ErrNoRows):
|
case err != nil && !errors.Is(err, sql.ErrNoRows):
|
||||||
return fmt.Errorf("failed to check variant accounting row: %w", err)
|
return fmt.Errorf("failed to check variant accounting row: %w", err)
|
||||||
case info.Size() > 0 && info.Size() == recordedSize:
|
case info.Size() > 0 && info.Size() == recordedSize:
|
||||||
@@ -693,14 +590,21 @@ func (c *Cache) reconcileVariantFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// adoptVariantFile inserts an accounting row for a variant file that
|
// adoptVariantFile inserts an accounting row for a variant file that
|
||||||
// has none, using the file's size and modification time.
|
// has none, using the file's size and modification time, and the content
|
||||||
|
// type its first bytes show, as a hit reads it.
|
||||||
func (c *Cache) adoptVariantFile(
|
func (c *Cache) adoptVariantFile(
|
||||||
ctx context.Context, path string, info fs.FileInfo, cacheKey imagerequest.VariantKey,
|
ctx context.Context, info fs.FileInfo, cacheKey imagerequest.VariantKey,
|
||||||
) error {
|
) error {
|
||||||
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
|
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
|
||||||
contentType := c.variantContentTypeFromSidecar(path)
|
contentType := fallbackContentType
|
||||||
|
|
||||||
_, err := c.db.ExecContext(ctx, `
|
content, _, detectedType, err := c.variants.LoadWithContentType(cacheKey)
|
||||||
|
if err == nil {
|
||||||
|
_ = content.Close()
|
||||||
|
contentType = detectedType
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.db.ExecContext(ctx, `
|
||||||
INSERT INTO variant_content
|
INSERT INTO variant_content
|
||||||
(cache_key, size_bytes, content_type, created_at, last_accessed_at)
|
(cache_key, size_bytes, content_type, created_at, last_accessed_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
@@ -715,23 +619,6 @@ func (c *Cache) adoptVariantFile(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// variantContentTypeFromSidecar reads the content type from a variant
|
|
||||||
// .meta sidecar, falling back to application/octet-stream.
|
|
||||||
func (c *Cache) variantContentTypeFromSidecar(variantPath string) string {
|
|
||||||
//nolint:gosec // path from cache walk
|
|
||||||
metaData, err := os.ReadFile(variantPath + variantMetaSuffix)
|
|
||||||
if err != nil {
|
|
||||||
return fallbackContentType
|
|
||||||
}
|
|
||||||
|
|
||||||
var meta VariantMeta
|
|
||||||
if json.Unmarshal(metaData, &meta) != nil || meta.ContentType == "" {
|
|
||||||
return fallbackContentType
|
|
||||||
}
|
|
||||||
|
|
||||||
return meta.ContentType
|
|
||||||
}
|
|
||||||
|
|
||||||
// reconcileVariantRows drops accounting rows whose variant files are
|
// reconcileVariantRows drops accounting rows whose variant files are
|
||||||
// missing, so the database never references deleted content.
|
// missing, so the database never references deleted content.
|
||||||
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
||||||
@@ -823,38 +710,20 @@ func queryStringColumn[T ~string](
|
|||||||
// each blob file against its source_content row (reconcileSourceFile)
|
// each blob file against its source_content row (reconcileSourceFile)
|
||||||
// and sweeping stale temp files.
|
// and sweeping stale temp files.
|
||||||
func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
|
func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
|
||||||
return filepath.WalkDir(
|
return c.reconcileFiles(ctx, c.srcContent.baseDir,
|
||||||
c.srcContent.baseDir,
|
func(path string, entry fs.DirEntry, name string) error {
|
||||||
func(path string, entry fs.DirEntry, err error) error {
|
return c.reconcileSourceFile(ctx, path, entry, imagerequest.SourceKey(name))
|
||||||
if ctx.Err() != nil {
|
})
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil || entry.IsDir() {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := entry.Name()
|
|
||||||
|
|
||||||
if strings.HasPrefix(name, tempFilePrefix) {
|
|
||||||
c.sweepStaleTempFile(path, entry)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.reconcileSourceFile(ctx, path, entry, ContentHash(name))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileSourceFile checks one source blob file against its
|
// reconcileSourceFile checks one source blob file against its
|
||||||
// source_content row. A file without a row is removed: it is
|
// source_content row. A file without a row is removed, as no lookup
|
||||||
// unreachable, as source lookups always go through source_metadata. A
|
// serves it and no eviction removes it. A file that is empty, or not the
|
||||||
// file that is empty, or not the size its row records, as a crash or a
|
// size its row records, as a crash or a restored snapshot can leave it,
|
||||||
// restored snapshot can leave it, is removed with its rows and a warning
|
// is removed with its row and a warning naming it, so the source is
|
||||||
// naming it, so the source is fetched again.
|
// fetched again.
|
||||||
func (c *Cache) reconcileSourceFile(
|
func (c *Cache) reconcileSourceFile(
|
||||||
ctx context.Context, path string, entry fs.DirEntry, contentHash ContentHash,
|
ctx context.Context, path string, entry fs.DirEntry, key imagerequest.SourceKey,
|
||||||
) error {
|
) error {
|
||||||
info, err := entry.Info()
|
info, err := entry.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -864,13 +733,13 @@ func (c *Cache) reconcileSourceFile(
|
|||||||
var recordedSize int64
|
var recordedSize int64
|
||||||
|
|
||||||
err = c.db.QueryRowContext(ctx,
|
err = c.db.QueryRowContext(ctx,
|
||||||
`SELECT size_bytes FROM source_content WHERE content_hash = ?`,
|
`SELECT size_bytes FROM source_content WHERE source_key = ?`,
|
||||||
string(contentHash),
|
string(key),
|
||||||
).Scan(&recordedSize)
|
).Scan(&recordedSize)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, sql.ErrNoRows):
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
return c.removeUntrackedSourceFile(ctx, path, contentHash)
|
return c.removeUntrackedSourceFile(path, key)
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return fmt.Errorf("failed to check source content row: %w", err)
|
return fmt.Errorf("failed to check source content row: %w", err)
|
||||||
case info.Size() > 0 && info.Size() == recordedSize:
|
case info.Size() > 0 && info.Size() == recordedSize:
|
||||||
@@ -881,96 +750,34 @@ func (c *Cache) reconcileSourceFile(
|
|||||||
c.log.Warn("removing damaged cached source file", "path", path,
|
c.log.Warn("removing damaged cached source file", "path", path,
|
||||||
"file_bytes", info.Size(), "recorded_bytes", recordedSize)
|
"file_bytes", info.Size(), "recorded_bytes", recordedSize)
|
||||||
|
|
||||||
return c.evictSourceBlob(ctx, contentHash)
|
return c.evictSourceBlob(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeUntrackedSourceFile deletes a source blob file that has no
|
// removeUntrackedSourceFile deletes a source blob file that has no
|
||||||
// source_content row. Any source_metadata rows referencing the hash
|
// source_content row.
|
||||||
// are removed first so no row ever points at a deleted file.
|
|
||||||
func (c *Cache) removeUntrackedSourceFile(
|
func (c *Cache) removeUntrackedSourceFile(
|
||||||
ctx context.Context, path string, contentHash ContentHash,
|
path string, key imagerequest.SourceKey,
|
||||||
) error {
|
) error {
|
||||||
_, err := c.db.ExecContext(ctx,
|
err := os.Remove(path)
|
||||||
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete metadata rows for untracked blob: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Remove(path)
|
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("failed to remove untracked source file: %w", err)
|
return fmt.Errorf("failed to remove untracked source file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.log.Info("removed untracked source content file", "content_hash", contentHash)
|
c.log.Info("removed untracked source content file", "source_key", key)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileSourceRows removes source_content rows (and their metadata
|
|
||||||
// references and sidecars) whose blob files are missing on disk.
|
|
||||||
func (c *Cache) reconcileSourceRows(ctx context.Context) error {
|
|
||||||
var after ContentHash
|
|
||||||
|
|
||||||
for {
|
|
||||||
hashes, err := c.sourceContentHashesAfter(ctx, after)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.reconciliationReadTestHook != nil {
|
|
||||||
c.reconciliationReadTestHook(len(hashes))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(hashes) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, hash := range hashes {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.srcContent.Exists(hash) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// The blob file is already gone; evictSourceBlob removes the
|
|
||||||
// rows and sidecars and tolerates the missing file.
|
|
||||||
err := c.evictSourceBlob(ctx, hash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.log.Info("dropped rows for missing source content file", "content_hash", hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
after = hashes[len(hashes)-1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sourceContentHashesAfter returns, in order, up to
|
|
||||||
// c.reconciliationPageSize tracked source content hashes that sort after
|
|
||||||
// the given one.
|
|
||||||
func (c *Cache) sourceContentHashesAfter(
|
|
||||||
ctx context.Context, after ContentHash,
|
|
||||||
) ([]ContentHash, error) {
|
|
||||||
return queryStringColumn[ContentHash](ctx, c.db, `
|
|
||||||
SELECT content_hash FROM source_content
|
|
||||||
WHERE content_hash > ? ORDER BY content_hash LIMIT ?
|
|
||||||
`, "source content hashes", "content hash", string(after),
|
|
||||||
c.reconciliationPageSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each of these queries sums size_bytes over the next page of rows of
|
// Each of these queries sums size_bytes over the next page of rows of
|
||||||
// one content table, the rows that sort after a key, and returns the
|
// one content table, the rows that sort after a key, and returns the
|
||||||
// page's last key, the sum and the number of rows in the page. Past the
|
// page's last key, the sum and the number of rows in the page. Past the
|
||||||
// last row the page has no rows and the key is NULL.
|
// last row the page has no rows and the key is NULL.
|
||||||
const (
|
const (
|
||||||
sourceSizePageQuery = `
|
sourceSizePageQuery = `
|
||||||
SELECT MAX(content_hash), COALESCE(SUM(size_bytes), 0), COUNT(*)
|
SELECT MAX(source_key), COALESCE(SUM(size_bytes), 0), COUNT(*)
|
||||||
FROM (
|
FROM (
|
||||||
SELECT content_hash, size_bytes FROM source_content
|
SELECT source_key, size_bytes FROM source_content
|
||||||
WHERE content_hash > ? ORDER BY content_hash LIMIT ?
|
WHERE source_key > ? ORDER BY source_key LIMIT ?
|
||||||
)`
|
)`
|
||||||
variantSizePageQuery = `
|
variantSizePageQuery = `
|
||||||
SELECT MAX(cache_key), COALESCE(SUM(size_bytes), 0), COUNT(*)
|
SELECT MAX(cache_key), COALESCE(SUM(size_bytes), 0), COUNT(*)
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
"modernc.org/sqlite"
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
@@ -83,10 +84,10 @@ func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// storeEvictionTestSource stores content as a fetched source for
|
// storeEvictionTestSource stores content as a fetched source for
|
||||||
// host/path and returns the resulting content hash.
|
// host/path and returns the resulting source key.
|
||||||
func storeEvictionTestSource(
|
func storeEvictionTestSource(
|
||||||
t *testing.T, cache *Cache, host, path string, content []byte,
|
t *testing.T, cache *Cache, host, path string, content []byte,
|
||||||
) ContentHash {
|
) imagerequest.SourceKey {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := &imagerequest.Request{
|
req := &imagerequest.Request{
|
||||||
@@ -110,14 +111,14 @@ func storeEvictionTestSource(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, err := cache.StoreSource(
|
key, err := cache.StoreSource(
|
||||||
context.Background(), req, bytes.NewReader(content), result,
|
context.Background(), req, bytes.NewReader(content), result,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreSource(%s%s) failed: %v", host, path, err)
|
t.Fatalf("StoreSource(%s%s) failed: %v", host, path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return hash
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeEvictionTestVariant stores content as a processed variant under
|
// storeEvictionTestVariant stores content as a processed variant under
|
||||||
@@ -162,13 +163,13 @@ func setVariantLastAccessed(
|
|||||||
// setSourceLastAccessed backdates the last access time of a tracked
|
// setSourceLastAccessed backdates the last access time of a tracked
|
||||||
// source content blob.
|
// source content blob.
|
||||||
func setSourceLastAccessed(
|
func setSourceLastAccessed(
|
||||||
t *testing.T, cache *Cache, hash ContentHash, when time.Time,
|
t *testing.T, cache *Cache, key imagerequest.SourceKey, when time.Time,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
res, err := cache.db.ExecContext(t.Context(),
|
res, err := cache.db.ExecContext(t.Context(),
|
||||||
`UPDATE source_content SET last_accessed_at = ? WHERE content_hash = ?`,
|
`UPDATE source_content SET last_accessed_at = ? WHERE source_key = ?`,
|
||||||
when.UTC().Format(sqliteTimestampFormat), string(hash),
|
when.UTC().Format(sqliteTimestampFormat), string(key),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to set source last_accessed_at: %v", err)
|
t.Fatalf("failed to set source last_accessed_at: %v", err)
|
||||||
@@ -180,7 +181,7 @@ func setSourceLastAccessed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if affected != 1 {
|
if affected != 1 {
|
||||||
t.Fatalf("source %s has no accounting row (affected=%d)", hash, affected)
|
t.Fatalf("source %s has no accounting row (affected=%d)", key, affected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,32 +206,31 @@ func assertNoDanglingReferences(t *testing.T, cache *Cache) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
rows, err := cache.db.QueryContext(t.Context(),
|
rows, err := cache.db.QueryContext(t.Context(),
|
||||||
`SELECT content_hash FROM source_metadata
|
`SELECT source_key FROM source_content`,
|
||||||
WHERE content_hash IS NOT NULL AND content_hash != ''`,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to query source_metadata: %v", err)
|
t.Fatalf("failed to query source_content: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var hash string
|
var key string
|
||||||
|
|
||||||
err := rows.Scan(&hash)
|
err := rows.Scan(&key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to scan content_hash: %v", err)
|
t.Fatalf("failed to scan source_key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cache.srcContent.Exists(ContentHash(hash)) {
|
if !cache.srcContent.Exists(imagerequest.SourceKey(key)) {
|
||||||
t.Errorf("source_metadata references content %s but the file is missing",
|
t.Errorf("source_content references content %s but the file is missing",
|
||||||
hash)
|
key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = rows.Err()
|
err = rows.Err()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("source_metadata iteration failed: %v", err)
|
t.Fatalf("source_content iteration failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
variantRows, err := cache.db.QueryContext(t.Context(),
|
variantRows, err := cache.db.QueryContext(t.Context(),
|
||||||
@@ -313,33 +313,6 @@ func TestUsageBytesAccountsSourceAndVariantBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUsageBytesCountsMultiReferencedBlobOnce(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
||||||
|
|
||||||
content := bytes.Repeat([]byte{0xCC}, 1200)
|
|
||||||
|
|
||||||
hashOne := storeEvictionTestSource(t, cache,
|
|
||||||
"src.example.com", "/one.jpg", content)
|
|
||||||
hashTwo := storeEvictionTestSource(t, cache,
|
|
||||||
"src.example.com", "/two.jpg", content)
|
|
||||||
|
|
||||||
if hashOne != hashTwo {
|
|
||||||
t.Fatalf("identical content produced different hashes: %s vs %s",
|
|
||||||
hashOne, hashTwo)
|
|
||||||
}
|
|
||||||
|
|
||||||
usage, err := cache.UsageBytes(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UsageBytes failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if usage != 1200 {
|
|
||||||
t.Errorf("UsageBytes = %d, want 1200 (deduplicated blob counted once)", usage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
|
func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -393,102 +366,15 @@ func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
|
|||||||
assertNoDanglingReferences(t, cache)
|
assertNoDanglingReferences(t, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvictionRemovesMultiReferencedBlobTogetherWithAllReferences(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const limit = 1000
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, limit)
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// One 800-byte blob referenced by two source paths.
|
|
||||||
sharedContent := bytes.Repeat([]byte{0xDD}, 800)
|
|
||||||
sharedHash := storeEvictionTestSource(t, cache,
|
|
||||||
"src.example.com", "/a.jpg", sharedContent)
|
|
||||||
|
|
||||||
h := storeEvictionTestSource(t, cache,
|
|
||||||
"src.example.com", "/b.jpg", sharedContent)
|
|
||||||
if h != sharedHash {
|
|
||||||
t.Fatalf("identical content produced different hashes: %s vs %s",
|
|
||||||
h, sharedHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A newer 600-byte blob referenced by one source path.
|
|
||||||
recentHash := storeEvictionTestSource(t, cache, "src.example.com", "/c.jpg",
|
|
||||||
bytes.Repeat([]byte{0xEE}, 600))
|
|
||||||
|
|
||||||
setSourceLastAccessed(t, cache, sharedHash, now.Add(-2*time.Hour))
|
|
||||||
setSourceLastAccessed(t, cache, recentHash, now.Add(-time.Minute))
|
|
||||||
|
|
||||||
err := cache.EvictToLimit(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("EvictToLimit failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
usage, err := cache.UsageBytes(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UsageBytes failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if usage > limit {
|
|
||||||
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The multi-referenced blob must be gone from disk, from
|
|
||||||
// source_content, and from BOTH source_metadata rows: references
|
|
||||||
// are removed together with the blob, never left dangling.
|
|
||||||
if cache.srcContent.Exists(sharedHash) {
|
|
||||||
t.Errorf("evicted blob %s still exists on disk", sharedHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := countRows(t, cache,
|
|
||||||
`SELECT COUNT(*) FROM source_content WHERE content_hash = ?`, string(sharedHash),
|
|
||||||
); n != 0 {
|
|
||||||
t.Errorf("evicted blob %s still has %d source_content rows", sharedHash, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := countRows(t, cache,
|
|
||||||
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(sharedHash),
|
|
||||||
); n != 0 {
|
|
||||||
t.Errorf("evicted blob %s still has %d source_metadata references", sharedHash, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The JSON metadata sidecars for both referencing paths must be
|
|
||||||
// removed along with the rows.
|
|
||||||
for _, path := range []string{"/a.jpg", "/b.jpg"} {
|
|
||||||
pathHash := HashPath(path + "?")
|
|
||||||
if cache.srcMetadata.Exists("src.example.com", pathHash) {
|
|
||||||
t.Errorf("metadata sidecar for %s must be removed with its row", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The more recently used blob survives fully intact.
|
|
||||||
if !cache.srcContent.Exists(recentHash) {
|
|
||||||
t.Errorf("recently used blob %s must survive eviction", recentHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := countRows(t, cache,
|
|
||||||
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(recentHash),
|
|
||||||
); n != 1 {
|
|
||||||
t.Errorf("recently used blob %s has %d source_metadata rows, want 1",
|
|
||||||
recentHash, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
|
func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
content := bytes.Repeat([]byte{0xDF}, 800)
|
content := bytes.Repeat([]byte{0xDF}, 800)
|
||||||
hash := storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg", content)
|
keys := []imagerequest.SourceKey{
|
||||||
|
storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg", content),
|
||||||
h := storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg", content)
|
storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg", content),
|
||||||
if h != hash {
|
|
||||||
t.Fatalf("identical content produced different hashes: %s vs %s", h, hash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||||
@@ -499,14 +385,16 @@ func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
|
|||||||
t.Fatalf("EvictToLimit failed: %v", err)
|
t.Fatalf("EvictToLimit failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cache.srcContent.Exists(hash) {
|
for _, key := range keys {
|
||||||
t.Errorf("blob %s must not be evicted while usage is under the limit", hash)
|
if !cache.srcContent.Exists(key) {
|
||||||
}
|
t.Errorf("source %s must not be evicted while usage is under the limit", key)
|
||||||
|
}
|
||||||
|
|
||||||
if n := countRows(t, cache,
|
if n := countRows(t, cache,
|
||||||
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(hash),
|
`SELECT COUNT(*) FROM source_content WHERE source_key = ?`, string(key),
|
||||||
); n != 2 {
|
); n != 1 {
|
||||||
t.Errorf("blob %s has %d source_metadata rows, want 2", hash, n)
|
t.Errorf("source %s has %d source_content rows, want 1", key, n)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cache.variants.Exists(testVariantKeyOne) {
|
if !cache.variants.Exists(testVariantKeyOne) {
|
||||||
@@ -562,13 +450,13 @@ func assertDisabledCacheWritesAreNoOps(
|
|||||||
Headers: map[string][]string{},
|
Headers: map[string][]string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, err := cache.StoreSource(ctx, req, bytes.NewReader([]byte("data")), result)
|
key, err := cache.StoreSource(ctx, req, bytes.NewReader([]byte("data")), result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreSource on disabled cache must be a no-op, got error: %v", err)
|
t.Fatalf("StoreSource on disabled cache must be a no-op, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hash != "" {
|
if key != "" {
|
||||||
t.Errorf("StoreSource on disabled cache returned hash %q, want empty", hash)
|
t.Errorf("StoreSource on disabled cache returned key %q, want empty", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,23 +469,14 @@ func assertDisabledCacheReadsAlwaysMiss(
|
|||||||
|
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
lookup, err := cache.Lookup(ctx, req)
|
key, srcType, err := cache.LookupSource(ctx, req)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup on disabled cache failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lookup.Hit {
|
|
||||||
t.Error("Lookup on disabled cache must always miss")
|
|
||||||
}
|
|
||||||
|
|
||||||
srcHash, srcType, err := cache.LookupSource(ctx, req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LookupSource on disabled cache failed: %v", err)
|
t.Fatalf("LookupSource on disabled cache failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if srcHash != "" || srcType != "" {
|
if key != "" || srcType != "" {
|
||||||
t.Errorf("LookupSource on disabled cache = (%q, %q), want empty",
|
t.Errorf("LookupSource on disabled cache = (%q, %q), want empty",
|
||||||
srcHash, srcType)
|
key, srcType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,10 +497,6 @@ func assertDisabledCacheTracksNothing(t *testing.T, cache *Cache) {
|
|||||||
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_content`); n != 0 {
|
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_content`); n != 0 {
|
||||||
t.Errorf("disabled cache wrote %d source_content rows, want 0", n)
|
t.Errorf("disabled cache wrote %d source_content rows, want 0", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_metadata`); n != 0 {
|
|
||||||
t.Errorf("disabled cache wrote %d source_metadata rows, want 0", n)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertDisabledCacheWroteNothingToDisk verifies that a disabled cache
|
// assertDisabledCacheWroteNothingToDisk verifies that a disabled cache
|
||||||
@@ -732,7 +607,7 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
|||||||
for i, key := range keys {
|
for i, key := range keys {
|
||||||
content := bytes.Repeat([]byte{fills[i]}, 1000)
|
content := bytes.Repeat([]byte{fills[i]}, 1000)
|
||||||
|
|
||||||
_, err = cache.variants.Store(key, bytes.NewReader(content), "image/webp")
|
_, err = cache.variants.Store(key, bytes.NewReader(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to store variant file: %v", err)
|
t.Fatalf("failed to store variant file: %v", err)
|
||||||
}
|
}
|
||||||
@@ -784,7 +659,7 @@ func TestStartEvictionReconcilesAccountingWithDisk(t *testing.T) {
|
|||||||
untracked := bytes.Repeat([]byte{0x31}, 1000)
|
untracked := bytes.Repeat([]byte{0x31}, 1000)
|
||||||
|
|
||||||
_, err := cache.variants.Store(
|
_, err := cache.variants.Store(
|
||||||
testVariantKeyOne, bytes.NewReader(untracked), "image/webp",
|
testVariantKeyOne, bytes.NewReader(untracked),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to store untracked variant file: %v", err)
|
t.Fatalf("failed to store untracked variant file: %v", err)
|
||||||
@@ -886,7 +761,7 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) {
|
|||||||
untracked := bytes.Repeat([]byte{0x41}, 900)
|
untracked := bytes.Repeat([]byte{0x41}, 900)
|
||||||
|
|
||||||
_, err = cache.variants.Store(
|
_, err = cache.variants.Store(
|
||||||
"aabbccdd0099", bytes.NewReader(untracked), "image/webp",
|
"aabbccdd0099", bytes.NewReader(untracked),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to store untracked variant file: %v", err)
|
t.Fatalf("failed to store untracked variant file: %v", err)
|
||||||
@@ -1064,9 +939,15 @@ func TestStopEvictionInterruptsPassInProgress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestEvictToLimitStopsAtNextCandidateOnceCancelled cancels the context
|
// TestEvictToLimitStopsAtNextCandidateOnceCancelled cancels the context
|
||||||
// while the oldest of three source blobs is being evicted, and checks that
|
// once the oldest of three sources is evicted, while the next one waits
|
||||||
// EvictToLimit then returns context.Canceled without evicting the other
|
// for the database, and checks that EvictToLimit then returns
|
||||||
// two or logging a warning for either of them.
|
// context.Canceled, with that source gone and the other two kept, and
|
||||||
|
// logs no warning. The test database has one connection. When the
|
||||||
|
// deletion of the oldest source's row commits, a commit hook holds the
|
||||||
|
// deletion until the test waits for the connection, so the test takes it
|
||||||
|
// as soon as the deletion is done. The eviction of the next source then
|
||||||
|
// waits for the connection, and the test cancels the context while it
|
||||||
|
// waits.
|
||||||
func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
|
func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1076,7 +957,7 @@ func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
|
|||||||
|
|
||||||
cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil))
|
cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil))
|
||||||
|
|
||||||
hashes := []ContentHash{
|
keys := []imagerequest.SourceKey{
|
||||||
storeEvictionTestSource(t, cache, "cancel.example.com", "/a.jpg",
|
storeEvictionTestSource(t, cache, "cancel.example.com", "/a.jpg",
|
||||||
bytes.Repeat([]byte{0x61}, 1000)),
|
bytes.Repeat([]byte{0x61}, 1000)),
|
||||||
storeEvictionTestSource(t, cache, "cancel.example.com", "/b.jpg",
|
storeEvictionTestSource(t, cache, "cancel.example.com", "/b.jpg",
|
||||||
@@ -1087,69 +968,155 @@ func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
|
|||||||
|
|
||||||
base := time.Now().Add(-time.Hour)
|
base := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
for i, hash := range hashes {
|
for i, key := range keys {
|
||||||
setSourceLastAccessed(t, cache, hash, base.Add(time.Duration(i)*time.Minute))
|
setSourceLastAccessed(t, cache, key, base.Add(time.Duration(i)*time.Minute))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitsBefore := cache.db.Stats().WaitCount
|
||||||
|
oldestRowDeleted := make(chan struct{})
|
||||||
|
|
||||||
|
var oldestRowDeletedOnce sync.Once
|
||||||
|
|
||||||
|
setCommitHook(t, cache, func() int32 {
|
||||||
|
oldestRowDeletedOnce.Do(func() {
|
||||||
|
close(oldestRowDeleted)
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for cache.db.Stats().WaitCount == waitsBefore && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cache.evictSourceBlobTestHook = func(ContentHash) { cancel() }
|
evicted := make(chan error, 1)
|
||||||
|
|
||||||
err := cache.EvictToLimit(ctx)
|
go func() { evicted <- cache.EvictToLimit(ctx) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-oldestRowDeleted:
|
||||||
|
case err := <-evicted:
|
||||||
|
t.Fatalf("EvictToLimit() returned %v without deleting a row", err)
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("EvictToLimit never deleted a row")
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDatabase := holdDatabase(t, cache)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
waitForConnectionWaits(t, cache.db, waitsBefore+2)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := <-evicted
|
||||||
t.Logf("EvictToLimit() error = %v", err)
|
t.Logf("EvictToLimit() error = %v", err)
|
||||||
t.Logf("log output: %s", logBuf.String())
|
t.Logf("log output: %s", logBuf.String())
|
||||||
|
|
||||||
|
// The checks below read the database, so the connection goes back.
|
||||||
|
releaseDatabase()
|
||||||
|
|
||||||
|
setCommitHook(t, cache, nil)
|
||||||
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Errorf("EvictToLimit() error = %v, want context.Canceled", err)
|
t.Errorf("EvictToLimit() error = %v, want context.Canceled", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache.srcContent.Exists(hashes[0]) {
|
assertOnlyOldestSourceEvicted(t, cache, keys)
|
||||||
t.Errorf("source blob %s, evicted when the context was cancelled, "+
|
|
||||||
"is still on disk", hashes[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, hash := range hashes[1:] {
|
|
||||||
if !cache.srcContent.Exists(hash) {
|
|
||||||
t.Errorf("source blob %s was evicted after the context was cancelled", hash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(logBuf.String(), `"level":"WARN"`) {
|
if strings.Contains(logBuf.String(), `"level":"WARN"`) {
|
||||||
t.Errorf("EvictToLimit logged a warning after the context was cancelled")
|
t.Errorf("EvictToLimit logged a warning after the context was cancelled")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCommitHook makes the SQLite driver call hook each time a write on
|
||||||
|
// the test database's one connection commits, or stops it when hook is
|
||||||
|
// nil.
|
||||||
|
func setCommitHook(t *testing.T, cache *Cache, hook sqlite.CommitHookFn) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
conn, err := cache.db.Conn(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to take the database connection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
err = conn.Raw(func(driverConn any) error {
|
||||||
|
hooks, ok := driverConn.(sqlite.HookRegisterer)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("the database connection is a %T, which takes no hooks",
|
||||||
|
driverConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks.RegisterCommitHook(hook)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set the commit hook: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertOnlyOldestSourceEvicted checks that the first of keys has neither
|
||||||
|
// its file nor its row, and that every other one has both.
|
||||||
|
func assertOnlyOldestSourceEvicted(
|
||||||
|
t *testing.T, cache *Cache, keys []imagerequest.SourceKey,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if cache.srcContent.Exists(keys[0]) {
|
||||||
|
t.Errorf("source %s, evicted when the context was cancelled, "+
|
||||||
|
"is still on disk", keys[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys[1:] {
|
||||||
|
if !cache.srcContent.Exists(key) {
|
||||||
|
t.Errorf("source %s was evicted after the context was cancelled", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM source_content WHERE source_key = ?`, string(key),
|
||||||
|
); n != 1 {
|
||||||
|
t.Errorf("source %s, kept, has %d source_content rows, want 1", key, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
assertNoDanglingReferences(t, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStopEvictionReturnsWhenItsContextEnds pauses an eviction pass where
|
// TestStopEvictionReturnsWhenItsContextEnds pauses the startup
|
||||||
// cancellation cannot reach it, after a source blob's rows are deleted and
|
// reconciliation pass in the hook called after each of its reads, where
|
||||||
// before its file is removed, and checks that StopEviction returns its
|
// cancellation cannot reach it, and checks that StopEviction returns an
|
||||||
// context's error when that context ends instead of waiting for the pass.
|
// error wrapping its context's error when that context ends, instead of
|
||||||
// Once the pass goes on, the goroutine exits and no row points at a
|
// waiting for the pass. Once the pass goes on, the goroutine exits, and a
|
||||||
// missing file.
|
// second StopEviction returns nil.
|
||||||
func TestStopEvictionReturnsWhenItsContextEnds(t *testing.T) {
|
func TestStopEvictionReturnsWhenItsContextEnds(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
paused := make(chan struct{})
|
paused := make(chan struct{})
|
||||||
resume := make(chan struct{})
|
resume := make(chan struct{})
|
||||||
|
|
||||||
cache.evictSourceBlobTestHook = func(ContentHash) {
|
var pauseOnce sync.Once
|
||||||
close(paused)
|
|
||||||
<-resume
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := storeEvictionTestSource(t, cache, "stop.example.com", "/a.jpg",
|
cache.reconciliationReadTestHook = func(int) {
|
||||||
bytes.Repeat([]byte{0x61}, 1000))
|
pauseOnce.Do(func() {
|
||||||
|
close(paused)
|
||||||
|
<-resume
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
cache.StartEviction(time.Hour)
|
cache.StartEviction(time.Hour)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-paused:
|
case <-paused:
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
t.Fatal("the eviction pass never reached the source blob")
|
t.Fatal("the reconciliation pass never made a read")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
||||||
@@ -1168,12 +1135,6 @@ func TestStopEvictionReturnsWhenItsContextEnds(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second StopEviction() error = %v, want nil", err)
|
t.Fatalf("second StopEviction() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
|
||||||
|
|
||||||
if cache.srcContent.Exists(hash) {
|
|
||||||
t.Errorf("source blob %s is still on disk after its rows were deleted", hash)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestReconciliationWalksStopOnceCancelled checks that both directory
|
// TestReconciliationWalksStopOnceCancelled checks that both directory
|
||||||
@@ -1245,131 +1206,3 @@ func TestReconciliationPassLogsNoWarningOnceCancelled(t *testing.T) {
|
|||||||
t.Errorf("runReconciliationPass logged a warning with a cancelled context")
|
t.Errorf("runReconciliationPass logged a warning with a cancelled context")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEvictSourceBlobExcludesConcurrentStoreOfIdenticalContent exercises
|
|
||||||
// the exact TOCTOU window between evictSourceBlob's row-deletion
|
|
||||||
// transaction commit and its content file unlink: a concurrent
|
|
||||||
// StoreSource for a different source path whose content hashes to the
|
|
||||||
// same value (real SHA-256 dedup, not a contrived case) must not be
|
|
||||||
// able to insert a fresh row referencing the file while eviction is
|
|
||||||
// mid-unlink, and must not lose its own store once eviction has fully
|
|
||||||
// released the content hash.
|
|
||||||
func TestEvictSourceBlobExcludesConcurrentStoreOfIdenticalContent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
content := bytes.Repeat([]byte{0x55}, 400)
|
|
||||||
|
|
||||||
hash := storeEvictionTestSource(t, cache,
|
|
||||||
"race.example.com", "/first.jpg", content)
|
|
||||||
|
|
||||||
proceed := make(chan struct{})
|
|
||||||
storeAttempted := make(chan struct{})
|
|
||||||
|
|
||||||
cache.evictSourceBlobTestHook = func(gotHash ContentHash) {
|
|
||||||
if gotHash != hash {
|
|
||||||
t.Errorf("test hook invoked for hash %s, want %s", gotHash, hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
close(storeAttempted)
|
|
||||||
<-proceed
|
|
||||||
}
|
|
||||||
|
|
||||||
evictDone := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
evictDone <- cache.evictSourceBlob(ctx, hash)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait until eviction has committed its delete transaction and is
|
|
||||||
// paused (inside the test hook) immediately before unlinking the
|
|
||||||
// content file: exactly the window the review flagged.
|
|
||||||
<-storeAttempted
|
|
||||||
|
|
||||||
storeDone := storeIdenticalContentConcurrently(ctx, cache, content)
|
|
||||||
|
|
||||||
// The concurrent store must not be able to complete while eviction
|
|
||||||
// still holds the content hash (i.e. before the file is unlinked):
|
|
||||||
// if it could, it would insert a row referencing a file about to be
|
|
||||||
// removed out from under it.
|
|
||||||
select {
|
|
||||||
case err := <-storeDone:
|
|
||||||
t.Fatalf("StoreSource for identical content completed (err=%v) while eviction "+
|
|
||||||
"still held the content hash open between commit and unlink; the store and "+
|
|
||||||
"the evict of identical content are not mutually exclusive", err)
|
|
||||||
case <-time.After(200 * time.Millisecond):
|
|
||||||
// Expected: the store is blocked behind eviction's exclusion.
|
|
||||||
}
|
|
||||||
|
|
||||||
close(proceed)
|
|
||||||
|
|
||||||
err := <-evictDone
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("evictSourceBlob failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = <-storeDone
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StoreSource failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
|
||||||
|
|
||||||
dupHash, _, err := cache.LookupSource(ctx, &imagerequest.Request{
|
|
||||||
Source: imagerequest.Source{
|
|
||||||
Host: "race.example.com",
|
|
||||||
Path: "/dup.jpg",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LookupSource failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if dupHash == "" {
|
|
||||||
t.Fatal("re-stored blob was lost: the store legitimately ran after eviction " +
|
|
||||||
"released the content hash and must have recreated the file and row")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cache.srcContent.Exists(dupHash) {
|
|
||||||
t.Errorf("source_content/source_metadata references %s but its file is missing",
|
|
||||||
dupHash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// storeIdenticalContentConcurrently starts a StoreSource for a second
|
|
||||||
// source path whose body hashes to the same content hash, returning the
|
|
||||||
// channel its error is delivered on.
|
|
||||||
func storeIdenticalContentConcurrently(
|
|
||||||
ctx context.Context, cache *Cache, content []byte,
|
|
||||||
) chan error {
|
|
||||||
storeDone := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
req := &imagerequest.Request{
|
|
||||||
Source: imagerequest.Source{
|
|
||||||
Host: "race.example.com",
|
|
||||||
Path: "/dup.jpg",
|
|
||||||
},
|
|
||||||
Transform: imagerequest.Transform{
|
|
||||||
Format: imagerequest.FormatJPEG,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: imagerequest.FitCover,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
result := &httpfetcher.FetchResult{
|
|
||||||
StatusCode: 200,
|
|
||||||
ContentType: testContentTypeJPEG,
|
|
||||||
ContentLength: int64(len(content)),
|
|
||||||
Headers: map[string][]string{
|
|
||||||
testHeaderContentType: {testContentTypeJPEG},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := cache.StoreSource(ctx, req, bytes.NewReader(content), result)
|
|
||||||
storeDone <- err
|
|
||||||
}()
|
|
||||||
|
|
||||||
return storeDone
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -77,11 +77,11 @@ func TestService_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
|||||||
|
|
||||||
_ = resp.Content.Close()
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
contentHash, _, err := svc.cache.LookupSource(t.Context(),
|
sourceKey, _, err := svc.cache.LookupSource(t.Context(),
|
||||||
widthOnlyRequest(fixtures, 50))
|
widthOnlyRequest(fixtures, 50))
|
||||||
if err != nil || contentHash == "" {
|
if err != nil || sourceKey == "" {
|
||||||
t.Fatalf("LookupSource() = %q, %v; want the cached source",
|
t.Fatalf("LookupSource() = %q, %v; want the cached source",
|
||||||
contentHash, err)
|
sourceKey, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
release := holdProcessingSlot(t, svc.processor)
|
release := holdProcessingSlot(t, svc.processor)
|
||||||
@@ -103,7 +103,7 @@ func TestService_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
|||||||
// before waiting, it would have read it by now.
|
// before waiting, it would have read it by now.
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
err = os.WriteFile(svc.cache.srcContent.hashToPath(contentHash),
|
err = os.WriteFile(svc.cache.srcContent.keyToPath(sourceKey),
|
||||||
generateTestJPEG(t, 100, 50, color.RGBA{0, 0, 255, 255}), 0o600)
|
generateTestJPEG(t, 100, 50, color.RGBA{0, 0, 255, 255}), 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to rewrite the cached source: %v", err)
|
t.Fatalf("failed to rewrite the cached source: %v", err)
|
||||||
|
|||||||
@@ -1,361 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
||||||
)
|
|
||||||
|
|
||||||
// webpRequest returns a request for a 100x100 WebP variant of path.
|
|
||||||
func webpRequest(path string) *imagerequest.Request {
|
|
||||||
return &imagerequest.Request{
|
|
||||||
Source: imagerequest.Source{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: path,
|
|
||||||
},
|
|
||||||
Transform: imagerequest.Transform{
|
|
||||||
Size: imagerequest.Size{Width: 100, Height: 100},
|
|
||||||
Format: imagerequest.FormatWebP,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: imagerequest.FitCover,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertVariantServed checks that GetVariant serves key with the given
|
|
||||||
// content and the image/webp content type storeEvictionTestVariant stores.
|
|
||||||
func assertVariantServed(
|
|
||||||
t *testing.T, cache *Cache, key imagerequest.VariantKey, content []byte,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
reader, size, contentType, err := cache.GetVariant(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetVariant(%s) error = %v", key, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = reader.Close() }()
|
|
||||||
|
|
||||||
got, err := io.ReadAll(reader)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading variant %s: %v", key, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !bytes.Equal(got, content) {
|
|
||||||
t.Errorf("GetVariant(%s) content = %q, want %q", key, got, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
if size != int64(len(content)) {
|
|
||||||
t.Errorf("GetVariant(%s) size = %d, want %d", key, size, len(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
if contentType != testContentTypeWebP {
|
|
||||||
t.Errorf("GetVariant(%s) content type = %q, want %q",
|
|
||||||
key, contentType, testContentTypeWebP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertVariantNotFound checks that GetVariant refuses key with
|
|
||||||
// ErrNotFound.
|
|
||||||
func assertVariantNotFound(
|
|
||||||
t *testing.T, cache *Cache, key imagerequest.VariantKey,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
reader, _, _, err := cache.GetVariant(key)
|
|
||||||
if err == nil {
|
|
||||||
_ = reader.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.Is(err, ErrNotFound) {
|
|
||||||
t.Errorf("GetVariant(%s) error = %v, want ErrNotFound", key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertLookupMisses checks that Lookup reports request as a miss.
|
|
||||||
func assertLookupMisses(
|
|
||||||
t *testing.T, cache *Cache, request *imagerequest.Request,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
lookup, err := cache.Lookup(t.Context(), request)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Lookup(%s) error = %v", request.Source.Path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lookup.Hit {
|
|
||||||
t.Errorf("Lookup(%s) is a hit, want a miss", request.Source.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// renameFile renames the file at from to to.
|
|
||||||
func renameFile(t *testing.T, from, to string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
err := os.Rename(from, to)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("renaming %s: %v", from, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSecondHitDoesNotReadMetaFile checks that once a variant has been
|
|
||||||
// stored or read, a hit takes its content type from memory: with the
|
|
||||||
// .meta file deleted, GetVariant must still return the stored content
|
|
||||||
// type rather than the application/octet-stream it uses without one.
|
|
||||||
func TestSecondHitDoesNotReadMetaFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
// A second Cache on the same state directory starts with nothing in
|
|
||||||
// memory, as pixad does after a restart, so its first read uses the
|
|
||||||
// .meta file.
|
|
||||||
restarted, err := NewCache(cache.db, cache.config)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewCache() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertVariantServed(t, restarted, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
err = os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("removing .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertVariantServed(t, cache, testVariantKeyOne, content)
|
|
||||||
assertVariantServed(t, restarted, testVariantKeyOne, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReadDuringStoreKeepsStoredContentType checks that a GetVariant
|
|
||||||
// which began before StoreVariant finished cannot replace the content
|
|
||||||
// type the store kept in memory. Such a read can find the variant file
|
|
||||||
// but not yet its .meta file, and so gets application/octet-stream. The
|
|
||||||
// test deletes the .meta file after the store, then runs the part of
|
|
||||||
// GetVariant that comes after its check of memory.
|
|
||||||
func TestReadDuringStoreKeepsStoredContentType(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
err := os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("removing .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, _, contentType, err := cache.loadVariantWithMeta(testVariantKeyOne)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("loadVariantWithMeta(%s) error = %v", testVariantKeyOne, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
t.Logf("the read without a .meta file got content type %q", contentType)
|
|
||||||
|
|
||||||
assertVariantServed(t, cache, testVariantKeyOne, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReadOfOlderMetaFileKeepsStoredContentType checks that a read
|
|
||||||
// which got its content type from a .meta file that StoreVariant had
|
|
||||||
// not yet rewritten cannot replace the type the store kept in memory.
|
|
||||||
// The test writes such a .meta file, with a different content type,
|
|
||||||
// after the store, then runs the part of GetVariant that comes after
|
|
||||||
// its check of memory.
|
|
||||||
func TestReadOfOlderMetaFileKeepsStoredContentType(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
olderMeta, err := json.Marshal(VariantMeta{
|
|
||||||
ContentType: testContentTypeJPEG,
|
|
||||||
Size: int64(len(content)),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("encoding .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metaPath := cache.variants.keyToPath(testVariantKeyOne) + ".meta"
|
|
||||||
|
|
||||||
err = os.WriteFile(metaPath, olderMeta, StorageFilePerm)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writing .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, _, contentType, err := cache.loadVariantWithMeta(testVariantKeyOne)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("loadVariantWithMeta(%s) error = %v", testVariantKeyOne, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
if contentType != testContentTypeJPEG {
|
|
||||||
t.Fatalf("loadVariantWithMeta(%s) content type = %q, want %q from the .meta file",
|
|
||||||
testVariantKeyOne, contentType, testContentTypeJPEG)
|
|
||||||
}
|
|
||||||
|
|
||||||
kept, _ := cache.metaCache.Get(testVariantKeyOne)
|
|
||||||
if kept != testContentTypeWebP {
|
|
||||||
t.Errorf("content type in memory = %q, want the stored %q",
|
|
||||||
kept, testContentTypeWebP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFailedReadDuringStoreKeepsStoredContentType checks that a read
|
|
||||||
// which found no .meta file cannot leave application/octet-stream in
|
|
||||||
// memory, even when another read has removed the content type
|
|
||||||
// StoreVariant kept there. In this order: the store; a read that finds
|
|
||||||
// the variant in memory but cannot open its file, and so removes it
|
|
||||||
// from memory; a read that opened the variant file before the store
|
|
||||||
// wrote its .meta file. Later hits must get the stored content type.
|
|
||||||
func TestFailedReadDuringStoreKeepsStoredContentType(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
variantPath := cache.variants.keyToPath(testVariantKeyOne)
|
|
||||||
metaPath := variantPath + ".meta"
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
||||||
|
|
||||||
renameFile(t, variantPath, variantPath+".hidden")
|
|
||||||
assertVariantNotFound(t, cache, testVariantKeyOne)
|
|
||||||
renameFile(t, variantPath+".hidden", variantPath)
|
|
||||||
|
|
||||||
renameFile(t, metaPath, metaPath+".hidden")
|
|
||||||
|
|
||||||
reader, _, contentType, err := cache.GetVariant(testVariantKeyOne)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetVariant(%s) error = %v", testVariantKeyOne, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
t.Logf("the read without a .meta file got content type %q", contentType)
|
|
||||||
|
|
||||||
renameFile(t, metaPath+".hidden", metaPath)
|
|
||||||
|
|
||||||
assertVariantServed(t, cache, testVariantKeyOne, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEvictedVariantIsNotServed checks that a variant the evictor
|
|
||||||
// removed is a miss and cannot be read, although it had been stored
|
|
||||||
// and served before.
|
|
||||||
func TestEvictedVariantIsNotServed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1500)
|
|
||||||
|
|
||||||
oldRequest := webpRequest("/old.jpg")
|
|
||||||
newRequest := webpRequest("/new.jpg")
|
|
||||||
oldKey := imagerequest.CacheKey(oldRequest)
|
|
||||||
newKey := imagerequest.CacheKey(newRequest)
|
|
||||||
oldContent := bytes.Repeat([]byte{0x01}, 1000)
|
|
||||||
newContent := bytes.Repeat([]byte{0x02}, 1000)
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, oldKey, oldContent)
|
|
||||||
storeEvictionTestVariant(t, cache, newKey, newContent)
|
|
||||||
assertVariantServed(t, cache, oldKey, oldContent)
|
|
||||||
setVariantLastAccessed(t, cache, oldKey, time.Now().Add(-time.Hour))
|
|
||||||
|
|
||||||
err := cache.EvictToLimit(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("EvictToLimit() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertLookupMisses(t, cache, oldRequest)
|
|
||||||
assertVariantNotFound(t, cache, oldKey)
|
|
||||||
assertVariantServed(t, cache, newKey, newContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVariantDeletedFromDiskIsNotServed checks that a variant whose
|
|
||||||
// file was deleted by something other than the evictor cannot be read,
|
|
||||||
// and is a miss afterwards.
|
|
||||||
func TestVariantDeletedFromDiskIsNotServed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
|
|
||||||
request := webpRequest("/deleted.jpg")
|
|
||||||
key := imagerequest.CacheKey(request)
|
|
||||||
content := []byte("webp variant bytes")
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, key, content)
|
|
||||||
assertVariantServed(t, cache, key, content)
|
|
||||||
|
|
||||||
err := os.Remove(cache.variants.keyToPath(key))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("removing variant file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertVariantNotFound(t, cache, key)
|
|
||||||
assertLookupMisses(t, cache, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConcurrentVariantStoreReadAndEvict stores, reads and evicts
|
|
||||||
// variants from several goroutines at once, for the race detector.
|
|
||||||
func TestConcurrentVariantStoreReadAndEvict(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
for goroutine := range 8 {
|
|
||||||
wg.Go(func() {
|
|
||||||
key := imagerequest.VariantKey(fmt.Sprintf("aabbccdd01%02d", goroutine))
|
|
||||||
content := []byte(key)
|
|
||||||
|
|
||||||
for range 20 {
|
|
||||||
err := cache.StoreVariant(
|
|
||||||
ctx, key, bytes.NewReader(content), testContentTypeWebP)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("StoreVariant(%s) error = %v", key, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, _, contentType, err := cache.GetVariant(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("GetVariant(%s) error = %v", key, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
if contentType != testContentTypeWebP {
|
|
||||||
t.Errorf("GetVariant(%s) content type = %q, want %q",
|
|
||||||
key, contentType, testContentTypeWebP)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cache.evictVariant(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("evictVariant(%s) error = %v", key, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
assertVariantNotFound(t, cache, key)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
@@ -147,13 +147,13 @@ func TestService_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
|
|||||||
|
|
||||||
_ = resp.Content.Close()
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
contentHash, _, err := svc.cache.LookupSource(t.Context(),
|
sourceKey, _, err := svc.cache.LookupSource(t.Context(),
|
||||||
photoVariant(fixtures, 85, imagerequest.FitCover))
|
photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
if err != nil || contentHash == "" {
|
if err != nil || sourceKey == "" {
|
||||||
t.Fatalf("LookupSource() = %q, %v, want the cached source", contentHash, err)
|
t.Fatalf("LookupSource() = %q, %v, want the cached source", sourceKey, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = svc.cache.evictSourceBlob(t.Context(), contentHash)
|
err = svc.cache.evictSourceBlob(t.Context(), sourceKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("evictSourceBlob() error = %v", err)
|
t.Fatalf("evictSourceBlob() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -67,12 +65,12 @@ func cachedSourcePath(
|
|||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
contentHash, _, err := svc.cache.LookupSource(t.Context(), req)
|
sourceKey, _, err := svc.cache.LookupSource(t.Context(), req)
|
||||||
if err != nil || contentHash == "" {
|
if err != nil || sourceKey == "" {
|
||||||
t.Fatalf("LookupSource() = %q, %v; want the cached source", contentHash, err)
|
t.Fatalf("LookupSource() = %q, %v; want the cached source", sourceKey, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return svc.cache.srcContent.hashToPath(contentHash)
|
return svc.cache.srcContent.keyToPath(sourceKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileAsAtStartup runs the cache's reconciliation pass, as pixa does
|
// reconcileAsAtStartup runs the cache's reconciliation pass, as pixa does
|
||||||
@@ -130,6 +128,8 @@ func TestService_Get_FetchesAgainForEmptyCachedSource(t *testing.T) {
|
|||||||
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
|
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||||
getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
|
getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
|
||||||
|
|
||||||
|
waitForPendingCountsWritten(t, svc.cache)
|
||||||
|
|
||||||
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
||||||
t.Logf("upstream fetches = %d", fetches)
|
t.Logf("upstream fetches = %d", fetches)
|
||||||
|
|
||||||
@@ -166,6 +166,8 @@ func TestService_Get_FetchesAgainForPartialCachedSource(t *testing.T) {
|
|||||||
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
|
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||||
getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
|
getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
|
||||||
|
|
||||||
|
waitForPendingCountsWritten(t, svc.cache)
|
||||||
|
|
||||||
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
||||||
t.Logf("upstream fetches = %d", fetches)
|
t.Logf("upstream fetches = %d", fetches)
|
||||||
|
|
||||||
@@ -241,8 +243,7 @@ func TestReconciliationRemovesEmptyVariantFileWithoutRow(t *testing.T) {
|
|||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
_, err := cache.variants.Store(testVariantKeyOne, bytes.NewReader(nil),
|
_, err := cache.variants.Store(testVariantKeyOne, bytes.NewReader(nil))
|
||||||
testContentTypeWebP)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to store an empty variant file: %v", err)
|
t.Fatalf("failed to store an empty variant file: %v", err)
|
||||||
}
|
}
|
||||||
@@ -263,63 +264,10 @@ func TestReconciliationRemovesEmptyVariantFileWithoutRow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVariantStorage_StoreReplacesMetaFile stores a variant over one
|
// TestContentStorage_StoreReplacesFileOfAnotherSize checks that a
|
||||||
// already stored and checks that Store puts a new .meta file in place of
|
// store over a file of another size at the source's path, such as a
|
||||||
// the old one, as it does the image, rather than writing into the old
|
|
||||||
// file, which a crash could leave empty. The old .meta file is given a
|
|
||||||
// second name before the store, so that it can be told apart from the new
|
|
||||||
// one after it.
|
|
||||||
func TestVariantStorage_StoreReplacesMetaFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
storage, key := storeTestVariant(t)
|
|
||||||
metaPath := storage.keyToPath(key) + variantMetaSuffix
|
|
||||||
oldMetaPath := metaPath + ".old"
|
|
||||||
|
|
||||||
err := os.Link(metaPath, oldMetaPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("giving the .meta file a second name: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = storage.Store(key, bytes.NewReader([]byte("new variant data")),
|
|
||||||
testContentTypeJPEG)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Store() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
oldMeta, err := os.Stat(oldMetaPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stat of the old .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
newMeta, err := os.Stat(metaPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stat of the new .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.SameFile(oldMeta, newMeta) {
|
|
||||||
t.Error("Store() wrote into the old .meta file instead of putting " +
|
|
||||||
"a new one in its place")
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, _, contentType, err := storage.LoadWithMeta(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadWithMeta() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
t.Logf("content type after the store: %s", contentType)
|
|
||||||
|
|
||||||
if contentType != testContentTypeJPEG {
|
|
||||||
t.Errorf("content type = %q, want %q", contentType, testContentTypeJPEG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestContentStorage_StoreHashedReplacesFileOfAnotherSize checks that a
|
|
||||||
// store over a file of another size at the content's path, such as a
|
|
||||||
// partial file a crash left, replaces it with the content.
|
// partial file a crash left, replaces it with the content.
|
||||||
func TestContentStorage_StoreHashedReplacesFileOfAnotherSize(t *testing.T) {
|
func TestContentStorage_StoreReplacesFileOfAnotherSize(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
storage, err := NewContentStorage(t.TempDir())
|
storage, err := NewContentStorage(t.TempDir())
|
||||||
@@ -328,13 +276,12 @@ func TestContentStorage_StoreHashedReplacesFileOfAnotherSize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
content := []byte("the whole source image")
|
content := []byte("the whole source image")
|
||||||
sum := sha256.Sum256(content)
|
key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
|
||||||
hash := ContentHash(hex.EncodeToString(sum[:]))
|
path := storage.keyToPath(key)
|
||||||
path := storage.hashToPath(hash)
|
|
||||||
|
|
||||||
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("creating the content's directory: %v", err)
|
t.Fatalf("creating the source's directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.WriteFile(path, content[:5], StorageFilePerm)
|
err = os.WriteFile(path, content[:5], StorageFilePerm)
|
||||||
@@ -342,12 +289,12 @@ func TestContentStorage_StoreHashedReplacesFileOfAnotherSize(t *testing.T) {
|
|||||||
t.Fatalf("writing a partial file: %v", err)
|
t.Fatalf("writing a partial file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = storage.StoreHashed(hash, content)
|
_, err = storage.Store(key, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreHashed() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := storage.Load(hash)
|
stored, err := storage.Load(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,26 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync/atomic"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StartPendingCountWrites starts the goroutine that writes the pending
|
// pendingWriteInterval is how often the goroutine StartPendingCountWrites
|
||||||
// counts to the database whenever there are some, one UPDATE at a time:
|
// starts writes the pending access times and counts. SQLite's
|
||||||
// however many requests pass their deadline before their counts are
|
// CURRENT_TIMESTAMP, which an access time is written as, counts whole
|
||||||
// written, at most this one write waits for the database. It is a no-op
|
// seconds.
|
||||||
// when already started. The goroutine outlives the caller, so it runs with
|
const pendingWriteInterval = time.Second
|
||||||
// its own context, which StopPendingCountWrites cancels to tell it to
|
|
||||||
// finish.
|
// StartPendingCountWrites starts the goroutine that writes, every
|
||||||
|
// pendingWriteInterval, the pending access times in one transaction and then
|
||||||
|
// the pending counts in one UPDATE. Requests only hand these to it, so none
|
||||||
|
// waits for the database to record what it did, and only this goroutine's
|
||||||
|
// write ever waits for it. It is a no-op when already started. The goroutine
|
||||||
|
// outlives the caller, so it runs with its own context, which
|
||||||
|
// StopPendingCountWrites cancels to tell it to finish.
|
||||||
func (c *Cache) StartPendingCountWrites() {
|
func (c *Cache) StartPendingCountWrites() {
|
||||||
if c.pendingCountsCancel != nil {
|
if c.pendingCountsCancel != nil {
|
||||||
return
|
return
|
||||||
@@ -28,12 +37,12 @@ func (c *Cache) StartPendingCountWrites() {
|
|||||||
|
|
||||||
// StopPendingCountWrites tells the goroutine StartPendingCountWrites
|
// StopPendingCountWrites tells the goroutine StartPendingCountWrites
|
||||||
// started, if it did, to finish, and waits for its write under way to end
|
// started, if it did, to finish, and waits for its write under way to end
|
||||||
// and for it to return. It then writes the pending counts left, so that
|
// and for it to return. It then writes the pending access times and counts
|
||||||
// they are written before the database closes. It waits for all of this at
|
// left, once, so that they are written before the database closes. It waits
|
||||||
// most until ctx ends, then logs the counts not written and returns an
|
// for all of this at most until ctx ends. It logs what was not written,
|
||||||
// error.
|
// access times or counts, and returns an error.
|
||||||
func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
|
func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
|
||||||
var err error
|
var accessTimesErr, countsErr error
|
||||||
|
|
||||||
if c.pendingCountsCancel != nil {
|
if c.pendingCountsCancel != nil {
|
||||||
c.pendingCountsCancel()
|
c.pendingCountsCancel()
|
||||||
@@ -41,71 +50,134 @@ func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
|
|||||||
select {
|
select {
|
||||||
case <-c.pendingCountsDone:
|
case <-c.pendingCountsDone:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// The write given up on here may not reach the database:
|
// The write given up on here, of access times or of counts,
|
||||||
// database/sql closes without waiting for a query, and a
|
// may not reach the database: database/sql closes without
|
||||||
// write still waiting for the connection fails when the
|
// waiting for a query, and a write still waiting for the
|
||||||
// database closes.
|
// connection fails when the database closes. Neither is
|
||||||
err = fmt.Errorf("pending counts still being written: %w", ctx.Err())
|
// written here.
|
||||||
|
accessTimesErr = fmt.Errorf("access times still being written: %w", ctx.Err())
|
||||||
|
countsErr = fmt.Errorf("pending counts still being written: %w", ctx.Err())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if accessTimesErr == nil && countsErr == nil {
|
||||||
err = c.writePendingCounts(ctx)
|
accessTimesErr = c.writePendingAccessTimes(ctx)
|
||||||
|
countsErr = c.writePendingCounts(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if accessTimesErr != nil {
|
||||||
|
c.log.Error("access times not written at shutdown", "error", accessTimesErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if countsErr != nil {
|
||||||
c.log.Error("counts not written at shutdown",
|
c.log.Error("counts not written at shutdown",
|
||||||
"hits", c.pendingHits.Load(),
|
"hits", c.pendingHits.Load(),
|
||||||
"misses", c.pendingMisses.Load(),
|
"misses", c.pendingMisses.Load(),
|
||||||
"upstream_fetches", c.pendingUpstreamFetches.Load(),
|
"upstream_fetches", c.pendingUpstreamFetches.Load(),
|
||||||
"upstream_fetch_bytes", c.pendingUpstreamFetchBytes.Load(),
|
"upstream_fetch_bytes", c.pendingUpstreamFetchBytes.Load(),
|
||||||
"transforms", c.pendingTransforms.Load(),
|
"transforms", c.pendingTransforms.Load(),
|
||||||
"error", err,
|
"error", countsErr,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return errors.Join(accessTimesErr, countsErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pendingCountWriteLoop is the body of the goroutine
|
// pendingCountWriteLoop is the body of the goroutine
|
||||||
// StartPendingCountWrites starts. Once ctx is cancelled it starts no write
|
// StartPendingCountWrites starts. Once ctx is cancelled it starts no write
|
||||||
// and returns, but does not cut off a write under way then. A write that
|
// and returns, but does not cut off a write under way then. Counts whose
|
||||||
// fails leaves the counts pending, for the next write.
|
// write fails stay pending, for the next write; access times whose write
|
||||||
|
// fails are dropped.
|
||||||
func (c *Cache) pendingCountWriteLoop(ctx context.Context) {
|
func (c *Cache) pendingCountWriteLoop(ctx context.Context) {
|
||||||
defer close(c.pendingCountsDone)
|
defer close(c.pendingCountsDone)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(pendingWriteInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-c.pendingCountsAdded:
|
case <-ticker.C:
|
||||||
}
|
}
|
||||||
|
|
||||||
// select picks at random when ctx is cancelled and a wakeup
|
// select picks at random when ctx is cancelled and a tick waits
|
||||||
// waits too.
|
// too.
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := c.writePendingCounts(context.WithoutCancel(ctx))
|
err := c.writePendingAccessTimes(context.WithoutCancel(ctx))
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to write access times", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.writePendingCounts(context.WithoutCancel(ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Warn("failed to write pending counts", "error", err)
|
c.log.Warn("failed to write pending counts", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// addPendingCount adds n to pendingCount, one of the pending counts, and
|
// writePendingAccessTimes takes out the variants waiting in
|
||||||
// wakes the goroutine that writes them. pendingCountsAdded has capacity one,
|
// pendingVariantAccessTimes and the sources waiting in
|
||||||
// so a wakeup already waiting is enough.
|
// pendingSourceAccessTimes, and sets their last_accessed_at to now in one
|
||||||
func (c *Cache) addPendingCount(pendingCount *atomic.Int64, n int64) {
|
// transaction, once for each however often it was used. Nothing else takes
|
||||||
pendingCount.Add(n)
|
// from those channels while it runs: only the goroutine
|
||||||
|
// StartPendingCountWrites starts calls it, or StopPendingCountWrites once
|
||||||
|
// that goroutine has returned.
|
||||||
|
func (c *Cache) writePendingAccessTimes(ctx context.Context) error {
|
||||||
|
variants := make(map[imagerequest.VariantKey]struct{})
|
||||||
|
|
||||||
select {
|
for range len(c.pendingVariantAccessTimes) {
|
||||||
case c.pendingCountsAdded <- struct{}{}:
|
variants[<-c.pendingVariantAccessTimes] = struct{}{}
|
||||||
default:
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sources := make(map[imagerequest.SourceKey]struct{})
|
||||||
|
|
||||||
|
for range len(c.pendingSourceAccessTimes) {
|
||||||
|
sources[<-c.pendingSourceAccessTimes] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
accessTimes := len(variants) + len(sources)
|
||||||
|
if accessTimes == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := c.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write %d access times: %w", accessTimes, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does nothing once the transaction is committed
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
for cacheKey := range variants {
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
UPDATE variant_content SET last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE cache_key = ?
|
||||||
|
`, string(cacheKey))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write %d access times: %w", accessTimes, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for sourceKey := range sources {
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
UPDATE source_content SET last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE source_key = ?
|
||||||
|
`, string(sourceKey))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write %d access times: %w", accessTimes, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write %d access times: %w", accessTimes, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writePendingCounts adds the pending counts to the cache_stats row in one
|
// writePendingCounts adds the pending counts to the cache_stats row in one
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package imgcache
|
package imgcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -73,6 +76,35 @@ func waitForCounters(t *testing.T, cache *Cache, want cacheStatsCounters) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitForPendingCountsWritten starts the goroutine that writes cache's
|
||||||
|
// pending counts, unless it is running, and waits until it has written them
|
||||||
|
// all, so that the database holds every count made so far. The goroutine is
|
||||||
|
// stopped when the test ends. The database is limited to one connection
|
||||||
|
// first: each connection to an in-memory SQLite database opens a new, empty
|
||||||
|
// one, so the goroutine must share the connection that holds the schema.
|
||||||
|
func waitForPendingCountsWritten(t *testing.T, cache *Cache) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if cache.pendingCountsCancel == nil {
|
||||||
|
cache.db.SetMaxOpenConns(1)
|
||||||
|
cache.StartPendingCountWrites()
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = cache.StopPendingCountWrites(context.Background()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(pendingCountsWait)
|
||||||
|
|
||||||
|
for cache.pendingHits.Load()+cache.pendingMisses.Load()+
|
||||||
|
cache.pendingUpstreamFetches.Load()+cache.pendingUpstreamFetchBytes.Load()+
|
||||||
|
cache.pendingTransforms.Load() > 0 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the pending counts were not written")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy holds the
|
// TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy holds the
|
||||||
// database's one connection while a request whose fetch is held reaches its
|
// database's one connection while a request whose fetch is held reaches its
|
||||||
// deadline. The request must return with the deadline's error while the
|
// deadline. The request must return with the deadline's error while the
|
||||||
@@ -123,11 +155,12 @@ func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
|
|||||||
waitForCounters(t, svc.cache, cacheStatsCounters{missCount: 1})
|
waitForCounters(t, svc.cache, cacheStatsCounters{missCount: 1})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestIncrementStats_ManyCountsPastTheirDeadlineLeaveOneWriteWaiting holds the
|
// TestIncrementStats_ManyCountsLeaveOneWriteWaiting holds the database's one
|
||||||
// database's one connection while many misses are counted past their
|
// connection while many misses are added to the pending counts, which the
|
||||||
// deadline. At most one write may then wait for the connection, and every
|
// goroutine StartPendingCountWrites starts writes to the database. At most one
|
||||||
// miss must reach the database once it is free.
|
// write may then wait for the connection, and every miss must reach the
|
||||||
func TestIncrementStats_ManyCountsPastTheirDeadlineLeaveOneWriteWaiting(t *testing.T) {
|
// database once it is free.
|
||||||
|
func TestIncrementStats_ManyCountsLeaveOneWriteWaiting(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
@@ -140,14 +173,10 @@ func TestIncrementStats_ManyCountsPastTheirDeadlineLeaveOneWriteWaiting(t *testi
|
|||||||
|
|
||||||
waitsBefore := cache.db.Stats().WaitCount
|
waitsBefore := cache.db.Stats().WaitCount
|
||||||
|
|
||||||
// Writes given a context past its deadline fail at once.
|
|
||||||
ended, cancel := context.WithDeadline(t.Context(), time.Now())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
const misses = 20
|
const misses = 20
|
||||||
|
|
||||||
for range misses {
|
for range misses {
|
||||||
cache.IncrementStats(ended, false, 0)
|
cache.IncrementStats(false, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once one write waits for the connection, any others start waiting too.
|
// Once one write waits for the connection, any others start waiting too.
|
||||||
@@ -166,9 +195,9 @@ func TestIncrementStats_ManyCountsPastTheirDeadlineLeaveOneWriteWaiting(t *testi
|
|||||||
waitForCounters(t, cache, cacheStatsCounters{missCount: misses})
|
waitForCounters(t, cache, cacheStatsCounters{missCount: misses})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStats_IncludesPendingCounts counts hits and misses whose writes miss
|
// TestStats_IncludesPendingCounts adds hits and misses to the pending counts,
|
||||||
// their deadline, and checks that Stats adds them to the database's counts
|
// with no goroutine started to write them, and checks that Stats adds them to
|
||||||
// before they are written.
|
// the database's counts before they are written.
|
||||||
func TestStats_IncludesPendingCounts(t *testing.T) {
|
func TestStats_IncludesPendingCounts(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -180,13 +209,9 @@ func TestStats_IncludesPendingCounts(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writes given a context past its deadline fail at once.
|
cache.IncrementStats(true, 0)
|
||||||
ended, cancel := context.WithDeadline(t.Context(), time.Now())
|
cache.IncrementStats(true, 0)
|
||||||
defer cancel()
|
cache.IncrementStats(false, 0)
|
||||||
|
|
||||||
cache.IncrementStats(ended, true, 0)
|
|
||||||
cache.IncrementStats(ended, true, 0)
|
|
||||||
cache.IncrementStats(ended, false, 0)
|
|
||||||
|
|
||||||
stats, err := cache.Stats(t.Context())
|
stats, err := cache.Stats(t.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -222,19 +247,15 @@ func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) {
|
|||||||
|
|
||||||
waitsBefore := cache.db.Stats().WaitCount
|
waitsBefore := cache.db.Stats().WaitCount
|
||||||
|
|
||||||
// Writes given a context past its deadline fail at once.
|
cache.IncrementStats(true, 0)
|
||||||
ended, cancel := context.WithDeadline(t.Context(), time.Now())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
cache.IncrementStats(ended, true, 0)
|
|
||||||
|
|
||||||
// The goroutine's write waits for the connection.
|
// The goroutine's write waits for the connection.
|
||||||
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
||||||
|
|
||||||
// Counted after that write read the pending counts
|
// Counted after that write read the pending counts
|
||||||
cache.IncrementStats(ended, false, 0)
|
cache.IncrementStats(false, 0)
|
||||||
cache.IncrementUpstreamFetch(ended, 1024)
|
cache.IncrementUpstreamFetch(1024)
|
||||||
cache.IncrementTransformCount(ended)
|
cache.IncrementTransformCount()
|
||||||
|
|
||||||
// The goroutine is told to finish here as well as by the stop, so that it
|
// The goroutine is told to finish here as well as by the stop, so that it
|
||||||
// is told before its write under way ends, however late the stop runs.
|
// is told before its write under way ends, however late the stop runs.
|
||||||
@@ -295,11 +316,7 @@ func TestStopPendingCountWrites_ReturnsWhenItsContextEnds(t *testing.T) {
|
|||||||
|
|
||||||
waitsBefore := cache.db.Stats().WaitCount
|
waitsBefore := cache.db.Stats().WaitCount
|
||||||
|
|
||||||
// Writes given a context past its deadline fail at once.
|
cache.IncrementStats(true, 0)
|
||||||
ended, cancel := context.WithDeadline(t.Context(), time.Now())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
cache.IncrementStats(ended, true, 0)
|
|
||||||
|
|
||||||
// The goroutine's write waits for the connection.
|
// The goroutine's write waits for the connection.
|
||||||
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
||||||
@@ -327,3 +344,88 @@ func TestStopPendingCountWrites_ReturnsWhenItsContextEnds(t *testing.T) {
|
|||||||
|
|
||||||
waitForCounters(t, cache, cacheStatsCounters{hitCount: 1})
|
waitForCounters(t, cache, cacheStatsCounters{hitCount: 1})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLookupSource_HandsAccessTimeToTheBackgroundWriter finds a cached source
|
||||||
|
// whose access time is an hour old. LookupSource must not write the access
|
||||||
|
// time itself but hand it over to be written, and StopPendingCountWrites must
|
||||||
|
// then write it.
|
||||||
|
func TestLookupSource_HandsAccessTimeToTheBackgroundWriter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
key := storeEvictionTestSource(t, cache, "example.com", "/photo.jpg",
|
||||||
|
[]byte("source bytes"))
|
||||||
|
anHourAgo := time.Now().Add(-time.Hour)
|
||||||
|
setSourceLastAccessed(t, cache, key, anHourAgo)
|
||||||
|
|
||||||
|
touchedQuery := `
|
||||||
|
SELECT COUNT(*) FROM source_content
|
||||||
|
WHERE source_key = ? AND last_accessed_at > ?
|
||||||
|
`
|
||||||
|
anHourAgoInDatabase := anHourAgo.UTC().Format(sqliteTimestampFormat)
|
||||||
|
|
||||||
|
found, _, err := cache.LookupSource(t.Context(), &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{Host: "example.com", Path: "/photo.jpg"},
|
||||||
|
})
|
||||||
|
if err != nil || found != key {
|
||||||
|
t.Fatalf("LookupSource() = %q, %v; want %q", found, err, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if countRows(t, cache, touchedQuery, string(key), anHourAgoInDatabase) != 0 {
|
||||||
|
t.Error("LookupSource wrote the source's access time itself")
|
||||||
|
}
|
||||||
|
|
||||||
|
if waiting := len(cache.pendingSourceAccessTimes); waiting != 1 {
|
||||||
|
t.Errorf("%d access times of sources wait to be written, want 1", waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cache.StopPendingCountWrites(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StopPendingCountWrites() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if countRows(t, cache, touchedQuery, string(key), anHourAgoInDatabase) != 1 {
|
||||||
|
t.Error("the source's access time was not written")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopPendingCountWrites_LogsWhatWasNotWritten makes the write of the
|
||||||
|
// access times fail at shutdown, by dropping the table they are written to,
|
||||||
|
// while a hit count is written. The stop must return an error and log that
|
||||||
|
// access times were not written, and must not log the counts as not written.
|
||||||
|
func TestStopPendingCountWrites_LogsWhatWasNotWritten(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
|
||||||
|
cache.log = slog.New(slog.NewJSONHandler(&logs, nil))
|
||||||
|
|
||||||
|
cache.touchVariant(testVariantKeyOne)
|
||||||
|
cache.IncrementStats(true, 0)
|
||||||
|
|
||||||
|
_, err := cache.db.ExecContext(t.Context(), `DROP TABLE variant_content`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dropping variant_content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cache.StopPendingCountWrites(t.Context())
|
||||||
|
t.Logf("StopPendingCountWrites() error = %v; logged:\n%s", err, logs.String())
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Error("StopPendingCountWrites() error = nil, want the access times' error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(logs.String(), "access times not written at shutdown") {
|
||||||
|
t.Error("the access times not written were not logged")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(logs.String(), "counts not written at shutdown") {
|
||||||
|
t.Error("the counts, which were written, were logged as not written")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := readCacheStatsCounters(t, cache); got.hitCount != 1 {
|
||||||
|
t.Errorf("hit count in the database = %d, want 1", got.hitCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,36 +140,37 @@ var ErrProcessingTimedOut = errors.New(
|
|||||||
// While its source URL is in the negative cache, a cached variant is still
|
// While its source URL is in the negative cache, a cached variant is still
|
||||||
// served and a variant not in the cache is still made from a cached source;
|
// served and a variant not in the cache is still made from a cached source;
|
||||||
// only a request that needs the source fetched is refused.
|
// only a request that needs the source fetched is refused.
|
||||||
|
//
|
||||||
|
// A cache hit opens the variant file and makes no database call: its access
|
||||||
|
// time and its hit count are handed to the goroutine that
|
||||||
|
// StartPendingCountWrites starts, without waiting for it. A variant file
|
||||||
|
// that cannot be served, such as one whose first bytes are not those of an
|
||||||
|
// output format, is a miss, and the variant is made again.
|
||||||
func (s *Service) Get(
|
func (s *Service) Get(
|
||||||
ctx context.Context, req *imagerequest.Request,
|
ctx context.Context, req *imagerequest.Request,
|
||||||
) (*ImageResponse, error) {
|
) (*ImageResponse, error) {
|
||||||
// Propagate AllowHTTP setting to the request
|
// Propagate AllowHTTP setting to the request
|
||||||
req.AllowHTTP = s.allowHTTP
|
req.AllowHTTP = s.allowHTTP
|
||||||
|
|
||||||
// Check variant cache first (disk only, no DB)
|
cacheKey := imagerequest.CacheKey(req)
|
||||||
result, err := s.cache.Lookup(ctx, req)
|
|
||||||
if err != nil {
|
reader, size, contentType, err := s.cache.GetVariant(cacheKey)
|
||||||
s.log.Warn("cache lookup failed", "error", err)
|
if err == nil {
|
||||||
|
s.cache.touchVariant(cacheKey)
|
||||||
|
s.cache.IncrementStats(true, 0)
|
||||||
|
|
||||||
|
return &ImageResponse{
|
||||||
|
Content: reader,
|
||||||
|
ContentLength: size,
|
||||||
|
ContentType: contentType,
|
||||||
|
CacheStatus: CacheHit,
|
||||||
|
ETag: formatETag(cacheKey),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache hit - serve directly from disk
|
if !errors.Is(err, ErrNotFound) {
|
||||||
if result != nil && result.Hit {
|
s.log.Warn("cached variant not served, making it again",
|
||||||
reader, size, contentType, err := s.cache.GetVariant(result.CacheKey)
|
"cache_key", cacheKey, "error", err)
|
||||||
if err != nil {
|
|
||||||
s.log.Error("failed to get cached variant", "key", result.CacheKey, "error", err)
|
|
||||||
// Fall through to re-process
|
|
||||||
} else {
|
|
||||||
// Counted also when the request context has ended meanwhile
|
|
||||||
s.cache.IncrementStats(ctx, true, 0)
|
|
||||||
|
|
||||||
return &ImageResponse{
|
|
||||||
Content: reader,
|
|
||||||
ContentLength: size,
|
|
||||||
ContentType: contentType,
|
|
||||||
CacheStatus: CacheHit,
|
|
||||||
ETag: formatETag(result.CacheKey),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache miss - get the variant, processed once for all the requests that
|
// Cache miss - get the variant, processed once for all the requests that
|
||||||
@@ -177,7 +178,7 @@ func (s *Service) Get(
|
|||||||
// failed or the request context has ended meanwhile
|
// failed or the request context has ended meanwhile
|
||||||
response, err := s.processOrWait(ctx, req)
|
response, err := s.processOrWait(ctx, req)
|
||||||
|
|
||||||
s.cache.IncrementStats(ctx, false, 0)
|
s.cache.IncrementStats(false, 0)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -359,9 +360,9 @@ func withoutCancelKeepingDeadline(
|
|||||||
// returns it with its size; nil if the cached data is unavailable, empty or
|
// returns it with its size; nil if the cached data is unavailable, empty or
|
||||||
// exceeds maxResponseSize.
|
// exceeds maxResponseSize.
|
||||||
func (s *Service) loadCachedSource(
|
func (s *Service) loadCachedSource(
|
||||||
contentHash ContentHash,
|
sourceKey imagerequest.SourceKey,
|
||||||
) (io.ReadCloser, int64) {
|
) (io.ReadCloser, int64) {
|
||||||
reader, size, err := s.cache.GetSourceContent(contentHash)
|
reader, size, err := s.cache.GetSourceContent(sourceKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("failed to load cached source, fetching", "error", err)
|
s.log.Warn("failed to load cached source, fetching", "error", err)
|
||||||
|
|
||||||
@@ -372,7 +373,7 @@ func (s *Service) loadCachedSource(
|
|||||||
_ = reader.Close()
|
_ = reader.Close()
|
||||||
|
|
||||||
s.log.Warn("cached source exceeds max response size, discarding",
|
s.log.Warn("cached source exceeds max response size, discarding",
|
||||||
"hash", contentHash,
|
"source_key", sourceKey,
|
||||||
"max_bytes", s.maxResponseSize,
|
"max_bytes", s.maxResponseSize,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -396,7 +397,7 @@ func (s *Service) processFromSourceOrFetch(
|
|||||||
cacheKey imagerequest.VariantKey,
|
cacheKey imagerequest.VariantKey,
|
||||||
) (*processedVariant, error) {
|
) (*processedVariant, error) {
|
||||||
// Check if we have cached source content
|
// Check if we have cached source content
|
||||||
contentHash, _, err := s.cache.LookupSource(ctx, req)
|
sourceKey, _, err := s.cache.LookupSource(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("source lookup failed", "error", err)
|
s.log.Warn("source lookup failed", "error", err)
|
||||||
}
|
}
|
||||||
@@ -406,9 +407,9 @@ func (s *Service) processFromSourceOrFetch(
|
|||||||
sourceSize int64
|
sourceSize int64
|
||||||
)
|
)
|
||||||
|
|
||||||
if contentHash != "" {
|
if sourceKey != "" {
|
||||||
s.log.Debug("using cached source", "hash", contentHash)
|
s.log.Debug("using cached source", "source_key", sourceKey)
|
||||||
source, sourceSize = s.loadCachedSource(contentHash)
|
source, sourceSize = s.loadCachedSource(sourceKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from upstream if we don't have source data or it's empty
|
// Fetch from upstream if we don't have source data or it's empty
|
||||||
@@ -474,7 +475,7 @@ func (s *Service) fetchAndProcess(
|
|||||||
fetchBytes := int64(len(sourceData))
|
fetchBytes := int64(len(sourceData))
|
||||||
|
|
||||||
// Counted also when the request context has ended meanwhile
|
// Counted also when the request context has ended meanwhile
|
||||||
s.cache.IncrementUpstreamFetch(ctx, fetchBytes)
|
s.cache.IncrementUpstreamFetch(fetchBytes)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read upstream response: %w", err)
|
return nil, fmt.Errorf("failed to read upstream response: %w", err)
|
||||||
@@ -575,7 +576,7 @@ func (s *Service) processAndStore(
|
|||||||
processDuration := time.Since(processStart)
|
processDuration := time.Since(processStart)
|
||||||
|
|
||||||
// Counted also when the request context has ended meanwhile
|
// Counted also when the request context has ended meanwhile
|
||||||
s.cache.IncrementTransformCount(ctx)
|
s.cache.IncrementTransformCount()
|
||||||
|
|
||||||
// Read processed content
|
// Read processed content
|
||||||
processedData, err := io.ReadAll(processResult.Content)
|
processedData, err := io.ReadAll(processResult.Content)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -550,21 +549,17 @@ func TestService_Get_Caching(t *testing.T) {
|
|||||||
|
|
||||||
// TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo caches a source and
|
// TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo caches a source and
|
||||||
// a variant of it and sets back two days every time pixa stored for them.
|
// a variant of it and sets back two days every time pixa stored for them.
|
||||||
// For the source: fetched_at in source_metadata, fetched_at and
|
// For the source: fetched_at and last_accessed_at in source_content, and the
|
||||||
// last_accessed_at in source_content, the fetched_at in its metadata file,
|
// access and modification times of its file. For the variant: created_at and
|
||||||
// and the access and modification times of its file and of its metadata
|
// last_accessed_at in variant_content, and the access and modification times
|
||||||
// file. For the variant: created_at and last_accessed_at in variant_content,
|
// of its file. It then checks that the variant is still served from the
|
||||||
// the created_at in its .meta file, and the access and modification times of
|
// cache and that a new variant is made from the cached source without
|
||||||
// its file and of its .meta file. It then checks that the variant is still
|
// fetching it again.
|
||||||
// served from the cache and that a new variant is made from the cached source
|
|
||||||
// without fetching it again.
|
|
||||||
//
|
//
|
||||||
// Left at the present: the files' status change and birth times, which a
|
// Left at the present: the files' status change and birth times, which a
|
||||||
// program cannot set, and the times of the directories holding the files,
|
// program cannot set, and the times of the directories holding the files,
|
||||||
// which other entries share. The source's stored response headers are empty,
|
// which other entries share. The request_cache and output_content tables are
|
||||||
// as the mock fetcher sends none. expires_at, etag and last_modified in
|
// never written.
|
||||||
// source_metadata and in the metadata file are never written, nor are the
|
|
||||||
// request_cache and output_content tables.
|
|
||||||
func TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
|
func TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -602,6 +597,8 @@ func TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
|
|||||||
|
|
||||||
_ = resp.Content.Close()
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
waitForPendingCountsWritten(t, svc.cache)
|
||||||
|
|
||||||
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
||||||
if fetches != 1 {
|
if fetches != 1 {
|
||||||
t.Errorf("upstream fetches = %d, want 1", fetches)
|
t.Errorf("upstream fetches = %d, want 1", fetches)
|
||||||
@@ -615,39 +612,19 @@ func backdateSource(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
pathHash := HashPath(req.Source.Path + "?" + req.Source.Query)
|
key := req.Source.Key()
|
||||||
|
|
||||||
meta, err := cache.srcMetadata.Load(req.Source.Host, pathHash)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading the source's metadata file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
meta.FetchedAt = when.Unix()
|
|
||||||
|
|
||||||
err = cache.srcMetadata.Store(req.Source.Host, pathHash, meta)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writing the source's metadata file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dbTime := when.UTC().Format(sqliteTimestampFormat)
|
dbTime := when.UTC().Format(sqliteTimestampFormat)
|
||||||
|
|
||||||
updateOneRow(t, cache, `
|
|
||||||
UPDATE source_metadata SET fetched_at = ?
|
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
|
||||||
`, dbTime, req.Source.Host, req.Source.Path, req.Source.Query)
|
|
||||||
updateOneRow(t, cache,
|
updateOneRow(t, cache,
|
||||||
`UPDATE source_content SET fetched_at = ? WHERE content_hash = ?`,
|
`UPDATE source_content SET fetched_at = ? WHERE source_key = ?`,
|
||||||
dbTime, meta.ContentHash)
|
dbTime, string(key))
|
||||||
setSourceLastAccessed(t, cache, ContentHash(meta.ContentHash), when)
|
setSourceLastAccessed(t, cache, key, when)
|
||||||
|
|
||||||
for _, path := range []string{
|
path := cache.srcContent.keyToPath(key)
|
||||||
cache.srcContent.hashToPath(ContentHash(meta.ContentHash)),
|
|
||||||
cache.srcMetadata.metaPath(req.Source.Host, pathHash),
|
err := os.Chtimes(path, when, when)
|
||||||
} {
|
if err != nil {
|
||||||
err = os.Chtimes(path, when, when)
|
t.Fatalf("setting back the times of %s: %v", path, err)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("setting back the times of %s: %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,41 +637,16 @@ func backdateVariant(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
key := imagerequest.CacheKey(req)
|
key := imagerequest.CacheKey(req)
|
||||||
|
|
||||||
content, size, contentType, err := cache.variants.LoadWithMeta(key)
|
|
||||||
if err != nil || contentType == "" {
|
|
||||||
t.Fatalf("reading the variant's .meta file: content type %q, error %v",
|
|
||||||
contentType, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = content.Close()
|
|
||||||
|
|
||||||
meta, err := json.Marshal(VariantMeta{
|
|
||||||
ContentType: contentType,
|
|
||||||
Size: size,
|
|
||||||
CreatedAt: when.Unix(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("encoding the variant's .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
path := cache.variants.keyToPath(key)
|
path := cache.variants.keyToPath(key)
|
||||||
|
|
||||||
err = os.WriteFile(path+variantMetaSuffix, meta, StorageFilePerm)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writing the variant's .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateOneRow(t, cache,
|
updateOneRow(t, cache,
|
||||||
`UPDATE variant_content SET created_at = ? WHERE cache_key = ?`,
|
`UPDATE variant_content SET created_at = ? WHERE cache_key = ?`,
|
||||||
when.UTC().Format(sqliteTimestampFormat), string(key))
|
when.UTC().Format(sqliteTimestampFormat), string(key))
|
||||||
setVariantLastAccessed(t, cache, key, when)
|
setVariantLastAccessed(t, cache, key, when)
|
||||||
|
|
||||||
for _, file := range []string{path, path + variantMetaSuffix} {
|
err := os.Chtimes(path, when, when)
|
||||||
err = os.Chtimes(file, when, when)
|
if err != nil {
|
||||||
if err != nil {
|
t.Fatalf("setting back the times of %s: %v", path, err)
|
||||||
t.Fatalf("setting back the times of %s: %v", file, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestStoreSource_StoresIdenticalBytesOfTwoURLsTwice stores the same bytes
|
||||||
|
// for two source URLs and checks that each URL has a source_content row of
|
||||||
|
// its own and a file of its own, named by its source key under
|
||||||
|
// cache/sources/<ab>/<cd>/.
|
||||||
|
func TestStoreSource_StoresIdenticalBytesOfTwoURLsTwice(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, stateDir := newEvictionTestCache(t, 1<<30)
|
||||||
|
content := bytes.Repeat([]byte{0x5A}, 400)
|
||||||
|
|
||||||
|
keys := map[string]imagerequest.SourceKey{
|
||||||
|
"/a.jpg": storeEvictionTestSource(t, cache, testHostCDN, "/a.jpg", content),
|
||||||
|
"/b.jpg": storeEvictionTestSource(t, cache, testHostCDN, "/b.jpg", content),
|
||||||
|
}
|
||||||
|
t.Logf("source keys: %v", keys)
|
||||||
|
|
||||||
|
if keys["/a.jpg"] == keys["/b.jpg"] {
|
||||||
|
t.Fatalf("both source URLs got the key %s", keys["/a.jpg"])
|
||||||
|
}
|
||||||
|
|
||||||
|
for sourcePath, key := range keys {
|
||||||
|
rows := countRows(t, cache, `
|
||||||
|
SELECT COUNT(*) FROM source_content
|
||||||
|
WHERE source_key = ? AND source_host = ? AND source_path = ?
|
||||||
|
`, string(key), testHostCDN, sourcePath)
|
||||||
|
if rows != 1 {
|
||||||
|
t.Errorf("%s has %d source_content rows, want 1", sourcePath, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := string(key)
|
||||||
|
file := filepath.Join(stateDir, "cache", "sources", name[0:2], name[2:4], name)
|
||||||
|
|
||||||
|
stored, err := os.ReadFile(file) //nolint:gosec // path in the test's state dir
|
||||||
|
if err != nil || !bytes.Equal(stored, content) {
|
||||||
|
t.Errorf("file of %s at %s holds %d bytes (error %v), want the %d stored",
|
||||||
|
sourcePath, file, len(stored), err, len(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage != 800 {
|
||||||
|
t.Errorf("UsageBytes() = %d, want 800, the 400 bytes once for each URL", usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvictSourceBlob_LeavesSourceWithIdenticalBytesServed stores the same
|
||||||
|
// bytes for two source URLs and evicts the first. Its row and file must be
|
||||||
|
// gone, and the second must still be found and read from the cache.
|
||||||
|
func TestEvictSourceBlob_LeavesSourceWithIdenticalBytesServed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
content := bytes.Repeat([]byte{0x5B}, 400)
|
||||||
|
|
||||||
|
evicted := storeEvictionTestSource(t, cache, testHostCDN, "/evicted.jpg", content)
|
||||||
|
storeEvictionTestSource(t, cache, testHostCDN, "/kept.jpg", content)
|
||||||
|
|
||||||
|
err := cache.evictSourceBlob(t.Context(), evicted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evictSourceBlob() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache.srcContent.Exists(evicted) {
|
||||||
|
t.Error("the evicted source's file is still on disk")
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM source_content WHERE source_key = ?`, string(evicted),
|
||||||
|
); n != 0 {
|
||||||
|
t.Errorf("the evicted source still has %d source_content rows", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
kept, contentType, err := cache.LookupSource(t.Context(), &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{Host: testHostCDN, Path: "/kept.jpg"},
|
||||||
|
})
|
||||||
|
t.Logf("LookupSource() of the kept source = %q, %q, error %v", kept, contentType, err)
|
||||||
|
|
||||||
|
if err != nil || kept == "" {
|
||||||
|
t.Fatalf("LookupSource() of the kept source = %q, %v; want it cached", kept, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, _, err := cache.GetSourceContent(kept)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSourceContent() of the kept source error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
|
served, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the kept source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(served, content) {
|
||||||
|
t.Errorf("the kept source reads as %d bytes, want the %d stored",
|
||||||
|
len(served), len(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStoreSource_FailedRowWriteLeavesNoFile drops source_content, so that
|
||||||
|
// StoreSource cannot write the source's row, and checks that the store fails
|
||||||
|
// and leaves no file under the source's key.
|
||||||
|
func TestStoreSource_FailedRowWriteLeavesNoFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
_, err := cache.db.ExecContext(t.Context(), `DROP TABLE source_content`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to drop source_content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &imagerequest.Request{
|
||||||
|
Source: imagerequest.Source{Host: testHostCDN, Path: testPathCat},
|
||||||
|
}
|
||||||
|
result := &httpfetcher.FetchResult{ContentType: testContentTypeJPEG}
|
||||||
|
|
||||||
|
key, err := cache.StoreSource(t.Context(), req,
|
||||||
|
bytes.NewReader([]byte("source bytes")), result)
|
||||||
|
t.Logf("StoreSource() = %q, error = %v", key, err)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Error("StoreSource() error = nil, want the failed row write")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache.srcContent.Exists(req.Source.Key()) {
|
||||||
|
t.Error("the source's file is on disk although its row was not written")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,43 +148,6 @@ func TestStats_LogsFailedCountQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestIncrementStats_LogsFailedUpdates verifies that a failed hit or miss
|
|
||||||
// count update and a failed upstream fetch count update are each logged at
|
|
||||||
// warn.
|
|
||||||
func TestIncrementStats_LogsFailedUpdates(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := setupStatsTestDB(t)
|
|
||||||
|
|
||||||
var logBuf bytes.Buffer
|
|
||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
|
||||||
StateDir: t.TempDir(),
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
|
||||||
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(t.Context(), `DROP TABLE cache_stats`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.IncrementStats(t.Context(), false, 1024)
|
|
||||||
|
|
||||||
for _, msg := range []string{
|
|
||||||
"failed to count cache hit or miss",
|
|
||||||
"failed to count upstream fetch",
|
|
||||||
} {
|
|
||||||
want := `"level":"WARN","msg":"` + msg + `"`
|
|
||||||
if !strings.Contains(logBuf.String(), want) {
|
|
||||||
t.Errorf("log missing %s; got %q", want, logBuf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStats_TotalsCountSourcesAndVariants verifies that TotalItems and
|
// TestStats_TotalsCountSourcesAndVariants verifies that TotalItems and
|
||||||
// TotalSizeBytes cover the stored source images and processed variants.
|
// TotalSizeBytes cover the stored source images and processed variants.
|
||||||
func TestStats_TotalsCountSourcesAndVariants(t *testing.T) {
|
func TestStats_TotalsCountSourcesAndVariants(t *testing.T) {
|
||||||
@@ -340,6 +303,8 @@ func TestService_Get_CountsStats(t *testing.T) {
|
|||||||
_ = resp.Content.Close()
|
_ = resp.Content.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitForPendingCountsWritten(t, svc.cache)
|
||||||
|
|
||||||
got := readCacheStatsCounters(t, svc.cache)
|
got := readCacheStatsCounters(t, svc.cache)
|
||||||
if got != step.want {
|
if got != step.want {
|
||||||
t.Fatalf("after the %s: counters = %+v, want %+v", step.name, got, step.want)
|
t.Fatalf("after the %s: counters = %+v, want %+v", step.name, got, step.want)
|
||||||
@@ -446,6 +411,8 @@ func TestService_Get_CountsInterruptedMisses(t *testing.T) {
|
|||||||
_ = resp.Content.Close()
|
_ = resp.Content.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitForPendingCountsWritten(t, svc.cache)
|
||||||
|
|
||||||
got := readCacheStatsCounters(t, svc.cache)
|
got := readCacheStatsCounters(t, svc.cache)
|
||||||
if got != tc.want {
|
if got != tc.want {
|
||||||
t.Errorf("counters = %+v, want %+v", got, tc.want)
|
t.Errorf("counters = %+v, want %+v", got, tc.want)
|
||||||
@@ -483,6 +450,8 @@ func TestService_Get_CountsHitAfterRequestEnds(t *testing.T) {
|
|||||||
|
|
||||||
_ = resp.Content.Close()
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
waitForPendingCountsWritten(t, svc.cache)
|
||||||
|
|
||||||
want := readCacheStatsCounters(t, svc.cache)
|
want := readCacheStatsCounters(t, svc.cache)
|
||||||
want.hitCount++
|
want.hitCount++
|
||||||
|
|
||||||
@@ -500,6 +469,8 @@ func TestService_Get_CountsHitAfterRequestEnds(t *testing.T) {
|
|||||||
t.Fatalf("CacheStatus = %v, want %v", resp.CacheStatus, CacheHit)
|
t.Fatalf("CacheStatus = %v, want %v", resp.CacheStatus, CacheHit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitForPendingCountsWritten(t, svc.cache)
|
||||||
|
|
||||||
got := readCacheStatsCounters(t, svc.cache)
|
got := readCacheStatsCounters(t, svc.cache)
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("counters = %+v, want %+v", got, want)
|
t.Errorf("counters = %+v, want %+v", got, want)
|
||||||
|
|||||||
+80
-306
@@ -1,18 +1,14 @@
|
|||||||
package imgcache
|
package imgcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Storage constants.
|
// Storage constants.
|
||||||
@@ -30,14 +26,8 @@ var (
|
|||||||
ErrNotFound = errors.New("content not found")
|
ErrNotFound = errors.New("content not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContentHash is a SHA256 hash of file content (hex-encoded).
|
// ContentStorage handles source image file storage, one file per source
|
||||||
type ContentHash string
|
// URL. Files are stored at: <basedir>/<ab>/<cd>/<source key>
|
||||||
|
|
||||||
// PathHash is a SHA256 hash of a URL path (hex-encoded).
|
|
||||||
type PathHash string
|
|
||||||
|
|
||||||
// ContentStorage handles content-addressable file storage.
|
|
||||||
// Files are stored at: <basedir>/<ab>/<cd>/<abcdef...sha256>
|
|
||||||
type ContentStorage struct {
|
type ContentStorage struct {
|
||||||
baseDir string
|
baseDir string
|
||||||
}
|
}
|
||||||
@@ -52,38 +42,19 @@ func NewContentStorage(baseDir string) (*ContentStorage, error) {
|
|||||||
return &ContentStorage{baseDir: baseDir}, nil
|
return &ContentStorage{baseDir: baseDir}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store writes content to storage and returns its SHA256 hash.
|
// Store writes data with writeFileSynced to the path derived from key,
|
||||||
// The content is read fully into memory to compute the hash before writing.
|
// replacing any file already there, and returns its size.
|
||||||
func (s *ContentStorage) Store(r io.Reader) (ContentHash, int64, error) {
|
func (s *ContentStorage) Store(
|
||||||
// Read all content to compute hash
|
key imagerequest.SourceKey, data []byte,
|
||||||
data, err := io.ReadAll(r)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, fmt.Errorf("failed to read content: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute hash
|
|
||||||
h := sha256.Sum256(data)
|
|
||||||
hash := ContentHash(hex.EncodeToString(h[:]))
|
|
||||||
size := int64(len(data))
|
|
||||||
|
|
||||||
err = s.writeIfAbsent(hash, data)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hash, size, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StoreHashed writes pre-hashed content to storage at the path derived
|
|
||||||
// from hash, without recomputing it. Callers that already know the
|
|
||||||
// hash before writing (e.g. because they must hold a hash-keyed lock
|
|
||||||
// across the whole store operation) use this instead of Store. Like
|
|
||||||
// Store, it is idempotent: a file of the content's size already at that
|
|
||||||
// path is left untouched, and a file of another size is replaced.
|
|
||||||
func (s *ContentStorage) StoreHashed(
|
|
||||||
hash ContentHash, data []byte,
|
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
err := s.writeIfAbsent(hash, data)
|
path := s.keyToPath(key)
|
||||||
|
|
||||||
|
err := os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to create directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeFileSynced(path, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -91,11 +62,11 @@ func (s *ContentStorage) StoreHashed(
|
|||||||
return int64(len(data)), nil
|
return int64(len(data)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load returns a reader for the content with the given hash.
|
// Load returns a reader for the content with the given key.
|
||||||
func (s *ContentStorage) Load(hash ContentHash) (io.ReadCloser, error) {
|
func (s *ContentStorage) Load(key imagerequest.SourceKey) (io.ReadCloser, error) {
|
||||||
path := s.hashToPath(hash)
|
path := s.keyToPath(key)
|
||||||
|
|
||||||
f, err := os.Open(path) //nolint:gosec // path derived from content hash
|
f, err := os.Open(path) //nolint:gosec // path derived from source key
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -107,11 +78,13 @@ func (s *ContentStorage) Load(hash ContentHash) (io.ReadCloser, error) {
|
|||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadWithSize returns a reader and file size for the content with the given hash.
|
// LoadWithSize returns a reader and file size for the content with the given key.
|
||||||
func (s *ContentStorage) LoadWithSize(hash ContentHash) (io.ReadCloser, int64, error) {
|
func (s *ContentStorage) LoadWithSize(
|
||||||
path := s.hashToPath(hash)
|
key imagerequest.SourceKey,
|
||||||
|
) (io.ReadCloser, int64, error) {
|
||||||
|
path := s.keyToPath(key)
|
||||||
|
|
||||||
f, err := os.Open(path) //nolint:gosec // path derived from content hash
|
f, err := os.Open(path) //nolint:gosec // path derived from source key
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return nil, 0, ErrNotFound
|
return nil, 0, ErrNotFound
|
||||||
@@ -130,9 +103,9 @@ func (s *ContentStorage) LoadWithSize(hash ContentHash) (io.ReadCloser, int64, e
|
|||||||
return f, stat.Size(), nil
|
return f, stat.Size(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes content with the given hash.
|
// Delete removes content with the given key.
|
||||||
func (s *ContentStorage) Delete(hash ContentHash) error {
|
func (s *ContentStorage) Delete(key imagerequest.SourceKey) error {
|
||||||
path := s.hashToPath(hash)
|
path := s.keyToPath(key)
|
||||||
|
|
||||||
err := os.Remove(path)
|
err := os.Remove(path)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
@@ -142,34 +115,14 @@ func (s *ContentStorage) Delete(hash ContentHash) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exists checks if content with the given hash exists.
|
// Exists checks if content with the given key exists.
|
||||||
func (s *ContentStorage) Exists(hash ContentHash) bool {
|
func (s *ContentStorage) Exists(key imagerequest.SourceKey) bool {
|
||||||
path := s.hashToPath(hash)
|
path := s.keyToPath(key)
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
|
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeIfAbsent writes data with writeFileSynced to the path derived
|
|
||||||
// from hash, unless a file of the data's size is already there.
|
|
||||||
func (s *ContentStorage) writeIfAbsent(hash ContentHash, data []byte) error {
|
|
||||||
// Build path: <basedir>/<ab>/<cd>/<hash>
|
|
||||||
path := s.hashToPath(hash)
|
|
||||||
|
|
||||||
// A file of another size there is damaged, and is replaced
|
|
||||||
info, statErr := os.Stat(path)
|
|
||||||
if statErr == nil && info.Size() == int64(len(data)) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err := os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return writeFileSynced(path, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeFileSynced writes data to path through a temp file in the same
|
// writeFileSynced writes data to path through a temp file in the same
|
||||||
// directory: the temp file is synced to disk, renamed to path, and the
|
// directory: the temp file is synced to disk, renamed to path, and the
|
||||||
// directory synced after, so that neither a concurrent reader nor a
|
// directory synced after, so that neither a concurrent reader nor a
|
||||||
@@ -244,237 +197,58 @@ func syncDirectory(dir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hashToPath converts a hash to a file path: <basedir>/<ab>/<cd>/<hash>
|
// keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key>
|
||||||
func (s *ContentStorage) hashToPath(hash ContentHash) string {
|
func (s *ContentStorage) keyToPath(key imagerequest.SourceKey) string {
|
||||||
h := string(hash)
|
k := string(key)
|
||||||
if len(h) < MinHashLength {
|
if len(k) < MinHashLength {
|
||||||
return filepath.Clean(filepath.Join(s.baseDir, h))
|
return filepath.Clean(filepath.Join(s.baseDir, k))
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.Clean(filepath.Join(s.baseDir, h[0:2], h[2:4], h))
|
return filepath.Clean(filepath.Join(s.baseDir, k[0:2], k[2:4], k))
|
||||||
}
|
|
||||||
|
|
||||||
// MetadataStorage handles JSON metadata file storage.
|
|
||||||
// Files are stored at: <basedir>/<hostname>/<path_hash>.json
|
|
||||||
type MetadataStorage struct {
|
|
||||||
baseDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMetadataStorage creates a new metadata storage at the given base directory.
|
|
||||||
func NewMetadataStorage(baseDir string) (*MetadataStorage, error) {
|
|
||||||
err := os.MkdirAll(baseDir, StorageDirPerm)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create metadata directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &MetadataStorage{baseDir: baseDir}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SourceMetadata represents cached metadata about a source URL.
|
|
||||||
//
|
|
||||||
//nolint:tagliatelle // stored metadata format uses snake_case
|
|
||||||
type SourceMetadata struct {
|
|
||||||
Host string `json:"host"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
Query string `json:"query,omitempty"`
|
|
||||||
ContentHash string `json:"content_hash,omitempty"`
|
|
||||||
StatusCode int `json:"status_code"`
|
|
||||||
ContentType string `json:"content_type,omitempty"`
|
|
||||||
ContentLength int64 `json:"content_length,omitempty"`
|
|
||||||
ResponseHeaders map[string][]string `json:"response_headers,omitempty"`
|
|
||||||
FetchedAt int64 `json:"fetched_at"`
|
|
||||||
FetchDurationMs int64 `json:"fetch_duration_ms,omitempty"`
|
|
||||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
|
||||||
ETag string `json:"etag,omitempty"`
|
|
||||||
LastModified string `json:"last_modified,omitempty"`
|
|
||||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store writes metadata to storage.
|
|
||||||
func (s *MetadataStorage) Store(
|
|
||||||
host string, pathHash PathHash, meta *SourceMetadata,
|
|
||||||
) error {
|
|
||||||
path := s.metaPath(host, pathHash)
|
|
||||||
|
|
||||||
// Create directory structure
|
|
||||||
dir := filepath.Dir(path)
|
|
||||||
|
|
||||||
err := os.MkdirAll(dir, StorageDirPerm)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marshal to JSON
|
|
||||||
data, err := json.MarshalIndent(meta, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to temp file first, then rename for atomicity
|
|
||||||
tmpFile, err := os.CreateTemp(dir, ".tmp-*.json")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpPath := tmpFile.Name()
|
|
||||||
|
|
||||||
_, err = tmpFile.Write(data)
|
|
||||||
if err != nil {
|
|
||||||
_ = tmpFile.Close()
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to write metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tmpFile.Close()
|
|
||||||
if err != nil {
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to close temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atomic rename
|
|
||||||
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
|
||||||
if err != nil {
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load reads metadata from storage.
|
|
||||||
func (s *MetadataStorage) Load(
|
|
||||||
host string, pathHash PathHash,
|
|
||||||
) (*SourceMetadata, error) {
|
|
||||||
path := s.metaPath(host, pathHash)
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // path derived from host+hash
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var meta SourceMetadata
|
|
||||||
|
|
||||||
err = json.Unmarshal(data, &meta)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &meta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes metadata for the given host and path hash.
|
|
||||||
func (s *MetadataStorage) Delete(host string, pathHash PathHash) error {
|
|
||||||
path := s.metaPath(host, pathHash)
|
|
||||||
|
|
||||||
err := os.Remove(path)
|
|
||||||
if err != nil && !os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("failed to delete metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exists checks if metadata exists for the given host and path hash.
|
|
||||||
func (s *MetadataStorage) Exists(host string, pathHash PathHash) bool {
|
|
||||||
path := s.metaPath(host, pathHash)
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// metaPath returns the file path for metadata: <basedir>/<host>/<path_hash>.json
|
|
||||||
func (s *MetadataStorage) metaPath(host string, pathHash PathHash) string {
|
|
||||||
return filepath.Clean(filepath.Join(s.baseDir, host, string(pathHash)+".json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// HashPath computes the SHA256 hash of a path string.
|
|
||||||
func HashPath(path string) PathHash {
|
|
||||||
h := sha256.Sum256([]byte(path))
|
|
||||||
|
|
||||||
return PathHash(hex.EncodeToString(h[:]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// VariantStorage handles key-based file storage for processed image variants.
|
// VariantStorage handles key-based file storage for processed image variants.
|
||||||
// Files are stored at: <basedir>/<ab>/<cd>/<cache_key>
|
// Files are stored at: <basedir>/<ab>/<cd>/<cache_key>
|
||||||
// Metadata is stored at: <basedir>/<ab>/<cd>/<cache_key>.meta
|
|
||||||
// Unlike ContentStorage, the key is provided by the caller (not computed from content).
|
|
||||||
type VariantStorage struct {
|
type VariantStorage struct {
|
||||||
baseDir string
|
baseDir string
|
||||||
log *slog.Logger
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// VariantMeta contains metadata about a cached variant.
|
// errNotAnOutputFormat is returned for a variant file whose first bytes are
|
||||||
//
|
// those of a format pixa reads but never writes, such as SVG.
|
||||||
//nolint:tagliatelle // stored metadata format uses snake_case
|
var errNotAnOutputFormat = errors.New("variant file is not in an output format")
|
||||||
type VariantMeta struct {
|
|
||||||
ContentType string `json:"content_type"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewVariantStorage creates a new variant storage at the given base directory.
|
// NewVariantStorage creates a new variant storage at the given base directory.
|
||||||
// A failed .meta write is logged to log.
|
func NewVariantStorage(baseDir string) (*VariantStorage, error) {
|
||||||
func NewVariantStorage(baseDir string, log *slog.Logger) (*VariantStorage, error) {
|
|
||||||
err := os.MkdirAll(baseDir, StorageDirPerm)
|
err := os.MkdirAll(baseDir, StorageDirPerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
|
return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &VariantStorage{baseDir: baseDir, log: log}, nil
|
return &VariantStorage{baseDir: baseDir}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store writes content and metadata to storage at the given key, each
|
// Store writes content to storage at the given key with writeFileSynced.
|
||||||
// with writeFileSynced. The .meta file is written first, so that a crash
|
|
||||||
// cannot leave the content in place without it.
|
|
||||||
func (s *VariantStorage) Store(
|
func (s *VariantStorage) Store(
|
||||||
key imagerequest.VariantKey, r io.Reader, contentType string,
|
key imagerequest.VariantKey, r io.Reader,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
data, err := io.ReadAll(r)
|
data, err := io.ReadAll(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to read content: %w", err)
|
return 0, fmt.Errorf("failed to read content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
size := int64(len(data))
|
|
||||||
path := s.keyToPath(key)
|
path := s.keyToPath(key)
|
||||||
metaPath := path + ".meta"
|
|
||||||
|
|
||||||
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to create directory: %w", err)
|
return 0, fmt.Errorf("failed to create directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
meta := VariantMeta{
|
|
||||||
ContentType: contentType,
|
|
||||||
Size: size,
|
|
||||||
CreatedAt: time.Now().UTC().Unix(),
|
|
||||||
}
|
|
||||||
|
|
||||||
metaData, err := json.Marshal(meta)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to marshal metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metadata write failure is non-fatal: the content is then served as
|
|
||||||
// application/octet-stream.
|
|
||||||
err = writeFileSynced(metaPath, metaData)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("failed to write variant metadata sidecar",
|
|
||||||
"path", metaPath, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = writeFileSynced(path, data)
|
err = writeFileSynced(path, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return size, nil
|
return int64(len(data)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load returns a reader for the content at the given key.
|
// Load returns a reader for the content at the given key.
|
||||||
@@ -493,56 +267,63 @@ func (s *VariantStorage) Load(key imagerequest.VariantKey) (io.ReadCloser, error
|
|||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadWithSize returns a reader and file size for the content at the
|
// LoadWithContentType opens the content at the given key and returns a
|
||||||
// given key.
|
// reader, the file size and the content type, which is read from the
|
||||||
func (s *VariantStorage) LoadWithSize(
|
// file's first bytes. A missing file is ErrNotFound. A file whose first
|
||||||
|
// bytes are not those of an output format, an empty one included, is
|
||||||
|
// closed again and the reason returned.
|
||||||
|
func (s *VariantStorage) LoadWithContentType(
|
||||||
key imagerequest.VariantKey,
|
key imagerequest.VariantKey,
|
||||||
) (io.ReadCloser, int64, error) {
|
) (io.ReadCloser, int64, string, error) {
|
||||||
path := s.keyToPath(key)
|
path := s.keyToPath(key)
|
||||||
|
|
||||||
f, err := os.Open(path) //nolint:gosec // path derived from cache key
|
f, err := os.Open(path) //nolint:gosec // path derived from cache key
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return nil, 0, ErrNotFound
|
return nil, 0, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, 0, fmt.Errorf("failed to open content: %w", err)
|
return nil, 0, "", fmt.Errorf("failed to open content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stat, err := f.Stat()
|
stat, err := f.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = f.Close()
|
_ = f.Close()
|
||||||
|
|
||||||
return nil, 0, fmt.Errorf("failed to stat content: %w", err)
|
return nil, 0, "", fmt.Errorf("failed to stat content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return f, stat.Size(), nil
|
contentType, err := outputContentType(f)
|
||||||
}
|
|
||||||
|
|
||||||
// LoadWithMeta returns a reader, size, and content type for the content at
|
|
||||||
// the given key. The content type is read from the .meta file, and is
|
|
||||||
// empty when that file is missing or unreadable.
|
|
||||||
func (s *VariantStorage) LoadWithMeta(
|
|
||||||
key imagerequest.VariantKey,
|
|
||||||
) (io.ReadCloser, int64, string, error) {
|
|
||||||
f, size, err := s.LoadWithSize(key)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
|
||||||
return nil, 0, "", err
|
return nil, 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var contentType string
|
return f, stat.Size(), contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
metaPath := s.keyToPath(key) + ".meta"
|
// outputContentType reads the first bytes of content, without moving its
|
||||||
|
// offset, and returns the content type of the output format they show.
|
||||||
|
func outputContentType(content io.ReaderAt) (string, error) {
|
||||||
|
firstBytes := make([]byte, magic.MinMagicBytes)
|
||||||
|
|
||||||
metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key
|
_, err := content.ReadAt(firstBytes, 0)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
var meta VariantMeta
|
return "", fmt.Errorf("failed to read the first bytes: %w", err)
|
||||||
if json.Unmarshal(metaData, &meta) == nil && meta.ContentType != "" {
|
|
||||||
contentType = meta.ContentType
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return f, size, contentType, nil
|
detected, err := magic.DetectFormat(firstBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, isOutputFormat := magic.MIMEToImageFormat(string(detected))
|
||||||
|
if !isOutputFormat {
|
||||||
|
return "", fmt.Errorf("%w: %s", errNotAnOutputFormat, detected)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(detected), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exists checks if content exists at the given key.
|
// Exists checks if content exists at the given key.
|
||||||
@@ -553,8 +334,8 @@ func (s *VariantStorage) Exists(key imagerequest.VariantKey) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes the content at the given key together with its .meta
|
// Delete removes the content at the given key. A missing file is not an
|
||||||
// sidecar file. A missing file is not an error.
|
// error.
|
||||||
func (s *VariantStorage) Delete(key imagerequest.VariantKey) error {
|
func (s *VariantStorage) Delete(key imagerequest.VariantKey) error {
|
||||||
path := s.keyToPath(key)
|
path := s.keyToPath(key)
|
||||||
|
|
||||||
@@ -563,13 +344,6 @@ func (s *VariantStorage) Delete(key imagerequest.VariantKey) error {
|
|||||||
return fmt.Errorf("failed to delete content: %w", err)
|
return fmt.Errorf("failed to delete content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
metaPath := path + ".meta"
|
|
||||||
|
|
||||||
err = os.Remove(metaPath)
|
|
||||||
if err != nil && !os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("failed to delete variant metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
@@ -24,8 +22,9 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
content := []byte("hello world")
|
content := []byte("hello world")
|
||||||
|
key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
|
||||||
|
|
||||||
hash, size, err := storage.Store(bytes.NewReader(content))
|
size, err := storage.Store(key, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Store() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -34,14 +33,10 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
|||||||
t.Errorf("Store() size = %d, want %d", size, len(content))
|
t.Errorf("Store() size = %d, want %d", size, len(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
if hash == "" {
|
|
||||||
t.Error("Store() returned empty hash")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify file exists at expected path
|
// Verify file exists at expected path
|
||||||
hashStr := string(hash)
|
keyStr := string(key)
|
||||||
|
|
||||||
expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr)
|
expectedPath := filepath.Join(tmpDir, keyStr[0:2], keyStr[2:4], keyStr)
|
||||||
|
|
||||||
_, err = os.Stat(expectedPath)
|
_, err = os.Stat(expectedPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -49,7 +44,7 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load and verify content
|
// Load and verify content
|
||||||
r, err := storage.Load(hash)
|
r, err := storage.Load(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -66,33 +61,6 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContentStorage_StoreIdempotent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
|
|
||||||
storage, err := NewContentStorage(tmpDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
content := []byte("same content")
|
|
||||||
|
|
||||||
hash1, _, err := storage.Store(bytes.NewReader(content))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Store() first error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hash2, _, err := storage.Store(bytes.NewReader(content))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Store() second error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hash1 != hash2 {
|
|
||||||
t.Errorf("Store() hashes differ: %s vs %s", hash1, hash2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContentStorage_LoadNotFound(t *testing.T) {
|
func TestContentStorage_LoadNotFound(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -103,7 +71,7 @@ func TestContentStorage_LoadNotFound(t *testing.T) {
|
|||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = storage.Load(ContentHash("nonexistent"))
|
_, err = storage.Load(imagerequest.SourceKey("nonexistent"))
|
||||||
if !errors.Is(err, ErrNotFound) {
|
if !errors.Is(err, ErrNotFound) {
|
||||||
t.Errorf("Load() error = %v, want ErrNotFound", err)
|
t.Errorf("Load() error = %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
@@ -120,22 +88,23 @@ func TestContentStorage_Delete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
content := []byte("to be deleted")
|
content := []byte("to be deleted")
|
||||||
|
key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
|
||||||
|
|
||||||
hash, _, err := storage.Store(bytes.NewReader(content))
|
_, err = storage.Store(key, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Store() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !storage.Exists(hash) {
|
if !storage.Exists(key) {
|
||||||
t.Error("Exists() = false, want true")
|
t.Error("Exists() = false, want true")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = storage.Delete(hash)
|
err = storage.Delete(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Delete() error = %v", err)
|
t.Fatalf("Delete() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if storage.Exists(hash) {
|
if storage.Exists(key) {
|
||||||
t.Error("Exists() = true after delete, want false")
|
t.Error("Exists() = true after delete, want false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,13 +120,13 @@ func TestContentStorage_DeleteNonexistent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Should not error
|
// Should not error
|
||||||
err = storage.Delete(ContentHash("nonexistent"))
|
err = storage.Delete(imagerequest.SourceKey("nonexistent"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Delete() error = %v, want nil", err)
|
t.Errorf("Delete() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContentStorage_HashToPath(t *testing.T) {
|
func TestContentStorage_KeyToPath(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
@@ -169,263 +138,19 @@ func TestContentStorage_HashToPath(t *testing.T) {
|
|||||||
|
|
||||||
// Test by storing and verifying the resulting path structure
|
// Test by storing and verifying the resulting path structure
|
||||||
content := []byte("test content for path verification")
|
content := []byte("test content for path verification")
|
||||||
|
key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
|
||||||
|
|
||||||
hash, _, err := storage.Store(bytes.NewReader(content))
|
_, err = storage.Store(key, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Store() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hashStr := string(hash)
|
keyStr := string(key)
|
||||||
|
|
||||||
expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr)
|
expectedPath := filepath.Join(tmpDir, keyStr[0:2], keyStr[2:4], keyStr)
|
||||||
|
|
||||||
_, err = os.Stat(expectedPath)
|
_, err = os.Stat(expectedPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetadataStorage_StoreAndLoad(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
|
|
||||||
storage, err := NewMetadataStorage(tmpDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewMetadataStorage() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := &SourceMetadata{
|
|
||||||
Host: testHostCDN,
|
|
||||||
Path: testPathCat,
|
|
||||||
ContentHash: "abc123",
|
|
||||||
StatusCode: 200,
|
|
||||||
ContentType: testContentTypeJPEG,
|
|
||||||
FetchedAt: 1704067200,
|
|
||||||
ETag: `"etag123"`,
|
|
||||||
}
|
|
||||||
|
|
||||||
pathHash := HashPath(testPathCat)
|
|
||||||
|
|
||||||
err = storage.Store(testHostCDN, pathHash, meta)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Store() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify file exists at expected path
|
|
||||||
expectedPath := filepath.Join(tmpDir, testHostCDN, string(pathHash)+".json")
|
|
||||||
|
|
||||||
_, err = os.Stat(expectedPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load and verify
|
|
||||||
loaded, err := storage.Load(testHostCDN, pathHash)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if loaded.Host != meta.Host {
|
|
||||||
t.Errorf("Host = %q, want %q", loaded.Host, meta.Host)
|
|
||||||
}
|
|
||||||
|
|
||||||
if loaded.Path != meta.Path {
|
|
||||||
t.Errorf("Path = %q, want %q", loaded.Path, meta.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if loaded.ContentHash != meta.ContentHash {
|
|
||||||
t.Errorf("ContentHash = %q, want %q", loaded.ContentHash, meta.ContentHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
if loaded.StatusCode != meta.StatusCode {
|
|
||||||
t.Errorf("StatusCode = %d, want %d", loaded.StatusCode, meta.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if loaded.ETag != meta.ETag {
|
|
||||||
t.Errorf("ETag = %q, want %q", loaded.ETag, meta.ETag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMetadataStorage_LoadNotFound(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
|
|
||||||
storage, err := NewMetadataStorage(tmpDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewMetadataStorage() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = storage.Load(testHostExample, PathHash("nonexistent"))
|
|
||||||
if !errors.Is(err, ErrNotFound) {
|
|
||||||
t.Errorf("Load() error = %v, want ErrNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMetadataStorage_Delete(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
|
|
||||||
storage, err := NewMetadataStorage(tmpDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewMetadataStorage() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := &SourceMetadata{
|
|
||||||
Host: testHostExample,
|
|
||||||
Path: "/test.jpg",
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
pathHash := HashPath("/test.jpg")
|
|
||||||
|
|
||||||
err = storage.Store(testHostExample, pathHash, meta)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Store() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !storage.Exists(testHostExample, pathHash) {
|
|
||||||
t.Error("Exists() = false, want true")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = storage.Delete(testHostExample, pathHash)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Delete() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if storage.Exists(testHostExample, pathHash) {
|
|
||||||
t.Error("Exists() = true after delete, want false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHashPath(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Same input should produce same hash
|
|
||||||
hash1 := HashPath(testPathCat)
|
|
||||||
hash2 := HashPath(testPathCat)
|
|
||||||
|
|
||||||
if hash1 != hash2 {
|
|
||||||
t.Errorf("HashPath() not deterministic: %s vs %s", hash1, hash2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Different input should produce different hash
|
|
||||||
hash3 := HashPath("/photos/dog.jpg")
|
|
||||||
|
|
||||||
if hash1 == hash3 {
|
|
||||||
t.Error("HashPath() produced same hash for different inputs")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash should be 64 hex chars (256 bits)
|
|
||||||
if len(string(hash1)) != 64 {
|
|
||||||
t.Errorf("HashPath() length = %d, want 64", len(string(hash1)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVariantStorage_StoreLogsFailedMetaWrite verifies that a .meta write
|
|
||||||
// that fails is logged at warn and the store still succeeds.
|
|
||||||
func TestVariantStorage_StoreLogsFailedMetaWrite(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var logBuf bytes.Buffer
|
|
||||||
|
|
||||||
storage, err := NewVariantStorage(
|
|
||||||
t.TempDir(), slog.New(slog.NewJSONHandler(&logBuf, nil)))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewVariantStorage() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
key := imagerequest.CacheKey(&imagerequest.Request{
|
|
||||||
Source: imagerequest.Source{Host: testHostCDN, Path: testPathCat},
|
|
||||||
})
|
|
||||||
|
|
||||||
// A directory where the .meta file goes makes the .meta write fail.
|
|
||||||
err = os.MkdirAll(storage.keyToPath(key)+".meta", StorageDirPerm)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = storage.Store(key, bytes.NewReader([]byte("variant data")), "image/webp")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Store() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := `"level":"WARN","msg":"failed to write variant metadata sidecar"`
|
|
||||||
if !strings.Contains(logBuf.String(), want) {
|
|
||||||
t.Errorf("log missing %s; got %q", want, logBuf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// storeTestVariant stores one variant, with its .meta file, in a new
|
|
||||||
// VariantStorage and returns the storage and the variant's key.
|
|
||||||
func storeTestVariant(t *testing.T) (*VariantStorage, imagerequest.VariantKey) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
storage, err := NewVariantStorage(t.TempDir(), slog.New(slog.DiscardHandler))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewVariantStorage() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
key := imagerequest.CacheKey(&imagerequest.Request{
|
|
||||||
Source: imagerequest.Source{Host: testHostCDN, Path: testPathCat},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err = storage.Store(key, bytes.NewReader([]byte("variant data")), "image/webp")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Store() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return storage, key
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVariantStorage_DeleteRemovesMeta verifies that Delete removes the
|
|
||||||
// variant's .meta file along with the variant file.
|
|
||||||
func TestVariantStorage_DeleteRemovesMeta(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
storage, key := storeTestVariant(t)
|
|
||||||
metaPath := storage.keyToPath(key) + ".meta"
|
|
||||||
|
|
||||||
_, err := os.Stat(metaPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Store() wrote no .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = storage.Delete(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Delete() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if storage.Exists(key) {
|
|
||||||
t.Error("Exists() = true after delete, want false")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = os.Stat(metaPath)
|
|
||||||
if !os.IsNotExist(err) {
|
|
||||||
t.Errorf(".meta file left after Delete() (stat err=%v)", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVariantStorage_DeleteWithoutMeta verifies that Delete succeeds for a
|
|
||||||
// variant whose .meta file is missing.
|
|
||||||
func TestVariantStorage_DeleteWithoutMeta(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
storage, key := storeTestVariant(t)
|
|
||||||
|
|
||||||
err := os.Remove(storage.keyToPath(key) + ".meta")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("removing .meta file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = storage.Delete(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Delete() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if storage.Exists(key) {
|
|
||||||
t.Error("Exists() = true after delete, want false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -30,6 +28,7 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/handlers"
|
"sneak.berlin/go/pixa/internal/handlers"
|
||||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
"sneak.berlin/go/pixa/internal/middleware"
|
"sneak.berlin/go/pixa/internal/middleware"
|
||||||
)
|
)
|
||||||
@@ -254,39 +253,26 @@ func getImage(t *testing.T, s *Server, target string) *httptest.ResponseRecorder
|
|||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkSourceCached checks that source is stored under its SHA-256 in
|
// checkSourceCached checks that source is stored in cache/sources under the
|
||||||
// cache/sources, recorded in source_content, and that the source URL's row in
|
// source key of upstreamHost and /photo.png, and recorded under that key in
|
||||||
// source_metadata points at it.
|
// source_content.
|
||||||
func checkSourceCached(t *testing.T, db *sql.DB, stateDir string, source []byte) {
|
func checkSourceCached(t *testing.T, db *sql.DB, stateDir string, source []byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
sum := sha256.Sum256(source)
|
key := string(imagerequest.Source{Host: upstreamHost, Path: "/photo.png"}.Key())
|
||||||
hash := hex.EncodeToString(sum[:])
|
|
||||||
|
|
||||||
checkFile(t, filepath.Join(stateDir, "cache", "sources", hash[0:2], hash[2:4], hash),
|
checkFile(t, filepath.Join(stateDir, "cache", "sources", key[0:2], key[2:4], key),
|
||||||
source)
|
source)
|
||||||
|
|
||||||
var rows int
|
var rows int
|
||||||
|
|
||||||
err := db.QueryRowContext(t.Context(),
|
err := db.QueryRowContext(t.Context(),
|
||||||
"SELECT COUNT(*) FROM source_content WHERE content_hash = ?", hash,
|
"SELECT COUNT(*) FROM source_content WHERE source_key = ?", key,
|
||||||
).Scan(&rows)
|
).Scan(&rows)
|
||||||
if err != nil || rows != 1 {
|
if err != nil || rows != 1 {
|
||||||
t.Errorf("source_content rows for the source = %d (error %v), want 1",
|
t.Errorf("source_content rows for the source = %d (error %v), want 1",
|
||||||
rows, err)
|
rows, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadataHash string
|
|
||||||
|
|
||||||
err = db.QueryRowContext(t.Context(),
|
|
||||||
`SELECT content_hash FROM source_metadata
|
|
||||||
WHERE source_host = ? AND source_path = ?`,
|
|
||||||
upstreamHost, "/photo.png",
|
|
||||||
).Scan(&metadataHash)
|
|
||||||
if err != nil || metadataHash != hash {
|
|
||||||
t.Errorf("source_metadata content_hash = %q (error %v), want %q",
|
|
||||||
metadataHash, err, hash)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkVariantCached checks that the converted image served is recorded in
|
// checkVariantCached checks that the converted image served is recorded in
|
||||||
|
|||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/bench: run the Go benchmarks of internal/handlers, which serve
|
||||||
|
# cache hits in parallel through the image handler and report the hits
|
||||||
|
# served per second. A benchmark, not a check: script/check does not run
|
||||||
|
# it. It runs Go on the host, as script/loadtest does, and compiles pixa
|
||||||
|
# there with CGO, so it needs the C compiler and libraries that
|
||||||
|
# `script/bootstrap --cgo` installs. -run '^$' runs no test, only the
|
||||||
|
# benchmarks.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
CGO_ENABLED=1 go test -run '^$' -bench . ./internal/handlers
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user