diff --git a/CMakeLists.txt b/CMakeLists.txt index 5cec805733e..f6b692f80df 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -479,6 +479,7 @@ if(TILEDB_TESTS) add_library(tiledb_Catch2WithMain STATIC test/support/src/tdb_catch_main.cc) target_link_libraries(tiledb_Catch2WithMain PUBLIC assert_header Catch2::Catch2) + add_compile_definitions("TILEDB_INTERCEPTS") endif() # ------------------------------------------------------- diff --git a/tiledb/common/util/intercept.h b/tiledb/common/util/intercept.h new file mode 100644 index 00000000000..fb52c140fcd --- /dev/null +++ b/tiledb/common/util/intercept.h @@ -0,0 +1,143 @@ +/** + * @file intercept.h + * + * @section LICENSE + * + * The MIT License + * + * @copyright Copyright (c) 2025 TileDB, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @section DESCRIPTION + * + * This file provides declarations for interception, which allows us + * to run arbitrary code internally at pre-defined "interception points". + * This can be used to verify that a test case causes a specific event + * to occur, or it can be used to pause and resume tasks so as + * to simulate particular patterns of concurrent execution, or it can + * be used to simulate exceptions being thrown from particular blocks + * of code, etc. etc. + */ + +#ifndef TILEDB_TEST_INTERCEPTION_H +#define TILEDB_TEST_INTERCEPTION_H + +#if defined(TILEDB_INTERCEPTS) + +#include "tiledb/common/macros.h" + +#include +#include +#include + +namespace tiledb::intercept { + +/** + * Describes a type which can be passed to the INTERCEPT macro. + */ +template +concept interceptible = std::copyable || std::is_reference_v; + +/** + * A set of actions to perform at a logical interception point. + * + * An `InterceptionPoint` may capture values from an `INTERCEPT` (see below) + * and runs an arbitrary set of callbacks over those values. + * + * Do not use this directly; instead use the macros `DECLARE_INTERCEPT`, + * `DEFINE_INTERCEPT`, and `INTERCEPT` to create functions which + * respectively create and invoke callbacks of `InterceptionPoint`. + */ +template +class InterceptionPoint { + using Self = InterceptionPoint; + + public: + class CallbackRegistration { + Self& intercept_; + + using iter = std::list>::const_iterator; + iter callback_node_; + + public: + CallbackRegistration(InterceptionPoint& intercept, iter node) + : intercept_(intercept) + , callback_node_(node) { + } + + ~CallbackRegistration() { + intercept_.callbacks_.erase(callback_node_); + } + + DISABLE_COPY_AND_COPY_ASSIGN(CallbackRegistration); + }; + + void event(T... args) { + for (auto& callback : callbacks_) { + callback(std::forward(args)...); + } + } + + CallbackRegistration and_also(std::function&& callback) { + callbacks_.push_back(std::move(callback)); + return CallbackRegistration(*this, std::prev(callbacks_.end())); + } + + private: + friend class CallbackRegistration; + + std::list> callbacks_; +}; + +template +void forward(auto& intercept, Args&&... args) { + intercept.event(std::forward(args)...); +} + +} // namespace tiledb::intercept + +#define DECLARE_INTERCEPT(name, ...) \ + extern tiledb::intercept::InterceptionPoint<__VA_ARGS__>& name() + +#define DEFINE_INTERCEPT(name, ...) \ + tiledb::intercept::InterceptionPoint<__VA_ARGS__>& name() { \ + static tiledb::intercept::InterceptionPoint<__VA_ARGS__> impl; \ + return impl; \ + } + +#define INTERCEPT(name, ...) \ + do { \ + name().event(__VA_ARGS__); \ + } while (0) + +#else // not defined(TILEDB_INTERCEPTS) + +/** + * Similar to `assert`, expand to nothing if TILEDB_INTERCEPTS is not enabled, + * so that we can leave the intercepts in the code and have them optimized + * out for production builds. + */ +#define DECLARE_INTERCEPT(...) +#define DEFINE_INTERCEPT(...) +#define INTERCEPT(...) + +#endif + +#endif diff --git a/tiledb/common/util/test/CMakeLists.txt b/tiledb/common/util/test/CMakeLists.txt index 0d6787bbf13..cc89471d0c1 100644 --- a/tiledb/common/util/test/CMakeLists.txt +++ b/tiledb/common/util/test/CMakeLists.txt @@ -85,3 +85,10 @@ this_target_sources( unit_view_combo.cc ) conclude(unit_test) + +commence(unit_test intercept) + this_target_sources( + unit_intercept.cc + unit_no_intercept.cc + ) +conclude(unit_test) diff --git a/tiledb/common/util/test/unit_intercept.cc b/tiledb/common/util/test/unit_intercept.cc new file mode 100644 index 00000000000..a0b2973f2b4 --- /dev/null +++ b/tiledb/common/util/test/unit_intercept.cc @@ -0,0 +1,177 @@ +/** + * @file unit_intercept.cc + * + * @section LICENSE + * + * The MIT License + * + * @copyright Copyright (c) 2025 TileDB, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @section DESCRIPTION + * + * This file contains unit tests and simple examples for the `INTERCEPT` + * capability. + */ + +#include "tiledb/common/util/intercept.h" + +#include + +#include +#include + +// global variable for demonstrating intercept perfect forwarding +int global = 0; + +namespace intercept { + +DECLARE_INTERCEPT(my_library_function_entry); +DEFINE_INTERCEPT(my_library_function_entry); + +// NB: DECLARE_INTERCEPT is not strictly necessary if everything is in the same +// file. The DECLARE also need not be in a header, the linker will resolve it +// correctly if the DECLARE is written in the unit test file and the DEFINE +// in the source file. +DEFINE_INTERCEPT(my_library_function_exit, int&, std::string_view, int); + +} // namespace intercept + +/** + * Silly library function for demonstrating intercepts. + */ +int my_library_function(std::string_view arg) { + INTERCEPT(intercept::my_library_function_entry); + + const int local = global++; + INTERCEPT(intercept::my_library_function_exit, global, arg, local); + + return local; +} + +/** + * Demonstrates using intercepts to log aspects of an execution + * which we might want to make assertions about in a test. + */ +TEST_CASE("Intercept log", "[intercept]") { + std::map>> values; + + { + auto cb = intercept::my_library_function_exit().and_also( + [&values](int snapshot_global, std::string_view arg, int local) { + values[std::string(arg)].emplace_back( + std::make_pair(snapshot_global, local)); + }); + + global = 0; + my_library_function("foo"); + my_library_function("bar"); + my_library_function("foo"); + + CHECK(values.size() == 2); + CHECK(values["foo"] == std::vector>{{1, 0}, {3, 2}}); + CHECK(values["bar"] == std::vector>{{2, 1}}); + } + + // now that the callback is de-registered we shouldn't see anything + const decltype(values) snapshot = values; + my_library_function("bar"); + CHECK(values == snapshot); +} + +/** + * Demonstrates using intercepts to simulate errors occurring + * within a library function. This can be very useful if it is known + * that the throwing of the error is causing problems, but the error + * itself is difficult to reproduce. + */ +TEST_CASE("Intercept simulate error", "[intercept]") { + // nothing happens + my_library_function("foo"); + + // we can register a callback to make it throw + { + auto cb = intercept::my_library_function_entry().and_also( + []() { throw std::logic_error("intercept"); }); + CHECK_THROWS( + my_library_function("foo"), + Catch::Matchers::ContainsSubstring("intercept")); + } + + // now the callback is de-registered, it should not throw again + my_library_function("foo"); +} + +/** + * Demonstrates using intercepts to synchronize multiple threads, + * producing a deterministic behavior. + */ +TEST_CASE("Intercept synchronize", "[intercept]") { + global = 0; + + std::barrier sync(2); + + auto cb = intercept::my_library_function_exit().and_also( + [&sync](int snapshot_global, std::string_view, int) { + if (snapshot_global == 2) { + // waits for the main thread + sync.arrive_and_wait(); + // the main thread has arrived; wait for its signal to resume + sync.arrive_and_wait(); + } + }); + + std::vector tt_values; + + std::thread tt([&tt_values]() { + tt_values.push_back(my_library_function("foo")); + tt_values.push_back(my_library_function("bar")); + tt_values.push_back(my_library_function("baz")); + tt_values.push_back(my_library_function("gub")); + }); + + sync.arrive_and_wait(); + + // the thread is waiting for a signal to continue, + // we can run arbitrary code while it does so + global = 100; + + sync.arrive_and_wait(); + + tt.join(); + + // because we synchronized the two threads we should always + // see exactly the same values + CHECK(tt_values == std::vector{0, 1, 100, 101}); +} + +/** + * Demonstrates using intercepts to modify intercepted + * values. This is more unusual but might be useful if you + * wanted to simulate a particular result. + */ +TEST_CASE("Intercept modify", "[intercept]") { + auto cb = intercept::my_library_function_exit().and_also( + [](int& ref_global, std::string_view, int) { ref_global = 7; }); + + global = 0; + my_library_function("foo"); + CHECK(global == 7); +} diff --git a/tiledb/common/util/test/unit_no_intercept.cc b/tiledb/common/util/test/unit_no_intercept.cc new file mode 100644 index 00000000000..ab1b429f68a --- /dev/null +++ b/tiledb/common/util/test/unit_no_intercept.cc @@ -0,0 +1,69 @@ +/** + * @file unit_no_intercept.cc + * + * @section LICENSE + * + * The MIT License + * + * @copyright Copyright (c) 2025 TileDB, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @section DESCRIPTION + * + * This file contains a simple test which validates that intercepts + * are no-ops when `TILEDB_INTERCEPTS` is not defined. + */ + +#undef TILEDB_INTERCEPTS +#include "tiledb/common/util/intercept.h" + +#include + +namespace no_intercept { + +DECLARE_INTERCEPT(not_my_library_function_entry, int); +DEFINE_INTERCEPT(not_my_library_function_entry, int); + +// declare another symbol with a different type, +// this will not compile if the intercepts are not removed by the preprocessor +int not_my_library_function_entry(void) { + return 1; +} + +DEFINE_INTERCEPT(test_case_body, int); + +} // namespace no_intercept + +TEST_CASE("Intercept undef declare and define", "[intercept]") { + REQUIRE(no_intercept::not_my_library_function_entry() == 1); +} + +TEST_CASE("Intercept undef inline", "[intercept]") { + // An intercept with side effects is a bad idea, + // but this does illustrate that the preprocessor removes + // all of the intercept arguments + int a = 0; + INTERCEPT(test_case_body, a++); + REQUIRE(a == 0); + + // and for good measure another symbol with the same name + volatile int test_case_body = 1; + REQUIRE(test_case_body == 1); +}