Repository: iTwin/imodel-native
Pull request #1084: Fix for: Clearing ECDb cache causes repeated ConcurrentQuery to fail
Author: login: rschili
Created: 2025-04-24T10:38:53Z | Merged: 2025-04-29T09:51:33Z

fixes: https://github.com/iTwin/itwinjs-core/issues/7984

The cleared cache did not properly clean ConcurrentQuery's internal caches, so if an identical query was sent again, the statement would be reused, which triggers an internal failsafe to protect against that.

Now we clean concurrent query caches.

@khanaffan please check. There are so many classes and caches in concurrent query, I'm only 80% sure that I hit the right spot and didn't miss anything. My added test passes after fix and fails without.


Diff:
diff --git a/iModelCore/ECDb/ECDb/ConcurrentQueryManagerImpl.cpp b/iModelCore/ECDb/ECDb/ConcurrentQueryManagerImpl.cpp
index 08608b4748a..454d46e3a10 100644
--- a/iModelCore/ECDb/ECDb/ConcurrentQueryManagerImpl.cpp
+++ b/iModelCore/ECDb/ECDb/ConcurrentQueryManagerImpl.cpp
@@ -397,6 +397,9 @@ void ConnectionCache::Interrupt(bool reset_conn, bool detach_dbs) {
         std::this_thread::yield();
     }
     if (reset_conn) {
+        if(m_syncConn)
+            m_syncConn->Reset(detach_dbs);
+
         for (auto& it : m_conns) {
             it->Reset(detach_dbs);
         }
diff --git a/iModelCore/ECDb/Tests/NonPublished/ConcurrentQueryTest_V2.cpp b/iModelCore/ECDb/Tests/NonPublished/ConcurrentQueryTest_V2.cpp
index 29c0b22e729..619c8cdbc11 100644
--- a/iModelCore/ECDb/Tests/NonPublished/ConcurrentQueryTest_V2.cpp
+++ b/iModelCore/ECDb/Tests/NonPublished/ConcurrentQueryTest_V2.cpp
@@ -1234,4 +1234,60 @@ TEST_F(ConcurrentQueryFixture, ReaderBindingForIdSetVirtualTable) {
         }
 }
 
+//---------------------------------------------------------------------------------------
+//@bsimethod
+//+---------------+---------------+---------------+---------------+---------------+------
+TEST_F(ConcurrentQueryFixture, ImportSchemaShouldClearQueryCache) {
+    // There was a bug that importing a schema did not clean the cached prepared statements for concurrent queries.
+    // So running a query that is cached would result in an error because the query needs to be reprepared.
+    ASSERT_EQ(BentleyStatus::SUCCESS, SetupECDbForCurrentTest(SchemaItem(
+        R"xml(<ECSchema schemaName="TestSchema" alias="ts" version="1.0.0" xmlns="http://www.bentley.com/schemas/Bentley.ECXML.3.2">
+                <ECEntityClass typeName="testEntity">
+                    <ECProperty propertyName="entity_id" typeName="int" />
+                </ECEntityClass>
+            </ECSchema>)xml")));
+
+    ECSqlStatement stmt;
+    ASSERT_EQ(ECSqlStatus::Success, stmt.Prepare(m_ecdb, "INSERT INTO ts.testEntity(entity_id) VALUES(?)"));
+    stmt.BindInt(1, 1);
+    ASSERT_EQ(stmt.Step(), BE_SQLITE_DONE);
+    m_ecdb.SaveChanges();
+
+    auto& mgr = ConcurrentQueryMgr::GetInstance(m_ecdb);
+
+    {
+        auto req = ECSqlRequest::MakeRequest("SELECT entity_id FROM ts.testEntity");
+        req->SetUsePrimaryConnection(true);
+        auto r = mgr.Enqueue(std::move(req)).Get();
+        ASSERT_EQ(r->GetStatus(), QueryResponse::Status::Done);
+
+        auto res = ((ECSqlResponse*) r.get());
+        BeJsDocument resJson;
+        res->ToJs(resJson, true);
+        ASSERT_EQ(res->asJsonString(), "[[1]]");
+    }
+
+    // Import a new schema to clear the cache
+    SchemaItem updatedSchema(
+        R"xml(<ECSchema schemaName="TestSchema" alias="ts" version="1.0.1" xmlns="http://www.bentley.com/schemas/Bentley.ECXML.3.2">
+        <ECEntityClass typeName="testEntity">
+            <ECProperty propertyName="entity_id" typeName="int" />
+            <ECProperty propertyName="new_prop" typeName="int" />
+        </ECEntityClass>
+    </ECSchema>)xml");
+    ASSERT_EQ(BentleyStatus::SUCCESS, ImportSchema(updatedSchema));
+    // Run an identical query again
+    {
+        auto req = ECSqlRequest::MakeRequest("SELECT entity_id FROM ts.testEntity");
+        req->SetUsePrimaryConnection(true);
+        auto r = mgr.Enqueue(std::move(req)).Get();
+        ASSERT_EQ(r->GetStatus(), QueryResponse::Status::Done);
+
+        auto res = ((ECSqlResponse*) r.get());
+        BeJsDocument resJson;
+        res->ToJs(resJson, true);
+        ASSERT_EQ(res->asJsonString(), "[[1]]");
+    }
+}
+
 END_ECDBUNITTESTS_NAMESPACE


Reviews:
user: login: khanaffan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T13:47:29Z
user: login: rschili
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T17:49:34Z
user: login: rschili
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T07:40:55Z
user: login: khanaffan
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T15:43:11Z

Review comments:
diff_hunk: @@ -328,6 +328,15 @@ void ConnectionCache::InterruptIf(std::function<bool(RunnableRequestBase const&)
             conn->InterruptIf(predicate, cancel);
     }
 }
+
+//---------------------------------------------------------------------------------------
+// @bsimethod
+//---------------------------------------------------------------------------------------
+void ConnectionCache::Clear() {
+    m_conns.clear();
path: iModelCore/ECDb/ECDb/ConcurrentQueryManagerImpl.cpp
user: login: khanaffan
body: Change the state of this class requires lock on mutex. But that could cause race condition.

May be the simple solution to this is to restart concurrent query on schema import
created_at: 2025-04-24T13:47:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -328,6 +328,15 @@ void ConnectionCache::InterruptIf(std::function<bool(RunnableRequestBase const&)
             conn->InterruptIf(predicate, cancel);
     }
 }
+
+//---------------------------------------------------------------------------------------
+// @bsimethod
+//---------------------------------------------------------------------------------------
+void ConnectionCache::Clear() {
+    m_conns.clear();
path: iModelCore/ECDb/ECDb/ConcurrentQueryManagerImpl.cpp
user: login: rschili
body: Does that even make sense? Either solution will result in undefined behavior. If we restart the query while somebody is reading, we will have duplicate results, and if we continue, state is undefined.
I don't think there is a scenario for "import a schema while queries are running and have those queries continue". It is probably fine to just cancel and fail all running queries on schema import. Do you agree?
created_at: 2025-04-24T17:49:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -328,6 +328,15 @@ void ConnectionCache::InterruptIf(std::function<bool(RunnableRequestBase const&)
             conn->InterruptIf(predicate, cancel);
     }
 }
+
+//---------------------------------------------------------------------------------------
+// @bsimethod
+//---------------------------------------------------------------------------------------
+void ConnectionCache::Clear() {
+    m_conns.clear();
path: iModelCore/ECDb/ECDb/ConcurrentQueryManagerImpl.cpp
user: login: rschili
body: I don't think Shutdown does what you suggested it does. It just clears some cache directories on disk, it does not reset or clear connections, so it does not help.
I've put a different fix now. It seems like there already is a reset logic inside Interrupt().
Allow me the criticism, I think this concurrent query architecture is too complicated.
The calltree is

```
ConcurrentQueryMgr::Impl::_OnBeforeClearECDbCache()
-> ConcurrentQueryMgr::Impl::Suspend(ClearCacheOption clearCache, DetachAttachDbs detachDbs)
-> ConnectionCache::Interrupt(bool reset_conn, bool detach_dbs)
-> CachedConnection::Reset(bool detachDbs) // Calls m_db.ClearECDbCache()
-> QueryAdaptorCache::Reset()
-> (clears vector of CachedQueryAdaptor)
```

There are so many managers adaptors and caches, reminds me of the C# days :-)

The CachedConnection::Reset method calls m_db.ClearECDbCache(). My new approach calls Reset() on the m_syncConnection, but I do not end up in a stack overflow, which is surprising. Is m_syncConnection a different connection than the ecdb that my outside test uses?
created_at: 2025-04-28T07:40:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: mergify[bot]
created_at: 2025-04-25T14:45:30Z
body: This pull request is now in conflicts. Could you fix it @rschili? 🙏
To fixup this pull request, you can check out it locally. See documentation: https://help.github.com/articles/checking-out-pull-requests-locally/
author_association: CONTRIBUTOR
user: login: rschili
created_at: 2025-04-29T08:10:37Z
body: /azp run imodel-native
author_association: CONTRIBUTOR
user: login: azure-pipelines[bot]
created_at: 2025-04-29T08:10:49Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE
user: login: aruniverse
created_at: 2025-04-29T13:40:00Z
body: @mergifyio backport release/5.0.x release/4.11.x
author_association: MEMBER
user: login: mergify[bot]
created_at: 2025-04-29T13:40:11Z
body: > backport release/5.0.x release/4.11.x

#### ✅ Backports have been created

<details>

* [#1095 Fix for: Clearing ECDb cache causes repeated ConcurrentQuery to fail (backport #1084) [release/5.0.x]](https://github.com/iTwin/imodel-native/pull/1095) has been created for branch `release/5.0.x`
* [#1096 Fix for: Clearing ECDb cache causes repeated ConcurrentQuery to fail (backport #1084) [release/4.11.x]](https://github.com/iTwin/imodel-native/pull/1096) has been created for branch `release/4.11.x`

</details>


<!---
DO NOT EDIT
-*- Mergify Payload -*-
{"command": "backport release/5.0.x release/4.11.x", "conclusion": "success", "action_is_running": true}
-*- Mergify Payload End -*-
-->
author_association: CONTRIBUTOR