Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions server/internal/database/developing_stories_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) == "" {
Expand Down Expand Up @@ -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))
}
9 changes: 4 additions & 5 deletions server/internal/database/footer_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
16 changes: 5 additions & 11 deletions server/internal/database/homepage_carousel_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down
13 changes: 2 additions & 11 deletions server/internal/database/poll_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
}
22 changes: 8 additions & 14 deletions server/internal/database/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -101,27 +101,21 @@ 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
}
return value, nil
}

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
Expand Down
124 changes: 124 additions & 0 deletions server/internal/database/settings_cache.go
Original file line number Diff line number Diff line change
@@ -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()
}
117 changes: 117 additions & 0 deletions server/internal/database/settings_cache_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading