Skip to content

perf: QueryNameMapper is rebuilt on every transaction/connection scope — ~1s per $transaction on large schemas #2773

Description

@matijaboban

Summary

QueryNameMapper is constructed per query-executor instance, and ZenStackQueryExecutor.withConnectionProvider() creates a new executor for every connection scope. Because entering a transaction goes through that path, each $transaction(...) rebuilds the mapper — and the mapper's constructor walks every model and every field, twice.

On a large schema this dominates the transaction. Measured on a 1054-model / 11754-field Postgres schema, a paginated read plus a follow-up query:

Latency
outside a transaction 4.53 ms
inside $transaction 995.64 ms

A no-op through a fresh connection scope isolates it: $qb.connection().execute(noop)488 ms, which is one rebuild.

The isolation level is irrelevant — any transaction scope pays it.

Where

packages/orm/dist/index.mjs (built output, v3.7.2 and v3.8.3 are identical here):

// executor constructor
if (client.$schema.provider.type === "postgresql" || this.schemaHasMappedNames(client.$schema))
    this.nameMapper = new QueryNameMapper(client);   // 3.7.2:6262 · 3.8.3:6328

// new executor per connection scope
withConnectionProvider(connectionProvider) {          // 3.8.3:6684
    const newExecutor = new ZenStackQueryExecutor(this.client, this.driver, this.compiler,
                                                  this.adapter, connectionProvider, ...);

ClientImpl then constructs a second one, so a transaction scope pays the cost twice.

The mapper's constructor iterates all models × all fields, with a second pass computing many-to-many join tables.

Scaling

Same code path, trimmed schemas — the cost grows superlinearly with schema size:

Models Fields Rebuild
100 1 187 4.28 ms
500 5 673 98.24 ms
1 054 11 754 479 ms

Why it matters

The cost is invisible at the call site — nothing suggests that wrapping two queries in a transaction multiplies latency by ~220×. It also grows as a project's schema grows, so it degrades quietly rather than failing.

Practically, it makes $transaction unusable on read paths for large schemas. In our case it ruled out an otherwise-correct fix: we needed a snapshot-consistent read (page query + a follow-up projection in one snapshot) and had to abandon it, keeping a documented race instead.

Suggested direction

The mapper appears to depend only on client.$schema, which does not change across connection scopes — so it looks cacheable per client (or per schema) rather than per executor. If there is a reason it must be per-executor that I have missed, I would genuinely like to know, since that would change how we work around it.

Environment

  • @zenstackhq/orm 3.7.2, and verified unchanged in 3.8.3 (latest at time of writing)
  • PostgreSQL 18.4
  • Bun 1.3.14
  • Postgres dialect, schema with @@map/@map throughout

Happy to put together a standalone reproduction if the scaling table above is not sufficient — the measurements come from a private schema, but the shape reproduces with any large generated one.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions