Repository navigation
pgxpool: health check should create new connections before destroying - #1213
Conversation
|
There's a lot going here -- more changes at once than I'm comfortable with for such a critical part of the code. At the very least the stat tracking and behavior changes should be considered separately. It appears that creating connections before closing them could allow the pool to temporarily exceed MaxConns. It could potentially double MaxConns if MinConns == MaxConns. I don't think that is desirable. We already have had issues with that. See #679 and 39b096d. If we are going try to reduce cases where there is a drastic change such as many connections being created or closed at once I think we should take a different approach. My thought is the health check should have a loop that executes as long as there are any changes required to restore health -- but each iteration of that loop only makes one change. i.e. each loop iteration will create or close one connection. If creation happened before close that would ensure that there were no cases where the pool was suddenly emptied or where there was suddenly way more than MaxConns. As far as the stat changes go, |
The stat changes felt related because they are touching the same code and were useful in testing to understand what is actually going on rather than just testing what the resulting number of connections are. They're also critical for us in production to understand how often the pool is changing and why its changing. If connections are consistently being closed for being idle maybe we can lower MinConns or if we're consistently having to make connections maybe we need to raise MinConns, for example. I can separate them out to a new PR if that's better?
I was concerned about that but since it would only be for a second or so I thought it might be okay. Definitely understand needing to respect that.
I actually started with that approach but thought it was needlessly complicated. I can go back to that pretty easily and push up a new commit. What I had done is guarantee we always stay under MaxConns by closing a connection first then creating a connection but only doing one per loop. The downside is that it required a LOT more calls to
There's
I think it says something with the stdlib has a stat given the scrutiny of the changes there. Knowing why connections were closed is extremely important in tweaking the lifetimes. Otherwise you have no idea why things are closing. The health check is the only other place locking that mutex and it only runs every minute, by default, so the lock contention should be almost nonexistent. |
|
I updated the PR with the suggested changes. I also tweaked some of the tests to check in |
|
I just spent a while looking over the code and I do not see any obvious problems. But as the old saying goes, there is a big difference between no obvious problems and obviously no problems.
I know that the async Here's more of what I was originally picturing. First, for p.checkMinConns() || p.checkIdleConnsHealth() {
time.Sleep(500 * time.Millisecond)
}The sleep gives some time for the async Destroy to finish. If we wanted to get really clever the destructor passed into Also, I noticed that |
|
I apologize for the complexity. I was trying to satisfy all of the conditions (stay above min conns and below max conns, ignore idle conns, etc). The only issue I see with your proposal is that
I think you might have this backwards. I removed that code from Release to simplify where connections are getting destroyed and to prevent what you described. |
I don't think it would need to though. At least if we allow it to go one below min conns for a brief window -- and I think that is acceptable. Here's what I picture happening. Assume the pool has min conns = 4 and it currently has 4 connections and all of them are too old. Our pool contents are the following: (conn 1 - too old), (conn 2 - too old), (conn 3 - too old), (conn 4 - too old) In the first iteration of the loop (conn 2 - too old), (conn 3 - too old), (conn 4 - too old) Next iteration: (conn 2 - too old), (conn 3 - too old), (conn 4 - too old), (conn 5 - okay) Next iteration: (conn 3 - too old), (conn 4 - too old), (conn 5 - okay) Next iteration: (conn 3 - too old), (conn 4 - too old), (conn 5 - okay), (conn 6 - okay) Next iteration: (conn 4 - too old), (conn 5 - okay), (conn 6 - okay) Next iteration: (conn 4 - too old), (conn 5 - okay), (conn 6 - okay), (conn 7 - okay) Next iteration: (conn 5 - okay), (conn 6 - okay), (conn 7 - okay) Next iteration: (conn 5 - okay), (conn 6 - okay), (conn 7 - okay), (conn 8 - okay) Last iteration: Neither It does briefly fall below min conns, but it never flushes the whole pool either. I think it is reasonable behavior.
Ah you're right. Though looking back on it again I think I see the reason why that code was there. The pool health check only runs occasionally and only can look at idle conns. If the pool was under heavy usage a conn might never actually be visible to the health check and thereby never actually be closed. Maybe the check should remain in |
I would prefer to air on the side of going above max versus going below min but I'll start with what you outlined and see if anything stands out as a way to solve it. If there is more than 1 connection above MinConns is it okay for multiple to be closed? I would think
Should it block Release or should it just make the connection in a goroutine? I wasn't sure if it would be okay for Release to take multiple seconds making a new connection (obviously that's a worst case). Overall, I appreciate the back-and-forth and I hope my next push is much closer to what you had in mind. |
I prefer to error on the side of going below min conns because the worst that happens below min conns is an
I'm not sure it's necessary.
Definitely should not block. Probably the most elegant solution would be to use a channel to do a non-blocking send to the |
Makes sense. I made sure to only preemptively create a new connection if we're under MaxConns.
The only issue I ran into with that was that the health check did nothing since The other thing I ran into was: What to do if |
b2dc7d0 to
bf96bc9
Compare
|
The |
|
Sorry to keep nitpicking this PR, but I still think this is too complicated for the benefit gained. We want to avoid all the connections in the pool dying at once when the all hit their max lifetime at once, but it's not worth worrying about it briefly going under by a small amount. There seems to be a lot of code dedicated to carefully preventing actual conns from going under min conns and minimizing the amount of time when it does. I'm willing and prefer to trade a little less rigor in maintaining Obviously, I haven't thought through the edge cases in the detail you have (in particular -- good catch on needing to handle For example, given the previously suggested change of func (p *Pool) backgroundHealthCheck() {
ticker := time.NewTicker(p.healthCheckPeriod)
defer ticker.Stop()
go func() {
for {
select {
case <-p.closeChan:
return
case <-ticker.C:
p.healthCheckChan <- struct{}{}
}
}
}()
for {
select {
case <-p.closeChan:
return
case <-p.healthCheckChan:
if p.checkIdleConnsHealth() || p.checkMinConns() {
select {
case p.healthCheckChan <- struct{}{}:
default:
}
}
time.Sleep(500 * time.Millisecond)
}
}
}
go func() {
time.Sleep(time.Second) // give connection time to completely close before triggering health check.
select {
case c.p.healthCheckChan <- struct{}{}:
default:
}
}()The sleep should be long enough for the destroy to finish before the health check is triggered -- and if occasionally its not enough it really shouldn't matter.
Creating the connection should be done asynchronously (not sure if that's happening in this new version). So we won't have any way of knowing if the connection was established successfully -- all we know is we started a connection process.
Yeah, it's annoying dealing with timing in tests like that. I wouldn't worry about it for now. |
You'd know better than I how common MinConns = 10 is versus MinConns = 1. We have MinConns = 1 in many of our production services. Granted I could make it 2 or 3 but to have more connections sitting around all the time just to prevent an empty pool when we might need it seems like a waste of resources. We might be an edge case but connection setup for us takes a few seconds per connection since we have to prime the pg catalog in AfterConnect (which is a separate issue in itself). It is actually more common than you'd think, multiple times an hour, that we'd see calls taking multiple seconds and fsil SLO because they're waiting in Acquire for a new connection since the pool was emptied. Hense why I started this PR anyways.
The extra goroutine to listen on the ticker just to write to the check channel seems unnecessary. Moving the actual health check if or loop into a separate function (or even duplicating the code since it's so small in your example) and having 2 branches call it (on ticker or channel recv) seems a lot simpler for that code. My current commit's Which is a lot easier to understand in my opinion especially if I rename I couldn't find a use for checkMinConns returning anything other than an error since really the only thing that should keep the health check going is if there are more connections to remove.
That seems fine assuming we're okay with the pool potentially not having any connections for at least a whole second. Again, not sure how common that is or how unexpected that is. Also, even with MinConns=10, the initial connections are all made at the same time so they'll all expire at the same time. So you could still end up with an empty pool if you're using all 10 connections heavily and each time you Release() they're destroyed. If you had 10 RPS and MinConns=10 then your pool would be empty in a second before we even try to make new ones. Would you be open to the max lifetime having some configurable jitter? Maybe time.Minute? So we would randomly add to the lifetime when checking up to the "max jitter".
I'm using the existing An example that happened to us with CockroachDB was that the leader was overloaded so new connections failed because they timed out trying to authenticate but existing connections were already authenticated so they were fine. Our applications kept trying to make new connections after killing existing ones which further overloaded the leader and the only solution was for us to stop all of our applications and start them up one-by-one. |
|
Okay, I think I see why now we keep coming to different solutions -- we've been looking at very different, almost opposite problems. The problem I was looking at is:
And the problem you were looking at is:
It may not be possible to solve both problems at the same time without a more complicated solution than I was hoping for. 😮💨 My insistence on handling one connection at a time with a delay in between cycles was aimed at the avoiding the thundering herd problem on a busy server with many connections. But I see now that that approach is unhelpful or even harmful to the problem you are trying to solve... It appears to me that the cause of most of the confusion I had with this PR was the attempt to work around the asynchronous, fire-and-forget nature of closing and creating connections. When a connection is created we know that we started the connection process but we don't get a signal when it is ready for use. And when a connection is destroyed we don't get a signal when it is finished and there is now room in the pool. If we had that signaling mechanism we could reliably handle cases like the following: When connection is too old:
That would still be a significant increase in complexity, but maybe it is unavoidable. Or maybe one of the approaches in this PR is enough... IDK. |
|
Updated the PR and removed a lot of the complexity. Getting something merged it better than nothing. I did add a "jitter" which can help reduce the chance for the pool to be emptied via I had to remove a bunch of the tests I added because without a guarantee of staying above MinConns a lot of the |
I was trying my best to find a middle ground between both solutions which is why there was a lot of complexity.
I was thinking about that and I thought puddle could add something like That was just my first thought but I didn't have any experience with puddle and I wasn't sure where else it was used so I was hesitant to add that complexity if it was just used by pgxpool, which is why I tried to add the complexity here too. You are correct though, things would be a lot easier in pgxpool if we could get some better control of when things are in and leaving the pool. |
Yeah, I agree. This is probably the best we can do without a signaling mechanism.
Puddle was designed with pgx in mind, but is intended to be entirely generic resource pool. It's goal is to encapsulate all the scary concurrent parts of a But I don't think it would be necessary to modify puddle. A Line 196 in a814153 |
One issue is that the destructor seems to be called before it leaves the pool: https://github.com/jackc/puddle/blob/4e959849469a78bd060ffa7531102bfca85bf125/puddleg/pool.go#L495 Is it possible for the connection to be destructed while it's still in the pool? It looks like the lock is after the deconstructor so is it possible for something to still acquire it from the pool between deconstructor and before it's removed? |
|
It is destructed before it leaves the pool, but that is what we want. |
Instead of closing connections before creating new ones this changes the health checker to create connections before closing them. It also will not close idle connections if that would put the pool below minimum connections.
I also added more stats to provide better visibility into the churn of the pool.
Fixes #1164