Files
openobserve/tests/integration_test.rs
T
7335a482ac feat(incidents): Incident Analysis revamp — recorded, streamed and steerable RCA runs (#15615)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Subhra264 <chakrabortysubhradeep556@gmail.com>
Co-authored-by: Hengfei Yang <hengfei.yang@gmail.com>
2026-10-22 19:47:40 +08:00

4319 lines
154 KiB
Rust

// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// macOS ld cannot encode compact-unwind offsets once __eh_frame exceeds 16MB;
// harmless for a binary this size (only slows panic unwinding), so silence it.
#![allow(linker_messages)]
#![recursion_limit = "256"]
#[cfg(test)]
mod tests {
use core::time;
use std::{
env, fs,
net::SocketAddr,
str,
sync::{Arc, Once},
thread,
};
use arrow_flight::flight_service_server::FlightServiceServer;
use axum::{
Router,
body::Body,
http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header},
};
use bytes::{Bytes, BytesMut};
use chrono::{Duration, Utc};
use common::meta::user::UserList;
use config::{
get_config,
meta::{
alerts::{Operator, QueryCondition, TriggerCondition, alert::Alert},
dashboards::{Dashboard, v5},
pipeline::{
Pipeline,
components::{DerivedStream, PipelineSource},
},
stream::StreamType,
triggers::{ScheduledTriggerData, Trigger, TriggerModule, TriggerStatus},
},
utils::{
enrichment_local_cache::{
get_key, get_metadata_content, get_metadata_path, get_table_dir, get_table_path,
},
json,
},
};
use enrichment_data::enrichment::storage::{Values, local};
use infra::schema::{STREAM_SCHEMAS, STREAM_SCHEMAS_LATEST};
use ingestion_common::IngestionResponse;
use openobserve::migration;
use openobserve_api_grpc::handler::grpc::{
auth::check_internal_auth, flight::FlightServiceImpl,
};
use openobserve_api_http::handler::http::router::{
basic_routes, config_routes, service_routes,
};
use openobserve_api_management::models::{
alerts::responses::{GetAlertResponseBody, ListAlertsResponseBody},
destinations::{Destination, DestinationType},
};
use openobserve_api_pipelines::models::pipelines::{Pipeline as ApiPipeline, PipelineList};
use openobserve_core::alerts::scheduler::handlers::handle_triggers;
use prost::Message;
use proto::{cluster_rpc::search_server::SearchServer, prometheus_rpc};
use search_service::SEARCH_SERVER;
use serde_json::json;
use tonic::codec::CompressionEncoding;
use tower::ServiceExt;
use transform::enrichment::ENRICHMENT_TABLES;
static START: Once = Once::new();
// A sub-test that calls `setup()` after `tear_down` fails fast instead of using deleted state.
static TORN_DOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// The e2e's own data dir, ports, config file and Postgres database, removed by `tear_down`.
struct Sandbox {
dir: tempfile::TempDir,
postgres: Option<OwnDatabase>,
}
impl Sandbox {
/// Runs before the first `get_config`, which reads the env this sets exactly once.
async fn create() -> Self {
let dir = tempfile::Builder::new()
.prefix("o2-e2e-")
.tempdir()
.expect("e2e temp dir");
let config_file = dir.path().join("e2e.env");
fs::write(&config_file, "").expect("e2e config file");
config::config_path_manager::set_config_file_path(config_file)
.expect("e2e config file path");
// The enterprise config also loads a `.env` from the working directory.
env::set_current_dir(dir.path()).expect("enter the e2e temp dir");
let postgres = OwnDatabase::create().await;
let [grpc_port, http_port] = free_ports();
unsafe {
env::set_var("ZO_DATA_DIR", format!("{}/data/", dir.path().display()));
for derived in [
"ZO_DATA_WAL_DIR",
"ZO_DATA_STREAM_DIR",
"ZO_DATA_DB_DIR",
"ZO_DATA_CACHE_DIR",
"ZO_DATA_TMP_DIR",
] {
env::remove_var(derived);
}
env::set_var("ZO_GRPC_ADDR", "127.0.0.1");
env::set_var("ZO_GRPC_PORT", grpc_port.to_string());
env::set_var("ZO_HTTP_ADDR", "127.0.0.1");
env::set_var("ZO_HTTP_PORT", http_port.to_string());
}
Self { dir, postgres }
}
/// Panics unless the config that every pool connects from names this database.
fn assert_config_uses_it(&self) {
let Some(postgres) = &self.postgres else {
return;
};
let cfg = get_config();
let dsn = url::Url::parse(&cfg.common.meta_postgres_dsn).expect("e2e DSN is a URL");
assert_eq!(dsn.path(), format!("/{}", postgres.name));
assert!(cfg.common.meta_postgres_ro_dsn.is_empty());
assert!(cfg.common.meta_ddl_dsn.is_empty());
}
async fn tear_down(self) {
log::info!("Tear Down Invoked");
TORN_DOWN.store(true, std::sync::atomic::Ordering::SeqCst);
let data = self.dir.path().join("data");
// The WAL push job can still be moving the last ingests' files while the dir goes.
let mut result = fs::remove_dir_all(&data);
for _ in 0..20 {
if result.is_ok() {
break;
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
result = fs::remove_dir_all(&data);
}
result.expect("Delete local dir failed");
if let Some(postgres) = self.postgres {
postgres.drop_database().await;
}
}
}
/// A database made from the CI DSN, so the e2e never writes into the one the unit tests share.
struct OwnDatabase {
admin_dsn: String,
name: String,
dropped: bool,
}
impl OwnDatabase {
async fn create() -> Option<Self> {
use sqlx::Connection;
if env::var("ZO_META_STORE").ok()?.as_str() != "postgres" {
return None;
}
let admin_dsn = env::var("ZO_META_POSTGRES_DSN")
.ok()
.filter(|dsn| !dsn.is_empty())
.expect("the Postgres e2e makes its own database from ZO_META_POSTGRES_DSN");
let name = format!(
"o2_e2e_{}_{}",
std::process::id(),
Utc::now().timestamp_micros()
);
let mut conn = sqlx::PgConnection::connect(&admin_dsn)
.await
.expect("connect to the CI Postgres");
sqlx::query(&format!("CREATE DATABASE {name}"))
.execute(&mut conn)
.await
.expect("create the e2e database");
let mut dsn = url::Url::parse(&admin_dsn).expect("ZO_META_POSTGRES_DSN is a URL");
dsn.set_path(&format!("/{name}"));
unsafe {
env::set_var("ZO_META_POSTGRES_DSN", dsn.as_str());
env::remove_var("ZO_META_POSTGRES_RO_DSN");
env::remove_var("ZO_META_DDL_DSN");
}
Some(Self {
admin_dsn,
name,
dropped: false,
})
}
async fn drop_database(mut self) {
Self::drop_named(&self.admin_dsn, &self.name)
.await
.expect("drop the e2e database");
self.dropped = true;
}
async fn drop_named(admin_dsn: &str, name: &str) -> Result<(), sqlx::Error> {
use sqlx::Connection;
let mut conn = sqlx::PgConnection::connect(admin_dsn).await?;
// FORCE ends the pools this process still holds on the database.
sqlx::query(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)"))
.execute(&mut conn)
.await?;
Ok(())
}
}
impl Drop for OwnDatabase {
/// Also runs on unwind; on a new thread because no runtime starts inside the test's.
fn drop(&mut self) {
if self.dropped {
return;
}
let (admin_dsn, name) = (self.admin_dsn.clone(), self.name.clone());
let dropped = std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?
.block_on(Self::drop_named(&admin_dsn, &name))
.map_err(|e| e.to_string())
})
.join();
if !matches!(dropped, Ok(Ok(()))) {
eprintln!(
"the e2e database {} was not dropped: {dropped:?}",
self.name
);
}
}
}
fn setup() -> (&'static str, &'static str) {
assert!(
!TORN_DOWN.load(std::sync::atomic::Ordering::SeqCst),
"a sub-test ran after tear_down removed the e2e's data dir and database"
);
START.call_once(|| unsafe {
env::set_var("ZO_ROOT_USER_EMAIL", "root@example.com");
env::set_var("ZO_ROOT_USER_PASSWORD", "Complexpass#123");
env::set_var("ZO_LOCAL_MODE", "true");
env::set_var("ZO_MAX_FILE_SIZE_ON_DISK", "1");
env::set_var("ZO_FILE_PUSH_INTERVAL", "1");
env::set_var("ZO_PAYLOAD_LIMIT", "209715200");
env::set_var("ZO_JSON_LIMIT", "209715200");
env::set_var("ZO_RESULT_CACHE_ENABLED", "false");
env::set_var("ZO_PRINT_KEY_SQL", "true");
env::set_var("ZO_SMTP_ENABLED", "true");
env::set_var("ZO_CREATE_ORG_THROUGH_INGESTION", "true");
env::set_var("ZO_SKIP_SSRF_CHECKS", "true");
env::set_var("O2_ORG_DELETION_GRACE_PERIOD_DAYS", "0");
env_logger::init_from_env(
env_logger::Env::new().default_filter_or(&get_config().log.level),
);
log::info!("setup Invoked");
});
(
"Authorization",
"Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=",
)
}
/// Initialize test router with service and basic routes
fn init_test_router() -> Router {
Router::new()
.merge(basic_routes())
.nest("/config", config_routes())
.nest("/api", service_routes())
}
/// Make a test request and return the response
async fn make_request(
app: &Router,
method: Method,
uri: &str,
headers: Option<HeaderMap>,
body: Option<String>,
) -> (StatusCode, Bytes) {
let mut req_builder = Request::builder().method(method).uri(uri);
if let Some(hdrs) = headers {
for (key, value) in hdrs.iter() {
req_builder = req_builder.header(key, value);
}
}
let req = if let Some(body_str) = body {
req_builder
.body(Body::from(body_str))
.expect("Failed to build request")
} else {
req_builder
.body(Body::empty())
.expect("Failed to build request")
};
let response = app
.clone()
.oneshot(req)
.await
.expect("Failed to execute request");
let status = response.status();
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("Failed to read response body");
(status, body_bytes)
}
/// Helper to create headers with auth
fn auth_headers(auth: (&str, &str)) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(auth.1).expect("Invalid auth header"),
);
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
headers
}
async fn init_grpc_server() -> Result<(), anyhow::Error> {
let cfg = get_config();
let ip = if !cfg.grpc.addr.is_empty() {
cfg.grpc.addr.clone()
} else {
"0.0.0.0".to_string()
};
let gaddr: SocketAddr = format!("{}:{}", ip, cfg.grpc.port).parse()?;
let search_svc = SearchServer::new(SEARCH_SERVER.clone())
.send_compressed(CompressionEncoding::Gzip)
.accept_compressed(CompressionEncoding::Gzip);
let flight_svc = FlightServiceServer::new(FlightServiceImpl)
.send_compressed(CompressionEncoding::Gzip)
.accept_compressed(CompressionEncoding::Gzip);
log::info!("starting gRPC server at {}", gaddr);
tonic::transport::Server::builder()
.layer(tonic::service::InterceptorLayer::new(check_internal_auth))
.add_service(search_svc)
.add_service(flight_svc)
.serve(gaddr)
.await
.expect("gRPC server init failed");
Ok(())
}
/// Ports free now; both listeners stay open until both are picked, so they differ.
fn free_ports() -> [u16; 2] {
let listeners = [(); 2]
.map(|_| std::net::TcpListener::bind("127.0.0.1:0").expect("bind an ephemeral port"));
listeners.map(|l| l.local_addr().expect("ephemeral port").port())
}
/// Helper function to flush memtable to ensure proper test isolation
/// This prevents memtable overflow errors when running sequential ingestion tests
async fn flush_memtable() {
if let Err(e) = ingester::flush_all().await {
log::warn!("Failed to flush memtable: {}", e);
}
}
/// Cleanup any leftover state from previous test runs/retries.
/// Order matters: alerts first (they reference destinations), then destinations, then
/// templates. This ensures idempotency when tests are retried.
async fn cleanup_previous_test_state() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
// 1. Delete alerts first (they reference destinations)
// List alerts and delete by ID
let (status, body) = make_request(
&app,
Method::GET,
"/api/v2/e2e/alerts?stream_type=logs",
Some(headers.clone()),
None,
)
.await;
if status.is_success()
&& let Ok(alerts) = serde_json::from_slice::<serde_json::Value>(&body)
&& let Some(list) = alerts.get("list").and_then(|l| l.as_array())
{
let alert_names = ["alertChk", "sns_test_alert", "multirange_alert"];
for alert in list {
if let Some(name) = alert.get("name").and_then(|n| n.as_str())
&& alert_names.contains(&name)
&& let Some(id) = alert.get("alert_id").and_then(|id| id.as_str())
{
let _ = make_request(
&app,
Method::DELETE,
&format!("/api/v2/e2e/alerts/{}?type=logs", id),
Some(headers.clone()),
None,
)
.await;
log::info!("Cleanup: deleted alert {}", name);
}
}
}
// 2. Delete destinations (they reference templates)
for dest in ["slack", "email", "sns_alert"] {
let _ = make_request(
&app,
Method::DELETE,
&format!("/api/e2e/alerts/destinations/{}", dest),
Some(headers.clone()),
None,
)
.await;
}
// 3. Delete templates last
for template in ["slackTemplate", "email_template", "snsTemplate"] {
let _ = make_request(
&app,
Method::DELETE,
&format!("/api/e2e/alerts/templates/{}", template),
Some(headers.clone()),
None,
)
.await;
}
// 4. Delete user if exists
let _ = make_request(
&app,
Method::DELETE,
"/api/e2e/users/nonadmin@example.com",
Some(headers.clone()),
None,
)
.await;
log::info!("Cleanup: finished cleaning up previous test state");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn e2e_test() {
let sandbox = Sandbox::create().await;
setup();
sandbox.assert_config_uses_it();
// start gRPC server
tokio::task::spawn(async move {
init_grpc_server()
.await
.expect("router gRPC server init failed");
});
// register node
common::infra::cluster::register_and_keep_alive()
.await
.unwrap();
// init config
config::init().await.unwrap();
// init infra
migration::init_db().await.unwrap();
// ensure database tables are created
infra::db::create_table().await.unwrap();
// db migration steps, since it's separated out
infra::table::migrate().await.unwrap();
infra::init().await.unwrap();
#[cfg(feature = "enterprise")]
let master_key = Some(
o2_enterprise::enterprise::common::config::get_config()
.encryption
.master_key
.clone(),
);
#[cfg(not(feature = "enterprise"))]
let master_key: Option<String> = None;
infra::table::cipher::boot(master_key.as_deref())
.await
.unwrap();
openobserve_core::bootstrap::init().await.unwrap();
// ingester init
ingester::init().await.unwrap();
// init job
openobserve_jobs::job::init().await.unwrap();
// Wait for async initialization tasks (like default user creation) to complete
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
// Clean up any leftover state from previous test runs/retries
// This ensures tests are idempotent
cleanup_previous_test_state().await;
for _i in 0..3 {
e2e_1_post_bulk().await;
}
// Flush memtable after bulk ingestion to ensure proper test isolation
// This prevents memtable overflow when subsequent tests try to ingest data
flush_memtable().await;
// ingest
e2e_post_json().await;
e2e_post_multi().await;
e2e_post_trace().await;
e2e_post_metrics().await;
e2e_post_hec().await;
// e2e_post_kinesis_data().await;
// streams
e2e_get_stream().await;
e2e_get_stream_schema().await;
e2e_get_org_summary().await;
e2e_post_stream_settings().await;
e2e_get_es_settings().await;
// functions
e2e_post_function().await;
e2e_list_functions().await;
e2e_delete_function().await;
// search
e2e_search().await;
e2e_search_around().await;
// users
e2e_post_user().await;
e2e_update_user().await;
e2e_update_user_with_empty().await;
e2e_add_user_to_org().await;
e2e_add_root_user_to_org().await;
e2e_list_users().await;
e2e_get_organizations().await;
e2e_get_user_passcode().await;
e2e_update_user_passcode().await;
e2e_user_authentication().await;
e2e_user_authentication_with_error().await;
// dashboards
{
let board = e2e_create_dashboard().await;
let list = e2e_list_dashboards().await;
assert_eq!(list[0], board);
let mut v5_board = board.v5.unwrap();
v5_board.title = "e2e test".to_owned();
v5_board.description = "Logs flow downstream".to_owned();
let board = e2e_update_dashboard(v5_board, board.hash).await;
assert_eq!(
e2e_get_dashboard(&board.v5.as_ref().unwrap().dashboard_id).await,
board
);
e2e_delete_dashboard(&board.v5.unwrap().dashboard_id).await;
assert!(e2e_list_dashboards().await.is_empty());
}
// A panel whose column is wrapped in a cast must come back with the
// nesting intact — the model used to flatten it, which silently dropped
// the y column from the query on the next edit.
e2e_dashboard_nested_cast_round_trip().await;
// alert
e2e_post_alert_template().await;
e2e_get_alert_template().await;
e2e_list_alert_template().await;
e2e_post_alert_destination().await;
e2e_get_alert_destination().await;
e2e_list_alert_destinations().await;
e2e_post_alert_multirange().await;
e2e_delete_alert_multirange().await;
e2e_post_alert().await;
e2e_get_alert().await;
e2e_handle_alert_after_destination_retries().await;
e2e_handle_alert_after_evaluation_retries().await;
e2e_handle_alert_reached_max_retries().await;
e2e_list_alerts().await;
e2e_list_real_time_alerts().await;
e2e_delete_alert().await;
e2e_delete_alert_destination().await;
e2e_delete_alert_template().await;
// Email-specific alert tests
e2e_post_alert_email_template().await;
e2e_get_alert_email_template().await;
e2e_post_alert_email_destination().await;
e2e_get_alert_email_destination().await;
e2e_post_alert_email_destination_should_fail().await;
e2e_delete_alert_email_destination().await;
e2e_delete_alert_email_template().await;
// Clean-up user here after email destinations deleted
e2e_delete_user().await;
// SNS-specific alert tests
// Set up templates
e2e_post_alert_template().await;
e2e_post_sns_alert_template().await;
// SNS destination tests
e2e_post_sns_alert_destination().await;
e2e_get_sns_alert_destination().await;
e2e_list_alert_destinations_with_sns().await;
e2e_update_sns_alert_destination().await;
// Create and test alert with SNS destination
e2e_post_alert_with_sns_destination().await;
// Cleanup
e2e_delete_alert_with_sns_destination().await;
e2e_delete_sns_alert_destination().await;
// derived streams
e2e_create_test_pipeline().await;
e2e_handle_derived_stream_success().await;
e2e_handle_derived_stream_pipeline_not_found().await;
e2e_handle_derived_stream_max_retries().await;
test_derived_stream_invalid_timerange_delay_scenario().await;
test_derived_stream_invalid_timerange_with_cron_frequency().await;
e2e_handle_derived_stream_evaluation_failure().await;
e2e_cleanup_test_pipeline().await;
// enrichment table
test_enrichment_table_integration().await;
test_enrichment_table_local_all_sequential().await;
// backfill jobs
test_backfill_job_list_and_delete().await;
test_backfill_job_get_nonexistent().await;
test_backfill_job_delete_by_pipeline().await;
test_backfill_job_enable_disable().await;
// Own fn: inline spawns widen e2e_test's frame and overflow the debug stack.
e2e_org_deletion().await;
// others
e2e_health_check().await;
e2e_config().await;
// clear
e2e_delete_stream().await;
sandbox.tear_down().await;
}
async fn e2e_org_deletion() {
// org deletion: each runs in its own task so one red test does not hide the others
let mut failed = Vec::new();
if tokio::spawn(e2e_meta_prefix_reads_are_org_exact())
.await
.is_err()
{
failed.push("e2e_meta_prefix_reads_are_org_exact");
}
if tokio::spawn(e2e_org_setting_writers_do_not_lose_updates())
.await
.is_err()
{
failed.push("e2e_org_setting_writers_do_not_lose_updates");
}
assert!(failed.is_empty(), "org deletion e2e failed: {failed:?}");
}
fn run_suffix() -> String {
(Utc::now().timestamp_micros() % 1_000_000_000).to_string()
}
async fn e2e_meta_prefix_reads_are_org_exact() {
setup();
let db = infra::db::get_db().await;
let suffix = run_suffix();
let orgs = ["a_b", "aXb", "a-b", "A_B"].map(|o| format!("{o}{suffix}"));
for org in &orgs {
db.put(
&format!("/organization/savedviews/{org}/v1"),
"{}".into(),
false,
None,
)
.await
.unwrap();
}
let target = format!("/organization/savedviews/{}/", orgs[0]);
let keys = db.list_keys(&target).await.unwrap();
assert_eq!(keys.len(), 1, "{keys:?}");
assert_eq!(db.count(&target).await.unwrap(), 1);
db.delete(&target, true, false, None).await.unwrap();
for org in &orgs[1..] {
let keys = db
.list_keys(&format!("/organization/savedviews/{org}/"))
.await
.unwrap();
assert_eq!(keys.len(), 1, "{org} lost its view: {keys:?}");
}
}
async fn e2e_org_setting_writers_do_not_lose_updates() {
setup();
let org = format!("orgsetw{}", run_suffix());
let writers: Vec<_> = (0..16)
.map(|i| {
let org = org.clone();
tokio::spawn(async move {
db::organization::update_org_setting(&org, move |s| {
s.cross_links.push(config::meta::stream::CrossLink {
name: format!("link{i}"),
url: "https://example.com".to_string(),
fields: vec![],
});
true
})
.await
.unwrap()
})
})
.collect();
for writer in writers {
assert!(writer.await.unwrap());
}
let stored = db::organization::get_org_setting(&org).await.unwrap();
assert_eq!(
stored.cross_links.len(),
16,
"concurrent settings writes were lost"
);
}
async fn e2e_1_post_bulk() {
let auth = setup();
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/input.json");
let body_str = fs::read_to_string(path).expect("Read file failed");
let app = init_test_router();
let headers = auth_headers(auth);
let (status, body) = make_request(
&app,
Method::POST,
&format!("/api/{}/_bulk", "e2e"),
Some(headers),
Some(body_str),
)
.await;
if !status.is_success() {
let body_str = String::from_utf8_lossy(&body);
panic!(
"e2e_1_post_bulk failed with status {}: {}",
status, body_str
);
}
}
async fn e2e_post_json() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
// timestamp in past
let body_str = "[{\"Year\": 1896, \"City\": \"Athens\", \"Sport\": \"Aquatics\", \"Discipline\": \"Swimming\", \"Athlete\": \"HERSCHMANN, Otto\", \"Country\": \"AUT\", \"Gender\": \"Men\", \"Event\": \"100M Freestyle\", \"Medal\": \"Silver\", \"Season\": \"summer\",\"_timestamp\":1665136888163792}]";
let (status, body) = make_request(
&app,
Method::POST,
&format!("/api/{}/{}/_json", "e2e", "olympics_schema"),
Some(headers.clone()),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
let res: IngestionResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(res.code, 200);
assert_eq!(res.status.len(), 1);
assert_eq!(res.status[0].status.successful, 0);
assert_eq!(res.status[0].status.failed, 1);
assert!(res.status[0].status.error.contains("Too old data"));
assert!(
res.status[0]
.status
.error
.contains("ZO_INGEST_ALLOWED_UPTO=")
);
// timestamp in future
let body_str = "[{\"Year\": 1896, \"City\": \"Athens\", \"Sport\": \"Aquatics\", \"Discipline\": \"Swimming\", \"Athlete\": \"HERSCHMANN, Otto\", \"Country\": \"AUT\", \"Gender\": \"Men\", \"Event\": \"100M Freestyle\", \"Medal\": \"Silver\", \"Season\": \"summer\",\"_timestamp\":9999999999999999}]";
let (status, body) = make_request(
&app,
Method::POST,
&format!("/api/{}/{}/_json", "e2e", "olympics_schema"),
Some(headers.clone()),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
let res: IngestionResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(res.code, 200);
assert_eq!(res.status.len(), 1);
assert_eq!(res.status[0].status.successful, 0);
assert_eq!(res.status[0].status.failed, 1);
assert!(res.status[0].status.error.contains("Too far data"));
assert!(
res.status[0]
.status
.error
.contains("ZO_INGEST_ALLOWED_IN_FUTURE=")
);
// timestamp not present
let body_str = "[{\"Year\": 1896, \"City\": \"Athens\", \"Sport\": \"Aquatics\", \"Discipline\": \"Swimming\", \"Athlete\": \"HERSCHMANN, Otto\", \"Country\": \"AUT\", \"Gender\": \"Men\", \"Event\": \"100M Freestyle\", \"Medal\": \"Silver\", \"Season\": \"summer\"}]";
let (status, body) = make_request(
&app,
Method::POST,
&format!("/api/{}/{}/_json", "e2e", "olympics_schema"),
Some(headers.clone()),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
let res: IngestionResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(res.code, 200);
assert_eq!(res.status.len(), 1);
assert_eq!(res.status[0].status.successful, 1);
assert_eq!(res.status[0].status.failed, 0);
// timestamp just right
let ts = chrono::Utc::now().timestamp_micros();
let body_str = format!(
"[{{\"Year\": 1896, \"City\": \"Athens\", \"Sport\": \"Aquatics\", \"Discipline\": \"Swimming\", \"Athlete\": \"HERSCHMANN, Otto\", \"Country\": \"AUT\", \"Gender\": \"Men\", \"Event\": \"100M Freestyle\", \"Medal\": \"Silver\", \"Season\": \"summer\",\"_timestamp\":{ts}}}]"
);
let (status, body) = make_request(
&app,
Method::POST,
&format!("/api/{}/{}/_json", "e2e", "olympics_schema"),
Some(headers.clone()),
Some(body_str),
)
.await;
assert!(status.is_success());
let res: IngestionResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(res.code, 200);
assert_eq!(res.status.len(), 1);
assert_eq!(res.status[0].status.successful, 1);
assert_eq!(res.status[0].status.failed, 0);
}
async fn e2e_post_hec() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
// test case : missing index in metadata
let body_str = "{\"event\":\"hello\"}";
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/_hec", "e2e"),
Some(headers.clone()),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
// test case : valid payload
let body_str = "{\"event\":\"hello\",\"index\":\"hec_test\"}";
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/_hec", "e2e"),
Some(headers.clone()),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
// test case : json event
let body_str =
"{\"event\":{\"log\":\"hello\",\"severity\":\"info\"},\"index\":\"hec_test\"}";
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/_hec", "e2e"),
Some(headers.clone()),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
// test case : ndjson
let body_str = r#"
{ "index": "hec_test", "event": "test log", "time": 1749113798091 }
{ "index": "hec_test", "event": {"log":"test log","severity":"info"}, "fields": {"cluster":"c1", "namespace":"n1"} }
{ "index": "hec_test", "event": {"log":"test log","severity":"info"}, "source" : "e2e_test", "fields": {"cluster":"c1", "namespace":"n1"}}
"#;
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/_hec", "e2e"),
Some(headers.clone()),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
async fn e2e_post_multi() {
let auth = setup();
let body_str = "{\"Year\": 1896, \"City\": \"Athens\", \"Sport\": \"Aquatics\", \"Discipline\": \"Swimming\", \"Athlete\": \"HERSCHMANN, Otto\", \"Country\": \"AUT\", \"Gender\": \"Men\", \"Event\": \"100M Freestyle\", \"Medal\": \"Silver\", \"Season\": \"summer\",\"_timestamp\":1665136888163792}";
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/{}/_multi", "e2e", "olympics_schema"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
async fn e2e_get_stream() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!("/api/{}/streams", "e2e"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_get_stream_schema() {
let auth = setup();
let one_sec = time::Duration::from_secs(2);
thread::sleep(one_sec);
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!("/api/{}/streams/{}/schema", "e2e", "olympics_schema"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_post_stream_settings() {
let auth = setup();
let body_str = r#"{"partition_keys":{"add":[{"field":"test_key"}],"remove":[]}, "full_text_search_keys":{"add":["city"],"remove":[]}}"#;
// app
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::PUT,
&format!("/api/{}/streams/{}/settings", "e2e", "olympics_schema"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
async fn e2e_delete_stream() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::DELETE,
&format!("/api/{}/streams/{}", "e2e", "olympics_schema"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_get_es_settings() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!("/api/{}/", "e2e"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_post_function() {
let auth = setup();
let body_str = r#"{
"name": "e2etestfn",
"function":".sqNew,err = .Year*.Year \n .",
"params":"row"
}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, body) = make_request(
&app,
Method::POST,
&format!("/api/{}/functions", "e2e"),
Some(headers),
Some(body_str.to_string()),
)
.await;
if !status.is_success() {
let body_str = String::from_utf8_lossy(&body);
println!("e2e_post_function response status: {status:?}");
println!("e2e_post_function response body: {body_str:?}");
// If function already exists, that's OK for our test
if status == StatusCode::BAD_REQUEST && body_str.contains("Function already exist") {
println!("Function already exists, continuing with test");
return;
}
panic!("e2e_post_function failed with status: {status:?}");
}
assert!(status.is_success());
}
async fn e2e_list_functions() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!("/api/{}/functions", "e2e"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_delete_function() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::DELETE,
&format!("/api/{}/functions/{}", "e2e", "e2etestfn"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_search() {
let auth = setup();
let body_str = r#"{
"query": {
"sql": "select * from olympics_schema",
"from": 0,
"size": 100,
"start_time": 1714857600000,
"end_time": 1714944000000
}
}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/_search", "e2e"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
async fn e2e_search_around() {
let auth = setup();
let app = init_test_router();
let ts = chrono::Utc::now().timestamp_micros();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!(
"/api/{}/{}/_around?key={}&size=10",
"e2e", "olympics_schema", ts
),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_list_users() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, body) = make_request(
&app,
Method::GET,
&format!("/api/{}/users", "e2e"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
let body_str = String::from_utf8_lossy(&body);
println!("list users resp: {body_str:?}");
// deserialize the body into UserList
let user_list: UserList = serde_json::from_str(&body_str).unwrap();
assert!(!user_list.data.is_empty());
assert!(
user_list
.data
.iter()
.any(|user| user.email == "admin@example.com")
);
assert!(
user_list
.data
.iter()
.any(|user| user.email == "nonadmin@example.com")
);
}
async fn e2e_get_organizations() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) =
make_request(&app, Method::GET, "/api/organizations", Some(headers), None).await;
assert!(status.is_success());
}
async fn e2e_get_user_passcode() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!("/api/{}/passcode", "e2e"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_update_user_passcode() {
let auth = setup();
let body_str = "";
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::PUT,
&format!("/api/{}/passcode", "e2e"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
async fn e2e_user_authentication() {
let _auth = setup();
let body_str = r#"{
"name": "root@example.com",
"password": "Complexpass#123"
}"#;
let app = init_test_router();
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
let (status, _body) = make_request(
&app,
Method::POST,
"/auth/login",
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
async fn e2e_user_authentication_with_error() {
let _auth = setup();
let body_str = r#"{
"name": "root2@example.com",
"password": "Complexpass#123"
}"#;
let app = init_test_router();
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
let (status, _body) = make_request(
&app,
Method::POST,
"/auth/login",
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(!status.is_success());
}
async fn e2e_post_user() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
// Delete user if it exists from previous test runs to avoid conflicts
let _ = make_request(
&app,
Method::DELETE,
&format!("/api/{}/users/{}", "e2e", "nonadmin@example.com"),
Some(headers.clone()),
None,
)
.await;
let body_str = r#"{
"email": "nonadmin@example.com",
"password": "Abcd12345!Zyxwv",
"role": "admin"
}"#;
let (status, body) = make_request(
&app,
Method::POST,
&format!("/api/{}/users", "e2e"),
Some(headers),
Some(body_str.to_string()),
)
.await;
// print the body from resp
println!("post user status: {status:?}");
println!("post user body: {}", String::from_utf8_lossy(&body));
assert!(status.is_success());
}
async fn e2e_update_user() {
let auth = setup();
let body_str = r#"{
"email": "nonadmin@example.com",
"new_password": "Newpass12!Abcde",
"change_password": true
}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::PUT,
&format!("/api/{}/users/{}", "e2e", "nonadmin@example.com"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
async fn e2e_update_user_with_empty() {
let auth = setup();
let body_str = r#"{}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::PUT,
&format!("/api/{}/users/{}", "e2e", "nonadmin@example.com"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(!status.is_success());
}
async fn e2e_add_root_user_to_org() {
let auth = setup();
let body_str = r#"{
"role":"member"
}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/users/{}", "e2e", "root@example.com"),
Some(headers),
Some(body_str.to_string()),
)
.await;
// It should fail as root user cannot be added to an organization
assert!(!status.is_success());
}
async fn e2e_add_user_to_org() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
// Check if admin@example.com already exists from initialization
let (status, body) = make_request(
&app,
Method::GET,
&format!("/api/{}/users", "e2e"),
Some(headers.clone()),
None,
)
.await;
assert!(status.is_success());
let body_str = String::from_utf8_lossy(&body);
let user_list: UserList = serde_json::from_str(&body_str).unwrap();
let admin_exists = user_list
.data
.iter()
.any(|user| user.email == "admin@example.com");
if !admin_exists {
// Test that adding user to org fails when user doesn't exist
let body_str = r#"{
"role":"admin"
}"#;
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/users/{}", "e2e", "admin@example.com"),
Some(headers.clone()),
Some(body_str.to_string()),
)
.await;
// Should fail as the user still does not exist
assert!(status.is_client_error());
// Add the user
let body_str = r#"{
"email": "admin@example.com",
"password": "Abcd12345!Zyxwv",
"role": "admin"
}"#;
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/users", "e2e"),
Some(headers.clone()),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
// Role in the default organization
let body_str = r#"{
"role":"admin"
}"#;
// Add the user to the default organization with role admin
let (status, body) = make_request(
&app,
Method::POST,
&format!("/api/{}/users/{}", "default", "admin@example.com"),
Some(headers.clone()),
Some(body_str.to_string()),
)
.await;
println!(
"add user to default org body: {}",
String::from_utf8_lossy(&body)
);
// Accept both success (user added) and conflict (user already in org) as valid outcomes
assert!(status.is_success() || status.as_u16() == 409);
}
async fn e2e_delete_user() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::DELETE,
&format!("/api/{}/users/{}", "e2e", "nonadmin@example.com"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_create_dashboard() -> Dashboard {
let auth = setup();
let body_str = r##"{"version":5,"title":"b2","dashboardId":"","description":"desc2","role":"","owner":"root@example.com","created":"2023-03-30T07:49:41.744+00:00","tabs":[{"tabId":"tab1","name":"Main","panels":[{"id":"Panel_ID7857010","type":"bar","title":"p5","description":"sample config blah blah blah","config":{"show_legends":true},"queryType":"sql","queries":[{"query":"SELECT histogram(_timestamp) as \"x_axis_1\", count(kubernetes_host) as \"y_axis_1\" FROM \"default\" GROUP BY \"x_axis_1\" ORDER BY \"x_axis_1\"","customQuery":false,"fields":{"stream":"default","stream_type":"logs","x":[{"label":"Timestamp","alias":"x_axis_1","column":"_timestamp","color":null,"aggregationFunction":"histogram"}],"y":[{"label":"Kubernetes Host","alias":"y_axis_1","column":"kubernetes_host","color":"#5960b2","aggregationFunction":"count"}],"filter":{"filterType":"group","logicalOperator":"AND","conditions":[]}},"config":{"promql_legend":""}}],"layout":{"x":0,"y":0,"w":12,"h":13,"i":1}}]}]}"##;
let app = init_test_router();
let headers = auth_headers(auth);
let (_status, body) = make_request(
&app,
Method::POST,
&format!("/api/{}/dashboards", "e2e"),
Some(headers),
Some(body_str.to_string()),
)
.await;
let body_str = String::from_utf8_lossy(&body);
let result: Dashboard = json::from_slice(&body)
.unwrap_or_else(|e| panic!("Failed to deserialize dashboard: {e}\nBody: {body_str}"));
result
}
async fn e2e_list_dashboards() -> Vec<Dashboard> {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (_status, body) = make_request(
&app,
Method::GET,
&format!("/api/{}/dashboards", "e2e"),
Some(headers),
None,
)
.await;
// Try to parse the response body as a list of dashboards.
let mut body_json: json::Value = json::from_slice(&body).unwrap();
let list_json = body_json
.as_object_mut()
.unwrap()
.remove("dashboards")
.unwrap();
let dashboards: Vec<Dashboard> = json::from_value(list_json).unwrap();
dashboards
}
async fn e2e_update_dashboard(dashboard: v5::Dashboard, hash: String) -> Dashboard {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (_status, body) = make_request(
&app,
Method::PUT,
&format!(
"/api/{}/dashboards/{}?hash={}",
"e2e", dashboard.dashboard_id, hash
),
Some(headers),
Some(json::to_string(&dashboard).unwrap()),
)
.await;
json::from_slice(&body).unwrap()
}
async fn e2e_get_dashboard(dashboard_id: &str) -> Dashboard {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (_status, body) = make_request(
&app,
Method::GET,
&format!("/api/{}/dashboards/{dashboard_id}", "e2e"),
Some(headers),
None,
)
.await;
json::from_slice(&body).unwrap()
}
async fn e2e_dashboard_nested_cast_round_trip() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
// sum(TRY_CAST(usage_amount AS DOUBLE)) exactly as the panel builder writes it.
let body_str = r#"{"version":8,"title":"cast","dashboardId":"","description":"","role":"","owner":"root@example.com","created":"2023-03-30T07:49:41.744+00:00","tabs":[{"tabId":"tab1","name":"Main","panels":[{"id":"Panel_cast","type":"bar","title":"cast","description":"","config":{"show_legends":true},"queryType":"sql","queries":[{"query":"SELECT sum(TRY_CAST(usage_amount AS DOUBLE)) as \"y_axis_1\" FROM \"default\"","customQuery":false,"fields":{"stream":"default","stream_type":"logs","filter":{"filterType":"group","logicalOperator":"AND","conditions":[]},"x":[],"y":[{"label":"Usage","alias":"y_axis_1","type":"build","functionName":"sum","args":[{"type":"function","value":{"functionName":"try_cast","args":[{"type":"field","value":{"field":"usage_amount","streamAlias":null}},{"type":"castType","value":"DOUBLE"}]}}]}]},"config":{"promql_legend":""}}],"layout":{"x":0,"y":0,"w":12,"h":13,"i":1}}]}]}"#;
let (status, body) = make_request(
&app,
Method::POST,
&format!("/api/{}/dashboards", "e2e"),
Some(headers.clone()),
Some(body_str.to_string()),
)
.await;
assert!(
status.is_success(),
"create failed: {}",
String::from_utf8_lossy(&body)
);
let created: json::Value = json::from_slice(&body).unwrap();
let dashboard_id = created["v8"]["dashboardId"].as_str().unwrap().to_string();
let (_status, body) = make_request(
&app,
Method::GET,
&format!("/api/{}/dashboards/{dashboard_id}", "e2e"),
Some(headers),
None,
)
.await;
let fetched: json::Value = json::from_slice(&body).unwrap();
let y = &fetched["v8"]["tabs"][0]["panels"][0]["queries"][0]["fields"]["y"][0];
assert_eq!(y["functionName"], "sum");
let cast = &y["args"][0]["value"];
assert_eq!(cast["functionName"], "try_cast");
assert_eq!(cast["args"][0]["value"]["field"], "usage_amount");
assert_eq!(cast["args"][1]["value"], "DOUBLE");
e2e_delete_dashboard(&dashboard_id).await;
}
async fn e2e_delete_dashboard(dashboard_id: &str) {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::DELETE,
&format!("/api/{}/dashboards/{dashboard_id}", "e2e"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_post_trace() {
let auth = setup();
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/trace_input.json");
let body_str = fs::read_to_string(path).expect("Read file failed");
// app
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/traces", "e2e"),
Some(headers),
Some(body_str),
)
.await;
assert!(status.is_success());
}
async fn e2e_post_metrics() {
let auth = setup();
let loc_label: Vec<prometheus_rpc::Label> = vec![
prometheus_rpc::Label {
name: "__name__".to_string(),
value: "grafana_api_dashboard_save_milliseconds_count".to_string(),
},
prometheus_rpc::Label {
name: "cluster".to_string(),
value: "prom-k8s".to_string(),
},
prometheus_rpc::Label {
name: "__replica__".to_string(),
value: "prom-k8s-0".to_string(),
},
];
let mut loc_samples: Vec<prometheus_rpc::Sample> = vec![];
for i in 1..2 {
loc_samples.push(prometheus_rpc::Sample {
value: i as f64,
timestamp: Utc::now().timestamp_micros(),
});
}
loc_samples.push(prometheus_rpc::Sample {
value: f64::NEG_INFINITY,
timestamp: Utc::now().timestamp_micros(),
});
loc_samples.push(prometheus_rpc::Sample {
value: f64::INFINITY,
timestamp: Utc::now().timestamp_micros(),
});
loc_samples.push(prometheus_rpc::Sample {
value: f64::NAN,
timestamp: Utc::now().timestamp_micros(),
});
let exemplar = |trace_id: &str| prometheus_rpc::Exemplar {
labels: vec![
prometheus_rpc::Label {
name: "trace_id".to_string(),
value: trace_id.to_string(),
},
prometheus_rpc::Label {
name: "span_id".to_string(),
value: "def".to_string(),
},
],
value: 1.0,
timestamp: Utc::now().timestamp_millis(),
};
let loc_hist: Vec<prometheus_rpc::Histogram> = vec![];
let ts = prometheus_rpc::TimeSeries {
labels: loc_label.clone(),
samples: loc_samples,
exemplars: vec![exemplar("with-sample")],
histograms: loc_hist,
};
// Prometheus sends an exemplar as a series entry of its own, without the sample
let exemplar_only = prometheus_rpc::TimeSeries {
labels: loc_label,
samples: vec![],
exemplars: vec![exemplar("exemplar-only")],
histograms: vec![],
};
let metadata: Vec<prometheus_rpc::MetricMetadata> = vec![];
let wr_req: prometheus_rpc::WriteRequest = prometheus_rpc::WriteRequest {
timeseries: vec![ts, exemplar_only],
metadata,
};
let mut out = BytesMut::with_capacity(wr_req.encoded_len());
wr_req.encode(&mut out).expect("Out of memory");
let data: Bytes = out.into();
let body = snap::raw::Encoder::new()
.compress_vec(&data)
.expect("Out of memory");
// app
let app = init_test_router();
let mut headers = HeaderMap::new();
headers.insert(
"X-Prometheus-Remote-Write-Version",
HeaderValue::from_static("0.1.0"),
);
headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static("snappy"));
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/x-protobuf"),
);
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(auth.1).expect("Invalid auth header"),
);
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api/{}/prometheus/api/v1/write", "e2e"));
let mut req_builder = req;
for (key, value) in headers.iter() {
req_builder = req_builder.header(key, value);
}
let req = req_builder
.body(Body::from(body))
.expect("Failed to build request");
let response = app
.clone()
.oneshot(req)
.await
.expect("Failed to execute request");
let status = response.status();
assert!(
status.is_success(),
"Prometheus write failed with status: {}. This may indicate memtable overflow or resource constraints.",
status
);
}
async fn e2e_get_org_summary() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!("/api/{}/summary", "e2e"),
Some(headers),
None,
)
.await;
log::info!("{:?}", status);
assert!(status.is_success());
}
async fn e2e_post_alert_template() {
let auth = setup();
let body_str = r#"{"name":"slackTemplate","body":"{\"text\":\"For stream {stream_name} of organization {org_name} alert {alert_name} of type {alert_type} is active app_name {app_name}\"}"}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, body) = make_request(
&app,
Method::POST,
&format!("/api/{}/alerts/templates", "e2e"),
Some(headers),
Some(body_str.to_string()),
)
.await;
let text = String::from_utf8_lossy(&body).to_string();
println!("e2e_post_alert_template: status: {status:?}, text: {text:?}");
assert!(status.is_success());
}
async fn e2e_get_alert_template() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!("/api/{}/alerts/templates/{}", "e2e", "slackTemplate"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_delete_alert_template() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::DELETE,
&format!("/api/{}/alerts/templates/{}", "e2e", "slackTemplate"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_post_alert_email_template() {
let auth = setup();
let body_str = r#"{"name":"email_template","body":"This is email for {alert_name}.","type":"email","title":"Email Subject"}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/alerts/templates", "e2e"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
async fn e2e_get_alert_email_template() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!("/api/{}/alerts/templates/{}", "e2e", "email_template"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_delete_alert_email_template() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::DELETE,
&format!("/api/{}/alerts/templates/{}", "e2e", "email_template"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_list_alert_template() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!("/api/{}/alerts/templates", "e2e"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_post_alert_destination() {
let auth = setup();
let body_str = r#"{
"name": "slack",
"url": "https://dummy/alert",
"method": "post",
"template": "slackTemplate",
"headers":{
"x_org_id":"Test_header"
}
}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/alerts/destinations", "e2e"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
async fn e2e_get_alert_destination() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!("/api/{}/alerts/destinations/{}", "e2e", "slack"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_delete_alert_destination() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::DELETE,
&format!("/api/{}/alerts/destinations/{}", "e2e", "slack"),
Some(headers),
None,
)
.await;
log::info!("{:?}", status);
assert!(status.is_success());
}
async fn e2e_post_alert_email_destination() {
let auth = setup();
let body_str = r#"{"url":"","method":"post","skip_tls_verify":false,"template":"email_template","headers":{},"name":"email","type":"email","emails":["nonadmin@example.com"]}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/alerts/destinations", "e2e"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
async fn e2e_post_alert_email_destination_should_fail() {
let auth = setup();
let body_str = r#"{"url":"","method":"post","skip_tls_verify":false,"template":"email_template","headers":{},"name":"email_fail","type":"email","emails":["nonadmin2@example.com"]}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/alerts/destinations", "e2e"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_client_error());
}
async fn e2e_get_alert_email_destination() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!("/api/{}/alerts/destinations/{}", "e2e", "email"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_delete_alert_email_destination() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::DELETE,
&format!("/api/{}/alerts/destinations/{}", "e2e", "email"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_list_alert_destinations() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!("/api/{}/alerts/destinations", "e2e"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_post_sns_alert_template() {
let auth = setup();
let body_str = r#"{
"name": "snsTemplate",
"body": "{\"default\": \"SNS alert {alert_name} triggered for {stream_name} in {org_name}\"}"
}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/alerts/templates", "e2e"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
async fn e2e_post_sns_alert_destination() {
let auth = setup();
let body_str = r#"{
"name": "sns_alert",
"type": "sns",
"sns_topic_arn": "arn:aws:sns:us-east-1:123456789012:MyTopic",
"aws_region": "us-east-1",
"template": "snsTemplate"
}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/{}/alerts/destinations", "e2e"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
async fn e2e_get_sns_alert_destination() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, body) = make_request(
&app,
Method::GET,
&format!("/api/{}/alerts/destinations/{}", "e2e", "sns_alert"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
// Optionally, deserialize and check the response body
let destination: Destination = serde_json::from_slice(&body).unwrap();
assert_eq!(destination.destination_type, DestinationType::Sns);
assert_eq!(
destination.sns_topic_arn,
Some("arn:aws:sns:us-east-1:123456789012:MyTopic".to_string())
);
assert_eq!(destination.aws_region, Some("us-east-1".to_string()));
}
async fn e2e_list_alert_destinations_with_sns() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, body) = make_request(
&app,
Method::GET,
&format!("/api/{}/alerts/destinations", "e2e"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
// Optionally, deserialize and check the response body
let destinations: Vec<Destination> = serde_json::from_slice(&body).unwrap();
assert!(
destinations
.iter()
.any(|d| d.destination_type == DestinationType::Sns)
);
}
async fn e2e_update_sns_alert_destination() {
let auth = setup();
let body_str = r#"{
"name": "sns_alert",
"type": "sns",
"sns_topic_arn": "arn:aws:sns:us-west-2:123456789012:UpdatedTopic",
"aws_region": "us-west-2",
"template": "snsTemplate"
}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::PUT,
&format!("/api/{}/alerts/destinations/{}", "e2e", "sns_alert"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
async fn e2e_delete_sns_alert_destination() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::DELETE,
&format!("/api/{}/alerts/destinations/{}", "e2e", "sns_alert"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_post_alert_with_sns_destination() {
let auth = setup();
let body_str = r#"{
"name": "sns_test_alert",
"stream_type": "logs",
"stream_name": "olympics_schema",
"is_real_time": false,
"query_condition": {
"conditions": [{
"column": "level",
"operator": "=",
"value": "error"
}]
},
"trigger_condition": {
"period": 5,
"threshold": 1,
"silence": 10
},
"destinations": ["sns_alert"],
"context_attributes": {
"app_name": "TestApp"
}
}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/v2/{}/alerts", "e2e"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
}
async fn e2e_delete_alert_with_sns_destination() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let alert = db::alerts::alert::get_by_name(
"e2e",
config::meta::stream::StreamType::Logs,
"olympics_schema",
"sns_test_alert",
)
.await;
assert!(alert.is_ok());
let alert = alert.unwrap();
assert!(alert.is_some());
let alert = alert.unwrap();
let id = alert.id;
assert!(id.is_some());
let id = id.unwrap();
let (status, _body) = make_request(
&app,
Method::DELETE,
&format!("/api/v2/{}/alerts/{}", "e2e", id),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_post_alert_multirange() {
let auth = setup();
let body_str = r#"{
"name": "alert_multi_range",
"stream_type": "logs",
"stream_name": "olympics_schema",
"is_real_time": false,
"query_condition": {
"conditions": [{
"column": "country",
"operator": "=",
"value": "USA"
}],
"multi_time_range": [{
"offSet": "1440m"
}]
},
"trigger_condition": {
"period": 5,
"threshold": 1,
"silence": 0,
"frequency": 1
},
"destinations": ["slack"],
"context_attributes":{
"app_name":"App1"
}
}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/v2/{}/alerts", "e2e"),
Some(headers),
Some(body_str.to_string()),
)
.await;
assert!(status.is_success());
// Get the alert with the same stream name
let alert = db::alerts::alert::get_by_name(
"e2e",
config::meta::stream::StreamType::Logs,
"olympics_schema",
"alert_multi_range",
)
.await;
assert!(alert.is_ok());
let alert = alert.unwrap();
assert!(alert.is_some());
let alert = alert.unwrap();
assert_eq!(alert.stream_type, config::meta::stream::StreamType::Logs);
assert_eq!(alert.stream_name, "olympics_schema");
assert_eq!(alert.name, "alert_multi_range");
let id = alert.id;
assert!(id.is_some());
let id = id.unwrap();
// Check the trigger
let trigger = db::scheduler::exists(
"e2e",
config::meta::triggers::TriggerModule::Alert,
&id.to_string(),
)
.await;
assert!(trigger);
}
async fn e2e_delete_alert_multirange() {
let auth = setup();
let app = init_test_router();
// Get the alert with the same stream name
let alert = db::alerts::alert::get_by_name(
"e2e",
config::meta::stream::StreamType::Logs,
"olympics_schema",
"alert_multi_range",
)
.await;
assert!(alert.is_ok());
let alert = alert.unwrap();
assert!(alert.is_some());
let alert = alert.unwrap();
let id = alert.id;
assert!(id.is_some());
let id = id.unwrap();
// Use the v2 api to delete the alert
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::DELETE,
&format!("/api/v2/{}/alerts/{}", "e2e", id),
Some(headers),
None,
)
.await;
assert!(status.is_success());
let trigger = db::scheduler::exists(
"e2e",
config::meta::triggers::TriggerModule::Alert,
&id.to_string(),
)
.await;
assert!(!trigger);
}
async fn e2e_post_alert() {
let auth = setup();
let body_str = r#"{
"name": "alertChk",
"stream_type": "logs",
"stream_name": "olympics_schema",
"is_real_time": false,
"enabled": true,
"query_condition": {
"conditions": [{
"column": "country",
"operator": "NotContains",
"value": "AUT"
}]
},
"trigger_condition": {
"period": 60,
"threshold": 1,
"silence": 0,
"frequency": 60,
"operator": ">="
},
"destinations": ["slack"],
"context_attributes":{
"app_name":"App1"
}
}"#;
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::POST,
&format!("/api/v2/{}/alerts", "e2e"),
Some(headers.clone()),
Some(body_str.to_string()),
)
.await;
println!("{:?}", status);
assert!(status.is_success());
// Get the alert list
let (status, body) = make_request(
&app,
Method::GET,
&format!("/api/v2/{}/alerts", "e2e"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
let alert_list_response: ListAlertsResponseBody = serde_json::from_slice(&body).unwrap();
assert!(!alert_list_response.list.is_empty());
let alert = alert_list_response
.list
.iter()
.find(|a| a.name == "alertChk");
assert!(alert.is_some());
let alert = alert.unwrap();
assert_eq!(alert.name, "alertChk");
assert!(alert.enabled);
let id = alert.alert_id;
let id = id.to_string();
// Check the trigger
let trigger = db::scheduler::exists(
"e2e",
config::meta::triggers::TriggerModule::Alert,
&id.to_string(),
)
.await;
assert!(trigger);
}
async fn e2e_get_alert() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
// Get the alert list
let (status, body) = make_request(
&app,
Method::GET,
&format!("/api/v2/{}/alerts", "e2e"),
Some(headers.clone()),
None,
)
.await;
assert!(status.is_success());
let alert_list_response: ListAlertsResponseBody = serde_json::from_slice(&body).unwrap();
assert!(!alert_list_response.list.is_empty());
let alert = alert_list_response
.list
.iter()
.find(|a| a.name == "alertChk");
assert!(alert.is_some());
let alert = alert.unwrap();
assert_eq!(alert.name, "alertChk");
assert!(alert.enabled);
let id = alert.alert_id;
let id = id.to_string();
let (status, body) = make_request(
&app,
Method::GET,
&format!("/api/v2/{}/alerts/{}", "e2e", id),
Some(headers),
None,
)
.await;
log::info!("{:?}", status);
assert!(status.is_success());
let alert_response: GetAlertResponseBody = serde_json::from_slice(&body).unwrap();
assert_eq!(alert_response.0.name, "alertChk");
assert_eq!(
alert_response.0.stream_type,
openobserve_api_management::models::alerts::StreamType::Logs
);
assert_eq!(alert_response.0.stream_name, "olympics_schema");
assert!(alert_response.0.enabled);
}
async fn e2e_handle_alert_after_destination_retries() {
let alert = db::alerts::alert::get_by_name(
"e2e",
config::meta::stream::StreamType::Logs,
"olympics_schema",
"alertChk",
)
.await;
assert!(alert.is_ok());
let alert = alert.unwrap();
assert!(alert.is_some());
let alert = alert.unwrap();
let id = alert.id;
assert!(id.is_some());
let id = id.unwrap();
let now = Utc::now().timestamp_micros();
let mins_3_later = now
+ Duration::try_minutes(3)
.unwrap()
.num_microseconds()
.unwrap();
let trigger = Trigger {
id: 1,
claim_epoch: 0,
org: "e2e".to_string(),
module: config::meta::triggers::TriggerModule::Alert,
module_key: id.to_string(),
start_time: Some(now),
end_time: Some(mins_3_later),
next_run_at: now,
is_realtime: false,
is_silenced: false,
status: config::meta::triggers::TriggerStatus::Processing,
retries: 2,
data: "{}".to_string(),
};
let trace_id = "test_trace_id";
let res = handle_triggers(trace_id, trigger).await;
// This alert has an invalid destination, but handle_triggers should succeed.
// Note: May get partial results if files are cleaned up during test execution.
if let Err(ref e) = res {
let err_msg = e.to_string();
// Accept partial response errors due to file cleanup race conditions in tests
if !err_msg.contains("Partial response") && !err_msg.contains("parquet file not found")
{
panic!("handle_triggers failed unexpectedly: {:?}", res);
}
}
let trigger = db::scheduler::get(
"e2e",
config::meta::triggers::TriggerModule::Alert,
&id.to_string(),
)
.await;
assert!(trigger.is_ok());
let trigger = trigger.unwrap();
assert!(trigger.next_run_at > now && trigger.retries == 0);
}
async fn e2e_handle_alert_reached_max_retries() {
let now = Utc::now().timestamp_micros();
let mins_3_later = now
+ Duration::try_minutes(3)
.unwrap()
.num_microseconds()
.unwrap();
let alert = db::alerts::alert::get_by_name(
"e2e",
config::meta::stream::StreamType::Logs,
"olympics_schema",
"alertChk",
)
.await;
assert!(alert.is_ok());
let alert = alert.unwrap();
assert!(alert.is_some());
let alert = alert.unwrap();
let id = alert.id;
assert!(id.is_some());
let id = id.unwrap();
let trigger = Trigger {
id: 1,
claim_epoch: 0,
org: "e2e".to_string(),
module: config::meta::triggers::TriggerModule::Alert,
module_key: id.to_string(),
start_time: Some(now),
end_time: Some(mins_3_later),
next_run_at: now,
is_realtime: false,
is_silenced: false,
status: config::meta::triggers::TriggerStatus::Processing,
retries: 3,
data: "{}".to_string(),
};
let trace_id = "test_trace_id";
let res = handle_triggers(trace_id, trigger).await;
// This alert has an invalid destination
assert!(res.is_ok());
let trigger = db::scheduler::get(
"e2e",
config::meta::triggers::TriggerModule::Alert,
&id.to_string(),
)
.await;
assert!(trigger.is_ok());
let trigger = trigger.unwrap();
assert!(trigger.next_run_at > now && trigger.retries == 0);
}
async fn e2e_handle_alert_after_evaluation_retries() {
let mut alert: Alert = Default::default();
alert.name = "test_alert_wrong_sql".to_string();
alert.stream_type = "logs".into();
alert.stream_name = "olympics_schema".to_string();
alert.is_real_time = false;
alert.enabled = true;
alert.query_condition = QueryCondition {
query_type: "sql".into(),
conditions: None,
sql: Some("SELEC country FROM \"olympics_schema\"".to_string()),
..Default::default()
};
alert.trigger_condition = TriggerCondition {
period: 60,
threshold: 1,
silence: 0,
frequency: 3600,
operator: Operator::GreaterThanEquals,
..Default::default()
};
alert.destinations = vec!["slack".to_string()];
let res = db::alerts::alert::set("e2e", alert, true).await;
assert!(res.is_ok());
let alert = res.unwrap();
let id = alert.id;
assert!(id.is_some());
let id = id.unwrap();
let now = Utc::now().timestamp_micros();
let mins_3_later = now
+ Duration::try_minutes(3)
.unwrap()
.num_microseconds()
.unwrap();
let trigger = Trigger {
id: 1,
claim_epoch: 0,
org: "e2e".to_string(),
module: config::meta::triggers::TriggerModule::Alert,
module_key: id.to_string(),
start_time: Some(now),
end_time: Some(mins_3_later),
next_run_at: now,
is_realtime: false,
is_silenced: false,
status: config::meta::triggers::TriggerStatus::Processing,
retries: 2,
data: "{}".to_string(),
};
let trace_id = "test_trace_id";
let res = handle_triggers(trace_id, trigger).await;
// In case of alert evaluation errors, this error is returned
assert!(res.is_err());
let trigger = db::scheduler::get(
"e2e",
config::meta::triggers::TriggerModule::Alert,
&id.to_string(),
)
.await;
assert!(trigger.is_ok());
let trigger = trigger.unwrap();
assert!(trigger.next_run_at > now && trigger.retries == 0);
let res =
db::alerts::alert::delete_by_id(infra::db::get_orm_client_rw().await, "e2e", id).await;
assert!(res.is_ok());
}
async fn e2e_delete_alert() {
let auth = setup();
let app = init_test_router();
let alert = db::alerts::alert::get_by_name(
"e2e",
config::meta::stream::StreamType::Logs,
"olympics_schema",
"alertChk",
)
.await;
assert!(alert.is_ok());
let alert = alert.unwrap();
assert!(alert.is_some());
let alert = alert.unwrap();
let id = alert.id;
assert!(id.is_some());
let id = id.unwrap();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::DELETE,
&format!("/api/v2/{}/alerts/{}", "e2e", id),
Some(headers),
None,
)
.await;
log::info!("{:?}", status);
assert!(status.is_success());
let trigger = db::scheduler::exists(
"e2e",
config::meta::triggers::TriggerModule::Alert,
&id.to_string(),
)
.await;
assert!(!trigger);
}
async fn e2e_list_alerts() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!("/api/v2/{}/alerts", "e2e"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_list_real_time_alerts() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::GET,
&format!("/api/v2/{}/alerts", "e2e"),
Some(headers),
None,
)
.await;
assert!(status.is_success());
}
async fn e2e_health_check() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) =
make_request(&app, Method::GET, "/healthz", Some(headers), None).await;
assert!(status.is_success());
}
async fn e2e_config() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(&app, Method::GET, "/config", Some(headers), None).await;
assert!(status.is_success());
}
// Helper function to create pipeline via API
async fn e2e_post_pipeline(pipeline_data: Pipeline) {
let auth = setup();
let body_str = serde_json::to_string(&pipeline_data).unwrap();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, body) = make_request(
&app,
Method::POST,
&format!("/api/{}/pipelines", "e2e"),
Some(headers),
Some(body_str),
)
.await;
if !status.is_success() {
println!("Response body: {}", String::from_utf8_lossy(&body));
panic!("Failed to create pipeline");
}
}
// Derived Stream Integration Tests
async fn e2e_create_test_pipeline() {
// Create a test pipeline with derived stream for testing
let pipeline_data = Pipeline {
id: "test_derived_stream_pipeline".to_string(),
version: 1,
enabled: true,
org: "e2e".to_string(),
name: "test_derived_stream".to_string(),
description: "Test pipeline for derived stream integration tests".to_string(),
source: PipelineSource::Scheduled(DerivedStream {
org_id: "e2e".to_string(),
stream_type: StreamType::Logs,
query_condition: QueryCondition {
query_type: config::meta::alerts::QueryType::SQL,
sql: Some("SELECT _timestamp FROM \"olympics_schema\"".to_string()),
..Default::default()
},
trigger_condition: TriggerCondition {
period: 5, // 5 minutes
frequency: 300, // 5 minutes in seconds
..Default::default()
},
tz_offset: 0,
start_at: None,
delay: None,
}),
kind: Default::default(),
nodes: vec![
// Source node (query node for scheduled pipeline)
config::meta::pipeline::components::Node::new(
"source-node-1".to_string(),
config::meta::pipeline::components::NodeData::Query(DerivedStream {
org_id: "e2e".to_string(),
stream_type: StreamType::Logs,
query_condition: QueryCondition {
query_type: config::meta::alerts::QueryType::SQL,
sql: Some("SELECT _timestamp FROM \"olympics_schema\"".to_string()),
..Default::default()
},
trigger_condition: TriggerCondition {
period: 5,
frequency: 300,
..Default::default()
},
tz_offset: 0,
start_at: None,
delay: None,
}),
100.0,
50.0,
"input".to_string(),
),
// Destination node (output stream)
config::meta::pipeline::components::Node::new(
"dest-node-1".to_string(),
config::meta::pipeline::components::NodeData::Stream(
config::meta::stream::StreamParams {
org_id: "e2e".to_string().into(),
stream_name: "test_derived_output_stream".to_string().into(),
stream_type: StreamType::Logs,
},
),
100.0,
200.0,
"output".to_string(),
),
],
edges: vec![config::meta::pipeline::components::Edge::new(
"source-node-1".to_string(),
"dest-node-1".to_string(),
)],
};
// Save pipeline using API call
e2e_post_pipeline(pipeline_data).await;
}
async fn e2e_handle_derived_stream_success() {
// list the pipelines and choose the first one using API
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, body) =
make_request(&app, Method::GET, "/api/e2e/pipelines", Some(headers), None).await;
assert!(status.is_success());
let pipeline_list: PipelineList = json::from_slice(&body).unwrap();
let pipeline = pipeline_list.list.first();
assert!(pipeline.is_some());
let pipeline = pipeline.unwrap();
let now = Utc::now().timestamp_micros();
let mins_5_later = now
+ Duration::try_minutes(5)
.unwrap()
.num_microseconds()
.unwrap();
let module_key = format!("logs/e2e/test_derived_stream/{}", pipeline.id);
println!(
"e2e handle derived stream success module_key: {}, where pipeline name is {}",
module_key, pipeline.name
);
let trigger = Trigger {
id: 1,
claim_epoch: 0,
org: "e2e".to_string(),
module: TriggerModule::DerivedStream,
module_key: module_key.clone(),
start_time: Some(now),
end_time: Some(mins_5_later),
next_run_at: now,
is_realtime: false,
is_silenced: false,
status: config::meta::triggers::TriggerStatus::Processing,
retries: 0,
data: "{}".to_string(),
};
let trace_id = "test_derived_stream_trace_id";
let res = handle_triggers(trace_id, trigger).await;
// Should succeed even with empty data
assert!(res.is_ok());
// Verify trigger was updated - retry with exponential backoff since trigger processing
// is async and involves batch updates that may not flush immediately in CI
let mut attempts = 0;
let max_attempts = 20;
let mut delay_ms = 100u64;
let mut trigger_updated = false;
while attempts < max_attempts {
let trigger =
db::scheduler::get("e2e", TriggerModule::DerivedStream, &module_key).await;
if let Ok(trigger) = trigger
&& let Ok(scheduled_trigger_data) =
serde_json::from_str::<ScheduledTriggerData>(&trigger.data)
&& scheduled_trigger_data.period_end_time.is_some()
{
assert!(scheduled_trigger_data.period_end_time.unwrap() > 0);
assert!(trigger.status == TriggerStatus::Waiting);
assert!(trigger.next_run_at > now && trigger.retries == 0);
trigger_updated = true;
break;
}
attempts += 1;
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
// Exponential backoff: 100ms, 200ms, 400ms, 800ms, 1000ms (capped)
delay_ms = std::cmp::min(delay_ms * 2, 1000);
}
assert!(
trigger_updated,
"Trigger was not updated after {max_attempts} attempts"
);
}
async fn e2e_handle_derived_stream_pipeline_not_found() {
let now = Utc::now().timestamp_micros();
let mins_5_later = now
+ Duration::try_minutes(5)
.unwrap()
.num_microseconds()
.unwrap();
let module_key = "logs/e2e/nonexistent_pipeline/invalid_id".to_string();
let trigger = Trigger {
id: 2,
claim_epoch: 0,
org: "e2e".to_string(),
module: TriggerModule::DerivedStream,
module_key,
start_time: Some(now),
end_time: Some(mins_5_later),
next_run_at: now,
is_realtime: false,
is_silenced: false,
status: config::meta::triggers::TriggerStatus::Processing,
retries: 0,
data: "{}".to_string(),
};
let trace_id = "test_derived_stream_not_found_trace_id";
let res = handle_triggers(trace_id, trigger).await;
// Should fail with pipeline not found error
assert!(res.is_err());
assert!(
res.unwrap_err()
.to_string()
.contains("Pipeline associated with trigger not found")
);
}
async fn e2e_handle_derived_stream_max_retries() {
// list pipelines using API
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, body) =
make_request(&app, Method::GET, "/api/e2e/pipelines", Some(headers), None).await;
assert!(status.is_success());
let pipeline_list: PipelineList = json::from_slice(&body).unwrap();
let pipelines = pipeline_list.list.first();
assert!(pipelines.is_some());
let pipeline = pipelines.unwrap();
let now = Utc::now().timestamp_micros();
let mins_5_later = now
+ Duration::try_minutes(5)
.unwrap()
.num_microseconds()
.unwrap();
let module_key = format!("logs/e2e/test_derived_stream/{}", pipeline.id);
let trigger = Trigger {
id: 3,
claim_epoch: 0,
org: "e2e".to_string(),
module: TriggerModule::DerivedStream,
module_key: module_key.clone(),
start_time: Some(now),
end_time: Some(mins_5_later),
next_run_at: now,
is_realtime: false,
is_silenced: false,
status: config::meta::triggers::TriggerStatus::Processing,
retries: 5, // Max retries reached
data: "{}".to_string(),
};
let trace_id = "test_derived_stream_max_retries_trace_id";
let res = handle_triggers(trace_id, trigger).await;
// Should succeed but skip to next run due to max retries
assert!(res.is_ok());
// Verify trigger was updated with next run time and retries reset
let trigger = db::scheduler::get("e2e", TriggerModule::DerivedStream, &module_key).await;
assert!(trigger.is_ok());
let trigger = trigger.unwrap();
assert!(trigger.next_run_at > now && trigger.retries == 0);
}
async fn e2e_handle_derived_stream_evaluation_failure() {
// Create a pipeline that queries a non-existent stream to cause evaluation failure.
// evaluate_scheduled() returns Err when the stream is not found in schema cache,
// which should increment the trigger's retries counter.
let pipeline_data = Pipeline {
id: "test_derived_stream_pipeline_invalid".to_string(),
version: 1,
enabled: true,
org: "e2e".to_string(),
name: "test_derived_stream_invalid".to_string(),
description: "Test pipeline with non-existent stream to cause evaluation failure"
.to_string(),
source: PipelineSource::Scheduled(DerivedStream {
org_id: "e2e".to_string(),
stream_type: StreamType::Logs,
query_condition: QueryCondition {
query_type: config::meta::alerts::QueryType::SQL,
sql: Some("SELECT * FROM \"nonexistent_stream_xyz_abc_test\"".to_string()),
..Default::default()
},
trigger_condition: TriggerCondition {
period: 5,
frequency: 300,
..Default::default()
},
tz_offset: 0,
start_at: None,
delay: None,
}),
kind: Default::default(),
nodes: vec![
// Source node (query node for scheduled pipeline with non-existent stream)
config::meta::pipeline::components::Node::new(
"source-node-2".to_string(),
config::meta::pipeline::components::NodeData::Query(DerivedStream {
org_id: "e2e".to_string(),
stream_type: StreamType::Logs,
query_condition: QueryCondition {
query_type: config::meta::alerts::QueryType::SQL,
sql: Some(
"SELECT * FROM \"nonexistent_stream_xyz_abc_test\"".to_string(),
),
..Default::default()
},
trigger_condition: TriggerCondition {
period: 5,
frequency: 300,
..Default::default()
},
tz_offset: 0,
start_at: None,
delay: None,
}),
150.0,
50.0,
"input".to_string(),
),
// Destination node (output stream)
config::meta::pipeline::components::Node::new(
"dest-node-2".to_string(),
config::meta::pipeline::components::NodeData::Stream(
config::meta::stream::StreamParams {
org_id: "e2e".to_string().into(),
stream_name: "test_invalid_pipeline_output".to_string().into(),
stream_type: StreamType::Logs,
},
),
150.0,
200.0,
"output".to_string(),
),
],
edges: vec![config::meta::pipeline::components::Edge::new(
"source-node-2".to_string(),
"dest-node-2".to_string(),
)],
};
// Save pipeline directly to DB (bypassing API validation) to simulate a pipeline
// with an invalid query that was saved before validation was added, or to test
// what happens at evaluation time when the stream does not exist.
openobserve_core::pipeline::db::set(&pipeline_data)
.await
.expect("Failed to set pipeline in DB");
// Create the scheduler trigger directly with needs_validated=false so the
// non-existent stream does not fail the pre-save test run.
let derived_stream = match &pipeline_data.source {
PipelineSource::Scheduled(ds) => ds.clone(),
_ => panic!("Expected scheduled pipeline"),
};
openobserve_core::alerts::derived_streams::save(
derived_stream,
&pipeline_data.name,
&pipeline_data.id,
false,
)
.await
.expect("Failed to save derived stream trigger");
let pipeline = get_pipeline_from_api(pipeline_data.name.as_str()).await;
let now = Utc::now().timestamp_micros();
let mins_5_later = now
+ Duration::try_minutes(5)
.unwrap()
.num_microseconds()
.unwrap();
let module_key = format!("logs/e2e/test_derived_stream_invalid/{}", pipeline.id);
let trigger = Trigger {
id: 4,
claim_epoch: 0,
org: "e2e".to_string(),
module: TriggerModule::DerivedStream,
module_key: module_key.clone(),
start_time: Some(now),
end_time: Some(mins_5_later),
next_run_at: now,
is_realtime: false,
is_silenced: false,
status: config::meta::triggers::TriggerStatus::Processing,
retries: 0,
data: "{}".to_string(),
};
let trace_id = "test_derived_stream_eval_failure_trace_id";
let _ = handle_triggers(trace_id, trigger).await;
// Should succeed (handler handles errors gracefully) but increment retries
// Verify trigger retries were incremented
let trigger = db::scheduler::get("e2e", TriggerModule::DerivedStream, &module_key).await;
assert!(trigger.is_ok());
let trigger = trigger.unwrap();
assert!(trigger.retries > 0);
// Clean up the invalid pipeline
let _ = openobserve_core::pipeline::db::delete(&pipeline.id).await;
}
// Test to handle case where pipeline triggers for invalid timerange where start time
// is greater than end time because of the delay feature which is turned on after alert
// is already created and is running for some time.
async fn test_derived_stream_invalid_timerange_delay_scenario() {
// Create a pipeline with derived stream that has delay configured
let pipeline_data = Pipeline {
id: "test_invalid_timerange_pipeline".to_string(),
version: 1,
enabled: true,
org: "e2e".to_string(),
name: "test_invalid_timerange_pipeline".to_string(),
description: "Test pipeline for invalid timerange delay scenario".to_string(),
source: PipelineSource::Scheduled(DerivedStream {
org_id: "e2e".to_string(),
stream_type: StreamType::Logs,
query_condition: QueryCondition {
query_type: config::meta::alerts::QueryType::SQL,
sql: Some("SELECT _timestamp, city FROM \"olympics_schema\"".to_string()),
..Default::default()
},
trigger_condition: TriggerCondition {
period: 5, // 5 minutes
frequency: 300, // 5 minutes in seconds
..Default::default()
},
tz_offset: 0,
start_at: None,
delay: Some(10), // 10 minutes delay
}),
kind: Default::default(),
nodes: vec![
// Source node (query node for scheduled pipeline)
config::meta::pipeline::components::Node::new(
"source-node-1".to_string(),
config::meta::pipeline::components::NodeData::Query(DerivedStream {
org_id: "e2e".to_string(),
stream_type: StreamType::Logs,
query_condition: QueryCondition {
query_type: config::meta::alerts::QueryType::SQL,
sql: Some(
"SELECT _timestamp, city FROM \"olympics_schema\"".to_string(),
),
..Default::default()
},
trigger_condition: TriggerCondition {
period: 5,
frequency: 300,
..Default::default()
},
tz_offset: 0,
start_at: None,
delay: Some(10), // 10 minutes delay
}),
100.0,
50.0,
"input".to_string(),
),
// Destination node (output stream)
config::meta::pipeline::components::Node::new(
"dest-node-1".to_string(),
config::meta::pipeline::components::NodeData::Stream(
config::meta::stream::StreamParams {
org_id: "e2e".to_string().into(),
stream_name: "derived_stream".to_string().into(),
stream_type: StreamType::Logs,
},
),
100.0,
200.0,
"output".to_string(),
),
],
edges: vec![config::meta::pipeline::components::Edge::new(
"source-node-1".to_string(),
"dest-node-1".to_string(),
)],
};
// Create the pipeline
e2e_post_pipeline(pipeline_data.clone()).await;
let pipeline = get_pipeline_from_api(pipeline_data.name.as_str()).await;
// Create a trigger that will cause invalid timerange scenario
// Simulate a scenario where the trigger was created before delay was enabled
// and now the start time is greater than end time due to delay
let current_time = Utc::now().timestamp_micros();
// 5 minutes is period of the pipeline and 1 min is delay in processing the pipeline
let period_micros = Duration::try_minutes(6)
.unwrap()
.num_microseconds()
.unwrap();
let timeout = Duration::try_minutes(2)
.unwrap()
.num_microseconds()
.unwrap();
// Create trigger with start time that will be greater than end time after delay
let trigger_start_time = current_time - period_micros; // 5 minutes ago
let trigger = Trigger {
id: 1,
claim_epoch: 0,
org: "e2e".to_string(),
module: TriggerModule::DerivedStream,
module_key: format!("logs/e2e/test_invalid_timerange_pipeline/{}", pipeline.id),
next_run_at: current_time,
start_time: Some(current_time),
end_time: Some(current_time + timeout),
is_realtime: false,
is_silenced: false,
status: config::meta::triggers::TriggerStatus::Processing,
retries: 0,
data: serde_json::json!({
"period_end_time": trigger_start_time // This will cause start > end
})
.to_string(),
};
// Process the trigger - this should handle invalid timerange gracefully
let trace_id = "test_invalid_timerange_trace_id";
let result = handle_triggers(trace_id, trigger).await;
// Should not return an error, but should handle invalid timerange gracefully
assert!(result.is_ok());
// Get the trigger from the database
let trigger = db::scheduler::get(
"e2e",
TriggerModule::DerivedStream,
&format!("logs/e2e/test_invalid_timerange_pipeline/{}", pipeline.id),
)
.await;
assert!(trigger.is_ok());
let trigger = trigger.unwrap();
// Next run at should be greater than current time
assert!(trigger.next_run_at > current_time);
// And last period end time should not change
assert_eq!(
trigger.data,
serde_json::json!({
"period_end_time": trigger_start_time
})
.to_string()
);
// Clean up
let _ = openobserve_core::pipeline::db::delete(&pipeline.id).await;
// Also delete the trigger job from scheduled jobs table
let _ = db::scheduler::delete(
"e2e",
TriggerModule::DerivedStream,
&format!("logs/e2e/test_invalid_timerange_pipeline/{}", pipeline.id),
)
.await;
}
async fn test_derived_stream_invalid_timerange_with_cron_frequency() {
let auth = setup();
let app = init_test_router();
// Create a pipeline with derived stream using cron frequency
let pipeline_data = Pipeline {
id: "test_cron_invalid_timerange_pipeline".to_string(),
version: 1,
enabled: true,
org: "e2e".to_string(),
name: "test_cron_invalid_timerange_pipeline".to_string(),
description: "Test pipeline for invalid timerange with cron frequency".to_string(),
source: PipelineSource::Scheduled(DerivedStream {
org_id: "e2e".to_string(),
stream_type: StreamType::Logs,
query_condition: QueryCondition {
query_type: config::meta::alerts::QueryType::SQL,
sql: Some("SELECT _timestamp, city FROM \"olympics_schema\"".to_string()),
..Default::default()
},
trigger_condition: TriggerCondition {
period: 5, // 5 minutes
frequency: 0, // 0 for cron frequency
cron: "0 */5 * * * *".to_string(), // Every 5 minutes
frequency_type: config::meta::alerts::FrequencyType::Cron,
..Default::default()
},
tz_offset: 0,
start_at: None,
delay: Some(10), // 10 minutes delay
}),
kind: Default::default(),
nodes: vec![
// Source node (query node for scheduled pipeline)
config::meta::pipeline::components::Node::new(
"source-node-1".to_string(),
config::meta::pipeline::components::NodeData::Query(DerivedStream {
org_id: "e2e".to_string(),
stream_type: StreamType::Logs,
query_condition: QueryCondition {
query_type: config::meta::alerts::QueryType::SQL,
sql: Some(
"SELECT _timestamp, city FROM \"olympics_schema\"".to_string(),
),
..Default::default()
},
trigger_condition: TriggerCondition {
period: 5,
frequency: 0,
cron: "0 */5 * * * *".to_string(),
frequency_type: config::meta::alerts::FrequencyType::Cron,
..Default::default()
},
tz_offset: 0,
start_at: None,
delay: Some(10), // 10 minutes delay
}),
100.0,
50.0,
"input".to_string(),
),
// Destination node (output stream)
config::meta::pipeline::components::Node::new(
"dest-node-1".to_string(),
config::meta::pipeline::components::NodeData::Stream(
config::meta::stream::StreamParams {
org_id: "e2e".to_string().into(),
stream_name: "derived_stream".to_string().into(),
stream_type: StreamType::Logs,
},
),
100.0,
200.0,
"output".to_string(),
),
],
edges: vec![config::meta::pipeline::components::Edge::new(
"source-node-1".to_string(),
"dest-node-1".to_string(),
)],
};
// Create the pipeline
let headers = auth_headers(auth);
let (status, _body) = make_request(
&app,
Method::POST,
"/api/e2e/pipelines",
Some(headers),
Some(serde_json::to_string(&pipeline_data).unwrap()),
)
.await;
println!("test derived stream invalid timerange with cron frequency status: {status:?}");
assert!(status.is_success());
let pipeline = get_pipeline_from_api(pipeline_data.name.as_str()).await;
// Create trigger that will cause invalid timerange with cron frequency
let current_time = Utc::now().timestamp_micros();
let dur_20_mins = Duration::try_minutes(20)
.unwrap()
.num_microseconds()
.unwrap();
let period_micros = Duration::try_minutes(5)
.unwrap()
.num_microseconds()
.unwrap();
// Say, this trigger was last run at 15 mins ago
let last_end_time = current_time - dur_20_mins;
// Current end time needs to be aligned (as this is cron frequency)
let current_next_run_time =
TriggerCondition::align_time(last_end_time + period_micros, 0, Some(300), None); // This will cause start > end
let timeout = Duration::try_minutes(2)
.unwrap()
.num_microseconds()
.unwrap();
let trigger = Trigger {
id: 3,
claim_epoch: 0,
org: "e2e".to_string(),
module: TriggerModule::DerivedStream,
module_key: format!(
"logs/e2e/test_cron_invalid_timerange_pipeline/{}",
pipeline.id
),
next_run_at: current_next_run_time,
start_time: Some(current_time),
end_time: Some(current_time + timeout), // end < start
is_realtime: false,
is_silenced: false,
status: config::meta::triggers::TriggerStatus::Processing,
retries: 0,
data: serde_json::json!({
"period_end_time": last_end_time // This will cause start > end
})
.to_string(),
};
// Process the trigger
let trace_id = "test_cron_invalid_timerange_trace_id";
let result = handle_triggers(trace_id, trigger).await;
// Should handle invalid timerange with cron frequency gracefully
assert!(result.is_ok());
// Get the trigger from the database
let trigger = db::scheduler::get(
"e2e",
TriggerModule::DerivedStream,
&format!(
"logs/e2e/test_cron_invalid_timerange_pipeline/{}",
pipeline.id
),
)
.await;
assert!(trigger.is_ok());
let trigger = trigger.unwrap();
// Next run at should be greater than current time
assert!(trigger.next_run_at > current_next_run_time);
// Next run at should be less than current time, because the frequency is 5 mins
assert!(trigger.next_run_at < current_time);
assert_eq!(
trigger.data,
serde_json::json!({
"period_end_time": last_end_time
})
.to_string()
);
// Clean up
let _ = openobserve_core::pipeline::db::delete(&pipeline.id).await;
// Also delete the trigger job from scheduled jobs table
let _ = db::scheduler::delete(
"e2e",
TriggerModule::DerivedStream,
&format!(
"logs/e2e/test_cron_invalid_timerange_pipeline/{}",
pipeline.id
),
)
.await;
}
async fn e2e_cleanup_test_pipeline() {
// list the pipelines and choose the first one using API
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, body) =
make_request(&app, Method::GET, "/api/e2e/pipelines", Some(headers), None).await;
assert!(status.is_success());
let pipeline_list: PipelineList = json::from_slice(&body).unwrap();
let pipeline = pipeline_list.list.first();
assert!(pipeline.is_some());
let pipeline = pipeline.unwrap();
// Clean up test pipelines
let _ = openobserve_core::pipeline::db::delete(&pipeline.id).await;
}
async fn get_pipeline_from_api(pipeline_name: &str) -> ApiPipeline {
let auth = setup();
// Check if pipeline was saved successfully by doing a list using API
let app = init_test_router();
let headers = auth_headers(auth);
let (status, body) =
make_request(&app, Method::GET, "/api/e2e/pipelines", Some(headers), None).await;
assert!(status.is_success(), "Failed to list pipelines");
let pipeline_response: PipelineList = json::from_slice(&body).unwrap();
// Get the pipeline that matches the pipeline name
let pipeline = pipeline_response
.list
.iter()
.find(|p| p.name == pipeline_name);
assert!(pipeline.is_some(), "Pipeline not found");
let pipeline = pipeline.unwrap();
pipeline.clone()
}
// ==================== ENRICHMENT TABLE TESTS ====================
async fn e2e_save_enrichment_data_new_table() {
let _auth = setup();
let org_id = "e2e";
let table_name = "test_enrichment_table";
// Create test data
let mut payload = Vec::new();
let mut record1 = json::Map::new();
record1.insert("name".to_string(), json::Value::String("John".to_string()));
record1.insert("age".to_string(), json::Value::String("25".to_string()));
record1.insert(
"city".to_string(),
json::Value::String("New York".to_string()),
);
payload.push(record1);
let mut record2 = json::Map::new();
record2.insert("name".to_string(), json::Value::String("Jane".to_string()));
record2.insert("age".to_string(), json::Value::String("30".to_string()));
record2.insert(
"city".to_string(),
json::Value::String("Los Angeles".to_string()),
);
payload.push(record2);
// Call save_enrichment_data
let result = enrichment_data::enrichment_table::save_enrichment_data(
org_id, table_name, payload, false, // append_data = false
)
.await;
assert!(result.is_ok());
let response = result.unwrap();
assert!(response.status().is_success());
// Verify schema was created in database
let schema_exists = schema::stream_schema_exists(
org_id,
table_name,
config::meta::stream::StreamType::EnrichmentTables,
&mut std::collections::HashMap::new(),
)
.await;
println!("schema_exists: {schema_exists:?}");
assert!(schema_exists.has_fields);
// Verify schema cache was updated
let schema_key = format!(
"{}/{}/{}",
org_id,
config::meta::stream::StreamType::EnrichmentTables,
table_name
);
let stream_schemas = STREAM_SCHEMAS.read().await;
assert!(stream_schemas.contains_key(&schema_key));
drop(stream_schemas);
// Verify latest schema cache was updated
let stream_schemas_latest = STREAM_SCHEMAS_LATEST.read().await;
assert!(stream_schemas_latest.contains_key(&schema_key));
drop(stream_schemas_latest);
// Verify stream settings cache was updated
assert!(infra::schema::get_stream_settings_atomic(&schema_key).is_some());
// Get the meta table stats for enrichment table
let meta_table_stats = db::enrichment_table::get_meta_table_stats(org_id, table_name).await;
assert!(meta_table_stats.is_some());
let meta_table_stats = meta_table_stats.unwrap();
assert_ne!(meta_table_stats.size, 0);
assert_ne!(meta_table_stats.start_time, 0);
// Check get_enrichment_table function, it should return same data
let data =
enrichment_data::enrichment::get_enrichment_table(org_id, table_name, false).await;
assert!(data.is_ok());
let data = data.unwrap();
assert!(data.len() == 2);
println!("save enrichment data new tabledata: {data:?}");
println!(
"save enrichment data new table data[0]: {:?}",
data[0].get("name").unwrap().to_string()
);
assert!(data[0].get("name").unwrap().to_string().eq("\"John\""));
assert!(data[1].get("name").unwrap().to_string().eq("\"Jane\""));
assert!(data[0].get("age").unwrap().to_string().eq("\"25\""));
assert!(data[1].get("age").unwrap().to_string().eq("\"30\""));
assert!(data[0].get("city").unwrap().to_string().eq("\"New York\""));
assert!(
data[1]
.get("city")
.unwrap()
.to_string()
.eq("\"Los Angeles\"")
);
// wait for 1 second
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
// Check the ENRICHMENT_TABLES cache to check if the table is created
let enrichment_tables = ENRICHMENT_TABLES.clone();
println!("save enrichment data new table enrichment_tables: {enrichment_tables:?}");
assert!(enrichment_tables.contains_key(&schema_key));
assert!(enrichment_tables.get(&schema_key).unwrap().data.len() == 2);
drop(enrichment_tables);
println!("save enrichment data new table meta_table_stats: {meta_table_stats:?}");
// Also, it should store the cache in the disk
check_enrichment_table_local_disk_cache(org_id, table_name, meta_table_stats.end_time)
.await;
// Clean up
e2e_cleanup_enrichment_table(org_id, table_name).await;
}
async fn e2e_cleanup_enrichment_table(org_id: &str, stream_name: &str) {
// Clean up the enrichment table and its schema
enrichment_data::enrichment_table::delete_enrichment_table(
org_id,
stream_name,
config::meta::stream::StreamType::EnrichmentTables,
(0, chrono::Utc::now().timestamp_micros()),
)
.await;
// Verify schema caches are cleaned up
let schema_key = format!(
"{}/{}/{}",
org_id,
config::meta::stream::StreamType::EnrichmentTables,
stream_name
);
// Check that schema caches are cleared
let stream_schemas = STREAM_SCHEMAS.read().await;
assert!(!stream_schemas.contains_key(&schema_key));
drop(stream_schemas);
let stream_schemas_latest = STREAM_SCHEMAS_LATEST.read().await;
assert!(!stream_schemas_latest.contains_key(&schema_key));
drop(stream_schemas_latest);
assert!(infra::schema::get_stream_settings_atomic(&schema_key).is_none());
// wait for 2 seconds
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
// Check the ENRICHMENT_TABLES cache to check if the table is deleted
let enrichment_tables = ENRICHMENT_TABLES.clone();
println!("enrichment data cleanup enrichment_tables: {enrichment_tables:?}");
assert!(!enrichment_tables.contains_key(&schema_key));
drop(enrichment_tables);
// Check the local disk cache to check if the table is deleted
check_enrichment_table_local_disk_cache_deleted(org_id, stream_name).await;
}
async fn e2e_save_enrichment_data_append_mode() {
let _auth = setup();
let org_id = "e2e";
let table_name = "test_enrichment_table_append";
// First, create initial data
let mut initial_payload = Vec::new();
let mut record1 = json::Map::new();
record1.insert("name".to_string(), json::Value::String("John".to_string()));
record1.insert("age".to_string(), json::Value::String("25".to_string()));
record1.insert(
"city".to_string(),
json::Value::String("New York".to_string()),
);
initial_payload.push(record1);
let result1 = enrichment_data::enrichment_table::save_enrichment_data(
org_id,
table_name,
initial_payload,
false, // append_data = false
)
.await;
assert!(result1.is_ok());
// Get the meta table stats for enrichment table
let meta_table_stats_first =
db::enrichment_table::get_meta_table_stats(org_id, table_name).await;
assert!(meta_table_stats_first.is_some());
let meta_table_stats_first = meta_table_stats_first.unwrap();
assert_ne!(meta_table_stats_first.size, 0);
assert_ne!(meta_table_stats_first.start_time, 0);
let schema_key = format!(
"{}/{}/{}",
org_id,
config::meta::stream::StreamType::EnrichmentTables,
table_name
);
// Wait for 2 seconds
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
// Check the ENRICHMENT_TABLES cache to check if the table is created
let enrichment_tables = ENRICHMENT_TABLES.clone();
assert!(enrichment_tables.contains_key(&schema_key));
assert!(enrichment_tables.get(&schema_key).unwrap().data.len() == 1);
drop(enrichment_tables);
println!(
"save enrichment data append mode check_enrichment_table_local_disk_cache meta_table_stats_first.start_time 1: {:?}",
meta_table_stats_first.start_time
);
// Check the local disk cache to check if the table is created
check_enrichment_table_local_disk_cache(
org_id,
table_name,
meta_table_stats_first.end_time,
)
.await;
// Now append more data
let mut append_payload = Vec::new();
let mut record2 = json::Map::new();
record2.insert("name".to_string(), json::Value::String("Jane".to_string()));
record2.insert("age".to_string(), json::Value::String("30".to_string()));
record2.insert(
"city".to_string(),
json::Value::String("Los Angeles".to_string()),
);
append_payload.push(record2);
let result2 = enrichment_data::enrichment_table::save_enrichment_data(
org_id,
table_name,
append_payload,
true, // append_data = true
)
.await;
assert!(result2.is_ok());
// Verify schema still exists and is valid
let schema_exists = schema::stream_schema_exists(
org_id,
table_name,
config::meta::stream::StreamType::EnrichmentTables,
&mut std::collections::HashMap::new(),
)
.await;
assert!(schema_exists.has_fields);
// Get the meta table stats for enrichment table
let meta_table_stats_second =
db::enrichment_table::get_meta_table_stats(org_id, table_name).await;
assert!(meta_table_stats_second.is_some());
let meta_table_stats_second = meta_table_stats_second.unwrap();
assert_ne!(meta_table_stats_second.size, 0);
assert_ne!(meta_table_stats_second.start_time, 0);
assert_eq!(
meta_table_stats_second.start_time,
meta_table_stats_first.start_time
);
assert!(meta_table_stats_second.end_time > meta_table_stats_first.end_time);
// Wait for 2 seconds
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
// Check the ENRICHMENT_TABLES cache to check if the table is created
let enrichment_tables = ENRICHMENT_TABLES.clone();
assert!(enrichment_tables.contains_key(&schema_key));
assert!(enrichment_tables.get(&schema_key).unwrap().data.len() == 2);
drop(enrichment_tables);
println!(
"save enrichment data append mode check_enrichment_table_local_disk_cache meta_table_stats_second.start_time 2: {:?}",
meta_table_stats_second.start_time
);
// Check the local disk cache to check if the table is created
check_enrichment_table_local_disk_cache(
org_id,
table_name,
meta_table_stats_second.end_time,
)
.await;
// Clean up
e2e_cleanup_enrichment_table(org_id, table_name).await;
}
async fn e2e_save_enrichment_data_schema_evolution() {
let _auth = setup();
let org_id = "e2e";
let table_name = "test_enrichment_table_evolution";
// First, create initial data with basic fields
let mut initial_payload = Vec::new();
let mut record1 = json::Map::new();
record1.insert("name".to_string(), json::Value::String("John".to_string()));
record1.insert("age".to_string(), json::Value::String("25".to_string()));
initial_payload.push(record1);
let result1 = enrichment_data::enrichment_table::save_enrichment_data(
org_id,
table_name,
initial_payload,
false, // append_data = false
)
.await;
assert!(result1.is_ok());
let schema_key = format!(
"{}/{}/{}",
org_id,
config::meta::stream::StreamType::EnrichmentTables,
table_name
);
// wait for 2 seconds
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
// Check the ENRICHMENT_TABLES cache to check if the table is created
let enrichment_tables = ENRICHMENT_TABLES.clone();
assert!(enrichment_tables.contains_key(&schema_key));
assert!(enrichment_tables.get(&schema_key).unwrap().data.len() == 1);
drop(enrichment_tables);
// Now append data with additional fields (schema evolution)
let mut append_payload = Vec::new();
let mut record2 = json::Map::new();
record2.insert("name".to_string(), json::Value::String("Jane".to_string()));
record2.insert("age".to_string(), json::Value::String("30".to_string()));
record2.insert(
"city".to_string(),
json::Value::String("Los Angeles".to_string()),
); // New field
record2.insert(
"country".to_string(),
json::Value::String("USA".to_string()),
); // New field
append_payload.push(record2);
let result2 = enrichment_data::enrichment_table::save_enrichment_data(
org_id,
table_name,
append_payload,
true, // append_data = true
)
.await;
assert!(result2.is_ok());
let result = result2.unwrap();
assert!(!result.status().is_success());
// wait for 2 seconds
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
// Clean up
e2e_cleanup_enrichment_table(org_id, table_name).await;
}
// Test runner function that calls all enrichment table tests
async fn test_enrichment_table_integration() {
e2e_save_enrichment_data_new_table().await;
e2e_save_enrichment_data_append_mode().await;
e2e_save_enrichment_data_schema_evolution().await;
}
async fn check_enrichment_table_local_disk_cache(
org_id: &str,
table_name: &str,
updated_at: i64,
) {
let key = get_key(org_id, table_name);
let table_dir = get_table_dir(&key);
let file_path = get_table_path(table_dir.to_str().unwrap(), updated_at);
assert!(file_path.exists());
}
async fn check_enrichment_table_local_disk_cache_deleted(org_id: &str, table_name: &str) {
let key = get_key(org_id, table_name);
let table_dir = get_table_dir(&key);
assert!(!table_dir.exists());
}
// Helper function to setup schema for enrichment tables in tests
async fn setup_enrichment_table_schema(org_id: &str, table_name: &str) {
use arrow::datatypes::{DataType, Field, Schema};
use config::meta::stream::StreamType;
// Create a simple schema that matches our test data
let schema = Schema::new(vec![
Field::new("id", DataType::Int64, true),
Field::new("name", DataType::Utf8, true),
Field::new("age", DataType::Int64, true),
Field::new("data", DataType::Utf8, true),
Field::new("version", DataType::Utf8, true),
Field::new("nested", DataType::Utf8, true),
Field::new("special_chars", DataType::Utf8, true),
]);
// Use infra::schema::merge to create/update the schema in the database
let result = infra::schema::merge(
org_id,
table_name,
StreamType::EnrichmentTables,
&schema,
None,
)
.await;
// Ignore error if schema already exists
match result {
Ok(_) => {}
Err(e) => {
log::debug!("Schema setup for {}/{}: {:?}", org_id, table_name, e);
}
}
}
async fn test_enrichment_table_local_all_sequential() {
// Run all tests sequentially to avoid directory conflicts
test_store_and_retrieve().await;
test_store_multiple_versions().await;
test_retrieve_nonexistent_table().await;
test_delete().await;
test_delete_nonexistent_table().await;
test_get_last_updated_at().await;
test_store_data_if_needed().await;
test_store_data_if_needed_background().await;
test_metadata_persistence().await;
test_large_data_handling().await;
test_error_handling().await;
}
async fn test_store_and_retrieve() {
let org_id = "test_org";
let table_name = "test_table";
let test_data = vec![
json!({"id": 1, "name": "Alice", "age": 25}),
json!({"id": 2, "name": "Bob", "age": 30}),
];
let updated_at = 1640995200;
// Setup schema before storing data
setup_enrichment_table_schema(org_id, table_name).await;
// Test store function
let test_data = Values::Json(Arc::new(test_data));
let result = local::store(org_id, table_name, test_data, updated_at).await;
println!("Store result: {result:?}");
assert!(result.is_ok(), "Store should succeed");
// Verify file was created
let key = get_key(org_id, table_name);
let table_dir = get_table_dir(&key);
let file_path = get_table_path(table_dir.to_str().unwrap(), updated_at);
assert!(file_path.exists(), "Data file should exist");
// Verify metadata was created
let metadata_path = get_metadata_path();
assert!(metadata_path.exists(), "Metadata file should exist");
// Test retrieve function
let retrieved_data = local::retrieve(org_id, table_name).await;
assert!(retrieved_data.is_ok(), "Retrieve should succeed");
let retrieved_data = retrieved_data.unwrap().to_json().unwrap();
assert_eq!(retrieved_data.len(), 2, "Should retrieve 2 records");
assert_eq!(
retrieved_data[0]["id"],
json!(1),
"First record should match"
);
assert_eq!(
retrieved_data[1]["id"],
json!(2),
"Second record should match"
);
}
async fn test_store_multiple_versions() {
let org_id = "test_org";
let table_name = "versioned_table";
// Setup schema before storing data
setup_enrichment_table_schema(org_id, table_name).await;
// Store first version
let data_v1 = Values::Json(Arc::new(vec![json!({"id": 1, "version": "v1"})]));
let updated_at_v1 = 1640995200;
let result = local::store(org_id, table_name, data_v1, updated_at_v1).await;
assert!(result.is_ok(), "First store should succeed");
// Store second version
let data_v2 = Values::Json(Arc::new(vec![json!({"id": 2, "version": "v2"})]));
let updated_at_v2 = 1640995300;
let result = local::store(org_id, table_name, data_v2, updated_at_v2).await;
assert!(result.is_ok(), "Second store should succeed");
// Retrieve should get both versions
let retrieved_data = local::retrieve(org_id, table_name).await.unwrap();
assert_eq!(retrieved_data.len(), 2, "Should retrieve both versions");
}
async fn test_retrieve_nonexistent_table() {
let result = local::retrieve("nonexistent_org", "nonexistent_table").await;
assert!(
result.is_err(),
"Should return an error for nonexistent table"
);
}
async fn test_delete() {
let org_id = "test_org";
let table_name = "delete_test_table";
let test_data = Values::Json(Arc::new(vec![json!({"id": 1, "data": "test"})]));
let updated_at = 1640995200;
// Setup schema before storing data
setup_enrichment_table_schema(org_id, table_name).await;
// First store some data
let result = local::store(org_id, table_name, test_data, updated_at).await;
assert!(result.is_ok(), "Store should succeed");
// Verify data exists
let key = get_key(org_id, table_name);
let table_dir = get_table_dir(&key);
assert!(table_dir.exists(), "Table directory should exist");
// Test delete function
let result = local::delete(org_id, table_name).await;
assert!(result.is_ok(), "Delete should succeed");
// Verify directory was removed
assert!(!table_dir.exists(), "Table directory should be removed");
// Verify metadata was updated
let metadata_content = get_metadata_content().await.unwrap();
assert!(
!metadata_content.contains_key(&key),
"Key should be removed from metadata"
);
}
async fn test_delete_nonexistent_table() {
// Delete non-existent table should succeed gracefully
let result = local::delete("nonexistent_org", "nonexistent_table").await;
assert!(
result.is_ok(),
"Delete should handle nonexistent table gracefully"
);
}
async fn test_get_last_updated_at() {
let org_id = "test_org";
let table_name = "timestamp_test_table";
let test_data = Values::Json(Arc::new(vec![json!({"id": 1, "data": "test"})]));
let updated_at = 1640995200;
// Test getting timestamp before storing data
let result = local::get_last_updated_at(org_id, table_name).await;
assert!(result.is_ok(), "Get last updated should succeed");
assert_eq!(result.unwrap(), 0, "Should return 0 for non-existent table");
// Setup schema before storing data
setup_enrichment_table_schema(org_id, table_name).await;
// Store data
local::store(org_id, table_name, test_data, updated_at)
.await
.unwrap();
// Test getting timestamp after storing data
let result = local::get_last_updated_at(org_id, table_name).await;
assert!(result.is_ok(), "Get last updated should succeed");
assert_eq!(
result.unwrap(),
updated_at,
"Should return correct timestamp"
);
}
async fn test_store_data_if_needed() {
let org_id = "test_org";
let table_name = "conditional_test_table";
let test_data_v1 = Values::Json(Arc::new(vec![json!({"id": 1, "version": "v1"})]));
let test_data_v2 = Values::Json(Arc::new(vec![json!({"id": 1, "version": "v2"})]));
let updated_at_v1 = 1640995200;
let updated_at_v2 = 1640995300;
// Setup schema before storing data
setup_enrichment_table_schema(org_id, table_name).await;
// Store initial data
let result =
local::store_data_if_needed(org_id, table_name, test_data_v1, updated_at_v1).await;
assert!(result.is_ok(), "Initial store should succeed");
// Verify data was stored
let retrieved_data = local::retrieve(org_id, table_name)
.await
.unwrap()
.to_json()
.unwrap();
assert_eq!(
retrieved_data[0]["version"],
json!("v1"),
"Should have v1 data"
);
// Try to store older data (should be ignored)
let old_updated_at = 1640995100;
let result =
local::store_data_if_needed(org_id, table_name, test_data_v2.clone(), old_updated_at)
.await;
assert!(
result.is_ok(),
"Store with old timestamp should succeed but not update"
);
// Verify data wasn't changed
let retrieved_data = local::retrieve(org_id, table_name)
.await
.unwrap()
.to_json()
.unwrap();
assert_eq!(
retrieved_data[0]["version"],
json!("v1"),
"Should still have v1 data"
);
// Store newer data (should update)
let result =
local::store_data_if_needed(org_id, table_name, test_data_v2, updated_at_v2).await;
assert!(result.is_ok(), "Store with newer timestamp should succeed");
// Verify data was updated
let retrieved_data = local::retrieve(org_id, table_name)
.await
.unwrap()
.to_json()
.unwrap();
assert_eq!(
retrieved_data[0]["version"],
json!("v2"),
"Should now have v2 data"
);
}
async fn test_store_data_if_needed_background() {
let org_id = "test_org";
let table_name = "background_test_table";
let test_data = Values::Json(Arc::new(vec![json!({"id": 1, "data": "background"})]));
let updated_at = 1640995200;
// Setup schema before storing data
setup_enrichment_table_schema(org_id, table_name).await;
// Test background store
let result =
local::store_data_if_needed_background(org_id, table_name, test_data, updated_at).await;
assert!(result.is_ok(), "Background store should succeed");
// Wait a bit for background task to complete
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// Verify data was eventually stored
let retrieved_data = local::retrieve(org_id, table_name)
.await
.unwrap()
.to_json()
.unwrap();
assert_eq!(retrieved_data.len(), 1, "Should have stored 1 record");
assert_eq!(
retrieved_data[0]["data"],
json!("background"),
"Should have correct data"
);
}
async fn test_metadata_persistence() {
let org_id = "test_org";
let table_name = "metadata_test_table";
let test_data = Values::Json(Arc::new(vec![json!({"id": 1, "data": "test"})]));
let updated_at = 1640995200;
// Setup schema before storing data
setup_enrichment_table_schema(org_id, table_name).await;
// Store data
local::store(org_id, table_name, test_data, updated_at)
.await
.unwrap();
// Manually read metadata file
let metadata_path = get_metadata_path();
let metadata_content = tokio::fs::read_to_string(&metadata_path).await.unwrap();
let metadata: std::collections::HashMap<String, i64> =
serde_json::from_str(&metadata_content).unwrap();
let key = get_key(org_id, table_name);
assert!(
metadata.contains_key(&key),
"Metadata should contain the key"
);
assert_eq!(
metadata[&key], updated_at,
"Metadata should have correct timestamp"
);
}
async fn test_large_data_handling() {
let org_id = "test_org";
let table_name = "large_data_table";
// Setup schema before storing data
setup_enrichment_table_schema(org_id, table_name).await;
// Create large dataset (1000 records)
let large_data: Vec<_> = (0..1000)
.map(|i| {
json!({
"id": i,
"name": format!("name_{}", i),
"data": "x".repeat(100), // 100 characters per record
"nested": {
"field1": i * 2,
"field2": format!("nested_value_{}", i)
}
})
})
.collect();
let updated_at = 1640995200;
// Test storing large data
let large_data = Values::Json(Arc::new(large_data));
let result = local::store(org_id, table_name, large_data, updated_at).await;
assert!(result.is_ok(), "Should handle large data successfully");
// Test retrieving large data
let retrieved_data = local::retrieve(org_id, table_name)
.await
.unwrap()
.to_json()
.unwrap();
assert_eq!(
retrieved_data.len(),
1000,
"Should retrieve all 1000 records"
);
assert_eq!(
retrieved_data[0]["id"],
json!(0),
"First record should be correct"
);
assert_eq!(
retrieved_data[999]["id"],
json!(999),
"Last record should be correct"
);
}
async fn test_error_handling() {
// Test with invalid JSON data
let org_id = "test_org";
let table_name = "error_test_table";
// Setup schema before storing data
setup_enrichment_table_schema(org_id, table_name).await;
// Create data that will serialize fine but test edge cases
let test_data = Values::Json(Arc::new(vec![
json!({"id": 1, "data": null}),
json!({"id": 2, "special_chars": "!@#$%^&*()"}),
]));
let updated_at = 1640995200;
let result = local::store(org_id, table_name, test_data, updated_at).await;
assert!(
result.is_ok(),
"Should handle special characters and null values"
);
let retrieved_data = local::retrieve(org_id, table_name)
.await
.unwrap()
.to_json()
.unwrap();
assert_eq!(retrieved_data.len(), 2, "Should retrieve both records");
assert!(
retrieved_data[0]["data"].is_null(),
"Should preserve null values"
);
}
// ========================================================================
// Backfill Jobs Integration Tests
// ========================================================================
async fn test_backfill_job_list_and_delete() {
use openobserve_core::alerts::backfill::{delete_backfill_job, list_backfill_jobs};
// Test listing backfill jobs
let org_id = "e2e";
let jobs_result = list_backfill_jobs(org_id).await;
assert!(
jobs_result.is_ok(),
"Should successfully list backfill jobs"
);
let jobs = jobs_result.unwrap();
// If there are any jobs, test delete functionality
if let Some(first_job) = jobs.first() {
let job_id = first_job.job_id.clone();
let delete_result = delete_backfill_job(org_id, &job_id).await;
// Delete may fail if job is in progress, which is acceptable
match delete_result {
Ok(_) => log::info!("Successfully deleted backfill job: {}", job_id),
Err(e) => log::warn!("Could not delete backfill job (may be in progress): {}", e),
}
}
}
async fn test_backfill_job_get_nonexistent() {
use openobserve_core::alerts::backfill::get_backfill_job;
// Test getting a non-existent job
let org_id = "e2e";
let fake_job_id = "nonexistent_job_12345";
let result = get_backfill_job(org_id, fake_job_id).await;
assert!(result.is_err(), "Should fail to get non-existent job");
}
async fn test_backfill_job_delete_by_pipeline() {
use openobserve_core::alerts::backfill::delete_backfill_jobs_by_pipeline;
// Test deleting jobs by pipeline
let org_id = "e2e";
let pipeline_id = "test_nonexistent_pipeline";
// Should succeed even if no jobs exist for this pipeline
let result = delete_backfill_jobs_by_pipeline(org_id, pipeline_id).await;
assert!(
result.is_ok(),
"Should successfully delete jobs by pipeline (even if none exist)"
);
}
async fn test_backfill_job_enable_disable() {
use openobserve_core::alerts::backfill::{enable_backfill_job, list_backfill_jobs};
// Test enable/disable on existing jobs
let org_id = "e2e";
let jobs_result = list_backfill_jobs(org_id).await;
if let Ok(jobs) = jobs_result
&& let Some(first_job) = jobs.first()
{
let job_id = first_job.job_id.clone();
// Try to disable
let disable_result = enable_backfill_job(org_id, &job_id, false).await;
match disable_result {
Ok(_) => {
log::info!("Successfully disabled backfill job: {}", job_id);
// Try to enable back
let enable_result = enable_backfill_job(org_id, &job_id, true).await;
if enable_result.is_ok() {
log::info!("Successfully re-enabled backfill job: {}", job_id);
}
}
Err(e) => log::warn!("Could not modify job state: {}", e),
}
}
}
}