diff --git a/Cargo.lock b/Cargo.lock index b972e8d..690d162 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + [[package]] name = "arrayvec" version = "0.7.6" @@ -161,6 +167,7 @@ dependencies = [ name = "banner" version = "0.1.0" dependencies = [ + "anyhow", "async-trait", "axum", "diesel", @@ -173,6 +180,7 @@ dependencies = [ "serde", "serde_json", "serenity", + "thiserror 2.0.16", "tokio", "tracing", "tracing-subscriber", @@ -2560,7 +2568,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl 2.0.16", ] [[package]] @@ -2574,6 +2591,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -2902,7 +2930,7 @@ dependencies = [ "rustls 0.22.4", "rustls-pki-types", "sha1", - "thiserror", + "thiserror 1.0.69", "url", "utf-8", ] diff --git a/Cargo.toml b/Cargo.toml index 5fa3ca4..72663d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,5 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } dotenvy = "0.15.7" poise = "0.6.1" async-trait = "0.1" +anyhow = "1.0.99" +thiserror = "2.0.16" diff --git a/src/config/mod.rs b/src/config/mod.rs index e69de29..f49febd 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct Config { + pub bot_token: String, + pub database_url: String, + pub redis_url: String, + pub banner_base_url: String, + pub bot_target_guild: u64, + pub bot_app_id: u64, +} diff --git a/src/main.rs b/src/main.rs index 5e425b2..952e015 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,187 +1,19 @@ -use serde::Deserialize; use serenity::all::{ClientBuilder, GatewayIntents}; use std::time::Duration; -use tokio::{signal, sync::broadcast, task::JoinSet}; +use tokio::{signal, task::JoinSet}; use tracing::{error, info, warn}; use tracing_subscriber::{EnvFilter, FmtSubscriber}; use crate::bot::{Data, age}; +use crate::config::Config; +use crate::services::{ServiceResult, bot::BotService, dummy::DummyService, run_service}; +use crate::shutdown::ShutdownCoordinator; use figment::{Figment, providers::Env}; -#[derive(Deserialize)] -struct Config { - bot_token: String, - database_url: String, - redis_url: String, - banner_base_url: String, - bot_target_guild: u64, - bot_app_id: u64, -} - mod bot; - -#[derive(Debug)] -enum ServiceResult { - GracefulShutdown, - NormalCompletion, - Error(Box), -} - -/// Common trait for all services in the application -#[async_trait::async_trait] -trait Service: Send + Sync { - /// The name of the service for logging - fn name(&self) -> &'static str; - - /// Run the service's main work loop - async fn run(&mut self) -> Result<(), Box>; - - /// Gracefully shutdown the service - async fn shutdown(&mut self) -> Result<(), Box>; -} - -/// Generic service runner that handles the lifecycle -async fn run_service( - mut service: Box, - mut shutdown_rx: broadcast::Receiver<()>, -) -> ServiceResult { - let name = service.name(); - info!(service = name, "Service started"); - - let work = async { - match service.run().await { - Ok(()) => { - warn!(service = name, "Service completed unexpectedly"); - ServiceResult::NormalCompletion - } - Err(e) => { - error!(service = name, "Service failed: {e}"); - ServiceResult::Error(e) - } - } - }; - - tokio::select! { - result = work => result, - _ = shutdown_rx.recv() => { - info!(service = name, "Shutting down..."); - let start_time = std::time::Instant::now(); - - match service.shutdown().await { - Ok(()) => { - let elapsed = start_time.elapsed(); - info!(service = name, "Shutdown completed in {elapsed:.2?}"); - ServiceResult::GracefulShutdown - } - Err(e) => { - let elapsed = start_time.elapsed(); - error!(service = name, "Shutdown failed after {elapsed:.2?}: {e}"); - ServiceResult::Error(e) - } - } - } - } -} - -/// Shutdown coordinator for managing graceful shutdown of multiple services -struct ShutdownCoordinator { - shutdown_tx: broadcast::Sender<()>, -} - -impl ShutdownCoordinator { - fn new() -> Self { - let (shutdown_tx, _) = broadcast::channel(1); - Self { shutdown_tx } - } - - fn subscribe(&self) -> broadcast::Receiver<()> { - self.shutdown_tx.subscribe() - } - - fn shutdown(&self) { - let _ = self.shutdown_tx.send(()); - } -} - -/// Discord bot service implementation -struct BotService { - client: serenity::Client, - shard_manager: std::sync::Arc, -} - -impl BotService { - fn new(client: serenity::Client) -> Self { - let shard_manager = client.shard_manager.clone(); - Self { - client, - shard_manager, - } - } -} - -#[async_trait::async_trait] -impl Service for BotService { - fn name(&self) -> &'static str { - "bot" - } - - async fn run(&mut self) -> Result<(), Box> { - match self.client.start().await { - Ok(()) => { - warn!(service = "bot", "Stopped early."); - Err("bot stopped early".into()) - } - Err(e) => { - error!(service = "bot", "Error: {e:?}"); - Err(e.into()) - } - } - } - - async fn shutdown(&mut self) -> Result<(), Box> { - self.shard_manager.shutdown_all().await; - Ok(()) - } -} - -/// Dummy service implementation for demonstration -struct DummyService { - name: &'static str, -} - -impl DummyService { - fn new(name: &'static str) -> Self { - Self { name } - } -} - -#[async_trait::async_trait] -impl Service for DummyService { - fn name(&self) -> &'static str { - self.name - } - - async fn run(&mut self) -> Result<(), Box> { - let mut counter = 0; - loop { - tokio::time::sleep(Duration::from_secs(10)).await; - counter += 1; - info!(service = self.name, "Service heartbeat ({counter})"); - - // Simulate service failure after 60 seconds for demo - if counter >= 6 { - error!(service = self.name, "Service encountered an error"); - return Err("Service error".into()); - } - } - } - - async fn shutdown(&mut self) -> Result<(), Box> { - // Simulate cleanup work - tokio::time::sleep(Duration::from_millis(3500)).await; - Ok(()) - } -} +mod config; +mod services; +mod shutdown; #[tokio::main] async fn main() { @@ -238,13 +70,13 @@ async fn main() { // Set up signal handling let signal_handle = { - let coordinator = shutdown_coordinator.shutdown_tx.clone(); + let shutdown_tx = shutdown_coordinator.shutdown_tx(); tokio::spawn(async move { signal::ctrl_c() .await .expect("Failed to install CTRL+C signal handler"); info!("Received CTRL+C, initiating shutdown..."); - let _ = coordinator.send(()); + let _ = shutdown_tx.send(()); ServiceResult::GracefulShutdown }) }; @@ -259,7 +91,7 @@ async fn main() { let mut exit_code = 0; let first_completion = services.join_next().await; - let service_result = match first_completion { + match first_completion { Some(Ok(Ok(service_result))) => { // A service completed successfully match &service_result { @@ -275,22 +107,18 @@ async fn main() { exit_code = 1; } } - service_result } Some(Ok(Err(e))) => { error!("Service task panicked: {e}"); exit_code = 1; - ServiceResult::Error("Task panic".into()) } Some(Err(e)) => { error!("JoinSet error: {e}"); exit_code = 1; - ServiceResult::Error("JoinSet error".into()) } None => { warn!("No services running"); exit_code = 1; - ServiceResult::Error("No services".into()) } }; diff --git a/src/services/bot.rs b/src/services/bot.rs new file mode 100644 index 0000000..2ad098a --- /dev/null +++ b/src/services/bot.rs @@ -0,0 +1,45 @@ +use super::{Service, ServiceResult}; +use serenity::Client; +use std::sync::Arc; +use tracing::{error, warn}; + +/// Discord bot service implementation +pub struct BotService { + client: Client, + shard_manager: Arc, +} + +impl BotService { + pub fn new(client: Client) -> Self { + let shard_manager = client.shard_manager.clone(); + Self { + client, + shard_manager, + } + } +} + +#[async_trait::async_trait] +impl Service for BotService { + fn name(&self) -> &'static str { + "bot" + } + + async fn run(&mut self) -> Result<(), anyhow::Error> { + match self.client.start().await { + Ok(()) => { + warn!(service = "bot", "Stopped early."); + Err(anyhow::anyhow!("bot stopped early")) + } + Err(e) => { + error!(service = "bot", "Error: {e:?}"); + Err(e.into()) + } + } + } + + async fn shutdown(&mut self) -> Result<(), anyhow::Error> { + self.shard_manager.shutdown_all().await; + Ok(()) + } +} diff --git a/src/services/dummy.rs b/src/services/dummy.rs new file mode 100644 index 0000000..d97d32a --- /dev/null +++ b/src/services/dummy.rs @@ -0,0 +1,42 @@ +use super::Service; +use std::time::Duration; +use tracing::{error, info}; + +/// Dummy service implementation for demonstration +pub struct DummyService { + name: &'static str, +} + +impl DummyService { + pub fn new(name: &'static str) -> Self { + Self { name } + } +} + +#[async_trait::async_trait] +impl Service for DummyService { + fn name(&self) -> &'static str { + self.name + } + + async fn run(&mut self) -> Result<(), anyhow::Error> { + let mut counter = 0; + loop { + tokio::time::sleep(Duration::from_secs(10)).await; + counter += 1; + info!(service = self.name, "Service heartbeat ({counter})"); + + // Simulate service failure after 60 seconds for demo + if counter >= 6 { + error!(service = self.name, "Service encountered an error"); + return Err(anyhow::anyhow!("Service error")); + } + } + } + + async fn shutdown(&mut self) -> Result<(), anyhow::Error> { + // Simulate cleanup work + tokio::time::sleep(Duration::from_millis(6000)).await; + Ok(()) + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..c845b7b --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,69 @@ +use std::time::Duration; +use tokio::sync::broadcast; +use tracing::{error, info, warn}; + +pub mod bot; +pub mod dummy; + +#[derive(Debug)] +pub enum ServiceResult { + GracefulShutdown, + NormalCompletion, + Error(anyhow::Error), +} + +/// Common trait for all services in the application +#[async_trait::async_trait] +pub trait Service: Send + Sync { + /// The name of the service for logging + fn name(&self) -> &'static str; + + /// Run the service's main work loop + async fn run(&mut self) -> Result<(), anyhow::Error>; + + /// Gracefully shutdown the service + async fn shutdown(&mut self) -> Result<(), anyhow::Error>; +} + +/// Generic service runner that handles the lifecycle +pub async fn run_service( + mut service: Box, + mut shutdown_rx: broadcast::Receiver<()>, +) -> ServiceResult { + let name = service.name(); + info!(service = name, "Service started"); + + let work = async { + match service.run().await { + Ok(()) => { + warn!(service = name, "Service completed unexpectedly"); + ServiceResult::NormalCompletion + } + Err(e) => { + error!(service = name, "Service failed: {e}"); + ServiceResult::Error(e) + } + } + }; + + tokio::select! { + result = work => result, + _ = shutdown_rx.recv() => { + info!(service = name, "Shutting down..."); + let start_time = std::time::Instant::now(); + + match service.shutdown().await { + Ok(()) => { + let elapsed = start_time.elapsed(); + info!(service = name, "Shutdown completed in {elapsed:.2?}"); + ServiceResult::GracefulShutdown + } + Err(e) => { + let elapsed = start_time.elapsed(); + error!(service = name, "Shutdown failed after {elapsed:.2?}: {e}"); + ServiceResult::Error(e) + } + } + } + } +} diff --git a/src/shutdown.rs b/src/shutdown.rs new file mode 100644 index 0000000..a071997 --- /dev/null +++ b/src/shutdown.rs @@ -0,0 +1,25 @@ +use tokio::sync::broadcast; + +/// Shutdown coordinator for managing graceful shutdown of multiple services +pub struct ShutdownCoordinator { + shutdown_tx: broadcast::Sender<()>, +} + +impl ShutdownCoordinator { + pub fn new() -> Self { + let (shutdown_tx, _) = broadcast::channel(1); + Self { shutdown_tx } + } + + pub fn subscribe(&self) -> broadcast::Receiver<()> { + self.shutdown_tx.subscribe() + } + + pub fn shutdown(&self) { + let _ = self.shutdown_tx.send(()); + } + + pub fn shutdown_tx(&self) -> broadcast::Sender<()> { + self.shutdown_tx.clone() + } +}