-
Notifications
You must be signed in to change notification settings - Fork 4
Implement ISR with RSC seeding for explore pages #1148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
teetangh
wants to merge
23
commits into
dev
Choose a base branch
from
claude/explore-pages-loading-nyibb0
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
d567933
perf(explore): convert /explore/programs to ISR, resolve org badges c…
claude ebfe492
perf(explore): seed the experts browse grid from the ISR render
claude d6d87ab
perf(explore): seed the All Programs grid and smooth the session key …
claude e20e1b9
fix(explore): FCP-capable loading states, experts error boundary, rea…
claude 2066efe
test(explore): anchor the perRequest detector on a fixture, not a liv…
claude ba5de63
Merge remote-tracking branch 'origin/dev' into claude/explore-pages-l…
claude 83731ab
refactor(explore): deduplicate error cards and hero copy for the Sona…
claude 887237a
fix(explore): stop shadowing the Error global in segment error bounda…
claude 5c15085
fix(explore): address CodeRabbit review — pagination bounds, error co…
claude b3cb5ed
Merge remote-tracking branch 'origin/dev' into claude/explore-pages-l…
claude 65aa2a7
Merge branch 'dev' into claude/explore-pages-loading-nyibb0
teetangh 7726eb2
Merge remote-tracking branch 'origin/dev' into claude/explore-pages-l…
teetangh 74f5813
perf(platform): 2GB handler memory, post-deploy warm-up, prod keep-wa…
teetangh 289d884
fix(ci): stagger keep-warm off 00:05 for the workflow-hygiene guard
teetangh f2e5d1a
fix(ci): keep-warm loops its 5-minute pings inside one hourly job
claude 634c0d5
fix(ci): address CodeRabbit review on the warm-up workflows
claude 886d7bf
test(ci): give the cron-lock registry an HTTP-only workflow category
claude 17228d7
fix(netlify): target ___netlify-server-handler — the v1 handler names…
teetangh 08b10ce
revert(netlify): 2048 MB handler memory measured no better, possibly …
teetangh ecaf160
Merge remote-tracking branch 'origin/dev' into claude/explore-pages-l…
claude 6ff6038
fix(ci): move keep-warm to :03 — dev's expire-stale-requests took :10
claude 58fb03f
fix(ci): address CodeRabbit round 3 on the warm-up workflows
claude 0646d8f
docs(skill): record the stall resolution — 2048 MB eliminates it; han…
teetangh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| name: Keep Warm | ||
|
|
||
| # Keep at least one warm function instance on PRODUCTION between real traffic. | ||
| # | ||
| # The Next.js server handler stalls its event loop for ~24s on a brand-new | ||
| # instance's first invocation (#1124). A single warm instance does not remove | ||
| # that stall — burst traffic still spawns fresh instances that each pay it — | ||
| # but it removes the stall from the COMMON case: a lone visitor clicking | ||
| # through an otherwise idle site, which is exactly the shape of manual testing | ||
| # and low-traffic days. | ||
| # | ||
| # Every ping is a billable invocation (~12/hour ≈ 8.6k/month at the 5-minute | ||
| # cadence); disable this workflow if that trade stops making sense. Previews | ||
| # are deliberately NOT warmed: each PR has its own isolated cache scope and | ||
| # subdomain, so warming one preview does nothing for another, and idle previews | ||
| # going cold is unavoidable (warm-deploy.yml covers their first minutes). | ||
| # | ||
| # GitHub schedule triggers are best-effort with no SLA and can lag several | ||
| # minutes; only the hourly START is exposed to that lag — the 5-minute ping | ||
| # cadence itself runs as an in-job loop and is exact. | ||
|
|
||
| on: | ||
| schedule: | ||
| # Hourly at :03, with the 5-minute ping cadence implemented as a loop | ||
| # INSIDE the job rather than as a */5 cron. The check-workflow-hygiene | ||
| # gate forbids two multi-daily crons sharing any start minute, and this | ||
| # fleet's minute map is dense enough that no 12-per-hour (or even | ||
| # 6-per-hour) schedule has a clean lattice — an offset grid like 3-59/5 | ||
| # still collides with the hourly and */6 jobs at :08/:13/:18/:23/:28/ | ||
| # :33/:38/:58. One hourly start on a free minute keeps the gate green | ||
| # while the loop below preserves the real warm cadence; it also shrinks | ||
| # exposure to GitHub's best-effort schedule lag (one delayed start per | ||
| # hour instead of twelve). | ||
| # | ||
| # :03 because the free-minute pool is nearly empty and SHRINKS as dev | ||
| # moves: this job started at :10 and dev's expire-stale-requests went | ||
| # hourly-at-:10 the same day, colliding in the PR merge build. As of | ||
| # 2026-08-22 the only minutes free of multi-daily crons are :00, :03 | ||
| # and :15 — re-derive that set before moving this again. | ||
| - cron: "3 * * * *" | ||
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: keep-warm | ||
| # A lagged start can overlap the previous hour's loop; the newer run | ||
| # supersedes it rather than double-pinging. | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| ping: | ||
| runs-on: ubuntu-latest | ||
| # One hour of 5-minute pings, then exit before the next scheduled run. | ||
| timeout-minutes: 58 | ||
|
|
||
| steps: | ||
| # The canonical production origins: the Netlify host and the pinned | ||
| # NEXT_PUBLIC_APP_URL domain. Edge cache scopes are per-host, so both are | ||
| # pinged; the function instance underneath is shared. | ||
| # | ||
| # /api/health guarantees a handler invocation (a page GET can be served | ||
| # entirely off the CDN cache without touching the function), so instance | ||
| # warmth does not depend on cache misses. It is also the cheapest route | ||
| # that proves end-to-end liveness: LIMIT-1 DB probe + status reads, and | ||
| # doubles as an outage signal — if these pings start failing, prod is | ||
| # degraded whether or not any user has noticed yet. | ||
| - name: Ping production every 5 minutes for an hour | ||
| env: | ||
| HOSTS: "https://familiarisenow.com https://familiarise.netlify.app" | ||
| run: | | ||
| # 11 rounds x 5-minute sleep ≈ 55 minutes of coverage per hourly | ||
| # run; the next run picks up next hour. Pings are sequential and the | ||
| # loop sleeps between rounds, so this never bursts the function. | ||
| # | ||
| # DEADLINE guard: the happy path fits easily, but if both hosts hit | ||
| # --max-time on the initial attempt AND the retry every round, the | ||
| # arithmetic exceeds the 58-minute job timeout (~95 min worst case) | ||
| # and GitHub would cancel mid-loop. Stop starting new rounds once | ||
| # 50 minutes have elapsed instead — the next hourly run continues. | ||
| # | ||
| # /api/health is fail-open: HTTP 200 with status "degraded" when the | ||
| # DB is unreachable, so the body is parsed too. Degradation is a | ||
| # WARNING here, not a failure — the ping still warmed the instance, | ||
| # and the loop keeps running so warmth (and the outage signal) | ||
| # continue through the incident. | ||
| DEADLINE=$((SECONDS + 50 * 60)) | ||
| for round in $(seq 1 11); do | ||
| if [ "$SECONDS" -ge "$DEADLINE" ]; then | ||
| echo "::notice::keep-warm stopping at round $round — 50-minute deadline reached" | ||
| break | ||
| fi | ||
| for host in $HOSTS; do | ||
| if BODY=$(curl -sS -w "\n$host/api/health: ttfb=%{time_starttransfer}s code=%{http_code}" \ | ||
| --fail --max-time 60 --retry 1 --retry-delay 3 \ | ||
| "$host/api/health"); then | ||
| echo "${BODY##*$'\n'}" | ||
| STATUS=$(printf '%s' "$BODY" | head -n 1 | jq -r '.status // "unknown"') | ||
| if [ "$STATUS" != "healthy" ]; then | ||
| echo "::warning::keep-warm: $host reports health status '$STATUS' — prod degraded" | ||
| fi | ||
| else | ||
| echo "::warning::keep-warm ping failed for $host" | ||
| fi | ||
| done | ||
| if [ "$round" -lt 11 ]; then sleep 300; fi | ||
| done | ||
|
|
||
| # NOTE: hot page caches are deliberately NOT refreshed here. The ISR | ||
| # windows on / and /explore/experts are 1h/5m; refreshing them every 5 | ||
| # minutes would force continuous background regeneration invocations for | ||
| # content that changes on the order of days. The durable cache already | ||
| # persists entries across instances (Netlify Blobs), so cache priming | ||
| # only matters right after a deploy — that is warm-deploy.yml's job. | ||
| # This workflow's single job is instance warmth via the health ping. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| name: Warm Deploy | ||
|
|
||
| # Prime the caches and function instances of a freshly finished deploy. | ||
| # | ||
| # A deploy starts every ISR/CDN cache entry empty, and a Netlify function | ||
| # instance that has never served a request stalls its event loop for roughly | ||
| # 24s before executing anything (#1124 — measured 27.8–31.0s TTFB across 11/12 | ||
| # concurrent unique-key requests on preview 1195). The FIRST real visitor after | ||
| # each deploy therefore pays full price per hot route. This workflow pays it | ||
| # instead, in a controlled order: | ||
| # | ||
| # 1. /api/health first — guarantees one function invocation, so the instance | ||
| # that will serve everything else absorbs the cold-boot stall here rather | ||
| # than on a page render. | ||
| # 2. Hot public pages sequentially afterwards — primes their CDN/durable | ||
| # entries while the instance from step 1 is still warm. SEQUENTIAL ON | ||
| # PURPOSE: concurrent pings would spawn one stalled instance EACH, which | ||
| # is precisely the pathology being mitigated. | ||
| # | ||
| # Fires on Netlify's deployment statuses for production deploys, branch deploys | ||
| # AND pull-request previews — previews are where the cold cost is worst, | ||
| # because their cache scope is isolated and nothing is ever warm. | ||
| # | ||
| # This does not keep instances alive (see keep-warm.yml); it only removes the | ||
| # empty-cache window right after a deploy. | ||
|
|
||
| # deployment_status supports no trigger-level filter (no `types`/`states` | ||
| # narrowing exists for it) — the workflow fires on every status event and the | ||
| # job-level `if` below is the real gate. | ||
| on: | ||
| deployment_status: | ||
| workflow_dispatch: | ||
| inputs: | ||
| base_url: | ||
| description: "Deploy origin to warm (defaults to production)" | ||
| required: false | ||
| default: "https://familiarisenow.com" | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: warm-deploy-${{ github.event.deployment.id || github.run_id }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| warm: | ||
| # Netlify also reports inactive/other states; only successful deploys with | ||
| # a resolvable environment URL are worth warming. | ||
| if: github.event_name == 'workflow_dispatch' || github.event.deployment_status.state == 'success' | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
|
|
||
| steps: | ||
| - name: Resolve deploy URL | ||
| id: url | ||
| env: | ||
| EVENT_URL: ${{ github.event.deployment_status.environment_url }} | ||
| PAYLOAD_WEB_URL: ${{ github.event.deployment.payload.web_url }} | ||
| DISPATCH_URL: ${{ inputs.base_url }} | ||
| run: | | ||
| BASE="${DISPATCH_URL:-${EVENT_URL:-${PAYLOAD_WEB_URL:-}}}" | ||
| if [ -z "$BASE" ]; then | ||
| echo "::error::No environment_url on the deployment status; cannot warm." | ||
| exit 1 | ||
| fi | ||
| # Strip a trailing slash so the path concatenations below stay clean. | ||
| BASE="${BASE%/}" | ||
| # The URL comes from the deployment event payload, i.e. from outside | ||
| # this repo. It is only ever used through env indirection below, but | ||
| # validate the shape anyway so nothing resembling shell syntax (or a | ||
| # control character) ever reaches a run script or GITHUB_OUTPUT. | ||
| if ! printf '%s' "$BASE" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?$'; then | ||
| echo "::error::environment_url '$BASE' is not a bare https origin; refusing to warm" | ||
| exit 1 | ||
| fi | ||
| echo "base=$BASE" >> "$GITHUB_OUTPUT" | ||
| echo "Warming $BASE" | ||
|
|
||
| # The first request against a brand-new instance can legitimately take | ||
| # ~30s (#1124), so max-time must sit well above that or the warm-up would | ||
| # kill the very request doing the warming. | ||
| # | ||
| # /api/health is deliberately fail-open: it answers HTTP 200 with | ||
| # status "degraded" + database "unreachable" when the DB is down, so a | ||
| # bare curl exit code is NOT a health verdict. Parse the body: warming a | ||
| # degraded origin is worse than useless — the ISR pages rethrow on DB | ||
| # failure (#1123), so every page ping would 500, cache nothing, and the | ||
| # run would still report success. Fail here instead so the red run says | ||
| # "deploy is up but degraded; nothing was warmed". | ||
| - name: Warm the function instance (health probe) | ||
| env: | ||
| BASE_URL: ${{ steps.url.outputs.base }} | ||
| run: | | ||
| BODY=$(curl -sS -w "\nhealth: ttfb=%{time_starttransfer}s code=%{http_code}" \ | ||
| --fail --max-time 60 --retry 1 --retry-delay 2 \ | ||
| "$BASE_URL/api/health") | ||
| echo "${BODY##*$'\n'}" | ||
| STATUS=$(printf '%s' "$BODY" | head -n 1 | jq -r '.status // "unknown"') | ||
| if [ "$STATUS" != "healthy" ]; then | ||
| echo "::error::health status is '$STATUS' (not healthy) — origin degraded, skipping page warming" | ||
| exit 1 | ||
| fi | ||
|
|
||
| # Hot public routes in click-order value. Keep this list short and | ||
| # deliberate: every line is a billable invocation, and sequential curls | ||
| # share the instance warmed above. | ||
| - name: Warm hot public pages | ||
| env: | ||
| BASE_URL: ${{ steps.url.outputs.base }} | ||
| run: | | ||
| BASE="$BASE_URL" | ||
| for path in "/" "/explore/experts" "/explore/programs" "/explore/community" "/about"; do | ||
| curl -sS -o /dev/null -w "$path: ttfb=%{time_starttransfer}s code=%{http_code}\n" \ | ||
| --max-time 45 "$BASE$path" || true | ||
| sleep 1 | ||
| done | ||
|
|
||
| # RSC navigation payloads are what an actual <Link> click fetches; priming | ||
| # them means the first soft navigation hits the CDN instead of the | ||
| # function. A generic RSC request also re-walks the render path, keeping | ||
| # the instance hot. | ||
| - name: Warm RSC navigation payloads | ||
| env: | ||
| BASE_URL: ${{ steps.url.outputs.base }} | ||
| run: | | ||
| BASE="$BASE_URL" | ||
| for path in "/explore/experts" "/explore/programs" "/about"; do | ||
| curl -sS -o /dev/null -H "RSC: 1" \ | ||
| -w "rsc $path: ttfb=%{time_starttransfer}s code=%{http_code}\n" \ | ||
| --max-time 45 "$BASE$path?_rsc=warmup" || true | ||
| sleep 1 | ||
| done |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.