mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
The partial-preface test raced a 5ms first-request deadline against a 10ms hyper header_read_timeout. On a slow runner both timers expire before the next poll and tokio::select! may pick the connection branch, surfacing hyper's header-timeout error instead of the clean deadline close. Push hyper's timeout out to 30s so only the deadline can fire. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
5099 lines
175 KiB
Rust
5099 lines
175 KiB
Rust
#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]
|
|
#[global_allocator]
|
|
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
|
|
|
|
use std::fs;
|
|
#[cfg(unix)]
|
|
use std::io::Write;
|
|
use std::io::{self, Read};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
use axum::{body::Body, extract::Request};
|
|
use clap::{Args as ClapArgs, Parser, Subcommand, ValueEnum};
|
|
use hyper::body::Incoming;
|
|
use hyper_util::{
|
|
rt::{TokioExecutor, TokioIo, TokioTimer},
|
|
server::conn::auto::Builder as HyperServerBuilder,
|
|
service::TowerToHyperService,
|
|
};
|
|
use tokio_util::sync::CancellationToken;
|
|
use tower::{Service as _, ServiceExt as _};
|
|
use tracing::{debug, info, warn};
|
|
|
|
/// Coordinates the connection-level deadline that covers protocol detection and
|
|
/// the first request header block. Hyper's HTTP/1 timer starts only after the
|
|
/// auto protocol detector has finished, while HTTP/2 has no equivalent header
|
|
/// timer. Keeping this gate outside the parser closes that initial gap without
|
|
/// imposing a deadline on request or response bodies.
|
|
#[derive(Clone)]
|
|
struct GatewayFirstRequestGate {
|
|
seen: Arc<AtomicBool>,
|
|
notify: Arc<tokio::sync::Notify>,
|
|
}
|
|
|
|
impl GatewayFirstRequestGate {
|
|
fn new() -> Self {
|
|
Self {
|
|
seen: Arc::new(AtomicBool::new(false)),
|
|
notify: Arc::new(tokio::sync::Notify::new()),
|
|
}
|
|
}
|
|
|
|
fn mark_seen(&self) {
|
|
if !self.seen.swap(true, Ordering::Release) {
|
|
self.notify.notify_one();
|
|
}
|
|
}
|
|
|
|
fn is_seen(&self) -> bool {
|
|
self.seen.load(Ordering::Acquire)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct GatewayFirstRequestService<S> {
|
|
inner: S,
|
|
gate: GatewayFirstRequestGate,
|
|
}
|
|
|
|
impl<S, Request> tower::Service<Request> for GatewayFirstRequestService<S>
|
|
where
|
|
S: tower::Service<Request>,
|
|
{
|
|
type Response = S::Response;
|
|
type Error = S::Error;
|
|
type Future = S::Future;
|
|
|
|
fn poll_ready(
|
|
&mut self,
|
|
cx: &mut std::task::Context<'_>,
|
|
) -> std::task::Poll<Result<(), Self::Error>> {
|
|
self.inner.poll_ready(cx)
|
|
}
|
|
|
|
fn call(&mut self, request: Request) -> Self::Future {
|
|
self.gate.mark_seen();
|
|
self.inner.call(request)
|
|
}
|
|
}
|
|
|
|
/// Drive one Hyper connection while enforcing the deadline for its first
|
|
/// request. This is kept generic so the timeout behavior can be regression
|
|
/// tested independently from the listener and application state.
|
|
async fn drive_gateway_connection<F, E>(
|
|
connection: F,
|
|
first_request_gate: GatewayFirstRequestGate,
|
|
first_request_timeout: std::time::Duration,
|
|
) -> Result<(), E>
|
|
where
|
|
F: std::future::Future<Output = Result<(), E>>,
|
|
{
|
|
if first_request_gate.is_seen() {
|
|
return connection.await;
|
|
}
|
|
|
|
let mut connection = Box::pin(connection);
|
|
let first_request_timeout = tokio::time::sleep(first_request_timeout);
|
|
tokio::pin!(first_request_timeout);
|
|
let first_request_notified = first_request_gate.notify.notified();
|
|
tokio::pin!(first_request_notified);
|
|
|
|
tokio::select! {
|
|
result = &mut connection => result,
|
|
_ = &mut first_request_timeout => {
|
|
if first_request_gate.is_seen() {
|
|
(&mut connection).await
|
|
} else {
|
|
tracing::debug!(
|
|
"gateway connection closed before the first request header completed"
|
|
);
|
|
Ok(())
|
|
}
|
|
}
|
|
_ = &mut first_request_notified => (&mut connection).await,
|
|
}
|
|
}
|
|
|
|
use aether_crypto::warm_python_fernet_secret;
|
|
use aether_data::lifecycle::export::{
|
|
copy_database_records, export_database_jsonl, import_database_jsonl_with_options,
|
|
DataCopyOptions, DataImportOptions, ExportDomain, MAX_JSONL_INPUT_BYTES,
|
|
};
|
|
use aether_data::{DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig};
|
|
use aether_gateway::{
|
|
attach_static_frontend, build_router_with_state,
|
|
prewarm_direct_h2c_sender_cache_from_env_for_startup, set_gateway_frontdoor_app_port, AppState,
|
|
FrontdoorCorsConfig, FrontdoorUserRpmConfig, GatewayDataConfig, UsageRuntimeConfig,
|
|
VideoTaskTruthSourceMode,
|
|
};
|
|
use aether_gateway_frontdoor::{http_connection_limit, HttpConnectionBudget};
|
|
use aether_runtime::{
|
|
init_service_runtime, FileLoggingConfig, LogDestination, LogFormat, LogRotation,
|
|
ServiceRuntimeConfig,
|
|
};
|
|
use aether_runtime_state::{
|
|
RedisClientConfig, RuntimeSemaphoreConfig, RuntimeState, RuntimeStateBackendMode,
|
|
RuntimeStateConfig,
|
|
};
|
|
|
|
const MIN_GATEWAY_DATA_ENCRYPTION_KEY_BYTES: usize = 32;
|
|
const INSECURE_GATEWAY_DATA_ENCRYPTION_KEYS: &[&str] = &[
|
|
"change-this-to-another-secure-random-string",
|
|
"change-this-to-a-secure-random-string",
|
|
"dev-encryption-key-do-not-use-in-production",
|
|
];
|
|
|
|
fn validate_gateway_data_encryption_key(value: Option<&str>) -> Result<(), &'static str> {
|
|
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
return Ok(());
|
|
};
|
|
if value.len() < MIN_GATEWAY_DATA_ENCRYPTION_KEY_BYTES {
|
|
return Err("gateway data encryption key must contain at least 32 bytes");
|
|
}
|
|
if INSECURE_GATEWAY_DATA_ENCRYPTION_KEYS.contains(&value) {
|
|
return Err(
|
|
"gateway data encryption key must not use a published example or development value",
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
|
enum VideoTaskTruthSourceArg {
|
|
PythonSyncReport,
|
|
RustAuthoritative,
|
|
}
|
|
|
|
impl From<VideoTaskTruthSourceArg> for VideoTaskTruthSourceMode {
|
|
fn from(value: VideoTaskTruthSourceArg) -> Self {
|
|
match value {
|
|
VideoTaskTruthSourceArg::PythonSyncReport => VideoTaskTruthSourceMode::PythonSyncReport,
|
|
VideoTaskTruthSourceArg::RustAuthoritative => {
|
|
VideoTaskTruthSourceMode::RustAuthoritative
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
|
enum DeploymentTopologyArg {
|
|
SingleNode,
|
|
MultiNode,
|
|
}
|
|
|
|
impl DeploymentTopologyArg {
|
|
const fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::SingleNode => "single-node",
|
|
Self::MultiNode => "multi-node",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
|
enum DatabaseDriverArg {
|
|
Postgres,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
|
enum DatabaseModeArg {
|
|
Auto,
|
|
VerifyOnly,
|
|
}
|
|
|
|
fn resolve_database_mode(
|
|
configured: Option<DatabaseModeArg>,
|
|
legacy_auto_prepare: Option<bool>,
|
|
) -> DatabaseModeArg {
|
|
if let Some(configured) = configured {
|
|
return configured;
|
|
}
|
|
if let Some(legacy_auto_prepare) = legacy_auto_prepare {
|
|
return if legacy_auto_prepare {
|
|
DatabaseModeArg::Auto
|
|
} else {
|
|
DatabaseModeArg::VerifyOnly
|
|
};
|
|
}
|
|
DatabaseModeArg::Auto
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
|
enum ExportDomainArg {
|
|
Users,
|
|
ApiKeys,
|
|
Providers,
|
|
ProviderKeys,
|
|
Endpoints,
|
|
Models,
|
|
GlobalModels,
|
|
AuthModules,
|
|
OAuthProviders,
|
|
UserOAuthLinks,
|
|
UserGroups,
|
|
UserGroupMembers,
|
|
ProxyNodes,
|
|
SystemConfigs,
|
|
Wallets,
|
|
Usage,
|
|
Billing,
|
|
Auxiliary,
|
|
}
|
|
|
|
impl From<ExportDomainArg> for ExportDomain {
|
|
fn from(value: ExportDomainArg) -> Self {
|
|
match value {
|
|
ExportDomainArg::Users => ExportDomain::Users,
|
|
ExportDomainArg::ApiKeys => ExportDomain::ApiKeys,
|
|
ExportDomainArg::Providers => ExportDomain::Providers,
|
|
ExportDomainArg::ProviderKeys => ExportDomain::ProviderKeys,
|
|
ExportDomainArg::Endpoints => ExportDomain::Endpoints,
|
|
ExportDomainArg::Models => ExportDomain::Models,
|
|
ExportDomainArg::GlobalModels => ExportDomain::GlobalModels,
|
|
ExportDomainArg::AuthModules => ExportDomain::AuthModules,
|
|
ExportDomainArg::OAuthProviders => ExportDomain::OAuthProviders,
|
|
ExportDomainArg::UserOAuthLinks => ExportDomain::UserOAuthLinks,
|
|
ExportDomainArg::UserGroups => ExportDomain::UserGroups,
|
|
ExportDomainArg::UserGroupMembers => ExportDomain::UserGroupMembers,
|
|
ExportDomainArg::ProxyNodes => ExportDomain::ProxyNodes,
|
|
ExportDomainArg::SystemConfigs => ExportDomain::SystemConfigs,
|
|
ExportDomainArg::Wallets => ExportDomain::Wallets,
|
|
ExportDomainArg::Usage => ExportDomain::Usage,
|
|
ExportDomainArg::Billing => ExportDomain::Billing,
|
|
ExportDomainArg::Auxiliary => ExportDomain::Auxiliary,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<DatabaseDriverArg> for DatabaseDriver {
|
|
fn from(value: DatabaseDriverArg) -> Self {
|
|
match value {
|
|
DatabaseDriverArg::Postgres => DatabaseDriver::Postgres,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
|
enum NodeRoleArg {
|
|
All,
|
|
Frontdoor,
|
|
Background,
|
|
}
|
|
|
|
impl NodeRoleArg {
|
|
const fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::All => "all",
|
|
Self::Frontdoor => "frontdoor",
|
|
Self::Background => "background",
|
|
}
|
|
}
|
|
|
|
const fn spawns_background_tasks(self) -> bool {
|
|
matches!(self, Self::All | Self::Background)
|
|
}
|
|
|
|
const fn isolates_background_database(self) -> bool {
|
|
matches!(self, Self::All)
|
|
}
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
|
enum RuntimeBackendArg {
|
|
Auto,
|
|
Redis,
|
|
Memory,
|
|
}
|
|
|
|
impl RuntimeBackendArg {
|
|
const fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Auto => "auto",
|
|
Self::Redis => "redis",
|
|
Self::Memory => "memory",
|
|
}
|
|
}
|
|
|
|
const fn to_runtime_state_backend(self) -> RuntimeStateBackendMode {
|
|
match self {
|
|
Self::Auto => RuntimeStateBackendMode::Auto,
|
|
Self::Redis => RuntimeStateBackendMode::Redis,
|
|
Self::Memory => RuntimeStateBackendMode::Memory,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
|
enum GatewayLogFormatArg {
|
|
Pretty,
|
|
Json,
|
|
}
|
|
|
|
impl From<GatewayLogFormatArg> for LogFormat {
|
|
fn from(value: GatewayLogFormatArg) -> Self {
|
|
match value {
|
|
GatewayLogFormatArg::Pretty => LogFormat::Pretty,
|
|
GatewayLogFormatArg::Json => LogFormat::Json,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
|
enum GatewayLogDestinationArg {
|
|
Stdout,
|
|
File,
|
|
Both,
|
|
}
|
|
|
|
impl GatewayLogDestinationArg {
|
|
const fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Stdout => "stdout",
|
|
Self::File => "file",
|
|
Self::Both => "both",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<GatewayLogDestinationArg> for LogDestination {
|
|
fn from(value: GatewayLogDestinationArg) -> Self {
|
|
match value {
|
|
GatewayLogDestinationArg::Stdout => LogDestination::Stdout,
|
|
GatewayLogDestinationArg::File => LogDestination::File,
|
|
GatewayLogDestinationArg::Both => LogDestination::Both,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
|
enum GatewayLogRotationArg {
|
|
Hourly,
|
|
Daily,
|
|
}
|
|
|
|
impl GatewayLogRotationArg {
|
|
const fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Hourly => "hourly",
|
|
Self::Daily => "daily",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<GatewayLogRotationArg> for LogRotation {
|
|
fn from(value: GatewayLogRotationArg) -> Self {
|
|
match value {
|
|
GatewayLogRotationArg::Hourly => LogRotation::Hourly,
|
|
GatewayLogRotationArg::Daily => LogRotation::Daily,
|
|
}
|
|
}
|
|
}
|
|
|
|
const GATEWAY_TOKIO_WORKER_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
|
|
const DEFAULT_SQL_POOL_ACQUIRE_TIMEOUT_MS: u64 = 10_000;
|
|
const DEFAULT_SQL_POOL_IDLE_TIMEOUT_MS: u64 = 30_000;
|
|
const DEFAULT_SQL_POOL_MAX_LIFETIME_MS: u64 = 30 * 60_000;
|
|
const DEFAULT_SQL_POOL_STATEMENT_CACHE_CAPACITY: usize = 100;
|
|
// Per-process default for server SQL backends. Keep this below common
|
|
// database server max_connections defaults; operators can override with
|
|
// AETHER_GATEWAY_DATA_POSTGRES_{MIN,MAX}_CONNECTIONS after sizing the DB.
|
|
const AUTO_SERVER_SQL_POOL_CONNECTIONS_PER_CPU: u32 = 4;
|
|
const AUTO_SERVER_SQL_POOL_MIN_CONNECTIONS_FLOOR: u32 = 4;
|
|
const AUTO_SERVER_SQL_POOL_MIN_CONNECTIONS_CAP: u32 = 16;
|
|
const AUTO_SERVER_SQL_POOL_MAX_CONNECTIONS_FLOOR: u32 = 32;
|
|
const AUTO_SERVER_SQL_POOL_MAX_CONNECTIONS_CAP: u32 = 100;
|
|
const DEFAULT_USAGE_QUEUE_WORKERS_CAP: usize = 8;
|
|
const AUTO_USAGE_QUEUE_WORKERS_MIN: usize = 2;
|
|
const AUTO_USAGE_QUEUE_WORKERS_REQUESTS_PER_WORKER: usize = 128;
|
|
const AUTO_USAGE_QUEUE_WORKERS_DB_SHARE_ALL: usize = 4;
|
|
const AUTO_USAGE_QUEUE_WORKERS_DB_SHARE_BACKGROUND: usize = 2;
|
|
const AUTO_USAGE_WORKER_RECORD_DB_SHARE_ALL: usize = 8;
|
|
const AUTO_USAGE_WORKER_RECORD_DB_SHARE_BACKGROUND: usize = 4;
|
|
const MAX_USAGE_QUEUE_WORKERS: usize = 64;
|
|
const DEFAULT_GATEWAY_LISTEN_BACKLOG: i32 = 65_535;
|
|
const MIN_GATEWAY_LISTEN_BACKLOG: i32 = 128;
|
|
const MAX_GATEWAY_LISTEN_BACKLOG: i32 = 65_535;
|
|
const DEFAULT_GATEWAY_LISTENER_SHARDS: usize = 0;
|
|
const MAX_GATEWAY_LISTENER_SHARDS: usize = 64;
|
|
const DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS: u32 = 16_384;
|
|
const MIN_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS: u32 = 200;
|
|
const MAX_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS: u32 = 1_000_000;
|
|
// These limits protect the connection parser from slow-header and header-bomb
|
|
// attacks. They apply to request metadata only and do not cap body size or
|
|
// HTTP/2 stream concurrency.
|
|
const DEFAULT_GATEWAY_HTTP_HEADER_READ_TIMEOUT_MS: u64 = 30_000;
|
|
const MIN_GATEWAY_HTTP_HEADER_READ_TIMEOUT_MS: u64 = 1_000;
|
|
const MAX_GATEWAY_HTTP_HEADER_READ_TIMEOUT_MS: u64 = 300_000;
|
|
const DEFAULT_GATEWAY_HTTP_HEADER_MAX_BYTES: usize = 64 * 1024;
|
|
const MIN_GATEWAY_HTTP_HEADER_MAX_BYTES: usize = 8 * 1024;
|
|
const MAX_GATEWAY_HTTP_HEADER_MAX_BYTES: usize = 16 * 1024 * 1024;
|
|
const DEFAULT_GATEWAY_HTTP_MAX_HEADERS: usize = 256;
|
|
const MIN_GATEWAY_HTTP_MAX_HEADERS: usize = 16;
|
|
const MAX_GATEWAY_HTTP_MAX_HEADERS: usize = 4_096;
|
|
const AUTO_GATEWAY_REQUESTS_PER_CPU: usize = 1_024;
|
|
const MIN_AUTO_GATEWAY_REQUEST_CONCURRENCY: usize = 512;
|
|
const MAX_AUTO_GATEWAY_REQUEST_CONCURRENCY: usize = 65_536;
|
|
const AUTO_GATEWAY_REQUEST_FD_DIVISOR: usize = 2;
|
|
const AUTO_GATEWAY_REQUEST_FD_RESERVE: usize = 256;
|
|
fn env_var_trimmed(name: &str) -> Option<String> {
|
|
std::env::var(name)
|
|
.ok()
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
fn available_parallelism_u32() -> u32 {
|
|
u32::try_from(available_parallelism_usize())
|
|
.unwrap_or(u32::MAX)
|
|
.max(1)
|
|
}
|
|
|
|
fn available_parallelism_usize() -> usize {
|
|
std::thread::available_parallelism()
|
|
.map(|value| value.get())
|
|
.unwrap_or(AUTO_SERVER_SQL_POOL_MIN_CONNECTIONS_FLOOR as usize)
|
|
.max(1)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn automatic_gateway_request_concurrency_for_parallelism(parallelism: usize) -> usize {
|
|
automatic_gateway_request_concurrency_for_capacity(parallelism, None)
|
|
}
|
|
|
|
fn automatic_gateway_request_concurrency_for_capacity(
|
|
parallelism: usize,
|
|
fd_soft_limit: Option<usize>,
|
|
) -> usize {
|
|
let cpu_limit = parallelism
|
|
.max(1)
|
|
.saturating_mul(AUTO_GATEWAY_REQUESTS_PER_CPU)
|
|
.clamp(
|
|
MIN_AUTO_GATEWAY_REQUEST_CONCURRENCY,
|
|
MAX_AUTO_GATEWAY_REQUEST_CONCURRENCY,
|
|
);
|
|
let fd_limit = fd_soft_limit
|
|
.map(|limit| {
|
|
limit
|
|
.saturating_sub(AUTO_GATEWAY_REQUEST_FD_RESERVE)
|
|
.checked_div(AUTO_GATEWAY_REQUEST_FD_DIVISOR)
|
|
.unwrap_or(1)
|
|
.max(1)
|
|
})
|
|
.unwrap_or(MAX_AUTO_GATEWAY_REQUEST_CONCURRENCY);
|
|
cpu_limit.min(fd_limit).max(1)
|
|
}
|
|
|
|
fn automatic_gateway_request_concurrency() -> usize {
|
|
automatic_gateway_request_concurrency_for_capacity(
|
|
available_parallelism_usize(),
|
|
soft_fd_limit(),
|
|
)
|
|
}
|
|
|
|
fn soft_fd_limit() -> Option<usize> {
|
|
#[cfg(unix)]
|
|
{
|
|
let mut limit = libc::rlimit {
|
|
rlim_cur: 0,
|
|
rlim_max: 0,
|
|
};
|
|
let result = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) };
|
|
if result == 0 {
|
|
return usize::try_from(limit.rlim_cur).ok();
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn usage_queue_request_concurrency_hint(
|
|
max_in_flight_requests: Option<usize>,
|
|
distributed_request_limit: Option<usize>,
|
|
) -> Option<usize> {
|
|
match (
|
|
max_in_flight_requests.filter(|limit| *limit > 0),
|
|
distributed_request_limit.filter(|limit| *limit > 0),
|
|
) {
|
|
(Some(local), Some(distributed)) => Some(local.min(distributed)),
|
|
(Some(local), None) => Some(local),
|
|
(None, Some(distributed)) => Some(distributed),
|
|
(None, None) => None,
|
|
}
|
|
}
|
|
|
|
fn usage_queue_workers_for_request_concurrency(request_concurrency: usize) -> usize {
|
|
let workers = request_concurrency
|
|
.saturating_add(AUTO_USAGE_QUEUE_WORKERS_REQUESTS_PER_WORKER - 1)
|
|
/ AUTO_USAGE_QUEUE_WORKERS_REQUESTS_PER_WORKER;
|
|
workers.clamp(AUTO_USAGE_QUEUE_WORKERS_MIN, MAX_USAGE_QUEUE_WORKERS)
|
|
}
|
|
|
|
fn usage_database_config_for_role<'a>(
|
|
node_role: NodeRoleArg,
|
|
database: Option<&'a SqlDatabaseConfig>,
|
|
isolated_background_database: Option<&'a SqlDatabaseConfig>,
|
|
) -> Option<&'a SqlDatabaseConfig> {
|
|
if node_role.isolates_background_database() {
|
|
isolated_background_database.or(database)
|
|
} else {
|
|
database
|
|
}
|
|
}
|
|
|
|
fn usage_queue_worker_database_cap(
|
|
node_role: NodeRoleArg,
|
|
database: Option<&SqlDatabaseConfig>,
|
|
database_is_isolated: bool,
|
|
) -> usize {
|
|
let Some(database) = database else {
|
|
return MAX_USAGE_QUEUE_WORKERS;
|
|
};
|
|
|
|
let max_connections = database.pool.max_connections.max(1) as usize;
|
|
// An isolated pool is already a dedicated background budget. Applying the shared-pool
|
|
// divisor a second time would underutilize that pool.
|
|
if database_is_isolated {
|
|
return max_connections
|
|
.saturating_sub(1)
|
|
.max(1)
|
|
.clamp(1, MAX_USAGE_QUEUE_WORKERS);
|
|
}
|
|
let divisor = if matches!(node_role, NodeRoleArg::Background) {
|
|
AUTO_USAGE_QUEUE_WORKERS_DB_SHARE_BACKGROUND
|
|
} else {
|
|
AUTO_USAGE_QUEUE_WORKERS_DB_SHARE_ALL
|
|
};
|
|
max_connections
|
|
.saturating_add(divisor - 1)
|
|
.checked_div(divisor)
|
|
.unwrap_or(1)
|
|
.clamp(1, MAX_USAGE_QUEUE_WORKERS)
|
|
}
|
|
|
|
fn usage_worker_record_concurrency_database_cap(
|
|
node_role: NodeRoleArg,
|
|
database: Option<&SqlDatabaseConfig>,
|
|
database_is_isolated: bool,
|
|
) -> Option<usize> {
|
|
let database = database?;
|
|
|
|
let max_connections = database.pool.max_connections.max(1) as usize;
|
|
// The isolated background pool has already been carved out of the foreground pool. Keep one
|
|
// connection available for maintenance/health work and use the rest for usage persistence.
|
|
if database_is_isolated {
|
|
return Some(
|
|
max_connections
|
|
.saturating_sub(1)
|
|
.max(1)
|
|
.clamp(1, MAX_USAGE_QUEUE_WORKERS),
|
|
);
|
|
}
|
|
let divisor = if matches!(node_role, NodeRoleArg::Background) {
|
|
AUTO_USAGE_WORKER_RECORD_DB_SHARE_BACKGROUND
|
|
} else {
|
|
AUTO_USAGE_WORKER_RECORD_DB_SHARE_ALL
|
|
};
|
|
Some(
|
|
max_connections
|
|
.checked_div(divisor.max(1))
|
|
.unwrap_or(1)
|
|
.clamp(1, MAX_USAGE_QUEUE_WORKERS),
|
|
)
|
|
}
|
|
|
|
fn automatic_usage_queue_workers_for_parallelism(
|
|
parallelism: usize,
|
|
node_role: NodeRoleArg,
|
|
max_in_flight_requests: Option<usize>,
|
|
distributed_request_limit: Option<usize>,
|
|
database: Option<&SqlDatabaseConfig>,
|
|
database_is_isolated: bool,
|
|
) -> usize {
|
|
let cpu_default = parallelism.max(1).clamp(
|
|
AUTO_USAGE_QUEUE_WORKERS_MIN,
|
|
DEFAULT_USAGE_QUEUE_WORKERS_CAP,
|
|
);
|
|
let requested =
|
|
usage_queue_request_concurrency_hint(max_in_flight_requests, distributed_request_limit)
|
|
.map(usage_queue_workers_for_request_concurrency)
|
|
.unwrap_or(cpu_default);
|
|
requested
|
|
.min(usage_queue_worker_database_cap(
|
|
node_role,
|
|
database,
|
|
database_is_isolated,
|
|
))
|
|
.clamp(1, MAX_USAGE_QUEUE_WORKERS)
|
|
}
|
|
|
|
fn automatic_usage_queue_workers(
|
|
node_role: NodeRoleArg,
|
|
max_in_flight_requests: Option<usize>,
|
|
distributed_request_limit: Option<usize>,
|
|
database: Option<&SqlDatabaseConfig>,
|
|
database_is_isolated: bool,
|
|
) -> usize {
|
|
automatic_usage_queue_workers_for_parallelism(
|
|
available_parallelism_usize(),
|
|
node_role,
|
|
max_in_flight_requests,
|
|
distributed_request_limit,
|
|
database,
|
|
database_is_isolated,
|
|
)
|
|
}
|
|
|
|
fn automatic_sql_pool_config(driver: DatabaseDriver) -> SqlPoolConfig {
|
|
automatic_sql_pool_config_for_parallelism(driver, available_parallelism_u32())
|
|
}
|
|
|
|
fn automatic_sql_pool_config_for_parallelism(
|
|
driver: DatabaseDriver,
|
|
parallelism: u32,
|
|
) -> SqlPoolConfig {
|
|
let (min_connections, max_connections) = match driver {
|
|
DatabaseDriver::Postgres => {
|
|
let cpu_count = parallelism.max(1);
|
|
let max_connections = cpu_count
|
|
.saturating_mul(AUTO_SERVER_SQL_POOL_CONNECTIONS_PER_CPU)
|
|
.clamp(
|
|
AUTO_SERVER_SQL_POOL_MAX_CONNECTIONS_FLOOR,
|
|
AUTO_SERVER_SQL_POOL_MAX_CONNECTIONS_CAP,
|
|
);
|
|
let min_connections = cpu_count
|
|
.clamp(
|
|
AUTO_SERVER_SQL_POOL_MIN_CONNECTIONS_FLOOR,
|
|
AUTO_SERVER_SQL_POOL_MIN_CONNECTIONS_CAP,
|
|
)
|
|
.min(max_connections);
|
|
(min_connections, max_connections)
|
|
}
|
|
};
|
|
|
|
SqlPoolConfig {
|
|
min_connections,
|
|
max_connections,
|
|
acquire_timeout_ms: DEFAULT_SQL_POOL_ACQUIRE_TIMEOUT_MS,
|
|
idle_timeout_ms: DEFAULT_SQL_POOL_IDLE_TIMEOUT_MS,
|
|
max_lifetime_ms: DEFAULT_SQL_POOL_MAX_LIFETIME_MS,
|
|
statement_cache_capacity: DEFAULT_SQL_POOL_STATEMENT_CACHE_CAPACITY,
|
|
require_ssl: false,
|
|
}
|
|
}
|
|
|
|
#[derive(ClapArgs, Debug, Clone)]
|
|
struct GatewayDataArgs {
|
|
#[arg(long, env = "AETHER_DATABASE_DRIVER", global = true)]
|
|
database_driver: Option<DatabaseDriverArg>,
|
|
|
|
#[arg(long, env = "AETHER_DATABASE_URL", global = true)]
|
|
database_url: Option<String>,
|
|
|
|
#[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_URL", global = true)]
|
|
postgres_url: Option<String>,
|
|
|
|
#[arg(long, env = "AETHER_GATEWAY_DATA_ENCRYPTION_KEY", global = true)]
|
|
encryption_key: Option<String>,
|
|
|
|
#[arg(long, env = "AETHER_GATEWAY_DATA_REDIS_URL", global = true)]
|
|
redis_url: Option<String>,
|
|
|
|
#[arg(long, env = "AETHER_GATEWAY_DATA_REDIS_KEY_PREFIX", global = true)]
|
|
redis_key_prefix: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS",
|
|
global = true
|
|
)]
|
|
postgres_min_connections: Option<u32>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS",
|
|
global = true
|
|
)]
|
|
postgres_max_connections: Option<u32>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_DATA_POSTGRES_ACQUIRE_TIMEOUT_MS",
|
|
global = true
|
|
)]
|
|
postgres_acquire_timeout_ms: Option<u64>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_DATA_POSTGRES_IDLE_TIMEOUT_MS",
|
|
global = true
|
|
)]
|
|
postgres_idle_timeout_ms: Option<u64>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_DATA_POSTGRES_MAX_LIFETIME_MS",
|
|
global = true
|
|
)]
|
|
postgres_max_lifetime_ms: Option<u64>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_DATA_POSTGRES_STATEMENT_CACHE_CAPACITY",
|
|
global = true
|
|
)]
|
|
postgres_statement_cache_capacity: Option<usize>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_DATA_POSTGRES_REQUIRE_SSL",
|
|
default_value_t = false,
|
|
global = true
|
|
)]
|
|
postgres_require_ssl: bool,
|
|
}
|
|
|
|
impl GatewayDataArgs {
|
|
fn validate_encryption_key(&self) -> Result<(), std::io::Error> {
|
|
validate_gateway_data_encryption_key(self.effective_encryption_key().as_deref())
|
|
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))
|
|
}
|
|
|
|
fn effective_database_driver(&self) -> Option<DatabaseDriver> {
|
|
self.database_driver.map(Into::into).or_else(|| {
|
|
self.database_url
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.and_then(DatabaseDriver::from_database_url)
|
|
})
|
|
}
|
|
|
|
fn effective_database_url(&self) -> Option<String> {
|
|
let configured_url = self
|
|
.database_url
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned);
|
|
let legacy_postgres_url = self
|
|
.postgres_url
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned);
|
|
let generic_database_url = std::env::var("DATABASE_URL")
|
|
.ok()
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty());
|
|
|
|
resolve_database_url(
|
|
self.effective_database_driver(),
|
|
configured_url,
|
|
legacy_postgres_url,
|
|
generic_database_url,
|
|
)
|
|
}
|
|
|
|
fn effective_sql_database_config(&self) -> Option<SqlDatabaseConfig> {
|
|
let url = self.effective_database_url()?;
|
|
let driver = self
|
|
.effective_database_driver()
|
|
.or_else(|| DatabaseDriver::from_database_url(&url))
|
|
.unwrap_or(DatabaseDriver::Postgres);
|
|
|
|
Some(SqlDatabaseConfig {
|
|
driver,
|
|
url,
|
|
pool: self.effective_sql_pool_config(driver),
|
|
})
|
|
}
|
|
|
|
fn effective_sql_pool_config(&self, driver: DatabaseDriver) -> SqlPoolConfig {
|
|
let auto = automatic_sql_pool_config(driver);
|
|
let mut min_connections = self
|
|
.postgres_min_connections
|
|
.unwrap_or(auto.min_connections);
|
|
let mut max_connections = self
|
|
.postgres_max_connections
|
|
.unwrap_or(auto.max_connections)
|
|
.max(1);
|
|
|
|
match (self.postgres_min_connections, self.postgres_max_connections) {
|
|
(None, Some(_)) if min_connections > max_connections => {
|
|
min_connections = max_connections;
|
|
}
|
|
(Some(_), None) if max_connections < min_connections => {
|
|
max_connections = min_connections.max(1);
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
SqlPoolConfig {
|
|
min_connections,
|
|
max_connections,
|
|
acquire_timeout_ms: self
|
|
.postgres_acquire_timeout_ms
|
|
.unwrap_or(auto.acquire_timeout_ms),
|
|
idle_timeout_ms: self
|
|
.postgres_idle_timeout_ms
|
|
.unwrap_or(auto.idle_timeout_ms),
|
|
max_lifetime_ms: self
|
|
.postgres_max_lifetime_ms
|
|
.unwrap_or(auto.max_lifetime_ms),
|
|
statement_cache_capacity: self
|
|
.postgres_statement_cache_capacity
|
|
.unwrap_or(auto.statement_cache_capacity),
|
|
require_ssl: self.postgres_require_ssl,
|
|
}
|
|
}
|
|
|
|
fn effective_redis_url(&self) -> Option<String> {
|
|
self.redis_url
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| {
|
|
std::env::var("REDIS_URL")
|
|
.ok()
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
})
|
|
}
|
|
|
|
fn effective_encryption_key(&self) -> Option<String> {
|
|
self.encryption_key
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| {
|
|
std::env::var("ENCRYPTION_KEY")
|
|
.ok()
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
})
|
|
}
|
|
|
|
fn configured_encryption_key_mismatch(&self) -> bool {
|
|
let gateway_value = std::env::var("AETHER_GATEWAY_DATA_ENCRYPTION_KEY")
|
|
.ok()
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty());
|
|
let default_value = std::env::var("ENCRYPTION_KEY")
|
|
.ok()
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty());
|
|
|
|
matches!(
|
|
(gateway_value, default_value),
|
|
(Some(gateway_value), Some(default_value)) if gateway_value != default_value
|
|
)
|
|
}
|
|
|
|
fn to_config(&self) -> GatewayDataConfig {
|
|
let database = self.effective_sql_database_config();
|
|
|
|
let config = match database {
|
|
Some(database) => GatewayDataConfig::from_database_config(database),
|
|
None => GatewayDataConfig::disabled(),
|
|
};
|
|
|
|
match self.effective_encryption_key() {
|
|
Some(value) => {
|
|
warm_python_fernet_secret(&value);
|
|
config.with_encryption_key(value)
|
|
}
|
|
None => config,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resolve_database_url(
|
|
driver: Option<DatabaseDriver>,
|
|
configured_url: Option<String>,
|
|
legacy_postgres_url: Option<String>,
|
|
generic_database_url: Option<String>,
|
|
) -> Option<String> {
|
|
if configured_url.is_some() {
|
|
return configured_url;
|
|
}
|
|
|
|
match driver {
|
|
Some(DatabaseDriver::Postgres) | None => legacy_postgres_url.or(generic_database_url),
|
|
}
|
|
}
|
|
|
|
#[derive(ClapArgs, Debug, Clone)]
|
|
struct GatewayUsageArgs {
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_TERMINAL_EVENTS",
|
|
default_value_t = true
|
|
)]
|
|
queue_terminal_events: bool,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_LIFECYCLE_EVENTS",
|
|
default_value_t = true
|
|
)]
|
|
queue_lifecycle_events: bool,
|
|
|
|
#[arg(long, env = "AETHER_GATEWAY_USAGE_QUEUE_WORKERS", value_name = "COUNT")]
|
|
queue_workers: Option<usize>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_WORKER_AUTOSCALE_ENABLED",
|
|
default_value_t = true
|
|
)]
|
|
queue_worker_autoscale_enabled: bool,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_WORKER_MAX_COUNT",
|
|
value_name = "COUNT",
|
|
default_value = "32"
|
|
)]
|
|
queue_worker_max_count: Option<usize>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_WORKER_RECORD_CONCURRENCY_LIMIT",
|
|
value_name = "COUNT",
|
|
default_value = "32"
|
|
)]
|
|
worker_record_concurrency_limit: Option<usize>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_WORKER_SCALE_INTERVAL_MS",
|
|
default_value_t = 1_000
|
|
)]
|
|
queue_worker_scale_interval_ms: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_WORKER_IDLE_SCALE_DOWN_TICKS",
|
|
default_value_t = 30
|
|
)]
|
|
queue_worker_idle_scale_down_ticks: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_STREAM_KEY",
|
|
default_value = "usage:events"
|
|
)]
|
|
queue_stream_key: String,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_GROUP",
|
|
default_value = "usage_consumers"
|
|
)]
|
|
queue_group: String,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_DLQ_STREAM_KEY",
|
|
default_value = "usage:events:dlq"
|
|
)]
|
|
queue_dlq_stream_key: String,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_STREAM_MAXLEN",
|
|
default_value_t = 200_000
|
|
)]
|
|
queue_stream_maxlen: usize,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_PAYLOAD_MAX_BYTES",
|
|
default_value_t = 1024 * 1024
|
|
)]
|
|
queue_payload_max_bytes: usize,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_BATCH_SIZE",
|
|
default_value_t = 128
|
|
)]
|
|
queue_batch_size: usize,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_BLOCK_MS",
|
|
default_value_t = 500
|
|
)]
|
|
queue_block_ms: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_RECLAIM_IDLE_MS",
|
|
default_value_t = 60_000
|
|
)]
|
|
queue_reclaim_idle_ms: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_RECLAIM_COUNT",
|
|
default_value_t = 128
|
|
)]
|
|
queue_reclaim_count: usize,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_QUEUE_RECLAIM_INTERVAL_MS",
|
|
default_value_t = 5_000
|
|
)]
|
|
queue_reclaim_interval_ms: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_TERMINAL_SUBMISSION_MAX_IN_FLIGHT",
|
|
default_value_t = 1_024
|
|
)]
|
|
terminal_submission_max_in_flight: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_TERMINAL_ENQUEUE_MAX_IN_FLIGHT",
|
|
default_value_t = 1_024
|
|
)]
|
|
terminal_enqueue_max_in_flight: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_LIFECYCLE_ENQUEUE_MAX_IN_FLIGHT",
|
|
default_value_t = 512
|
|
)]
|
|
lifecycle_enqueue_max_in_flight: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_LIFECYCLE_ENQUEUE_DELAY_MS",
|
|
default_value_t = 1_000
|
|
)]
|
|
lifecycle_enqueue_delay_ms: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_RETRY_DEFERRED_LIFECYCLE_EVENTS",
|
|
default_value_t = true
|
|
)]
|
|
retry_deferred_lifecycle_events: bool,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_ENQUEUE_RETRY_BUFFER_CAPACITY",
|
|
default_value_t = 131_072
|
|
)]
|
|
enqueue_retry_buffer_capacity: usize,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_ENQUEUE_RETRY_WORKERS",
|
|
default_value_t = 8
|
|
)]
|
|
enqueue_retry_workers: usize,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_ENQUEUE_RETRY_INITIAL_BACKOFF_MS",
|
|
default_value_t = 3_000
|
|
)]
|
|
enqueue_retry_initial_backoff_ms: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_ENQUEUE_RETRY_MAX_BACKOFF_MS",
|
|
default_value_t = 10_000
|
|
)]
|
|
enqueue_retry_max_backoff_ms: u64,
|
|
}
|
|
|
|
impl GatewayUsageArgs {
|
|
fn effective_queue_workers(
|
|
&self,
|
|
node_role: NodeRoleArg,
|
|
max_in_flight_requests: Option<usize>,
|
|
distributed_request_limit: Option<usize>,
|
|
database: Option<&SqlDatabaseConfig>,
|
|
database_is_isolated: bool,
|
|
) -> usize {
|
|
if let Some(queue_workers) = self.queue_workers {
|
|
return queue_workers.clamp(1, MAX_USAGE_QUEUE_WORKERS);
|
|
}
|
|
if !self.queue_terminal_events && !self.queue_lifecycle_events {
|
|
return 1;
|
|
}
|
|
automatic_usage_queue_workers(
|
|
node_role,
|
|
max_in_flight_requests,
|
|
distributed_request_limit,
|
|
database,
|
|
database_is_isolated,
|
|
)
|
|
}
|
|
|
|
fn effective_queue_worker_max_count(
|
|
&self,
|
|
node_role: NodeRoleArg,
|
|
database: Option<&SqlDatabaseConfig>,
|
|
worker_count: usize,
|
|
database_is_isolated: bool,
|
|
) -> usize {
|
|
if !self.queue_worker_autoscale_enabled {
|
|
return worker_count.clamp(1, MAX_USAGE_QUEUE_WORKERS);
|
|
}
|
|
self.queue_worker_max_count
|
|
.unwrap_or_else(|| {
|
|
usage_queue_worker_database_cap(node_role, database, database_is_isolated)
|
|
})
|
|
.max(1)
|
|
.min(usage_queue_worker_database_cap(
|
|
node_role,
|
|
database,
|
|
database_is_isolated,
|
|
))
|
|
.clamp(worker_count.max(1), MAX_USAGE_QUEUE_WORKERS)
|
|
}
|
|
|
|
fn runtime_state_blocking_stream_lanes(
|
|
&self,
|
|
node_role: NodeRoleArg,
|
|
database: Option<&SqlDatabaseConfig>,
|
|
worker_max_count: usize,
|
|
) -> Option<usize> {
|
|
if !node_role.spawns_background_tasks()
|
|
|| (!self.queue_terminal_events && !self.queue_lifecycle_events)
|
|
|| database.is_none()
|
|
{
|
|
return None;
|
|
}
|
|
Some(worker_max_count.clamp(1, MAX_USAGE_QUEUE_WORKERS))
|
|
}
|
|
|
|
fn effective_worker_record_concurrency_limit(
|
|
&self,
|
|
node_role: NodeRoleArg,
|
|
database: Option<&SqlDatabaseConfig>,
|
|
database_is_isolated: bool,
|
|
) -> Option<usize> {
|
|
if let Some(limit) = self.worker_record_concurrency_limit {
|
|
if limit == 0 {
|
|
return None;
|
|
}
|
|
return Some(
|
|
limit
|
|
.min(MAX_USAGE_QUEUE_WORKERS)
|
|
.min(
|
|
usage_worker_record_concurrency_database_cap(
|
|
node_role,
|
|
database,
|
|
database_is_isolated,
|
|
)
|
|
.unwrap_or(MAX_USAGE_QUEUE_WORKERS),
|
|
)
|
|
.max(1),
|
|
);
|
|
}
|
|
if !node_role.spawns_background_tasks()
|
|
|| (!self.queue_terminal_events && !self.queue_lifecycle_events)
|
|
{
|
|
return None;
|
|
}
|
|
usage_worker_record_concurrency_database_cap(node_role, database, database_is_isolated)
|
|
}
|
|
|
|
fn to_config(
|
|
&self,
|
|
worker_count: usize,
|
|
worker_max_count: usize,
|
|
worker_record_concurrency_limit: Option<usize>,
|
|
) -> UsageRuntimeConfig {
|
|
UsageRuntimeConfig {
|
|
enabled: true,
|
|
queue_terminal_events: self.queue_terminal_events,
|
|
queue_lifecycle_events: self.queue_lifecycle_events,
|
|
worker_count: worker_count.clamp(1, MAX_USAGE_QUEUE_WORKERS),
|
|
worker_autoscale_enabled: self.queue_worker_autoscale_enabled,
|
|
worker_max_count: worker_max_count.clamp(worker_count.max(1), MAX_USAGE_QUEUE_WORKERS),
|
|
worker_record_concurrency_limit,
|
|
worker_scale_interval_ms: self.queue_worker_scale_interval_ms.max(1),
|
|
worker_idle_scale_down_ticks: self.queue_worker_idle_scale_down_ticks.max(1),
|
|
stream_key: self.queue_stream_key.trim().to_string(),
|
|
consumer_group: self.queue_group.trim().to_string(),
|
|
dlq_stream_key: self.queue_dlq_stream_key.trim().to_string(),
|
|
stream_maxlen: self.queue_stream_maxlen.max(1),
|
|
queue_payload_max_bytes: self.queue_payload_max_bytes,
|
|
consumer_batch_size: self.queue_batch_size.max(1),
|
|
consumer_block_ms: self.queue_block_ms.max(1),
|
|
reclaim_idle_ms: self.queue_reclaim_idle_ms.max(1),
|
|
reclaim_count: self.queue_reclaim_count.max(1),
|
|
reclaim_interval_ms: self.queue_reclaim_interval_ms.max(1),
|
|
terminal_submission_max_in_flight: self.terminal_submission_max_in_flight.max(1),
|
|
terminal_enqueue_max_in_flight: self.terminal_enqueue_max_in_flight.max(1),
|
|
lifecycle_enqueue_max_in_flight: self.lifecycle_enqueue_max_in_flight.max(1),
|
|
lifecycle_enqueue_delay_ms: self.lifecycle_enqueue_delay_ms,
|
|
retry_deferred_lifecycle_events: self.retry_deferred_lifecycle_events,
|
|
enqueue_retry_buffer_capacity: self.enqueue_retry_buffer_capacity.max(1),
|
|
enqueue_retry_workers: self.enqueue_retry_workers.clamp(1, 64),
|
|
enqueue_retry_initial_backoff_ms: self.enqueue_retry_initial_backoff_ms.max(1),
|
|
enqueue_retry_max_backoff_ms: self
|
|
.enqueue_retry_max_backoff_ms
|
|
.max(self.enqueue_retry_initial_backoff_ms.max(1)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(ClapArgs, Debug, Clone)]
|
|
struct GatewayFrontdoorArgs {
|
|
#[arg(long, env = "ENVIRONMENT", default_value = "development")]
|
|
environment: String,
|
|
|
|
#[arg(long, env = "CORS_ORIGINS")]
|
|
cors_origins: Option<String>,
|
|
|
|
#[arg(long, env = "CORS_ALLOW_CREDENTIALS", default_value_t = true)]
|
|
cors_allow_credentials: bool,
|
|
}
|
|
|
|
impl GatewayFrontdoorArgs {
|
|
fn cors_config(&self) -> Option<FrontdoorCorsConfig> {
|
|
FrontdoorCorsConfig::from_environment(
|
|
self.environment.trim(),
|
|
self.cors_origins.as_deref(),
|
|
self.cors_allow_credentials,
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(ClapArgs, Debug, Clone)]
|
|
struct GatewayRateLimitArgs {
|
|
#[arg(long, env = "RPM_BUCKET_SECONDS", default_value_t = 60)]
|
|
bucket_seconds: u64,
|
|
|
|
#[arg(long, env = "RPM_KEY_TTL_SECONDS", default_value_t = 120)]
|
|
key_ttl_seconds: u64,
|
|
|
|
/// Explicitly allow requests when the shared RPM backend is unavailable.
|
|
/// Keep the secure fail-closed behavior as the production default.
|
|
#[arg(long, env = "RATE_LIMIT_FAIL_OPEN", default_value_t = false)]
|
|
fail_open: bool,
|
|
}
|
|
|
|
impl GatewayRateLimitArgs {
|
|
fn config(&self) -> FrontdoorUserRpmConfig {
|
|
FrontdoorUserRpmConfig::new(self.bucket_seconds, self.key_ttl_seconds, self.fail_open)
|
|
}
|
|
}
|
|
|
|
#[derive(ClapArgs, Debug, Clone)]
|
|
struct GatewayLoggingArgs {
|
|
#[arg(long, env = "AETHER_LOG_FORMAT", value_enum, default_value = "pretty")]
|
|
log_format: GatewayLogFormatArg,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_LOG_DESTINATION",
|
|
value_enum,
|
|
default_value = "stdout"
|
|
)]
|
|
log_destination: GatewayLogDestinationArg,
|
|
|
|
#[arg(long, env = "AETHER_LOG_DIR")]
|
|
log_dir: Option<String>,
|
|
|
|
#[arg(long, env = "AETHER_LOG_ROTATION", value_enum, default_value = "daily")]
|
|
log_rotation: GatewayLogRotationArg,
|
|
|
|
#[arg(long, env = "AETHER_LOG_RETENTION_DAYS", default_value_t = 7)]
|
|
log_retention_days: u64,
|
|
|
|
#[arg(long, env = "AETHER_LOG_MAX_FILES", default_value_t = 30)]
|
|
log_max_files: usize,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug, Clone)]
|
|
enum DataCommand {
|
|
/// Export persistent SQL data to database-neutral JSONL.
|
|
Export(DataExportArgs),
|
|
/// Import database-neutral JSONL into the selected SQL database.
|
|
Import(DataImportArgs),
|
|
/// Copy persistent SQL data directly between two databases without a JSONL file.
|
|
Copy(DataCopyArgs),
|
|
/// Inspect or prepare the configured database.
|
|
Db(DatabaseCommandArgs),
|
|
}
|
|
|
|
#[derive(ClapArgs, Debug, Clone)]
|
|
struct DatabaseCommandArgs {
|
|
#[command(subcommand)]
|
|
command: DatabaseCommand,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug, Clone)]
|
|
enum DatabaseCommand {
|
|
/// Show whether schema migrations and data backfills are current.
|
|
Status,
|
|
/// Apply pending schema migrations and data backfills.
|
|
Prepare,
|
|
}
|
|
|
|
#[derive(ClapArgs, Debug, Clone)]
|
|
struct DataExportArgs {
|
|
#[arg(long)]
|
|
output: PathBuf,
|
|
|
|
/// Atomically replace an existing regular output owned by the current user.
|
|
#[arg(long)]
|
|
overwrite: bool,
|
|
|
|
#[arg(long, value_enum, value_delimiter = ',')]
|
|
domains: Vec<ExportDomainArg>,
|
|
}
|
|
|
|
#[derive(ClapArgs, Debug, Clone)]
|
|
struct DataImportArgs {
|
|
#[arg(long)]
|
|
input: PathBuf,
|
|
#[arg(
|
|
long,
|
|
help = "Preserve passwords and API/management credentials from a trusted import; imported sessions remain revoked. Without this flag identity credentials are revoked."
|
|
)]
|
|
preserve_credentials: bool,
|
|
}
|
|
|
|
#[derive(ClapArgs, Debug, Clone)]
|
|
struct DataCopyArgs {
|
|
#[arg(long, value_enum)]
|
|
source_driver: DatabaseDriverArg,
|
|
|
|
#[arg(long)]
|
|
source_url: String,
|
|
|
|
/// Permit a cleartext source connection for a non-loopback database.
|
|
/// Leave unset to require TLS for remote Postgres URLs.
|
|
#[arg(long)]
|
|
source_allow_insecure: bool,
|
|
|
|
#[arg(long, value_enum)]
|
|
target_driver: DatabaseDriverArg,
|
|
|
|
#[arg(long)]
|
|
target_url: String,
|
|
|
|
/// Permit a cleartext target connection for a non-loopback database.
|
|
/// Leave unset to require TLS for remote Postgres URLs.
|
|
#[arg(long)]
|
|
target_allow_insecure: bool,
|
|
|
|
#[arg(long, value_enum, value_delimiter = ',')]
|
|
domains: Vec<ExportDomainArg>,
|
|
|
|
#[arg(long)]
|
|
omit_request_body_details: bool,
|
|
#[arg(
|
|
long,
|
|
help = "Preserve passwords and API/management credentials from the trusted source; imported sessions remain revoked. The target must use the source encryption key."
|
|
)]
|
|
preserve_credentials: bool,
|
|
}
|
|
|
|
impl GatewayLoggingArgs {
|
|
fn apply_to_runtime_config(
|
|
&self,
|
|
mut config: ServiceRuntimeConfig,
|
|
) -> Result<ServiceRuntimeConfig, std::io::Error> {
|
|
config = config
|
|
.with_log_format(self.log_format.into())
|
|
.with_log_destination(self.log_destination.into());
|
|
if matches!(
|
|
self.log_destination,
|
|
GatewayLogDestinationArg::File | GatewayLogDestinationArg::Both
|
|
) {
|
|
let log_dir = self
|
|
.log_dir
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| {
|
|
std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"AETHER_LOG_DIR is required when AETHER_LOG_DESTINATION=file|both",
|
|
)
|
|
})?;
|
|
config = config.with_file_logging(FileLoggingConfig::new(
|
|
log_dir,
|
|
self.log_rotation.into(),
|
|
self.log_retention_days,
|
|
self.log_max_files,
|
|
));
|
|
}
|
|
Ok(config)
|
|
}
|
|
}
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(
|
|
name = "aether-gateway",
|
|
about = "Phase 3a Rust ingress gateway for Aether"
|
|
)]
|
|
struct Args {
|
|
#[command(subcommand)]
|
|
command: Option<DataCommand>,
|
|
|
|
#[arg(long, env = "APP_PORT", default_value_t = 8084)]
|
|
app_port: u16,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_LISTEN_BACKLOG",
|
|
default_value_t = DEFAULT_GATEWAY_LISTEN_BACKLOG
|
|
)]
|
|
listen_backlog: i32,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_LISTENER_SHARDS",
|
|
default_value_t = DEFAULT_GATEWAY_LISTENER_SHARDS
|
|
)]
|
|
/// Number of SO_REUSEPORT listener shards. 0 selects a high-concurrency default.
|
|
listener_shards: usize,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS",
|
|
default_value_t = DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS
|
|
)]
|
|
http2_max_concurrent_streams: u32,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_HTTP_HEADER_READ_TIMEOUT_MS",
|
|
default_value_t = DEFAULT_GATEWAY_HTTP_HEADER_READ_TIMEOUT_MS
|
|
)]
|
|
/// Maximum time allowed to receive one complete HTTP request header block.
|
|
http_header_read_timeout_ms: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_HTTP_HEADER_MAX_BYTES",
|
|
default_value_t = DEFAULT_GATEWAY_HTTP_HEADER_MAX_BYTES
|
|
)]
|
|
/// Maximum HTTP request header bytes (HTTP/2 uses decompressed list size).
|
|
http_header_max_bytes: usize,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_HTTP_MAX_HEADERS",
|
|
default_value_t = DEFAULT_GATEWAY_HTTP_MAX_HEADERS
|
|
)]
|
|
/// Maximum number of HTTP/1 request header fields.
|
|
http_max_headers: usize,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_HTTP_SHUTDOWN_TIMEOUT_MS",
|
|
default_value_t = 30_000
|
|
)]
|
|
/// Grace period for HTTP requests and upgraded connections before forced close.
|
|
http_shutdown_timeout_ms: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_USAGE_SHUTDOWN_TIMEOUT_MS",
|
|
default_value_t = 30_000
|
|
)]
|
|
/// Additional time for request finalizers and local usage buffers to persist.
|
|
usage_shutdown_timeout_ms: u64,
|
|
|
|
/// 容器内健康检查入口:根据当前 bind 端口探测本地 /health。
|
|
#[arg(long, hide = true, default_value_t = false)]
|
|
healthcheck: bool,
|
|
|
|
#[arg(
|
|
long,
|
|
hide = true,
|
|
env = "AETHER_GATEWAY_HEALTHCHECK_TIMEOUT_MS",
|
|
default_value_t = 3_000
|
|
)]
|
|
healthcheck_timeout_ms: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY",
|
|
value_enum,
|
|
default_value = "single-node"
|
|
)]
|
|
deployment_topology: DeploymentTopologyArg,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_NODE_ROLE",
|
|
value_enum,
|
|
default_value = "all"
|
|
)]
|
|
node_role: NodeRoleArg,
|
|
|
|
#[arg(long, hide = true, default_value_t = false)]
|
|
migrate: bool,
|
|
|
|
#[arg(long, hide = true, default_value_t = false)]
|
|
apply_backfills: bool,
|
|
|
|
/// Database startup policy. Defaults to auto when neither this nor the legacy setting is set.
|
|
#[arg(long, env = "AETHER_GATEWAY_DATABASE_MODE", value_enum)]
|
|
database_mode: Option<DatabaseModeArg>,
|
|
|
|
/// Legacy compatibility switch. Prefer --database-mode.
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_AUTO_PREPARE_DATABASE",
|
|
hide = true,
|
|
num_args = 0..=1,
|
|
default_missing_value = "true"
|
|
)]
|
|
auto_prepare_database: Option<bool>,
|
|
|
|
/// Path to frontend static files directory (SPA). When set, the gateway
|
|
/// serves the frontend directly without nginx.
|
|
#[arg(long, env = "AETHER_GATEWAY_STATIC_DIR")]
|
|
static_dir: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE",
|
|
value_enum,
|
|
default_value = "python-sync-report"
|
|
)]
|
|
video_task_truth_source_mode: VideoTaskTruthSourceArg,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_VIDEO_TASK_POLLER_INTERVAL_MS",
|
|
default_value_t = 5000
|
|
)]
|
|
video_task_poller_interval_ms: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_VIDEO_TASK_POLLER_BATCH_SIZE",
|
|
default_value_t = 32
|
|
)]
|
|
video_task_poller_batch_size: usize,
|
|
|
|
#[arg(long, env = "AETHER_GATEWAY_VIDEO_TASK_STORE_PATH")]
|
|
video_task_store_path: Option<String>,
|
|
|
|
#[arg(long, env = "AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS")]
|
|
max_in_flight_requests: Option<usize>,
|
|
|
|
/// Maximum accepted HTTP TCP connections across all listener shards, including upgrades.
|
|
/// Unset or 0 follows request plus WebSocket capacity, bounded by the FD allowance.
|
|
#[arg(long, env = "AETHER_GATEWAY_MAX_HTTP_CONNECTIONS")]
|
|
max_http_connections: Option<usize>,
|
|
|
|
/// Maximum number of long-lived public WebSocket connections. When unset,
|
|
/// this follows `max_in_flight_requests` while remaining an independent
|
|
/// gate. Set `AETHER_GATEWAY_MAX_WEBSOCKET_CONNECTIONS` to override it.
|
|
#[arg(long, env = "AETHER_GATEWAY_MAX_WEBSOCKET_CONNECTIONS")]
|
|
max_websocket_connections: Option<usize>,
|
|
|
|
#[arg(long, env = "AETHER_GATEWAY_DISTRIBUTED_REQUEST_LIMIT")]
|
|
distributed_request_limit: Option<usize>,
|
|
|
|
/// Optional distributed limit for long-lived WebSocket connections. When
|
|
/// omitted, the distributed request limit is reused; set it to 0 to keep
|
|
/// WebSocket admission local-only.
|
|
#[arg(long, env = "AETHER_GATEWAY_DISTRIBUTED_WEBSOCKET_CONNECTION_LIMIT")]
|
|
distributed_websocket_connection_limit: Option<usize>,
|
|
|
|
#[arg(long, env = "AETHER_GATEWAY_DISTRIBUTED_REQUEST_REDIS_URL")]
|
|
distributed_request_redis_url: Option<String>,
|
|
|
|
#[arg(long, env = "AETHER_GATEWAY_DISTRIBUTED_REQUEST_REDIS_KEY_PREFIX")]
|
|
distributed_request_redis_key_prefix: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_DISTRIBUTED_REQUEST_LEASE_TTL_MS",
|
|
default_value_t = 30_000
|
|
)]
|
|
distributed_request_lease_ttl_ms: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_DISTRIBUTED_REQUEST_RENEW_INTERVAL_MS",
|
|
default_value_t = 10_000
|
|
)]
|
|
distributed_request_renew_interval_ms: u64,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_GATEWAY_DISTRIBUTED_REQUEST_COMMAND_TIMEOUT_MS",
|
|
default_value_t = 1_000
|
|
)]
|
|
distributed_request_command_timeout_ms: u64,
|
|
|
|
#[arg(long, env = "AETHER_RUNTIME_BACKEND", value_enum)]
|
|
runtime_backend: Option<RuntimeBackendArg>,
|
|
|
|
#[arg(long, env = "AETHER_RUNTIME_REDIS_URL")]
|
|
runtime_redis_url: Option<String>,
|
|
|
|
#[arg(long, env = "AETHER_RUNTIME_REDIS_KEY_PREFIX")]
|
|
runtime_redis_key_prefix: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "AETHER_RUNTIME_COMMAND_TIMEOUT_MS",
|
|
default_value_t = 2_000
|
|
)]
|
|
runtime_command_timeout_ms: u64,
|
|
|
|
#[command(flatten)]
|
|
data: GatewayDataArgs,
|
|
|
|
#[command(flatten)]
|
|
usage: GatewayUsageArgs,
|
|
|
|
#[command(flatten)]
|
|
frontdoor: GatewayFrontdoorArgs,
|
|
|
|
#[command(flatten)]
|
|
rate_limit: GatewayRateLimitArgs,
|
|
|
|
#[command(flatten)]
|
|
logging: GatewayLoggingArgs,
|
|
}
|
|
|
|
impl Args {
|
|
fn effective_database_mode(&self) -> DatabaseModeArg {
|
|
resolve_database_mode(self.database_mode, self.auto_prepare_database)
|
|
}
|
|
|
|
fn effective_runtime_backend(
|
|
&self,
|
|
_database: Option<&SqlDatabaseConfig>,
|
|
data_redis_url: Option<&str>,
|
|
) -> RuntimeBackendArg {
|
|
if let Some(runtime_backend) = self.runtime_backend {
|
|
if !matches!(runtime_backend, RuntimeBackendArg::Auto) {
|
|
return runtime_backend;
|
|
}
|
|
}
|
|
if matches!(self.deployment_topology, DeploymentTopologyArg::MultiNode) {
|
|
return RuntimeBackendArg::Redis;
|
|
}
|
|
if data_redis_url.is_some() {
|
|
RuntimeBackendArg::Redis
|
|
} else {
|
|
RuntimeBackendArg::Memory
|
|
}
|
|
}
|
|
|
|
fn effective_runtime_redis_url(&self, data_redis_url: Option<&str>) -> Option<String> {
|
|
self.runtime_redis_url
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| data_redis_url.map(ToOwned::to_owned))
|
|
}
|
|
|
|
fn effective_runtime_redis_key_prefix(&self) -> Option<String> {
|
|
self.runtime_redis_key_prefix
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| {
|
|
self.data
|
|
.redis_key_prefix
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
})
|
|
}
|
|
|
|
fn runtime_state_config(
|
|
&self,
|
|
runtime_backend: RuntimeBackendArg,
|
|
data_redis_url: Option<&str>,
|
|
blocking_stream_lanes: Option<usize>,
|
|
) -> RuntimeStateConfig {
|
|
let redis = self
|
|
.effective_runtime_redis_url(data_redis_url)
|
|
.map(|url| RedisClientConfig {
|
|
url,
|
|
key_prefix: self.effective_runtime_redis_key_prefix(),
|
|
});
|
|
RuntimeStateConfig {
|
|
backend: runtime_backend.to_runtime_state_backend(),
|
|
redis,
|
|
command_timeout_ms: Some(self.runtime_command_timeout_ms.max(1)),
|
|
blocking_stream_lanes,
|
|
..RuntimeStateConfig::default()
|
|
}
|
|
}
|
|
|
|
fn runtime_config(&self) -> Result<ServiceRuntimeConfig, std::io::Error> {
|
|
let default_log_filter = "aether_gateway=info,aether_data=info";
|
|
let config = self
|
|
.logging
|
|
.apply_to_runtime_config(ServiceRuntimeConfig::new(
|
|
"aether-gateway",
|
|
default_log_filter,
|
|
))?;
|
|
Ok(config
|
|
.with_node_role(self.node_role.as_str())
|
|
.with_instance_id(resolve_gateway_log_instance_id()))
|
|
}
|
|
}
|
|
|
|
fn resolve_gateway_log_instance_id() -> String {
|
|
env_var_trimmed("AETHER_GATEWAY_INSTANCE_ID")
|
|
.or_else(|| env_var_trimmed("HOSTNAME"))
|
|
.unwrap_or_else(|| "local".to_string())
|
|
}
|
|
|
|
fn validate_app_port(app_port: u16) -> Result<u16, std::io::Error> {
|
|
if app_port == 0 {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"APP_PORT must be between 1 and 65535",
|
|
));
|
|
}
|
|
Ok(app_port)
|
|
}
|
|
|
|
fn gateway_bind_addr(app_port: u16) -> Result<std::net::SocketAddr, std::io::Error> {
|
|
Ok(std::net::SocketAddr::from((
|
|
[0, 0, 0, 0],
|
|
validate_app_port(app_port)?,
|
|
)))
|
|
}
|
|
|
|
fn gateway_listen_backlog(backlog: i32) -> i32 {
|
|
backlog.clamp(MIN_GATEWAY_LISTEN_BACKLOG, MAX_GATEWAY_LISTEN_BACKLOG)
|
|
}
|
|
|
|
fn gateway_auto_listener_shards() -> usize {
|
|
#[cfg(unix)]
|
|
{
|
|
std::thread::available_parallelism()
|
|
.map(|parallelism| parallelism.get().saturating_mul(2))
|
|
.unwrap_or(16)
|
|
.clamp(8, 16)
|
|
.min(MAX_GATEWAY_LISTENER_SHARDS)
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
{
|
|
1
|
|
}
|
|
}
|
|
|
|
fn gateway_listener_shards(shards: usize) -> usize {
|
|
if shards == 0 {
|
|
return gateway_auto_listener_shards();
|
|
}
|
|
shards.clamp(1, MAX_GATEWAY_LISTENER_SHARDS)
|
|
}
|
|
|
|
fn gateway_http2_max_concurrent_streams(streams: u32) -> u32 {
|
|
streams.clamp(
|
|
MIN_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS,
|
|
MAX_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS,
|
|
)
|
|
}
|
|
|
|
fn gateway_http_header_read_timeout_ms(timeout_ms: u64) -> u64 {
|
|
timeout_ms.clamp(
|
|
MIN_GATEWAY_HTTP_HEADER_READ_TIMEOUT_MS,
|
|
MAX_GATEWAY_HTTP_HEADER_READ_TIMEOUT_MS,
|
|
)
|
|
}
|
|
|
|
fn gateway_http_header_max_bytes(bytes: usize) -> usize {
|
|
bytes.clamp(
|
|
MIN_GATEWAY_HTTP_HEADER_MAX_BYTES,
|
|
MAX_GATEWAY_HTTP_HEADER_MAX_BYTES,
|
|
)
|
|
}
|
|
|
|
fn gateway_http_max_headers(headers: usize) -> usize {
|
|
headers.clamp(MIN_GATEWAY_HTTP_MAX_HEADERS, MAX_GATEWAY_HTTP_MAX_HEADERS)
|
|
}
|
|
|
|
fn gateway_listener(
|
|
bind_addr: std::net::SocketAddr,
|
|
backlog: i32,
|
|
reuse_port: bool,
|
|
) -> Result<tokio::net::TcpListener, std::io::Error> {
|
|
let domain = match bind_addr {
|
|
std::net::SocketAddr::V4(_) => socket2::Domain::IPV4,
|
|
std::net::SocketAddr::V6(_) => socket2::Domain::IPV6,
|
|
};
|
|
let socket = socket2::Socket::new(domain, socket2::Type::STREAM, Some(socket2::Protocol::TCP))?;
|
|
socket.set_reuse_address(true)?;
|
|
if reuse_port {
|
|
set_gateway_listener_reuse_port(&socket)?;
|
|
}
|
|
socket.set_nonblocking(true)?;
|
|
socket.set_tcp_nodelay(true)?;
|
|
socket.bind(&bind_addr.into())?;
|
|
socket.listen(gateway_listen_backlog(backlog))?;
|
|
tokio::net::TcpListener::from_std(socket.into())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn set_gateway_listener_reuse_port(socket: &socket2::Socket) -> Result<(), std::io::Error> {
|
|
socket.set_reuse_port(true)
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn set_gateway_listener_reuse_port(_socket: &socket2::Socket) -> Result<(), std::io::Error> {
|
|
Err(std::io::Error::new(
|
|
std::io::ErrorKind::Unsupported,
|
|
"AETHER_GATEWAY_LISTENER_SHARDS > 1 requires SO_REUSEPORT support",
|
|
))
|
|
}
|
|
|
|
fn gateway_listeners(
|
|
bind_addr: std::net::SocketAddr,
|
|
backlog: i32,
|
|
shards: usize,
|
|
) -> Result<Vec<tokio::net::TcpListener>, std::io::Error> {
|
|
let shards = gateway_listener_shards(shards);
|
|
let mut listeners = Vec::with_capacity(shards);
|
|
for _ in 0..shards {
|
|
listeners.push(gateway_listener(bind_addr, backlog, shards > 1)?);
|
|
}
|
|
Ok(listeners)
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct GatewayHttpLimits {
|
|
http2_max_concurrent_streams: u32,
|
|
http_header_read_timeout_ms: u64,
|
|
http_header_max_bytes: usize,
|
|
http_max_headers: usize,
|
|
}
|
|
|
|
async fn serve_gateway_router(
|
|
listeners: Vec<tokio::net::TcpListener>,
|
|
router: axum::Router,
|
|
connection_budget: Arc<HttpConnectionBudget>,
|
|
limits: GatewayHttpLimits,
|
|
shutdown: CancellationToken,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let limits = GatewayHttpLimits {
|
|
http2_max_concurrent_streams: gateway_http2_max_concurrent_streams(
|
|
limits.http2_max_concurrent_streams,
|
|
),
|
|
http_header_read_timeout_ms: gateway_http_header_read_timeout_ms(
|
|
limits.http_header_read_timeout_ms,
|
|
),
|
|
http_header_max_bytes: gateway_http_header_max_bytes(limits.http_header_max_bytes),
|
|
http_max_headers: gateway_http_max_headers(limits.http_max_headers),
|
|
};
|
|
let mut servers = tokio::task::JoinSet::new();
|
|
for listener in listeners {
|
|
let router = router.clone();
|
|
let connection_budget = Arc::clone(&connection_budget);
|
|
let shutdown = shutdown.clone();
|
|
servers.spawn(async move {
|
|
serve_gateway_listener(listener, router, connection_budget, limits, shutdown).await
|
|
});
|
|
}
|
|
let mut failure = None;
|
|
while let Some(result) = servers.join_next().await {
|
|
let result = result.unwrap_or_else(|err| {
|
|
Err(std::io::Error::other(format!(
|
|
"gateway listener task failed: {err}"
|
|
)))
|
|
});
|
|
if let Err(error) = result {
|
|
failure.get_or_insert(error);
|
|
shutdown.cancel();
|
|
connection_budget.force_close();
|
|
}
|
|
}
|
|
if let Some(error) = failure {
|
|
return Err(error.into());
|
|
}
|
|
// Hyper hands upgrades to application tasks; their IO still owns this budget.
|
|
while connection_budget.snapshot().in_flight != 0 {
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn serve_gateway_listener(
|
|
listener: tokio::net::TcpListener,
|
|
router: axum::Router,
|
|
connection_budget: Arc<HttpConnectionBudget>,
|
|
limits: GatewayHttpLimits,
|
|
shutdown: CancellationToken,
|
|
) -> Result<(), std::io::Error> {
|
|
let GatewayHttpLimits {
|
|
http2_max_concurrent_streams,
|
|
http_header_read_timeout_ms,
|
|
http_header_max_bytes,
|
|
http_max_headers,
|
|
} = limits;
|
|
let mut make_service = router.into_make_service_with_connect_info::<std::net::SocketAddr>();
|
|
let mut connections = tokio::task::JoinSet::new();
|
|
loop {
|
|
let (io, remote_addr) = tokio::select! {
|
|
biased;
|
|
_ = shutdown.cancelled() => break,
|
|
_ = connections.join_next(), if !connections.is_empty() => continue,
|
|
accepted = connection_budget.accept(&listener) => accepted,
|
|
};
|
|
let Ok(io) = connection_budget.try_admit(io) else {
|
|
tokio::task::yield_now().await;
|
|
continue;
|
|
};
|
|
let tower_service = make_service
|
|
.call(remote_addr)
|
|
.await
|
|
.unwrap_or_else(|err| match err {})
|
|
.map_request(|req: Request<Incoming>| req.map(Body::new));
|
|
let first_request_gate = GatewayFirstRequestGate::new();
|
|
let hyper_service = TowerToHyperService::new(GatewayFirstRequestService {
|
|
inner: tower_service,
|
|
gate: first_request_gate.clone(),
|
|
});
|
|
let io = TokioIo::new(io);
|
|
|
|
let shutdown = shutdown.clone();
|
|
let connection_budget = Arc::clone(&connection_budget);
|
|
connections.spawn(async move {
|
|
let mut builder = HyperServerBuilder::new(TokioExecutor::new());
|
|
// Hyper's HTTP/1 header timer is opt-in when using the custom
|
|
// connection builder. Configure both protocol parsers explicitly:
|
|
// HTTP/1 gets a slow-header deadline and bounded parser buffer;
|
|
// HTTP/2 gets a decompressed header-list limit. The timer is
|
|
// connection metadata protection and does not affect request body
|
|
// streaming or the configured stream concurrency.
|
|
builder
|
|
.http1()
|
|
.timer(TokioTimer::new())
|
|
.header_read_timeout(std::time::Duration::from_millis(
|
|
http_header_read_timeout_ms,
|
|
))
|
|
.max_buf_size(http_header_max_bytes)
|
|
.max_headers(http_max_headers);
|
|
builder.http2().enable_connect_protocol();
|
|
builder
|
|
.http2()
|
|
.timer(TokioTimer::new())
|
|
.max_concurrent_streams(http2_max_concurrent_streams)
|
|
.max_header_list_size(u32::try_from(http_header_max_bytes).unwrap_or(u32::MAX));
|
|
|
|
// The auto builder reads the HTTP/2 preface before Hyper's H1
|
|
// header timer starts, and H2 has no header-read timer of its own.
|
|
// Race the whole connection until the first valid request reaches
|
|
// the service so a peer cannot hold a socket open while dribbling
|
|
// protocol bytes or an initial header block. Once the gate opens,
|
|
// request and response bodies remain fully streaming.
|
|
let connection = builder.serve_connection_with_upgrades(io, hyper_service);
|
|
tokio::pin!(connection);
|
|
let draining_connection = async {
|
|
tokio::select! {
|
|
result = &mut connection => result,
|
|
_ = shutdown.cancelled() => {
|
|
connection.as_mut().graceful_shutdown();
|
|
connection.await
|
|
}
|
|
}
|
|
};
|
|
let connection_result = tokio::select! {
|
|
biased;
|
|
_ = connection_budget.wait_for_forced_close() => Ok(()),
|
|
result = drive_gateway_connection(
|
|
draining_connection,
|
|
first_request_gate,
|
|
std::time::Duration::from_millis(http_header_read_timeout_ms),
|
|
) => result,
|
|
};
|
|
if let Err(err) = connection_result {
|
|
tracing::trace!(error = ?err, "gateway connection closed with error");
|
|
}
|
|
});
|
|
}
|
|
drop(listener);
|
|
while connections.join_next().await.is_some() {}
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_local_http_base_url(app_port: u16) -> Result<String, std::io::Error> {
|
|
Ok(format!("http://127.0.0.1:{}", validate_app_port(app_port)?))
|
|
}
|
|
|
|
fn resolve_healthcheck_url(app_port: u16) -> Result<String, std::io::Error> {
|
|
Ok(format!("{}/health", resolve_local_http_base_url(app_port)?))
|
|
}
|
|
|
|
async fn run_healthcheck(
|
|
app_port: u16,
|
|
healthcheck_timeout_ms: u64,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let url = resolve_healthcheck_url(app_port)?;
|
|
reqwest::Client::builder()
|
|
.no_proxy()
|
|
.timeout(std::time::Duration::from_millis(
|
|
healthcheck_timeout_ms.max(1),
|
|
))
|
|
.build()?
|
|
.get(url)
|
|
.send()
|
|
.await?
|
|
.error_for_status()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_deployment_topology(
|
|
args: &Args,
|
|
database: Option<&SqlDatabaseConfig>,
|
|
data_redis_url: Option<&str>,
|
|
runtime_backend: RuntimeBackendArg,
|
|
) -> Result<(), std::io::Error> {
|
|
if matches!(args.deployment_topology, DeploymentTopologyArg::SingleNode) {
|
|
if database.is_none() && data_redis_url.is_none() {
|
|
warn!(
|
|
"single-node deployment is starting without SQL database or Redis; local-only mode is allowed, but admin/auth/billing persistence will be limited"
|
|
);
|
|
}
|
|
if matches!(runtime_backend, RuntimeBackendArg::Redis) && data_redis_url.is_none() {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"AETHER_RUNTIME_BACKEND=redis requires REDIS_URL or AETHER_GATEWAY_DATA_REDIS_URL",
|
|
));
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
if matches!(args.node_role, NodeRoleArg::All) {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"AETHER_GATEWAY_NODE_ROLE=all is only valid for single-node deployment; use frontdoor or background when AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node",
|
|
));
|
|
}
|
|
|
|
let mut missing = Vec::new();
|
|
if database.is_none() {
|
|
missing.push("AETHER_DATABASE_URL, DATABASE_URL, or AETHER_GATEWAY_DATA_POSTGRES_URL");
|
|
}
|
|
if data_redis_url.is_none() {
|
|
missing.push("REDIS_URL or AETHER_GATEWAY_DATA_REDIS_URL");
|
|
}
|
|
|
|
if !missing.is_empty() {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
format!(
|
|
"multi-node deployment requires shared data backends; missing {}",
|
|
missing.join(", ")
|
|
),
|
|
));
|
|
}
|
|
|
|
if matches!(runtime_backend, RuntimeBackendArg::Memory) {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"AETHER_RUNTIME_BACKEND=memory is only valid for single-node deployment",
|
|
));
|
|
}
|
|
|
|
if args
|
|
.video_task_store_path
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.is_some_and(|value| !value.is_empty())
|
|
{
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"AETHER_GATEWAY_VIDEO_TASK_STORE_PATH must be unset when AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node; use shared SQL-backed state instead",
|
|
));
|
|
}
|
|
|
|
if env_var_trimmed("AETHER_GATEWAY_INSTANCE_ID").is_none() {
|
|
warn!(
|
|
"multi-node deployment started without AETHER_GATEWAY_INSTANCE_ID; this is acceptable for stateless frontdoor replicas, but tunnel owner routing should set an explicit per-node instance id"
|
|
);
|
|
}
|
|
if env_var_trimmed("AETHER_TUNNEL_RELAY_BASE_URL").is_none() {
|
|
warn!(
|
|
"multi-node deployment started without AETHER_TUNNEL_RELAY_BASE_URL; frontdoor replicas are fine, but proxy tunnel owner relay cannot forward across nodes until a per-node reachable base URL is configured"
|
|
);
|
|
}
|
|
if !matches!(
|
|
args.video_task_truth_source_mode,
|
|
VideoTaskTruthSourceArg::RustAuthoritative
|
|
) {
|
|
warn!(
|
|
"multi-node deployment is still using python-sync-report video task truth source; keep rust-authoritative as the long-term cluster baseline"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
let _log_shutdown = aether_runtime::LogShutdownGuard::new();
|
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.thread_stack_size(GATEWAY_TOKIO_WORKER_STACK_SIZE_BYTES)
|
|
.build()?;
|
|
let result = runtime.block_on(run());
|
|
aether_usage_runtime::shutdown_usage_background_runtime(std::time::Duration::from_secs(5));
|
|
result
|
|
}
|
|
|
|
async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|
let args = Args::parse();
|
|
if let Some(command) = args.command.as_ref() {
|
|
init_service_runtime(args.runtime_config()?)?;
|
|
// Data export/import can decrypt and persist sensitive credentials;
|
|
// apply the same encryption-key policy as the normal gateway path
|
|
// before touching the selected database.
|
|
if matches!(command, DataCommand::Export(_) | DataCommand::Import(_)) {
|
|
args.data.validate_encryption_key()?;
|
|
}
|
|
return run_data_command(command, &args.data).await;
|
|
}
|
|
if args.migrate {
|
|
init_service_runtime(args.runtime_config()?)?;
|
|
return run_explicit_migrations(&args).await;
|
|
}
|
|
if args.apply_backfills {
|
|
init_service_runtime(args.runtime_config()?)?;
|
|
return run_explicit_backfills(&args).await;
|
|
}
|
|
let app_port = validate_app_port(args.app_port)?;
|
|
let bind_addr = gateway_bind_addr(app_port)?;
|
|
set_gateway_frontdoor_app_port(app_port);
|
|
if args.healthcheck {
|
|
return run_healthcheck(app_port, args.healthcheck_timeout_ms).await;
|
|
}
|
|
init_service_runtime(args.runtime_config()?)?;
|
|
let sql_database_config = args.data.effective_sql_database_config();
|
|
let data_redis_url = args.data.effective_redis_url();
|
|
let runtime_backend =
|
|
args.effective_runtime_backend(sql_database_config.as_ref(), data_redis_url.as_deref());
|
|
let runtime_redis_url = args.effective_runtime_redis_url(data_redis_url.as_deref());
|
|
validate_deployment_topology(
|
|
&args,
|
|
sql_database_config.as_ref(),
|
|
runtime_redis_url.as_deref(),
|
|
runtime_backend,
|
|
)?;
|
|
args.data.validate_encryption_key()?;
|
|
let data_config = args.data.to_config();
|
|
let isolate_background_database = args.node_role.isolates_background_database();
|
|
let background_database_config = if isolate_background_database {
|
|
data_config.background_database_config()
|
|
} else {
|
|
None
|
|
};
|
|
let usage_database_is_isolated =
|
|
isolate_background_database && background_database_config.is_some();
|
|
let usage_database_config = usage_database_config_for_role(
|
|
args.node_role,
|
|
data_config.database(),
|
|
background_database_config.as_ref(),
|
|
);
|
|
let request_concurrency_limit = args
|
|
.max_in_flight_requests
|
|
.filter(|limit| *limit > 0)
|
|
.unwrap_or_else(automatic_gateway_request_concurrency);
|
|
let websocket_connection_limit = args
|
|
.max_websocket_connections
|
|
.filter(|limit| *limit > 0)
|
|
.unwrap_or(request_concurrency_limit);
|
|
let http_connection_limit = http_connection_limit(
|
|
args.max_http_connections,
|
|
request_concurrency_limit,
|
|
websocket_connection_limit,
|
|
soft_fd_limit(),
|
|
);
|
|
let http_connection_budget = Arc::new(HttpConnectionBudget::new(http_connection_limit));
|
|
let distributed_websocket_connection_limit = match args.distributed_websocket_connection_limit {
|
|
Some(limit) if limit > 0 => Some(limit),
|
|
Some(_) => None,
|
|
None => args.distributed_request_limit.filter(|limit| *limit > 0),
|
|
};
|
|
let usage_queue_request_concurrency_hint = usage_queue_request_concurrency_hint(
|
|
Some(request_concurrency_limit),
|
|
args.distributed_request_limit,
|
|
);
|
|
let usage_queue_request_concurrency_hint_source =
|
|
if args.max_in_flight_requests.is_some() || args.distributed_request_limit.is_some() {
|
|
"explicit"
|
|
} else {
|
|
"auto"
|
|
};
|
|
let usage_queue_workers = args.usage.effective_queue_workers(
|
|
args.node_role,
|
|
Some(request_concurrency_limit),
|
|
args.distributed_request_limit,
|
|
usage_database_config,
|
|
usage_database_is_isolated,
|
|
);
|
|
let usage_queue_worker_max_count = args.usage.effective_queue_worker_max_count(
|
|
args.node_role,
|
|
usage_database_config,
|
|
usage_queue_workers,
|
|
usage_database_is_isolated,
|
|
);
|
|
let usage_worker_record_concurrency_limit =
|
|
args.usage.effective_worker_record_concurrency_limit(
|
|
args.node_role,
|
|
usage_database_config,
|
|
usage_database_is_isolated,
|
|
);
|
|
let usage_config = args.usage.to_config(
|
|
usage_queue_workers,
|
|
usage_queue_worker_max_count,
|
|
usage_worker_record_concurrency_limit,
|
|
);
|
|
let usage_blocking_stream_lanes = args.usage.runtime_state_blocking_stream_lanes(
|
|
args.node_role,
|
|
usage_database_config,
|
|
usage_config.worker_max_count,
|
|
);
|
|
let runtime_state = Arc::new(
|
|
RuntimeState::from_config(args.runtime_state_config(
|
|
runtime_backend,
|
|
data_redis_url.as_deref(),
|
|
usage_blocking_stream_lanes,
|
|
))
|
|
.await
|
|
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err.to_string()))?,
|
|
);
|
|
let rate_limit_config = if matches!(args.deployment_topology, DeploymentTopologyArg::MultiNode)
|
|
{
|
|
args.rate_limit.config().with_local_fallback(false)
|
|
} else {
|
|
args.rate_limit.config()
|
|
};
|
|
if args.data.configured_encryption_key_mismatch() {
|
|
warn!(
|
|
"AETHER_GATEWAY_DATA_ENCRYPTION_KEY differs from ENCRYPTION_KEY; aether-gateway will prefer the gateway-specific value"
|
|
);
|
|
}
|
|
info!(
|
|
event_name = "gateway_starting",
|
|
log_type = "ops",
|
|
bind = %bind_addr,
|
|
app_port,
|
|
environment = %args.frontdoor.environment,
|
|
deployment_topology = args.deployment_topology.as_str(),
|
|
node_role = args.node_role.as_str(),
|
|
runtime_backend = runtime_backend.as_str(),
|
|
usage_queue_workers = usage_config.worker_count,
|
|
usage_queue_worker_autoscale_enabled = usage_config.worker_autoscale_enabled,
|
|
usage_queue_worker_max_count = usage_config.worker_max_count,
|
|
usage_worker_record_concurrency_limit = usage_config
|
|
.worker_record_concurrency_limit
|
|
.unwrap_or_default(),
|
|
usage_queue_request_concurrency_hint =
|
|
usage_queue_request_concurrency_hint.unwrap_or_default(),
|
|
usage_queue_request_concurrency_hint_source,
|
|
frontdoor_mode = "compatibility_frontdoor",
|
|
log_format = ?args.logging.log_format,
|
|
log_destination = args.logging.log_destination.as_str(),
|
|
video_task_truth_source_mode = ?args.video_task_truth_source_mode,
|
|
"aether-gateway starting"
|
|
);
|
|
debug!(
|
|
event_name = "gateway_startup_config",
|
|
log_type = "ops",
|
|
log_dir = args.logging.log_dir.as_deref().unwrap_or("-"),
|
|
log_rotation = args.logging.log_rotation.as_str(),
|
|
log_retention_days = args.logging.log_retention_days,
|
|
log_max_files = args.logging.log_max_files,
|
|
static_dir = args.static_dir.as_deref().unwrap_or("-"),
|
|
cors_origins = args.frontdoor.cors_origins.as_deref().unwrap_or("-"),
|
|
cors_allow_credentials = args.frontdoor.cors_allow_credentials,
|
|
frontdoor_rpm_bucket_seconds = args.rate_limit.bucket_seconds,
|
|
frontdoor_rpm_key_ttl_seconds = args.rate_limit.key_ttl_seconds,
|
|
frontdoor_rpm_fail_open = args.rate_limit.fail_open,
|
|
frontdoor_rpm_allow_local_fallback = rate_limit_config.allow_local_fallback(),
|
|
video_task_poller_interval_ms = args.video_task_poller_interval_ms,
|
|
video_task_poller_batch_size = args.video_task_poller_batch_size,
|
|
video_task_store_path = args.video_task_store_path.as_deref().unwrap_or("-"),
|
|
usage_queue_workers = usage_config.worker_count,
|
|
usage_queue_workers_source = if args.usage.queue_workers.is_some() {
|
|
"explicit"
|
|
} else {
|
|
"auto"
|
|
},
|
|
usage_queue_worker_autoscale_enabled = usage_config.worker_autoscale_enabled,
|
|
usage_queue_worker_max_count = usage_config.worker_max_count,
|
|
usage_worker_record_concurrency_limit = usage_config
|
|
.worker_record_concurrency_limit
|
|
.unwrap_or_default(),
|
|
usage_queue_request_concurrency_hint =
|
|
usage_queue_request_concurrency_hint.unwrap_or_default(),
|
|
usage_queue_request_concurrency_hint_source,
|
|
max_in_flight_requests = request_concurrency_limit,
|
|
max_in_flight_requests_source = if args.max_in_flight_requests.is_some() {
|
|
"explicit"
|
|
} else {
|
|
"auto"
|
|
},
|
|
distributed_request_limit = args.distributed_request_limit.unwrap_or_default(),
|
|
max_websocket_connections = websocket_connection_limit,
|
|
max_websocket_connections_source = if args.max_websocket_connections.is_some() {
|
|
"explicit"
|
|
} else {
|
|
"request_concurrency_fallback"
|
|
},
|
|
distributed_websocket_connection_limit =
|
|
distributed_websocket_connection_limit.unwrap_or_default(),
|
|
distributed_request_redis_configured = args
|
|
.distributed_request_redis_url
|
|
.as_deref()
|
|
.or(runtime_redis_url.as_deref())
|
|
.is_some(),
|
|
data_database_configured = sql_database_config.is_some(),
|
|
data_database_driver = sql_database_config
|
|
.as_ref()
|
|
.map(|database| database.driver.as_str())
|
|
.unwrap_or("-"),
|
|
data_database_pool_min_connections = sql_database_config
|
|
.as_ref()
|
|
.map(|database| database.pool.min_connections)
|
|
.unwrap_or_default(),
|
|
data_database_pool_max_connections = sql_database_config
|
|
.as_ref()
|
|
.map(|database| database.pool.max_connections)
|
|
.unwrap_or_default(),
|
|
data_postgres_configured = sql_database_config
|
|
.as_ref()
|
|
.is_some_and(|database| database.driver == DatabaseDriver::Postgres),
|
|
runtime_redis_configured = matches!(runtime_backend, RuntimeBackendArg::Redis),
|
|
data_redis_url_supplied = data_redis_url.is_some(),
|
|
data_has_encryption_key = data_config.encryption_key().is_some(),
|
|
data_postgres_require_ssl = args.data.postgres_require_ssl,
|
|
"aether-gateway startup configuration"
|
|
);
|
|
|
|
let mut state = AppState::new()?
|
|
.with_runtime_state(runtime_state)
|
|
.with_data_config_and_background_isolation(data_config, isolate_background_database)?
|
|
.with_usage_runtime_config(usage_config)?
|
|
.with_video_task_truth_source_mode(args.video_task_truth_source_mode.into());
|
|
if let Some(cors_config) = args.frontdoor.cors_config() {
|
|
state = state.with_frontdoor_cors_config(cors_config);
|
|
}
|
|
state = state.with_frontdoor_user_rpm_config(rate_limit_config);
|
|
if matches!(
|
|
args.video_task_truth_source_mode,
|
|
VideoTaskTruthSourceArg::RustAuthoritative
|
|
) {
|
|
state = state.with_video_task_poller_config(
|
|
std::time::Duration::from_millis(args.video_task_poller_interval_ms.max(1)),
|
|
args.video_task_poller_batch_size.max(1),
|
|
);
|
|
}
|
|
if let Some(path) = args
|
|
.video_task_store_path
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
state = state.with_video_task_store_path(path)?;
|
|
}
|
|
state = state
|
|
.with_request_concurrency_limit(request_concurrency_limit)
|
|
.with_websocket_connection_limit(websocket_connection_limit)
|
|
.with_http_connection_budget(Arc::clone(&http_connection_budget));
|
|
if let Some(limit) = args.distributed_request_limit.filter(|limit| *limit > 0) {
|
|
let distributed_gate = state
|
|
.runtime_state()
|
|
.semaphore(
|
|
"gateway_requests_distributed",
|
|
limit,
|
|
RuntimeSemaphoreConfig {
|
|
lease_ttl_ms: args.distributed_request_lease_ttl_ms.max(1),
|
|
renew_interval_ms: args.distributed_request_renew_interval_ms.max(1),
|
|
command_timeout_ms: Some(args.distributed_request_command_timeout_ms.max(1)),
|
|
},
|
|
)
|
|
.map_err(|err| {
|
|
std::io::Error::new(std::io::ErrorKind::InvalidInput, err.to_string())
|
|
})?;
|
|
state = state.with_distributed_request_concurrency_gate(distributed_gate);
|
|
}
|
|
if let Some(limit) = distributed_websocket_connection_limit {
|
|
let distributed_gate = state
|
|
.runtime_state()
|
|
.semaphore(
|
|
"gateway_websocket_connections_distributed",
|
|
limit,
|
|
RuntimeSemaphoreConfig {
|
|
lease_ttl_ms: args.distributed_request_lease_ttl_ms.max(1),
|
|
renew_interval_ms: args.distributed_request_renew_interval_ms.max(1),
|
|
command_timeout_ms: Some(args.distributed_request_command_timeout_ms.max(1)),
|
|
},
|
|
)
|
|
.map_err(|err| {
|
|
std::io::Error::new(std::io::ErrorKind::InvalidInput, err.to_string())
|
|
})?;
|
|
state = state.with_distributed_websocket_connection_gate(distributed_gate);
|
|
}
|
|
if matches!(args.deployment_topology, DeploymentTopologyArg::MultiNode)
|
|
&& !state.has_usage_data_writer()
|
|
{
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"usage persistence requires a configured shared SQL data backend; set AETHER_DATABASE_DRIVER and AETHER_DATABASE_URL before starting aether-gateway",
|
|
)
|
|
.into());
|
|
}
|
|
if matches!(args.deployment_topology, DeploymentTopologyArg::SingleNode)
|
|
&& !state.has_usage_data_writer()
|
|
{
|
|
warn!(
|
|
"usage persistence backend is not configured; single-node local-only mode will run without durable usage records"
|
|
);
|
|
}
|
|
info!(
|
|
has_data_backends = state.has_data_backends(),
|
|
has_video_task_data_reader = state.has_video_task_data_reader(),
|
|
has_usage_data_writer = state.has_usage_data_writer(),
|
|
has_usage_worker_backend = state.has_usage_worker_backend(),
|
|
control_api_configured = true,
|
|
execution_runtime_configured = state.execution_runtime_configured(),
|
|
"aether-gateway data layer configured"
|
|
);
|
|
prepare_database_startup_requirements(&state, args.effective_database_mode()).await?;
|
|
state.warm_database_pools().await?;
|
|
let reset_stale_proxy_nodes = state.reset_stale_proxy_node_tunnel_statuses().await?;
|
|
if reset_stale_proxy_nodes > 0 {
|
|
info!(
|
|
reset_stale_proxy_nodes,
|
|
"reset stale tunnel-connected proxy nodes on startup"
|
|
);
|
|
}
|
|
state.bootstrap_admin_from_env().await?;
|
|
match state.ensure_system_default_routing_group().await {
|
|
Ok(Some(group)) => {
|
|
info!(
|
|
group_id = %group.id,
|
|
group_name = %group.name,
|
|
"created system default routing group from routing strategy defaults"
|
|
);
|
|
}
|
|
Ok(None) => {}
|
|
Err(err) => return Err(err.into()),
|
|
}
|
|
match state.prewarm_chat_pii_redaction_runtime_config().await {
|
|
Ok(enabled) => {
|
|
info!(
|
|
chat_pii_redaction_enabled = enabled,
|
|
"prewarmed chat pii redaction runtime config"
|
|
);
|
|
}
|
|
Err(err) => {
|
|
warn!(
|
|
error = %err,
|
|
"failed to prewarm chat pii redaction runtime config"
|
|
);
|
|
}
|
|
}
|
|
match state.prewarm_codex_client_profile().await {
|
|
Ok(version) => {
|
|
info!(
|
|
codex_client_version = %version,
|
|
"prewarmed Codex client profile"
|
|
);
|
|
}
|
|
Err(err) => {
|
|
warn!(
|
|
error = %err,
|
|
"failed to refresh Codex client profile; built-in or cached profile remains active"
|
|
);
|
|
}
|
|
}
|
|
match prewarm_direct_h2c_sender_cache_from_env_for_startup().await {
|
|
Ok(Some(report)) => {
|
|
if report.failed_targets > 0 {
|
|
warn!(
|
|
requested_urls = report.requested_urls,
|
|
unique_targets = report.unique_targets,
|
|
warmed_targets = report.warmed_targets,
|
|
failed_targets = report.failed_targets,
|
|
ready_required = report.ready_required,
|
|
first_error = ?report.first_error,
|
|
"direct h2c sender cache prewarm completed with failures"
|
|
);
|
|
} else {
|
|
info!(
|
|
requested_urls = report.requested_urls,
|
|
unique_targets = report.unique_targets,
|
|
warmed_targets = report.warmed_targets,
|
|
ready_required = report.ready_required,
|
|
"direct h2c sender cache prewarmed"
|
|
);
|
|
}
|
|
}
|
|
Ok(None) => {}
|
|
Err(err) => {
|
|
return Err(std::io::Error::other(err).into());
|
|
}
|
|
}
|
|
|
|
let background_tasks = if args.node_role.spawns_background_tasks() {
|
|
Some(state.spawn_background_tasks())
|
|
} else {
|
|
info!(
|
|
node_role = args.node_role.as_str(),
|
|
"background workers disabled for this node role"
|
|
);
|
|
None
|
|
};
|
|
if state.prewarm_metric_snapshot().await {
|
|
info!("gateway metric snapshot prewarmed");
|
|
} else {
|
|
warn!(
|
|
"gateway metric snapshot prewarm did not complete; continuing with fail-open metrics"
|
|
);
|
|
}
|
|
let listen_backlog = gateway_listen_backlog(args.listen_backlog);
|
|
let listener_shards = gateway_listener_shards(args.listener_shards);
|
|
let listeners = gateway_listeners(bind_addr, listen_backlog, listener_shards)?;
|
|
let public_base_url = resolve_local_http_base_url(app_port)?;
|
|
let frontdoor_health_url = format!("{public_base_url}/_gateway/health");
|
|
let shutdown_state = state.clone();
|
|
let api_router = build_router_with_state(state);
|
|
|
|
// Compose the final router: API routes + optional static file serving.
|
|
let router = if let Some(ref static_dir) = args.static_dir {
|
|
use tower_http::compression::CompressionLayer;
|
|
info!(static_dir = %static_dir, "serving frontend static files");
|
|
|
|
attach_static_frontend(api_router, static_dir).layer(CompressionLayer::new())
|
|
} else {
|
|
api_router
|
|
};
|
|
|
|
info!(
|
|
event_name = "gateway_ready",
|
|
log_type = "ops",
|
|
bind = %bind_addr,
|
|
app_port,
|
|
listen_backlog,
|
|
listener_shards,
|
|
max_http_connections = http_connection_limit,
|
|
http2_max_concurrent_streams = gateway_http2_max_concurrent_streams(args.http2_max_concurrent_streams),
|
|
public_url = %public_base_url,
|
|
healthcheck_url = %frontdoor_health_url,
|
|
legacy_route_policy = "fail_closed",
|
|
"aether-gateway ready"
|
|
);
|
|
|
|
let shutdown = CancellationToken::new();
|
|
let serve_result = {
|
|
let server = serve_gateway_router(
|
|
listeners,
|
|
router,
|
|
Arc::clone(&http_connection_budget),
|
|
GatewayHttpLimits {
|
|
http2_max_concurrent_streams: args.http2_max_concurrent_streams,
|
|
http_header_read_timeout_ms: args.http_header_read_timeout_ms,
|
|
http_header_max_bytes: args.http_header_max_bytes,
|
|
http_max_headers: args.http_max_headers,
|
|
},
|
|
shutdown.clone(),
|
|
);
|
|
tokio::pin!(server);
|
|
tokio::select! {
|
|
result = &mut server => result,
|
|
signal = aether_runtime::wait_for_shutdown_signal() => {
|
|
signal?;
|
|
info!("shutdown signal received, draining gateway requests");
|
|
shutdown.cancel();
|
|
match tokio::time::timeout(
|
|
std::time::Duration::from_millis(args.http_shutdown_timeout_ms),
|
|
&mut server,
|
|
).await {
|
|
Ok(result) => result,
|
|
Err(_) => {
|
|
warn!(
|
|
event_name = "gateway_http_shutdown_deadline",
|
|
connections = http_connection_budget.snapshot().in_flight,
|
|
"HTTP drain deadline reached; closing remaining sockets"
|
|
);
|
|
http_connection_budget.force_close();
|
|
match tokio::time::timeout(std::time::Duration::from_secs(5), &mut server).await {
|
|
Ok(result) => result,
|
|
Err(_) => Err(std::io::Error::new(std::io::ErrorKind::TimedOut,
|
|
"gateway connection tasks did not stop after forced close").into()),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
let usage_result = shutdown_state
|
|
.shutdown_usage_runtime(std::time::Duration::from_millis(
|
|
args.usage_shutdown_timeout_ms,
|
|
))
|
|
.await;
|
|
if let Some(background_tasks) = background_tasks {
|
|
background_tasks.shutdown().await;
|
|
}
|
|
serve_result?;
|
|
usage_result?;
|
|
info!(
|
|
event_name = "gateway_shutdown_complete",
|
|
"gateway local persistence drained"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_data_command(
|
|
command: &DataCommand,
|
|
data: &GatewayDataArgs,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
match command {
|
|
DataCommand::Export(args) => run_data_export(args, data).await,
|
|
DataCommand::Import(args) => run_data_import(args, data).await,
|
|
DataCommand::Copy(args) => run_data_copy(args).await,
|
|
DataCommand::Db(args) => run_database_command(args, data).await,
|
|
}
|
|
}
|
|
|
|
async fn run_database_command(
|
|
args: &DatabaseCommandArgs,
|
|
data: &GatewayDataArgs,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
match args.command {
|
|
DatabaseCommand::Status => run_database_status(data).await,
|
|
DatabaseCommand::Prepare => run_database_prepare(data).await,
|
|
}
|
|
}
|
|
|
|
fn database_maintenance_state(
|
|
data: &GatewayDataArgs,
|
|
) -> Result<(DatabaseDriver, AppState), Box<dyn std::error::Error>> {
|
|
let database = required_sql_database_config(data)?;
|
|
let driver = database.driver;
|
|
let state = AppState::new()?.with_data_config(data.to_config())?;
|
|
Ok((driver, state))
|
|
}
|
|
|
|
async fn run_database_status(data: &GatewayDataArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
let (driver, state) = database_maintenance_state(data)?;
|
|
let pending_migrations = state
|
|
.pending_database_migrations()
|
|
.await?
|
|
.unwrap_or_default();
|
|
|
|
if let Some(next) = pending_migrations.first() {
|
|
println!("database {driver}: preparation required");
|
|
println!("pending migrations: {}", pending_migrations.len());
|
|
println!("next migration: {} ({})", next.version, next.description);
|
|
println!("pending backfills: not checked until migrations are current");
|
|
println!("run `aether-gateway db prepare`");
|
|
return Ok(());
|
|
}
|
|
|
|
let pending_backfills = state
|
|
.pending_database_backfills()
|
|
.await?
|
|
.unwrap_or_default();
|
|
if let Some(next) = pending_backfills.first() {
|
|
println!("database {driver}: preparation required");
|
|
println!("pending migrations: 0");
|
|
println!("pending backfills: {}", pending_backfills.len());
|
|
println!("next backfill: {} ({})", next.version, next.description);
|
|
println!("run `aether-gateway db prepare`");
|
|
return Ok(());
|
|
}
|
|
|
|
println!("database {driver}: ready (schema and backfills are current)");
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_database_prepare(data: &GatewayDataArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
let (driver, state) = database_maintenance_state(data)?;
|
|
prepare_database_startup_requirements(&state, DatabaseModeArg::Auto).await?;
|
|
println!("database {driver}: ready (schema and backfills are current)");
|
|
Ok(())
|
|
}
|
|
|
|
fn required_sql_database_config(
|
|
data: &GatewayDataArgs,
|
|
) -> Result<SqlDatabaseConfig, Box<dyn std::error::Error>> {
|
|
data.effective_sql_database_config().ok_or_else(|| {
|
|
std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"AETHER_DATABASE_DRIVER/AETHER_DATABASE_URL, AETHER_GATEWAY_DATA_POSTGRES_URL, or DATABASE_URL is required",
|
|
)
|
|
.into()
|
|
})
|
|
}
|
|
|
|
fn requested_export_domains(args: &DataExportArgs) -> Vec<ExportDomain> {
|
|
requested_domains(&args.domains)
|
|
}
|
|
|
|
fn requested_domains(domains: &[ExportDomainArg]) -> Vec<ExportDomain> {
|
|
domains.iter().copied().map(Into::into).collect::<Vec<_>>()
|
|
}
|
|
|
|
fn current_unix_secs() -> Result<u64, std::time::SystemTimeError> {
|
|
Ok(std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)?
|
|
.as_secs())
|
|
}
|
|
|
|
async fn run_data_export(
|
|
args: &DataExportArgs,
|
|
data: &GatewayDataArgs,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let database = required_sql_database_config(data)?;
|
|
let driver = database.driver;
|
|
let domains = requested_export_domains(args);
|
|
let created_at_unix_secs = current_unix_secs()?;
|
|
let encoded = export_database_jsonl(database, domains, created_at_unix_secs).await?;
|
|
|
|
write_atomic_private_export(&args.output, encoded.as_bytes(), args.overwrite)?;
|
|
info!(
|
|
driver = %driver,
|
|
output = %args.output.display(),
|
|
bytes = encoded.len(),
|
|
"database export complete"
|
|
);
|
|
println!(
|
|
"exported {} bytes from {} to {}",
|
|
encoded.len(),
|
|
driver,
|
|
args.output.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn write_atomic_private_export(path: &Path, bytes: &[u8], overwrite: bool) -> io::Result<()> {
|
|
#[cfg(not(unix))]
|
|
{
|
|
let _ = (path, bytes, overwrite);
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::Unsupported,
|
|
"private atomic database exports currently require Unix filesystem checks",
|
|
));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::ffi::CString;
|
|
use std::os::fd::{AsRawFd, FromRawFd};
|
|
use std::os::unix::ffi::OsStrExt;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let file_name = path.file_name().ok_or_else(|| {
|
|
io::Error::new(io::ErrorKind::InvalidInput, "export path must name a file")
|
|
})?;
|
|
let input_parent = path
|
|
.parent()
|
|
.filter(|parent| !parent.as_os_str().is_empty())
|
|
.unwrap_or_else(|| Path::new("."));
|
|
let parent = open_private_export_directory(input_parent)?;
|
|
let output_name = CString::new(file_name.as_bytes()).map_err(|_| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"export path must not contain an embedded NUL byte",
|
|
)
|
|
})?;
|
|
|
|
// Check the target through the already-open parent directory. The
|
|
// later renameat/linkat calls use that same descriptor, so replacing a
|
|
// writable ancestor cannot redirect the export to another directory.
|
|
if let Some(stat) = private_export_stat_at(&parent, &output_name)? {
|
|
let effective_uid = unsafe { libc::geteuid() };
|
|
if stat.st_mode & libc::S_IFMT != libc::S_IFREG
|
|
|| stat.st_uid != effective_uid
|
|
|| stat.st_nlink != 1
|
|
{
|
|
return Err(io::Error::other(
|
|
"export output must be a regular, single-link file owned by the current user",
|
|
));
|
|
}
|
|
if !overwrite {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::AlreadyExists,
|
|
"export output already exists; pass --overwrite to replace it",
|
|
));
|
|
}
|
|
}
|
|
|
|
let temporary_name = CString::new(format!(
|
|
".aether-data-export-{}-{}.tmp",
|
|
std::process::id(),
|
|
uuid::Uuid::new_v4()
|
|
))
|
|
.expect("generated temporary export name cannot contain NUL");
|
|
// O_EXCL + O_NOFOLLOW makes creation of the temporary file independent
|
|
// of any attacker-controlled directory entry with the same name.
|
|
let descriptor = unsafe {
|
|
libc::openat(
|
|
parent.as_raw_fd(),
|
|
temporary_name.as_ptr(),
|
|
libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
|
|
0o600,
|
|
)
|
|
};
|
|
if descriptor < 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
let mut file = unsafe { fs::File::from_raw_fd(descriptor) };
|
|
let result = (|| -> io::Result<()> {
|
|
file.set_permissions(fs::Permissions::from_mode(0o600))?;
|
|
file.write_all(bytes)?;
|
|
file.sync_all()?;
|
|
drop(file);
|
|
|
|
if overwrite {
|
|
// renameat replaces the directory entry and never dereferences
|
|
// a destination symlink. No attacker-selected file is opened
|
|
// or truncated even if the target changed after the check.
|
|
private_export_rename_at(&parent, &temporary_name, &output_name)?;
|
|
} else {
|
|
private_export_link_at(&parent, &temporary_name, &output_name).map_err(
|
|
|error| {
|
|
if error.kind() == io::ErrorKind::AlreadyExists {
|
|
io::Error::new(
|
|
io::ErrorKind::AlreadyExists,
|
|
"export output already exists; pass --overwrite to replace it",
|
|
)
|
|
} else {
|
|
error
|
|
}
|
|
},
|
|
)?;
|
|
private_export_unlink_at(&parent, &temporary_name)?;
|
|
}
|
|
parent.sync_all()
|
|
})();
|
|
if result.is_err() {
|
|
let _ = private_export_unlink_at(&parent, &temporary_name);
|
|
}
|
|
result
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn open_private_export_directory(path: &Path) -> io::Result<fs::File> {
|
|
use std::ffi::CString;
|
|
use std::os::fd::{AsRawFd, FromRawFd};
|
|
use std::os::unix::ffi::OsStrExt;
|
|
use std::path::Component;
|
|
|
|
// Walk one component at a time and retain the final descriptor. We allow
|
|
// trusted system symlink components (for example macOS `/var`), but
|
|
// validate the directory reached by every open before continuing. The
|
|
// descriptor remains pinned even if the symlink is exchanged later.
|
|
let mut directory = fs::File::open(if path.is_absolute() { "/" } else { "." })?;
|
|
validate_private_export_directory_fd(&directory, path)?;
|
|
for component in path.components() {
|
|
let name = match component {
|
|
Component::RootDir | Component::CurDir => continue,
|
|
Component::Normal(name) => name,
|
|
Component::ParentDir => {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"export path must not contain '..' components",
|
|
))
|
|
}
|
|
Component::Prefix(_) => {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"export path uses an unsupported prefix",
|
|
))
|
|
}
|
|
};
|
|
let name = CString::new(name.as_bytes()).map_err(|_| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"export directory path must not contain an embedded NUL byte",
|
|
)
|
|
})?;
|
|
let descriptor = unsafe {
|
|
libc::openat(
|
|
directory.as_raw_fd(),
|
|
name.as_ptr(),
|
|
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY,
|
|
)
|
|
};
|
|
if descriptor < 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
let next = unsafe { fs::File::from_raw_fd(descriptor) };
|
|
validate_private_export_directory_fd(&next, path)?;
|
|
directory = next;
|
|
}
|
|
Ok(directory)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn validate_private_export_directory_fd(
|
|
directory: &fs::File,
|
|
display_path: &Path,
|
|
) -> io::Result<()> {
|
|
use std::mem::MaybeUninit;
|
|
use std::os::fd::AsRawFd;
|
|
|
|
let mut stat = MaybeUninit::<libc::stat>::uninit();
|
|
let result = unsafe { libc::fstat(directory.as_raw_fd(), stat.as_mut_ptr()) };
|
|
if result != 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
let stat = unsafe { stat.assume_init() };
|
|
let effective_uid = unsafe { libc::geteuid() };
|
|
let mode = stat.st_mode;
|
|
if mode & libc::S_IFMT != libc::S_IFDIR
|
|
|| (stat.st_uid != effective_uid && stat.st_uid != 0)
|
|
|| (mode & 0o022 != 0 && mode & 0o1000 == 0)
|
|
{
|
|
return Err(io::Error::other(format!(
|
|
"export output directory '{}' has unsafe ownership or permissions",
|
|
display_path.display()
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn private_export_stat_at(
|
|
parent: &fs::File,
|
|
name: &std::ffi::CStr,
|
|
) -> io::Result<Option<libc::stat>> {
|
|
use std::mem::MaybeUninit;
|
|
use std::os::fd::AsRawFd;
|
|
|
|
let mut stat = MaybeUninit::<libc::stat>::uninit();
|
|
let result = unsafe {
|
|
libc::fstatat(
|
|
parent.as_raw_fd(),
|
|
name.as_ptr(),
|
|
stat.as_mut_ptr(),
|
|
libc::AT_SYMLINK_NOFOLLOW,
|
|
)
|
|
};
|
|
if result == 0 {
|
|
return Ok(Some(unsafe { stat.assume_init() }));
|
|
}
|
|
let error = io::Error::last_os_error();
|
|
if error.kind() == io::ErrorKind::NotFound {
|
|
Ok(None)
|
|
} else {
|
|
Err(error)
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn private_export_link_at(
|
|
parent: &fs::File,
|
|
source: &std::ffi::CStr,
|
|
destination: &std::ffi::CStr,
|
|
) -> io::Result<()> {
|
|
use std::os::fd::AsRawFd;
|
|
|
|
let result = unsafe {
|
|
libc::linkat(
|
|
parent.as_raw_fd(),
|
|
source.as_ptr(),
|
|
parent.as_raw_fd(),
|
|
destination.as_ptr(),
|
|
0,
|
|
)
|
|
};
|
|
if result == 0 {
|
|
Ok(())
|
|
} else {
|
|
Err(io::Error::last_os_error())
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn private_export_rename_at(
|
|
parent: &fs::File,
|
|
source: &std::ffi::CStr,
|
|
destination: &std::ffi::CStr,
|
|
) -> io::Result<()> {
|
|
use std::os::fd::AsRawFd;
|
|
|
|
let result = unsafe {
|
|
libc::renameat(
|
|
parent.as_raw_fd(),
|
|
source.as_ptr(),
|
|
parent.as_raw_fd(),
|
|
destination.as_ptr(),
|
|
)
|
|
};
|
|
if result == 0 {
|
|
Ok(())
|
|
} else {
|
|
Err(io::Error::last_os_error())
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn private_export_unlink_at(parent: &fs::File, name: &std::ffi::CStr) -> io::Result<()> {
|
|
use std::os::fd::AsRawFd;
|
|
|
|
let result = unsafe { libc::unlinkat(parent.as_raw_fd(), name.as_ptr(), 0) };
|
|
if result == 0 {
|
|
Ok(())
|
|
} else {
|
|
Err(io::Error::last_os_error())
|
|
}
|
|
}
|
|
|
|
async fn run_data_import(
|
|
args: &DataImportArgs,
|
|
data: &GatewayDataArgs,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let database = required_sql_database_config(data)?;
|
|
let driver = database.driver;
|
|
let input_path = args.input.clone();
|
|
let input = tokio::task::spawn_blocking(move || read_data_import_input(&input_path)).await??;
|
|
if !args.preserve_credentials {
|
|
warn!("identity credentials will be revoked; use --preserve-credentials only for trusted recovery or migration");
|
|
}
|
|
let imported = import_database_jsonl_with_options(
|
|
database,
|
|
&input,
|
|
DataImportOptions {
|
|
preserve_credentials: args.preserve_credentials,
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
info!(
|
|
driver = %driver,
|
|
input = %args.input.display(),
|
|
imported,
|
|
preserve_credentials = args.preserve_credentials,
|
|
"database import complete"
|
|
);
|
|
println!(
|
|
"imported {} records into {} from {}",
|
|
imported,
|
|
driver,
|
|
args.input.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Read a CLI JSONL import through a descriptor that cannot be redirected by a
|
|
/// later path replacement. The parser itself also enforces these limits, but
|
|
/// bounding the file read first prevents an oversized input from being held in
|
|
/// memory before validation starts.
|
|
fn read_data_import_input(path: &Path) -> io::Result<String> {
|
|
read_data_import_input_with_limit(path, MAX_JSONL_INPUT_BYTES)
|
|
}
|
|
|
|
fn read_data_import_input_with_limit(path: &Path, limit: usize) -> io::Result<String> {
|
|
let mut file = open_data_import_file(path)?;
|
|
let metadata = file.metadata()?;
|
|
if !metadata.is_file() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!(
|
|
"data import input '{}' must be a regular file",
|
|
path.display()
|
|
),
|
|
));
|
|
}
|
|
|
|
let limit_u64 = u64::try_from(limit).unwrap_or(u64::MAX);
|
|
if metadata.len() > limit_u64 {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!(
|
|
"data import input '{}' exceeds the {} byte limit",
|
|
path.display(),
|
|
limit
|
|
),
|
|
));
|
|
}
|
|
|
|
// A file can grow after metadata() returns. Reading one extra byte catches
|
|
// that race without allowing the input buffer to exceed the configured
|
|
// parser budget.
|
|
let read_limit = limit.saturating_add(1);
|
|
// Do not reserve the whole metadata length: sparse or concurrently grown
|
|
// files can advertise a huge size while containing little data, and a
|
|
// single capacity reservation would otherwise become a local DoS vector.
|
|
const MAX_INITIAL_IMPORT_READ_CAPACITY: usize = 8 * 1024 * 1024;
|
|
let initial_capacity = usize::try_from(metadata.len())
|
|
.unwrap_or(limit)
|
|
.min(limit)
|
|
.min(MAX_INITIAL_IMPORT_READ_CAPACITY);
|
|
let mut bytes = Vec::with_capacity(initial_capacity.min(read_limit));
|
|
// Read in fixed-size chunks instead of `read_to_end`: the latter may use a
|
|
// file's attacker-controlled size hint to reserve a large buffer before
|
|
// the limit check runs. Reserve only the exact next chunk so capacity
|
|
// stays close to the configured `limit + 1` budget.
|
|
let mut chunk = [0_u8; 32 * 1024];
|
|
while bytes.len() < read_limit {
|
|
let remaining = read_limit - bytes.len();
|
|
let chunk_len = remaining.min(chunk.len());
|
|
let read = file.read(&mut chunk[..chunk_len])?;
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
bytes.try_reserve_exact(read).map_err(|error| {
|
|
io::Error::other(format!(
|
|
"data import input buffer allocation failed: {error}"
|
|
))
|
|
})?;
|
|
bytes.extend_from_slice(&chunk[..read]);
|
|
}
|
|
if bytes.len() > limit {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!(
|
|
"data import input '{}' exceeds the {} byte limit",
|
|
path.display(),
|
|
limit
|
|
),
|
|
));
|
|
}
|
|
|
|
String::from_utf8(bytes).map_err(|error| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"data import input '{}' is not valid UTF-8: {error}",
|
|
path.display()
|
|
),
|
|
)
|
|
})
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn open_data_import_file(path: &Path) -> io::Result<fs::File> {
|
|
use std::ffi::CString;
|
|
use std::os::fd::{AsRawFd, FromRawFd};
|
|
use std::os::unix::ffi::OsStrExt;
|
|
|
|
let file_name = path.file_name().ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"data import input path must name a file",
|
|
)
|
|
})?;
|
|
let input_parent = path
|
|
.parent()
|
|
.filter(|parent| !parent.as_os_str().is_empty())
|
|
.unwrap_or_else(|| Path::new("."));
|
|
// Keep the directory descriptor alive through openat. This prevents a
|
|
// concurrent rename of an ancestor from changing which directory is used.
|
|
let parent = open_private_export_directory(input_parent)?;
|
|
let file_name = CString::new(file_name.as_bytes()).map_err(|_| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"data import input path must not contain an embedded NUL byte",
|
|
)
|
|
})?;
|
|
|
|
// O_NONBLOCK is important even though imports require regular files:
|
|
// opening a FIFO without it can block before fstat has a chance to reject
|
|
// the special file. O_NOFOLLOW makes the final path component race-free.
|
|
let descriptor = unsafe {
|
|
libc::openat(
|
|
parent.as_raw_fd(),
|
|
file_name.as_ptr(),
|
|
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK,
|
|
)
|
|
};
|
|
if descriptor < 0 {
|
|
let error = io::Error::last_os_error();
|
|
if error.raw_os_error() == Some(libc::ELOOP) {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!(
|
|
"data import input '{}' must not be a symbolic link",
|
|
path.display()
|
|
),
|
|
));
|
|
}
|
|
return Err(error);
|
|
}
|
|
|
|
// SAFETY: openat returned a new descriptor owned by this function; no
|
|
// other owner exists and File closes it on every return path.
|
|
Ok(unsafe { fs::File::from_raw_fd(descriptor) })
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn open_data_import_file(path: &Path) -> io::Result<fs::File> {
|
|
let metadata = fs::symlink_metadata(path)?;
|
|
if metadata.file_type().is_symlink() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!(
|
|
"data import input '{}' must not be a symbolic link",
|
|
path.display()
|
|
),
|
|
));
|
|
}
|
|
fs::OpenOptions::new().read(true).open(path)
|
|
}
|
|
|
|
fn copy_database_host_is_literal_loopback(host: &str) -> bool {
|
|
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
|
|
if host.eq_ignore_ascii_case("localhost") {
|
|
return true;
|
|
}
|
|
let Ok(address) = host.parse::<std::net::IpAddr>() else {
|
|
return false;
|
|
};
|
|
match address {
|
|
std::net::IpAddr::V4(address) => address.is_loopback(),
|
|
std::net::IpAddr::V6(address) => {
|
|
address.is_loopback()
|
|
|| address
|
|
.to_ipv4_mapped()
|
|
.is_some_and(|mapped| mapped.is_loopback())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn copy_database_url_is_literal_loopback(
|
|
driver: DatabaseDriver,
|
|
url: &str,
|
|
label: &str,
|
|
) -> Result<bool, io::Error> {
|
|
// Parse with the same SQLx driver that will open the pool. This preserves
|
|
// query-parameter overrides such as PostgreSQL `host`/`hostaddr` and
|
|
// PostgreSQL Unix `socket` paths, which URL authority inspection
|
|
// alone would miss.
|
|
match driver {
|
|
DatabaseDriver::Postgres => {
|
|
let options = url
|
|
.parse::<sqlx::postgres::PgConnectOptions>()
|
|
.map_err(|error| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!("{label} database URL is invalid: {error}"),
|
|
)
|
|
})?;
|
|
Ok(options.get_socket().is_some()
|
|
|| copy_database_host_is_literal_loopback(options.get_host()))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn copy_database_config(
|
|
driver: DatabaseDriverArg,
|
|
url: &str,
|
|
label: &str,
|
|
allow_insecure: bool,
|
|
) -> Result<SqlDatabaseConfig, Box<dyn std::error::Error>> {
|
|
let url = url.trim();
|
|
if url.is_empty() {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
format!("{label} database URL must not be empty"),
|
|
)
|
|
.into());
|
|
}
|
|
let driver = DatabaseDriver::from(driver);
|
|
// A loopback exception preserves the existing local-development workflow,
|
|
// while every named/remote SQL host defaults to an encrypted connection.
|
|
// `allow_insecure` is deliberately endpoint-specific so a local source
|
|
// does not silently downgrade a remote target (or vice versa).
|
|
let literal_loopback = copy_database_url_is_literal_loopback(driver, url, label)?;
|
|
let require_ssl = !allow_insecure && !literal_loopback;
|
|
Ok(SqlDatabaseConfig::new(
|
|
driver,
|
|
url,
|
|
SqlPoolConfig {
|
|
require_ssl,
|
|
..SqlPoolConfig::default()
|
|
},
|
|
)?)
|
|
}
|
|
|
|
async fn run_data_copy(args: &DataCopyArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|
let source = copy_database_config(
|
|
args.source_driver,
|
|
&args.source_url,
|
|
"source",
|
|
args.source_allow_insecure,
|
|
)?;
|
|
let target = copy_database_config(
|
|
args.target_driver,
|
|
&args.target_url,
|
|
"target",
|
|
args.target_allow_insecure,
|
|
)?;
|
|
let source_driver = source.driver;
|
|
let target_driver = target.driver;
|
|
let domains = requested_domains(&args.domains);
|
|
let created_at_unix_secs = current_unix_secs()?;
|
|
if !args.preserve_credentials {
|
|
warn!("identity credentials will be revoked; use --preserve-credentials only for trusted recovery or migration");
|
|
}
|
|
let imported = copy_database_records(
|
|
source,
|
|
target,
|
|
domains,
|
|
created_at_unix_secs,
|
|
DataCopyOptions {
|
|
omit_request_body_details: args.omit_request_body_details,
|
|
preserve_credentials: args.preserve_credentials,
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
info!(
|
|
source_driver = %source_driver,
|
|
target_driver = %target_driver,
|
|
imported,
|
|
preserve_credentials = args.preserve_credentials,
|
|
"database copy complete"
|
|
);
|
|
println!(
|
|
"copied {} records from {} to {} without a JSONL file",
|
|
imported, source_driver, target_driver
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_explicit_migrations(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
|
|
if args.data.effective_sql_database_config().is_none() {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"AETHER_DATABASE_DRIVER/AETHER_DATABASE_URL, AETHER_GATEWAY_DATA_POSTGRES_URL, or DATABASE_URL is required when running --migrate",
|
|
)
|
|
.into());
|
|
}
|
|
|
|
if args.data.configured_encryption_key_mismatch() {
|
|
warn!(
|
|
"AETHER_GATEWAY_DATA_ENCRYPTION_KEY differs from ENCRYPTION_KEY; aether-gateway will prefer the gateway-specific value"
|
|
);
|
|
}
|
|
|
|
args.data.validate_encryption_key()?;
|
|
let state = AppState::new()?.with_data_config(args.data.to_config())?;
|
|
let pending = state
|
|
.pending_database_migrations()
|
|
.await?
|
|
.unwrap_or_default();
|
|
if pending.is_empty() {
|
|
info!(
|
|
pending_migrations = 0,
|
|
"database migrations already up to date"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let next = pending
|
|
.first()
|
|
.expect("pending migrations should have a first element");
|
|
info!(
|
|
pending_migrations = pending.len(),
|
|
next_version = next.version,
|
|
next_description = %next.description,
|
|
pending_versions = %format_pending_migrations(&pending),
|
|
"running database migrations by explicit request..."
|
|
);
|
|
if state.run_database_migrations().await? {
|
|
info!("database migrations complete");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_explicit_backfills(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
|
|
let database = args.data.effective_sql_database_config().ok_or_else(|| {
|
|
std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"AETHER_DATABASE_DRIVER/AETHER_DATABASE_URL, AETHER_GATEWAY_DATA_POSTGRES_URL, or DATABASE_URL is required when running --apply-backfills",
|
|
)
|
|
})?;
|
|
args.data.validate_encryption_key()?;
|
|
let state = AppState::new()?.with_data_config(args.data.to_config())?;
|
|
ensure_database_schema_is_current(&state).await?;
|
|
|
|
let pending = state
|
|
.pending_database_backfills()
|
|
.await?
|
|
.unwrap_or_default();
|
|
if pending.is_empty() {
|
|
info!(
|
|
driver = %database.driver,
|
|
pending_backfills = 0,
|
|
"database backfills already up to date"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let next = pending
|
|
.first()
|
|
.expect("pending backfills should have a first element");
|
|
info!(
|
|
pending_backfills = pending.len(),
|
|
next_version = next.version,
|
|
next_description = %next.description,
|
|
pending_versions = %format_pending_backfills(&pending),
|
|
"running database backfills by explicit request..."
|
|
);
|
|
if state.run_database_backfills().await? {
|
|
info!("database backfills complete");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn prepare_database_startup_requirements(
|
|
state: &AppState,
|
|
database_mode: DatabaseModeArg,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
if matches!(database_mode, DatabaseModeArg::VerifyOnly) {
|
|
ensure_database_schema_is_current(state).await?;
|
|
ensure_database_backfills_are_current(state).await?;
|
|
return Ok(());
|
|
}
|
|
|
|
info!("database preparation enabled; applying pending migrations and backfills");
|
|
|
|
let Some(pending_migrations) = state.prepare_database_for_startup().await? else {
|
|
return Ok(());
|
|
};
|
|
if !pending_migrations.is_empty() {
|
|
let next = pending_migrations
|
|
.first()
|
|
.expect("pending migrations should have a first element");
|
|
info!(
|
|
pending_migrations = pending_migrations.len(),
|
|
next_version = next.version,
|
|
next_description = %next.description,
|
|
pending_versions = %format_pending_migrations(&pending_migrations),
|
|
"running database migrations during database preparation..."
|
|
);
|
|
if state.run_database_migrations().await? {
|
|
info!("database migrations complete");
|
|
}
|
|
}
|
|
|
|
let Some(pending_backfills) = state.pending_database_backfills().await? else {
|
|
return Ok(());
|
|
};
|
|
if pending_backfills.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let next = pending_backfills
|
|
.first()
|
|
.expect("pending backfills should have a first element");
|
|
info!(
|
|
pending_backfills = pending_backfills.len(),
|
|
next_version = next.version,
|
|
next_description = %next.description,
|
|
pending_versions = %format_pending_backfills(&pending_backfills),
|
|
"running database backfills during database preparation..."
|
|
);
|
|
if state.run_database_backfills().await? {
|
|
info!("database backfills complete");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn format_pending_migrations(
|
|
pending: &[aether_data::lifecycle::migrate::PendingMigrationInfo],
|
|
) -> String {
|
|
pending
|
|
.iter()
|
|
.map(|migration| format!("{} ({})", migration.version, migration.description))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
fn format_pending_backfills(
|
|
pending: &[aether_data::lifecycle::backfill::PendingBackfillInfo],
|
|
) -> String {
|
|
pending
|
|
.iter()
|
|
.map(|backfill| format!("{} ({})", backfill.version, backfill.description))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
async fn ensure_database_backfills_are_current(
|
|
state: &AppState,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let Some(pending) = state.pending_database_backfills().await? else {
|
|
return Ok(());
|
|
};
|
|
if pending.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let next = pending
|
|
.first()
|
|
.expect("pending backfills should have a first element");
|
|
Err(pending_backfills_error(pending.len(), next.version, &next.description).into())
|
|
}
|
|
|
|
async fn ensure_database_schema_is_current(
|
|
state: &AppState,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let Some(pending) = state.pending_database_migrations().await? else {
|
|
return Ok(());
|
|
};
|
|
if pending.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let next = pending
|
|
.first()
|
|
.expect("pending migrations should have a first element");
|
|
Err(pending_schema_error(pending.len(), next.version, &next.description).into())
|
|
}
|
|
|
|
fn pending_schema_error(
|
|
pending_count: usize,
|
|
next_version: i64,
|
|
next_description: &str,
|
|
) -> std::io::Error {
|
|
std::io::Error::other(format!(
|
|
"database schema is behind by {} migration(s); next pending migration is {} ({})\nrun `aether-gateway db prepare` before starting the service",
|
|
pending_count, next_version, next_description
|
|
))
|
|
}
|
|
|
|
fn pending_backfills_error(
|
|
pending_count: usize,
|
|
next_version: i64,
|
|
next_description: &str,
|
|
) -> std::io::Error {
|
|
std::io::Error::other(format!(
|
|
"database backfills are behind by {} backfill(s); next pending backfill is {} ({})\nrun `aether-gateway db prepare` before starting the service",
|
|
pending_count, next_version, next_description
|
|
))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
mod shutdown {
|
|
include!("shutdown_tests.rs");
|
|
}
|
|
|
|
use super::{
|
|
automatic_gateway_request_concurrency_for_capacity,
|
|
automatic_gateway_request_concurrency_for_parallelism, automatic_sql_pool_config,
|
|
automatic_sql_pool_config_for_parallelism, automatic_usage_queue_workers_for_parallelism,
|
|
copy_database_config, ensure_database_backfills_are_current,
|
|
ensure_database_schema_is_current, pending_backfills_error, pending_schema_error,
|
|
read_data_import_input_with_limit, resolve_database_mode, resolve_healthcheck_url,
|
|
usage_database_config_for_role, validate_gateway_data_encryption_key,
|
|
write_atomic_private_export, Args, DataCommand, DatabaseCommand, DatabaseDriverArg,
|
|
DatabaseModeArg, DeploymentTopologyArg, GatewayDataArgs, GatewayFrontdoorArgs,
|
|
GatewayLogDestinationArg, GatewayLogFormatArg, GatewayLogRotationArg, GatewayLoggingArgs,
|
|
GatewayRateLimitArgs, GatewayUsageArgs, NodeRoleArg, RuntimeBackendArg,
|
|
VideoTaskTruthSourceArg, DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS,
|
|
DEFAULT_GATEWAY_HTTP_HEADER_MAX_BYTES, DEFAULT_GATEWAY_HTTP_HEADER_READ_TIMEOUT_MS,
|
|
DEFAULT_GATEWAY_HTTP_MAX_HEADERS, DEFAULT_GATEWAY_LISTENER_SHARDS,
|
|
DEFAULT_GATEWAY_LISTEN_BACKLOG, MAX_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS,
|
|
MAX_GATEWAY_LISTENER_SHARDS, MAX_GATEWAY_LISTEN_BACKLOG,
|
|
MIN_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS, MIN_GATEWAY_LISTEN_BACKLOG,
|
|
};
|
|
use aether_data::{DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig};
|
|
use aether_gateway::AppState;
|
|
use bytes::Bytes;
|
|
use clap::Parser;
|
|
use http_body_util::{BodyExt, Full};
|
|
use hyper::body::Incoming as HyperIncoming;
|
|
use hyper::{Request as HyperRequest, Response as HyperResponse};
|
|
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
|
|
use hyper_util::server::conn::auto::Builder as HyperServerBuilder;
|
|
use std::convert::Infallible;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
fn test_args() -> Args {
|
|
Args {
|
|
command: None,
|
|
app_port: 8084,
|
|
listen_backlog: DEFAULT_GATEWAY_LISTEN_BACKLOG,
|
|
listener_shards: DEFAULT_GATEWAY_LISTENER_SHARDS,
|
|
http2_max_concurrent_streams: DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS,
|
|
http_header_read_timeout_ms: DEFAULT_GATEWAY_HTTP_HEADER_READ_TIMEOUT_MS,
|
|
http_header_max_bytes: DEFAULT_GATEWAY_HTTP_HEADER_MAX_BYTES,
|
|
http_max_headers: DEFAULT_GATEWAY_HTTP_MAX_HEADERS,
|
|
http_shutdown_timeout_ms: 30_000,
|
|
usage_shutdown_timeout_ms: 30_000,
|
|
healthcheck: false,
|
|
healthcheck_timeout_ms: 3_000,
|
|
deployment_topology: DeploymentTopologyArg::SingleNode,
|
|
node_role: NodeRoleArg::All,
|
|
migrate: false,
|
|
apply_backfills: false,
|
|
database_mode: None,
|
|
auto_prepare_database: None,
|
|
static_dir: None,
|
|
video_task_truth_source_mode: VideoTaskTruthSourceArg::PythonSyncReport,
|
|
video_task_poller_interval_ms: 5_000,
|
|
video_task_poller_batch_size: 32,
|
|
video_task_store_path: None,
|
|
max_in_flight_requests: None,
|
|
max_http_connections: None,
|
|
max_websocket_connections: None,
|
|
distributed_request_limit: None,
|
|
distributed_websocket_connection_limit: None,
|
|
distributed_request_redis_url: None,
|
|
distributed_request_redis_key_prefix: None,
|
|
distributed_request_lease_ttl_ms: 30_000,
|
|
distributed_request_renew_interval_ms: 10_000,
|
|
distributed_request_command_timeout_ms: 1_000,
|
|
runtime_backend: None,
|
|
runtime_redis_url: None,
|
|
runtime_redis_key_prefix: None,
|
|
runtime_command_timeout_ms: 1_000,
|
|
data: GatewayDataArgs {
|
|
database_driver: None,
|
|
database_url: None,
|
|
postgres_url: None,
|
|
encryption_key: None,
|
|
redis_url: None,
|
|
redis_key_prefix: None,
|
|
postgres_min_connections: None,
|
|
postgres_max_connections: None,
|
|
postgres_acquire_timeout_ms: None,
|
|
postgres_idle_timeout_ms: None,
|
|
postgres_max_lifetime_ms: None,
|
|
postgres_statement_cache_capacity: None,
|
|
postgres_require_ssl: false,
|
|
},
|
|
usage: GatewayUsageArgs {
|
|
queue_terminal_events: true,
|
|
queue_lifecycle_events: true,
|
|
queue_workers: Some(4),
|
|
queue_worker_autoscale_enabled: true,
|
|
queue_worker_max_count: Some(32),
|
|
worker_record_concurrency_limit: Some(32),
|
|
queue_worker_scale_interval_ms: 1_000,
|
|
queue_worker_idle_scale_down_ticks: 30,
|
|
queue_stream_key: "usage:events".to_string(),
|
|
queue_group: "usage_consumers".to_string(),
|
|
queue_dlq_stream_key: "usage:events:dlq".to_string(),
|
|
queue_stream_maxlen: 200_000,
|
|
queue_payload_max_bytes: 1024 * 1024,
|
|
queue_batch_size: 128,
|
|
queue_block_ms: 500,
|
|
queue_reclaim_idle_ms: 60_000,
|
|
queue_reclaim_count: 128,
|
|
queue_reclaim_interval_ms: 5_000,
|
|
terminal_submission_max_in_flight: 1_024,
|
|
terminal_enqueue_max_in_flight: 1_024,
|
|
lifecycle_enqueue_max_in_flight: 512,
|
|
lifecycle_enqueue_delay_ms: 1_000,
|
|
retry_deferred_lifecycle_events: true,
|
|
enqueue_retry_buffer_capacity: 131_072,
|
|
enqueue_retry_workers: 8,
|
|
enqueue_retry_initial_backoff_ms: 3_000,
|
|
enqueue_retry_max_backoff_ms: 10_000,
|
|
},
|
|
frontdoor: GatewayFrontdoorArgs {
|
|
environment: "development".to_string(),
|
|
cors_origins: None,
|
|
cors_allow_credentials: true,
|
|
},
|
|
rate_limit: GatewayRateLimitArgs {
|
|
bucket_seconds: 60,
|
|
key_ttl_seconds: 120,
|
|
fail_open: false,
|
|
},
|
|
logging: GatewayLoggingArgs {
|
|
log_format: GatewayLogFormatArg::Pretty,
|
|
log_destination: GatewayLogDestinationArg::Stdout,
|
|
log_dir: None,
|
|
log_rotation: GatewayLogRotationArg::Daily,
|
|
log_retention_days: 7,
|
|
log_max_files: 30,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn test_database(driver: DatabaseDriver, max_connections: u32) -> SqlDatabaseConfig {
|
|
let url = match driver {
|
|
DatabaseDriver::Postgres => "postgres://postgres:postgres@localhost/aether",
|
|
};
|
|
let max_connections = max_connections.max(1);
|
|
SqlDatabaseConfig::new(
|
|
driver,
|
|
url,
|
|
SqlPoolConfig {
|
|
min_connections: 1,
|
|
max_connections,
|
|
..SqlPoolConfig::default()
|
|
},
|
|
)
|
|
.expect("test database config should build")
|
|
}
|
|
|
|
#[test]
|
|
fn resolves_healthcheck_url_from_app_port() {
|
|
assert_eq!(
|
|
resolve_healthcheck_url(8084).unwrap(),
|
|
"http://127.0.0.1:8084/health"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_zero_app_port() {
|
|
let error = resolve_healthcheck_url(0).unwrap_err();
|
|
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
|
}
|
|
|
|
#[test]
|
|
fn clamps_gateway_listen_backlog() {
|
|
assert_eq!(
|
|
super::gateway_listen_backlog(MIN_GATEWAY_LISTEN_BACKLOG - 1),
|
|
MIN_GATEWAY_LISTEN_BACKLOG
|
|
);
|
|
assert_eq!(
|
|
super::gateway_listen_backlog(DEFAULT_GATEWAY_LISTEN_BACKLOG),
|
|
DEFAULT_GATEWAY_LISTEN_BACKLOG
|
|
);
|
|
assert_eq!(
|
|
super::gateway_listen_backlog(MAX_GATEWAY_LISTEN_BACKLOG + 1),
|
|
MAX_GATEWAY_LISTEN_BACKLOG
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn clamps_gateway_listener_shards() {
|
|
let auto_shards = super::gateway_listener_shards(0);
|
|
assert!((1..=MAX_GATEWAY_LISTENER_SHARDS).contains(&auto_shards));
|
|
assert_eq!(super::gateway_listener_shards(1), 1);
|
|
assert_eq!(
|
|
super::gateway_listener_shards(DEFAULT_GATEWAY_LISTENER_SHARDS),
|
|
auto_shards
|
|
);
|
|
assert_eq!(
|
|
super::gateway_listener_shards(MAX_GATEWAY_LISTENER_SHARDS + 1),
|
|
MAX_GATEWAY_LISTENER_SHARDS
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn clamps_gateway_http2_max_concurrent_streams() {
|
|
assert_eq!(
|
|
super::gateway_http2_max_concurrent_streams(
|
|
MIN_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS - 1
|
|
),
|
|
MIN_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS
|
|
);
|
|
assert_eq!(
|
|
super::gateway_http2_max_concurrent_streams(
|
|
DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS
|
|
),
|
|
DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS
|
|
);
|
|
assert_eq!(
|
|
super::gateway_http2_max_concurrent_streams(
|
|
MAX_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS + 1
|
|
),
|
|
MAX_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn clamps_http_header_security_settings_without_touching_stream_concurrency() {
|
|
assert_eq!(
|
|
super::gateway_http_header_read_timeout_ms(0),
|
|
super::MIN_GATEWAY_HTTP_HEADER_READ_TIMEOUT_MS
|
|
);
|
|
assert_eq!(
|
|
super::gateway_http_header_read_timeout_ms(u64::MAX),
|
|
super::MAX_GATEWAY_HTTP_HEADER_READ_TIMEOUT_MS
|
|
);
|
|
assert_eq!(
|
|
super::gateway_http_header_max_bytes(1),
|
|
super::MIN_GATEWAY_HTTP_HEADER_MAX_BYTES
|
|
);
|
|
assert_eq!(
|
|
super::gateway_http_header_max_bytes(usize::MAX),
|
|
super::MAX_GATEWAY_HTTP_HEADER_MAX_BYTES
|
|
);
|
|
assert_eq!(
|
|
super::gateway_http_max_headers(0),
|
|
super::MIN_GATEWAY_HTTP_MAX_HEADERS
|
|
);
|
|
assert_eq!(
|
|
super::gateway_http_max_headers(usize::MAX),
|
|
super::MAX_GATEWAY_HTTP_MAX_HEADERS
|
|
);
|
|
assert_eq!(
|
|
super::gateway_http2_max_concurrent_streams(
|
|
super::DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS
|
|
),
|
|
16_384
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_gateway_request_concurrency_scales_and_clamps() {
|
|
assert_eq!(
|
|
automatic_gateway_request_concurrency_for_parallelism(1),
|
|
1_024
|
|
);
|
|
assert_eq!(
|
|
automatic_gateway_request_concurrency_for_parallelism(4),
|
|
4_096
|
|
);
|
|
assert_eq!(
|
|
automatic_gateway_request_concurrency_for_parallelism(64),
|
|
65_536
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_gateway_request_concurrency_respects_fd_budget() {
|
|
assert_eq!(
|
|
automatic_gateway_request_concurrency_for_capacity(64, Some(16_384)),
|
|
8_064
|
|
);
|
|
assert_eq!(
|
|
automatic_gateway_request_concurrency_for_capacity(64, Some(1_024)),
|
|
384
|
|
);
|
|
assert_eq!(
|
|
automatic_gateway_request_concurrency_for_capacity(64, None),
|
|
65_536
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_migrate_runtime_config_enables_data_logs() {
|
|
let mut args = test_args();
|
|
args.migrate = true;
|
|
let config = args.runtime_config().expect("runtime config should build");
|
|
assert_eq!(
|
|
config.default_log_filter,
|
|
"aether_gateway=info,aether_data=info"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normal_runtime_config_includes_database_lifecycle_logs() {
|
|
let config = test_args()
|
|
.runtime_config()
|
|
.expect("runtime config should build");
|
|
assert_eq!(
|
|
config.default_log_filter,
|
|
"aether_gateway=info,aether_data=info"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_backfills_runtime_config_enables_data_logs() {
|
|
let mut args = test_args();
|
|
args.apply_backfills = true;
|
|
let config = args.runtime_config().expect("runtime config should build");
|
|
assert_eq!(
|
|
config.default_log_filter,
|
|
"aether_gateway=info,aether_data=info"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_prepare_database_runtime_config_enables_data_logs() {
|
|
let mut args = test_args();
|
|
args.auto_prepare_database = Some(true);
|
|
let config = args.runtime_config().expect("runtime config should build");
|
|
assert_eq!(
|
|
config.default_log_filter,
|
|
"aether_gateway=info,aether_data=info"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn database_mode_defaults_to_auto_and_preserves_legacy_false() {
|
|
assert_eq!(resolve_database_mode(None, None), DatabaseModeArg::Auto);
|
|
assert_eq!(
|
|
resolve_database_mode(None, Some(false)),
|
|
DatabaseModeArg::VerifyOnly
|
|
);
|
|
assert_eq!(
|
|
resolve_database_mode(Some(DatabaseModeArg::Auto), Some(false)),
|
|
DatabaseModeArg::Auto
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_database_commands_and_verify_only_mode() {
|
|
let status = Args::try_parse_from(["aether-gateway", "db", "status"])
|
|
.expect("db status should parse");
|
|
assert!(matches!(
|
|
status.command,
|
|
Some(DataCommand::Db(args))
|
|
if matches!(args.command, DatabaseCommand::Status)
|
|
));
|
|
|
|
let verify_only =
|
|
Args::try_parse_from(["aether-gateway", "--database-mode", "verify-only"])
|
|
.expect("verify-only mode should parse");
|
|
assert_eq!(
|
|
verify_only.effective_database_mode(),
|
|
DatabaseModeArg::VerifyOnly
|
|
);
|
|
|
|
let legacy_false =
|
|
Args::try_parse_from(["aether-gateway", "--auto-prepare-database=false"])
|
|
.expect("legacy false setting should parse");
|
|
assert_eq!(
|
|
legacy_false.effective_database_mode(),
|
|
DatabaseModeArg::VerifyOnly
|
|
);
|
|
|
|
let prepare = Args::try_parse_from(["aether-gateway", "db", "prepare"])
|
|
.expect("db prepare should parse");
|
|
assert!(matches!(
|
|
prepare.command,
|
|
Some(DataCommand::Db(args))
|
|
if matches!(args.command, DatabaseCommand::Prepare)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn database_arguments_are_global_for_database_commands() {
|
|
let before = Args::try_parse_from([
|
|
"aether-gateway",
|
|
"--database-driver",
|
|
"postgres",
|
|
"--database-url",
|
|
"postgres://localhost/before",
|
|
"db",
|
|
"status",
|
|
])
|
|
.expect("database arguments before db should parse");
|
|
assert_eq!(
|
|
before.data.database_url.as_deref(),
|
|
Some("postgres://localhost/before")
|
|
);
|
|
|
|
let after = Args::try_parse_from([
|
|
"aether-gateway",
|
|
"db",
|
|
"prepare",
|
|
"--database-driver",
|
|
"postgres",
|
|
"--database-url",
|
|
"postgres://localhost/after",
|
|
])
|
|
.expect("database arguments after db prepare should parse");
|
|
assert_eq!(
|
|
after.data.database_url.as_deref(),
|
|
Some("postgres://localhost/after")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn postgres_legacy_url_keeps_precedence_over_generic_database_url() {
|
|
let url = super::resolve_database_url(
|
|
Some(DatabaseDriver::Postgres),
|
|
None,
|
|
Some("postgres://legacy/aether".to_string()),
|
|
Some("postgres://generic/aether".to_string()),
|
|
);
|
|
|
|
assert_eq!(url.as_deref(), Some("postgres://legacy/aether"));
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_data_pool_auto_sizes_server_databases_from_runtime_cpu() {
|
|
let mut args = test_args();
|
|
args.data.database_driver = Some(DatabaseDriverArg::Postgres);
|
|
args.data.database_url = Some("postgres://postgres:postgres@localhost/aether".to_string());
|
|
|
|
let database = args
|
|
.data
|
|
.effective_sql_database_config()
|
|
.expect("postgres database config should build");
|
|
let auto = automatic_sql_pool_config(DatabaseDriver::Postgres);
|
|
|
|
assert_eq!(database.driver, DatabaseDriver::Postgres);
|
|
assert_eq!(database.pool.min_connections, auto.min_connections);
|
|
assert_eq!(database.pool.max_connections, auto.max_connections);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_data_pool_cpu_sizing_examples() {
|
|
let two_cpu = automatic_sql_pool_config_for_parallelism(DatabaseDriver::Postgres, 2);
|
|
assert_eq!(two_cpu.min_connections, 4);
|
|
assert_eq!(two_cpu.max_connections, 32);
|
|
|
|
let four_cpu = automatic_sql_pool_config_for_parallelism(DatabaseDriver::Postgres, 4);
|
|
assert_eq!(four_cpu.min_connections, 4);
|
|
assert_eq!(four_cpu.max_connections, 32);
|
|
|
|
let eight_cpu = automatic_sql_pool_config_for_parallelism(DatabaseDriver::Postgres, 8);
|
|
assert_eq!(eight_cpu.min_connections, 8);
|
|
assert_eq!(eight_cpu.max_connections, 32);
|
|
|
|
let sixteen_cpu = automatic_sql_pool_config_for_parallelism(DatabaseDriver::Postgres, 16);
|
|
assert_eq!(sixteen_cpu.min_connections, 16);
|
|
assert_eq!(sixteen_cpu.max_connections, 64);
|
|
|
|
let many_cpu = automatic_sql_pool_config_for_parallelism(DatabaseDriver::Postgres, 32);
|
|
assert_eq!(many_cpu.min_connections, 16);
|
|
assert_eq!(many_cpu.max_connections, 100);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_database_pool_isolation_and_usage_capacity_follow_role() {
|
|
assert!(NodeRoleArg::All.isolates_background_database());
|
|
assert!(!NodeRoleArg::Frontdoor.isolates_background_database());
|
|
assert!(!NodeRoleArg::Background.isolates_background_database());
|
|
|
|
let database = test_database(DatabaseDriver::Postgres, 20);
|
|
let isolated_background = test_database(DatabaseDriver::Postgres, 4);
|
|
assert_eq!(
|
|
usage_database_config_for_role(
|
|
NodeRoleArg::All,
|
|
Some(&database),
|
|
Some(&isolated_background),
|
|
)
|
|
.expect("all-role usage database")
|
|
.pool
|
|
.max_connections,
|
|
4
|
|
);
|
|
for role in [NodeRoleArg::Frontdoor, NodeRoleArg::Background] {
|
|
assert_eq!(
|
|
usage_database_config_for_role(role, Some(&database), Some(&isolated_background),)
|
|
.expect("single-pool role usage database")
|
|
.pool
|
|
.max_connections,
|
|
20
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_usage_queue_payload_limit_preserves_cli_override_and_rejects_zero() {
|
|
let command = <Args as clap::CommandFactory>::command();
|
|
let argument = command
|
|
.get_arguments()
|
|
.find(|argument| argument.get_id() == "queue_payload_max_bytes")
|
|
.expect("usage payload argument must be registered");
|
|
assert_eq!(
|
|
argument.get_env(),
|
|
Some(std::ffi::OsStr::new(
|
|
"AETHER_GATEWAY_USAGE_QUEUE_PAYLOAD_MAX_BYTES"
|
|
))
|
|
);
|
|
assert_eq!(argument.get_default_values()[0].to_str(), Some("1048576"));
|
|
let args = Args::try_parse_from(["aether-gateway", "--queue-payload-max-bytes", "32768"])
|
|
.expect("explicit usage payload limit should parse");
|
|
let config = args.usage.to_config(4, 8, Some(4));
|
|
assert_eq!(config.queue_payload_max_bytes, 32_768);
|
|
assert!(config.validate().is_ok());
|
|
|
|
let mut args = test_args();
|
|
assert_eq!(
|
|
args.usage.to_config(4, 8, Some(4)).queue_payload_max_bytes,
|
|
1024 * 1024
|
|
);
|
|
args.usage.queue_payload_max_bytes = 0;
|
|
let config = args.usage.to_config(4, 8, Some(4));
|
|
assert_eq!(config.queue_payload_max_bytes, 0);
|
|
assert!(config.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_usage_queue_workers_manual_override_wins_and_is_capped() {
|
|
let mut args = test_args();
|
|
args.usage.queue_workers = Some(72);
|
|
let database = test_database(DatabaseDriver::Postgres, 100);
|
|
|
|
let workers = args.usage.effective_queue_workers(
|
|
NodeRoleArg::All,
|
|
Some(10_000),
|
|
None,
|
|
Some(&database),
|
|
false,
|
|
);
|
|
|
|
assert_eq!(workers, 64);
|
|
assert_eq!(args.usage.to_config(workers, 64, Some(8)).worker_count, 64);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_usage_queue_workers_auto_uses_cpu_default_without_concurrency_hint() {
|
|
let database = test_database(DatabaseDriver::Postgres, 100);
|
|
|
|
let workers = automatic_usage_queue_workers_for_parallelism(
|
|
4,
|
|
NodeRoleArg::All,
|
|
None,
|
|
None,
|
|
Some(&database),
|
|
false,
|
|
);
|
|
|
|
assert_eq!(workers, 4);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_usage_queue_worker_autoscale_max_uses_database_cap() {
|
|
let mut args = test_args();
|
|
args.usage.queue_workers = None;
|
|
let database = test_database(DatabaseDriver::Postgres, 40);
|
|
|
|
let workers = args.usage.effective_queue_workers(
|
|
args.node_role,
|
|
Some(1_024),
|
|
None,
|
|
Some(&database),
|
|
false,
|
|
);
|
|
let max_workers = args.usage.effective_queue_worker_max_count(
|
|
args.node_role,
|
|
Some(&database),
|
|
workers,
|
|
false,
|
|
);
|
|
|
|
assert_eq!(workers, 8);
|
|
assert_eq!(max_workers, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_usage_queue_worker_autoscale_max_respects_explicit_override() {
|
|
let mut args = test_args();
|
|
args.usage.queue_workers = None;
|
|
args.usage.queue_worker_max_count = Some(32);
|
|
let database = test_database(DatabaseDriver::Postgres, 200);
|
|
|
|
let workers = args.usage.effective_queue_workers(
|
|
args.node_role,
|
|
Some(1_024),
|
|
None,
|
|
Some(&database),
|
|
false,
|
|
);
|
|
let max_workers = args.usage.effective_queue_worker_max_count(
|
|
args.node_role,
|
|
Some(&database),
|
|
workers,
|
|
false,
|
|
);
|
|
|
|
assert_eq!(workers, 8);
|
|
assert_eq!(max_workers, 32);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_usage_worker_record_concurrency_defaults_to_pool_reserve_share() {
|
|
let args = test_args();
|
|
let database = test_database(DatabaseDriver::Postgres, 64);
|
|
|
|
assert_eq!(
|
|
args.usage.effective_worker_record_concurrency_limit(
|
|
NodeRoleArg::All,
|
|
Some(&database),
|
|
false,
|
|
),
|
|
Some(8)
|
|
);
|
|
assert_eq!(
|
|
args.usage.effective_worker_record_concurrency_limit(
|
|
NodeRoleArg::Background,
|
|
Some(&database),
|
|
false,
|
|
),
|
|
Some(16)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_usage_isolated_database_uses_dedicated_capacity_once() {
|
|
let mut args = test_args();
|
|
args.usage.queue_workers = None;
|
|
let database = test_database(DatabaseDriver::Postgres, 64);
|
|
let isolated_background = test_database(DatabaseDriver::Postgres, 8);
|
|
let usage_database = usage_database_config_for_role(
|
|
NodeRoleArg::All,
|
|
Some(&database),
|
|
Some(&isolated_background),
|
|
)
|
|
.expect("isolated usage database");
|
|
|
|
let workers = args.usage.effective_queue_workers(
|
|
NodeRoleArg::All,
|
|
Some(5_000),
|
|
None,
|
|
Some(usage_database),
|
|
true,
|
|
);
|
|
let max_workers = args.usage.effective_queue_worker_max_count(
|
|
NodeRoleArg::All,
|
|
Some(usage_database),
|
|
workers,
|
|
true,
|
|
);
|
|
|
|
assert_eq!(usage_database.pool.max_connections, 8);
|
|
assert_eq!(workers, 7);
|
|
assert_eq!(max_workers, 7);
|
|
assert_eq!(
|
|
args.usage.effective_worker_record_concurrency_limit(
|
|
NodeRoleArg::All,
|
|
Some(usage_database),
|
|
true,
|
|
),
|
|
Some(7)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_usage_worker_record_concurrency_can_be_explicitly_disabled() {
|
|
let mut args = test_args();
|
|
args.usage.worker_record_concurrency_limit = Some(0);
|
|
let database = test_database(DatabaseDriver::Postgres, 64);
|
|
|
|
assert_eq!(
|
|
args.usage.effective_worker_record_concurrency_limit(
|
|
NodeRoleArg::All,
|
|
Some(&database),
|
|
false,
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_usage_queue_blocking_stream_lanes_only_expand_when_worker_can_spawn() {
|
|
let database = test_database(DatabaseDriver::Postgres, 100);
|
|
let args = test_args();
|
|
|
|
assert_eq!(
|
|
args.usage
|
|
.runtime_state_blocking_stream_lanes(NodeRoleArg::All, Some(&database), 10,),
|
|
Some(10)
|
|
);
|
|
assert_eq!(
|
|
args.usage.runtime_state_blocking_stream_lanes(
|
|
NodeRoleArg::Frontdoor,
|
|
Some(&database),
|
|
10,
|
|
),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
args.usage
|
|
.runtime_state_blocking_stream_lanes(NodeRoleArg::All, None, 10),
|
|
None
|
|
);
|
|
|
|
let mut disabled_queue_args = args;
|
|
disabled_queue_args.usage.queue_terminal_events = false;
|
|
disabled_queue_args.usage.queue_lifecycle_events = false;
|
|
assert_eq!(
|
|
disabled_queue_args
|
|
.usage
|
|
.runtime_state_blocking_stream_lanes(NodeRoleArg::All, Some(&database), 10,),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_usage_queue_workers_auto_scales_from_request_concurrency() {
|
|
let database = test_database(DatabaseDriver::Postgres, 100);
|
|
|
|
let workers = automatic_usage_queue_workers_for_parallelism(
|
|
8,
|
|
NodeRoleArg::All,
|
|
Some(1_536),
|
|
None,
|
|
Some(&database),
|
|
false,
|
|
);
|
|
|
|
assert_eq!(workers, 12);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_usage_queue_workers_auto_respects_effective_request_limit() {
|
|
let database = test_database(DatabaseDriver::Postgres, 100);
|
|
|
|
let workers = automatic_usage_queue_workers_for_parallelism(
|
|
8,
|
|
NodeRoleArg::All,
|
|
Some(2_048),
|
|
Some(256),
|
|
Some(&database),
|
|
false,
|
|
);
|
|
|
|
assert_eq!(workers, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_usage_queue_workers_auto_is_capped_by_database_pool() {
|
|
let database = test_database(DatabaseDriver::Postgres, 20);
|
|
|
|
let workers = automatic_usage_queue_workers_for_parallelism(
|
|
16,
|
|
NodeRoleArg::All,
|
|
Some(5_000),
|
|
None,
|
|
Some(&database),
|
|
false,
|
|
);
|
|
|
|
assert_eq!(workers, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_usage_queue_workers_auto_gives_background_nodes_more_pool_budget() {
|
|
let database = test_database(DatabaseDriver::Postgres, 20);
|
|
|
|
let workers = automatic_usage_queue_workers_for_parallelism(
|
|
16,
|
|
NodeRoleArg::Background,
|
|
Some(5_000),
|
|
None,
|
|
Some(&database),
|
|
false,
|
|
);
|
|
|
|
assert_eq!(workers, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_data_pool_explicit_values_override_auto_sizing() {
|
|
let mut args = test_args();
|
|
args.data.database_driver = Some(DatabaseDriverArg::Postgres);
|
|
args.data.database_url = Some("postgres://localhost/aether".to_string());
|
|
args.data.postgres_min_connections = Some(2);
|
|
args.data.postgres_max_connections = Some(8);
|
|
args.data.postgres_acquire_timeout_ms = Some(2_000);
|
|
|
|
let database = args
|
|
.data
|
|
.effective_sql_database_config()
|
|
.expect("postgres database config should build");
|
|
|
|
assert_eq!(database.pool.min_connections, 2);
|
|
assert_eq!(database.pool.max_connections, 8);
|
|
assert_eq!(database.pool.acquire_timeout_ms, 2_000);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_data_pool_partial_max_override_clamps_auto_minimum() {
|
|
let mut args = test_args();
|
|
args.data.database_driver = Some(DatabaseDriverArg::Postgres);
|
|
args.data.database_url = Some("postgres://postgres:postgres@localhost/aether".to_string());
|
|
args.data.postgres_max_connections = Some(2);
|
|
|
|
let database = args
|
|
.data
|
|
.effective_sql_database_config()
|
|
.expect("postgres database config should build");
|
|
|
|
assert_eq!(database.pool.min_connections, 2);
|
|
assert_eq!(database.pool.max_connections, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_data_pool_partial_min_override_raises_auto_maximum() {
|
|
let mut args = test_args();
|
|
args.data.database_driver = Some(DatabaseDriverArg::Postgres);
|
|
args.data.database_url = Some("postgres://postgres:postgres@localhost/aether".to_string());
|
|
args.data.postgres_min_connections = Some(128);
|
|
|
|
let database = args
|
|
.data
|
|
.effective_sql_database_config()
|
|
.expect("postgres database config should build");
|
|
|
|
assert_eq!(database.pool.min_connections, 128);
|
|
assert_eq!(database.pool.max_connections, 128);
|
|
}
|
|
|
|
#[test]
|
|
fn memory_runtime_data_config_keeps_redis_out_of_data_layer() {
|
|
let mut args = test_args();
|
|
args.data.database_driver = Some(DatabaseDriverArg::Postgres);
|
|
args.data.database_url = Some("postgres://localhost/aether".to_string());
|
|
args.data.redis_url = Some("redis://127.0.0.1/0".to_string());
|
|
|
|
let config = args.data.to_config();
|
|
|
|
assert_eq!(
|
|
config
|
|
.database()
|
|
.expect("database should be configured")
|
|
.driver,
|
|
DatabaseDriver::Postgres
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_data_encryption_key_rejects_weak_and_published_values() {
|
|
assert!(validate_gateway_data_encryption_key(None).is_ok());
|
|
assert!(
|
|
validate_gateway_data_encryption_key(Some("0123456789abcdef0123456789abcdef")).is_ok()
|
|
);
|
|
|
|
for insecure in [
|
|
"short-secret",
|
|
"change-this-to-another-secure-random-string",
|
|
"change-this-to-a-secure-random-string",
|
|
"dev-encryption-key-do-not-use-in-production",
|
|
] {
|
|
assert!(
|
|
validate_gateway_data_encryption_key(Some(insecure)).is_err(),
|
|
"accepted insecure key: {insecure}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn data_copy_requires_tls_for_remote_sql_but_preserves_loopback_compatibility() {
|
|
let remote_postgres = copy_database_config(
|
|
DatabaseDriverArg::Postgres,
|
|
"postgres://user:[email protected]/aether",
|
|
"source",
|
|
false,
|
|
)
|
|
.expect("remote postgres config should build");
|
|
assert!(remote_postgres.pool.require_ssl);
|
|
|
|
for url in [
|
|
"postgres://user:pass@localhost/aether",
|
|
"postgres://user:[email protected]/aether",
|
|
"postgres://user:pass@[::1]/aether",
|
|
"postgres://user:pass@[::ffff:127.0.0.1]/aether",
|
|
] {
|
|
let config = copy_database_config(DatabaseDriverArg::Postgres, url, "source", false)
|
|
.expect("literal loopback config should build");
|
|
assert!(
|
|
!config.pool.require_ssl,
|
|
"loopback URL unexpectedly requires TLS: {url}"
|
|
);
|
|
}
|
|
|
|
let explicitly_insecure = copy_database_config(
|
|
DatabaseDriverArg::Postgres,
|
|
"postgres://user:[email protected]/aether",
|
|
"source",
|
|
true,
|
|
)
|
|
.expect("explicit insecure opt-out should build");
|
|
assert!(!explicitly_insecure.pool.require_ssl);
|
|
}
|
|
|
|
#[test]
|
|
fn data_copy_tls_policy_is_conservative_for_non_loopback_and_query_hosts() {
|
|
let query_remote = copy_database_config(
|
|
DatabaseDriverArg::Postgres,
|
|
"postgres:///aether?host=db.example",
|
|
"source",
|
|
false,
|
|
)
|
|
.expect("query-host postgres config should build");
|
|
assert!(query_remote.pool.require_ssl);
|
|
|
|
let authority_loopback_query_remote = copy_database_config(
|
|
DatabaseDriverArg::Postgres,
|
|
"postgres://user:pass@localhost/aether?host=db.example",
|
|
"source",
|
|
false,
|
|
)
|
|
.expect("query-host override config should build");
|
|
assert!(authority_loopback_query_remote.pool.require_ssl);
|
|
|
|
let authority_loopback_hostaddr_remote = copy_database_config(
|
|
DatabaseDriverArg::Postgres,
|
|
"postgres://user:pass@localhost/aether?hostaddr=192.0.2.10",
|
|
"source",
|
|
false,
|
|
)
|
|
.expect("hostaddr override config should build");
|
|
assert!(authority_loopback_hostaddr_remote.pool.require_ssl);
|
|
|
|
let query_loopback = copy_database_config(
|
|
DatabaseDriverArg::Postgres,
|
|
"postgres://user:[email protected]/aether?host=127.0.0.1",
|
|
"source",
|
|
false,
|
|
)
|
|
.expect("query-loopback config should build");
|
|
assert!(!query_loopback.pool.require_ssl);
|
|
|
|
let hostless = copy_database_config(
|
|
DatabaseDriverArg::Postgres,
|
|
"postgres:///aether",
|
|
"source",
|
|
false,
|
|
)
|
|
.expect("hostless postgres config should build");
|
|
// SQLx resolves a hostless PostgreSQL URL to its local socket or
|
|
// localhost default; that path is safe to keep plaintext for local
|
|
// development, just like an explicit loopback URL.
|
|
assert!(!hostless.pool.require_ssl);
|
|
}
|
|
|
|
#[test]
|
|
fn data_copy_cli_accepts_independent_insecure_opt_outs() {
|
|
let parsed = Args::try_parse_from([
|
|
"aether-gateway",
|
|
"copy",
|
|
"--source-driver",
|
|
"postgres",
|
|
"--source-url",
|
|
"postgres://user:[email protected]/aether",
|
|
"--source-allow-insecure",
|
|
"--target-driver",
|
|
"postgres",
|
|
"--target-url",
|
|
"postgres://user:[email protected]/aether",
|
|
])
|
|
.expect("copy command should parse endpoint-specific TLS flags");
|
|
|
|
let Some(DataCommand::Copy(copy)) = parsed.command else {
|
|
panic!("expected copy command");
|
|
};
|
|
assert!(copy.source_allow_insecure);
|
|
assert!(!copy.target_allow_insecure);
|
|
assert!(!copy.preserve_credentials);
|
|
}
|
|
|
|
#[test]
|
|
fn data_import_and_copy_require_explicit_credential_preservation() {
|
|
for preserve in [false, true] {
|
|
let mut import_args = vec!["aether-gateway", "import", "--input", "trusted.jsonl"];
|
|
let mut copy_args = vec![
|
|
"aether-gateway",
|
|
"copy",
|
|
"--source-driver",
|
|
"postgres",
|
|
"--source-url",
|
|
"postgres://localhost/source",
|
|
"--target-driver",
|
|
"postgres",
|
|
"--target-url",
|
|
"postgres://localhost/target",
|
|
];
|
|
if preserve {
|
|
import_args.push("--preserve-credentials");
|
|
copy_args.push("--preserve-credentials");
|
|
}
|
|
let Some(DataCommand::Import(import)) =
|
|
Args::try_parse_from(import_args).unwrap().command
|
|
else {
|
|
panic!("expected import command");
|
|
};
|
|
let Some(DataCommand::Copy(copy)) = Args::try_parse_from(copy_args).unwrap().command
|
|
else {
|
|
panic!("expected copy command");
|
|
};
|
|
assert_eq!(import.preserve_credentials, preserve);
|
|
assert_eq!(copy.preserve_credentials, preserve);
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn database_export_output_is_private_atomic_and_no_clobber_by_default() {
|
|
use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt};
|
|
|
|
let root = std::env::temp_dir().join(format!(
|
|
"aether-data-export-output-test-{}",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
std::fs::create_dir(&root).unwrap();
|
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
let output = root.join("export.jsonl");
|
|
|
|
write_atomic_private_export(&output, b"first\n", false).unwrap();
|
|
let metadata = std::fs::symlink_metadata(&output).unwrap();
|
|
assert_eq!(metadata.mode() & 0o777, 0o600);
|
|
assert_eq!(metadata.nlink(), 1);
|
|
assert!(write_atomic_private_export(&output, b"second\n", false).is_err());
|
|
assert_eq!(std::fs::read(&output).unwrap(), b"first\n");
|
|
|
|
write_atomic_private_export(&output, b"second\n", true).unwrap();
|
|
assert_eq!(std::fs::read(&output).unwrap(), b"second\n");
|
|
|
|
let victim = root.join("victim");
|
|
std::fs::write(&victim, b"known-good").unwrap();
|
|
std::fs::remove_file(&output).unwrap();
|
|
symlink(&victim, &output).unwrap();
|
|
assert!(write_atomic_private_export(&output, b"replace\n", true).is_err());
|
|
assert_eq!(std::fs::read(&victim).unwrap(), b"known-good");
|
|
|
|
std::fs::remove_file(&output).unwrap();
|
|
std::fs::hard_link(&victim, &output).unwrap();
|
|
assert!(write_atomic_private_export(&output, b"replace\n", true).is_err());
|
|
assert_eq!(std::fs::read(&victim).unwrap(), b"known-good");
|
|
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn database_export_rejects_an_unsafe_symbolic_link_parent_target() {
|
|
use std::os::unix::fs::{symlink, PermissionsExt};
|
|
|
|
let root = std::env::temp_dir().join(format!(
|
|
"aether-data-export-parent-link-test-{}",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
let real_parent = root.join("real");
|
|
let linked_parent = root.join("linked");
|
|
std::fs::create_dir_all(&real_parent).unwrap();
|
|
// A symlink into a directory writable by other users must not become
|
|
// an escape hatch for the private export.
|
|
std::fs::set_permissions(&real_parent, std::fs::Permissions::from_mode(0o777)).unwrap();
|
|
symlink(&real_parent, &linked_parent).unwrap();
|
|
|
|
let output = linked_parent.join("export.jsonl");
|
|
assert!(write_atomic_private_export(&output, b"must not be written", false).is_err());
|
|
assert!(!real_parent.join("export.jsonl").exists());
|
|
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn database_import_input_is_bounded_and_rejects_final_symlinks() {
|
|
use std::os::unix::fs::{symlink, PermissionsExt};
|
|
|
|
let root = std::env::temp_dir().join(format!(
|
|
"aether-data-import-input-test-{}",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
std::fs::create_dir(&root).unwrap();
|
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
|
|
let input = root.join("input.jsonl");
|
|
std::fs::write(&input, b"0123456789").unwrap();
|
|
let error = read_data_import_input_with_limit(&input, 4)
|
|
.expect_err("an input larger than the configured limit must be rejected");
|
|
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
|
assert!(error.to_string().contains("4 byte limit"));
|
|
|
|
let target = root.join("target.jsonl");
|
|
std::fs::write(&target, b"safe\n").unwrap();
|
|
let link = root.join("input-link.jsonl");
|
|
symlink(&target, &link).unwrap();
|
|
let error = read_data_import_input_with_limit(&link, 1024)
|
|
.expect_err("a final symbolic link must not be followed");
|
|
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
|
assert!(error.to_string().contains("must not be a symbolic link"));
|
|
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn database_import_input_rejects_fifo_without_blocking() {
|
|
use std::ffi::CString;
|
|
use std::os::unix::ffi::OsStrExt;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let root = std::env::temp_dir().join(format!(
|
|
"aether-data-import-fifo-test-{}",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
std::fs::create_dir(&root).unwrap();
|
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
let fifo = root.join("input.fifo");
|
|
let fifo_name = CString::new(fifo.as_os_str().as_bytes()).unwrap();
|
|
let result = unsafe { libc::mkfifo(fifo_name.as_ptr(), 0o600) };
|
|
assert_eq!(
|
|
result,
|
|
0,
|
|
"mkfifo failed: {}",
|
|
std::io::Error::last_os_error()
|
|
);
|
|
|
|
// O_NONBLOCK in open_data_import_file means this call reaches the
|
|
// regular-file check immediately even when no FIFO writer exists.
|
|
let error = read_data_import_input_with_limit(&fifo, 1024)
|
|
.expect_err("FIFO input must be rejected before reading");
|
|
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
|
assert!(error.to_string().contains("regular file"));
|
|
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn redis_runtime_config_owns_redis_connection() {
|
|
let mut args = test_args();
|
|
args.data.database_driver = Some(DatabaseDriverArg::Postgres);
|
|
args.data.database_url = Some("postgres://postgres:postgres@localhost/aether".to_string());
|
|
args.data.redis_url = Some("redis://127.0.0.1/0".to_string());
|
|
|
|
let config = args.runtime_state_config(
|
|
RuntimeBackendArg::Redis,
|
|
args.data.effective_redis_url().as_deref(),
|
|
Some(7),
|
|
);
|
|
|
|
assert_eq!(config.blocking_stream_lanes, Some(7));
|
|
assert_eq!(
|
|
config
|
|
.redis
|
|
.as_ref()
|
|
.expect("redis should be configured for runtime state")
|
|
.url,
|
|
"redis://127.0.0.1/0"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn redis_url_defaults_to_redis_runtime_backend_for_server_database() {
|
|
let args = test_args();
|
|
let database = SqlDatabaseConfig::new(
|
|
DatabaseDriver::Postgres,
|
|
"postgres://postgres:postgres@localhost/aether".to_string(),
|
|
SqlPoolConfig::default(),
|
|
)
|
|
.expect("postgres config should build");
|
|
|
|
assert_eq!(
|
|
args.effective_runtime_backend(Some(&database), Some("redis://127.0.0.1/0")),
|
|
RuntimeBackendArg::Redis
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn multi_node_rejects_memory_runtime_backend() {
|
|
let mut args = test_args();
|
|
args.deployment_topology = DeploymentTopologyArg::MultiNode;
|
|
args.node_role = NodeRoleArg::Frontdoor;
|
|
let database = SqlDatabaseConfig::new(
|
|
DatabaseDriver::Postgres,
|
|
"postgres://postgres:postgres@localhost/aether".to_string(),
|
|
SqlPoolConfig::default(),
|
|
)
|
|
.expect("postgres config should build");
|
|
|
|
let error = super::validate_deployment_topology(
|
|
&args,
|
|
Some(&database),
|
|
Some("redis://127.0.0.1/0"),
|
|
RuntimeBackendArg::Memory,
|
|
)
|
|
.expect_err("multi-node memory runtime should be rejected");
|
|
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
|
assert!(error.to_string().contains("AETHER_RUNTIME_BACKEND=memory"));
|
|
}
|
|
|
|
#[test]
|
|
fn multi_node_rejects_missing_redis_runtime_backend() {
|
|
let mut args = test_args();
|
|
args.deployment_topology = DeploymentTopologyArg::MultiNode;
|
|
args.node_role = NodeRoleArg::Frontdoor;
|
|
let database = SqlDatabaseConfig::new(
|
|
DatabaseDriver::Postgres,
|
|
"postgres://postgres:postgres@localhost/aether".to_string(),
|
|
SqlPoolConfig::default(),
|
|
)
|
|
.expect("postgres config should build");
|
|
|
|
let error = super::validate_deployment_topology(
|
|
&args,
|
|
Some(&database),
|
|
None,
|
|
RuntimeBackendArg::Redis,
|
|
)
|
|
.expect_err("multi-node should require redis");
|
|
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
|
assert!(error.to_string().contains("REDIS_URL"));
|
|
}
|
|
|
|
#[test]
|
|
fn pending_schema_error_mentions_database_prepare_command() {
|
|
let error = pending_schema_error(2, 20260413020000, "squash usage schema split");
|
|
let message = error.to_string();
|
|
assert!(message.contains("database schema is behind by 2 migration(s)"));
|
|
assert!(message.contains("20260413020000"));
|
|
assert!(message.contains("squash usage schema split"));
|
|
assert!(message.contains("aether-gateway db prepare"));
|
|
}
|
|
|
|
#[test]
|
|
fn pending_backfills_error_mentions_database_prepare_command() {
|
|
let message = pending_backfills_error(
|
|
1,
|
|
20260422110000,
|
|
"backfill stats aggregate read path support",
|
|
)
|
|
.to_string();
|
|
assert!(message.contains("database backfills are behind by 1 backfill(s)"));
|
|
assert!(message.contains("20260422110000"));
|
|
assert!(message.contains("backfill stats aggregate read path support"));
|
|
assert!(message.contains("aether-gateway db prepare"));
|
|
assert!(message.contains("before starting the service"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ensure_database_schema_is_current_is_noop_without_database_pool() {
|
|
let state = AppState::new().expect("state should build");
|
|
ensure_database_schema_is_current(&state)
|
|
.await
|
|
.expect("disabled data backend should not block startup");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ensure_database_backfills_are_current_is_noop_without_database_pool() {
|
|
let state = AppState::new().expect("state should build");
|
|
ensure_database_backfills_are_current(&state)
|
|
.await
|
|
.expect("disabled data backend should not block startup");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auto_prepare_database_is_noop_without_database_pool() {
|
|
let state = AppState::new().expect("state should build");
|
|
super::prepare_database_startup_requirements(&state, DatabaseModeArg::Auto)
|
|
.await
|
|
.expect("disabled data backend should not block startup");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn database_prepare_requires_database_url() {
|
|
let data = test_args().data;
|
|
let error = super::run_database_prepare(&data)
|
|
.await
|
|
.expect_err("missing database URL should fail");
|
|
assert!(error
|
|
.to_string()
|
|
.contains("AETHER_DATABASE_DRIVER/AETHER_DATABASE_URL"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explicit_migrate_requires_database_url() {
|
|
let args = test_args();
|
|
let error = super::run_explicit_migrations(&args)
|
|
.await
|
|
.expect_err("missing database URL should fail");
|
|
let message = error.to_string();
|
|
assert!(message.contains("AETHER_DATABASE_DRIVER/AETHER_DATABASE_URL"));
|
|
assert!(message.contains("--migrate"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explicit_migrate_does_not_depend_on_app_port_validation() {
|
|
let mut args = test_args();
|
|
args.app_port = 0;
|
|
|
|
let error = super::run_explicit_migrations(&args)
|
|
.await
|
|
.expect_err("missing database URL should fail before any app port validation");
|
|
let message = error.to_string();
|
|
assert!(message.contains("AETHER_DATABASE_DRIVER/AETHER_DATABASE_URL"));
|
|
assert!(!message.contains("APP_PORT"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explicit_backfills_require_database_url() {
|
|
let args = test_args();
|
|
let error = super::run_explicit_backfills(&args)
|
|
.await
|
|
.expect_err("missing database URL should fail");
|
|
let message = error.to_string();
|
|
assert!(message.contains("AETHER_DATABASE_DRIVER/AETHER_DATABASE_URL"));
|
|
assert!(message.contains("--apply-backfills"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn first_request_gate_closes_a_connection_that_never_reaches_service() {
|
|
let gate = super::GatewayFirstRequestGate::new();
|
|
let result = tokio::time::timeout(
|
|
std::time::Duration::from_secs(1),
|
|
super::drive_gateway_connection(
|
|
std::future::pending::<Result<(), ()>>(),
|
|
gate,
|
|
std::time::Duration::from_millis(5),
|
|
),
|
|
)
|
|
.await
|
|
.expect("first-request deadline should fire promptly");
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn first_request_gate_does_not_deadline_a_streaming_connection() {
|
|
let gate = super::GatewayFirstRequestGate::new();
|
|
gate.mark_seen();
|
|
// Model a body that remains active after request headers arrive.
|
|
let connection = async {
|
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
|
Ok::<(), ()>(())
|
|
};
|
|
let result = tokio::time::timeout(std::time::Duration::from_secs(1), async move {
|
|
super::drive_gateway_connection(connection, gate, std::time::Duration::from_millis(5))
|
|
.await
|
|
})
|
|
.await
|
|
.expect("streaming connection should finish");
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn first_request_deadline_covers_partial_http1_and_h2_preface() {
|
|
let prefixes: &[&[u8]] = &[
|
|
b"G",
|
|
b"GET / HTTP/1.1\r\nHost: localhost\r\n",
|
|
b"PRI * HTTP/2.0\r\n\r\nSM\r\n",
|
|
];
|
|
for prefix in prefixes {
|
|
let (mut client, server) = tokio::io::duplex(16 * 1024);
|
|
client
|
|
.write_all(prefix)
|
|
.await
|
|
.expect("fixture prefix should be writable");
|
|
let gate = super::GatewayFirstRequestGate::new();
|
|
let service = tower::service_fn(|_request: HyperRequest<HyperIncoming>| async {
|
|
Ok::<_, Infallible>(HyperResponse::new(Full::new(Bytes::from_static(b"ok"))))
|
|
});
|
|
let mut builder = HyperServerBuilder::new(TokioExecutor::new());
|
|
builder
|
|
.http1()
|
|
.timer(TokioTimer::new())
|
|
// Keep hyper's own header timeout far from the 5ms first-request
|
|
// deadline: when a slow runner lets both expire before the next
|
|
// poll, `select!` may pick the connection branch and surface
|
|
// hyper's header-timeout error instead of the clean deadline close.
|
|
.header_read_timeout(std::time::Duration::from_secs(30))
|
|
.max_buf_size(super::MIN_GATEWAY_HTTP_HEADER_MAX_BYTES)
|
|
.max_headers(super::MIN_GATEWAY_HTTP_MAX_HEADERS);
|
|
builder
|
|
.http2()
|
|
.timer(TokioTimer::new())
|
|
.max_concurrent_streams(super::DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS)
|
|
.max_header_list_size(super::MIN_GATEWAY_HTTP_HEADER_MAX_BYTES as u32);
|
|
|
|
let result = tokio::time::timeout(
|
|
std::time::Duration::from_secs(1),
|
|
super::drive_gateway_connection(
|
|
builder.serve_connection_with_upgrades(
|
|
TokioIo::new(server),
|
|
super::TowerToHyperService::new(super::GatewayFirstRequestService {
|
|
inner: service,
|
|
gate: gate.clone(),
|
|
}),
|
|
),
|
|
gate,
|
|
std::time::Duration::from_millis(5),
|
|
),
|
|
)
|
|
.await
|
|
.expect("partial protocol input should hit the first-request deadline");
|
|
assert!(
|
|
result.is_ok(),
|
|
"deadline should close without a parser error"
|
|
);
|
|
|
|
let mut byte = [0u8; 1];
|
|
let read =
|
|
tokio::time::timeout(std::time::Duration::from_secs(1), client.read(&mut byte))
|
|
.await
|
|
.expect("timed-out connection should close its peer");
|
|
assert!(matches!(read, Ok(0) | Err(_)));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn first_request_deadline_does_not_cut_off_a_delayed_http1_body() {
|
|
let (mut client, server) = tokio::io::duplex(16 * 1024);
|
|
let gate = super::GatewayFirstRequestGate::new();
|
|
let (headers_seen_tx, mut headers_seen_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let service = tower::service_fn(move |request: HyperRequest<HyperIncoming>| {
|
|
let _ = headers_seen_tx.send(());
|
|
async move {
|
|
let body = request
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("test body should decode")
|
|
.to_bytes();
|
|
Ok::<_, Infallible>(HyperResponse::new(Full::new(body)))
|
|
}
|
|
});
|
|
let mut builder = HyperServerBuilder::new(TokioExecutor::new());
|
|
builder
|
|
.http1()
|
|
.timer(TokioTimer::new())
|
|
.header_read_timeout(std::time::Duration::from_millis(20))
|
|
.max_buf_size(super::MIN_GATEWAY_HTTP_HEADER_MAX_BYTES)
|
|
.max_headers(super::MIN_GATEWAY_HTTP_MAX_HEADERS);
|
|
builder
|
|
.http2()
|
|
.timer(TokioTimer::new())
|
|
.max_concurrent_streams(super::DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS)
|
|
.max_header_list_size(super::MIN_GATEWAY_HTTP_HEADER_MAX_BYTES as u32);
|
|
|
|
let server_task = tokio::spawn(async move {
|
|
super::drive_gateway_connection(
|
|
builder.serve_connection_with_upgrades(
|
|
TokioIo::new(server),
|
|
super::TowerToHyperService::new(super::GatewayFirstRequestService {
|
|
inner: service,
|
|
gate: gate.clone(),
|
|
}),
|
|
),
|
|
gate,
|
|
std::time::Duration::from_millis(20),
|
|
)
|
|
.await
|
|
});
|
|
client
|
|
.write_all(
|
|
b"POST / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: 5\r\n\r\n",
|
|
)
|
|
.await
|
|
.expect("request headers should be writable");
|
|
tokio::time::timeout(std::time::Duration::from_secs(1), headers_seen_rx.recv())
|
|
.await
|
|
.expect("request headers should reach the service")
|
|
.expect("service notification should remain available");
|
|
tokio::time::sleep(std::time::Duration::from_millis(40)).await;
|
|
client
|
|
.write_all(b"hello")
|
|
.await
|
|
.expect("body should remain writable after the header deadline");
|
|
let mut response = Vec::new();
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_secs(1),
|
|
client.read_to_end(&mut response),
|
|
)
|
|
.await
|
|
.expect("streaming response should complete")
|
|
.expect("response should be readable");
|
|
let result = server_task
|
|
.await
|
|
.expect("server connection task should join");
|
|
assert!(result.is_ok());
|
|
assert!(response
|
|
.windows(b"hello".len())
|
|
.any(|window| window == b"hello"));
|
|
}
|
|
}
|