PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
test_stress.cpp
Go to the documentation of this file.
1
16#include "test_fixtures.h"
17
18#include <catch2/catch_test_macros.hpp>
19
26
27#include <atomic>
28#include <future>
29#include <latch>
30#include <mutex>
31#include <random>
32#include <thread>
33#include <vector>
34#include <set>
35
36using namespace kcenon::pacs::integration_test;
37using namespace kcenon::pacs::network;
38using namespace kcenon::pacs::services;
39using namespace kcenon::pacs::storage;
40using namespace kcenon::pacs::core;
41using namespace kcenon::pacs::encoding;
42using namespace kcenon::pacs::network::dimse;
43
44// =============================================================================
45// Helper: Stress Test Storage Server
46// =============================================================================
47
48namespace {
49
55class stress_test_server {
56public:
57 explicit stress_test_server(uint16_t port, const std::string& ae_title = "STRESS_SCP")
58 : port_(port)
59 , ae_title_(ae_title)
60 , test_dir_("stress_test_")
61 , storage_dir_(test_dir_.path() / "archive")
62 , db_path_(test_dir_.path() / "index.db") {
63
64 std::filesystem::create_directories(storage_dir_);
65
66 server_config config;
67 config.ae_title = ae_title_;
68 config.port = port_;
69 config.max_associations = 50; // High limit for stress testing
70 config.idle_timeout = std::chrono::seconds{120};
71 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.7";
72 config.implementation_version_name = "STRESS_SCP";
73
74 server_ = std::make_unique<dicom_server>(config);
75
76 file_storage_config fs_conf;
77 fs_conf.root_path = storage_dir_;
78 file_storage_ = std::make_unique<file_storage>(fs_conf);
79
80 auto db_result = index_database::open(db_path_.string());
81 if (db_result.is_ok()) {
82 database_ = std::move(db_result.value());
83 } else {
84 throw std::runtime_error("Failed to open database: " + db_result.error().message);
85 }
86 }
87
88 bool initialize() {
89 server_->register_service(std::make_shared<verification_scp>());
90
91 auto storage_scp_ptr = std::make_shared<storage_scp>();
92 storage_scp_ptr->set_handler([this](
93 const dicom_dataset& dataset,
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);
98 });
99 server_->register_service(storage_scp_ptr);
100 return true;
101 }
102
103 bool start() {
104 auto result = server_->start();
105 if (result.is_ok()) {
106 std::this_thread::sleep_for(std::chrono::milliseconds{100});
107 return true;
108 }
109 return false;
110 }
111
112 void stop() {
113 server_->stop();
114 }
115
116 uint16_t port() const { return port_; }
117 const std::string& ae_title() const { return ae_title_; }
118
119 size_t stored_count() const { return stored_count_.load(); }
120 size_t failed_count() const { return failed_count_.load(); }
121
122 std::vector<std::string> stored_instance_uids() const {
123 std::lock_guard<std::mutex> lock(mutex_);
124 return stored_instance_uids_;
125 }
126
127 bool verify_consistency() const {
128 std::lock_guard<std::mutex> lock(mutex_);
129
130 // Check that stored count matches unique instance UIDs
131 std::set<std::string> unique_uids(
132 stored_instance_uids_.begin(),
133 stored_instance_uids_.end());
134
135 return unique_uids.size() == stored_count_.load();
136 }
137
138private:
139 storage_status handle_store(
140 const dicom_dataset& dataset,
141 const std::string& /* calling_ae */,
142 const std::string& /* sop_class_uid */,
143 const std::string& sop_instance_uid) {
144
145 // Store to filesystem
146 auto store_result = file_storage_->store(dataset);
147 if (store_result.is_err()) {
148 ++failed_count_;
149 return storage_status::storage_error;
150 }
151
152 // 1. Upsert Patient
153 auto patient_res = database_->upsert_patient(
154 dataset.get_string(tags::patient_id),
155 dataset.get_string(tags::patient_name),
156 dataset.get_string(tags::patient_birth_date),
157 dataset.get_string(tags::patient_sex));
158
159 if (patient_res.is_err()) {
160 ++failed_count_;
161 return storage_status::storage_error;
162 }
163 int64_t patient_pk = patient_res.value();
164
165 // 2. Upsert Study
166 auto study_res = database_->upsert_study(
167 patient_pk,
168 dataset.get_string(tags::study_instance_uid),
169 dataset.get_string(tags::study_id),
170 dataset.get_string(tags::study_date),
171 dataset.get_string(tags::study_time),
172 dataset.get_string(tags::accession_number));
173
174 if (study_res.is_err()) {
175 ++failed_count_;
176 return storage_status::storage_error;
177 }
178 int64_t study_pk = study_res.value();
179
180 // 3. Upsert Series
181 // Parse series number safely
182 std::optional<int> series_number;
183 try {
184 std::string sn = dataset.get_string(tags::series_number);
185 if (!sn.empty()) series_number = std::stoi(sn);
186 } catch (...) {}
187
188 auto series_res = database_->upsert_series(
189 study_pk,
190 dataset.get_string(tags::series_instance_uid),
191 dataset.get_string(tags::modality),
192 series_number);
193
194 if (series_res.is_err()) {
195 ++failed_count_;
196 return storage_status::storage_error;
197 }
198 int64_t series_pk = series_res.value();
199
200 // 4. Upsert Instance
201 auto file_path = file_storage_->get_file_path(sop_instance_uid);
202
203 // Parse instance number safely
204 std::optional<int> instance_number;
205 try {
206 std::string in = dataset.get_string(tags::instance_number);
207 if (!in.empty()) instance_number = std::stoi(in);
208 } catch (...) {}
209
210 auto instance_res = database_->upsert_instance(
211 series_pk,
212 sop_instance_uid,
213 dataset.get_string(tags::sop_class_uid),
214 file_path.string(),
215 static_cast<int64_t>(std::filesystem::file_size(file_path)),
216 "", // transfer syntax
217 instance_number);
218
219 if (instance_res.is_err()) {
220 ++failed_count_;
221 return storage_status::storage_error;
222 }
223
224 // Track stored instance
225 {
226 std::lock_guard<std::mutex> lock(mutex_);
227 stored_instance_uids_.push_back(sop_instance_uid);
228 }
229
230 ++stored_count_;
231 return storage_status::success;
232 }
233
234 uint16_t port_;
235 std::string ae_title_;
236 test_directory test_dir_;
237 std::filesystem::path storage_dir_;
238 std::filesystem::path db_path_;
239
240 std::unique_ptr<dicom_server> server_;
241 std::unique_ptr<file_storage> file_storage_;
242 std::unique_ptr<index_database> database_;
243
244 std::atomic<size_t> stored_count_{0};
245 std::atomic<size_t> failed_count_{0};
246
247 mutable std::mutex mutex_;
248 std::vector<std::string> stored_instance_uids_;
249};
250
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;
259};
260
264worker_result run_storage_worker(
265 uint16_t server_port,
266 const std::string& server_ae,
267 const std::string& worker_id,
268 int file_count,
269 std::latch& start_latch) {
270
271 worker_result result;
272 auto start_time = std::chrono::steady_clock::now();
273
274 // Wait for all workers to be ready
275 start_latch.arrive_and_wait();
276
277 try {
278 // Configure association
279 association_config config;
280 config.calling_ae_title = "SCU_" + worker_id;
281 config.called_ae_title = server_ae;
282 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.8";
283 config.proposed_contexts.push_back({
284 1,
285 "1.2.840.10008.5.1.4.1.1.2", // CT Image Storage
286 {"1.2.840.10008.1.2.1", "1.2.840.10008.1.2"}
287 });
288
289 auto connect_result = association::connect(
290 "localhost", server_port, config, default_timeout() * 2);
291
292 if (connect_result.is_err()) {
293 result.error_message = "Connection failed: " + connect_result.error().message;
294 result.failure_count = file_count;
295 return result;
296 }
297
298 auto& assoc = connect_result.value();
299 storage_scu_config scu_config;
300 scu_config.response_timeout = default_timeout();
301 storage_scu scu{scu_config};
302
303 // Generate unique study for this worker
304 auto study_uid = generate_uid();
305
306 // Send files
307 for (int i = 0; i < file_count; ++i) {
308 auto dataset = generate_ct_dataset(study_uid);
309 dataset.set_string(tags::instance_number, vr_type::IS, std::to_string(i + 1));
310
311 auto store_result = scu.store(assoc, dataset);
312 if (store_result.is_ok() && store_result.value().is_success()) {
313 ++result.success_count;
314 } else {
315 ++result.failure_count;
316 }
317 }
318
319 (void)assoc.release(default_timeout());
320
321 } catch (const std::exception& e) {
322 result.error_message = "Exception: " + std::string(e.what());
323 }
324
325 auto end_time = std::chrono::steady_clock::now();
326 result.duration = std::chrono::duration_cast<std::chrono::milliseconds>(
327 end_time - start_time);
328
329 return result;
330}
331
332} // namespace
333
334// =============================================================================
335// Scenario 4: Multi-Association Stress Tests
336// =============================================================================
337
338TEST_CASE("Concurrent storage from multiple SCUs", "[stress][concurrent]") {
339 auto port = find_available_port();
340 stress_test_server server(port, "STRESS_SCP");
341
342 REQUIRE(server.initialize());
343 REQUIRE(server.start());
344
345 // Test parameters - reduced for CI/testing
346 constexpr int num_workers = 5;
347 constexpr int files_per_worker = 10;
348 constexpr int total_expected = num_workers * files_per_worker;
349
350 std::latch start_latch(num_workers + 1); // +1 for main thread
351 std::vector<std::future<worker_result>> futures;
352
353 // Launch workers
354 for (int i = 0; i < num_workers; ++i) {
355 futures.push_back(std::async(
356 std::launch::async,
357 run_storage_worker,
358 port,
359 server.ae_title(),
360 std::to_string(i),
361 files_per_worker,
362 std::ref(start_latch)
363 ));
364 }
365
366 // Release all workers simultaneously
367 start_latch.arrive_and_wait();
368
369 // Collect results
370 size_t total_success = 0;
371 size_t total_failure = 0;
372 std::chrono::milliseconds max_duration{0};
373
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);
379
380 if (!result.error_message.empty()) {
381 INFO("Worker error: " << result.error_message);
382 }
383 }
384
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());
389
390 // Verify results
391 REQUIRE(total_success == total_expected);
392 REQUIRE(total_failure == 0);
393 REQUIRE(server.stored_count() == total_expected);
394 REQUIRE(server.verify_consistency());
395
396 server.stop();
397}
398
399TEST_CASE("Rapid sequential connections", "[stress][sequential]") {
400 auto port = find_available_port();
401 stress_test_server server(port, "STRESS_SCP");
402
403 REQUIRE(server.initialize());
404 REQUIRE(server.start());
405
406 constexpr int num_connections = 20;
407 size_t success_count = 0;
408
409 for (int i = 0; i < num_connections; ++i) {
410 association_config config;
411 config.calling_ae_title = "RAPID_" + std::to_string(i);
412 config.called_ae_title = server.ae_title();
413 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.9";
414 config.proposed_contexts.push_back({
415 1,
416 std::string(verification_sop_class_uid),
417 {"1.2.840.10008.1.2.1"}
418 });
419
420 auto connect_result = association::connect(
421 "localhost", port, config, default_timeout());
422
423 if (connect_result.is_ok()) {
424 auto& assoc = connect_result.value();
425 (void)assoc.release(std::chrono::milliseconds{500});
426 ++success_count;
427 }
428 }
429
430 REQUIRE(success_count == num_connections);
431
432 server.stop();
433}
434
435TEST_CASE("Large dataset storage", "[stress][large]") {
436 auto port = find_available_port();
437 stress_test_server server(port, "STRESS_SCP");
438
439 REQUIRE(server.initialize());
440 REQUIRE(server.start());
441
442 // Create larger dataset (512x512 instead of 64x64)
443 dicom_dataset large_ds;
444
445 // Standard patient/study info
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");
448 large_ds.set_string(tags::study_instance_uid, vr_type::UI, generate_uid());
449 large_ds.set_string(tags::series_instance_uid, vr_type::UI, generate_uid());
450 large_ds.set_string(tags::sop_class_uid, vr_type::UI, "1.2.840.10008.5.1.4.1.1.2");
451 large_ds.set_string(tags::sop_instance_uid, vr_type::UI, generate_uid());
452 large_ds.set_string(tags::modality, vr_type::CS, "CT");
453
454 // Large image (512x512 16-bit = 512KB pixel data)
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");
465
466 // Generate pixel data
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) {
472 pixel = dist(gen);
473 }
474 large_ds.insert(dicom_element(
475 tags::pixel_data,
476 vr_type::OW,
477 std::span<const uint8_t>(
478 reinterpret_cast<const uint8_t*>(pixel_data.data()),
479 pixel_data.size() * sizeof(uint16_t))));
480
481 // Store the large dataset
482 association_config config;
483 config.calling_ae_title = "LARGE_SCU";
484 config.called_ae_title = server.ae_title();
485 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.10";
486 config.proposed_contexts.push_back({
487 1,
488 "1.2.840.10008.5.1.4.1.1.2",
489 {"1.2.840.10008.1.2.1"}
490 });
491
492 auto connect_result = association::connect(
493 "localhost", port, config, std::chrono::milliseconds{10000});
494 REQUIRE(connect_result.is_ok());
495
496 auto& assoc = connect_result.value();
497 storage_scu_config scu_config;
498 scu_config.response_timeout = std::chrono::milliseconds{10000};
499 storage_scu scu{scu_config};
500
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);
505
506 INFO("Large dataset storage took: " << duration.count() << " ms");
507
508 REQUIRE(result.is_ok());
509 REQUIRE(result.value().is_success());
510
511 (void)assoc.release(default_timeout());
512 server.stop();
513}
514
515TEST_CASE("Connection pool exhaustion recovery", "[stress][exhaustion]") {
516 auto port = find_available_port();
517 stress_test_server server(port, "STRESS_SCP");
518
519 REQUIRE(server.initialize());
520 REQUIRE(server.start());
521
522 // Hold connections open
523 constexpr int num_held = 10;
524 std::vector<std::optional<association>> held_connections;
525
526 for (int i = 0; i < num_held; ++i) {
527 association_config config;
528 config.calling_ae_title = "HOLD_" + std::to_string(i);
529 config.called_ae_title = server.ae_title();
530 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.11";
531 config.proposed_contexts.push_back({
532 1,
533 std::string(verification_sop_class_uid),
534 {"1.2.840.10008.1.2.1"}
535 });
536
537 auto connect_result = association::connect(
538 "localhost", port, config, default_timeout());
539 if (connect_result.is_ok()) {
540 held_connections.push_back(std::move(connect_result.value()));
541 }
542 }
543
544 REQUIRE(held_connections.size() == num_held);
545
546 // Try more connections (should still work due to max_associations = 50)
547 constexpr int num_additional = 5;
548 size_t additional_success = 0;
549
550 for (int i = 0; i < num_additional; ++i) {
551 association_config config;
552 config.calling_ae_title = "EXTRA_" + std::to_string(i);
553 config.called_ae_title = server.ae_title();
554 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.12";
555 config.proposed_contexts.push_back({
556 1,
557 std::string(verification_sop_class_uid),
558 {"1.2.840.10008.1.2.1"}
559 });
560
561 auto connect_result = association::connect(
562 "localhost", port, config, default_timeout());
563 if (connect_result.is_ok()) {
564 auto& assoc = connect_result.value();
565 (void)assoc.release(std::chrono::milliseconds{500});
566 ++additional_success;
567 }
568 }
569
570 REQUIRE(additional_success == num_additional);
571
572 // Release held connections
573 for (auto& opt_assoc : held_connections) {
574 if (opt_assoc) {
575 (void)opt_assoc->release(std::chrono::milliseconds{500});
576 }
577 }
578 held_connections.clear();
579
580 // Verify new connections work after release
581 association_config config;
582 config.calling_ae_title = "AFTER_RELEASE";
583 config.called_ae_title = server.ae_title();
584 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.13";
585 config.proposed_contexts.push_back({
586 1,
587 std::string(verification_sop_class_uid),
588 {"1.2.840.10008.1.2.1"}
589 });
590
591 auto final_connect = association::connect(
592 "localhost", port, config, default_timeout());
593 REQUIRE(final_connect.is_ok());
594 (void)final_connect.value().release(default_timeout());
595
596 server.stop();
597}
598
599TEST_CASE("Mixed operations stress test", "[stress][mixed]") {
600 auto port = find_available_port();
601 stress_test_server server(port, "STRESS_SCP");
602
603 REQUIRE(server.initialize());
604 REQUIRE(server.start());
605
606 constexpr int num_iterations = 10;
607 std::atomic<int> echo_success{0};
608 std::atomic<int> store_success{0};
609
610 std::vector<std::thread> threads;
611
612 // Echo workers
613 for (int i = 0; i < 3; ++i) {
614 threads.emplace_back([&, i]() {
615 for (int j = 0; j < num_iterations; ++j) {
616 association_config config;
617 config.calling_ae_title = "ECHO_" + std::to_string(i);
618 config.called_ae_title = server.ae_title();
619 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.14";
620 config.proposed_contexts.push_back({
621 1,
622 std::string(verification_sop_class_uid),
623 {"1.2.840.10008.1.2.1"}
624 });
625
626 auto connect = association::connect(
627 "localhost", port, config, default_timeout());
628 if (connect.is_ok()) {
629 auto& assoc = connect.value();
630 auto ctx = assoc.accepted_context_id(verification_sop_class_uid);
631 if (ctx) {
633 if (assoc.send_dimse(*ctx, echo_rq).is_ok()) {
634 auto recv = assoc.receive_dimse(default_timeout());
635 if (recv.is_ok() && recv.value().second.status() == status_success) {
636 ++echo_success;
637 }
638 }
639 }
640 (void)assoc.release(std::chrono::milliseconds{500});
641 }
642 }
643 });
644 }
645
646 // Store workers
647 for (int i = 0; i < 2; ++i) {
648 threads.emplace_back([&, i]() {
649 for (int j = 0; j < num_iterations; ++j) {
650 association_config config;
651 config.calling_ae_title = "STORE_" + std::to_string(i);
652 config.called_ae_title = server.ae_title();
653 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.15";
654 config.proposed_contexts.push_back({
655 1,
656 "1.2.840.10008.5.1.4.1.1.2",
657 {"1.2.840.10008.1.2.1"}
658 });
659
660 auto connect = association::connect(
661 "localhost", port, config, default_timeout());
662 if (connect.is_ok()) {
663 auto& assoc = connect.value();
664 storage_scu scu;
665 auto ds = generate_ct_dataset();
666 auto result = scu.store(assoc, ds);
667 if (result.is_ok() && result.value().is_success()) {
668 ++store_success;
669 }
670 (void)assoc.release(std::chrono::milliseconds{500});
671 }
672 }
673 });
674 }
675
676 for (auto& t : threads) {
677 t.join();
678 }
679
680 INFO("Echo success: " << echo_success.load());
681 INFO("Store success: " << store_success.load());
682
683 REQUIRE(echo_success == 3 * num_iterations);
684 REQUIRE(store_success == 2 * num_iterations);
685
686 server.stop();
687}
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.
constexpr dicom_tag series_number
Series Number.
constexpr dicom_tag instance_number
Instance Number.
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::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.
Definition storage_scu.h:80
std::chrono::milliseconds response_timeout
Timeout for receiving C-STORE response (milliseconds)
Definition storage_scu.h:85
Result of a C-STORE operation.
Definition storage_scu.h:43
bool is_success() const noexcept
Check if the store operation was successful.
Definition storage_scu.h:54
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)