diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 70647ab..076409e 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -5,12 +5,47 @@ server { root /usr/share/nginx/html; index index.html; + # The SPA bundle is the reason this is here: several hundred kilobytes of + # JavaScript and CSS that compress to roughly a third of their size. + gzip on; + gzip_vary on; + gzip_comp_level 6; + # Below this, the gzip header and trailer eat the saving. + gzip_min_length 1024; + gzip_proxied any; + gzip_types + application/javascript + application/json + application/manifest+json + application/xml + image/svg+xml + text/css + text/plain + text/xml; + location = /healthz { access_log off; add_header Content-Type text/plain; return 204; } + # Vite fingerprints these filenames with a content hash, so a given URL can + # never change what it serves: a new build produces new names. That makes + # them safe to cache permanently, which is what turns a repeat visit from a + # revalidation of every asset into no requests at all. + location /assets/ { + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # index.html carries the references to those hashed filenames, so it is the + # one file that must never be cached -- a stale copy points the browser at + # assets the last deploy removed. + location = /index.html { + add_header Cache-Control "no-cache"; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 17cac24..3e90e91 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,28 +1,47 @@ +import { lazy, Suspense } from "react" import { useCurrentUserRole } from "./hooks/useCurrentUserRole" import { Routes, Route, useLocation, Navigate } from "react-router-dom" import { useSessionAuth } from "./auth/sessionAuthContext" import Header from "./components/Header" import Sidebar from "./components/Sidebar" + +// The three screens a session can *start* on stay in the main bundle. Splitting +// a landing route only moves its download from the bundle to a second round +// trip the user waits through on a blank page. import DashboardPage from "./pages/DashboardPage" import LoginPage from "./pages/LoginPage" import AuthCallback from "./pages/AuthCallback" -import ArticleView from "./pages/articleView" -import DevelopingStoriesView from "./pages/developingStoriesView" -import EditArticleView from "./pages/editArticleView" -import MediaView from "./pages/mediaView" -import AuthorsView from "./pages/authorsView" -import SectionsView from "./pages/sectionsView" -import UsersView from "./pages/usersView" -import CommentsView from "./pages/commentsView" -import ClassifiedsView from "./pages/classifiedsView" -import ActivityView from "./pages/activityView" -import NewsletterView from "./pages/newsletterView" -import SeoView from "./pages/seoView" -import SettingsPage from "./pages/settingsPage" -import PollView from "./pages/pollView" + +// Everything else is reached by a click from one of those, so its chunk +// downloads while the editor is already looking at a rendered page. The article +// editor matters most: it pulls in Trix, which no other route touches. +const ArticleView = lazy(() => import("./pages/articleView")) +const DevelopingStoriesView = lazy(() => import("./pages/developingStoriesView")) +const EditArticleView = lazy(() => import("./pages/editArticleView")) +const MediaView = lazy(() => import("./pages/mediaView")) +const AuthorsView = lazy(() => import("./pages/authorsView")) +const SectionsView = lazy(() => import("./pages/sectionsView")) +const UsersView = lazy(() => import("./pages/usersView")) +const CommentsView = lazy(() => import("./pages/commentsView")) +const ClassifiedsView = lazy(() => import("./pages/classifiedsView")) +const ActivityView = lazy(() => import("./pages/activityView")) +const NewsletterView = lazy(() => import("./pages/newsletterView")) +const SeoView = lazy(() => import("./pages/seoView")) +const SettingsPage = lazy(() => import("./pages/settingsPage")) +const PollView = lazy(() => import("./pages/pollView")) const AUTH_ROUTES = ["/login"] +// Rendered inside AppShell, so the sidebar and header stay put while a route +// chunk loads and only the content area changes. +function RouteFallback() { + return ( +
+

Loading…

+
+ ) +} + function AppShell({ children }: { children: React.ReactNode }) { return (
@@ -98,48 +117,50 @@ export default function App() { return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - )} - /> - - - - )} - /> - } /> - - - - )} - /> - + }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + )} + /> + + + + )} + /> + } /> + + + + )} + /> + + ) } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f9ad34b..005c1c7 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -18,6 +18,33 @@ export default defineConfig({ url: path.resolve(__dirname, "node_modules/url/url.js"), }, }, + build: { + rollupOptions: { + output: { + // Vendor code changes only when a dependency is upgraded, while app + // code changes every deploy. Splitting them means a routine deploy + // invalidates the app chunk and leaves React and the UI kit in the + // browser cache, which is what the immutable caching in nginx.conf is + // there to exploit. + manualChunks: { + react: ["react", "react-dom", "react-router-dom"], + // Radix and the two icon sets: large, stable, and pulled in by nearly + // every route, so they belong in neither the app chunk nor a route's. + ui: [ + "@radix-ui/react-avatar", + "@radix-ui/react-checkbox", + "@radix-ui/react-dropdown-menu", + "@radix-ui/react-label", + "@radix-ui/react-popover", + "@radix-ui/react-separator", + "@radix-ui/react-slot", + "@phosphor-icons/react", + "lucide-react", + ], + }, + }, + }, + }, test: { environment: "jsdom", globals: true, diff --git a/server/internal/database/article_categories.go b/server/internal/database/article_categories.go new file mode 100644 index 0000000..ab37d1f --- /dev/null +++ b/server/internal/database/article_categories.go @@ -0,0 +1,235 @@ +package database + +import ( + "context" + "database/sql" + "encoding/json" + "strings" +) + +// article_categories is a derived index of `articles`.`categories`: one row per +// (article, category title), so "which articles are in this section" is an index +// lookup instead of a scan. +// +// It replaces a `categories LIKE '%"news"%'` predicate. That predicate could +// never use an index -- a leading wildcard on a LONGTEXT column rules it out -- +// so every section page, every homepage block and every taxonomy count read the +// whole articles table, twice per request once the paging COUNT(*) is included. +// +// The table is strictly derived, never authoritative. `articles`.`categories` +// remains the source of truth, this is rebuilt from it at every startup, and +// article writes keep it in step in between. That combination is deliberate: +// the corpus is periodically reloaded wholesale by the WordPress ETL, which +// knows nothing about this table, and a derived index that silently survives a +// reseed would serve empty section pages with no indication why. + +// maxCategoryLength matches the VARCHAR in schema/article_categories.sql. A +// longer title is skipped rather than truncated, because a truncated key would +// match a section it does not belong to. +const maxCategoryLength = 191 + +// A note for the next person who looks at the listing's paging COUNT(*), which +// evaluates the artifact filter (see handlers.articleQueryFilters) over every +// published row and is the largest remaining cost on /v1/articles. +// +// Two rewrites were measured against this corpus and both rejected: +// +// - Two EXISTS over articles_authors and this table. Correct on today's data +// -- zero disagreements across all 9,371 development and 10,113 production +// rows -- and worth 13.1ms -> 9.4ms on the COUNT, but it also costs 0.1ms +// on the listing query, which materializes both subqueries to return 21 +// rows. Under 4ms net, in exchange for moving the definition of "filed" +// onto indexes that match the columns by data rather than by construction. +// - A PERSISTENT generated column with an index, which would make the COUNT +// index-only. Adding one rewrites `articles`, and rewriting `articles` +// re-tokenizes the bodies into the two FULLTEXT indexes: 8m53s for a single +// ALTER here, during which 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 cheap wins are already taken (see EnsureArticlesPublishedIndex and +// EnsureArticleAuthorsIndex). What is left needs either a schema the ETL owns +// or an approximate count, and neither is worth four milliseconds. + +func EnsureArticleCategoriesTable(ctx context.Context, conn *sql.DB) error { + _, err := conn.ExecContext(ctx, TableSchema("article_categories")) + return err +} + +// normalizeCategoryValues turns the stored `categories` JSON into the keys this +// table is indexed on. +// +// Only a well-formed JSON array counts. parseStringListField falls back to +// splitting on commas for rows that are not JSON, but the LIKE predicate this +// replaces anchored on JSON quotes and so never matched those rows either; +// indexing them here would quietly add articles to sections that have never +// shown them. +func normalizeCategoryValues(raw string) []string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + var parsed []string + if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { + return nil + } + return normalizeCategoryList(parsed) +} + +// normalizeCategoryList lowercases, trims and dedupes category titles. The +// lowercasing mirrors the LOWER() the old predicate applied to the column, and +// is what lets CategoryMatchValues compare against these rows directly. +func normalizeCategoryList(categories []string) []string { + out := make([]string, 0, len(categories)) + for _, category := range categories { + value := strings.ToLower(strings.TrimSpace(category)) + if value == "" || len(value) > maxCategoryLength { + continue + } + duplicate := false + for _, existing := range out { + if existing == value { + duplicate = true + break + } + } + if !duplicate { + out = append(out, value) + } + } + return out +} + +// execer is satisfied by both *sql.DB and *sql.Tx, so the write-path helpers can +// join a caller's transaction. +type execer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +// ReplaceArticleCategories rewrites one article's rows. Delete-then-insert +// rather than a diff: an article carries a handful of categories, and the +// simpler operation cannot leave a stale row behind. +func ReplaceArticleCategories(ctx context.Context, conn execer, articleID int64, categories []string) error { + if _, err := conn.ExecContext(ctx, "DELETE FROM `article_categories` WHERE `article_id` = ?", articleID); err != nil { + return err + } + values := normalizeCategoryList(categories) + if len(values) == 0 { + return nil + } + + placeholders := make([]string, 0, len(values)) + args := make([]any, 0, len(values)*2) + for _, value := range values { + placeholders = append(placeholders, "(?, ?)") + args = append(args, articleID, value) + } + _, err := conn.ExecContext(ctx, + "INSERT INTO `article_categories` (`article_id`, `category`) VALUES "+strings.Join(placeholders, ", "), + args..., + ) + return err +} + +// ReplaceArticleCategoriesBySlug is ReplaceArticleCategories for the update +// paths, which address an article by slug. A slug that matches nothing is not an +// error here: the caller's own UPDATE already reported that as a 404, and +// failing again would turn one missing article into a 500. +func ReplaceArticleCategoriesBySlug(ctx context.Context, conn *sql.DB, slug string, categories []string) error { + var articleID int64 + switch err := conn.QueryRowContext(ctx, + "SELECT `id` FROM `articles` WHERE `slug` = ? LIMIT 1", strings.TrimSpace(slug), + ).Scan(&articleID); err { + case nil: + case sql.ErrNoRows: + return nil + default: + return err + } + return ReplaceArticleCategories(ctx, conn, articleID, categories) +} + +// RebuildArticleCategories rebuilds the whole table from `articles`. +// +// Run at every startup. It is cheap -- the corpus is under ten thousand rows and +// two or three categories each -- and running it unconditionally is what makes +// the table safe to depend on: after a WordPress ETL reseed, which replaces +// `articles` wholesale and renumbers its ids, the previous contents are not +// merely stale but point at the wrong articles. +func RebuildArticleCategories(ctx context.Context, conn *sql.DB) error { + rows, err := conn.QueryContext(ctx, "SELECT `id`, `categories` FROM `articles`") + if err != nil { + return err + } + defer rows.Close() + + type articleCategories struct { + id int64 + categories []string + } + var pending []articleCategories + for rows.Next() { + var id int64 + var raw sql.NullString + if err := rows.Scan(&id, &raw); err != nil { + return err + } + values := normalizeCategoryValues(raw.String) + if len(values) == 0 { + continue + } + pending = append(pending, articleCategories{id: id, categories: values}) + } + if err := rows.Err(); err != nil { + return err + } + // The scan is finished before the writes start: holding a result set open + // across them would need a second connection from the pool, and the pool is + // the resource this whole change is trying to spend less of. + + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + // TRUNCATE rather than DELETE: this is a full replacement, and it leaves no + // undo log to write for rows that are about to be re-inserted anyway. + if _, err := tx.ExecContext(ctx, "TRUNCATE TABLE `article_categories`"); err != nil { + return err + } + + // Batched, because one INSERT per article is ten thousand round trips on + // every boot. + const batchSize = 500 + placeholders := make([]string, 0, batchSize) + args := make([]any, 0, batchSize*2) + flush := func() error { + if len(placeholders) == 0 { + return nil + } + _, err := tx.ExecContext(ctx, + "INSERT INTO `article_categories` (`article_id`, `category`) VALUES "+strings.Join(placeholders, ", "), + args..., + ) + placeholders = placeholders[:0] + args = args[:0] + return err + } + + for _, article := range pending { + for _, category := range article.categories { + placeholders = append(placeholders, "(?, ?)") + args = append(args, article.id, category) + if len(placeholders) >= batchSize { + if err := flush(); err != nil { + return err + } + } + } + } + if err := flush(); err != nil { + return err + } + return tx.Commit() +} diff --git a/server/internal/database/article_categories_test.go b/server/internal/database/article_categories_test.go new file mode 100644 index 0000000..ac5faf6 --- /dev/null +++ b/server/internal/database/article_categories_test.go @@ -0,0 +1,71 @@ +package database + +import ( + "reflect" + "strings" + "testing" +) + +func TestNormalizeCategoryValues(t *testing.T) { + cases := map[string]struct { + raw string + want []string + }{ + "json array": {`["News","Campus"]`, []string{"news", "campus"}}, + // The escape encoding/json used to emit for "&". Decoding resolves it, + // which is what makes the SQL-side REPLACE unnecessary. + "escaped ampersand": {`["Comics & Puzzles"]`, []string{"comics & puzzles"}}, + "whitespace": {`[" News "]`, []string{"news"}}, + "duplicates": {`["News","news"]`, []string{"news"}}, + "empty members": {`["News",""]`, []string{"news"}}, + "empty array": {`[]`, nil}, + "blank": {" ", nil}, + // A row that is not a JSON array never matched the LIKE predicate this + // replaced, so indexing it would add articles to sections that have + // never shown them. + "not json": {`News,Campus`, nil}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := normalizeCategoryValues(tc.raw) + if len(got) == 0 && len(tc.want) == 0 { + return + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("normalizeCategoryValues(%q) = %v, want %v", tc.raw, got, tc.want) + } + }) + } +} + +// A title longer than the column is skipped rather than truncated: a truncated +// key would compare equal to a section it does not belong to. +func TestNormalizeCategoryListSkipsOverlongTitles(t *testing.T) { + long := strings.Repeat("a", maxCategoryLength+1) + got := normalizeCategoryList([]string{"News", long}) + if !reflect.DeepEqual(got, []string{"news"}) { + t.Errorf("got %v, want just [news]", got) + } +} + +// The index stores what CategoryMatchValues asks for. If the two normalizations +// ever diverge, every section page silently empties. +func TestIndexedCategoriesMatchTheValuesQueriedFor(t *testing.T) { + withCategoryAliases(t, map[string][]string{"entertainment": {"Arts & Entertainment"}}) + + indexed := normalizeCategoryValues(`["Arts & Entertainment"]`) + wanted := CategoryMatchValues("entertainment") + + found := false + for _, have := range indexed { + for _, want := range wanted { + if have == want { + found = true + } + } + } + if !found { + t.Errorf("indexed %v matches none of the queried values %v", indexed, wanted) + } +} diff --git a/server/internal/database/article_search.go b/server/internal/database/article_search.go index 3c60869..11312f5 100644 --- a/server/internal/database/article_search.go +++ b/server/internal/database/article_search.go @@ -18,7 +18,11 @@ import ( // not soft-deleted. Archived rows were previously reachable here. const searchLiveArticlesClause = "`pub_date` IS NOT NULL AND `pub_date` <= UTC_TIMESTAMP() AND `archived_at` IS NULL" -const searchSelectColumns = "SELECT `id`, `title`, `slug`, `description`, `text`, `excerpt`, `tags`, `categories`, `pub_date`, `mod_date`, `priority`, `breaking_news`, `comment_status`, `photo_url`, `focus_keyword`, `meta_description`, `seo_title`, `creation_date`, `scheduled_pub_date`, `canonical_url`, `noindex`, `photo_alt` FROM `articles` " +// searchSelectColumns omits `text`, like every other listing read: search +// results are rendered as excerpts. The body is still matched and ranked on -- +// it just appears in WHERE and ORDER BY rather than in the SELECT list, which +// costs nothing to read. +var searchSelectColumns = "SELECT " + articleSelectList(false, "") + " FROM `articles` " // fulltextMinTokenSize mirrors innodb_ft_min_token_size. InnoDB never indexes // tokens shorter than this, and a required term (`+ab`) that the index cannot diff --git a/server/internal/database/http_models.go b/server/internal/database/http_models.go index b728185..e396c54 100644 --- a/server/internal/database/http_models.go +++ b/server/internal/database/http_models.go @@ -36,19 +36,135 @@ var ArticleSortByColumn = map[string]string{ var AuthorColumns = []string{"id", "display_name", "first_name", "last_name", "email", "login", "archived_at"} -// ArticleColumns, articleSelectColumnsQualified, searchSelectColumns -// (article_search.go) and the inline SELECT in handlers.ListArticles are all -// consumed by the one positional ScanArticle below. Adding a column means adding -// it to all four -- in the same place -- and to ScanArticle. -var ArticleColumns = []string{ - "id", "title", "slug", "description", "text", "excerpt", "tags", "categories", - "pub_date", "mod_date", "priority", "breaking_news", - "comment_status", "photo_url", - "focus_keyword", "meta_description", "seo_title", "creation_date", "scheduled_pub_date", - "canonical_url", "noindex", "photo_alt", +// articleRow is the landing zone for a scanned article: every column at its +// nullable database type, before toArticle turns it into the model. +// +// It exists so that the column list and the scan targets come from one place. +// They used to be four hand-maintained lists that a positional Scan had to +// agree with, which meant adding a column in the wrong position silently +// shifted every value after it into the wrong field. +type articleRow struct { + id int64 + title string + slug sql.NullString + description sql.NullString + text sql.NullString + excerpt sql.NullString + tags sql.NullString + categories sql.NullString + pubDate sql.NullTime + modDate sql.NullTime + priority sql.NullBool + breakingNews sql.NullBool + commentStatus sql.NullString + photoURL sql.NullString + focusKeyword sql.NullString + metaDescription sql.NullString + seoTitle sql.NullString + creationDate sql.NullTime + scheduledDate sql.NullTime + canonicalURL sql.NullString + noIndex sql.NullBool + photoAlt sql.NullString +} + +// articleColumn pairs a column name with the field it scans into, so a query's +// SELECT list and its Scan targets are generated from the same ordered slice and +// cannot drift out of step. +type articleColumn struct { + name string + target func(*articleRow) any +} + +// articleColumnSet is the full set, in the order every article query selects +// them. Adding a column here is the only edit needed: the lists below and the +// scanners all derive from it. +var articleColumnSet = []articleColumn{ + {"id", func(r *articleRow) any { return &r.id }}, + {"title", func(r *articleRow) any { return &r.title }}, + {"slug", func(r *articleRow) any { return &r.slug }}, + {"description", func(r *articleRow) any { return &r.description }}, + {"text", func(r *articleRow) any { return &r.text }}, + {"excerpt", func(r *articleRow) any { return &r.excerpt }}, + {"tags", func(r *articleRow) any { return &r.tags }}, + {"categories", func(r *articleRow) any { return &r.categories }}, + {"pub_date", func(r *articleRow) any { return &r.pubDate }}, + {"mod_date", func(r *articleRow) any { return &r.modDate }}, + {"priority", func(r *articleRow) any { return &r.priority }}, + {"breaking_news", func(r *articleRow) any { return &r.breakingNews }}, + {"comment_status", func(r *articleRow) any { return &r.commentStatus }}, + {"photo_url", func(r *articleRow) any { return &r.photoURL }}, + {"focus_keyword", func(r *articleRow) any { return &r.focusKeyword }}, + {"meta_description", func(r *articleRow) any { return &r.metaDescription }}, + {"seo_title", func(r *articleRow) any { return &r.seoTitle }}, + {"creation_date", func(r *articleRow) any { return &r.creationDate }}, + {"scheduled_pub_date", func(r *articleRow) any { return &r.scheduledDate }}, + {"canonical_url", func(r *articleRow) any { return &r.canonicalURL }}, + {"noindex", func(r *articleRow) any { return &r.noIndex }}, + {"photo_alt", func(r *articleRow) any { return &r.photoAlt }}, +} + +// summaryOmittedColumns are the columns a listing does not read. +// +// `text` is the whole article body, averaging ~5KB across the migrated corpus. +// A twenty-item listing was fetching around 95KB of it per request and using +// none: list responses carry an excerpt, which comes from `excerpt` (falling +// back to `description`), and is derived from the body only at write time. On +// the homepage, which builds six section blocks, the same waste was paid six +// times over. +var summaryOmittedColumns = map[string]bool{"text": true} + +func articleColumnNames(full bool) []string { + names := make([]string, 0, len(articleColumnSet)) + for _, col := range articleColumnSet { + if !full && summaryOmittedColumns[col.name] { + continue + } + names = append(names, col.name) + } + return names +} + +// articleSelectList renders the SELECT list. qualifier prefixes each column for +// queries that join (""=unqualified, "a"=`a`.`col`). +func articleSelectList(full bool, qualifier string) string { + prefix := "" + if qualifier != "" { + prefix = qualifier + "." + } + var b strings.Builder + for i, name := range articleColumnNames(full) { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(prefix + "`" + name + "`") + } + return b.String() +} + +func (r *articleRow) scanTargets(full bool) []any { + targets := make([]any, 0, len(articleColumnSet)) + for _, col := range articleColumnSet { + if !full && summaryOmittedColumns[col.name] { + continue + } + targets = append(targets, col.target(r)) + } + return targets } -const articleSelectColumnsQualified = "a.`id`, a.`title`, a.`slug`, a.`description`, a.`text`, a.`excerpt`, a.`tags`, a.`categories`, a.`pub_date`, a.`mod_date`, a.`priority`, a.`breaking_news`, a.`comment_status`, a.`photo_url`, a.`focus_keyword`, a.`meta_description`, a.`seo_title`, a.`creation_date`, a.`scheduled_pub_date`, a.`canonical_url`, a.`noindex`, a.`photo_alt`" +// ArticleColumns is the full column list, for the article detail endpoint -- +// the one read that actually renders the body. +var ArticleColumns = articleColumnNames(true) + +// ArticleSummaryColumns is the listing column list. Everything that produces +// models.ArticleListItem uses it. +var ArticleSummaryColumns = articleColumnNames(false) + +// ArticleSummarySelectList is ArticleSummaryColumns rendered as a quoted SELECT +// list, for the handlers that assemble their query as a string rather than +// through Select. +var ArticleSummarySelectList = articleSelectList(false, "") // Image/photo URLs are canonicalized upstream in the WordPress ETL (see // wordpress-etl Utils/MediaURL) so `photo_url` and inline body images are stored @@ -133,104 +249,98 @@ func ScanAuthorOverview(rows *sql.Rows) (models.AuthorOverview, error) { return a, nil } +// ScanArticle reads a row selected with ArticleColumns -- the full set, +// including the article body. func ScanArticle(rows *sql.Rows) (models.Article, error) { - var ( - a models.Article - slug sql.NullString - description sql.NullString - text sql.NullString - excerpt sql.NullString - tags sql.NullString - categories sql.NullString - pubDate sql.NullTime - priority sql.NullBool - breakingNews sql.NullBool - commentStatus sql.NullString - photoURL sql.NullString - modDate sql.NullTime - focusKeyword sql.NullString - metaDescription sql.NullString - seoTitle sql.NullString - creationDate sql.NullTime - scheduledDate sql.NullTime - canonicalURL sql.NullString - noIndex sql.NullBool - photoAlt sql.NullString - ) - err := rows.Scan( - &a.ID, &a.Title, &slug, &description, &text, &excerpt, &tags, &categories, - &pubDate, &modDate, &priority, &breakingNews, - &commentStatus, &photoURL, - &focusKeyword, &metaDescription, &seoTitle, &creationDate, &scheduledDate, - &canonicalURL, &noIndex, &photoAlt, - ) - if err != nil { + return scanArticleRow(rows, true) +} + +// ScanArticleSummary reads a row selected with ArticleSummaryColumns. The +// returned Article has an empty Content, which is correct for every caller that +// renders a listing: none of them read the body. +func ScanArticleSummary(rows *sql.Rows) (models.Article, error) { + return scanArticleRow(rows, false) +} + +func scanArticleRow(rows *sql.Rows, full bool) (models.Article, error) { + var row articleRow + if err := rows.Scan(row.scanTargets(full)...); err != nil { return models.Article{}, err } - if modDate.Valid { - t := modDate.Time + return row.toArticle(), nil +} + +// toArticle maps a scanned row onto the model. A column the query did not +// select is simply invalid here and leaves its field at the zero value, which is +// what makes a summary scan a strict subset of a full one rather than a +// different shape. +func (row articleRow) toArticle() models.Article { + a := models.Article{ID: row.id, Title: row.title} + + if row.modDate.Valid { + t := row.modDate.Time a.ModifiedAt = &t } - if canonicalURL.Valid { - a.CanonicalURL = strings.TrimSpace(canonicalURL.String) + if row.canonicalURL.Valid { + a.CanonicalURL = strings.TrimSpace(row.canonicalURL.String) } - if noIndex.Valid { - a.NoIndex = noIndex.Bool + if row.noIndex.Valid { + a.NoIndex = row.noIndex.Bool } - if focusKeyword.Valid { - a.FocusKeyword = focusKeyword.String + if row.focusKeyword.Valid { + a.FocusKeyword = row.focusKeyword.String } - if metaDescription.Valid { - a.MetaDescription = metaDescription.String + if row.metaDescription.Valid { + a.MetaDescription = row.metaDescription.String } - if seoTitle.Valid { - a.SEOTitle = seoTitle.String + if row.seoTitle.Valid { + a.SEOTitle = row.seoTitle.String } - if text.Valid { - a.Content = text.String + if row.text.Valid { + a.Content = row.text.String } - if excerpt.Valid { - a.Excerpt = excerpt.String - } else if description.Valid { - a.Excerpt = description.String + if row.excerpt.Valid { + a.Excerpt = row.excerpt.String + } else if row.description.Valid { + a.Excerpt = row.description.String } - if slug.Valid { - a.Slug = slug.String + if row.slug.Valid { + a.Slug = row.slug.String } - a.Tags = parseStringListField(tags) - a.Categories = parseStringListField(categories) - if creationDate.Valid { - t := creationDate.Time + a.Tags = parseStringListField(row.tags) + a.Categories = parseStringListField(row.categories) + if row.creationDate.Valid { + t := row.creationDate.Time a.CreatedAt = &t } - if pubDate.Valid { - t := pubDate.Time + if row.pubDate.Valid { + t := row.pubDate.Time a.PublishedAt = &t a.Status = models.ArticleStatusPublished - } else if scheduledDate.Valid { - t := scheduledDate.Time + } else if row.scheduledDate.Valid { + t := row.scheduledDate.Time a.PublishedAt = &t a.ScheduledAt = &t a.Status = models.ArticleStatusScheduled } else { a.Status = models.ArticleStatusDraft } - if priority.Valid { - a.IsFeatured = priority.Bool + if row.priority.Valid { + a.IsFeatured = row.priority.Bool } - if breakingNews.Valid { - a.BreakingNews = breakingNews.Bool + if row.breakingNews.Valid { + a.BreakingNews = row.breakingNews.Bool } - if commentStatus.Valid { - a.CommentStatus = normalizeCommentStatus(commentStatus.String) + if row.commentStatus.Valid { + a.CommentStatus = normalizeCommentStatus(row.commentStatus.String) } - if photoURL.Valid { - a.PhotoURL = photoURL.String + if row.photoURL.Valid { + a.PhotoURL = row.photoURL.String } - if photoAlt.Valid { - a.PhotoAlt = photoAlt.String + if row.photoAlt.Valid { + a.PhotoAlt = row.photoAlt.String } - return a, nil + return a } // NormalizeCanonicalURL trims a canonical-URL override and reports whether it is @@ -268,10 +378,16 @@ func parseStringListField(value sql.NullString) []string { return parsed } +// CollectArticles drains rows selected with ArticleSummaryColumns. +// +// It is the listing path, and there is deliberately no full-column counterpart: +// every caller that collects more than one article is building a list of +// excerpts, and the single read that needs the body -- the article detail +// endpoint -- scans its one row with ScanArticle directly. func CollectArticles(rows *sql.Rows) ([]models.Article, error) { var articles []models.Article for rows.Next() { - a, err := ScanArticle(rows) + a, err := ScanArticleSummary(rows) if err != nil { return nil, err } @@ -379,7 +495,7 @@ func GetRelatedArticlesBySlug(ctx context.Context, conn *sql.DB, slug string, k return nil, fmt.Errorf("k must be greater than 0") } - query := "SELECT " + articleSelectColumnsQualified + " " + + query := "SELECT " + articleSelectList(false, "a") + " " + "FROM articles AS src " + "JOIN article_embeddings AS src_vec ON src_vec.article_id = src.id " + "JOIN article_embeddings AS cand_vec ON cand_vec.article_id <> src.id " + diff --git a/server/internal/database/http_models_test.go b/server/internal/database/http_models_test.go index 6b7ff0f..89a009c 100644 --- a/server/internal/database/http_models_test.go +++ b/server/internal/database/http_models_test.go @@ -255,3 +255,60 @@ func TestDeriveExcerpt_KeepsEditorialBrackets(t *testing.T) { t.Errorf("deriveExcerpt = %q, want %q", got, want) } } + +// The listing column set must be the full set minus exactly the body. If a new +// column is added to articleColumnSet and quietly lands in summaryOmittedColumns +// too, listings stop returning it and the public site renders a blank field. +func TestArticleSummaryColumns_OmitOnlyTheBody(t *testing.T) { + full := map[string]bool{} + for _, name := range ArticleColumns { + full[name] = true + } + summary := map[string]bool{} + for _, name := range ArticleSummaryColumns { + summary[name] = true + } + + for name := range full { + if name == "text" { + continue + } + if !summary[name] { + t.Errorf("column %q is missing from ArticleSummaryColumns", name) + } + } + if summary["text"] { + t.Error("ArticleSummaryColumns still selects `text`; listings do not read the body") + } + if len(ArticleSummaryColumns) != len(ArticleColumns)-1 { + t.Errorf("ArticleSummaryColumns has %d columns, want %d", len(ArticleSummaryColumns), len(ArticleColumns)-1) + } +} + +// scanTargets is what makes the column list and the Scan positional agreement +// one fact rather than two. If they can disagree, every value after the +// mismatch lands in the wrong field. +func TestArticleScanTargets_MatchColumnCounts(t *testing.T) { + var row articleRow + if got, want := len(row.scanTargets(true)), len(ArticleColumns); got != want { + t.Errorf("full scan targets = %d, want %d", got, want) + } + if got, want := len(row.scanTargets(false)), len(ArticleSummaryColumns); got != want { + t.Errorf("summary scan targets = %d, want %d", got, want) + } +} + +func TestArticleSelectList_QualifiesForJoins(t *testing.T) { + if got := articleSelectList(false, "a"); !strings.HasPrefix(got, "a.`id`, a.`title`") { + t.Errorf("qualified select list = %q, want it to start with a.`id`, a.`title`", got) + } + if got := articleSelectList(false, ""); !strings.HasPrefix(got, "`id`, `title`") { + t.Errorf("unqualified select list = %q, want it to start with `id`, `title`", got) + } + if strings.Contains(articleSelectList(false, "a"), "`text`") { + t.Error("summary select list names `text`") + } + if !strings.Contains(articleSelectList(true, "a"), "`text`") { + t.Error("full select list is missing `text`") + } +} diff --git a/server/internal/database/schema/article_categories.sql b/server/internal/database/schema/article_categories.sql new file mode 100644 index 0000000..dd9e2e2 --- /dev/null +++ b/server/internal/database/schema/article_categories.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS article_categories ( + article_id BIGINT NOT NULL, + -- The category title as it appears in `articles`.`categories`, lowercased and + -- trimmed. 191 is the longest utf8mb4 VARCHAR that fits a legacy 767-byte + -- index prefix; the longest category in the corpus is under 30. + category VARCHAR(191) NOT NULL, + PRIMARY KEY (article_id, category), + -- The section-page lookup: given a handful of category titles, find the + -- articles. article_id rides along so the index covers the whole subquery. + KEY idx_article_categories_category (category, article_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 diff --git a/server/internal/database/taxonomy.go b/server/internal/database/taxonomy.go index f61e33d..04483e2 100644 --- a/server/internal/database/taxonomy.go +++ b/server/internal/database/taxonomy.go @@ -290,19 +290,9 @@ func categoryAliasesFor(slug string) []string { return categoryAliasBySlug[slug] } -// CategoryColumnExpr is the SQL expression every category match runs against. -// -// `articles`.`categories` is a JSON array of category titles, but it is written -// by two producers that escape it differently: the ETL emits a plain "&", while -// Go's encoding/json HTML-escapes it to a backslash-u escape (see FormatTags), -// so an article edited in the CMS stops matching the very section it is filed -// under. Folding that escape away here means callers only ever reason about one -// spelling. FormatTags no longer produces it, but rows written before that fix -// still carry it. -const CategoryColumnExpr = "REPLACE(LOWER(`categories`), '\\\\u0026', '&')" - -// CategoryMatchPatterns returns the CategoryColumnExpr LIKE patterns that -// identify articles filed under a taxonomy slug. +// CategoryMatchValues returns the category titles that identify articles filed +// under a taxonomy slug, normalized the same way article_categories stores +// them: lowercased and trimmed. // // WordPress category text does not match our slugs literally, so a slug stands // in for several spellings: "comics-puzzles" has to find "Comics & Puzzles". @@ -310,35 +300,40 @@ const CategoryColumnExpr = "REPLACE(LOWER(`categories`), '\\\\u0026', '&')" // article listing and the count rebuild call it, because when they disagreed a // section could list 2545 articles while reporting 8. // -// Every pattern is anchored on the JSON quotes around a member, so a slug -// matches a WHOLE category and never a fragment of a longer one. Unanchored -// patterns silently merged sibling taxonomies: `%puzzles%` matched the parent +// Every value is a WHOLE category title, compared for equality, so a slug never +// matches a fragment of a longer one. This used to be a LIKE pattern anchored on +// the JSON quotes around an array member, for the same reason: unanchored +// patterns silently merged sibling taxonomies -- `%puzzles%` matched the parent // title "Comics & Puzzles" and so pulled all 219 comics into the Puzzles // subsection, and `%men's basketball%` is a substring of "Women's Basketball", // which folded the women's team into the men's. Both read as plausible-but-wrong -// pages rather than as errors, so anchoring is load-bearing, not cosmetic. +// pages rather than as errors, so exactness is load-bearing, not cosmetic. An +// equality join gives it for free, and can use an index besides. // // Because the match is exact, a slug that is not the canonicalized category // string resolves to nothing on its own -- it needs an alias row. Those come // from the cache, so callers must have run RefreshCategoryAliases first; // EnsureTaxonomyTable does it at startup. -func CategoryMatchPatterns(slug string) []string { +// +// Ampersand escaping used to be folded in SQL here too: the ETL emits a plain +// "&" while Go's encoding/json HTML-escapes it to & (see FormatTags), so an +// article edited in the CMS stopped matching its own section. That fold is now +// implicit -- article_categories is built by JSON-decoding the column, which +// resolves the escape to the character it stands for. +func CategoryMatchValues(slug string) []string { normalized := strings.ToLower(strings.TrimSpace(slug)) if normalized == "" { return nil } - patterns := make([]string, 0, 4) + values := make([]string, 0, 4) add := func(value string) { - // The JSON quotes are what make the match exact; without them a - // pattern matches any category containing the phrase. - pattern := `%"` + value + `"%` - for _, existing := range patterns { - if existing == pattern { + for _, existing := range values { + if existing == value { return } } - patterns = append(patterns, pattern) + values = append(values, value) } add(normalized) @@ -353,7 +348,7 @@ func CategoryMatchPatterns(slug string) []string { for _, alias := range categoryAliasesFor(normalized) { add(strings.ToLower(strings.TrimSpace(alias))) } - return patterns + return values } // possessiveStems are the words a slug can only have lost an apostrophe from. @@ -438,19 +433,46 @@ func taxonomyMatchSlugs(ctx context.Context, conn *sql.DB) (map[string][]string, // TaxonomyCountCondition builds the WHERE fragment matching articles in any of // the given slugs, along with its arguments. +// +// It reads the article_categories index rather than the `categories` column. +// The predicate it replaced was a chain of `LOWER(categories) LIKE '%"news"%'` +// -- a leading wildcard, so no index could ever serve it, and every section page +// scanned the whole table to answer it (twice, since the listing pages with a +// COUNT(*) alongside). +// +// EXISTS rather than IN: callers negate this fragment (see +// ReportOrphanedArticles), and NOT IN against a subquery that can yield NULL +// evaluates to UNKNOWN and quietly returns nothing. NOT EXISTS has no such +// edge. +// +// The correlation name is the bare `articles` table, which every caller selects +// from unaliased. func TaxonomyCountCondition(slugs []string) (string, []any) { - var clauses []string - var args []any + var values []string + seen := make(map[string]bool) for _, slug := range slugs { - for _, pattern := range CategoryMatchPatterns(slug) { - clauses = append(clauses, CategoryColumnExpr+" LIKE ?") - args = append(args, pattern) + for _, value := range CategoryMatchValues(slug) { + if seen[value] { + continue + } + seen[value] = true + values = append(values, value) } } - if len(clauses) == 0 { + if len(values) == 0 { return "", nil } - return "(" + strings.Join(clauses, " OR ") + ")", args + + placeholders := make([]string, len(values)) + args := make([]any, len(values)) + for i, value := range values { + placeholders[i] = "?" + args[i] = value + } + + condition := "EXISTS (SELECT 1 FROM `article_categories` `ac` WHERE `ac`.`article_id` = `articles`.`id` " + + "AND `ac`.`category` IN (" + strings.Join(placeholders, ", ") + "))" + return condition, args } // countArticlesForSlugs counts the articles a taxonomy row matches, over the diff --git a/server/internal/database/taxonomy_integration_test.go b/server/internal/database/taxonomy_integration_test.go index 9561961..6acbb2f 100644 --- a/server/internal/database/taxonomy_integration_test.go +++ b/server/internal/database/taxonomy_integration_test.go @@ -91,11 +91,11 @@ func TestTaxonomyAliasSeedAndCacheRoundTrip(t *testing.T) { // defaultCategoryAliases rather than a literal list: the map grows every // time another orphaned category is filed, and a test that pins the // contents fails on the filing rather than on anything being broken. - patterns := CategoryMatchPatterns("entertainment") + values := CategoryMatchValues("entertainment") for _, alias := range defaultCategoryAliases["entertainment"] { - want := `%"` + strings.ToLower(alias) + `"%` - if !containsPattern(patterns, want) { - t.Errorf("entertainment patterns %v missing the seeded alias %s", patterns, want) + want := strings.ToLower(alias) + if !containsValue(values, want) { + t.Errorf("entertainment values %v missing the seeded alias %s", values, want) } } // The aliases must not be HTML-escaped in the column, or one holding an @@ -118,8 +118,8 @@ func TestTaxonomyAliasSeedAndCacheRoundTrip(t *testing.T) { } // A slug with no default keeps no aliases. - if got := CategoryMatchPatterns(unseededSlug); len(got) != 1 { - t.Errorf("%s patterns = %v, want just its own", unseededSlug, got) + if got := CategoryMatchValues(unseededSlug); len(got) != 1 { + t.Errorf("%s values = %v, want just its own", unseededSlug, got) } } @@ -169,8 +169,8 @@ func TestTaxonomyAliasRefreshPicksUpWrites(t *testing.T) { if err := RefreshCategoryAliases(ctx, conn); err != nil { t.Fatalf("refresh: %v", err) } - if got := CategoryMatchPatterns("movies"); len(got) != 1 { - t.Fatalf("movies patterns = %v, want just its own", got) + if got := CategoryMatchValues("movies"); len(got) != 1 { + t.Fatalf("movies values = %v, want just its own", got) } if _, err := conn.ExecContext(ctx, @@ -180,20 +180,20 @@ func TestTaxonomyAliasRefreshPicksUpWrites(t *testing.T) { } // Still stale until the cache is told, which is why every write path calls // RefreshCategoryAliases. - if got := CategoryMatchPatterns("movies"); len(got) != 1 { - t.Errorf("patterns changed before a refresh: %v", got) + if got := CategoryMatchValues("movies"); len(got) != 1 { + t.Errorf("values changed before a refresh: %v", got) } if err := RefreshCategoryAliases(ctx, conn); err != nil { t.Fatalf("refresh after write: %v", err) } - if got := CategoryMatchPatterns("movies"); !containsPattern(got, `%"movies i've seen"%`) { - t.Errorf("movies patterns %v missing the written alias", got) + if got := CategoryMatchValues("movies"); !containsValue(got, "movies i've seen") { + t.Errorf("movies values %v missing the written alias", got) } } -func containsPattern(patterns []string, want string) bool { - for _, pattern := range patterns { - if pattern == want { +func containsValue(values []string, want string) bool { + for _, value := range values { + if value == want { return true } } diff --git a/server/internal/database/taxonomy_test.go b/server/internal/database/taxonomy_test.go index 3765cc8..ba8e970 100644 --- a/server/internal/database/taxonomy_test.go +++ b/server/internal/database/taxonomy_test.go @@ -7,49 +7,49 @@ import ( func assertPatterns(t *testing.T, slug string, want []string) { t.Helper() - got := CategoryMatchPatterns(slug) + got := CategoryMatchValues(slug) if len(got) != len(want) { - t.Fatalf("%s: got %d patterns %v, want %d %v", slug, len(got), got, len(want), want) + t.Fatalf("%s: got %d values %v, want %d %v", slug, len(got), got, len(want), want) } for i := range want { if got[i] != want[i] { - t.Errorf("%s: pattern %d = %q, want %q", slug, i, got[i], want[i]) + t.Errorf("%s: value %d = %q, want %q", slug, i, got[i], want[i]) } } } -func TestCategoryMatchPatternsExpandsDashedSlugs(t *testing.T) { +func TestCategoryMatchValuesExpandsDashedSlugs(t *testing.T) { // A dashed slug has to find the WordPress spelling, which uses spaces and // often an ampersand: "comics-puzzles" must match "Comics & Puzzles". assertPatterns(t, "comics-puzzles", []string{ - `%"comics-puzzles"%`, `%"comics puzzles"%`, `%"comics & puzzles"%`, + "comics-puzzles", "comics puzzles", "comics & puzzles", }) } -func TestCategoryMatchPatternsRestoresPossessiveApostrophe(t *testing.T) { +func TestCategoryMatchValuesRestoresPossessiveApostrophe(t *testing.T) { // The category text is "Men's Basketball"; no slug can carry the // apostrophe, so without this pattern the subsection matched nothing. assertPatterns(t, "mens-basketball", []string{ - `%"mens-basketball"%`, `%"mens basketball"%`, `%"mens & basketball"%`, `%"men's basketball"%`, + "mens-basketball", "mens basketball", "mens & basketball", "men's basketball", }) } -func TestCategoryMatchPatternsLeavesPluralsAlone(t *testing.T) { +func TestCategoryMatchValuesLeavesPluralsAlone(t *testing.T) { // "philly-sports" is a plural, not a possessive: guessing "philly sport's" // would add a pattern that matches nothing. - for _, pattern := range CategoryMatchPatterns("philly-sports") { - if strings.Contains(pattern, "'") { - t.Fatalf("unexpected possessive pattern %q", pattern) + for _, value := range CategoryMatchValues("philly-sports") { + if strings.Contains(value, "'") { + t.Fatalf("unexpected possessive value %q", value) } } } -func TestCategoryMatchPatternsSingleWordSlug(t *testing.T) { - assertPatterns(t, "comics", []string{`%"comics"%`}) +func TestCategoryMatchValuesSingleWordSlug(t *testing.T) { + assertPatterns(t, "comics", []string{"comics"}) } -func TestCategoryMatchPatternsEmpty(t *testing.T) { - if got := CategoryMatchPatterns(" "); got != nil { +func TestCategoryMatchValuesEmpty(t *testing.T) { + if got := CategoryMatchValues(" "); got != nil { t.Fatalf("got %v, want nil for a blank slug", got) } } @@ -75,14 +75,17 @@ func withCategoryAliases(t *testing.T, aliases map[string][]string) { // editor or a copy-paste, which would make the tests below silently vacuous. var escapedAmp = `\` + "u0026" -// matchesCategories reports whether any pattern for slug would match a -// `categories` JSON array, mirroring what CategoryColumnExpr + LIKE does in -// SQL: lowercase the column, unescape the ampersand, then substring-match. +// matchesCategories reports whether slug matches a `categories` JSON array. It +// composes the two halves of the real lookup -- the rows RebuildArticleCategories +// would index for the article, and the values TaxonomyCountCondition would ask +// for -- so these assertions describe what the database will actually answer. func matchesCategories(slug, categoriesJSON string) bool { - column := strings.ReplaceAll(strings.ToLower(categoriesJSON), escapedAmp, "&") - for _, pattern := range CategoryMatchPatterns(slug) { - if strings.Contains(column, strings.Trim(pattern, "%")) { - return true + indexed := normalizeCategoryValues(categoriesJSON) + for _, want := range CategoryMatchValues(slug) { + for _, have := range indexed { + if have == want { + return true + } } } return false @@ -165,24 +168,22 @@ func TestCategoryMatchToleratesEscapedAmpersand(t *testing.T) { } } -func TestTaxonomyCountConditionORsEverySlug(t *testing.T) { +func TestTaxonomyCountConditionMatchesEverySlug(t *testing.T) { // A section matches its own slug OR any child's, so a container section // with no category of its own still resolves to its subsections' articles. + // Every spelling lands in one IN list, which is that OR. condition, args := TaxonomyCountCondition([]string{"special-editions", "welcome-week"}) if condition == "" { t.Fatal("expected a condition") } - if strings.Contains(condition, " AND ") { - t.Errorf("condition must OR its slugs, not AND them: %s", condition) - } - // 3 patterns per dashed slug, two slugs. - if got := strings.Count(condition, "LIKE ?"); got != 6 { + // 3 spellings per dashed slug, two slugs. + if got := strings.Count(condition, "?"); got != 6 { t.Errorf("got %d placeholders, want 6: %s", got, condition) } if len(args) != 6 { t.Errorf("got %d args, want 6: %v", len(args), args) } - for _, want := range []string{`%"welcome week"%`, `%"special editions"%`} { + for _, want := range []string{"welcome week", "special editions"} { found := false for _, arg := range args { if arg == want { @@ -190,16 +191,31 @@ func TestTaxonomyCountConditionORsEverySlug(t *testing.T) { } } if !found { - t.Errorf("missing pattern %q in %v", want, args) + t.Errorf("missing category %q in %v", want, args) } } } -func TestTaxonomyCountConditionMatchesOnTheNormalizedColumn(t *testing.T) { - // The escape-folding has to be in the SQL, not just in the patterns. +// The predicate has to be correlated on the outer articles row, and it has to +// be EXISTS: callers negate it, and NOT IN over a subquery that can produce +// NULL evaluates to UNKNOWN and silently matches nothing. +func TestTaxonomyCountConditionIsCorrelatedExists(t *testing.T) { condition, _ := TaxonomyCountCondition([]string{"comics"}) - if !strings.Contains(condition, CategoryColumnExpr) { - t.Errorf("condition must match on %s, got %s", CategoryColumnExpr, condition) + if !strings.HasPrefix(condition, "EXISTS (") { + t.Errorf("condition must be an EXISTS, got %s", condition) + } + if !strings.Contains(condition, "`ac`.`article_id` = `articles`.`id`") { + t.Errorf("condition must correlate on the outer articles row, got %s", condition) + } +} + +// Slugs resolving to the same spelling must not each contribute a placeholder; +// a section and its identically-named subsection would otherwise double the +// argument list on every section page. +func TestTaxonomyCountConditionDeduplicatesValues(t *testing.T) { + _, args := TaxonomyCountCondition([]string{"comics", "comics"}) + if len(args) != 1 { + t.Errorf("got %d args, want 1: %v", len(args), args) } } @@ -281,11 +297,11 @@ func TestCategoryAliasesAreCaseInsensitiveOnTheSlug(t *testing.T) { } } -func TestCategoryMatchPatternsDeduplicatesAliases(t *testing.T) { +func TestCategoryMatchValuesDeduplicatesAliases(t *testing.T) { // An alias that merely restates a derived pattern must not double the // placeholders in every query that uses the slug. withCategoryAliases(t, map[string][]string{"comics": {"Comics"}}) - assertPatterns(t, "comics", []string{`%"comics"%`}) + assertPatterns(t, "comics", []string{"comics"}) } func TestDefaultCategoryAliasesCoverTheKnownMismatches(t *testing.T) { diff --git a/server/internal/database/users.go b/server/internal/database/users.go index c4645f4..8550378 100644 --- a/server/internal/database/users.go +++ b/server/internal/database/users.go @@ -45,6 +45,56 @@ func EnsureArticlesPublishedIndex(ctx context.Context, conn *sql.DB) error { return err } +// EnsureArticlesSlugIndex indexes the column every article is addressed by: +// the detail endpoint, the comment thread, the permalink check, the +// featured-article write. Without it each of those is a full scan of the +// migrated corpus -- ten thousand rows read to return one. +// +// It is a PREFIX index, and that detail is the whole design. The ETL ships +// `slug` as LONGTEXT, which cannot be indexed whole; the obvious fix is to +// narrow the column to VARCHAR first. Do not. Retyping a column rewrites the +// table, and rewriting `articles` means re-tokenizing 44MB of article bodies +// into the two FULLTEXT indexes -- measured at over six minutes on a corpus +// this size, holding a lock the newsroom's writes would queue behind, and on a +// blue/green deploy the container doing it is not even the one serving. Adding +// a secondary index is an in-place operation that does none of that. +// +// 191 characters is a prefix no slug in the corpus reaches (the longest is 154) +// and stays under the 767-byte limit older row formats impose, so it is exact +// in practice and safe on any table layout. Even where it were not, a prefix +// index only narrows the candidates -- InnoDB rechecks the full value -- so +// this can cost an extra row read but can never return a wrong article. +// +// The index is deliberately not UNIQUE. Uniqueness is a property of the data, +// and the corpus belongs to the ETL, not the CMS: production already carries +// one duplicated slug from an archived pair, which a UNIQUE index would turn +// into a CMS that will not start. +func EnsureArticlesSlugIndex(ctx context.Context, conn *sql.DB) error { + _, err := conn.ExecContext(ctx, ` + ALTER TABLE articles + ADD INDEX IF NOT EXISTS idx_articles_slug (`+"`slug`"+`(191)) + `) + return err +} + +// EnsureArticleAuthorsIndex indexes the join column that every listing reads. +// +// LoadAuthorsByArticleIDs resolves a page of articles' bylines with one +// `WHERE articles_id IN (...)`, but the ETL creates `articles_authors` with +// only a primary key on its own id, so that lookup scanned the entire join +// table on every listing request -- including each of the homepage's six +// section blocks. author_id rides along so the index covers the join. +// +// Cheap and safe to run at boot, unlike anything touching `articles`: the table +// is a few hundred kilobytes and carries no FULLTEXT index to rebuild. +func EnsureArticleAuthorsIndex(ctx context.Context, conn *sql.DB) error { + _, err := conn.ExecContext(ctx, ` + ALTER TABLE articles_authors + ADD INDEX IF NOT EXISTS idx_articles_authors_article (`+"`articles_id`, `author_id`"+`) + `) + return err +} + // EnsureArticlesSearchIndex adds the FULLTEXT indexes public search ranks on. // // Two indexes, not one: the combined index answers "does this article match at diff --git a/server/internal/handlers/article_default_order_integration_test.go b/server/internal/handlers/article_default_order_integration_test.go index f83dd83..a204ec7 100644 --- a/server/internal/handlers/article_default_order_integration_test.go +++ b/server/internal/handlers/article_default_order_integration_test.go @@ -33,9 +33,13 @@ func seedDefaultOrderArticles(t *testing.T, conn *sql.DB) { {10020, "an-archive-story", "2011-04-08 15:04:51"}, } for _, row := range rows { + // `categories` is a JSON array, which is what both producers write -- + // the ETL json.dumps its list, and the CMS goes through FormatTags. The + // bare string this fixture used before matched no shape in the corpus + // and quietly exempted itself from the category index. if _, err := conn.ExecContext(ctx, "INSERT INTO articles (id, title, slug, `text`, authors, categories, pub_date, creation_date) VALUES (?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())", - row.id, row.slug, row.slug, "Body", `["Rui Zhao"]`, "News", row.pub, + row.id, row.slug, row.slug, "Body", `["Rui Zhao"]`, `["News"]`, row.pub, ); err != nil { t.Fatalf("seed %s: %v", row.slug, err) } diff --git a/server/internal/handlers/article_patch_integration_test.go b/server/internal/handlers/article_patch_integration_test.go index 2adbbcc..1f5282a 100644 --- a/server/internal/handlers/article_patch_integration_test.go +++ b/server/internal/handlers/article_patch_integration_test.go @@ -107,6 +107,14 @@ func articlePatchTestDB(t *testing.T) *sql.DB { if err := db.EnsureTaxonomyTable(ctx, conn); err != nil { t.Fatalf("ensure taxonomy table: %v", err) } + // The write handlers keep this index in step with `articles`.`categories`, + // so every save touches it. + if _, err := conn.ExecContext(ctx, "DROP TABLE IF EXISTS article_categories"); err != nil { + t.Fatalf("drop article_categories: %v", err) + } + if err := db.EnsureArticleCategoriesTable(ctx, conn); err != nil { + t.Fatalf("ensure article_categories: %v", err) + } return conn } diff --git a/server/internal/handlers/featured_article_integration_test.go b/server/internal/handlers/featured_article_integration_test.go index f9342a4..d73ae40 100644 --- a/server/internal/handlers/featured_article_integration_test.go +++ b/server/internal/handlers/featured_article_integration_test.go @@ -147,6 +147,10 @@ func TestFeaturedArticleHTTP_HomepageLeadsWithTheFeaturedArticle(t *testing.T) { ); err != nil { t.Fatalf("seed articles: %v", err) } + // The homepage blocks are section-filtered, which reads the derived + // category index; these rows were inserted straight into `articles`, so + // nothing has indexed them yet. + indexArticleCategories(t, conn) homepageNewsSlugs := func() []string { rec := httptest.NewRecorder() diff --git a/server/internal/handlers/handlers.go b/server/internal/handlers/handlers.go index 9b91a04..26f8915 100644 --- a/server/internal/handlers/handlers.go +++ b/server/internal/handlers/handlers.go @@ -1227,7 +1227,7 @@ type ArticleParams struct { func queryArticles(r *http.Request, conn *sql.DB, params ArticleParams, limit, offset int) (*sql.Rows, error) { q := r.URL.Query() conditions, args := articleQueryFilters(r, params) - query := "SELECT `id`, `title`, `slug`, `description`, `text`, `excerpt`, `tags`, `categories`, `pub_date`, `mod_date`, `priority`, `breaking_news`, `comment_status`, `photo_url`, `focus_keyword`, `meta_description`, `seo_title`, `creation_date`, `scheduled_pub_date`, `canonical_url`, `noindex`, `photo_alt` FROM `articles`" + query := "SELECT " + db.ArticleSummarySelectList + " FROM `articles`" if len(conditions) > 0 { query += " WHERE " + strings.Join(conditions, " AND ") } @@ -1316,6 +1316,15 @@ func articleQueryFilters(r *http.Request, params ArticleParams) ([]string, []any // A brand-new CMS draft looks exactly like one of those until an author or a // category is attached, so editors keep unpublished rows regardless — without // the exemption a draft is invisible in the CMS listing until it is filed. + // + // Kept as a predicate over the columns rather than over the derived + // article_categories / articles_authors indexes. Rewriting it as two EXISTS + // was measured: it saves 3.8ms on the paging COUNT and costs 0.1ms on the + // listing itself, and in exchange the definition of "filed" would move from + // the columns to two indexes that agree with them by data rather than by + // construction. Four milliseconds does not buy that -- the failure mode is + // articles silently vanishing from every listing while still resolving by + // direct link. artifactFilter := "((TRIM(COALESCE(`authors`, '')) <> '' AND TRIM(`authors`) <> '[]') OR (TRIM(COALESCE(`categories`, '')) <> '' AND TRIM(`categories`) <> '[]'))" if isEditor { artifactFilter = "(" + artifactFilter + " OR `pub_date` IS NULL)" @@ -2136,6 +2145,10 @@ func PostArticles(conn *sql.DB) http.HandlerFunc { writeError(w, http.StatusInternalServerError, err.Error()) return } + if err := db.ReplaceArticleCategories(r.Context(), conn, articleID, body.Categories); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } if err := incrementTaxonomyArticleCounts(r.Context(), conn, body.Categories); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -2228,6 +2241,15 @@ func PutArticle(conn *sql.DB) http.HandlerFunc { writeError(w, http.StatusInternalServerError, err.Error()) return } + // Keyed on the new slug, because the UPDATE above may have renamed it. + // Unconditional, unlike the taxonomy counts: the index describes where + // the article is filed, which is true of archived rows too -- they are + // excluded by the listing's own archived_at predicate, not by being + // missing from here. + if err := db.ReplaceArticleCategoriesBySlug(r.Context(), conn, body.Slug, body.Categories); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } if isActiveArticle { if err := reconcileTaxonomyArticleCounts(r.Context(), conn, oldCategories, body.Categories); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) @@ -2495,6 +2517,13 @@ func PatchArticle(conn *sql.DB) http.HandlerFunc { return } } + // nextCategories is oldCategories unless this patch carried a categories + // field, so an unrelated patch rewrites the index to what it already + // held rather than clearing it. + if err := db.ReplaceArticleCategoriesBySlug(r.Context(), conn, targetSlug, nextCategories); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } if isActiveArticle { if err := reconcileTaxonomyArticleCounts(r.Context(), conn, oldCategories, nextCategories); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) diff --git a/server/internal/handlers/handlers_test.go b/server/internal/handlers/handlers_test.go index 77a98c2..cb190db 100644 --- a/server/internal/handlers/handlers_test.go +++ b/server/internal/handlers/handlers_test.go @@ -118,13 +118,13 @@ func TestAppendCategorySlugCondition(t *testing.T) { t.Fatalf("expected 3 args, got %d", len(args)) } - // Patterns are anchored on the JSON quotes so a slug matches a whole - // category, never a fragment of a longer one -- see - // db.CategoryMatchPatterns. + // Whole category titles, compared for equality against the + // article_categories index -- so a slug matches a whole category and never + // a fragment of a longer one. See db.CategoryMatchValues. wantArgs := []string{ - `%"comics-puzzles"%`, - `%"comics puzzles"%`, - `%"comics & puzzles"%`, + "comics-puzzles", + "comics puzzles", + "comics & puzzles", } for i, want := range wantArgs { got, ok := args[i].(string) diff --git a/server/internal/handlers/taxonomy_integration_test.go b/server/internal/handlers/taxonomy_integration_test.go index 5856cff..834cb6b 100644 --- a/server/internal/handlers/taxonomy_integration_test.go +++ b/server/internal/handlers/taxonomy_integration_test.go @@ -72,6 +72,16 @@ func taxonomyHTTPTestDB(t *testing.T) *sql.DB { t.Fatalf("create articles: %v", err) } t.Cleanup(func() { _, _ = conn.ExecContext(context.Background(), "DROP TABLE IF EXISTS articles") }) + + // Section matching reads this index rather than the categories column, so a + // harness without it answers every count with an error. + if _, err := conn.ExecContext(ctx, "DROP TABLE IF EXISTS article_categories"); err != nil { + t.Fatalf("drop article_categories: %v", err) + } + if err := db.EnsureArticleCategoriesTable(ctx, conn); err != nil { + t.Fatalf("ensure article_categories: %v", err) + } + t.Cleanup(func() { _, _ = conn.ExecContext(context.Background(), "DROP TABLE IF EXISTS article_categories") }) return conn } @@ -146,8 +156,8 @@ func TestTaxonomyHTTPAliasFixesAnEmptySection(t *testing.T) { } // Nothing matches "Food" yet, because the articles say "Restaurant Reviews". - if got := db.CategoryMatchPatterns("food"); len(got) != 1 { - t.Fatalf("patterns = %v, want only the slug's own", got) + if got := db.CategoryMatchValues("food"); len(got) != 1 { + t.Fatalf("values = %v, want only the slug's own", got) } // The editor saves the real category name. This is the exact payload the @@ -164,16 +174,16 @@ func TestTaxonomyHTTPAliasFixesAnEmptySection(t *testing.T) { // The matcher must see it immediately -- no restart. This is what the // cache refresh on write buys. - patterns := db.CategoryMatchPatterns("food") - for _, want := range []string{`%"restaurant reviews"%`, `%"beer reviews"%`} { + values := db.CategoryMatchValues("food") + for _, want := range []string{"restaurant reviews", "beer reviews"} { found := false - for _, pattern := range patterns { - if pattern == want { + for _, value := range values { + if value == want { found = true } } if !found { - t.Errorf("patterns %v missing %q right after the save", patterns, want) + t.Errorf("values %v missing %q right after the save", values, want) } } @@ -243,8 +253,8 @@ func TestTaxonomyHTTPExplicitEmptyClearsAliases(t *testing.T) { if len(item.CategoryAliases) != 0 { t.Errorf("aliases = %v, want cleared", item.CategoryAliases) } - if got := db.CategoryMatchPatterns("food"); len(got) != 1 { - t.Errorf("patterns = %v, want the alias gone from matching too", got) + if got := db.CategoryMatchValues("food"); len(got) != 1 { + t.Errorf("values = %v, want the alias gone from matching too", got) } } @@ -309,6 +319,7 @@ func TestTaxonomyHTTPDeleteRefusesWhenArticlesExist(t *testing.T) { ); err != nil { t.Fatalf("insert article: %v", err) } + indexArticleCategories(t, conn) var storedCount int64 if err := conn.QueryRowContext(ctx, @@ -372,6 +383,7 @@ func TestTaxonomyHTTPAliasSaveUpdatesTheDisplayedCount(t *testing.T) { ); err != nil { t.Fatalf("insert articles: %v", err) } + indexArticleCategories(t, conn) if code := taxonomyRequest(t, PostTaxonomy(conn), http.MethodPost, "/v1/taxonomy", map[string]any{ "type": "section", @@ -416,6 +428,7 @@ func TestTaxonomyHTTPSubsectionSaveRecountsItsParent(t *testing.T) { ); err != nil { t.Fatalf("insert articles: %v", err) } + indexArticleCategories(t, conn) for _, body := range []map[string]any{ {"type": "section", "slug": "food", "canonical_title": "Food"}, @@ -468,6 +481,7 @@ func TestTaxonomyHTTPDeleteRecountsTheParent(t *testing.T) { ); err != nil { t.Fatalf("insert article: %v", err) } + indexArticleCategories(t, conn) if _, err := conn.ExecContext(ctx, "UPDATE site_taxonomy SET article_count = 99 WHERE slug = 'food'", ); err != nil { @@ -482,3 +496,13 @@ func TestTaxonomyHTTPDeleteRecountsTheParent(t *testing.T) { t.Errorf("parent count after deleting a child = %d, want 1 -- the parent was not recounted", got) } } + +// indexArticleCategories rebuilds the derived category index, standing in for +// the startup rebuild. These tests seed `articles` with raw INSERTs rather than +// through the write handlers, so nothing else would populate it. +func indexArticleCategories(t *testing.T, conn *sql.DB) { + t.Helper() + if err := db.RebuildArticleCategories(context.Background(), conn); err != nil { + t.Fatalf("rebuild article categories: %v", err) + } +} diff --git a/server/internal/middleware/compression.go b/server/internal/middleware/compression.go new file mode 100644 index 0000000..5868575 --- /dev/null +++ b/server/internal/middleware/compression.go @@ -0,0 +1,203 @@ +package middleware + +import ( + "compress/gzip" + "net/http" + "strings" + "sync" +) + +// gzipWriterPool reuses the compressor state across requests. A gzip.Writer +// carries a 32KB window plus its Huffman tables, so allocating one per response +// is the kind of per-request garbage that shows up as GC pressure long before it +// shows up as latency. +var gzipWriterPool = sync.Pool{ + New: func() any { + // BestSpeed rather than the default: article JSON is highly redundant + // (repeated keys, HTML markup) and already compresses ~5-8x at level 1. + // The extra CPU of the default level buys single-digit percent on this + // payload shape and is paid on every request. + w, _ := gzip.NewWriterLevel(nil, gzip.BestSpeed) + return w + }, +} + +// compressibleTypes are the media types worth compressing. Everything else -- +// JPEG and PNG from the media library above all -- is already compressed, and +// running it through gzip spends CPU to make the response marginally larger. +var compressibleTypes = []string{ + "application/json", + "application/javascript", + "application/xml", + "image/svg+xml", + "text/", +} + +func compressible(contentType string) bool { + // Content-Type carries parameters ("application/json; charset=utf-8"), so + // match on the prefix before them. + mediaType := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) + for _, candidate := range compressibleTypes { + if strings.HasPrefix(mediaType, candidate) { + return true + } + } + return false +} + +// minCompressSize is the payload below which gzip is not worth it. The gzip +// header and trailer alone are 18 bytes, and a small JSON error body routinely +// comes out larger compressed than raw. +const minCompressSize = 512 + +// gzipResponseWriter defers the decision to compress until the first Write, +// because that is the earliest point at which Content-Type is known -- handlers +// set it just before writing the body, and some let net/http sniff it. +type gzipResponseWriter struct { + http.ResponseWriter + + gz *gzip.Writer + + // buf holds the first writes until there is either enough of the body to + // judge it worth compressing or the handler is done. Without it a response + // shorter than minCompressSize could not be passed through uncompressed, + // since the headers would already be on the wire. + buf []byte + + decided bool + compress bool + status int + wroteHead bool +} + +func (g *gzipResponseWriter) WriteHeader(code int) { + if g.wroteHead { + return + } + g.status = code + // Deliberately not forwarded yet: the choice to compress rewrites headers, + // and that has to happen before the status line goes out. flush() sends it. + g.wroteHead = true +} + +func (g *gzipResponseWriter) Write(b []byte) (int, error) { + if !g.wroteHead { + g.WriteHeader(http.StatusOK) + } + if g.decided { + if g.compress { + return g.gz.Write(b) + } + return g.ResponseWriter.Write(b) + } + + g.buf = append(g.buf, b...) + if len(g.buf) < minCompressSize { + // Not enough body yet to know which way this goes. + return len(b), nil + } + if err := g.decide(true); err != nil { + return 0, err + } + return len(b), nil +} + +// decide commits to compressing or not, emits the headers and drains the +// buffer. large reports whether the body has already exceeded minCompressSize; +// a short body that ends at flush time is never compressed. +func (g *gzipResponseWriter) decide(large bool) error { + g.decided = true + + header := g.Header() + // A handler that encoded the body itself (or explicitly opted out with an + // identity encoding) owns the wire format; never double-encode it. + alreadyEncoded := header.Get("Content-Encoding") != "" + g.compress = large && !alreadyEncoded && compressible(header.Get("Content-Type")) + + if g.compress { + header.Set("Content-Encoding", "gzip") + // Content-Length describes the uncompressed body and is now wrong. + // Leaving it makes the client truncate the response at that many bytes. + header.Del("Content-Length") + g.ResponseWriter.WriteHeader(g.status) + + g.gz = gzipWriterPool.Get().(*gzip.Writer) + g.gz.Reset(g.ResponseWriter) + _, err := g.gz.Write(g.buf) + g.buf = nil + return err + } + + g.ResponseWriter.WriteHeader(g.status) + if len(g.buf) > 0 { + _, err := g.ResponseWriter.Write(g.buf) + g.buf = nil + return err + } + return nil +} + +// flush ends the response: it settles any still-undecided short body and closes +// the compressor so the gzip trailer is written. +func (g *gzipResponseWriter) flush() { + if !g.decided { + if !g.wroteHead { + // A handler that wrote nothing at all -- 204s, and the HEAD-like + // paths. Fall through to the standard 200 net/http would send. + g.status = http.StatusOK + } + _ = g.decide(false) + } + if g.gz != nil { + _ = g.gz.Close() + gzipWriterPool.Put(g.gz) + g.gz = nil + } +} + +// Flush forwards explicit flushes, so a streaming handler is not silently +// buffered forever by this middleware. +func (g *gzipResponseWriter) Flush() { + if !g.decided { + // A handler that flushes is streaming: it may never reach + // minCompressSize, and holding its bytes back would stall the client. + _ = g.decide(len(g.buf) >= minCompressSize) + } + if g.gz != nil { + _ = g.gz.Flush() + } + if f, ok := g.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Compression gzips responses for clients that accept it. +// +// Article JSON is the reason this exists: a listing page is tens of kilobytes of +// highly redundant text, and the CMS previously shipped all of it raw, both to +// the editor SPA and through Cloudflare to the public site. +// +// Placement matters. This must sit *outside* Recovery, so that the 500 body +// Recovery writes after a panic goes through the same encoder as everything +// else -- inside it, the gzip stream would be closed during unwinding and the +// plain-text error would be appended to a finished stream, producing a response +// no client can decode. The consequence is that Logging and Metrics, which are +// outside this, keep counting uncompressed bytes; that is the same number they +// reported before compression existed, so the dashboards stay comparable. +func Compression(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Vary goes on unconditionally, including for clients that did not ask + // for gzip: caches keyed on the URL alone would otherwise serve a + // compressed body to a client that cannot read it. + w.Header().Add("Vary", "Accept-Encoding") + + if !strings.Contains(strings.ToLower(r.Header.Get("Accept-Encoding")), "gzip") { + next.ServeHTTP(w, r) + return + } + + gw := &gzipResponseWriter{ResponseWriter: w, status: http.StatusOK} + defer gw.flush() + next.ServeHTTP(gw, r) + }) +} diff --git a/server/internal/middleware/compression_test.go b/server/internal/middleware/compression_test.go new file mode 100644 index 0000000..c54de27 --- /dev/null +++ b/server/internal/middleware/compression_test.go @@ -0,0 +1,180 @@ +package middleware + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// jsonBody is comfortably over minCompressSize and as redundant as real article +// JSON, so a test that asserts "smaller than the original" is not asserting a +// coin flip. +func jsonBody(n int) string { + return `{"articles":[` + strings.Repeat(`{"title":"Dragons win again","excerpt":"The Drexel Dragons"},`, n) + `]}` +} + +func handlerWriting(contentType, body string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", contentType) + _, _ = io.WriteString(w, body) + }) +} + +func serve(t *testing.T, h http.Handler, acceptEncoding string) *http.Response { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/v1/articles", nil) + if acceptEncoding != "" { + req.Header.Set("Accept-Encoding", acceptEncoding) + } + rec := httptest.NewRecorder() + Compression(h).ServeHTTP(rec, req) + return rec.Result() +} + +func TestCompressionGzipsJSONForAcceptingClients(t *testing.T) { + body := jsonBody(50) + res := serve(t, handlerWriting("application/json", body), "gzip, deflate, br") + + if got := res.Header.Get("Content-Encoding"); got != "gzip" { + t.Fatalf("Content-Encoding = %q, want gzip", got) + } + if got := res.Header.Get("Vary"); !strings.Contains(got, "Accept-Encoding") { + t.Errorf("Vary = %q, want it to contain Accept-Encoding", got) + } + + raw, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if len(raw) >= len(body) { + t.Errorf("compressed body is %d bytes, not smaller than the %d-byte original", len(raw), len(body)) + } + + zr, err := gzip.NewReader(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("open gzip reader: %v", err) + } + decoded, err := io.ReadAll(zr) + if err != nil { + t.Fatalf("decompress: %v", err) + } + if string(decoded) != body { + t.Errorf("round-tripped body does not match the original") + } +} + +func TestCompressionSkipsClientsThatDidNotAskForIt(t *testing.T) { + body := jsonBody(50) + res := serve(t, handlerWriting("application/json", body), "") + + if got := res.Header.Get("Content-Encoding"); got != "" { + t.Fatalf("Content-Encoding = %q, want empty", got) + } + raw, _ := io.ReadAll(res.Body) + if string(raw) != body { + t.Errorf("body was altered for a client that did not accept gzip") + } + // The header still has to be there, or a shared cache keyed on the URL + // alone will hand this identity response to a gzip client and vice versa. + if got := res.Header.Get("Vary"); !strings.Contains(got, "Accept-Encoding") { + t.Errorf("Vary = %q, want it to contain Accept-Encoding", got) + } +} + +func TestCompressionSkipsAlreadyCompressedContentTypes(t *testing.T) { + // Bytes that do not compress, standing in for a JPEG out of the media + // library: the point is that the middleware never looks at them. + body := strings.Repeat("\x89PNG\r\n\x1a\n\xde\xad\xbe\xef", 200) + res := serve(t, handlerWriting("image/jpeg", body), "gzip") + + if got := res.Header.Get("Content-Encoding"); got != "" { + t.Fatalf("Content-Encoding = %q, want empty for image/jpeg", got) + } + raw, _ := io.ReadAll(res.Body) + if string(raw) != body { + t.Errorf("image body was altered") + } +} + +func TestCompressionSkipsShortBodies(t *testing.T) { + body := `{"error":"not found"}` + res := serve(t, handlerWriting("application/json", body), "gzip") + + if got := res.Header.Get("Content-Encoding"); got != "" { + t.Fatalf("Content-Encoding = %q, want empty for a body under minCompressSize", got) + } + raw, _ := io.ReadAll(res.Body) + if string(raw) != body { + t.Errorf("short body = %q, want %q", raw, body) + } +} + +func TestCompressionPreservesStatusCode(t *testing.T) { + body := jsonBody(50) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, body) + }) + res := serve(t, h, "gzip") + + if res.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusCreated) + } + if got := res.Header.Get("Content-Encoding"); got != "gzip" { + t.Errorf("Content-Encoding = %q, want gzip", got) + } +} + +// A stale Content-Length is the difference between a whole response and one the +// client truncates at the uncompressed length. +func TestCompressionDropsContentLength(t *testing.T) { + body := jsonBody(50) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", "999999") + _, _ = io.WriteString(w, body) + }) + res := serve(t, h, "gzip") + + if got := res.Header.Get("Content-Length"); got != "" { + t.Errorf("Content-Length = %q, want it removed once the body is gzipped", got) + } +} + +func TestCompressionLeavesHandlerEncodedBodiesAlone(t *testing.T) { + body := jsonBody(50) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Encoding", "identity") + _, _ = io.WriteString(w, body) + }) + res := serve(t, h, "gzip") + + if got := res.Header.Get("Content-Encoding"); got != "identity" { + t.Fatalf("Content-Encoding = %q, want the handler's own identity to survive", got) + } + raw, _ := io.ReadAll(res.Body) + if string(raw) != body { + t.Errorf("body was double-encoded") + } +} + +func TestCompressionHandlesEmptyResponses(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + res := serve(t, h, "gzip") + + if res.StatusCode != http.StatusNoContent { + t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusNoContent) + } + raw, _ := io.ReadAll(res.Body) + if len(raw) != 0 { + t.Errorf("body = %q, want empty", raw) + } +} diff --git a/server/main.go b/server/main.go index 663dee4..8ebfbe1 100644 --- a/server/main.go +++ b/server/main.go @@ -130,6 +130,15 @@ func main() { slog.Error("failed to build article pub_date index; public listings filesort", "error", err) } + // Non-fatal for the same reason as the pub_date index: the lookups are + // correct without it, only slower. + if err := database.EnsureArticlesSlugIndex(context.Background(), db); err != nil { + slog.Error("failed to index article slugs; article lookups scan the table", "error", err) + } + if err := database.EnsureArticleAuthorsIndex(context.Background(), db); err != nil { + slog.Error("failed to index article authors; byline lookups scan the join table", "error", err) + } + // Deliberately not fatal, and deliberately after the column migration: the // first FULLTEXT index on `articles` rebuilds the table, which on the // migrated corpus is slow enough that failing the boot over it would trade a @@ -202,6 +211,27 @@ func main() { slog.Error("failed to create classifieds table", "error", err) os.Exit(1) } + + // Fatal, unlike the index migrations above, because this one is not an + // optimization the queries can do without: every section page, homepage + // block and taxonomy count now reads article_categories, and a CMS that + // booted without it would serve empty sections rather than slow ones. + // + // Rebuilt unconditionally, and deliberately not behind + // CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP. The table is derived from + // `articles`, which the WordPress ETL replaces wholesale and renumbers; a + // rebuild that an operator has to remember to ask for is one that will be + // forgotten on the reseed that needs it most. It costs a single scan of a + // ten-thousand-row table. + if err := database.EnsureArticleCategoriesTable(context.Background(), db); err != nil { + slog.Error("failed to create article categories index", "error", err) + os.Exit(1) + } + if err := database.RebuildArticleCategories(context.Background(), db); err != nil { + slog.Error("failed to rebuild article categories index", "error", err) + os.Exit(1) + } + if strings.EqualFold(strings.TrimSpace(os.Getenv("CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP")), "true") { if err := database.RebuildTaxonomyArticleCounts(context.Background(), db); err != nil { slog.Error("failed to rebuild taxonomy article counts", "error", err) @@ -347,8 +377,10 @@ func newDefaultServer(cert *tls.Certificate, mux *http.ServeMux, logger *slog.Lo Addr: ":8080", // Metrics is outermost: Chain applies in reverse, so it wraps // Recovery and therefore records the 500 a panic turns into rather - // than losing the request entirely. - Handler: middleware.Chain(mux, middleware.Metrics, middleware.Logging, middleware.Recovery), + // than losing the request entirely. Compression sits just inside + // Logging and just outside Recovery -- see middleware.Compression + // for why that position is the only correct one. + Handler: middleware.Chain(mux, middleware.Metrics, middleware.Logging, middleware.Compression, middleware.Recovery), TLSConfig: tlsConfig, ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), },