use std::sync::Arc;
use async_trait::async_trait;
use cloudillo_core::scheduler::{Task, TaskId};
use cloudillo_types::meta_adapter::{
ListActionOptions, ListFileOptions, ListProfileOptions, ListTenantsMetaOptions,
};
use serde::{Deserialize, Serialize};
use crate::{indexer, objects, prelude::*};
const PAGE: u32 = 200;
const MAX_PAGES: u32 = 5000;
const INDEX_REV_KEY: &str = "search.index_rev";
#[derive(Debug, Default, Clone, Copy)]
pub struct SweepStats {
pub files: u64,
pub documents: u64,
pub profiles: u64,
pub actions: u64,
pub failed: u64,
}
impl SweepStats {
fn add(&mut self, other: Self) {
self.files += other.files;
self.documents += other.documents;
self.profiles += other.profiles;
self.actions += other.actions;
self.failed += other.failed;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "scope", rename_all = "camelCase")]
pub enum ReindexScope {
All,
Startup,
Tenant { tn_id: TnId },
ContentType { tn_id: TnId, content_type: Box<str> },
}
pub async fn schedule_content_type(app: &App, tn_id: TnId, content_type: &str) -> ClResult<()> {
let key = format!("search.reindex:{}:ct:{}", tn_id.0, content_type);
let task = ReindexTask {
scope: ReindexScope::ContentType { tn_id, content_type: content_type.into() },
};
app.scheduler
.task(Arc::new(task))
.key(key)
.with_retry(cloudillo_core::scheduler::RetryPolicy::default())
.after(5)
.await
.inspect_err(|e| {
warn!(tn_id = %tn_id, %content_type, error = %e,
"Failed to schedule search reindex");
})?;
Ok(())
}
pub async fn reindex_tenant(app: &App, tn_id: TnId) -> ClResult<SweepStats> {
info!(tn_id = %tn_id, index_rev = crate::INDEX_REV, "Search reindex starting");
let started = std::time::Instant::now();
let mut stats = SweepStats::default();
let mut failure: Option<Error> = None;
let mut record =
|label: &str, result: ClResult<SweepStats>, stats: &mut SweepStats| match result {
Ok(step) => stats.add(step),
Err(e) => {
warn!(tn_id = %tn_id, step = label, error = %e, "Search reindex step failed");
failure.get_or_insert(e);
}
};
record("files", reindex_files(app, tn_id).await, &mut stats);
record("profiles", reindex_profiles(app, tn_id).await, &mut stats);
record("actions", reindex_actions(app, tn_id).await, &mut stats);
record(
"reap",
app.meta_adapter
.reap_search_orphans(tn_id)
.await
.map(|()| SweepStats::default()),
&mut stats,
);
info!(
tn_id = %tn_id,
files = stats.files,
documents = stats.documents,
profiles = stats.profiles,
actions = stats.actions,
failed = stats.failed,
elapsed_ms = started.elapsed().as_millis(),
"Search reindex finished"
);
if let Some(e) = failure {
return Err(e);
}
app.meta_adapter
.write_tenant_data(tn_id, INDEX_REV_KEY, Some(&index_stamp(app, tn_id).await))
.await?;
Ok(stats)
}
async fn index_stamp(app: &App, tn_id: TnId) -> String {
let store_text = crate::store_text(app, tn_id).await;
format!("{}:{}:{}", crate::INDEX_REV, u8::from(store_text), app.bundled_apps.rules_hash)
}
async fn reindex_tenant_if_stale(app: &App, tn_id: TnId) -> ClResult<SweepStats> {
let stored = app.meta_adapter.read_tenant_data(tn_id, INDEX_REV_KEY).await?;
let stamp = index_stamp(app, tn_id).await;
if stored.as_deref() == Some(stamp.as_str()) {
app.meta_adapter.reap_search_orphans(tn_id).await?;
return Ok(SweepStats::default());
}
info!(tn_id = %tn_id, index_stamp = %stamp, stored = ?stored,
"Search index revision changed; rebuilding");
reindex_tenant(app, tn_id).await
}
async fn reindex_files(app: &App, tn_id: TnId) -> ClResult<SweepStats> {
page_files(app, tn_id, None).await
}
async fn reindex_documents(app: &App, tn_id: TnId, content_type: &str) -> ClResult<SweepStats> {
page_files(app, tn_id, Some(content_type)).await
}
async fn page_files(
app: &App,
tn_id: TnId,
only_content_type: Option<&str>,
) -> ClResult<SweepStats> {
let whole_rows = only_content_type.is_none();
let mut stats = SweepStats::default();
let mut cursor: Option<String> = None;
let mut hit_cap = true;
for _ in 0..MAX_PAGES {
let opts = ListFileOptions {
limit: Some(PAGE),
cursor: cursor.clone(),
file_type: only_content_type
.map(|_| vec![indexer::STORE_RTDB.to_owned(), indexer::STORE_CRDT.to_owned()]),
content_type: only_content_type.map(|ct| vec![ct.to_owned()]),
include_tree_children: true,
sweep_all: true,
..Default::default()
};
let files = app.meta_adapter.list_files(tn_id, &opts).await?;
if files.is_empty() {
hit_cap = false;
break;
}
for file in &files {
let deep =
matches!(file.file_tp.as_deref(), Some(indexer::STORE_RTDB | indexer::STORE_CRDT));
if let Err(e) = index_one_file(app, tn_id, file, whole_rows).await {
warn!(tn_id = %tn_id, file_id = %file.file_id, error = %e,
"Search reindex: file failed");
stats.failed += 1;
continue;
}
stats.files += u64::from(whole_rows);
stats.documents += u64::from(deep);
}
if files.len() < PAGE as usize {
hit_cap = false;
break;
}
let Some(last) = files.last() else {
hit_cap = false;
break;
};
cursor = Some(
cloudillo_types::types::CursorData::new(
"created",
last.created_at.0.into(),
&last.file_id,
)
.encode(),
);
}
if hit_cap {
warn!(tn_id = %tn_id, "Search reindex: file sweep hit the page cap");
}
Ok(stats)
}
async fn index_one_file(
app: &App,
tn_id: TnId,
file: &cloudillo_types::meta_adapter::FileView,
whole_row: bool,
) -> ClResult<()> {
if whole_row {
objects::index_file_row(app, tn_id, file).await?;
}
if matches!(file.file_tp.as_deref(), Some(indexer::STORE_RTDB | indexer::STORE_CRDT)) {
indexer::index_document(app, tn_id, &file.file_id).await?;
}
Ok(())
}
async fn reindex_profiles(app: &App, tn_id: TnId) -> ClResult<SweepStats> {
let mut stats = SweepStats::default();
let mut after: Option<String> = None;
let mut hit_cap = true;
for _ in 0..MAX_PAGES {
let opts = ListProfileOptions {
limit: Some(PAGE),
after_id_tag: after.clone(),
..Default::default()
};
let profiles = app.meta_adapter.list_profiles(tn_id, &opts).await?;
let Some(last) = profiles.last() else {
hit_cap = false;
break;
};
after = Some(last.id_tag.to_string());
for profile in &profiles {
if let Err(e) = objects::index_profile_row(app, tn_id, profile).await {
warn!(tn_id = %tn_id, id_tag = %profile.id_tag, error = %e,
"Search reindex: profile failed");
stats.failed += 1;
} else {
stats.profiles += 1;
}
}
if profiles.len() < PAGE as usize {
hit_cap = false;
break;
}
}
if hit_cap {
warn!(tn_id = %tn_id, "Search reindex: profile sweep hit the page cap");
}
Ok(stats)
}
const ALL_ACTION_STATUSES: [&str; 6] = ["A", "P", "R", "D", "V", "F"];
async fn reindex_actions(app: &App, tn_id: TnId) -> ClResult<SweepStats> {
let mut stats = SweepStats::default();
let mut cursor: Option<String> = None;
let mut hit_cap = true;
for _ in 0..MAX_PAGES {
let opts = ListActionOptions {
limit: Some(PAGE),
cursor: cursor.clone(),
sort: Some("created".to_owned()),
status: Some(ALL_ACTION_STATUSES.iter().map(|s| (*s).to_owned()).collect()),
..Default::default()
};
let actions = app.meta_adapter.list_actions(tn_id, &opts).await?;
let Some(last) = actions.last() else {
hit_cap = false;
break;
};
cursor = Some(
cloudillo_types::types::CursorData::new(
"created",
last.created_at.0.into(),
&last.action_id,
)
.encode(),
);
for action in &actions {
if let Err(e) = objects::index_action_row(app, tn_id, action).await {
warn!(tn_id = %tn_id, action_id = %action.action_id, error = %e,
"Search reindex: action failed");
stats.failed += 1;
} else {
stats.actions += 1;
}
}
if actions.len() < PAGE as usize {
hit_cap = false;
break;
}
}
if hit_cap {
warn!(tn_id = %tn_id, "Search reindex: action sweep hit the page cap");
}
Ok(stats)
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ReindexTask {
#[serde(flatten)]
pub scope: ReindexScope,
}
async fn notify_reindex(app: &App, tn_id: TnId, data: serde_json::Value) {
let msg =
cloudillo_core::ws_broadcast::BroadcastMessage::new("SEARCH_REINDEX_DONE", data, "system");
let delivered = app.broadcast.send_to_tenant(tn_id, msg).await;
debug!(tn_id = %tn_id, delivered, "Search reindex outcome broadcast");
}
impl ReindexTask {
async fn notify_failure(&self, app: &App, will_retry: bool, error: &str) {
let ReindexScope::Tenant { tn_id } = self.scope else { return };
notify_reindex(
app,
tn_id,
serde_json::json!({ "ok": false, "willRetry": will_retry, "error": error }),
)
.await;
}
}
#[async_trait]
impl Task<App> for ReindexTask {
fn kind() -> &'static str {
"search.reindex"
}
fn kind_of(&self) -> &'static str {
Self::kind()
}
fn build(_id: TaskId, ctx: &str) -> ClResult<Arc<dyn Task<App>>> {
Ok(Arc::new(serde_json::from_str::<Self>(ctx)?))
}
fn serialize(&self) -> String {
let mut obj = serde_json::Map::with_capacity(3);
match &self.scope {
ReindexScope::All => {
obj.insert("scope".into(), "all".into());
}
ReindexScope::Startup => {
obj.insert("scope".into(), "startup".into());
}
ReindexScope::Tenant { tn_id } => {
obj.insert("scope".into(), "tenant".into());
obj.insert("tn_id".into(), tn_id.0.into());
}
ReindexScope::ContentType { tn_id, content_type } => {
obj.insert("scope".into(), "contentType".into());
obj.insert("tn_id".into(), tn_id.0.into());
obj.insert("content_type".into(), content_type.as_ref().into());
}
}
serde_json::Value::Object(obj).to_string()
}
async fn run(&self, app: &App) -> ClResult<()> {
match &self.scope {
ReindexScope::All => every_tenant(app, false).await,
ReindexScope::Startup => every_tenant(app, true).await,
ReindexScope::Tenant { tn_id } => {
let started = std::time::Instant::now();
let stats = reindex_tenant(app, *tn_id).await?;
notify_reindex(
app,
*tn_id,
serde_json::json!({
"ok": true,
"files": stats.files,
"documents": stats.documents,
"profiles": stats.profiles,
"actions": stats.actions,
"failed": stats.failed,
"indexRev": crate::INDEX_REV,
"elapsedMs": u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
}),
)
.await;
Ok(())
}
ReindexScope::ContentType { tn_id, content_type } => {
let stats = reindex_documents(app, *tn_id, content_type).await?;
info!(tn_id = %tn_id, %content_type, documents = stats.documents,
"Search reindex finished for one content type");
Ok(())
}
}
}
async fn on_attempt_failed(&self, app: &App, attempt: u16, error: &str) {
if attempt == 0 {
self.notify_failure(app, true, error).await;
}
}
async fn on_failed(&self, app: &App, attempts: u16, error: &str) {
if attempts == 0 {
self.notify_failure(app, false, error).await;
}
}
}
async fn every_tenant(app: &App, only_if_stale: bool) -> ClResult<()> {
let tenants = app.meta_adapter.list_tenants(&ListTenantsMetaOptions::default()).await?;
let started = std::time::Instant::now();
let mut total = SweepStats::default();
let mut failed = 0usize;
for tenant in &tenants {
let result = if only_if_stale {
reindex_tenant_if_stale(app, tenant.tn_id).await
} else {
reindex_tenant(app, tenant.tn_id).await
};
match result {
Ok(stats) => total.add(stats),
Err(e) => {
warn!(tn_id = %tenant.tn_id, error = %e, "Search reindex: tenant failed");
failed += 1;
}
}
}
let did_work =
total.files > 0 || total.documents > 0 || total.profiles > 0 || total.actions > 0;
if did_work && let Err(e) = app.meta_adapter.optimize_search_index(true).await {
warn!(error = %e, "Search reindex: FTS optimize failed");
}
info!(
tenants = tenants.len(),
tenants_failed = failed,
files = total.files,
documents = total.documents,
profiles = total.profiles,
actions = total.actions,
objects_failed = total.failed,
elapsed_ms = started.elapsed().as_millis(),
startup_gated = only_if_stale,
optimized = did_work,
"Search reindex sweep finished"
);
if failed > 0 {
return Err(Error::Internal(format!(
"search reindex failed for {failed} of {} tenants",
tenants.len()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_scope_serializes_exactly_as_the_derive_would() {
let scopes = [
ReindexScope::All,
ReindexScope::Startup,
ReindexScope::Tenant { tn_id: TnId(7) },
ReindexScope::ContentType { tn_id: TnId(7), content_type: "cloudillo/notillo".into() },
];
for scope in scopes {
let task = ReindexTask { scope };
let derived = serde_json::to_string(&task).expect("derive serializes");
let ours = <ReindexTask as Task<App>>::serialize(&task);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&ours).expect("ours parses"),
serde_json::from_str::<serde_json::Value>(&derived).expect("derived parses"),
"hand-built form {ours} drifted from the derive's {derived}"
);
}
}
#[test]
fn a_persisted_task_rebuilds_with_the_scope_it_was_created_with() {
let task = ReindexTask { scope: ReindexScope::Tenant { tn_id: TnId(42) } };
let stored = <ReindexTask as Task<App>>::serialize(&task);
let back: ReindexTask = serde_json::from_str(&stored).expect("round-trips");
assert!(
matches!(back.scope, ReindexScope::Tenant { tn_id } if tn_id == TnId(42)),
"got {:?}",
back.scope
);
}
}