Skip to content

perf: faster doc open — compressed/immutable assets, editor preload, blob ETags, doc-load metrics - #32

Merged
trevex merged 1 commit into
mainfrom
perf/doc-load-latency
Oct 2, 2026
Merged

trevex merged 1 commit into
mainfrom
perf/doc-load-latency

Conversation

@christianhuening

Copy link
Copy Markdown
Contributor

Opening a document on the dogfood (Hetzner) deployment is slow. This PR does not find the root cause. It removes avoidable load time, and adds the metrics that will show where the rest of the time goes.

Not Hetzner Object Storage. Dogfood runs with the default blob.backend: postgres. The Hetzner bucket only holds CNPG's WAL archive and nightly backups, which are not on the read path. A cache in front of S3 would not help.

Changes

  • Static assets. The files under /assets have content-hashed names. They are now brotli-compressed and served as public, max-age=31536000, immutable. The 749 KB editor chunk goes over the wire as 241 KB. index.html and client-side routes are no-cache, so a deploy is picked up on the next load, not after the browser's heuristic cache expires. The serving code moves into static_files.rs, and compression applies there only, not to the API.
  • Editor preload. DocPage starts downloading the editor chunk on mount, in parallel with GET /api/docs/{id}. Before, the download only started after that request returned. import() is cached, so the preload and lazy share one fetch.
  • Blob ETags. GET /api/blobs/{id} now sends the stored sha256 as a strong ETag. A matching If-None-Match gets a 304 without the bytes being read. The check runs after the ACL check. max-age stays at 60 s, so an access revoke still takes effect within a minute.
  • Document-load histograms. knot_collab_initial_sync_seconds covers the time from the socket upgrade until the document state is queued to the client. knot_room_hydrate_seconds covers loading a cold room. The HTTP histogram stops at the 101 upgrade, so neither was measured before.

Bugs found on the way

  • Grafana's latency panels have been empty. The Prometheus exporter was installed without buckets, so every histogram was exported as a summary. knot_http_request_duration_seconds_bucket never existed, so the dashboard, the docs/SLO.md queries and the PrometheusRule latency alert had nothing to read. set_buckets fixes this; the bucket edges include each SLO threshold.
  • Deep links answered 404. ServeDir::not_found_service forces a 404 status on its fallback, so /docs/<id> returned 404 with index.html as the body. It rendered, but inflated the 4xx rate. It now uses fallback, which returns 200. A missing /assets/* chunk is now a plain 404 instead of index.html served as JavaScript.

Tests

  • static_files_integration.rs (new) covers:

    • immutable caching of hashed assets;
    • no-cache on the shell and on client-side routes;
    • a 404 for a missing asset;
    • brotli round-trip vs. identity encoding.
  • blobs_integration.rs covers:

    • the ETag matching the content sha256;
    • a 304 that reads no bytes (a counting wrapper around the real store checks this);
    • a stale tag returning the full body;
    • a matching tag not bypassing the ACL.
  • metrics_integration.rs covers:

    • HTTP latency exported as a bucketed histogram;
    • a real WebSocket document open recording both new histograms.

    The exporter now runs on its own runtime. Before, it lived on the runtime of whichever test installed it first, and died when that test finished.

  • DocPage.preload.test.tsx (new) checks that the editor module is requested while the metadata request is still pending.

Each new behaviour test failed before its change was made. The Rust workspace suite, the web suite, clippy (-D warnings, all features), rustfmt, tsc and eslint pass locally. The Playwright e2e suite could not run locally (no Docker), so it runs here in CI first.

Also checked end to end against a release build with a real SPA build:

  • curl shows content-encoding: br and the expected cache-control values.
  • An 8 MB image revalidates as a 304 with a 0-byte body in 3 ms, against 30 ms for the full download.
  • /metrics shows # TYPE … histogram with _bucket series for the new metrics.

After deploy

This shows where document-open time goes on dogfood:

histogram_quantile(0.95, sum(rate(knot_collab_initial_sync_seconds_bucket[5m])) by (le))
histogram_quantile(0.95, sum(rate(knot_room_hydrate_seconds_bucket[5m])) by (le))

Not in this PR

  • The server sends the full document state twice when a socket opens: once on join, and again in reply to the client's SyncStep1. The fix changes the sync handshake, so it gets its own PR with its own tests.
  • Blobs on public share links (/p/<token>/blobs/…) don't get the ETag handling yet.

🤖 Generated with Claude Code

@trevex
trevex merged commit ee4274e into main Oct 2, 2026
5 checks passed
@christianhuening
christianhuening deleted the perf/doc-load-latency branch October 2, 2026 12:42
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