Commit 8edd4c13 authored by jan.koester's avatar jan.koester
Browse files

session: purge never-delivered and expired sessions

RequestEvent creates a session row for every request without a valid
sessionid cookie, but that Set-Cookie never reaches the browser (the
cookie-banner logic depends on the session not existing until consent),
so each anonymous request left one dead row behind. Nothing ever
deleted sessions.

createSession() now triggers _purgeStale() at most every 10 minutes:
- data-less sessions older than 1 h (consent and login always write
  session_data, so these were never handed out)
- sessions older than 45 days incl. their session_data (cookie
  max-age is 3600000 s and never refreshed)

Adds indexes on session_data(sessionid) and session(created) so the
anti-join and range scan don't degrade to full nested scans.

Tested against the real Session class on SQLite and PostgreSQL 17.

Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 40d175ff
Loading
Loading
Loading
Loading
+81 −0
Changes for src/session.cpp: 81 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -31,6 +31,8 @@
#include <httppp/exception.h>
#include <uuidp.h>
#include <chrono>
#include <atomic>
#include <ctime>


#include "session.h"
@@ -75,10 +77,89 @@ void blogi::Session::_initSession(){
    try {
        _sessiondb.exec(alterSql, res);
    } catch (...) {}

    // Indexes backing _purgeStale(): its NOT EXISTS anti-join and the
    // created range scan would otherwise be full-table nested scans.
    const char *indexSql[] = {
        "CREATE INDEX IF NOT EXISTS session_data_sessionid_idx ON session_data (sessionid);",
        "CREATE INDEX IF NOT EXISTS session_created_idx ON session (created);",
    };
    for (const char *stmt : indexSql) {
        dbpp::SQL idx;
        idx << stmt;
        try {
            _sessiondb.exec(idx, res);
        } catch (...) {}
    }
}

namespace {
    // Same "%Y-%m-%d %H:%M:%S" UTC format createSession() stores in
    // session.created, so string (SQLite) and timestamp (PostgreSQL)
    // comparisons against it agree.
    std::string utcTimestamp(std::chrono::system_clock::time_point tp){
        const std::time_t tt = std::chrono::system_clock::to_time_t(tp);
        std::tm utc_tm{};
#ifdef _WIN32
        gmtime_s(&utc_tm, &tt);
#else
        gmtime_r(&tt, &utc_tm);
#endif
        char date_buf[20] = {0};
        std::strftime(date_buf, sizeof(date_buf), "%Y-%m-%d %H:%M:%S", &utc_tm);
        return date_buf;
    }
}

// Every request without a valid sessionid cookie creates a session row
// (RequestEvent's anonymous/cookie-banner branch), but that branch's
// Set-Cookie never reaches the browser -- the banner logic relies on the
// session *not* existing until consent is given -- so those rows are never
// used again. Nothing ever deleted them, so the table grew by one row per
// anonymous request. Rows that matter always carry session_data (consent
// choices via CookieBanner::setPermission, authid/uid on login), so drop
// data-less rows past ORPHAN_MAX_AGE_SECONDS, plus anything older than the
// cookie lifetime.
void blogi::Session::_purgeStale(){
    static std::atomic<int64_t> lastRun{0};
    const int64_t now = std::chrono::duration_cast<std::chrono::seconds>(
        std::chrono::steady_clock::now().time_since_epoch()).count();
    int64_t prev = lastRun.load();
    if (prev != 0 && now - prev < PURGE_INTERVAL_SECONDS)
        return;
    if (!lastRun.compare_exchange_strong(prev, now))
        return; // another thread is purging

    const auto wallNow = std::chrono::system_clock::now();
    const std::string orphanCut = utcTimestamp(wallNow - std::chrono::seconds(ORPHAN_MAX_AGE_SECONDS));
    const std::string expiredCut = utcTimestamp(wallNow - std::chrono::seconds(SESSION_MAX_AGE_SECONDS));

    dbpp::DBResult res;
    try {
        dbpp::SQL orphans;
        orphans << "DELETE FROM session WHERE created < '" << orphanCut << "'"
                << " AND NOT EXISTS (SELECT 1 FROM session_data d WHERE d.sessionid = session.id);";
        _sessiondb.exec(orphans, res);

        // session_data first: FOREIGN KEY (sessionid) REFERENCES session (id).
        dbpp::SQL expiredData;
        expiredData << "DELETE FROM session_data WHERE sessionid IN"
                    << " (SELECT id FROM session WHERE created < '" << expiredCut << "');";
        _sessiondb.exec(expiredData, res);

        dbpp::SQL expired;
        expired << "DELETE FROM session WHERE created < '" << expiredCut << "';";
        _sessiondb.exec(expired, res);
    } catch (...) {
        // Best effort -- never fail the request that triggered it.
        std::cerr << "Session: purging stale sessions failed" << std::endl;
    }
}


const std::string &blogi::Session::createSession(std::string &buf,const std::string &useragent){
    _purgeStale();

    uuid::uuid sessionid;

    sessionid.generate();
+10 −0
Changes for src/session.h: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -52,9 +52,19 @@ namespace blogi {
  private:

    void            _initSession();
    void            _purgeStale();

    const dbpp::ReplicatedDatabase &_sessiondb;

    // _purgeStale(): runs at most this often (piggybacks on createSession).
    static constexpr int PURGE_INTERVAL_SECONDS = 600;
    // Sessions without any session_data older than this were never handed
    // to a browser (see _purgeStale) and are dropped.
    static constexpr int ORPHAN_MAX_AGE_SECONDS = 60 * 60;
    // Every sessionid cookie is set with max-age=3600000 s (~41.7 days) and
    // never refreshed, so anything older is dead on the client side too.
    static constexpr int SESSION_MAX_AGE_SECONDS = 45 * 24 * 60 * 60;

    // In-memory session cache to avoid DB round-trip on every request
    static constexpr int CACHE_TTL_SECONDS = 120;
    static std::mutex _cacheMutex;