From 86ee6e2bcc6c947a001951b2bcea41f611e8cef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dr=2E=20Patrick=20Urbanke=20=28=E5=8A=89=E8=87=AA=E6=88=90?= =?UTF-8?q?=29?= Date: Sat, 1 Nov 2025 14:30:58 +0100 Subject: [PATCH] Added a caching strategy (#83) --- docs/README.md | 3 +- docs/cache.md | 65 ++++++++++++ include/sqlgen.hpp | 1 + include/sqlgen/cache.hpp | 120 ++++++++++++++++++++++ include/sqlgen/internal/query_value_t.hpp | 30 ++++++ tests/mysql/test_cache.cpp | 56 ++++++++++ tests/postgres/test_cache.cpp | 56 ++++++++++ tests/sqlite/test_cache.cpp | 45 ++++++++ 8 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 docs/cache.md create mode 100644 include/sqlgen/cache.hpp create mode 100644 include/sqlgen/internal/query_value_t.hpp create mode 100644 tests/mysql/test_cache.cpp create mode 100644 tests/postgres/test_cache.cpp create mode 100644 tests/sqlite/test_cache.cpp diff --git a/docs/README.md b/docs/README.md index 2cddcb1..8c98144 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,7 @@ Welcome to the sqlgen documentation. This guide provides detailed information ab ## Other Operations +- [Cache](cache.md) - How to improve performance with caching. - [Mathematical Operations](mathematical_operations.md) - How to use mathematical functions in queries (e.g., abs, ceil, floor, exp, trigonometric functions, round). - [String Operations](string_operations.md) - How to manipulate and transform strings in queries (e.g., length, lower, upper, trim, replace, concat). - [Type Conversion Operations](type_conversion_operations.md) - How to convert between types safely in queries (e.g., cast int to double). @@ -62,4 +63,4 @@ Welcome to the sqlgen documentation. This guide provides detailed information ab - [PostgreSQL](postgres.md) - How to interact with PostgreSQL and compatible databases (Redshift, Aurora, Greenplum, CockroachDB, ...) - [SQLite](sqlite.md) - How to interact with SQLite3 -For installation instructions, quick start guide, and usage examples, please refer to the [main README](../README.md). \ No newline at end of file +For installation instructions, quick start guide, and usage examples, please refer to the [main README](../README.md). diff --git a/docs/cache.md b/docs/cache.md new file mode 100644 index 0000000..d359cf7 --- /dev/null +++ b/docs/cache.md @@ -0,0 +1,65 @@ +# `sqlgen::cache` + +The `sqlgen` library provides a high-performance caching mechanism to reduce database load and improve query performance. The cache is designed to be thread-safe and uses a first-in-first-out (FIFO) eviction policy. + +## Usage + +### Basic Caching Example + +To use the cache, you can wrap a query with `sqlgen::cache`. The first time the query is executed, the result is fetched from the database and stored in the cache. Subsequent executions of the same query will return the cached result. + +```cpp +#include + +// Define a table +struct User { + std::string name; + int age; +}; + +// Create a query +const auto query = sqlgen::read | where("name"_c == "John"); + +// Wrap the query with the cache +const auto cached_query = sqlgen::cache<100>(query); + +// Execute the query +const auto user1 = cached_query(conn).value(); +const auto user2 = cached_query(conn).value(); + +// Also OK +const auto user3 = conn.and_then( + cache<100>(sqlgen::read | where("name"_c == "John"))).value(); + +// The cache size will be 1, because the second and third query were served from the cache. +// auto cache_size = cached_query.cache(conn).size(); // cache_size is 1 +``` + +### How it Works + +The cache stores the results of queries in memory, using the generated SQL string as the key. When a cached query is executed, `sqlgen` first checks if a result for the corresponding SQL query exists in the cache. If it does, the cached result is returned immediately. Otherwise, the query is executed against the database, and the result is stored in the cache before being returned. + +### Eviction Policy + +The cache uses a simple first-in-first-out (FIFO) eviction policy. When the cache reaches its maximum size, the oldest entry is removed to make space for the new one. The maximum size of the cache is specified as a template parameter to `sqlgen::cache`. + +To create a cache with a virtually unlimited size, you can specify a `max_size` of `0`: + +```cpp +// This cache will grow indefinitely. +const auto cached_query = sqlgen::cache<0>(query); +``` + +### Thread Safety and Concurrency + +The cache is thread-safe and can be accessed from multiple threads concurrently. A `std::shared_mutex` is used to protect the cache from data races. + +It is important to understand the cache's behavior under high contention. If multiple threads request the same uncached query at the exact same time, the database query might be executed multiple times in parallel. However, as soon as one thread has started the query and placed a future for its result into the cache, any other threads that subsequently request the same query will not start a new database operation. Instead, they will wait for the result of the already running query. This mechanism prevents a "cache stampede" for all but the initial concurrent requests. + +## Notes + +- The cache is enabled by wrapping a query with `sqlgen::cache`. +- The cache uses a FIFO eviction policy. +- The maximum size of the cache can be configured. +- The cache is thread-safe. + diff --git a/include/sqlgen.hpp b/include/sqlgen.hpp index 1172fad..8de6a00 100644 --- a/include/sqlgen.hpp +++ b/include/sqlgen.hpp @@ -20,6 +20,7 @@ #include "sqlgen/aggregations.hpp" #include "sqlgen/as.hpp" #include "sqlgen/begin_transaction.hpp" +#include "sqlgen/cache.hpp" #include "sqlgen/cascade.hpp" #include "sqlgen/col.hpp" #include "sqlgen/commit.hpp" diff --git a/include/sqlgen/cache.hpp b/include/sqlgen/cache.hpp new file mode 100644 index 0000000..45de380 --- /dev/null +++ b/include/sqlgen/cache.hpp @@ -0,0 +1,120 @@ +#ifndef SQLGEN_CACHE_HPP_ +#define SQLGEN_CACHE_HPP_ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Ref.hpp" +#include "Result.hpp" +#include "internal/query_value_t.hpp" +#include "is_connection.hpp" +#include "transpilation/to_sql.hpp" + +namespace sqlgen { + +template + requires is_connection +class CacheImpl { + static constexpr size_t max_size_ = + _max_size == 0 ? std::numeric_limits::max() : _max_size; + + public: + using ValueType = internal::query_value_t>; + + static Result fetch(const QueryT& _query, + const Ref& _conn) { + const auto sql = _conn->to_sql(transpilation::to_sql(_query)); + + const auto try_read_from_cache = + [&]() -> Result>> { + std::shared_lock read_lock(mtx_); + const auto it = cache_.find(sql); + if (it != cache_.end()) { + return it->second.first; + } + return error("Could not find the result for the query in the cache."); + }; + + const auto write_to_cache = + [&](const std::shared_future>& _future) { + std::unique_lock write_lock(mtx_); + cache_[sql] = std::make_pair(_future, counter_++); + if (cache_.size() > max_size_) { + const auto it = + std::min_element(cache_.begin(), cache_.end(), + [](const auto& _p1, const auto& _p2) { + return _p1.second.second < _p2.second.second; + }); + cache_.erase(it); + } + }; + + return try_read_from_cache() + .and_then([](auto _future) { return _future.get(); }) + .or_else([&](auto&&) -> Result { + const std::shared_future> future = + std::async(std::launch::async, [&]() { return _query(_conn); }); + write_to_cache(future); + return future.get(); + }); + } + + static const auto& cache() { return cache_; } + + private: + inline static size_t counter_ = 0; + + inline static std::unordered_map< + std::string, std::pair>, size_t>> + cache_; + + inline static std::shared_mutex mtx_; +}; + +template +struct Cache { + template + requires is_connection + auto operator()(const Ref& _conn) const { + return CacheImpl, _max_size>::fetch( + query_, _conn); + } + + template + requires is_connection + auto operator()(const Result>& _res) const { + return _res.and_then( + [&](const Ref& _conn) { return (*this)(_conn); }); + } + + template + requires is_connection + static const auto& cache(const Ref& _conn) { + return CacheImpl, + _max_size>::cache(); + } + + template + requires is_connection + static const auto& cache(const Result>& _res) { + return CacheImpl, + _max_size>::cache(); + } + + QueryT query_; +}; + +template +auto cache(const QueryT& _query) { + return Cache, _max_size>{.query_ = _query}; +} + +} // namespace sqlgen + +#endif diff --git a/include/sqlgen/internal/query_value_t.hpp b/include/sqlgen/internal/query_value_t.hpp new file mode 100644 index 0000000..09be60f --- /dev/null +++ b/include/sqlgen/internal/query_value_t.hpp @@ -0,0 +1,30 @@ +#ifndef SQLGEN_INTERNAL_QUERYVALUET_HPP_ +#define SQLGEN_INTERNAL_QUERYVALUET_HPP_ + +#include + +#include "../read.hpp" +#include "../select_from.hpp" + +namespace sqlgen::internal { + +template +struct QueryValueType; + +template +struct QueryValueType, ConnectionT> { + using Type = std::invoke_result_t, ConnectionT>; +}; + +template +struct QueryValueType, ConnectionT> { + using Type = std::invoke_result_t, ConnectionT>; +}; + +template +using query_value_t = + typename QueryValueType::Type::value_type; + +} // namespace sqlgen::internal + +#endif diff --git a/tests/mysql/test_cache.cpp b/tests/mysql/test_cache.cpp new file mode 100644 index 0000000..cd54cae --- /dev/null +++ b/tests/mysql/test_cache.cpp @@ -0,0 +1,56 @@ +#ifndef SQLGEN_BUILD_DRY_TESTS_ONLY + +#include + +#include +#include +#include +#include +#include + +namespace test_cache { + +struct User { + std::string name; + int age; +}; + +TEST(mysql, test_cache) { + const auto credentials = sqlgen::mysql::Credentials{.host = "localhost", + .user = "sqlgen", + .password = "password", + .dbname = "mysql"}; + + const auto conn = sqlgen::mysql::connect(credentials); + + using namespace sqlgen; + using namespace sqlgen::literals; + + (sqlgen::drop | if_exists)(conn); + + const auto user = User{.name = "John", .age = 30}; + sqlgen::write(conn, user); + + const auto query = sqlgen::read | where("name"_c == "John"); + + const auto cached_query = sqlgen::cache<100>(query); + + const auto user1 = conn.and_then(cache<100>(query)).value(); + + EXPECT_EQ(cached_query.cache(conn).size(), 1); + + const auto user2 = cached_query(conn).value(); + const auto user3 = cached_query(conn).value(); + + EXPECT_EQ(user1.name, "John"); + EXPECT_EQ(user1.age, 30); + EXPECT_EQ(user2.name, "John"); + EXPECT_EQ(user2.age, 30); + EXPECT_EQ(cached_query.cache(conn).size(), 1); + EXPECT_EQ(user3.name, "John"); + EXPECT_EQ(user3.age, 30); +} + +} // namespace test_cache + +#endif diff --git a/tests/postgres/test_cache.cpp b/tests/postgres/test_cache.cpp new file mode 100644 index 0000000..5972206 --- /dev/null +++ b/tests/postgres/test_cache.cpp @@ -0,0 +1,56 @@ +#ifndef SQLGEN_BUILD_DRY_TESTS_ONLY + +#include + +#include +#include +#include +#include +#include + +namespace test_cache { + +struct User { + std::string name; + int age; +}; + +TEST(postgres, test_cache) { + const auto credentials = sqlgen::postgres::Credentials{.user = "postgres", + .password = "password", + .host = "localhost", + .dbname = "postgres"}; + + const auto conn = sqlgen::postgres::connect(credentials); + + using namespace sqlgen; + using namespace sqlgen::literals; + + (sqlgen::drop | if_exists)(conn); + + const auto user = User{.name = "John", .age = 30}; + sqlgen::write(conn, user); + + const auto query = sqlgen::read | where("name"_c == "John"); + + const auto cached_query = sqlgen::cache<100>(query); + + const auto user1 = conn.and_then(cache<100>(query)).value(); + + EXPECT_EQ(cached_query.cache(conn).size(), 1); + + const auto user2 = cached_query(conn).value(); + const auto user3 = cached_query(conn).value(); + + EXPECT_EQ(user1.name, "John"); + EXPECT_EQ(user1.age, 30); + EXPECT_EQ(user2.name, "John"); + EXPECT_EQ(user2.age, 30); + EXPECT_EQ(cached_query.cache(conn).size(), 1); + EXPECT_EQ(user3.name, "John"); + EXPECT_EQ(user3.age, 30); +} + +} // namespace test_cache + +#endif diff --git a/tests/sqlite/test_cache.cpp b/tests/sqlite/test_cache.cpp new file mode 100644 index 0000000..86b6366 --- /dev/null +++ b/tests/sqlite/test_cache.cpp @@ -0,0 +1,45 @@ +#include + +#include +#include +#include +#include +#include + +namespace test_cache { + +struct User { + std::string name; + int age; +}; + +TEST(sqlite, test_cache) { + const auto conn = sqlgen::sqlite::connect(); + + const auto user = User{.name = "John", .age = 30}; + sqlgen::write(conn, user); + + using namespace sqlgen; + using namespace sqlgen::literals; + + const auto query = sqlgen::read | where("name"_c == "John"); + + const auto cached_query = sqlgen::cache<100>(query); + + const auto user1 = conn.and_then(cache<100>(query)).value(); + + EXPECT_EQ(cached_query.cache(conn).size(), 1); + + const auto user2 = cached_query(conn).value(); + const auto user3 = cached_query(conn).value(); + + EXPECT_EQ(user1.name, "John"); + EXPECT_EQ(user1.age, 30); + EXPECT_EQ(user2.name, "John"); + EXPECT_EQ(user2.age, 30); + EXPECT_EQ(cached_query.cache(conn).size(), 1); + EXPECT_EQ(user3.name, "John"); + EXPECT_EQ(user3.age, 30); +} + +} // namespace test_cache