diff --git a/server/internal/database/developing_stories_settings.go b/server/internal/database/developing_stories_settings.go index 91fb452..1509d48 100644 --- a/server/internal/database/developing_stories_settings.go +++ b/server/internal/database/developing_stories_settings.go @@ -10,14 +10,13 @@ import ( const developingStoriesSettingKey = "developing_stories_titles" func GetDevelopingStories(ctx context.Context, conn *sql.DB) ([]string, error) { - var raw string - err := conn.QueryRowContext(ctx, "SELECT value_text FROM cms_settings WHERE key_name = ? LIMIT 1", developingStoriesSettingKey).Scan(&raw) - if err == sql.ErrNoRows { - return []string{}, nil - } + raw, found, err := readSettingRaw(ctx, conn, developingStoriesSettingKey) if err != nil { return nil, err } + if !found { + return []string{}, nil + } var parsed []string if strings.TrimSpace(raw) == "" { @@ -64,10 +63,5 @@ func SetDevelopingStories(ctx context.Context, conn *sql.DB, stories []string) e return err } - _, err = conn.ExecContext(ctx, ` - INSERT INTO cms_settings (key_name, value_text) - VALUES (?, ?) - ON DUPLICATE KEY UPDATE value_text = VALUES(value_text) - `, developingStoriesSettingKey, string(payload)) - return err + return writeSettingRaw(ctx, conn, developingStoriesSettingKey, string(payload)) } diff --git a/server/internal/database/footer_settings.go b/server/internal/database/footer_settings.go index cd5c4a9..1290f24 100644 --- a/server/internal/database/footer_settings.go +++ b/server/internal/database/footer_settings.go @@ -98,14 +98,13 @@ func defaultFooterColumns() []models.FooterColumn { // empty menu. The public footer is not something that should ever render empty // because of a bad write. func GetFooterSettings(ctx context.Context, conn *sql.DB) (models.FooterSettings, error) { - var raw string - err := conn.QueryRowContext(ctx, "SELECT value_text FROM cms_settings WHERE key_name = ? LIMIT 1", footerSettingKey).Scan(&raw) - if err == sql.ErrNoRows { - return models.FooterSettings{Columns: defaultFooterColumns()}, nil - } + raw, found, err := readSettingRaw(ctx, conn, footerSettingKey) if err != nil { return models.FooterSettings{}, err } + if !found { + return models.FooterSettings{Columns: defaultFooterColumns()}, nil + } if strings.TrimSpace(raw) == "" { return models.FooterSettings{Columns: defaultFooterColumns()}, nil } diff --git a/server/internal/database/homepage_carousel_settings.go b/server/internal/database/homepage_carousel_settings.go index 30d9809..8d487e6 100644 --- a/server/internal/database/homepage_carousel_settings.go +++ b/server/internal/database/homepage_carousel_settings.go @@ -66,14 +66,13 @@ func DefaultHomepageCarouselSlides() []models.HomepageCarouselSlide { } func GetHomepageCarousel(ctx context.Context, conn *sql.DB) ([]models.HomepageCarouselSlide, error) { - var raw string - err := conn.QueryRowContext(ctx, "SELECT value_text FROM cms_settings WHERE key_name = ? LIMIT 1", homepageCarouselSettingKey).Scan(&raw) - if err == sql.ErrNoRows { - return DefaultHomepageCarouselSlides(), nil - } + raw, found, err := readSettingRaw(ctx, conn, homepageCarouselSettingKey) if err != nil { return nil, err } + if !found { + return DefaultHomepageCarouselSlides(), nil + } if strings.TrimSpace(raw) == "" { return DefaultHomepageCarouselSlides(), nil } @@ -93,12 +92,7 @@ func SetHomepageCarousel(ctx context.Context, conn *sql.DB, slides []models.Home return err } - _, err = conn.ExecContext(ctx, ` - INSERT INTO cms_settings (key_name, value_text) - VALUES (?, ?) - ON DUPLICATE KEY UPDATE value_text = VALUES(value_text) - `, homepageCarouselSettingKey, string(payload)) - return err + return writeSettingRaw(ctx, conn, homepageCarouselSettingKey, string(payload)) } func PublishedHomepageCarousel(slides []models.HomepageCarouselSlide) []models.HomepageCarouselSlide { diff --git a/server/internal/database/poll_settings.go b/server/internal/database/poll_settings.go index f6cb5c4..49ea103 100644 --- a/server/internal/database/poll_settings.go +++ b/server/internal/database/poll_settings.go @@ -11,11 +11,7 @@ func EnsurePollSettings(ctx context.Context, conn *sql.DB) error { } func GetPollTitle(ctx context.Context, conn *sql.DB) (string, error) { - var value string - err := conn.QueryRowContext(ctx, "SELECT value_text FROM cms_settings WHERE key_name = 'poll_title' LIMIT 1").Scan(&value) - if err == sql.ErrNoRows { - return "", nil - } + value, _, err := readSettingRaw(ctx, conn, "poll_title") if err != nil { return "", err } @@ -24,10 +20,5 @@ func GetPollTitle(ctx context.Context, conn *sql.DB) (string, error) { func SetPollTitle(ctx context.Context, conn *sql.DB, title string) error { normalized := strings.TrimSpace(title) - _, err := conn.ExecContext(ctx, ` - INSERT INTO cms_settings (key_name, value_text) - VALUES ('poll_title', ?) - ON DUPLICATE KEY UPDATE value_text = VALUES(value_text) - `, normalized) - return err + return writeSettingRaw(ctx, conn, "poll_title", normalized) } diff --git a/server/internal/database/settings.go b/server/internal/database/settings.go index 92b3a56..2b522f6 100644 --- a/server/internal/database/settings.go +++ b/server/internal/database/settings.go @@ -75,9 +75,9 @@ func GetBreakingNews(ctx context.Context, conn *sql.DB) (models.BreakingNewsSett return models.BreakingNewsSettings{}, err } // getSetting falls back when the stored value is blank, so an empty banner - // text is read directly rather than through getSetting's fallback handling. - var text string - if err := conn.QueryRowContext(ctx, "SELECT value_text FROM cms_settings WHERE key_name = ? LIMIT 1", keyBreakingNewsText).Scan(&text); err != nil && err != sql.ErrNoRows { + // text is read raw rather than through getSetting's fallback handling. + text, _, err := readSettingRaw(ctx, conn, keyBreakingNewsText) + if err != nil { return models.BreakingNewsSettings{}, err } return models.BreakingNewsSettings{ @@ -101,14 +101,13 @@ func SetBreakingNews(ctx context.Context, conn *sql.DB, s models.BreakingNewsSet // getSetting reads a single cms_settings value, returning fallback when the key // is absent or stored empty. func getSetting(ctx context.Context, conn *sql.DB, key, fallback string) (string, error) { - var value string - err := conn.QueryRowContext(ctx, "SELECT value_text FROM cms_settings WHERE key_name = ? LIMIT 1", key).Scan(&value) - if err == sql.ErrNoRows { - return fallback, nil - } + value, found, err := readSettingRaw(ctx, conn, key) if err != nil { return "", err } + if !found { + return fallback, nil + } if value = strings.TrimSpace(value); value == "" { return fallback, nil } @@ -116,12 +115,7 @@ func getSetting(ctx context.Context, conn *sql.DB, key, fallback string) (string } func setSetting(ctx context.Context, conn *sql.DB, key, value string) error { - _, err := conn.ExecContext(ctx, ` - INSERT INTO cms_settings (key_name, value_text) - VALUES (?, ?) - ON DUPLICATE KEY UPDATE value_text = VALUES(value_text) - `, key, value) - return err + return writeSettingRaw(ctx, conn, key, value) } // GetSEOSettings returns the site-wide SEO/social defaults, falling back to the diff --git a/server/internal/database/settings_cache.go b/server/internal/database/settings_cache.go new file mode 100644 index 0000000..e96e558 --- /dev/null +++ b/server/internal/database/settings_cache.go @@ -0,0 +1,124 @@ +package database + +import ( + "context" + "database/sql" + "os" + "strconv" + "strings" + "sync" + "time" +) + +// cms_settings is read on nearly every public page render -- the site title and +// footer on every layout, the carousel and developing stories on the homepage -- +// and written a few times a month by an editor. Each read was its own round +// trip, so a burst of traffic turned a handful of near-constant values into the +// dominant query load and, with a small connection pool, into a queue in front +// of the database. See the 2026-08-06 cutover outage. +// +// The cache is per-process and deliberately tiny: cms_settings holds under a +// dozen keys. Blue/green runs two processes, but only the active slot serves +// traffic, so the standby's cache is cold rather than wrong. + +const defaultSettingsCacheTTL = 30 * time.Second + +type settingsCacheEntry struct { + raw string + found bool + expires time.Time +} + +var ( + settingsCacheMu sync.RWMutex + settingsCacheMap = map[string]settingsCacheEntry{} +) + +// settingsCacheTTL is read per call rather than cached in a package variable so +// a deployment can change it without a rebuild, matching DB_MAX_OPEN_CONNS. +// Setting it to 0 disables caching entirely, which is the escape hatch if a +// stale value is ever suspected of hiding a bug. +func settingsCacheTTL() time.Duration { + raw := strings.TrimSpace(os.Getenv("SETTINGS_CACHE_TTL_SECONDS")) + if raw == "" { + return defaultSettingsCacheTTL + } + seconds, err := strconv.Atoi(raw) + if err != nil || seconds < 0 { + return defaultSettingsCacheTTL + } + return time.Duration(seconds) * time.Second +} + +// readSettingRaw returns the stored value for a key and whether the row exists, +// which callers distinguish: a missing row falls back to a built-in default, +// while a present-but-blank one is a deliberate empty value in some cases. +func readSettingRaw(ctx context.Context, conn *sql.DB, key string) (string, bool, error) { + ttl := settingsCacheTTL() + if ttl > 0 { + settingsCacheMu.RLock() + entry, ok := settingsCacheMap[key] + settingsCacheMu.RUnlock() + if ok && time.Now().Before(entry.expires) { + return entry.raw, entry.found, nil + } + } + + var raw string + err := conn.QueryRowContext(ctx, + "SELECT value_text FROM cms_settings WHERE key_name = ? LIMIT 1", key).Scan(&raw) + switch { + case err == sql.ErrNoRows: + raw, err = "", nil + storeSetting(key, raw, false, ttl) + return "", false, nil + case err != nil: + // Errors are never cached: a failed read must not pin an empty value + // for the whole TTL. + return "", false, err + } + storeSetting(key, raw, true, ttl) + return raw, true, nil +} + +func storeSetting(key, raw string, found bool, ttl time.Duration) { + if ttl <= 0 { + return + } + settingsCacheMu.Lock() + settingsCacheMap[key] = settingsCacheEntry{raw: raw, found: found, expires: time.Now().Add(ttl)} + settingsCacheMu.Unlock() +} + +// writeSettingRaw upserts a key and drops its cache entry, so an editor's save +// is visible on the next request rather than up to a TTL later. Every writer to +// cms_settings goes through here; an inline INSERT elsewhere would leave the +// cache holding the old value. +func writeSettingRaw(ctx context.Context, conn *sql.DB, key, value string) error { + _, err := conn.ExecContext(ctx, ` + INSERT INTO cms_settings (key_name, value_text) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE value_text = VALUES(value_text) + `, key, value) + if err != nil { + return err + } + InvalidateSettingCache(key) + return nil +} + +// InvalidateSettingCache drops one key. Exported so a writer that cannot use +// writeSettingRaw -- a migration, or a multi-statement transaction -- can still +// keep the cache honest. +func InvalidateSettingCache(key string) { + settingsCacheMu.Lock() + delete(settingsCacheMap, key) + settingsCacheMu.Unlock() +} + +// ResetSettingsCache drops every entry. +func ResetSettingsCache() { + settingsCacheMu.Lock() + settingsCacheMap = map[string]settingsCacheEntry{} + settingsCacheMu.Unlock() +} diff --git a/server/internal/database/settings_cache_test.go b/server/internal/database/settings_cache_test.go new file mode 100644 index 0000000..95eb438 --- /dev/null +++ b/server/internal/database/settings_cache_test.go @@ -0,0 +1,117 @@ +package database + +import ( + "context" + "testing" + "time" +) + +func TestSettingsCacheTTLDefaults(t *testing.T) { + t.Setenv("SETTINGS_CACHE_TTL_SECONDS", "") + if got := settingsCacheTTL(); got != defaultSettingsCacheTTL { + t.Fatalf("unset: got %v, want %v", got, defaultSettingsCacheTTL) + } + + t.Setenv("SETTINGS_CACHE_TTL_SECONDS", "5") + if got := settingsCacheTTL(); got != 5*time.Second { + t.Fatalf("override: got %v, want 5s", got) + } + + // Disabling the cache is a supported escape hatch, so 0 must survive rather + // than fall back to the default. + t.Setenv("SETTINGS_CACHE_TTL_SECONDS", "0") + if got := settingsCacheTTL(); got != 0 { + t.Fatalf("zero: got %v, want 0", got) + } + + for _, bad := range []string{"abc", "-1", "1.5"} { + t.Setenv("SETTINGS_CACHE_TTL_SECONDS", bad) + if got := settingsCacheTTL(); got != defaultSettingsCacheTTL { + t.Fatalf("invalid %q: got %v, want the default", bad, got) + } + } +} + +// A cache hit must not reach the database. Passing a nil *sql.DB proves it: if +// readSettingRaw ever falls through to a query, this panics rather than quietly +// still working because a real connection was available. +func TestCachedReadDoesNotTouchTheDatabase(t *testing.T) { + t.Setenv("SETTINGS_CACHE_TTL_SECONDS", "30") + ResetSettingsCache() + t.Cleanup(ResetSettingsCache) + + storeSetting("site_title", "The Triangle", true, 30*time.Second) + + raw, found, err := readSettingRaw(context.Background(), nil, "site_title") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !found || raw != "The Triangle" { + t.Fatalf("got (%q, %v), want (\"The Triangle\", true)", raw, found) + } +} + +// A missing row is cached too, otherwise every render of an unset key -- the +// footer before anyone customizes it -- keeps querying. +func TestMissingRowIsCached(t *testing.T) { + t.Setenv("SETTINGS_CACHE_TTL_SECONDS", "30") + ResetSettingsCache() + t.Cleanup(ResetSettingsCache) + + storeSetting("footer_menu", "", false, 30*time.Second) + + raw, found, err := readSettingRaw(context.Background(), nil, "footer_menu") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found || raw != "" { + t.Fatalf("got (%q, %v), want (\"\", false)", raw, found) + } +} + +func TestInvalidateDropsTheEntry(t *testing.T) { + ResetSettingsCache() + t.Cleanup(ResetSettingsCache) + + storeSetting("site_title", "Stale", true, 30*time.Second) + InvalidateSettingCache("site_title") + + settingsCacheMu.RLock() + _, ok := settingsCacheMap["site_title"] + settingsCacheMu.RUnlock() + if ok { + t.Fatal("entry survived invalidation, so an editor's save would not be visible") + } +} + +func TestExpiredEntryIsNotServed(t *testing.T) { + t.Setenv("SETTINGS_CACHE_TTL_SECONDS", "30") + ResetSettingsCache() + t.Cleanup(ResetSettingsCache) + + // Already expired. + storeSetting("site_title", "Stale", true, -time.Second) + + settingsCacheMu.RLock() + entry := settingsCacheMap["site_title"] + settingsCacheMu.RUnlock() + if time.Now().Before(entry.expires) { + t.Fatal("entry should already be expired") + } +} + +// Storing with caching disabled must be a no-op, or SETTINGS_CACHE_TTL_SECONDS=0 +// would still serve values written before it was set. +func TestDisabledCacheStoresNothing(t *testing.T) { + ResetSettingsCache() + t.Cleanup(ResetSettingsCache) + + storeSetting("site_title", "The Triangle", true, 0) + + settingsCacheMu.RLock() + _, ok := settingsCacheMap["site_title"] + settingsCacheMu.RUnlock() + if ok { + t.Fatal("value cached despite the cache being disabled") + } +} diff --git a/server/internal/database/users.go b/server/internal/database/users.go index ab782b0..fc87750 100644 --- a/server/internal/database/users.go +++ b/server/internal/database/users.go @@ -88,11 +88,10 @@ func BackfillArticleSEOFromYoast(ctx context.Context, conn *sql.DB) error { return err } - _, err := conn.ExecContext(ctx, ` - INSERT INTO cms_settings (key_name, value_text) VALUES ('articles_seo_backfilled', '1') - ON DUPLICATE KEY UPDATE value_text = '1' - `) - return err + // Through the cached writer so the flag cannot be read back stale; this runs + // once at startup, but leaving one inline INSERT behind is how the cache + // quietly diverges later. + return writeSettingRaw(ctx, conn, "articles_seo_backfilled", "1") } func EnsureAuthorsSchema(ctx context.Context, conn *sql.DB) error {