registry: configure postgres pool explicitly

This commit is contained in:
a.tolmachev
2026-04-12 11:42:48 +03:00
parent 7a9248a0f3
commit 825d3cb138
7 changed files with 280 additions and 11 deletions
+5
View File
@@ -3,6 +3,11 @@ POSTGRES_USER=crank
POSTGRES_PASSWORD=change-me
POSTGRES_HOST=postgres
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_MCP_SERVER_IMAGE=crank/mcp-server:dev
CRANK_UI_IMAGE=crank/ui:dev
+5 -5
View File
@@ -2,18 +2,18 @@
## Current
### `feat/postgres-pool-config`
### `feat/postgres-registry-modularization`
Status: in_progress
DoD:
- PostgreSQL pool settings are explicit instead of implicit sqlx defaults
- pool sizing and timeouts come from runtime config/env
- startup validates pool configuration and tests cover parsing/defaults
- `crates/crank-registry/src/postgres.rs` is split into domain modules under `postgres/`
- `PostgresRegistry` keeps only constructors and high-level surface wiring
- existing registry tests still pass after module split
## Next
- `feat/postgres-registry-modularization`
- `feat/sqlx-compile-time-verification`
## Backlog
+15 -2
View File
@@ -8,7 +8,7 @@ mod storage;
use std::{env, net::SocketAddr, path::PathBuf};
use crank_registry::PostgresRegistry;
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
use sqlx::postgres::PgConnectOptions;
use tokio::net::TcpListener;
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 base_url = env::var("CRANK_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into());
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 {
session_secret: env::var("CRANK_SESSION_SECRET")?,
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 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);
axum::serve(listener, app).await?;
+15 -2
View File
@@ -5,7 +5,7 @@ mod session;
use std::{env, net::SocketAddr, time::Duration};
use crank_registry::PostgresRegistry;
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
use crank_runtime::SecretCrypto;
use sqlx::postgres::PgConnectOptions;
use tokio::net::TcpListener;
@@ -30,11 +30,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.map(Duration::from_millis)
.unwrap_or_else(|| Duration::from_secs(5));
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 app = build_app(registry, refresh_interval, base_url, secret_crypto);
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);
axum::serve(listener, app).await?;
+1 -1
View File
@@ -21,4 +21,4 @@ pub use model::{
WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
YamlImportJobId, YamlImportJobStatus,
};
pub use postgres::PostgresRegistry;
pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
+221 -1
View File
@@ -13,6 +13,8 @@ use sqlx::{
postgres::{PgConnectOptions, PgPoolOptions, PgRow},
types::Json,
};
use std::{collections::BTreeMap, env, time::Duration};
use thiserror::Error;
use crate::{
error::RegistryError,
@@ -41,6 +43,97 @@ pub struct PostgresRegistry {
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 {
pub async fn connect(database_url: &str) -> Result<Self, RegistryError> {
Self::connect_in_schema(database_url, None).await
@@ -49,7 +142,22 @@ impl PostgresRegistry {
pub async fn connect_with_options(
connect_options: PgConnectOptions,
) -> 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?;
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(
tx: &mut Transaction<'_, Postgres>,
snapshot: &RegistryOperation,
+18
View File
@@ -60,6 +60,11 @@ var/crank/
- `POSTGRES_DB`
- `POSTGRES_USER`
- `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_MCP_SERVER_IMAGE`
- `CRANK_UI_IMAGE`
@@ -161,6 +166,19 @@ runtime env-переменной. Это значит, что:
- `POSTGRES_DB`
- `POSTGRES_USER`
- `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 для локальных тестов и
переходного периода, но не как основная deployment-модель.