Commit f9561cb6 authored by Dominik Charousset's avatar Dominik Charousset

Implement prefix_and_tail flow operator

parent 5bbe822f
...@@ -288,6 +288,7 @@ caf_add_component( ...@@ -288,6 +288,7 @@ caf_add_component(
flow.for_each flow.for_each
flow.merge flow.merge
flow.observe_on flow.observe_on
flow.prefix_and_tail
flow.single flow.single
function_view function_view
fused_downstream_manager fused_downstream_manager
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
#include "caf/async/consumer.hpp" #include "caf/async/consumer.hpp"
#include "caf/async/producer.hpp" #include "caf/async/producer.hpp"
#include "caf/async/spsc_buffer.hpp" #include "caf/async/spsc_buffer.hpp"
#include "caf/cow_tuple.hpp"
#include "caf/defaults.hpp" #include "caf/defaults.hpp"
#include "caf/detail/core_export.hpp" #include "caf/detail/core_export.hpp"
#include "caf/disposable.hpp" #include "caf/disposable.hpp"
...@@ -157,19 +158,24 @@ public: ...@@ -157,19 +158,24 @@ public:
/// Registers a callback for `on_complete` events. /// Registers a callback for `on_complete` events.
template <class F> template <class F>
auto do_on_complete(F f) { auto do_on_complete(F f) {
return transform(on_complete_step<T, F>{std::move(f)}); return transform(do_on_complete_step<T, F>{std::move(f)});
} }
/// Registers a callback for `on_error` events. /// Registers a callback for `on_error` events.
template <class F> template <class F>
auto do_on_error(F f) { auto do_on_error(F f) {
return transform(on_error_step<T, F>{std::move(f)}); return transform(do_on_error_step<T, F>{std::move(f)});
} }
/// Registers a callback that runs on `on_complete` or `on_error`. /// Registers a callback that runs on `on_complete` or `on_error`.
template <class F> template <class F>
auto do_finally(F f) { auto do_finally(F f) {
return transform(finally_step<T, F>{std::move(f)}); return transform(do_finally_step<T, F>{std::move(f)});
}
/// Catches errors by converting them into errors instead.
auto on_error_complete() {
return transform(on_error_complete_step<T>{});
} }
/// Returns a transformation that selects only the first `n` items. /// Returns a transformation that selects only the first `n` items.
...@@ -213,6 +219,18 @@ public: ...@@ -213,6 +219,18 @@ public:
template <class F> template <class F>
auto concat_map(F f); auto concat_map(F f);
/// Takes @p prefix_size elements from this observable and emits it in a tuple
/// containing an observable for the remaining elements as the second value.
/// The returned observable either emits a single element (the tuple) or none
/// if this observable never produces sufficient elements for the prefix.
/// @pre `prefix_size > 0`
observable<cow_tuple<std::vector<T>, observable<T>>>
prefix_and_tail(size_t prefix_size);
/// Similar to `prefix_and_tail(1)` but passes the single element directly in
/// the tuple instead of wrapping it in a list.
observable<cow_tuple<T, observable<T>>> head_and_tail();
/// Creates an asynchronous resource that makes emitted items available in a /// Creates an asynchronous resource that makes emitted items available in a
/// spsc buffer. /// spsc buffer.
async::consumer_resource<T> to_resource(size_t buffer_size, async::consumer_resource<T> to_resource(size_t buffer_size,
...@@ -323,6 +341,15 @@ public: ...@@ -323,6 +341,15 @@ public:
return lift().concat_map(std::move(f)); return lift().concat_map(std::move(f));
} }
observable<cow_tuple<std::vector<T>, observable<T>>>
prefix_and_tail(size_t prefix_size) && {
return lift().prefix_and_tail(prefix_size);
}
observable<cow_tuple<T, observable<T>>> head_and_tail() && {
return lift().head_and_tail();
}
disposable subscribe(observer<T> what) && { disposable subscribe(observer<T> what) && {
return lift().subscribe(std::move(what)); return lift().subscribe(std::move(what));
} }
...@@ -425,7 +452,7 @@ private: ...@@ -425,7 +452,7 @@ private:
intrusive_ptr<impl> pimpl_; intrusive_ptr<impl> pimpl_;
}; };
template <class In, class Out> template <class In, class Out = In>
using processor_impl = typename processor<In, Out>::impl; using processor_impl = typename processor<In, Out>::impl;
// -- representing an error as an observable ----------------------------------- // -- representing an error as an observable -----------------------------------
...@@ -950,21 +977,8 @@ public: ...@@ -950,21 +977,8 @@ public:
// nop // nop
} }
std::tuple<Step, Steps...> steps; void on_complete() override {
super::sub_ = nullptr;
private:
bool do_on_next(span<const input_type> items) override {
auto f = [this, items](auto& step, auto&... steps) {
term_step<output_type> term{this};
for (auto&& item : items)
if (!step.on_next(item, steps..., term))
return false;
return true;
};
return std::apply(f, steps);
}
void do_on_complete() override {
auto f = [this](auto& step, auto&... steps) { auto f = [this](auto& step, auto&... steps) {
term_step<output_type> term{this}; term_step<output_type> term{this};
step.on_complete(steps..., term); step.on_complete(steps..., term);
...@@ -972,13 +986,28 @@ public: ...@@ -972,13 +986,28 @@ public:
std::apply(f, steps); std::apply(f, steps);
} }
void do_on_error(const error& what) override { void on_error(const error& what) override {
super::sub_ = nullptr;
auto f = [this, &what](auto& step, auto&... steps) { auto f = [this, &what](auto& step, auto&... steps) {
term_step<output_type> term{this}; term_step<output_type> term{this};
step.on_error(what, steps..., term); step.on_error(what, steps..., term);
}; };
std::apply(f, steps); std::apply(f, steps);
} }
std::tuple<Step, Steps...> steps;
private:
bool do_on_next(span<const input_type> items) override {
auto f = [this, items](auto& step, auto&... steps) {
term_step<output_type> term{this};
for (auto&& item : items)
if (!step.on_next(item, steps..., term))
return false;
return true;
};
return std::apply(f, steps);
}
}; };
template <class Tuple> template <class Tuple>
...@@ -1025,22 +1054,26 @@ public: ...@@ -1025,22 +1054,26 @@ public:
template <class F> template <class F>
auto do_on_complete(F f) && { auto do_on_complete(F f) && {
return std::move(*this) // return std::move(*this) //
.transform(on_complete_step<output_type, F>{std::move(f)}); .transform(do_on_complete_step<output_type, F>{std::move(f)});
} }
template <class F> template <class F>
auto do_on_error(F f) && { auto do_on_error(F f) && {
return std::move(*this) // return std::move(*this) //
.transform(on_error_step<output_type, F>{std::move(f)}); .transform(do_on_error_step<output_type, F>{std::move(f)});
} }
template <class F> template <class F>
auto do_finally(F f) && { auto do_finally(F f) && {
return std::move(*this) // return std::move(*this) //
.transform(finally_step<output_type, F>{std::move(f)}); .transform(do_finally_step<output_type, F>{std::move(f)});
}
auto on_error_complete() {
return std::move(*this) //
.transform(on_error_complete_step<output_type>{});
} }
observable<output_type> as_observable() && override { observable<output_type> as_observable() && {
auto pimpl = make_counted<impl>(source_.ptr()->ctx(), std::move(steps_)); auto pimpl = make_counted<impl>(source_.ptr()->ctx(), std::move(steps_));
auto res = pimpl->as_observable(); auto res = pimpl->as_observable();
source_.subscribe(observer<input_type>{std::move(pimpl)}); source_.subscribe(observer<input_type>{std::move(pimpl)});
...@@ -1511,6 +1544,219 @@ auto observable<T>::concat_map(F f) { ...@@ -1511,6 +1544,219 @@ auto observable<T>::concat_map(F f) {
return obs->merger(); return obs->merger();
} }
// -- observable::prefix_and_tail ----------------------------------------------
template <class T>
class prefix_and_tail_observable_impl final
: public ref_counted,
public observable_impl<T>, // For the forwarding to the 'tail'.
public processor_impl<T, cow_tuple<std::vector<T>, observable<T>>> {
public:
// -- member types -----------------------------------------------------------
using in_t = T;
using out_t = cow_tuple<std::vector<T>, observable<T>>;
using in_obs_t = observable_impl<in_t>;
using out_obs_t = observable_impl<out_t>;
// -- constructors, destructors, and assignment operators --------------------
prefix_and_tail_observable_impl(coordinator* ctx, size_t prefix_size)
: ctx_(ctx), prefix_size_(prefix_size) {
// nop
}
// -- friends ----------------------------------------------------------------
CAF_INTRUSIVE_PTR_FRIENDS(prefix_and_tail_observable_impl)
// -- implementation of disposable::impl -------------------------------------
void dispose() override {
if (sub_) {
sub_.cancel();
sub_ = nullptr;
}
if (obs_) {
obs_.on_complete();
obs_ = nullptr;
}
if (tail_) {
tail_.on_complete();
tail_ = nullptr;
}
}
bool disposed() const noexcept override {
return !sub_ && !obs_ && !tail_;
}
void ref_disposable() const noexcept override {
this->ref();
}
void deref_disposable() const noexcept override {
this->deref();
}
// -- implementation of observable<in_t>::impl -------------------------------
coordinator* ctx() const noexcept override {
return ctx_;
}
disposable subscribe(observer<in_t> sink) override {
if (sink.ptr() == tail_.ptr()) {
return in_obs_t::do_subscribe(sink.ptr());
} else {
sink.on_error(make_error(sec::invalid_observable));
return disposable{};
}
}
void on_request(observer_impl<in_t>*, size_t n) override {
if (sub_)
sub_.request(n);
}
void on_cancel(observer_impl<in_t>*) override {
if (sub_) {
sub_.cancel();
sub_ = nullptr;
}
}
// -- implementation of observable<out_t>::impl ------------------------------
disposable subscribe(observer<out_t> sink) override {
obs_ = sink;
return out_obs_t::do_subscribe(sink.ptr());
}
void on_request(observer_impl<out_t>*, size_t) override {
if (sub_ && !requested_prefix_) {
requested_prefix_ = true;
sub_.request(prefix_size_);
}
}
void on_cancel(observer_impl<out_t>*) override {
// Only has an effect when canceling immediately. Otherwise, we forward to
// tail_ and the original observer no longer is of any interest since it
// receives at most one item anyways.
if (sub_ && !tail_) {
sub_.cancel();
sub_ = nullptr;
}
}
// -- implementation of observer<in_t>::impl ---------------------------------
void on_subscribe(subscription sub) override {
if (!had_subscriber_) {
had_subscriber_ = true;
sub_ = std::move(sub);
} else {
sub.cancel();
}
}
void on_next(span<const in_t> items) override {
if (tail_) {
tail_.on_next(items);
} else if (obs_) {
CAF_ASSERT(prefix_.size() + items.size() <= prefix_size_);
prefix_.insert(prefix_.end(), items.begin(), items.end());
if (prefix_.size() >= prefix_size_) {
auto tptr = make_counted<broadcaster_impl<in_t>>(ctx_);
tail_ = tptr->as_observer();
static_cast<observable_impl<in_t>*>(this)->subscribe(tail_);
auto item = make_cow_tuple(std::move(prefix_), tptr->as_observable());
obs_.on_next(make_span(&item, 1));
obs_.on_complete();
obs_ = nullptr;
}
}
}
void on_complete() override {
sub_ = nullptr;
if (obs_) {
obs_.on_complete();
obs_ = nullptr;
}
if (tail_) {
tail_.on_complete();
tail_ = nullptr;
}
}
void on_error(const error& what) override {
sub_ = nullptr;
if (obs_) {
obs_.on_error(what);
obs_ = nullptr;
}
if (tail_) {
tail_.on_error(what);
tail_ = nullptr;
}
}
private:
/// Our context.
coordinator* ctx_;
/// Subscription to the input data.
subscription sub_;
/// Handle for the observer that gets the prefix + tail tuple.
observer<out_t> obs_;
/// Handle to the tail for forwarding any data after the prefix.
observer<in_t> tail_;
/// Makes sure we only respect the first subscriber.
bool had_subscriber_ = false;
/// Stores whether we have requested the prefix;
bool requested_prefix_ = false;
/// Buffer for storing the prefix elements.
std::vector<in_t> prefix_;
/// User-defined size of the prefix.
size_t prefix_size_;
};
template <class T>
observable<cow_tuple<std::vector<T>, observable<T>>>
observable<T>::prefix_and_tail(size_t prefix_size) {
using impl_t = prefix_and_tail_observable_impl<T>;
using out_t = cow_tuple<std::vector<T>, observable<T>>;
auto obs = make_counted<impl_t>(pimpl_->ctx(), prefix_size);
pimpl_->subscribe(obs->as_observer());
return static_cast<observable_impl<out_t>*>(obs.get())->as_observable();
}
// -- observable::prefix_and_tail ----------------------------------------------
template <class T>
observable<cow_tuple<T, observable<T>>> observable<T>::head_and_tail() {
using tuple_t = cow_tuple<std::vector<T>, observable<T>>;
return prefix_and_tail(1)
.map([](const tuple_t& tup) {
auto& [prefix, tail] = tup.data();
CAF_ASSERT(prefix.size() == 1);
return make_cow_tuple(prefix.front(), tail);
})
.as_observable();
}
// -- observable::to_resource -------------------------------------------------- // -- observable::to_resource --------------------------------------------------
/// Reads from an observable buffer and emits the consumed items. /// Reads from an observable buffer and emits the consumed items.
......
...@@ -426,6 +426,10 @@ private: ...@@ -426,6 +426,10 @@ private:
namespace caf::flow { namespace caf::flow {
/// Creates an observer from given callbacks.
/// @param on_next Callback for handling incoming elements.
/// @param on_error Callback for handling an error.
/// @param on_complete Callback for handling the end-of-stream event.
template <class OnNext, class OnError, class OnComplete> template <class OnNext, class OnError, class OnComplete>
auto make_observer(OnNext on_next, OnError on_error, OnComplete on_complete) { auto make_observer(OnNext on_next, OnError on_error, OnComplete on_complete) {
using impl_type = detail::default_observer_impl<OnNext, OnError, OnComplete>; using impl_type = detail::default_observer_impl<OnNext, OnError, OnComplete>;
...@@ -435,6 +439,9 @@ auto make_observer(OnNext on_next, OnError on_error, OnComplete on_complete) { ...@@ -435,6 +439,9 @@ auto make_observer(OnNext on_next, OnError on_error, OnComplete on_complete) {
return observer<input_type>{std::move(ptr)}; return observer<input_type>{std::move(ptr)};
} }
/// Creates an observer from given callbacks.
/// @param on_next Callback for handling incoming elements.
/// @param on_error Callback for handling an error.
template <class OnNext, class OnError> template <class OnNext, class OnError>
auto make_observer(OnNext on_next, OnError on_error) { auto make_observer(OnNext on_next, OnError on_error) {
using impl_type = detail::default_observer_impl<OnNext, OnError>; using impl_type = detail::default_observer_impl<OnNext, OnError>;
...@@ -443,6 +450,8 @@ auto make_observer(OnNext on_next, OnError on_error) { ...@@ -443,6 +450,8 @@ auto make_observer(OnNext on_next, OnError on_error) {
return observer<input_type>{std::move(ptr)}; return observer<input_type>{std::move(ptr)};
} }
/// Creates an observer from given callbacks.
/// @param on_next Callback for handling incoming elements.
template <class OnNext> template <class OnNext>
auto make_observer(OnNext on_next) { auto make_observer(OnNext on_next) {
using impl_type = detail::default_observer_impl<OnNext>; using impl_type = detail::default_observer_impl<OnNext>;
...@@ -451,4 +460,17 @@ auto make_observer(OnNext on_next) { ...@@ -451,4 +460,17 @@ auto make_observer(OnNext on_next) {
return observer<input_type>{std::move(ptr)}; return observer<input_type>{std::move(ptr)};
} }
/// Creates an observer from a smart pointer to a custom object that implements
/// `on_next`, `on_error` and `on_complete` as member functions.
/// @param ptr Smart pointer to a custom object.
template <class SmartPointer>
auto make_observer_from_ptr(SmartPointer ptr) {
using obj_t = std::remove_reference_t<decltype(*ptr)>;
using on_next_fn = decltype(&obj_t::on_next);
using value_type = typename detail::on_next_trait_t<on_next_fn>::value_type;
return make_observer([ptr](const value_type& x) { ptr->on_next(x); },
[ptr](const error& what) { ptr->on_error(what); },
[ptr] { ptr->on_complete(); });
}
} // namespace caf::flow } // namespace caf::flow
...@@ -150,7 +150,7 @@ struct flat_map_optional_step { ...@@ -150,7 +150,7 @@ struct flat_map_optional_step {
}; };
template <class T, class Fn> template <class T, class Fn>
struct on_complete_step { struct do_on_complete_step {
using input_type = T; using input_type = T;
using output_type = T; using output_type = T;
...@@ -175,7 +175,7 @@ struct on_complete_step { ...@@ -175,7 +175,7 @@ struct on_complete_step {
}; };
template <class T, class Fn> template <class T, class Fn>
struct on_error_step { struct do_on_error_step {
using input_type = T; using input_type = T;
using output_type = T; using output_type = T;
...@@ -200,7 +200,7 @@ struct on_error_step { ...@@ -200,7 +200,7 @@ struct on_error_step {
}; };
template <class T, class Fn> template <class T, class Fn>
struct finally_step { struct do_finally_step {
using input_type = T; using input_type = T;
using output_type = T; using output_type = T;
...@@ -225,4 +225,28 @@ struct finally_step { ...@@ -225,4 +225,28 @@ struct finally_step {
} }
}; };
/// Catches errors by converting them into complete events instead.
template <class T>
struct on_error_complete_step {
using input_type = T;
using output_type = T;
template <class Next, class... Steps>
bool on_next(const input_type& item, Next& next, Steps&... steps) {
return next.on_next(item, steps...);
}
template <class Next, class... Steps>
void on_complete(Next& next, Steps&... steps) {
next.on_complete(steps...);
}
template <class Next, class... Steps>
void on_error(const error&, Next& next, Steps&... steps) {
next.on_complete(steps...);
}
};
} // namespace caf::flow } // namespace caf::flow
// This file is part of CAF, the C++ Actor Framework. See the file LICENSE in
// the main distribution directory for license terms and copyright or visit
// https://github.com/actor-framework/actor-framework/blob/master/LICENSE.
#define CAF_SUITE flow.prefix_and_tail
#include "caf/flow/observable.hpp"
#include "core-test.hpp"
#include <memory>
#include "caf/flow/coordinator.hpp"
#include "caf/flow/merge.hpp"
#include "caf/flow/observable_builder.hpp"
#include "caf/flow/observer.hpp"
#include "caf/flow/scoped_coordinator.hpp"
using namespace caf;
namespace {
template <class T>
struct test_observer {
void on_next(T x) {
values.emplace_back(std::move(x));
}
void on_error(const error& what) {
had_error = true;
err = what;
}
void on_complete() {
had_complete = true;
}
std::vector<T> values;
bool had_error = false;
bool had_complete = false;
error err;
};
struct fixture : test_coordinator_fixture<> {
flow::scoped_coordinator_ptr ctx = flow::make_scoped_coordinator();
};
} // namespace
BEGIN_FIXTURE_SCOPE(fixture)
SCENARIO("prefix_and_tail splits off initial elements") {
using tuple_t = cow_tuple<std::vector<int>, flow::observable<int>>;
GIVEN("a generation with 0 values") {
WHEN("calling prefix_and_tail(2)") {
THEN("the observer of prefix_and_tail only receives on_complete") {
auto inputs = std::vector<int>{};
auto obs = std::make_shared<test_observer<tuple_t>>();
ctx->make_observable()
.from_container(inputs)
.prefix_and_tail(2)
.subscribe(flow::make_observer_from_ptr(obs));
ctx->run();
CHECK(obs->had_complete);
CHECK(!obs->had_error);
CHECK(obs->values.empty());
}
}
}
GIVEN("a generation with 1 values") {
WHEN("calling prefix_and_tail(2)") {
THEN("the observer of prefix_and_tail only receives on_complete") {
auto inputs = std::vector<int>{1};
auto obs = std::make_shared<test_observer<tuple_t>>();
ctx->make_observable()
.from_container(inputs)
.prefix_and_tail(2)
.subscribe(flow::make_observer_from_ptr(obs));
ctx->run();
CHECK(obs->had_complete);
CHECK(!obs->had_error);
CHECK(obs->values.empty());
}
}
}
GIVEN("a generation with 2 values") {
WHEN("calling prefix_and_tail(2)") {
THEN("the observer receives the first 2 elements plus empty remainder") {
auto inputs = std::vector<int>{1, 2};
auto prefix_vals = std::vector<int>{1, 2};
auto tail_vals = std::vector<int>{};
auto obs = std::make_shared<test_observer<int>>();
auto flat_map_calls = 0;
ctx->make_observable()
.from_container(inputs)
.prefix_and_tail(2)
.flat_map([&](const tuple_t& x) {
++flat_map_calls;
auto& [prefix, tail] = x.data();
CHECK_EQ(prefix, prefix_vals);
return tail;
})
.subscribe(flow::make_observer_from_ptr(obs));
ctx->run();
CHECK_EQ(flat_map_calls, 1);
CHECK_EQ(obs->values, tail_vals);
CHECK(obs->had_complete);
CHECK(!obs->had_error);
}
}
}
GIVEN("a generation with 8 values") {
WHEN("calling prefix_and_tail(2)") {
THEN("the observer receives the first 2 elements plus remainder") {
auto inputs = std::vector<int>{1, 2, 4, 8, 16, 32, 64, 128};
auto prefix_vals = std::vector<int>{1, 2};
auto tail_vals = std::vector<int>{4, 8, 16, 32, 64, 128};
auto obs = std::make_shared<test_observer<int>>();
auto flat_map_calls = 0;
ctx->make_observable()
.from_container(inputs)
.prefix_and_tail(2)
.flat_map([&](const tuple_t& x) {
++flat_map_calls;
auto& [prefix, tail] = x.data();
CHECK_EQ(prefix, prefix_vals);
return tail;
})
.subscribe(flow::make_observer_from_ptr(obs));
ctx->run();
CHECK_EQ(flat_map_calls, 1);
CHECK_EQ(obs->values, tail_vals);
CHECK(obs->had_complete);
CHECK(!obs->had_error);
}
}
}
}
SCENARIO("head_and_tail splits off the first element") {
using tuple_t = cow_tuple<int, flow::observable<int>>;
GIVEN("a generation with 0 values") {
WHEN("calling head_and_tail") {
THEN("the observer of head_and_tail only receives on_complete") {
auto inputs = std::vector<int>{};
auto obs = std::make_shared<test_observer<tuple_t>>();
ctx->make_observable()
.from_container(inputs)
.head_and_tail()
.subscribe(flow::make_observer_from_ptr(obs));
ctx->run();
CHECK(obs->had_complete);
CHECK(!obs->had_error);
CHECK(obs->values.empty());
}
}
}
GIVEN("a generation with 1 values") {
WHEN("calling head_and_tail()") {
THEN("the observer receives the first element plus empty remainder") {
auto inputs = std::vector<int>{1};
auto prefix_val = 1;
auto tail_vals = std::vector<int>{};
auto obs = std::make_shared<test_observer<int>>();
auto flat_map_calls = 0;
ctx->make_observable()
.from_container(inputs)
.head_and_tail()
.flat_map([&](const tuple_t& x) {
++flat_map_calls;
auto& [prefix, tail] = x.data();
CHECK_EQ(prefix, prefix_val);
return tail;
})
.subscribe(flow::make_observer_from_ptr(obs));
ctx->run();
CHECK_EQ(flat_map_calls, 1);
CHECK_EQ(obs->values, tail_vals);
CHECK(obs->had_complete);
CHECK(!obs->had_error);
}
}
}
GIVEN("a generation with 2 values") {
WHEN("calling head_and_tail()") {
THEN("the observer receives the first element plus remainder") {
auto inputs = std::vector<int>{1, 2};
auto prefix_val = 1;
auto tail_vals = std::vector<int>{2};
auto obs = std::make_shared<test_observer<int>>();
auto flat_map_calls = 0;
ctx->make_observable()
.from_container(inputs)
.head_and_tail()
.flat_map([&](const tuple_t& x) {
++flat_map_calls;
auto& [prefix, tail] = x.data();
CHECK_EQ(prefix, prefix_val);
return tail;
})
.subscribe(flow::make_observer_from_ptr(obs));
ctx->run();
CHECK_EQ(flat_map_calls, 1);
CHECK_EQ(obs->values, tail_vals);
CHECK(obs->had_complete);
CHECK(!obs->had_error);
}
}
}
}
END_FIXTURE_SCOPE()
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment