Skip to content

Speed up the public read paths - #212

Merged
ssavutu merged 6 commits into
mainfrom
perf/listing-reads-and-indexes
Aug 8, 2026
Merged

Speed up the public read paths#212
ssavutu merged 6 commits into
mainfrom
perf/listing-reads-and-indexes

Conversation

@ssavutu

@ssavutu ssavutu commented Aug 8, 2026

Copy link
Copy Markdown
Member

Six changes to the paths the public site and the editor actually hit. No
behaviour changes: the API returns the same bytes it did before.

Measured

Against the real corpus (9,371 articles locally, 10,113 in production),
median of 12 requests over a kept-alive connection:

endpoint before after
/v1/sections/news/articles 76.2 ms 40.2 ms
/v1/search?q=drexel 111.6 ms 66.9 ms
/v1/homepage 149.1 ms 134.8 ms
/v1/articles?limit=20 24.8 ms 23.3 ms

Wire size, same corpus:

before after
/v1/articles?limit=20 16,433 B 5,178 B
/v1/articles/{slug} 8,551 B 3,172 B
editor initial load (gzip) 260.3 kB 147.5 kB

Article lookup by slug goes from type: ALL, 5,150 rows to type: ref,
1 row.

Verification

The old binary and the new one were run against the same live database
and their JSON diffed. Eight endpoints — listing, paged listing, three
section pages, a subsection, homepage, search — byte-identical, zero
differences.

Separately, for all 76 categories in the corpus the old LIKE predicate
and the new indexed predicate select the same article sets, and all 132
articles the index omits genuinely have no categories.

go vet clean; full Go suite including MariaDB integration tests, plus
frontend lint and vitest, all pass.

Production notes

Nothing here needs a reseed. Every migration is an in-place
ADD INDEX or a new table built by scanning what is already there.

The one thing worth knowing before merge: a schema change that rewrites
articles is not viable on this database. It re-tokenizes 44MB of bodies
into the two FULLTEXT indexes — 8m53s for a single ALTER at this corpus
size, during which the CMS's own startup migration was observed sitting in
Waiting for table metadata lock. Both index migrations here avoid that
deliberately; the reasoning is in the code so it does not get undone.

Production currently carries one duplicated slug (an archived pair), which
is why the slug index is not UNIQUE.

Not done, deliberately

The paging COUNT(*) is now the largest remaining cost on /v1/articles.
Two rewrites were built and measured, then rejected — a join-table
EXISTS (nets under 4ms and moves the definition of "filed" onto indexes
that match the columns by data rather than by construction) and an indexed
generated column (needs the table rewrite above). The analysis is recorded
in article_categories.go so the next person does not re-derive it.

🤖 Generated with Claude Code

ssavutu and others added 6 commits August 7, 2026 21:32
Article JSON is highly redundant -- repeated keys, HTML markup -- and the
CMS shipped all of it raw, both to the editor SPA and through Cloudflare
to the public site. A twenty-item listing measured 16,433 bytes on the
wire and 5,178 gzipped; an article detail 8,551 and 3,172.

Content-Type gates it, so media-library JPEGs and PNGs pass through
untouched rather than spending CPU to grow slightly. Bodies under 512
bytes are left alone, because the gzip header and trailer alone are 18 of
them and a short error payload comes out larger compressed.

Placement is load-bearing and the middleware says so: this must wrap
Recovery, not sit inside it. Inside, a panic would close the gzip stream
during unwinding and Recovery's plain-text 500 would be appended to a
finished stream, giving the client a body it cannot decode. The
consequence is that Logging and Metrics keep counting uncompressed bytes
-- the same number they reported before, so the dashboards stay
comparable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nginx served the SPA bundle uncompressed and with no freshness headers at
all, so every asset was re-downloaded or at best revalidated on each
visit. The main JS chunk goes 247,941 -> 77,811 bytes with gzip on.

Vite fingerprints asset filenames with a content hash, so a given URL can
never change what it serves -- a new build produces new names. That makes
/assets/ safe to cache permanently, which turns a repeat visit into no
requests at all. index.html is the one file that must not be cached: it
carries the references to those hashed names, and a stale copy points the
browser at assets the last deploy removed.

/assets/ also stops falling back to index.html, so a missing asset is a
404 rather than a page of HTML served with a .js name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every one of the fifteen routes was imported eagerly, so opening the
dashboard downloaded the article editor, Trix, the media library and the
settings screens first. Initial load was 961.8 kB raw / 260.3 kB gzipped;
it is now 485.0 kB / 147.5 kB.

The three screens a session can start on -- dashboard, login, auth
callback -- stay eager. Splitting a landing route only moves its download
from the bundle into a second round trip the user waits through on a
blank page.

React and the UI kit go in their own chunks because they change only when
a dependency is upgraded, while app code changes every deploy. A routine
deploy now invalidates the app chunk and leaves 194 kB of vendor code in
the browser cache, which is what the immutable caching added alongside is
there to exploit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every listing selected `text`, the full article body, and threw it away:
ScanArticle loaded it into Content and articleListItems never read it.
The excerpt comes from `excerpt`, falling back to `description`, and is
derived from the body only at write time. One default page of twenty
articles was fetching 97,270 bytes of body to use 12,905 bytes of
excerpt, and the homepage paid that six times over building its section
blocks.

The four hand-maintained column lists this required are gone with it.
They were coupled to one positional Scan, so adding a column in the wrong
place silently shifted every value after it into the wrong field -- the
comment above them warned as much, and dropping `text` meant adding a
fifth. The SELECT lists and the Scan targets now derive from one ordered
articleColumnSet, so they cannot disagree. ArticleColumns is the full set
for the article detail endpoint, the one read that renders the body;
ArticleSummaryColumns is everything else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`slug` is how every article is addressed -- the detail endpoint, the
comment thread, the permalink check, the featured-article write -- and
nothing indexed it, so each of those scanned the whole corpus to return
one row. `articles_authors` had only a primary key on its own id, so
resolving a page of bylines scanned the entire join table, once per
listing and once per homepage block.

Both are prefix/secondary ADD INDEX, deliberately. The obvious fix for
slug is to narrow the LONGTEXT to VARCHAR first, and that is a trap:
retyping a column rewrites the table, and rewriting `articles`
re-tokenizes 44MB of bodies into the two FULLTEXT indexes. Measured at
8m53s for one ALTER on a corpus this size, during which a second
connection running the CMS's own startup migration sat in "Waiting for
table metadata lock" -- in production that is the newsroom's writes
queued behind a container that is not yet serving traffic. The prefix
index takes 0.31s on the same shape and yields the same plan.

191 characters is above the longest slug in the corpus (154) and stays
under the 767-byte limit older row formats impose. A prefix index only
narrows candidates -- InnoDB rechecks the full value -- so it can cost an
extra row read but never return a wrong article.

Not UNIQUE: uniqueness is a property of the data, and the corpus belongs
to the ETL. Production already carries one duplicated slug from an
archived pair, which a UNIQUE index would turn into a CMS that will not
start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section filtering ran `LOWER(categories) LIKE '%"news"%'` against a
LONGTEXT column. A leading wildcard rules out every index, so each
section page read the whole articles table -- twice, since the listing
pages with a COUNT(*) alongside -- and so did each of the homepage's six
blocks and every taxonomy recount.

article_categories is a derived index of that column: one row per
(article, category title), so the question becomes an equality join. The
optimizer flips it, driving from the index and doing eq_ref primary
lookups instead of applying REPLACE/LOWER to a LONGTEXT for 6,038 rows.
A section page goes from 76.2ms to 40.2ms, search from 111.6ms to 66.9ms.

It is strictly derived, never authoritative. `articles`.`categories`
remains the source of truth, article writes keep the index in step, and
it is rebuilt at every startup -- deliberately unconditional, and
deliberately not behind CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP. The
corpus is periodically reloaded wholesale by the ETL, which renumbers
article ids: stale contents would not merely be old, they would point at
the wrong articles. A rebuild an operator has to remember is one that
gets forgotten on the reseed that needs it most, and it costs one scan of
a ten-thousand-row table.

Creating it is fatal on failure, unlike the index migrations, because the
queries now depend on it: a CMS that booted without it would serve empty
sections rather than slow ones.

EXISTS rather than IN because callers negate the fragment (see
ReportOrphanedArticles), and NOT IN over a subquery that can yield NULL
evaluates to UNKNOWN and silently matches nothing.

The LIKE mechanism is deleted rather than left dead. Its rules -- the
anchoring that keeps Women's Basketball out of mens-basketball, the alias
map, the restored possessives -- move intact to CategoryMatchValues, and
their tests move with them: matchesCategories now composes the two real
halves, what the rebuild would index and what the query would ask for.

Verified against the full corpus: for all 76 categories the old and new
predicates select identical article sets, and every one of the 132
unindexed articles genuinely has no categories.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ssavutu
ssavutu merged commit 82dee93 into main Aug 8, 2026
6 checks passed
@ssavutu
ssavutu deleted the perf/listing-reads-and-indexes branch August 8, 2026 01:35
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