use std::sync::{Mutex, PoisonError};
use std::time::Duration;
use anyhow::{bail, Result};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde_json::{json, Value};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::daemon::service::{DaemonService, MenuItem, MenuSnapshot, ServiceStatus};
use crate::github_metrics::{self, GhCounts};
use crate::request_log;
const STARTUP_DELAY: Duration = Duration::from_secs(5);
const PERIODIC_INTERVAL: Duration = Duration::from_secs(10 * 60);
struct LoggerTask {
token: CancellationToken,
handle: JoinHandle<()>,
}
pub struct GithubCountersService {
started_at: DateTime<Utc>,
logger: Mutex<Option<LoggerTask>>,
}
impl Default for GithubCountersService {
fn default() -> Self {
Self::new()
}
}
impl GithubCountersService {
#[must_use]
pub fn new() -> Self {
Self {
started_at: Utc::now(),
logger: Mutex::new(None),
}
}
pub fn start_counter_logger(&self) {
self.start_counter_logger_with(STARTUP_DELAY, PERIODIC_INTERVAL);
}
fn start_counter_logger_with(&self, startup_delay: Duration, interval: Duration) {
if tokio::runtime::Handle::try_current().is_err() {
tracing::debug!("no tokio runtime; github counter logger not started");
return;
}
let mut guard = self.logger.lock().unwrap_or_else(PoisonError::into_inner);
if guard.is_some() {
return;
}
let token = CancellationToken::new();
let loop_token = token.clone();
let started_at = self.started_at;
let handle = tokio::spawn(async move {
tokio::select! {
() = loop_token.cancelled() => return,
() = tokio::time::sleep(startup_delay) => {}
}
emit(started_at, "startup").await;
loop {
tokio::select! {
() = loop_token.cancelled() => break,
() = tokio::time::sleep(interval) => emit(started_at, "periodic").await,
}
}
});
*guard = Some(LoggerTask { token, handle });
}
}
fn counts_since(started_at: DateTime<Utc>) -> GhCounts {
match request_log::log_file_path() {
Some(path) => github_metrics::aggregate(&path, Some(started_at), None, None),
None => GhCounts::default(),
}
}
async fn emit(started_at: DateTime<Utc>, phase: &str) {
let counts = tokio::task::spawn_blocking(move || counts_since(started_at))
.await
.unwrap_or_default();
let summary = counts.summary_line();
tracing::info!("github api calls ({phase}): {summary}");
}
#[async_trait]
impl DaemonService for GithubCountersService {
fn name(&self) -> &'static str {
"github"
}
async fn handle(&self, op: &str, _payload: Value) -> Result<Value> {
match op {
"summary" => {
let started_at = self.started_at;
let counts = tokio::task::spawn_blocking(move || counts_since(started_at)).await?;
let mut value = counts.to_json();
if let Value::Object(map) = &mut value {
map.insert("since".to_string(), json!(started_at.to_rfc3339()));
}
Ok(value)
}
other => bail!("unknown github op: {other}"),
}
}
fn menu(&self) -> MenuSnapshot {
MenuSnapshot {
title: "GitHub API".to_string(),
items: vec![MenuItem::Label(
"call counters logged to daemon.log".to_string(),
)],
}
}
async fn menu_action(&self, _action_id: &str) -> Result<()> {
Ok(())
}
async fn status(&self) -> ServiceStatus {
let started_at = self.started_at;
let counts = tokio::task::spawn_blocking(move || counts_since(started_at))
.await
.unwrap_or_default();
ServiceStatus {
name: self.name().to_string(),
healthy: true,
summary: format!("{} GitHub API call(s) since start", counts.api_total()),
detail: counts.to_json(),
}
}
async fn shutdown(&self) {
let task = self
.logger
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
if let Some(task) = task {
task.token.cancel();
let _ = task.handle.await;
}
emit(self.started_at, "shutdown").await;
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[tokio::test]
async fn service_reports_status_and_handles_summary() {
let svc = GithubCountersService::new();
assert_eq!(svc.name(), "github");
let status = svc.status().await;
assert_eq!(status.name, "github");
assert!(status.healthy);
assert!(status.detail.get("api_total").is_some());
let summary = svc.handle("summary", Value::Null).await.unwrap();
assert!(summary.get("since").is_some());
assert!(summary.get("by_source").is_some());
assert!(svc.handle("bogus", Value::Null).await.is_err());
assert_eq!(svc.menu().title, "GitHub API");
svc.menu_action("x").await.unwrap();
svc.shutdown().await;
}
}