changeset 595:272eeb046a88 find-refactoring

Introduced a new ChildCount table in PG to improve retrieval of NumberOfRelatedStudyInstances and other similar tags that could consume up to 95% of a request time + added a DB-Housekeeper thread to populate the new table
author Alain Mazy <am@orthanc.team>
date Tue, 26 Nov 2024 17:59:14 +0100
parents 14ba6a6f633f
children 28c9b3e5b3ad
files Framework/Plugins/DatabaseBackendAdapterV4.cpp Framework/Plugins/IndexBackend.cpp Framework/Plugins/IndexBackend.h MySQL/Plugins/MySQLIndex.h Odbc/Plugins/OdbcIndex.h PostgreSQL/CMakeLists.txt PostgreSQL/NEWS PostgreSQL/Plugins/PostgreSQLIndex.cpp PostgreSQL/Plugins/PostgreSQLIndex.h PostgreSQL/Plugins/SQL/Downgrades/Rev3ToRev2.sql PostgreSQL/Plugins/SQL/PrepareIndex.sql PostgreSQL/Plugins/SQL/Upgrades/Partial/Rev2_1_ReplaceChildrenIndex.sql PostgreSQL/Plugins/SQL/Upgrades/Rev2ToRev3.sql
diffstat 13 files changed, 427 insertions(+), 107 deletions(-) [+]
line wrap: on
line diff
--- a/Framework/Plugins/DatabaseBackendAdapterV4.cpp	Fri Nov 15 11:11:05 2024 +0100
+++ b/Framework/Plugins/DatabaseBackendAdapterV4.cpp	Tue Nov 26 17:59:14 2024 +0100
@@ -1436,6 +1436,9 @@
       
       if (isBackendInUse_)
       {
+        IndexConnectionsPool::Accessor accessor(*pool);
+        accessor.GetBackend().Shutdown();
+        
         isBackendInUse_ = false;
       }
       else
--- a/Framework/Plugins/IndexBackend.cpp	Fri Nov 15 11:11:05 2024 +0100
+++ b/Framework/Plugins/IndexBackend.cpp	Tue Nov 26 17:59:14 2024 +0100
@@ -3587,20 +3587,46 @@
       }
       else if (childrenSpec->retrieve_count())  // no need to count if we have retrieved the list of identifiers
       {
-        sql += "UNION ALL SELECT "
-               "  " TOSTRING(QUERY_CHILDREN_COUNT) " AS c0_queryId, "
-               "  Lookup.internalId AS c1_internalId, "
-               "  " + formatter.FormatNull("BIGINT") + " AS c2_rowNumber, "
-               "  " + formatter.FormatNull("TEXT") + " AS c3_string1, "
-               "  " + formatter.FormatNull("TEXT") + " AS c4_string2, "
-               "  " + formatter.FormatNull("TEXT") + " AS c5_string3, "
-               "  " + formatter.FormatNull("INT") + " AS c6_int1, "
-               "  " + formatter.FormatNull("INT") + " AS c7_int2, "
-               "  " + formatter.FormatNull("INT") + " AS c8_int3, "
-               "  COUNT(childLevel.internalId) AS c9_big_int1, "
-               "  " + formatter.FormatNull("BIGINT") + " AS c10_big_int2 "
-               "FROM Lookup "
-               "  INNER JOIN Resources childLevel ON Lookup.internalId = childLevel.parentId GROUP BY Lookup.internalId ";
+        if (HasChildCountTable())
+        {
+          // we get the count value either from the childCount table if it has been computed or from the Resources table
+          sql += "UNION ALL SELECT "
+                "  " TOSTRING(QUERY_CHILDREN_COUNT) " AS c0_queryId, "
+                "  Lookup.internalId AS c1_internalId, "
+                "  " + formatter.FormatNull("BIGINT") + " AS c2_rowNumber, "
+                "  " + formatter.FormatNull("TEXT") + " AS c3_string1, "
+                "  " + formatter.FormatNull("TEXT") + " AS c4_string2, "
+                "  " + formatter.FormatNull("TEXT") + " AS c5_string3, "
+                "  " + formatter.FormatNull("INT") + " AS c6_int1, "
+                "  " + formatter.FormatNull("INT") + " AS c7_int2, "
+                "  " + formatter.FormatNull("INT") + " AS c8_int3, "
+                "  COALESCE("
+                "           (ChildCount.childCount),"
+                "        		(SELECT COUNT(childLevel.internalId)"
+                "            FROM Resources AS childLevel"
+                "            WHERE Lookup.internalId = childLevel.parentId"
+                "           )) AS c9_big_int1, "
+                "  " + formatter.FormatNull("BIGINT") + " AS c10_big_int2 "
+                "FROM Lookup "
+                "LEFT JOIN ChildCount ON Lookup.internalId = ChildCount.parentId ";
+        }
+        else
+        {
+          sql += "UNION ALL SELECT "
+                "  " TOSTRING(QUERY_CHILDREN_COUNT) " AS c0_queryId, "
+                "  Lookup.internalId AS c1_internalId, "
+                "  " + formatter.FormatNull("BIGINT") + " AS c2_rowNumber, "
+                "  " + formatter.FormatNull("TEXT") + " AS c3_string1, "
+                "  " + formatter.FormatNull("TEXT") + " AS c4_string2, "
+                "  " + formatter.FormatNull("TEXT") + " AS c5_string3, "
+                "  " + formatter.FormatNull("INT") + " AS c6_int1, "
+                "  " + formatter.FormatNull("INT") + " AS c7_int2, "
+                "  " + formatter.FormatNull("INT") + " AS c8_int3, "
+                "  COUNT(childLevel.internalId) AS c9_big_int1, "
+                "  " + formatter.FormatNull("BIGINT") + " AS c10_big_int2 "
+                "FROM Lookup "
+                "  INNER JOIN Resources childLevel ON Lookup.internalId = childLevel.parentId GROUP BY Lookup.internalId ";
+        }
       }
 
       if (childrenSpec->retrieve_metadata_size() > 0)
@@ -3659,21 +3685,50 @@
         }
         else if (grandchildrenSpec->retrieve_count())  // no need to count if we have retrieved the list of identifiers
         {
-          sql += "UNION ALL SELECT "
-                "  " TOSTRING(QUERY_GRAND_CHILDREN_COUNT) " AS c0_queryId, "
-                "  Lookup.internalId AS c1_internalId, "
-                "  " + formatter.FormatNull("BIGINT") + " AS c2_rowNumber, "
-                "  " + formatter.FormatNull("TEXT") + " AS c3_string1, "
-                "  " + formatter.FormatNull("TEXT") + " AS c4_string2, "
-                "  " + formatter.FormatNull("TEXT") + " AS c5_string3, "
-                "  " + formatter.FormatNull("INT") + " AS c6_int1, "
-                "  " + formatter.FormatNull("INT") + " AS c7_int2, "
-                "  " + formatter.FormatNull("INT") + " AS c8_int3, "
-                "  COUNT(grandChildLevel.internalId) AS c9_big_int1, "
-                "  " + formatter.FormatNull("BIGINT") + " AS c10_big_int2 "
-                "FROM Lookup "
-                "  INNER JOIN Resources childLevel ON Lookup.internalId = childLevel.parentId "
-                "  INNER JOIN Resources grandChildLevel ON childLevel.internalId = grandChildLevel.parentId GROUP BY Lookup.internalId ";
+          if (HasChildCountTable())
+          {
+            // we get the count value either from the childCount table if it has been computed or from the Resources table
+            sql += "UNION ALL SELECT "
+                  "  " TOSTRING(QUERY_GRAND_CHILDREN_COUNT) " AS c0_queryId, "
+                  "  Lookup.internalId AS c1_internalId, "
+                  "  " + formatter.FormatNull("BIGINT") + " AS c2_rowNumber, "
+                  "  " + formatter.FormatNull("TEXT") + " AS c3_string1, "
+                  "  " + formatter.FormatNull("TEXT") + " AS c4_string2, "
+                  "  " + formatter.FormatNull("TEXT") + " AS c5_string3, "
+                  "  " + formatter.FormatNull("INT") + " AS c6_int1, "
+                  "  " + formatter.FormatNull("INT") + " AS c7_int2, "
+                  "  " + formatter.FormatNull("INT") + " AS c8_int3, "
+                  "  COALESCE("
+		              "           (SELECT SUM(ChildCount.childCount)"
+		              "            FROM ChildCount"
+                  "            INNER JOIN Resources AS childLevel ON childLevel.parentId = Lookup.internalId"
+                  "            WHERE ChildCount.parentId = childLevel.internalId),"
+                  "        		(SELECT COUNT(grandChildLevel.internalId)"
+                  "            FROM Resources AS childLevel"
+                  "            INNER JOIN Resources AS grandChildLevel ON childLevel.internalId = grandChildLevel.parentId"
+                  "            WHERE Lookup.internalId = childLevel.parentId"
+                  "           )) AS c9_big_int1, "
+                  "  " + formatter.FormatNull("BIGINT") + " AS c10_big_int2 "
+                  "FROM Lookup ";
+          }
+          else
+          {
+            sql += "UNION ALL SELECT "
+                  "  " TOSTRING(QUERY_GRAND_CHILDREN_COUNT) " AS c0_queryId, "
+                  "  Lookup.internalId AS c1_internalId, "
+                  "  " + formatter.FormatNull("BIGINT") + " AS c2_rowNumber, "
+                  "  " + formatter.FormatNull("TEXT") + " AS c3_string1, "
+                  "  " + formatter.FormatNull("TEXT") + " AS c4_string2, "
+                  "  " + formatter.FormatNull("TEXT") + " AS c5_string3, "
+                  "  " + formatter.FormatNull("INT") + " AS c6_int1, "
+                  "  " + formatter.FormatNull("INT") + " AS c7_int2, "
+                  "  " + formatter.FormatNull("INT") + " AS c8_int3, "
+                  "  COUNT(grandChildLevel.internalId) AS c9_big_int1, "
+                  "  " + formatter.FormatNull("BIGINT") + " AS c10_big_int2 "
+                  "FROM Lookup "
+                  "  INNER JOIN Resources childLevel ON Lookup.internalId = childLevel.parentId "
+                  "  INNER JOIN Resources grandChildLevel ON childLevel.internalId = grandChildLevel.parentId GROUP BY Lookup.internalId ";
+          }
         }
 
         if (grandchildrenSpec->retrieve_main_dicom_tags_size() > 0)
@@ -3742,22 +3797,53 @@
           }
           else if (grandgrandchildrenSpec->retrieve_count())  // no need to count if we have retrieved the list of identifiers
           {
-            sql += "UNION ALL SELECT "
-                  "  " TOSTRING(QUERY_GRAND_GRAND_CHILDREN_COUNT) " AS c0_queryId, "
-                  "  Lookup.internalId AS c1_internalId, "
-                  "  " + formatter.FormatNull("BIGINT") + " AS c2_rowNumber, "
-                  "  " + formatter.FormatNull("TEXT") + " AS c3_string1, "
-                  "  " + formatter.FormatNull("TEXT") + " AS c4_string2, "
-                  "  " + formatter.FormatNull("TEXT") + " AS c5_string3, "
-                  "  " + formatter.FormatNull("INT") + " AS c6_int1, "
-                  "  " + formatter.FormatNull("INT") + " AS c7_int2, "
-                  "  " + formatter.FormatNull("INT") + " AS c8_int3, "
-                  "  COUNT(grandChildLevel.internalId) AS c9_big_int1, "
-                  "  " + formatter.FormatNull("BIGINT") + " AS c10_big_int2 "
-                  "FROM Lookup "
-                  "INNER JOIN Resources childLevel ON Lookup.internalId = childLevel.parentId "
-                  "INNER JOIN Resources grandChildLevel ON childLevel.internalId = grandChildLevel.parentId "
-                  "INNER JOIN Resources grandGrandChildLevel ON grandChildLevel.internalId = grandGrandChildLevel.parentId GROUP BY Lookup.internalId ";
+            if (HasChildCountTable())
+            {
+              // we get the count value either from the childCount table if it has been computed or from the Resources table
+              sql += "UNION ALL SELECT "
+                    "  " TOSTRING(QUERY_GRAND_GRAND_CHILDREN_COUNT) " AS c0_queryId, "
+                    "  Lookup.internalId AS c1_internalId, "
+                    "  " + formatter.FormatNull("BIGINT") + " AS c2_rowNumber, "
+                    "  " + formatter.FormatNull("TEXT") + " AS c3_string1, "
+                    "  " + formatter.FormatNull("TEXT") + " AS c4_string2, "
+                    "  " + formatter.FormatNull("TEXT") + " AS c5_string3, "
+                    "  " + formatter.FormatNull("INT") + " AS c6_int1, "
+                    "  " + formatter.FormatNull("INT") + " AS c7_int2, "
+                    "  " + formatter.FormatNull("INT") + " AS c8_int3, "
+                    "  COALESCE("
+                    "           (SELECT SUM(ChildCount.childCount)"
+                    "            FROM ChildCount"
+                    "            INNER JOIN Resources AS childLevel ON childLevel.parentId = Lookup.internalId"
+                    "            INNER JOIN Resources AS grandChildLevel ON grandChildLevel.parentId = childLevel.internalId"
+                    "            WHERE ChildCount.parentId = grandChildLevel.internalId),"
+                    "        		(SELECT COUNT(grandGrandChildLevel.internalId)"
+                    "            FROM Resources AS childLevel"
+                    "            INNER JOIN Resources AS grandChildLevel ON childLevel.internalId = grandChildLevel.parentId"
+                    "            INNER JOIN Resources AS grandGrandChildLevel ON grandChildLevel.internalId = grandGrandChildLevel.parentId"
+                    "            WHERE Lookup.internalId = childLevel.parentId"
+                    "           )) AS c9_big_int1, "
+                    "  " + formatter.FormatNull("BIGINT") + " AS c10_big_int2 "
+                    "FROM Lookup ";
+            }
+            else
+            {
+              sql += "UNION ALL SELECT "
+                    "  " TOSTRING(QUERY_GRAND_GRAND_CHILDREN_COUNT) " AS c0_queryId, "
+                    "  Lookup.internalId AS c1_internalId, "
+                    "  " + formatter.FormatNull("BIGINT") + " AS c2_rowNumber, "
+                    "  " + formatter.FormatNull("TEXT") + " AS c3_string1, "
+                    "  " + formatter.FormatNull("TEXT") + " AS c4_string2, "
+                    "  " + formatter.FormatNull("TEXT") + " AS c5_string3, "
+                    "  " + formatter.FormatNull("INT") + " AS c6_int1, "
+                    "  " + formatter.FormatNull("INT") + " AS c7_int2, "
+                    "  " + formatter.FormatNull("INT") + " AS c8_int3, "
+                    "  COUNT(grandChildLevel.internalId) AS c9_big_int1, "
+                    "  " + formatter.FormatNull("BIGINT") + " AS c10_big_int2 "
+                    "FROM Lookup "
+                    "INNER JOIN Resources childLevel ON Lookup.internalId = childLevel.parentId "
+                    "INNER JOIN Resources grandChildLevel ON childLevel.internalId = grandChildLevel.parentId "
+                    "INNER JOIN Resources grandGrandChildLevel ON grandChildLevel.internalId = grandGrandChildLevel.parentId GROUP BY Lookup.internalId ";
+            }
           }
         }
       }
--- a/Framework/Plugins/IndexBackend.h	Fri Nov 15 11:11:05 2024 +0100
+++ b/Framework/Plugins/IndexBackend.h	Tue Nov 26 17:59:14 2024 +0100
@@ -54,6 +54,8 @@
 
     virtual void ClearRemainingAncestor(DatabaseManager& manager);
 
+    virtual bool HasChildCountTable() const = 0;
+
     void SignalDeletedFiles(IDatabaseBackendOutput& output,
                             DatabaseManager& manager);
 
@@ -458,6 +460,10 @@
 #endif
 
 
+    virtual void Shutdown()
+    {};
+
+
     /**
      * "maxDatabaseRetries" is to handle
      * "OrthancPluginErrorCode_DatabaseCannotSerialize" if there is a
--- a/MySQL/Plugins/MySQLIndex.h	Fri Nov 15 11:11:05 2024 +0100
+++ b/MySQL/Plugins/MySQLIndex.h	Tue Nov 26 17:59:14 2024 +0100
@@ -34,6 +34,12 @@
     MySQLParameters        parameters_;
     bool                   clearAll_;
 
+  protected:
+    virtual bool HasChildCountTable() const
+    {
+      return false;
+    }
+
   public:
     MySQLIndex(OrthancPluginContext* context,
                const MySQLParameters& parameters,
--- a/Odbc/Plugins/OdbcIndex.h	Fri Nov 15 11:11:05 2024 +0100
+++ b/Odbc/Plugins/OdbcIndex.h	Tue Nov 26 17:59:14 2024 +0100
@@ -34,6 +34,12 @@
     unsigned int connectionRetryInterval_;
     std::string  connectionString_;
     
+  protected:
+    virtual bool HasChildCountTable() const
+    {
+      return false;
+    }
+
   public:
     OdbcIndex(OrthancPluginContext* context,
               const std::string& connectionString,
--- a/PostgreSQL/CMakeLists.txt	Fri Nov 15 11:11:05 2024 +0100
+++ b/PostgreSQL/CMakeLists.txt	Tue Nov 26 17:59:14 2024 +0100
@@ -92,7 +92,7 @@
   POSTGRESQL_PREPARE_INDEX           ${CMAKE_SOURCE_DIR}/Plugins/SQL/PrepareIndex.sql
   POSTGRESQL_UPGRADE_UNKNOWN_TO_REV1 ${CMAKE_SOURCE_DIR}/Plugins/SQL/Upgrades/UnknownToRev1.sql
   POSTGRESQL_UPGRADE_REV1_TO_REV2    ${CMAKE_SOURCE_DIR}/Plugins/SQL/Upgrades/Rev1ToRev2.sql
-  POSTGRESQL_UPGRADE_REV2_1_REPLACE_CHILDREN_INDEX    ${CMAKE_SOURCE_DIR}/Plugins/SQL/Upgrades/Partial/Rev2_1_ReplaceChildrenIndex.sql
+  POSTGRESQL_UPGRADE_REV2_TO_REV3    ${CMAKE_SOURCE_DIR}/Plugins/SQL/Upgrades/Rev2ToRev3.sql
   )
 
 
--- a/PostgreSQL/NEWS	Fri Nov 15 11:11:05 2024 +0100
+++ b/PostgreSQL/NEWS	Tue Nov 26 17:59:14 2024 +0100
@@ -1,7 +1,7 @@
 Pending changes in the mainline (future 7.0)
 ===============================
 
-DB schema revision: 2
+DB schema revision: 3
 Minimum plugin SDK (for build): 1.12.3
 Optimal plugin SDK (for build): 1.12.5+
 Minimum Orthanc runtime: 1.12.3
@@ -18,6 +18,9 @@
 
     https://orthanc.uclouvain.be/hg/orthanc-databases/file/tip/PostgreSQL/Plugins/SQL/Upgrades/Partial/Rev2_1_ReplaceChildrenIndex.sql
 
+* WARNING: A new table is being created when upgrading to this version.  A new thread
+  is populating this new table and might consume DB bandwitdh and CPU.
+
 
 * Fix updates from plugin version 3.3 to latest version
 * Added support for ExtendedChanges:
--- a/PostgreSQL/Plugins/PostgreSQLIndex.cpp	Fri Nov 15 11:11:05 2024 +0100
+++ b/PostgreSQL/Plugins/PostgreSQLIndex.cpp	Tue Nov 26 17:59:14 2024 +0100
@@ -32,6 +32,7 @@
 
 #include <Compatibility.h>  // For std::unique_ptr<>
 #include <Toolbox.h>
+#include <SystemToolbox.h>
 #include <Logging.h>
 #include <OrthancException.h>
 
@@ -54,7 +55,8 @@
                                    bool readOnly) :
     IndexBackend(context, readOnly),
     parameters_(parameters),
-    clearAll_(false)
+    clearAll_(false),
+    housekeeperShouldStop_(false)
   {
   }
 
@@ -108,7 +110,7 @@
 
     if (!IsReadOnly())
     {
-      // lock the full DB while checking if it needs to be create/ugraded
+      // lock the full DB while checking if it needs to be created/ugraded
       PostgreSQLDatabase::TransientAdvisoryLock lock(db, POSTGRESQL_LOCK_DATABASE_SETUP);
 
       if (clearAll_)
@@ -143,22 +145,30 @@
             throw Orthanc::OrthancException(Orthanc::ErrorCode_Database);        
           }
 
-          bool needToRunUpgradeFromUnknownToV1 = false;
-          bool needToRunUpgradeV1toV2 = false;
+          bool applyUpgradeFromUnknownToV1 = false;
+          bool applyUpgradeV1toV2 = false;
+          bool applyUpgradeV2toV3 = false;
+          bool applyPrepareIndex = false;
 
           int revision;
           if (!LookupGlobalIntegerProperty(revision, manager, MISSING_SERVER_IDENTIFIER, Orthanc::GlobalProperty_DatabasePatchLevel))
           {
             LOG(WARNING) << "No DatabasePatchLevel found, assuming it's 1";
             revision = 1;
-            needToRunUpgradeFromUnknownToV1 = true;
-            needToRunUpgradeV1toV2 = true;
+            applyUpgradeFromUnknownToV1 = true;
+            applyUpgradeV1toV2 = true;
+            applyUpgradeV2toV3 = true;
           }
           else if (revision == 1)
           {
             LOG(WARNING) << "DatabasePatchLevel is 1";
-            needToRunUpgradeFromUnknownToV1 = true;
-            needToRunUpgradeV1toV2 = true;
+            applyUpgradeV1toV2 = true;
+            applyUpgradeV2toV3 = true;
+          }
+          else if (revision == 2)
+          {
+            LOG(WARNING) << "DatabasePatchLevel is 2";
+            applyUpgradeV2toV3 = true;
           }
 
           int hasTrigram = 0;
@@ -169,31 +179,24 @@
             // We've observed 9 minutes on DB with 100000 studies
             LOG(WARNING) << "The DB schema update will try to enable trigram matching on the PostgreSQL database "
                          << "to speed up wildcard searches. This may take several minutes";
-            needToRunUpgradeV1toV2 = true;
+            applyUpgradeV1toV2 = true;
+            applyUpgradeV2toV3 = true;
           }
 
           int property = 0;
-          // these extensions are not installed anymore from v6.0 of the plugin (but the plugin is fast to compute the size and count the resources)
-          // if (!LookupGlobalIntegerProperty(property, manager, MISSING_SERVER_IDENTIFIER,
-          //                                  Orthanc::GlobalProperty_HasFastCountResources) ||
-          //     property != 1)
-          // {
-          //   needToRunUpgradeV1toV2 = true;
-          // }
-          // if (!LookupGlobalIntegerProperty(property, manager, MISSING_SERVER_IDENTIFIER,
-          //                                 Orthanc::GlobalProperty_GetTotalSizeIsFast) ||
-          //     property != 1)
-          // {
-          //   needToRunUpgradeV1toV2 = true;
-          // }
           if (!LookupGlobalIntegerProperty(property, manager, MISSING_SERVER_IDENTIFIER,
                                           Orthanc::GlobalProperty_GetLastChangeIndex) ||
               property != 1)
           {
-            needToRunUpgradeV1toV2 = true;
+            applyUpgradeV1toV2 = true;
+            applyUpgradeV2toV3 = true;
           }
 
-          if (needToRunUpgradeFromUnknownToV1)
+          // If you add new tests here, update the test in the "ReadOnly" code below
+
+          applyPrepareIndex = applyUpgradeV2toV3;
+
+          if (applyUpgradeFromUnknownToV1)
           {
             LOG(WARNING) << "Upgrading DB schema from unknown to revision 1";
             std::string query;
@@ -203,7 +206,7 @@
             t.GetDatabaseTransaction().ExecuteMultiLines(query);
           }
           
-          if (needToRunUpgradeV1toV2)
+          if (applyUpgradeV1toV2)
           {
             LOG(WARNING) << "Upgrading DB schema from revision 1 to revision 2";
 
@@ -212,32 +215,25 @@
             Orthanc::EmbeddedResources::GetFileResource
               (query, Orthanc::EmbeddedResources::POSTGRESQL_UPGRADE_REV1_TO_REV2);
             t.GetDatabaseTransaction().ExecuteMultiLines(query);
-
-            // apply all idempotent changes that are in the PrepareIndexV2
-            ApplyPrepareIndex(t, manager);
           }
 
-          if (!LookupGlobalIntegerProperty(property, manager, MISSING_SERVER_IDENTIFIER,
-                                           Orthanc::GlobalProperty_HasComputeStatisticsReadOnly) ||
-              property != 1)
+          if (applyUpgradeV2toV3)
           {
-            // apply all idempotent changes that are in the PrepareIndex.  In this case, we are just interested by
-            // ComputeStatisticsReadOnly() that does not need to be uninstalled in case of downgrade.
-            ApplyPrepareIndex(t, manager);
-          }
-
-          if (!t.GetDatabaseTransaction().DoesIndexExist("ChildrenIndex2"))
-          {
-            LOG(WARNING) << "Installing ChildrenIndex2";
+            LOG(WARNING) << "Upgrading DB schema from revision 2 to revision 3";
 
             std::string query;
 
             Orthanc::EmbeddedResources::GetFileResource
-              (query, Orthanc::EmbeddedResources::POSTGRESQL_UPGRADE_REV2_1_REPLACE_CHILDREN_INDEX);
+              (query, Orthanc::EmbeddedResources::POSTGRESQL_UPGRADE_REV2_TO_REV3);
             t.GetDatabaseTransaction().ExecuteMultiLines(query);
           }
 
-          // If you add new tests here, update the test in the "ReadOnly" code below
+          if (applyPrepareIndex)
+          {
+            // apply all idempotent changes that are in the PrepareIndex.sql
+            ApplyPrepareIndex(t, manager);
+          }
+
         }
 
         t.Commit();
@@ -250,12 +246,19 @@
       DatabaseManager::Transaction t(manager, TransactionType_ReadOnly);
 
       // test if the latest "extension" has been installed
-      if (!t.GetDatabaseTransaction().DoesIndexExist("ChildrenIndex2"))
-      {
+      int revision;
+      if (!LookupGlobalIntegerProperty(revision, manager, MISSING_SERVER_IDENTIFIER, Orthanc::GlobalProperty_DatabasePatchLevel)
+          || revision != 2)
+      {      
         LOG(ERROR) << "READ-ONLY SYSTEM: the DB does not have the correct schema to run with this version of the plugin"; 
         throw Orthanc::OrthancException(Orthanc::ErrorCode_Database);
       }
     }
+
+    if (!IsReadOnly())
+    {
+      StartDbHousekeeper(manager);
+    }
   }
 
 
@@ -745,6 +748,60 @@
     throw Orthanc::OrthancException(Orthanc::ErrorCode_Database);
   }
 
+  void PostgreSQLIndex::StartDbHousekeeper(DatabaseManager& manager)
+  {
+    housekeeperShouldStop_ = false;
+    dbHousekeeper_.reset(new boost::thread(WorkerHousekeeper, housekeeperShouldStop_, &manager));
+  }
+
+  void PostgreSQLIndex::Shutdown()
+  {
+    // TODO: stop thread
+    housekeeperShouldStop_ = true;
+    if (dbHousekeeper_.get() != NULL && dbHousekeeper_->joinable())
+    {
+      dbHousekeeper_->join();
+      dbHousekeeper_.reset();
+    }
+  }
+
+  void PostgreSQLIndex::WorkerHousekeeper(bool& housekeeperShouldStop,
+                                          DatabaseManager* manager)
+  {
+    OrthancPluginSetCurrentThreadName(OrthancPlugins::GetGlobalContext(), "PG DB-HK");
+    LOG(WARNING) << "Starting the DB Housekeeper thread";
+
+    bool hasComputedAllMissingChildCount = false;
+    while (!housekeeperShouldStop && !hasComputedAllMissingChildCount)
+    {
+      if (!hasComputedAllMissingChildCount)
+      {
+        LOG(INFO) << "Computing missing ChildCount";
+
+        DatabaseManager::Transaction t(*manager, TransactionType_ReadWrite);        
+        
+        try
+        {
+          DatabaseManager::CachedStatement statement(STATEMENT_FROM_HERE, *manager,
+            "SELECT ComputeMissingChildCount(50)");
+
+          statement.Execute();
+
+          int64_t updatedCount = statement.ReadInteger64(0);
+          hasComputedAllMissingChildCount = updatedCount == 0;
+
+          t.Commit();
+        }
+        catch (...)
+        {
+          LOG(ERROR) << "Unexpected error";
+        }
+      }
+      Orthanc::SystemToolbox::USleep(10000);
+    }
+
+    LOG(INFO) << "Stopping the DB Housekeeper thread";
+  }
 
 // #if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 5)
 //   bool PostgreSQLIndex::HasFindSupport() const
--- a/PostgreSQL/Plugins/PostgreSQLIndex.h	Fri Nov 15 11:11:05 2024 +0100
+++ b/PostgreSQL/Plugins/PostgreSQLIndex.h	Tue Nov 26 17:59:14 2024 +0100
@@ -25,6 +25,8 @@
 
 #include "../../Framework/Plugins/IndexBackend.h"
 #include "../../Framework/PostgreSQL/PostgreSQLParameters.h"
+#include <boost/thread.hpp>
+
 
 namespace OrthancDatabases
 {
@@ -33,6 +35,8 @@
   private:
     PostgreSQLParameters   parameters_;
     bool                   clearAll_;
+    std::unique_ptr<boost::thread>  dbHousekeeper_;
+    bool                   housekeeperShouldStop_;
 
   protected:
     virtual void ClearDeletedFiles(DatabaseManager& manager) ORTHANC_OVERRIDE;
@@ -41,8 +45,17 @@
 
     virtual void ClearRemainingAncestor(DatabaseManager& manager) ORTHANC_OVERRIDE;
 
+    virtual bool HasChildCountTable() const ORTHANC_OVERRIDE
+    {
+      return true;
+    }
+
     void ApplyPrepareIndex(DatabaseManager::Transaction& t, DatabaseManager& manager);
 
+    void StartDbHousekeeper(DatabaseManager& manager);
+
+    static void WorkerHousekeeper(bool& housekeeperShouldStop, DatabaseManager* manager);
+
   public:
     PostgreSQLIndex(OrthancPluginContext* context,
                     const PostgreSQLParameters& parameters,
@@ -136,6 +149,8 @@
                                         int64_t& compressedSize,
                                         int64_t& uncompressedSize) ORTHANC_OVERRIDE;
 
+    virtual void Shutdown() ORTHANC_OVERRIDE;
+
 // #if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 5)
 //     virtual bool HasFindSupport() const ORTHANC_OVERRIDE;
 // #endif
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/PostgreSQL/Plugins/SQL/Downgrades/Rev3ToRev2.sql	Tue Nov 26 17:59:14 2024 +0100
@@ -0,0 +1,15 @@
+-- This file contains an SQL procedure to downgrade from schema Rev3 to Rev2 (version = 6, revision = 1).
+  -- It actually deletes the ChildCount table and triggers
+  -- It actually does not uninstall ChildrenIndex2 because it is anyway more efficient than 
+     -- ChildrenIndex and is not incompatible with previous revisions.
+
+DROP TRIGGER IF EXISTS DecrementChildCount ON Resources;
+DROP TRIGGER IF EXISTS IncrementChildCount ON Resources;
+DROP TABLE ChildCount;
+DROP FUNCTION UpdateChildCount;
+
+
+-- set the global properties that actually documents the DB version, revision and some of the capabilities
+-- modify only the ones that have changed
+DELETE FROM GlobalProperties WHERE property IN (4, 11);
+INSERT INTO GlobalProperties VALUES (4, 2); -- GlobalProperty_DatabasePatchLevel
--- a/PostgreSQL/Plugins/SQL/PrepareIndex.sql	Fri Nov 15 11:11:05 2024 +0100
+++ b/PostgreSQL/Plugins/SQL/PrepareIndex.sql	Tue Nov 26 17:59:14 2024 +0100
@@ -106,7 +106,6 @@
         PRIMARY KEY(server, property)
         );
 
--- new ChildrenIndex2 introduced in v 7.0 (replacing previous ChildrenIndex)
 DO $$
 DECLARE
     pg_version text;
@@ -115,6 +114,8 @@
 
     IF substring(pg_version from 'PostgreSQL (\d+)\.')::int >= 11 THEN
         -- PostgreSQL 11 or later
+
+        -- new ChildrenIndex2 introduced in Rev3 (replacing previous ChildrenIndex)
         EXECUTE 'CREATE INDEX IF NOT EXISTS ChildrenIndex2 ON Resources USING btree (parentId ASC NULLS LAST) INCLUDE (publicId, internalId)';
     ELSE
         EXECUTE 'CREATE INDEX IF NOT EXISTS ChildrenIndex2 ON Resources USING btree (parentId ASC NULLS LAST, publicId, internalId)';
@@ -129,6 +130,7 @@
 CREATE INDEX IF NOT EXISTS MainDicomTagsIndex ON MainDicomTags(id);
 CREATE INDEX IF NOT EXISTS DicomIdentifiersIndex1 ON DicomIdentifiers(id);
 CREATE INDEX IF NOT EXISTS DicomIdentifiersIndex2 ON DicomIdentifiers(tagGroup, tagElement);
+CREATE INDEX IF NOT EXISTS DicomIdentifiersIndex3 ON DicomIdentifiers(tagGroup, tagElement, value);
 CREATE INDEX IF NOT EXISTS DicomIdentifiersIndexValues ON DicomIdentifiers(value);
 
 CREATE INDEX IF NOT EXISTS ChangesIndex ON Changes(internalId);
@@ -585,11 +587,116 @@
 $body$ LANGUAGE plpgsql;
 
 
+-- new in Rev3
+
+CREATE TABLE IF NOT EXISTS ChildCount (
+    parentId BIGINT PRIMARY KEY REFERENCES Resources(internalId) ON DELETE CASCADE,
+    childCount INTEGER NOT NULL DEFAULT 0,
+    CONSTRAINT UniqueParentID UNIQUE (parentId)
+);
+
+
+-- Computes the ChildCount for a number of resources for which it has not been computed yet.
+-- This is actually used only after an update to Rev3.  A thread will call this function
+-- at regular interval to update all missing values and stop once all values have been processed.
+CREATE OR REPLACE FUNCTION ComputeMissingChildCount(
+    IN batch_size BIGINT,
+    OUT updated_rows_count BIGINT
+) RETURNS BIGINT AS $body$
+
+BEGIN
+
+    INSERT INTO ChildCount (parentID, childCount)
+    SELECT r.internalId AS parentId, COUNT(childLevel.internalId) AS childCount
+    FROM Resources AS r
+    LEFT JOIN Resources AS childLevel ON r.internalId = childLevel.parentId
+    WHERE r.internalId IN (
+        SELECT internalId FROM Resources AS r
+        WHERE resourceType < 3 AND NOT EXISTS(SELECT 1 FROM ChildCount WHERE ChildCount.parentId = r.internalId)
+        LIMIT batch_size)
+    GROUP BY r.internalId;
+    
+    -- Get the number of rows affected
+    GET DIAGNOSTICS updated_rows_count = ROW_COUNT;
+
+END;
+$body$ LANGUAGE plpgsql;
+
+
+
+CREATE OR REPLACE FUNCTION UpdateChildCount()
+RETURNS TRIGGER AS $body$
+DECLARE
+	parent_id BIGINT;
+BEGIN
+    IF TG_OP = 'INSERT' THEN
+		IF NEW.parentId IS NOT NULL THEN
+            -- try to increment the childcount from the parent
+			UPDATE ChildCount
+		    SET childCount = childCount + 1
+		    WHERE parentId = NEW.parentId
+		    RETURNING parentId INTO parent_id;
+		
+            -- this should only happen for old studies whose childCount has not yet been computed
+		    IF NOT FOUND THEN
+		        INSERT INTO childcount (parentId, childCount)
+		        SELECT parentID, COUNT(*)
+		        FROM Resources
+		        WHERE parentId = parent_id
+		        GROUP BY parentId;
+		    END IF;
+        END IF;
+	
+        -- this is a future parent, start counting the children
+        IF NEW.resourcetype < 3 THEN
+		   insert into ChildCount (parentId, childCount)
+		   values (new.internalId, 0);
+	 	END IF;
+
+    ELSIF TG_OP = 'DELETE' THEN
+
+		IF NEW.parentId IS NOT NULL THEN
+
+            -- Decrement the child count for the parent
+            UPDATE ChildCount
+            SET childCount = childCount - 1
+            WHERE parentId = OLD.parentId
+		    RETURNING parentId INTO parent_id;
+		
+            -- this should only happen for old studies whose childCount has not yet been computed
+		    IF NOT FOUND THEN
+		        INSERT INTO childcount (parentId, childCount)
+		        SELECT parentID, COUNT(*)
+		        FROM Resources
+		        WHERE parentId = parent_id
+		        GROUP BY parentId;
+		    END IF;
+        END IF;
+        
+    END IF;
+    RETURN NULL;
+END;
+$body$ LANGUAGE plpgsql;
+
+-- Trigger for INSERT
+CREATE TRIGGER IncrementChildCount
+AFTER INSERT ON Resources
+FOR EACH ROW
+EXECUTE FUNCTION UpdateChildCount();
+
+-- Trigger for DELETE
+CREATE TRIGGER DecrementChildCount
+AFTER DELETE ON Resources
+FOR EACH ROW
+WHEN (OLD.parentId IS NOT NULL)
+EXECUTE FUNCTION UpdateChildCount();
+
+
 
 -- set the global properties that actually documents the DB version, revision and some of the capabilities
 DELETE FROM GlobalProperties WHERE property IN (1, 4, 6, 10, 11, 12, 13, 14);
 INSERT INTO GlobalProperties VALUES (1, 6); -- GlobalProperty_DatabaseSchemaVersion
-INSERT INTO GlobalProperties VALUES (4, 2); -- GlobalProperty_DatabasePatchLevel
+INSERT INTO GlobalProperties VALUES (4, 3); -- GlobalProperty_DatabasePatchLevel
 INSERT INTO GlobalProperties VALUES (6, 1); -- GlobalProperty_GetTotalSizeIsFast
 INSERT INTO GlobalProperties VALUES (10, 1); -- GlobalProperty_HasTrigramIndex
 INSERT INTO GlobalProperties VALUES (11, 3); -- GlobalProperty_HasCreateInstance  -- this is actually the 3rd version of HasCreateInstance
--- a/PostgreSQL/Plugins/SQL/Upgrades/Partial/Rev2_1_ReplaceChildrenIndex.sql	Fri Nov 15 11:11:05 2024 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,15 +0,0 @@
-DO $$
-DECLARE
-    pg_version text;
-BEGIN
-    SELECT version() INTO pg_version;
-
-    IF substring(pg_version from 'PostgreSQL (\d+)\.')::int >= 11 THEN
-        -- PostgreSQL 11 or later
-        EXECUTE 'CREATE INDEX IF NOT EXISTS ChildrenIndex2 ON Resources USING btree (parentId ASC NULLS LAST) INCLUDE (publicId, internalId)';
-    ELSE
-        EXECUTE 'CREATE INDEX IF NOT EXISTS ChildrenIndex2 ON Resources USING btree (parentId ASC NULLS LAST, publicId, internalId)';
-    END IF;
-END $$;
-
-DROP INDEX IF EXISTS ChildrenIndex;  -- replaced by ChildrenIndex2 but no need to uninstall ChildrenIndex2 when downgrading
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/PostgreSQL/Plugins/SQL/Upgrades/Rev2ToRev3.sql	Tue Nov 26 17:59:14 2024 +0100
@@ -0,0 +1,31 @@
+-- This file contains part of the changes required to upgrade from Revision 2 to Revision 3 (DB version 6 and revision 2)
+-- It actually contains only the changes that:
+   -- can not be executed with an idempotent statement in SQL
+   -- or would polute the PrepareIndex.sql
+   -- do facilite an up-time upgrade
+-- This file is executed only if the current schema is in revision 2 and it is executed 
+-- before PrepareIndex.sql that is idempotent.
+
+
+-- create a new ChildrenIndex2 that is replacing ChildrenIndex.
+-- We create it in this partial update so it can be created while the system is up !
+DO $$
+DECLARE
+    pg_version text;
+BEGIN
+    SELECT version() INTO pg_version;
+
+    IF substring(pg_version from 'PostgreSQL (\d+)\.')::int >= 11 THEN
+        -- PostgreSQL 11 or later
+        EXECUTE 'CREATE INDEX IF NOT EXISTS ChildrenIndex2 ON Resources USING btree (parentId ASC NULLS LAST) INCLUDE (publicId, internalId)';
+    ELSE
+        EXECUTE 'CREATE INDEX IF NOT EXISTS ChildrenIndex2 ON Resources USING btree (parentId ASC NULLS LAST, publicId, internalId)';
+    END IF;
+END $$;
+
+DROP INDEX IF EXISTS ChildrenIndex;  -- replaced by ChildrenIndex2 but no need to uninstall ChildrenIndex2 when downgrading
+
+
+-- other changes performed in PrepareIndex.sql:
+  -- add ChildCount tables and triggers
+