Skip to content

Commit dc57622

Browse files
jirhikerclaude
andcommitted
fix: stop per-request OOM on location export endpoints
The /location/feature_collection and /location/shapefile endpoints loaded the entire SampleLocation table into memory on a single request, spiking memory enough for App Engine to terminate the process. - feature_collection: stream GeoJSON row-by-row via StreamingResponse + yield_per instead of building two full in-memory lists. Output shape unchanged. - shapefile: write to a tempfile.mkdtemp() dir (App Engine app dir is read-only; /tmp is RAM-backed) with yield_per streaming and clean up the temp dir via BackgroundTask after send. - Both endpoints changed from async def to def so blocking sync DB/file work runs in the threadpool instead of stalling the event loop. - db: disable echo on the production async engine and add bounded pools (pool_size, max_overflow, pool_recycle, pool_pre_ping) on both engines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4542ff8 commit dc57622

2 files changed

Lines changed: 48 additions & 27 deletions

File tree

api/base.py

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515
# ===============================================================================
16+
import json
17+
import os
18+
import shutil
19+
import tempfile
1620
from typing import List, Union
1721

1822
from constants import SRID_WGS84
@@ -29,7 +33,11 @@
2933
from services.validation.well import validate_screens
3034
from sqlalchemy import select, func
3135
from sqlalchemy.orm import Session
32-
from starlette.responses import FileResponse
36+
from starlette.background import BackgroundTask
37+
from starlette.responses import FileResponse, StreamingResponse
38+
39+
# stream DB rows in chunks instead of buffering the whole table in memory
40+
_STREAM_CHUNK = 1000
3341

3442
from db import get_db_session, adder
3543
from db.base import (
@@ -196,7 +204,7 @@ def create_equipment(
196204

197205
# ==== Get ============================================
198206
@router.get("/location/shapefile", summary="Get location as shapefile")
199-
async def get_location_shapefile(
207+
def get_location_shapefile(
200208
query: str = None, session: Session = Depends(get_db_session)
201209
):
202210
"""
@@ -206,50 +214,55 @@ async def get_location_shapefile(
206214
if query:
207215
sql = sql.where(make_query(SampleLocation, query))
208216

209-
result = session.execute(sql)
210-
locations = result.scalars().all()
211-
# create a shapefile from the locations
217+
# Write into a temp dir: the App Engine app directory is read-only, and
218+
# streaming rows keeps the whole table out of memory at once.
219+
tmpdir = tempfile.mkdtemp()
220+
shp_path = os.path.join(tmpdir, "locations.shp")
221+
zip_path = os.path.join(tmpdir, "locations.zip")
222+
223+
locations = session.execute(sql).scalars().yield_per(_STREAM_CHUNK)
224+
create_shapefile(locations, shp_path)
212225

213-
create_shapefile(locations, "locations.shp")
214-
# Return the shapefile as a zip (optional: zip the .shp, .shx, .dbf files)
215226
import zipfile
216227

217-
with zipfile.ZipFile("locations.zip", "w") as zf:
228+
with zipfile.ZipFile(zip_path, "w") as zf:
218229
for ext in ["shp", "shx", "dbf"]:
219-
zf.write(f"locations.{ext}")
230+
zf.write(os.path.join(tmpdir, f"locations.{ext}"), arcname=f"locations.{ext}")
231+
220232
return FileResponse(
221-
"locations.zip", media_type="application/zip", filename="locations.zip"
233+
zip_path,
234+
media_type="application/zip",
235+
filename="locations.zip",
236+
background=BackgroundTask(shutil.rmtree, tmpdir, ignore_errors=True),
222237
)
223238

224239

225240
@router.get("/location/feature_collection", summary="Get location feature collection")
226-
async def get_location_feature_collection(
241+
def get_location_feature_collection(
227242
query: str = None, session: Session = Depends(get_db_session)
228243
):
229244
"""
230245
Retrieve all sample locations as a GeoJSON FeatureCollection.
246+
247+
Streamed row-by-row so the entire table is never buffered in memory at once.
231248
"""
232249
sql = select(
233250
SampleLocation, geofunc.ST_AsGeoJSON(SampleLocation.point).label("geojson")
234251
)
235252
if query:
236253
sql = sql.where(make_query(SampleLocation, query))
237254

238-
result = session.execute(sql)
239-
locations = result.all()
240-
241-
features = []
242-
for location, geojson in locations:
243-
feature = {
244-
"type": "Feature",
245-
"geometry": geojson,
246-
}
247-
features.append(feature)
248-
249-
return {
250-
"type": "FeatureCollection",
251-
"features": features,
252-
}
255+
def generate():
256+
yield '{"type": "FeatureCollection", "features": ['
257+
first = True
258+
result = session.execute(sql).yield_per(_STREAM_CHUNK)
259+
for location, geojson in result:
260+
feature = {"type": "Feature", "geometry": geojson}
261+
yield ("" if first else ",") + json.dumps(feature)
262+
first = False
263+
yield "]}"
264+
265+
return StreamingResponse(generate(), media_type="application/json")
253266

254267

255268
@router.get(

db/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,12 @@ def asyncify_connection():
7777

7878
return create_async_engine(
7979
"postgresql+asyncpg://",
80-
echo=True,
80+
echo=False,
8181
creator=asyncify_connection,
82+
pool_size=5,
83+
max_overflow=2,
84+
pool_recycle=1800,
85+
pool_pre_ping=True,
8286
)
8387

8488

@@ -106,6 +110,10 @@ def getconn():
106110
"postgresql+pg8000://",
107111
creator=getconn,
108112
echo=False,
113+
pool_size=5,
114+
max_overflow=2,
115+
pool_recycle=1800,
116+
pool_pre_ping=True,
109117
)
110118
return engine
111119

0 commit comments

Comments
 (0)