Skip to content

Faster PDF report: one image fetch, a Web Worker, JPEG and a satellite cache - #65

Merged
BlancaMunizaga merged 10 commits into
devfrom
BlancaMunizaga/pdf-optimize
Oct 8, 2026
Merged

BlancaMunizaga merged 10 commits into
devfrom
BlancaMunizaga/pdf-optimize

Conversation

@BlancaMunizaga

@BlancaMunizaga BlancaMunizaga commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Implements the OpenSpec change optimize-report-pdf-generation. The PDF report was slow, expensive in Google Static Maps calls and froze the browser; the PDF stays client-side (@react-pdf), moving it to the server was ruled out.

Results

Reference report: 50 farms × 3 layers (GFW, TMF, MAATE 2020-2022), satellite background on, production Docker images of 208b7b8 and of this branch, API cache cold, timed with Playwright over the real flow.

Before After
Preview 34.3 s 8.7 s
"Download" complete PDF (click → file) 43.7 s 0.04 s
Separated download (ZIP of 50) 395 s 7.5 s
Image requests (preview / complete / ZIP) 150 / 150 / 300 150 / 0 / 0
Google Static Maps calls, whole flow 600 50
Longest main-thread freeze 1.7 s 0 ms
Complete PDF / ZIP 42.4 / 51.4 MB 17.1 / 22.4 MB
Third-party hosts while rendering fonts.gstatic.com none

The content doesn't change: the complete PDF from both versions has the same 159 pages, the same text on every page and the same 153 links, in es and en; the per-farm PDFs in the ZIP match too.

Bugs fixed

  • The separated download fetched every image again, and more: 300 image requests in production (450 in dev), 395 s.
  • The preview regenerated itself on the main thread when the page's state changed (opening the download modal, for instance).
  • "Download" redid the preview's work: 150 image requests and a full render (the code had a TODO for it).
  • The satellite background turned off from 34 farms with 3 layers: the limit (100 in production) was compared with farm × layer pairs.
  • A failed preview left a blank page, and the images' blob URLs were never revoked.

API

  • AsyncTTLCache (new): the satellite image depends only on the farm, so GoogleMapsAPIHelper fetches it once for all layers. LRU of 256, 10-minute TTL (approved), shares in-flight calls, doesn't cache failures, checks the bytes decode before caching.
  • One httpx.AsyncClient for the app's lifespan.
  • RasterDatasetCache (new): rasters stay open between requests, LRU of 16 keyed by the versioned path, one reader at a time per raster (GDAL handles aren't thread-safe). Rasters are never replaced in place, so an entry can't go stale.
  • Overlay drawing, compositing and encoding run in threads.
  • /generate-image returns image/jpeg (quality 85, no chroma subsampling): 106 KB instead of 505 KB. The OpenAPI doesn't declare the media type, so the contract and the generated types don't change. Tiles stay PNG.

Web

  • ReportProvider (src/context/ReportContext.tsx, new) fetches the images once per selection and shares them with the preview and both downloads. useDeforestationCompleteReportDocument is removed; useDeforestationReportDownload keeps its API.
  • Web Worker (src/workers/reportPdf.worker.tsx + reportPdfClient.ts): every PDF and the ZIP render off the main thread. The worker sets up its own i18next (same bundled locale files) and runtime config.
  • The preview is an iframe over the worker's blob instead of <PDFViewer>.
  • The complete report (with links) is pre-rendered once the preview shows; a click before it finishes waits for that same render.
  • The satellite limit counts distinct farms.
  • Local assets: Roboto regular, medium, bold, italic and bold italic in public/fonts/roboto (Apache 2.0; the italics are needed by layers whose considerations use _italic_, like IDEAM, and bold italic's old gstatic URL was a 404), absolute URLs via assetUrl(), cover image 211 KB → 32 KB.
  • Preview errors replace the spinner with an error and a "Retry" button (reportGeneration:preview:error / retry, en/es); the failed render is forgotten, so the retry fetches or renders again.

Design deviation

The design proposed grouping each farm's image requests so they'd share the satellite call. Measured, it was twice as slow (5.2 s vs 2.5 s for 150 images): it cut the parallel Google calls from 20 to ~7. The current order already hits the cache for every farm while a report has ≤ 256 farms, which the satellite limit guarantees. Recorded in design.md.

Verified

  • API: 294 tests pass (12 new: the cache's concurrency, failures, LRU, TTL and cancellation; one Google call per farm; the raster cache; and a liveness test that fails if image drawing goes back to the event loop). Ruff, black and mypy are clean; openapi.json doesn't change.
  • Web: tsc is clean; lint has 0 errors (18 warnings, all pre-existing).
  • Production image: Dockerfile.prod builds. Turbopack bundles the worker, and entrypoint.sh substitutes the variables in its chunks (no placeholder left).
  • JPEG: visual check on polygons and points, with and without deforestation and satellite: the outline and the red pixels are unchanged at 3×.
  • Navigation: clicking the header while the preview renders navigates in 0.46 s.
  • CodeQL: the first run flagged 37 js/unvalidated-dynamic-method-call alerts: the worker's message reached the report's t() calls through the locale. The worker now takes the configured locale that matches (es, en) and rejects anything else (second commit); CodeQL reports no new alerts.

Notes

  • Turbopack also copies the worker's source to static/media/reportPdf.worker.*.tsx. It isn't loaded and is just the repo's code.
  • Running the production build locally with node .next/standalone/server.js answered every route with a 307 to itself; the Docker image doesn't, so it looks specific to that local setup. Not addressed here.

…e cache

The report was slow, expensive in Google Static Maps calls and froze the
browser. On the reference report (50 farms x 3 layers, production build):
preview 34.3 s -> 8.7 s, "Download" 43.7 s -> 0.04 s, separated ZIP
395 s -> 7.5 s, Google calls 600 -> 50, longest main-thread freeze
1.7 s -> 0 ms, complete PDF 42 MB -> 17 MB. The PDF's content is
unchanged (same pages, texts and links in es and en).

API
- AsyncTTLCache: the satellite image depends only on the farm, so it is
  fetched once for all layers (LRU 256, 10 min TTL, in-flight dedup,
  failures not cached)
- One httpx client for the app's lifespan
- RasterDatasetCache: rasters stay open between requests (LRU 16 keyed by
  the versioned path, one reader at a time per raster)
- Overlay drawing, compositing and encoding run in threads
- /generate-image returns JPEG (q85, 4:4:4): ~5x smaller than the PNG

Web
- ReportProvider fetches the images once per selection and shares them
  with the preview and both downloads (the downloads fetched them again;
  the ZIP made up to 300 requests)
- Every PDF and the ZIP render in a Web Worker; the preview is an iframe
  over its blob instead of <PDFViewer>, which regenerated itself
- The complete report (with links) is pre-rendered once the preview shows
- The satellite limit counts distinct farms, not farm x layer pairs
- Roboto served from public/fonts (OFL); lighter cover image
- Preview errors show a snackbar instead of a blank page

OpenSpec change: optimize-report-pdf-generation (all tasks done, with the
measurements).
…e message

CodeQL (js/unvalidated-dynamic-method-call, 37 alerts) traced the worker's
message to the t() calls in the report: the message's locale picked the
translations. Only the report page messages the worker, but it now uses the
configured locale that matches (es, en) and rejects anything else.

@BlancaMunizaga BlancaMunizaga left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. Summary of Findings

  • 🔴 Critical: 0
  • 🟠 High: 2
  • 🟡 Medium: 1
  • 🟢 Low: 1

2. Prioritized Findings

  • 🟠 High · Reliability: A worker can start after the report page unmounts — apps/web/src/context/ReportContext.tsx:148 (inline).
  • 🟠 High · Reliability: A failed preview leaves a permanent loading spinner with no retry — apps/web/src/context/ReportContext.tsx:199 (inline).
  • 🟡 Medium · Performance: Reports with more than 256 farms can fetch every satellite image again for the next layer when the limit is unset — apps/api/app/utils/image_generation/GoogleMapsAPIHelper.py:31 (inline).
  • 🟢 Low · Documentation: docs/onboarding.md:73 still describes /generate-image as PNG, although this PR changes it to JPEG. The onboarding guide says behavior changes must update it, and it is the canonical flow description for new contributors. Please update that report step to JPEG and the worker-based preview.

4. Positive Feedback

The version check still prevents reports from mixing raster versions. The new cache has focused tests for concurrent requests, failures, expiry, and eviction. Both API and frontend CI checks passed on this head commit.

Comment thread apps/web/src/context/ReportContext.tsx
Comment thread apps/web/src/context/ReportContext.tsx
Comment thread apps/api/app/utils/image_generation/GoogleMapsAPIHelper.py
If the user left the preview page while the images were still being fetched, the
unmount cleanup found no worker to terminate, and the render started one once the
fetch resolved: a worker rendering the whole PDF, holding the image blobs, with no
one to stop it. The render now checks that the page is still mounted before
creating the worker and fails with ReportPdfWorkerTerminatedError otherwise, which
the preview already ignores; the download hook ignores it too, so leaving the page
mid-download shows no error.
… fails

A failed preview only showed a snackbar: the preview stayed null, so the spinner
ran forever, and nothing re-ran the effect. The provider now records the failed
selection, which stops the loading state and exposes previewFailed and
retryPreview; the preview page replaces the spinner with an error and a Retry
button (reportGeneration:preview:error and retry, en and es). The retry bumps an
attempt counter so the effect fetches or renders again (the failed promise was
already forgotten). The snackbar text moves out of common:snackbarAlerts.
…lite cache is hit for every farm

The API caches the last 256 satellite images (one per farm). With a single
map-major pass over the report, a farm's requests for the first and the last map
were a whole report apart, so above 256 farms (the limit can be unset) every entry
was evicted before its next use: 257 farms x 2 maps made 514 Google calls instead
of 257. The requests now go out by groups of 64 farms, map-major within each
group, through the same single pLimit(20) queue: at most 63 other farms come
between a farm's first and last request, so the cache holds every farm's image
until its last map, with room for other reports in flight. Up to 64 farms the
order is unchanged from the measured one.
…iew in the onboarding guide

The report steps still said /generate-image returns a PNG and didn't mention
that the images are fetched once per selection, that the PDF renders in a Web
Worker, or that the complete report is pre-rendered.
@BlancaMunizaga

Copy link
Copy Markdown
Collaborator Author

🟢 Low · Documentation: docs/onboarding.md:73 still describes /generate-image as PNG, although this PR changes it to JPEG. [...] Please update that report step to JPEG and the worker-based preview.

Fixed in 54176be.

Step 7 now says the image is a 500×500 JPEG, that the API fetches each farm's satellite image once and caches it for the other maps, and that the images are fetched once per selection. Step 8 describes the Web Worker render, the iframe preview and the pre-rendered complete report.

@BlancaMunizaga

Copy link
Copy Markdown
Collaborator Author

Review triage

All four findings of the review were valid and are fixed; nothing was discarded or deferred.

Addressed

  • 🟠 Worker could start after the report page unmounts → 4d078d4: render checks a mounted ref before creating the worker; the download hook ignores the resulting ReportPdfWorkerTerminatedError.
  • 🟠 Failed preview left a permanent spinner → f30a7fc: the provider records the failed selection; the page shows an error with a Retry button (reportGeneration:preview:error / retry, en and es) instead of a snackbar.
  • 🟡 Reports above 256 farms thrashed the satellite cache → ecd6065: image requests go out by groups of 64 farms, map-major within each group, through the same pLimit(20) queue; simulated against AsyncTTLCache, one Google call per farm for 257 × 2, 300 × 3 and 1000 × 3.
  • 🟢 docs/onboarding.md still said PNG and no worker → 54176be.

Verification. Web only (no API change): tsc clean, lint 0 errors and the 18 pre-existing warnings, after every commit and on the final state. Not exercised in a running app.

Left open. Aborting the pending /generate-image fetches on navigation (today's behaviour, not addressed) is offered as a follow-up in the first thread.

@nivek0o0 nivek0o0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • 🟢 Low · Reliability: The dataset handle leaks if WarpedVRT fails — apps/api/app/utils/image_generation/RasterDatasetCache.py:13 (inline).
  • 🟢 Low · Documentation: The PR description no longer matches the code. The "Web" section says preview errors "show a new snackbar (errorGeneratingReportPreview, en/es)". Since f30a7fc they show an inline Alert with a Retry button, using reportGeneration:preview:error / retry, and the key errorGeneratingReportPreview no longer exists anywhere. Please update that bullet so the description stays an accurate record of the change.

Open Questions

  1. Re-seeding the share while the API runs. RasterDatasetCache keeps up to 16 raster handles open indefinitely on the SMB mount. layers-ops.sh seed empties and re-uploads the share, and its README says it "doesn't restart anything". If the seed writes a raster to the same path as one the API has open, the cache (keyed by path alone) would keep serving the old dataset. Report images would then be drawn from a different raster than the /analize ratios, which open the file fresh. Can the seed produce the same filenames? If so, either the seed docs should require an API restart, or the cache key should include st_mtime_ns/st_size. I couldn't verify how Azure Files and the filename scheme behave here (confidence: Low).
  2. Google Maps Platform terms. The description says the 10-minute TTL was approved. Did that approval cover the Maps terms on caching Static Maps content? satellite_image_cache is server-side and shared across users, which is a different situation from embedding the image in one user's PDF.

Comment on lines +13 to +14
self.src: Any = rasterio_open(path)
self.vrt: Any = WarpedVRT(self.src, crs=target_crs)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low · Reliability — The dataset handle leaks if WarpedVRT fails.

src is opened first, then WarpedVRT(self.src, ...) is built. If the VRT raises (a corrupt or unreadable COG, a transient SMB error on the Azure Files mount), _get never stores the entry and nothing closes src. Every retry for that raster leaks another GDAL handle on the share. RasterDataContext closed the handle in every case before.

Suggested fix:

self.src: Any = rasterio_open(path)
try:
    self.vrt: Any = WarpedVRT(self.src, crs=target_crs)
except Exception:
    self.src.close()
    raise

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 54f0ca3.

_CachedRaster.__init__ now closes the dataset and re-raises if WarpedVRT fails, as you suggested (with except BaseException, so a cancellation doesn't leak either). Regression test test_the_dataset_is_closed_if_the_vrt_fails: a failing VRT leaves the cache empty and the opened dataset closed, and the next read opens the raster normally. Black, ruff, mypy and the suite (296) are clean.

@nivek0o0 nivek0o0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review update

  • 🟠 High · Reliability: Missing local italic Roboto faces breaks reports whose layer considerations use italics — apps/web/src/utils/deforestationReport.tsx:34 (inline).

fonts: [
{ src: assetUrl("/fonts/roboto/Roboto-Regular.ttf"), fontWeight: 400 },
{ src: assetUrl("/fonts/roboto/Roboto-Medium.ttf"), fontWeight: 500 },
{ src: assetUrl("/fonts/roboto/Roboto-Bold.ttf"), fontWeight: 700 },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Reliability — Restore the italic Roboto faces. The new local registration includes only normal styles, but sections.tsx still renders _..._ consideration text with fontStyle: "italic", and the enabled Colombia IDEAM layer contains that markup in both languages. React-pdf fails when an italic style is requested without a registered italic face, so selecting IDEAM prevents the preview and both downloads from rendering; the old registration included regular-italic and bold-italic faces. Please bundle/register Roboto-Italic.ttf and Roboto-BoldItalic.ttf (or otherwise remove the italic style deliberately), and add a render regression using the IDEAM considerations. The current reference benchmark uses maps 0–2, so it does not exercise IDEAM/map 3.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9e08a1b.

registerReportFonts registers Roboto-Italic (400 italic, the same v2.137 file gstatic served) and Roboto-BoldItalic (700 italic, from the v2.138 release, subset like the others), and the licence file is the v2 files' Apache 2.0. Note that bold italic was already broken on dev: its gstatic URL answers 404.

On the regression: the web has no test harness, and the project convention is not to set one up inside a fix, so there is no committed test. I did check it outside the app, rendering a document with @react-pdf/renderer in Node using the real _…_ quote from considerations/en/ideam.md: with the three upright faces only it fails with Could not resolve font for Roboto, fontWeight 400, fontStyle italic, and with the five faces of 9e08a1b it renders both the italic and the bold-italic text. If you'd rather have that as a proper regression, I'd propose a follow-up that adds a minimal Node render test for sections.tsx (it needs a TSX runner the web doesn't have today).

The layers' considerations are markdown, and their _italic_ and
**_bold italic_** render with fontStyle "italic". The italic faces were
dropped when the fonts moved to public/fonts, so a layer with italics
(Colombia's IDEAM) failed the whole render: "Could not resolve font for
Roboto, fontWeight 400, fontStyle italic".

- Roboto-Italic: the same v2.137 file the report used from gstatic
- Roboto-BoldItalic: from the official v2.138 release, subset to the same
  characters as the other faces. Its old gstatic URL answers 404, so bold
  italic already failed before this branch
- The v2 files are Apache 2.0, not OFL: LICENSE replaces OFL.txt, and the
  CHANGELOG, README and OpenSpec artifacts say so
_CachedRaster opened the dataset and then built the VRT; if the VRT raised (a
corrupt raster, a transient error on the share) the entry was never stored and
nothing closed the dataset, so every retry leaked a GDAL handle. The dataset is
now closed before re-raising. With a regression test.
The raster cache was keyed by path alone, on the premise that a raster is never
replaced in place. The admin honours it, but layers-ops.sh seed rewrites the
same <stem>-v1.tif paths on a running API: a cached handle would keep drawing
the old raster while /analize, which opens the file fresh, used the new one.
Each entry now remembers the file's (st_mtime_ns, st_size), checked with one
os.stat per read as LayerStore.read_index already does, and a changed file is
closed and reopened. With a test.
@BlancaMunizaga

Copy link
Copy Markdown
Collaborator Author

🟢 Low · Documentation: The PR description no longer matches the code. The "Web" section says preview errors "show a new snackbar (errorGeneratingReportPreview, en/es)" […]

Updated: the bullet now says the preview replaces the spinner with an error and a "Retry" button (reportGeneration:preview:error / retry, en/es) and that the failed render is forgotten so the retry fetches or renders again.

1. Re-seeding the share while the API runs. […] Can the seed produce the same filenames?

Yes. app.modules.layers.seed names every raster <stem>-v1.tif (seed.py:63), so a re-seed rewrites the same paths the API may have open, and read_index would pick up the new index while the raster cache kept the old handle. Fixed in bb505e8: each entry remembers the file's (st_mtime_ns, st_size) and read checks it with one os.stat, the same signature LayerStore.read_index already uses; a changed file is closed and reopened. Test test_a_raster_rewritten_at_the_same_path_is_reopened (same bytes, bumped mtime). design.md D4, tasks.md 2.3 and docs/architecture.md describe it. I didn't verify Azure Files' mtime behaviour on overwrite either, but the seed writes a new file (size and mtime change), and in the worst case the cost is a reopen.

2. Google Maps Platform terms. Did that approval cover the Maps terms on caching Static Maps content?

I can't confirm that it did, so I'm leaving this open for the team rather than closing it. What I checked today:

  • The general terms, §3.2.3(b) No Caching: "Customer will not cache Google Maps Content except as expressly permitted under the Maps Service Specific Terms."
  • The Service Specific Terms list caching permissions per API (Address Validation, Air Quality, Geocoding, Directions, Roads, Street View pano_id, Google IDs). There is no section for the Maps Static API, so there is no express permission for its imagery, and this cache is server-side and shared across users.

What the cache does today: in-memory only, 256 entries, 10-minute TTL, never persisted, used to serve the same farm's image to the other layers of the same report. If the team decides the terms don't cover it, the smallest change that keeps most of the gain is dropping the TTL to the in-flight window (share concurrent requests, forget the bytes right after): with the grouped request order of ecd6065 the other layers' requests for a farm arrive within seconds. I'd rather have that decision made explicitly than guess at it here.

@BlancaMunizaga

Copy link
Copy Markdown
Collaborator Author

Review triage (round 2, @nivek0o0's review)

Addressed

  • 🟠 Missing italic Roboto faces broke reports with _italic_ considerations (IDEAM) → 9e08a1b: Roboto-Italic and Roboto-BoldItalic registered; verified by rendering the real IDEAM quote in Node with and without the faces.
  • 🟢 Dataset handle leaked if WarpedVRT failed → 54f0ca3, with a regression test.
  • 🟢 PR description still described the preview snackbar → description updated.
  • Open question 1 (re-seeding rewrites <stem>-v1.tif while the API runs) → bb505e8: cache entries are checked against the file's mtime and size, with a test; design, tasks and architecture docs updated.

Left open

  • Open question 2 (Google Maps terms on caching Static Maps imagery): the general terms forbid caching unless the Service Specific Terms permit it, and those have no Maps Static API section. Needs a team decision; a fallback (in-flight dedup only) is described in the reply.
  • A committed render regression for the italics: the web has no test harness; proposed as a follow-up.

Verification. API: black, ruff, mypy clean; 296 tests pass. Web: unchanged this round beyond 9e08a1b (tsc and lint were clean on it).

- New spec `report-generation` (11 requirements): images fetched once per
  selection, one satellite call per farm, the satellite limit counts farms,
  JPEG images, rasters follow the file on disk, the API isn't blocked, the
  PDF renders off the main thread, the download is pre-rendered, a failed
  preview can be retried, no third-party requests, and every text style
  (italic and bold italic included) has a font face
- The delta spec gains the three behaviours added by the fixes after review
  (preview retry, raster reopened when its file changes, italic faces), and
  states the image release as implemented (Blobs; the worker owns the URLs)
- The change moves to openspec/changes/archive/2026-10-08-optimize-report-pdf-generation
@BlancaMunizaga
BlancaMunizaga merged commit c4c8c20 into dev Oct 8, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants