mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
estimation
This commit is contained in:
@@ -19,6 +19,7 @@ actix-web = { workspace = true }
|
||||
actix-web-prom = { workspace = true, features = ["process"] }
|
||||
actix-ws = { workspace = true }
|
||||
arc-swap = { workspace = true }
|
||||
aditude = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
ariadne = { workspace = true }
|
||||
async-minecraft-ping = { workspace = true, features = ["srv"] }
|
||||
|
||||
@@ -1,5 +1,114 @@
|
||||
//! Logic for fetching and caching revenue estimations from our ad provider.
|
||||
|
||||
pub async fn estimate(aditude: &aditude::Client) {
|
||||
aditude.
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{Datelike, Months};
|
||||
use dashmap::DashMap;
|
||||
use eyre::{Result, eyre};
|
||||
use rust_decimal::Decimal;
|
||||
use xredis::RedisPool;
|
||||
|
||||
use crate::{
|
||||
routes::ApiError,
|
||||
util::{error::Context, time::YearMonth},
|
||||
};
|
||||
|
||||
const REDIS_KEY: &str = "aditude_month_estimate_v1";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PeriodEstimate {
|
||||
pub period: YearMonth,
|
||||
pub days: Vec<DayEstimate>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DayEstimate {
|
||||
pub day: u32,
|
||||
pub raw_estimated_revenue_usd: Decimal,
|
||||
pub impressions: u128,
|
||||
}
|
||||
|
||||
pub async fn estimate(
|
||||
aditude: &aditude::Client,
|
||||
redis: &RedisPool,
|
||||
periods: &[YearMonth],
|
||||
) -> Result<Vec<PeriodEstimate>> {
|
||||
redis
|
||||
.get_cached_keys(REDIS_KEY, periods, |periods| async {
|
||||
fetch_estimates(aditude, redis, &periods)
|
||||
.await
|
||||
.map_err(ApiError::Internal)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_estimates(
|
||||
aditude: &aditude::Client,
|
||||
redis: &RedisPool,
|
||||
periods: &[YearMonth],
|
||||
) -> Result<DashMap<YearMonth, PeriodEstimate>> {
|
||||
let mut periods = periods.iter();
|
||||
let first_period = periods.next().wrap_err("no first period")?;
|
||||
let last_period = periods.last().unwrap_or(first_period);
|
||||
|
||||
let range_start = first_period
|
||||
.date()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.wrap_err("calculating payout period start")?
|
||||
.and_utc();
|
||||
let range_end = last_period
|
||||
.date()
|
||||
.checked_add_months(Months::new(1))
|
||||
.wrap_err("calculating month after payout period end")?
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.wrap_err("calculating payout period end")?
|
||||
.and_utc();
|
||||
|
||||
let metrics = aditude
|
||||
.get_metrics_v2(aditude::v2::GetMetrics {
|
||||
metrics: &[
|
||||
aditude::v2::MetricKind::Revenue,
|
||||
aditude::v2::MetricKind::Impressions,
|
||||
],
|
||||
range: aditude::v2::Range::Custom {
|
||||
start: range_start,
|
||||
end: range_end,
|
||||
},
|
||||
interval: aditude::v2::Interval::OneDay,
|
||||
})
|
||||
.await
|
||||
.wrap_err("fetching metrics from Aditude")?;
|
||||
|
||||
let mut map = HashMap::<YearMonth, PeriodEstimate>::new();
|
||||
for response in metrics.responses {
|
||||
for row in response.rows {
|
||||
let date = row.time.date_naive();
|
||||
let period = YearMonth::from_day1(date);
|
||||
|
||||
let period_estimate = map.entry(period).or_insert(PeriodEstimate {
|
||||
period,
|
||||
days: Vec::new(),
|
||||
});
|
||||
let days = &mut period_estimate.days;
|
||||
|
||||
let day = date.day();
|
||||
days.push(DayEstimate {
|
||||
day,
|
||||
raw_estimated_revenue_usd: row
|
||||
.revenue
|
||||
.wrap_err_with(|| eyre!("no revenue data for day {day}"))?,
|
||||
impressions: row.impressions.wrap_err_with(|| {
|
||||
eyre!("no impressions data for day {day}")
|
||||
})?,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// we have no clue if the Aditude row return order is stable,
|
||||
// so for safety sort the days here
|
||||
for period in map.values_mut() {
|
||||
period.days.sort_unstable_by_key(|day| day.day);
|
||||
}
|
||||
|
||||
Ok(map.into_iter().collect::<DashMap<_, _>>())
|
||||
}
|
||||
|
||||
@@ -20,5 +20,6 @@ pub mod routes;
|
||||
pub mod sentry;
|
||||
pub mod tags;
|
||||
pub mod tiltify;
|
||||
pub mod time;
|
||||
pub mod validate;
|
||||
pub mod webhook;
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use chrono::{DateTime, Datelike, Days, Months, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct YearMonth(NaiveDate);
|
||||
|
||||
impl YearMonth {
|
||||
pub fn from_day1(date: NaiveDate) -> Self {
|
||||
Self(date.with_day(1).expect("every monht has a first day"))
|
||||
}
|
||||
|
||||
pub fn from_year_month(year: i32, month: u32) -> Option<Self> {
|
||||
NaiveDate::from_ymd_opt(year, month, 1).map(Self)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn date(self) -> NaiveDate {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate when a payout period becomes available under Net-60 terms.
|
||||
pub fn net_60_payout_available_at(period: YearMonth) -> Option<DateTime<Utc>> {
|
||||
period
|
||||
.date()
|
||||
.checked_add_months(Months::new(1))?
|
||||
.and_hms_opt(0, 0, 0)?
|
||||
.and_utc()
|
||||
.checked_add_days(Days::new(59))
|
||||
}
|
||||
|
||||
impl fmt::Display for YearMonth {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(formatter, "{}", self.0.format("%Y-%m"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("expected a valid year and month in `YYYY-MM` format")]
|
||||
pub struct ParseYearMonthError;
|
||||
|
||||
impl FromStr for YearMonth {
|
||||
type Err = ParseYearMonthError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
let mut segments = value.split('-');
|
||||
let (Some(year), Some(month), None) =
|
||||
(segments.next(), segments.next(), segments.next())
|
||||
else {
|
||||
return Err(ParseYearMonthError);
|
||||
};
|
||||
|
||||
let year = year.parse().map_err(|_| ParseYearMonthError)?;
|
||||
let month = month.parse().map_err(|_| ParseYearMonthError)?;
|
||||
Self::from_year_month(year, month).ok_or(ParseYearMonthError)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for YearMonth {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.collect_str(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for YearMonth {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
value.parse().map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl utoipa::PartialSchema for YearMonth {
|
||||
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
|
||||
utoipa::openapi::ObjectBuilder::new()
|
||||
.schema_type(utoipa::openapi::schema::Type::String)
|
||||
.pattern(Some(r"^\d{4}-(0[1-9]|1[0-2])$"))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl utoipa::ToSchema for YearMonth {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_dates_after_the_first() {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 7, 2).unwrap();
|
||||
|
||||
assert_eq!(YearMonth::new(date), Err(InvalidYearMonth));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructs_from_year_and_month() {
|
||||
let year_month = YearMonth::from_year_month(2026, 7).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
year_month.date(),
|
||||
NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()
|
||||
);
|
||||
assert!(YearMonth::from_year_month(2026, 13).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructs_from_date_using_first_day() {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 7, 20).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
YearMonth::from_day1(date).date(),
|
||||
NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_as_year_and_month() {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
|
||||
let year_month = YearMonth::new(date).unwrap();
|
||||
|
||||
assert_eq!(serde_json::to_string(&year_month).unwrap(), r#""2026-07""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_year_and_month_to_the_first() {
|
||||
let year_month: YearMonth =
|
||||
serde_json::from_str(r#""2026-07""#).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
year_month.date(),
|
||||
NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_year_and_month_to_the_first() {
|
||||
let year_month = YearMonth::from_str("2026-7").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
year_month.date(),
|
||||
NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()
|
||||
);
|
||||
assert_eq!(year_month.to_string(), "2026-07");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_other_serialized_formats() {
|
||||
for value in [r#""2026""#, r#""2026-07-01""#, r#""2026-13""#] {
|
||||
assert!(serde_json::from_str::<YearMonth>(value).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculates_net_60_payout_availability() {
|
||||
let august = YearMonth::from_year_month(2026, 8).unwrap();
|
||||
let december = YearMonth::from_year_month(2026, 12).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
net_60_payout_available_at(august),
|
||||
Some(
|
||||
NaiveDate::from_ymd_opt(2026, 10, 30)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
net_60_payout_available_at(december),
|
||||
Some(
|
||||
NaiveDate::from_ymd_opt(2027, 3, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user