use std::sync::{Arc, Mutex};
use std::time::Duration;
use super::client::{LicenceClient, LicenceClientError};
use super::relay::{GrantFlood, install_grant};
use super::{CheckoutGrant, CheckoutGrantStore, Outcome, SignedGrant};
use crate::chain::ChainManager;
use crate::mesh_artifact::ArtifactExchange;
pub const EVENT_KIND_CHECKOUT_RENEWED: &str = "cog.checkout.renewed";
pub const EVENT_KIND_CHECKOUT_LAPSED: &str = "cog.checkout.lapsed";
#[derive(Debug, Clone)]
pub struct RenewalConfig {
pub period: Duration,
pub jitter: Duration,
pub first_delay: Duration,
pub backoff_min: Duration,
pub backoff_max: Duration,
pub max_pages: u32,
pub skip_poll: Duration,
}
impl Default for RenewalConfig {
fn default() -> Self {
Self {
period: Duration::from_secs(12 * 3600),
jitter: Duration::from_secs(30 * 60),
first_delay: Duration::from_secs(5 * 60),
backoff_min: Duration::from_secs(60),
backoff_max: Duration::from_secs(3600),
max_pages: 16,
skip_poll: Duration::from_secs(5 * 60),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RenewalReport {
pub skipped: bool,
pub renewed: u32,
pub withdrawn: u32,
pub unchanged: u32,
pub refused: u32,
}
pub struct Renewer {
store: Arc<CheckoutGrantStore>,
exchange: Arc<ArtifactExchange>,
client: Arc<dyn LicenceClient>,
flood: Arc<dyn GrantFlood>,
chain: Option<Arc<ChainManager>>,
cfg: RenewalConfig,
cursor: Mutex<u64>,
cursor_for: Mutex<Option<(u64, String)>>,
}
fn skip(e: &LicenceClientError) -> bool {
matches!(e, LicenceClientError::Refused { status: 0, code } if code == "not_steward" || code == "seed_not_bound" || code == "not_holder")
}
impl Renewer {
pub fn new(
store: Arc<CheckoutGrantStore>,
exchange: Arc<ArtifactExchange>,
client: Arc<dyn LicenceClient>,
flood: Arc<dyn GrantFlood>,
chain: Option<Arc<ChainManager>>,
cfg: RenewalConfig,
) -> Arc<Self> {
Arc::new(Self { store, exchange, client, flood, chain, cfg, cursor: Mutex::new(0), cursor_for: Mutex::new(None) })
}
pub fn cursor(&self) -> u64 {
*self.cursor.lock().unwrap_or_else(|p| p.into_inner())
}
fn set_cursor(&self, v: u64) {
*self.cursor.lock().unwrap_or_else(|p| p.into_inner()) = v;
}
fn reset_cursor_on_rebind(&self) {
let now = self.store.held_binding().map(|b| (b.seq, b.device_id));
let mut f = self.cursor_for.lock().unwrap_or_else(|p| p.into_inner());
if *f != now {
*f = now;
self.set_cursor(0);
}
}
pub async fn run_once(&self) -> Result<RenewalReport, LicenceClientError> {
let mut report = RenewalReport::default();
self.reset_cursor_on_rebind();
let renewed = match self.client.renew().await {
Ok(g) => g,
Err(e) if skip(&e) => return Ok(RenewalReport { skipped: true, ..report }),
Err(e) => return Err(e),
};
let seed_ctr = renewed.next;
self.apply_all(renewed.grants, &mut report).await;
for _ in 0..self.cfg.max_pages {
let since = self.cursor();
let page = self.client.grants_page(since).await?;
let bound = seed_ctr.max(since).saturating_add(page.grants.len() as u64);
if page.next > bound {
return Err(LicenceClientError::BadResponse(format!(
"grants page moves the cursor to {} past the bound {bound}",
page.next
)));
}
self.apply_all(page.grants, &mut report).await;
self.set_cursor(page.next.max(since));
if !page.more || page.next <= since {
break;
}
}
Ok(report)
}
pub async fn release(&self, cog_id: &str, version: &str) -> Result<RenewalReport, LicenceClientError> {
let mut report = RenewalReport::default();
let page = match self.client.release(cog_id, version).await {
Ok(p) => p,
Err(e) if skip(&e) => return Ok(RenewalReport { skipped: true, ..report }),
Err(e) => return Err(e),
};
self.apply_all(page.grants, &mut report).await;
Ok(report)
}
async fn apply_all(&self, grants: Vec<SignedGrant>, report: &mut RenewalReport) {
let parsed: Vec<(SignedGrant, Option<CheckoutGrant>)> =
grants.into_iter().map(|s| { let g = serde_json::from_str(&s.payload).ok(); (s, g) }).collect();
let (mut first, rest): (Vec<_>, Vec<_>) =
parsed.into_iter().partition(|(_, g)| g.as_ref().is_some_and(CheckoutGrant::is_withdrawal));
first.extend(rest);
for (s, g) in first {
self.apply(&s, g.as_ref(), report).await;
}
}
async fn apply(&self, signed: &SignedGrant, g: Option<&CheckoutGrant>, report: &mut RenewalReport) {
match install_grant(&self.store, &self.exchange, signed) {
Ok(Outcome::Applied | Outcome::AppliedUnsaved) => {
let withdrawn = g.is_some_and(CheckoutGrant::is_withdrawal);
self.flood.flood(signed).await;
if withdrawn {
report.withdrawn += 1;
} else {
report.renewed += 1;
}
if let (Some(cm), Some(g)) = (&self.chain, g) {
let kind = if withdrawn { EVENT_KIND_CHECKOUT_LAPSED } else { EVENT_KIND_CHECKOUT_RENEWED };
cm.append("licence", kind, Some(serde_json::json!({
"cog_id": g.cog_id, "version": g.version, "seq": g.seq, "grant_id": g.grant_id,
"expires_at": g.expires_at, "reason": if withdrawn { "withdrawn by the Seed" } else { "renewed" },
})));
}
}
Ok(_) => report.unchanged += 1,
Err(e) => {
report.refused += 1;
tracing::warn!(error = %e, "renewed grant refused by the store");
}
}
}
pub fn next_delay(&self, failures: u32) -> Duration {
if failures == 0 {
let j = self.cfg.jitter.as_millis() as u64;
let extra = if j == 0 { 0 } else { rand::random::<u64>() % j };
return self.cfg.period + Duration::from_millis(extra);
}
let exp = self.cfg.backoff_min.saturating_mul(1u32 << (failures - 1).min(16));
exp.min(self.cfg.backoff_max)
}
pub fn spawn(self: Arc<Self>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
tokio::time::sleep(self.cfg.first_delay).await;
let mut failures = 0u32;
loop {
let delay = match self.run_once().await {
Ok(r) if r.skipped => {
failures = 0;
self.cfg.skip_poll
}
Ok(r) => {
failures = 0;
tracing::info!(renewed = r.renewed, withdrawn = r.withdrawn, unchanged = r.unchanged,
refused = r.refused, "checkout renewal pass");
self.next_delay(0)
}
Err(e) => {
failures = failures.saturating_add(1);
tracing::warn!(error = %e, failures, "checkout renewal failed; backing off");
self.next_delay(failures)
}
};
tokio::time::sleep(delay).await;
}
})
}
}