apply request timeout also while waiting for a connection from pool

This commit is contained in:
Sarah Hoffmann
2025-09-05 14:47:14 +02:00
parent 563255202d
commit 3a50f749dd
6 changed files with 93 additions and 22 deletions

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Implementation of classes for API access via libraries.
@@ -14,6 +14,11 @@ import sys
import contextlib
from pathlib import Path
if sys.version_info >= (3, 11):
from asyncio import timeout_at
else:
from async_timeout import timeout_at
import sqlalchemy as sa
import sqlalchemy.ext.asyncio as sa_asyncio
@@ -26,6 +31,7 @@ from .connection import SearchConnection
from .status import get_status, StatusResult
from .lookup import get_places, get_detailed_place
from .reverse import ReverseGeocoder
from .timeout import Timeout
from . import search as nsearch
from . import types as ntyp
from .results import DetailedResult, ReverseResult, SearchResults
@@ -172,12 +178,15 @@ class NominatimAPIAsync:
await self.close()
@contextlib.asynccontextmanager
async def begin(self) -> AsyncIterator[SearchConnection]:
async def begin(self, abs_timeout: Optional[float] = None) -> AsyncIterator[SearchConnection]:
""" Create a new connection with automatic transaction handling.
This function may be used to get low-level access to the database.
Refer to the documentation of SQLAlchemy for details how to use
the connection object.
You may optionally give an absolute timeout until when to wait
for a connection to become available.
"""
if self._engine is None:
await self.setup_database()
@@ -185,14 +194,15 @@ class NominatimAPIAsync:
assert self._engine is not None
assert self._tables is not None
async with self._engine.begin() as conn:
async with timeout_at(abs_timeout), self._engine.begin() as conn:
yield SearchConnection(conn, self._tables, self._property_cache, self.config)
async def status(self) -> StatusResult:
""" Return the status of the database.
"""
timeout = Timeout(self.request_timeout)
try:
async with self.begin() as conn:
async with self.begin(abs_timeout=timeout.abs) as conn:
conn.set_query_timeout(self.query_timeout)
status = await get_status(conn)
except (PGCORE_ERROR, sa.exc.OperationalError):
@@ -205,8 +215,9 @@ class NominatimAPIAsync:
Returns None if there is no entry under the given ID.
"""
timeout = Timeout(self.request_timeout)
details = ntyp.LookupDetails.from_kwargs(params)
async with self.begin() as conn:
async with self.begin(abs_timeout=timeout.abs) as conn:
conn.set_query_timeout(self.query_timeout)
if details.keywords:
await nsearch.make_query_analyzer(conn)
@@ -217,8 +228,9 @@ class NominatimAPIAsync:
Returns a list of place information for all IDs that were found.
"""
timeout = Timeout(self.request_timeout)
details = ntyp.LookupDetails.from_kwargs(params)
async with self.begin() as conn:
async with self.begin(abs_timeout=timeout.abs) as conn:
conn.set_query_timeout(self.query_timeout)
if details.keywords:
await nsearch.make_query_analyzer(conn)
@@ -235,8 +247,9 @@ class NominatimAPIAsync:
# There are no results to be expected outside valid coordinates.
return None
timeout = Timeout(self.request_timeout)
details = ntyp.ReverseDetails.from_kwargs(params)
async with self.begin() as conn:
async with self.begin(abs_timeout=timeout.abs) as conn:
conn.set_query_timeout(self.query_timeout)
if details.keywords:
await nsearch.make_query_analyzer(conn)
@@ -251,10 +264,11 @@ class NominatimAPIAsync:
if not query:
raise UsageError('Nothing to search for.')
async with self.begin() as conn:
timeout = Timeout(self.request_timeout)
async with self.begin(abs_timeout=timeout.abs) as conn:
conn.set_query_timeout(self.query_timeout)
geocoder = nsearch.ForwardGeocoder(conn, ntyp.SearchDetails.from_kwargs(params),
self.request_timeout)
timeout)
phrases = [nsearch.Phrase(nsearch.PHRASE_ANY, p.strip()) for p in query.split(',')]
return await geocoder.lookup(phrases)
@@ -268,7 +282,8 @@ class NominatimAPIAsync:
**params: Any) -> SearchResults:
""" Find an address using structured search.
"""
async with self.begin() as conn:
timeout = Timeout(self.request_timeout)
async with self.begin(abs_timeout=timeout.abs) as conn:
conn.set_query_timeout(self.query_timeout)
details = ntyp.SearchDetails.from_kwargs(params)
@@ -310,7 +325,7 @@ class NominatimAPIAsync:
if amenity:
details.layers |= ntyp.DataLayer.POI
geocoder = nsearch.ForwardGeocoder(conn, details, self.request_timeout)
geocoder = nsearch.ForwardGeocoder(conn, details, timeout)
return await geocoder.lookup(phrases)
async def search_category(self, categories: List[Tuple[str, str]],
@@ -323,8 +338,9 @@ class NominatimAPIAsync:
if not categories:
return SearchResults()
timeout = Timeout(self.request_timeout)
details = ntyp.SearchDetails.from_kwargs(params)
async with self.begin() as conn:
async with self.begin(abs_timeout=timeout.abs) as conn:
conn.set_query_timeout(self.query_timeout)
if near_query:
phrases = [nsearch.Phrase(nsearch.PHRASE_ANY, p) for p in near_query.split(',')]
@@ -333,7 +349,7 @@ class NominatimAPIAsync:
if details.keywords:
await nsearch.make_query_analyzer(conn)
geocoder = nsearch.ForwardGeocoder(conn, details, self.request_timeout)
geocoder = nsearch.ForwardGeocoder(conn, details, timeout)
return await geocoder.lookup_pois(categories, phrases)

View File

@@ -10,12 +10,12 @@ Public interface to the search code.
from typing import List, Any, Optional, Iterator, Tuple, Dict
import itertools
import re
import datetime as dt
import difflib
from ..connection import SearchConnection
from ..types import SearchDetails
from ..results import SearchResult, SearchResults, add_result_details
from ..timeout import Timeout
from ..logging import log
from .token_assignment import yield_token_assignments
from .db_search_builder import SearchBuilder, build_poi_search, wrap_near_search
@@ -29,10 +29,10 @@ class ForwardGeocoder:
"""
def __init__(self, conn: SearchConnection,
params: SearchDetails, timeout: Optional[int]) -> None:
params: SearchDetails, timeout: Timeout) -> None:
self.conn = conn
self.params = params
self.timeout = dt.timedelta(seconds=timeout or 1000000)
self.timeout = timeout
self.query_analyzer: Optional[AbstractQueryAnalyzer] = None
@property
@@ -78,8 +78,6 @@ class ForwardGeocoder:
log().section('Execute database searches')
results: Dict[Any, SearchResult] = {}
end_time = dt.datetime.now() + self.timeout
min_ranking = searches[0].penalty + 2.0
prev_penalty = 0.0
for i, search in enumerate(searches):
@@ -99,7 +97,7 @@ class ForwardGeocoder:
min_ranking = min(min_ranking, result.accuracy * 1.2, 2.0)
log().result_dump('Results', ((r.accuracy, r) for r in lookup_results))
prev_penalty = search.penalty
if dt.datetime.now() >= end_time:
if self.timeout.is_elapsed():
break
return SearchResults(results.values())

View File

@@ -0,0 +1,24 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Helpers for handling of timeouts for request.
"""
from typing import Union, Optional
import asyncio
class Timeout:
""" A class that provides helper functions to ensure a given timeout
is respected. Can only be used from coroutines.
"""
def __init__(self, timeout: Optional[Union[int, float]]) -> None:
self.abs = None if timeout is None else asyncio.get_running_loop().time() + timeout
def is_elapsed(self) -> bool:
""" Check if the timeout has already passed.
"""
return (self.abs is not None) and (asyncio.get_running_loop().time() >= self.abs)