Skip to content

Commit ec03e65

Browse files
perf: Cloud Run CPU optimization, server-side pagination, GCS cache
- Reduce frontend Cloud Run CPU from 2 to 1 (nginx doesn't need 2 cores) - Reduce backend Cloud Run CPU from 2 to 1, enable gen2 execution environment - Add server-side pagination (limit/offset) to /plots/filter endpoint - Increase GCS image Cache-Control from 1h to 1 day (86400s) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 846a33b commit ec03e65

8 files changed

Lines changed: 149 additions & 19 deletions

File tree

.github/workflows/impl-generate.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,7 @@ jobs:
534534
535535
# Upload PNG (with watermark)
536536
if [ -f "$IMPL_DIR/plot.png" ]; then
537-
gsutil cp "$IMPL_DIR/plot.png" "${STAGING_PATH}/plot.png"
537+
gsutil -h "Cache-Control:public, max-age=86400" cp "$IMPL_DIR/plot.png" "${STAGING_PATH}/plot.png"
538538
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.png" 2>/dev/null || true
539539
echo "png_url=${PUBLIC_URL}/plot.png" >> $GITHUB_OUTPUT
540540
echo "uploaded=true" >> $GITHUB_OUTPUT
@@ -545,15 +545,15 @@ jobs:
545545
546546
# Upload thumbnail
547547
if [ -f "$IMPL_DIR/plot_thumb.png" ]; then
548-
gsutil cp "$IMPL_DIR/plot_thumb.png" "${STAGING_PATH}/plot_thumb.png"
548+
gsutil -h "Cache-Control:public, max-age=86400" cp "$IMPL_DIR/plot_thumb.png" "${STAGING_PATH}/plot_thumb.png"
549549
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot_thumb.png" 2>/dev/null || true
550550
echo "thumb_url=${PUBLIC_URL}/plot_thumb.png" >> $GITHUB_OUTPUT
551551
echo "::notice::Uploaded thumbnail"
552552
fi
553553
554554
# Upload HTML (interactive libraries)
555555
if [ -f "$IMPL_DIR/plot.html" ]; then
556-
gsutil cp "$IMPL_DIR/plot.html" "${STAGING_PATH}/plot.html"
556+
gsutil -h "Cache-Control:public, max-age=86400" cp "$IMPL_DIR/plot.html" "${STAGING_PATH}/plot.html"
557557
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.html" 2>/dev/null || true
558558
echo "html_url=${PUBLIC_URL}/plot.html" >> $GITHUB_OUTPUT
559559
fi

.github/workflows/impl-merge.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ jobs:
201201
PRODUCTION="gs://pyplots-images/plots/${SPEC_ID}/${LIBRARY}"
202202
203203
# Copy from staging to production
204-
gsutil -m cp -r "${STAGING}/*" "${PRODUCTION}/" 2>/dev/null || echo "No staging files to promote"
204+
gsutil -m -h "Cache-Control:public, max-age=86400" cp -r "${STAGING}/*" "${PRODUCTION}/" 2>/dev/null || echo "No staging files to promote"
205205
206206
# Make production files public
207207
gsutil -m acl ch -r -u AllUsers:R "${PRODUCTION}/" 2>/dev/null || true

.github/workflows/impl-repair.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,17 +187,17 @@ jobs:
187187
gcloud auth activate-service-account --key-file=/tmp/gcs-key.json
188188
189189
if [ -f "$IMPL_DIR/plot.png" ]; then
190-
gsutil cp "$IMPL_DIR/plot.png" "${STAGING_PATH}/plot.png"
190+
gsutil -h "Cache-Control:public, max-age=86400" cp "$IMPL_DIR/plot.png" "${STAGING_PATH}/plot.png"
191191
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.png" 2>/dev/null || true
192192
fi
193193
194194
if [ -f "$IMPL_DIR/plot_thumb.png" ]; then
195-
gsutil cp "$IMPL_DIR/plot_thumb.png" "${STAGING_PATH}/plot_thumb.png"
195+
gsutil -h "Cache-Control:public, max-age=86400" cp "$IMPL_DIR/plot_thumb.png" "${STAGING_PATH}/plot_thumb.png"
196196
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot_thumb.png" 2>/dev/null || true
197197
fi
198198
199199
if [ -f "$IMPL_DIR/plot.html" ]; then
200-
gsutil cp "$IMPL_DIR/plot.html" "${STAGING_PATH}/plot.html"
200+
gsutil -h "Cache-Control:public, max-age=86400" cp "$IMPL_DIR/plot.html" "${STAGING_PATH}/plot.html"
201201
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot.html" 2>/dev/null || true
202202
fi
203203

api/cloudbuild.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ substitutions:
33
_SERVICE_NAME: pyplots-backend
44
_REGION: europe-west4
55
_MEMORY: 512Mi
6-
_CPU: "2"
6+
_CPU: "1"
77
_MIN_INSTANCES: "1"
88
_MAX_INSTANCES: "3"
99

@@ -55,6 +55,7 @@ steps:
5555
- "--add-cloudsql-instances=pyplots:europe-west4:pyplots-db"
5656
- "--set-secrets=DATABASE_URL=DATABASE_URL:latest"
5757
- "--set-env-vars=ENVIRONMENT=production"
58+
- "--execution-environment=gen2"
5859
- "--set-env-vars=GOOGLE_CLOUD_PROJECT=$PROJECT_ID"
5960
- "--set-env-vars=GCS_BUCKET=pyplots-images"
6061
id: "deploy"

api/routers/plots.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
from collections.abc import Callable
55

6-
from fastapi import APIRouter, Depends, Request
6+
from fastapi import APIRouter, Depends, Query, Request
77
from sqlalchemy.exc import SQLAlchemyError
88
from sqlalchemy.ext.asyncio import AsyncSession
99

@@ -263,21 +263,27 @@ def _parse_filter_groups(request: Request) -> list[dict]:
263263
return filter_groups
264264

265265

266-
def _build_cache_key(filter_groups: list[dict]) -> str:
266+
def _build_cache_key(filter_groups: list[dict], *, offset: int = 0, limit: int | None = None) -> str:
267267
"""
268-
Build cache key from filter groups.
268+
Build cache key from filter groups and pagination params.
269269
270270
Args:
271271
filter_groups: List of filter group dicts
272+
offset: Pagination offset
273+
limit: Pagination limit
272274
273275
Returns:
274276
Cache key string
275277
"""
276278
if not filter_groups:
277-
return "filter:all"
279+
base = "filter:all"
280+
else:
281+
cache_parts = [f"{g['category']}={','.join(sorted(g['values']))}" for g in filter_groups]
282+
base = f"filter:{':'.join(cache_parts)}"
278283

279-
cache_parts = [f"{g['category']}={','.join(sorted(g['values']))}" for g in filter_groups]
280-
return f"filter:{':'.join(cache_parts)}"
284+
if limit is not None or offset:
285+
base += f":o={offset}:l={limit}"
286+
return base
281287

282288

283289
def _build_spec_lookup(all_specs: list) -> dict:
@@ -370,7 +376,12 @@ def _filter_images(
370376

371377

372378
@router.get("/plots/filter", response_model=FilteredPlotsResponse)
373-
async def get_filtered_plots(request: Request, db: AsyncSession = Depends(require_db)):
379+
async def get_filtered_plots(
380+
request: Request,
381+
db: AsyncSession = Depends(require_db),
382+
limit: int | None = Query(None, ge=1),
383+
offset: int = Query(0, ge=0),
384+
):
374385
"""
375386
Get filtered plot images with counts for all filter categories.
376387
@@ -399,7 +410,7 @@ async def get_filtered_plots(request: Request, db: AsyncSession = Depends(requir
399410
filter_groups = _parse_filter_groups(request)
400411

401412
# Check cache
402-
cache_key = _build_cache_key(filter_groups)
413+
cache_key = _build_cache_key(filter_groups, offset=offset, limit=limit)
403414
try:
404415
cached = get_cache(cache_key)
405416
if cached:
@@ -425,22 +436,27 @@ async def get_filtered_plots(request: Request, db: AsyncSession = Depends(requir
425436
# Filter images
426437
filtered_images = _filter_images(all_images, filter_groups, spec_lookup, impl_lookup)
427438

428-
# Calculate counts
439+
# Calculate counts (always from ALL filtered images, not paginated)
429440
global_counts = _calculate_global_counts(all_specs)
430441
counts = _calculate_contextual_counts(filtered_images, spec_id_to_tags, impl_lookup)
431442
or_counts = _calculate_or_counts(filter_groups, all_images, spec_id_to_tags, spec_lookup, impl_lookup)
432443

433444
# Build spec_id -> title mapping for search/tooltips
434445
spec_titles = {spec_id: data["spec"].title for spec_id, data in spec_lookup.items() if data["spec"].title}
435446

447+
# Apply pagination
448+
paginated = filtered_images[offset : offset + limit] if limit else filtered_images[offset:]
449+
436450
# Build and cache response
437451
result = FilteredPlotsResponse(
438452
total=len(filtered_images),
439-
images=filtered_images,
453+
images=paginated,
440454
counts=counts,
441455
globalCounts=global_counts,
442456
orCounts=or_counts,
443457
specTitles=spec_titles,
458+
offset=offset,
459+
limit=limit,
444460
)
445461

446462
try:

api/schemas.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ class FilteredPlotsResponse(BaseModel):
9898
globalCounts: dict[str, dict[str, int]] # Same structure for global counts
9999
orCounts: list[dict[str, int]] # Per-group OR counts
100100
specTitles: dict[str, str] = {} # Mapping spec_id -> title for search/tooltips
101+
offset: int = 0
102+
limit: int | None = None
101103

102104

103105
class LibraryInfo(BaseModel):

app/cloudbuild.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ steps:
4242
- "--memory"
4343
- "256Mi"
4444
- "--cpu"
45-
- "2"
45+
- "1"
4646
- "--timeout"
4747
- "60"
4848
- "--min-instances"

tests/unit/api/test_routers.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,8 @@ def test_filter_cached(self, client: TestClient) -> None:
841841
cached_response.globalCounts = {}
842842
cached_response.orCounts = []
843843
cached_response.specTitles = {}
844+
cached_response.offset = 0
845+
cached_response.limit = None
844846

845847
with (
846848
patch(DB_CONFIG_PATCH, return_value=True),
@@ -849,6 +851,115 @@ def test_filter_cached(self, client: TestClient) -> None:
849851
response = client.get("/plots/filter")
850852
assert response.status_code == 200
851853

854+
def test_filter_with_limit(self, client: TestClient, mock_spec) -> None:
855+
"""Filter with limit should return limited images but total of all."""
856+
# Add a second impl to have 2 images
857+
mock_impl2 = MagicMock()
858+
mock_impl2.library_id = "seaborn"
859+
mock_impl2.preview_url = TEST_IMAGE_URL
860+
mock_impl2.preview_thumb = TEST_THUMB_URL
861+
mock_impl2.preview_html = None
862+
mock_impl2.quality_score = 85.0
863+
mock_impl2.impl_tags = {}
864+
mock_spec.impls.append(mock_impl2)
865+
866+
mock_spec_repo = MagicMock()
867+
mock_spec_repo.get_all = AsyncMock(return_value=[mock_spec])
868+
869+
with (
870+
patch(DB_CONFIG_PATCH, return_value=True),
871+
patch("api.routers.plots.get_cache", return_value=None),
872+
patch("api.routers.plots.set_cache"),
873+
patch("api.routers.plots.SpecRepository", return_value=mock_spec_repo),
874+
):
875+
response = client.get("/plots/filter?limit=1")
876+
assert response.status_code == 200
877+
data = response.json()
878+
assert len(data["images"]) == 1
879+
assert data["total"] == 2
880+
assert data["limit"] == 1
881+
assert data["offset"] == 0
882+
883+
def test_filter_with_offset(self, client: TestClient, mock_spec) -> None:
884+
"""Filter with offset should skip images."""
885+
mock_impl2 = MagicMock()
886+
mock_impl2.library_id = "seaborn"
887+
mock_impl2.preview_url = TEST_IMAGE_URL
888+
mock_impl2.preview_thumb = TEST_THUMB_URL
889+
mock_impl2.preview_html = None
890+
mock_impl2.quality_score = 85.0
891+
mock_impl2.impl_tags = {}
892+
mock_spec.impls.append(mock_impl2)
893+
894+
mock_spec_repo = MagicMock()
895+
mock_spec_repo.get_all = AsyncMock(return_value=[mock_spec])
896+
897+
with (
898+
patch(DB_CONFIG_PATCH, return_value=True),
899+
patch("api.routers.plots.get_cache", return_value=None),
900+
patch("api.routers.plots.set_cache"),
901+
patch("api.routers.plots.SpecRepository", return_value=mock_spec_repo),
902+
):
903+
response = client.get("/plots/filter?offset=1")
904+
assert response.status_code == 200
905+
data = response.json()
906+
assert len(data["images"]) == 1
907+
assert data["total"] == 2
908+
assert data["offset"] == 1
909+
910+
def test_filter_with_limit_and_offset(self, client: TestClient, mock_spec) -> None:
911+
"""Filter with limit and offset combined."""
912+
mock_impl2 = MagicMock()
913+
mock_impl2.library_id = "seaborn"
914+
mock_impl2.preview_url = TEST_IMAGE_URL
915+
mock_impl2.preview_thumb = TEST_THUMB_URL
916+
mock_impl2.preview_html = None
917+
mock_impl2.quality_score = 85.0
918+
mock_impl2.impl_tags = {}
919+
mock_impl3 = MagicMock()
920+
mock_impl3.library_id = "plotly"
921+
mock_impl3.preview_url = TEST_IMAGE_URL
922+
mock_impl3.preview_thumb = TEST_THUMB_URL
923+
mock_impl3.preview_html = None
924+
mock_impl3.quality_score = 80.0
925+
mock_impl3.impl_tags = {}
926+
mock_spec.impls.extend([mock_impl2, mock_impl3])
927+
928+
mock_spec_repo = MagicMock()
929+
mock_spec_repo.get_all = AsyncMock(return_value=[mock_spec])
930+
931+
with (
932+
patch(DB_CONFIG_PATCH, return_value=True),
933+
patch("api.routers.plots.get_cache", return_value=None),
934+
patch("api.routers.plots.set_cache"),
935+
patch("api.routers.plots.SpecRepository", return_value=mock_spec_repo),
936+
):
937+
response = client.get("/plots/filter?offset=1&limit=1")
938+
assert response.status_code == 200
939+
data = response.json()
940+
assert len(data["images"]) == 1
941+
assert data["total"] == 3
942+
assert data["offset"] == 1
943+
assert data["limit"] == 1
944+
945+
def test_filter_default_returns_all(self, client: TestClient, mock_spec) -> None:
946+
"""Filter without pagination params returns all images (backward compat)."""
947+
mock_spec_repo = MagicMock()
948+
mock_spec_repo.get_all = AsyncMock(return_value=[mock_spec])
949+
950+
with (
951+
patch(DB_CONFIG_PATCH, return_value=True),
952+
patch("api.routers.plots.get_cache", return_value=None),
953+
patch("api.routers.plots.set_cache"),
954+
patch("api.routers.plots.SpecRepository", return_value=mock_spec_repo),
955+
):
956+
response = client.get("/plots/filter")
957+
assert response.status_code == 200
958+
data = response.json()
959+
assert len(data["images"]) == data["total"]
960+
assert data["offset"] == 0
961+
assert data["limit"] is None
962+
852963

853964
class TestPlotsHelperFunctions:
854965
"""Tests for plots.py helper functions."""

0 commit comments

Comments
 (0)