Skip to content

Commit 0f51e3d

Browse files
authored
Merge feature/service-status-events into main (#54)
* Add config file (#14) * Implement config - Add dirs crate - Implement ConfigHandler - Implement custom error types - Implement config struct - Load config on startup * Use thiserror - Add thiserror crate - Refactor existing error types to use thiserror * Reference Milestones in README.md (#15) Update README.md Add reference to Milestones page * Add README.md portrait (#16) * Add portrait image * Add portrait image to README.md * Add Service Framework (#21) * Implement service framework - Make main async - Implement Status enum - Implement Priority enum - Implement ServiceInfo struct - Implement ServiceInternals trait - Implement Service trait * Bot library - Add fern crate - Add humantime crate - Add log crate - Implement Bot - Implement BotBuilder - Refactor config Display trait implementation - Implement library is_debug() function - Implement library run(Bot) function - Implement log module (log::setup(), log::is_set_up() and log::get_min_log_level()) - Adapt main to new changes * WIP: Finish services framework Just a lot of refactoring and fixing. No time to describe all this now. Happy new year! :) * Finish services framework Too much to describe. It's done, that's it. This was one hell of a ride. * Refactor codebase (#23) - Refined some derived traits - Refactor Service/ServiceInfo: Move some trait implementations from Service to ServiceInfo and refer to the implementations from Service - Compare ID instead of name in ServiceManagerBuilder * Feature/discord service (#24) * Add serenity Add serenity as a dependency and use some extra features and the native TLS backend * Implement Discord Service - Implement Discord Service - Add discordTimeout config variable - Add additional logger setup to mute unimportant messages introduced by serenity * Refactor service framework (#25) * Service framework improvements - Way better handling of mutable/immutable attributes - Less Mutexes - Better handling of passing references through the service framework's API - Reimplement get_service accepting a TypeId as a generic parameter for easier usage - Reimplement status_map and status_tree as a result of the above adaptations, resulting in way simpler versions * More service framework improvements - Replace all Mutexes with RwLock - Remove status_map method of ServiceManager - Services vector in ServiceManager now wraps the Service trait objects in a RwLock to potentially make them available mutably through the public API of ServiceManager * Implement get_service<T> method - Add downcast-rs crate - Implement get_service<T> method of ServiceManager Had to use unsafe Rust for this. Tried it with safe Rust for 3 days and couldn't do it. With unsafe Rust, it's very easy. It's also still kinda safe, as the crash case is checked and prevented before going into the unsafe block. * Finish refactor of service framework - ServiceManager now holds an Arc to itself - Self-Arc is now passed to services when initializing them, so they can access other services and copy Arcs to those for themselves - Implement SetLock<T> struct which is a wrapper around Option<T> for lazy-initialization - ServiceManagerBuilder handles the creation and injection of the Self-Arc of ServiceManager. That's why the build() method is now async and the From trait had to be removed. The From trait cannot be implemented async. - To keep everything consistent, the From trait has also been removed from the BotBuilder and the build() method becase async. * Adapt Discord service - Adapt Discord service to new service framework and SetLock type * Fix service framework (#31) - Fix deadlock when accessing the ServiceManager parameter in start() method of a Service - Known bug: Deadlock still happens when a service accesses itself through the ServiceManager on start * Refactor/service-framework: start_wrapped and stop_wrapped (#32) Refactor service start_wrapped and stop_wrapped have been removed from the Service trait and the logic has been implemented in the Service Manager. * Implement service startup/shutdown timeout (#33) - Add hardcoded timeout of 10 seconds on service startup - Add hardcoded timeout of 10 seconds on service shutdown - Remove timeout implementation of Discord service, as it is now handled by the Service Manager. * Service framework background tasks and watchdogs (#39) * Service framework background task - Implement an optional background task for services that starts automatically on service start. - Implement a watchdog that updates the service's status when its task fails at runtime * WIP: Partially implemented - Background task startup + watchdog implemented for service startup - Implementation for service shutdown missing - Handling of watchdog triggers of essential tasks missing * Refactor of service framework Split service framework into multiple submodules * Implement Watchdog module * Optimize Watchdog module Make usage of Watchdog more flexible * Refactor start_service Refactor the giant block of cod e into smaller helper methods where possible * Edit StartupError string representations * Finish refactor of start_service method * Refactor stop_service - Apply same refactoring to stop_service - Increase name placeholder space to 30 in logger * Clonable Status - Made Status clonable by not using BoxedErrors anymore but Strings for holding the error information - Add get_status() to Service - Made status property of Service private * Adapt Service Manager to new Status enum * Events - Implement Event<T> - Add status_changed Event to ServiceInfo * Event improvements - Add name attribute to Event<T> - Unify Channel and Closure subscribers by using an Enum - Propagate errors when dispatching events - Add error log when errors occur while dispatching events - Subscribers are now removed from an event when they run into an error while dispatching * Event improvements Make the removal of Event subscribers on error optional * Slight refactors - Refactors in service_manager.rs - Refactors in watchdog.rs * WIP: Idk lol I made these changes many months ago. Reviewed them for like half an hour, looks good. I know what I was working on. Will continue now :) * add: allow clippy::multiple_bound_locations for service module * add: observables * refactor: use Mutex instead of RwLock everywhere * refactor: make remove_on_error work on per-subscriber basis * refactor: make subscribers identifiable * refactor: move dispatch logic to Subscriber * add: AsyncClosure Callback type * WIP: EventRepeater * add: EventRepeater * refactor: event subscribe method names * refactor: move subscription into own module * add: AsRef<Event<T>> * add: UUID, PartialEq/Eq, unsubscribe() * add: event_repeater detach(), close() * add: attach/deattach EventRepeater on start/stop of service * add: service runtime failure handling
1 parent 967bfcb commit 0f51e3d

24 files changed

+2114
-7
lines changed

.github/assets/portrait.png

1.37 MB
Loading

Cargo.toml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "lum"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
edition = "2021"
55
description = "Lum Discord Bot"
66
license= "MIT"
@@ -12,7 +12,15 @@ repository = "https://github.com/Kitt3120/lum"
1212
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
1313

1414
[dependencies]
15+
dirs = "5.0.1"
16+
downcast-rs = "1.2.0"
17+
fern = { version = "0.6.2", features = ["chrono", "colored", "date-based"] }
18+
humantime = "2.1.0"
19+
log = { version = "0.4.20", features = ["serde"] }
1520
serde = { version = "1.0.193", features = ["derive"] }
1621
serde_json = "1.0.108"
22+
serenity = { version = "0.12.0", default-features=false, features = ["builder", "cache", "collector", "client", "framework", "gateway", "http", "model", "standard_framework", "utils", "voice", "default_native_tls", "tokio_task_builder", "unstable_discord_api", "simd_json", "temp_cache", "chrono", "interactions_endpoint"] }
1723
sqlx = { version = "0.7.3", features = ["runtime-tokio", "any", "postgres", "mysql", "sqlite", "tls-native-tls", "migrate", "macros", "uuid", "chrono", "json"] }
24+
thiserror = "1.0.52"
1825
tokio = { version = "1.35.1", features = ["full"] }
26+
uuid = { version = "1.10.0", features = ["fast-rng", "macro-diagnostics", "v4"] }

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
<p align="center">
2+
<img src="https://raw.githubusercontent.com/Kitt3120/lum/main/.github/assets/portrait.png" width="512">
3+
</p>
4+
15
# lum
26

37
Lum Discord Bot
@@ -10,6 +14,4 @@ Beta: [![Deploy](https://github.com/Kitt3120/lum/actions/workflows/deploy_prerel
1014

1115
# Collaborating
1216

13-
A board can be viewed [here](https://github.com/users/Kitt3120/projects/3)
14-
15-
Issues can be viewed [here](https://github.com/Kitt3120/lum/issues)
17+
Checkout out [Milestones](https://github.com/Kitt3120/lum/milestones), [Board](https://github.com/users/Kitt3120/projects/3), and [Issues](https://github.com/Kitt3120/lum/issues)

build.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
fn main() {
33
// trigger recompilation when a new migration is added
44
println!("cargo:rerun-if-changed=migrations");
5-
}
5+
}

rustfmt.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
max_width = 110

src/bot.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
use core::fmt;
2+
use std::{fmt::Display, sync::Arc};
3+
4+
use log::error;
5+
use tokio::{signal, sync::Mutex};
6+
7+
use crate::service::{
8+
types::LifetimedPinnedBoxedFuture, OverallStatus, Service, ServiceManager, ServiceManagerBuilder,
9+
};
10+
11+
#[derive(Debug, Clone, Copy)]
12+
pub enum ExitReason {
13+
SIGINT,
14+
EssentialServiceFailed,
15+
}
16+
17+
impl Display for ExitReason {
18+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19+
match self {
20+
Self::SIGINT => write!(f, "SIGINT"),
21+
Self::EssentialServiceFailed => write!(f, "Essential Service Failed"),
22+
}
23+
}
24+
}
25+
26+
pub struct BotBuilder {
27+
name: String,
28+
service_manager: ServiceManagerBuilder,
29+
}
30+
31+
impl BotBuilder {
32+
pub fn new(name: &str) -> Self {
33+
Self {
34+
name: name.to_string(),
35+
service_manager: ServiceManager::builder(),
36+
}
37+
}
38+
39+
pub async fn with_service(mut self, service: Arc<Mutex<dyn Service>>) -> Self {
40+
self.service_manager = self.service_manager.with_service(service).await; // The ServiceManagerBuilder itself will warn when adding a service multiple times
41+
42+
self
43+
}
44+
45+
pub async fn with_services(mut self, services: Vec<Arc<Mutex<dyn Service>>>) -> Self {
46+
for service in services {
47+
self.service_manager = self.service_manager.with_service(service).await;
48+
}
49+
50+
self
51+
}
52+
53+
pub async fn build(self) -> Bot {
54+
Bot {
55+
name: self.name,
56+
service_manager: self.service_manager.build().await,
57+
}
58+
}
59+
}
60+
61+
pub struct Bot {
62+
pub name: String,
63+
pub service_manager: Arc<ServiceManager>,
64+
}
65+
66+
impl Bot {
67+
pub fn builder(name: &str) -> BotBuilder {
68+
BotBuilder::new(name)
69+
}
70+
71+
//TODO: When Rust allows async trait methods to be object-safe, refactor this to use async instead of returning a future
72+
pub fn start(&mut self) -> LifetimedPinnedBoxedFuture<'_, ()> {
73+
Box::pin(async move {
74+
self.service_manager.start_services().await;
75+
//TODO: Potential for further initialization here, like modules
76+
})
77+
}
78+
79+
//TODO: When Rust allows async trait methods to be object-safe, refactor this to use async instead of returning a future
80+
pub fn stop(&mut self) -> LifetimedPinnedBoxedFuture<'_, ()> {
81+
Box::pin(async move {
82+
self.service_manager.stop_services().await;
83+
//TODO: Potential for further deinitialization here, like modules
84+
})
85+
}
86+
87+
pub async fn join(&self) -> ExitReason {
88+
let name_clone = self.name.clone();
89+
let signal_task = tokio::spawn(async move {
90+
let name = name_clone;
91+
92+
let result = signal::ctrl_c().await;
93+
if let Err(error) = result {
94+
error!(
95+
"Error receiving SIGINT: {}. {} will exit ungracefully immediately to prevent undefined behavior.",
96+
error, name
97+
);
98+
panic!("Error receiving SIGINT: {}", error);
99+
}
100+
});
101+
102+
let service_manager_clone = self.service_manager.clone();
103+
let mut receiver = self
104+
.service_manager
105+
.on_status_change
106+
.event
107+
.subscribe_channel("t", 2, true, true)
108+
.await;
109+
let status_task = tokio::spawn(async move {
110+
let service_manager = service_manager_clone;
111+
while (receiver.receiver.recv().await).is_some() {
112+
let overall_status = service_manager.overall_status().await;
113+
if overall_status == OverallStatus::Unhealthy {
114+
return;
115+
}
116+
}
117+
});
118+
119+
tokio::select! {
120+
_ = signal_task => ExitReason::SIGINT,
121+
_ = status_task => ExitReason::EssentialServiceFailed,
122+
}
123+
}
124+
}

src/config.rs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
use core::fmt;
2+
use serde::{Deserialize, Serialize};
3+
use std::{
4+
fmt::{Display, Formatter},
5+
fs, io,
6+
path::PathBuf,
7+
};
8+
use thiserror::Error;
9+
10+
#[derive(Debug, Error)]
11+
pub enum ConfigPathError {
12+
#[error("Unable to get OS config directory")]
13+
UnknownBasePath,
14+
}
15+
16+
#[derive(Debug, Error)]
17+
pub enum ConfigInitError {
18+
#[error("Unable to get config path: {0}")]
19+
Path(#[from] ConfigPathError),
20+
#[error("I/O error: {0}")]
21+
IO(#[from] io::Error),
22+
}
23+
24+
#[derive(Debug, Error)]
25+
pub enum ConfigParseError {
26+
#[error("Unable to get config path: {0}")]
27+
Path(#[from] ConfigPathError),
28+
#[error("Unable to initialize config: {0}")]
29+
Init(#[from] ConfigInitError),
30+
#[error("Unable to serialize or deserialize config: {0}")]
31+
Serde(#[from] serde_json::Error),
32+
#[error("I/O error: {0}")]
33+
IO(#[from] io::Error),
34+
}
35+
36+
fn discord_token_default() -> String {
37+
String::from("Please provide a token")
38+
}
39+
40+
#[derive(Debug, PartialEq, PartialOrd, Serialize, Deserialize, Clone)]
41+
pub struct Config {
42+
#[serde(rename = "discordToken", default = "discord_token_default")]
43+
pub discord_token: String,
44+
}
45+
46+
impl Default for Config {
47+
fn default() -> Self {
48+
Config {
49+
discord_token: discord_token_default(),
50+
}
51+
}
52+
}
53+
54+
impl Display for Config {
55+
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
56+
let content = match serde_json::to_string(self) {
57+
Ok(content) => content,
58+
Err(error) => {
59+
return write!(f, "Unable to serialize config: {}", error);
60+
}
61+
};
62+
63+
write!(f, "{}", content)
64+
}
65+
}
66+
67+
#[derive(Debug)]
68+
pub struct ConfigHandler {
69+
pub app_name: String,
70+
}
71+
72+
impl ConfigHandler {
73+
pub fn new(app_name: &str) -> Self {
74+
ConfigHandler {
75+
app_name: app_name.to_string(),
76+
}
77+
}
78+
79+
pub fn get_config_dir_path(&self) -> Result<PathBuf, ConfigPathError> {
80+
let mut path = match dirs::config_dir() {
81+
Some(path) => path,
82+
None => return Err(ConfigPathError::UnknownBasePath),
83+
};
84+
85+
path.push(&self.app_name);
86+
Ok(path)
87+
}
88+
89+
pub fn create_config_dir_path(&self) -> Result<(), ConfigInitError> {
90+
let path = self.get_config_dir_path()?;
91+
fs::create_dir_all(path)?;
92+
Ok(())
93+
}
94+
95+
pub fn get_config_file_path(&self) -> Result<PathBuf, ConfigPathError> {
96+
let mut path = self.get_config_dir_path()?;
97+
path.push("config.json");
98+
Ok(path)
99+
}
100+
101+
pub fn save_config(&self, config: &Config) -> Result<(), ConfigParseError> {
102+
let path = self.get_config_file_path()?;
103+
104+
if !path.exists() {
105+
self.create_config_dir_path()?;
106+
}
107+
108+
let config_json = serde_json::to_string_pretty(config)?;
109+
110+
fs::write(path, config_json)?;
111+
112+
Ok(())
113+
}
114+
115+
pub fn load_config(&self) -> Result<Config, ConfigParseError> {
116+
let path = self.get_config_file_path()?;
117+
if !path.exists() {
118+
self.create_config_dir_path()?;
119+
fs::write(&path, "{}")?;
120+
}
121+
122+
let config_json = fs::read_to_string(path)?;
123+
let config: Config = serde_json::from_str(&config_json)?;
124+
125+
self.save_config(&config)?; // In case the config file was missing some fields which serde used the defaults for
126+
127+
Ok(config)
128+
}
129+
}

src/event.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
pub mod arc_observable;
2+
pub mod event;
3+
pub mod event_repeater;
4+
pub mod observable;
5+
pub mod subscriber;
6+
pub mod subscription;
7+
8+
pub use arc_observable::ArcObservable;
9+
pub use event::Event;
10+
pub use event_repeater::EventRepeater;
11+
pub use observable::{Observable, ObservableResult};
12+
pub use subscriber::{Callback, DispatchError, Subscriber};
13+
pub use subscription::{ReceiverSubscription, Subscription};

src/event/arc_observable.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
use std::{
2+
hash::{DefaultHasher, Hash, Hasher},
3+
sync::Arc,
4+
};
5+
6+
use tokio::sync::Mutex;
7+
8+
use super::{Event, ObservableResult};
9+
10+
#[derive(Debug)]
11+
pub struct ArcObservable<T>
12+
where
13+
T: Send + 'static + Hash,
14+
{
15+
value: Arc<Mutex<T>>,
16+
on_change: Event<Mutex<T>>,
17+
}
18+
19+
impl<T> ArcObservable<T>
20+
where
21+
T: Send + 'static + Hash,
22+
{
23+
pub fn new(value: T, event_name: impl Into<String>) -> Self {
24+
Self {
25+
value: Arc::new(Mutex::new(value)),
26+
on_change: Event::new(event_name),
27+
}
28+
}
29+
30+
pub async fn get(&self) -> Arc<Mutex<T>> {
31+
Arc::clone(&self.value)
32+
}
33+
34+
pub async fn set(&self, value: T) -> ObservableResult<Mutex<T>> {
35+
let mut lock = self.value.lock().await;
36+
37+
let mut hasher = DefaultHasher::new();
38+
(*lock).hash(&mut hasher);
39+
let current_value = hasher.finish();
40+
41+
let mut hasher = DefaultHasher::new();
42+
value.hash(&mut hasher);
43+
let new_value = hasher.finish();
44+
45+
if current_value == new_value {
46+
return ObservableResult::Unchanged;
47+
}
48+
49+
*lock = value;
50+
drop(lock);
51+
52+
let value = Arc::clone(&self.value);
53+
let dispatch_result = self.on_change.dispatch(value).await;
54+
55+
match dispatch_result {
56+
Ok(_) => ObservableResult::Changed(Ok(())),
57+
Err(errors) => ObservableResult::Changed(Err(errors)),
58+
}
59+
}
60+
}

0 commit comments

Comments
 (0)