Skip to content

Commit 6f3295c

Browse files
committed
Improvements
1 parent 549e6a3 commit 6f3295c

7 files changed

Lines changed: 122 additions & 39 deletions

File tree

adagucserverEC/adagucserver.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ int run_adaguc_once(int argc, char **argv, char **envp) {
8888

8989
int main(int argc, char **argv, char **envp) {
9090
const char *fork_enable = getenv("ADAGUC_FORK_ENABLE");
91-
if (fork_enable && std::string(fork_enable) == "TRUE") {
91+
bool use_fork_server = fork_enable && std::string(fork_enable) == "TRUE" && argc == 1;
92+
if (use_fork_server) {
9293
// Fork children inherit the mother's stdio buffers.
9394
// This keeps stdout/stderr unbuffered, so old buffered output cannot be written into a the unix socket before the HTTP headers.
9495
setvbuf(stdout, NULL, _IONBF, 0);

adagucserverEC/fork_server.cpp

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ See also: doc/fork_server.md
2323
- Methods prefixed with `child_` only get executed by the (forked) children
2424
*/
2525

26-
// Check this many seconds for old left-over processes
27-
const int CHECK_CHILD_PROC_INTERVAL = 30;
2826
// Default for ADAGUC_NUMPARALLELPROCESSES, matches the python default
2927
const int DEFAULT_NUM_PARALLEL_PROCESSES = 4;
3028
// Default for ADAGUC_MAX_PROC_TIMEOUT, matches the python default.
@@ -38,6 +36,19 @@ typedef struct {
3836
static std::map<pid_t, child_proc_t> child_procs;
3937
int self_pipe[2];
4038

39+
/** Close descriptors that belong only to the mother process after a fork. */
40+
void child_close_mother_file_descriptors(int listen_socket) {
41+
close(listen_socket);
42+
close(self_pipe[0]);
43+
close(self_pipe[1]);
44+
45+
// Note: `child_procs` comes from the forked mother process but is not shared. Safe to clean everything.
46+
for (const auto &[pid, child_proc]: child_procs) {
47+
(void)pid;
48+
close(child_proc.child_socket_fd);
49+
}
50+
}
51+
4152
/**
4253
* Gets a positive integer value from the environment.
4354
*
@@ -241,7 +252,7 @@ void mother_handle_child_exited_events() {
241252
void mother_kill_old_child_procs(int max_child_proc_timeout) {
242253
time_t now = time(NULL);
243254
for (auto it = child_procs.begin(); it != child_procs.end(); ++it) {
244-
if (difftime(now, it->second.forked_at) > max_child_proc_timeout) {
255+
if (difftime(now, it->second.forked_at) >= max_child_proc_timeout) {
245256
kill(it->first, SIGKILL);
246257
}
247258
}
@@ -317,8 +328,9 @@ int mother_run_as_fork_service(int (*run_adaguc_once)(int, char **, char **), in
317328
return 1;
318329
}
319330

320-
// Keep one extra child slot above the request limit so ping messages can still be handled when Python's request semaphore is full.
321-
int max_child_procs = mother_get_env_var_int("ADAGUC_NUMPARALLELPROCESSES", DEFAULT_NUM_PARALLEL_PROCESSES) + 1;
331+
// Use `ADAGUC_NUMPARALLELPROCESSES` to set maximum requests, keep one extra slot so PING can be handled when its semaphore is full.
332+
int max_request_child_procs = std::max(mother_get_env_var_int("ADAGUC_NUMPARALLELPROCESSES", DEFAULT_NUM_PARALLEL_PROCESSES), 2);
333+
int max_child_procs = max_request_child_procs + 1;
322334
int max_child_proc_timeout = mother_get_env_var_int("ADAGUC_MAX_PROC_TIMEOUT", DEFAULT_MAX_CHILD_PROC_TIMEOUT);
323335
CDBDebug("Max child processes: %d", max_child_procs);
324336
CDBDebug("Max child process timeout: %d", max_child_proc_timeout);
@@ -330,8 +342,6 @@ int mother_run_as_fork_service(int (*run_adaguc_once)(int, char **, char **), in
330342
}
331343

332344
CDBDebug("Entering fork server loop");
333-
time_t last_cleanup = time(NULL);
334-
335345
while (1) {
336346
// fd_set is modified by select(); reinitialize it each loop to monitor listen_socket and self_pipe again
337347
fd_set readfds;
@@ -359,12 +369,8 @@ int mother_run_as_fork_service(int (*run_adaguc_once)(int, char **, char **), in
359369
continue;
360370
}
361371

362-
// Check for dead processes
363-
time_t now = time(NULL);
364-
if (now - last_cleanup >= CHECK_CHILD_PROC_INTERVAL) {
365-
mother_kill_old_child_procs(max_child_proc_timeout);
366-
last_cleanup = now;
367-
}
372+
// select() wakes at least once per second, first check if there are old processes that need cleaning
373+
mother_kill_old_child_procs(max_child_proc_timeout);
368374

369375
// Only run if there is activity on the self_pipe (to handle exit events)
370376
if (FD_ISSET(self_pipe[0], &readfds)) {
@@ -385,7 +391,7 @@ int mother_run_as_fork_service(int (*run_adaguc_once)(int, char **, char **), in
385391
// Both if/else paths are taken. Mother process takes pid > 0, child process takes pid == 0.
386392
if (pid == 0) {
387393
// Child process handles request. Communication with python happens through `child_socket_fd`
388-
close(listen_socket);
394+
child_close_mother_file_descriptors(listen_socket);
389395
child_handle_client(child_socket_fd, run_adaguc_once, argc, argv, envp);
390396
_exit(1);
391397
} else if (pid > 0) {

doc/EnvironmentVariablesAndExitCodes.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ The following exit codes are supported:
3838
| `ADAGUC_FONT` | Path to a default TrueType font (TTF) used for rendering text in generated imagery (e.g. `FreeSans.ttf`). | -|
3939
| `ADAGUC_FORK_ENABLE` | Enables running adaguc-server in [fork mode](/doc/fork_server.md) when set to `TRUE`. | `FALSE` |
4040
| `ADAGUC_LOGFILE` | File where log messages are written. | -|
41+
| `ADAGUC_MAX_COMMAND_TIMEOUT` | Maximum processing time in seconds for command-style invocations such as `--updatedb` and `--updatelayermetadata`. | `300` |
4142
| `ADAGUC_MAX_PROC_TIMEOUT` | Maximum allowed processing time (in seconds) for a single adaguc-server request. If a request exceeds this limit, the server terminates the process and returns an HTTP 500 error to the client. This prevents requests from running indefinitely and blocking server resources. | 10 |
4243
| `ADAGUC_NUMPARALLELPROCESSES` | Number of parallel worker processes used by the adaguc-server. | `4` |
4344
| `ADAGUC_ONLINERESOURCE` | Optional override for the [OnlineResource](configuration/OnlineResource.md) URL used by the CGI service. Can also be configured in the XML configuration file. | -|
@@ -51,4 +52,4 @@ The following exit codes are supported:
5152
| `ADAGUC_TRUSTED_HOSTS` | When EXTERNALADDRESS is unset, this is the allowed hosts list that can be advertised as online resource in the WMS GetCapabilities | * |
5253
| `ADAGUC_TRUSTED_PROXIES` | When EXTERNALADDRESS is unset, this is the list of trusted proxies from which adaguc-server will determine its external address. (Via the x-forwared- headers) | * |
5354
| `PGBOUNCER_DISABLE_SSL` | Disables SSL when connecting through PgBouncer. | `true` |
54-
| `PGBOUNCER_ENABLE` | Enables or disables PostgreSQL connection pooling through PgBouncer. | `true` |
55+
| `PGBOUNCER_ENABLE` | Enables or disables PostgreSQL connection pooling through PgBouncer. | `true` |

doc/Running.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ bash docker-compose-generate-env.sh \
4242
-f $ADAGUCDOCKERHOME/adaguc-data \
4343
-p 443
4444
# You can view or edit the file ./.env file
45-
46-
For more info on what environment variables you can configure, see [Environment variables](/doc/Environment_Variables.md)
4745
```
4846

47+
For more info on what environment variables you can configure, see [Environment variables](EnvironmentVariablesAndExitCodes.md).
48+
4949
### Step 3. Once the steps above have been done, it is time to start:
5050

5151
```
@@ -129,4 +129,4 @@ A tip to list the actual contents in the docker container is to do:
129129
```
130130
docker exec -i -t my-adaguc-server bash -c "ls -lrt /data/adaguc-data"
131131
docker exec -i -t my-adaguc-server bash -c "ls -lrt /data/adaguc-datasets"
132-
```
132+
```

doc/fork_server.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Each request is handled in a separate child process, while the mother process re
55

66
This design reduces per-request overhead by avoiding repeated process initialization.
77

8-
The fork server is enabled by setting the environment variable `ADAGUC_FORK_ENABLE` to `TRUE`. If this variable is not set to `TRUE`, ADAGUC runs without the fork server.
8+
The fork server is enabled by setting the environment variable `ADAGUC_FORK_ENABLE` to `TRUE`. If this variable is not set to `TRUE`, ADAGUC runs without the fork server. Command-style invocations with arguments, such as `--updatedb`, `--updatelayermetadata`, `--lint`, and `--report`, always run as normal subprocesses.
99

1010
# Components
1111

@@ -15,7 +15,7 @@ The system consists of three parts:
1515
- Mother process (C++): A persistent process that listens for requests and manages child processes.
1616
- Child processes (C++): Short-lived processes created with `fork()`. Each child handles one request.
1717

18-
The maximum number of concurrent children is limited by the environment variable `ADAGUC_NUMPARALLELPROCESSES`.
18+
The environment variable `ADAGUC_NUMPARALLELPROCESSES` determines the number of concurrent processes, with a minimum of two so a metadata update does not block other requests. The mother process permits one additional child, so the supervisor can perform its `PING` health check while other requests can get handled.
1919

2020
Communication between the Python server and the mother process occurs via a Unix domain socket.
2121

@@ -122,4 +122,4 @@ The fork server (mother process) is managed by a Python supervisor `fork_server_
122122
- Starts the process during application startup
123123
- Performs periodic health checks via the Unix socket
124124
- Restarts the process if it becomes unresponsive or exits
125-
- Terminates the process during application shutdown
125+
- Terminates the mother and its child process group during application shutdown

python/lib/adaguc/CGIRunner.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
ON_POSIX = "posix" in sys.builtin_module_names
3232

3333
MAX_PROC_TIMEOUT = int(os.getenv("ADAGUC_MAX_PROC_TIMEOUT", "10"))
34+
MAX_COMMAND_TIMEOUT = int(os.getenv("ADAGUC_MAX_COMMAND_TIMEOUT", "300"))
3435

3536

3637
class AdagucResponse(NamedTuple):
@@ -84,13 +85,20 @@ async def socket_communicate(url: str, env: dict[str, str]) -> AdagucResponse:
8485

8586
reader, writer = await asyncio.open_unix_connection(get_fork_socket_path())
8687

87-
writer.write(message_bytes)
88-
await writer.drain()
88+
try:
89+
writer.write(message_bytes)
90+
await writer.drain()
8991

90-
process_output = await reader.read()
92+
process_output = await reader.read()
93+
finally:
94+
writer.close()
95+
try:
96+
await writer.wait_closed()
97+
except OSError:
98+
pass
9199

92-
writer.close()
93-
await writer.wait_closed()
100+
if len(process_output) < 4:
101+
raise RuntimeError("Invalid response from ADAGUC fork server: missing exit status")
94102

95103
# Status code is stored in the last 4 bytes from the received data
96104
status_code = int.from_bytes(process_output[-4:], sys.byteorder)
@@ -146,7 +154,7 @@ async def run(
146154
env: dict = {},
147155
path: str | None = None,
148156
isCGI: bool = True,
149-
timeout: int = MAX_PROC_TIMEOUT,
157+
timeout: int | None = None,
150158
) -> tuple[int, list[str], bytes | None, bytes]:
151159
localenv = {}
152160
if url != None:
@@ -159,8 +167,11 @@ async def run(
159167
localenv["REQUEST_URI"] = "/myscriptname/" + path
160168
localenv.update(env)
161169

162-
# Only use fork server if ADAGUC_FORK_ENABLE=TRUE and adaguc is not executed with extra arguments e.g. `--updatelayermetadata`
163-
use_fork = is_fork_enabled() and len(cmds) == 1
170+
# Command-style calls, such as `--updatelayermetadata`, always use a subprocess and may run much longer than web requests.
171+
is_command = len(cmds) > 1
172+
use_fork = is_fork_enabled() and not is_command
173+
if timeout is None:
174+
timeout = MAX_COMMAND_TIMEOUT if is_command else MAX_PROC_TIMEOUT
164175

165176
async with sem:
166177
if use_fork:

python/python_fastapi_server/fork_server_supervisor.py

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,22 @@
1212

1313

1414
class ForkServerSupervisor:
15-
def __init__(self, interval: int = 5):
15+
def __init__(self, interval: int = 5, startup_timeout: float = 10.0):
1616
"""Initialize supervisor with binary path and health check interval.
1717
1818
ADAGUC_CONFIG and ADAGUC_ONLINERESOURCE need to be set manually
1919
"""
2020

2121
self.interval = interval
22+
self.startup_timeout = startup_timeout
2223
self.process: asyncio.subprocess.Process | None = None
2324

2425
self.env = os.environ.copy()
2526
adaguc_env = runAdaguc().getAdagucEnv()
2627
self.env.update({k: str(v) for k, v in adaguc_env.items()})
27-
self.env["ADAGUC_CONFIG"] = f"{self.env.get('ADAGUC_PATH')}/python/lib/adaguc/adaguc-server-config-python-postgres.xml"
28+
self.env["ADAGUC_CONFIG"] = os.environ.get(
29+
"ADAGUC_CONFIG", f"{self.env.get('ADAGUC_PATH')}/python/lib/adaguc/adaguc-server-config-python-postgres.xml"
30+
)
2831
self.env["ADAGUC_ONLINERESOURCE"] = os.environ.get("EXTERNALADDRESS", "") + "/adaguc-server?"
2932

3033
self.adaguc_binary_path = f"{self.env.get('ADAGUC_PATH')}/bin/adagucserver"
@@ -40,7 +43,23 @@ async def start_process(self):
4043
return
4144

4245
logger.info("Starting forkserver")
43-
self.process = await asyncio.create_subprocess_exec(self.adaguc_binary_path, env=self.env)
46+
47+
# Isolate the mother and its future forked children in a process group separate from FastAPI.
48+
self.process = await asyncio.create_subprocess_exec(self.adaguc_binary_path, env=self.env, start_new_session=True)
49+
50+
async def wait_for_process_group_exit(self, process_group_id: int, timeout: float) -> bool:
51+
"""Wait until a process group no longer contains any processes."""
52+
53+
loop = asyncio.get_running_loop()
54+
deadline = loop.time() + timeout
55+
while True:
56+
try:
57+
os.killpg(process_group_id, 0)
58+
except ProcessLookupError:
59+
return True
60+
if loop.time() >= deadline:
61+
return False
62+
await asyncio.sleep(min(0.05, deadline - loop.time()))
4463

4564
async def stop_process(self):
4665
"""Terminate the subprocess gracefully, force kill if needed."""
@@ -49,14 +68,37 @@ async def stop_process(self):
4968
return
5069

5170
logger.info("Stopping forkserver")
71+
loop = asyncio.get_running_loop()
72+
deadline = loop.time() + 2
73+
74+
try:
75+
# Signal the isolated process group, including the mother and every forked request child.
76+
os.killpg(self.process.pid, signal.SIGTERM)
77+
except ProcessLookupError:
78+
pass
79+
5280
if self.process.returncode is None:
53-
self.process.send_signal(signal.SIGTERM)
5481
try:
55-
await asyncio.wait_for(self.process.wait(), timeout=2)
82+
await asyncio.wait_for(self.process.wait(), timeout=max(0, deadline - loop.time()))
5683
except asyncio.TimeoutError:
57-
logger.warning("Force killing forkserver")
58-
self.process.kill()
59-
await self.process.wait()
84+
pass
85+
86+
group_exited = await self.wait_for_process_group_exit(self.process.pid, timeout=max(0, deadline - loop.time()))
87+
if not group_exited:
88+
logger.warning("Force killing forkserver process group")
89+
try:
90+
# Force-stop anything in the group that did not handle SIGTERM within the grace period.
91+
os.killpg(self.process.pid, signal.SIGKILL)
92+
except ProcessLookupError:
93+
pass
94+
95+
if self.process.returncode is None:
96+
await self.process.wait()
97+
98+
try:
99+
os.unlink(get_fork_socket_path())
100+
except FileNotFoundError:
101+
pass
60102

61103
self.process = None
62104

@@ -68,6 +110,24 @@ async def restart_process(self):
68110

69111
await self.stop_process()
70112
await self.start_process()
113+
if not await self.wait_until_ready():
114+
logger.error("Forkserver did not become ready after restart")
115+
await self.stop_process()
116+
117+
async def wait_until_ready(self) -> bool:
118+
"""Wait until the forkserver answers health checks or the startup deadline expires."""
119+
120+
loop = asyncio.get_running_loop()
121+
deadline = loop.time() + self.startup_timeout
122+
123+
while not self._stopping and loop.time() < deadline:
124+
if not self.process or self.process.returncode is not None:
125+
return False
126+
if await self.health_check_mother():
127+
return True
128+
await asyncio.sleep(0.05)
129+
130+
return False
71131

72132
async def health_check_mother(self, timeout: float = 1.0) -> bool:
73133
"""Check forkserver health via UNIX socket ping."""
@@ -97,7 +157,7 @@ async def _loop(self):
97157

98158
# Restart if process crashed
99159
if self.process and self.process.returncode is not None:
100-
await self.start_process()
160+
await self.restart_process()
101161

102162
# Health check
103163
elif not await self.health_check_mother():
@@ -114,6 +174,10 @@ async def start_monitoring(self):
114174
self._running = True
115175
self._stopping = False
116176
await self.start_process()
177+
if not await self.wait_until_ready():
178+
await self.stop_process()
179+
self._running = False
180+
raise RuntimeError("Forkserver did not become ready during application startup")
117181
self._task = asyncio.create_task(self._loop())
118182

119183
async def stop_monitoring(self):

0 commit comments

Comments
 (0)