performance improvements

This commit is contained in:
2026-05-14 17:52:13 +02:00
parent 6bc51d6d14
commit 046f60dcb6
9 changed files with 299 additions and 134 deletions
+120 -55
View File
@@ -4,14 +4,15 @@ use axum::{
http::{HeaderMap, StatusCode},
};
use chrono::Utc;
use std::{fs, sync::Arc};
use std::sync::Arc;
use tokio::fs;
use tracing::{error, info, warn};
use crate::{
AppState,
auth::is_authed,
error::AppError,
models::{CoverImage, CreatePostRequest, PostDetail, PostInfo, PostMeta},
models::{CoverImage, CreatePostRequest, PostDetail, PostInfo, PostMeta, PostNeighbor},
};
const WORDS_PER_MINUTE: u32 = 200;
@@ -183,6 +184,70 @@ fn excerpt_from(meta: &PostMeta, body: &str) -> String {
out.trim().to_string()
}
fn build_post_info(slug: &str, meta: &PostMeta, body: &str) -> PostInfo {
let images = extract_images(body);
PostInfo {
slug: slug.to_string(),
date: meta.date,
title: meta.title.clone(),
summary: meta.summary.clone(),
tags: meta.tags.clone(),
draft: meta.draft,
reading_time: reading_time(body),
excerpt: excerpt_from(meta, body),
cover_image: cover_from(&images),
image_count: images.len() as u32,
}
}
/// Scans the posts directory and replaces the in-memory cache.
/// Called at startup and after any mutation (create/rename/delete).
pub async fn rebuild_posts_cache(state: &AppState) {
let posts_dir = state.data_dir.join("posts");
let mut posts: Vec<PostInfo> = Vec::new();
let mut rd = match fs::read_dir(&posts_dir).await {
Ok(rd) => rd,
Err(_) => {
*state.posts_cache.write().await = posts;
return;
}
};
loop {
match rd.next_entry().await {
Ok(Some(entry)) => {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(slug) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if slug.starts_with('.') {
continue;
}
let Ok(raw) = fs::read_to_string(&path).await else {
continue;
};
let Ok((meta, body)) = parse_post(&raw) else {
warn!("Skipping post with bad frontmatter: {}", slug);
continue;
};
posts.push(build_post_info(slug, &meta, &body));
}
Ok(None) => break,
Err(e) => {
warn!("Error iterating posts dir: {}", e);
break;
}
}
}
posts.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.slug.cmp(&b.slug)));
*state.posts_cache.write().await = posts;
}
async fn write_post_atomic(
state: &AppState,
slug: &str,
@@ -191,13 +256,16 @@ async fn write_post_atomic(
let _guard = state.post_lock.lock().await;
let final_path = state.data_dir.join("posts").join(format!("{}.md", slug));
let tmp_path = state.data_dir.join("posts").join(format!(".{}.md.tmp", slug));
fs::write(&tmp_path, contents).map_err(|e| {
fs::write(&tmp_path, contents).await.map_err(|e| {
AppError::Internal("Write error".to_string(), Some(e.to_string()))
})?;
fs::rename(&tmp_path, &final_path).map_err(|e| {
let _ = fs::remove_file(&tmp_path);
AppError::Internal("Rename error".to_string(), Some(e.to_string()))
})?;
if let Err(e) = fs::rename(&tmp_path, &final_path).await {
let _ = fs::remove_file(&tmp_path).await;
return Err(AppError::Internal(
"Rename error".to_string(),
Some(e.to_string()),
));
}
Ok(())
}
@@ -227,14 +295,14 @@ pub async fn create_post(
if let Some(ref old_slug) = payload.old_slug {
if old_slug != &slug {
let old_path = posts_dir.join(format!("{}.md", old_slug));
if old_path.exists() {
if file_path.exists() {
if fs::try_exists(&old_path).await.unwrap_or(false) {
if fs::try_exists(&file_path).await.unwrap_or(false) {
return Err(AppError::BadRequest(
"A post with this new title already exists".to_string(),
));
}
let _guard = state.post_lock.lock().await;
fs::rename(&old_path, &file_path).map_err(|e| {
fs::rename(&old_path, &file_path).await.map_err(|e| {
error!("Rename error from {} to {}: {}", old_slug, slug, e);
AppError::Internal("Rename error".to_string(), Some(e.to_string()))
})?;
@@ -264,6 +332,10 @@ pub async fn create_post(
info!("Post saved: {}", slug);
let image_count = images.len() as u32;
let cover = cover_from(&images);
rebuild_posts_cache(&state).await;
let (prev, next) = neighbors_from_cache(&state, &slug, true).await;
Ok(Json(PostDetail {
slug,
date: meta.date,
@@ -275,6 +347,8 @@ pub async fn create_post(
content: payload.content,
cover_image: cover,
image_count,
prev,
next,
}))
}
@@ -291,17 +365,19 @@ pub async fn delete_post(
let _guard = state.post_lock.lock().await;
let file_path = state.data_dir.join("posts").join(format!("{}.md", slug));
if !file_path.exists() {
if !fs::try_exists(&file_path).await.unwrap_or(false) {
warn!("Post not found for deletion: {}", slug);
return Err(AppError::NotFound("Post not found".to_string()));
}
fs::remove_file(file_path).map_err(|e| {
fs::remove_file(file_path).await.map_err(|e| {
error!("Delete error for post {}: {}", slug, e);
AppError::Internal("Delete error".to_string(), Some(e.to_string()))
})?;
drop(_guard);
info!("Post deleted: {}", slug);
rebuild_posts_cache(&state).await;
Ok(StatusCode::NO_CONTENT)
}
@@ -310,51 +386,37 @@ pub async fn list_posts(
headers: HeaderMap,
) -> Json<Vec<PostInfo>> {
let admin = is_authed(&headers, &state.admin_token);
let posts_dir = state.data_dir.join("posts");
let mut posts: Vec<PostInfo> = Vec::new();
if let Ok(entries) = fs::read_dir(posts_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(slug) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if slug.starts_with('.') {
continue;
}
let Ok(raw) = fs::read_to_string(&path) else {
continue;
};
let Ok((meta, body)) = parse_post(&raw) else {
warn!("Skipping post with bad frontmatter: {}", slug);
continue;
};
if meta.draft && !admin {
continue;
}
let images = extract_images(&body);
posts.push(PostInfo {
slug: slug.to_string(),
date: meta.date,
title: meta.title.clone(),
summary: meta.summary.clone(),
tags: meta.tags.clone(),
draft: meta.draft,
reading_time: reading_time(&body),
excerpt: excerpt_from(&meta, &body),
cover_image: cover_from(&images),
image_count: images.len() as u32,
});
}
}
posts.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.slug.cmp(&b.slug)));
let cache = state.posts_cache.read().await;
let posts: Vec<PostInfo> = cache
.iter()
.filter(|p| admin || !p.draft)
.cloned()
.collect();
Json(posts)
}
async fn neighbors_from_cache(
state: &AppState,
slug: &str,
admin: bool,
) -> (Option<PostNeighbor>, Option<PostNeighbor>) {
let cache = state.posts_cache.read().await;
let visible: Vec<&PostInfo> = cache
.iter()
.filter(|p| admin || !p.draft)
.collect();
let Some(i) = visible.iter().position(|p| p.slug == slug) else {
return (None, None);
};
let to_neighbor = |p: &PostInfo| PostNeighbor {
slug: p.slug.clone(),
title: p.title.clone(),
};
let prev = if i > 0 { Some(to_neighbor(visible[i - 1])) } else { None };
let next = visible.get(i + 1).map(|p| to_neighbor(p));
(prev, next)
}
pub async fn get_post(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
@@ -364,7 +426,7 @@ pub async fn get_post(
let admin = is_authed(&headers, &state.admin_token);
let file_path = state.data_dir.join("posts").join(format!("{}.md", slug));
let raw = fs::read_to_string(&file_path)
let raw = fs::read_to_string(&file_path).await
.map_err(|_| AppError::NotFound("Post not found".to_string()))?;
let (meta, body) = parse_post(&raw)?;
@@ -373,6 +435,7 @@ pub async fn get_post(
}
let images = extract_images(&body);
let (prev, next) = neighbors_from_cache(&state, &slug, admin).await;
Ok(Json(PostDetail {
slug,
date: meta.date,
@@ -384,5 +447,7 @@ pub async fn get_post(
content: body,
cover_image: cover_from(&images),
image_count: images.len() as u32,
prev,
next,
}))
}