registry: configure postgres pool explicitly
This commit is contained in:
@@ -3,6 +3,11 @@ POSTGRES_USER=crank
|
|||||||
POSTGRES_PASSWORD=change-me
|
POSTGRES_PASSWORD=change-me
|
||||||
POSTGRES_HOST=postgres
|
POSTGRES_HOST=postgres
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_MAX_CONNECTIONS=20
|
||||||
|
POSTGRES_MIN_CONNECTIONS=2
|
||||||
|
POSTGRES_ACQUIRE_TIMEOUT_MS=5000
|
||||||
|
POSTGRES_IDLE_TIMEOUT_MS=600000
|
||||||
|
POSTGRES_MAX_LIFETIME_MS=1800000
|
||||||
CRANK_ADMIN_API_IMAGE=crank/admin-api:dev
|
CRANK_ADMIN_API_IMAGE=crank/admin-api:dev
|
||||||
CRANK_MCP_SERVER_IMAGE=crank/mcp-server:dev
|
CRANK_MCP_SERVER_IMAGE=crank/mcp-server:dev
|
||||||
CRANK_UI_IMAGE=crank/ui:dev
|
CRANK_UI_IMAGE=crank/ui:dev
|
||||||
|
|||||||
@@ -2,18 +2,18 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/postgres-pool-config`
|
### `feat/postgres-registry-modularization`
|
||||||
|
|
||||||
Status: in_progress
|
Status: in_progress
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
- PostgreSQL pool settings are explicit instead of implicit sqlx defaults
|
- `crates/crank-registry/src/postgres.rs` is split into domain modules under `postgres/`
|
||||||
- pool sizing and timeouts come from runtime config/env
|
- `PostgresRegistry` keeps only constructors and high-level surface wiring
|
||||||
- startup validates pool configuration and tests cover parsing/defaults
|
- existing registry tests still pass after module split
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/postgres-registry-modularization`
|
- `feat/sqlx-compile-time-verification`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ mod storage;
|
|||||||
|
|
||||||
use std::{env, net::SocketAddr, path::PathBuf};
|
use std::{env, net::SocketAddr, path::PathBuf};
|
||||||
|
|
||||||
use crank_registry::PostgresRegistry;
|
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
||||||
use sqlx::postgres::PgConnectOptions;
|
use sqlx::postgres::PgConnectOptions;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -35,7 +35,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let bind_addr = env::var("CRANK_ADMIN_BIND").unwrap_or_else(|_| "0.0.0.0:3001".into());
|
let bind_addr = env::var("CRANK_ADMIN_BIND").unwrap_or_else(|_| "0.0.0.0:3001".into());
|
||||||
let base_url = env::var("CRANK_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into());
|
let base_url = env::var("CRANK_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into());
|
||||||
let socket_addr: SocketAddr = bind_addr.parse()?;
|
let socket_addr: SocketAddr = bind_addr.parse()?;
|
||||||
let registry = PostgresRegistry::connect_with_options(database_options_from_env()?).await?;
|
let pool_config = PostgresPoolConfig::from_env()?;
|
||||||
|
let registry = PostgresRegistry::connect_with_options_and_pool_config(
|
||||||
|
database_options_from_env()?,
|
||||||
|
pool_config,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let auth_settings = AuthSettings {
|
let auth_settings = AuthSettings {
|
||||||
session_secret: env::var("CRANK_SESSION_SECRET")?,
|
session_secret: env::var("CRANK_SESSION_SECRET")?,
|
||||||
password_pepper: env::var("CRANK_PASSWORD_PEPPER")?,
|
password_pepper: env::var("CRANK_PASSWORD_PEPPER")?,
|
||||||
@@ -61,6 +66,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let app = build_app(state);
|
let app = build_app(state);
|
||||||
let listener = TcpListener::bind(socket_addr).await?;
|
let listener = TcpListener::bind(socket_addr).await?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
max_connections = pool_config.max_connections,
|
||||||
|
min_connections = pool_config.min_connections,
|
||||||
|
acquire_timeout_ms = pool_config.acquire_timeout_ms,
|
||||||
|
idle_timeout_ms = pool_config.idle_timeout_ms,
|
||||||
|
max_lifetime_ms = pool_config.max_lifetime_ms,
|
||||||
|
"postgres pool configured"
|
||||||
|
);
|
||||||
info!("admin-api listening on {}", socket_addr);
|
info!("admin-api listening on {}", socket_addr);
|
||||||
|
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ mod session;
|
|||||||
|
|
||||||
use std::{env, net::SocketAddr, time::Duration};
|
use std::{env, net::SocketAddr, time::Duration};
|
||||||
|
|
||||||
use crank_registry::PostgresRegistry;
|
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
||||||
use crank_runtime::SecretCrypto;
|
use crank_runtime::SecretCrypto;
|
||||||
use sqlx::postgres::PgConnectOptions;
|
use sqlx::postgres::PgConnectOptions;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
@@ -30,11 +30,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.map(Duration::from_millis)
|
.map(Duration::from_millis)
|
||||||
.unwrap_or_else(|| Duration::from_secs(5));
|
.unwrap_or_else(|| Duration::from_secs(5));
|
||||||
let socket_addr: SocketAddr = bind_addr.parse()?;
|
let socket_addr: SocketAddr = bind_addr.parse()?;
|
||||||
let registry = PostgresRegistry::connect_with_options(database_options_from_env()?).await?;
|
let pool_config = PostgresPoolConfig::from_env()?;
|
||||||
|
let registry = PostgresRegistry::connect_with_options_and_pool_config(
|
||||||
|
database_options_from_env()?,
|
||||||
|
pool_config,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||||
let app = build_app(registry, refresh_interval, base_url, secret_crypto);
|
let app = build_app(registry, refresh_interval, base_url, secret_crypto);
|
||||||
let listener = TcpListener::bind(socket_addr).await?;
|
let listener = TcpListener::bind(socket_addr).await?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
max_connections = pool_config.max_connections,
|
||||||
|
min_connections = pool_config.min_connections,
|
||||||
|
acquire_timeout_ms = pool_config.acquire_timeout_ms,
|
||||||
|
idle_timeout_ms = pool_config.idle_timeout_ms,
|
||||||
|
max_lifetime_ms = pool_config.max_lifetime_ms,
|
||||||
|
"postgres pool configured"
|
||||||
|
);
|
||||||
info!("mcp-server listening on {}", socket_addr);
|
info!("mcp-server listening on {}", socket_addr);
|
||||||
|
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ pub use model::{
|
|||||||
WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
|
WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
|
||||||
YamlImportJobId, YamlImportJobStatus,
|
YamlImportJobId, YamlImportJobStatus,
|
||||||
};
|
};
|
||||||
pub use postgres::PostgresRegistry;
|
pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ use sqlx::{
|
|||||||
postgres::{PgConnectOptions, PgPoolOptions, PgRow},
|
postgres::{PgConnectOptions, PgPoolOptions, PgRow},
|
||||||
types::Json,
|
types::Json,
|
||||||
};
|
};
|
||||||
|
use std::{collections::BTreeMap, env, time::Duration};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::RegistryError,
|
error::RegistryError,
|
||||||
@@ -41,6 +43,97 @@ pub struct PostgresRegistry {
|
|||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PostgresPoolConfig {
|
||||||
|
pub max_connections: u32,
|
||||||
|
pub min_connections: u32,
|
||||||
|
pub acquire_timeout_ms: u64,
|
||||||
|
pub idle_timeout_ms: u64,
|
||||||
|
pub max_lifetime_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum PostgresPoolConfigError {
|
||||||
|
#[error("invalid postgres pool setting {name}={value}")]
|
||||||
|
InvalidValue { name: &'static str, value: String },
|
||||||
|
#[error("POSTGRES_MAX_CONNECTIONS must be greater than zero")]
|
||||||
|
ZeroMaxConnections,
|
||||||
|
#[error("POSTGRES_MIN_CONNECTIONS must not exceed POSTGRES_MAX_CONNECTIONS")]
|
||||||
|
MinConnectionsExceedMax,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PostgresPoolConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_connections: 20,
|
||||||
|
min_connections: 2,
|
||||||
|
acquire_timeout_ms: 5_000,
|
||||||
|
idle_timeout_ms: 600_000,
|
||||||
|
max_lifetime_ms: 1_800_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresPoolConfig {
|
||||||
|
pub fn from_env() -> Result<Self, PostgresPoolConfigError> {
|
||||||
|
Self::from_vars(env::vars())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_vars<I, K, V>(vars: I) -> Result<Self, PostgresPoolConfigError>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = (K, V)>,
|
||||||
|
K: AsRef<str>,
|
||||||
|
V: AsRef<str>,
|
||||||
|
{
|
||||||
|
let vars = vars
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, value)| (name.as_ref().to_owned(), value.as_ref().to_owned()))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let defaults = Self::default();
|
||||||
|
let config = Self {
|
||||||
|
max_connections: parse_u32_setting(
|
||||||
|
&vars,
|
||||||
|
"POSTGRES_MAX_CONNECTIONS",
|
||||||
|
defaults.max_connections,
|
||||||
|
)?,
|
||||||
|
min_connections: parse_u32_setting(
|
||||||
|
&vars,
|
||||||
|
"POSTGRES_MIN_CONNECTIONS",
|
||||||
|
defaults.min_connections,
|
||||||
|
)?,
|
||||||
|
acquire_timeout_ms: parse_u64_setting(
|
||||||
|
&vars,
|
||||||
|
"POSTGRES_ACQUIRE_TIMEOUT_MS",
|
||||||
|
defaults.acquire_timeout_ms,
|
||||||
|
)?,
|
||||||
|
idle_timeout_ms: parse_u64_setting(
|
||||||
|
&vars,
|
||||||
|
"POSTGRES_IDLE_TIMEOUT_MS",
|
||||||
|
defaults.idle_timeout_ms,
|
||||||
|
)?,
|
||||||
|
max_lifetime_ms: parse_u64_setting(
|
||||||
|
&vars,
|
||||||
|
"POSTGRES_MAX_LIFETIME_MS",
|
||||||
|
defaults.max_lifetime_ms,
|
||||||
|
)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
config.validate()?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(self) -> Result<Self, PostgresPoolConfigError> {
|
||||||
|
if self.max_connections == 0 {
|
||||||
|
return Err(PostgresPoolConfigError::ZeroMaxConnections);
|
||||||
|
}
|
||||||
|
if self.min_connections > self.max_connections {
|
||||||
|
return Err(PostgresPoolConfigError::MinConnectionsExceedMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl PostgresRegistry {
|
impl PostgresRegistry {
|
||||||
pub async fn connect(database_url: &str) -> Result<Self, RegistryError> {
|
pub async fn connect(database_url: &str) -> Result<Self, RegistryError> {
|
||||||
Self::connect_in_schema(database_url, None).await
|
Self::connect_in_schema(database_url, None).await
|
||||||
@@ -49,7 +142,22 @@ impl PostgresRegistry {
|
|||||||
pub async fn connect_with_options(
|
pub async fn connect_with_options(
|
||||||
connect_options: PgConnectOptions,
|
connect_options: PgConnectOptions,
|
||||||
) -> Result<Self, RegistryError> {
|
) -> Result<Self, RegistryError> {
|
||||||
let pool = PgPoolOptions::new().connect_with(connect_options).await?;
|
Self::connect_with_options_and_pool_config(connect_options, PostgresPoolConfig::default())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect_with_options_and_pool_config(
|
||||||
|
connect_options: PgConnectOptions,
|
||||||
|
pool_config: PostgresPoolConfig,
|
||||||
|
) -> Result<Self, RegistryError> {
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(pool_config.max_connections)
|
||||||
|
.min_connections(pool_config.min_connections)
|
||||||
|
.acquire_timeout(Duration::from_millis(pool_config.acquire_timeout_ms))
|
||||||
|
.idle_timeout(Duration::from_millis(pool_config.idle_timeout_ms))
|
||||||
|
.max_lifetime(Duration::from_millis(pool_config.max_lifetime_ms))
|
||||||
|
.connect_with(connect_options)
|
||||||
|
.await?;
|
||||||
migrations::apply_postgres(&pool).await?;
|
migrations::apply_postgres(&pool).await?;
|
||||||
Ok(Self { pool })
|
Ok(Self { pool })
|
||||||
}
|
}
|
||||||
@@ -3583,6 +3691,118 @@ impl PostgresRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_u32_setting(
|
||||||
|
vars: &BTreeMap<String, String>,
|
||||||
|
name: &'static str,
|
||||||
|
default: u32,
|
||||||
|
) -> Result<u32, PostgresPoolConfigError> {
|
||||||
|
vars.get(name)
|
||||||
|
.map(|value| {
|
||||||
|
value
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| PostgresPoolConfigError::InvalidValue {
|
||||||
|
name,
|
||||||
|
value: value.clone(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
.map(|value| value.unwrap_or(default))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_u64_setting(
|
||||||
|
vars: &BTreeMap<String, String>,
|
||||||
|
name: &'static str,
|
||||||
|
default: u64,
|
||||||
|
) -> Result<u64, PostgresPoolConfigError> {
|
||||||
|
vars.get(name)
|
||||||
|
.map(|value| {
|
||||||
|
value
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|_| PostgresPoolConfigError::InvalidValue {
|
||||||
|
name,
|
||||||
|
value: value.clone(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
.map(|value| value.unwrap_or(default))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod pool_config_tests {
|
||||||
|
use super::{PostgresPoolConfig, PostgresPoolConfigError};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_config_uses_explicit_defaults() {
|
||||||
|
let config = PostgresPoolConfig::from_vars(std::iter::empty::<(&str, &str)>()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config,
|
||||||
|
PostgresPoolConfig {
|
||||||
|
max_connections: 20,
|
||||||
|
min_connections: 2,
|
||||||
|
acquire_timeout_ms: 5_000,
|
||||||
|
idle_timeout_ms: 600_000,
|
||||||
|
max_lifetime_ms: 1_800_000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_config_parses_overrides() {
|
||||||
|
let config = PostgresPoolConfig::from_vars([
|
||||||
|
("POSTGRES_MAX_CONNECTIONS", "32"),
|
||||||
|
("POSTGRES_MIN_CONNECTIONS", "4"),
|
||||||
|
("POSTGRES_ACQUIRE_TIMEOUT_MS", "7000"),
|
||||||
|
("POSTGRES_IDLE_TIMEOUT_MS", "900000"),
|
||||||
|
("POSTGRES_MAX_LIFETIME_MS", "3600000"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config,
|
||||||
|
PostgresPoolConfig {
|
||||||
|
max_connections: 32,
|
||||||
|
min_connections: 4,
|
||||||
|
acquire_timeout_ms: 7_000,
|
||||||
|
idle_timeout_ms: 900_000,
|
||||||
|
max_lifetime_ms: 3_600_000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_config_rejects_invalid_numeric_value() {
|
||||||
|
let error =
|
||||||
|
PostgresPoolConfig::from_vars([("POSTGRES_MAX_CONNECTIONS", "abc")]).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
PostgresPoolConfigError::InvalidValue {
|
||||||
|
name: "POSTGRES_MAX_CONNECTIONS",
|
||||||
|
value: "abc".to_owned(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_config_rejects_zero_max_connections() {
|
||||||
|
let error = PostgresPoolConfig::from_vars([("POSTGRES_MAX_CONNECTIONS", "0")]).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error, PostgresPoolConfigError::ZeroMaxConnections);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_config_rejects_min_connections_above_max() {
|
||||||
|
let error = PostgresPoolConfig::from_vars([
|
||||||
|
("POSTGRES_MAX_CONNECTIONS", "2"),
|
||||||
|
("POSTGRES_MIN_CONNECTIONS", "3"),
|
||||||
|
])
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error, PostgresPoolConfigError::MinConnectionsExceedMax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn insert_version_row(
|
async fn insert_version_row(
|
||||||
tx: &mut Transaction<'_, Postgres>,
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
snapshot: &RegistryOperation,
|
snapshot: &RegistryOperation,
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ var/crank/
|
|||||||
- `POSTGRES_DB`
|
- `POSTGRES_DB`
|
||||||
- `POSTGRES_USER`
|
- `POSTGRES_USER`
|
||||||
- `POSTGRES_PASSWORD`
|
- `POSTGRES_PASSWORD`
|
||||||
|
- `POSTGRES_MAX_CONNECTIONS`
|
||||||
|
- `POSTGRES_MIN_CONNECTIONS`
|
||||||
|
- `POSTGRES_ACQUIRE_TIMEOUT_MS`
|
||||||
|
- `POSTGRES_IDLE_TIMEOUT_MS`
|
||||||
|
- `POSTGRES_MAX_LIFETIME_MS`
|
||||||
- `CRANK_ADMIN_API_IMAGE`
|
- `CRANK_ADMIN_API_IMAGE`
|
||||||
- `CRANK_MCP_SERVER_IMAGE`
|
- `CRANK_MCP_SERVER_IMAGE`
|
||||||
- `CRANK_UI_IMAGE`
|
- `CRANK_UI_IMAGE`
|
||||||
@@ -161,6 +166,19 @@ runtime env-переменной. Это значит, что:
|
|||||||
- `POSTGRES_DB`
|
- `POSTGRES_DB`
|
||||||
- `POSTGRES_USER`
|
- `POSTGRES_USER`
|
||||||
- `POSTGRES_PASSWORD`
|
- `POSTGRES_PASSWORD`
|
||||||
|
- `POSTGRES_MAX_CONNECTIONS`
|
||||||
|
- `POSTGRES_MIN_CONNECTIONS`
|
||||||
|
- `POSTGRES_ACQUIRE_TIMEOUT_MS`
|
||||||
|
- `POSTGRES_IDLE_TIMEOUT_MS`
|
||||||
|
- `POSTGRES_MAX_LIFETIME_MS`
|
||||||
|
|
||||||
|
Для pool behavior используются явные defaults:
|
||||||
|
|
||||||
|
- `POSTGRES_MAX_CONNECTIONS=20`
|
||||||
|
- `POSTGRES_MIN_CONNECTIONS=2`
|
||||||
|
- `POSTGRES_ACQUIRE_TIMEOUT_MS=5000`
|
||||||
|
- `POSTGRES_IDLE_TIMEOUT_MS=600000`
|
||||||
|
- `POSTGRES_MAX_LIFETIME_MS=1800000`
|
||||||
|
|
||||||
`CRANK_DATABASE_URL` допускается только как backward-compatible fallback для локальных тестов и
|
`CRANK_DATABASE_URL` допускается только как backward-compatible fallback для локальных тестов и
|
||||||
переходного периода, но не как основная deployment-модель.
|
переходного периода, но не как основная deployment-модель.
|
||||||
|
|||||||
Reference in New Issue
Block a user