feat: use anyhow, refactor services & coordinator out of main.rs

This commit is contained in:
2025-08-26 15:33:29 -05:00
parent d4c55a3fd8
commit cff672b30a
8 changed files with 234 additions and 184 deletions

32
Cargo.lock generated
View File

@@ -47,6 +47,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anyhow"
version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.6" version = "0.7.6"
@@ -161,6 +167,7 @@ dependencies = [
name = "banner" name = "banner"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"async-trait", "async-trait",
"axum", "axum",
"diesel", "diesel",
@@ -173,6 +180,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serenity", "serenity",
"thiserror 2.0.16",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -2560,7 +2568,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [ 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]] [[package]]
@@ -2574,6 +2591,17 @@ dependencies = [
"syn 2.0.106", "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]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.9" version = "1.1.9"
@@ -2902,7 +2930,7 @@ dependencies = [
"rustls 0.22.4", "rustls 0.22.4",
"rustls-pki-types", "rustls-pki-types",
"sha1", "sha1",
"thiserror", "thiserror 1.0.69",
"url", "url",
"utf-8", "utf-8",
] ]

View File

@@ -19,3 +19,5 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
poise = "0.6.1" poise = "0.6.1"
async-trait = "0.1" async-trait = "0.1"
anyhow = "1.0.99"
thiserror = "2.0.16"

View File

@@ -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,
}

View File

@@ -1,187 +1,19 @@
use serde::Deserialize;
use serenity::all::{ClientBuilder, GatewayIntents}; use serenity::all::{ClientBuilder, GatewayIntents};
use std::time::Duration; use std::time::Duration;
use tokio::{signal, sync::broadcast, task::JoinSet}; use tokio::{signal, task::JoinSet};
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use tracing_subscriber::{EnvFilter, FmtSubscriber}; use tracing_subscriber::{EnvFilter, FmtSubscriber};
use crate::bot::{Data, age}; 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}; 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; mod bot;
mod config;
#[derive(Debug)] mod services;
enum ServiceResult { mod shutdown;
GracefulShutdown,
NormalCompletion,
Error(Box<dyn std::error::Error + Send + Sync>),
}
/// 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<dyn std::error::Error + Send + Sync>>;
/// Gracefully shutdown the service
async fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
}
/// Generic service runner that handles the lifecycle
async fn run_service(
mut service: Box<dyn Service>,
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<serenity::gateway::ShardManager>,
}
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
// Simulate cleanup work
tokio::time::sleep(Duration::from_millis(3500)).await;
Ok(())
}
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@@ -238,13 +70,13 @@ async fn main() {
// Set up signal handling // Set up signal handling
let signal_handle = { let signal_handle = {
let coordinator = shutdown_coordinator.shutdown_tx.clone(); let shutdown_tx = shutdown_coordinator.shutdown_tx();
tokio::spawn(async move { tokio::spawn(async move {
signal::ctrl_c() signal::ctrl_c()
.await .await
.expect("Failed to install CTRL+C signal handler"); .expect("Failed to install CTRL+C signal handler");
info!("Received CTRL+C, initiating shutdown..."); info!("Received CTRL+C, initiating shutdown...");
let _ = coordinator.send(()); let _ = shutdown_tx.send(());
ServiceResult::GracefulShutdown ServiceResult::GracefulShutdown
}) })
}; };
@@ -259,7 +91,7 @@ async fn main() {
let mut exit_code = 0; let mut exit_code = 0;
let first_completion = services.join_next().await; let first_completion = services.join_next().await;
let service_result = match first_completion { match first_completion {
Some(Ok(Ok(service_result))) => { Some(Ok(Ok(service_result))) => {
// A service completed successfully // A service completed successfully
match &service_result { match &service_result {
@@ -275,22 +107,18 @@ async fn main() {
exit_code = 1; exit_code = 1;
} }
} }
service_result
} }
Some(Ok(Err(e))) => { Some(Ok(Err(e))) => {
error!("Service task panicked: {e}"); error!("Service task panicked: {e}");
exit_code = 1; exit_code = 1;
ServiceResult::Error("Task panic".into())
} }
Some(Err(e)) => { Some(Err(e)) => {
error!("JoinSet error: {e}"); error!("JoinSet error: {e}");
exit_code = 1; exit_code = 1;
ServiceResult::Error("JoinSet error".into())
} }
None => { None => {
warn!("No services running"); warn!("No services running");
exit_code = 1; exit_code = 1;
ServiceResult::Error("No services".into())
} }
}; };

45
src/services/bot.rs Normal file
View File

@@ -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<serenity::gateway::ShardManager>,
}
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(())
}
}

42
src/services/dummy.rs Normal file
View File

@@ -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(())
}
}

69
src/services/mod.rs Normal file
View File

@@ -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<dyn Service>,
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)
}
}
}
}
}

25
src/shutdown.rs Normal file
View File

@@ -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()
}
}