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):
- Get thumbnail ID —
GET /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.
- Poll for thumbnail —
GET /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.
Cache configuration thumbnails in R2
Motivation
Group and default insertable thumbnails are permanently cached in R2 (the
THUMBNAILSbucket), 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):GET /api/thumbnail-id/d/:docId/:instanceType/:instanceId/e/:elementId?configuration=...callsgetThumbnailId(), which uses Onshape's insertables API to obtain apredictableThumbnailId. Configuration thumbnails obtained this way are known to be buggy.GET /api/thumbnail?size=X&thumbnailId=YcallsgetThumbnailFromId(), hit repeatedly while Onshape generates the thumbnail.Default (non-configured) insertable thumbnails are handled separately:
uploadThumbnails()runs duringLoadDocumentWorkflow(src/backend/parse/load-document.ts) viagetElementThumbnail(), which hits the element thumbnail endpoint directly. This path is more reliable but still polls (less aggressively). The resulting URLs are stored ininsertables.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()insrc/shared/configuration-utils.ts) takes both the configuration and the configuration parameter definitions (needed to identify defaults and parameter types):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.true/false.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.thumbnailUrlsandinsertables.thumbnailUrlscolumns 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 onfavoriteseither, 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: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
configurationup front means a single lifecycle rule onthumbnail/configuration/can later expire all configuration thumbnails, while defaults underthumbnail/default/are never touched and live forever. A fully-default configuration (empty canonical string) routes to thedefaultprefix, 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 optionalnotificationUrl(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 giventype+id, so callers don't pass them.When
documentLoadis set, the workflow fetches both sizes in parallel (rather than the singlesize), and if it fully times out it sets the document-levelthumbnailsFailedToLoadbuild check.Fetch path:
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
notificationUrlif 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:
passivemoderateaggressiveWhen invoked from load,
LoadDocumentWorkflowinvokes the thumbnail workflow with a notificationUrl, waits for the notification, and continues. On full timeout the load workflow should add the document/group'sthumbnailsFailedToLoadbuild 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:
GETgroup thumbnail — given a group and a size, returns the thumbnail or404if not found.GETinsertable 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, or404if not found. No fallback — a missing thumbnail is a plain404, which is the signal for the frontend to keep polling.POSTinsertable thumbnail — given an insertable, a size to load, an optional configuration, and a pollingurgency(e.g.aggressivefor an active configuration preview,moderatefor 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) ornullif 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-idand/api/thumbnailtwo-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 documentthumbnailsFailedToLoadbuild check rather than a user-triggered reload.5. Frontend flows
POSTfor the relevant size withaggressiveurgency. If it returns a placeholder (nullor the default), show a spinner and aggressively poll the insertableGETendpoint until it returns the real thumbnail (200) or a reasonable client-side timeout elapses.POSTfor the relevant size withmoderateurgency. If it returns the actual thumbnail, display it; if it returns a placeholder, show the default/spinner and aggressively poll the insertableGETendpoint until resolved or a reasonable client-side timeout elapses.POSTonce per size withmoderateurgency 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>/andthumbnail/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
configurationprefix is for: a future R2 lifecycle rule onthumbnail/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
GETresponses ideal for edge caching, which matters more now that there's no readiness flag — rendering a list is oneGETper thumbnail.GET200s with a long, immutableCache-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.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.