From 28f875857e5cb608f20c12309b6e9cb655bd028e Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Thu, 22 Jun 2023 12:13:51 -0700 Subject: [PATCH] add timestamps to incremental rendering --- packages/router/Cargo.toml | 9 +- packages/router/benches/incremental.rs | 187 +++++++++++++ packages/router/examples/static_generation.rs | 11 +- packages/router/src/ssr.rs | 263 +++++++++++++++--- 4 files changed, 421 insertions(+), 49 deletions(-) create mode 100644 packages/router/benches/incremental.rs diff --git a/packages/router/Cargo.toml b/packages/router/Cargo.toml index f0ec24757..bebc5001e 100644 --- a/packages/router/Cargo.toml +++ b/packages/router/Cargo.toml @@ -25,10 +25,12 @@ js-sys = { version = "0.3.63", optional = true } gloo-utils = { version = "0.1.6", optional = true } dioxus-ssr = { path = "../ssr", optional = true } lru = { version = "0.10.0", optional = true } +radix_trie = { version = "0.2.1", optional = true } +rustc-hash = "1.1.0" [features] default = ["web", "ssr"] -ssr = ["dioxus-ssr", "lru"] +ssr = ["dioxus-ssr", "lru", "radix_trie"] wasm_test = [] serde = ["dep:serde", "gloo-utils/serde"] web = ["gloo", "web-sys", "wasm-bindgen", "gloo-utils", "js-sys"] @@ -36,6 +38,11 @@ web = ["gloo", "web-sys", "wasm-bindgen", "gloo-utils", "js-sys"] [dev-dependencies] dioxus = { path = "../dioxus" } dioxus-ssr = { path = "../ssr" } +criterion = "0.3" + +[[bench]] +name = "incremental" +harness = false [target.'cfg(not(target_family = "wasm"))'.dev-dependencies] dioxus-desktop = { path = "../desktop" } diff --git a/packages/router/benches/incremental.rs b/packages/router/benches/incremental.rs new file mode 100644 index 000000000..eba28b91a --- /dev/null +++ b/packages/router/benches/incremental.rs @@ -0,0 +1,187 @@ +#![allow(unused, non_snake_case)] + +use std::time::Duration; + +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use dioxus_router::ssr::{DefaultRenderer, IncrementalRenderer}; +use dioxus_ssr::Renderer; + +pub fn criterion_benchmark(c: &mut Criterion) { + c.bench_function("build 1000 routes", |b| { + let mut renderer = IncrementalRenderer::builder(DefaultRenderer { + before_body: r#" + + + + + Dioxus Application + + "# + .to_string(), + after_body: r#" + "# + .to_string(), + }) + .static_dir("./static") + .invalidate_after(Duration::from_secs(10)) + .build(); + + b.iter(|| { + for id in 0..100 { + for id in 0..10 { + renderer + .render(Route::Post { id }, &mut std::io::sink()) + .unwrap(); + } + } + }) + }); + c.bench_function("build 1000 routes no memory cache", |b| { + let mut renderer = IncrementalRenderer::builder(DefaultRenderer { + before_body: r#" + + + + + Dioxus Application + + "# + .to_string(), + after_body: r#" + "# + .to_string(), + }) + .static_dir("./static") + .memory_cache_limit(0) + .invalidate_after(Duration::from_secs(10)) + .build(); + + b.iter(|| { + for id in 0..1000 { + renderer + .render(Route::Post { id }, &mut std::io::sink()) + .unwrap(); + } + }) + }); + c.bench_function("build 1000 routes no cache", |b| { + let mut renderer = Renderer::default(); + + b.iter(|| { + for id in 0..1000 { + let mut vdom = VirtualDom::new_with_props( + RenderPath, + RenderPathProps::builder().path(Route::Post { id }).build(), + ); + + vdom.rebuild(); + + struct Ignore; + + impl std::fmt::Write for Ignore { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + Ok(()) + } + } + + renderer.render_to(&mut Ignore, &vdom).unwrap(); + } + }) + }); + c.bench_function("cache static", |b| { + b.iter(|| { + let mut renderer = IncrementalRenderer::builder(DefaultRenderer { + before_body: r#" + + + + + Dioxus Application + + "# + .to_string(), + after_body: r#" + "# + .to_string(), + }) + .static_dir("./static") + .build(); + + renderer.pre_cache_static_routes::().unwrap(); + }) + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); + +#[inline_props] +fn Blog(cx: Scope) -> Element { + render! { + div { + "Blog" + } + } +} + +#[inline_props] +fn Post(cx: Scope, id: usize) -> Element { + render! { + for _ in 0..*id { + div { + "PostId: {id}" + } + } + } +} + +#[inline_props] +fn PostHome(cx: Scope) -> Element { + render! { + div { + "Post" + } + } +} + +#[inline_props] +fn Home(cx: Scope) -> Element { + render! { + div { + "Home" + } + } +} + +#[rustfmt::skip] +#[derive(Clone, Debug, PartialEq, Routable)] +enum Route { + #[nest("/blog")] + #[route("/")] + Blog {}, + #[route("/post/index")] + PostHome {}, + #[route("/post/:id")] + Post { + id: usize, + }, + #[end_nest] + #[route("/")] + Home {}, +} + +#[inline_props] +fn RenderPath(cx: Scope, path: Route) -> Element { + let path = path.clone(); + render! { + Router { + config: || RouterConfig::default().history(MemoryHistory::with_initial_path(path)) + } + } +} diff --git a/packages/router/examples/static_generation.rs b/packages/router/examples/static_generation.rs index 00c08e0fa..0aa97cd80 100644 --- a/packages/router/examples/static_generation.rs +++ b/packages/router/examples/static_generation.rs @@ -1,5 +1,7 @@ #![allow(non_snake_case)] +use std::time::Duration; + use dioxus::prelude::*; use dioxus_router::prelude::*; @@ -22,13 +24,16 @@ fn main() { .to_string(), }) .static_dir("./static") + .invalidate_after(Duration::from_secs(10)) .build(); - renderer.pre_cache_static::(); + renderer.pre_cache_static_routes::().unwrap(); - for _ in 0..2 { + for _ in 0..1_000_000 { for id in 0..10 { - renderer.render(Route::Post { id }); + renderer + .render(Route::Post { id }, &mut std::io::sink()) + .unwrap(); } } } diff --git a/packages/router/src/ssr.rs b/packages/router/src/ssr.rs index f72f4ec6e..5aa8ef02f 100644 --- a/packages/router/src/ssr.rs +++ b/packages/router/src/ssr.rs @@ -4,17 +4,22 @@ use crate::prelude::*; use dioxus::prelude::*; +use rustc_hash::FxHasher; use std::{ - io::{Read, Write}, + hash::BuildHasherDefault, + io::Write, num::NonZeroUsize, path::{Path, PathBuf}, str::FromStr, + time::{Duration, SystemTime}, }; /// Something that can render a HTML page from a body. pub trait RenderHTML { - /// Render a HTML page from a body. - fn render_html(&self, body: &str) -> String; + /// Render the HTML before the body + fn render_before_body(&self, to: &mut R) -> Result<(), IncrementalRendererError>; + /// Render the HTML after the body + fn render_after_body(&self, to: &mut R) -> Result<(), IncrementalRendererError>; } /// The default page renderer @@ -45,8 +50,14 @@ impl Default for DefaultRenderer { } impl RenderHTML for DefaultRenderer { - fn render_html(&self, body: &str) -> String { - format!("{}{}{}", self.before_body, body, self.after_body) + fn render_before_body(&self, to: &mut R) -> Result<(), IncrementalRendererError> { + to.write_all(self.before_body.as_bytes())?; + Ok(()) + } + + fn render_after_body(&self, to: &mut R) -> Result<(), IncrementalRendererError> { + to.write_all(self.after_body.as_bytes())?; + Ok(()) } } @@ -54,6 +65,7 @@ impl RenderHTML for DefaultRenderer { pub struct IncrementalRendererConfig { static_dir: PathBuf, memory_cache_limit: usize, + invalidate_after: Option, render: R, } @@ -68,7 +80,8 @@ impl IncrementalRendererConfig { pub fn new(render: R) -> Self { Self { static_dir: PathBuf::from("./static"), - memory_cache_limit: 100, + memory_cache_limit: 10000, + invalidate_after: None, render, } } @@ -85,13 +98,21 @@ impl IncrementalRendererConfig { self } + /// Set the invalidation time. + pub fn invalidate_after(mut self, invalidate_after: Duration) -> Self { + self.invalidate_after = Some(invalidate_after); + self + } + /// Build the incremental renderer. pub fn build(self) -> IncrementalRenderer { IncrementalRenderer { static_dir: self.static_dir, memory_cache: NonZeroUsize::new(self.memory_cache_limit) - .map(|limit| lru::LruCache::new(limit)), + .map(|limit| lru::LruCache::with_hasher(limit, Default::default())), + invalidate_after: self.invalidate_after, render: self.render, + ssr_renderer: dioxus_ssr::Renderer::new(), } } } @@ -99,7 +120,10 @@ impl IncrementalRendererConfig { /// An incremental renderer. pub struct IncrementalRenderer { static_dir: PathBuf, - memory_cache: Option>, + memory_cache: + Option), BuildHasherDefault>>, + invalidate_after: Option, + ssr_renderer: dioxus_ssr::Renderer, render: R, } @@ -109,80 +133,157 @@ impl IncrementalRenderer { IncrementalRendererConfig::new(renderer) } - fn render_uncached(&self, route: Rt) -> String + fn track_timestamps(&self) -> bool { + self.invalidate_after.is_some() + } + + fn render_and_cache( + &mut self, + route: Rt, + output: &mut impl Write, + ) -> Result<(), IncrementalRendererError> where Rt: Routable, ::Err: std::fmt::Display, { + let route_str = route.to_string(); let mut vdom = VirtualDom::new_with_props(RenderPath, RenderPathProps { path: route }); let _ = vdom.rebuild(); - let body = dioxus_ssr::render(&vdom); + let mut html_buffer = WriteBuffer { buffer: Vec::new() }; + self.render.render_before_body(&mut html_buffer)?; + self.ssr_renderer.render_to(&mut html_buffer, &mut vdom)?; + self.render.render_after_body(&mut html_buffer)?; + let html_buffer = html_buffer.buffer; - self.render.render_html(&body) + output.write_all(&html_buffer)?; + + self.add_to_cache(route_str, html_buffer) } - fn add_to_cache(&mut self, route: String, html: String) { + fn add_to_cache( + &mut self, + route: String, + html: Vec, + ) -> Result<(), IncrementalRendererError> { let file_path = self.route_as_path(&route); if let Some(parent) = file_path.parent() { if !parent.exists() { - std::fs::create_dir_all(parent).unwrap(); + std::fs::create_dir_all(parent)?; } } - let file = std::fs::File::create(dbg!(file_path)).unwrap(); + let file = std::fs::File::create(file_path)?; let mut file = std::io::BufWriter::new(file); - file.write_all(html.as_bytes()).unwrap(); + file.write_all(&html)?; self.add_to_memory_cache(route, html); + Ok(()) } - fn add_to_memory_cache + ToString, V: ToString>(&mut self, route: K, html: V) { + fn add_to_memory_cache(&mut self, route: String, html: Vec) { if let Some(cache) = self.memory_cache.as_mut() { - if cache.contains(route.as_ref()) { - cache.promote(route.as_ref()) - } else { - cache.put(route.to_string(), html.to_string()); - } + cache.put(route.to_string(), (SystemTime::now(), html)); } } - fn search_cache(&mut self, route: String) -> Option { - if let Some(cache_hit) = self + fn promote_memory_cache>(&mut self, route: K) { + if let Some(cache) = self.memory_cache.as_mut() { + cache.promote(route.as_ref()) + } + } + + fn search_cache( + &mut self, + route: String, + output: &mut impl Write, + ) -> Result { + if let Some((timestamp, cache_hit)) = self .memory_cache .as_mut() - .and_then(|cache| cache.get(&route).cloned()) + .and_then(|cache| cache.get(&route)) { - Some(cache_hit) - } else { - let file_path = self.route_as_path(&route); - if let Ok(file) = std::fs::File::open(file_path) { - let mut file = std::io::BufReader::new(file); - let mut html = String::new(); - file.read_to_string(&mut html).ok()?; - self.add_to_memory_cache(route, html.clone()); - Some(html) + if let (Ok(elapsed), Some(invalidate_after)) = + (timestamp.elapsed(), self.invalidate_after) + { + if elapsed < invalidate_after { + log::trace!("memory cache hit {:?}", route); + output.write_all(cache_hit)?; + return Ok(true); + } } else { - None + log::trace!("memory cache hit {:?}", route); + output.write_all(cache_hit)?; + return Ok(true); } } + if let Some(file_path) = self.find_file(&route) { + if let Ok(file) = std::fs::File::open(file_path.full_path) { + let mut file = std::io::BufReader::new(file); + std::io::copy(&mut file, output)?; + log::trace!("file cache hit {:?}", route); + self.promote_memory_cache(&route); + return Ok(true); + } + } + Ok(false) } /// Render a route or get it from cache. - pub fn render(&mut self, route: Rt) -> String + pub fn render( + &mut self, + route: Rt, + output: &mut impl Write, + ) -> Result<(), IncrementalRendererError> where Rt: Routable, ::Err: std::fmt::Display, { // check if this route is cached - if let Some(html) = self.search_cache(route.to_string()) { - return html; + if !self.search_cache(route.to_string(), output)? { + // if not, create it + self.render_and_cache(route, output)?; + log::trace!("cache miss"); } - // if not, create it - println!("cache miss"); - let html = self.render_uncached(route.clone()); - self.add_to_cache(route.to_string(), html.clone()); + Ok(()) + } - html + fn find_file(&self, route: &str) -> Option { + let mut file_path = self.static_dir.clone(); + for segment in route.split('/') { + file_path.push(segment); + } + if let Some(deadline) = self.invalidate_after { + // find the first file that matches the route and is a html file + file_path.push("index"); + if let Ok(dir) = std::fs::read_dir(file_path) { + let mut file = None; + for entry in dir.flatten() { + if let Some(cached_path) = ValidCachedPath::try_from_path(entry.path()) { + if let Ok(elapsed) = cached_path.timestamp.elapsed() { + if elapsed < deadline { + file = Some(cached_path); + continue; + } + } + // if the timestamp is invalid or passed, delete the file + if let Err(err) = std::fs::remove_file(entry.path()) { + log::error!("Failed to remove file: {}", err); + } + } + } + file + } else { + None + } + } else { + file_path.push("index.html"); + file_path.exists().then_some({ + ValidCachedPath { + full_path: file_path, + timestamp: SystemTime::now(), + } + }) + } } fn route_as_path(&self, route: &str) -> PathBuf { @@ -190,13 +291,18 @@ impl IncrementalRenderer { for segment in route.split('/') { file_path.push(segment); } - file_path.push("index"); + if self.track_timestamps() { + file_path.push("index"); + file_path.push(timestamp()); + } else { + file_path.push("index"); + } file_path.set_extension("html"); file_path } /// Pre-cache all static routes. - pub fn pre_cache_static(&mut self) + pub fn pre_cache_static_routes(&mut self) -> Result<(), IncrementalRendererError> where Rt: Routable, ::Err: std::fmt::Display, @@ -225,7 +331,7 @@ impl IncrementalRenderer { if is_static { match Rt::from_str(&full_path) { Ok(route) => { - let _ = self.render(route); + let _ = self.render(route, &mut std::io::sink())?; } Err(e) => { log::error!("Error pre-caching static route: {}", e); @@ -233,9 +339,65 @@ impl IncrementalRenderer { } } } + + Ok(()) } } +struct WriteBuffer { + buffer: Vec, +} + +impl std::fmt::Write for WriteBuffer { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.buffer.extend_from_slice(s.as_bytes()); + Ok(()) + } +} + +impl Write for WriteBuffer { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buffer.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buffer.flush() + } +} + +struct ValidCachedPath { + full_path: PathBuf, + timestamp: std::time::SystemTime, +} + +impl ValidCachedPath { + fn try_from_path(value: PathBuf) -> Option { + if value.extension() != Some(std::ffi::OsStr::new("html")) { + return None; + } + let timestamp = decode_timestamp(&value.file_stem()?.to_str()?)?; + let full_path = value; + Some(Self { + full_path, + timestamp, + }) + } +} + +fn decode_timestamp(timestamp: &str) -> Option { + let timestamp = u64::from_str_radix(timestamp, 16).ok()?; + Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(timestamp)) +} + +fn timestamp() -> String { + let datetime = std::time::SystemTime::now(); + let timestamp = datetime + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + format!("{:x}", timestamp) +} + #[inline_props] fn RenderPath(cx: Scope, path: R) -> Element where @@ -249,3 +411,14 @@ where } } } + +/// An error that can occur while rendering a route or retrieving a cached route. +#[derive(Debug, thiserror::Error)] +pub enum IncrementalRendererError { + /// An formatting error occurred while rendering a route. + #[error("RenderError: {0}")] + RenderError(#[from] std::fmt::Error), + /// An IO error occurred while rendering a route. + #[error("IoError: {0}")] + IoError(#[from] std::io::Error), +}