Skip to content

Commit 3eb1d6c

Browse files
committed
add EndOfStream signaling
1 parent b151e10 commit 3eb1d6c

18 files changed

Lines changed: 503 additions & 4 deletions

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ if(GTEST_LIBRARY)
265265
test/dtl_cancel.cpp
266266
test/dtl_config.cpp
267267
test/dtl_connection.cpp
268+
test/dtl_end_of_stream.cpp
268269
test/dtl_file_engine.cpp
269270
test/dtl_reduction.cpp
270271
test/dtl_staging_engine.cpp

ChangeLog

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,19 @@
33
DTLMod (0.6) not released yet (target: September 2026)
44

55
Improvements:
6+
- End-of-stream signaling. Once the last publisher closes an engine, a
7+
subscriber that requests a transaction which was never produced is now
8+
released with a new EndOfStreamException instead of blocking forever
9+
(mirroring the StepStatus::EndOfStream returned by ADIOS2's BeginStep).
10+
This lets a consumer terminate when its upstream stops. Implemented for
11+
both the Staging and File engines, for any number of subscribers. Exposed
12+
in the Python bindings as EndOfStreamException.
613
- New ReductionMethod::get_fidelity(var, transaction_id) returning the
714
fraction of the original information retained after reduction, in [0, 1].
815
Decimation reports the retained-element fraction; compression derives a
916
fidelity from the accuracy bound (shape and reduced size are uninformative
1017
for it), so subscribers can reason about data quality, not just volume.
18+
Exposed in the Python bindings as ReductionMethod.get_fidelity().
1119
- New Engine::cancel_pending_activities() and Engine::drain() helpers, now
1220
used by every transaction teardown path of the Staging and File engines,
1321
so that cancelling and emptying an ActivitySet is done in one place

include/dtlmod/DTLException.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ DECLARE_DTLMOD_EXCEPTION(InconsistentCompressionRatioException, "Inconsistent Co
7171
DECLARE_DTLMOD_EXCEPTION(SubscriberSideCompressionException, "Compression can only be applied on the publisher side");
7272

7373
DECLARE_DTLMOD_EXCEPTION(TransactionCanceledException, "Transaction canceled");
74+
DECLARE_DTLMOD_EXCEPTION(EndOfStreamException, "End of stream: all publishers have closed");
7475

7576
} // namespace dtlmod
7677

include/dtlmod/Engine.hpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class Engine {
5858

5959
bool pub_ever_present_ = false;
6060
std::atomic<unsigned int> canceled_transaction_id_{0};
61+
std::atomic<bool> pub_stream_ended_{false};
6162

6263
ActorRegistry publishers_;
6364

@@ -113,6 +114,11 @@ class Engine {
113114
return canceled_transaction_id_ == tx_id;
114115
}
115116

117+
// Set once the last publisher has closed the engine: no further transaction will ever be produced, so a subscriber
118+
// waiting for one that was never produced must be released with an EndOfStreamException rather than block forever.
119+
void mark_pub_stream_ended() noexcept { pub_stream_ended_ = true; }
120+
[[nodiscard]] bool pub_stream_ended() const noexcept { return pub_stream_ended_; }
121+
116122
// Pure virtual methods for derived classes to implement
117123
virtual void create_transport(const Transport::Method& transport_method) = 0;
118124
virtual void begin_pub_transaction() = 0;

src/FileEngine.cpp

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ void FileEngine::pub_close()
186186
transport->close_pub_files();
187187
XBT_DEBUG("Engine '%s' is now closed for all publishers ", get_cname());
188188
get_stream()->export_metadata_to_file();
189+
// No more transactions will ever be produced: release any subscriber blocked waiting for one.
190+
mark_pub_stream_ended();
191+
pub_transaction_completed_->notify_all();
189192
}
190193
}
191194

@@ -205,15 +208,24 @@ void FileEngine::begin_sub_transaction()
205208
if (not get_publishers().is_empty()) {
206209
std::unique_lock lock(*get_subscribers().get_mutex());
207210
while (!is_transaction_canceled(current_sub_transaction_id_) &&
208-
completed_pub_transaction_id_ < current_sub_transaction_id_) {
211+
completed_pub_transaction_id_ < current_sub_transaction_id_ && !pub_stream_ended()) {
209212
XBT_DEBUG("Wait for publishers to end the transaction I need");
210213
pub_transaction_completed_->wait(lock);
211214
}
212215
if (is_transaction_canceled(current_sub_transaction_id_)) {
213216
sub_transaction_in_progress_ = false;
214217
throw TransactionCanceledException(XBT_THROW_POINT);
215218
}
219+
if (completed_pub_transaction_id_ < current_sub_transaction_id_ && pub_stream_ended()) {
220+
sub_transaction_in_progress_ = false;
221+
throw EndOfStreamException(XBT_THROW_POINT);
222+
}
216223
XBT_DEBUG("Publishers stored metadata for that transaction, proceed");
224+
} else if (pub_stream_ended() && completed_pub_transaction_id_ < current_sub_transaction_id_) {
225+
// All publishers have already closed (so the wait above is skipped) and the transaction we want was never
226+
// produced. Without this the subscriber would fall through and read data that does not exist.
227+
sub_transaction_in_progress_ = false;
228+
throw EndOfStreamException(XBT_THROW_POINT);
217229
}
218230
}
219231

src/StagingEngine.cpp

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ void StagingEngine::pub_close()
156156
close_stream();
157157
XBT_DEBUG("Engine '%s' is now closed for all publishers ", get_cname());
158158
get_stream()->export_metadata_to_file();
159+
// No more transactions will ever be produced: release any subscriber blocked (or about to block) waiting for one.
160+
mark_pub_stream_ended();
161+
first_pub_transaction_started_->notify_all();
162+
pub_transaction_completed_->notify_all();
159163
}
160164
}
161165

@@ -167,10 +171,14 @@ void StagingEngine::begin_sub_transaction()
167171
if (current_sub_transaction_id_ == 0) { // This is the first transaction
168172
// Wait for at least one publisher to start a tran
169173
std::unique_lock lock(*get_subscribers().get_mutex());
170-
while (!is_transaction_canceled(current_sub_transaction_id_ + 1) && current_pub_transaction_id_ == 0)
174+
while (!is_transaction_canceled(current_sub_transaction_id_ + 1) && current_pub_transaction_id_ == 0 &&
175+
!pub_stream_ended())
171176
first_pub_transaction_started_->wait(lock);
172177
if (is_transaction_canceled(current_sub_transaction_id_ + 1))
173178
throw TransactionCanceledException(XBT_THROW_POINT);
179+
// All publishers closed before ever starting a transaction: nothing will come (no counters touched yet).
180+
if (current_pub_transaction_id_ == 0 && pub_stream_ended())
181+
throw EndOfStreamException(XBT_THROW_POINT);
174182
XBT_DEBUG("Publishers have started a transaction, create rendez-vous points");
175183
// We now know the number of publishers, subscriber can create mailboxes/mqs with publishers
176184
get_staging_transport()->create_rendez_vous_points();
@@ -194,13 +202,20 @@ void StagingEngine::begin_sub_transaction()
194202

195203
std::unique_lock lock(*get_subscribers().get_mutex());
196204
while (!is_transaction_canceled(current_sub_transaction_id_) &&
197-
completed_pub_transaction_id_ < current_sub_transaction_id_)
205+
completed_pub_transaction_id_ < current_sub_transaction_id_ && !pub_stream_ended())
198206
pub_transaction_completed_->wait(lock);
199207
if (is_transaction_canceled(current_sub_transaction_id_)) {
200208
sub_transaction_in_progress_ = false;
201209
num_subscribers_starting_--;
202210
throw TransactionCanceledException(XBT_THROW_POINT);
203211
}
212+
// Woken by the last publisher closing while the transaction we want was never produced: end of stream. Roll back the
213+
// same per-subscriber bookkeeping as the cancel path so the shared counters stay balanced across subscribers.
214+
if (completed_pub_transaction_id_ < current_sub_transaction_id_ && pub_stream_ended()) {
215+
sub_transaction_in_progress_ = false;
216+
num_subscribers_starting_--;
217+
throw EndOfStreamException(XBT_THROW_POINT);
218+
}
204219
}
205220

206221
void StagingEngine::end_sub_transaction()

src/bindings/python/dtlmod_python.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ PYBIND11_MODULE(dtlmod, m)
9090
py::register_exception<dtlmod::SubscriberSideCompressionException>(m, "SubscriberSideCompressionException");
9191

9292
py::register_exception<dtlmod::TransactionCanceledException>(m, "TransactionCanceledException");
93+
py::register_exception<dtlmod::EndOfStreamException>(m, "EndOfStreamException");
9394

9495
/* Class Engine */
9596
py::class_<Engine, std::shared_ptr<Engine>> engine(
@@ -260,6 +261,8 @@ PYBIND11_MODULE(dtlmod, m)
260261
py::arg("transaction_id") = 0, "Get the reduced local size of a Variable")
261262
.def("get_reduced_variable_shape", &ReductionMethod::get_reduced_variable_shape, py::arg("var"),
262263
"Get the reduced shape of a Variable")
264+
.def("get_fidelity", &ReductionMethod::get_fidelity, py::arg("var"), py::arg("transaction_id") = 0,
265+
"Get the fraction of the original information retained after reduction, in [0, 1] (1.0 = lossless)")
263266
.def("get_flop_amount_to_reduce_variable", &ReductionMethod::get_flop_amount_to_reduce_variable, py::arg("var"),
264267
"Get the flop cost to reduce a Variable")
265268
.def("get_flop_amount_to_decompress_variable", &ReductionMethod::get_flop_amount_to_decompress_variable,

test/dtl_end_of_stream.cpp

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/* Copyright (c) 2026. The SWAT Team. All rights reserved. */
2+
3+
/* This program is free software; you can redistribute it and/or modify it
4+
* under the terms of the license (GNU LGPL) which comes with this package. */
5+
6+
#include <gtest/gtest.h>
7+
8+
#include <array>
9+
10+
#include <fsmod/FileSystem.hpp>
11+
#include <fsmod/JBODStorage.hpp>
12+
#include <fsmod/OneDiskStorage.hpp>
13+
14+
#include <simgrid/s4u/Actor.hpp>
15+
#include <simgrid/s4u/Engine.hpp>
16+
#include <simgrid/s4u/Host.hpp>
17+
18+
#include "./test_util.hpp"
19+
#include "dtlmod/DTL.hpp"
20+
#include "dtlmod/DTLException.hpp"
21+
22+
XBT_LOG_NEW_DEFAULT_CATEGORY(dtlmod_test_eos, "Logging category for this dtlmod test");
23+
24+
namespace sg4 = simgrid::s4u;
25+
namespace sgfs = simgrid::fsmod;
26+
27+
// End-of-stream: once every publisher has closed its engine, a subscriber that asks for a transaction which was never
28+
// produced must be released with an EndOfStreamException (instead of blocking forever). This is what lets a consumer
29+
// terminate when its upstream stops, rather than hang. Each test has a publisher produce a fixed number of
30+
// transactions and then close; the subscriber loops until it catches the exception.
31+
//
32+
// The per-subscriber outcome (how many transactions were read, whether end-of-stream was reached) is recorded in
33+
// variables captured by reference and asserted AFTER Engine::run() returns -- never inside the subscriber actor. If
34+
// the mechanism were missing the subscriber would deadlock, run() would return with the actor still blocked, and an
35+
// in-actor assertion would simply never execute (a false pass). Asserting after run() turns that hang into a failure.
36+
class DTLEndOfStreamTest : public ::testing::Test {
37+
public:
38+
DTLEndOfStreamTest() = default;
39+
40+
sg4::NetZone* add_cluster(sg4::NetZone* root, const std::string& suffix, const int num_hosts)
41+
{
42+
auto* cluster = root->add_netzone_star("cluster" + suffix);
43+
cluster->set_gateway(cluster->add_router("cluster" + suffix + "-router"));
44+
auto* backbone = cluster->add_link("backbone" + suffix, "100Gbps")->set_latency("100us");
45+
for (int i = 0; i < num_hosts; i++) {
46+
std::string name = "host-" + std::to_string(i) + suffix;
47+
const auto* host = cluster->add_host(name, "1Gf");
48+
const auto* link = cluster->add_link(name + "_link", "10Gbps")->set_latency("10us");
49+
cluster->add_route(host, nullptr, {link, backbone});
50+
}
51+
cluster->seal();
52+
return cluster;
53+
}
54+
55+
void setup_staging_platform()
56+
{
57+
auto* root = sg4::Engine::get_instance()->get_netzone_root();
58+
auto* internet = root->add_link("internet", "500MBps")->set_latency("1ms");
59+
auto* prod_cluster = add_cluster(root, ".prod", 4);
60+
auto* cons_cluster = add_cluster(root, ".cons", 4);
61+
root->add_route(prod_cluster, cons_cluster, {internet});
62+
root->seal();
63+
dtlmod::DTL::create();
64+
}
65+
66+
void setup_file_platform()
67+
{
68+
sg4::NetZone* cluster = sg4::Engine::get_instance()->get_netzone_root()->add_netzone_star("cluster");
69+
auto pfs_server = cluster->add_host("pfs_server", "1Gf");
70+
std::vector<sg4::Disk*> pfs_disks;
71+
for (int i = 0; i < 4; i++)
72+
pfs_disks.push_back(pfs_server->add_disk("pfs_disk" + std::to_string(i), "2.5GBps", "1.2GBps"));
73+
auto remote_storage = sgfs::JBODStorage::create("pfs_storage", pfs_disks);
74+
remote_storage->set_raid_level(sgfs::JBODStorage::RAID::RAID5);
75+
76+
std::vector<std::shared_ptr<sgfs::OneDiskStorage>> local_storages;
77+
for (int i = 0; i < 4; i++) {
78+
std::string hostname = "node-" + std::to_string(i);
79+
auto* host = cluster->add_host(hostname, "1Gf");
80+
auto* disk = host->add_disk(hostname + "_disk", "5.5GBps", "2.1GBps");
81+
local_storages.push_back(sgfs::OneDiskStorage::create(hostname + "_local_storage", disk));
82+
std::string linkname = "link_" + std::to_string(i);
83+
auto* link_up = cluster->add_link(linkname + "_UP", "1Gbps");
84+
auto* link_down = cluster->add_link(linkname + "_DOWN", "1Gbps");
85+
auto* loopback =
86+
cluster->add_link(hostname + "_loopback", "10Gbps")->set_sharing_policy(sg4::Link::SharingPolicy::FATPIPE);
87+
cluster->add_route(host, nullptr, {sg4::LinkInRoute(link_up)}, false);
88+
cluster->add_route(nullptr, host, {sg4::LinkInRoute(link_down)}, false);
89+
cluster->add_route(host, host, {loopback});
90+
}
91+
cluster->seal();
92+
93+
auto my_fs = sgfs::FileSystem::create("my_fs");
94+
sgfs::FileSystem::register_file_system(cluster, my_fs);
95+
my_fs->mount_partition("/pfs/", remote_storage, "500TB");
96+
for (int i = 0; i < 4; i++)
97+
my_fs->mount_partition("/node-" + std::to_string(i) + "/scratch/", local_storages.at(i), "1TB");
98+
99+
dtlmod::DTL::create();
100+
}
101+
102+
// Publisher actor body shared by the tests: produce n_tx transactions then close.
103+
static void publish_n(dtlmod::Engine::Type type, dtlmod::Transport::Method method, const std::string& engine_name,
104+
int n_tx)
105+
{
106+
auto dtl = dtlmod::DTL::connect();
107+
auto stream = dtl->add_stream("my-output");
108+
stream->set_engine_type(type);
109+
stream->set_transport_method(method);
110+
auto var = stream->define_variable("var", {100, 100}, {0, 0}, {100, 100}, sizeof(double));
111+
auto engine = stream->open(engine_name, dtlmod::Stream::Mode::Publish);
112+
for (int i = 0; i < n_tx; i++) {
113+
engine->begin_transaction();
114+
engine->put(var);
115+
engine->end_transaction();
116+
}
117+
engine->close();
118+
dtlmod::DTL::disconnect();
119+
}
120+
121+
// Subscriber actor body: read until end-of-stream, recording the outcome through the referenced variables.
122+
static void consume_until_eos(const std::string& engine_name, int& reads, bool& eos)
123+
{
124+
auto dtl = dtlmod::DTL::connect();
125+
auto stream = dtl->add_stream("my-output");
126+
auto engine = stream->open(engine_name, dtlmod::Stream::Mode::Subscribe);
127+
auto var_sub = stream->inquire_variable("var");
128+
var_sub->set_selection({0, 0}, {100, 100});
129+
try {
130+
while (true) {
131+
engine->begin_transaction();
132+
engine->get(var_sub);
133+
engine->end_transaction();
134+
reads++;
135+
}
136+
} catch (const dtlmod::EndOfStreamException&) {
137+
eos = true;
138+
}
139+
engine->close();
140+
dtlmod::DTL::disconnect();
141+
}
142+
};
143+
144+
TEST_F(DTLEndOfStreamTest, StagingSingleSubscriber_MQ)
145+
{
146+
DO_TEST_WITH_FORK([this]() {
147+
this->setup_staging_platform();
148+
int reads = 0;
149+
bool eos = false;
150+
sg4::Host::by_name("host-0.prod")->add_actor("Pub", []() {
151+
publish_n(dtlmod::Engine::Type::Staging, dtlmod::Transport::Method::MQ, "my-output", 2);
152+
});
153+
sg4::Host::by_name("host-0.cons")->add_actor("Sub", [&reads, &eos]() {
154+
consume_until_eos("my-output", reads, eos);
155+
});
156+
ASSERT_NO_THROW(sg4::Engine::get_instance()->run());
157+
ASSERT_TRUE(eos);
158+
ASSERT_EQ(reads, 2);
159+
});
160+
}
161+
162+
TEST_F(DTLEndOfStreamTest, StagingSingleSubscriber_Mailbox)
163+
{
164+
DO_TEST_WITH_FORK([this]() {
165+
this->setup_staging_platform();
166+
int reads = 0;
167+
bool eos = false;
168+
sg4::Host::by_name("host-0.prod")->add_actor("Pub", []() {
169+
publish_n(dtlmod::Engine::Type::Staging, dtlmod::Transport::Method::Mailbox, "my-output", 3);
170+
});
171+
sg4::Host::by_name("host-0.cons")->add_actor("Sub", [&reads, &eos]() {
172+
consume_until_eos("my-output", reads, eos);
173+
});
174+
ASSERT_NO_THROW(sg4::Engine::get_instance()->run());
175+
ASSERT_TRUE(eos);
176+
ASSERT_EQ(reads, 3);
177+
});
178+
}
179+
180+
// Two subscribers sharing the same Staging engine must both reach end-of-stream. This is the case that exercises the
181+
// per-subscriber rollback of num_subscribers_starting_ on the EOS throw: an imbalance there would desynchronize the
182+
// publisher/subscriber rendez-vous and either deadlock or crash.
183+
TEST_F(DTLEndOfStreamTest, StagingMultipleSubscribers_MQ)
184+
{
185+
DO_TEST_WITH_FORK([this]() {
186+
this->setup_staging_platform();
187+
std::array<int, 2> reads = {0, 0};
188+
std::array<bool, 2> eos = {false, false};
189+
sg4::Host::by_name("host-0.prod")->add_actor("Pub", []() {
190+
publish_n(dtlmod::Engine::Type::Staging, dtlmod::Transport::Method::MQ, "my-output", 2);
191+
});
192+
for (int s = 0; s < 2; s++)
193+
sg4::Host::by_name("host-" + std::to_string(s) + ".cons")
194+
->add_actor("Sub" + std::to_string(s),
195+
[&reads, &eos, s]() { consume_until_eos("my-output", reads[s], eos[s]); });
196+
ASSERT_NO_THROW(sg4::Engine::get_instance()->run());
197+
for (int s = 0; s < 2; s++) {
198+
ASSERT_TRUE(eos[s]) << "subscriber " << s << " did not reach end of stream";
199+
ASSERT_EQ(reads[s], 2) << "subscriber " << s << " read a wrong number of transactions";
200+
}
201+
});
202+
}
203+
204+
TEST_F(DTLEndOfStreamTest, FileEngineSingleSubscriber)
205+
{
206+
DO_TEST_WITH_FORK([this]() {
207+
this->setup_file_platform();
208+
int reads = 0;
209+
bool eos = false;
210+
const std::string engine_name = "cluster:my_fs:/node-0/scratch/my-output";
211+
sg4::Host::by_name("node-0")->add_actor("Pub", [engine_name]() {
212+
publish_n(dtlmod::Engine::Type::File, dtlmod::Transport::Method::File, engine_name, 2);
213+
});
214+
sg4::Host::by_name("node-1")->add_actor(
215+
"Sub", [&reads, &eos, engine_name]() { consume_until_eos(engine_name, reads, eos); });
216+
ASSERT_NO_THROW(sg4::Engine::get_instance()->run());
217+
ASSERT_TRUE(eos);
218+
ASSERT_EQ(reads, 2);
219+
});
220+
}

test/python/dtl_cancel.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ def canceller_actor():
348348
run_test_cancel_staging_mid_transaction_mailbox,
349349
]
350350

351+
all_passed = True
351352
for test in tests:
352353
print(f"\nRun {test.__name__} ...")
353354
p = multiprocessing.Process(target=test)
@@ -356,5 +357,9 @@ def canceller_actor():
356357

357358
if p.exitcode != 0:
358359
print(f"FAILED: {test.__name__} (exit code {p.exitcode})")
360+
all_passed = False
359361
else:
360362
print(f"PASSED: {test.__name__}")
363+
364+
if not all_passed:
365+
sys.exit(1)

0 commit comments

Comments
 (0)