perf(photos): ETag/304 conditional revalidation on the timeline

GET /api/photos sent only X-Next-Cursor — no ETag — so every gallery
re-mount rebuilt up to 500 PhotoDtos, serde-serialized the whole vector,
and shipped the full body even when nothing changed.

The handler now emits a lightweight content-derived ETag
(hash of before + limit + max(modified_at) + row count) and honours
If-None-Match, with Cache-Control: private, no-cache so the SPA's default
fetch cache mode always revalidates. An unchanged "navigate away and back"
becomes an empty 304 instead of a full rebuild + reserialize + transfer.
The DB query still runs (the cheap part); the win is skipping the DTO
build, serialization, and body bytes.

Proven end-to-end (throwaway Postgres + server, 7 images):
  1st GET (no If-None-Match)      -> 200  4586 bytes + ETag
  2nd GET (If-None-Match matches) -> 304     0 bytes
  3rd GET (If-None-Match stale)   -> 200  4586 bytes (correctly invalidated)
~655 B/photo, so a full 500-row first page saves ~320 KB + a 500-DTO
build/serialize per unchanged revalidation. Unlike a cold load this is the
common gallery-navigation path, so it hits real user-facing latency.

Regression test: tests/api/photos_etag.hurl (added to the api-test suite).
Methodology in benches/PHOTOS-ETAG.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DioCrafts
2026-06-22 00:22:49 +02:00
parent f68972e368
commit 0c40c69f9b
4 changed files with 171 additions and 7 deletions
+90
View File
@@ -0,0 +1,90 @@
# =============================================================
# OxiCloud – Photos timeline ETag / 304 conditional revalidation
# =============================================================
# Proves GET /api/photos returns a stable, content-derived ETag and
# honours If-None-Match with an EMPTY 304 — so "navigate away and back"
# to an unchanged gallery skips rebuilding the DTOs + reserializing +
# reshipping the whole page body. Run:
# hurl --variables-file tests/api/test.env --file-root tests \
# --test tests/api/setup.hurl tests/api/photos_etag.hurl
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 – Login
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{
"username": "{{username}}",
"password": "{{password}}"
}
HTTP 200
[Captures]
token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 2 – Home folder id (upload target)
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders
Authorization: Bearer {{token}}
HTTP 200
[Captures]
home_folder_id: jsonpath "$[0].id"
# ─────────────────────────────────────────────────────────────
# Step 3 – Upload an image so the photos timeline is non-empty
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{token}}
[MultipartFormData]
folder_id: {{home_folder_id}}
file: file,fixtures/dedup-test.jpg; image/jpeg
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 4 – First GET: 200 with a body and an ETag (capture it).
# Cache-Control: no-cache makes the browser revalidate.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/photos?limit=200
Authorization: Bearer {{token}}
HTTP 200
[Captures]
photos_etag: header "ETag"
[Asserts]
header "ETag" exists
header "Cache-Control" contains "no-cache"
jsonpath "$" isCollection
jsonpath "$" count >= 1
# ─────────────────────────────────────────────────────────────
# Step 5 – Conditional GET with the same ETag: empty 304 (the win).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/photos?limit=200
Authorization: Bearer {{token}}
If-None-Match: {{photos_etag}}
HTTP 304
[Asserts]
header "ETag" == "{{photos_etag}}"
bytes count == 0
# ─────────────────────────────────────────────────────────────
# Step 6 – A stale/mismatched ETag still gets the full 200 body.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/photos?limit=200
Authorization: Bearer {{token}}
If-None-Match: "stale-etag-does-not-match"
HTTP 200
[Asserts]
jsonpath "$" count >= 1
+1
View File
@@ -136,6 +136,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/nc_ocs_user_info.hurl" \
"$API_DIR/nc_avatar_preview.hurl" \
"$API_DIR/files-folders.hurl" \
"$API_DIR/photos_etag.hurl" \
"$API_DIR/favorites.hurl" \
"$API_DIR/trash.hurl" \
"$API_DIR/trash_resources.hurl" \