Skip to content

Commit 18f1993

Browse files
authored
Enforce min_pool_size with prewarm and retain replenishment (#135)
1 parent 3433593 commit 18f1993

File tree

8 files changed

+242
-2
lines changed

8 files changed

+242
-2
lines changed

documentation/en/src/changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@
22

33
### 3.3.1 <small>Feb 26, 2026</small>
44

5+
**Bug Fixes:**
6+
7+
- **Minimum pool size enforcement (`min_pool_size`)**: The `min_pool_size` user setting is now enforced at runtime. After each connection retain cycle, pg_doorman checks pool sizes and creates new connections to maintain the configured minimum. Previously, `min_pool_size` was accepted in config but never applied — pools started empty and could drop to 0 connections even with `min_pool_size` set. Replenishment stops on the first connection failure to avoid hammering an unavailable server.
8+
59
**Improvements:**
610

11+
- **Pool prewarm at startup**: When `min_pool_size` is configured, pg_doorman now creates the minimum number of connections immediately at startup, before the first retain cycle. Previously, pools started empty and connections were only created lazily on first client request or after the first retain interval (default 60s). This eliminates cold-start latency for the first clients connecting after pg_doorman restart.
12+
713
- **Configurable connection scaling parameters**: New `general` settings `scaling_warm_pool_ratio`, `scaling_fast_retries`, and `scaling_cooldown_sleep` allow tuning connection pool scaling behavior. All three can be overridden at the pool level. `scaling_cooldown_sleep` uses the human-readable `Duration` type (e.g. `"10ms"`, `"1s"`) consistent with other timeout fields.
814

915
- **`max_concurrent_creates` setting**: Controls the maximum number of server connections that can be created concurrently per pool. Uses a semaphore instead of a mutex for parallel connection creation.

pg_doorman.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,7 @@ password = "md5dd9a0f26a4302744db881776a09bbfad"
483483
pool_size = 40
484484

485485
# Minimum connections to maintain in the pool.
486+
# Prewarmed at startup, then maintained by retain cycle.
486487
# Must be <= pool_size.
487488
# min_pool_size = 5
488489

pg_doorman.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,7 @@ pools:
527527
pool_size: 40
528528

529529
# Minimum connections to maintain in the pool.
530+
# Prewarmed at startup, then maintained by retain cycle.
530531
# Must be <= pool_size.
531532
# min_pool_size: 5
532533

src/app/generate/fields.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1079,11 +1079,13 @@ fields:
10791079
config:
10801080
en: |
10811081
Minimum connections to maintain in the pool.
1082+
Prewarmed at startup, then maintained by retain cycle.
10821083
Must be <= pool_size.
10831084
ru: |
10841085
Минимальное количество соединений для поддержания в пуле.
1086+
Создаются при старте (prewarm), затем поддерживаются retain-циклом.
10851087
Должно быть <= pool_size.
1086-
doc: "The minimum number of connections to maintain in the pool for this user. This helps with performance by keeping connections ready. If specified, it must be less than or equal to pool_size."
1088+
doc: "The minimum number of connections to maintain in the pool for this user. Connections are prewarmed at startup (before the first retain cycle) and then maintained by periodic replenishment. If specified, it must be less than or equal to pool_size."
10871089
default: "None"
10881090

10891091
pool_mode:

src/pool/inner.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ use std::{
88
},
99
};
1010

11+
use log::warn;
12+
1113
use crate::utils::clock;
1214

1315
use parking_lot::Mutex;
@@ -596,6 +598,56 @@ impl Pool {
596598
self.inner.config.timeouts
597599
}
598600

601+
/// Creates new connections to bring the pool up to the desired count.
602+
/// Returns the number of connections successfully created.
603+
/// Stops on the first creation failure to avoid hammering a failing server.
604+
pub async fn replenish(&self, count: usize) -> usize {
605+
let mut created = 0;
606+
for _ in 0..count {
607+
// Check if there's still room in the pool
608+
{
609+
let slots = self.inner.slots.lock();
610+
if slots.size >= slots.max_size {
611+
break;
612+
}
613+
}
614+
615+
// Create a new connection
616+
let obj = match self.inner.server_pool.create().await {
617+
Ok(obj) => obj,
618+
Err(e) => {
619+
warn!(
620+
"[pool: {}] failed to create connection during replenish: {}",
621+
self.inner.server_pool.address(),
622+
e
623+
);
624+
break;
625+
}
626+
};
627+
628+
let lifetime_ms = self.inner.server_pool.lifetime_ms();
629+
let inner = ObjectInner {
630+
obj,
631+
metrics: Metrics::new_with_lifetime(lifetime_ms),
632+
};
633+
634+
{
635+
let mut slots = self.inner.slots.lock();
636+
if slots.size >= slots.max_size {
637+
break;
638+
}
639+
slots.size += 1;
640+
match self.inner.config.queue_mode {
641+
QueueMode::Fifo => slots.vec.push_back(inner),
642+
QueueMode::Lifo => slots.vec.push_front(inner),
643+
}
644+
}
645+
646+
created += 1;
647+
}
648+
created
649+
}
650+
599651
/// Closes this Pool.
600652
pub fn close(&self) {
601653
self.resize(0);

src/pool/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,11 @@ impl ServerPool {
858858
}
859859
}
860860

861+
/// Returns the address of this pool.
862+
pub fn address(&self) -> &Address {
863+
&self.address
864+
}
865+
861866
/// Returns the base lifetime in milliseconds for connections in this pool.
862867
pub fn lifetime_ms(&self) -> u64 {
863868
self.lifetime_ms

src/pool/retain.rs

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::sync::atomic::{AtomicUsize, Ordering};
22
use std::sync::Arc;
33

4-
use log::info;
4+
use log::{info, warn};
55

66
use crate::config::get_config;
77

@@ -102,12 +102,67 @@ pub async fn retain_connections() {
102102
}
103103
);
104104

105+
// Prewarm pools with min_pool_size before the first retain cycle
106+
for (_, pool) in get_all_pools().iter() {
107+
if let Some(min_pool_size) = pool.settings.user.min_pool_size {
108+
let min = min_pool_size as usize;
109+
let created = pool.database.replenish(min).await;
110+
if created > 0 {
111+
info!(
112+
"[pool: {}][user: {}] prewarmed {} connection{} (min_pool_size: {})",
113+
pool.address.pool_name,
114+
pool.address.username,
115+
created,
116+
if created == 1 { "" } else { "s" },
117+
min,
118+
);
119+
} else {
120+
warn!(
121+
"[pool: {}][user: {}] prewarm failed — could not create connections (min_pool_size: {})",
122+
pool.address.pool_name,
123+
pool.address.username,
124+
min,
125+
);
126+
}
127+
}
128+
}
129+
105130
loop {
106131
interval.tick().await;
107132
for (_, pool) in get_all_pools().iter() {
108133
pool.retain_pool_connections(count.clone(), retain_max);
109134
}
110135
count.store(0, Ordering::Relaxed);
136+
137+
// Replenish pools below min_pool_size
138+
for (_, pool) in get_all_pools().iter() {
139+
if let Some(min_pool_size) = pool.settings.user.min_pool_size {
140+
let min = min_pool_size as usize;
141+
let current_size = pool.database.status().size;
142+
if current_size < min {
143+
let deficit = min - current_size;
144+
let created = pool.database.replenish(deficit).await;
145+
if created > 0 {
146+
info!(
147+
"[pool: {}][user: {}] replenished {} connection{} (min_pool_size: {})",
148+
pool.address.pool_name,
149+
pool.address.username,
150+
created,
151+
if created == 1 { "" } else { "s" },
152+
min,
153+
);
154+
} else {
155+
warn!(
156+
"[pool: {}][user: {}] failed to replenish connections (deficit: {}, min_pool_size: {})",
157+
pool.address.pool_name,
158+
pool.address.username,
159+
deficit,
160+
min,
161+
);
162+
}
163+
}
164+
}
165+
}
111166
}
112167
}
113168

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
@rust @rust-3 @min-pool-size
2+
Feature: min_pool_size enforcement
3+
Test that min_pool_size setting is enforced at runtime.
4+
After each retain cycle, pg_doorman should replenish pools below min_pool_size.
5+
6+
Background:
7+
Given PostgreSQL started with pg_hba.conf:
8+
"""
9+
local all all trust
10+
host all all 127.0.0.1/32 trust
11+
"""
12+
And fixtures from "tests/fixture.sql" applied
13+
14+
@replenish-after-startup
15+
Scenario: Pool replenishes to min_pool_size after startup
16+
Given pg_doorman started with config:
17+
"""
18+
[general]
19+
host = "127.0.0.1"
20+
port = ${DOORMAN_PORT}
21+
admin_username = "admin"
22+
admin_password = "admin"
23+
pg_hba.content = "host all all 127.0.0.1/32 trust"
24+
retain_connections_time = 500
25+
server_lifetime = 60000
26+
27+
[pools.example_db]
28+
server_host = "127.0.0.1"
29+
server_port = ${PG_PORT}
30+
31+
[[pools.example_db.users]]
32+
username = "example_user_1"
33+
password = ""
34+
pool_size = 5
35+
min_pool_size = 3
36+
"""
37+
# Wait for at least 2 retain cycles so replenish can create connections
38+
When we sleep for 1500 milliseconds
39+
# Check server connections via admin console
40+
When we create admin session "admin1" to pg_doorman as "admin" with password "admin"
41+
And we execute "SHOW SERVERS" on admin session "admin1" and store row count
42+
Then admin session "admin1" row count should be greater than or equal to 3
43+
44+
@prewarm-at-startup
45+
Scenario: Pool is prewarmed at startup before retain cycle
46+
Given pg_doorman started with config:
47+
"""
48+
[general]
49+
host = "127.0.0.1"
50+
port = ${DOORMAN_PORT}
51+
admin_username = "admin"
52+
admin_password = "admin"
53+
pg_hba.content = "host all all 127.0.0.1/32 trust"
54+
retain_connections_time = 60000
55+
server_lifetime = 60000
56+
57+
[pools.example_db]
58+
server_host = "127.0.0.1"
59+
server_port = ${PG_PORT}
60+
61+
[[pools.example_db.users]]
62+
username = "example_user_1"
63+
password = ""
64+
pool_size = 5
65+
min_pool_size = 2
66+
"""
67+
# Wait for prewarm to complete, but retain cycle (60s) has NOT fired yet
68+
When we sleep for 1000 milliseconds
69+
When we create admin session "admin1" to pg_doorman as "admin" with password "admin"
70+
And we execute "SHOW SERVERS" on admin session "admin1" and store row count
71+
Then admin session "admin1" row count should be greater than or equal to 2
72+
73+
@maintain-after-expiry
74+
Scenario: Pool maintains min_pool_size after connections expire
75+
Given pg_doorman started with config:
76+
"""
77+
[general]
78+
host = "127.0.0.1"
79+
port = ${DOORMAN_PORT}
80+
admin_username = "admin"
81+
admin_password = "admin"
82+
pg_hba.content = "host all all 127.0.0.1/32 trust"
83+
retain_connections_time = 500
84+
server_lifetime = 1000
85+
server_idle_check_timeout = 0
86+
87+
[pools.example_db]
88+
server_host = "127.0.0.1"
89+
server_port = ${PG_PORT}
90+
91+
[[pools.example_db.users]]
92+
username = "example_user_1"
93+
password = ""
94+
pool_size = 5
95+
min_pool_size = 2
96+
"""
97+
# Create a session and make a query to establish at least 1 backend connection
98+
When we create session "one" to pg_doorman as "example_user_1" with password "" and database "example_db"
99+
And we send Parse "" with query "SELECT pg_backend_pid()" to session "one"
100+
And we send Bind "" to "" with params "" to session "one"
101+
And we send Execute "" to session "one"
102+
And we send Sync to session "one"
103+
Then we remember backend_pid from session "one" as "first_pid"
104+
# Wait for replenish to bring pool up to min_pool_size
105+
When we sleep for 2000 milliseconds
106+
When we create admin session "admin1" to pg_doorman as "admin" with password "admin"
107+
And we execute "SHOW SERVERS" on admin session "admin1" and store row count
108+
Then admin session "admin1" row count should be greater than or equal to 2
109+
# Wait for server_lifetime (1s ±20% jitter) to expire and retain to close + replenish
110+
When we sleep for 3000 milliseconds
111+
And we execute "SHOW SERVERS" on admin session "admin1" and store row count
112+
Then admin session "admin1" row count should be greater than or equal to 2
113+
# Verify the backend was replaced (new PID after lifetime expiry)
114+
When we send Parse "" with query "SELECT pg_backend_pid()" to session "one"
115+
And we send Bind "" to "" with params "" to session "one"
116+
And we send Execute "" to session "one"
117+
And we send Sync to session "one"
118+
Then we verify backend_pid from session "one" is different from "first_pid"

0 commit comments

Comments
 (0)