fix: Admin server dies silently#5064
Conversation
d5b6b67 to
7f2a8bf
Compare
wolfgangwalther
left a comment
There was a problem hiding this comment.
Thought a bit about how to test EMFILE stuff. I think it should be possible to test this with our current IO-test infrastructure. Will need to change how PostgREST is run - something like:
subprocess.Popen('ulimit ...; postgrest', shell=True)
This way we can lower the ulimit to the bare minimum, so that possibly even the first admin request fails, when no other concurrent requests are handled.
155ff1d to
fb666ef
Compare
3b0a203 to
5d23def
Compare
Added reproducer in a separate commit. |
5d23def to
77226f7
Compare
3195a6d to
b104780
Compare
|
|
||
| with contextlib.ExitStack() as stack: | ||
| opened_sockets = 0 | ||
| for _ in range(nofile_limit * 3): |
There was a problem hiding this comment.
Why multiple it by 3? I am assuming this is to ensure that socket exhaustion is reached, right? Maybe we can add a comment?
There was a problem hiding this comment.
I think we could indeed remove the * 3. That's because PostgREST will always use some FDs for other things than http connections: At least stdin, stdout, stderr, but also PG connections and other stuff. So we are guaranteed to hit EMFILE before rlimit_nofile - if not, something is seriously broken.
| accept sock `catchEMFile` | ||
| do | ||
| logEMFILE | ||
|
|
There was a problem hiding this comment.
I noticed that this function could be moved to where like this. You may ignore this suggestion as this isn't strictly needed, just a matter of personal preference I think.
address <- resolveSocketToAddress adminSocket
debouncedEMFILELogger <- makeDebouncer $ observer AdminServerAcceptEMFILEFailure *> threadDelay 10_000_000
void . forkIO $ handle (onError adminSocket) $
Warp.runSettingsSocket (adminServerSettings conf address (safeAccept debouncedEMFILELogger)) adminSocket adminApp
where
...
isEMFILE err = Just eMFILE == fmap Errno (ioe_errno err)
catchEMFile action handler = handleJust (\e -> [ e | isEMFILE e]) (const handler) action
adminServerSettings config addr safeAccept' = do
-- log EMFILE at most once every 10s
settings
& Warp.setAccept safeAccept'
& Warp.setBeforeMainLoop (observer $ AdminStartObs addr)
& maybe identity Warp.setPort (configAdminServerPort config)
...
-- Keep accepting on eMFILE
safeAccept logEMFILEObs sock =
accept sock `catchEMFile`
do
void logEMFILEObs
connCount <- Warp.currentOpenConnections serverState
join $ atomically $ do
if connCount > 0 then do
-- There are open connections
-- wait for closing some and restart accepting
nextConnCount <- Warp.currentOpenConnectionsSTM serverState
check (nextConnCount < connCount)
pure $ safeAccept logEMFILEObs sock
else
pure $ do
-- Backoff for 1ms so that we don't enter busy accept loop
-- this should let the system recover fd's once the pressure is gone
-- then restart accepting
threadDelay 1_000
safeAccept logEMFILEObs sock
There was a problem hiding this comment.
@mkleczek Please address this feedback. Trying to merge this now.
|
Overall looks good! I think this should be tested the same way as done in #5012 (comment) to make sure this works under heavy load. |
|
Q: Shouldn't we better try to discuss/fix this upstream given we already got feedback? So far looks like the fix here is resulting in perf loss (ref) and the logic looks a bit fragile. |
Not sure when upstream is going to release the fix, created this PR to have it fixed in PostgREST ASAP (for
As stated in one of the previous threads, our loadtests are not trustworthy. The changes in this PR have nothing to do with main server (they are really only affecting an exceptional path in
Why? That's really what upstream is going to do. Right now they are doing the same except they don't check for There is also a question about observation logging in upstream fix. |
b104780 to
554fe47
Compare
I wonder whether we can accidentally test changes that are on main, but not in the PR, with the loadtests. Here's how:
We should really checkout the merge-base between the PR and main instead. |
wolfgangwalther
left a comment
There was a problem hiding this comment.
Only looked at the test, this looks much better than my shell-based proposal.
(I would have tried that and opened a PR, but didn't find the time earlier)
|
|
||
| with contextlib.ExitStack() as stack: | ||
| opened_sockets = 0 | ||
| for _ in range(nofile_limit * 3): |
There was a problem hiding this comment.
I think we could indeed remove the * 3. That's because PostgREST will always use some FDs for other things than http connections: At least stdin, stdout, stderr, but also PG connections and other stuff. So we are guaranteed to hit EMFILE before rlimit_nofile - if not, something is seriously broken.
| with run(env=env, wait_for=None, rlimit_nofile=nofile_limit) as postgrest: | ||
| wait_until_status_code(postgrest.session.baseurl + "/", 5, 200) |
There was a problem hiding this comment.
| with run(env=env, wait_for=None, rlimit_nofile=nofile_limit) as postgrest: | |
| wait_until_status_code(postgrest.session.baseurl + "/", 5, 200) | |
| with run(env=env, rlimit_nofile=nofile_limit) as postgrest: |
It does not seem necessary to do that in my tests. It's fine to not have the very first connection to the admin server further below. Fails and passes as expected.
(the comment further down needs adjustment as well then)
| opened_sockets = 0 | ||
| for _ in range(nofile_limit * 3): | ||
| sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) | ||
| sock.settimeout(0.2) |
There was a problem hiding this comment.
In my tests it seemed like this was not needed:
| sock.settimeout(0.2) |
| # sockets are connected to the main server, so the admin server has | ||
| # no active accepted connections when accept hits EMFILE. | ||
| with pytest.raises(requests.RequestException): | ||
| postgrest.admin.get("/live", timeout=0.2) |
There was a problem hiding this comment.
Maybe have a comment explaining this timeout: If we don't have it, the connection will be stuck forever.
I believe this would be fixed by #5077, so maybe make it a TODO to remove the timeout after that PR is in.
Although I can't reproduce it, so maybe it was not even this sleep at all...
| opened_sockets += 1 | ||
|
|
||
| assert opened_sockets >= nofile_limit | ||
| time.sleep(0.2) |
There was a problem hiding this comment.
I seem to need that sleep, but I don't know exactly why - and I don't like that fact. This seems to just open the door for another strange timing related failure in IO tests.
When I don't have it, then I sometimes get a Failed: DID NOT RAISE <class 'requests.exceptions.RequestException'>, right below...
There was a problem hiding this comment.
I think that sleep is needed to give the server some time to hit the FD limit and sit in that EMFILE error state. Not sure exactly why either. Without this the below exception isn't raised which is why you see the Failed: DID NOT RAISE ... error.
This seems to just open the door for another strange timing related failure in IO tests.
Agreed. Another option could be to test that the exception occurs by making the request in a while loop with 0.05 sec sleep on each iteration.
d3e7298 to
6ea1a07
Compare
|
Tested this with # while under load
curl localhost:3001/ready -i
# this hangs for a while.. then replies:
HTTP/1.1 500 Internal Server Error
Transfer-Encoding: chunked
Date: Mon, 06 Jul 2026 20:21:55 GMT
Server: postgrest/15 (pre-release)
Content-Type: text/plain; charset=utf-8
Something went wrongMaybe we can improve the error message above? Then once the load stops the Admin server recovers: The logs show: |
|
So far it's looking like an acceptable fix. Although I'm seeing a fix upstream already open yesodweb/wai#1084, would be good to compare the solutions and give feeback there too. |
Any idea why the above response happens? |
| def test_admin_server_does_not_crash_when_file_limit_is_reached(defaultenv): | ||
| "Admin server should keep running when accept reaches the open file limit." |
There was a problem hiding this comment.
I was thinking this test is unnecessary because it's testing Warp behavior.
But on second thought, it's worth to have it since EMFILE will surface even with the upstream patch and we have an uncommon setup with two servers.
So this test can be merged independently of this PR (marked with xfail). That would also allow reusing it on #5078.
Looks like the message is produced by |
Our code allows escaping exceptions - it does not wrap the whole piece in I suppose Looks like the code should be changed to: It is also a strong hint to merge #5077. |
Thanks for clarifying, I think we should do that.
There are some doubts if that's the right move, but even then this fix is good because the admin server survives. I'll also add that I've tried |
Can confirm the "Something went wrong" body goes away with the above, added it on 1e4dc38 |
|
@mkleczek @wolfgangwalther @taimoorzaeem Any thoughts on whether we should prefer this PR or #5078? |
Although we have tested the upstream fix, I think we can't completely trust it until it is merged and released. Besides, the fix in this PR is also logging an observation on failing to accept the connection which #5078 doesn't have. If the behavior and performance is almost the same, I think we should go with this PR for now (we do need to improve this PR though). |
|
Ok cool, so then we should merge #5077 before to not have the |
Added test_admin_server_does_not_crash_when_file_limit_is_reached marked with xfail.
This is a workaround for Warp crashing with "thread blocked indefinitely in an STM transaction" when receiving eMFILE from accept. The fix is to provide Warp with a custom accept function that handles eMFILE by logging it and keeping accepting connections.
6ea1a07 to
975f4ec
Compare
Looking at that log line I don't think it helps operators that much. We already log: Which gives more or less the same info plus
Upstream already merged the PR yesodweb/wai#1084. I think it's clear that relying on upstream patches when we can is much better for us since we have less code/tests + less to review. Also not that much difference in patch speed. So IMO we should go with #5078. |
Agreed. 👍 |
This is a workaround for Warp crashing with "thread blocked indefinitely in an STM transaction" when receiving eMFILE from accept.
The fix is to provide Warp with a custom accept function that handles eMFILE by logging it and keeping accepting connections.
Fixes #5012.