use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::parse::Parser as _;
use syn::{ItemFn, LitStr};
struct ScheduledAttrs {
every: Option<String>,
cron: Option<String>,
name: Option<String>,
tz: Option<String>,
coordination: Option<String>,
deferrable: bool,
}
fn parse_scheduled_args(attr: TokenStream) -> syn::Result<ScheduledAttrs> {
let mut result = ScheduledAttrs {
every: None,
cron: None,
name: None,
tz: None,
coordination: None,
deferrable: false,
};
syn::meta::parser(|meta| {
if meta.path.is_ident("every") {
let value: LitStr = meta.value()?.parse()?;
result.every = Some(value.value());
Ok(())
} else if meta.path.is_ident("cron") {
let value: LitStr = meta.value()?.parse()?;
result.cron = Some(value.value());
Ok(())
} else if meta.path.is_ident("name") {
let value: LitStr = meta.value()?.parse()?;
result.name = Some(value.value());
Ok(())
} else if meta.path.is_ident("tz") {
let value: LitStr = meta.value()?.parse()?;
result.tz = Some(value.value());
Ok(())
} else if meta.path.is_ident("coordination") {
let value: LitStr = meta.value()?.parse()?;
result.coordination = Some(value.value());
Ok(())
} else if meta.path.is_ident("deferrable") {
result.deferrable = crate::job::parse_flag(&meta)?;
Ok(())
} else {
Err(meta.error(
"unsupported attribute: expected every, cron, name, tz, coordination, or deferrable",
))
}
})
.parse2(attr)?;
if result.every.is_none() && result.cron.is_none() {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"#[scheduled] requires either `every` or `cron` parameter",
));
}
if result.every.is_some() && result.cron.is_some() {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"#[scheduled] cannot have both `every` and `cron`",
));
}
Ok(result)
}
pub fn scheduled_macro(attr: TokenStream, item: TokenStream) -> TokenStream {
let attrs = match parse_scheduled_args(attr) {
Ok(a) => a,
Err(err) => return err.to_compile_error(),
};
let input_fn: ItemFn = match syn::parse2(item) {
Ok(f) => f,
Err(err) => return err.to_compile_error(),
};
if input_fn.sig.asyncness.is_none() {
return syn::Error::new_spanned(
input_fn.sig.fn_token,
"#[scheduled] functions must be async",
)
.to_compile_error();
}
let fn_name = &input_fn.sig.ident;
let task_name = attrs.name.unwrap_or_else(|| fn_name.to_string());
let companion_name = format_ident!("__autumn_task_info_{fn_name}");
let schedule_expr = if let Some(every) = &attrs.every {
let every_str = every.clone();
quote! {
::autumn_web::task::Schedule::FixedDelay(
::autumn_web::task::parse_duration(#every_str)
.expect(concat!("invalid duration in #[scheduled(every = \"", #every_str, "\")]"))
)
}
} else if let Some(cron) = &attrs.cron {
let tz = attrs.tz.as_deref();
let tz_expr = tz.map_or_else(|| quote! { None }, |tz| quote! { Some(#tz.to_string()) });
quote! {
::autumn_web::task::Schedule::Cron {
expression: #cron.to_string(),
timezone: #tz_expr,
}
}
} else {
unreachable!()
};
let task_name_str = task_name;
let mark_deferrable = attrs.deferrable.then(|| {
quote! {
::autumn_web::cost::mark_deferrable(::autumn_web::cost::WorkKind::Task, #task_name_str);
}
});
let coordination_expr = match attrs.coordination.as_deref() {
None | Some("fleet") => quote! { ::autumn_web::task::TaskCoordination::Fleet },
Some("per_replica") => quote! { ::autumn_web::task::TaskCoordination::PerReplica },
Some(other) => {
return syn::Error::new(
proc_macro2::Span::call_site(),
format!(
"invalid #[scheduled(coordination = {other:?})]; expected \"fleet\" or \"per_replica\""
),
)
.to_compile_error();
}
};
let graph_schedule = attrs.every.clone().unwrap_or_else(|| {
let cron = attrs.cron.clone().unwrap_or_default();
match attrs.tz.as_deref() {
Some(tz) => format!("{cron} ({tz})"),
None => cron,
}
});
let graph_descriptor = crate::graph::emit_job_descriptor(
&input_fn,
"e! { #task_name_str },
"Scheduled",
"e! { #graph_schedule },
);
quote! {
#input_fn
#graph_descriptor
#[doc(hidden)]
pub fn #companion_name() -> ::autumn_web::task::TaskInfo {
#mark_deferrable
::autumn_web::task::TaskInfo {
name: #task_name_str.to_string(),
schedule: #schedule_expr,
coordination: #coordination_expr,
handler: |state: ::autumn_web::AppState| {
Box::pin(async move {
#fn_name(state).await
})
},
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use quote::quote;
#[test]
fn parses_deferrable_flag() {
let attrs = parse_scheduled_args(quote! { every = "1m" }).expect("parse");
assert!(!attrs.deferrable);
let attrs = parse_scheduled_args(quote! { every = "1m", deferrable }).expect("parse");
assert!(attrs.deferrable);
let attrs = parse_scheduled_args(quote! { cron = "0 * * * * *", deferrable = false })
.expect("parse");
assert!(!attrs.deferrable);
}
#[test]
fn deferrable_task_marks_itself_in_the_info_fn() {
let out = scheduled_macro(
quote! { every = "1m", deferrable },
quote! { async fn sweep(state: AppState) -> AutumnResult<()> { Ok(()) } },
)
.to_string();
assert!(out.contains("mark_deferrable"), "{out}");
assert!(out.contains("WorkKind :: Task"), "{out}");
}
fn graph_schedule_of(attr: TokenStream) -> String {
scheduled_macro(
attr,
quote! { async fn sweep(state: AppState) -> AutumnResult<()> { Ok(()) } },
)
.to_string()
}
#[test]
fn graph_schedule_names_the_cron_timezone() {
let out = graph_schedule_of(quote! { cron = "0 3 * * *", tz = "America/New_York" });
assert!(
out.contains(r#"schedule : "0 3 * * * (America/New_York)""#),
"{out}"
);
}
#[test]
fn graph_schedule_without_a_timezone_is_the_bare_cron() {
let out = graph_schedule_of(quote! { cron = "0 3 * * *" });
assert!(out.contains(r#"schedule : "0 3 * * *""#), "{out}");
}
#[test]
fn graph_schedule_ignores_tz_for_a_fixed_delay() {
let out = graph_schedule_of(quote! { every = "15m", tz = "UTC" });
assert!(out.contains(r#"schedule : "15m""#), "{out}");
}
}