mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-03 16:10:21 +00:00
8666 lines
317 KiB
Rust
8666 lines
317 KiB
Rust
// Copyright 2026 OpenObserve Inc.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
// macOS ld cannot encode compact-unwind offsets once __eh_frame exceeds 16MB;
|
|
// harmless for a binary this size (only slows panic unwinding), so silence it.
|
|
#![allow(linker_messages)]
|
|
#![recursion_limit = "256"]
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use core::time;
|
|
use std::{
|
|
env, fs,
|
|
net::SocketAddr,
|
|
str,
|
|
sync::{Arc, Once},
|
|
thread,
|
|
};
|
|
|
|
use arrow_flight::flight_service_server::FlightServiceServer;
|
|
use axum::{
|
|
Router,
|
|
body::Body,
|
|
http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header},
|
|
};
|
|
use bytes::{Bytes, BytesMut};
|
|
use chrono::{Duration, Utc};
|
|
use common::meta::user::UserList;
|
|
use config::{
|
|
get_config,
|
|
meta::{
|
|
alerts::{Operator, QueryCondition, TriggerCondition, alert::Alert},
|
|
dashboards::{Dashboard, v5},
|
|
pipeline::{
|
|
Pipeline,
|
|
components::{DerivedStream, PipelineSource},
|
|
},
|
|
stream::StreamType,
|
|
triggers::{ScheduledTriggerData, Trigger, TriggerModule, TriggerStatus},
|
|
},
|
|
utils::{
|
|
enrichment_local_cache::{
|
|
get_key, get_metadata_content, get_metadata_path, get_table_dir, get_table_path,
|
|
},
|
|
json,
|
|
},
|
|
};
|
|
use enrichment_data::enrichment::storage::{Values, local};
|
|
use infra::schema::{STREAM_SCHEMAS, STREAM_SCHEMAS_LATEST};
|
|
use ingestion_common::IngestionResponse;
|
|
use openobserve::migration;
|
|
use openobserve_api_grpc::handler::grpc::{
|
|
auth::check_internal_auth, flight::FlightServiceImpl, 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<u8>,
|
|
}
|
|
|
|
/// The e2e's own data dir, ports, config file and Postgres database, removed by `tear_down`.
|
|
struct Sandbox {
|
|
dir: tempfile::TempDir,
|
|
postgres: Option<OwnDatabase>,
|
|
}
|
|
|
|
impl Sandbox {
|
|
/// Runs before the first `get_config`, which reads the env this sets exactly once.
|
|
async fn create() -> Self {
|
|
let dir = tempfile::Builder::new()
|
|
.prefix("o2-e2e-")
|
|
.tempdir()
|
|
.expect("e2e temp dir");
|
|
let config_file = dir.path().join("e2e.env");
|
|
fs::write(&config_file, "").expect("e2e config file");
|
|
config::config_path_manager::set_config_file_path(config_file)
|
|
.expect("e2e config file path");
|
|
// The enterprise config also loads a `.env` from the working directory.
|
|
env::set_current_dir(dir.path()).expect("enter the e2e temp dir");
|
|
let postgres = OwnDatabase::create().await;
|
|
let [grpc_port, http_port] = free_ports();
|
|
unsafe {
|
|
env::set_var("ZO_DATA_DIR", format!("{}/data/", dir.path().display()));
|
|
for derived in [
|
|
"ZO_DATA_WAL_DIR",
|
|
"ZO_DATA_STREAM_DIR",
|
|
"ZO_DATA_DB_DIR",
|
|
"ZO_DATA_CACHE_DIR",
|
|
"ZO_DATA_TMP_DIR",
|
|
] {
|
|
env::remove_var(derived);
|
|
}
|
|
env::set_var("ZO_GRPC_ADDR", "127.0.0.1");
|
|
env::set_var("ZO_GRPC_PORT", grpc_port.to_string());
|
|
env::set_var("ZO_HTTP_ADDR", "127.0.0.1");
|
|
env::set_var("ZO_HTTP_PORT", http_port.to_string());
|
|
}
|
|
Self { dir, postgres }
|
|
}
|
|
|
|
/// Panics unless the config that every pool connects from names this database.
|
|
fn assert_config_uses_it(&self) {
|
|
let Some(postgres) = &self.postgres else {
|
|
return;
|
|
};
|
|
let cfg = get_config();
|
|
let dsn = url::Url::parse(&cfg.common.meta_postgres_dsn).expect("e2e DSN is a URL");
|
|
assert_eq!(dsn.path(), format!("/{}", postgres.name));
|
|
assert!(cfg.common.meta_postgres_ro_dsn.is_empty());
|
|
assert!(cfg.common.meta_ddl_dsn.is_empty());
|
|
}
|
|
|
|
async fn tear_down(self) {
|
|
log::info!("Tear Down Invoked");
|
|
TORN_DOWN.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
let data = self.dir.path().join("data");
|
|
// The WAL push job can still be moving the last ingests' files while the dir goes.
|
|
let mut result = fs::remove_dir_all(&data);
|
|
for _ in 0..20 {
|
|
if result.is_ok() {
|
|
break;
|
|
}
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
|
result = fs::remove_dir_all(&data);
|
|
}
|
|
result.expect("Delete local dir failed");
|
|
if let Some(postgres) = self.postgres {
|
|
postgres.drop_database().await;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A database made from the CI DSN, so the e2e never writes into the one the unit tests share.
|
|
struct OwnDatabase {
|
|
admin_dsn: String,
|
|
name: String,
|
|
dropped: bool,
|
|
}
|
|
|
|
impl OwnDatabase {
|
|
async fn create() -> Option<Self> {
|
|
use sqlx::Connection;
|
|
|
|
if env::var("ZO_META_STORE").ok()?.as_str() != "postgres" {
|
|
return None;
|
|
}
|
|
let admin_dsn = env::var("ZO_META_POSTGRES_DSN")
|
|
.ok()
|
|
.filter(|dsn| !dsn.is_empty())
|
|
.expect("the Postgres e2e makes its own database from ZO_META_POSTGRES_DSN");
|
|
let name = format!(
|
|
"o2_e2e_{}_{}",
|
|
std::process::id(),
|
|
Utc::now().timestamp_micros()
|
|
);
|
|
let mut conn = sqlx::PgConnection::connect(&admin_dsn)
|
|
.await
|
|
.expect("connect to the CI Postgres");
|
|
sqlx::query(&format!("CREATE DATABASE {name}"))
|
|
.execute(&mut conn)
|
|
.await
|
|
.expect("create the e2e database");
|
|
let mut dsn = url::Url::parse(&admin_dsn).expect("ZO_META_POSTGRES_DSN is a URL");
|
|
dsn.set_path(&format!("/{name}"));
|
|
unsafe {
|
|
env::set_var("ZO_META_POSTGRES_DSN", dsn.as_str());
|
|
env::remove_var("ZO_META_POSTGRES_RO_DSN");
|
|
env::remove_var("ZO_META_DDL_DSN");
|
|
}
|
|
Some(Self {
|
|
admin_dsn,
|
|
name,
|
|
dropped: false,
|
|
})
|
|
}
|
|
|
|
async fn drop_database(mut self) {
|
|
Self::drop_named(&self.admin_dsn, &self.name)
|
|
.await
|
|
.expect("drop the e2e database");
|
|
self.dropped = true;
|
|
}
|
|
|
|
async fn drop_named(admin_dsn: &str, name: &str) -> Result<(), sqlx::Error> {
|
|
use sqlx::Connection;
|
|
|
|
let mut conn = sqlx::PgConnection::connect(admin_dsn).await?;
|
|
// FORCE ends the pools this process still holds on the database.
|
|
sqlx::query(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)"))
|
|
.execute(&mut conn)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for OwnDatabase {
|
|
/// Also runs on unwind; on a new thread because no runtime starts inside the test's.
|
|
fn drop(&mut self) {
|
|
if self.dropped {
|
|
return;
|
|
}
|
|
let (admin_dsn, name) = (self.admin_dsn.clone(), self.name.clone());
|
|
let dropped = std::thread::spawn(move || {
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.map_err(|e| e.to_string())?
|
|
.block_on(Self::drop_named(&admin_dsn, &name))
|
|
.map_err(|e| e.to_string())
|
|
})
|
|
.join();
|
|
if !matches!(dropped, Ok(Ok(()))) {
|
|
eprintln!(
|
|
"the e2e database {} was not dropped: {dropped:?}",
|
|
self.name
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn setup() -> (&'static str, &'static str) {
|
|
assert!(
|
|
!TORN_DOWN.load(std::sync::atomic::Ordering::SeqCst),
|
|
"a sub-test ran after tear_down removed the e2e's data dir and database"
|
|
);
|
|
START.call_once(|| unsafe {
|
|
env::set_var("ZO_ROOT_USER_EMAIL", "root@example.com");
|
|
env::set_var("ZO_ROOT_USER_PASSWORD", "Complexpass#123");
|
|
env::set_var("ZO_LOCAL_MODE", "true");
|
|
env::set_var("ZO_MAX_FILE_SIZE_ON_DISK", "1");
|
|
env::set_var("ZO_FILE_PUSH_INTERVAL", "1");
|
|
env::set_var("ZO_PAYLOAD_LIMIT", "209715200");
|
|
env::set_var("ZO_JSON_LIMIT", "209715200");
|
|
env::set_var("ZO_RESULT_CACHE_ENABLED", "false");
|
|
env::set_var("ZO_PRINT_KEY_SQL", "true");
|
|
env::set_var("ZO_SMTP_ENABLED", "true");
|
|
env::set_var("ZO_CREATE_ORG_THROUGH_INGESTION", "true");
|
|
env::set_var("ZO_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<HeaderMap>,
|
|
body: Option<String>,
|
|
) -> (StatusCode, Bytes) {
|
|
let mut req_builder = Request::builder().method(method).uri(uri);
|
|
|
|
if let Some(hdrs) = headers {
|
|
for (key, value) in hdrs.iter() {
|
|
req_builder = req_builder.header(key, value);
|
|
}
|
|
}
|
|
|
|
let req = if let Some(body_str) = body {
|
|
req_builder
|
|
.body(Body::from(body_str))
|
|
.expect("Failed to build request")
|
|
} else {
|
|
req_builder
|
|
.body(Body::empty())
|
|
.expect("Failed to build request")
|
|
};
|
|
|
|
let response = app
|
|
.clone()
|
|
.oneshot(req)
|
|
.await
|
|
.expect("Failed to execute request");
|
|
|
|
let status = response.status();
|
|
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("Failed to read response body");
|
|
|
|
(status, body_bytes)
|
|
}
|
|
|
|
/// Helper to create headers with auth
|
|
fn auth_headers(auth: (&str, &str)) -> HeaderMap {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::AUTHORIZATION,
|
|
HeaderValue::from_str(auth.1).expect("Invalid auth header"),
|
|
);
|
|
headers.insert(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("application/json"),
|
|
);
|
|
headers
|
|
}
|
|
|
|
async fn init_grpc_server() -> Result<(), anyhow::Error> {
|
|
let cfg = get_config();
|
|
let ip = if !cfg.grpc.addr.is_empty() {
|
|
cfg.grpc.addr.clone()
|
|
} else {
|
|
"0.0.0.0".to_string()
|
|
};
|
|
let gaddr: SocketAddr = format!("{}:{}", ip, cfg.grpc.port).parse()?;
|
|
let search_svc = SearchServer::new(SEARCH_SERVER.clone())
|
|
.send_compressed(CompressionEncoding::Gzip)
|
|
.accept_compressed(CompressionEncoding::Gzip);
|
|
let flight_svc = FlightServiceServer::new(FlightServiceImpl)
|
|
.send_compressed(CompressionEncoding::Gzip)
|
|
.accept_compressed(CompressionEncoding::Gzip);
|
|
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::<serde_json::Value>(&body)
|
|
&& let Some(list) = alerts.get("list").and_then(|l| l.as_array())
|
|
{
|
|
let alert_names = ["alertChk", "sns_test_alert", "multirange_alert"];
|
|
for alert in list {
|
|
if let Some(name) = alert.get("name").and_then(|n| n.as_str())
|
|
&& alert_names.contains(&name)
|
|
&& let Some(id) = alert.get("alert_id").and_then(|id| id.as_str())
|
|
{
|
|
let _ = make_request(
|
|
&app,
|
|
Method::DELETE,
|
|
&format!("/api/v2/e2e/alerts/{}?type=logs", id),
|
|
Some(headers.clone()),
|
|
None,
|
|
)
|
|
.await;
|
|
log::info!("Cleanup: deleted alert {}", name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Delete destinations (they reference templates)
|
|
for dest in ["slack", "email", "sns_alert"] {
|
|
let _ = make_request(
|
|
&app,
|
|
Method::DELETE,
|
|
&format!("/api/e2e/alerts/destinations/{}", dest),
|
|
Some(headers.clone()),
|
|
None,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// 3. Delete templates last
|
|
for template in ["slackTemplate", "email_template", "snsTemplate"] {
|
|
let _ = make_request(
|
|
&app,
|
|
Method::DELETE,
|
|
&format!("/api/e2e/alerts/templates/{}", template),
|
|
Some(headers.clone()),
|
|
None,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// 4. Delete user if exists
|
|
let _ = make_request(
|
|
&app,
|
|
Method::DELETE,
|
|
"/api/e2e/users/nonadmin@example.com",
|
|
Some(headers.clone()),
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
log::info!("Cleanup: finished cleaning up previous test state");
|
|
}
|
|
|
|
// tokio::test 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<String> = 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<dyn openobserve_core::org_cleanup::StreamDataCleanup> =
|
|
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<String> {
|
|
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<E>(row: serde_json::Value)
|
|
where
|
|
E: sea_orm::EntityTrait,
|
|
E::Model: serde::de::DeserializeOwned + sea_orm::IntoActiveModel<E::ActiveModel>,
|
|
{
|
|
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<A>(row: A)
|
|
where
|
|
A: sea_orm::ActiveModelTrait,
|
|
<A::Entity as sea_orm::EntityTrait>::Model: sea_orm::IntoActiveModel<A>,
|
|
{
|
|
use sea_orm::EntityTrait;
|
|
|
|
<A::Entity as 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::<status_page_components::Entity>(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::<status_page_component_checks::Entity>(json!({
|
|
"id": config::ider::generate(), "component_id": component,
|
|
"synthetics_id": check_id, "org_id": org,
|
|
}))
|
|
.await;
|
|
insert_entity_row::<status_page_notices::Entity>(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::<status_page_notice_components::Entity>(json!({
|
|
"id": config::ider::generate(), "notice_id": notice, "component_id": component,
|
|
"org_id": org,
|
|
}))
|
|
.await;
|
|
insert_entity_row::<status_page_notice_updates::Entity>(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::<anomaly_detection_config::Entity>(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::<anomaly_detection_models::Entity>(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<String> {
|
|
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<F, Fut>(what: &str, mut check: F)
|
|
where
|
|
F: FnMut() -> Fut,
|
|
Fut: std::future::Future<Output = bool>,
|
|
{
|
|
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<String> {
|
|
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<dyn openobserve_core::org_cleanup::StreamDataCleanup> =
|
|
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<dyn openobserve_core::org_cleanup::StreamDataCleanup> =
|
|
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<axum::response::Response>| 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<String> = 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<String> = 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<Dashboard> {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (_status, body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/{}/dashboards", "e2e"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
// Try to parse the response body as a list of dashboards.
|
|
let mut body_json: json::Value = json::from_slice(&body).unwrap();
|
|
let list_json = body_json
|
|
.as_object_mut()
|
|
.unwrap()
|
|
.remove("dashboards")
|
|
.unwrap();
|
|
let dashboards: Vec<Dashboard> = json::from_value(list_json).unwrap();
|
|
|
|
dashboards
|
|
}
|
|
|
|
async fn e2e_update_dashboard(dashboard: v5::Dashboard, hash: String) -> Dashboard {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (_status, body) = make_request(
|
|
&app,
|
|
Method::PUT,
|
|
&format!(
|
|
"/api/{}/dashboards/{}?hash={}",
|
|
"e2e", dashboard.dashboard_id, hash
|
|
),
|
|
Some(headers),
|
|
Some(json::to_string(&dashboard).unwrap()),
|
|
)
|
|
.await;
|
|
|
|
json::from_slice(&body).unwrap()
|
|
}
|
|
|
|
async fn e2e_get_dashboard(dashboard_id: &str) -> Dashboard {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (_status, body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/{}/dashboards/{dashboard_id}", "e2e"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
json::from_slice(&body).unwrap()
|
|
}
|
|
|
|
async fn e2e_dashboard_nested_cast_round_trip() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
|
|
// sum(TRY_CAST(usage_amount AS DOUBLE)) exactly as the panel builder writes it.
|
|
let body_str = r#"{"version":8,"title":"cast","dashboardId":"","description":"","role":"","owner":"root@example.com","created":"2023-03-30T07:49:41.744+00:00","tabs":[{"tabId":"tab1","name":"Main","panels":[{"id":"Panel_cast","type":"bar","title":"cast","description":"","config":{"show_legends":true},"queryType":"sql","queries":[{"query":"SELECT sum(TRY_CAST(usage_amount AS DOUBLE)) as \"y_axis_1\" FROM \"default\"","customQuery":false,"fields":{"stream":"default","stream_type":"logs","filter":{"filterType":"group","logicalOperator":"AND","conditions":[]},"x":[],"y":[{"label":"Usage","alias":"y_axis_1","type":"build","functionName":"sum","args":[{"type":"function","value":{"functionName":"try_cast","args":[{"type":"field","value":{"field":"usage_amount","streamAlias":null}},{"type":"castType","value":"DOUBLE"}]}}]}]},"config":{"promql_legend":""}}],"layout":{"x":0,"y":0,"w":12,"h":13,"i":1}}]}]}"#;
|
|
|
|
let (status, body) = make_request(
|
|
&app,
|
|
Method::POST,
|
|
&format!("/api/{}/dashboards", "e2e"),
|
|
Some(headers.clone()),
|
|
Some(body_str.to_string()),
|
|
)
|
|
.await;
|
|
assert!(
|
|
status.is_success(),
|
|
"create failed: {}",
|
|
String::from_utf8_lossy(&body)
|
|
);
|
|
|
|
let created: json::Value = json::from_slice(&body).unwrap();
|
|
let dashboard_id = created["v8"]["dashboardId"].as_str().unwrap().to_string();
|
|
|
|
let (_status, body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/{}/dashboards/{dashboard_id}", "e2e"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
let fetched: json::Value = json::from_slice(&body).unwrap();
|
|
|
|
let y = &fetched["v8"]["tabs"][0]["panels"][0]["queries"][0]["fields"]["y"][0];
|
|
assert_eq!(y["functionName"], "sum");
|
|
|
|
let cast = &y["args"][0]["value"];
|
|
assert_eq!(cast["functionName"], "try_cast");
|
|
assert_eq!(cast["args"][0]["value"]["field"], "usage_amount");
|
|
assert_eq!(cast["args"][1]["value"], "DOUBLE");
|
|
|
|
e2e_delete_dashboard(&dashboard_id).await;
|
|
}
|
|
|
|
async fn e2e_delete_dashboard(dashboard_id: &str) {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::DELETE,
|
|
&format!("/api/{}/dashboards/{dashboard_id}", "e2e"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_post_trace() {
|
|
let auth = setup();
|
|
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/trace_input.json");
|
|
let body_str = fs::read_to_string(path).expect("Read file failed");
|
|
|
|
// app
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::POST,
|
|
&format!("/api/{}/traces", "e2e"),
|
|
Some(headers),
|
|
Some(body_str),
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_post_metrics() {
|
|
let auth = setup();
|
|
|
|
let loc_label: Vec<prometheus_rpc::Label> = vec![
|
|
prometheus_rpc::Label {
|
|
name: "__name__".to_string(),
|
|
value: "grafana_api_dashboard_save_milliseconds_count".to_string(),
|
|
},
|
|
prometheus_rpc::Label {
|
|
name: "cluster".to_string(),
|
|
value: "prom-k8s".to_string(),
|
|
},
|
|
prometheus_rpc::Label {
|
|
name: "__replica__".to_string(),
|
|
value: "prom-k8s-0".to_string(),
|
|
},
|
|
];
|
|
|
|
let mut loc_samples: Vec<prometheus_rpc::Sample> = vec![];
|
|
|
|
for i in 1..2 {
|
|
loc_samples.push(prometheus_rpc::Sample {
|
|
value: i as f64,
|
|
timestamp: Utc::now().timestamp_micros(),
|
|
});
|
|
}
|
|
loc_samples.push(prometheus_rpc::Sample {
|
|
value: f64::NEG_INFINITY,
|
|
timestamp: Utc::now().timestamp_micros(),
|
|
});
|
|
loc_samples.push(prometheus_rpc::Sample {
|
|
value: f64::INFINITY,
|
|
timestamp: Utc::now().timestamp_micros(),
|
|
});
|
|
|
|
loc_samples.push(prometheus_rpc::Sample {
|
|
value: f64::NAN,
|
|
timestamp: Utc::now().timestamp_micros(),
|
|
});
|
|
let exemplar = |trace_id: &str| prometheus_rpc::Exemplar {
|
|
labels: vec![
|
|
prometheus_rpc::Label {
|
|
name: "trace_id".to_string(),
|
|
value: trace_id.to_string(),
|
|
},
|
|
prometheus_rpc::Label {
|
|
name: "span_id".to_string(),
|
|
value: "def".to_string(),
|
|
},
|
|
],
|
|
value: 1.0,
|
|
timestamp: Utc::now().timestamp_millis(),
|
|
};
|
|
let loc_hist: Vec<prometheus_rpc::Histogram> = vec![];
|
|
|
|
let ts = prometheus_rpc::TimeSeries {
|
|
labels: loc_label.clone(),
|
|
samples: loc_samples,
|
|
exemplars: vec![exemplar("with-sample")],
|
|
histograms: loc_hist,
|
|
};
|
|
// Prometheus sends an exemplar as a series entry of its own, without the sample
|
|
let exemplar_only = prometheus_rpc::TimeSeries {
|
|
labels: loc_label,
|
|
samples: vec![],
|
|
exemplars: vec![exemplar("exemplar-only")],
|
|
histograms: vec![],
|
|
};
|
|
|
|
let metadata: Vec<prometheus_rpc::MetricMetadata> = vec![];
|
|
let wr_req: prometheus_rpc::WriteRequest = prometheus_rpc::WriteRequest {
|
|
timeseries: vec![ts, exemplar_only],
|
|
metadata,
|
|
};
|
|
let mut out = BytesMut::with_capacity(wr_req.encoded_len());
|
|
wr_req.encode(&mut out).expect("Out of memory");
|
|
let data: Bytes = out.into();
|
|
let body = snap::raw::Encoder::new()
|
|
.compress_vec(&data)
|
|
.expect("Out of memory");
|
|
|
|
// app
|
|
let app = init_test_router();
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"X-Prometheus-Remote-Write-Version",
|
|
HeaderValue::from_static("0.1.0"),
|
|
);
|
|
headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static("snappy"));
|
|
headers.insert(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("application/x-protobuf"),
|
|
);
|
|
headers.insert(
|
|
header::AUTHORIZATION,
|
|
HeaderValue::from_str(auth.1).expect("Invalid auth header"),
|
|
);
|
|
|
|
let req = Request::builder()
|
|
.method(Method::POST)
|
|
.uri(format!("/api/{}/prometheus/api/v1/write", "e2e"));
|
|
let mut req_builder = req;
|
|
for (key, value) in headers.iter() {
|
|
req_builder = req_builder.header(key, value);
|
|
}
|
|
let req = req_builder
|
|
.body(Body::from(body))
|
|
.expect("Failed to build request");
|
|
|
|
let response = app
|
|
.clone()
|
|
.oneshot(req)
|
|
.await
|
|
.expect("Failed to execute request");
|
|
let status = response.status();
|
|
assert!(
|
|
status.is_success(),
|
|
"Prometheus write failed with status: {}. This may indicate memtable overflow or resource constraints.",
|
|
status
|
|
);
|
|
}
|
|
|
|
async fn e2e_get_org_summary() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/{}/summary", "e2e"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
log::info!("{:?}", status);
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_post_alert_template() {
|
|
let auth = setup();
|
|
let body_str = r#"{"name":"slackTemplate","body":"{\"text\":\"For stream {stream_name} of organization {org_name} alert {alert_name} of type {alert_type} is active app_name {app_name}\"}"}"#;
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, body) = make_request(
|
|
&app,
|
|
Method::POST,
|
|
&format!("/api/{}/alerts/templates", "e2e"),
|
|
Some(headers),
|
|
Some(body_str.to_string()),
|
|
)
|
|
.await;
|
|
let text = String::from_utf8_lossy(&body).to_string();
|
|
println!("e2e_post_alert_template: status: {status:?}, text: {text:?}");
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_get_alert_template() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/{}/alerts/templates/{}", "e2e", "slackTemplate"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_delete_alert_template() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::DELETE,
|
|
&format!("/api/{}/alerts/templates/{}", "e2e", "slackTemplate"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_post_alert_email_template() {
|
|
let auth = setup();
|
|
let body_str = r#"{"name":"email_template","body":"This is email for {alert_name}.","type":"email","title":"Email Subject"}"#;
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::POST,
|
|
&format!("/api/{}/alerts/templates", "e2e"),
|
|
Some(headers),
|
|
Some(body_str.to_string()),
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_get_alert_email_template() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/{}/alerts/templates/{}", "e2e", "email_template"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_delete_alert_email_template() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::DELETE,
|
|
&format!("/api/{}/alerts/templates/{}", "e2e", "email_template"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_list_alert_template() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/{}/alerts/templates", "e2e"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_post_alert_destination() {
|
|
let auth = setup();
|
|
let body_str = r#"{
|
|
"name": "slack",
|
|
"url": "https://dummy/alert",
|
|
"method": "post",
|
|
"template": "slackTemplate",
|
|
"headers":{
|
|
"x_org_id":"Test_header"
|
|
}
|
|
}"#;
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::POST,
|
|
&format!("/api/{}/alerts/destinations", "e2e"),
|
|
Some(headers),
|
|
Some(body_str.to_string()),
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_get_alert_destination() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/{}/alerts/destinations/{}", "e2e", "slack"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_delete_alert_destination() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::DELETE,
|
|
&format!("/api/{}/alerts/destinations/{}", "e2e", "slack"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
log::info!("{:?}", status);
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_post_alert_email_destination() {
|
|
let auth = setup();
|
|
let body_str = r#"{"url":"","method":"post","skip_tls_verify":false,"template":"email_template","headers":{},"name":"email","type":"email","emails":["nonadmin@example.com"]}"#;
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::POST,
|
|
&format!("/api/{}/alerts/destinations", "e2e"),
|
|
Some(headers),
|
|
Some(body_str.to_string()),
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_post_alert_email_destination_should_fail() {
|
|
let auth = setup();
|
|
let body_str = r#"{"url":"","method":"post","skip_tls_verify":false,"template":"email_template","headers":{},"name":"email_fail","type":"email","emails":["nonadmin2@example.com"]}"#;
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::POST,
|
|
&format!("/api/{}/alerts/destinations", "e2e"),
|
|
Some(headers),
|
|
Some(body_str.to_string()),
|
|
)
|
|
.await;
|
|
assert!(status.is_client_error());
|
|
}
|
|
|
|
async fn e2e_get_alert_email_destination() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/{}/alerts/destinations/{}", "e2e", "email"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_delete_alert_email_destination() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::DELETE,
|
|
&format!("/api/{}/alerts/destinations/{}", "e2e", "email"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_list_alert_destinations() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/{}/alerts/destinations", "e2e"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_post_sns_alert_template() {
|
|
let auth = setup();
|
|
let body_str = r#"{
|
|
"name": "snsTemplate",
|
|
"body": "{\"default\": \"SNS alert {alert_name} triggered for {stream_name} in {org_name}\"}"
|
|
}"#;
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::POST,
|
|
&format!("/api/{}/alerts/templates", "e2e"),
|
|
Some(headers),
|
|
Some(body_str.to_string()),
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_post_sns_alert_destination() {
|
|
let auth = setup();
|
|
let body_str = r#"{
|
|
"name": "sns_alert",
|
|
"type": "sns",
|
|
"sns_topic_arn": "arn:aws:sns:us-east-1:123456789012:MyTopic",
|
|
"aws_region": "us-east-1",
|
|
"template": "snsTemplate"
|
|
}"#;
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::POST,
|
|
&format!("/api/{}/alerts/destinations", "e2e"),
|
|
Some(headers),
|
|
Some(body_str.to_string()),
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_get_sns_alert_destination() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/{}/alerts/destinations/{}", "e2e", "sns_alert"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
|
|
// Optionally, deserialize and check the response body
|
|
let destination: Destination = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(destination.destination_type, DestinationType::Sns);
|
|
assert_eq!(
|
|
destination.sns_topic_arn,
|
|
Some("arn:aws:sns:us-east-1:123456789012:MyTopic".to_string())
|
|
);
|
|
assert_eq!(destination.aws_region, Some("us-east-1".to_string()));
|
|
}
|
|
|
|
async fn e2e_list_alert_destinations_with_sns() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/{}/alerts/destinations", "e2e"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
|
|
// Optionally, deserialize and check the response body
|
|
let destinations: Vec<Destination> = serde_json::from_slice(&body).unwrap();
|
|
assert!(
|
|
destinations
|
|
.iter()
|
|
.any(|d| d.destination_type == DestinationType::Sns)
|
|
);
|
|
}
|
|
|
|
async fn e2e_update_sns_alert_destination() {
|
|
let auth = setup();
|
|
let body_str = r#"{
|
|
"name": "sns_alert",
|
|
"type": "sns",
|
|
"sns_topic_arn": "arn:aws:sns:us-west-2:123456789012:UpdatedTopic",
|
|
"aws_region": "us-west-2",
|
|
"template": "snsTemplate"
|
|
}"#;
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::PUT,
|
|
&format!("/api/{}/alerts/destinations/{}", "e2e", "sns_alert"),
|
|
Some(headers),
|
|
Some(body_str.to_string()),
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_delete_sns_alert_destination() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::DELETE,
|
|
&format!("/api/{}/alerts/destinations/{}", "e2e", "sns_alert"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_post_alert_with_sns_destination() {
|
|
let auth = setup();
|
|
let body_str = r#"{
|
|
"name": "sns_test_alert",
|
|
"stream_type": "logs",
|
|
"stream_name": "olympics_schema",
|
|
"is_real_time": false,
|
|
"query_condition": {
|
|
"conditions": [{
|
|
"column": "level",
|
|
"operator": "=",
|
|
"value": "error"
|
|
}]
|
|
},
|
|
"trigger_condition": {
|
|
"period": 5,
|
|
"threshold": 1,
|
|
"silence": 10
|
|
},
|
|
"destinations": ["sns_alert"],
|
|
"context_attributes": {
|
|
"app_name": "TestApp"
|
|
}
|
|
}"#;
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::POST,
|
|
&format!("/api/v2/{}/alerts", "e2e"),
|
|
Some(headers),
|
|
Some(body_str.to_string()),
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_delete_alert_with_sns_destination() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
|
|
let alert = db::alerts::alert::get_by_name(
|
|
"e2e",
|
|
config::meta::stream::StreamType::Logs,
|
|
"olympics_schema",
|
|
"sns_test_alert",
|
|
)
|
|
.await;
|
|
assert!(alert.is_ok());
|
|
let alert = alert.unwrap();
|
|
assert!(alert.is_some());
|
|
let alert = alert.unwrap();
|
|
let id = alert.id;
|
|
assert!(id.is_some());
|
|
let id = id.unwrap();
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::DELETE,
|
|
&format!("/api/v2/{}/alerts/{}", "e2e", id),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
}
|
|
|
|
async fn e2e_post_alert_multirange() {
|
|
let auth = setup();
|
|
let body_str = r#"{
|
|
"name": "alert_multi_range",
|
|
"stream_type": "logs",
|
|
"stream_name": "olympics_schema",
|
|
"is_real_time": false,
|
|
"query_condition": {
|
|
"conditions": [{
|
|
"column": "country",
|
|
"operator": "=",
|
|
"value": "USA"
|
|
}],
|
|
"multi_time_range": [{
|
|
"offSet": "1440m"
|
|
}]
|
|
},
|
|
"trigger_condition": {
|
|
"period": 5,
|
|
"threshold": 1,
|
|
"silence": 0,
|
|
"frequency": 1
|
|
},
|
|
"destinations": ["slack"],
|
|
"context_attributes":{
|
|
"app_name":"App1"
|
|
}
|
|
}"#;
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::POST,
|
|
&format!("/api/v2/{}/alerts", "e2e"),
|
|
Some(headers),
|
|
Some(body_str.to_string()),
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
|
|
// Get the alert with the same stream name
|
|
let alert = db::alerts::alert::get_by_name(
|
|
"e2e",
|
|
config::meta::stream::StreamType::Logs,
|
|
"olympics_schema",
|
|
"alert_multi_range",
|
|
)
|
|
.await;
|
|
assert!(alert.is_ok());
|
|
let alert = alert.unwrap();
|
|
assert!(alert.is_some());
|
|
let alert = alert.unwrap();
|
|
assert_eq!(alert.stream_type, config::meta::stream::StreamType::Logs);
|
|
assert_eq!(alert.stream_name, "olympics_schema");
|
|
assert_eq!(alert.name, "alert_multi_range");
|
|
let id = alert.id;
|
|
assert!(id.is_some());
|
|
let id = id.unwrap();
|
|
// Check the trigger
|
|
let trigger = db::scheduler::exists(
|
|
"e2e",
|
|
config::meta::triggers::TriggerModule::Alert,
|
|
&id.to_string(),
|
|
)
|
|
.await;
|
|
assert!(trigger);
|
|
}
|
|
|
|
async fn e2e_delete_alert_multirange() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
|
|
// Get the alert with the same stream name
|
|
let alert = db::alerts::alert::get_by_name(
|
|
"e2e",
|
|
config::meta::stream::StreamType::Logs,
|
|
"olympics_schema",
|
|
"alert_multi_range",
|
|
)
|
|
.await;
|
|
assert!(alert.is_ok());
|
|
let alert = alert.unwrap();
|
|
assert!(alert.is_some());
|
|
let alert = alert.unwrap();
|
|
let id = alert.id;
|
|
assert!(id.is_some());
|
|
let id = id.unwrap();
|
|
|
|
// Use the v2 api to delete the alert
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::DELETE,
|
|
&format!("/api/v2/{}/alerts/{}", "e2e", id),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
|
|
let trigger = db::scheduler::exists(
|
|
"e2e",
|
|
config::meta::triggers::TriggerModule::Alert,
|
|
&id.to_string(),
|
|
)
|
|
.await;
|
|
assert!(!trigger);
|
|
}
|
|
|
|
async fn e2e_post_alert() {
|
|
let auth = setup();
|
|
let body_str = r#"{
|
|
"name": "alertChk",
|
|
"stream_type": "logs",
|
|
"stream_name": "olympics_schema",
|
|
"is_real_time": false,
|
|
"enabled": true,
|
|
"query_condition": {
|
|
"conditions": [{
|
|
"column": "country",
|
|
"operator": "NotContains",
|
|
"value": "AUT"
|
|
}]
|
|
},
|
|
"trigger_condition": {
|
|
"period": 60,
|
|
"threshold": 1,
|
|
"silence": 0,
|
|
"frequency": 60,
|
|
"operator": ">="
|
|
},
|
|
"destinations": ["slack"],
|
|
"context_attributes":{
|
|
"app_name":"App1"
|
|
}
|
|
}"#;
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::POST,
|
|
&format!("/api/v2/{}/alerts", "e2e"),
|
|
Some(headers.clone()),
|
|
Some(body_str.to_string()),
|
|
)
|
|
.await;
|
|
println!("{:?}", status);
|
|
assert!(status.is_success());
|
|
|
|
// Get the alert list
|
|
let (status, body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/v2/{}/alerts", "e2e"),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
let alert_list_response: ListAlertsResponseBody = serde_json::from_slice(&body).unwrap();
|
|
assert!(!alert_list_response.list.is_empty());
|
|
let alert = alert_list_response
|
|
.list
|
|
.iter()
|
|
.find(|a| a.name == "alertChk");
|
|
assert!(alert.is_some());
|
|
let alert = alert.unwrap();
|
|
assert_eq!(alert.name, "alertChk");
|
|
assert!(alert.enabled);
|
|
let id = alert.alert_id;
|
|
let id = id.to_string();
|
|
|
|
// Check the trigger
|
|
let trigger = db::scheduler::exists(
|
|
"e2e",
|
|
config::meta::triggers::TriggerModule::Alert,
|
|
&id.to_string(),
|
|
)
|
|
.await;
|
|
assert!(trigger);
|
|
}
|
|
|
|
async fn e2e_get_alert() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
|
|
// Get the alert list
|
|
let (status, body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/v2/{}/alerts", "e2e"),
|
|
Some(headers.clone()),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(status.is_success());
|
|
let alert_list_response: ListAlertsResponseBody = serde_json::from_slice(&body).unwrap();
|
|
assert!(!alert_list_response.list.is_empty());
|
|
let alert = alert_list_response
|
|
.list
|
|
.iter()
|
|
.find(|a| a.name == "alertChk");
|
|
assert!(alert.is_some());
|
|
let alert = alert.unwrap();
|
|
assert_eq!(alert.name, "alertChk");
|
|
assert!(alert.enabled);
|
|
let id = alert.alert_id;
|
|
let id = id.to_string();
|
|
|
|
let (status, body) = make_request(
|
|
&app,
|
|
Method::GET,
|
|
&format!("/api/v2/{}/alerts/{}", "e2e", id),
|
|
Some(headers),
|
|
None,
|
|
)
|
|
.await;
|
|
log::info!("{:?}", status);
|
|
assert!(status.is_success());
|
|
let alert_response: GetAlertResponseBody = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(alert_response.0.name, "alertChk");
|
|
assert_eq!(
|
|
alert_response.0.stream_type,
|
|
openobserve_api_management::models::alerts::StreamType::Logs
|
|
);
|
|
assert_eq!(alert_response.0.stream_name, "olympics_schema");
|
|
assert!(alert_response.0.enabled);
|
|
}
|
|
|
|
async fn e2e_handle_alert_after_destination_retries() {
|
|
let alert = db::alerts::alert::get_by_name(
|
|
"e2e",
|
|
config::meta::stream::StreamType::Logs,
|
|
"olympics_schema",
|
|
"alertChk",
|
|
)
|
|
.await;
|
|
assert!(alert.is_ok());
|
|
let alert = alert.unwrap();
|
|
assert!(alert.is_some());
|
|
let alert = alert.unwrap();
|
|
let id = alert.id;
|
|
assert!(id.is_some());
|
|
let id = id.unwrap();
|
|
|
|
let now = Utc::now().timestamp_micros();
|
|
let mins_3_later = now
|
|
+ Duration::try_minutes(3)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap();
|
|
let trigger = Trigger {
|
|
id: 1,
|
|
claim_epoch: 0,
|
|
org: "e2e".to_string(),
|
|
module: config::meta::triggers::TriggerModule::Alert,
|
|
module_key: id.to_string(),
|
|
start_time: Some(now),
|
|
end_time: Some(mins_3_later),
|
|
next_run_at: now,
|
|
is_realtime: false,
|
|
is_silenced: false,
|
|
status: config::meta::triggers::TriggerStatus::Processing,
|
|
retries: 2,
|
|
data: "{}".to_string(),
|
|
};
|
|
|
|
let trace_id = "test_trace_id";
|
|
let res = handle_triggers(trace_id, trigger).await;
|
|
// This alert has an invalid destination, but handle_triggers should succeed.
|
|
// Note: May get partial results if files are cleaned up during test execution.
|
|
if let Err(ref e) = res {
|
|
let err_msg = e.to_string();
|
|
// Accept partial response errors due to file cleanup race conditions in tests
|
|
if !err_msg.contains("Partial response") && !err_msg.contains("parquet file not found")
|
|
{
|
|
panic!("handle_triggers failed unexpectedly: {:?}", res);
|
|
}
|
|
}
|
|
|
|
let trigger = db::scheduler::get(
|
|
"e2e",
|
|
config::meta::triggers::TriggerModule::Alert,
|
|
&id.to_string(),
|
|
)
|
|
.await;
|
|
assert!(trigger.is_ok());
|
|
let trigger = trigger.unwrap();
|
|
assert!(trigger.next_run_at > now && trigger.retries == 0);
|
|
}
|
|
|
|
async fn e2e_handle_alert_reached_max_retries() {
|
|
let now = Utc::now().timestamp_micros();
|
|
let mins_3_later = now
|
|
+ Duration::try_minutes(3)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap();
|
|
let alert = db::alerts::alert::get_by_name(
|
|
"e2e",
|
|
config::meta::stream::StreamType::Logs,
|
|
"olympics_schema",
|
|
"alertChk",
|
|
)
|
|
.await;
|
|
assert!(alert.is_ok());
|
|
let alert = alert.unwrap();
|
|
assert!(alert.is_some());
|
|
let alert = alert.unwrap();
|
|
let id = alert.id;
|
|
assert!(id.is_some());
|
|
let id = id.unwrap();
|
|
let trigger = Trigger {
|
|
id: 1,
|
|
claim_epoch: 0,
|
|
org: "e2e".to_string(),
|
|
module: config::meta::triggers::TriggerModule::Alert,
|
|
module_key: id.to_string(),
|
|
start_time: Some(now),
|
|
end_time: Some(mins_3_later),
|
|
next_run_at: now,
|
|
is_realtime: false,
|
|
is_silenced: false,
|
|
status: config::meta::triggers::TriggerStatus::Processing,
|
|
retries: 3,
|
|
data: "{}".to_string(),
|
|
};
|
|
|
|
let trace_id = "test_trace_id";
|
|
let res = handle_triggers(trace_id, trigger).await;
|
|
// This alert has an invalid destination
|
|
assert!(res.is_ok());
|
|
|
|
let trigger = db::scheduler::get(
|
|
"e2e",
|
|
config::meta::triggers::TriggerModule::Alert,
|
|
&id.to_string(),
|
|
)
|
|
.await;
|
|
assert!(trigger.is_ok());
|
|
let trigger = trigger.unwrap();
|
|
assert!(trigger.next_run_at > now && trigger.retries == 0);
|
|
}
|
|
|
|
async fn e2e_handle_alert_after_evaluation_retries() {
|
|
let mut alert: Alert = Default::default();
|
|
alert.name = "test_alert_wrong_sql".to_string();
|
|
alert.stream_type = "logs".into();
|
|
alert.stream_name = "olympics_schema".to_string();
|
|
alert.is_real_time = false;
|
|
alert.enabled = true;
|
|
alert.query_condition = QueryCondition {
|
|
query_type: 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::<ScheduledTriggerData>(&trigger.data)
|
|
&& scheduled_trigger_data.period_end_time.is_some()
|
|
{
|
|
assert!(scheduled_trigger_data.period_end_time.unwrap() > 0);
|
|
assert!(trigger.status == TriggerStatus::Waiting);
|
|
assert!(trigger.next_run_at > now && trigger.retries == 0);
|
|
trigger_updated = true;
|
|
break;
|
|
}
|
|
|
|
attempts += 1;
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
|
|
// Exponential backoff: 100ms, 200ms, 400ms, 800ms, 1000ms (capped)
|
|
delay_ms = std::cmp::min(delay_ms * 2, 1000);
|
|
}
|
|
|
|
assert!(
|
|
trigger_updated,
|
|
"Trigger was not updated after {max_attempts} attempts"
|
|
);
|
|
}
|
|
|
|
async fn e2e_handle_derived_stream_pipeline_not_found() {
|
|
let now = Utc::now().timestamp_micros();
|
|
let mins_5_later = now
|
|
+ Duration::try_minutes(5)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap();
|
|
let module_key = "logs/e2e/nonexistent_pipeline/invalid_id".to_string();
|
|
|
|
let trigger = Trigger {
|
|
id: 2,
|
|
claim_epoch: 0,
|
|
org: "e2e".to_string(),
|
|
module: TriggerModule::DerivedStream,
|
|
module_key,
|
|
start_time: Some(now),
|
|
end_time: Some(mins_5_later),
|
|
next_run_at: now,
|
|
is_realtime: false,
|
|
is_silenced: false,
|
|
status: config::meta::triggers::TriggerStatus::Processing,
|
|
retries: 0,
|
|
data: "{}".to_string(),
|
|
};
|
|
|
|
let trace_id = "test_derived_stream_not_found_trace_id";
|
|
let res = handle_triggers(trace_id, trigger).await;
|
|
// Should fail with pipeline not found error
|
|
assert!(res.is_err());
|
|
assert!(
|
|
res.unwrap_err()
|
|
.to_string()
|
|
.contains("Pipeline associated with trigger not found")
|
|
);
|
|
}
|
|
|
|
async fn e2e_handle_derived_stream_max_retries() {
|
|
// list pipelines using API
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, body) =
|
|
make_request(&app, Method::GET, "/api/e2e/pipelines", Some(headers), None).await;
|
|
assert!(status.is_success());
|
|
let pipeline_list: PipelineList = json::from_slice(&body).unwrap();
|
|
let pipelines = pipeline_list.list.first();
|
|
assert!(pipelines.is_some());
|
|
let pipeline = pipelines.unwrap();
|
|
|
|
let now = Utc::now().timestamp_micros();
|
|
let mins_5_later = now
|
|
+ Duration::try_minutes(5)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap();
|
|
let module_key = format!("logs/e2e/test_derived_stream/{}", pipeline.id);
|
|
|
|
let trigger = Trigger {
|
|
id: 3,
|
|
claim_epoch: 0,
|
|
org: "e2e".to_string(),
|
|
module: TriggerModule::DerivedStream,
|
|
module_key: module_key.clone(),
|
|
start_time: Some(now),
|
|
end_time: Some(mins_5_later),
|
|
next_run_at: now,
|
|
is_realtime: false,
|
|
is_silenced: false,
|
|
status: config::meta::triggers::TriggerStatus::Processing,
|
|
retries: 5, // Max retries reached
|
|
data: "{}".to_string(),
|
|
};
|
|
|
|
let trace_id = "test_derived_stream_max_retries_trace_id";
|
|
let res = handle_triggers(trace_id, trigger).await;
|
|
// Should succeed but skip to next run due to max retries
|
|
assert!(res.is_ok());
|
|
|
|
// Verify trigger was updated with next run time and retries reset
|
|
let trigger = db::scheduler::get("e2e", TriggerModule::DerivedStream, &module_key).await;
|
|
assert!(trigger.is_ok());
|
|
let trigger = trigger.unwrap();
|
|
assert!(trigger.next_run_at > now && trigger.retries == 0);
|
|
}
|
|
|
|
async fn e2e_handle_derived_stream_evaluation_failure() {
|
|
// Create a pipeline that queries a non-existent stream to cause evaluation failure.
|
|
// evaluate_scheduled() returns Err when the stream is not found in schema cache,
|
|
// which should increment the trigger's retries counter.
|
|
let pipeline_data = Pipeline {
|
|
id: "test_derived_stream_pipeline_invalid".to_string(),
|
|
version: 1,
|
|
enabled: true,
|
|
org: "e2e".to_string(),
|
|
name: "test_derived_stream_invalid".to_string(),
|
|
description: "Test pipeline with non-existent stream to cause evaluation failure"
|
|
.to_string(),
|
|
source: PipelineSource::Scheduled(DerivedStream {
|
|
org_id: "e2e".to_string(),
|
|
stream_type: StreamType::Logs,
|
|
query_condition: QueryCondition {
|
|
query_type: config::meta::alerts::QueryType::SQL,
|
|
sql: Some("SELECT * FROM \"nonexistent_stream_xyz_abc_test\"".to_string()),
|
|
..Default::default()
|
|
},
|
|
trigger_condition: TriggerCondition {
|
|
period: 5,
|
|
frequency: 300,
|
|
..Default::default()
|
|
},
|
|
tz_offset: 0,
|
|
start_at: None,
|
|
delay: None,
|
|
}),
|
|
kind: Default::default(),
|
|
nodes: vec![
|
|
// Source node (query node for scheduled pipeline with non-existent stream)
|
|
config::meta::pipeline::components::Node::new(
|
|
"source-node-2".to_string(),
|
|
config::meta::pipeline::components::NodeData::Query(DerivedStream {
|
|
org_id: "e2e".to_string(),
|
|
stream_type: StreamType::Logs,
|
|
query_condition: QueryCondition {
|
|
query_type: config::meta::alerts::QueryType::SQL,
|
|
sql: Some(
|
|
"SELECT * FROM \"nonexistent_stream_xyz_abc_test\"".to_string(),
|
|
),
|
|
..Default::default()
|
|
},
|
|
trigger_condition: TriggerCondition {
|
|
period: 5,
|
|
frequency: 300,
|
|
..Default::default()
|
|
},
|
|
tz_offset: 0,
|
|
start_at: None,
|
|
delay: None,
|
|
}),
|
|
150.0,
|
|
50.0,
|
|
"input".to_string(),
|
|
),
|
|
// Destination node (output stream)
|
|
config::meta::pipeline::components::Node::new(
|
|
"dest-node-2".to_string(),
|
|
config::meta::pipeline::components::NodeData::Stream(
|
|
config::meta::stream::StreamParams {
|
|
org_id: "e2e".to_string().into(),
|
|
stream_name: "test_invalid_pipeline_output".to_string().into(),
|
|
stream_type: StreamType::Logs,
|
|
},
|
|
),
|
|
150.0,
|
|
200.0,
|
|
"output".to_string(),
|
|
),
|
|
],
|
|
edges: vec![config::meta::pipeline::components::Edge::new(
|
|
"source-node-2".to_string(),
|
|
"dest-node-2".to_string(),
|
|
)],
|
|
};
|
|
|
|
// Save pipeline directly to DB (bypassing API validation) to simulate a pipeline
|
|
// with an invalid query that was saved before validation was added, or to test
|
|
// what happens at evaluation time when the stream does not exist.
|
|
openobserve_core::pipeline::db::set(&pipeline_data)
|
|
.await
|
|
.expect("Failed to set pipeline in DB");
|
|
// Create the scheduler trigger directly with needs_validated=false so the
|
|
// non-existent stream does not fail the pre-save test run.
|
|
let derived_stream = match &pipeline_data.source {
|
|
PipelineSource::Scheduled(ds) => ds.clone(),
|
|
_ => panic!("Expected scheduled pipeline"),
|
|
};
|
|
openobserve_core::alerts::derived_streams::save(
|
|
derived_stream,
|
|
&pipeline_data.name,
|
|
&pipeline_data.id,
|
|
false,
|
|
)
|
|
.await
|
|
.expect("Failed to save derived stream trigger");
|
|
|
|
let pipeline = get_pipeline_from_api(pipeline_data.name.as_str()).await;
|
|
|
|
let now = Utc::now().timestamp_micros();
|
|
let mins_5_later = now
|
|
+ Duration::try_minutes(5)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap();
|
|
let module_key = format!("logs/e2e/test_derived_stream_invalid/{}", pipeline.id);
|
|
|
|
let trigger = Trigger {
|
|
id: 4,
|
|
claim_epoch: 0,
|
|
org: "e2e".to_string(),
|
|
module: TriggerModule::DerivedStream,
|
|
module_key: module_key.clone(),
|
|
start_time: Some(now),
|
|
end_time: Some(mins_5_later),
|
|
next_run_at: now,
|
|
is_realtime: false,
|
|
is_silenced: false,
|
|
status: config::meta::triggers::TriggerStatus::Processing,
|
|
retries: 0,
|
|
data: "{}".to_string(),
|
|
};
|
|
|
|
let trace_id = "test_derived_stream_eval_failure_trace_id";
|
|
let _ = handle_triggers(trace_id, trigger).await;
|
|
// Should succeed (handler handles errors gracefully) but increment retries
|
|
// Verify trigger retries were incremented
|
|
let trigger = db::scheduler::get("e2e", TriggerModule::DerivedStream, &module_key).await;
|
|
assert!(trigger.is_ok());
|
|
let trigger = trigger.unwrap();
|
|
assert!(trigger.retries > 0);
|
|
|
|
// Clean up the invalid pipeline
|
|
let _ = openobserve_core::pipeline::db::delete(&pipeline.id).await;
|
|
}
|
|
|
|
// Test to handle case where pipeline triggers for invalid timerange where start time
|
|
// is greater than end time because of the delay feature which is turned on after alert
|
|
// is already created and is running for some time.
|
|
|
|
async fn test_derived_stream_invalid_timerange_delay_scenario() {
|
|
// Create a pipeline with derived stream that has delay configured
|
|
let pipeline_data = Pipeline {
|
|
id: "test_invalid_timerange_pipeline".to_string(),
|
|
version: 1,
|
|
enabled: true,
|
|
org: "e2e".to_string(),
|
|
name: "test_invalid_timerange_pipeline".to_string(),
|
|
description: "Test pipeline for invalid timerange delay scenario".to_string(),
|
|
source: PipelineSource::Scheduled(DerivedStream {
|
|
org_id: "e2e".to_string(),
|
|
stream_type: StreamType::Logs,
|
|
query_condition: QueryCondition {
|
|
query_type: config::meta::alerts::QueryType::SQL,
|
|
sql: Some("SELECT _timestamp, city FROM \"olympics_schema\"".to_string()),
|
|
..Default::default()
|
|
},
|
|
trigger_condition: TriggerCondition {
|
|
period: 5, // 5 minutes
|
|
frequency: 300, // 5 minutes in seconds
|
|
..Default::default()
|
|
},
|
|
tz_offset: 0,
|
|
start_at: None,
|
|
delay: Some(10), // 10 minutes delay
|
|
}),
|
|
kind: Default::default(),
|
|
nodes: vec![
|
|
// Source node (query node for scheduled pipeline)
|
|
config::meta::pipeline::components::Node::new(
|
|
"source-node-1".to_string(),
|
|
config::meta::pipeline::components::NodeData::Query(DerivedStream {
|
|
org_id: "e2e".to_string(),
|
|
stream_type: StreamType::Logs,
|
|
query_condition: QueryCondition {
|
|
query_type: config::meta::alerts::QueryType::SQL,
|
|
sql: Some(
|
|
"SELECT _timestamp, city FROM \"olympics_schema\"".to_string(),
|
|
),
|
|
..Default::default()
|
|
},
|
|
trigger_condition: TriggerCondition {
|
|
period: 5,
|
|
frequency: 300,
|
|
..Default::default()
|
|
},
|
|
tz_offset: 0,
|
|
start_at: None,
|
|
delay: Some(10), // 10 minutes delay
|
|
}),
|
|
100.0,
|
|
50.0,
|
|
"input".to_string(),
|
|
),
|
|
// Destination node (output stream)
|
|
config::meta::pipeline::components::Node::new(
|
|
"dest-node-1".to_string(),
|
|
config::meta::pipeline::components::NodeData::Stream(
|
|
config::meta::stream::StreamParams {
|
|
org_id: "e2e".to_string().into(),
|
|
stream_name: "derived_stream".to_string().into(),
|
|
stream_type: StreamType::Logs,
|
|
},
|
|
),
|
|
100.0,
|
|
200.0,
|
|
"output".to_string(),
|
|
),
|
|
],
|
|
edges: vec![config::meta::pipeline::components::Edge::new(
|
|
"source-node-1".to_string(),
|
|
"dest-node-1".to_string(),
|
|
)],
|
|
};
|
|
|
|
// Create the pipeline
|
|
e2e_post_pipeline(pipeline_data.clone()).await;
|
|
let pipeline = get_pipeline_from_api(pipeline_data.name.as_str()).await;
|
|
|
|
// Create a trigger that will cause invalid timerange scenario
|
|
// Simulate a scenario where the trigger was created before delay was enabled
|
|
// and now the start time is greater than end time due to delay
|
|
let current_time = Utc::now().timestamp_micros();
|
|
// 5 minutes is period of the pipeline and 1 min is delay in processing the pipeline
|
|
let period_micros = Duration::try_minutes(6)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap();
|
|
let timeout = Duration::try_minutes(2)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap();
|
|
|
|
// Create trigger with start time that will be greater than end time after delay
|
|
let trigger_start_time = current_time - period_micros; // 5 minutes ago
|
|
|
|
let trigger = Trigger {
|
|
id: 1,
|
|
claim_epoch: 0,
|
|
org: "e2e".to_string(),
|
|
module: TriggerModule::DerivedStream,
|
|
module_key: format!("logs/e2e/test_invalid_timerange_pipeline/{}", pipeline.id),
|
|
next_run_at: current_time,
|
|
start_time: Some(current_time),
|
|
end_time: Some(current_time + timeout),
|
|
is_realtime: false,
|
|
is_silenced: false,
|
|
status: config::meta::triggers::TriggerStatus::Processing,
|
|
retries: 0,
|
|
data: serde_json::json!({
|
|
"period_end_time": trigger_start_time // This will cause start > end
|
|
})
|
|
.to_string(),
|
|
};
|
|
|
|
// Process the trigger - this should handle invalid timerange gracefully
|
|
let trace_id = "test_invalid_timerange_trace_id";
|
|
let result = handle_triggers(trace_id, trigger).await;
|
|
|
|
// Should not return an error, but should handle invalid timerange gracefully
|
|
assert!(result.is_ok());
|
|
|
|
// Get the trigger from the database
|
|
let trigger = db::scheduler::get(
|
|
"e2e",
|
|
TriggerModule::DerivedStream,
|
|
&format!("logs/e2e/test_invalid_timerange_pipeline/{}", pipeline.id),
|
|
)
|
|
.await;
|
|
assert!(trigger.is_ok());
|
|
let trigger = trigger.unwrap();
|
|
// Next run at should be greater than current time
|
|
assert!(trigger.next_run_at > current_time);
|
|
|
|
// And last period end time should not change
|
|
assert_eq!(
|
|
trigger.data,
|
|
serde_json::json!({
|
|
"period_end_time": trigger_start_time
|
|
})
|
|
.to_string()
|
|
);
|
|
|
|
// Clean up
|
|
let _ = openobserve_core::pipeline::db::delete(&pipeline.id).await;
|
|
// Also delete the trigger job from scheduled jobs table
|
|
let _ = db::scheduler::delete(
|
|
"e2e",
|
|
TriggerModule::DerivedStream,
|
|
&format!("logs/e2e/test_invalid_timerange_pipeline/{}", pipeline.id),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn test_derived_stream_invalid_timerange_with_cron_frequency() {
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
|
|
// Create a pipeline with derived stream using cron frequency
|
|
let pipeline_data = Pipeline {
|
|
id: "test_cron_invalid_timerange_pipeline".to_string(),
|
|
version: 1,
|
|
enabled: true,
|
|
org: "e2e".to_string(),
|
|
name: "test_cron_invalid_timerange_pipeline".to_string(),
|
|
description: "Test pipeline for invalid timerange with cron frequency".to_string(),
|
|
source: PipelineSource::Scheduled(DerivedStream {
|
|
org_id: "e2e".to_string(),
|
|
stream_type: StreamType::Logs,
|
|
query_condition: QueryCondition {
|
|
query_type: config::meta::alerts::QueryType::SQL,
|
|
sql: Some("SELECT _timestamp, city FROM \"olympics_schema\"".to_string()),
|
|
..Default::default()
|
|
},
|
|
trigger_condition: TriggerCondition {
|
|
period: 5, // 5 minutes
|
|
frequency: 0, // 0 for cron frequency
|
|
cron: "0 */5 * * * *".to_string(), // Every 5 minutes
|
|
frequency_type: config::meta::alerts::FrequencyType::Cron,
|
|
..Default::default()
|
|
},
|
|
tz_offset: 0,
|
|
start_at: None,
|
|
delay: Some(10), // 10 minutes delay
|
|
}),
|
|
kind: Default::default(),
|
|
nodes: vec![
|
|
// Source node (query node for scheduled pipeline)
|
|
config::meta::pipeline::components::Node::new(
|
|
"source-node-1".to_string(),
|
|
config::meta::pipeline::components::NodeData::Query(DerivedStream {
|
|
org_id: "e2e".to_string(),
|
|
stream_type: StreamType::Logs,
|
|
query_condition: QueryCondition {
|
|
query_type: config::meta::alerts::QueryType::SQL,
|
|
sql: Some(
|
|
"SELECT _timestamp, city FROM \"olympics_schema\"".to_string(),
|
|
),
|
|
..Default::default()
|
|
},
|
|
trigger_condition: TriggerCondition {
|
|
period: 5,
|
|
frequency: 0,
|
|
cron: "0 */5 * * * *".to_string(),
|
|
frequency_type: config::meta::alerts::FrequencyType::Cron,
|
|
..Default::default()
|
|
},
|
|
tz_offset: 0,
|
|
start_at: None,
|
|
delay: Some(10), // 10 minutes delay
|
|
}),
|
|
100.0,
|
|
50.0,
|
|
"input".to_string(),
|
|
),
|
|
// Destination node (output stream)
|
|
config::meta::pipeline::components::Node::new(
|
|
"dest-node-1".to_string(),
|
|
config::meta::pipeline::components::NodeData::Stream(
|
|
config::meta::stream::StreamParams {
|
|
org_id: "e2e".to_string().into(),
|
|
stream_name: "derived_stream".to_string().into(),
|
|
stream_type: StreamType::Logs,
|
|
},
|
|
),
|
|
100.0,
|
|
200.0,
|
|
"output".to_string(),
|
|
),
|
|
],
|
|
edges: vec![config::meta::pipeline::components::Edge::new(
|
|
"source-node-1".to_string(),
|
|
"dest-node-1".to_string(),
|
|
)],
|
|
};
|
|
|
|
// Create the pipeline
|
|
let headers = auth_headers(auth);
|
|
let (status, _body) = make_request(
|
|
&app,
|
|
Method::POST,
|
|
"/api/e2e/pipelines",
|
|
Some(headers),
|
|
Some(serde_json::to_string(&pipeline_data).unwrap()),
|
|
)
|
|
.await;
|
|
println!("test derived stream invalid timerange with cron frequency status: {status:?}");
|
|
assert!(status.is_success());
|
|
|
|
let pipeline = get_pipeline_from_api(pipeline_data.name.as_str()).await;
|
|
|
|
// Create trigger that will cause invalid timerange with cron frequency
|
|
let current_time = Utc::now().timestamp_micros();
|
|
let dur_20_mins = Duration::try_minutes(20)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap();
|
|
let period_micros = Duration::try_minutes(5)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap();
|
|
|
|
// Say, this trigger was last run at 15 mins ago
|
|
let last_end_time = current_time - dur_20_mins;
|
|
// Current end time needs to be aligned (as this is cron frequency)
|
|
let current_next_run_time =
|
|
TriggerCondition::align_time(last_end_time + period_micros, 0, Some(300), None); // This will cause start > end
|
|
let timeout = Duration::try_minutes(2)
|
|
.unwrap()
|
|
.num_microseconds()
|
|
.unwrap();
|
|
|
|
let trigger = Trigger {
|
|
id: 3,
|
|
claim_epoch: 0,
|
|
org: "e2e".to_string(),
|
|
module: TriggerModule::DerivedStream,
|
|
module_key: format!(
|
|
"logs/e2e/test_cron_invalid_timerange_pipeline/{}",
|
|
pipeline.id
|
|
),
|
|
next_run_at: current_next_run_time,
|
|
start_time: Some(current_time),
|
|
end_time: Some(current_time + timeout), // end < start
|
|
is_realtime: false,
|
|
is_silenced: false,
|
|
status: config::meta::triggers::TriggerStatus::Processing,
|
|
retries: 0,
|
|
data: serde_json::json!({
|
|
"period_end_time": last_end_time // This will cause start > end
|
|
})
|
|
.to_string(),
|
|
};
|
|
|
|
// Process the trigger
|
|
let trace_id = "test_cron_invalid_timerange_trace_id";
|
|
let result = handle_triggers(trace_id, trigger).await;
|
|
|
|
// Should handle invalid timerange with cron frequency gracefully
|
|
assert!(result.is_ok());
|
|
|
|
// Get the trigger from the database
|
|
let trigger = db::scheduler::get(
|
|
"e2e",
|
|
TriggerModule::DerivedStream,
|
|
&format!(
|
|
"logs/e2e/test_cron_invalid_timerange_pipeline/{}",
|
|
pipeline.id
|
|
),
|
|
)
|
|
.await;
|
|
assert!(trigger.is_ok());
|
|
let trigger = trigger.unwrap();
|
|
// Next run at should be greater than current time
|
|
assert!(trigger.next_run_at > current_next_run_time);
|
|
// Next run at should be less than current time, because the frequency is 5 mins
|
|
assert!(trigger.next_run_at < current_time);
|
|
assert_eq!(
|
|
trigger.data,
|
|
serde_json::json!({
|
|
"period_end_time": last_end_time
|
|
})
|
|
.to_string()
|
|
);
|
|
|
|
// Clean up
|
|
let _ = openobserve_core::pipeline::db::delete(&pipeline.id).await;
|
|
// Also delete the trigger job from scheduled jobs table
|
|
let _ = db::scheduler::delete(
|
|
"e2e",
|
|
TriggerModule::DerivedStream,
|
|
&format!(
|
|
"logs/e2e/test_cron_invalid_timerange_pipeline/{}",
|
|
pipeline.id
|
|
),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn e2e_cleanup_test_pipeline() {
|
|
// list the pipelines and choose the first one using API
|
|
let auth = setup();
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, body) =
|
|
make_request(&app, Method::GET, "/api/e2e/pipelines", Some(headers), None).await;
|
|
assert!(status.is_success());
|
|
let pipeline_list: PipelineList = json::from_slice(&body).unwrap();
|
|
let pipeline = pipeline_list.list.first();
|
|
assert!(pipeline.is_some());
|
|
let pipeline = pipeline.unwrap();
|
|
|
|
// Clean up test pipelines
|
|
let _ = openobserve_core::pipeline::db::delete(&pipeline.id).await;
|
|
}
|
|
|
|
async fn get_pipeline_from_api(pipeline_name: &str) -> ApiPipeline {
|
|
let auth = setup();
|
|
// Check if pipeline was saved successfully by doing a list using API
|
|
let app = init_test_router();
|
|
let headers = auth_headers(auth);
|
|
let (status, body) =
|
|
make_request(&app, Method::GET, "/api/e2e/pipelines", Some(headers), None).await;
|
|
assert!(status.is_success(), "Failed to list pipelines");
|
|
let pipeline_response: PipelineList = json::from_slice(&body).unwrap();
|
|
// Get the pipeline that matches the pipeline name
|
|
let pipeline = pipeline_response
|
|
.list
|
|
.iter()
|
|
.find(|p| p.name == pipeline_name);
|
|
assert!(pipeline.is_some(), "Pipeline not found");
|
|
let pipeline = pipeline.unwrap();
|
|
pipeline.clone()
|
|
}
|
|
|
|
// ==================== ENRICHMENT TABLE TESTS ====================
|
|
|
|
async fn e2e_save_enrichment_data_new_table() {
|
|
let _auth = setup();
|
|
let org_id = "e2e";
|
|
let table_name = "test_enrichment_table";
|
|
|
|
// Create test data
|
|
let mut payload = Vec::new();
|
|
let mut record1 = json::Map::new();
|
|
record1.insert("name".to_string(), json::Value::String("John".to_string()));
|
|
record1.insert("age".to_string(), json::Value::String("25".to_string()));
|
|
record1.insert(
|
|
"city".to_string(),
|
|
json::Value::String("New York".to_string()),
|
|
);
|
|
payload.push(record1);
|
|
|
|
let mut record2 = json::Map::new();
|
|
record2.insert("name".to_string(), json::Value::String("Jane".to_string()));
|
|
record2.insert("age".to_string(), json::Value::String("30".to_string()));
|
|
record2.insert(
|
|
"city".to_string(),
|
|
json::Value::String("Los Angeles".to_string()),
|
|
);
|
|
payload.push(record2);
|
|
|
|
// Call save_enrichment_data
|
|
let result = enrichment_data::enrichment_table::save_enrichment_data(
|
|
org_id, table_name, payload, false, // append_data = false
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
let response = result.unwrap();
|
|
assert!(response.status().is_success());
|
|
|
|
// Verify schema was created in database
|
|
let schema_exists = schema::stream_schema_exists(
|
|
org_id,
|
|
table_name,
|
|
config::meta::stream::StreamType::EnrichmentTables,
|
|
&mut std::collections::HashMap::new(),
|
|
)
|
|
.await;
|
|
|
|
println!("schema_exists: {schema_exists:?}");
|
|
|
|
assert!(schema_exists.has_fields);
|
|
|
|
// Verify schema cache was updated
|
|
let schema_key = format!(
|
|
"{}/{}/{}",
|
|
org_id,
|
|
config::meta::stream::StreamType::EnrichmentTables,
|
|
table_name
|
|
);
|
|
let stream_schemas = STREAM_SCHEMAS.read().await;
|
|
assert!(stream_schemas.contains_key(&schema_key));
|
|
drop(stream_schemas);
|
|
|
|
// Verify latest schema cache was updated
|
|
let stream_schemas_latest = STREAM_SCHEMAS_LATEST.read().await;
|
|
assert!(stream_schemas_latest.contains_key(&schema_key));
|
|
drop(stream_schemas_latest);
|
|
|
|
// Verify stream settings cache was updated
|
|
assert!(infra::schema::get_stream_settings_atomic(&schema_key).is_some());
|
|
|
|
// Get the meta table stats for enrichment table
|
|
let meta_table_stats = db::enrichment_table::get_meta_table_stats(org_id, table_name).await;
|
|
assert!(meta_table_stats.is_some());
|
|
let meta_table_stats = meta_table_stats.unwrap();
|
|
assert_ne!(meta_table_stats.size, 0);
|
|
assert_ne!(meta_table_stats.start_time, 0);
|
|
|
|
// Check get_enrichment_table function, it should return same data
|
|
let data =
|
|
enrichment_data::enrichment::get_enrichment_table(org_id, table_name, false).await;
|
|
assert!(data.is_ok());
|
|
let data = data.unwrap();
|
|
assert!(data.len() == 2);
|
|
println!("save enrichment data new tabledata: {data:?}");
|
|
println!(
|
|
"save enrichment data new table data[0]: {:?}",
|
|
data[0].get("name").unwrap().to_string()
|
|
);
|
|
assert!(data[0].get("name").unwrap().to_string().eq("\"John\""));
|
|
assert!(data[1].get("name").unwrap().to_string().eq("\"Jane\""));
|
|
assert!(data[0].get("age").unwrap().to_string().eq("\"25\""));
|
|
assert!(data[1].get("age").unwrap().to_string().eq("\"30\""));
|
|
assert!(data[0].get("city").unwrap().to_string().eq("\"New York\""));
|
|
assert!(
|
|
data[1]
|
|
.get("city")
|
|
.unwrap()
|
|
.to_string()
|
|
.eq("\"Los Angeles\"")
|
|
);
|
|
|
|
// wait for 1 second
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
|
|
|
// Check the ENRICHMENT_TABLES cache to check if the table is created
|
|
let enrichment_tables = ENRICHMENT_TABLES.clone();
|
|
println!("save enrichment data new table enrichment_tables: {enrichment_tables:?}");
|
|
assert!(enrichment_tables.contains_key(&schema_key));
|
|
assert!(enrichment_tables.get(&schema_key).unwrap().data.len() == 2);
|
|
|
|
drop(enrichment_tables);
|
|
|
|
println!("save enrichment data new table meta_table_stats: {meta_table_stats:?}");
|
|
// Also, it should store the cache in the disk
|
|
check_enrichment_table_local_disk_cache(org_id, table_name, meta_table_stats.end_time)
|
|
.await;
|
|
// Clean up
|
|
e2e_cleanup_enrichment_table(org_id, table_name).await;
|
|
}
|
|
|
|
async fn e2e_cleanup_enrichment_table(org_id: &str, stream_name: &str) {
|
|
// Clean up the enrichment table and its schema
|
|
enrichment_data::enrichment_table::delete_enrichment_table(
|
|
org_id,
|
|
stream_name,
|
|
config::meta::stream::StreamType::EnrichmentTables,
|
|
(0, chrono::Utc::now().timestamp_micros()),
|
|
)
|
|
.await;
|
|
|
|
// Verify schema caches are cleaned up
|
|
let schema_key = format!(
|
|
"{}/{}/{}",
|
|
org_id,
|
|
config::meta::stream::StreamType::EnrichmentTables,
|
|
stream_name
|
|
);
|
|
|
|
// Check that schema caches are cleared
|
|
let stream_schemas = STREAM_SCHEMAS.read().await;
|
|
assert!(!stream_schemas.contains_key(&schema_key));
|
|
drop(stream_schemas);
|
|
|
|
let stream_schemas_latest = STREAM_SCHEMAS_LATEST.read().await;
|
|
assert!(!stream_schemas_latest.contains_key(&schema_key));
|
|
drop(stream_schemas_latest);
|
|
|
|
assert!(infra::schema::get_stream_settings_atomic(&schema_key).is_none());
|
|
|
|
// wait for 2 seconds
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
|
|
|
// Check the ENRICHMENT_TABLES cache to check if the table is deleted
|
|
let enrichment_tables = ENRICHMENT_TABLES.clone();
|
|
println!("enrichment data cleanup enrichment_tables: {enrichment_tables:?}");
|
|
assert!(!enrichment_tables.contains_key(&schema_key));
|
|
drop(enrichment_tables);
|
|
|
|
// Check the local disk cache to check if the table is deleted
|
|
check_enrichment_table_local_disk_cache_deleted(org_id, stream_name).await;
|
|
}
|
|
|
|
async fn e2e_save_enrichment_data_append_mode() {
|
|
let _auth = setup();
|
|
let org_id = "e2e";
|
|
let table_name = "test_enrichment_table_append";
|
|
|
|
// First, create initial data
|
|
let mut initial_payload = Vec::new();
|
|
let mut record1 = json::Map::new();
|
|
record1.insert("name".to_string(), json::Value::String("John".to_string()));
|
|
record1.insert("age".to_string(), json::Value::String("25".to_string()));
|
|
record1.insert(
|
|
"city".to_string(),
|
|
json::Value::String("New York".to_string()),
|
|
);
|
|
initial_payload.push(record1);
|
|
|
|
let result1 = enrichment_data::enrichment_table::save_enrichment_data(
|
|
org_id,
|
|
table_name,
|
|
initial_payload,
|
|
false, // append_data = false
|
|
)
|
|
.await;
|
|
assert!(result1.is_ok());
|
|
|
|
// Get the meta table stats for enrichment table
|
|
let meta_table_stats_first =
|
|
db::enrichment_table::get_meta_table_stats(org_id, table_name).await;
|
|
assert!(meta_table_stats_first.is_some());
|
|
let meta_table_stats_first = meta_table_stats_first.unwrap();
|
|
assert_ne!(meta_table_stats_first.size, 0);
|
|
assert_ne!(meta_table_stats_first.start_time, 0);
|
|
let schema_key = format!(
|
|
"{}/{}/{}",
|
|
org_id,
|
|
config::meta::stream::StreamType::EnrichmentTables,
|
|
table_name
|
|
);
|
|
|
|
// Wait for 2 seconds
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
|
|
|
// Check the ENRICHMENT_TABLES cache to check if the table is created
|
|
let enrichment_tables = ENRICHMENT_TABLES.clone();
|
|
assert!(enrichment_tables.contains_key(&schema_key));
|
|
assert!(enrichment_tables.get(&schema_key).unwrap().data.len() == 1);
|
|
drop(enrichment_tables);
|
|
|
|
println!(
|
|
"save enrichment data append mode check_enrichment_table_local_disk_cache meta_table_stats_first.start_time 1: {:?}",
|
|
meta_table_stats_first.start_time
|
|
);
|
|
// Check the local disk cache to check if the table is created
|
|
check_enrichment_table_local_disk_cache(
|
|
org_id,
|
|
table_name,
|
|
meta_table_stats_first.end_time,
|
|
)
|
|
.await;
|
|
|
|
// Now append more data
|
|
let mut append_payload = Vec::new();
|
|
let mut record2 = json::Map::new();
|
|
record2.insert("name".to_string(), json::Value::String("Jane".to_string()));
|
|
record2.insert("age".to_string(), json::Value::String("30".to_string()));
|
|
record2.insert(
|
|
"city".to_string(),
|
|
json::Value::String("Los Angeles".to_string()),
|
|
);
|
|
append_payload.push(record2);
|
|
|
|
let result2 = enrichment_data::enrichment_table::save_enrichment_data(
|
|
org_id,
|
|
table_name,
|
|
append_payload,
|
|
true, // append_data = true
|
|
)
|
|
.await;
|
|
assert!(result2.is_ok());
|
|
|
|
// Verify schema still exists and is valid
|
|
let schema_exists = schema::stream_schema_exists(
|
|
org_id,
|
|
table_name,
|
|
config::meta::stream::StreamType::EnrichmentTables,
|
|
&mut std::collections::HashMap::new(),
|
|
)
|
|
.await;
|
|
|
|
assert!(schema_exists.has_fields);
|
|
|
|
// Get the meta table stats for enrichment table
|
|
let meta_table_stats_second =
|
|
db::enrichment_table::get_meta_table_stats(org_id, table_name).await;
|
|
assert!(meta_table_stats_second.is_some());
|
|
let meta_table_stats_second = meta_table_stats_second.unwrap();
|
|
assert_ne!(meta_table_stats_second.size, 0);
|
|
assert_ne!(meta_table_stats_second.start_time, 0);
|
|
assert_eq!(
|
|
meta_table_stats_second.start_time,
|
|
meta_table_stats_first.start_time
|
|
);
|
|
assert!(meta_table_stats_second.end_time > meta_table_stats_first.end_time);
|
|
|
|
// Wait for 2 seconds
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
|
|
|
// Check the ENRICHMENT_TABLES cache to check if the table is created
|
|
let enrichment_tables = ENRICHMENT_TABLES.clone();
|
|
assert!(enrichment_tables.contains_key(&schema_key));
|
|
assert!(enrichment_tables.get(&schema_key).unwrap().data.len() == 2);
|
|
drop(enrichment_tables);
|
|
println!(
|
|
"save enrichment data append mode check_enrichment_table_local_disk_cache meta_table_stats_second.start_time 2: {:?}",
|
|
meta_table_stats_second.start_time
|
|
);
|
|
|
|
// Check the local disk cache to check if the table is created
|
|
check_enrichment_table_local_disk_cache(
|
|
org_id,
|
|
table_name,
|
|
meta_table_stats_second.end_time,
|
|
)
|
|
.await;
|
|
|
|
// Clean up
|
|
e2e_cleanup_enrichment_table(org_id, table_name).await;
|
|
}
|
|
|
|
async fn e2e_save_enrichment_data_schema_evolution() {
|
|
let _auth = setup();
|
|
let org_id = "e2e";
|
|
let table_name = "test_enrichment_table_evolution";
|
|
|
|
// First, create initial data with basic fields
|
|
let mut initial_payload = Vec::new();
|
|
let mut record1 = json::Map::new();
|
|
record1.insert("name".to_string(), json::Value::String("John".to_string()));
|
|
record1.insert("age".to_string(), json::Value::String("25".to_string()));
|
|
initial_payload.push(record1);
|
|
|
|
let result1 = enrichment_data::enrichment_table::save_enrichment_data(
|
|
org_id,
|
|
table_name,
|
|
initial_payload,
|
|
false, // append_data = false
|
|
)
|
|
.await;
|
|
assert!(result1.is_ok());
|
|
|
|
let schema_key = format!(
|
|
"{}/{}/{}",
|
|
org_id,
|
|
config::meta::stream::StreamType::EnrichmentTables,
|
|
table_name
|
|
);
|
|
|
|
// wait for 2 seconds
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
|
|
|
// Check the ENRICHMENT_TABLES cache to check if the table is created
|
|
let enrichment_tables = ENRICHMENT_TABLES.clone();
|
|
assert!(enrichment_tables.contains_key(&schema_key));
|
|
assert!(enrichment_tables.get(&schema_key).unwrap().data.len() == 1);
|
|
drop(enrichment_tables);
|
|
|
|
// Now append data with additional fields (schema evolution)
|
|
let mut append_payload = Vec::new();
|
|
let mut record2 = json::Map::new();
|
|
record2.insert("name".to_string(), json::Value::String("Jane".to_string()));
|
|
record2.insert("age".to_string(), json::Value::String("30".to_string()));
|
|
record2.insert(
|
|
"city".to_string(),
|
|
json::Value::String("Los Angeles".to_string()),
|
|
); // New field
|
|
record2.insert(
|
|
"country".to_string(),
|
|
json::Value::String("USA".to_string()),
|
|
); // New field
|
|
append_payload.push(record2);
|
|
|
|
let result2 = enrichment_data::enrichment_table::save_enrichment_data(
|
|
org_id,
|
|
table_name,
|
|
append_payload,
|
|
true, // append_data = true
|
|
)
|
|
.await;
|
|
assert!(result2.is_ok());
|
|
let result = result2.unwrap();
|
|
assert!(!result.status().is_success());
|
|
// wait for 2 seconds
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
|
|
|
// Clean up
|
|
e2e_cleanup_enrichment_table(org_id, table_name).await;
|
|
}
|
|
|
|
// Test runner function that calls all enrichment table tests
|
|
async fn test_enrichment_table_integration() {
|
|
e2e_save_enrichment_data_new_table().await;
|
|
e2e_save_enrichment_data_append_mode().await;
|
|
e2e_save_enrichment_data_schema_evolution().await;
|
|
}
|
|
|
|
async fn check_enrichment_table_local_disk_cache(
|
|
org_id: &str,
|
|
table_name: &str,
|
|
updated_at: i64,
|
|
) {
|
|
let key = get_key(org_id, table_name);
|
|
let table_dir = get_table_dir(&key);
|
|
let file_path = get_table_path(table_dir.to_str().unwrap(), updated_at);
|
|
assert!(file_path.exists());
|
|
}
|
|
|
|
async fn check_enrichment_table_local_disk_cache_deleted(org_id: &str, table_name: &str) {
|
|
let key = get_key(org_id, table_name);
|
|
let table_dir = get_table_dir(&key);
|
|
assert!(!table_dir.exists());
|
|
}
|
|
|
|
// Helper function to setup schema for enrichment tables in tests
|
|
async fn setup_enrichment_table_schema(org_id: &str, table_name: &str) {
|
|
use arrow::datatypes::{DataType, Field, Schema};
|
|
use config::meta::stream::StreamType;
|
|
|
|
// Create a simple schema that matches our test data
|
|
let schema = Schema::new(vec![
|
|
Field::new("id", DataType::Int64, true),
|
|
Field::new("name", DataType::Utf8, true),
|
|
Field::new("age", DataType::Int64, true),
|
|
Field::new("data", DataType::Utf8, true),
|
|
Field::new("version", DataType::Utf8, true),
|
|
Field::new("nested", DataType::Utf8, true),
|
|
Field::new("special_chars", DataType::Utf8, true),
|
|
]);
|
|
|
|
// Use infra::schema::merge to create/update the schema in the database
|
|
let result = infra::schema::merge(
|
|
org_id,
|
|
table_name,
|
|
StreamType::EnrichmentTables,
|
|
&schema,
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
// Ignore error if schema already exists
|
|
match result {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
log::debug!("Schema setup for {}/{}: {:?}", org_id, table_name, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn test_enrichment_table_local_all_sequential() {
|
|
// Run all tests sequentially to avoid directory conflicts
|
|
test_store_and_retrieve().await;
|
|
test_store_multiple_versions().await;
|
|
test_retrieve_nonexistent_table().await;
|
|
test_delete().await;
|
|
test_delete_nonexistent_table().await;
|
|
test_get_last_updated_at().await;
|
|
test_store_data_if_needed().await;
|
|
test_store_data_if_needed_background().await;
|
|
test_metadata_persistence().await;
|
|
test_large_data_handling().await;
|
|
test_error_handling().await;
|
|
}
|
|
|
|
async fn test_store_and_retrieve() {
|
|
let org_id = "test_org";
|
|
let table_name = "test_table";
|
|
let test_data = vec![
|
|
json!({"id": 1, "name": "Alice", "age": 25}),
|
|
json!({"id": 2, "name": "Bob", "age": 30}),
|
|
];
|
|
let updated_at = 1640995200;
|
|
|
|
// Setup schema before storing data
|
|
setup_enrichment_table_schema(org_id, table_name).await;
|
|
|
|
// Test store function
|
|
let test_data = Values::Json(Arc::new(test_data));
|
|
let result = local::store(org_id, table_name, test_data, updated_at).await;
|
|
println!("Store result: {result:?}");
|
|
assert!(result.is_ok(), "Store should succeed");
|
|
|
|
// Verify file was created
|
|
let key = get_key(org_id, table_name);
|
|
let table_dir = get_table_dir(&key);
|
|
let file_path = get_table_path(table_dir.to_str().unwrap(), updated_at);
|
|
assert!(file_path.exists(), "Data file should exist");
|
|
|
|
// Verify metadata was created
|
|
let metadata_path = get_metadata_path();
|
|
assert!(metadata_path.exists(), "Metadata file should exist");
|
|
|
|
// Test retrieve function
|
|
let retrieved_data = local::retrieve(org_id, table_name).await;
|
|
assert!(retrieved_data.is_ok(), "Retrieve should succeed");
|
|
|
|
let retrieved_data = retrieved_data.unwrap().to_json().unwrap();
|
|
assert_eq!(retrieved_data.len(), 2, "Should retrieve 2 records");
|
|
assert_eq!(
|
|
retrieved_data[0]["id"],
|
|
json!(1),
|
|
"First record should match"
|
|
);
|
|
assert_eq!(
|
|
retrieved_data[1]["id"],
|
|
json!(2),
|
|
"Second record should match"
|
|
);
|
|
}
|
|
|
|
async fn test_store_multiple_versions() {
|
|
let org_id = "test_org";
|
|
let table_name = "versioned_table";
|
|
|
|
// Setup schema before storing data
|
|
setup_enrichment_table_schema(org_id, table_name).await;
|
|
|
|
// Store first version
|
|
let data_v1 = Values::Json(Arc::new(vec![json!({"id": 1, "version": "v1"})]));
|
|
let updated_at_v1 = 1640995200;
|
|
let result = local::store(org_id, table_name, data_v1, updated_at_v1).await;
|
|
assert!(result.is_ok(), "First store should succeed");
|
|
|
|
// Store second version
|
|
let data_v2 = Values::Json(Arc::new(vec![json!({"id": 2, "version": "v2"})]));
|
|
let updated_at_v2 = 1640995300;
|
|
let result = local::store(org_id, table_name, data_v2, updated_at_v2).await;
|
|
assert!(result.is_ok(), "Second store should succeed");
|
|
|
|
// Retrieve should get both versions
|
|
let retrieved_data = local::retrieve(org_id, table_name).await.unwrap();
|
|
assert_eq!(retrieved_data.len(), 2, "Should retrieve both versions");
|
|
}
|
|
|
|
async fn test_retrieve_nonexistent_table() {
|
|
let result = local::retrieve("nonexistent_org", "nonexistent_table").await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Should return an error for nonexistent table"
|
|
);
|
|
}
|
|
|
|
async fn test_delete() {
|
|
let org_id = "test_org";
|
|
let table_name = "delete_test_table";
|
|
let test_data = Values::Json(Arc::new(vec![json!({"id": 1, "data": "test"})]));
|
|
let updated_at = 1640995200;
|
|
|
|
// Setup schema before storing data
|
|
setup_enrichment_table_schema(org_id, table_name).await;
|
|
|
|
// First store some data
|
|
let result = local::store(org_id, table_name, test_data, updated_at).await;
|
|
assert!(result.is_ok(), "Store should succeed");
|
|
|
|
// Verify data exists
|
|
let key = get_key(org_id, table_name);
|
|
let table_dir = get_table_dir(&key);
|
|
assert!(table_dir.exists(), "Table directory should exist");
|
|
|
|
// Test delete function
|
|
let result = local::delete(org_id, table_name).await;
|
|
assert!(result.is_ok(), "Delete should succeed");
|
|
|
|
// Verify directory was removed
|
|
assert!(!table_dir.exists(), "Table directory should be removed");
|
|
|
|
// Verify metadata was updated
|
|
let metadata_content = get_metadata_content().await.unwrap();
|
|
assert!(
|
|
!metadata_content.contains_key(&key),
|
|
"Key should be removed from metadata"
|
|
);
|
|
}
|
|
|
|
async fn test_delete_nonexistent_table() {
|
|
// Delete non-existent table should succeed gracefully
|
|
let result = local::delete("nonexistent_org", "nonexistent_table").await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"Delete should handle nonexistent table gracefully"
|
|
);
|
|
}
|
|
|
|
async fn test_get_last_updated_at() {
|
|
let org_id = "test_org";
|
|
let table_name = "timestamp_test_table";
|
|
let test_data = Values::Json(Arc::new(vec![json!({"id": 1, "data": "test"})]));
|
|
let updated_at = 1640995200;
|
|
|
|
// Test getting timestamp before storing data
|
|
let result = local::get_last_updated_at(org_id, table_name).await;
|
|
assert!(result.is_ok(), "Get last updated should succeed");
|
|
assert_eq!(result.unwrap(), 0, "Should return 0 for non-existent table");
|
|
|
|
// Setup schema before storing data
|
|
setup_enrichment_table_schema(org_id, table_name).await;
|
|
|
|
// Store data
|
|
local::store(org_id, table_name, test_data, updated_at)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Test getting timestamp after storing data
|
|
let result = local::get_last_updated_at(org_id, table_name).await;
|
|
assert!(result.is_ok(), "Get last updated should succeed");
|
|
assert_eq!(
|
|
result.unwrap(),
|
|
updated_at,
|
|
"Should return correct timestamp"
|
|
);
|
|
}
|
|
|
|
async fn test_store_data_if_needed() {
|
|
let org_id = "test_org";
|
|
let table_name = "conditional_test_table";
|
|
let test_data_v1 = Values::Json(Arc::new(vec![json!({"id": 1, "version": "v1"})]));
|
|
let test_data_v2 = Values::Json(Arc::new(vec![json!({"id": 1, "version": "v2"})]));
|
|
let updated_at_v1 = 1640995200;
|
|
let updated_at_v2 = 1640995300;
|
|
|
|
// Setup schema before storing data
|
|
setup_enrichment_table_schema(org_id, table_name).await;
|
|
|
|
// Store initial data
|
|
let result =
|
|
local::store_data_if_needed(org_id, table_name, test_data_v1, updated_at_v1).await;
|
|
assert!(result.is_ok(), "Initial store should succeed");
|
|
|
|
// Verify data was stored
|
|
let retrieved_data = local::retrieve(org_id, table_name)
|
|
.await
|
|
.unwrap()
|
|
.to_json()
|
|
.unwrap();
|
|
assert_eq!(
|
|
retrieved_data[0]["version"],
|
|
json!("v1"),
|
|
"Should have v1 data"
|
|
);
|
|
|
|
// Try to store older data (should be ignored)
|
|
let old_updated_at = 1640995100;
|
|
let result =
|
|
local::store_data_if_needed(org_id, table_name, test_data_v2.clone(), old_updated_at)
|
|
.await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"Store with old timestamp should succeed but not update"
|
|
);
|
|
|
|
// Verify data wasn't changed
|
|
let retrieved_data = local::retrieve(org_id, table_name)
|
|
.await
|
|
.unwrap()
|
|
.to_json()
|
|
.unwrap();
|
|
assert_eq!(
|
|
retrieved_data[0]["version"],
|
|
json!("v1"),
|
|
"Should still have v1 data"
|
|
);
|
|
|
|
// Store newer data (should update)
|
|
let result =
|
|
local::store_data_if_needed(org_id, table_name, test_data_v2, updated_at_v2).await;
|
|
assert!(result.is_ok(), "Store with newer timestamp should succeed");
|
|
|
|
// Verify data was updated
|
|
let retrieved_data = local::retrieve(org_id, table_name)
|
|
.await
|
|
.unwrap()
|
|
.to_json()
|
|
.unwrap();
|
|
assert_eq!(
|
|
retrieved_data[0]["version"],
|
|
json!("v2"),
|
|
"Should now have v2 data"
|
|
);
|
|
}
|
|
|
|
async fn test_store_data_if_needed_background() {
|
|
let org_id = "test_org";
|
|
let table_name = "background_test_table";
|
|
let test_data = Values::Json(Arc::new(vec![json!({"id": 1, "data": "background"})]));
|
|
let updated_at = 1640995200;
|
|
|
|
// Setup schema before storing data
|
|
setup_enrichment_table_schema(org_id, table_name).await;
|
|
|
|
// Test background store
|
|
let result =
|
|
local::store_data_if_needed_background(org_id, table_name, test_data, updated_at).await;
|
|
assert!(result.is_ok(), "Background store should succeed");
|
|
|
|
// Wait a bit for background task to complete
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
|
|
|
// Verify data was eventually stored
|
|
let retrieved_data = local::retrieve(org_id, table_name)
|
|
.await
|
|
.unwrap()
|
|
.to_json()
|
|
.unwrap();
|
|
assert_eq!(retrieved_data.len(), 1, "Should have stored 1 record");
|
|
assert_eq!(
|
|
retrieved_data[0]["data"],
|
|
json!("background"),
|
|
"Should have correct data"
|
|
);
|
|
}
|
|
|
|
async fn test_metadata_persistence() {
|
|
let org_id = "test_org";
|
|
let table_name = "metadata_test_table";
|
|
let test_data = Values::Json(Arc::new(vec![json!({"id": 1, "data": "test"})]));
|
|
let updated_at = 1640995200;
|
|
|
|
// Setup schema before storing data
|
|
setup_enrichment_table_schema(org_id, table_name).await;
|
|
|
|
// Store data
|
|
local::store(org_id, table_name, test_data, updated_at)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Manually read metadata file
|
|
let metadata_path = get_metadata_path();
|
|
let metadata_content = tokio::fs::read_to_string(&metadata_path).await.unwrap();
|
|
let metadata: std::collections::HashMap<String, i64> =
|
|
serde_json::from_str(&metadata_content).unwrap();
|
|
|
|
let key = get_key(org_id, table_name);
|
|
assert!(
|
|
metadata.contains_key(&key),
|
|
"Metadata should contain the key"
|
|
);
|
|
assert_eq!(
|
|
metadata[&key], updated_at,
|
|
"Metadata should have correct timestamp"
|
|
);
|
|
}
|
|
|
|
async fn test_large_data_handling() {
|
|
let org_id = "test_org";
|
|
let table_name = "large_data_table";
|
|
|
|
// Setup schema before storing data
|
|
setup_enrichment_table_schema(org_id, table_name).await;
|
|
|
|
// Create large dataset (1000 records)
|
|
let large_data: Vec<_> = (0..1000)
|
|
.map(|i| {
|
|
json!({
|
|
"id": i,
|
|
"name": format!("name_{}", i),
|
|
"data": "x".repeat(100), // 100 characters per record
|
|
"nested": {
|
|
"field1": i * 2,
|
|
"field2": format!("nested_value_{}", i)
|
|
}
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let updated_at = 1640995200;
|
|
|
|
// Test storing large data
|
|
let large_data = Values::Json(Arc::new(large_data));
|
|
let result = local::store(org_id, table_name, large_data, updated_at).await;
|
|
assert!(result.is_ok(), "Should handle large data successfully");
|
|
|
|
// Test retrieving large data
|
|
let retrieved_data = local::retrieve(org_id, table_name)
|
|
.await
|
|
.unwrap()
|
|
.to_json()
|
|
.unwrap();
|
|
assert_eq!(
|
|
retrieved_data.len(),
|
|
1000,
|
|
"Should retrieve all 1000 records"
|
|
);
|
|
assert_eq!(
|
|
retrieved_data[0]["id"],
|
|
json!(0),
|
|
"First record should be correct"
|
|
);
|
|
assert_eq!(
|
|
retrieved_data[999]["id"],
|
|
json!(999),
|
|
"Last record should be correct"
|
|
);
|
|
}
|
|
|
|
async fn test_error_handling() {
|
|
// Test with invalid JSON data
|
|
let org_id = "test_org";
|
|
let table_name = "error_test_table";
|
|
|
|
// Setup schema before storing data
|
|
setup_enrichment_table_schema(org_id, table_name).await;
|
|
|
|
// Create data that will serialize fine but test edge cases
|
|
let test_data = Values::Json(Arc::new(vec![
|
|
json!({"id": 1, "data": null}),
|
|
json!({"id": 2, "special_chars": "!@#$%^&*()"}),
|
|
]));
|
|
let updated_at = 1640995200;
|
|
|
|
let result = local::store(org_id, table_name, test_data, updated_at).await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"Should handle special characters and null values"
|
|
);
|
|
|
|
let retrieved_data = local::retrieve(org_id, table_name)
|
|
.await
|
|
.unwrap()
|
|
.to_json()
|
|
.unwrap();
|
|
assert_eq!(retrieved_data.len(), 2, "Should retrieve both records");
|
|
assert!(
|
|
retrieved_data[0]["data"].is_null(),
|
|
"Should preserve null values"
|
|
);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Backfill Jobs Integration Tests
|
|
// ========================================================================
|
|
|
|
async fn test_backfill_job_list_and_delete() {
|
|
use openobserve_core::alerts::backfill::{delete_backfill_job, list_backfill_jobs};
|
|
|
|
// Test listing backfill jobs
|
|
let org_id = "e2e";
|
|
let jobs_result = list_backfill_jobs(org_id).await;
|
|
assert!(
|
|
jobs_result.is_ok(),
|
|
"Should successfully list backfill jobs"
|
|
);
|
|
|
|
let jobs = jobs_result.unwrap();
|
|
// If there are any jobs, test delete functionality
|
|
if let Some(first_job) = jobs.first() {
|
|
let job_id = first_job.job_id.clone();
|
|
let delete_result = delete_backfill_job(org_id, &job_id).await;
|
|
// Delete may fail if job is in progress, which is acceptable
|
|
match delete_result {
|
|
Ok(_) => log::info!("Successfully deleted backfill job: {}", job_id),
|
|
Err(e) => log::warn!("Could not delete backfill job (may be in progress): {}", e),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn test_backfill_job_get_nonexistent() {
|
|
use openobserve_core::alerts::backfill::get_backfill_job;
|
|
|
|
// Test getting a non-existent job
|
|
let org_id = "e2e";
|
|
let fake_job_id = "nonexistent_job_12345";
|
|
let result = get_backfill_job(org_id, fake_job_id).await;
|
|
assert!(result.is_err(), "Should fail to get non-existent job");
|
|
}
|
|
|
|
async fn test_backfill_job_delete_by_pipeline() {
|
|
use openobserve_core::alerts::backfill::delete_backfill_jobs_by_pipeline;
|
|
|
|
// Test deleting jobs by pipeline
|
|
let org_id = "e2e";
|
|
let pipeline_id = "test_nonexistent_pipeline";
|
|
|
|
// Should succeed even if no jobs exist for this pipeline
|
|
let result = delete_backfill_jobs_by_pipeline(org_id, pipeline_id).await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"Should successfully delete jobs by pipeline (even if none exist)"
|
|
);
|
|
}
|
|
|
|
async fn test_backfill_job_enable_disable() {
|
|
use openobserve_core::alerts::backfill::{enable_backfill_job, list_backfill_jobs};
|
|
|
|
// Test enable/disable on existing jobs
|
|
let org_id = "e2e";
|
|
let jobs_result = list_backfill_jobs(org_id).await;
|
|
|
|
if let Ok(jobs) = jobs_result
|
|
&& let Some(first_job) = jobs.first()
|
|
{
|
|
let job_id = first_job.job_id.clone();
|
|
|
|
// Try to disable
|
|
let disable_result = enable_backfill_job(org_id, &job_id, false).await;
|
|
match disable_result {
|
|
Ok(_) => {
|
|
log::info!("Successfully disabled backfill job: {}", job_id);
|
|
|
|
// Try to enable back
|
|
let enable_result = enable_backfill_job(org_id, &job_id, true).await;
|
|
if enable_result.is_ok() {
|
|
log::info!("Successfully re-enabled backfill job: {}", job_id);
|
|
}
|
|
}
|
|
Err(e) => log::warn!("Could not modify job state: {}", e),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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_core::org_cleanup::DrainPass, anyhow::Error> {
|
|
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<dyn StreamDataCleanup> = 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:?}"
|
|
);
|
|
}
|
|
}
|