Skip to content

Upstram merge - #100

Merged
heshanlk merged 12 commits into
heidisoft:mainfrom
brightbeanxyz:main
Sep 18, 2026
Merged

heshanlk merged 12 commits into
heidisoft:mainfrom
brightbeanxyz:main

Conversation

@heshanlk

Copy link
Copy Markdown
Member

What does this PR do?

Why?

How to test

Checklist

  • Tests pass (pytest)
  • Lint passes (ruff check . and ruff format --check .)
  • Documentation updated (if applicable)

JanSchm and others added 12 commits September 18, 2026 10:45
The Heroku worker sat at 110% of its 512 MB quota with 3081 R14s in 24h and
82 MB of swap, and the web dyno at 88%. Neither is a leak: there is no
accumulating Python object in the tree. It is a ratchet — transient peaks raise
the high-water mark, CPython and glibc keep the freed pages in their own
arenas, and nothing restarts the process to hand them back. After a deploy the
worker starts at ~280 MB and climbs ~19 MB/h for 15 hours.

process_tasks is a single non-forking process that runs every task in the same
heap and never restarts, so it has no floor to return to. --duration gives it
one. The condition is evaluated at the TOP of the run loop, so a task in flight
always completes; the process exits 0 between tasks and Heroku restarts the
dyno. Nothing is stranded — a recycle cannot interrupt a publish, and
confirm_pending_publishes settles anything in flight regardless. 3600s keeps
well clear of Heroku's crash cool-off, which only engages on repeated quick
exits.

Left out of docker-compose.yml deliberately: its worker has no restart policy,
so there a clean exit would just stop it.

MALLOC_ARENA_MAX caps the per-thread arenas glibc hands out (default 8 x nproc,
and containers report the host's core count). This app has real thread churn to
feed that: the publisher builds a fresh ThreadPoolExecutor every 15 seconds and
boto3's managed transfer adds ten threads per download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…read

django-storages backs S3Storage.connection with a threading.local(), so
reaching through it builds a brand-new boto3.Session() and session.resource()
on every thread that touches storage — and a fresh session means botocore
re-parsing the S3 service model and endpoint ruleset, several MB of Python
dicts each time.

download_to_path calls this from inside the publisher's group and platform
threads, which are new objects every 15-second cycle, and boto3's own managed
transfer adds ten more threads per download. So the worker was building and
discarding multi-MB clients continuously, all day, into an allocator that never
hands the pages back. That is a large part of the ratchet the hourly recycle
was papering over.

Safe to share: boto3 clients are documented thread-safe (resources are not) and
every caller in this module uses client methods only. Credential refresh is
handled inside the client.

The setting_changed receiver matters for the suite rather than production —
without it the first test to touch S3 would pin its client for the whole run
and every later override_settings on a bucket or endpoint would be ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is the spike half of the memory problem — the class of event behind the
R15 on 2026-09-14, not the R14 baseline.

Ordering. Flattening alpha or converting a colour space BEFORE the resize does
that work at full resolution: a white background allocated at the source's
size, pasted onto, all to produce a 400x400 JPEG. Shrinking first makes both
operations free. Measured on a 6000x4000 RGBA PNG: peak RSS +196 MB before,
+104 MB after. It also keeps thumbnail()'s own internal draft() call effective,
since converting first replaces the JpegImageFile with a plain Image and the
DCT downscale goes with it — which is what made CMYK JPEGs so expensive.

Ceiling. MEDIA_LIBRARY_MAX_IMAGE_SIZE is 20 MB, which says nothing about how
many pixels that expands to. Pillow's own bomb check does not close the gap: it
only warns between 1x and 2x MAX_IMAGE_PIXELS and raises above 2x, leaving a
window that decodes to over 500 MB. open_image checks width*height itself, on
the header, before a pixel is decoded.

The check runs AFTER draft() on purpose. A 61 MP JPEG is cheap because the
decoder downscales during the read, so judging it on its header dimensions
would refuse a file that never costs us the memory. What the limit really
bounds is the formats with no draft support (PNG, WebP, GIF) and the edit path,
which needs full resolution by definition. For the same reason we do NOT assign
Image.MAX_IMAGE_PIXELS from the setting — Pillow evaluates that inside
Image.open(), before draft() has run, so pinning it there would reject exactly
the files draft() makes cheap. Its default stays as the outer backstop.

An over-limit image now fails the asset with a real reason rather than taking
the dyno down with it.

Also fixes a latent bug found while restructuring apply_image_edits: the save
block sat outside the open context, so with no operations the working image IS
the opened one and leaving the context closed its file pointer before save()
had ever loaded it.

None of these functions had a single test — no test in the repo imported PIL.
Adds 22 covering the guard, both draft behaviours, every accepted format, and
the no-operations edit path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing them

Two survivors of the streaming pass in 4ca3647.

Bluesky's _upload_blob did f.read() with no size check, and _build_embed routes
VIDEO through it — MEDIA_LIBRARY_MAX_VIDEO_SIZE allows 1 GB. On a 512 MB worker
that is not a ratchet, it is an immediate OOM kill on the first large video.

LinkedIn is narrower than it looks: the video path already chunks from disk and
_upload_binary's local-file branch is bounded by the 20 MB image cap. The
unbounded one was the HTTP(S) branch, resp.content on an arbitrary URL. It now
streams to a temp file first.

Both fixes are small because providers/base.py already does the right thing —
it routes a non-dict `data` to httpx's `content`, which streams a file object
and derives Content-Length from it. Bluesky just had to hand over the handle
rather than the bytes. Content-Length is passed explicitly so httpx cannot fall
back to chunked transfer encoding, which some PDS deployments reject.

_upload_blob had no test; the new ones assert on the object handed to the
request rather than on the response, since "did this stream" is not observable
afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The web dyno's equivalent of the image decode: one page render that allocates
hundreds of MB on a process with ~60 MB of headroom.

PostInsightsSnapshot is one row per (post, metric, day) and carries two
JSONFields — `raw` is the entire provider response — which Django decodes
eagerly while hydrating a row. _latest_post_stats iterated those as model
instances to read platform_post_id, metric_key and value, none of which are in
the JSON. On an account with 300 posts and 90 days of history that is 216k
instances and 432k needless json.loads calls per render.

Now three columns via values_list, with the dedup pushed into Postgres:
the order_by DISTINCT ON requires is the ordering this needed anyway, so the
query returns one row per (post, metric) rather than one per day. Verified the
emitted SQL keeps `date DESC` in the ORDER BY, which is the only reason the
newest row survives; the new tests pin that.

`raw` has no reader anywhere in the application — it is written in three places
and read in none. Shrinking the write side is worth doing but is a database
problem now, not a memory one, so it stays separate.

Same one-line change in the two live siblings: account_analytics_bundle, which
runs on every analytics render and every agent-API account call, and
_post_sparklines_with_freshness. Drops _post_sparklines, which nothing calls.

_series_for is deliberately left alone: both callers always take the series_map
branch, so it is unreachable in production and fixing it buys nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
config/urls.py imports apps.accounts.views for health_check, so a module-scope
`from PIL import Image` loaded the _imaging extension into every web AND worker
process at boot, whether or not an avatar was ever touched. Worth ~8-15 MB in
each, and the rest of the codebase already imports PIL inside the function that
needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`.distinct(*fields)` compiles to PostgreSQL's DISTINCT ON, and every other
backend inherits Django's base `distinct_sql`, which raises NotSupportedError
the moment a field is passed. README.md documents SQLite twice — as the
"Fully Local Development (without Docker)" path, and as "fine for local
development and small deployments" — so this took out the entire analytics
surface on those deployments: the index page, the post-detail drawer, the agent
API analytics endpoints and the MCP analytics tools, all 500.

CI could not catch it because config/settings/test.py pins Postgres.

The clause is now applied only when the backend advertises the capability, with
the previous Python dedup as the fallback over the same ordering. Nothing is
lost on Postgres, and the `values_list` that avoids hydrating every JSONField —
which is where nearly all of the memory saving came from — was never
backend-specific and applies either way.

Verified against a real SQLite database, not just a patched feature flag: the
old form raises NotSupportedError there, the new one compiles, and 228 tests
across analytics, the media library, the API router and MCP parity pass on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three bugs, all from the ceiling added in 3f98205.

Dimensions disagreed with thumbnails. `extract_image_metadata` opened without
a draft while `generate_image_thumbnail` opened with one, so the same file was
judged against two different pixel counts. A 40MP JPEG drafts down to something
cheap and thumbnails fine, but metadata saw the header size, refused, and
stored 0x0 — a working thumbnail beside zero dimensions, which `aspect_ratio`
then reports as 0 to agent clients. Reading `img.size` decodes nothing, so
metadata is now exempt from the ceiling entirely: there is no memory to save by
refusing, and the numbers withheld are the ones the guard just read.

Rejections were invisible. Both helpers swallowed ImageTooLargeError, so
`_process_image` never raised and the asset was saved COMPLETED with no
thumbnail and no reason — it read as success everywhere in the UI. "Too large"
is determinate and actionable, so it now propagates and the asset lands FAILED,
keeping the dimensions that caused it. "Pillow could not read this" stays a
soft None, and a test pins that distinction.

A rejected edit left a phantom version. `create_version` seeds the row with a
copy of the source and points the asset at it, so a swallowed failure left what
looks like an unchanged duplicate that will never become anything else. It is
now removed, rewinding `current_version` to the version it superseded first —
the FK is SET_NULL, so deleting without that strands the asset with no current
version at all.

Also enforces the ceiling in `validate_file`, so a normal upload is refused at
submit time with the dimensions and the limit. Presigned direct-to-storage
uploads bypass Django entirely, which is why the worker keeps its own check.

Replaces the `setting_changed` receiver from 3af4831 with an explicit
`reset_cached_client()` called from a conftest fixture. That receiver put
test-only machinery in every production process, matched setting names by
string prefix so a new storage setting would silently stop resetting it, and
could fire mid-call — `_client_and_bucket` reads the cache before taking the
lock, so a concurrent reset could be missed and a stale client returned.

Adds test_asset_processing.py: the unit tests passed happily while the task
built on them did the wrong thing, because nothing composed the two helpers the
way `_process_image` does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spooling the URL branch to disk in 04f7f64 moved an unbounded read off the heap
but not out of the dyno — it now fills the shared ephemeral disk instead, which
affects every process there rather than just the publish.

Capped at 20MB, mirroring the media library's image limit (providers are
Django-independent, so the constant is restated rather than read from settings).
Checked against Content-Length up front and against bytes written as we go,
because Content-Length is the server's claim and is absent entirely on a chunked
response. Local paths are exempt: those came from our own upload path and were
sized there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Validator and worker disagreed about JPEGs. `_image_pixel_errors` measured raw
header dimensions while `generate_image_thumbnail` measures the drafted decode,
so `validate_file` rejected a 40MP JPEG the worker thumbnails without trouble —
the JPEG decoder downscales during the read. It also made a REST upload behave
differently from a presigned one for the same file, since presigned uploads
never reach the validator. This is the same asymmetry fixed in 76b9735 between
metadata and thumbnails, reintroduced one layer up. The validator now goes
through `open_image` with the thumbnail path's own `draft_size`, so the two
agree by construction rather than by matching constants.

A rejected edit orphaned the file it had written. Django does not delete
FileField objects when a row goes, and the delete added in 76b9735 fired after
`version.file.save()` in one reachable case: `apply_image_edits` checks the
SOURCE size, so an upscaling resize succeeds and then produces output too large
to thumbnail. Confirmed — a 400x400 source resized to 7000x7000 passes the edit
and fails the thumbnail at 49MP. Generated artifacts are now removed first,
guarded on the stored name: `create_version` seeds the row by assigning the
asset's FieldFile, which copies the name rather than the bytes, so until the
edit is written the version and the asset are the same stored object and a
naive cleanup would delete the asset's own file. The regression test was
verified to fail without the fix.

Oversize remote media was retryable. `PublishError` defaults `retryable=True`
and `apps.publisher.engine` reads it via `getattr(e, "retryable", True)`, so a
URL over the 20MB ceiling walked the full backoff ladder, re-downloading the
file each time to fail identically, before telling the user anything. Now
`retryable=False`, matching how TikTok already reports its own size rejections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes the one manual step this branch otherwise left behind. Heroku sources
.profile before the dyno command for every process type, so web, worker, the
release phase and one-off `heroku run` dynos all get it from a single
declaration — where the Procfile alternative would need the assignment repeated
on each line and would still miss `heroku run` and release.

Written as `${MALLOC_ARENA_MAX:-2}` rather than a bare assignment. .profile is
sourced after config vars are injected, so assigning unconditionally would
silently stomp a value set from the dashboard and make the setting untunable
without a deploy. As a default it stays overridable.

Drops the app.json entry that set the same variable. It only applied at app
creation, so keeping both left two places stating one value with no mechanism
to keep them in step — and the config var would win for one-click apps while
.profile governed everything else.

Note this is Heroku-only: Render, Railway and docker-compose all build from the
Dockerfile, which does not read .profile. README says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s-d3b6bf

fix: get both Heroku dynos back under their 512MB quota
@heshanlk
heshanlk merged commit 1161048 into heidisoft:main Sep 18, 2026
4 checks passed
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