v_feed computed like_count/comment_count with LEFT JOINs and a GROUP BY. Postgres CAN push `event_id = $1` and the keyset predicate through the view — verified with EXPLAIN, it uses idx_upload_event_created_id — but it CANNOT push ORDER BY ... LIMIT across a GroupAggregate. So every request aggregated every upload in the event, times its likes and comments, and only then sorted and took 21 rows. Cost grew with the event, not with the page. Measured on a throwaway database seeded to a real reception (1000 uploads, 100 guests, 27k likes, 10k comments), same query, same data: before GroupAggregate (actual rows=1001) -> Sort -> Limit 449 ms after Index Scan (actual rows=21) -> Limit -> SubPlans 0.58 ms migration 022 replaces the joins with correlated scalar subqueries, which puts the counts ABOVE the Limit so they run 21 times instead of 1001. Exactly equivalent, not merely close: "like" is keyed (upload_id, user_id) so COUNT(DISTINCT user_id) == count(*), comment.id is the PK so COUNT(DISTINCT c.id) == count(*), and the GROUP BY was on u.id so it was already one row per upload. Column names, order and types are unchanged, so no Rust changes. No new index needed — idx_like_upload and idx_comment_upload already match the subqueries. Note the existing load harness cannot see any of this: e2e/loadtest/driver.mjs creates no likes and no comments, so the expensive path had never been exercised. The amplifier, feed/+page.svelte: every open feed subscribed to `upload-processed` and refetched page 1 — the most expensive page — so ~100 open feeds each fired one per completed upload. Now gated on whether this client actually shows the card that changed, and the debounce is jittered, because a fixed delay just moves a simultaneous herd 800 ms later. Nothing is lost by skipping: a client without the card also missed its `new-upload`, and the reconnect `feed-delta` already schedules a refresh. Load shedding, because the above reduces the risk rather than removing it: db.rs set no acquire_timeout, so sqlx's 30 s default applied — longer than the frontend's own 20 s fetch timeout, meaning the browser gave up while the server kept holding the slot and the work was done for nobody. Now 5 s, and PoolTimedOut maps to 503 + Retry-After instead of a generic 500. That mattered because the upload queue classifies 5xx as transient and retries: a 500 sent the retries straight back into the saturated pool with nothing to pace them. PoolClosed stays Internal — it only occurs during shutdown, where a 503 would invite a retry against a server that is going away. The Retry-After extraction in into_response matches on variants, so unlike message() a missing arm is not a compile error — it would silently drop the header. Pinned by a test covering both retry-carrying variants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Migrations
SQLx-managed Postgres migrations. Each NNN_topic.up.sql has a matching
NNN_topic.down.sql. Run by sqlx::migrate!() at app start.
Rules
- Never edit a shipped migration. If a column needs to change or a fix needs to land, write a new migration. Production has already applied the old one and SQLx tracks each by checksum — editing in place will fail to apply on existing databases.
- Always pair
.up.sqlwith a.down.sql. Reverts may not be perfect (data loss is sometimes unavoidable) but the file must exist and do the best it can. - Prefer additive changes. New columns, new tables, new keys in
config. Drop / rename only when there is no alternative. - No business logic in migrations. Schema + seeds only. Anything that needs Rust code goes in a one-off binary, not a migration file.
- One concern per migration. Easier to revert. Easier to read in
git log.
Numbering
Zero-padded three digits, monotonically increasing. The next free number lives at the bottom of the directory listing — pick that.
Seed-only migrations
When you only need to add config keys (feature flags, defaults), use
INSERT … ON CONFLICT DO NOTHING so existing operator overrides survive. See
009_feature_toggles.up.sql for the canonical shape.