mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 09:10:32 +00:00
fix(io): replace blocking I/O with async in hot paths
Three subsystems were using std::fs (blocking) inside async context, which stalls the tokio runtime thread during I/O: - DashboardMetrics::save(): now uses tokio::fs::write + rename - TokenStore::flush_last_used(): now uses tokio::fs for batch updates - AuditLog::log(): moved file write to spawn_blocking (fire-and-forget) The background task and shutdown handler now properly .await the async save/flush methods. AuditLog writer wrapped in Arc for cross-thread access from spawn_blocking.
This commit is contained in:
@@ -12,6 +12,7 @@ use serde::Serialize;
|
|||||||
use std::fs::{self, OpenOptions};
|
use std::fs::{self, OpenOptions};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -39,7 +40,7 @@ impl AuditEntry {
|
|||||||
|
|
||||||
pub struct AuditLog {
|
pub struct AuditLog {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
writer: Mutex<Option<fs::File>>,
|
writer: Arc<Mutex<Option<fs::File>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuditLog {
|
impl AuditLog {
|
||||||
@@ -48,23 +49,26 @@ impl AuditLog {
|
|||||||
let writer = match OpenOptions::new().create(true).append(true).open(&path) {
|
let writer = match OpenOptions::new().create(true).append(true).open(&path) {
|
||||||
Ok(f) => {
|
Ok(f) => {
|
||||||
info!(path = %path.display(), "Audit log initialized");
|
info!(path = %path.display(), "Audit log initialized");
|
||||||
Mutex::new(Some(f))
|
Arc::new(Mutex::new(Some(f)))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(path = %path.display(), error = %e, "Failed to open audit log, auditing disabled");
|
warn!(path = %path.display(), error = %e, "Failed to open audit log, auditing disabled");
|
||||||
Mutex::new(None)
|
Arc::new(Mutex::new(None))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Self { path, writer }
|
Self { path, writer }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn log(&self, entry: AuditEntry) {
|
pub fn log(&self, entry: AuditEntry) {
|
||||||
if let Some(ref mut file) = *self.writer.lock() {
|
let writer = Arc::clone(&self.writer);
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
if let Some(ref mut file) = *writer.lock() {
|
||||||
if let Ok(json) = serde_json::to_string(&entry) {
|
if let Ok(json) = serde_json::to_string(&entry) {
|
||||||
let _ = writeln!(file, "{}", json);
|
let _ = writeln!(file, "{}", json);
|
||||||
let _ = file.flush();
|
let _ = file.flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn path(&self) -> &PathBuf {
|
pub fn path(&self) -> &PathBuf {
|
||||||
@@ -100,23 +104,25 @@ mod tests {
|
|||||||
assert!(log.path().ends_with("audit.jsonl"));
|
assert!(log.path().ends_with("audit.jsonl"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn test_audit_log_write_entry() {
|
async fn test_audit_log_write_entry() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let log = AuditLog::new(tmp.path().to_str().unwrap());
|
let log = AuditLog::new(tmp.path().to_str().unwrap());
|
||||||
|
|
||||||
let entry = AuditEntry::new("pull", "user1", "lodash", "npm", "downloaded");
|
let entry = AuditEntry::new("pull", "user1", "lodash", "npm", "downloaded");
|
||||||
log.log(entry);
|
log.log(entry);
|
||||||
|
|
||||||
// Verify file contains the entry
|
// spawn_blocking is fire-and-forget; give it time to flush
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
|
||||||
let content = std::fs::read_to_string(log.path()).unwrap();
|
let content = std::fs::read_to_string(log.path()).unwrap();
|
||||||
assert!(content.contains(r#""action":"pull""#));
|
assert!(content.contains(r#""action":"pull""#));
|
||||||
assert!(content.contains(r#""actor":"user1""#));
|
assert!(content.contains(r#""actor":"user1""#));
|
||||||
assert!(content.contains(r#""artifact":"lodash""#));
|
assert!(content.contains(r#""artifact":"lodash""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn test_audit_log_multiple_entries() {
|
async fn test_audit_log_multiple_entries() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let log = AuditLog::new(tmp.path().to_str().unwrap());
|
let log = AuditLog::new(tmp.path().to_str().unwrap());
|
||||||
|
|
||||||
@@ -124,6 +130,8 @@ mod tests {
|
|||||||
log.log(AuditEntry::new("pull", "user", "b", "npm", ""));
|
log.log(AuditEntry::new("pull", "user", "b", "npm", ""));
|
||||||
log.log(AuditEntry::new("delete", "admin", "c", "maven", ""));
|
log.log(AuditEntry::new("delete", "admin", "c", "maven", ""));
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
|
||||||
let content = std::fs::read_to_string(log.path()).unwrap();
|
let content = std::fs::read_to_string(log.path()).unwrap();
|
||||||
let lines: Vec<&str> = content.lines().collect();
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
assert_eq!(lines.len(), 3);
|
assert_eq!(lines.len(), 3);
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ impl DashboardMetrics {
|
|||||||
metrics
|
metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save current metrics to disk
|
/// Save current metrics to disk (async to avoid blocking the runtime)
|
||||||
pub fn save(&self) {
|
pub async fn save(&self) {
|
||||||
let Some(path) = &self.persist_path else {
|
let Some(path) = &self.persist_path else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -134,8 +134,8 @@ impl DashboardMetrics {
|
|||||||
// Atomic write: write to tmp then rename
|
// Atomic write: write to tmp then rename
|
||||||
let tmp = path.with_extension("json.tmp");
|
let tmp = path.with_extension("json.tmp");
|
||||||
if let Ok(data) = serde_json::to_string_pretty(&snap) {
|
if let Ok(data) = serde_json::to_string_pretty(&snap) {
|
||||||
if std::fs::write(&tmp, &data).is_ok() {
|
if tokio::fs::write(&tmp, &data).await.is_ok() {
|
||||||
let _ = std::fs::rename(&tmp, path);
|
let _ = tokio::fs::rename(&tmp, path).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,8 +317,8 @@ mod tests {
|
|||||||
assert_eq!(m.get_registry_uploads("unknown"), 0);
|
assert_eq!(m.get_registry_uploads("unknown"), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn test_persistence_save_and_load() {
|
async fn test_persistence_save_and_load() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let path = tmp.path().to_str().unwrap();
|
let path = tmp.path().to_str().unwrap();
|
||||||
|
|
||||||
@@ -329,7 +329,7 @@ mod tests {
|
|||||||
m.record_download("docker");
|
m.record_download("docker");
|
||||||
m.record_upload("maven");
|
m.record_upload("maven");
|
||||||
m.record_cache_hit();
|
m.record_cache_hit();
|
||||||
m.save();
|
m.save().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load in new instance
|
// Load in new instance
|
||||||
|
|||||||
@@ -441,9 +441,9 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
metrics_state.metrics.save();
|
metrics_state.metrics.save().await;
|
||||||
if let Some(ref token_store) = metrics_state.tokens {
|
if let Some(ref token_store) = metrics_state.tokens {
|
||||||
token_store.flush_last_used();
|
token_store.flush_last_used().await;
|
||||||
}
|
}
|
||||||
registry::docker::cleanup_expired_sessions(&metrics_state.upload_sessions);
|
registry::docker::cleanup_expired_sessions(&metrics_state.upload_sessions);
|
||||||
}
|
}
|
||||||
@@ -459,7 +459,7 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
.expect("Server error");
|
.expect("Server error");
|
||||||
|
|
||||||
// Save metrics on shutdown
|
// Save metrics on shutdown
|
||||||
state.metrics.save();
|
state.metrics.save().await;
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
uptime_seconds = state.start_time.elapsed().as_secs(),
|
uptime_seconds = state.start_time.elapsed().as_secs(),
|
||||||
|
|||||||
@@ -270,9 +270,9 @@ impl TokenStore {
|
|||||||
tokens
|
tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flush pending last_used timestamps to disk.
|
/// Flush pending last_used timestamps to disk (async to avoid blocking runtime).
|
||||||
/// Called periodically by background task (every 30s).
|
/// Called periodically by background task (every 30s).
|
||||||
pub fn flush_last_used(&self) {
|
pub async fn flush_last_used(&self) {
|
||||||
let pending: HashMap<String, u64> = {
|
let pending: HashMap<String, u64> = {
|
||||||
let mut map = self.pending_last_used.write();
|
let mut map = self.pending_last_used.write();
|
||||||
std::mem::take(&mut *map)
|
std::mem::take(&mut *map)
|
||||||
@@ -284,7 +284,7 @@ impl TokenStore {
|
|||||||
|
|
||||||
for (file_prefix, timestamp) in &pending {
|
for (file_prefix, timestamp) in &pending {
|
||||||
let file_path = self.storage_path.join(format!("{}.json", file_prefix));
|
let file_path = self.storage_path.join(format!("{}.json", file_prefix));
|
||||||
let content = match fs::read_to_string(&file_path) {
|
let content = match tokio::fs::read_to_string(&file_path).await {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
@@ -294,7 +294,7 @@ impl TokenStore {
|
|||||||
};
|
};
|
||||||
info.last_used = Some(*timestamp);
|
info.last_used = Some(*timestamp);
|
||||||
if let Ok(json) = serde_json::to_string_pretty(&info) {
|
if let Ok(json) = serde_json::to_string_pretty(&info) {
|
||||||
let _ = fs::write(&file_path, &json);
|
let _ = tokio::fs::write(&file_path, &json).await;
|
||||||
set_file_permissions_600(&file_path);
|
set_file_permissions_600(&file_path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -597,8 +597,8 @@ mod tests {
|
|||||||
assert_eq!(store.list_tokens("user2").len(), 1);
|
assert_eq!(store.list_tokens("user2").len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn test_token_updates_last_used() {
|
async fn test_token_updates_last_used() {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let store = TokenStore::new(temp_dir.path());
|
let store = TokenStore::new(temp_dir.path());
|
||||||
|
|
||||||
@@ -609,7 +609,7 @@ mod tests {
|
|||||||
store.verify_token(&token).unwrap();
|
store.verify_token(&token).unwrap();
|
||||||
|
|
||||||
// last_used is deferred — flush to persist
|
// last_used is deferred — flush to persist
|
||||||
store.flush_last_used();
|
store.flush_last_used().await;
|
||||||
|
|
||||||
let tokens = store.list_tokens("testuser");
|
let tokens = store.list_tokens("testuser");
|
||||||
assert!(tokens[0].last_used.is_some());
|
assert!(tokens[0].last_used.is_some());
|
||||||
|
|||||||
Reference in New Issue
Block a user