// 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 . // 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, request::logs::LogsServer, }; use openobserve_api_http::handler::http::router::{ basic_routes, config_routes, other_service_routes, service_routes, }; use openobserve_api_management::{ models::{ alerts::responses::{GetAlertResponseBody, ListAlertsResponseBody}, destinations::{Destination, DestinationType}, }, request::alerts::external_events::inactive_org_response, }; use openobserve_api_pipelines::models::pipelines::{Pipeline as ApiPipeline, PipelineList}; use openobserve_core::alerts::scheduler::handlers::handle_triggers; use opentelemetry_proto::tonic::{ collector::logs::v1::{ ExportLogsServiceRequest, logs_service_client::LogsServiceClient, logs_service_server::LogsServiceServer, }, common::v1::{AnyValue, any_value}, logs::v1::{LogRecord, ResourceLogs, ScopeLogs}, }; 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; /// Tables of Task 0.17 that carry an org column, and that column. const KV_COVERAGE_TABLES: [(&str, &str); 10] = [ ("stream_stats", "org"), ("file_list_history", "org"), ("file_list_jobs", "org"), ("pipeline_last_errors", "org_id"), ("kv_store", "org_id"), ("system_settings", "org_id"), ("short_urls", "org_id"), ("backfill_jobs", "org"), ("enrichment_tables", "org"), ("enrichment_table_urls", "org"), ]; /// The stack infra's runtimes give every thread that polls a server future. const E2E_STACK_SIZE: usize = 16 * 1024 * 1024; 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); struct CoverageRows { check_id: String, integration_id: String, anomaly_id: String, workflow_id: String, probe_token: String, agent_id: String, location_id: String, artifact: String, pricing_model: String, team_id: String, response_id: String, policy_id: String, dek: Vec, } /// The e2e's own data dir, ports, config file and Postgres database, removed by `tear_down`. struct Sandbox { dir: tempfile::TempDir, postgres: Option, } 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 { 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_ORG_CLEANUP_POLL_INTERVAL_SECS", "1"); env::set_var("ZO_COMPACT_DELETE_FILES_DELAY_MINUTES", "0"); env::set_var("ZO_SYNTHETICS_ENABLED", "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, body: Option, ) -> (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); let logs_svc = LogsServiceServer::new(LogsServer); 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) .add_service(logs_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::(&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 polls the body on the 2 MiB harness thread, which the enterprise build overflows. #[test] fn e2e_test() { let body = std::thread::Builder::new() .name("e2e_test".to_string()) .stack_size(E2E_STACK_SIZE) .spawn(|| { tokio::runtime::Builder::new_multi_thread() .worker_threads(4) .thread_stack_size(E2E_STACK_SIZE) .enable_all() .build() .unwrap() .block_on(e2e_test_body()); }) .unwrap(); if let Err(panic) = body.join() { std::panic::resume_unwind(panic); } } async fn e2e_test_body() { let sandbox = Sandbox::create().await; setup(); sandbox.assert_config_uses_it(); e2e_boot().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_firehose_and_pubsub_status().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; e2e_alerts().await; 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; test_enrichment_table_integration().await; test_enrichment_table_local_all_sequential().await; 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; e2e_health_check().await; e2e_config().await; e2e_delete_stream().await; sandbox.tear_down().await; } async fn e2e_boot() { tokio::task::spawn(async move { init_grpc_server() .await .expect("router gRPC server init failed"); }); common::infra::cluster::register_and_keep_alive() .await .unwrap(); config::init().await.unwrap(); migration::init_db().await.unwrap(); infra::db::create_table().await.unwrap(); 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 = None; infra::table::cipher::boot(master_key.as_deref()) .await .unwrap(); openobserve_core::bootstrap::init().await.unwrap(); ingester::init().await.unwrap(); openobserve_jobs::job::init().await.unwrap(); // Default user creation runs asynchronously after bootstrap. tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; // A retried run starts from the state the failed attempt left behind. cleanup_previous_test_state().await; } async fn e2e_alerts() { // 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; } async fn e2e_org_deletion() { // org deletion: each runs in its own task so one red test does not hide the others let saved_config = config::CONFIG.load_full(); set_cleanup_test_config(0); // A worker poll already sleeping when the pause lands still runs once. tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; let mut failed = Vec::new(); let file_list = tokio::spawn(e2e_file_list_step_moves_every_stream_to_deleted()).await; if file_list.is_err() { failed.push("e2e_file_list_step_moves_every_stream_to_deleted"); } if tokio::spawn(e2e_public_paths_refuse_blocked_org()) .await .is_err() { failed.push("e2e_public_paths_refuse_blocked_org"); } #[cfg(feature = "enterprise")] if tokio::spawn(e2e_incident_webhook_refuses_inactive_org()) .await .is_err() { failed.push("e2e_incident_webhook_refuses_inactive_org"); } if tokio::spawn(e2e_org_delete_completes()).await.is_err() { failed.push("e2e_org_delete_completes"); } if tokio::spawn(e2e_online_eval_runner_skips_deleting_org()) .await .is_err() { failed.push("e2e_online_eval_runner_skips_deleting_org"); } #[cfg(feature = "enterprise")] if tokio::spawn(e2e_host_routed_status_page_refuses_blocked_org()) .await .is_err() { failed.push("e2e_host_routed_status_page_refuses_blocked_org"); } if tokio::spawn(e2e_org_delete_removes_memberships()) .await .is_err() { failed.push("e2e_org_delete_removes_memberships"); } 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_delete_rejects_old_credentials()) .await .is_err() { failed.push("e2e_org_delete_rejects_old_credentials"); } if tokio::spawn(e2e_org_setting_writers_do_not_lose_updates()) .await .is_err() { failed.push("e2e_org_setting_writers_do_not_lose_updates"); } if tokio::spawn(e2e_ingestion_blocked_during_deletion()) .await .is_err() { failed.push("e2e_ingestion_blocked_during_deletion"); } #[cfg(feature = "enterprise")] if tokio::spawn(test_initiate_refused_under_super_cluster()) .await .is_err() { failed.push("test_initiate_refused_under_super_cluster"); } if tokio::spawn(e2e_drain_retry_keeps_attempts_at_zero()) .await .is_err() { failed.push("e2e_drain_retry_keeps_attempts_at_zero"); } if tokio::spawn(e2e_drain_stalled_row_is_marked_failed()) .await .is_err() { failed.push("e2e_drain_stalled_row_is_marked_failed"); } if tokio::spawn(e2e_drain_keeps_rows_when_an_object_cannot_be_deleted()) .await .is_err() { failed.push("e2e_drain_keeps_rows_when_an_object_cannot_be_deleted"); } if tokio::spawn(e2e_drain_refuses_an_unloaded_storage_account()) .await .is_err() { failed.push("e2e_drain_refuses_an_unloaded_storage_account"); } if tokio::spawn(e2e_drain_failure_spends_an_attempt_each_pass()) .await .is_err() { failed.push("e2e_drain_failure_spends_an_attempt_each_pass"); } if tokio::spawn(e2e_resurrect_releases_parked_triggers()) .await .is_err() { failed.push("e2e_resurrect_releases_parked_triggers"); } if tokio::spawn(e2e_blocked_org_reaches_only_its_cleanup_retry()) .await .is_err() { failed.push("e2e_blocked_org_reaches_only_its_cleanup_retry"); } if tokio::spawn(e2e_compactor_pass_keeps_rows_of_a_deleting_org()) .await .is_err() { failed.push("e2e_compactor_pass_keeps_rows_of_a_deleting_org"); } if tokio::spawn(e2e_compactor_pass_refuses_an_unloaded_account_of_a_deleting_org()) .await .is_err() { failed.push("e2e_compactor_pass_refuses_an_unloaded_account_of_a_deleting_org"); } if tokio::spawn(e2e_compactor_removal_waits_for_deletion_start()) .await .is_err() { failed.push("e2e_compactor_removal_waits_for_deletion_start"); } if tokio::spawn(e2e_retry_backfills_missing_steps_and_resets_attempts()) .await .is_err() { failed.push("e2e_retry_backfills_missing_steps_and_resets_attempts"); } if tokio::spawn(e2e_scan_failed_sets_gauge_and_emits_once()) .await .is_err() { failed.push("e2e_scan_failed_sets_gauge_and_emits_once"); } if tokio::spawn(e2e_reconcile_requeues_deleting_org_without_tasks()) .await .is_err() { failed.push("e2e_reconcile_requeues_deleting_org_without_tasks"); } if tokio::spawn(e2e_delete_org_record_is_idempotent()) .await .is_err() { failed.push("e2e_delete_org_record_is_idempotent"); } if tokio::spawn(e2e_initiate_rolls_back_status_when_add_batch_fails()) .await .is_err() { failed.push("e2e_initiate_rolls_back_status_when_add_batch_fails"); } if tokio::spawn(e2e_org_delete_retry_after_exhaustion()) .await .is_err() { failed.push("e2e_org_delete_retry_after_exhaustion"); } if tokio::spawn(e2e_two_workers_never_run_one_step_twice()) .await .is_err() { failed.push("e2e_two_workers_never_run_one_step_twice"); } config::CONFIG.store(saved_config); assert!(failed.is_empty(), "org deletion e2e failed: {failed:?}"); } fn run_suffix() -> String { (Utc::now().timestamp_micros() % 1_000_000_000).to_string() } /// Ingests one record so the org is auto-created (`ZO_CREATE_ORG_THROUGH_INGESTION`). async fn ingest_one(app: &Router, headers: &HeaderMap, org: &str) { let (status, body) = make_request( app, Method::POST, &format!("/api/{org}/orgdel/_json"), Some(headers.clone()), Some(r#"[{"a":1}]"#.to_string()), ) .await; assert!( status.is_success(), "ingest into {org}: {status} {}", String::from_utf8_lossy(&body) ); flush_memtable().await; } /// Pauses the background cleanup and promotion jobs, so only the test drives them. fn set_cleanup_test_config(delete_files_delay_minutes: i64) { let mut cfg = config::config::init(); cfg.common.org_cleanup_poll_interval_secs = 0; // Set here, not by env: config raises a delay below 1 minute to 120. cfg.compact.delete_files_delay_minutes = delete_files_delay_minutes; config::CONFIG.store(Arc::new(cfg)); } async fn run_cleanup_once() { let cleanup: Arc = Arc::new(openobserve_jobs::job::CompactionStreamDataCleanup); openobserve_core::org_cleanup::run_once(cleanup).await; } async fn cleanup_task(org: &str, step: &str) -> infra::table::org_cleanup_tasks::CleanupTask { infra::table::org_cleanup_tasks::list_by_org_status(org, None) .await .unwrap() .into_iter() .find(|t| t.step == step) .unwrap_or_else(|| panic!("{org} has no {step} task")) } async fn add_tasks(org: &str, steps: &[(&str, i32)]) { let tasks: Vec<_> = steps .iter() .map( |(step, order)| infra::table::org_cleanup_tasks::NewCleanupTask { org_id: org.to_string(), org_name: org.to_string(), step: step.to_string(), step_order: *order, }, ) .collect(); infra::table::org_cleanup_tasks::add_batch(&tasks) .await .unwrap(); } /// Spends every attempt of the step the way the worker does: claim, then fail. async fn exhaust_task(org: &str, step: &str) { let id = cleanup_task(org, step).await.id; for _ in 0..10 { let claim = infra::table::org_cleanup_tasks::mark_running(&id) .await .unwrap() .expect("an idle task can be claimed"); assert!( infra::table::org_cleanup_tasks::mark_failed(&claim, "e2e failure") .await .unwrap() ); } } fn deleted_file(org: &str, name: &str) -> config::meta::stream::FileListDeleted { config::meta::stream::FileListDeleted { file: format!("files/{org}/logs/s1/2026/01/01/00/{name}.parquet"), ..Default::default() } } async fn remove_deleted_rows(org: &str) { let rows = infra::file_list::query_deleted(org, i64::MAX, 1000) .await .unwrap(); let keys: Vec<_> = rows .into_iter() .map(|r| { config::meta::stream::FileKey::new( r.id, r.account, r.file, config::meta::stream::FileMeta::default(), false, ) }) .collect(); infra::file_list::batch_remove_deleted(&keys).await.unwrap(); } async fn add_deleting_org(org: &str) { infra::table::organizations::add( org, org, config::meta::organization::OrganizationType::Default, ) .await .unwrap(); infra::table::organizations::set_status(org, "deleting") .await .unwrap(); } async fn org_status_of(org: &str) -> Option { infra::table::organizations::get_status_uncached(org) .await .unwrap() } fn failed_steps_gauge(org: &str) -> i64 { config::metrics::ORG_CLEANUP_FAILED_STEPS .with_label_values(&[org]) .get() } /// A stored file of a stream without a schema, so only steps 200 and 250 can remove it. /// An object and its file_list row in a stream with no schema or stats row. async fn seed_orphan_file(org: &str, stream: &str) -> String { let key = format!("files/{org}/logs/{stream}/2026/01/01/00/a.parquet"); infra::storage::put("", &key, Bytes::from_static(b"orphan")) .await .unwrap(); let meta = config::meta::stream::FileMeta { min_ts: 1, max_ts: 2, records: 1, original_size: 6, compressed_size: 6, ..Default::default() }; infra::file_list::add("", &key, &meta).await.unwrap(); key } async fn seed_synthetic_with_children(org: &str) -> String { use config::meta::folder::{Folder, FolderType}; use infra::table::{synthetics_checks, synthetics_jobs, synthetics_refs, synthetics_runs}; let folder = Folder { folder_id: "default".to_string(), name: "default".to_string(), ..Default::default() }; let (folder_pk, ..) = infra::table::folders::get_or_create(org, folder, FolderType::Synthetics) .await .unwrap(); let check = config::meta::synthetics::Synthetic { name: "orgdel check".to_string(), target: "https://example.com".to_string(), folder_id: folder_pk.to_string(), ..Default::default() }; let conn = infra::db::get_orm_client_rw().await; let check = synthetics_checks::create(conn, org, check, false) .await .unwrap(); let now = Utc::now().timestamp_micros(); let run_id = config::ider::generate(); synthetics_runs::insert_run( conn, synthetics_runs::InsertRunParams { id: &run_id, synthetics_id: &check.id, org_id: org, scheduled_ts: now, trigger_type: "manual", job_count: 1, created_at: now, }, ) .await .unwrap(); synthetics_jobs::enqueue( conn, synthetics_jobs::EnqueueParams { synthetics_id: &check.id, synthetics_name: &check.name, org_id: org, location: "local", env: None, pool: "default", scheduled_ts: now, valid_until: now + 60_000_000, run_id: &run_id, browser_devices: None, steps_configured: 1, metadata: "{}", }, ) .await .unwrap(); synthetics_refs::replace_for_parent(conn, org, &check.id, &["orgdel-child".to_string()]) .await .unwrap(); check.id } async fn count_org_rows(table: &str, org: &str) -> i64 { use sea_orm::{ConnectionTrait, Statement}; let conn = infra::db::get_orm_client_ro().await; let row = conn .query_one(Statement::from_sql_and_values( conn.get_database_backend(), format!("SELECT COUNT(*) AS n FROM {table} WHERE org_id = $1"), [org.into()], )) .await .unwrap() .unwrap(); row.try_get("", "n").unwrap() } async fn count_rows(table: &str, column: &str, value: &str) -> i64 { use sea_orm::{ConnectionTrait, Statement}; let conn = infra::db::get_orm_client_ro().await; let row = conn .query_one(Statement::from_sql_and_values( conn.get_database_backend(), format!("SELECT COUNT(*) AS n FROM {table} WHERE {column} = $1"), [value.into()], )) .await .unwrap() .unwrap(); row.try_get("", "n").unwrap() } async fn insert_entity_row(row: serde_json::Value) where E: sea_orm::EntityTrait, E::Model: serde::de::DeserializeOwned + sea_orm::IntoActiveModel, { use sea_orm::IntoActiveModel; let model: E::Model = serde_json::from_value(row).unwrap(); E::insert(model.into_active_model()) .exec_without_returning(infra::db::get_orm_client_rw().await) .await .unwrap(); } async fn seed_coverage_rows(org: &str) -> CoverageRows { let check_id = seed_synthetic_with_children(org).await; seed_status_page_rows(org, &check_id).await; let integration_id = seed_incident_rows(org).await; let (probe_token, agent_id, location_id) = seed_synthetics_registry_rows(org).await; let workflow_id = seed_workflow_rows(org).await; let anomaly_id = seed_anomaly_rows(org).await; let artifact = format!("synthetics/{org}/{check_id}/run1/screenshot.png"); infra::storage::put("", &artifact, Bytes::from_static(b"png")) .await .unwrap(); let pricing_model = seed_llm_rows(org).await; let (team_id, response_id, policy_id) = seed_oncall_rows(org).await; seed_ai_chat_rows(org).await; let dek = infra::table::cipher::get_dek(org).await.unwrap(); CoverageRows { check_id, integration_id, anomaly_id, workflow_id, probe_token, agent_id, location_id, artifact, pricing_model, team_id, response_id, policy_id, dek, } } async fn insert_active(row: A) where A: sea_orm::ActiveModelTrait, ::Model: sea_orm::IntoActiveModel, { use sea_orm::EntityTrait; ::insert(row) .exec_without_returning(infra::db::get_orm_client_rw().await) .await .unwrap(); } async fn seed_llm_rows(org: &str) -> String { use infra::table::entity::{ llm_annotation_queue_bindings, llm_annotation_queue_items, llm_annotation_queues, llm_dataset_items, llm_datasets, llm_experiment_slot_retries, llm_experiments, llm_idempotency_records, llm_playground_snapshots, score_configs, }; use sea_orm::Set; let now = Utc::now().timestamp_micros(); let id = config::ider::generate; let (dataset, queue, score_config, experiment) = (id(), id(), id(), id()); insert_active(llm_datasets::ActiveModel { id: Set(dataset.clone()), org_id: Set(org.to_string()), name: Set(format!("orgdel {dataset}")), global_version: Set(1), created_by: Set("root@example.com".to_string()), created_at: Set(now), updated_by: Set("root@example.com".to_string()), updated_at: Set(now), ..Default::default() }) .await; insert_active(llm_dataset_items::ActiveModel { row_id: Set(id()), logical_id: Set(id()), org_id: Set(org.to_string()), dataset_id: Set(dataset.clone()), input: Set(json!({"q": "a"})), expected_output: Set(json!({"a": "b"})), global_version: Set(1), is_deleted: Set(false), source: Set("manual".to_string()), updated_by: Set("root@example.com".to_string()), updated_at: Set(now), ..Default::default() }) .await; insert_active(llm_annotation_queues::ActiveModel { id: Set(queue.clone()), org_id: Set(org.to_string()), name: Set(format!("orgdel {queue}")), allowed_ref_types: Set(json!(["trace"])), created_by: Set("root@example.com".to_string()), created_at: Set(now), updated_by: Set("root@example.com".to_string()), updated_at: Set(now), ..Default::default() }) .await; insert_active(llm_annotation_queue_items::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), queue_id: Set(queue.clone()), ref_type: Set("trace".to_string()), ref_id: Set(id()), ref_trace_start_time: Set(now), status: Set("pending".to_string()), created_at: Set(now), updated_at: Set(now), ..Default::default() }) .await; insert_active(score_configs::ActiveModel { id: Set(score_config.clone()), org_id: Set(org.to_string()), entity_id: Set(id()), name: Set(format!("orgdel {score_config}")), version: Set(1), data_type: Set("numeric".to_string()), is_active: Set(true), created_at: Set(now), updated_at: Set(now), ..Default::default() }) .await; insert_active(llm_annotation_queue_bindings::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), queue_id: Set(queue), score_config_row_id: Set(score_config), created_at: Set(now), }) .await; insert_active(llm_experiments::ActiveModel { id: Set(experiment.clone()), org_id: Set(org.to_string()), name: Set(format!("orgdel {experiment}")), dataset_id: Set(dataset), dataset_version: Set(1), task_config: Set(json!({})), scorers: Set(json!([])), trial_count: Set(1), status: Set("completed".to_string()), deadline_at: Set(now), lifecycle_version: Set(1), retry_count: Set(0), is_baseline: Set(false), created_by: Set("root@example.com".to_string()), created_at: Set(now), ..Default::default() }) .await; insert_active(llm_experiment_slot_retries::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), experiment_id: Set(experiment), row_id: Set(id()), trial_index: Set(0), idempotency_key: Set(id()), state: Set("done".to_string()), execution: Set(json!({})), created_at: Set(now), updated_at: Set(now), expires_at: Set(now), ..Default::default() }) .await; insert_active(llm_idempotency_records::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), scope: Set("experiment".to_string()), idempotency_key: Set(id()), request_hash: Set("h".to_string()), response: Set(json!({})), created_at: Set(now), expires_at: Set(now), }) .await; insert_active(llm_playground_snapshots::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), payload: Set(json!({})), created_by: Set("root@example.com".to_string()), created_at: Set(now), last_accessed_at: Set(now), ..Default::default() }) .await; seed_eval_job(org, now).await; seed_ai_rows(org, now).await; seed_pricing_and_rate_limit(org, now).await } async fn seed_eval_job(org: &str, now: i64) -> String { use infra::table::entity::online_eval_jobs; use sea_orm::Set; let eval_job_id = config::ider::generate(); insert_active(online_eval_jobs::ActiveModel { id: Set(eval_job_id.clone()), org_id: Set(org.to_string()), name: Set(format!("orgdel {eval_job_id}")), stream: Set("orgdel".to_string()), stream_type: Set("traces".to_string()), target_scope: Set("trace".to_string()), filter_condition: Set(json!({})), scorers: Set(json!([])), sampling_mode: Set("rate".to_string()), sampling_value: Set(json!(0)), status: Set("active".to_string()), version: Set(1), created_at: Set(now), updated_at: Set(now), ..Default::default() }) .await; eval_job_id } async fn seed_ai_rows(org: &str, now: i64) { use infra::table::entity::{ gen_ai_agents, llm_secrets, org_ai_toolsets, providers, scorers, }; use sea_orm::Set; let id = config::ider::generate; insert_active(scorers::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), entity_id: Set(id()), name: Set(format!("orgdel {}", id())), version: Set(1), scorer_type: Set(scorers::ScorerType::LlmJudge), template: Set("t".to_string()), params: Set(json!({})), is_active: Set(true), created_at: Set(now), updated_at: Set(now), ..Default::default() }) .await; insert_active(providers::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), name: Set(format!("orgdel {}", id())), provider_type: Set("openai".to_string()), default_model: Set("gpt".to_string()), available_models: Set(json!(["gpt"])), is_default: Set(false), created_at: Set(now), updated_at: Set(now), ..Default::default() }) .await; insert_active(llm_secrets::ActiveModel { id: Set(id()), secret_ref: Set(id()), org_id: Set(org.to_string()), owner_kind: Set("provider".to_string()), owner_id: Set(id()), purpose: Set("api_key".to_string()), state: Set("active".to_string()), ciphertext: Set("c".to_string()), created_at: Set(now), updated_at: Set(now), ..Default::default() }) .await; insert_active(org_ai_toolsets::ActiveModel { id: Set(id()), org: Set(org.to_string()), name: Set(format!("orgdel {}", id())), kind: Set("mcp".to_string()), created_by: Set("root@example.com".to_string()), created_at: Set(now), updated_at: Set(now), ..Default::default() }) .await; insert_active(gen_ai_agents::ActiveModel { agent_key: Set(id()), org_id: Set(org.to_string()), stream_type: Set("traces".to_string()), stream_name: Set("orgdel".to_string()), identity_source: Set("attribute".to_string()), first_seen: Set(now), last_seen: Set(now), created_at: Set(now), updated_at: Set(now), ..Default::default() }) .await; #[cfg(feature = "enterprise")] insert_active(infra::table::entity::llm_remote_tasks::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), entity_id: Set(id()), name: Set(format!("orgdel {}", id())), version: Set(1), endpoint: Set("https://dummy/task".to_string()), http_method: Set("POST".to_string()), auth: Set(json!({})), custom_headers: Set(json!({})), content_type: Set("application/json".to_string()), response_schema: Set("{}".to_string()), timeout_ms: Set(1000), retry_policy: Set(json!({})), max_concurrency: Set(1), signing: Set(json!({})), verification_status: Set("unverified".to_string()), is_active: Set(false), created_at: Set(now), updated_at: Set(now), ..Default::default() }) .await; } async fn seed_pricing_and_rate_limit(org: &str, now: i64) -> String { use config::meta::model_pricing::{ModelPricingDefinition, PricingTierDefinition}; use sea_orm::Set; let pricing_model = format!("orgdel-model-{org}"); infra::table::model_pricing::put(ModelPricingDefinition { org_id: org.to_string(), name: pricing_model.clone(), match_pattern: "(?i)^orgdel".to_string(), enabled: true, tiers: vec![PricingTierDefinition { name: "Default".to_string(), condition: None, prices: Default::default(), utc_windows: Vec::new(), rules: Vec::new(), priority: 0, }], ..Default::default() }) .await .unwrap(); insert_active(infra::table::entity::rate_limit_rules::ActiveModel { rule_id: Set(config::ider::generate()), org: Set(org.to_string()), rule_type: Set("exact".to_string()), user_role: Set("admin".to_string()), user_id: Set(".*".to_string()), api_group_name: Set("logs".to_string()), api_group_operation: Set("list".to_string()), threshold: Set(10), stat_interval_ms: Set(1000), created_at: Set(now), }) .await; pricing_model } /// A chat with a revoked share and a turn record, as a persisted AI chat leaves them. async fn seed_ai_chat_rows(org: &str) { use infra::table::{ai_chat_sessions, ai_chat_shares, ai_chat_turns}; let now = Utc::now().timestamp_micros(); let session = config::ider::uuid(); ai_chat_sessions::get_or_create(org, &session, "orgdel-user", "a@x", "o2-ai", now) .await .unwrap(); let share = ai_chat_shares::NewShare { org_id: org, session_id: &session, created_by: "orgdel-user", mode: ai_chat_shares::MODE_LIVE, snapshot_seq: None, visibility: ai_chat_shares::VISIBILITY_ORG, expires_at: None, redact_tools: false, }; let share = ai_chat_shares::insert(&share, now).await.unwrap(); ai_chat_shares::revoke(org, &share.id, now).await.unwrap(); ai_chat_turns::admit(org, &session, "orgdel-turn", 0, now) .await .unwrap(); // Deleted by their user before: one the sweep still re-purges, one past that window. let window = ai_chat_sessions::REPLICA_REPURGE_WINDOW_MICROS; for purged_at in [now, now - window - 1] { let session = config::ider::uuid(); ai_chat_sessions::get_or_create(org, &session, "orgdel-user", "a@x", "o2-ai", now) .await .unwrap(); assert!( ai_chat_sessions::mark_deleted(org, &session, "orgdel-user", now) .await .unwrap() ); ai_chat_sessions::mark_replica_purged(org, &session, purged_at) .await .unwrap(); } } /// Chat rows step 300 removes: all but deleted chats the retention sweep still re-purges. async fn count_unheld_chats(org: &str) -> i64 { use sea_orm::{ConnectionTrait, Statement}; let repurge_since = Utc::now().timestamp_micros() - infra::table::ai_chat_sessions::REPLICA_REPURGE_WINDOW_MICROS; let conn = infra::db::get_orm_client_ro().await; let row = conn .query_one(Statement::from_sql_and_values( conn.get_database_backend(), "SELECT COUNT(*) AS n FROM ai_chat_sessions WHERE org_id = $1 \ AND (status <> 'deleted' OR replica_purged_at IS NULL OR replica_purged_at <= $2)", [org.into(), repurge_since.into()], )) .await .unwrap() .unwrap(); row.try_get("", "n").unwrap() } async fn seed_oncall_rows(org: &str) -> (String, String, String) { use infra::table::entity::{ oncall_delivery_reads, oncall_overrides, oncall_ownership_rules, oncall_policies, oncall_response_events, oncall_response_reports, oncall_responses, oncall_routing_config, oncall_schedules, oncall_team_members, oncall_teams, oncall_unavailability, oncall_unrouted_signals, oncall_user_contacts, }; use sea_orm::Set; let now = Utc::now().timestamp_micros(); let id = config::ider::generate; let who = || "oncall@example.com".to_string(); let (team, response, policy) = (id(), id(), id()); insert_active(oncall_teams::ActiveModel { id: Set(team.clone()), org_id: Set(org.to_string()), name: Set(format!("orgdel {team}")), timezone: Set("UTC".to_string()), created_at: Set(now), updated_at: Set(now), ..Default::default() }) .await; insert_active(oncall_team_members::ActiveModel { id: Set(id()), team_id: Set(team.clone()), user_email: Set(who()), created_at: Set(now), }) .await; insert_active(oncall_schedules::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), team_id: Set(team.clone()), timezone: Set("UTC".to_string()), rotations: Set("[]".to_string()), created_at: Set(now), updated_at: Set(now), }) .await; insert_active(oncall_overrides::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), team_id: Set(team.clone()), rotation_id: Set(id()), user_email: Set(who()), start_at: Set(now), end_at: Set(now + 1), created_by: Set(who()), created_at: Set(now), ..Default::default() }) .await; insert_active(oncall_policies::ActiveModel { id: Set(policy.clone()), org_id: Set(org.to_string()), team_id: Set(team.clone()), rungs: Set("[]".to_string()), destinations: Set("[]".to_string()), l0_json: Set("{}".to_string()), created_at: Set(now), updated_at: Set(now), }) .await; insert_active(oncall_ownership_rules::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), team_id: Set(team.clone()), path: Set("orgdel".to_string()), dimensions: Set("{}".to_string()), created_at: Set(now), updated_at: Set(now), }) .await; insert_active(oncall_routing_config::ActiveModel { org_id: Set(org.to_string()), updated_at: Set(now), ..Default::default() }) .await; insert_active(oncall_responses::ActiveModel { id: Set(response.clone()), org_id: Set(org.to_string()), subject_type: Set(0), subject_id: Set(id()), responder_role: Set(0), priority: Set(0), state: Set(0), opened_at: Set(now), updated_at: Set(now), ..Default::default() }) .await; let event = id(); insert_active(oncall_response_events::ActiveModel { id: Set(event.clone()), response_id: Set(response.clone()), kind: Set(0), at: Set(now), actor: Set(who()), body: Set("{}".to_string()), ..Default::default() }) .await; insert_active(oncall_response_reports::ActiveModel { org_id: Set(org.to_string()), response_id: Set(response.clone()), report: Set("{}".to_string()), generated_at: Set(now), ..Default::default() }) .await; insert_active(oncall_delivery_reads::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), user_email: Set(who()), event_id: Set(event), read_at: Set(now), }) .await; insert_active(oncall_unavailability::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), user_email: Set(who()), start_at: Set(now), end_at: Set(now + 1), created_by: Set(who()), created_at: Set(now), ..Default::default() }) .await; insert_active(oncall_unrouted_signals::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), path: Set(format!("orgdel/{}", id())), dimensions: Set("{}".to_string()), occurrences: Set(1), first_seen_at: Set(now), last_seen_at: Set(now), ..Default::default() }) .await; insert_active(oncall_user_contacts::ActiveModel { id: Set(id()), org_id: Set(org.to_string()), user_email: Set(who()), updated_at: Set(now), ..Default::default() }) .await; (team, response, policy) } async fn seed_status_page_rows(org: &str, check_id: &str) { use infra::table::entity::{ status_page_component_checks, status_page_components, status_page_notice_components, status_page_notice_updates, status_page_notices, }; let slug = insert_public_status_page(org).await; let page = infra::table::status_pages::get_page_by_slug_any(&slug) .await .unwrap() .unwrap(); let now = Utc::now().timestamp_micros(); let (component, notice) = (config::ider::generate(), config::ider::generate()); insert_entity_row::(json!({ "id": component, "status_page_id": page.id, "org_id": org, "name": "api", "sort_order": 0, "created_at": now, "updated_at": now, })) .await; insert_entity_row::(json!({ "id": config::ider::generate(), "component_id": component, "synthetics_id": check_id, "org_id": org, })) .await; insert_entity_row::(json!({ "id": notice, "org_id": org, "kind": 0, "impact": 1, "source": 0, "title": "outage", "body": "", "state": 0, "starts_at": now, "segments": "[]", "excluded_from_uptime": false, "auto_recovery_streak": 0, "created_at": now, "updated_at": now, })) .await; insert_entity_row::(json!({ "id": config::ider::generate(), "notice_id": notice, "component_id": component, "org_id": org, })) .await; insert_entity_row::(json!({ "id": config::ider::generate(), "notice_id": notice, "org_id": org, "body": "investigating", "created_at": now, })) .await; infra::table::status_pages::upsert_check_snooze(org, check_id, now + 60_000_000, None, now) .await .unwrap(); infra::table::status_pages::insert_audit( org, "root@example.com", "create", Some(¬ice), now, ) .await .unwrap(); let claim = infra::table::entity::status_page_custom_domains::Model { id: config::ider::generate(), org_id: org.to_string(), status_page_id: page.id, domain: format!("{org}.status.example.com"), verification_token: config::ider::generate(), verification_state: 1, verification_failure_reason: None, verified_at: Some(now), released_at: None, last_checked_at: None, created_at: now, updated_at: now, }; assert!( infra::table::status_pages::insert_domain_if_unclaimed(&claim) .await .unwrap() ); } async fn seed_incident_rows(org: &str) -> String { use infra::table::incident_integrations; use sea_orm::{EntityTrait, IntoActiveModel}; let now = Utc::now().timestamp_micros(); let integration = incident_integrations::IncidentIntegrationRecord { id: config::ider::generate(), org_id: org.to_string(), name: "orgdel".to_string(), source_type: "generic".to_string(), token: incident_integrations::generate_token(), enabled: true, config: json!({}), destinations: vec![], created_by: "root@example.com".to_string(), created_at: now, updated_at: now, }; incident_integrations::add(&integration).await.unwrap(); incident_integrations::touch_sender(incident_integrations::TouchSenderParams { integration_id: &integration.id, detected_source: "generic", sender_label: None, now, accepted: 1, rejected: 0, saw_resolved: false, }) .await .unwrap(); let alert = infra::table::entity::external_alerts::Model { id: config::ider::generate(), org_id: org.to_string(), integration_id: integration.id.clone(), detected_source: "generic".to_string(), dedup_key: "orgdel".to_string(), title: "orgdel".to_string(), severity: "critical".to_string(), state: "firing".to_string(), labels: "{}".to_string(), source_url: None, last_payload: "{}".to_string(), first_seen_at: now, last_seen_at: now, resolved_at: None, }; infra::table::entity::external_alerts::Entity::insert(alert.into_active_model()) .exec_without_returning(infra::db::get_orm_client_rw().await) .await .unwrap(); integration.id } async fn seed_synthetics_registry_rows(org: &str) -> (String, String, String) { use infra::table::{ synthetics_agents, synthetics_environments, synthetics_locations, synthetics_probe_tokens, synthetics_variables, }; let now = Utc::now().timestamp_micros(); let has_default = synthetics_probe_tokens::find_default(org).await.unwrap(); let token = synthetics_probe_tokens::SyntheticsProbeTokenRecord { id: config::ider::uuid(), org_id: org.to_string(), name: "orgdel".to_string(), token: synthetics_probe_tokens::generate_token(), is_default: has_default.is_none(), enabled: true, created_by: "root@example.com".to_string(), created_at: now, updated_at: now, }; synthetics_probe_tokens::add(&token).await.unwrap(); let conn = infra::db::get_orm_client_rw().await; let (env, _) = synthetics_environments::get_or_create_global(conn, org) .await .unwrap(); let variable = synthetics_variables::SyntheticsVariableRecord { id: config::ider::generate(), org_id: org.to_string(), env: env.id, name: "orgdel".to_string(), value: "1".to_string(), kind: synthetics_variables::KIND_PLAIN.to_string(), description: String::new(), example: String::new(), tags: vec![], owner: None, created_at: now, updated_at: now, }; synthetics_variables::add(conn, &variable).await.unwrap(); let location_id = config::ider::generate(); synthetics_locations::add(&synthetics_locations::SyntheticsLocationRecord { id: location_id.clone(), org_id: Some(org.to_string()), kind: synthetics_locations::KIND_PRIVATE.to_string(), provider: "self".to_string(), region: "local".to_string(), label: format!("{org} private"), pool: format!("private-{location_id}"), enabled: false, created_at: now, updated_at: now, }) .await .unwrap(); let agent_id = config::ider::generate(); synthetics_agents::register(&synthetics_agents::SyntheticsAgentRecord { id: agent_id.clone(), org_id: org.to_string(), location_id: location_id.clone(), name: "orgdel-agent".to_string(), version: None, capabilities: None, token_id: Some(token.id), last_seen_at: now, created_at: now, }) .await .unwrap(); (token.token, agent_id, location_id) } async fn seed_workflow_rows(org: &str) -> String { use config::meta::folder::{Folder, FolderType}; use infra::table::workflows; let folder = Folder { folder_id: "default".to_string(), name: "default".to_string(), ..Default::default() }; let (folder_pk, ..) = infra::table::folders::get_or_create(org, folder, FolderType::Workflows) .await .unwrap(); let now = Utc::now().timestamp_micros(); let workflow = |id: &str| workflows::Workflow { id: id.to_string(), org_id: org.to_string(), folder_id: folder_pk.to_string(), created_at: now, updated_at: now, created_by: "root@example.com".to_string(), enabled: false, name: format!("orgdel {id}"), description: String::new(), nodes: vec![], edges: vec![], }; let id = config::ider::generate(); workflows::save_workflow(workflow(&id)).await.unwrap(); workflows::save_draft(workflow(&config::ider::generate())) .await .unwrap(); let run_id = config::ider::generate(); workflows::save_workflow_errors(workflows::WorkflowRunErrors { id: 0, cluster: "local".to_string(), org_id: org.to_string(), workflow_id: id.clone(), run_id: run_id.clone(), ran_at: now, data: vec![], input_data: None, }) .await .unwrap(); workflows::save_workflow_run_data(workflows::WorkflowRunData { id: 0, org_id: org.to_string(), workflow_id: id.clone(), run_id, triggered_at: now, data: "{}".to_string(), }) .await .unwrap(); workflows::add_workflow_association(workflows::WorkflowAssociation { id: 0, org_id: org.to_string(), entity_id: "orgdel-alert".to_string(), entity_type: "alert".to_string(), workflow_id: id.clone(), trigger_type: "manual".to_string(), created_at: now, }) .await .unwrap(); id } async fn seed_anomaly_rows(org: &str) -> String { use config::meta::folder::{Folder, FolderType}; use infra::table::entity::{anomaly_detection_config, anomaly_detection_models}; let folder = Folder { folder_id: "default".to_string(), name: "default".to_string(), ..Default::default() }; let (folder_pk, ..) = infra::table::folders::get_or_create(org, folder, FolderType::Alerts) .await .unwrap(); let now = Utc::now().timestamp_micros(); let anomaly_id = config::ider::generate(); insert_entity_row::(json!({ "anomaly_id": anomaly_id, "org_id": org, "stream_name": "orgdel", "stream_type": "logs", "enabled": false, "name": format!("orgdel {anomaly_id}"), "query_mode": "filters", "detection_function": "count", "histogram_interval": "5m", "schedule_interval": "5m", "detection_window_seconds": 3600, "training_window_days": 7, "retrain_interval_days": 1, "threshold": 97, "seasonality": "none", "is_trained": true, "current_model_version": 1, "rcf_num_trees": 50, "rcf_tree_size": 256, "rcf_shingle_size": 1, "alert_enabled": false, "folder_id": folder_pk.to_string(), "status": 4, "retries": 0, "last_updated": now, "created_at": now, "updated_at": now, })) .await; insert_entity_row::(json!({ "anomaly_id": anomaly_id, "version": 1, "s3_path": format!("s3://bucket/anomaly_models/{org}/{anomaly_id}/1.bin.zst"), "s3_bucket": "bucket", "model_size_bytes": 1, "training_start_time": now, "training_end_time": now, "training_data_points": 1, "created_at": now, })) .await; #[cfg(feature = "enterprise")] infra::storage::put( "", &format!("anomaly_models/{org}/{anomaly_id}/1.bin.zst"), Bytes::from_static(b"model"), ) .await .unwrap(); anomaly_id } async fn coverage_counts(org: &str, rows: &CoverageRows) -> Vec<(&'static str, i64)> { let org_tables = [ "ai_chat_shares", "ai_chat_turns", "status_pages", "status_page_snapshots", "status_page_components", "status_page_component_checks", "status_page_notices", "status_page_notice_components", "status_page_notice_updates", "status_page_check_snoozes", "status_page_audit_log", "status_page_custom_domains", "incident_integrations", "external_alerts", "synthetics", "synthetics_runs", "synthetics_jobs", "synthetics_refs", "synthetics_probe_tokens", "synthetics_variables", "synthetics_environments", "synthetics_locations", "synthetics_agents", "workflows", "workflow_drafts", "workflow_associations", "workflow_errors", "workflow_run_data", "anomaly_detection_config", "llm_annotation_queue_bindings", "llm_annotation_queue_items", "llm_annotation_queues", "llm_dataset_items", "llm_datasets", "llm_experiment_slot_retries", "llm_experiments", "llm_idempotency_records", "llm_playground_snapshots", "score_configs", "scorers", "online_eval_jobs", "providers", "llm_secrets", "gen_ai_agents", "oncall_teams", "oncall_schedules", "oncall_overrides", "oncall_policies", "oncall_ownership_rules", "oncall_routing_config", "oncall_responses", "oncall_response_reports", "oncall_delivery_reads", "oncall_unavailability", "oncall_unrouted_signals", "oncall_user_contacts", ]; let mut counts = vec![("ai_chat_sessions", count_unheld_chats(org).await)]; for table in org_tables { counts.push((table, count_org_rows(table, org).await)); } #[cfg(feature = "enterprise")] counts.push(( "llm_remote_tasks", count_org_rows("llm_remote_tasks", org).await, )); for table in [ "org_ai_toolsets", "model_pricing", "rate_limit_rules", "cipher_keys", ] { counts.push((table, count_rows(table, "org", org).await)); } counts.push(( "oncall_team_members", count_rows("oncall_team_members", "team_id", &rows.team_id).await, )); counts.push(( "oncall_response_events", count_rows("oncall_response_events", "response_id", &rows.response_id).await, )); // These two tables carry no org column, so they are counted by the parent row's id. counts.push(( "incident_integration_senders", count_rows( "incident_integration_senders", "integration_id", &rows.integration_id, ) .await, )); counts.push(( "anomaly_detection_models", count_rows("anomaly_detection_models", "anomaly_id", &rows.anomaly_id).await, )); counts } async fn object_exists(key: &str) -> bool { infra::storage::head("", key).await.is_ok() } async fn assert_coverage(org: &str, rows: &CoverageRows, present: bool) { for (table, n) in coverage_counts(org, rows).await { assert_eq!(n > 0, present, "{table} holds {n} rows of {org}"); } let workflow = infra::table::workflows::get_by_org_wid(org, &rows.workflow_id).await; assert_eq!(workflow.unwrap().is_some(), present, "{}", rows.workflow_id); assert_eq!( object_exists(&rows.artifact).await, present, "{}", rows.artifact ); #[cfg(feature = "enterprise")] { let model = format!("anomaly_models/{org}/{}/1.bin.zst", rows.anomaly_id); assert_eq!(object_exists(&model).await, present, "{model}"); } } async fn warm_coverage_caches(org: &str, rows: &CoverageRows) { use infra::table::{ synthetics_agents, synthetics_checks, synthetics_locations, synthetics_probe_tokens, synthetics_variables, }; let conn = infra::db::get_orm_client_ro().await; let cached = synthetics_checks::get_cached(conn, org, &rows.check_id).await; assert!(cached.unwrap().is_some()); let token = synthetics_probe_tokens::find_global(&rows.probe_token).await; assert!(token.unwrap().is_some()); let default_token = synthetics_probe_tokens::find_default(org).await; assert!(default_token.unwrap().is_some()); let variables = synthetics_variables::list_cached(conn, org).await; assert!(!variables.unwrap().is_empty()); let location = synthetics_locations::get(&rows.location_id).await; assert!(location.unwrap().is_some()); let agent = synthetics_agents::get_cached(&rows.agent_id).await; assert!(agent.unwrap().is_some()); #[cfg(feature = "enterprise")] { let workflow = db::workflows::get_workflow(org, &rows.workflow_id).await; assert!(workflow.unwrap().is_some()); } warm_llm_and_oncall_caches(org, rows).await; } async fn warm_llm_and_oncall_caches(org: &str, rows: &CoverageRows) { use infra::table::{ cipher, oncall_ownership, oncall_policies, oncall_schedules, oncall_teams, }; db::model_pricing::cache().await.unwrap(); let pricing = db::model_pricing::get_org_pricing_entries(org); assert!( pricing .iter() .any(|e| e.definition.name == rows.pricing_model) ); let team = oncall_teams::get_cached(org, &rows.team_id).await; assert!(team.unwrap().is_some()); assert!(oncall_teams::has_any_cached(org).await.unwrap()); let members = oncall_teams::list_members_cached(&rows.team_id).await; assert!(!members.unwrap().is_empty()); let schedule = oncall_schedules::get_by_team_cached(org, &rows.team_id).await; assert!(schedule.unwrap().is_some()); let rules = oncall_ownership::list_cached(org).await; assert!(!rules.unwrap().is_empty()); let policy = oncall_policies::get_or_create_cached(org, &rows.team_id).await; assert_eq!(policy.unwrap().id, rows.policy_id); assert_eq!(cipher::get_dek(org).await.unwrap(), rows.dek); #[cfg(feature = "enterprise")] { let org_rules = infra::table::ratelimit::list(org, None, None) .await .unwrap(); assert!(!org_rules.is_empty()); o2_ratelimit::dataresource::db::RATELIMIT_RULES_CACHE .write() .await .extend(org_rules); } } /// Each read below serves a cache hit while the entry lives, so `None` proves the eviction. async fn assert_coverage_caches_evicted( org: &str, rows: &CoverageRows, warmed: std::time::Instant, ) { use infra::table::{ synthetics_agents, synthetics_checks, synthetics_locations, synthetics_probe_tokens, synthetics_variables, }; let since = warmed.elapsed(); let conn = infra::db::get_orm_client_ro().await; let cached = synthetics_checks::get_cached(conn, org, &rows.check_id).await; assert!( cached.unwrap().is_none(), "SYNTHETIC_CACHE kept {org} ({since:?})" ); let token = synthetics_probe_tokens::find_global(&rows.probe_token).await; assert!( token.unwrap().is_none(), "TOKEN_CACHE kept {org} ({since:?})" ); let default_token = synthetics_probe_tokens::find_default(org).await; assert!( default_token.unwrap().is_none(), "DEFAULT_TOKEN_CACHE kept {org} ({since:?})" ); let variables = synthetics_variables::list_cached(conn, org).await; assert!( variables.unwrap().is_empty(), "VARIABLE_CACHE kept {org} ({since:?})" ); let location = synthetics_locations::get(&rows.location_id).await; assert!( location.unwrap().is_none(), "LOCATIONS_CACHE kept {org} ({since:?})" ); let agent = synthetics_agents::get_cached(&rows.agent_id).await; assert!( agent.unwrap().is_none(), "AGENT_CACHE kept {org} ({since:?})" ); #[cfg(feature = "enterprise")] { let workflow = db::workflows::get_workflow(org, &rows.workflow_id).await; assert!(workflow.unwrap().is_none(), "workflows CACHE kept {org}"); } assert_llm_and_oncall_caches_evicted(org, rows, since).await; } async fn assert_llm_and_oncall_caches_evicted( org: &str, rows: &CoverageRows, since: std::time::Duration, ) { use infra::table::{oncall_ownership, oncall_schedules, oncall_teams}; let pricing = db::model_pricing::get_org_pricing_entries(org); assert!( !pricing .iter() .any(|e| e.definition.name == rows.pricing_model), "model pricing CACHE kept {org} ({since:?})" ); let team = oncall_teams::get_cached(org, &rows.team_id).await; assert!(team.unwrap().is_none(), "TEAM_CACHE kept {org} ({since:?})"); assert!( !oncall_teams::has_any_cached(org).await.unwrap(), "ORG_HAS_TEAMS_CACHE kept {org} ({since:?})" ); let members = oncall_teams::list_members_cached(&rows.team_id).await; assert!( members.unwrap().is_empty(), "MEMBERS_CACHE kept {org} ({since:?})" ); let schedule = oncall_schedules::get_by_team_cached(org, &rows.team_id).await; assert!( schedule.unwrap().is_none(), "SCHEDULE_CACHE kept {org} ({since:?})" ); let rules = oncall_ownership::list_cached(org).await; assert!( rules.unwrap().is_empty(), "RULES_CACHE kept {org} ({since:?})" ); #[cfg(feature = "enterprise")] assert!( !o2_ratelimit::dataresource::db::RATELIMIT_RULES_CACHE .read() .await .iter() .any(|r| r.org == org), "RATELIMIT_RULES_CACHE kept {org}" ); } /// A miss here mints a new policy and DEK, so a different id or key proves the eviction. async fn assert_reminting_caches_evicted(org: &str, rows: &CoverageRows) { use infra::table::{cipher, oncall_policies, oncall_teams}; let policy = oncall_policies::get_or_create_cached(org, &rows.team_id).await; let dek = cipher::get_dek(org).await.unwrap(); oncall_teams::delete_by_org(org).await.unwrap(); cipher::delete_by_org(org).await.unwrap(); assert_ne!( policy.unwrap().id, rows.policy_id, "POLICY_CACHE kept {org}" ); assert_ne!(dek, rows.dek, "DEK_CACHE kept {org}"); } async fn drop_coverage_rows(org: &str, rows: &CoverageRows) { use infra::table::{ anomaly_detection, external_alerts, folders, incident_integrations, status_pages, synthetics_agents, synthetics_checks, synthetics_environments, synthetics_locations, synthetics_probe_tokens, synthetics_variables, workflows, }; let conn = infra::db::get_orm_client_rw().await; status_pages::delete_by_org(org).await.unwrap(); incident_integrations::delete_by_org(org).await.unwrap(); external_alerts::delete_by_org(org).await.unwrap(); synthetics_checks::delete_by_org(org).await.unwrap(); synthetics_probe_tokens::delete_by_org(org).await.unwrap(); synthetics_variables::delete_by_org(conn, org) .await .unwrap(); synthetics_environments::delete_by_org(conn, org) .await .unwrap(); synthetics_agents::delete_by_org(org).await.unwrap(); synthetics_locations::delete_by_org(org).await.unwrap(); infra::storage::del_org_prefix_strict(org, &format!("synthetics/{org}/")) .await .unwrap(); workflows::delete_children_by_org(org).await.unwrap(); workflows::delete_drafts_by_org(org).await.unwrap(); workflows::delete_by_org(org).await.unwrap(); anomaly_detection::models::delete_by_org(conn, org) .await .unwrap(); anomaly_detection::config::delete_by_org(conn, org) .await .unwrap(); #[cfg(feature = "enterprise")] o2_enterprise::enterprise::anomaly_detection::storage::delete_org_models(org) .await .unwrap(); folders::delete_by_org(org).await.unwrap(); drop_llm_and_oncall_rows(org).await; assert_coverage(org, rows, false).await; } async fn drop_llm_and_oncall_rows(org: &str) { use infra::table::{ ai_chat_sessions, ai_chat_shares, ai_chat_turns, cipher, gen_ai_agents, llm_eval, llm_secrets, oncall_teams, online_eval_jobs, org_ai_toolsets, providers, ratelimit, score_configs, scorers, }; #[cfg(feature = "enterprise")] o2_enterprise::enterprise::llm_evaluations::remote_tasks::delete_all_by_org(org) .await .unwrap(); llm_eval::delete_by_org(org).await.unwrap(); score_configs::delete_all_by_org(org).await.unwrap(); scorers::delete_all_by_org(org).await.unwrap(); online_eval_jobs::delete_all_by_org(org).await.unwrap(); providers::delete_all_by_org(org).await.unwrap(); llm_secrets::delete_by_org(org).await.unwrap(); org_ai_toolsets::delete_by_org(org).await.unwrap(); gen_ai_agents::delete_by_org(org).await.unwrap(); db::model_pricing::delete_by_org(org).await.unwrap(); ratelimit::delete_by_org(org).await.unwrap(); oncall_teams::delete_by_org(org).await.unwrap(); cipher::delete_by_org(org).await.unwrap(); ai_chat_turns::delete_by_org(org).await.unwrap(); ai_chat_shares::delete_by_org(org).await.unwrap(); ai_chat_sessions::delete_by_org(org).await.unwrap(); } /// Rows only the deleted org gets: a dashboard made through the API, and a report on it. async fn seed_raw_routed_rows(app: &Router, headers: &HeaderMap, org: &str) -> String { use config::meta::{ dashboards::reports::Report, folder::{Folder, FolderType}, }; let board = r#"{"version":5,"title":"orgdelBoard","dashboardId":"","description":"","role":"","owner":"root@example.com","created":"2023-03-30T07:49:41.744+00:00","tabs":[{"tabId":"tab1","name":"Main","panels":[]}]}"#; let (status, body) = make_request( app, Method::POST, &format!("/api/{org}/dashboards"), Some(headers.clone()), Some(board.to_string()), ) .await; assert!(status.is_success(), "{}", String::from_utf8_lossy(&body)); let dashboard: Dashboard = json::from_slice(&body).unwrap(); let dashboard_id = dashboard.dashboard_id().unwrap().to_string(); common::infra::config::DASHBOARD_ID_TO_ORG .write() .await .insert(dashboard_id.clone(), org.to_string()); let folder = Folder { folder_id: String::new(), name: "orgdelReports".to_string(), description: String::new(), icon: None, }; let folder = db::folders::save_folder(org, folder, FolderType::Reports, false) .await .unwrap(); let report: Report = serde_json::from_value(json!({ "name": "orgdelReport", "orgId": org, "dashboards": [{"dashboard": dashboard_id, "folder": "default", "tabs": ["tab1"]}], "destinations": [] })) .unwrap(); let conn = infra::db::get_orm_client_rw().await; infra::table::reports::create_report(conn, &folder.folder_id, report, None) .await .unwrap(); dashboard_id } async fn seed_shared_templates(org: &str, kept: &str) -> [String; 4] { use config::meta::destinations::{Destination, Module}; use infra::table::entity::{destinations, templates}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; let conn = infra::db::get_orm_client_rw().await; let twin = format!("{org}Twin"); let fallback = format!("{org}Fallback"); let mut ids = Vec::new(); for (owner, name) in [ (config::DEFAULT_ORG, twin.as_str()), (org, twin.as_str()), (kept, twin.as_str()), (config::DEFAULT_ORG, fallback.as_str()), ] { let id = config::ider::uuid(); templates::ActiveModel { id: Set(id.clone()), org: Set(owner.to_string()), name: Set(name.to_string()), is_default: Set(false), r#type: Set("http".to_string()), body: Set(r#"{"text":"{alert_name}"}"#.to_string()), title: Set(None), kind: Set("custom".to_string()), } .insert(conn) .await .unwrap(); ids.push(id); } let dest = infra::table::destinations::get(org, "orgdelDest") .await .unwrap() .unwrap(); let Module::Alert { destination_type, .. } = dest.module else { panic!("orgdelDest is an alert destination"); }; for (name, template, expected_id) in [ ("orgdelOwnedDest", twin, &ids[2]), ("orgdelDefaultDest", fallback, &ids[3]), ] { infra::table::destinations::put(Destination { org_id: kept.to_string(), name: name.to_string(), module: Module::Alert { template: Some(template), destination_type: destination_type.clone(), }, ..Default::default() }) .await .unwrap(); let stored = destinations::Entity::find() .filter(destinations::Column::Org.eq(kept)) .filter(destinations::Column::Name.eq(name)) .one(conn) .await .unwrap() .unwrap(); assert_eq!(stored.template_id.as_ref(), Some(expected_id)); } ids.try_into().unwrap() } async fn assert_shared_templates(org: &str, kept: &str, ids: &[String; 4]) { let ids_of = |rows: Vec<(String, String)>| -> Vec { rows.into_iter().map(|(id, _)| id).collect() }; let defaults = ids_of( infra::table::templates::list_ids_by_org(config::DEFAULT_ORG) .await .unwrap(), ); assert!(defaults.contains(&ids[0]), "the default twin was deleted"); assert!( defaults.contains(&ids[3]), "the default fallback was deleted" ); assert!( infra::table::templates::list_ids_by_org(org) .await .unwrap() .is_empty(), "the deleted organization's templates remain" ); let survivors = infra::table::templates::list_ids_by_org(kept) .await .unwrap(); assert!( ids_of(survivors).contains(&ids[2]), "the live twin was deleted" ); for destination in ["orgdelOwnedDest", "orgdelDefaultDest"] { assert!( infra::table::destinations::get(kept, destination) .await .unwrap() .is_some() ); infra::table::destinations::delete(kept, destination) .await .unwrap(); } assert!( !infra::table::templates::delete_by_id(org, &ids[0]) .await .unwrap() ); for (owner, id) in [ (config::DEFAULT_ORG, &ids[0]), (kept, &ids[2]), (config::DEFAULT_ORG, &ids[3]), ] { assert!( infra::table::templates::delete_by_id(owner, id) .await .unwrap() ); } } /// Asserts the raw-routed rows and the caches their db-layer deletes evict. async fn assert_raw_routed_rows(org: &str, dashboard_id: &str, present: bool) { use common::infra::config::{ALERTS_TEMPLATES, DASHBOARD_ID_TO_ORG, DESTINATIONS}; let dashboard = infra::table::dashboards::get_by_id(org, dashboard_id).await; assert_eq!(dashboard.unwrap().is_some(), present, "dashboard of {org}"); let mapped = DASHBOARD_ID_TO_ORG.read().await.contains_key(dashboard_id); assert_eq!(mapped, present, "DASHBOARD_ID_TO_ORG for {org}"); for (table, column) in [ ("reports", "org"), ("folders", "org"), ("destinations", "org"), ("templates", "org"), ] { let n = count_rows(table, column, org).await; assert_eq!(n > 0, present, "{table} holds {n} rows of {org}"); } let destination = &format!("{org}/orgdelDest"); let template = &format!("{org}/orgdelTemplate"); eventually( &format!("DESTINATIONS and ALERTS_TEMPLATES for {org}"), || async move { DESTINATIONS.contains_key(destination) == present && ALERTS_TEMPLATES.contains_key(template) == present }, ) .await; let org_key = format!("/organization/org/{org}"); let coordinator = infra::db::get_coordinator().await; assert_eq!( coordinator.get(&org_key).await.is_ok(), present, "{org_key}" ); } async fn eventually(what: &str, mut check: F) where F: FnMut() -> Fut, Fut: std::future::Future, { for _ in 0..50 { if check().await { return; } tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; } panic!("{what} never reached the expected state"); } /// Meta-store keys of Task 0.17, each under an org-exact prefix. fn kv_coverage_keys(org: &str) -> Vec { vec![ format!("/compact/organization/{org}/retention"), format!("/compact/files/{org}/logs/kvstream"), format!("/compact/downsampling/{org}/metrics/kvstream/60/10"), format!("/service_graph/v4/offsets/{org}/kvstream"), format!("/db_monitoring/offsets/{org}/kvstream"), format!("/metrics_members/{org}/c1"), format!("/metrics_leader/{org}/c1"), format!("/function/{org}/orgdelfn"), format!("/organization/setting/{org}"), format!("/enrichment_table_meta_stream_stats/{org}/orgdel_et"), ] } fn disk_cache_keys(org: &str) -> [String; 2] { [ format!("results/{org}/logs/kvstream/123/1_2_0_0.json"), format!( "metrics_results/{org}/2026/01/01/00/v2_b235015c612525ad7c11c109e3fdc261_1_2_1.pb" ), ] } /// A service-graph v4 snapshot for the deleted org and for the kept one. fn seed_service_graph_snapshots(org: &str, kept: &str) -> [std::path::PathBuf; 2] { use openobserve_core::traces::service_graph::v4::resolution::{ snapshot_path, write_snapshot_file, }; let paths = [snapshot_path(org), snapshot_path(kept)]; for path in &paths { write_snapshot_file(path, b"{}").unwrap(); } paths } fn assert_service_graph_snapshots([gone, kept]: &[std::path::PathBuf; 2]) { assert!(!gone.exists(), "{} survived the deletion", gone.display()); assert!( kept.exists(), "{} of the kept org was removed", kept.display() ); std::fs::remove_file(kept).unwrap(); } fn short_url_id(org: &str) -> String { format!("s{org}") } /// One key, offset, cache entry and stats row per Task 0.17 row, named after the org. async fn seed_kv_and_cache_rows(org: &str) { use config::meta::{ function::Transform, promql::ClusterLeader, system_settings::SystemSetting, }; let function = Transform { function: ".".to_string(), name: "orgdelfn".to_string(), params: "row".to_string(), num_args: 1, trans_type: Some(0), streams: None, }; db::functions::set(org, "orgdelfn", &function) .await .unwrap(); transform::QUERY_FUNCTIONS.insert(format!("{org}/orgdelfn"), function); db::organization::set_org_setting(org, &Default::default()) .await .unwrap(); let meta_db = infra::db::get_db().await; for key in &kv_coverage_keys(org)[..5] { meta_db .put( key, Bytes::from_static(b"1"), infra::db::NO_NEED_WATCH, None, ) .await .unwrap(); } db::compact::files::set_offset(org, StreamType::Logs, "kvstream", 9, Some("node")) .await .unwrap(); let cluster = format!("{org}/c1"); let leader = ClusterLeader { name: "r1".to_string(), last_received: 1, updated_by: "node".to_string(), }; db::metrics::set_prom_cluster_info(&cluster, &["r1".to_string()]) .await .unwrap(); db::metrics::set_prom_cluster_leader(&cluster, &leader) .await .unwrap(); let leaders = common::infra::config::METRIC_CLUSTER_LEADER.clone(); leaders.write().await.insert(cluster.clone(), leader); let members = common::infra::config::METRIC_CLUSTER_MAP.clone(); members .write() .await .insert(cluster, vec!["r1".to_string()]); for key in disk_cache_keys(org) { infra::cache::file_data::disk::set(&key, Bytes::from_static(b"{}")) .await .unwrap(); } infra::cache::file_data::disk::add_result_cache_meta(&disk_cache_keys(org)[0]).await; seed_enrichment_and_stats_rows(org).await; db::kv::set(org, "orgdel_key", Bytes::from_static(b"v")) .await .unwrap(); let setting = SystemSetting::new_org(org, "orgdel_setting", json!(1)); db::system_settings::set(&setting).await.unwrap(); let short_id = short_url_id(org); let short = infra::table::short_urls::ShortUrlRecord::new(&short_id, "https://dummy", org); assert!(db::short_url::set(&short_id, short).await.unwrap()); } async fn seed_enrichment_and_stats_rows(org: &str) { use config::meta::{ enrichment_table::EnrichmentTableUrlJob, stream::{EnrichmentTableMetaStreamStats, FileMeta, StreamStats}, }; db::enrichment_table::save_enrichment_data_to_db( org, "orgdel_et", &vec![json!({"a": 1})], 1, ) .await .unwrap(); let job = EnrichmentTableUrlJob::new( org.to_string(), "orgdel_et".to_string(), "https://dummy/et.csv".to_string(), false, ); db::enrichment_table::save_url_job(&job).await.unwrap(); let stats = EnrichmentTableMetaStreamStats { start_time: 1, end_time: 2, size: 1, }; db::enrichment_table::update_meta_table_stats(org, "orgdel_et", stats) .await .unwrap(); ENRICHMENT_TABLES.insert( format!("{org}/enrichment_tables/orgdel_et"), transform::enrichment::StreamTable { org_id: org.to_string(), stream_name: "orgdel_et".to_string(), data: Arc::new(Vec::new()), }, ); let stats = StreamStats { doc_num: 1, file_num: 1, ..Default::default() }; infra::file_list::set_stream_stats(org, StreamType::Logs, "kvstream", &stats, false) .await .unwrap(); let meta = FileMeta { min_ts: 1, max_ts: 2, records: 1, original_size: 6, compressed_size: 6, ..Default::default() }; let history = format!("files/{org}/logs/kvstream/2026/01/01/00/h.parquet"); infra::file_list::add_history("", &history, &meta) .await .unwrap(); infra::file_list::add_job(org, StreamType::Logs, "kvstream", 1) .await .unwrap(); let error = config::meta::self_reporting::error::PipelineError::new("p", "p"); let now = Utc::now().timestamp_micros(); db::pipeline_errors::upsert(&format!("{org}-p"), "p", org, now, &error) .await .unwrap(); let backfill = infra::table::backfill_jobs::BackfillJob { id: config::ider::generate(), org: org.to_string(), pipeline_id: format!("{org}-p"), start_time: 1, end_time: 2, chunk_period_minutes: None, delay_between_chunks_secs: None, delete_before_backfill: false, created_at: now, enabled: true, }; db::backfill::add(backfill).await.unwrap(); } /// The stored side of Task 0.17: meta keys, disk cache files, stats and rows. async fn assert_kv_and_cache_rows(org: &str, present: bool) { let meta_db = infra::db::get_db().await; for key in kv_coverage_keys(org) { assert_eq!(meta_db.get(&key).await.is_ok(), present, "{key}"); } for key in disk_cache_keys(org) { let exists = infra::cache::file_data::disk::exist(&key).await; assert_eq!(exists, present, "{key}"); } for (table, column) in KV_COVERAGE_TABLES { let n = count_rows(table, column, org).await; assert_eq!(n > 0, present, "{table} holds {n} rows of {org}"); } let stats = infra::file_list::get_stream_stats(org, None, None).await; assert_eq!(!stats.unwrap().is_empty(), present, "stream stats of {org}"); } /// The node-cache side of Task 0.17; a watcher-driven eviction gets a few seconds. async fn assert_kv_caches(org: &str, present: bool) { use common::infra::config::{ KVS, METRIC_CLUSTER_LEADER, METRIC_CLUSTER_MAP, ORGANIZATION_SETTING, SHORT_URLS, }; use config::meta::system_settings::SettingScope; let function = &format!("{org}/orgdelfn"); let cluster = &format!("{org}/c1"); let setting_key = &format!("/organization/setting/{org}"); let enrichment = &format!("{org}/enrichment_tables/orgdel_et"); let short_id = &short_url_id(org); let query_key = &format!("{org}_logs_kvstream_123"); let kv_key = &format!("{org}/orgdel_key"); eventually(&format!("Task 0.17 caches of {org}"), || async move { let setting = db::system_settings::get_cached( &SettingScope::Org, Some(org), None, "orgdel_setting", ) .await; let offset = db::compact::files::get_offset_from_cache(org, StreamType::Logs, "kvstream").await; let results = infra::cache::file_data::disk::QUERY_RESULT_CACHE .read() .await .contains_key(query_key); [ transform::QUERY_FUNCTIONS.contains_key(function), ORGANIZATION_SETTING.read().await.contains_key(setting_key), METRIC_CLUSTER_LEADER.read().await.contains_key(cluster), METRIC_CLUSTER_MAP.read().await.contains_key(cluster), offset.is_some(), KVS.contains_key(kv_key), setting.is_some(), SHORT_URLS.contains_key(short_id), ENRICHMENT_TABLES.contains_key(enrichment), results, ] .iter() .all(|held| *held == present) }) .await; } /// Removes the surviving org's Task 0.17 rows through the same deletes the cleanup runs. async fn drop_kv_and_cache_rows(org: &str) { db::functions::delete_by_org(org).await.unwrap(); db::organization::delete_org_setting(org).await.unwrap(); db::compact::files::delete_by_org(org).await.unwrap(); db::metrics::delete_by_org(org).await.unwrap(); let meta_db = infra::db::get_db().await; for key in kv_coverage_keys(org) { meta_db.delete_if_exists(&key, false, false).await.unwrap(); } for key in disk_cache_keys(org) { infra::cache::file_data::disk::remove(&key).await.unwrap(); } infra::cache::file_data::disk::remove_result_cache_metas(&disk_cache_keys(org)[..1]).await; infra::file_list::del_stream_stats(org, StreamType::Logs, "kvstream") .await .unwrap(); let stream = format!("{org}/logs/kvstream"); let history = infra::file_list::StreamRowsTable::History; infra::file_list::remove_stream_batch(history, org, &stream, 100, false) .await .unwrap(); infra::file_list::delete_jobs_by_streams(org, &[stream]) .await .unwrap(); db::pipeline_errors::delete_by_org(org).await.unwrap(); db::kv::delete(org, "orgdel_key").await.unwrap(); db::system_settings::delete_org_settings(org).await.unwrap(); db::short_url::delete_by_org(org).await.unwrap(); infra::table::backfill_jobs::delete_by_org(org) .await .unwrap(); infra::table::enrichment_tables::delete_by_org(org) .await .unwrap(); infra::table::enrichment_table_urls::delete_by_org(org) .await .unwrap(); ENRICHMENT_TABLES.remove(&format!("{org}/enrichment_tables/orgdel_et")); assert_kv_and_cache_rows(org, false).await; } /// A cached workflow whose row is gone, as after a crash between row delete and eviction. #[cfg(feature = "enterprise")] async fn strand_cached_workflow(org: &str, like: &str) -> String { let mut workflow = infra::table::workflows::get_by_org_wid(org, like) .await .unwrap() .unwrap(); workflow.id = config::ider::generate(); infra::table::workflows::save_workflow(workflow.clone()) .await .unwrap(); let cached = db::workflows::get_workflow(org, &workflow.id).await; assert!(cached.unwrap().is_some()); infra::table::workflows::delete_workflow(&workflow.id) .await .unwrap(); workflow.id } async fn drive_cleanup_until_step_done(org: &str, step: &str, max_rounds: usize) { let cleanup: Arc = Arc::new(openobserve_jobs::job::CompactionStreamDataCleanup); for _ in 0..max_rounds { openobserve_core::org_cleanup::run_once(Arc::clone(&cleanup)).await; if cleanup_task(org, step).await.status == "done" { return; } tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; } panic!("org {org} step {step} not done after {max_rounds} rounds"); } async fn drive_cleanup_until_done(org: &str, max_rounds: usize) { let cleanup: Arc = Arc::new(openobserve_jobs::job::CompactionStreamDataCleanup); let mut tasks = Vec::new(); for _ in 0..max_rounds { openobserve_core::org_cleanup::run_once(Arc::clone(&cleanup)).await; tasks = infra::table::org_cleanup_tasks::list_by_org_status(org, None) .await .unwrap(); if tasks.iter().all(|t| t.status == "done") { return; } tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; } panic!("org {org} cleanup not done after {max_rounds} rounds: {tasks:#?}"); } async fn insert_public_status_page(org_id: &str) -> String { let now = Utc::now().timestamp_micros(); let slug = config::ider::generate(); let page = infra::table::entity::status_pages::Model { id: config::ider::generate(), org_id: org_id.to_string(), name: format!("e2e page {slug}"), slug: slug.clone(), description: None, visibility: 1, password_hash: None, noindex: true, show_uptime_percent: false, show_timeline_bars: false, show_response_time: false, confirm_failures: 1, confirm_recovery: 1, confirm_after_secs: None, brand_name: None, accent_color: None, logo_img: None, favicon_img: None, tracking_since: Some(now), owner: None, created_at: now, updated_at: now, }; infra::table::status_pages::insert_page(&page) .await .unwrap(); let conn = infra::db::get_orm_client_rw().await; infra::table::status_pages::upsert_snapshot( conn, &page.id, org_id, Some("[]"), Some("{}"), now, ) .await .unwrap(); slug } async fn e2e_file_list_step_moves_every_stream_to_deleted() { let suffix = run_suffix(); let (gone, kept) = (format!("fldel{suffix}"), format!("flkeep{suffix}")); let meta = config::meta::stream::FileMeta { min_ts: 1, max_ts: 2, records: 1, original_size: 1, compressed_size: 1, flattened: false, index_size: 5, mindex_size: 0, bloom_ver: 0, }; // Neither stream has a schema, stats row or task row: only their fresh rows lead to them. let key = |org: &str, stream: &str| format!("files/{org}/logs/{stream}/2026/01/01/00/a.parquet"); for org in [&gone, &kept] { for stream in ["s1", "late"] { infra::file_list::add("", &key(org, stream), &meta) .await .unwrap(); } } add_tasks(&gone, &[("delete_file_list", 200)]).await; run_cleanup_once().await; let step = cleanup_task(&gone, "delete_file_list").await; let deleted = infra::file_list::query_deleted(&gone, i64::MAX, 100) .await .unwrap(); let mut left = Vec::new(); for (org, stream) in [ (&gone, "s1"), (&gone, "late"), (&kept, "s1"), (&kept, "late"), ] { left.push(infra::file_list::contains(&key(org, stream)).await.unwrap()); } let kept_deleted = infra::file_list::query_deleted(&kept, i64::MAX, 100) .await .unwrap(); infra::table::org_cleanup_tasks::delete_by_org(&gone) .await .unwrap(); remove_deleted_rows(&gone).await; for stream in ["s1", "late"] { infra::file_list::remove(&key(&kept, stream)).await.unwrap(); } assert_eq!(step.status, "done", "{step:?}"); assert_eq!(deleted.len(), 2, "{deleted:?}"); assert!( deleted.iter().all(|f| f.index_file), "index_size > 0 must map to index_file = true" ); assert_eq!(left, [false, false, true, true]); assert!(kept_deleted.is_empty(), "{kept_deleted:?}"); } async fn e2e_public_paths_refuse_blocked_org() { let auth = setup(); let app = init_test_router(); let headers = auth_headers(auth); let org = format!("spblock{}", run_suffix()); let ghost = format!("spghost{}", run_suffix()); ingest_one(&app, &headers, &org).await; let live = insert_public_status_page(&org).await; let blocked = insert_public_status_page(&org).await; let orphan = insert_public_status_page(&ghost).await; let (status, _) = make_request( &app, Method::GET, &format!("/api/status_pages_public/{live}"), None, None, ) .await; assert_eq!(status, StatusCode::OK, "an active org's page is served"); let (status, _) = make_request( &app, Method::GET, &format!("/api/status_pages_public/{orphan}"), None, None, ) .await; assert_eq!(status, StatusCode::NOT_FOUND, "a page of a missing org"); db::org_status::broadcast_deleting(&org).await.unwrap(); let (status, _) = make_request( &app, Method::GET, &format!("/api/status_pages_public/{blocked}"), None, None, ) .await; assert_eq!(status, StatusCode::NOT_FOUND, "a page of a deleting org"); let (status, _) = make_request( &app, Method::GET, &format!("/api/status_pages_public/{live}"), None, None, ) .await; assert_eq!( status, StatusCode::NOT_FOUND, "a page cached while its org was active" ); // The webhook route is enterprise only, so its org guard is called directly. let refuses = |r: Option| r.map(|r| r.status()); assert_eq!( refuses(inactive_org_response(&org).await), Some(StatusCode::FORBIDDEN), "webhook token of a deleting org" ); assert_eq!( refuses(inactive_org_response(&ghost).await), Some(StatusCode::FORBIDDEN), "webhook token of a missing org" ); db::org_status::set_active(&org).await.unwrap(); assert!( inactive_org_response(&org).await.is_none(), "an active org's webhook token is accepted" ); // A probe token whose org row is gone must stop authorizing probe calls (D-11). let token = infra::table::synthetics_probe_tokens::create_for_org(&ghost, "root@example.com") .await .unwrap(); let mut probe_headers = HeaderMap::new(); let basic = config::utils::base64::encode(&format!("probe:{}", token.token)); probe_headers.insert( header::AUTHORIZATION, HeaderValue::from_str(&format!("Basic {basic}")).unwrap(), ); probe_headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("application/json"), ); let (status, body) = make_request( &app, Method::POST, &format!("/api/{ghost}/synthetics/jobs/ack"), Some(probe_headers), Some(r#"{"acks":[]}"#.to_string()), ) .await; assert_eq!( status, StatusCode::FORBIDDEN, "{}", String::from_utf8_lossy(&body) ); assert!(String::from_utf8_lossy(&body).contains("not active")); } #[cfg(feature = "enterprise")] async fn post_incident_event(app: &Router, uri: &str, bearer: Option<&str>) -> StatusCode { let mut headers = HeaderMap::new(); headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("application/json"), ); if let Some(token) = bearer { headers.insert( header::AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), ); } let body = r#"{"status":"firing","labels":{"alertname":"orgdel"},"title":"orgdel"}"#; let (status, resp) = make_request( app, Method::POST, uri, Some(headers), Some(body.to_string()), ) .await; log::info!( "incident webhook {uri}: {status} {}", String::from_utf8_lossy(&resp) ); status } #[cfg(feature = "enterprise")] async fn e2e_incident_webhook_refuses_inactive_org() { let auth = setup(); let app = init_test_router(); let headers = auth_headers(auth); let org = format!("iwblock{}", run_suffix()); let ghost = format!("iwghost{}", run_suffix()); ingest_one(&app, &headers, &org).await; // SAFETY: the org-deletion e2e tasks run one at a time, so no thread reads the env here. unsafe { env::set_var("O2_INCIDENTS_ENABLED", "true") }; o2_enterprise::enterprise::common::config::refresh_config().unwrap(); let live = infra::table::incident_integrations::ensure_default_for_org(&org, "root@example.com") .await .unwrap(); let orphan = infra::table::incident_integrations::ensure_default_for_org(&ghost, "root@example.com") .await .unwrap(); let bearer_uri = format!("/api/v2/{org}/incidents/events"); let active = post_incident_event(&app, &bearer_uri, Some(&live.token)).await; let ghost_uri = format!("/api/v2/{ghost}/incidents/events/{}", orphan.token); let missing = post_incident_event(&app, &ghost_uri, None).await; db::org_status::broadcast_deleting(&org).await.unwrap(); let deleting = post_incident_event(&app, &bearer_uri, Some(&live.token)).await; db::org_status::set_active(&org).await.unwrap(); // SAFETY: restored before asserting so a failure does not leak incidents into later tests. unsafe { env::remove_var("O2_INCIDENTS_ENABLED") }; o2_enterprise::enterprise::common::config::refresh_config().unwrap(); assert_eq!(active, StatusCode::ACCEPTED, "an active org's event"); assert_eq!(missing, StatusCode::FORBIDDEN, "a missing org's token"); assert_eq!(deleting, StatusCode::FORBIDDEN, "a deleting org's token"); } async fn e2e_org_delete_completes() { let auth = setup(); let app = init_test_router(); let headers = auth_headers(auth); // POST /api/organizations generates ids, so the org is created by ingestion instead. let org = format!("orgdel{}", run_suffix()); ingest_one(&app, &headers, &org).await; // An alert outside the default folder exercises D-05 and the folders FK. let (status, body) = make_request( &app, Method::POST, &format!("/api/{org}/alerts/templates"), Some(headers.clone()), Some(r#"{"name":"orgdelTemplate","body":"{\"text\":\"{alert_name}\"}"}"#.to_string()), ) .await; assert!(status.is_success(), "{}", String::from_utf8_lossy(&body)); let (status, body) = make_request( &app, Method::POST, &format!("/api/{org}/alerts/destinations"), Some(headers.clone()), Some( r#"{"name":"orgdelDest","url":"https://dummy/alert","method":"post","template":"orgdelTemplate"}"# .to_string(), ), ) .await; assert!(status.is_success(), "{}", String::from_utf8_lossy(&body)); let (status, body) = make_request( &app, Method::POST, &format!("/api/v2/{org}/folders/alerts"), Some(headers.clone()), Some(r#"{"name":"f1","description":""}"#.to_string()), ) .await; assert!(status.is_success(), "{}", String::from_utf8_lossy(&body)); let folder: serde_json::Value = serde_json::from_slice(&body).unwrap(); let folder_id = folder["folderId"].as_str().unwrap().to_string(); let alert = json!({ "name": "orgdelAlert", "stream_type": "logs", "stream_name": "orgdel", "is_real_time": false, "enabled": true, "query_condition": {"conditions": [{"column": "a", "operator": "=", "value": "1"}]}, "trigger_condition": {"period": 60, "threshold": 1, "silence": 0, "frequency": 60, "operator": ">="}, "destinations": ["orgdelDest"] }); let (status, body) = make_request( &app, Method::POST, &format!("/api/v2/{org}/alerts?folder={folder_id}"), Some(headers.clone()), Some(alert.to_string()), ) .await; assert!(status.is_success(), "{}", String::from_utf8_lossy(&body)); let orphan = seed_orphan_file(&org, "orphan").await; // A legacy row, written long before the deletion: only step 200's org-wide read sees it. sea_orm::ConnectionTrait::execute_unprepared( infra::db::get_orm_client_rw().await, &format!("UPDATE file_list SET updated_at = 1 WHERE stream = '{org}/logs/orphan'"), ) .await .unwrap(); let orphan_path = format!("{}{orphan}", get_config().common.data_stream_dir); assert!(std::path::Path::new(&orphan_path).exists()); let kept = format!("orgkeep{}", run_suffix()); let gone_rows = seed_coverage_rows(&org).await; let kept_rows = seed_coverage_rows(&kept).await; assert_coverage(&org, &gone_rows, true).await; let dashboard_id = seed_raw_routed_rows(&app, &headers, &org).await; let shared_templates = seed_shared_templates(&org, &kept).await; assert_raw_routed_rows(&org, &dashboard_id, true).await; seed_kv_and_cache_rows(&org).await; seed_kv_and_cache_rows(&kept).await; assert_kv_and_cache_rows(&org, true).await; assert_kv_caches(&org, true).await; #[cfg(feature = "enterprise")] let stranded = strand_cached_workflow(&org, &gone_rows.workflow_id).await; #[cfg(feature = "enterprise")] let mut org_evictions = infra::coordinator::get_coordinator() .await .watch(&format!("/workflows/org/{org}")) .await .unwrap(); warm_coverage_caches(&org, &gone_rows).await; let warmed = std::time::Instant::now(); let snapshots = seed_service_graph_snapshots(&org, &kept); openobserve_core::org_cleanup::initiate_deletion(&org, "root@example.com") .await .unwrap(); // Hold the deletion at step 600, past both drains of step 200's rows, then write late. unsafe { env::set_var("ZO_ORG_CLEANUP_FAIL_STEP", "delete_users") }; drive_cleanup_until_step_done(&org, "drain_deleted_files", 10).await; let late = seed_orphan_file(&org, "late").await; unsafe { env::remove_var("ZO_ORG_CLEANUP_FAIL_STEP") }; let late_path = format!("{}{late}", get_config().common.data_stream_dir); drive_cleanup_until_done(&org, 20).await; assert_service_graph_snapshots(&snapshots); assert!( !std::path::Path::new(&late_path).exists(), "{late_path}, written after step 200, survived the deletion" ); assert!( infra::file_list::query_deleted(&org, i64::MAX, 1) .await .unwrap() .is_empty(), "file_list_deleted still holds rows of {org}" ); assert!( !std::path::Path::new(&orphan_path).exists(), "{orphan_path} survived the deletion" ); // On local disk the drain also removes the org's folder, so the rows prove the queueing. for table in ["file_list", "file_list_history"] { let sql = format!("SELECT COUNT(*) FROM {table} WHERE org = '{org}'"); let conn = infra::db::get_orm_client_rw().await; let row = sea_orm::ConnectionTrait::query_one( conn, sea_orm::Statement::from_string( sea_orm::ConnectionTrait::get_database_backend(conn), sql, ), ) .await .unwrap() .unwrap(); let left: i64 = row.try_get_by_index(0).unwrap(); assert_eq!(left, 0, "{table} still holds rows of {org}"); } let files_dir = format!("{}files/{org}", get_config().common.data_stream_dir); assert!( !std::path::Path::new(&files_dir).exists(), "{files_dir} survived the deletion" ); assert_coverage_caches_evicted(&org, &gone_rows, warmed).await; #[cfg(feature = "enterprise")] { let cached = db::workflows::get_workflow(&org, &stranded).await; assert!( cached.unwrap().is_none(), "the stranded workflow stayed cached" ); let event = tokio::time::timeout( tokio::time::Duration::from_secs(5), Arc::get_mut(&mut org_evictions).unwrap().recv(), ) .await; assert!( matches!(event, Ok(Some(infra::db::Event::Delete(_)))), "no org-scoped workflow eviction reached the watchers: {event:?}" ); } assert_coverage(&org, &gone_rows, false).await; assert_coverage(&kept, &kept_rows, true).await; drop_coverage_rows(&kept, &kept_rows).await; assert_shared_templates(&org, &kept, &shared_templates).await; assert_raw_routed_rows(&org, &dashboard_id, false).await; assert_kv_and_cache_rows(&org, false).await; assert!( infra::file_list::get_stream_stats(&org, None, None) .await .unwrap() .is_empty() ); assert_kv_caches(&org, false).await; assert_kv_and_cache_rows(&kept, true).await; assert_kv_caches(&kept, true).await; drop_kv_and_cache_rows(&kept).await; assert_reminting_caches_evicted(&org, &gone_rows).await; // Step 900 removes the task rows last, so an empty list means every step ran. let tasks = infra::table::org_cleanup_tasks::list_by_org_status(&org, None) .await .unwrap(); assert!(tasks.is_empty(), "{tasks:#?}"); assert!( db::alerts::alert::list_all_for_cleanup(&org) .await .unwrap() .is_empty() ); assert_org_is_tombstone(&app, &headers, &org).await; assert!( !db::organization::tombstone_org(&org).await.unwrap(), "a step 900 retry must not count as the transition that emits OrgDeleted" ); assert_tombstone_sweep(&org).await; assert_stale_mark_spares_reused_id(&org).await; } async fn e2e_online_eval_runner_skips_deleting_org() { let auth = setup(); let app = init_test_router(); let headers = auth_headers(auth); let org = format!("orgeval{}", run_suffix()); ingest_one(&app, &headers, &org).await; let job = seed_eval_job(&org, Utc::now().timestamp_micros()).await; assert!(eval_job_is_scheduled(&job).await); openobserve_core::org_cleanup::initiate_deletion(&org, "root@example.com") .await .unwrap(); let scheduled = eval_job_is_scheduled(&job).await; drive_cleanup_until_done(&org, 20).await; assert!( !scheduled, "the runner still schedules a job of a deleting org" ); } async fn eval_job_is_scheduled(job_id: &str) -> bool { infra::table::online_eval_jobs::get_active_trace_session() .await .unwrap() .iter() .any(|j| j.id == job_id) } /// Step 900 keeps a `deleted` row that refuses ingestion, auto-create and the org list. async fn assert_org_is_tombstone(app: &Router, headers: &HeaderMap, org: &str) { assert!(get_config().common.create_org_through_ingestion); let row = infra::table::organizations::get(org).await.unwrap(); assert_eq!(row.status, "deleted"); assert!(db::org_status::is_blocked(org)); let (status, body) = make_request( app, Method::POST, &format!("/api/{org}/orgdel/_json"), Some(headers.clone()), Some(r#"[{"a":2}]"#.to_string()), ) .await; assert_eq!( status, StatusCode::FORBIDDEN, "{}", String::from_utf8_lossy(&body) ); assert!( openobserve_core::organization::check_and_create_org(org) .await .is_err() ); assert_eq!( infra::table::organizations::get(org).await.unwrap().status, "deleted", "auto-create revived the tombstone" ); let (status, body) = make_request( app, Method::GET, "/api/organizations?page_size=1000", Some(headers.clone()), None, ) .await; assert!(status.is_success()); let listed: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!( !listed["data"] .as_array() .unwrap() .iter() .any(|o| o["identifier"] == org), "the org list shows a tombstone" ); } /// Only tombstones older than the cutoff are listed, and removal frees the id. async fn assert_tombstone_sweep(org: &str) { let now = Utc::now().timestamp_micros(); let month_ago = now - Duration::days(30).num_microseconds().unwrap(); let fresh = infra::table::organizations::list_expired_tombstone_rows(month_ago) .await .unwrap(); assert!(!fresh.iter().any(|t| t.identifier == org), "{fresh:?}"); let expired = infra::table::organizations::list_expired_tombstone_rows(now + 1_000_000) .await .unwrap(); assert!(expired.iter().any(|t| t.identifier == org), "{expired:?}"); // Only the chat the retention sweep still re-purges outlives step 300, until this sweep. assert_eq!(count_org_rows("ai_chat_sessions", org).await, 1); use infra::table::entity::{ ai_chat_sessions, organizations::{Column, Entity}, }; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, sea_query::Expr}; // The chat first, so a concurrent sweep never sees the aged tombstone with a fresh purge. ai_chat_sessions::Entity::update_many() .col_expr( ai_chat_sessions::Column::ReplicaPurgedAt, Expr::value(Some(month_ago - 2_000_000)), ) .filter(ai_chat_sessions::Column::OrgId.eq(org)) .exec(infra::db::get_orm_client_rw().await) .await .unwrap(); // Age the tombstone past the window so the scheduler's own sweep picks it up. Entity::update_many() .col_expr(Column::UpdatedAt, Expr::value(month_ago - 1_000_000)) .filter(Column::Identifier.eq(org)) .exec(infra::db::get_orm_client_rw().await) .await .unwrap(); // The promotion scheduler may sweep the same row concurrently, so wait for the end state. let mut swept = false; for _ in 0..20 { openobserve_core::org_cleanup::sweep_tombstones() .await .unwrap(); let marks = infra::db::get_db() .await .list_keys("/org_cleanup/tombstone_purge/") .await .unwrap(); swept = infra::table::organizations::get(org).await.is_err() && !db::org_status::is_blocked(org) && !marks .iter() .any(|k| k.contains(&format!("/tombstone_purge/{org}/"))); if swept { break; } tokio::time::sleep(tokio::time::Duration::from_millis(250)).await; } assert!(swept, "{org}: the expired tombstone was not fully swept"); assert_eq!(count_org_rows("ai_chat_sessions", org).await, 0); } /// A purge mark left by a failed unmark must neither delete nor unblock a later tombstone. async fn assert_stale_mark_spares_reused_id(org: &str) { let marks_of_org = format!("/tombstone_purge/{org}/"); infra::db::get_db() .await .put( &format!("/org_cleanup/tombstone_purge/{org}/1"), "1".to_string().into(), false, None, ) .await .unwrap(); infra::table::organizations::add( org, org, config::meta::organization::OrganizationType::Default, ) .await .unwrap(); assert!( infra::table::organizations::set_status_if(org, "active", "deleted") .await .unwrap() ); db::org_status::broadcast_deleted(org).await.unwrap(); let mut dropped = false; for _ in 0..20 { openobserve_core::org_cleanup::sweep_tombstones() .await .unwrap(); let marks = infra::db::get_db() .await .list_keys("/org_cleanup/tombstone_purge/") .await .unwrap(); dropped = !marks.iter().any(|k| k.contains(&marks_of_org)); if dropped { break; } tokio::time::sleep(tokio::time::Duration::from_millis(250)).await; } assert!(dropped, "{org}: the stale purge mark was never dropped"); assert_eq!( infra::table::organizations::get_status_uncached(org) .await .unwrap() .as_deref(), Some("deleted"), "{org}: the stale mark removed a fresh tombstone" ); assert!( db::org_status::is_blocked(org), "{org}: the stale mark unblocked a fresh tombstone" ); let tombstone = infra::table::organizations::get_from_db(org).await.unwrap(); assert!( infra::table::organizations::remove_tombstone_at(org, tombstone.updated_at) .await .unwrap() ); db::org_status::evict(org).await.unwrap(); } /// A status eviction must also drop a node's cached row, or a swept id stays refused there. async fn assert_eviction_drops_cached_row(org: &str) { assert_eq!( infra::table::organizations::get(org).await.unwrap().status, "deleted", "precondition: the tombstone row is cached" ); // Delete the row behind the cache's back, as another node's sweep does. use infra::table::entity::organizations::{Column, Entity}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; Entity::delete_many() .filter(Column::Identifier.eq(org)) .exec(infra::db::get_orm_client_rw().await) .await .unwrap(); infra::coordinator::org_status::emit_evict_event(org) .await .unwrap(); for _ in 0..40 { if infra::table::organizations::get(org).await.is_err() { return; } tokio::time::sleep(tokio::time::Duration::from_millis(250)).await; } panic!("{org}: the swept tombstone row is still served from the cache"); } #[cfg(feature = "enterprise")] async fn e2e_host_routed_status_page_refuses_blocked_org() { let auth = setup(); let app = init_test_router().layer(axum::middleware::from_fn( openobserve_api_management::request::status_pages::public::host_route_middleware, )); let headers = auth_headers(auth); let suffix = run_suffix(); let org = format!("hrblock{suffix}"); ingest_one(&app, &headers, &org).await; let mut hosts = Vec::new(); for name in ["live", "blocked"] { let slug = insert_public_status_page(&org).await; let page = infra::table::status_pages::get_page_by_slug_any(&slug) .await .unwrap() .unwrap(); let now = Utc::now().timestamp_micros(); let host = format!("{name}-{suffix}.status.example.com"); let claim = infra::table::entity::status_page_custom_domains::Model { id: config::ider::generate(), org_id: org.clone(), status_page_id: page.id, domain: host.clone(), verification_token: config::ider::generate(), verification_state: 1, verification_failure_reason: None, verified_at: Some(now), released_at: None, last_checked_at: None, created_at: now, updated_at: now, }; assert!( infra::table::status_pages::insert_domain_if_unclaimed(&claim) .await .unwrap() ); hosts.push(host); } let by_host = |host: &str| { let mut h = HeaderMap::new(); h.insert(header::HOST, HeaderValue::from_str(host).unwrap()); h }; // Both pages are fetched first, so the blocked one is refused from a warm cache too. for host in &hosts { let (status, body) = make_request( &app, Method::GET, "/api/status_pages_public_by_host", Some(by_host(host)), None, ) .await; assert_eq!( status, StatusCode::OK, "an active org's custom domain is served: {}", String::from_utf8_lossy(&body) ); } db::org_status::broadcast_deleting(&org).await.unwrap(); let (status, _) = make_request( &app, Method::GET, "/api/status_pages_public_by_host", Some(by_host(&hosts[1])), None, ) .await; db::org_status::set_active(&org).await.unwrap(); assert_eq!( status, StatusCode::NOT_FOUND, "a deleting org's custom domain" ); } /// Creates `email` with a password in `org`, or adds the existing user to `org`. async fn put_org_user(app: &Router, headers: &HeaderMap, org: &str, email: &str) { let exists = infra::table::users::get(email).await.is_ok(); let (uri, body) = if exists { ( format!("/api/{org}/users/{email}"), json!({"role": "admin"}), ) } else { ( format!("/api/{org}/users"), json!({"email": email, "password": "Abcd12345!Zyxwv", "role": "admin"}), ) }; let (status, resp) = make_request( app, Method::POST, &uri, Some(headers.clone()), Some(body.to_string()), ) .await; assert!( status.is_success(), "add {email} to {org}: {status} {}", String::from_utf8_lossy(&resp) ); } async fn ingest_as(app: &Router, org: &str, user: &str, password: &str) -> StatusCode { let mut headers = HeaderMap::new(); let basic = config::utils::base64::encode(&format!("{user}:{password}")); headers.insert( header::AUTHORIZATION, HeaderValue::from_str(&format!("Basic {basic}")).unwrap(), ); headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("application/json"), ); let (status, _) = make_request( app, Method::POST, &format!("/api/{org}/orgdel/_json"), Some(headers), Some(r#"[{"a":2}]"#.to_string()), ) .await; status } async fn e2e_org_delete_removes_memberships() { let auth = setup(); let app = init_test_router(); let headers = auth_headers(auth); let suffix = run_suffix(); let (org, other) = (format!("orgmem{suffix}"), format!("orgmemkeep{suffix}")); let (solo, shared) = ( format!("solo{suffix}@example.com"), format!("shared{suffix}@example.com"), ); ingest_one(&app, &headers, &org).await; ingest_one(&app, &headers, &other).await; put_org_user(&app, &headers, &org, &solo).await; put_org_user(&app, &headers, &org, &shared).await; put_org_user(&app, &headers, &other, &shared).await; openobserve_core::org_cleanup::initiate_deletion(&org, "root@example.com") .await .unwrap(); drive_cleanup_until_done(&org, 20).await; assert!( infra::table::org_users::list_users_by_org(&org) .await .unwrap() .is_empty() ); assert!(infra::table::users::get(&solo).await.is_err()); let kept: Vec = infra::table::org_users::list_orgs_by_user(&shared) .await .unwrap() .into_iter() .map(|m| m.org_id) .collect(); assert_eq!(kept, vec![other.clone()]); assert!( common::infra::config::USERS.get(&solo).is_none(), "the deleted user stays cached" ); for email in [&solo, &shared] { assert!( common::infra::config::ORG_USERS .get(&format!("{org}/{email}")) .is_none(), "{email}'s membership stays cached" ); } } 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_org_delete_rejects_old_credentials() { let auth = setup(); let app = init_test_router(); let headers = auth_headers(auth); let suffix = run_suffix(); let org = format!("orgcred{suffix}"); let user = format!("cred{suffix}@example.com"); ingest_one(&app, &headers, &org).await; put_org_user(&app, &headers, &org, &user).await; let now = Utc::now().timestamp_micros(); let token = infra::table::org_ingestion_tokens::generate_token(); db::org_ingestion_tokens::add( &infra::table::org_ingestion_tokens::OrgIngestionTokenRecord { id: config::ider::generate(), org_id: org.clone(), name: format!("orgdel{suffix}"), token: token.clone(), description: String::new(), is_default: false, enabled: true, created_by: "root@example.com".to_string(), created_at: now, updated_at: now, splunk_token: None, }, ) .await .unwrap(); assert!( ingest_as(&app, &org, &user, "Abcd12345!Zyxwv") .await .is_success() ); assert!(ingest_as(&app, &org, "anyone", &token).await.is_success()); assert!( common::infra::config::ORGANIZATIONS .read() .await .contains_key(&org), "precondition: the org is cached" ); openobserve_core::org_cleanup::initiate_deletion(&org, "root@example.com") .await .unwrap(); drive_cleanup_until_done(&org, 20).await; let refused = |s: StatusCode| s == StatusCode::UNAUTHORIZED || s == StatusCode::FORBIDDEN; let status = ingest_as(&app, &org, &user, "Abcd12345!Zyxwv").await; assert!(refused(status), "a deleted member's password: {status}"); let status = ingest_as(&app, &org, "anyone", &token).await; assert!(refused(status), "a deleted org's ingestion token: {status}"); assert_eq!( infra::table::organizations::get(&org).await.unwrap().status, "deleted" ); assert!( !common::infra::config::ORGANIZATIONS .read() .await .contains_key(&org), "the deleted org stays cached" ); assert_eviction_drops_cached_row(&org).await; } /// One OTLP log export to `org` over the test gRPC server, authenticated as a cluster peer. async fn grpc_export_log(org: &str) -> Result<(), tonic::Status> { let cfg = get_config(); let channel = tonic::transport::Endpoint::from_shared(format!("http://127.0.0.1:{}", cfg.grpc.port)) .unwrap() .connect() .await .unwrap(); let record = LogRecord { time_unix_nano: Utc::now().timestamp_nanos_opt().unwrap() as u64, body: Some(AnyValue { value: Some(any_value::Value::StringValue("blocked?".to_string())), }), ..Default::default() }; let mut request = tonic::Request::new(ExportLogsServiceRequest { resource_logs: vec![ResourceLogs { scope_logs: vec![ScopeLogs { log_records: vec![record], ..Default::default() }], ..Default::default() }], }); let token = config::meta::cluster::get_internal_grpc_token(); request .metadata_mut() .insert("authorization", token.parse().unwrap()); request.metadata_mut().insert( tonic::metadata::MetadataKey::from_bytes(cfg.grpc.org_header_key.as_bytes()).unwrap(), org.parse().unwrap(), ); LogsServiceClient::new(channel) .export(request) .await .map(|_| ()) } /// Status of each ingestion path for `org`: JSON, Firehose, RUM and v2 alerts. async fn ingest_paths( app: &Router, headers: &HeaderMap, org: &str, rum_token: &str, ) -> Vec<(&'static str, StatusCode)> { let (json, _) = make_request( app, Method::POST, &format!("/api/{org}/orgdel/_json"), Some(headers.clone()), Some(r#"[{"a":3}]"#.to_string()), ) .await; let mut firehose_headers = HeaderMap::new(); let basic = headers[header::AUTHORIZATION].to_str().unwrap(); firehose_headers.insert( "X-Amz-Firehose-Access-Key", HeaderValue::from_str(basic.trim_start_matches("Basic ")).unwrap(), ); firehose_headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("application/json"), ); let record = config::utils::base64::encode(r#"{"a":4}"#); let (firehose, _) = make_request( app, Method::POST, &format!("/aws/{org}/orgdel/_kinesis_firehose"), Some(firehose_headers), Some(format!( r#"{{"requestId":"r1","timestamp":{},"records":[{{"data":"{record}"}}]}}"#, Utc::now().timestamp_millis() )), ) .await; let (rum, _) = make_request( app, Method::POST, &format!("/rum/v1/{org}/logs?oo-api-key={rum_token}"), None, Some("{\"a\":5}\n".to_string()), ) .await; let (alerts, _) = make_request( app, Method::GET, &format!("/api/v2/{org}/alerts"), Some(headers.clone()), None, ) .await; vec![ ("json", json), ("firehose", firehose), ("rum", rum), ("v2 alerts", alerts), ] } async fn e2e_ingestion_blocked_during_deletion() { let auth = setup(); let app = init_test_router().merge(other_service_routes()); let headers = auth_headers(auth); let org = format!("orgblk{}", run_suffix()); ingest_one(&app, &headers, &org).await; let root = "root@example.com"; let rum_token = match openobserve_core::organization::get_rum_token(Some("default"), root) .await .unwrap() .rum_token { Some(token) => token, None => openobserve_core::organization::update_rum_token(Some("default"), root) .await .unwrap() .rum_token .unwrap(), }; // Every path must accept the org first, or a 403 below could come from auth instead. for (path, status) in ingest_paths(&app, &headers, &org, &rum_token).await { assert!(status.is_success(), "{path} before the block: {status}"); } grpc_export_log(&org).await.unwrap(); db::org_status::broadcast_deleting(&org).await.unwrap(); let blocked = ingest_paths(&app, &headers, &org, &rum_token).await; let (alert_post, _) = make_request( &app, Method::POST, &format!("/api/v2/{org}/alerts"), Some(headers.clone()), Some("{}".to_string()), ) .await; let grpc = grpc_export_log(&org).await; db::org_status::set_active(&org).await.unwrap(); let mut open: Vec = blocked .into_iter() .chain([("v2 alert create", alert_post)]) .filter(|(_, status)| *status != StatusCode::FORBIDDEN) .map(|(path, status)| format!("{path}: {status}")) .collect(); match grpc { Err(e) if e.code() == tonic::Code::PermissionDenied => {} other => open.push(format!("gRPC OTLP: {other:?}")), } assert!( open.is_empty(), "paths still open during deletion: {open:?}" ); } /// Deletion refuses to start with super-cluster on, before writing anything. #[cfg(feature = "enterprise")] async fn test_initiate_refused_under_super_cluster() { let auth = setup(); let app = init_test_router(); let headers = auth_headers(auth); let org = format!("orgsc{}", run_suffix()); ingest_one(&app, &headers, &org).await; unsafe { env::set_var("O2_SUPER_CLUSTER_ENABLED", "true") }; o2_enterprise::enterprise::common::config::refresh_config().unwrap(); let enabled = openobserve_core::org_cleanup::super_cluster_enabled(); let refused = openobserve_core::org_cleanup::initiate_deletion(&org, "root@example.com").await; unsafe { env::remove_var("O2_SUPER_CLUSTER_ENABLED") }; o2_enterprise::enterprise::common::config::refresh_config().unwrap(); assert!(enabled); assert_eq!( refused.unwrap_err().to_string(), "Organization delete is not supported with super-cluster enabled yet" ); assert_eq!( infra::table::organizations::get(&org).await.unwrap().status, "active" ); assert!(!db::org_status::is_blocked(&org)); assert!( infra::table::org_cleanup_tasks::list_by_org_status(&org, None) .await .unwrap() .is_empty() ); } async fn e2e_drain_retry_keeps_attempts_at_zero() { let org = format!("t4drain{}", run_suffix()); add_tasks(&org, &[("drain_deleted_files", 250)]).await; let files = [deleted_file(&org, "a"), deleted_file(&org, "b")]; infra::file_list::batch_add_deleted(&org, Utc::now().timestamp_micros(), &files) .await .unwrap(); set_cleanup_test_config(120); for _ in 0..3 { run_cleanup_once().await; } let waiting = cleanup_task(&org, "drain_deleted_files").await; set_cleanup_test_config(0); run_cleanup_once().await; let drained = cleanup_task(&org, "drain_deleted_files").await; let left = infra::file_list::query_deleted(&org, i64::MAX, 10) .await .unwrap(); infra::table::org_cleanup_tasks::delete_by_org(&org) .await .unwrap(); assert_eq!( (waiting.status.as_str(), waiting.attempts), ("pending", 0), "{waiting:?}" ); assert_eq!(drained.status, "done", "{drained:?}"); assert!(left.is_empty(), "{left:?}"); } async fn e2e_drain_stalled_row_is_marked_failed() { use infra::table::entity::org_cleanup_tasks::{Column, Entity}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, sea_query::Expr}; let org = format!("t5stall{}", run_suffix()); add_tasks(&org, &[("drain_deleted_files", 250)]).await; let day_and_hour_ago = Utc::now().timestamp_micros() - 25 * 3600 * 1_000_000; Entity::update_many() .col_expr(Column::CreatedAt, Expr::value(day_and_hour_ago)) .filter(Column::OrgId.eq(&org)) .exec(infra::db::get_orm_client_rw().await) .await .unwrap(); // Rows stamped a year ahead never mature. let year_ahead = Utc::now().timestamp_micros() + 365 * 24 * 3600 * 1_000_000; infra::file_list::batch_add_deleted(&org, year_ahead, &[deleted_file(&org, "a")]) .await .unwrap(); // A day-old step alone is not stalled: the clock starts at the first pass without progress. run_cleanup_once().await; let first = cleanup_task(&org, "drain_deleted_files").await; let stall_clock = infra::table::org_cleanup_tasks::stall_clock_message(day_and_hour_ago); Entity::update_many() .col_expr(Column::LastError, Expr::value(stall_clock)) .filter(Column::OrgId.eq(&org)) .exec(infra::db::get_orm_client_rw().await) .await .unwrap(); run_cleanup_once().await; let row = cleanup_task(&org, "drain_deleted_files").await; // The stalled attempt keeps the clock, so the next quiet pass spends an attempt too. run_cleanup_once().await; let again = cleanup_task(&org, "drain_deleted_files").await; infra::table::org_cleanup_tasks::delete_by_org(&org) .await .unwrap(); remove_deleted_rows(&org).await; assert_eq!( (first.status.as_str(), first.attempts), ("pending", 0), "{first:?}" ); assert_eq!( (row.status.as_str(), row.attempts), ("failed", 1), "{row:?}" ); assert!( row.last_error .as_deref() .is_some_and(|e| e.contains("drain stalled")), "{row:?}" ); assert_eq!( (again.status.as_str(), again.attempts), ("failed", 2), "{again:?}" ); } async fn e2e_drain_keeps_rows_when_an_object_cannot_be_deleted() { let org = format!("t11undeletable{}", run_suffix()); add_tasks( &org, &[("drain_deleted_files", 250), ("delete_db_resources", 300)], ) .await; let file = deleted_file(&org, "a"); // A directory at the object's path makes the local store's delete fail with an IO error. let blocker = format!("{}{}", get_config().common.data_stream_dir, file.file); std::fs::create_dir_all(format!("{blocker}/child")).unwrap(); infra::file_list::batch_add_deleted(&org, Utc::now().timestamp_micros(), &[file]) .await .unwrap(); run_cleanup_once().await; let drain = cleanup_task(&org, "drain_deleted_files").await; let provider_step = cleanup_task(&org, "delete_db_resources").await; let left = infra::file_list::query_deleted(&org, i64::MAX, 10) .await .unwrap(); let _ = std::fs::remove_dir_all(&blocker); remove_deleted_rows(&org).await; infra::table::org_cleanup_tasks::delete_by_org(&org) .await .unwrap(); assert_eq!(drain.status, "failed", "{drain:?}"); assert_eq!( (provider_step.status.as_str(), provider_step.attempts), ("pending", 0), "{provider_step:?}" ); assert_eq!(left.len(), 1, "the row of the surviving object was removed"); } async fn e2e_drain_refuses_an_unloaded_storage_account() { let org = format!("t12account{}", run_suffix()); add_tasks( &org, &[("drain_deleted_files", 250), ("delete_db_resources", 300)], ) .await; let file = config::meta::stream::FileListDeleted { account: format!("{org}_gone:default"), ..deleted_file(&org, "a") }; // The same key on the default store must survive: the row names another bucket. infra::storage::put("", &file.file, Bytes::from_static(b"kept")) .await .unwrap(); let path = format!("{}{}", get_config().common.data_stream_dir, file.file); infra::file_list::batch_add_deleted(&org, Utc::now().timestamp_micros(), &[file]) .await .unwrap(); run_cleanup_once().await; let drain = cleanup_task(&org, "drain_deleted_files").await; let provider_step = cleanup_task(&org, "delete_db_resources").await; let left = infra::file_list::query_deleted(&org, i64::MAX, 10) .await .unwrap(); let survived = std::path::Path::new(&path).exists(); let _ = std::fs::remove_file(&path); remove_deleted_rows(&org).await; infra::table::org_cleanup_tasks::delete_by_org(&org) .await .unwrap(); assert_eq!(drain.status, "failed", "{drain:?}"); assert!( drain .last_error .as_deref() .is_some_and(|e| e.contains("not loaded")), "{drain:?}" ); assert_eq!(provider_step.status, "pending", "{provider_step:?}"); assert_eq!( left.len(), 1, "the row of an unreachable account was removed" ); assert!(survived, "the default store's object was deleted instead"); } async fn e2e_drain_failure_spends_an_attempt_each_pass() { let org = format!("t15outage{}", run_suffix()); add_deleting_org(&org).await; add_tasks(&org, &[("drain_deleted_files", 250)]).await; let file = deleted_file(&org, "a"); // A directory at the object's path makes the local store's delete fail with an IO error. let blocker = format!("{}{}", get_config().common.data_stream_dir, file.file); std::fs::create_dir_all(format!("{blocker}/child")).unwrap(); let matured = Utc::now().timestamp_micros() - 3 * 3600 * 1_000_000; infra::file_list::batch_add_deleted(&org, matured, &[file]) .await .unwrap(); set_cleanup_test_config(120); for _ in 0..3 { run_cleanup_once().await; } let failing = cleanup_task(&org, "drain_deleted_files").await; // Checked, not queried: a Postgres query claims the rows and pushes them behind the delay. let kept = infra::file_list::has_deleted_by_org(&org).await.unwrap(); std::fs::remove_dir_all(&blocker).unwrap(); run_cleanup_once().await; let drained = cleanup_task(&org, "drain_deleted_files").await; let left = infra::file_list::has_deleted_by_org(&org).await.unwrap(); set_cleanup_test_config(0); remove_deleted_rows(&org).await; infra::table::org_cleanup_tasks::delete_by_org(&org) .await .unwrap(); infra::table::organizations::remove(&org).await.unwrap(); assert_eq!( (failing.status.as_str(), failing.attempts), ("failed", 3), "{failing:?}" ); assert!(kept, "a failed delete dropped its row"); assert_eq!(drained.status, "done", "{drained:?}"); assert!(!left, "the pass after the outage left rows behind"); } async fn e2e_resurrect_releases_parked_triggers() { let org = format!("t16parked{}", run_suffix()); infra::table::organizations::add( &org, &org, config::meta::organization::OrganizationType::Default, ) .await .unwrap(); let hour = 3600 * 1_000_000; let deleted_at = Utc::now().timestamp_micros() - 90 * 60 * 1_000_000; infra::table::organizations::set_status_if_with_deleted_at( &org, "active", "pending_deletion", Some(deleted_at), ) .await .unwrap(); db::org_status::broadcast_pending_deletion(&org) .await .unwrap(); let now = Utc::now().timestamp_micros(); let scheduled = now + 30 * 60 * 1_000_000; for (key, next_run_at) in [("due", now - 1_000_000), ("scheduled", scheduled)] { infra::scheduler::push(Trigger { org: org.clone(), module: TriggerModule::Alert, module_key: key.to_string(), next_run_at, status: TriggerStatus::Waiting, ..Default::default() }) .await .unwrap(); } let next_run = |key: &'static str| { let org = org.clone(); async move { infra::scheduler::get(&org, TriggerModule::Alert, key) .await .unwrap() .next_run_at } }; let due = infra::scheduler::get(&org, TriggerModule::Alert, "due") .await .unwrap(); handle_triggers("e2e-parked", due).await.unwrap(); let parked = next_run("due").await; openobserve_core::org_cleanup::resurrect_org(&org, "root@example.com") .await .unwrap(); let resurrected = Utc::now().timestamp_micros(); let released = next_run("due").await; let untouched = next_run("scheduled").await; for key in ["due", "scheduled"] { infra::scheduler::delete(&org, TriggerModule::Alert, key) .await .unwrap(); } infra::table::organizations::remove(&org).await.unwrap(); assert!(parked > now && parked <= now + hour, "parked at {parked}"); assert_eq!((parked - deleted_at) % hour, 0, "parked off the grid"); assert!(released <= resurrected, "still parked after the resurrect"); assert_eq!(untouched, scheduled, "a real schedule was moved"); } async fn e2e_blocked_org_reaches_only_its_cleanup_retry() { let auth = setup(); let app = init_test_router(); let headers = auth_headers(auth); let org = format!("orgretry{}", run_suffix()); ingest_one(&app, &headers, &org).await; db::org_status::broadcast_deleting(&org).await.unwrap(); let mut answers = vec![]; for (method, uri) in [ ( Method::POST, format!("/api/{org}/org_cleanup_tasks/{org}/retry"), ), ( Method::POST, format!("/api/{org}/org_cleanup_tasks/default/retry"), ), (Method::GET, format!("/api/{org}/org_cleanup_tasks/{org}")), (Method::GET, format!("/api/{org}/streams")), ] { let (status, body) = make_request(&app, method, &uri, Some(headers.clone()), None).await; answers.push((uri, status, String::from_utf8_lossy(&body).to_string())); } db::org_status::set_active(&org).await.unwrap(); let (own, rest) = answers.split_first().unwrap(); // This build's handler answers in place of the gate. assert!( own.2.contains("Not supported on this deployment"), "{own:?}" ); for (uri, status, body) in rest { assert_eq!( (*status, body.as_str()), (StatusCode::FORBIDDEN, "Organization is deleted"), "{uri}" ); } } async fn e2e_compactor_pass_keeps_rows_of_a_deleting_org() { let org = format!("t13overlap{}", run_suffix()); add_deleting_org(&org).await; add_tasks( &org, &[("drain_deleted_files", 250), ("delete_db_resources", 300)], ) .await; let file = deleted_file(&org, "a"); // A directory at the object's path makes the local store's delete fail with an IO error. let blocker = format!("{}{}", get_config().common.data_stream_dir, file.file); std::fs::create_dir_all(format!("{blocker}/child")).unwrap(); infra::file_list::batch_add_deleted(&org, Utc::now().timestamp_micros(), &[file]) .await .unwrap(); // The compactor's delayed deletion, as a pass that listed the org before its cleanup began. let pass = compaction::deleted::delete(&org, Utc::now().timestamp_micros()).await; let kept = infra::file_list::query_deleted(&org, i64::MAX, 10) .await .unwrap() .len(); run_cleanup_once().await; let drain = cleanup_task(&org, "drain_deleted_files").await; let provider_step = cleanup_task(&org, "delete_db_resources").await; let _ = std::fs::remove_dir_all(&blocker); remove_deleted_rows(&org).await; infra::table::org_cleanup_tasks::delete_by_org(&org) .await .unwrap(); infra::table::organizations::remove(&org).await.unwrap(); assert!(pass.is_err(), "{pass:?}"); assert_eq!( kept, 1, "the compactor removed the row of a surviving object" ); assert_eq!(drain.status, "failed", "{drain:?}"); assert_eq!( (provider_step.status.as_str(), provider_step.attempts), ("pending", 0), "{provider_step:?}" ); } async fn e2e_compactor_pass_refuses_an_unloaded_account_of_a_deleting_org() { let org = format!("t14account{}", run_suffix()); add_deleting_org(&org).await; let file = config::meta::stream::FileListDeleted { account: format!("{org}_gone:default"), ..deleted_file(&org, "a") }; // The same key on the default store must survive: the row names another bucket. infra::storage::put("", &file.file, Bytes::from_static(b"kept")) .await .unwrap(); let path = format!("{}{}", get_config().common.data_stream_dir, file.file); infra::file_list::batch_add_deleted(&org, Utc::now().timestamp_micros(), &[file]) .await .unwrap(); let pass = compaction::deleted::delete(&org, Utc::now().timestamp_micros()).await; let left = infra::file_list::query_deleted(&org, i64::MAX, 10) .await .unwrap() .len(); let survived = std::path::Path::new(&path).exists(); let _ = std::fs::remove_file(&path); remove_deleted_rows(&org).await; infra::table::organizations::remove(&org).await.unwrap(); assert!( pass.as_ref() .is_err_and(|e| e.to_string().contains("not loaded")), "{pass:?}" ); assert_eq!(left, 1, "the row of an unreachable account was removed"); assert!(survived, "the default store's object was deleted instead"); } async fn e2e_compactor_removal_waits_for_deletion_start() { let org = format!("t15window{}", run_suffix()); infra::table::organizations::add( &org, &org, config::meta::organization::OrganizationType::Default, ) .await .unwrap(); let file = deleted_file(&org, "a"); // A directory at the object's path fails the delete, and the lenient pass swallows it. let blocker = format!("{}{}", get_config().common.data_stream_dir, file.file); std::fs::create_dir_all(format!("{blocker}/child")).unwrap(); infra::file_list::batch_add_deleted(&org, Utc::now().timestamp_micros(), &[file]) .await .unwrap(); let (reached, resume) = compaction::deleted::pause_before_removal(&org); let mut pass = tokio::spawn({ let org = org.clone(); async move { compaction::deleted::delete(&org, Utc::now().timestamp_micros()).await } }); tokio::select! { r = reached => r.unwrap(), done = &mut pass => panic!("the batch never reached its row removal: {done:?}"), } let mut deletion = tokio::spawn({ let org = org.clone(); async move { openobserve_core::org_cleanup::initiate_deletion(&org, "root@example.com").await } }); let early = tokio::time::timeout(tokio::time::Duration::from_secs(3), &mut deletion).await; let started_early = early.is_ok(); let status_while_paused = org_status_of(&org).await; resume.send(()).unwrap(); let pass = pass.await.unwrap(); let deletion = match early { Ok(done) => done.unwrap(), Err(_) => deletion.await.unwrap(), }; let status_after = org_status_of(&org).await; let left = infra::file_list::query_deleted(&org, i64::MAX, 10) .await .unwrap() .len(); let _ = std::fs::remove_dir_all(&blocker); remove_deleted_rows(&org).await; infra::table::organizations::remove(&org).await.unwrap(); infra::table::org_cleanup_tasks::delete_by_org(&org) .await .unwrap(); assert!( !started_early, "the deletion started while a lenient batch held rows it was about to remove" ); assert_eq!(status_while_paused.as_deref(), Some("active")); assert_eq!(pass.unwrap(), 1, "the paused batch did not remove its row"); assert_eq!(left, 0); assert!(deletion.is_ok(), "{deletion:?}"); assert_eq!(status_after.as_deref(), Some("deleting")); } async fn e2e_retry_backfills_missing_steps_and_resets_attempts() { let auth = setup(); let app = init_test_router(); let headers = auth_headers(auth); let org = format!("t3retry{}", run_suffix()); ingest_one(&app, &headers, &org).await; assert!( openobserve_core::org_cleanup::retry(&org).await.is_err(), "retry must refuse an active org" ); assert!( infra::table::organizations::set_status_if(&org, "active", "deleting") .await .unwrap() ); db::org_status::broadcast_deleting(&org).await.unwrap(); // The six step names that orgs enqueued before Phase 0 carry. add_tasks( &org, &[ ("delete_streams", 100), ("delete_file_list", 200), ("delete_db_resources", 300), ("delete_scheduler_triggers", 400), ("delete_users", 600), ("delete_org_record", 900), ], ) .await; exhaust_task(&org, "delete_file_list").await; openobserve_core::org_cleanup::retry(&org).await.unwrap(); // Every row now exists, so this insert conflicts on all of them. openobserve_core::org_cleanup::retry(&org).await.unwrap(); let tasks = infra::table::org_cleanup_tasks::list_by_org_status(&org, None) .await .unwrap(); for step in openobserve_core::org_cleanup::fixed_steps(&org, &org) { assert!( tasks.iter().any(|t| t.step == step.step), "{} not backfilled: {tasks:#?}", step.step ); } assert!(tasks.iter().all(|t| t.attempts < 10), "{tasks:#?}"); drive_cleanup_until_done(&org, 30).await; assert_eq!(org_status_of(&org).await.as_deref(), Some("deleted")); } async fn e2e_scan_failed_sets_gauge_and_emits_once() { let org = format!("t6scan{}", run_suffix()); add_tasks(&org, &[("delete_db_resources", 300)]).await; exhaust_task(&org, "delete_db_resources").await; let first = openobserve_core::org_cleanup::scan_failed().await.unwrap(); let second = openobserve_core::org_cleanup::scan_failed().await.unwrap(); let gauge = failed_steps_gauge(&org); infra::table::org_cleanup_tasks::delete_by_org(&org) .await .unwrap(); let after_recovery = openobserve_core::org_cleanup::scan_failed().await.unwrap(); assert!(first.contains(&org), "{first:?}"); assert!(!second.contains(&org), "alerted twice: {second:?}"); assert!(!after_recovery.contains(&org)); assert_eq!(gauge, 1); assert_eq!(failed_steps_gauge(&org), 0); } async fn e2e_reconcile_requeues_deleting_org_without_tasks() { let auth = setup(); let app = init_test_router(); let headers = auth_headers(auth); let org = format!("t7recon{}", run_suffix()); ingest_one(&app, &headers, &org).await; assert!( infra::table::organizations::set_status_if(&org, "active", "deleting") .await .unwrap() ); db::org_status::broadcast_deleting(&org).await.unwrap(); let requeued = openobserve_core::org_cleanup::reconcile().await.unwrap(); let tasks = infra::table::org_cleanup_tasks::list_by_org_status(&org, None) .await .unwrap(); assert!(requeued >= 1); assert_eq!( tasks.len(), openobserve_core::org_cleanup::fixed_steps(&org, &org).len(), "{tasks:#?}" ); drive_cleanup_until_done(&org, 30).await; assert_eq!(org_status_of(&org).await.as_deref(), Some("deleted")); openobserve_core::org_cleanup::reconcile().await.unwrap(); assert!( infra::table::org_cleanup_tasks::list_by_org_status(&org, None) .await .unwrap() .is_empty(), "reconcile revived a finished deletion" ); } async fn e2e_delete_org_record_is_idempotent() { let auth = setup(); let app = init_test_router(); let headers = auth_headers(auth); let org = format!("t8rec{}", run_suffix()); ingest_one(&app, &headers, &org).await; assert!( infra::table::organizations::set_status_if(&org, "active", "deleting") .await .unwrap() ); add_tasks(&org, &[("delete_org_record", 900)]).await; assert!( infra::coordinator::ingest_rejection::put_if_revision( &org, Bytes::from_static(b"[]"), None ) .await .unwrap() ); let first = openobserve_core::org_cleanup::step_delete_org_record(&org).await; let second = openobserve_core::org_cleanup::step_delete_org_record(&org).await; assert!(first.unwrap(), "the first run tombstones the org"); assert!( !second.unwrap(), "a repeat must succeed without counting as the transition that emits OrgDeleted" ); assert_eq!(org_status_of(&org).await.as_deref(), Some("deleted")); assert!(db::org_status::is_blocked(&org)); assert!( infra::table::org_cleanup_tasks::list_by_org_status(&org, None) .await .unwrap() .is_empty() ); assert!( infra::coordinator::ingest_rejection::get(&org) .await .unwrap() .is_none(), "the org's ingest rejection key outlived the deletion" ); } async fn e2e_initiate_rolls_back_status_when_add_batch_fails() { let auth = setup(); let app = init_test_router(); let headers = auth_headers(auth); let org = format!("t9atomic{}", run_suffix()); ingest_one(&app, &headers, &org).await; unsafe { env::set_var("ZO_ORG_CLEANUP_FAIL_STEP", "enqueue") }; let result = openobserve_core::org_cleanup::initiate_deletion(&org, "root@example.com").await; unsafe { env::remove_var("ZO_ORG_CLEANUP_FAIL_STEP") }; assert!(result.is_err()); assert_eq!(org_status_of(&org).await.as_deref(), Some("active")); assert!(!db::org_status::is_blocked(&org)); assert!( infra::table::org_cleanup_tasks::list_by_org_status(&org, None) .await .unwrap() .is_empty() ); } async fn e2e_org_delete_retry_after_exhaustion() { let auth = setup(); let app = init_test_router(); let headers = auth_headers(auth); let org = format!("t10exh{}", run_suffix()); ingest_one(&app, &headers, &org).await; unsafe { env::set_var("ZO_ORG_CLEANUP_FAIL_STEP", "delete_db_resources") }; openobserve_core::org_cleanup::initiate_deletion(&org, "root@example.com") .await .unwrap(); let mut stuck = cleanup_task(&org, "delete_db_resources").await; for _ in 0..40 { if openobserve_core::org_cleanup::is_exhausted(&stuck) { break; } run_cleanup_once().await; stuck = cleanup_task(&org, "delete_db_resources").await; } let gauge_when_stuck = failed_steps_gauge(&org); unsafe { env::remove_var("ZO_ORG_CLEANUP_FAIL_STEP") }; run_cleanup_once().await; let still_stuck = cleanup_task(&org, "delete_db_resources").await; openobserve_core::org_cleanup::retry(&org).await.unwrap(); drive_cleanup_until_done(&org, 30).await; assert!( openobserve_core::org_cleanup::is_exhausted(&stuck), "{stuck:?}" ); assert_eq!(gauge_when_stuck, 1); assert_eq!( still_stuck.attempts, 10, "an exhausted step must wait for retry: {still_stuck:?}" ); assert_eq!(org_status_of(&org).await.as_deref(), Some("deleted")); assert_eq!(failed_steps_gauge(&org), 0); } 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 post_firehose(app: &Router, stream: &str, record: &str) -> (StatusCode, json::Value) { let mut headers = HeaderMap::new(); let basic = setup().1.trim_start_matches("Basic "); headers.insert( "X-Amz-Firehose-Access-Key", HeaderValue::from_str(basic).unwrap(), ); headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("application/json"), ); let body = json!({ "requestId": "r1", "timestamp": Utc::now().timestamp_millis(), "records": [{"data": config::utils::base64::encode(record)}], }); let (status, body) = make_request( app, Method::POST, &format!("/aws/e2e/{stream}/_kinesis_firehose"), Some(headers), Some(body.to_string()), ) .await; (status, json::from_slice(&body).unwrap()) } async fn post_pubsub(app: &Router, stream: &str, data: &str) -> (StatusCode, json::Value) { let basic = setup().1.trim_start_matches("Basic "); let api_key: String = url::form_urlencoded::byte_serialize(basic.as_bytes()).collect(); let now = Utc::now().to_rfc3339(); let body = json!({ "message": { "attributes": {"logging.googleapis.com/timestamp": now}, "data": config::utils::base64::encode(data), "messageId": "m1", "message_id": "m1", "publishTime": now, "publish_time": now, }, "subscription": "projects/p/subscriptions/s", }); let mut headers = HeaderMap::new(); headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("application/json"), ); let (status, body) = make_request( app, Method::POST, &format!("/gcp/e2e/{stream}/_sub?API-Key={api_key}"), Some(headers), Some(body.to_string()), ) .await; (status, json::from_slice(&body).unwrap()) } async fn e2e_firehose_and_pubsub_status() { let app = init_test_router().merge(other_service_routes()); let (status, _) = post_firehose(&app, "firehose_status", r#"{"a":1}"#).await; assert_eq!(status, StatusCode::OK); let (status, _) = post_pubsub(&app, "pubsub_status", r#"{"a":1}"#).await; assert_eq!(status, StatusCode::OK); // a message that is not an object stores no row, so Pub/Sub must redeliver it let (status, body) = post_pubsub(&app, "pubsub_status", "123").await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); // only PostgreSQL rejects a stream key over 256 characters if get_config().common.meta_store != "postgres" { return; } let long_stream = "a".repeat(250); let (status, body) = post_firehose(&app, &long_stream, r#"{"a":1}"#).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); assert!( body["errorMessage"] .as_str() .unwrap() .contains("Stream name too long") ); let (status, body) = post_pubsub(&app, &long_stream, r#"{"a":1}"#).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); assert!( body["message"] .as_str() .unwrap() .contains("Stream name too long") ); } 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() { use base64::Engine; let auth = setup(); let app = init_test_router(); // The non-root path lists orgs through memberships, whose own created_at must not leak. let member_auth = format!( "Basic {}", base64::engine::general_purpose::STANDARD .encode("nonadmin@example.com:Newpass12!Abcde") ); for headers in [ auth_headers(auth), auth_headers((auth.0, member_auth.as_str())), ] { let (status, body) = make_request(&app, Method::GET, "/api/organizations", Some(headers), None).await; assert!(status.is_success()); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); let orgs = json["data"].as_array().unwrap(); assert!(!orgs.is_empty()); for org in orgs { let stored = infra::table::organizations::get(org["identifier"].as_str().unwrap()) .await .unwrap(); assert_eq!(org["created_at"].as_i64(), Some(stored.created_at), "{org}"); assert!(stored.created_at > 0); } } } 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 { 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 = 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 = 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 = 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 = 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 = 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 = 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: config::meta::alerts::QueryType::SQL, 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 (status, body) = make_request(&app, Method::GET, "/config", None, None).await; assert!(status.is_success()); let bootstrap: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(bootstrap.get("rum").is_none(), "{bootstrap}"); assert!(bootstrap.get("commit_hash").is_some(), "{bootstrap}"); let headers = auth_headers(auth); let (status, body) = make_request(&app, Method::GET, "/api/e2e/config", Some(headers), None).await; assert!(status.is_success()); let full: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(full["rum"]["version"], config::VERSION, "{full}"); assert!(full["rum"].get("client_token").is_some(), "{full}"); } // 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::(&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 = 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), } } } /// Two workers race one slow step; a compactor restarts past one lease and a third polls. async fn e2e_two_workers_never_run_one_step_twice() { use std::sync::atomic::{AtomicUsize, Ordering}; use config::meta::stream::StreamType; use openobserve_core::org_cleanup::{StreamDataCleanup, run_once}; struct SlowStep { org: String, runs: AtomicUsize, } #[async_trait::async_trait] impl StreamDataCleanup for SlowStep { async fn delete_stream_data( &self, org_id: &str, stream_type: StreamType, stream_name: &str, ) -> Result<(), anyhow::Error> { if org_id != self.org { return openobserve_jobs::job::CompactionStreamDataCleanup .delete_stream_data(org_id, stream_type, stream_name) .await; } self.runs.fetch_add(1, Ordering::SeqCst); tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; Ok(()) } async fn drain_deleted_files( &self, org_id: &str, ) -> Result { openobserve_jobs::job::CompactionStreamDataCleanup .drain_deleted_files(org_id) .await } } let org = format!("t19lease{}", run_suffix()); let step = "delete_stream:logs/slow"; add_tasks(&org, &[(step, 150)]).await; let slow = Arc::new(SlowStep { org: org.clone(), runs: AtomicUsize::new(0), }); let cleanup: Arc = slow.clone(); // With the worker paused the lease is 2 s, so the 5 s step outlives two lease periods. assert_eq!(infra::table::org_cleanup_tasks::lease_micros(), 2_000_000); let first = tokio::spawn(run_once(Arc::clone(&cleanup))); let second = tokio::spawn(run_once(Arc::clone(&cleanup))); eventually("the slow step to start", || async { slow.runs.load(Ordering::SeqCst) > 0 }) .await; tokio::time::sleep(tokio::time::Duration::from_millis(2500)).await; infra::table::org_cleanup_tasks::requeue_running() .await .unwrap(); let third = tokio::spawn(run_once(Arc::clone(&cleanup))); for worker in [first, second, third] { worker.await.unwrap(); } let task = cleanup_task(&org, step).await; run_once(Arc::clone(&cleanup)).await; let runs = slow.runs.load(Ordering::SeqCst); infra::table::org_cleanup_tasks::delete_by_org(&org) .await .unwrap(); assert_eq!(runs, 1, "the step ran {runs} times"); assert_eq!( (task.status.as_str(), task.attempts), ("done", 1), "{task:?}" ); } }