18#include <catch2/catch_test_macros.hpp>
55class stress_test_server {
57 explicit stress_test_server(uint16_t port,
const std::string& ae_title =
"STRESS_SCP")
60 , test_dir_(
"stress_test_")
61 , storage_dir_(test_dir_.path() /
"archive")
62 , db_path_(test_dir_.path() /
"index.db") {
64 std::filesystem::create_directories(storage_dir_);
74 server_ = std::make_unique<dicom_server>(config);
78 file_storage_ = std::make_unique<file_storage>(fs_conf);
80 auto db_result = index_database::open(db_path_.string());
81 if (db_result.is_ok()) {
82 database_ = std::move(db_result.value());
84 throw std::runtime_error(
"Failed to open database: " + db_result.error().message);
89 server_->register_service(std::make_shared<verification_scp>());
91 auto storage_scp_ptr = std::make_shared<storage_scp>();
92 storage_scp_ptr->set_handler([
this](
94 const std::string& calling_ae,
95 const std::string& sop_class_uid,
96 const std::string& sop_instance_uid) {
97 return handle_store(dataset, calling_ae, sop_class_uid, sop_instance_uid);
99 server_->register_service(storage_scp_ptr);
104 auto result = server_->start();
105 if (result.is_ok()) {
106 std::this_thread::sleep_for(std::chrono::milliseconds{100});
116 uint16_t port()
const {
return port_; }
117 const std::string& ae_title()
const {
return ae_title_; }
119 size_t stored_count()
const {
return stored_count_.load(); }
120 size_t failed_count()
const {
return failed_count_.load(); }
122 std::vector<std::string> stored_instance_uids()
const {
123 std::lock_guard<std::mutex> lock(mutex_);
124 return stored_instance_uids_;
127 bool verify_consistency()
const {
128 std::lock_guard<std::mutex> lock(mutex_);
131 std::set<std::string> unique_uids(
132 stored_instance_uids_.begin(),
133 stored_instance_uids_.end());
135 return unique_uids.size() == stored_count_.load();
143 const std::string& sop_instance_uid) {
149 return storage_status::storage_error;
153 auto patient_res = database_->upsert_patient(
159 if (patient_res.is_err()) {
161 return storage_status::storage_error;
163 int64_t patient_pk = patient_res.value();
166 auto study_res = database_->upsert_study(
174 if (study_res.is_err()) {
176 return storage_status::storage_error;
178 int64_t study_pk = study_res.value();
184 std::string sn = dataset.
get_string(tags::series_number);
188 auto series_res = database_->upsert_series(
190 dataset.
get_string(tags::series_instance_uid),
194 if (series_res.is_err()) {
196 return storage_status::storage_error;
198 int64_t series_pk = series_res.value();
201 auto file_path = file_storage_->get_file_path(sop_instance_uid);
206 std::string in = dataset.
get_string(tags::instance_number);
210 auto instance_res = database_->upsert_instance(
215 static_cast<int64_t
>(std::filesystem::file_size(file_path)),
219 if (instance_res.is_err()) {
221 return storage_status::storage_error;
226 std::lock_guard<std::mutex> lock(mutex_);
227 stored_instance_uids_.push_back(sop_instance_uid);
231 return storage_status::success;
235 std::string ae_title_;
237 std::filesystem::path storage_dir_;
238 std::filesystem::path db_path_;
240 std::unique_ptr<dicom_server> server_;
241 std::unique_ptr<file_storage> file_storage_;
242 std::unique_ptr<index_database> database_;
244 std::atomic<size_t> stored_count_{0};
245 std::atomic<size_t> failed_count_{0};
247 mutable std::mutex mutex_;
248 std::vector<std::string> stored_instance_uids_;
254struct worker_result {
255 size_t success_count{0};
256 size_t failure_count{0};
257 std::chrono::milliseconds duration{0};
258 std::string error_message;
264worker_result run_storage_worker(
265 uint16_t server_port,
266 const std::string& server_ae,
267 const std::string& worker_id,
269 std::latch& start_latch) {
271 worker_result result;
272 auto start_time = std::chrono::steady_clock::now();
275 start_latch.arrive_and_wait();
285 "1.2.840.10008.5.1.4.1.1.2",
286 {
"1.2.840.10008.1.2.1",
"1.2.840.10008.1.2"}
289 auto connect_result = association::connect(
292 if (connect_result.is_err()) {
293 result.error_message =
"Connection failed: " + connect_result.error().message;
294 result.failure_count = file_count;
298 auto& assoc = connect_result.value();
307 for (
int i = 0; i < file_count; ++i) {
309 dataset.
set_string(tags::instance_number, vr_type::IS, std::to_string(i + 1));
313 ++result.success_count;
315 ++result.failure_count;
321 }
catch (
const std::exception& e) {
322 result.error_message =
"Exception: " + std::string(e.what());
325 auto end_time = std::chrono::steady_clock::now();
326 result.duration = std::chrono::duration_cast<std::chrono::milliseconds>(
327 end_time - start_time);
338TEST_CASE(
"Concurrent storage from multiple SCUs",
"[stress][concurrent]") {
340 stress_test_server server(port,
"STRESS_SCP");
342 REQUIRE(server.initialize());
343 REQUIRE(server.start());
346 constexpr int num_workers = 5;
347 constexpr int files_per_worker = 10;
348 constexpr int total_expected = num_workers * files_per_worker;
350 std::latch start_latch(num_workers + 1);
351 std::vector<std::future<worker_result>> futures;
354 for (
int i = 0; i < num_workers; ++i) {
355 futures.push_back(std::async(
362 std::ref(start_latch)
367 start_latch.arrive_and_wait();
370 size_t total_success = 0;
371 size_t total_failure = 0;
372 std::chrono::milliseconds max_duration{0};
374 for (
auto& future : futures) {
375 auto result = future.get();
376 total_success += result.success_count;
377 total_failure += result.failure_count;
378 max_duration = (std::max)(max_duration, result.duration);
380 if (!result.error_message.empty()) {
381 INFO(
"Worker error: " << result.error_message);
385 INFO(
"Total success: " << total_success);
386 INFO(
"Total failure: " << total_failure);
387 INFO(
"Max duration: " << max_duration.count() <<
" ms");
388 INFO(
"Server stored: " << server.stored_count());
391 REQUIRE(total_success == total_expected);
392 REQUIRE(total_failure == 0);
393 REQUIRE(server.stored_count() == total_expected);
394 REQUIRE(server.verify_consistency());
399TEST_CASE(
"Rapid sequential connections",
"[stress][sequential]") {
401 stress_test_server server(port,
"STRESS_SCP");
403 REQUIRE(server.initialize());
404 REQUIRE(server.start());
406 constexpr int num_connections = 20;
407 size_t success_count = 0;
409 for (
int i = 0; i < num_connections; ++i) {
417 {
"1.2.840.10008.1.2.1"}
420 auto connect_result = association::connect(
423 if (connect_result.is_ok()) {
424 auto& assoc = connect_result.value();
425 (void)assoc.release(std::chrono::milliseconds{500});
430 REQUIRE(success_count == num_connections);
437 stress_test_server server(port,
"STRESS_SCP");
439 REQUIRE(server.initialize());
440 REQUIRE(server.start());
446 large_ds.
set_string(tags::patient_name, vr_type::PN,
"LARGE^DATASET");
447 large_ds.
set_string(tags::patient_id, vr_type::LO,
"LARGE001");
450 large_ds.
set_string(tags::sop_class_uid, vr_type::UI,
"1.2.840.10008.5.1.4.1.1.2");
452 large_ds.
set_string(tags::modality, vr_type::CS,
"CT");
455 constexpr int rows = 512;
456 constexpr int cols = 512;
457 large_ds.
set_numeric<uint16_t>(tags::rows, vr_type::US, rows);
458 large_ds.
set_numeric<uint16_t>(tags::columns, vr_type::US, cols);
459 large_ds.
set_numeric<uint16_t>(tags::bits_allocated, vr_type::US, 16);
460 large_ds.
set_numeric<uint16_t>(tags::bits_stored, vr_type::US, 12);
461 large_ds.
set_numeric<uint16_t>(tags::high_bit, vr_type::US, 11);
462 large_ds.
set_numeric<uint16_t>(tags::pixel_representation, vr_type::US, 0);
463 large_ds.
set_numeric<uint16_t>(tags::samples_per_pixel, vr_type::US, 1);
464 large_ds.
set_string(tags::photometric_interpretation, vr_type::CS,
"MONOCHROME2");
467 std::vector<uint16_t> pixel_data(rows * cols);
468 std::random_device rd;
469 std::mt19937 gen(rd());
470 std::uniform_int_distribution<uint16_t> dist(0, 4095);
471 for (
auto& pixel : pixel_data) {
477 std::span<const uint8_t>(
478 reinterpret_cast<const uint8_t*
>(pixel_data.data()),
479 pixel_data.size() *
sizeof(uint16_t))));
488 "1.2.840.10008.5.1.4.1.1.2",
489 {
"1.2.840.10008.1.2.1"}
492 auto connect_result = association::connect(
493 "localhost", port, config, std::chrono::milliseconds{10000});
494 REQUIRE(connect_result.is_ok());
496 auto& assoc = connect_result.value();
501 auto start = std::chrono::steady_clock::now();
502 auto result = scu.store(assoc, large_ds);
503 auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(
504 std::chrono::steady_clock::now() - start);
506 INFO(
"Large dataset storage took: " << duration.count() <<
" ms");
508 REQUIRE(result.is_ok());
509 REQUIRE(result.value().is_success());
515TEST_CASE(
"Connection pool exhaustion recovery",
"[stress][exhaustion]") {
517 stress_test_server server(port,
"STRESS_SCP");
519 REQUIRE(server.initialize());
520 REQUIRE(server.start());
523 constexpr int num_held = 10;
524 std::vector<std::optional<association>> held_connections;
526 for (
int i = 0; i < num_held; ++i) {
534 {
"1.2.840.10008.1.2.1"}
537 auto connect_result = association::connect(
539 if (connect_result.is_ok()) {
540 held_connections.push_back(std::move(connect_result.value()));
544 REQUIRE(held_connections.size() == num_held);
547 constexpr int num_additional = 5;
548 size_t additional_success = 0;
550 for (
int i = 0; i < num_additional; ++i) {
558 {
"1.2.840.10008.1.2.1"}
561 auto connect_result = association::connect(
563 if (connect_result.is_ok()) {
564 auto& assoc = connect_result.value();
565 (void)assoc.release(std::chrono::milliseconds{500});
566 ++additional_success;
570 REQUIRE(additional_success == num_additional);
573 for (
auto& opt_assoc : held_connections) {
575 (void)opt_assoc->release(std::chrono::milliseconds{500});
578 held_connections.clear();
588 {
"1.2.840.10008.1.2.1"}
591 auto final_connect = association::connect(
593 REQUIRE(final_connect.is_ok());
599TEST_CASE(
"Mixed operations stress test",
"[stress][mixed]") {
601 stress_test_server server(port,
"STRESS_SCP");
603 REQUIRE(server.initialize());
604 REQUIRE(server.start());
606 constexpr int num_iterations = 10;
607 std::atomic<int> echo_success{0};
608 std::atomic<int> store_success{0};
610 std::vector<std::thread> threads;
613 for (
int i = 0; i < 3; ++i) {
614 threads.emplace_back([&, i]() {
615 for (
int j = 0; j < num_iterations; ++j) {
623 {
"1.2.840.10008.1.2.1"}
626 auto connect = association::connect(
628 if (connect.is_ok()) {
629 auto& assoc = connect.value();
633 if (assoc.send_dimse(*ctx, echo_rq).is_ok()) {
635 if (recv.is_ok() && recv.value().second.status() == status_success) {
640 (void)assoc.release(std::chrono::milliseconds{500});
647 for (
int i = 0; i < 2; ++i) {
648 threads.emplace_back([&, i]() {
649 for (
int j = 0; j < num_iterations; ++j) {
656 "1.2.840.10008.5.1.4.1.1.2",
657 {
"1.2.840.10008.1.2.1"}
660 auto connect = association::connect(
662 if (connect.is_ok()) {
663 auto& assoc = connect.value();
666 auto result = scu.
store(assoc, ds);
667 if (result.is_ok() && result.value().is_success()) {
670 (void)assoc.release(std::chrono::milliseconds{500});
676 for (
auto& t : threads) {
680 INFO(
"Echo success: " << echo_success.load());
681 INFO(
"Store success: " << store_success.load());
683 REQUIRE(echo_success == 3 * num_iterations);
684 REQUIRE(store_success == 2 * num_iterations);
void set_numeric(dicom_tag tag, encoding::vr_type vr, T value)
Set a numeric value for the given tag.
void insert(dicom_element element)
Insert or replace an element in the dataset.
void set_string(dicom_tag tag, encoding::vr_type vr, std::string_view value)
Set a string value for the given tag.
auto get_string(dicom_tag tag, std::string_view default_value="") const -> std::string
Get the string value of an element.
RAII wrapper for temporary test directory.
network::Result< store_result > store(network::association &assoc, const core::dicom_dataset &dataset)
Store a single DICOM dataset.
DIMSE message encoding and decoding.
Filesystem-based DICOM storage with hierarchical organization.
PACS index database for metadata storage and retrieval.
uint16_t find_available_port(uint16_t start=default_test_port, int max_attempts=200)
Find an available port for testing.
core::dicom_dataset generate_ct_dataset(const std::string &study_uid="", const std::string &series_uid="", const std::string &instance_uid="")
Generate a minimal CT image dataset for testing.
std::chrono::milliseconds default_timeout()
Default timeout for test operations (5s normal, 30s CI)
TEST_CASE("test_data_generator::ct generates valid CT dataset", "[data_generator][ct]")
std::string generate_uid(const std::string &root="1.2.826.0.1.3680043.9.9999")
Generate a unique UID for testing.
auto make_c_echo_rq(uint16_t message_id, std::string_view sop_class_uid="1.2.840.10008.1.1") -> dimse_message
Create a C-ECHO request message.
storage_status
Storage operation status codes.
constexpr std::string_view verification_sop_class_uid
Verification SOP Class UID (1.2.840.10008.1.1)
DICOM Storage SCP service (C-STORE handler)
DICOM Storage SCU service (C-STORE sender)
Configuration for SCU association request.
std::string called_ae_title
Remote AE Title (16 chars max)
std::string calling_ae_title
Our AE Title (16 chars max)
std::string implementation_class_uid
std::vector< proposed_presentation_context > proposed_contexts
size_t max_associations
Maximum concurrent associations (0 = unlimited)
std::chrono::seconds idle_timeout
Idle timeout for associations (0 = no timeout)
std::string implementation_version_name
Implementation Version Name.
uint16_t port
Port to listen on (default: 11112, standard alternate DICOM port)
std::string ae_title
Application Entity Title for this server (16 chars max)
std::string implementation_class_uid
Implementation Class UID.
Configuration for Storage SCU service.
std::chrono::milliseconds response_timeout
Timeout for receiving C-STORE response (milliseconds)
Result of a C-STORE operation.
bool is_success() const noexcept
Check if the store operation was successful.
Configuration for file_storage.
std::filesystem::path root_path
Root directory for storage.
Common test fixtures and utilities for integration tests.
DICOM Verification SCP service (C-ECHO handler)