use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use autumn_web::job::{JobHandler, JobInfo, JobUniqueness, JobUniquenessWindow};
use autumn_web::{AppState, AutumnError, AutumnResult};
use serde::{Deserialize, Serialize};
use crate::client::{BackfillOptions, SearchClient};
pub const REINDEX_JOB: &str = "autumn_search_reindex";
pub const BACKFILL_JOB: &str = "autumn_search_backfill";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReindexOp {
Upsert,
Delete,
}
pub const REINDEX_SCOPE_FIELD: &str = "scope";
#[must_use]
pub fn reindex_concurrency() -> autumn_web::job::JobConcurrency {
autumn_web::job::JobConcurrency {
limit: 1,
key: Some(REINDEX_SCOPE_FIELD.to_owned()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReindexArgs {
pub index: String,
pub id: i64,
pub op: ReindexOp,
#[serde(default)]
pub scope: String,
}
impl ReindexArgs {
#[must_use]
pub fn upsert(index: impl Into<String>, id: i64) -> Self {
Self::new(index, id, ReindexOp::Upsert)
}
#[must_use]
pub fn delete(index: impl Into<String>, id: i64) -> Self {
Self::new(index, id, ReindexOp::Delete)
}
fn new(index: impl Into<String>, id: i64, op: ReindexOp) -> Self {
let index = index.into();
Self {
scope: format!("{index}:{id}"),
index,
id,
op,
}
}
#[must_use]
pub fn unique_key(&self) -> String {
self.scope.clone()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct BackfillArgs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub batch_size: Option<usize>,
#[serde(default)]
pub purge: bool,
}
impl BackfillArgs {
#[must_use]
pub fn for_index(index: impl Into<String>) -> Self {
Self {
index: Some(index.into()),
..Self::default()
}
}
#[must_use]
pub fn options(&self, default_batch_size: usize) -> BackfillOptions {
BackfillOptions::default()
.batch_size(self.batch_size.unwrap_or(default_batch_size))
.purge(self.purge)
}
}
type JobFuture = Pin<Box<dyn Future<Output = AutumnResult<()>> + Send + 'static>>;
pub fn client_from_state(state: &AppState) -> AutumnResult<Arc<SearchClient>> {
state.extension::<SearchClient>().ok_or_else(|| {
AutumnError::internal_server_error_msg(
"the SearchClient extension is not installed; add \
`.plugin(SearchPlugin::new()…)` to the app builder",
)
})
}
fn reindex_handler(state: AppState, args: serde_json::Value) -> JobFuture {
Box::pin(async move {
let args: ReindexArgs = serde_json::from_value(args).map_err(|e| {
AutumnError::internal_server_error_msg(format!("invalid reindex payload: {e}"))
})?;
let client = client_from_state(&state)?;
client.reindex(&args).await?;
Ok(())
})
}
fn backfill_handler(state: AppState, args: serde_json::Value) -> JobFuture {
Box::pin(async move {
let args: BackfillArgs = serde_json::from_value(args).map_err(|e| {
AutumnError::internal_server_error_msg(format!("invalid backfill payload: {e}"))
})?;
let client = client_from_state(&state)?;
let options = args.options(client.default_batch_size());
match &args.index {
Some(index) => {
client.backfill(index, &options).await?;
}
None => {
client.backfill_all(&options).await?;
}
}
Ok(())
})
}
#[must_use]
pub fn search_job_infos(queue: &str) -> Vec<JobInfo> {
let reindex: JobHandler = reindex_handler;
let backfill: JobHandler = backfill_handler;
let mut reindex = JobInfo::new(REINDEX_JOB, 5, 250, reindex);
reindex.uniqueness = Some(JobUniqueness {
by: vec!["index".to_owned(), "id".to_owned()],
window: JobUniquenessWindow::Pending,
});
reindex.concurrency = Some(reindex_concurrency());
let mut backfill = JobInfo::new(BACKFILL_JOB, 3, 30_000, backfill);
backfill.uniqueness = Some(JobUniqueness {
by: vec!["index".to_owned()],
window: JobUniquenessWindow::Running,
});
[reindex, backfill]
.into_iter()
.map(|mut info| {
queue.clone_into(&mut info.queue);
info
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_jobs_are_registered_on_the_requested_queue() {
let infos = search_job_infos("indexing");
let names: Vec<&str> = infos.iter().map(|i| i.name.as_str()).collect();
assert_eq!(names, vec![REINDEX_JOB, BACKFILL_JOB]);
assert!(infos.iter().all(|i| i.queue == "indexing"));
}
#[test]
fn the_backfill_backs_off_much_harder_than_a_reindex() {
let infos = search_job_infos("search");
let reindex = infos
.iter()
.find(|i| i.name == REINDEX_JOB)
.expect("reindex");
let backfill = infos
.iter()
.find(|i| i.name == BACKFILL_JOB)
.expect("backfill");
assert!(backfill.initial_backoff_ms > reindex.initial_backoff_ms);
}
#[test]
fn reindex_is_capped_at_one_in_flight_job_per_record() {
let reindex = search_job_infos("search")
.into_iter()
.find(|i| i.name == REINDEX_JOB)
.expect("reindex");
let concurrency = reindex.concurrency.expect("a per-record cap");
assert_eq!(concurrency.limit, 1);
assert_eq!(concurrency.key.as_deref(), Some(REINDEX_SCOPE_FIELD));
}
#[test]
fn the_concurrency_scope_is_a_real_payload_field_and_is_per_record() {
let args = ReindexArgs::upsert("articles", 7);
let json = serde_json::to_value(&args).expect("serialize");
assert_eq!(
json.get(REINDEX_SCOPE_FIELD).and_then(|v| v.as_str()),
Some("articles:7"),
"the declared concurrency key must name a field that is actually there: {json}"
);
assert_ne!(
ReindexArgs::upsert("articles", 7).scope,
ReindexArgs::upsert("notes", 7).scope
);
assert_ne!(
ReindexArgs::upsert("articles", 7).scope,
ReindexArgs::upsert("articles", 8).scope
);
assert_eq!(
ReindexArgs::upsert("articles", 7).scope,
ReindexArgs::delete("articles", 7).scope
);
}
#[test]
fn a_payload_without_a_scope_still_deserializes() {
let args: ReindexArgs =
serde_json::from_str(r#"{"index":"articles","id":7,"op":"upsert"}"#).expect("legacy");
assert_eq!(args.index, "articles");
assert_eq!(args.id, 7);
assert_eq!(args.op, ReindexOp::Upsert);
assert_eq!(args.scope, "");
}
#[test]
fn reindex_args_round_trip() {
for args in [
ReindexArgs::upsert("articles", 1),
ReindexArgs::delete("articles", 2),
] {
let json = serde_json::to_value(&args).expect("serialize");
let back: ReindexArgs = serde_json::from_value(json).expect("deserialize");
assert_eq!(back, args);
}
}
#[test]
fn the_reindex_op_wire_format_is_snake_case() {
let json = serde_json::to_value(ReindexArgs::delete("articles", 1)).expect("serialize");
assert_eq!(json["op"], serde_json::json!("delete"));
assert_eq!(json["index"], serde_json::json!("articles"));
assert_eq!(json["id"], serde_json::json!(1));
}
#[test]
fn the_dedup_key_collapses_repeated_writes_to_one_record() {
assert_eq!(
ReindexArgs::upsert("articles", 7).unique_key(),
ReindexArgs::delete("articles", 7).unique_key()
);
assert_ne!(
ReindexArgs::upsert("articles", 7).unique_key(),
ReindexArgs::upsert("articles", 8).unique_key()
);
assert_ne!(
ReindexArgs::upsert("articles", 7).unique_key(),
ReindexArgs::upsert("notes", 7).unique_key()
);
}
#[test]
fn the_reindex_job_actually_declares_that_dedup_to_the_queue() {
let infos = search_job_infos("search");
let reindex = infos
.iter()
.find(|i| i.name == REINDEX_JOB)
.expect("reindex");
let uniqueness = reindex.uniqueness.as_ref().expect("reindex must dedup");
assert_eq!(uniqueness.by, vec!["index".to_owned(), "id".to_owned()]);
assert!(!uniqueness.by.contains(&"op".to_owned()));
assert_eq!(uniqueness.window, JobUniquenessWindow::Pending);
}
#[test]
fn concurrent_full_rebuilds_of_one_index_are_deduped_while_running() {
let infos = search_job_infos("search");
let backfill = infos
.iter()
.find(|i| i.name == BACKFILL_JOB)
.expect("backfill");
let uniqueness = backfill.uniqueness.as_ref().expect("backfill must dedup");
assert_eq!(uniqueness.by, vec!["index".to_owned()]);
assert_eq!(uniqueness.window, JobUniquenessWindow::Running);
}
#[test]
fn backfill_args_default_to_every_index() {
let args = BackfillArgs::default();
assert!(args.index.is_none());
assert!(!args.purge);
assert_eq!(args.options(500).batch_size, 500);
assert_eq!(args.options(500).effective_batch_size(), 500);
}
#[test]
fn backfill_args_round_trip_and_omit_absent_fields() {
let args = BackfillArgs::for_index("articles");
let json = serde_json::to_value(&args).expect("serialize");
assert_eq!(json["index"], serde_json::json!("articles"));
assert!(json.get("batch_size").is_none());
let back: BackfillArgs = serde_json::from_value(json).expect("deserialize");
assert_eq!(back, args);
}
#[test]
fn an_explicit_batch_size_overrides_the_configured_default() {
let args = BackfillArgs {
batch_size: Some(10),
..BackfillArgs::default()
};
assert_eq!(args.options(500).batch_size, 10);
}
}