Skip to content

perf: cache open-building bazaar reads + scale audit - #1

Merged
g1mliii merged 9 commits into
mainfrom
perf/scale-pass
Jul 1, 2026
Merged

perf: cache open-building bazaar reads + scale audit#1
g1mliii merged 9 commits into
mainfrom
perf/scale-pass

Conversation

@g1mliii

@g1mliii g1mliii commented Jun 30, 2026

Copy link
Copy Markdown
Owner

Summary

A scale pass aimed at ~10k concurrent users. The headline finding: the app is already heavily optimized for scale — ISR storefront, no select * on PII, explicit columns, batched email fan-out, Promise.all parallelism, an RPC instead of N+1 on the bazaar, DO-based SSE with a poll fallback, and dedicated scale-index migrations. So rather than manufacture churn, this PR makes the one genuinely high-value change and documents the rest.

Changed

  • Cache open-building bazaar reads (app/b/[publicSlug]/page.tsx). The bazaar was force-dynamic, hitting Postgres on every request (member join + highlights RPC). Open buildings' reads now go through unstable_cache (30s revalidate, tagged bazaar:<id>) — the same freshness the storefront already uses. A viral open bazaar drops from O(requests) round-trips to ~1 per 30s window.
  • docs/scale-audit.md — full audit: what's already handled, this change, and the remaining human-decision levers.

Invariants checked

  • RLS unchanged. The cache holds only already-public, Zod-projected, PII-free data. No policy relaxed/bypassed/removed.
  • Invite gate intact. Invite-only buildings still read fresh on every request — the cookie gate can't be bypassed by a cached payload, and nothing behind an invite is ever cached.
  • No select * on PII, no unit numbers, no Stripe-UI rebuild, service key stays server-side.

Recommendations (not implemented — your call)

  1. Unbounded admin metrics fetch (lib/admin/load-metrics.ts) pulls all paid orders into the Worker and sums in JS. Move to a security definer SQL aggregate before it bites. Low risk; single-founder page, not the concurrency hot path.
  2. Tracking polls while SSE is healthy (app/o/[token]/tracking.tsx) — pause the 20s poll while EventSource is open. Low–medium risk; verify payment-status transitions still land promptly.
  3. RLS tradeoffs: none — no optimization required touching a policy.

Verification

npm run verify passes locally: typecheck + lint + 240 unit tests + build + image-processor (9 tests).

🤖 Generated with Claude Code

Your Name and others added 5 commits June 30, 2026 12:02
The building bazaar was force-dynamic, so every request hit Postgres
(member join + highlights RPC). For a viral open building that is the one
real DB hot spot at high concurrency.

Extract the member roster + drop highlights into readBazaarContent() and,
for open buildings only, serve it through unstable_cache (30s revalidate,
tagged bazaar:<id>) — the same freshness the storefront already accepts.
Invite-only buildings still read fresh on every request so the cookie gate
is never bypassed by a cached payload, and nothing behind an invite is
cached. No RLS change; the cache holds only already-public, Zod-projected,
PII-free data.

Add docs/scale-audit.md: what is already handled, this change, and the
remaining human-decision levers (unbounded admin metrics fetch; redundant
tracking poll while SSE is healthy). No optimization required relaxing RLS.

npm run verify passes (typecheck + lint + 240 unit tests + build +
image-processor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two scale follow-ups from docs/scale-audit.md.

1. Admin metrics aggregation moved to Postgres. loadAdminMetrics() pulled
   every paid order into the Worker and summed/grouped in JS, growing
   linearly with lifetime order volume. New security-definer RPC
   public.get_admin_metrics (migration 0040) does the rollup in SQL and
   returns one bounded JSON document. The fee rate stays single-sourced in
   TS (PLATFORM_FEE_BPS passed as p_fee_bps); only per-order rounding is
   mirrored in SQL, where round(numeric) is half-away-from-zero — identical
   to Math.round for the non-negative totals paid orders carry. Granted to
   service_role only; reads store_id + total_cents only (no PII, no
   select *). The JSON is Zod-validated at the loader boundary
   (parseAdminMetrics). The aggregation unit test becomes a boundary test;
   the SQL math is covered by the integration suite.

2. Order tracker pauses its redundant poll once SSE covers the order, but
   only for pay-at-pickup orders (static payment status). Online orders keep
   the reconciliation poll because Stripe webhook payment/refund changes are
   not published to the stream. onerror resumes the poll.

npm run verify passes (typecheck + lint + 237 unit tests + build +
image-processor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Exercises migration 0040 against a real database — the SQL the unit suite
can't reach. Parallel-safe: every assertion is scoped to this test's seeded
seller/building by id (the RPC aggregates the whole platform, and the suite
seeds tenants concurrently), never to platform-wide totals.

Verifies the per-seller and per-building rollups equal the seeded paid
orders exactly, that the unpaid seed order is excluded, that platform fees
stay within the per-order rounding bound of rate*GMV on a single snapshot,
and that p_top_limit bounds the ranking lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The file was removed from the working tree and the deletion was swept into
the previous commit by accident. Restore the up-to-date version (with the
second-pass follow-ups: SQL admin metrics + SSE-gated tracking poll).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI provides NEXT_PUBLIC_APP_URL but can leave it empty. `?? "..."` only
falls back on undefined, so an empty string reached new URL("") and threw
Invalid URL — failing page-data collection for /_not-found (and thus the
OpenNext Cloudflare build) for every route. Use `||` so an empty value
falls back to the localhost default too.

Verified: `next build` and `npx opennextjs-cloudflare build` both pass with
NEXT_PUBLIC_APP_URL unset/empty.

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

@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: a823538079

ℹ️ 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".

Comment thread app/o/[token]/tracking.tsx Outdated
if (typeof EventSource !== "undefined") {
es = new EventSource(`/api/track/${token}/stream`);
es.onopen = () => {
if (sseCoversAllUpdates) stopPoll();

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 Keep a reconciliation poll while SSE remains best-effort

When a pay-at-pickup tracker has an open EventSource, this stops the only reconciliation loop. The stream publisher is explicitly best-effort (publishOrderUpdate swallows Durable Object failures in lib/orders/order-stream.ts), so if a seller changes a pickup order's status or marks it paid while that publish misses but the EventSource connection stays open, the customer page can remain stale until a visibility change or reconnect; before this change the 20s poll corrected that gap.

Useful? React with 👍 / 👎.

Your Name and others added 2 commits June 30, 2026 16:05
The cloudflare-preview job runs on every PR but its deploy step needs
CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID, which aren't set in this repo
(didn't carry over from the aptbazaar -> Stoop rename), so the deploy failed
and blocked PRs. Probe the token in a step and gate the deploy on it: the
OpenNext build still runs on every PR (it's what catches build breakage),
the deploy is skipped with a warning when unconfigured, and it re-enables
itself automatically once the secrets are added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous change stopped the poll entirely for pay-at-pickup orders once
SSE connected. But publishOrderUpdate is best-effort (swallows Durable
Object failures), and a missed publish on a still-open EventSource doesn't
fire onerror — so the page could stay stale until a tab refocus/reconnect.
markPaid also transitions a pickup order pay_at_pickup -> paid over that
same best-effort stream, so the gap is real.

Relax the poll instead of removing it: online orders keep the tight 20s
interval (their payment/refund state arrives only via poll); pay-at-pickup
backs off to a 60s reconciliation backstop once SSE is healthy, and onerror
tightens it straight back to 20s with an immediate refetch. Keeps the ~3x
load reduction for the dominant pickup population while never losing the
safety net. verify + e2e (incl. order-lifecycle tracking) pass.

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

@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: 227b1c16d3

ℹ️ 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".

Comment thread .github/workflows/ci.yml
'gmv_cents', coalesce((select sum(total_cents) from paid), 0),
'platform_fees_cents', coalesce((select sum(fee_cents) from paid), 0),
'top_sellers', coalesce((
select jsonb_agg(row)

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 Preserve ranking order inside the JSON aggregates

When the dashboard has multiple top sellers, this jsonb_agg(row) does not specify an aggregate ORDER BY, so the JSON array order is not guaranteed even though the UI renders it as rank 1, 2, 3; the same pattern is used for buildings below. The old TypeScript path explicitly sorted before slicing, so put the ranking keys on the aggregate order as well as the limiting subquery to avoid displaying the right top-N rows in an arbitrary order.

Useful? React with 👍 / 👎.

Your Name added 2 commits June 30, 2026 18:07
Phase 10.6: Add aggregate ORDER BY keys for founder dashboard top sellers and buildings.
Phase 10.6: Add forward migration for databases that already applied 0040.
@g1mliii
g1mliii merged commit 61fc287 into main Jul 1, 2026
5 checks passed
@g1mliii
g1mliii deleted the perf/scale-pass branch July 1, 2026 15:45
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.

1 participant