changeset 5032:a3486b8e5d69

merge
author Alain Mazy <am@osimis.io>
date Tue, 21 Jun 2022 17:30:37 +0200
parents 1ff06e0ea532 (current diff) eec3e4a91663 (diff)
children 3ba08f5b58b8
files
diffstat 14 files changed, 1045 insertions(+), 9 deletions(-) [+]
line wrap: on
line diff
--- a/NEWS	Tue Jun 21 11:03:20 2022 +0200
+++ b/NEWS	Tue Jun 21 17:30:37 2022 +0200
@@ -4,6 +4,8 @@
 General
 -------
 
+* New sample plugin: "DelayedDeletion" that will delete files from disk
+  asynchronously to speed up deletion of large studies.
 * Lua: new "SetHttpTimeout" function
 * Lua: new "OnHeartBeat" callback called at regular interval provided that
        you have configured "LuaHeartBeatPeriod" > 0.
@@ -18,8 +20,17 @@
 * Improved HttpClient error logging (add method + url)
 
 
+REST API
+--------
+
+* API version upgraded to 18
+* /system is now reporting "DatabaseServerIdentifier"
 * Added an Asynchronous mode to /modalities/../move.
 
+Plugins
+-------
+
+* New function in the SDK: "OrthancPluginGetDatabaseServerIdentifier"
 
 
 Version 1.11.0 (2022-05-09)
--- a/OrthancFramework/Resources/CMake/OrthancFrameworkParameters.cmake	Tue Jun 21 11:03:20 2022 +0200
+++ b/OrthancFramework/Resources/CMake/OrthancFrameworkParameters.cmake	Tue Jun 21 17:30:37 2022 +0200
@@ -38,7 +38,7 @@
 # Version of the Orthanc API, can be retrieved from "/system" URI in
 # order to check whether new URI endpoints are available even if using
 # the mainline version of Orthanc
-set(ORTHANC_API_VERSION "17")
+set(ORTHANC_API_VERSION "18")
 
 
 #####################################################################
--- a/OrthancFramework/Sources/Logging.cpp	Tue Jun 21 11:03:20 2022 +0200
+++ b/OrthancFramework/Sources/Logging.cpp	Tue Jun 21 17:30:37 2022 +0200
@@ -698,6 +698,8 @@
       boost::mutex::scoped_lock lock(loggingStreamsMutex_);
       loggingStreamsContext_.reset(NULL);
       pluginContext_ = reinterpret_cast<OrthancPluginContext*>(pluginContext);
+
+      EnableInfoLevel(true);  // allow the plugin to log at info level (but the Orthanc Core still decides of the level)
     }
 
 
--- a/OrthancServer/CMakeLists.txt	Tue Jun 21 11:03:20 2022 +0200
+++ b/OrthancServer/CMakeLists.txt	Tue Jun 21 17:30:37 2022 +0200
@@ -60,6 +60,7 @@
 SET(BUILD_SERVE_FOLDERS ON CACHE BOOL "Whether to build the ServeFolders plugin")
 SET(BUILD_CONNECTIVITY_CHECKS ON CACHE BOOL "Whether to build the ConnectivityChecks plugin")
 SET(BUILD_HOUSEKEEPER ON CACHE BOOL "Whether to build the Housekeeper plugin")
+SET(BUILD_DELAYED_DELETION ON CACHE BOOL "Whether to build the DelayedDeletion plugin")
 SET(ENABLE_PLUGINS ON CACHE BOOL "Enable plugins")
 SET(UNIT_TESTS_WITH_HTTP_CONNEXIONS ON CACHE BOOL "Allow unit tests to make HTTP requests")
 
@@ -536,24 +537,28 @@
 endif()
 
 
+if (ENABLE_PLUGINS AND (BUILD_DELAYED_DELETION OR BUILD_CONNECTIVITY_CHECKS))
+  include(ExternalProject)
+
+endif()
+
 
 #####################################################################
 ## Build the "ConnectivityChecks" plugin
 #####################################################################
 
 if (ENABLE_PLUGINS AND BUILD_CONNECTIVITY_CHECKS)
-  include(ExternalProject)
 
-  set(Flags)
+  set(ConnectivityChecksFlags)
 
   if (CMAKE_TOOLCHAIN_FILE)
     # Take absolute path to the toolchain
     get_filename_component(TMP ${CMAKE_TOOLCHAIN_FILE} REALPATH BASE ${CMAKE_SOURCE_DIR})
-    list(APPEND Flags -DCMAKE_TOOLCHAIN_FILE=${TMP})
+    list(APPEND ConnectivityChecksFlags -DCMAKE_TOOLCHAIN_FILE=${TMP})
   endif()
 
   if ("${CMAKE_SYSTEM_VERSION}" STREQUAL "LinuxStandardBase")
-    list(APPEND Flags
+    list(APPEND ConnectivityChecksFlags
       -DLSB_CC=${CMAKE_LSB_CC}
       -DLSB_CXX=${CMAKE_LSB_CXX}
       )
@@ -566,6 +571,9 @@
     # that are too long on our Visual Studio 2008 CIS
     BINARY_DIR "${CMAKE_CURRENT_BINARY_DIR}/ConnectivityChecks-build"
 
+    # this helps triggering build when changing the external project
+    BUILD_ALWAYS 1
+
     CMAKE_ARGS
     -DCMAKE_BUILD_TYPE:STRING=${CMAKE_BUILD_TYPE}
     -DCMAKE_INSTALL_PREFIX=${CMAKE_CURRENT_BINARY_DIR}
@@ -574,7 +582,7 @@
     -DALLOW_DOWNLOADS=${ALLOW_DOWNLOADS}
     -DUSE_SYSTEM_BOOST=${USE_SYSTEM_BOOST}
     -DUSE_LEGACY_JSONCPP=${USE_LEGACY_JSONCPP}
-    ${Flags}
+    ${ConnectivityChecksFlags}
 
     -DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}
     -DCMAKE_CXX_FLAGS=${CMAKE_CXX_FLAGS}
@@ -605,6 +613,75 @@
 endif()
 
 
+#####################################################################
+## Build the "DelayedDeletion" plugin
+#####################################################################
+
+if (ENABLE_PLUGINS AND BUILD_DELAYED_DELETION)
+
+  set(DelayedDeletionFlags)
+
+  if (CMAKE_TOOLCHAIN_FILE)
+    # Take absolute path to the toolchain
+    get_filename_component(TMP ${CMAKE_TOOLCHAIN_FILE} REALPATH BASE ${CMAKE_SOURCE_DIR})
+    list(APPEND DelayedDeletionFlags -DCMAKE_TOOLCHAIN_FILE=${TMP})
+  endif()
+
+  if ("${CMAKE_SYSTEM_VERSION}" STREQUAL "LinuxStandardBase")
+    list(APPEND DelayedDeletionFlags
+      -DLSB_CC=${CMAKE_LSB_CC}
+      -DLSB_CXX=${CMAKE_LSB_CXX}
+      )
+  endif()
+
+  externalproject_add(DelayedDeletion
+    SOURCE_DIR "${CMAKE_SOURCE_DIR}/Plugins/Samples/DelayedDeletion"
+
+    # We explicitly provide a build directory, in order to avoid paths
+    # that are too long on our Visual Studio 2008 CIS
+    BINARY_DIR "${CMAKE_CURRENT_BINARY_DIR}/DelayedDeletion-build"
+
+    # this helps triggering build when changing the external project
+    BUILD_ALWAYS 1
+
+    CMAKE_ARGS
+    -DCMAKE_BUILD_TYPE:STRING=${CMAKE_BUILD_TYPE}
+    -DCMAKE_INSTALL_PREFIX=${CMAKE_CURRENT_BINARY_DIR}
+    -DPLUGIN_VERSION=${ORTHANC_VERSION}
+    -DSTATIC_BUILD=${STATIC_BUILD}
+    -DALLOW_DOWNLOADS=${ALLOW_DOWNLOADS}
+    -DUSE_SYSTEM_BOOST=${USE_SYSTEM_BOOST}
+    -DUSE_LEGACY_JSONCPP=${USE_LEGACY_JSONCPP}
+    ${DelayedDeletionFlags}
+
+    -DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}
+    -DCMAKE_CXX_FLAGS=${CMAKE_CXX_FLAGS}
+    -DCMAKE_C_COMPILER=${CMAKE_C_COMPILER}
+    -DCMAKE_C_FLAGS=${CMAKE_C_FLAGS}
+    -DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET}
+    -DCMAKE_OSX_ARCHITECTURES=${CMAKE_OSX_ARCHITECTURES}
+    )
+
+  if (${CMAKE_SYSTEM_NAME} STREQUAL "Windows")
+    if (MSVC)
+      set(Prefix "")
+    else()
+      set(Prefix "lib")  # MinGW
+    endif()
+
+    install(FILES
+      ${CMAKE_CURRENT_BINARY_DIR}/${Prefix}DelayedDeletion.dll
+      DESTINATION "lib")
+  else()
+    list(GET CMAKE_FIND_LIBRARY_PREFIXES 0 Prefix)
+    list(GET CMAKE_FIND_LIBRARY_SUFFIXES 0 Suffix)
+    install(FILES
+      ${CMAKE_CURRENT_BINARY_DIR}/${Prefix}DelayedDeletion${Suffix}
+      ${CMAKE_CURRENT_BINARY_DIR}/${Prefix}DelayedDeletion${Suffix}.${ORTHANC_VERSION}
+      DESTINATION "share/orthanc/plugins")
+  endif()
+endif()
+
 
 #####################################################################
 ## Build the "Housekeeper" plugin
--- a/OrthancServer/Plugins/Engine/OrthancPlugins.cpp	Tue Jun 21 11:03:20 2022 +0200
+++ b/OrthancServer/Plugins/Engine/OrthancPlugins.cpp	Tue Jun 21 17:30:37 2022 +0200
@@ -5621,6 +5621,16 @@
         return true;
       }
 
+      case _OrthancPluginService_GetDatabaseServerIdentifier:
+      {
+        const _OrthancPluginRetrieveStaticString& p =
+          *reinterpret_cast<const _OrthancPluginRetrieveStaticString*>(parameters);
+
+        *p.result = pimpl_->databaseServerIdentifier_.c_str();
+
+        return true;
+      }
+
       default:
       {
         // This service is unknown to the Orthanc plugin engine
--- a/OrthancServer/Plugins/Include/orthanc/OrthancCPlugin.h	Tue Jun 21 11:03:20 2022 +0200
+++ b/OrthancServer/Plugins/Include/orthanc/OrthancCPlugin.h	Tue Jun 21 17:30:37 2022 +0200
@@ -446,7 +446,8 @@
     _OrthancPluginService_GenerateRestApiAuthorizationToken = 39,   /* New in Orthanc 1.8.1 */
     _OrthancPluginService_CreateMemoryBuffer64 = 40, /* New in Orthanc 1.9.0 */
     _OrthancPluginService_CreateDicom2 = 41,         /* New in Orthanc 1.9.0 */
-    
+    _OrthancPluginService_GetDatabaseServerIdentifier = 42,         /* New in Orthanc 1.11.1 */
+
     /* Registration of callbacks */
     _OrthancPluginService_RegisterRestCallback = 1000,
     _OrthancPluginService_RegisterOnStoredInstanceCallback = 1001,
@@ -9002,7 +9003,36 @@
 
     return context->InvokeService(context, _OrthancPluginService_RegisterWebDavCollection, &params);
   }
-  
+
+
+  /**
+   * @brief Gets the DatabaseServerIdentifier.
+   *
+   * @param context The Orthanc plugin context, as received by OrthancPluginInitialize().
+   * @return the database server identifier.  This is a statically-allocated
+   * string, do not free it.
+   * @ingroup Toolbox
+   **/
+  ORTHANC_PLUGIN_INLINE const char* OrthancPluginGetDatabaseServerIdentifier(
+    OrthancPluginContext*  context)
+  {
+    const char* result;
+
+    _OrthancPluginRetrieveStaticString params;
+    params.result = &result;
+    params.argument = NULL;
+
+    if (context->InvokeService(context, _OrthancPluginService_GetDatabaseServerIdentifier, &params) != OrthancPluginErrorCode_Success)
+    {
+      /* Error */
+      return NULL;
+    }
+    else
+    {
+      return result;
+    }
+  }
+
 
 #ifdef  __cplusplus
 }
--- a/OrthancServer/Plugins/Samples/ConnectivityChecks/CMakeLists.txt	Tue Jun 21 11:03:20 2022 +0200
+++ b/OrthancServer/Plugins/Samples/ConnectivityChecks/CMakeLists.txt	Tue Jun 21 17:30:37 2022 +0200
@@ -36,7 +36,7 @@
   execute_process(
     COMMAND 
     ${PYTHON_EXECUTABLE} ${CMAKE_SOURCE_DIR}/../../../../OrthancFramework/Resources/WindowsResources.py
-    ${PLUGIN_VERSION} ConnectivityChecks ConnectivityChecks.dll "Orthanc plugin to serve additional folders"
+    ${PLUGIN_VERSION} ConnectivityChecks ConnectivityChecks.dll "Orthanc plugin to show connectivity status"
     ERROR_VARIABLE Failure
     OUTPUT_FILE ${AUTOGENERATED_DIR}/ConnectivityChecks.rc
     )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Plugins/Samples/DelayedDeletion/CMakeLists.txt	Tue Jun 21 17:30:37 2022 +0200
@@ -0,0 +1,88 @@
+# Orthanc - A Lightweight, RESTful DICOM Store
+# Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+# Department, University Hospital of Liege, Belgium
+# Copyright (C) 2017-2022 Osimis S.A., Belgium
+# Copyright (C) 2021-2022 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+#
+# This program is free software: you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+# 
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+cmake_minimum_required(VERSION 2.8)
+cmake_policy(SET CMP0058 NEW)
+
+project(DelayedDeletion)
+
+SET(PLUGIN_NAME "delayed-deletion" CACHE STRING "Name of the plugin")
+SET(PLUGIN_VERSION "mainline" CACHE STRING "Version of the plugin")
+
+include(${CMAKE_CURRENT_SOURCE_DIR}/../../../../OrthancFramework/Resources/CMake/OrthancFrameworkParameters.cmake)
+
+set(ENABLE_SQLITE ON)
+set(ENABLE_MODULE_IMAGES OFF)
+set(ENABLE_MODULE_JOBS OFF)
+set(ENABLE_MODULE_DICOM OFF)
+
+include(${CMAKE_CURRENT_SOURCE_DIR}/../../../../OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake)
+
+if (${CMAKE_SYSTEM_NAME} STREQUAL "Windows")
+  execute_process(
+    COMMAND 
+    ${PYTHON_EXECUTABLE} ${CMAKE_SOURCE_DIR}/../../../../OrthancFramework/Resources/WindowsResources.py
+    ${PLUGIN_VERSION} DelayedDeletion DelayedDeletion.dll "Orthanc plugin to delay deletion of files"
+    ERROR_VARIABLE Failure
+    OUTPUT_FILE ${AUTOGENERATED_DIR}/DelayedDeletion.rc
+    )
+
+  if (Failure)
+    message(FATAL_ERROR "Error while computing the version information: ${Failure}")
+  endif()
+
+  list(APPEND ADDITIONAL_RESOURCES ${AUTOGENERATED_DIR}/DelayedDeletion.rc)
+endif()  
+
+add_definitions(
+  -DHAS_ORTHANC_EXCEPTION=1
+  -DORTHANC_PLUGIN_NAME="${PLUGIN_NAME}"
+  -DORTHANC_PLUGIN_VERSION="${PLUGIN_VERSION}"
+  -DORTHANC_ENABLE_LOGGING=1
+  -DORTHANC_ENABLE_PLUGINS=1
+  -DORTHANC_BUILDING_SERVER_LIBRARY=0
+  )
+
+include_directories(
+  ${CMAKE_SOURCE_DIR}/../../Include/
+  ${CMAKE_SOURCE_DIR}/../../../../OrthancFramework/Sources/
+  )
+
+add_library(DelayedDeletion SHARED
+  ${ADDITIONAL_RESOURCES}
+  ${AUTOGENERATED_SOURCES}
+  ${ORTHANC_CORE_SOURCES}
+  ${CMAKE_SOURCE_DIR}/../../../Plugins/Samples/DelayedDeletion/Plugin.cpp
+  ${CMAKE_SOURCE_DIR}/../../../Plugins/Samples/DelayedDeletion/PendingDeletionsDatabase.cpp
+  ${CMAKE_SOURCE_DIR}/../../../Plugins/Engine/PluginsEnumerations.cpp
+  ${CMAKE_SOURCE_DIR}/../../../Plugins/Samples/Common/OrthancPluginCppWrapper.cpp
+  Plugin.cpp
+  )
+
+set_target_properties(
+  DelayedDeletion PROPERTIES 
+  VERSION ${PLUGIN_VERSION} 
+  SOVERSION ${PLUGIN_VERSION}
+  )
+
+install(
+  TARGETS DelayedDeletion
+  DESTINATION .
+  )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Plugins/Samples/DelayedDeletion/LargeDeleteJob.cpp	Tue Jun 21 17:30:37 2022 +0200
@@ -0,0 +1,301 @@
+#include "LargeDeleteJob.h"
+
+#include "../../../../OrthancFramework/Sources/Logging.h"
+#include "../../../../OrthancFramework/Sources/OrthancException.h"
+
+#include <json/reader.h>
+
+void LargeDeleteJob::UpdateDeleteProgress()
+{
+  size_t total = 2 * resources_.size() + instances_.size() + series_.size();
+
+  float progress;
+  if (total == 0)
+  {
+    progress = 1;
+  }
+  else
+  {
+    progress = (static_cast<float>(posResources_ + posInstances_ + posSeries_ + posDelete_) /
+                static_cast<float>(total));
+  }
+
+  UpdateProgress(progress);
+}
+
+
+void LargeDeleteJob::ScheduleChildrenResources(std::vector<std::string>& target,
+                                               const std::string& uri)
+{
+  Json::Value items;
+  if (OrthancPlugins::RestApiGet(items, uri, false))
+  {
+    if (items.type() != Json::arrayValue)
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+    }
+
+    for (Json::Value::ArrayIndex i = 0; i < items.size(); i++)
+    {
+      if (items[i].type() != Json::objectValue ||
+          !items[i].isMember("ID") ||
+          items[i]["ID"].type() != Json::stringValue)
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+      }
+      else
+      {
+        target.push_back(items[i]["ID"].asString());
+      }
+    }
+  }
+}
+  
+
+void LargeDeleteJob::ScheduleResource(Orthanc::ResourceType level,
+                                      const std::string& id)
+{
+#if 0
+  // Instance-level granularity => very slow!
+  switch (level)
+  {
+    case Orthanc::ResourceType_Patient:
+      ScheduleChildrenResources(instances_, "/patients/" + id + "/instances");
+      break;
+            
+    case Orthanc::ResourceType_Study:
+      ScheduleChildrenResources(instances_, "/studies/" + id + "/instances");
+      break;
+            
+    case Orthanc::ResourceType_Series:
+      ScheduleChildrenResources(instances_, "/series/" + id + "/instances");
+      break;
+
+    case Orthanc::ResourceType_Instance:
+      instances_.push_back(id);
+      break;
+
+    default:
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+  }
+#else
+  /**
+   * Series-level granularity => looks like a good compromise between
+   * having the Orthanc mutex locked during all the study, and very
+   * slow instance-level granularity.
+   **/
+  switch (level)
+  {
+    case Orthanc::ResourceType_Patient:
+      ScheduleChildrenResources(series_, "/patients/" + id + "/series");
+      break;
+            
+    case Orthanc::ResourceType_Study:
+      ScheduleChildrenResources(series_, "/studies/" + id + "/series");
+      break;
+            
+    case Orthanc::ResourceType_Series:
+      series_.push_back(id);
+      break;
+
+    case Orthanc::ResourceType_Instance:
+      instances_.push_back(id);
+      break;
+
+    default:
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+  }
+#endif
+}
+
+
+void LargeDeleteJob::DeleteResource(Orthanc::ResourceType level,
+                                    const std::string& id)
+{
+  std::string uri;      
+  switch (level)
+  {
+    case Orthanc::ResourceType_Patient:
+      uri = "/patients/" + id;
+      break;
+          
+    case Orthanc::ResourceType_Study:
+      uri = "/studies/" + id;
+      break;
+          
+    case Orthanc::ResourceType_Series:
+      uri = "/series/" + id;
+      break;
+          
+    case Orthanc::ResourceType_Instance:
+      uri = "/instances/" + id;
+      break;
+
+    default:
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+  }
+
+  OrthancPlugins::RestApiDelete(uri, false);
+}
+
+  
+LargeDeleteJob::LargeDeleteJob(const std::vector<std::string>& resources,
+                               const std::vector<Orthanc::ResourceType>& levels) :
+  OrthancJob("LargeDelete"),
+  resources_(resources),
+  levels_(levels),
+  posResources_(0),
+  posInstances_(0),
+  posDelete_(0)
+{
+  if (resources.size() != levels.size())
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
+  }
+}
+
+  
+OrthancPluginJobStepStatus LargeDeleteJob::Step()
+{
+  if (posResources_ == 0)
+  {
+    if (resources_.size() == 1)
+    {
+      // LOG(WARNING) << "LargeDeleteJob has started on resource: " << resources_[0];
+    }
+    else
+    {
+      // LOG(WARNING) << "LargeDeleteJob has started";
+    }
+  }
+  
+  if (posResources_ < resources_.size())
+  {
+    // First step: Discovering all the instances of the resources
+
+    ScheduleResource(levels_[posResources_], resources_[posResources_]);
+      
+    posResources_ += 1;
+    UpdateDeleteProgress();
+    return OrthancPluginJobStepStatus_Continue;
+  }    
+  else if (posInstances_ < instances_.size())
+  {
+    // Second step: Deleting the instances one by one
+
+    DeleteResource(Orthanc::ResourceType_Instance, instances_[posInstances_]);
+
+    posInstances_ += 1;
+    UpdateDeleteProgress();
+    return OrthancPluginJobStepStatus_Continue;
+  }
+  else if (posSeries_ < series_.size())
+  {
+    // Third step: Deleting the series one by one
+
+    DeleteResource(Orthanc::ResourceType_Series, series_[posSeries_]);
+
+    posSeries_ += 1;
+    UpdateDeleteProgress();
+    return OrthancPluginJobStepStatus_Continue;
+  }
+  else if (posDelete_ < resources_.size())
+  {
+    // Fourth step: Make sure the resources where fully deleted
+    // (instances might have been received since the beginning of
+    // the job)
+
+    DeleteResource(levels_[posDelete_], resources_[posDelete_]);
+
+    posDelete_ += 1;
+    UpdateDeleteProgress();
+    return OrthancPluginJobStepStatus_Continue;
+  }
+  else
+  {
+    if (resources_.size() == 1)
+    {
+      // LOG(WARNING) << "LargeDeleteJob has completed on resource: " << resources_[0];
+    }
+    else
+    {
+      // LOG(WARNING) << "LargeDeleteJob has completed";
+    }
+
+    UpdateProgress(1);
+    return OrthancPluginJobStepStatus_Success;
+  }                   
+}
+
+
+void LargeDeleteJob::Reset()
+{
+  posResources_ = 0;
+  posInstances_ = 0;
+  posDelete_ = 0;
+  instances_.clear();
+}
+
+
+void LargeDeleteJob::RestHandler(OrthancPluginRestOutput* output,
+                                 const char* url,
+                                 const OrthancPluginHttpRequest* request)
+{
+  static const char* KEY_RESOURCES = "Resources";
+  
+  if (request->method != OrthancPluginHttpMethod_Post)
+  {
+    OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "POST");
+    return;
+  }
+
+  Json::Value body;
+  Json::Reader reader;
+  if (!reader.parse(reinterpret_cast<const char*>(request->body),
+                    reinterpret_cast<const char*>(request->body) + request->bodySize, body))
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, "JSON body is expected");
+  }
+
+  if (body.type() != Json::objectValue)
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+                                    "Expected a JSON object in the body");
+  }
+
+  if (!body.isMember(KEY_RESOURCES) ||
+      body[KEY_RESOURCES].type() != Json::arrayValue)
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+                                    "The JSON object must contain an array in \"" +
+                                    std::string(KEY_RESOURCES) + "\"");
+  }
+
+  std::vector<std::string> resources;
+  std::vector<Orthanc::ResourceType>  levels;
+
+  resources.reserve(body.size());
+  levels.reserve(body.size());
+
+  const Json::Value& arr = body[KEY_RESOURCES];
+  for (Json::Value::ArrayIndex i = 0; i < arr.size(); i++)
+  {
+    if (arr[i].type() != Json::arrayValue ||
+        arr[i].size() != 2u ||
+        arr[i][0].type() != Json::stringValue ||
+        arr[i][1].type() != Json::stringValue)
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+                                      "Each entry must be an array containing 2 strings, "
+                                      "the resource level and its ID");
+    }
+    else
+    {
+      levels.push_back(Orthanc::StringToResourceType(arr[i][0].asCString()));
+      resources.push_back(arr[i][1].asString());
+    }
+  }
+  
+  OrthancPlugins::OrthancJob::SubmitFromRestApiPost(
+    output, body, new LargeDeleteJob(resources, levels));
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Plugins/Samples/DelayedDeletion/LargeDeleteJob.h	Tue Jun 21 17:30:37 2022 +0200
@@ -0,0 +1,46 @@
+#pragma once
+
+#include "../Common/OrthancPluginCppWrapper.h"
+#include "../../../../OrthancFramework/Sources/Enumerations.h"
+#include "../../../../OrthancFramework/Sources/Compatibility.h"
+
+
+class LargeDeleteJob : public OrthancPlugins::OrthancJob
+{
+private:
+  std::vector<std::string>            resources_;
+  std::vector<Orthanc::ResourceType>  levels_;
+  std::vector<std::string>            instances_;
+  std::vector<std::string>            series_;
+  size_t                              posResources_;
+  size_t                              posInstances_;
+  size_t                              posSeries_;
+  size_t                              posDelete_;
+
+  void UpdateDeleteProgress();
+
+  void ScheduleChildrenResources(std::vector<std::string>& target,
+                                 const std::string& uri);
+  
+  void ScheduleResource(Orthanc::ResourceType level,
+                        const std::string& id);
+
+  void DeleteResource(Orthanc::ResourceType level,
+                      const std::string& id);
+  
+public:
+  LargeDeleteJob(const std::vector<std::string>& resources,
+                 const std::vector<Orthanc::ResourceType>& levels);
+
+  virtual OrthancPluginJobStepStatus Step() ORTHANC_OVERRIDE;
+
+  virtual void Stop(OrthancPluginJobStopReason reason) ORTHANC_OVERRIDE
+  {
+  }
+
+  virtual void Reset() ORTHANC_OVERRIDE;
+
+  static void RestHandler(OrthancPluginRestOutput* output,
+                          const char* url,
+                          const OrthancPluginHttpRequest* request);
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Plugins/Samples/DelayedDeletion/PendingDeletionsDatabase.cpp	Tue Jun 21 17:30:37 2022 +0200
@@ -0,0 +1,112 @@
+#include "PendingDeletionsDatabase.h"
+
+#include "../../../../OrthancFramework/Sources/SQLite/Statement.h"
+#include "../../../../OrthancFramework/Sources/SQLite/Transaction.h"
+
+void PendingDeletionsDatabase::Setup()
+{
+  // Performance tuning of SQLite with PRAGMAs
+  // http://www.sqlite.org/pragma.html
+  db_.Execute("PRAGMA SYNCHRONOUS=NORMAL;");
+  db_.Execute("PRAGMA JOURNAL_MODE=WAL;");
+  db_.Execute("PRAGMA LOCKING_MODE=EXCLUSIVE;");
+  db_.Execute("PRAGMA WAL_AUTOCHECKPOINT=1000;");
+
+  {
+    Orthanc::SQLite::Transaction t(db_);
+    t.Begin();
+
+    if (!db_.DoesTableExist("Pending"))
+    {
+      db_.Execute("CREATE TABLE Pending(uuid TEXT, type INTEGER)");
+    }
+    
+    t.Commit();
+  }
+}
+  
+
+PendingDeletionsDatabase::PendingDeletionsDatabase(const std::string& path)
+{
+  db_.Open(path);
+  Setup();
+}
+  
+
+void PendingDeletionsDatabase::Enqueue(const std::string& uuid,
+                                       Orthanc::FileContentType type)
+{
+  boost::mutex::scoped_lock lock(mutex_);
+
+  Orthanc::SQLite::Transaction t(db_);
+  t.Begin();
+
+  {
+    Orthanc::SQLite::Statement s(db_, SQLITE_FROM_HERE, "INSERT INTO Pending VALUES(?, ?)");
+    s.BindString(0, uuid);
+    s.BindInt(1, type);
+    s.Run();
+  }
+
+  t.Commit();
+}
+  
+
+bool PendingDeletionsDatabase::Dequeue(std::string& uuid,
+                                       Orthanc::FileContentType& type)
+{
+  bool ok = false;
+    
+  boost::mutex::scoped_lock lock(mutex_);
+
+  Orthanc::SQLite::Transaction t(db_);
+  t.Begin();
+
+  {
+    Orthanc::SQLite::Statement s(db_, SQLITE_FROM_HERE, "SELECT uuid, type FROM Pending LIMIT 1");
+
+    if (s.Step())
+    {
+      uuid = s.ColumnString(0);
+      type = static_cast<Orthanc::FileContentType>(s.ColumnInt(1));
+
+      Orthanc::SQLite::Statement s(db_, SQLITE_FROM_HERE, "DELETE FROM Pending WHERE uuid=?");
+      s.BindString(0, uuid);
+      s.Run();
+      
+      ok = true;
+    }
+  }
+
+  t.Commit();
+
+  return ok;
+}
+
+
+unsigned int PendingDeletionsDatabase::GetSize()
+{
+  boost::mutex::scoped_lock lock(mutex_);
+
+  unsigned int value = 0;
+  
+  Orthanc::SQLite::Transaction t(db_);
+  t.Begin();
+
+  {
+    Orthanc::SQLite::Statement s(db_, SQLITE_FROM_HERE, "SELECT COUNT(*) FROM Pending");
+
+    if (s.Step())
+    {
+      int tmp = s.ColumnInt(0);
+      if (tmp > 0)
+      {
+        value = static_cast<unsigned int>(tmp);
+      }
+    }
+  }
+
+  t.Commit();
+
+  return value;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Plugins/Samples/DelayedDeletion/PendingDeletionsDatabase.h	Tue Jun 21 17:30:37 2022 +0200
@@ -0,0 +1,26 @@
+#pragma once
+
+
+#include "../../../../OrthancFramework/Sources/SQLite/Connection.h"
+#include <boost/thread/mutex.hpp>
+#include <boost/noncopyable.hpp>
+
+class PendingDeletionsDatabase : public boost::noncopyable
+{
+private:
+  boost::mutex                 mutex_;
+  Orthanc::SQLite::Connection  db_;
+
+  void Setup();
+  
+public:
+  PendingDeletionsDatabase(const std::string& path);
+
+  void Enqueue(const std::string& uuid,
+               Orthanc::FileContentType type);
+  
+  bool Dequeue(std::string& uuid,
+               Orthanc::FileContentType& type);
+
+  unsigned int GetSize();
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Plugins/Samples/DelayedDeletion/Plugin.cpp	Tue Jun 21 17:30:37 2022 +0200
@@ -0,0 +1,329 @@
+#include "PendingDeletionsDatabase.h"
+
+#include "../../../../OrthancFramework/Sources/FileStorage/FilesystemStorage.h"
+#include "../../../../OrthancFramework/Sources/Logging.h"
+#include "../../../../OrthancFramework/Sources/MultiThreading/SharedMessageQueue.h"
+#include "../../../../OrthancServer/Plugins/Engine/PluginsEnumerations.h"
+#include "../../../../OrthancServer/Plugins/Samples/Common/OrthancPluginCppWrapper.h"
+
+#include <boost/thread.hpp>
+
+
+class PendingDeletion : public Orthanc::IDynamicObject
+{
+private:
+  Orthanc::FileContentType  type_;
+  std::string               uuid_;
+
+public:
+  PendingDeletion(Orthanc::FileContentType type,
+                  const std::string& uuid) :
+    type_(type),
+    uuid_(uuid)
+  {
+  }
+
+  Orthanc::FileContentType GetType() const
+  {
+    return type_;
+  }
+
+  const std::string& GetUuid() const
+  {
+    return uuid_;
+  }
+};
+
+
+static const char* DELAYED_DELETION = "DelayedDeletion";
+static bool                                         continue_ = false;
+static Orthanc::SharedMessageQueue                  queue_;
+static std::unique_ptr<Orthanc::FilesystemStorage>  storage_;
+static std::unique_ptr<PendingDeletionsDatabase>    db_;
+static std::unique_ptr<boost::thread>               deletionThread_;
+static const char*                                  databaseServerIdentifier_ = NULL;
+static unsigned int                                 throttleDelayMs_ = 0;
+
+
+static OrthancPluginErrorCode StorageCreate(const char* uuid,
+                                            const void* content,
+                                            int64_t size,
+                                            OrthancPluginContentType type)
+{
+  try
+  {
+    storage_->Create(uuid, content, size, Orthanc::Plugins::Convert(type));
+    return OrthancPluginErrorCode_Success;
+  }
+  catch (Orthanc::OrthancException& e)
+  {
+    return static_cast<OrthancPluginErrorCode>(e.GetErrorCode());
+  }
+  catch (...)
+  {
+    return OrthancPluginErrorCode_StorageAreaPlugin;
+  }
+}
+
+
+static OrthancPluginErrorCode StorageReadWhole(OrthancPluginMemoryBuffer64* target, // Memory buffer where to store the content of the file. It must be allocated by the plugin using OrthancPluginCreateMemoryBuffer64(). The core of Orthanc will free it.
+                                               const char* uuid,
+                                               OrthancPluginContentType type)
+{
+  try
+  {
+    std::unique_ptr<Orthanc::IMemoryBuffer> buffer(storage_->Read(uuid, Orthanc::Plugins::Convert(type)));
+
+    // copy from a buffer allocated on plugin's heap into a buffer allocated on core's heap
+    if (OrthancPluginCreateMemoryBuffer64(OrthancPlugins::GetGlobalContext(), target, buffer->GetSize()) != OrthancPluginErrorCode_Success)
+    {
+      OrthancPlugins::LogError("Delayed deletion plugin: error while reading object " + std::string(uuid) + ", cannot allocate memory of size " + boost::lexical_cast<std::string>(buffer->GetSize()) + " bytes");
+      return OrthancPluginErrorCode_StorageAreaPlugin;
+    }
+
+    memcpy(target->data, buffer->GetData(), buffer->GetSize());
+    
+    return OrthancPluginErrorCode_Success;
+  }
+  catch (Orthanc::OrthancException& e)
+  {
+    return static_cast<OrthancPluginErrorCode>(e.GetErrorCode());
+  }
+  catch (...)
+  {
+    return OrthancPluginErrorCode_StorageAreaPlugin;
+  }
+}
+
+
+static OrthancPluginErrorCode StorageReadRange(OrthancPluginMemoryBuffer64* target, // Memory buffer where to store the content of the range.  The memory buffer is allocated and freed by Orthanc. The length of the range of interest corresponds to the size of this buffer.
+                                               const char* uuid,
+                                               OrthancPluginContentType type,
+                                               uint64_t rangeStart)
+{
+  try
+  {
+    std::unique_ptr<Orthanc::IMemoryBuffer> buffer(storage_->ReadRange(uuid, Orthanc::Plugins::Convert(type), rangeStart, rangeStart + target->size));
+
+    assert(buffer->GetSize() == target->size);
+
+    memcpy(target->data, buffer->GetData(), buffer->GetSize());
+    
+    return OrthancPluginErrorCode_Success;
+  }
+  catch (Orthanc::OrthancException& e)
+  {
+    return static_cast<OrthancPluginErrorCode>(e.GetErrorCode());
+  }
+  catch (...)
+  {
+    return OrthancPluginErrorCode_StorageAreaPlugin;
+  }
+
+  return OrthancPluginErrorCode_Success;
+}
+
+
+static OrthancPluginErrorCode StorageRemove(const char* uuid,
+                                            OrthancPluginContentType type)
+{
+  try
+  {
+    LOG(INFO) << "DelayedDeletion - Scheduling delayed deletion of " << uuid;
+    db_->Enqueue(uuid, Orthanc::Plugins::Convert(type));
+    
+    return OrthancPluginErrorCode_Success;
+  }
+  catch (Orthanc::OrthancException& e)
+  {
+    return static_cast<OrthancPluginErrorCode>(e.GetErrorCode());
+  }
+  catch (...)
+  {
+    return OrthancPluginErrorCode_StorageAreaPlugin;
+  }
+}
+
+static void DeletionWorker()
+{
+  static const unsigned int GRANULARITY = 100;  // In milliseconds
+
+  while (continue_)
+  {
+    std::string uuid;
+    Orthanc::FileContentType type = Orthanc::FileContentType_Dicom;  // Dummy initialization
+
+    bool hasDeleted = false;
+    
+    while (continue_ && db_->Dequeue(uuid, type))
+    {
+      if (!hasDeleted)
+      {
+        LOG(INFO) << "DelayedDeletion - Starting to process the pending deletions";        
+      }
+      
+      hasDeleted = true;
+      
+      try
+      {
+        LOG(INFO) << "DelayedDeletion - Asynchronous removal of file: " << uuid;
+        storage_->Remove(uuid, type);
+
+        if (throttleDelayMs_ > 0)
+        {
+          boost::this_thread::sleep(boost::posix_time::milliseconds(throttleDelayMs_));
+        }
+      }
+      catch (Orthanc::OrthancException& ex)
+      {
+        LOG(ERROR) << "DelayedDeletion - Cannot remove file: " << uuid << " " << ex.What();
+      }
+    }
+
+    if (hasDeleted)
+    {
+      LOG(INFO) << "DelayedDeletion - All the pending deletions have been completed";
+    }      
+
+    boost::this_thread::sleep(boost::posix_time::milliseconds(GRANULARITY));
+  }
+}
+
+
+OrthancPluginErrorCode OnChangeCallback(OrthancPluginChangeType changeType,
+                                        OrthancPluginResourceType resourceType,
+                                        const char* resourceId)
+{
+  switch (changeType)
+  {
+    case OrthancPluginChangeType_OrthancStarted:
+      assert(deletionThread_.get() == NULL);
+      
+      LOG(WARNING) << "DelayedDeletion - Starting the deletion thread";
+      continue_ = true;
+      deletionThread_.reset(new boost::thread(DeletionWorker));
+      break;
+
+    case OrthancPluginChangeType_OrthancStopped:
+
+      if (deletionThread_.get() != NULL)
+      {
+        LOG(WARNING) << "DelayedDeletion - Stopping the deletion thread";
+        continue_ = false;
+        if (deletionThread_->joinable())
+        {
+          deletionThread_->join();
+        }
+      }
+
+      break;
+
+    default:
+      break;
+  }
+
+  return OrthancPluginErrorCode_Success;
+}
+
+  
+
+void GetPluginStatus(OrthancPluginRestOutput* output,
+                const char* url,
+                const OrthancPluginHttpRequest* request)
+{
+
+  Json::Value status;
+  status["FilesPendingDeletion"] = db_->GetSize();
+  status["DatabaseServerIdentifier"] = databaseServerIdentifier_;
+
+  std::string s = status.toStyledString();
+  OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, s.c_str(),
+                            s.size(), "application/json");
+}
+
+
+
+extern "C"
+{
+  ORTHANC_PLUGINS_API int32_t OrthancPluginInitialize(OrthancPluginContext* context)
+  {
+    OrthancPlugins::SetGlobalContext(context);
+    Orthanc::Logging::InitializePluginContext(context);
+    
+
+    /* Check the version of the Orthanc core */
+    if (OrthancPluginCheckVersion(context) == 0)
+    {
+      char info[1024];
+      sprintf(info, "Your version of Orthanc (%s) must be above %d.%d.%d to run this plugin",
+              context->orthancVersion,
+              ORTHANC_PLUGINS_MINIMAL_MAJOR_NUMBER,
+              ORTHANC_PLUGINS_MINIMAL_MINOR_NUMBER,
+              ORTHANC_PLUGINS_MINIMAL_REVISION_NUMBER);
+      OrthancPluginLogError(context, info);
+      return -1;
+    }
+
+    OrthancPluginSetDescription(context, "Plugin removing files from storage asynchronously.");
+
+    OrthancPlugins::OrthancConfiguration orthancConfig;
+
+    if (!orthancConfig.IsSection(DELAYED_DELETION))
+    {
+      LOG(WARNING) << "DelayedDeletion - plugin is loaded but not enabled (no \"DelayedDeletion\" section found in configuration)";
+      return 0;
+    }
+
+    OrthancPlugins::OrthancConfiguration delayedDeletionConfig;
+    orthancConfig.GetSection(delayedDeletionConfig, DELAYED_DELETION);
+
+    if (delayedDeletionConfig.GetBooleanValue("Enable", true))
+    {
+      databaseServerIdentifier_ = OrthancPluginGetDatabaseServerIdentifier(context);
+      throttleDelayMs_ = delayedDeletionConfig.GetUnsignedIntegerValue("ThrottleDelayMs", 0);   // delay in ms    
+
+
+      std::string pathStorage = orthancConfig.GetStringValue("StorageDirectory", "OrthancStorage");
+      LOG(WARNING) << "DelayedDeletion - Path to the storage area: " << pathStorage;
+
+      storage_.reset(new Orthanc::FilesystemStorage(pathStorage));
+
+      boost::filesystem::path defaultDbPath = boost::filesystem::path(pathStorage) / (std::string("pending-deletions.") + databaseServerIdentifier_ + ".db");
+      std::string dbPath = delayedDeletionConfig.GetStringValue("Path", defaultDbPath.string());
+
+      LOG(WARNING) << "DelayedDeletion - Path to the SQLite database: " << dbPath;
+      
+      // This must run after the allocation of "storage_", to make sure
+      // that the folder actually exists
+      db_.reset(new PendingDeletionsDatabase(dbPath));
+
+      OrthancPluginRegisterStorageArea2(context, StorageCreate, StorageReadWhole, StorageReadRange, StorageRemove);
+
+      OrthancPluginRegisterOnChangeCallback(context, OnChangeCallback);
+
+      OrthancPlugins::RegisterRestCallback<GetPluginStatus>(std::string("/plugins/") + ORTHANC_PLUGIN_NAME + "/status", true);
+    }
+    else
+    {
+      LOG(WARNING) << "DelayedDeletion - plugin is loaded but disabled (check your \"DelayedDeletion.Enable\" configuration)";
+    }
+
+    return 0;
+  }
+
+  ORTHANC_PLUGINS_API void OrthancPluginFinalize()
+  {
+    db_.reset();
+    storage_.reset();
+  }
+
+  ORTHANC_PLUGINS_API const char* OrthancPluginGetName()
+  {
+    return ORTHANC_PLUGIN_NAME;
+  }
+
+  ORTHANC_PLUGINS_API const char* OrthancPluginGetVersion()
+  {
+    return ORTHANC_PLUGIN_VERSION;
+  }
+}
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestSystem.cpp	Tue Jun 21 11:03:20 2022 +0200
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestSystem.cpp	Tue Jun 21 17:30:37 2022 +0200
@@ -64,6 +64,7 @@
     static const char* const CHECK_REVISIONS = "CheckRevisions";
     static const char* const DATABASE_BACKEND_PLUGIN = "DatabaseBackendPlugin";
     static const char* const DATABASE_VERSION = "DatabaseVersion";
+    static const char* const DATABASE_SERVER_IDENTIFIER = "DatabaseServerIdentifier";
     static const char* const DICOM_AET = "DicomAet";
     static const char* const DICOM_PORT = "DicomPort";
     static const char* const HTTP_PORT = "HttpPort";
@@ -87,6 +88,8 @@
         .SetAnswerField(VERSION, RestApiCallDocumentation::Type_String, "Version of Orthanc")
         .SetAnswerField(DATABASE_VERSION, RestApiCallDocumentation::Type_Number,
                         "Version of the database: https://book.orthanc-server.com/developers/db-versioning.html")
+        .SetAnswerField(DATABASE_SERVER_IDENTIFIER, RestApiCallDocumentation::Type_String,
+                        "ID of the server in the database (when running multiple Orthanc on the same DB)")
         .SetAnswerField(IS_HTTP_SERVER_SECURE, RestApiCallDocumentation::Type_Boolean,
                         "Whether the REST API is properly secured (assuming no reverse proxy is in use): https://book.orthanc-server.com/faq/security.html#securing-the-http-server")
         .SetAnswerField(STORAGE_AREA_PLUGIN, RestApiCallDocumentation::Type_String,
@@ -133,6 +136,7 @@
       result[STORAGE_COMPRESSION] = lock.GetConfiguration().GetBooleanParameter(STORAGE_COMPRESSION, false); // New in Orthanc 1.11.0
       result[OVERWRITE_INSTANCES] = lock.GetConfiguration().GetBooleanParameter(OVERWRITE_INSTANCES, false); // New in Orthanc 1.11.0
       result[INGEST_TRANSCODING] = lock.GetConfiguration().GetStringParameter(INGEST_TRANSCODING, ""); // New in Orthanc 1.11.0
+      result[DATABASE_SERVER_IDENTIFIER] = lock.GetConfiguration().GetDatabaseServerIdentifier();
     }
 
     result[STORAGE_AREA_PLUGIN] = Json::nullValue;