Back to Blog

Language: English

Promise.all Inside an Interactive Transaction Doesn't Run in Parallel

Promise.all inside a Prisma interactive transaction ran its queries serially until the tx timed out with P2028. Splitting into a tx per query restored parallelism, and two static analysis rules pointing in opposite directions now coexist to keep both mistakes out.

On our annotation platform, a GET endpoint aggregating statistics intermittently returned 500s. The error was Prisma’s P2028, the code signaling an interactive transaction timeout, and it had been repeating since July 22.

The read in question sends five queries through Promise.all. At a glance the code should run in parallel. In practice the queries had stacked serially inside a single transaction.

A transaction occupying one connection

The cause was the shape of the statistics read:

// Before のイメージ
const [r1, r2, r3, r4, r5] = await this.prisma.withCurrentRlsScope((tx) =>
  Promise.all([
    tx.a.count(),
    tx.b.groupBy(),
    tx.c.findMany(),
    tx.d.count(),
    tx.e.findMany(),
  ]),
);

withCurrentRlsScope is a wrapper that runs reads inside a transaction configured with the RLS authorization scope. Confining RLS reads to a single transaction to keep the authorization scope uniform is the practice described in When RLS Authorization Checks Scaled Proportionally With Row Count.

The problem is that an interactive transaction (the callback form of $transaction behaves the same way) occupies one connection. Stack five queries into Promise.all inside the callback and they issue onto that single connection all the same. The five queries run serially and their latencies add up. Success and P2028 split on whether the total crosses the tx timeout of 5 seconds.

So the code kept looking parallel while serial wait time accumulated all the way to the timeout.

Splitting into a tx per query

The fix abandons the single-tx shape and opens a tx per query.

// After のイメージ
const [r1, r2, r3, r4, r5] = await Promise.all([
  this.prisma.withCurrentRlsScope((tx) => tx.a.count()),
  this.prisma.withCurrentRlsScope((tx) => tx.b.groupBy()),
  this.prisma.withCurrentRlsScope((tx) => tx.c.findMany()),
  this.prisma.withCurrentRlsScope((tx) => tx.d.count()),
  this.prisma.withCurrentRlsScope((tx) => tx.e.findMany()),
]);

Promise.all moved outside the transactions. Each tx contains only the RLS scope setup (set_config) and one query, and runs on its own connection, so the five queries now run in parallel. The return values and the query conditions stayed untouched.

Parallelism costs connections: simultaneous borrows grew from one to five. We widened the application connection pool to DB_CONNECTION_LIMIT=30, uniform across environments and regions; the previous values were 10 for dev and stg and 15 for production.

The number comes from measurement. Cloud SQL’s max_connections defaults to at least 400 on the DB side, while peak actual connection counts were 18 in one region’s production and 3 in the other’s. Even multiplied by the application instance count, 30 leaves headroom below that limit.

A rule pointing the other way

This aggregation once lived as bare Promise.all calls. The July 10 RLS performance audit had applied the fix in the opposite direction. Back then, the read fired five unscoped queries through Promise.all. Each query carried its own BEGIN / set_config / body / COMMIT as physical statements, and parallel fan-out amplifies connection borrowing N-fold. So a change consolidated the five-way Promise.all into one tx.

P2028 observations began after that consolidation. What accumulated to the timeout was the post-consolidation shape itself: five queries stacked into one tx through Promise.all.

The repository already had an ast-grep rule named rls-promise-all-not-scoped. It detects unscoped Prisma model calls fired through Promise.all under an RLS context and warns you to consolidate them into a single scoped tx. The rule added this time, no-promise-all-in-prisma-tx, detects Promise.all / Promise.allSettled nested directly under a tx callback and pushes toward splitting. The two directions look opposed.

The rules don’t contradict each other. Two facts hold at once: Promise.all inside a tx gains no parallelism, and fan-out bypassing a tx wastes connections. Query weight decides between consolidating and splitting. The convention gained this exception clause: bundles of heavy reads whose combined runtime approaches the tx timeout gain nothing from consolidation into one tx; execution stays serial and wait time stacks up to the timeout anyway. In that case, split into independent scoped txs per query and run Promise.all outside them. Light reads get by with consolidation into one tx and sequential awaits.

rls-promise-all-not-scopedno-promise-all-in-prisma-tx
Shape detectedUnscoped queries fired through Promise.all with no tx in betweenPromise.all nested inside a tx callback
Direction of the warningConsolidate into one scoped txSplit into a tx per query
Severitywarningerror (hard gate in CI)

Detection in two layers

Recurrence prevention landed as two layers, ESLint and ast-grep.

  • ESLint reports a warning, shown where you write, in the IDE and pre-commit
  • ast-grep reports an error and fails the build as CI’s hard gate

The ESLint selector is the following (illustrative):

CallExpression[callee.property.name=/^(\$transaction|withCurrentRlsScope|withTenantScope|withListScope)$/] > :function CallExpression[callee.object.name='Promise'][callee.property.name=/^(all|allSettled)$/]

Its targets are Promise.all / Promise.allSettled sitting directly under the callbacks of the four tx-producing calls ($transaction plus the three scope wrappers). Array-form batch calls such as $transaction([...]) and Promise.all outside a tx are out of scope.

The original plan added a new no-restricted-syntax: error block to the ESLint config. That breaks under flat config: defining the same rule name against the same files selector wins last and replaces the existing definition wholesale. A probe using a throwaway file confirmed the existing warn block (which bans new Logger and process.stdout.write) would go inert across the entire application codebase.

So instead of a new block, we appended one line to the selector array of the existing warn block. Error-level enforcement lives on the ast-grep side, following the repository’s standing policy of concentrating hard gates there.

Shapes detection misses

Once the rules landed, four existing violations surfaced: bundles of read-only queries scattered across three files handling request statistics, project access permissions, and task aggregation. We fixed every one with the incident’s own pattern, a tx per query with Promise.all outside. No violation involved writes, none was a false positive, and no suppression comments (eslint-disable, ast-grep-ignore) were needed.

The selectors see only calls nested directly in the tx callback, though. Passing a tx into another function and stacking Promise.all inside it stays invisible to detection. Two helpers fit that shape, both performing light reads (three counts and two findMany calls specifying ids). Because the reads were light, we replaced Promise.all with sequential awaits rather than splitting the tx. Inside a tx execution is serial regardless, so connection consumption and latency effectively stay the same.

Inside and outside

The location of Promise.all separates parallel from serial. Within a unit occupying one connection, Promise.all arranges the order queries fire and execution remains serial. To get parallelism, shrink the occupied unit to a single query and place Promise.all outside it. Bare fan-out without a tx showed up as the opposite problem in the audit, multiplying collections and physical statements.

The input to the decision is how many connections the queries issue onto. However parallel the code looks, the moment issuance concentrates onto a single connection, execution returns to serial.