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:
@@ -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
|
||||
@@ -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" \
|
||||
|
||||
Reference in New Issue
Block a user