Mads
Multi-Agent Distributed System
Loading...
Searching...
No Matches
plugin_cache.hpp
Go to the documentation of this file.
1/*
2Internal helper: a content-addressed on-disk cache for broker-served
3attachments (the OTA plugin delivery driven by the `attachment` INI key). Not
4part of the installed SDK (src/detail/ is excluded from the LIB_HEADERS install
5glob in CMakeLists.txt).
6
7The problem it solves. The attachment used to land on a fixed path derived only
8from the settings section name, `<temp>/mads/<name>.plugin`, and was written
9with a truncating ofstream. That is fine for one agent, and fatal for several:
10`mads-director`/`mads-up` with `scale > 1` start N instances of the same
11section on one host, and instance 2 rewrote the very inode instance 1 had
12already dlopen()'d. Rewriting a mapped file in place does not give the running
13process a stale-but-valid copy -- it changes the pages under it, so instance 1
14took a SIGBUS (or executed garbage) at its next page fault. Intermittent, and
15it killed a process other than the one that misbehaved.
16
17The fix is not "one copy per instance" but the narrower invariant that makes
18that unnecessary: *the bytes at an in-use path never change*. Naming the file
19after a digest of its own content gives exactly that, and invalidation falls
20out for free -- a new binary on the broker hashes differently, so it lands on a
21new path, and no instance has to work out whether it is the "first" one.
22
23Layout:
24
25 <temp>/mads/<section-name>/<digest16>/<section-name>.<ext>
26
27The digest is a *directory* component and never part of the filename stem,
28because both C++ plugin loaders fall back to the plugin file's stem for the
29driver name when the settings section has no `driver` key (plugin_loader.cpp
30"Driver: --driver > 'driver' setting > file stem", and the same in worker.cpp).
31A digest in the stem would silently change the driver name every OTA agent that
32relies on that fallback resolves. Scoping the digest directory under the
33section name also keeps sweep_stale() honest: it can only ever remove that
34agent's own older versions, never a sibling agent's cache.
35
36The digest is a cache key over bytes that already arrived from a trusted (and,
37under --crypto, CURVE-authenticated) broker -- it is not a security control and
38does not need to be a cryptographic hash. FNV-1a keeps this header free of any
39dependency, in the same spirit as the self-contained CRC-32 in bag.cpp.
40*/
41#pragma once
42
43#include <atomic>
44#include <cstddef>
45#include <cstdint>
46#include <filesystem>
47#include <fstream>
48#include <stdexcept>
49#include <string>
50#include <string_view>
51#include <system_error>
52
53#ifdef _WIN32
54#include <process.h>
55#else
56#include <unistd.h>
57#endif
58
59namespace Mads::detail {
60
61// FNV-1a, 64-bit (offset basis 0xcbf29ce484222325, prime 0x100000001b3).
62inline uint64_t fnv1a64(const void *data, size_t len) {
63 const auto *p = static_cast<const unsigned char *>(data);
64 uint64_t h = 0xcbf29ce484222325ull;
65 for (size_t i = 0; i < len; ++i) {
66 h ^= p[i];
67 h *= 0x100000001b3ull;
68 }
69 return h;
70}
71
72// The digest as exactly 16 lowercase hex digits, so every cache directory name
73// has the same width regardless of leading zeros.
74inline std::string digest_hex(std::string_view bytes) {
75 static constexpr char HEX[] = "0123456789abcdef";
76 uint64_t h = fnv1a64(bytes.data(), bytes.size());
77 std::string out(16, '0');
78 for (int i = 15; i >= 0; --i) {
79 out[static_cast<size_t>(i)] = HEX[h & 0xful];
80 h >>= 4;
81 }
82 return out;
83}
84
85// Root of the attachment cache, shared by every agent on the host.
86inline std::filesystem::path attachment_cache_root() {
87 return std::filesystem::temp_directory_path() / "mads";
88}
89
90// Removes every version of `name`'s attachment except `keep_digest`, plus the
91// flat `<temp>/mads/<name>.*` files left behind by MADS <= 2.4.2. Entirely
92// best-effort: every call takes the error_code overload and ignores failures.
93// Unlinking a mapped file is safe on POSIX (the inode outlives the last
94// mapping), and on Windows removing a currently loaded DLL simply fails, which
95// is equally harmless -- a still-running sibling keeps the copy it loaded.
96inline void sweep_stale(const std::filesystem::path &agent_dir,
97 std::string_view keep_digest, const std::string &name,
98 const std::string &ext) {
99 namespace fs = std::filesystem;
100 std::error_code ec;
101
102 for (fs::directory_iterator it(agent_dir, ec), end; !ec && it != end;
103 it.increment(ec)) {
104 if (it->path().filename() == keep_digest)
105 continue;
106 std::error_code rm_ec;
107 fs::remove_all(it->path(), rm_ec);
108 }
109
110 // Pre-2.4.3 layout: one flat file per agent, directly under the cache root.
111 // Both the ".plugin" the download always used and the settings-driven
112 // extension it was then renamed to.
113 const fs::path root = agent_dir.parent_path();
114 std::error_code legacy_ec;
115 fs::remove(root / (name + ".plugin"), legacy_ec);
116 legacy_ec.clear();
117 fs::remove(root / (name + "." + ext), legacy_ec);
118}
119
134inline std::filesystem::path store_attachment(const std::string &name,
135 const std::string &ext,
136 const std::string &bytes) {
137 namespace fs = std::filesystem;
138
139 const std::string digest = digest_hex(bytes);
140 const fs::path agent_dir = attachment_cache_root() / name;
141 const fs::path dir = agent_dir / digest;
142 const fs::path target = dir / (name + "." + ext);
143
144 // Already cached. This is the path every instance after the first takes when
145 // an agent is scaled, so the common case does no I/O beyond a stat. A size
146 // check is enough to reject a truncated leftover: the write below publishes
147 // by rename, so a file at `target` is either complete or not there at all.
148 std::error_code ec;
149 if (fs::exists(target, ec) && fs::file_size(target, ec) == bytes.size()) {
150 sweep_stale(agent_dir, digest, name, ext);
151 return target;
152 }
153
154 fs::create_directories(dir, ec);
155 if (!fs::is_directory(dir, ec)) {
156 throw std::runtime_error(
157 "Failed to create attachment cache directory " + dir.string() +
158 (ec ? ": " + ec.message() : std::string{}));
159 }
160
161 // Stage in the destination directory so the publishing rename never crosses
162 // a filesystem boundary. pid + counter keeps two processes -- or two threads
163 // of one process -- from picking the same staging name.
164 static std::atomic<uint64_t> seq{0};
165#ifdef _WIN32
166 const auto pid = static_cast<long long>(_getpid());
167#else
168 const auto pid = static_cast<long long>(getpid());
169#endif
170 const fs::path staged =
171 dir / ("." + name + "." + std::to_string(pid) + "." +
172 std::to_string(seq.fetch_add(1)) + ".tmp");
173
174 {
175 std::ofstream ofs(staged, std::ios::out | std::ios::binary);
176 if (!ofs) {
177 throw std::runtime_error("Failed to open " + staged.string() +
178 " for writing the attachment from the broker");
179 }
180 ofs.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
181 ofs.close();
182 if (!ofs) {
183 std::error_code rm_ec;
184 fs::remove(staged, rm_ec);
185 throw std::runtime_error(
186 "Failed to write the attachment from the broker to " +
187 staged.string());
188 }
189 }
190
191 // Atomic publish: a reader either sees the previous file or this one, never
192 // a partial write, and a process that mapped an earlier inode keeps it.
193 ec.clear();
194 fs::rename(staged, target, ec);
195 if (ec) {
196 // Lost a race with a concurrent writer, or -- on Windows -- the target is
197 // a DLL some process still has loaded, which cannot be replaced. Both are
198 // fine: content addressing means whatever sits at `target` has the same
199 // digest as what we just staged, so it is the file we wanted.
200 std::error_code check_ec;
201 const bool usable = fs::exists(target, check_ec) &&
202 fs::file_size(target, check_ec) == bytes.size();
203 std::error_code rm_ec;
204 fs::remove(staged, rm_ec);
205 if (!usable) {
206 throw std::runtime_error("Failed to install the attachment at " +
207 target.string() + ": " + ec.message());
208 }
209 }
210
211 sweep_stale(agent_dir, digest, name, ext);
212 return target;
213}
214
215} // namespace Mads::detail
std::filesystem::path store_attachment(const std::string &name, const std::string &ext, const std::string &bytes)
Persist a broker-served attachment under its content digest and return the path to load it from.
std::filesystem::path attachment_cache_root()
uint64_t fnv1a64(const void *data, size_t len)
std::string digest_hex(std::string_view bytes)
void sweep_stale(const std::filesystem::path &agent_dir, std::string_view keep_digest, const std::string &name, const std::string &ext)