Skip to content

Commit f136373

Browse files
authored
DPL Analysis: retry remote reads and fail over to other replicas in the native AOD reader (#16051)
1 parent 09403ed commit f136373

12 files changed

Lines changed: 656 additions & 79 deletions

File tree

‎Framework/AODNative/CMakeLists.txt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# ROOT, at build or run time: see the check at the end of this file.
1414
o2_add_library(AODNative
1515
SOURCES src/ByteSource.cxx
16+
src/FailoverByteSource.cxx
1617
src/LocalByteSource.cxx
1718
src/Container.cxx
1819
src/Objects.cxx
@@ -54,7 +55,8 @@ add_executable(o2-test-framework-aod-native
5455
test/test_Codecs.cxx
5556
test/test_Objects.cxx
5657
test/test_Decoder.cxx
57-
test/test_ReadPlan.cxx)
58+
test/test_ReadPlan.cxx
59+
test/test_Failover.cxx)
5860
target_include_directories(o2-test-framework-aod-native PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src)
5961
target_link_libraries(o2-test-framework-aod-native PRIVATE O2::AODNative O2::Catch2 PkgConfig::zstd PkgConfig::lz4 PkgConfig::lzma ZLIB::ZLIB)
6062

‎Framework/AODNative/DEVELOPMENT.md‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -229,10 +229,20 @@ ROOT's 50 MB TTreeCache. With the macOS allocator cache on, max RSS is higher
229229

230230
`XRootDByteSource` (root://, roots://, xroot://, xroots://), built when O2 finds
231231
XRootD (optional in O2); otherwise those URLs get an IoError, so `auto` falls
232-
back. Links only libXrdCl. `readvInto` sends a whole read plan at once: ranges
233-
up to 2097136 bytes go in vectored reads of at most 1024 chunks, larger ones
234-
in plain reads of at most 64 MiB, all asynchronous, then waits for all of them;
235-
a short or failed reply is an IoError. `readCalls()` counts requests sent.
232+
back. Links only libXrdCl. After opening it asks the data server for its
233+
kXR_readv limits (`readv_ior_max`, `readv_iov_max`), as TNetXNGFile does,
234+
keeping XrdCl's defaults (2097136 bytes, 1024 chunks) when it does not answer.
235+
`readvInto` sends ranges up to the chunk limit in vectored reads and larger ones
236+
in plain reads of at most 8 MiB, asynchronously, in rounds of at most
237+
`Options::remoteInFlightBytes` (32 MiB), waiting for each round; a short or
238+
failed reply is an IoError. `readCalls()` counts requests sent.
239+
240+
Until 2026-11-05 a whole plan went out at once, with plain reads of up to
241+
64 MiB. On the Grid, at ORNL reading Poznan EOS (slow transatlantic link), the
242+
native reader then failed 2-6x more often than ROOT, mostly with "Operation
243+
expired": requests sharing the link took longer than XRD_REQUESTTIMEOUT (150 s
244+
in the ALICE environment). Smaller requests and the in-flight limit keep each
245+
request well within it.
236246

237247
Tested against a local server (Homebrew xrootd 6.2.0, `all.export ~/src/data
238248
r/o`): byte-identical with both readers over root:// on hy_3993289/0065,
@@ -319,9 +329,16 @@ in DPL (analysis-histograms, LHC24ar) 380 vs 319, with identical results.
319329
sends `access read <lfn>` through `gGrid`, as `TJAlienFile::Open` does, and
320330
turns each replica into `root://<se>//<pfn>?&authz=<envelope>`. Files inside
321331
an archive (`#member`, on the LFN or the replica) are refused, so `auto` falls
322-
back to ROOT. `openNativeAOD()` tries the replicas in the order AliEn proposes,
323-
moving on after an IoError only (a format problem is the same on every
324-
replica); DPL's reader and the comparison tool both open files through it.
332+
back to ROOT. `openNativeAOD()` reads the replicas, in the order AliEn
333+
proposes, through `ByteSource::openReplicas()` (`FailoverByteSource`): the
334+
first replica which opens is read; a read which fails with an IoError, at any
335+
point of the file, is retried on that replica, reopened, up to
336+
`Options::readRetries` (2) times per replica with a doubling delay from
337+
`retryDelay` (2 s), and then continues on the next replica of the same size.
338+
Only IoErrors are retried: a format problem is the same on every replica. Each
339+
retry and change of replica is logged. ROOT only moves to another replica when
340+
opening fails, so this makes the native reader more robust than ROOT on flaky
341+
storage. DPL's reader and the comparison tool both open files through it.
325342
The file keeps its LFN as name: what DataInputDirector compares, what goes
326343
with derived output, and what monitoring reports, with `se=` and
327344
`open_time=` as for TJAlienFile. `XRootDByteSource` uses the full URL only to

‎Framework/AODNative/include/AODNative/ByteSource.h‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414

1515
#include <cstddef>
1616
#include <cstdint>
17+
#include <functional>
1718
#include <memory>
1819
#include <span>
1920
#include <string>
21+
#include <vector>
2022

2123
namespace o2::aod::native
2224
{
@@ -46,6 +48,23 @@ struct Options {
4648
size_t streamWindowBytes = 2 * 1024 * 1024;
4749
HttpVersion httpVersion = HttpVersion::Auto;
4850
int httpConnections = 4;
51+
/// For a source opened with ByteSource::openReplicas: how many times, at
52+
/// most, failed reads are retried on a replica (reopened) during the life of
53+
/// the file before moving on to the next replica, and the seconds waited
54+
/// before the first retry (doubled for each further one).
55+
int readRetries = 2;
56+
double retryDelay = 2.0;
57+
/// Most bytes of one read plan in flight at once on a remote file, so that
58+
/// each request finishes within the server timeout on a slow link (XRootD
59+
/// expires requests after XRD_REQUESTTIMEOUT, 150 s in the ALICE setup).
60+
size_t remoteInFlightBytes = 32 * 1024 * 1024;
61+
};
62+
63+
/// One copy of a file, for ByteSource::openReplicas: where it is, and a label
64+
/// (e.g. the storage element) to report which one is being read.
65+
struct Replica {
66+
std::string url;
67+
std::string label;
4968
};
5069

5170
struct ReadRequest {
@@ -77,7 +96,15 @@ class ByteSource
7796
virtual uint64_t bytesRead() const = 0;
7897
virtual uint64_t readCalls() const = 0;
7998
virtual std::string url() const = 0;
99+
/// The label of the replica being read, for a source opened with openReplicas().
100+
virtual std::string replicaLabel() const { return {}; }
80101
static std::unique_ptr<ByteSource> open(std::string url, const Options&);
102+
/// Opens the first replica which opens, and on a read error retries it
103+
/// (Options::readRetries times, reopened) and then moves on to the next
104+
/// replica of the same size. @a log, if given, is told about every retry
105+
/// and every change of replica. Throws IoError when no replica is left.
106+
static std::unique_ptr<ByteSource> openReplicas(std::vector<Replica> replicas, const Options&,
107+
std::function<void(std::string const&)> log = {});
81108
};
82109

83110
} // namespace o2::aod::native

‎Framework/AODNative/src/ByteSource.cxx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
#include "AODNative/ByteSource.h"
1313
#include "AODNative/Errors.h"
14+
#include "FailoverByteSource.h"
1415
#include "LocalByteSource.h"
1516
#ifdef AODNATIVE_WITH_XROOTD
1617
#include "XRootDByteSource.h"
@@ -59,4 +60,10 @@ std::unique_ptr<ByteSource> ByteSource::open(std::string url, const Options& opt
5960
return std::make_unique<LocalByteSource>(std::move(url), options);
6061
}
6162

63+
std::unique_ptr<ByteSource> ByteSource::openReplicas(std::vector<Replica> replicas, const Options& options,
64+
std::function<void(std::string const&)> log)
65+
{
66+
return std::make_unique<FailoverByteSource>(std::move(replicas), options, &ByteSource::open, std::move(log));
67+
}
68+
6269
} // namespace o2::aod::native
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright 2019-2026 CERN and copyright holders of ALICE O2.
2+
// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders.
3+
// All rights not expressly granted are reserved.
4+
//
5+
// This software is distributed under the terms of the GNU General Public
6+
// License v3 (GPL Version 3), copied verbatim in the file "COPYING".
7+
//
8+
// In applying this license CERN does not waive the privileges and immunities
9+
// granted to it by virtue of its status as an Intergovernmental Organization
10+
// or submit itself to any jurisdiction.
11+
12+
#include "FailoverByteSource.h"
13+
#include "AODNative/Errors.h"
14+
#include <chrono>
15+
#include <cmath>
16+
#include <thread>
17+
18+
namespace o2::aod::native
19+
{
20+
21+
namespace
22+
{
23+
std::string labelOf(Replica const& r)
24+
{
25+
return r.label.empty() ? r.url.substr(0, r.url.find('?')) : r.label;
26+
}
27+
} // namespace
28+
29+
FailoverByteSource::FailoverByteSource(std::vector<Replica> replicas, Options const& options, Opener opener, Log log)
30+
: mReplicas(std::move(replicas)), mOptions(options), mOpener(std::move(opener)), mLog(std::move(log))
31+
{
32+
if (mReplicas.empty()) {
33+
throw IoError("(no replica)", "no replica to open");
34+
}
35+
for (mIndex = 0; mIndex < mReplicas.size(); ++mIndex) {
36+
try {
37+
mSource = openReplica(mIndex);
38+
mSize = mSource->size();
39+
mRemote = mSource->isRemote();
40+
return;
41+
} catch (IoError const& e) {
42+
report("Could not open replica on " + labelOf(mReplicas[mIndex]) + ": " + e.what());
43+
mErrors += " " + labelOf(mReplicas[mIndex]) + ": " + e.what() + ";";
44+
}
45+
}
46+
throw IoError(labelOf(mReplicas.front()), "no replica could be opened:" + mErrors);
47+
}
48+
49+
std::shared_ptr<ByteSource> FailoverByteSource::openReplica(size_t index)
50+
{
51+
std::shared_ptr<ByteSource> source = mOpener(mReplicas[index].url, mOptions);
52+
if (mSource && source->size() != mSize) {
53+
throw IoError(source->url(), "size " + std::to_string(source->size()) + " differs from the " + std::to_string(mSize) + " bytes of the replica read so far");
54+
}
55+
return source;
56+
}
57+
58+
std::shared_ptr<ByteSource> FailoverByteSource::current() const
59+
{
60+
std::lock_guard lock{mMutex};
61+
return mSource;
62+
}
63+
64+
std::string FailoverByteSource::replicaLabel() const
65+
{
66+
std::lock_guard lock{mMutex};
67+
return mReplicas[mIndex].label;
68+
}
69+
70+
uint64_t FailoverByteSource::bytesRead() const
71+
{
72+
return mRetiredBytes.load(std::memory_order_relaxed) + current()->bytesRead();
73+
}
74+
75+
uint64_t FailoverByteSource::readCalls() const
76+
{
77+
return mRetiredCalls.load(std::memory_order_relaxed) + current()->readCalls();
78+
}
79+
80+
void FailoverByteSource::report(std::string const& message) const
81+
{
82+
if (mLog) {
83+
mLog(message);
84+
}
85+
}
86+
87+
void FailoverByteSource::readInto(uint64_t off, size_t len, void* dst)
88+
{
89+
withFailover([&](ByteSource& s) { s.readInto(off, len, dst); });
90+
}
91+
92+
void FailoverByteSource::readvInto(std::span<ReadRequest> requests)
93+
{
94+
withFailover([&](ByteSource& s) { s.readvInto(requests); });
95+
}
96+
97+
void FailoverByteSource::withFailover(std::function<void(ByteSource&)> const& read)
98+
{
99+
while (true) {
100+
std::shared_ptr<ByteSource> source;
101+
uint64_t generation;
102+
{
103+
std::lock_guard lock{mMutex};
104+
source = mSource;
105+
generation = mGeneration;
106+
}
107+
try {
108+
read(*source);
109+
return;
110+
} catch (IoError const& e) {
111+
std::lock_guard lock{mMutex};
112+
if (generation != mGeneration) {
113+
continue; // another read already replaced the replica: try the new one
114+
}
115+
mRetiredBytes.fetch_add(source->bytesRead(), std::memory_order_relaxed);
116+
mRetiredCalls.fetch_add(source->readCalls(), std::memory_order_relaxed);
117+
std::string error = e.what();
118+
// Reopen the same replica, after a growing delay, or move on to the next
119+
// one, until a replica opens. Other readers wait on mMutex meanwhile.
120+
while (true) {
121+
if (mAttempts < mOptions.readRetries) {
122+
double delay = mOptions.retryDelay * std::pow(2., mAttempts);
123+
++mAttempts;
124+
report("Read error on " + labelOf(mReplicas[mIndex]) + ", retry " + std::to_string(mAttempts) + " of " +
125+
std::to_string(mOptions.readRetries) + " in " + std::to_string((int)std::lround(delay)) + " s: " + error);
126+
std::this_thread::sleep_for(std::chrono::duration<double>(delay));
127+
} else {
128+
mErrors += " " + labelOf(mReplicas[mIndex]) + ": " + error + ";";
129+
if (mIndex + 1 >= mReplicas.size()) {
130+
throw IoError(labelOf(mReplicas.front()), "no replica left to read from:" + mErrors);
131+
}
132+
++mIndex;
133+
mAttempts = 0;
134+
report("Moving to replica on " + labelOf(mReplicas[mIndex]) + " after: " + error);
135+
}
136+
try {
137+
mSource = openReplica(mIndex);
138+
++mGeneration;
139+
break;
140+
} catch (IoError const& reopen) {
141+
error = reopen.what();
142+
if (mAttempts == 0) {
143+
mAttempts = mOptions.readRetries; // a replica that does not open is not retried
144+
}
145+
}
146+
}
147+
}
148+
}
149+
}
150+
151+
} // namespace o2::aod::native
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright 2019-2026 CERN and copyright holders of ALICE O2.
2+
// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders.
3+
// All rights not expressly granted are reserved.
4+
//
5+
// This software is distributed under the terms of the GNU General Public
6+
// License v3 (GPL Version 3), copied verbatim in the file "COPYING".
7+
//
8+
// In applying this license CERN does not waive the privileges and immunities
9+
// granted to it by virtue of its status as an Intergovernmental Organization
10+
// or submit itself to any jurisdiction.
11+
12+
#ifndef O2_FRAMEWORK_AODNATIVE_FAILOVERBYTESOURCE_H_
13+
#define O2_FRAMEWORK_AODNATIVE_FAILOVERBYTESOURCE_H_
14+
15+
#include "AODNative/ByteSource.h"
16+
#include <atomic>
17+
#include <functional>
18+
#include <mutex>
19+
20+
namespace o2::aod::native
21+
{
22+
23+
/// The replicas of one file, read as one ByteSource. A read which fails with
24+
/// an IoError is retried on the same replica, reopened, Options::readRetries
25+
/// times with a growing delay, and then on the next replica of the same size.
26+
/// Opening goes through the replicas in order too. Reads already in flight on
27+
/// a replica that is being replaced finish there, or fail and are retried.
28+
class FailoverByteSource : public ByteSource
29+
{
30+
public:
31+
using Opener = std::function<std::unique_ptr<ByteSource>(std::string const& url, Options const&)>;
32+
using Log = std::function<void(std::string const&)>;
33+
34+
/// Throws IoError when no replica opens.
35+
FailoverByteSource(std::vector<Replica> replicas, Options const& options, Opener opener, Log log);
36+
37+
uint64_t size() const override { return mSize; }
38+
void readInto(uint64_t off, size_t len, void* dst) override;
39+
void readvInto(std::span<ReadRequest> requests) override;
40+
std::span<const uint8_t> tryMap(uint64_t off, size_t len) override { return current()->tryMap(off, len); }
41+
bool canMap() const override { return current()->canMap(); }
42+
bool isRemote() const override { return mRemote; }
43+
void willNeed(uint64_t off, size_t len) override { current()->willNeed(off, len); }
44+
void dontNeed(uint64_t off, size_t len) override { current()->dontNeed(off, len); }
45+
uint64_t bytesRead() const override;
46+
uint64_t readCalls() const override;
47+
std::string url() const override { return current()->url(); }
48+
std::string replicaLabel() const override;
49+
50+
private:
51+
std::shared_ptr<ByteSource> current() const;
52+
/// Runs @a read on the current replica, retrying and failing over on IoError.
53+
void withFailover(std::function<void(ByteSource&)> const& read);
54+
/// Opens replica @a index; throws IoError, also when its size differs.
55+
std::shared_ptr<ByteSource> openReplica(size_t index);
56+
void report(std::string const& message) const;
57+
58+
std::vector<Replica> mReplicas;
59+
Options mOptions;
60+
Opener mOpener;
61+
Log mLog;
62+
63+
mutable std::mutex mMutex;
64+
std::shared_ptr<ByteSource> mSource; // guarded by mMutex
65+
size_t mIndex = 0; // guarded by mMutex
66+
uint64_t mGeneration = 0; // guarded by mMutex, bumped at every reopen
67+
int mAttempts = 0; // retries done on the current replica, guarded by mMutex
68+
std::string mErrors; // what went wrong on the replicas left behind
69+
70+
uint64_t mSize = 0;
71+
bool mRemote = false;
72+
std::atomic<uint64_t> mRetiredBytes = 0;
73+
std::atomic<uint64_t> mRetiredCalls = 0;
74+
};
75+
76+
} // namespace o2::aod::native
77+
78+
#endif // O2_FRAMEWORK_AODNATIVE_FAILOVERBYTESOURCE_H_

0 commit comments

Comments
 (0)