performance improvements
This commit is contained in:
+120
-55
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user