Repair incompatible search indexes safely
This commit is contained in:
@@ -32,6 +32,30 @@ struct TableCountRow {
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
#[diesel(check_for_backend(Sqlite))]
|
||||
struct ColumnNameRow {
|
||||
#[diesel(sql_type = Text)]
|
||||
name: String,
|
||||
}
|
||||
|
||||
const CREATE_FTS_TABLES: &str = "CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||
post_id UNINDEXED,
|
||||
title,
|
||||
excerpt,
|
||||
content,
|
||||
tags,
|
||||
categories
|
||||
);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5(
|
||||
media_id UNINDEXED,
|
||||
title,
|
||||
alt,
|
||||
caption,
|
||||
original_name,
|
||||
tags
|
||||
);";
|
||||
|
||||
/// Whether both application FTS5 virtual tables are present.
|
||||
pub fn tables_exist(conn: &Connection) -> QueryResult<bool> {
|
||||
conn.with(|c| {
|
||||
@@ -49,30 +73,64 @@ pub fn tables_exist(conn: &Connection) -> QueryResult<bool> {
|
||||
/// Schema follows specs/schema.allium: multi-column FTS5 with separate fields
|
||||
/// for weighted search. Not content-sync — we manually manage stemmed content.
|
||||
pub fn ensure_fts_tables(conn: &Connection) -> QueryResult<()> {
|
||||
conn.with(|c| c.batch_execute(CREATE_FTS_TABLES))
|
||||
}
|
||||
|
||||
/// Whether the runtime-managed FTS tables have the current deployed schema.
|
||||
pub fn schema_is_current(conn: &Connection) -> QueryResult<bool> {
|
||||
conn.with(|c| {
|
||||
c.batch_execute(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||
post_id UNINDEXED,
|
||||
title,
|
||||
excerpt,
|
||||
content,
|
||||
tags,
|
||||
categories
|
||||
);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5(
|
||||
media_id UNINDEXED,
|
||||
title,
|
||||
alt,
|
||||
caption,
|
||||
original_name,
|
||||
tags
|
||||
);",
|
||||
)
|
||||
let post_columns =
|
||||
diesel::sql_query("SELECT name FROM pragma_table_info('posts_fts') ORDER BY cid")
|
||||
.load::<ColumnNameRow>(c)?
|
||||
.into_iter()
|
||||
.map(|row| row.name)
|
||||
.collect::<Vec<_>>();
|
||||
let media_columns =
|
||||
diesel::sql_query("SELECT name FROM pragma_table_info('media_fts') ORDER BY cid")
|
||||
.load::<ColumnNameRow>(c)?
|
||||
.into_iter()
|
||||
.map(|row| row.name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(post_columns
|
||||
== [
|
||||
"post_id",
|
||||
"title",
|
||||
"excerpt",
|
||||
"content",
|
||||
"tags",
|
||||
"categories",
|
||||
]
|
||||
&& media_columns
|
||||
== [
|
||||
"media_id",
|
||||
"title",
|
||||
"alt",
|
||||
"caption",
|
||||
"original_name",
|
||||
"tags",
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_indexes(conn: &Connection) -> QueryResult<()> {
|
||||
conn.with(|c| c.batch_execute("DELETE FROM posts_fts; DELETE FROM media_fts;"))
|
||||
/// Replace the derived FTS tables without touching user-authored data.
|
||||
pub fn recreate_tables(conn: &Connection) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
c.batch_execute("DROP TABLE IF EXISTS posts_fts; DROP TABLE IF EXISTS media_fts;")?;
|
||||
c.batch_execute(CREATE_FTS_TABLES)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_deployed_schema_for_test(conn: &Connection) -> QueryResult<()> {
|
||||
conn.with(|c| {
|
||||
c.batch_execute(
|
||||
"DROP TABLE posts_fts;
|
||||
DROP TABLE media_fts;
|
||||
CREATE VIRTUAL TABLE posts_fts USING fts5(post_id UNINDEXED, content);
|
||||
CREATE VIRTUAL TABLE media_fts USING fts5(media_id UNINDEXED, content);",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn drop_post_index(conn: &Connection) -> QueryResult<()> {
|
||||
|
||||
@@ -440,9 +440,21 @@ pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) ->
|
||||
post.status = PostStatus::Published;
|
||||
post.checksum = checksum;
|
||||
post.updated_at = now_unix_ms();
|
||||
qp::update_post(conn, &post)?;
|
||||
fts_index_post(conn, &post)?;
|
||||
Ok(post)
|
||||
conn.begin_savepoint()?;
|
||||
match (|| {
|
||||
qp::update_post(conn, &post)?;
|
||||
fts_index_post(conn, &post)?;
|
||||
Ok(post)
|
||||
})() {
|
||||
Ok(post) => {
|
||||
conn.release_savepoint()?;
|
||||
Ok(post)
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = conn.rollback_savepoint();
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new draft post by duplicating an existing post.
|
||||
@@ -1429,6 +1441,93 @@ mod tests {
|
||||
assert!(!discarded.do_not_translate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_works_after_deployed_search_index_schema_is_repaired() {
|
||||
let (db, dir) = setup();
|
||||
let post = create_post(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"Deployed Schema",
|
||||
Some("published body"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
Some("en"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||
update_post(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&post.id,
|
||||
Some("Draft Title"),
|
||||
None,
|
||||
None,
|
||||
Some("draft body"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
crate::db::fts::install_deployed_schema_for_test(db.conn()).unwrap();
|
||||
|
||||
assert!(crate::engine::search::prepare_search_index(db.conn()).unwrap());
|
||||
crate::engine::search::rebuild_search_index(db.conn(), None).unwrap();
|
||||
let discarded = discard_post_draft(db.conn(), dir.path(), &post.id).unwrap();
|
||||
|
||||
assert_eq!(discarded.title, "Deployed Schema");
|
||||
assert_eq!(discarded.status, PostStatus::Published);
|
||||
assert!(!crate::engine::search::search_index_rebuild_required(db.conn()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_rolls_back_when_search_index_update_fails() {
|
||||
let (db, dir) = setup();
|
||||
let post = create_post(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"Published Title",
|
||||
Some("published body"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
Some("en"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||
update_post(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&post.id,
|
||||
Some("Draft Title"),
|
||||
None,
|
||||
None,
|
||||
Some("draft body"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
crate::db::fts::install_deployed_schema_for_test(db.conn()).unwrap();
|
||||
|
||||
assert!(discard_post_draft(db.conn(), dir.path(), &post.id).is_err());
|
||||
let unchanged = qp::get_post_by_id(db.conn(), &post.id).unwrap();
|
||||
assert_eq!(unchanged.title, "Draft Title");
|
||||
assert_eq!(unchanged.status, PostStatus::Draft);
|
||||
assert_eq!(unchanged.content.as_deref(), Some("draft body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_post_creates_new_draft_copy() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
//! Full-text search reindexing engine functions.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::db::DbConnection as Connection;
|
||||
|
||||
use crate::db::fts;
|
||||
use crate::db::queries::{media as media_q, media_translation, post as post_q, post_translation};
|
||||
use crate::db::queries::{
|
||||
media as media_q, media_translation, post as post_q, post_translation, project as project_q,
|
||||
setting,
|
||||
};
|
||||
use crate::engine::EngineResult;
|
||||
use crate::util::now_unix_ms;
|
||||
|
||||
const REBUILD_REQUIRED_SETTING: &str = "app.search-index-rebuild-required";
|
||||
|
||||
/// Result of a full reindex operation.
|
||||
pub struct ReindexReport {
|
||||
@@ -15,104 +23,163 @@ pub struct ReindexReport {
|
||||
/// Per-item progress callback: (current_item, total_items, item_description).
|
||||
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
|
||||
|
||||
/// Drop and rebuild the entire FTS index for all posts and media in a project.
|
||||
pub fn reindex_all(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
main_language: &str,
|
||||
) -> EngineResult<ReindexReport> {
|
||||
reindex_all_with_progress(conn, project_id, main_language, None)
|
||||
/// Repair a missing or previously deployed FTS schema and report whether its
|
||||
/// derived content still needs to be rebuilt.
|
||||
pub fn prepare_search_index(conn: &Connection) -> EngineResult<bool> {
|
||||
if fts::schema_is_current(conn)? {
|
||||
return search_index_rebuild_required(conn);
|
||||
}
|
||||
|
||||
let projects = project_q::list_projects(conn)?;
|
||||
let has_content = projects.iter().try_fold(false, |has_content, project| {
|
||||
Ok::<_, diesel::result::Error>(
|
||||
has_content
|
||||
|| post_q::count_posts_by_project(conn, &project.id)? > 0
|
||||
|| media_q::count_media_by_project(conn, &project.id)? > 0,
|
||||
)
|
||||
})?;
|
||||
|
||||
conn.begin_savepoint()?;
|
||||
let result = (|| {
|
||||
fts::recreate_tables(conn)?;
|
||||
setting::set_setting_value(
|
||||
conn,
|
||||
REBUILD_REQUIRED_SETTING,
|
||||
if has_content { "true" } else { "false" },
|
||||
now_unix_ms(),
|
||||
)?;
|
||||
Ok::<_, diesel::result::Error>(())
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => conn.release_savepoint()?,
|
||||
Err(error) => {
|
||||
let _ = conn.rollback_savepoint();
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(has_content)
|
||||
}
|
||||
|
||||
/// Like `reindex_all` but with optional per-item progress.
|
||||
pub fn reindex_all_with_progress(
|
||||
pub fn search_index_rebuild_required(conn: &Connection) -> EngineResult<bool> {
|
||||
match setting::get_setting_by_key(conn, REBUILD_REQUIRED_SETTING) {
|
||||
Ok(value) => Ok(value.value == "true"),
|
||||
Err(diesel::result::Error::NotFound) => Ok(false),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically recreate and rebuild the shared FTS index for every project.
|
||||
pub fn rebuild_search_index(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
main_language: &str,
|
||||
on_item: Option<ItemProgressFn>,
|
||||
) -> EngineResult<ReindexReport> {
|
||||
// Wipe existing FTS content
|
||||
fts::clear_indexes(conn)?;
|
||||
|
||||
// Reindex all posts
|
||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||
|
||||
// Reindex all media
|
||||
let media_items = media_q::list_media_by_project(conn, project_id)?;
|
||||
|
||||
let total = posts.len() + media_items.len();
|
||||
|
||||
let mut posts_indexed = 0;
|
||||
for (i, post) in posts.iter().enumerate() {
|
||||
if let Some(ref cb) = on_item {
|
||||
cb(i + 1, total, &post.title);
|
||||
conn.begin_savepoint()?;
|
||||
let result = (|| {
|
||||
fts::recreate_tables(conn)?;
|
||||
let report = index_all_projects(conn, on_item.as_ref())?;
|
||||
setting::set_setting_value(conn, REBUILD_REQUIRED_SETTING, "false", now_unix_ms())?;
|
||||
Ok(report)
|
||||
})();
|
||||
match result {
|
||||
Ok(report) => {
|
||||
conn.release_savepoint()?;
|
||||
Ok(report)
|
||||
}
|
||||
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
|
||||
Err(error) => {
|
||||
let _ = conn.rollback_savepoint();
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let trans_data: Vec<fts::PostTranslationFts> = translations
|
||||
.iter()
|
||||
.map(|t| fts::PostTranslationFts {
|
||||
title: t.title.clone(),
|
||||
excerpt: t.excerpt.clone(),
|
||||
content: t.content.clone(),
|
||||
language: t.language.clone(),
|
||||
})
|
||||
.collect();
|
||||
fn index_all_projects(
|
||||
conn: &Connection,
|
||||
on_item: Option<&ItemProgressFn>,
|
||||
) -> EngineResult<ReindexReport> {
|
||||
let projects = project_q::list_projects(conn)?;
|
||||
let total = projects.iter().try_fold(0usize, |total, project| {
|
||||
Ok::<_, diesel::result::Error>(
|
||||
total
|
||||
+ post_q::count_posts_by_project(conn, &project.id)? as usize
|
||||
+ media_q::count_media_by_project(conn, &project.id)? as usize,
|
||||
)
|
||||
})?;
|
||||
let mut current = 0;
|
||||
let mut report = ReindexReport {
|
||||
posts_indexed: 0,
|
||||
media_indexed: 0,
|
||||
};
|
||||
|
||||
let language = post.language.as_deref().unwrap_or(main_language);
|
||||
fts::index_post(
|
||||
conn,
|
||||
&post.id,
|
||||
&post.title,
|
||||
post.excerpt.as_deref(),
|
||||
post.content.as_deref(),
|
||||
&post.tags,
|
||||
&post.categories,
|
||||
&trans_data,
|
||||
language,
|
||||
)?;
|
||||
for project in projects {
|
||||
let main_language = project
|
||||
.data_path
|
||||
.as_deref()
|
||||
.and_then(|path| crate::engine::meta::read_project_json(Path::new(path)).ok())
|
||||
.and_then(|metadata| metadata.main_language)
|
||||
.unwrap_or_else(|| "en".to_string());
|
||||
|
||||
posts_indexed += 1;
|
||||
for post in post_q::list_posts_by_project(conn, &project.id)? {
|
||||
current += 1;
|
||||
if let Some(callback) = on_item {
|
||||
callback(current, total, &post.title);
|
||||
}
|
||||
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
|
||||
let translation_data = translations
|
||||
.iter()
|
||||
.map(|translation| fts::PostTranslationFts {
|
||||
title: translation.title.clone(),
|
||||
excerpt: translation.excerpt.clone(),
|
||||
content: translation.content.clone(),
|
||||
language: translation.language.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
fts::index_post(
|
||||
conn,
|
||||
&post.id,
|
||||
&post.title,
|
||||
post.excerpt.as_deref(),
|
||||
post.content.as_deref(),
|
||||
&post.tags,
|
||||
&post.categories,
|
||||
&translation_data,
|
||||
post.language.as_deref().unwrap_or(&main_language),
|
||||
)?;
|
||||
report.posts_indexed += 1;
|
||||
}
|
||||
|
||||
for media in media_q::list_media_by_project(conn, &project.id)? {
|
||||
current += 1;
|
||||
if let Some(callback) = on_item {
|
||||
callback(current, total, &media.original_name);
|
||||
}
|
||||
let translations =
|
||||
media_translation::list_media_translations_by_media(conn, &media.id)?;
|
||||
let translation_data = translations
|
||||
.iter()
|
||||
.map(|translation| fts::MediaTranslationFts {
|
||||
title: translation.title.clone(),
|
||||
alt: translation.alt.clone(),
|
||||
caption: translation.caption.clone(),
|
||||
language: translation.language.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
fts::index_media(
|
||||
conn,
|
||||
&media.id,
|
||||
media.title.as_deref(),
|
||||
media.alt.as_deref(),
|
||||
media.caption.as_deref(),
|
||||
&media.original_name,
|
||||
&media.tags,
|
||||
&translation_data,
|
||||
media.language.as_deref().unwrap_or(&main_language),
|
||||
)?;
|
||||
report.media_indexed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let offset = posts.len();
|
||||
let mut media_indexed = 0;
|
||||
for (i, m) in media_items.iter().enumerate() {
|
||||
if let Some(ref cb) = on_item {
|
||||
cb(offset + i + 1, total, &m.original_name);
|
||||
}
|
||||
let translations = media_translation::list_media_translations_by_media(conn, &m.id)?;
|
||||
|
||||
let trans_data: Vec<fts::MediaTranslationFts> = translations
|
||||
.iter()
|
||||
.map(|t| fts::MediaTranslationFts {
|
||||
title: t.title.clone(),
|
||||
alt: t.alt.clone(),
|
||||
caption: t.caption.clone(),
|
||||
language: t.language.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let language = m.language.as_deref().unwrap_or(main_language);
|
||||
fts::index_media(
|
||||
conn,
|
||||
&m.id,
|
||||
m.title.as_deref(),
|
||||
m.alt.as_deref(),
|
||||
m.caption.as_deref(),
|
||||
&m.original_name,
|
||||
&m.tags,
|
||||
&trans_data,
|
||||
language,
|
||||
)?;
|
||||
|
||||
media_indexed += 1;
|
||||
}
|
||||
|
||||
Ok(ReindexReport {
|
||||
posts_indexed,
|
||||
media_indexed,
|
||||
})
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -139,15 +206,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reindex_empty_project() {
|
||||
let (db, project_id) = setup();
|
||||
let report = reindex_all(db.conn(), &project_id, "en").unwrap();
|
||||
fn rebuild_empty_index() {
|
||||
let (db, _) = setup();
|
||||
let report = rebuild_search_index(db.conn(), None).unwrap();
|
||||
assert_eq!(report.posts_indexed, 0);
|
||||
assert_eq!(report.media_indexed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reindex_with_posts() {
|
||||
fn rebuild_with_posts() {
|
||||
let (db, project_id) = setup();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -165,7 +232,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = reindex_all(db.conn(), &project_id, "en").unwrap();
|
||||
let report = rebuild_search_index(db.conn(), None).unwrap();
|
||||
assert_eq!(report.posts_indexed, 1);
|
||||
assert_eq!(report.media_indexed, 0);
|
||||
|
||||
@@ -173,4 +240,50 @@ mod tests {
|
||||
let results = crate::db::fts::search_posts(db.conn(), "test", "en").unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_rebuild_indexes_every_project() {
|
||||
let (db, first_project_id) = setup();
|
||||
let first_dir = tempfile::tempdir().unwrap();
|
||||
let second_dir = tempfile::tempdir().unwrap();
|
||||
let second_project = engine::project::create_project(
|
||||
db.conn(),
|
||||
"Second Project",
|
||||
Some(second_dir.path().to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for (project_id, data_dir, title) in [
|
||||
(
|
||||
&first_project_id,
|
||||
first_dir.path(),
|
||||
"First Project Searchable",
|
||||
),
|
||||
(
|
||||
&second_project.id,
|
||||
second_dir.path(),
|
||||
"Second Project Searchable",
|
||||
),
|
||||
] {
|
||||
engine::post::create_post(
|
||||
db.conn(),
|
||||
data_dir,
|
||||
project_id,
|
||||
title,
|
||||
Some("body"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
Some("en"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let report = rebuild_search_index(db.conn(), None).unwrap();
|
||||
let results = crate::db::fts::search_posts(db.conn(), "searchable", "en").unwrap();
|
||||
|
||||
assert_eq!(report.posts_indexed, 2);
|
||||
assert_eq!(results.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user