| name | oxy-customer-apps-perf |
| description | Use when adding or modifying a custom-app serving route (crates/app/src/server/api/custom_apps_serve.rs, the /customer-apps/{*path} handler in serve.rs) or a custom-app data endpoint (projects/query.rs, projects/semantic_query.rs). Encodes the serve-plane + data-plane performance guardrails so every custom app stays fast. Triggers on "custom-app cache", "Cache-Control", "ETag", "compression on customer-apps", "result cache", "project-scoped cache", or a new per-request read on the custom-app hot path. |
Customer-apps performance (serve + data plane)
The perf baseline every custom-app request must keep, established by PR #2634
(full rationale, threat model, and future options: internal-docs/customer-apps-performance.md).
When you touch the serve or data path, preserve these — they are cheap to keep and
expensive to retrofit under load.
Serve plane — custom_apps_serve.rs, cli/commands/serve.rs
- Content-hashed assets → immutable Cache-Control. URLs under
assets/
(Vite / Astro / Rsbuild / SvelteKit) and _next/static/ (Next) change only when
their bytes change → public, max-age=31536000, immutable. HTML →
private, no-cache; unfingerprinted root files → public, max-age=300. This is
cache_control_for; a new hashed-asset dir means adding its prefix there. Without
it, every chunk re-runs the full auth + membership walk in serve_inner.
- HTML stays
private. It carries a per-visitor tracking Set-Cookie, and
no-cache alone still lets a shared cache store the response — which would hand
every later visitor one session id. Do not "simplify" this to no-cache.
- The resolution chain must stay fully cached. Warm steady state is zero DB
round-trips per asset. Adding an uncached lookup to
serve_pretty /
serve_from_s3_build costs ~100 queries per page load on its own. New app-row
state means a new invalidate_app_resolution_cache() call site.
- Assets are pre-compressed at publish, not per request (
custom_apps_precompress).
A new compressible output type belongs in is_precompressible_extension; HTML must
stay excluded (it is rewritten at serve time). Any response that ships
Content-Encoding MUST also ship Vary: accept-encoding.
- HTML → weak ETag +
If-None-Match 304. Injected HTML is a transform, so use a
weak W/"…" ETag over the final bytes (etag_for / if_none_match in
serve_from_s3_build); a matching If-None-Match returns 304 and skips the
re-inject.
- Compression is SSE-safe.
CompressionLayer on /customer-apps/{*path} relies
on DefaultPredicate, which skips text/event-stream and already-encoded bodies —
so the /fn SSE stream and the v0 proxy are untouched. If you add a streaming
response under this route, confirm it stays text/event-stream (or keep it off the
layer); never compress a stream.
Data plane — projects/query.rs, projects/semantic_query.rs, projects/result_cache.rs
- Short-TTL result cache, keyed by
project_id FIRST. The cache key is
(project_id, namespace, database, sql). project_id is the multi-tenant
isolation boundary — a shared process-global cache without it leaks results
across tenants. result_cache.rs has a miss_on_different_project_or_sql_or_db
test asserting exactly this; keep it. Namespace by endpoint (query vs semantic).
- Read the cache AFTER auth. The
result_cache::get must sit below
check_custom_app_gates in the handler, or a cached hit bypasses authorization.
Cache only successful responses (never error bodies).
- Honor
?refresh. Every cached endpoint parses ?refresh (or refresh=) and
bypasses the cache so callers can force-run.
Client plane — custom_apps_asset_manifest, custom_apps_client, custom_apps_beacon
-
Every build ships an asset manifest at __oxy/asset-manifest.json, written
by publish from the file list + a scan of the bundle's index.html. It is a
build artifact, not a column — it reads through the same LRU, absence cache and
pre-compression as any asset, so a promote/rollback needs no invalidation. A
build without one must degrade to "no hints", never to an error.
-
__oxy/ is a reserved namespace. Publish strips author files under it
(asset_manifest::install_into) and serve_pretty classifies every request
under it after the auth gate, before the source dispatch. A new platform
endpoint for apps goes here, not in server/router/ — it inherits the
app's exact gate, works on both the subpath and subdomain surfaces, and needs
no CORS or route classification. classify_reserved is the whole routing
table: a name nobody claims is Unknown → 404, never a fall-through to the
bundle, and a wrong method is Unknown too rather than a 405. Adding a
platform path means adding a Reserved variant — the asset manifest's
BuildObject is the one that deliberately falls through, because it is a real
object inside the build.
-
HTML carries Link preload hints, x-oxy-build, and Cache-Tag — on the
304 as well as the 200. A 304 has no body to carry <link> tags, and
x-oxy-build is how a running service worker notices a publish without being
replaced. Dropping any of them from the revalidation path is the easy
regression here.
-
Three surfaces, two bases. Subpath and custom-app subdomain both address
assets under /customer-apps/<org>/<app>/; the org subdomain serves the
document at /a/<slug>/ while keeping that asset base, so the worker cannot
register there and deliberately fails closed (a / scope would claim the org
product origin). Anything computing a scope, a base, or a containment check
must say which of the three it means — see customer-apps-performance.md
§ The client plane.
-
currentBuild in the worker is a cache, not a fact. A worker's global
scope is torn down when it goes idle, so it is null on most events;
recovers it from a that
writes (the cache), falling back to a lone generation and then to
. Any new reader that branches on it must go through that, and any
destructive branch must treat "unknown" as rather than — swept the live precache exactly once for
want of that distinction.
Litmus test before merging
A new serving route under /customer-apps/** with no Cache-Control/compression, an
uncached per-asset DB lookup, a Content-Encoding without Vary, HTML that isn't
private, or a new cached data endpoint whose key does not start with
project_id, is read before the auth gate, or ignores ?refresh — should be
challenged through this skill. So should a platform endpoint for apps mounted in
server/router/ instead of under __oxy/, a per-viewer field on
AppRuntimeConfig, an automatic skipWaiting, or a Link/x-oxy-build dropped
from the 304 path. This complements oxy-route-classification (which
decides where the route runs); this skill governs how fast it answers.
CDN work (CloudFront in front of the customer-apps hosts) is analysed in
internal-docs/customer-apps-performance.md § CDN — read the two-step ordering
there before proposing edge caching; the auth gate is the whole difficulty.