Skip to content

chore: back-merge hotfix/v1.1.5 into production - #787

Merged
jirhiker merged 26 commits into
productionfrom
chore/backmerge-hotfix-v1.1.5-to-production
Jul 20, 2026
Merged

chore: back-merge hotfix/v1.1.5 into production#787
jirhiker merged 26 commits into
productionfrom
chore/backmerge-hotfix-v1.1.5-to-production

Conversation

@jirhiker

Copy link
Copy Markdown
Member

Why

The last 5 hotfix releases (v1.1.1v1.1.5) were tagged and deployed to production, but their commits were never merged back into the production branch. Production deploys off the release tag, so the running service is correct, but the production branch history is 24 commits behind the code it's actually running.

This is release-flow.md §3 step 4 (hotfix/vX.Y.Z → production), which never ran for any of the 5 hotfixes. Without it, the next documented release risks regressing these fixes.

What

Back-merges hotfix/v1.1.5 (cumulative v1.1.1v1.1.5) into production. No new release is cut — the release commits are already in the branch.

Merge was clean, zero conflicts. Verified post-merge:

  • .release-please-manifest.json1.1.5
  • pyproject.toml version → 1.1.5
  • CD_production.yml deploy-gate not duplicated (the CI fix that existed on both sides reconciled cleanly)
  • api/geospatial.py retains the OOM streaming fix

Brings in: geospatial OOM fixes (api/geospatial.py, services/geospatial_helper.py), F4_1G instance-class bump, and the nmw mirror / OGC-view / ngwmn / transducer-matview / pg_cron migrations.

Next

Followed by a production → staging PR (§3 step 5) to propagate the one substantive fix staging is still missing (the geospatial OOM streaming fix).

🤖 Generated with Claude Code

jirhiker and others added 25 commits July 6, 2026 10:22
Prod cycled between 0-1 instances and returned site-wide 500/503 with
"Request was aborted after waiting too long to attempt to service your
request." Requests died in the pending queue (blank instanceId, ~2ms
latency), not in app code — App Engine never scaled out past one
instance despite max_instances=10.

Root cause: the scheduler had no visibility into real per-instance
concurrency (gunicorn -w 4), so it kept routing bursts to a single
saturated instance instead of spinning up more. min_instances=0 added
cold-start pile-ups on top.

- app.template.yaml: add max_concurrent_requests: 6 so the scheduler
  scales out before an instance saturates (2-worker headroom for bursts)
- CD_production.yml: MIN_INSTANCES 0 -> 1 to kill cold-start pile-ups
- CD_production.yml: gunicorn -w 4 -> -w 8 for more concurrency per F4

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(deploy): prevent App Engine request starvation under burst load
…nches--hotfix/v1.1.1--components--OcotilloAPI

chore(hotfix/v1.1.1): release 1.1.1
…ease tag passthrough

The v1.1.1 production deploy failed at `alembic upgrade head` with
"Can't locate revision identified by 'e2f3a4b5c6d7'". The production
database (ocotillo) is already migrated to head e2f3a4b5c6d7 — 7
revisions ahead of the v1.1.0 line this hotfix branched from — so the
hotfix's older migration set (head t6u7v8w9x0y1) couldn't resolve the
DB's current revision.

Bring the 7 intervening migration files (u8v9w0x1y2z3 .. e2f3a4b5c6d7)
onto the hotfix line so its alembic head matches the production DB
exactly. `alembic upgrade head` then no-ops on production (DB already at
that revision); no schema change ships. App code stays at the v1.1.0
level — this is already how production runs today — so the only
behavioral change to prod is the App Engine scaling config from v1.1.1.

Also carry the release-please tag-passthrough fix (path-scoped
`.--tag_name`) so the resulting v1.1.2 release actually triggers the
production deploy instead of silently skipping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-rev

fix(deploy): align hotfix migration head with production DB (unblock v1.1.x deploy)
…nches--hotfix/v1.1.1--components--OcotilloAPI

chore(hotfix/v1.1.1): release 1.1.2
The /geospatial endpoint (geojson and shapefile formats) loaded the entire
thing/location result set into memory on a single request, spiking memory
enough for App Engine to terminate the process mid-request.

- get_thing_features: stream the query result via yield_per instead of
  buffering the whole table with .all(). unique() still dedups eager-loaded
  rows. Callers iterate exactly once.
- geojson format: stream the FeatureCollection feature-by-feature through a
  StreamingResponse instead of building a full list of feature dicts.
- shapefile format: write to a tempfile.mkdtemp() dir instead of the
  read-only App Engine app directory (/tmp is RAM-backed), and clean up the
  temp dir via BackgroundTask after the response is sent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pyproject version was bumped to 1.1.0 without re-locking, so CI's
`uv sync --locked` failed. Regenerate the lockfile; no dependency changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SQLAlchemy raises "Can't use the ORM yield_per feature in conjunction with
unique()", and unique() is required to dedup eager-loaded rows. Revert to
unique().all(); the memory win still comes from streaming the JSON response
and writing the shapefile to a temp dir instead of building a second full
copy / writing to the read-only app dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- create_shapefile defined 2 DBF fields (id, name) but wrote 3 values
  (id, name, elevation), which raises a field/record mismatch at runtime.
  This was latent because writes to the read-only app dir failed first;
  now that the shapefile is written to a temp dir it is exercised. Add the
  elevation field and fix the id field type (was "L"/logical -> "N").
- Wrap shapefile generation in try/except so the temp dir is removed if
  generation fails (BackgroundTask only runs on a successful response).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
.all() still materialized the entire result set, so both exports loaded
everything into memory. Thing has no eager-loaded collections (all
relationships are lazy), so unique() was unnecessary -- and unique() is
incompatible with yield_per anyway. Make get_thing_features a generator that
streams via yield_per and dedups defensively by id with a bounded int set.

Because the geojson StreamingResponse body is produced after the request
session is closed, the generator now opens a dedicated session via
session_ctx() scoped to the stream. The shapefile path consumes the
generator inside the endpoint (request session still open).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix: stop per-request OOM on /geospatial export endpoint
…nches--hotfix/v1.1.3--components--OcotilloAPI

chore(hotfix/v1.1.3): release 1.1.3
A called workflow inherits the caller's event context, so
`github.event_name` inside CD (Production) is the caller's event (`push`
from release-please), never `workflow_call`. The old gate

    startsWith((github.event_name == 'workflow_call' && inputs.tag_name)
               || github.event.release.tag_name, 'v')

therefore always fell through to `github.event.release.tag_name`, which
is empty on a push, so `production-deploy` was skipped every time
release-please invoked the deploy inline. Only release-event deploys
worked. Gate on `inputs.tag_name` directly (empty on the release path,
so `||` still falls through). Same fix already live on staging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gate-v1.1.3

fix(ci): deploy on inline workflow_call to CD (Production)
…nches--hotfix/v1.1.3--components--OcotilloAPI

chore(hotfix/v1.1.3): release 1.1.4
Production ran gunicorn -w 8 on an F4 (1 GB) instance. Eight workers each
importing the full stack (sqlalchemy + geoalchemy2 + shapely + cloud-sql
connector + pygeoapi) exceeded 1 GB, so App Engine terminated processes for
"using too much memory" ~44x per 3h and cycled workers continuously (304
"Booting worker", 297 SIGTERM in a 3h window). With the single min-instance
constantly reborn, every request hit a booting instance and re-cold-loaded
the app -- site-wide degraded latency even at low traffic.

Raise instance_class F4 -> F4_1G (2 GB) so the eight workers fit, per App
Engine's own "consider a larger instance class" guidance. Keeps the -w 8 /
max_concurrent_requests: 6 scale-out tuning from the prior starvation fix
intact. Template is shared; staging/testing (-w 4, scale-to-zero) gain
harmless headroom at negligible idle cost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nches--hotfix/v1.1.5--components--OcotilloAPI

chore(hotfix/v1.1.5): release 1.1.5
Copilot AI review requested due to automatic review settings July 20, 2026 21:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Back-merges the cumulative hotfix/v1.1.1v1.1.5 changes into production so the branch history matches what’s already deployed from release tags, including geospatial OOM mitigations, release/deploy workflow fixes, and several DB/Alembic additions for NMW/OGC and pg_cron scheduling.

Changes:

  • Aligns production metadata and release automation with the already-deployed v1.1.5 hotfix series (manifest, changelog, release-please outputs).
  • Reduces memory pressure in geospatial exports by streaming GeoJSON output and writing shapefiles in a temp directory with cleanup.
  • Adds multiple Alembic migrations for NGWMN views, transducer daily materialized view, NMW mirror tables + OGC views, and an optional pg_cron nightly refresh job.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
uv.lock Updates lockfile editable package metadata (currently version mismatch noted).
pyproject.toml Bumps project version to 1.1.5.
.release-please-manifest.json Updates release manifest version to 1.1.5.
CHANGELOG.md Adds changelog entries for 1.1.11.1.5.
.github/workflows/release-please.yml Fixes release-please outputs wiring to ensure tag propagates to CD.
.github/workflows/CD_production.yml Adjusts deploy settings (gunicorn workers, min instances).
.github/app.template.yaml Raises App Engine instance class and adjusts scaling/concurrency settings.
api/geospatial.py Streams GeoJSON response and writes shapefile artifacts in /tmp with cleanup.
services/geospatial_helper.py Streams DB results for geospatial exports; updates shapefile DBF schema.
alembic/versions/u8v9w0x1y2z3_add_ngwmn_views_from_ocotillo_model.py Adds NGWMN export views sourced from the Ocotillo model.
alembic/versions/v0w1x2y3z4a5_add_transducer_daily_data_materialized_view.py Adds transducer_daily_data materialized view + indexes.
alembic/versions/w1x2y3z4a5b6_drop_child_release_filters_from_ngwmn_views.py Adjusts NGWMN views to avoid filtering child rows by unset release_status.
alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py Optionally registers a pg_cron nightly materialized view refresh job.
alembic/versions/c0d1e2f3a4b5_nmw_mirror_tables.py Adds NMW mirror tables and FK constraints (docstring/header inconsistency noted).
alembic/versions/d1e2f3a4b5c6_nmw_per_well_geothermal_ogc_views.py Adds per-well geothermal OGC views/materialized view over NMW mirror tables.
alembic/versions/e2f3a4b5c6d7_nmw_measurement_ogc_views.py Adds individual-measurement and analytical OGC views for NMW.

Comment on lines +92 to +104
# Stream the result with yield_per so the whole table is never buffered in
# memory at once. Thing has no eager-loaded collections (all relationships
# are lazy), so unique() is unnecessary -- and unique() is incompatible with
# yield_per anyway. Dedup defensively by id with a bounded set of ints in
# case the joins ever produce duplicate rows.
seen = set()
result = session.execute(sql.execution_options(yield_per=1000))
for row in result:
thing_id = row[0].id
if thing_id in seen:
continue
seen.add(thing_id)
yield row

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining this one — the premise doesn't hold.

latest_assoc groups by thing_id on max(effective_start), which selects a single timestamp per thing, not a single row. If two LocationThingAssociation rows for the same thing_id share that same effective_start, both satisfy the join predicate and both come back. The seen set is guarding exactly that tie case, not a hypothetical one.

On cost: the set holds ints, not rows — roughly 60 bytes per entry against the fully-buffered result set the yield_per change removed. That trade is the whole point of the fix.

If we want the guard gone, the right move is to make the tie-break deterministic in SQL (DISTINCT ON (thing_id) ... ORDER BY thing_id, effective_start DESC, id) rather than dropping the dedup and hoping the join never produces duplicates. Leaving as-is for now; noted as a possible follow-up.

"""NMW staging mirror tables and FK constraints

Revision ID: c0d1e2f3a4b5
Revises: t6u7v8w9x0y1

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. The docstring says Revises: t6u7v8w9x0y1 but down_revision on line 47 is x2y3z4a5b6c7. The executable value is the correct one — only the docstring is stale, so the migration graph itself is fine.

Not fixing it in this PR: per docs/release-flow.md §3 step 4, the hotfix/vX.Y.Z → production back-merge is a pure merge with no new commits. Tracking this on a staging branch alongside the two substantive findings below.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3d321090c9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".



def upgrade() -> None:
if not _pg_cron_enabled():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enable pg_cron before applying the migration

In the production deploy workflow I checked .github/workflows/CD_production.yml lines 74-82, and the migration step does not pass ENABLE_PG_CRON. In that production path _pg_cron_enabled() is false here, so this revision returns and Alembic records it as applied without creating refresh_materialized_views() or the cron job; adding the variable later will not rerun this migration, leaving the nightly materialized-view refresh unscheduled.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and it's worse than a branch-config issue — this is already the live state in production.

Verified:

  • ENABLE_PG_CRON is absent from CD_production.yml on production, on hotfix/v1.1.5, and on this branch. Only staging has it.
  • Migration x2y3z4a5b6c7 shipped in v1.1.2 and was deployed to production.

So the production run hit _pg_cron_enabled() → False, returned early, and Alembic stamped the revision. refresh_materialized_views() and the cron job were never created, and — exactly as you note — adding the env var now will not rerun the migration.

To be clear on blast radius: CD_production.yml has a separate "Refresh materialized views on production database" step that runs the CLI refresh on every deploy, so the views are not stale indefinitely. What's actually missing is the nightly cadence between deploys.

The fix needs both halves, since the revision is already stamped:

  1. ENABLE_PG_CRON: "1" on the production migration step.
  2. A new migration that creates the helper function and schedules the job.

Not landing it here — this PR is a pure back-merge per docs/release-flow.md §3 step 4. Fixing on a staging branch.

JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = s."RecrdsetID"
JOIN "NMW_WellZDatum" AS z ON z."RecrdsetID" = r."RecrdSetID"
JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID"
JOIN "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deduplicate locations before joining measurement views

When a well has more than one NMW_WellLocations row for the same WellDataID—the previous geothermal-view migration explicitly handles this with DISTINCT ON because OBJECTID is the PK—this direct join multiplies a single BHT measurement into multiple rows with the same d."OBJECTID" id. That gives pygeoapi duplicate feature IDs and repeated features; use the same deduped location CTE before joining here and in the other measurement views that join NMW_WellLocations directly.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. The precedent you're pointing at is explicit: d1e2f3a4b5c6 wraps NMW_WellLocations in a DISTINCT ON ("WellDataID") ... ORDER BY "WellDataID", "OBJECTID" CTE, and its own docstring states the reason — a well can have multiple location rows because OBJECTID is the PK, not WellDataID.

This migration joins NMW_WellLocations directly at four sites: lines 82, 123, 199, and 266. Each one can fan a single measurement row into N rows sharing the same id, which surfaces as duplicate feature IDs in pygeoapi.

Fix will reuse the same deduped-location CTE at all four joins. Since these views are already applied in production, it has to be a new migration that recreates them, not an edit to this file.

Not landing it here — this PR is a pure back-merge per docs/release-flow.md §3 step 4. Fixing on a staging branch.

The back-merge kept the stale root package version (1.1.0) in uv.lock while
pyproject.toml is 1.1.5, so `uv sync --locked` failed in CI. Regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 20, 2026 21:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

services/geospatial_helper.py:96

  • The comment says the dedup uses a "bounded set of ints", but seen = set() grows with the number of unique Thing IDs returned (potentially large). This is misleading documentation for the OOM/streaming behavior.
    # Stream the result with yield_per so the whole table is never buffered in
    # memory at once. Thing has no eager-loaded collections (all relationships
    # are lazy), so unique() is unnecessary -- and unique() is incompatible with
    # yield_per anyway. Dedup defensively by id with a bounded set of ints in
    # case the joins ever produce duplicate rows.

alembic/versions/c0d1e2f3a4b5_nmw_mirror_tables.py:5

  • The module docstring says this migration "Revises: t6u7v8w9x0y1", but the actual down_revision is x2y3z4a5b6c7. This mismatch can confuse operators when inspecting migration history.
Revision ID: c0d1e2f3a4b5
Revises: t6u7v8w9x0y1
Create Date: 2026-06-22

@jirhiker
jirhiker merged commit fabf531 into production Jul 20, 2026
10 checks passed
@jirhiker
jirhiker deleted the chore/backmerge-hotfix-v1.1.5-to-production branch July 20, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants