feat(flags): ship-dark feature flags, with last-distance-offered behind the first one #125
@@ -37,8 +37,9 @@ to handle them:
|
||||
|
||||
1. **Flag state lives outside the repository.** `main` is no longer the whole
|
||||
truth about what is switched on. The registry in §2 exists to bound that.
|
||||
2. **A flag can change without a deploy**, so nothing else invalidates the
|
||||
caches that a deploy would have cleared. §4 is that mechanism.
|
||||
2. **A flag can change without a deploy**, so nothing else clears the caches
|
||||
that a deploy would have cleared. §4 establishes how long a flip takes to
|
||||
become visible, and why that is short enough to need no extra mechanism.
|
||||
|
||||
Also considered: Flagsmith (heavier — Django, Postgres and Redis), GrowthBook
|
||||
(requires MongoDB), and Flipt v2 (the closest conceptual fit, git-native, but
|
||||
@@ -140,38 +141,49 @@ named constant with a comment saying what belongs on it.
|
||||
|
||||
## 4. Propagation
|
||||
|
||||
School and place pages carry `revalidate = 604800`. A flag value consulted
|
||||
during render is baked into the cached HTML, so **polling alone changes
|
||||
nothing** — the page was rendered days ago. Propagation is push, not pull.
|
||||
**Time-based revalidation is sufficient. There is no webhook.**
|
||||
|
||||
Two webhook integrations in Unleash, both firing on feature-environment
|
||||
enable/disable. The webhook cannot set custom headers, so each is authenticated
|
||||
by a shared secret in the query string.
|
||||
An earlier draft of this section specified two Unleash webhooks and a
|
||||
`revalidateTag('flags')` purge, on the premise that pages cache for seven days.
|
||||
That premise was wrong, and checking it removed the most complex part of the
|
||||
design.
|
||||
|
||||
1. → `POST /api/admin/flags-changed` on FastAPI. Refreshes the SDK cache, and
|
||||
rebuilds the sitemap — a route-family flag changes which URLs exist, and the
|
||||
sitemap is held in memory.
|
||||
2. → `POST /api/revalidate-flags` on Next. Calls `revalidateTag('flags')`.
|
||||
Next uses the **lowest** `revalidate` among a route's fetches to set the
|
||||
revalidation frequency of the whole route — the segment-level
|
||||
`export const revalidate` does not override a lower value inside it. Measured
|
||||
against this codebase:
|
||||
|
||||
Unleash retries once and can deliver duplicate or out-of-order events, so both
|
||||
handlers are idempotent: they re-read current state rather than applying a
|
||||
delta from the payload body.
|
||||
| Page family | Segment | Lowest fetch | Effective |
|
||||
|---|---|---|---|
|
||||
| `/school/[slug]` | 604800 | `fetchSchoolDetails` at 300 | **5 minutes** |
|
||||
| `/schools/*` | 604800 | `fetchNationalAverages` at 3600 | **1 hour** |
|
||||
|
||||
### Cache tagging: coarse, deliberately
|
||||
The Unleash SDK polls every 15 seconds, so a flip reaches school pages within
|
||||
about five minutes and place pages within the hour, unaided. Flags flip
|
||||
monthly, by hand, deliberately. That is fast enough.
|
||||
|
||||
**Every server-side fetch in `nextjs-app/lib/` carries the `flags` tag**, not
|
||||
only the fetch of `/api/flags` itself.
|
||||
What this removes: two webhook integrations, a `/api/revalidate-flags` route, a
|
||||
shared-secret-in-a-query-string scheme, an idempotency requirement against
|
||||
duplicate and out-of-order delivery, and a rule that every fetch in
|
||||
`nextjs-app/lib/` carry a cache tag. None of it has to be built, maintained, or
|
||||
kept correct as new fetches are added.
|
||||
|
||||
The tempting rule — tag only those fetches whose response shape a flag can
|
||||
change — is wrong in a way that fails silently. The first consumer proves it:
|
||||
`admission_distance` changes the response of `/api/schools/{urn}`, not of
|
||||
`/api/flags`, so a narrowly-tagged purge would leave ~25,000 school pages
|
||||
serving the pre-flip render for up to seven days. The failure is invisible
|
||||
locally and visible to Google.
|
||||
**If instant flips are ever wanted**, the webhook is the way to add them, and it
|
||||
is purely additive — nothing in this design has to change first.
|
||||
|
||||
Flips are rare and Next serves stale-while-revalidate, so a full purge costs a
|
||||
gradual re-render rather than a cliff. Correctness is worth more here than
|
||||
precision.
|
||||
### Two constraints this leaves behind
|
||||
|
||||
**Never flag content on a `force-static` page.** `app/admissions/page.tsx`
|
||||
declares `export const dynamic = 'force-static'`, so it is baked at build time
|
||||
and never revalidates. A flag gating anything on such a page would not take
|
||||
effect until the next deploy, silently. If a flag ever needs to reach one, that
|
||||
page must first move to ISR.
|
||||
|
||||
**A route-family flag still needs the sitemap rebuilt.** The sitemap is held in
|
||||
memory and rebuilt only at startup or via `POST /api/admin/regenerate-sitemap`.
|
||||
No flag in scope touches the sitemap (§6), so this is deferred with the route
|
||||
case rather than solved now — but a route flag must not ship without it, or the
|
||||
sitemap will advertise URLs that `notFound()`.
|
||||
|
||||
## 5. What "off" means, per surface
|
||||
|
||||
|
||||
Reference in new issue
Block a user