Skip to content

Reuse cached prepared statements in ECSql synchronous reader path#9542

Open
khanaffan wants to merge 3 commits into
masterfrom
affan/ecsql-syncreader-statement-cache
Open

Reuse cached prepared statements in ECSql synchronous reader path#9542
khanaffan wants to merge 3 commits into
masterfrom
affan/ecsql-syncreader-statement-cache

Conversation

@khanaffan

@khanaffan khanaffan commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

closes: https://github.com/iTwin/itwinjs-backlog/issues/2239

Restores ECSQL statement caching in the synchronous withQueryReader path. ECSqlRowExecutor now reuses a prepared statement obtained from the owning db's statement cache instead of re-preparing the ECSQL on every call, fixing a per-element performance regression.

Changes

  • Added an internal [_getStatementCache]() accessor on ECDb and IModelDb (via a new _getStatementCache symbol) so internal collaborators can reuse the existing prepared-statement cache.
  • ECSqlRowExecutor retrieves/returns a prepared statement from the cache rather than compiling the ECSQL each call.
  • Added regression tests covering statement reuse, cache round-trip, nested-reader independence, and prepare-failure recovery for both the IModelDb and ECDb paths.

API / release notes

  • [_getStatementCache] is tagged @internal; common/api/core-backend.api.md regenerated via rush extract-api. The only report delta is the two new @internal entries — withPreparedStatement keeps its existing @public @deprecated tags.
  • rush change file added with "type": "none" (lockstep monorepo, no version bump / no public API change).

ECSqlRowExecutor now retrieves a prepared statement from the owning
db's statement cache (via the new internal [_getStatementCache] accessor
on ECDb and IModelDb) instead of re-preparing the ECSQL on every call in
the synchronous withQueryReader path. This fixes a per-element
performance regression.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 24, 2026 19:57
@khanaffan
khanaffan marked this pull request as ready for review July 24, 2026 19:58
@khanaffan
khanaffan requested a review from a team as a code owner July 24, 2026 19:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Restores prepared ECSQL statement reuse for the synchronous withQueryReader execution path by routing ECSqlRowExecutor through the existing per-db StatementCache, addressing a per-element prepare/compile performance regression.

Changes:

  • Added an internal [_getStatementCache]() symbol accessor on IModelDb and ECDb to expose the existing ECSQL StatementCache to internal collaborators.
  • Updated ECSqlRowExecutor to checkout prepared statements from the cache (or prepare once) and return them to the cache on disposal.
  • Added regression tests validating reuse, cache round-trip, nested-reader independence, and prepare-failure recovery for both IModelDb and ECDb readers.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
core/backend/src/test/ecdb/ECSqlSyncReader.test.ts Adds regression coverage for prepared-statement reuse and cache behavior in sync readers.
core/backend/src/internal/Symbols.ts Introduces _getStatementCache internal symbol for cache access.
core/backend/src/IModelDb.ts Exposes ECSQL statement cache via [_getStatementCache]() for internal use.
core/backend/src/ECSqlRowExecutor.ts Reuses cached prepared statements and returns them to the cache on dispose.
core/backend/src/ECDb.ts Exposes ECSQL statement cache via [_getStatementCache]() for internal use.
common/changes/@itwin/core-backend/statement-cache-withqueryreader.json Records the change (type none) for lockstep release tracking.
common/api/core-backend.api.md Updates API report with new @internal [_getStatementCache]() entries.

Comment thread core/backend/src/ECSqlRowExecutor.ts
Comment thread core/backend/src/ECSqlRowExecutor.ts
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 24, 2026 20:01
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (3)

core/backend/src/test/ecdb/ECSqlSyncReader.test.ts:165

  • These assertions reach into the private _statementCache via any. With _getStatementCache available, the test can verify cache behavior through the intended internal accessor and avoid depending on the private field name.
      expect((iModel as any)._statementCache.size).to.equal(0);
      iModel.withQueryReader(sql, (reader) => reader.step(), new QueryBinder().bindId(1, "0x1"));
      expect((iModel as any)._statementCache.size).to.be.greaterThan(0);
      // A subsequent call with the same SQL must be able to check the cached statement back out.
      const found = (iModel as any)._statementCache.findAndRemove(sql);

core/backend/src/test/ecdb/ECSqlSyncReader.test.ts:192

  • Same here: prefer using the new internal _getStatementCache accessor over (iModel as any)._statementCache so the test doesn't depend on a private field name.
      expect((iModel as any)._statementCache.findAndRemove("SELECT * FROM bis.ThisClassDoesNotExist")).to.be.undefined;

core/backend/src/test/ecdb/ECSqlSyncReader.test.ts:212

  • Prefer using the new internal _getStatementCache accessor over (ecdb as any)._statementCache to avoid any and reliance on a private field name.
      expect((ecdb as any)._statementCache.size).to.be.greaterThan(0);

Comment on lines +12 to 15
import { ECSqlStatement } from "../../ECSqlStatement";
import { ECDbTestHelper } from "./ECDbTestHelper";
import { KnownTestLocations } from "../KnownTestLocations";

reader.step();
}, new QueryBinder().bindId(1, "0x1"));
}
expect(prepareSpy.callCount).to.equal(1, "identical-shape queries should re-use one cached prepared statement");
try {
stmt?.[Symbol.dispose]();
} catch {
// Best-effort teardown: disposing a statement whose native ECDb cache was already torn down

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we log a trace in this best effort teardown, I think we should not completely swallow errors even if they are expected.

try {
stmt[Symbol.dispose]();
} catch {
// ignore - best effort

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

try {
stmt[Symbol.dispose]();
} catch {
// ignore - best effort

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

return { isSuccessful: true };

this._stmt.bindParams(args);
this._stmt!.bindParams(args); // eslint-disable-line @typescript-eslint/no-non-null-assertion

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we also just check if stmt is undef?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants