Skip to content

Thumbnail Caching #20

Description

@AlexKempen

Cache configuration thumbnails in R2

Motivation

Group and default insertable thumbnails are permanently cached in R2 (the THUMBNAILS bucket), but configuration-specific thumbnails shown in the part configuration preview are fetched from Onshape at runtime. Those fetches are slow and require polling, which degrades performance when browsing or modifying configurations.

Caching configuration thumbnails is also a prerequisite for showing the correct thumbnail for favorited parts that have a saved defaultConfiguration, and later for recently used parts.

Current state

Loading a configuration thumbnail is a two-step flow handled by PreviewImage (src/frontend/insert/thumbnail.tsx):

  1. Get thumbnail IDGET /api/thumbnail-id/d/:docId/:instanceType/:instanceId/e/:elementId?configuration=... calls getThumbnailId(), which uses Onshape's insertables API to obtain a predictableThumbnailId. Configuration thumbnails obtained this way are known to be buggy.
  2. Poll for thumbnailGET /api/thumbnail?size=X&thumbnailId=Y calls getThumbnailFromId(), hit repeatedly while Onshape generates the thumbnail.

Default (non-configured) insertable thumbnails are handled separately: uploadThumbnails() runs during LoadDocumentWorkflow (src/backend/parse/load-document.ts) via getElementThumbnail(), which hits the element thumbnail endpoint directly. This path is more reliable but still polls (less aggressively). The resulting URLs are stored in insertables.thumbnailUrls (plural — two sizes).

1. Canonical configuration key

We need a canonical string form of a configuration to use as the basis for an R2 key. A new function (alongside encodeConfigurationForQuery() in src/shared/configuration-utils.ts) takes both the configuration and the configuration parameter definitions (needed to identify defaults and parameter types):

  • Quantity parameters — evaluate custom expressions (e.g. 1 in + 2 in) to a canonical numeric form. Pin exact precision: length in meters, rounded to 5 decimal places; angle in radians, rounded to 4 decimal places (confirm these). Default comparison happens after evaluation.
  • Enum parameters — canonicalize to the option id.
  • Boolean parameters — canonicalize to true/false.
  • String parameters — pass through (define any normalization, e.g. trim).
  • Drop any parameter equal to its default (post-evaluation).
  • Drop invalid parameters — a saved default configuration may reference parameters that no longer exist, because the underlying element was updated and the configuration was deleted. Drop any parameter not present in the current parameter definitions.
  • Sort remaining parameters alphabetically by id.

A fully-default configuration produces the empty canonical string and maps to the default thumbnail rather than a configuration key (see §2), so it's stored and served identically to the no-configuration default.

This canonical string is distinct from encodeConfigurationForQuery(): the canonical form keys storage, the query encoding is what we send to Onshape. The workflow needs both.

Because the canonical string can be long or contain path-unsafe characters, the R2 key uses its sha256 hex digest (<empty> → the hash of the empty string).

2. R2 storage model

All thumbnails — default and configuration-specific, for both insertables and groups — live under a single microversion-keyed R2 path (below). There is no separate "permanent" vs. "cached" handling: a favorited part's thumbnail is just the insertable's thumbnail under its saved configuration, derived from the same key.

The database stores no thumbnail URLs or readiness state at all. Drop the groups.thumbnailUrls and insertables.thumbnailUrls columns entirely; reads always derive the R2 key from the row's microversion (looked up in D1) plus the optional canonical configuration and check R2 directly. No thumbnail column is needed on favorites either, since the key derives from the insertable's microversion and the favorite's saved configuration.

Key scheme

This supersedes the current thumbnails/{size}/{elementId} scheme (migration required). Keys split defaults from configurations at the top level, then scope by microversion. Microversion ids are element-scoped, so an unrelated edit elsewhere in the document does not invalidate a tab's thumbnails:

thumbnail/default/<insertable|group>/<id>/microversion/<microversionId>/size/<size>
thumbnail/configuration/<insertable|group>/<id>/microversion/<microversionId>/size/<size>/<sha256(canonicalConfig)>

The default/configuration split is at the front of the key on purpose: R2 lifecycle rules are prefix-based and age-based (delete N days after upload, not by last access). Putting configuration up front means a single lifecycle rule on thumbnail/configuration/ can later expire all configuration thumbnails, while defaults under thumbnail/default/ are never touched and live forever. A fully-default configuration (empty canonical string) routes to the default prefix, so it shares the default thumbnail and its permanence.

3. Thumbnail workflow

A Cloudflare Workflow that fetches a thumbnail from Onshape and stores it in R2.

Inputs: type (insertable/group), id, urgency, configuration (optional), size(s) to load, and an optional notificationUrl (only the load path uses it; other callers rely on frontend polling the GET endpoint). The workflow resolves the Onshape ids and microversion from D1 given type + id, so callers don't pass them.

When documentLoad is set, the workflow fetches both sizes in parallel (rather than the single size), and if it fully times out it sets the document-level thumbnailsFailedToLoad build check.

Fetch path:

  • No configuration → poll the Onshape element thumbnail endpoint directly.
  • With configuration → use the predictableThumbnailId + poll path.

Behavior: poll Onshape per the urgency policy, store the result in R2 at the canonical key, then notify the calling workflow via notificationUrl if present (along with an indication of whether the thumbnails actually loaded).

Deduplication: create the workflow with a deterministic instance id derived from the R2 key, so a duplicate create is a no-op rather than a check-then-create race.

Urgency policies:

Urgency Caller Sizes Polling
passive Load both very passive
moderate Favorite (save or load), recently used requested moderate
aggressive Active configuration preview requested aggressive

When invoked from load, LoadDocumentWorkflow invokes the thumbnail workflow with a notificationUrl, waits for the notification, and continues. On full timeout the load workflow should add the document/group's thumbnailsFailedToLoad build check.

Note multiple sizes are only used when invoked from the LoadDocumentWorkflow path, for the API paths the Thumbmnail workflow should just load a single thumbnail at a time.

4. Endpoints

Unify the existing thumbnail endpoints into three:

  • GET group thumbnail — given a group and a size, returns the thumbnail or 404 if not found.
  • GET insertable thumbnail — given an insertable, a size, and an optional canonical configuration. Derives the R2 key from the insertable row's microversion and returns the thumbnail, or 404 if not found. No fallback — a missing thumbnail is a plain 404, which is the signal for the frontend to keep polling.
  • POST insertable thumbnail — given an insertable, a size to load, an optional configuration, and a polling urgency (e.g. aggressive for an active configuration preview, moderate for a favorite). If present in R2, returns it. Otherwise kicks off the thumbnail workflow (dedup by deterministic id) at the requested urgency and returns the default thumbnail (if a configuration was requested) or null if it doesn't exist. Also include a field specifying whether the returned thumbnail is a default thumbnail vs. the normal one.

These replace the existing /api/thumbnail-id and /api/thumbnail two-step endpoints. The manual reload-thumbnails endpoint and its UI can also be removed: the workflow retries within its polling budget, and persistent failures surface through the document thumbnailsFailedToLoad build check rather than a user-triggered reload.

5. Frontend flows

  • Regular insertable / group thumbnail — use the relevant GET endpoint.
  • Configuration preview — call POST for the relevant size with aggressive urgency. If it returns a placeholder (null or the default), show a spinner and aggressively poll the insertable GET endpoint until it returns the real thumbnail (200) or a reasonable client-side timeout elapses.
  • Favorite thumbnail — same shape as the configuration preview, since a favorite's thumbnail may be missing at load time. Call POST for the relevant size with moderate urgency. If it returns the actual thumbnail, display it; if it returns a placeholder, show the default/spinner and aggressively poll the insertable GET endpoint until resolved or a reasonable client-side timeout elapses.
  • Saving a default configuration / creating a favorite — optimistically call POST once per size with moderate urgency to pre-warm both thumbnails (nice-to-have, so a later load is more likely to hit a cached thumbnail).

6. Cleanup

Insertable and group deletion/update must remove associated R2 thumbnails. Because keys are scoped by microversion, deleting a row (or incrementing its microversion) means deleting every key under both the thumbnail/default/<type>/<id>/microversion/<microversionId>/ and thumbnail/configuration/<type>/<id>/microversion/<microversionId>/ prefixes (list-by-prefix, then batch delete). The two-prefix cost is negligible since prefix deletion is a list + batch-delete either way.

Configuration thumbnails that accumulate under a stable microversion (a part that isn't changing but whose configurations are browsed over time) are not caught by this. That's what the front-of-key configuration prefix is for: a future R2 lifecycle rule on thumbnail/configuration/ can expire them on an age basis, and they re-cache on next view. Defaults are never expired.

7. CDN caching

Every stored thumbnail is fully immutable: the microversion id plus canonical configuration completely determine the bytes, so a given key's contents never change. This makes the GET responses ideal for edge caching, which matters more now that there's no readiness flag — rendering a list is one GET per thumbnail.

  • Serve GET 200s with a long, immutable Cache-Control (e.g. public, max-age=31536000, immutable) and front the endpoints with Cloudflare's cache, so repeat loads are served at the edge and never reach the Worker or R2.
  • Do not cache 404s (or cache them only very briefly), so the frontend poll loop sees a thumbnail as soon as it lands.

Immutability and configuration expiry don't conflict: immutability means the bytes are always safe to serve, so an edge copy that outlives an expired R2 object is still correct, and a cache miss at another POP simply re-caches. Expiry is purely a storage-cost cleanup, not a correctness mechanism.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions