[feat] modularized backend with plugin architecture, added module-api, module-host, and summarizer crates, and integrated dynamic module loading into main.rs

This commit is contained in:
2025-08-20 08:51:38 +02:00
parent 7c6724800f
commit 16167d18ff
11 changed files with 420 additions and 10 deletions

View File

@@ -5,7 +5,10 @@ edition.workspace = true
[dependencies]
owly-news-api = { path = "../api" }
tokio = { workspace = true, features = ["full"] }
owly-news-module-host = { path = "../module-host" }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter", "json"] }
anyhow = "1.0.99"
anyhow = { workspace = true }
serde_json = { workspace = true }
num_cpus = { workspace = true }

View File

@@ -1,8 +1,8 @@
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
#[tokio::main(flavor = "multi_thread")]
async fn main() -> anyhow::Result<()> {
// Basic tracing setup (adjust as needed)
// Tracing setup
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
@@ -11,9 +11,35 @@ async fn main() -> anyhow::Result<()> {
.with(tracing_subscriber::fmt::layer())
.init();
// TODO: invoke your API server bootstrap here.
// For example, if you have a function like `owly-news-api::run_server().await`
// call it here. This is just a placeholder:
tracing::info!("owly-news app starting...");
// Limit worker threads for CPU control (can be tuned via env)
// Note: When using #[tokio::main], configure via env TOKIO_WORKER_THREADS.
// Alternatively, build a Runtime manually for stricter control.
if let Ok(threads) = std::env::var("TOKIO_WORKER_THREADS") {
tracing::warn!(
"TOKIO_WORKER_THREADS is set to {threads}, ensure it matches deployment requirements"
);
} else {
// Provide a sane default via env if not set
let default_threads = std::cmp::max(1, num_cpus::get_physical() / 2);
unsafe { std::env::set_var("TOKIO_WORKER_THREADS", default_threads.to_string()); }
tracing::info!("Defaulting worker threads to {}", default_threads);
}
// Example: lazily load and invoke the "summarizer" module when needed
let host = owly_news_module_host::ModuleHost::default();
// Simulate an on-demand call (e.g., from an HTTP handler)
let summarizer = host.get("summarizer").await?;
let resp = summarizer.invoke_json(
"summarize",
serde_json::json!({
"text": "Rust enables fearless concurrency with strong guarantees over memory safety.",
"ratio": 0.3
}),
)?;
tracing::info!(?resp, "summarizer response");
// TODO: wire this into your API routes/handlers, using the host.get("<module>").await when needed.
tracing::info!("owly-news daemon running");
Ok(())
}

View File

@@ -0,0 +1,12 @@
[package]
name = "owly-news-module-api"
version.workspace = true
edition.workspace = true
[lib]
path = "src/lib.rs"
[dependencies]
anyhow = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

View File

@@ -0,0 +1,30 @@
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
// Symbols every module must export with `extern "C"` and `#[no_mangle]`.
// Signature: fn module_name() -> *const c_char
// Signature: fn module_invoke(op: *const c_char, payload: *const c_char) -> *mut c_char
pub const SYMBOL_NAME: &str = "module_name";
pub const SYMBOL_INVOKE: &str = "module_invoke";
// Helper to convert C char* to &str
pub unsafe fn cstr_to_str<'a>(ptr: *const c_char) -> anyhow::Result<&'a str> {
if ptr.is_null() {
anyhow::bail!("null pointer");
}
Ok(CStr::from_ptr(ptr).to_str()?)
}
// Helper to allocate a CString for return across FFI boundary (module side)
pub fn string_to_cstring_ptr(s: String) -> *mut c_char {
CString::new(s).unwrap().into_raw()
}
// Helper to take back ownership of a CString (host side), then free by letting CString drop
pub unsafe fn take_cstring(ptr: *mut c_char) -> anyhow::Result<String> {
if ptr.is_null() {
anyhow::bail!("null pointer");
}
let s = CString::from_raw(ptr);
Ok(s.into_string()?)
}

View File

@@ -0,0 +1,17 @@
[package]
name = "owly-news-module-host"
version.workspace = true
edition.workspace = true
[lib]
path = "src/lib.rs"
[dependencies]
anyhow = { workspace = true }
libloading = { workspace = true }
once_cell = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] }
tracing = { workspace = true }
owly-news-module-api = { path = "../module-api" }

View File

@@ -0,0 +1,114 @@
use anyhow::Context;
use libloading::{Library, Symbol};
use once_cell::sync::OnceCell;
use owly_news_module_api::{take_cstring, SYMBOL_INVOKE, SYMBOL_NAME};
use std::collections::HashMap;
use std::ffi::CString;
use std::os::raw::c_char;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::info;
type ModuleNameFn = unsafe extern "C" fn() -> *const c_char;
type ModuleInvokeFn = unsafe extern "C" fn(*const c_char, *const c_char) -> *mut c_char;
pub struct ModuleHandle {
_lib: Arc<Library>,
invoke: ModuleInvokeFn,
}
impl ModuleHandle {
pub fn invoke_json(&self, op: &str, payload: serde_json::Value) -> anyhow::Result<serde_json::Value> {
let op_c = CString::new(op)?;
let payload_c = CString::new(serde_json::to_string(&payload)?)?;
let out_ptr = unsafe { (self.invoke)(op_c.as_ptr(), payload_c.as_ptr()) };
let out = unsafe { take_cstring(out_ptr) }?;
let val = serde_json::from_str(&out).context("module returned invalid JSON")?;
Ok(val)
}
}
pub struct ModuleHost {
// Lazy cache of loaded modules by logical name
loaded: Mutex<HashMap<String, Arc<ModuleHandle>>>,
modules_dir: PathBuf,
}
static DEFAULT_HOST: OnceCell<Arc<ModuleHost>> = OnceCell::new();
impl ModuleHost {
pub fn default() -> Arc<Self> {
DEFAULT_HOST
.get_or_init(|| {
Arc::new(Self::new(
std::env::var_os("OWLY_MODULES_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("target/modules")), // default location
))
})
.clone()
}
pub fn new(modules_dir: PathBuf) -> Self {
Self {
loaded: Mutex::new(HashMap::new()),
modules_dir,
}
}
pub async fn get(&self, name: &str) -> anyhow::Result<Arc<ModuleHandle>> {
if let Some(h) = self.loaded.lock().await.get(name).cloned() {
return Ok(h);
}
let handle = Arc::new(self.load_module(name)?);
self.loaded.lock().await.insert(name.to_string(), handle.clone());
Ok(handle)
}
fn load_module(&self, name: &str) -> anyhow::Result<ModuleHandle> {
let lib_path = resolve_module_path(&self.modules_dir, name)?;
info!(module = name, path = %lib_path.display(), "loading module");
// SAFETY: we keep Library alive in ModuleHandle to ensure symbols remain valid
let lib = unsafe { Library::new(lib_path) }.with_context(|| "failed to load module library")?;
// Validate and bind symbols
let name_fn: Symbol<ModuleNameFn> = unsafe { lib.get(SYMBOL_NAME.as_bytes()) }
.with_context(|| "missing symbol `module_name`")?;
let invoke_fn: Symbol<ModuleInvokeFn> = unsafe { lib.get(SYMBOL_INVOKE.as_bytes()) }
.with_context(|| "missing symbol `module_invoke`")?;
// Optional: verify reported name matches requested
let c_name_ptr = unsafe { name_fn() };
let c_name = unsafe { std::ffi::CStr::from_ptr(c_name_ptr) }.to_string_lossy().into_owned();
if c_name != name {
anyhow::bail!("module reported name `{c_name}`, expected `{name}`");
}
// Copy the function pointer before moving the library
let invoke_fn_copy = *invoke_fn;
Ok(ModuleHandle {
_lib: Arc::new(lib),
invoke: invoke_fn_copy,
})
}
}
fn resolve_module_path(dir: &Path, name: &str) -> anyhow::Result<PathBuf> {
#[cfg(target_os = "windows")]
const EXT: &str = "dll";
#[cfg(target_os = "macos")]
const EXT: &str = "dylib";
#[cfg(all(unix, not(target_os = "macos")))]
const EXT: &str = "so";
let fname = format!("lib{name}.{EXT}");
let path = dir.join(fname);
if !path.exists() {
anyhow::bail!("module `{name}` not found at {}", path.display());
}
Ok(path)
}

View File

@@ -0,0 +1,14 @@
[package]
name = "owly-news-module-summarizer"
version.workspace = true
edition.workspace = true
[lib]
crate-type = ["cdylib"]
path = "src/lib.rs"
[dependencies]
anyhow = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
owly-news-module-api = { path = "../../module-api" }

View File

@@ -0,0 +1,50 @@
use owly_news_module_api::{cstr_to_str, string_to_cstring_ptr};
use serde::{Deserialize, Serialize};
use std::os::raw::c_char;
#[derive(Deserialize)]
struct SummarizeReq {
text: String,
#[serde(default = "default_ratio")]
ratio: f32,
}
fn default_ratio() -> f32 { 0.2 }
#[derive(Serialize)]
struct SummarizeResp {
summary: String,
}
#[unsafe(no_mangle)]
pub extern "C" fn module_name() -> *const c_char {
// IMPORTANT: string must live forever; use a const C string
static NAME: &str = "summarizer\0";
NAME.as_ptr() as *const c_char
}
#[unsafe(no_mangle)]
pub extern "C" fn module_invoke(op: *const c_char, payload: *const c_char) -> *mut c_char {
// SAFETY: called by trusted host with valid pointers
let res = (|| -> anyhow::Result<String> {
let op = unsafe { cstr_to_str(op)? };
let payload = unsafe { cstr_to_str(payload)? };
match op {
"summarize" => {
let req: SummarizeReq = serde_json::from_str(payload)?;
// Placeholder summarization logic. Replace with real algorithm.
let words: Vec<&str> = req.text.split_whitespace().collect();
let take = ((words.len() as f32) * req.ratio).max(1.0).round() as usize;
let summary = words.into_iter().take(take).collect::<Vec<_>>().join(" ");
let resp = SummarizeResp { summary };
Ok(serde_json::to_string(&resp)?)
}
_ => anyhow::bail!("unknown op: {op}"),
}
})();
let json = res.unwrap_or_else(|e| serde_json::json!({ "error": e.to_string() }).to_string());
string_to_cstring_ptr(json)
}