feat(wfe-opensearch): add OpenSearch search index provider

OpenSearchIndex implementing SearchIndex trait. Index mapping for
workflow documents. Bool queries with multi_match for terms, term
filters for status/reference, range filters for dates. Pagination support.
This commit is contained in:
2026-03-25 20:14:24 +00:00
parent f95bef3883
commit c74b9b6ad7
3 changed files with 519 additions and 0 deletions

View File

@@ -0,0 +1,198 @@
use chrono::Utc;
use opensearch::http::transport::Transport;
use opensearch::OpenSearch;
use pretty_assertions::assert_eq;
use serde_json::json;
use uuid::Uuid;
use wfe_core::models::{WorkflowInstance, WorkflowStatus};
use wfe_core::traits::search::{SearchFilter, SearchIndex};
use wfe_opensearch::OpenSearchIndex;
const OPENSEARCH_URL: &str = "http://localhost:9200";
/// Check if OpenSearch is reachable, skip test if not.
async fn opensearch_available() -> bool {
let transport = Transport::single_node(OPENSEARCH_URL);
if transport.is_err() {
return false;
}
let client = OpenSearch::new(transport.unwrap());
client
.ping()
.send()
.await
.map(|r| r.status_code().is_success())
.unwrap_or(false)
}
/// Helper to create a unique test index and return the provider + cleanup handle.
async fn setup() -> Option<(OpenSearchIndex, String)> {
if !opensearch_available().await {
eprintln!("OpenSearch not available, skipping test");
return None;
}
let index_name = format!("wfe_test_{}", Uuid::new_v4());
let provider = OpenSearchIndex::new(OPENSEARCH_URL, &index_name).unwrap();
provider.start().await.unwrap();
Some((provider, index_name))
}
/// Refresh the index so documents become searchable immediately.
async fn refresh_index(provider: &OpenSearchIndex) {
let url = format!("/{}/_refresh", provider.index_name());
provider
.client()
.send(
opensearch::http::Method::Post,
&url,
opensearch::http::headers::HeaderMap::new(),
Option::<&serde_json::Value>::None,
Some(b"".as_ref()),
None,
)
.await
.unwrap();
}
/// Delete the test index.
async fn cleanup(provider: &OpenSearchIndex) {
let _ = provider
.client()
.indices()
.delete(opensearch::indices::IndicesDeleteParts::Index(&[
provider.index_name(),
]))
.send()
.await;
}
fn make_instance(description: Option<&str>, reference: Option<&str>) -> WorkflowInstance {
let mut instance = WorkflowInstance::new("test-workflow", 1, json!({"key": "value"}));
instance.description = description.map(String::from);
instance.reference = reference.map(String::from);
instance
}
#[tokio::test]
async fn index_and_search_by_terms() {
let Some((provider, _index)) = setup().await else {
return;
};
let instance = make_instance(Some("Process the quarterly financial report"), None);
provider.index_workflow(&instance).await.unwrap();
refresh_index(&provider).await;
let page = provider
.search("quarterly financial", 0, 10, &[])
.await
.unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.data.len(), 1);
assert_eq!(page.data[0].id, instance.id);
assert_eq!(
page.data[0].description.as_deref(),
Some("Process the quarterly financial report")
);
cleanup(&provider).await;
}
#[tokio::test]
async fn search_with_status_filter() {
let Some((provider, _index)) = setup().await else {
return;
};
let mut runnable = make_instance(Some("Runnable workflow"), None);
runnable.status = WorkflowStatus::Runnable;
let mut complete = make_instance(Some("Complete workflow"), None);
complete.status = WorkflowStatus::Complete;
complete.complete_time = Some(Utc::now());
provider.index_workflow(&runnable).await.unwrap();
provider.index_workflow(&complete).await.unwrap();
refresh_index(&provider).await;
let page = provider
.search("", 0, 10, &[SearchFilter::Status(WorkflowStatus::Complete)])
.await
.unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.data[0].id, complete.id);
assert_eq!(page.data[0].status, WorkflowStatus::Complete);
cleanup(&provider).await;
}
#[tokio::test]
async fn search_with_no_results() {
let Some((provider, _index)) = setup().await else {
return;
};
let instance = make_instance(Some("A regular workflow"), None);
provider.index_workflow(&instance).await.unwrap();
refresh_index(&provider).await;
let page = provider
.search("nonexistent-xyzzy-42", 0, 10, &[])
.await
.unwrap();
assert_eq!(page.total, 0);
assert!(page.data.is_empty());
cleanup(&provider).await;
}
#[tokio::test]
async fn index_multiple_and_paginate() {
let Some((provider, _index)) = setup().await else {
return;
};
let mut instances = Vec::new();
for i in 0..5 {
let instance = make_instance(Some(&format!("Paginated workflow number {i}")), None);
provider.index_workflow(&instance).await.unwrap();
instances.push(instance);
}
refresh_index(&provider).await;
// Search all, but skip 2 and take 2
let page = provider.search("Paginated workflow", 2, 2, &[]).await.unwrap();
assert_eq!(page.total, 5);
assert_eq!(page.data.len(), 2);
cleanup(&provider).await;
}
#[tokio::test]
async fn search_by_reference() {
let Some((provider, _index)) = setup().await else {
return;
};
let inst1 = make_instance(Some("First workflow"), Some("REF-001"));
let inst2 = make_instance(Some("Second workflow"), Some("REF-002"));
provider.index_workflow(&inst1).await.unwrap();
provider.index_workflow(&inst2).await.unwrap();
refresh_index(&provider).await;
let page = provider
.search("", 0, 10, &[SearchFilter::Reference("REF-001".to_string())])
.await
.unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.data[0].id, inst1.id);
assert_eq!(page.data[0].reference.as_deref(), Some("REF-001"));
cleanup(&provider).await;
}