Haio 0.2.X
Homebrew Asset IO/CDN
Loading...
Searching...
No Matches
redis.cpp
Go to the documentation of this file.
1#include <haio_cache.hpp>
2
3#include <boost/asio/as_tuple.hpp>
4#include <boost/asio/consign.hpp>
5#include <boost/asio/detached.hpp>
6#include <boost/asio/use_awaitable.hpp>
7#include <boost/redis/connection.hpp>
8
9// boost.redis is not header only, and this is the one translation unit that compiles
10// it: src.hpp exists for exactly that, and keeping it here means the library comes
11// and goes with the file that uses it
12#include <boost/redis/src.hpp>
13#include <boost/url/parse.hpp>
14#include <boost/url/url.hpp>
15
16#include <iostream>
17#include <stdexcept>
18#include <algorithm>
19#include <cstdlib>
20#include <vector>
21
22namespace asio = boost::asio;
23namespace redis = boost::redis;
24namespace urls = boost::urls;
25
26namespace {
27
28/**
29 * a shared redis, so several haio processes answer from one cache.
30 *
31 * every call goes through asTuple: a cache that cannot be reached has to read as a
32 * miss and let the request carry on to the upstream, never as a failed response. the
33 * whole point of a cache is that losing it costs latency and nothing else.
34 */
35class RedisStore final : public Haio::Cdn::CacheStore {
36public:
37 RedisStore(const std::string& url, std::chrono::seconds ttl, size_t maxUsage, asio::any_io_executor executor)
38 : ttl_(ttl), maxUsage_(maxUsage), connection_(std::make_shared<redis::connection>(executor)) {
39 const auto parsed = urls::parse_uri(url);
40 if (!parsed) throw std::runtime_error("redis cache url is not a url: " + url);
41
42 redis::config config;
43 if (!parsed->host().empty()) config.addr.host = std::string(parsed->host());
44 if (parsed->has_port()) config.addr.port = std::string(parsed->port());
45 if (!parsed->userinfo().empty()) {
46 config.username = std::string(parsed->user());
47 config.password = std::string(parsed->password());
48 }
49 // redis://host:port/3 selects database 3, the way every other client reads it
50 if (const auto path = std::string(parsed->path()); path.size() > 1) {
51 config.database_index = std::stoi(path.substr(1));
52 }
53 config.use_ssl = parsed->scheme() == "rediss";
54
55 connection_->async_run(config, asio::consign(asio::detached, connection_));
56 }
57
58 ~RedisStore() override { connection_->cancel(); }
59
60 asio::awaitable<std::optional<Haio::Cdn::CacheEntry>> get(const std::string& key) override {
61 // read, renew and touch the index in one round trip rather than three
62 redis::request request;
63 request.push("GET", prefixed(key));
64 request.push("EXPIRE", prefixed(key), std::to_string(ttl_.count()), "XX");
65 request.push("ZADD", index(), std::to_string(stamp()), key);
66
67 redis::response<std::optional<std::string>, redis::ignore_t, redis::ignore_t> reply;
68 const auto [error, size] = co_await connection_->async_exec(request, reply, asio::as_tuple(asio::use_awaitable));
69 if (error) {
70 warnOnce(error.message());
71 co_return std::nullopt;
72 }
73
74 const auto& value = std::get<0>(reply);
75 if (!value.has_value() || !value->has_value()) co_return std::nullopt;
76
77 const auto& raw = **value;
78 co_return Haio::Cdn::decodeEntry({reinterpret_cast<const uint8_t*>(raw.data()), raw.size()});
79 }
80
81 asio::awaitable<void> put(const std::string& key, const Haio::Cdn::CacheEntry& entry) override {
82 const auto raw = Haio::Cdn::encodeEntry(entry);
83
84 redis::request request;
85 // redis owns the expiry, so an entry cannot outlive the ttl even across restarts
86 request.push("SET", prefixed(key), std::string_view(reinterpret_cast<const char*>(raw.data()), raw.size()),
87 "EX", std::to_string(ttl_.count()));
88 request.push("ZADD", index(), std::to_string(stamp()), key);
89 request.push("HSET", sizes(), key, std::to_string(raw.size()));
90 request.push("EXPIRE", index(), std::to_string(ttl_.count() * 2));
91 request.push("EXPIRE", sizes(), std::to_string(ttl_.count() * 2));
92
93 redis::response<redis::ignore_t, redis::ignore_t, redis::ignore_t,
94 redis::ignore_t, redis::ignore_t> reply;
95 const auto [error, size] = co_await connection_->async_exec(request, reply, asio::as_tuple(asio::use_awaitable));
96 if (error) {
97 warnOnce(error.message());
98 co_return;
99 }
100
101 if (++sinceTrim_ >= trimEvery) {
102 sinceTrim_ = 0;
103 co_await trim();
104 }
105 co_return;
106 }
107
108private:
109 /** the keys are namespaced so a shared redis can hold more than haio */
110 static std::string prefixed(const std::string& key) { return "haio:" + key; }
111
112 /** a sorted set of the keys haio owns, scored by when each was last touched */
113 static std::string index() { return "haio:index"; }
114
115 static long long stamp() {
116 return std::chrono::duration_cast<std::chrono::seconds>(
117 std::chrono::system_clock::now().time_since_epoch()).count();
118 }
119
120 /** what each key costs, so the budget can be met without asking redis to measure */
121 static std::string sizes() { return "haio:sizes"; }
122
123 /**
124 * redis expires entries on its own, but nothing would stop haio from asking it to
125 * hold a gigabyte first. the index says which keys are ours and when each was last
126 * wanted; the hash beside it says what each one costs.
127 *
128 * the two have to be reconciled rather than trusted: redis drops a key when its
129 * ttl runs out and tells nobody, so an index left alone would keep counting bytes
130 * that are already gone and evict entries that are still wanted. every pass checks
131 * which keys are really there and forgets the rest.
132 *
133 * this is about being a decent tenant of a shared redis rather than about
134 * correctness: redis has its own maxmemory, and that one is not ours to set.
135 */
136 asio::awaitable<void> trim() {
137 redis::request listing;
138 listing.push("ZRANGE", index(), "0", "-1");
139
140 redis::response<std::vector<std::string>> listed;
141 if (const auto [error, size] = co_await connection_->async_exec(listing, listed, asio::as_tuple(asio::use_awaitable)); error) {
142 warnOnce(error.message());
143 co_return;
144 }
145
146 const auto& keys = std::get<0>(listed).value();
147 if (keys.empty()) co_return;
148
149 // oldest first, and ask in one round trip what is still there and what it costs
150 redis::request measuring;
151 for (const auto& key : keys) measuring.push("EXISTS", prefixed(key));
152
153 redis::generic_response present;
154 if (const auto [error, size] = co_await connection_->async_exec(measuring, present, asio::as_tuple(asio::use_awaitable)); error) {
155 warnOnce(error.message());
156 co_return;
157 }
158
159 redis::request weighing;
160 for (const auto& key : keys) weighing.push("HGET", sizes(), key);
161
162 redis::generic_response weights;
163 if (const auto [error, size] = co_await connection_->async_exec(weighing, weights, asio::as_tuple(asio::use_awaitable)); error) {
164 warnOnce(error.message());
165 co_return;
166 }
167
168 std::vector<size_t> cost(keys.size(), 0);
169 std::vector<bool> alive(keys.size(), false);
170 for (size_t at = 0; at < keys.size(); at++) {
171 if (at < present.value().size()) alive[at] = present.value()[at].value == "1";
172 if (at < weights.value().size()) {
173 const auto& text = weights.value()[at].value;
174 if (!text.empty()) cost[at] = static_cast<size_t>(std::strtoull(text.c_str(), nullptr, 10));
175 }
176 }
177
178 size_t held = 0;
179 for (size_t at = 0; at < keys.size(); at++) {
180 if (alive[at]) held += cost[at];
181 }
182
183 redis::request dropping;
184 bool dropped = false;
185
186 // whatever redis already expired is forgotten here, not counted against anyone
187 for (size_t at = 0; at < keys.size(); at++) {
188 if (alive[at]) continue;
189 dropping.push("ZREM", index(), keys[at]);
190 dropping.push("HDEL", sizes(), keys[at]);
191 dropped = true;
192 }
193
194 // then the least recently wanted, until what is left fits the budget
195 for (size_t at = 0; at < keys.size() && held > maxUsage_; at++) {
196 if (!alive[at]) continue;
197 dropping.push("DEL", prefixed(keys[at]));
198 dropping.push("ZREM", index(), keys[at]);
199 dropping.push("HDEL", sizes(), keys[at]);
200 held -= std::min(held, cost[at]);
201 dropped = true;
202 }
203
204 if (!dropped) co_return;
205
206 redis::generic_response ignored;
207 if (const auto [error, size] = co_await connection_->async_exec(dropping, ignored, asio::as_tuple(asio::use_awaitable)); error) {
208 warnOnce(error.message());
209 }
210 co_return;
211 }
212
213 // trimming on every write would double the round trips for no benefit
214 static constexpr int trimEvery = 64;
215
216 void warnOnce(const std::string& reason) {
217 if (std::exchange(warned_, true)) return;
218 std::cerr << "warning: redis cache unavailable, serving from upstream: " << reason << "\n";
219 }
220
221 std::chrono::seconds ttl_;
222 size_t maxUsage_;
223 int sinceTrim_ = 0;
224 std::shared_ptr<redis::connection> connection_;
225 bool warned_ = false;
226};
227
228}
229
230namespace Haio::Cdn {
231
232std::unique_ptr<CacheStore> makeRedisStore(std::string url, std::chrono::seconds ttl, size_t maxUsage, asio::any_io_executor executor) {
233 return std::make_unique<RedisStore>(url, ttl, maxUsage, std::move(executor));
234}
235
236}
std::optional< CacheEntry > decodeEntry(std::span< const uint8_t > raw)
Definition key.cpp:58
std::vector< uint8_t > encodeEntry(const CacheEntry &entry)
Definition key.cpp:45
std::unique_ptr< CacheStore > makeRedisStore(std::string url, std::chrono::seconds ttl, size_t maxUsage, boost::asio::any_io_executor executor)