Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,28 @@ so this log can be regenerated from history (e.g. with `git-cliff`).
rather than set directly, so existing `[data-theme="dark"]` overrides keep
working. JetBrains Mono, which code blocks have asked for since the start,
is now actually bundled.
- **Opening a document downloads less and waits less.** The SPA's hashed
bundles are brotli-compressed (the 749 KB editor chunk goes over the wire as
241 KB) and cached as `immutable`; `index.html` is `no-cache`, so a deploy is
picked up on the next load. The editor chunk now downloads alongside the
document's metadata instead of after it. An image or attachment carries its
sha256 as `ETag`, so a revalidation after the one-minute `max-age` is a 304
answered without reading the bytes.
- **Two new histograms time opening a document:**
`knot_collab_initial_sync_seconds` (socket upgraded → document state queued
to the client) and `knot_room_hydrate_seconds` (loading a room that was not
in memory). The HTTP histogram stops at the WebSocket upgrade, so neither
was visible before.
### Fixed
- **Every latency panel, SLO query and the latency alert showed nothing.** The
Prometheus exporter was installed without buckets, so it exported every
histogram as a summary: `knot_http_request_duration_seconds_bucket` never
existed for the dashboard's `histogram_quantile` to read. Histograms now
export buckets whose edges include each SLO threshold.
- **A deep link such as `/docs/<id>` answered 404** (with the app's HTML, so
it still rendered). Client-side routes now answer 200, and a missing
`/assets/*` chunk is a plain 404 instead of `index.html` served as
JavaScript.
- **A document title longer than about fifteen characters was cut off behind
the tool icons.** On the desktop column the title shared its line with the
header's action row, and the row's ~490px left the title only 208px of the
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/knot-crdt/src/room.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ impl Room {
dirty_tx: Option<mpsc::Sender<Uuid>>,
) -> Result<RoomHandle, EngineError> {
// Hydrate the doc.
let hydrate_started = std::time::Instant::now();
let doc = engine.new_doc();
let mut last_applied_seq: i64 = 0;
if let Ok(Some(snap)) = snapshots.latest(doc_id).await {
Expand All @@ -146,6 +147,8 @@ impl Room {
}
}
}
metrics::histogram!("knot_room_hydrate_seconds")
.record(hydrate_started.elapsed().as_secs_f64());

// Spawn the actor with the hydrated doc + watermark.
let (tx, rx) = mpsc::channel::<Event>(256);
Expand Down
22 changes: 22 additions & 0 deletions crates/knot-obs/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,27 @@ pub enum MetricsError {
Install(String),
}

/// Shared by every histogram (all of them are latencies in seconds). The
/// edges include each SLO threshold in docs/SLO.md so a target reads off a
/// bucket boundary instead of an interpolation.
const LATENCY_BUCKETS_SECONDS: [f64; 13] = [
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 30.0,
];

/// Install the global metrics recorder and start the HTTP exporter
/// on `addr` (e.g. ":9090" or "0.0.0.0:9090").
pub fn init(addr: &str) -> Result<(), MetricsError> {
let sa: SocketAddr = normalize_addr(addr)?
.parse()
.map_err(|e| MetricsError::Address(format!("{addr}: {e}")))?;

// Without buckets the exporter renders every histogram as a summary, and
// the dashboard, SLO queries and alert rule (`histogram_quantile` over
// `_bucket`) all come back empty.
PrometheusBuilder::new()
.with_http_listener(sa)
.set_buckets(&LATENCY_BUCKETS_SECONDS)
.map_err(|e| MetricsError::Install(e.to_string()))?
.install()
.map_err(|e| MetricsError::Install(e.to_string()))?;

Expand All @@ -47,6 +59,16 @@ pub fn init(addr: &str) -> Result<(), MetricsError> {
"CRDT updates applied to rooms, by source (local|peer)"
);
describe_counter!("knot_room_snapshots_total", "Snapshots written to storage");
describe_histogram!(
"knot_room_hydrate_seconds",
Unit::Seconds,
"Loading a cold room: latest snapshot + replay of later updates"
);
describe_histogram!(
"knot_collab_initial_sync_seconds",
Unit::Seconds,
"Collab socket upgraded -> initial document state queued to the client"
);

// Storage / pool
describe_gauge!("knot_db_pool_size", "Total connections in the pool");
Expand Down
2 changes: 2 additions & 0 deletions crates/knot-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,6 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
serde = { version = "1", features = ["derive"] }
knot-auth = { path = "../knot-auth", features = ["fast-params"] }
http-body-util = "0.1"
tempfile = "3"
brotli = "8"
knot-test-support = { path = "../knot-test-support" }
8 changes: 2 additions & 6 deletions crates/knot-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ use knot_storage::{
PgShareTokenStore, PgUserStore, PgWorkspaceStore, Pool, SearchStore, SessionStore,
ShareTokenStore, UserStore, WorkspaceStore,
};
use tower_http::services::{ServeDir, ServeFile};
use uuid::Uuid;

/// Max inbound collab/board WS message. CRDT updates and board ops are far
Expand All @@ -35,6 +34,7 @@ pub mod reindex;
pub mod room;
pub mod routes;
pub mod security_headers;
pub mod static_files;

use auth::SessionDeps;

Expand Down Expand Up @@ -184,10 +184,6 @@ pub fn router() -> Router {

pub fn router_with_state(state: AppState) -> Router {
let web_dist = std::env::var("KNOT_WEB_DIST").unwrap_or_else(|_| "/web/dist".into());
let index_path = format!("{web_dist}/index.html");
let spa = ServeDir::new(&web_dist)
.append_index_html_on_directories(true)
.not_found_service(ServeFile::new(&index_path));

// WS routes: NO timeout / body-limit (long-lived, streamed).
let collab = Router::new()
Expand All @@ -200,7 +196,7 @@ pub fn router_with_state(state: AppState) -> Router {
.merge(routes::auth::router())
.merge(routes::public::router())
.merge(routes::api::router(state.clone()))
.fallback_service(spa)
.merge(static_files::router(&web_dist))
.layer(axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024))
.layer(tower_http::timeout::TimeoutLayer::with_status_code(
axum::http::StatusCode::REQUEST_TIMEOUT,
Expand Down
6 changes: 6 additions & 0 deletions crates/knot-server/src/room.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub async fn serve(
can_write: bool,
shutdown: CancellationToken,
) {
let join_started = std::time::Instant::now();
let handle = match rooms.acquire(doc_id).await {
Ok(h) => h,
Err(e) => {
Expand Down Expand Up @@ -52,6 +53,11 @@ pub async fn serve(
_ => return,
};
let _ = out_tx.send(initial).await;
// Server-side share of "open a doc, wait for content": includes the
// hydrate of a cold room (see knot_room_hydrate_seconds) and the wait for
// the room actor's reply.
metrics::histogram!("knot_collab_initial_sync_seconds")
.record(join_started.elapsed().as_secs_f64());

let (mut sink, mut stream) = socket.split();
let writer_shutdown = shutdown.clone();
Expand Down
33 changes: 33 additions & 0 deletions crates/knot-server/src/routes/api/blobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,26 @@ async fn download(State(state): State<AppState>, Path(id): Path<Uuid>, req: Requ
Err(_) => return internal(),
}

// A blob id never changes content, so its sha256 is a strong ETag. The
// short max-age keeps an ACL revoke effective within a minute; after that
// the browser revalidates, and a match is answered from metadata alone,
// skipping the byte read. Only after the ACL check above.
let etag = format!(
"\"{}\"",
meta.sha256
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
);
if if_none_match_hits(req.headers(), &etag) {
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, &etag)
.header(header::CACHE_CONTROL, "private, max-age=60")
.body(Body::empty())
.unwrap();
}

let bytes = match store.get(id).await {
Ok(b) => b,
Err(knot_storage::BlobStoreError::NotFound) => {
Expand All @@ -223,11 +243,24 @@ async fn download(State(state): State<AppState>, Path(id): Path<Uuid>, req: Requ
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CACHE_CONTROL, "private, max-age=60")
.header(header::ETAG, &etag)
.header(header::CONTENT_LENGTH, meta.byte_size)
.body(Body::from(bytes))
.unwrap()
}

/// RFC 9110 §13.1.2: `If-None-Match` uses weak comparison, so a `W/` prefix
/// on the client's tag still matches, and `*` matches any existing blob.
fn if_none_match_hits(headers: &axum::http::HeaderMap, etag: &str) -> bool {
headers
.get_all(header::IF_NONE_MATCH)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.map(str::trim)
.any(|candidate| candidate == "*" || candidate.trim_start_matches("W/") == etag)
}

async fn delete_blob(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Expand Down
56 changes: 56 additions & 0 deletions crates/knot-server/src/static_files.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//! Serves the built SPA (`web/dist`).
//!
//! Vite content-hashes every file under `/assets`, so an asset URL never
//! changes content: those are cached for a year, and a missing one is a real
//! 404 rather than `index.html` served as JavaScript. Everything else —
//! `index.html` for client-side routes, favicons, the manifest — is `no-cache`
//! so a deploy is picked up on the next load instead of a heuristically cached
//! shell pointing at chunks that no longer exist.
//!
//! Compression is applied here and not to the API: these bodies are public,
//! so compressing them cannot leak a secret the way BREACH exploits.

use axum::Router;
use axum::http::{HeaderValue, Response, StatusCode, header};
use tower::ServiceBuilder;
use tower_http::compression::CompressionLayer;
use tower_http::services::{ServeDir, ServeFile};

pub fn router<S>(web_dist: &str) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
let assets = ServiceBuilder::new()
.map_response(cache_forever_when_found)
.service(ServeDir::new(format!("{web_dist}/assets")));
// `fallback` (not `not_found_service`) so client-side routes answer 200.
let pages = ServiceBuilder::new()
.map_response(always_revalidate)
.service(
ServeDir::new(web_dist)
.append_index_html_on_directories(true)
.fallback(ServeFile::new(format!("{web_dist}/index.html"))),
);
Router::new()
.nest_service("/assets", assets)
.fallback_service(pages)
.layer(CompressionLayer::new())
}

fn cache_forever_when_found<B>(mut response: Response<B>) -> Response<B> {
let status = response.status();
if status.is_success() || status == StatusCode::NOT_MODIFIED {
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
}
response
}

fn always_revalidate<B>(mut response: Response<B>) -> Response<B> {
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
response
}
Loading
Loading