use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use tokio::sync::{mpsc, oneshot};
use super::Capabilities;
use super::catalog::{
Admission, CatalogContentId, CatalogError, CatalogRefresh, CatalogReport, CatalogSource,
RefusalReason, SourceValidators,
};
use super::catalog_refresh::{
CatalogRefresher, InvalidSchedule, RefreshOutcome, RefreshTrigger, Restored,
};
use super::catalog_store::postgres::PostgresCatalogStore;
use super::catalog_store::{
CatalogStore, CatalogStoreError, InMemoryCatalogStore, RetainedCatalog, Retention,
StoredCatalogState,
};
use super::models_dev::{HttpCatalogFetch, ModelsDevAdapter, ModelsDevSource, SeedCatalogSource};
use crate::config::{CatalogConfig, CatalogSourceBackend, CatalogStoreBackend};
#[derive(Debug, Default)]
pub struct CatalogStatus {
report: Mutex<Option<CatalogReport>>,
}
impl CatalogStatus {
pub fn new() -> Self {
Self::default()
}
pub fn report(&self) -> Option<CatalogReport> {
let mut report = (*self.report.lock().expect("catalogue status lock"))?;
let now = SystemTime::now();
if let Some(active) = report.active.as_mut() {
active.age = now
.duration_since(active.fetched_at)
.unwrap_or(Duration::ZERO);
}
Some(report)
}
fn publish(&self, report: CatalogReport) {
*self.report.lock().expect("catalogue status lock") = Some(report);
}
}
#[derive(Debug)]
pub enum RuntimeSource {
ModelsDev(ModelsDevSource<HttpCatalogFetch>),
Seed(SeedCatalogSource),
}
#[async_trait]
impl CatalogSource for RuntimeSource {
fn name(&self) -> &'static str {
match self {
Self::ModelsDev(source) => source.name(),
Self::Seed(source) => source.name(),
}
}
fn capabilities(&self) -> Capabilities {
match self {
Self::ModelsDev(source) => source.capabilities(),
Self::Seed(source) => source.capabilities(),
}
}
async fn refresh(
&self,
since: Option<&SourceValidators>,
) -> Result<CatalogRefresh, CatalogError> {
match self {
Self::ModelsDev(source) => source.refresh(since).await,
Self::Seed(source) => source.refresh(since).await,
}
}
}
#[derive(Debug)]
pub enum RuntimeStore {
Postgres(Box<PostgresCatalogStore>),
InMemory(InMemoryCatalogStore),
}
#[async_trait]
impl CatalogStore for RuntimeStore {
fn name(&self) -> &'static str {
match self {
Self::Postgres(store) => store.name(),
Self::InMemory(store) => store.name(),
}
}
fn capabilities(&self) -> Capabilities {
match self {
Self::Postgres(store) => store.capabilities(),
Self::InMemory(store) => store.capabilities(),
}
}
async fn load(&self) -> Result<StoredCatalogState, CatalogStoreError> {
match self {
Self::Postgres(store) => store.load().await,
Self::InMemory(store) => store.load().await,
}
}
async fn retained(
&self,
content_id: CatalogContentId,
) -> Result<Option<RetainedCatalog>, CatalogStoreError> {
match self {
Self::Postgres(store) => store.retained(content_id).await,
Self::InMemory(store) => store.retained(content_id).await,
}
}
async fn activate(
&self,
import: &RetainedCatalog,
activated_at: SystemTime,
) -> Result<Retention, CatalogStoreError> {
match self {
Self::Postgres(store) => store.activate(import, activated_at).await,
Self::InMemory(store) => store.activate(import, activated_at).await,
}
}
async fn confirm(
&self,
content_id: CatalogContentId,
validators: &SourceValidators,
confirmed_at: SystemTime,
) -> Result<bool, CatalogStoreError> {
match self {
Self::Postgres(store) => store.confirm(content_id, validators, confirmed_at).await,
Self::InMemory(store) => store.confirm(content_id, validators, confirmed_at).await,
}
}
async fn refuse(
&self,
reason: RefusalReason,
refused_at: SystemTime,
) -> Result<(), CatalogStoreError> {
match self {
Self::Postgres(store) => store.refuse(reason, refused_at).await,
Self::InMemory(store) => store.refuse(reason, refused_at).await,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum CatalogBootError {
#[error("the catalogue refresh schedule is unusable: {0}")]
Schedule(#[from] InvalidSchedule),
#[error("`{0}` is not a supported models.dev document")]
Source(String),
#[error(
"catalogue retention names `{name}`, which holds no connection string: the DSN stays in \
the environment and is never written to the config"
)]
MissingDsn { name: String },
#[error("the catalogue store could not be opened: {0}")]
Store(#[from] CatalogStoreError),
#[error("the HTTP client for catalogue imports could not be built: {0}")]
Client(String),
}
struct ManualRefresh {
answer: oneshot::Sender<RefreshOutcome>,
}
#[derive(Debug, Clone)]
pub struct CatalogHandle {
status: Arc<CatalogStatus>,
refresh: mpsc::Sender<ManualRefresh>,
}
impl std::fmt::Debug for ManualRefresh {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ManualRefresh").finish_non_exhaustive()
}
}
impl CatalogHandle {
pub fn status(&self) -> &Arc<CatalogStatus> {
&self.status
}
pub async fn refresh_now(&self) -> Option<RefreshOutcome> {
let (answer, wait) = oneshot::channel();
self.refresh.send(ManualRefresh { answer }).await.ok()?;
wait.await.ok()
}
}
fn source(config: &CatalogConfig) -> Result<RuntimeSource, CatalogBootError> {
match config.source {
CatalogSourceBackend::None => unreachable!("a disabled catalogue builds no source"),
CatalogSourceBackend::Seed => Ok(RuntimeSource::Seed(SeedCatalogSource)),
CatalogSourceBackend::ModelsDev => {
let adapter = ModelsDevAdapter::new(config.url())
.map_err(|_| CatalogBootError::Source(config.url().to_owned()))?;
let fetch = HttpCatalogFetch::new(Duration::from_secs(config.refresh_timeout_seconds))
.map_err(|error| CatalogBootError::Client(error.to_string()))?
.holding_at_most(config.max_payload_bytes);
Ok(RuntimeSource::ModelsDev(
ModelsDevSource::new(adapter, fetch).with_payload_limit(config.max_payload_bytes),
))
}
}
}
async fn store(
config: &CatalogConfig,
control_plane_dsn_env: Option<&str>,
env: &std::collections::HashMap<String, String>,
) -> Result<RuntimeStore, CatalogBootError> {
match config.store {
CatalogStoreBackend::InMemory => Ok(RuntimeStore::InMemory(InMemoryCatalogStore::new())),
CatalogStoreBackend::Postgres => {
let name = config
.dsn_env
.as_deref()
.or(control_plane_dsn_env)
.map(str::trim)
.filter(|name| !name.is_empty())
.ok_or_else(|| CatalogBootError::MissingDsn {
name: "catalog.dsn_env".to_owned(),
})?;
let dsn = env
.get(name)
.map(String::as_str)
.map(str::trim)
.filter(|dsn| !dsn.is_empty())
.ok_or_else(|| CatalogBootError::MissingDsn {
name: name.to_owned(),
})?;
Ok(RuntimeStore::Postgres(Box::new(
PostgresCatalogStore::connect(dsn, config.store_settings()).await?,
)))
}
}
}
pub async fn start(
config: &CatalogConfig,
control_plane_dsn_env: Option<&str>,
env: &std::collections::HashMap<String, String>,
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
) -> Result<Option<CatalogHandle>, CatalogBootError> {
if !config.enabled() {
return Ok(None);
}
let source = source(config)?;
let store = store(config, control_plane_dsn_env, env).await?;
let source_name = source.name();
let store_name = store.name();
let mut refresher = CatalogRefresher::new(
source,
store,
config.schedule(),
config.bootstrap_mode(),
SystemTime::now(),
)?;
let status = Arc::new(CatalogStatus::new());
match refresher.restore(SystemTime::now()).await {
Ok(Restored::Stored {
content_id,
confirmed_at,
}) => tracing::info!(
source = source_name,
store = store_name,
content = %content_id.short(),
age_s = SystemTime::now()
.duration_since(confirmed_at)
.unwrap_or(Duration::ZERO)
.as_secs(),
"catalogue restored",
),
Ok(Restored::Seeded { content_id }) => tracing::info!(
source = source_name,
store = store_name,
content = %content_id.short(),
"catalogue seeded; the first refresh transfers the upstream document",
),
Ok(Restored::Empty) => tracing::info!(
source = source_name,
store = store_name,
"no catalogue retained yet; the first refresh imports one",
),
Err(error) => tracing::warn!(
source = source_name,
store = store_name,
%error,
"the retained catalogue could not be adopted; the deployment reports it as refused \
and keeps refreshing",
),
}
status.publish(refresher.report(SystemTime::now()));
let (sender, receiver) = mpsc::channel(1);
tokio::spawn(run(refresher, Arc::clone(&status), receiver, shutdown));
Ok(Some(CatalogHandle {
status,
refresh: sender,
}))
}
async fn run(
mut refresher: CatalogRefresher<RuntimeSource, RuntimeStore>,
status: Arc<CatalogStatus>,
mut manual: mpsc::Receiver<ManualRefresh>,
shutdown: impl std::future::Future<Output = ()> + Send,
) {
let mut shutdown = std::pin::pin!(shutdown);
let mut askable = true;
loop {
let now = SystemTime::now();
let delay = refresher
.next_due()
.duration_since(now)
.unwrap_or(Duration::ZERO);
tokio::select! {
biased;
() = &mut shutdown => {
tracing::debug!("catalogue refresh stopped");
return;
}
asked = manual.recv(), if askable => {
let Some(ManualRefresh { answer }) = asked else {
tracing::debug!("no catalogue handle remains; refreshing on schedule only");
askable = false;
continue;
};
let outcome = refresh(&mut refresher, &status, RefreshTrigger::Manual).await;
let _ = answer.send(outcome);
}
() = tokio::time::sleep(delay) => {
refresh(&mut refresher, &status, RefreshTrigger::Scheduled).await;
}
}
}
}
async fn refresh(
refresher: &mut CatalogRefresher<RuntimeSource, RuntimeStore>,
status: &CatalogStatus,
trigger: RefreshTrigger,
) -> RefreshOutcome {
let now = SystemTime::now();
let outcome = refresher.refresh(trigger, now).await;
status.publish(refresher.report(SystemTime::now()));
match &outcome {
RefreshOutcome::Admitted {
admission,
retention,
..
} => tracing::info!(
trigger = trigger_name(trigger),
content = %admission.content_id().short(),
change = admitted_change(admission),
retained = retention.is_some(),
"catalogue import admitted",
),
RefreshOutcome::Refused {
refusal, retry_in, ..
} => tracing::warn!(
trigger = trigger_name(trigger),
reason = refusal.reason().as_str(),
retry_in_s = retry_in.as_secs(),
"catalogue import refused; the active catalogue is unchanged",
),
RefreshOutcome::NotDue { .. } => {}
}
outcome
}
const fn admitted_change(admission: &Admission) -> &'static str {
match admission {
Admission::Unchanged { .. } => "unchanged",
Admission::Updated { .. } => "updated",
Admission::Initial { .. } => "initial",
}
}
const fn trigger_name(trigger: RefreshTrigger) -> &'static str {
match trigger {
RefreshTrigger::Scheduled => "scheduled",
RefreshTrigger::Manual => "manual",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::CatalogBootstrap;
fn offline() -> CatalogConfig {
CatalogConfig {
source: CatalogSourceBackend::Seed,
store: CatalogStoreBackend::InMemory,
bootstrap: CatalogBootstrap::Seed,
..CatalogConfig::default()
}
}
fn no_env() -> std::collections::HashMap<String, String> {
std::collections::HashMap::new()
}
#[tokio::test]
async fn a_disabled_catalogue_starts_nothing() {
let handle = start(
&CatalogConfig::default(),
None,
&no_env(),
std::future::pending(),
)
.await
.expect("an inert configuration cannot fail");
assert!(handle.is_none(), "nothing to report and nothing to stop");
}
#[tokio::test]
async fn boot_publishes_what_it_adopted_before_anything_is_served() {
let handle = start(&offline(), None, &no_env(), std::future::pending())
.await
.expect("an offline catalogue starts")
.expect("an enabled catalogue yields a handle");
let report = handle
.status()
.report()
.expect("boot published its restoration");
assert!(
report.active.is_some(),
"a seeded bootstrap is active immediately"
);
assert_eq!(report.consecutive_refusals, 0);
}
#[tokio::test]
async fn a_manual_refresh_is_not_skipped_for_not_being_due() {
let config = CatalogConfig {
refresh_interval_seconds: 86_400,
..offline()
};
let handle = start(&config, None, &no_env(), std::future::pending())
.await
.expect("an offline catalogue starts")
.expect("an enabled catalogue yields a handle");
let outcome = handle
.refresh_now()
.await
.expect("the refresh task is running");
assert!(
!matches!(outcome, RefreshOutcome::NotDue { .. }),
"a manual refresh must run, said: {outcome:?}"
);
assert!(
handle
.status()
.report()
.is_some_and(|report| report.active.is_some()),
"the refresh published what it left active"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_catalogue_nothing_holds_a_handle_to_keeps_refreshing_on_schedule() {
let config = CatalogConfig {
refresh_interval_seconds: 1,
refresh_timeout_seconds: 1,
retry_initial_seconds: 1,
retry_max_seconds: 1,
..offline()
};
let handle = start(&config, None, &no_env(), std::future::pending())
.await
.expect("an offline catalogue starts")
.expect("an enabled catalogue yields a handle");
let status = Arc::clone(handle.status());
drop(handle);
let confirmed = |report: Option<CatalogReport>| {
report
.and_then(|report| report.active)
.map(|active| active.fetched_at)
};
let before = confirmed(status.report());
let deadline = std::time::Instant::now() + Duration::from_secs(20);
loop {
tokio::time::sleep(Duration::from_millis(50)).await;
if confirmed(status.report()) > before {
break;
}
assert!(
std::time::Instant::now() < deadline,
"the schedule stopped importing once the last handle dropped"
);
}
}
#[tokio::test]
async fn shutdown_stops_the_loop_and_refuses_nothing() {
let (stop, stopped) = oneshot::channel::<()>();
let handle = start(&offline(), None, &no_env(), async move {
let _ = stopped.await;
})
.await
.expect("an offline catalogue starts")
.expect("an enabled catalogue yields a handle");
let before = handle.status().report().expect("boot published a report");
stop.send(()).expect("the task is listening");
while handle.refresh_now().await.is_some() {
tokio::task::yield_now().await;
}
let after = handle.status().report().expect("the report is still there");
assert_eq!(after.consecutive_refusals, before.consecutive_refusals);
assert_eq!(
after.active.map(|active| active.content_id),
before.active.map(|active| active.content_id)
);
}
#[tokio::test]
async fn a_dsn_reference_nothing_resolves_fails_boot_without_the_dsn() {
let config = CatalogConfig {
store: CatalogStoreBackend::Postgres,
dsn_env: Some("AXOND_CATALOG_DSN_ABSENT".to_owned()),
..offline()
};
let error = start(&config, None, &no_env(), std::future::pending())
.await
.expect_err("retention cannot be opened without a connection string");
let message = error.to_string();
assert!(
message.contains("AXOND_CATALOG_DSN_ABSENT"),
"the failure must name the variable, said: {message}"
);
assert!(
!message.contains("postgres://"),
"the failure must never carry a connection string, said: {message}"
);
}
}