Skip to content

Commit cafbadb

Browse files
committed
chore: sync hotfix/v1.1.5 (via production) into staging
# Conflicts: # .github/app.template.yaml # .github/workflows/release-please.yml
2 parents 271f850 + 3d32109 commit cafbadb

5 files changed

Lines changed: 110 additions & 26 deletions

File tree

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "1.1.0"
2+
".": "1.1.5"
33
}

CHANGELOG.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,47 @@
11
# Changelog
22

3+
## [1.1.5](https://github.com/DataIntegrationGroup/OcotilloAPI/compare/v1.1.4...v1.1.5) (2026-07-07)
4+
5+
6+
### Bug Fixes
7+
8+
* **deploy:** raise instance class to F4_1G to stop OOM instance churn ([ec012d6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ec012d60ff8b985e89c8a2ca4167b4f24cae8540))
9+
10+
## [1.1.4](https://github.com/DataIntegrationGroup/OcotilloAPI/compare/v1.1.3...v1.1.4) (2026-07-07)
11+
12+
13+
### Bug Fixes
14+
15+
* **ci:** deploy on inline workflow_call to CD (Production) ([8371f64](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8371f646b08363a578d339d99da8ebf5863d21c9))
16+
* **ci:** deploy on inline workflow_call to CD (Production) ([1a11ee9](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1a11ee9e5237d33ce7b9ee7f093dad5e023436c6))
17+
18+
## [1.1.3](https://github.com/DataIntegrationGroup/OcotilloAPI/compare/v1.1.2...v1.1.3) (2026-07-06)
19+
20+
21+
### Bug Fixes
22+
23+
* correct shapefile DBF schema and clean up temp dir on failure ([f63d9ce](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/f63d9ce2d28ba190b63628f40c61edee09ac1a5e))
24+
* drop yield_per in get_thing_features (incompatible with unique) ([fa727c4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fa727c42898e5d8791f248ff4dc0b5f43c7b3da2))
25+
* stop per-request OOM on /geospatial export endpoint ([d3a1358](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/d3a1358f54b9b6d02a6187d0004ba44b2b365ed0))
26+
* stop per-request OOM on /geospatial export endpoint ([2ed12b6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2ed12b602a080d98e2025fc0e4edefdc341412ba))
27+
* stream get_thing_features with yield_per instead of buffering ([877fff8](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/877fff833755034e9c0f1b67ed3d6c74a92d4084))
28+
29+
## [1.1.2](https://github.com/DataIntegrationGroup/OcotilloAPI/compare/v1.1.1...v1.1.2) (2026-07-06)
30+
31+
32+
### Bug Fixes
33+
34+
* **deploy:** align hotfix migration head with production DB (unblock v1.1.x deploy) ([1ee8a4b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1ee8a4b893027b851450d6ed98c811aea283c365))
35+
* **deploy:** align hotfix migration head with production DB + fix release tag passthrough ([790377f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/790377f53e57c9f7671d45efbcab617189f2af36))
36+
37+
## [1.1.1](https://github.com/DataIntegrationGroup/OcotilloAPI/compare/v1.1.0...v1.1.1) (2026-07-06)
38+
39+
40+
### Bug Fixes
41+
42+
* **deploy:** prevent App Engine request starvation under burst load ([fec7dbd](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fec7dbd2e0f59d6ce1c3319cda9fded5c6051eb8))
43+
* **deploy:** prevent App Engine request starvation under burst load ([385c974](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/385c9743185520b82034f0b1549d51c57781f9e9))
44+
345
## [1.1.0](https://github.com/DataIntegrationGroup/OcotilloAPI/compare/v1.0.0...v1.1.0) (2026-06-08)
446

547

api/geospatial.py

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,21 @@
1414
# limitations under the License.
1515
# ===============================================================================
1616
import json
17+
import os
18+
import shutil
19+
import tempfile
1720
from typing import Annotated, List
1821

1922
from fastapi import APIRouter, Query, HTTPException
2023
from fastapi.responses import FileResponse
2124
from geoalchemy2.shape import to_shape
2225
from shapely.io import to_geojson
23-
from starlette.responses import JSONResponse
26+
from starlette.background import BackgroundTask
27+
from starlette.responses import StreamingResponse
2428

2529
from core.dependencies import session_dependency, viewer_dependency
2630
from db import Group
31+
from db.engine import session_ctx
2732
from schemas.thing import FeatureCollectionResponse
2833
from services.geospatial_helper import create_shapefile, get_thing_features
2934
from services.query_helper import simple_get_by_id
@@ -54,8 +59,7 @@ def get_geospatial(
5459
"""
5560

5661
if format_ == "geojson":
57-
content = get_feature_collection(session, thing_type, group)
58-
return JSONResponse(content=content, media_type="application/geo+json")
62+
return get_feature_collection(thing_type, group)
5963
else:
6064
return get_location_shapefile(session, thing_type, group)
6165

@@ -89,17 +93,17 @@ def get_project_area(
8993

9094

9195
def get_feature_collection(
92-
session: session_dependency,
9396
thing_type: List[str] | None = None,
9497
group: Annotated[
9598
str | int, Query(title="group", description="group", alias="group")
9699
] = None,
97-
) -> FeatureCollectionResponse:
98-
"""
99-
Endpoint to retrieve a GeoJSON FeatureCollection.
100+
) -> StreamingResponse:
100101
"""
102+
Retrieve a GeoJSON FeatureCollection.
101103
102-
things = get_thing_features(session, thing_type, group)
104+
Streamed feature-by-feature so the entire result set is never buffered in
105+
memory at once.
106+
"""
103107

104108
def make_feature_dict(thing, geometry, elevation, *other):
105109
geometry = json.loads(geometry)
@@ -115,12 +119,18 @@ def make_feature_dict(thing, geometry, elevation, *other):
115119
"geometry": geometry,
116120
}
117121

118-
features = [make_feature_dict(*item) for item in things]
122+
def generate():
123+
# The request-scoped session is closed before this response body
124+
# streams, so open a dedicated session scoped to the stream.
125+
with session_ctx() as stream_session:
126+
yield '{"type": "FeatureCollection", "features": ['
127+
first = True
128+
for item in get_thing_features(stream_session, thing_type, group):
129+
yield ("" if first else ",") + json.dumps(make_feature_dict(*item))
130+
first = False
131+
yield "]}"
119132

120-
return {
121-
"type": "FeatureCollection",
122-
"features": features,
123-
}
133+
return StreamingResponse(generate(), media_type="application/geo+json")
124134

125135

126136
def get_location_shapefile(
@@ -133,16 +143,32 @@ def get_location_shapefile(
133143
"""
134144

135145
things = get_thing_features(session, thing_type, group)
136-
create_shapefile(things, "things.shp")
137146

138-
# Return the shapefile as a zip (optional: zip the .shp, .shx, .dbf files)
139-
import zipfile
147+
# Write into a temp dir: the App Engine app directory is read-only, and /tmp
148+
# is RAM-backed, so build here and clean up after the response is sent.
149+
tmpdir = tempfile.mkdtemp()
150+
try:
151+
shp_path = os.path.join(tmpdir, "things.shp")
152+
zip_path = os.path.join(tmpdir, "things.zip")
153+
154+
create_shapefile(things, shp_path)
155+
156+
import zipfile
157+
158+
with zipfile.ZipFile(zip_path, "w") as zf:
159+
for ext in ["shp", "shx", "dbf"]:
160+
zf.write(os.path.join(tmpdir, f"things.{ext}"), arcname=f"things.{ext}")
161+
except Exception:
162+
# BackgroundTask only runs on a successful response, so clean up here to
163+
# avoid leaking temp dirs when generation fails.
164+
shutil.rmtree(tmpdir, ignore_errors=True)
165+
raise
140166

141-
with zipfile.ZipFile("things.zip", "w") as zf:
142-
for ext in ["shp", "shx", "dbf"]:
143-
zf.write(f"things.{ext}")
144167
return FileResponse(
145-
"things.zip", media_type="application/zip", filename="things.zip"
168+
zip_path,
169+
media_type="application/zip",
170+
filename="things.zip",
171+
background=BackgroundTask(shutil.rmtree, tmpdir, ignore_errors=True),
146172
)
147173

148174

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "OcotilloAPI"
3-
version = "1.1.0"
3+
version = "1.1.5"
44
description = "FastAPI backend and CLI for managing Ocotillo groundwater locations, wells, assets, and bulk water-level data transfers."
55
readme = "README.md"
66
requires-python = ">=3.13"

services/geospatial_helper.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515
# ===============================================================================
16+
from typing import Iterator
17+
1618
import shapefile
1719
from geoalchemy2.functions import ST_GeomFromText, ST_Within, ST_AsGeoJSON
1820
from geoalchemy2.shape import to_shape
@@ -31,7 +33,7 @@
3133

3234
def get_thing_features(
3335
session, thing_type: list | str | None, group: str | int | None
34-
) -> list:
36+
) -> Iterator:
3537
# sql = (
3638
# select(Thing, ST_AsGeoJSON(Location.point).label("geojson"))
3739
# .join(LocationThingAssociation, Thing.id == LocationThingAssociation.thing_id)
@@ -87,15 +89,29 @@ def get_thing_features(
8789
else:
8890
sql = sql.where(Group.id == group)
8991

90-
# unique needs to be invoked to prevent duplicates from eager loading
91-
return session.execute(sql).unique().all()
92+
# Stream the result with yield_per so the whole table is never buffered in
93+
# memory at once. Thing has no eager-loaded collections (all relationships
94+
# are lazy), so unique() is unnecessary -- and unique() is incompatible with
95+
# yield_per anyway. Dedup defensively by id with a bounded set of ints in
96+
# case the joins ever produce duplicate rows.
97+
seen = set()
98+
result = session.execute(sql.execution_options(yield_per=1000))
99+
for row in result:
100+
thing_id = row[0].id
101+
if thing_id in seen:
102+
continue
103+
seen.add(thing_id)
104+
yield row
92105

93106

94107
def create_shapefile(things: list, filename: str = "things.shp") -> None:
95108
# Create a point shapefile
96109
with shapefile.Writer(filename, shapeType=shapefile.POINT) as shp:
97-
shp.field("id", "L")
110+
# Field schema must match the values written in shp.record() below:
111+
# id (numeric), name (char), elevation (numeric).
112+
shp.field("id", "N")
98113
shp.field("name", "C")
114+
shp.field("elevation", "N", decimal=3)
99115

100116
for thing, point, elevation in things:
101117
# Assume loc.point is WKT or a Shapely geometry or GeoJSON

0 commit comments

Comments
 (0)