Skip to content

Commit 8023e1c

Browse files
authored
Merge pull request #5058 from eisenhauer/xrootd-access-log
XRootD: add per-request access log
2 parents 87182bb + ee7509e commit 8023e1c

8 files changed

Lines changed: 515 additions & 1 deletion

File tree

docs/user_guide/source/introduction/whatsnew.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ resource-aware cache management:
163163
variable reads into a single network round-trip, reducing latency
164164
for workloads that read many variables per step.
165165

166+
- **Per-request access log**: An optional server-side log records each remote
167+
read (variable, step, selection, byte count) as JSON lines, for analyzing
168+
access patterns. It is off by default; set ``ADIOS2_XROOTD_ACCESSLOG`` to a
169+
file path to enable it, with size-based rotation and retention configurable
170+
via environment variables. See the XRootD plugin ``README.md`` for details.
171+
166172
Cross-Endian Interoperability
167173
-----------------------------
168174

scripts/docker/spin-xrootd/Dockerfile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ RUN openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
6161
-batch && \
6262
chmod 644 /tmp/server.key /tmp/server.crt
6363

64+
# Enable the ADIOS per-request access log (telemetry for offline access-pattern
65+
# analysis). Override at deploy time with -e ADIOS2_XROOTD_ACCESSLOG=<path>, or
66+
# set it empty to disable. Size-based rotation/retention default to ~2 GB; see
67+
# source/utils/xrootd-plugin/README.md. NOTE: /var/log/xrootd is in the
68+
# container's ephemeral layer, so mount a persistent volume there (or point the
69+
# path at one) to retain the log across container restarts.
70+
ENV ADIOS2_XROOTD_ACCESSLOG=/var/log/xrootd/access.jsonl
71+
6472
# Expose HTTP port (Spin Ingress will terminate TLS and forward to this port)
6573
EXPOSE 8080
6674

scripts/docker/spin-xrootd/docker-entrypoint.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ fi
4646
echo ""
4747
echo "Starting XRootD HTTP server on port 8080..."
4848
echo "Data directory: /data"
49+
echo "Access log: ${ADIOS2_XROOTD_ACCESSLOG:-disabled}"
4950
echo ""
5051

5152
# Start XRootD with HTTP configuration in foreground
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 Oak Ridge National Laboratory and Contributors
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
#include "AccessLog.h"
8+
9+
#include <algorithm>
10+
#include <chrono>
11+
#include <cstdio> // rename, remove
12+
#include <cstdlib> // getenv, strtol
13+
#include <ctime> // gmtime_r, strftime
14+
#include <sstream>
15+
16+
#include <dirent.h> // opendir/readdir for retention
17+
#include <unistd.h> // access
18+
19+
namespace
20+
{
21+
22+
// Append s as a JSON string literal, escaping the few characters that matter.
23+
void AppendJsonString(std::ostringstream &os, const char *s)
24+
{
25+
os << '"';
26+
for (; s && *s; ++s)
27+
{
28+
const unsigned char c = static_cast<unsigned char>(*s);
29+
if (c == '"' || c == '\\')
30+
os << '\\' << static_cast<char>(c);
31+
else if (c < 0x20)
32+
os << ' '; // drop control chars rather than emit invalid JSON
33+
else
34+
os << static_cast<char>(c);
35+
}
36+
os << '"';
37+
}
38+
39+
void AppendDims(std::ostringstream &os, const std::vector<size_t> &dims)
40+
{
41+
os << '[';
42+
for (size_t i = 0; i < dims.size(); ++i)
43+
{
44+
if (i)
45+
os << ',';
46+
os << dims[i];
47+
}
48+
os << ']';
49+
}
50+
51+
// UTC timestamp suffix for rotated segments; sorts chronologically.
52+
std::string UtcStamp()
53+
{
54+
const std::time_t t = std::time(nullptr);
55+
std::tm tm;
56+
gmtime_r(&t, &tm);
57+
char buf[24];
58+
std::strftime(buf, sizeof(buf), "%Y%m%dT%H%M%S", &tm);
59+
return std::string(buf);
60+
}
61+
62+
} // namespace
63+
64+
AccessLog &AccessLog::Instance()
65+
{
66+
static AccessLog instance;
67+
return instance;
68+
}
69+
70+
AccessLog::AccessLog()
71+
{
72+
const char *path = std::getenv("ADIOS2_XROOTD_ACCESSLOG");
73+
if (!path || !*path)
74+
return; // disabled
75+
76+
const char *maxsize = std::getenv("ADIOS2_XROOTD_ACCESSLOG_MAXSIZE");
77+
if (maxsize)
78+
{
79+
const long long n = std::strtoll(maxsize, nullptr, 10);
80+
if (n >= 0)
81+
m_MaxSize = static_cast<size_t>(n); // 0 => never rotate
82+
}
83+
84+
const char *keep = std::getenv("ADIOS2_XROOTD_ACCESSLOG_KEEP");
85+
if (keep)
86+
{
87+
const long n = std::strtol(keep, nullptr, 10);
88+
if (n >= 0)
89+
m_Keep = static_cast<size_t>(n);
90+
}
91+
92+
m_File.open(path, std::ios::out | std::ios::app);
93+
if (!m_File.is_open())
94+
return; // can't open => stay disabled
95+
96+
m_Path = path;
97+
m_File.seekp(0, std::ios::end);
98+
const std::streampos pos = m_File.tellp();
99+
m_CurrentSize = (pos > 0) ? static_cast<size_t>(pos) : 0;
100+
if (m_CurrentSize == 0)
101+
WriteHeader(); // fresh file gets a schema line; an appended one keeps its own
102+
103+
m_Running.store(true);
104+
m_Writer = std::thread(&AccessLog::WriterLoop, this);
105+
m_Enabled.store(true, std::memory_order_relaxed);
106+
}
107+
108+
AccessLog::~AccessLog()
109+
{
110+
m_Enabled.store(false, std::memory_order_relaxed);
111+
if (!m_Running.exchange(false))
112+
return;
113+
m_CV.notify_all();
114+
if (m_Writer.joinable())
115+
m_Writer.join();
116+
}
117+
118+
void AccessLog::Log(const Record &r)
119+
{
120+
if (!m_Enabled.load(std::memory_order_relaxed))
121+
return;
122+
123+
const double ts =
124+
std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
125+
126+
// Formatted synchronously on the caller's thread: the Record's pointers are
127+
// dereferenced here, while the caller is still blocked, and only the
128+
// finished string is handed to the writer thread. Do not defer this.
129+
std::ostringstream os;
130+
os << "{\"ts\":" << ts;
131+
if (r.file)
132+
{
133+
os << ",\"file\":";
134+
AppendJsonString(os, r.file);
135+
}
136+
if (r.var)
137+
{
138+
os << ",\"var\":";
139+
AppendJsonString(os, r.var->c_str());
140+
}
141+
os << ",\"step\":" << r.step << ",\"nsteps\":" << r.stepCount;
142+
if (r.start && !r.start->empty())
143+
{
144+
os << ",\"start\":";
145+
AppendDims(os, *r.start);
146+
}
147+
if (r.count && !r.count->empty())
148+
{
149+
os << ",\"count\":";
150+
AppendDims(os, *r.count);
151+
}
152+
if (r.blockID != static_cast<uint64_t>(-1))
153+
os << ",\"block\":" << r.blockID;
154+
if (r.accuracyError != 0.0)
155+
os << ",\"acc\":" << r.accuracyError;
156+
os << ",\"bytes\":" << r.bytes << ",\"batch\":" << r.batch << "}";
157+
158+
std::string line = os.str();
159+
{
160+
std::lock_guard<std::mutex> lock(m_Mutex);
161+
if (m_Active.size() < m_MaxQueue)
162+
m_Active.push_back(std::move(line));
163+
else
164+
++m_Dropped;
165+
}
166+
m_CV.notify_one();
167+
}
168+
169+
void AccessLog::WriteHeader()
170+
{
171+
static const std::string h = "{\"_schema\":1}\n";
172+
m_File << h;
173+
m_File.flush();
174+
m_CurrentSize += h.size();
175+
}
176+
177+
void AccessLog::Rotate()
178+
{
179+
m_File.flush();
180+
m_File.close();
181+
182+
std::string target = m_Path + "." + UtcStamp();
183+
if (access(target.c_str(), F_OK) == 0)
184+
{
185+
// Same-second collision (only plausible under extreme load): disambiguate.
186+
int seq = 1;
187+
std::string candidate;
188+
do
189+
{
190+
candidate = target + "-" + std::to_string(seq++);
191+
} while (access(candidate.c_str(), F_OK) == 0);
192+
target = candidate;
193+
}
194+
std::rename(m_Path.c_str(), target.c_str());
195+
196+
m_File.open(m_Path, std::ios::out | std::ios::trunc);
197+
m_CurrentSize = 0;
198+
if (m_File.is_open())
199+
WriteHeader();
200+
PruneSegments();
201+
}
202+
203+
void AccessLog::PruneSegments()
204+
{
205+
std::string dir;
206+
std::string base;
207+
const size_t slash = m_Path.find_last_of('/');
208+
if (slash == std::string::npos)
209+
{
210+
dir = ".";
211+
base = m_Path;
212+
}
213+
else
214+
{
215+
dir = m_Path.substr(0, slash);
216+
base = m_Path.substr(slash + 1);
217+
}
218+
const std::string prefix = base + "."; // matches segments, not the active file
219+
220+
DIR *d = opendir(dir.c_str());
221+
if (!d)
222+
return;
223+
std::vector<std::string> segments;
224+
for (struct dirent *e = readdir(d); e != nullptr; e = readdir(d))
225+
{
226+
const std::string name = e->d_name;
227+
if (name.size() > prefix.size() && name.compare(0, prefix.size(), prefix) == 0)
228+
segments.push_back(name);
229+
}
230+
closedir(d);
231+
232+
if (segments.size() <= m_Keep)
233+
return;
234+
std::sort(segments.begin(), segments.end()); // chronological by timestamp suffix
235+
const size_t toDelete = segments.size() - m_Keep;
236+
for (size_t i = 0; i < toDelete; ++i)
237+
std::remove((dir + "/" + segments[i]).c_str());
238+
}
239+
240+
void AccessLog::WriterLoop()
241+
{
242+
std::vector<std::string> batch;
243+
bool running = true;
244+
while (running)
245+
{
246+
uint64_t dropped = 0;
247+
{
248+
std::unique_lock<std::mutex> lock(m_Mutex);
249+
m_CV.wait_for(lock, std::chrono::milliseconds(250),
250+
[this] { return !m_Active.empty() || !m_Running.load(); });
251+
batch.swap(m_Active);
252+
dropped = m_Dropped;
253+
m_Dropped = 0;
254+
running = m_Running.load();
255+
}
256+
bool wrote = false;
257+
for (const auto &line : batch)
258+
{
259+
m_File << line << '\n';
260+
m_CurrentSize += line.size() + 1;
261+
wrote = true;
262+
}
263+
if (dropped)
264+
{
265+
std::ostringstream os;
266+
os << "{\"dropped\":" << dropped << "}\n";
267+
const std::string d = os.str();
268+
m_File << d;
269+
m_CurrentSize += d.size();
270+
wrote = true;
271+
}
272+
if (wrote)
273+
m_File.flush();
274+
batch.clear();
275+
276+
if (m_MaxSize > 0 && m_CurrentSize > m_MaxSize)
277+
Rotate();
278+
}
279+
280+
// Shutting down: drain anything enqueued after the last swap. Request
281+
// threads are quiescent by now, so one final pass suffices.
282+
std::lock_guard<std::mutex> lock(m_Mutex);
283+
for (const auto &line : m_Active)
284+
m_File << line << '\n';
285+
if (m_Dropped)
286+
m_File << "{\"dropped\":" << m_Dropped << "}\n";
287+
m_File.flush();
288+
}

0 commit comments

Comments
 (0)