Skip to content

fix: a full-size suite could not be ingested at all - #234

Merged
AKogut merged 1 commit into
mainfrom
fix/full-suite-ingestion
Aug 4, 2026
Merged

fix: a full-size suite could not be ingested at all#234
AKogut merged 1 commit into
mainfrom
fix/full-suite-ingestion

Conversation

@AKogut

@AKogut AKogut commented Aug 4, 2026

Copy link
Copy Markdown
Owner

The defect

Prisma caps an interactive transaction at five seconds. processJob wrote one statement per test inside a single transaction, so any suite past roughly a thousand tests overran the cap.

The failure mode is the bad part. The API has already answered 202 Accepted with a receipt id by the time the worker touches the batch. The job then fails all five attempts, lands in the dead letter, and the only trace is a line on the worker's stderr. The run never appears in the dashboard and nothing tells anyone why.

The contract accepts executions.max(5000), so the ingest path advertised several times the size it could actually process.

This is squarely in the way of integrating Flakemetry into a real project — a suite of a few thousand tests is the normal case, not the large one.

Reproduction

Against a live Postgres, 3000 executions through processJob:

PrismaClientKnownRequestError:
Transaction API error: Transaction already closed: A query cannot be executed
on an expired transaction. The timeout for this transaction was 5000 ms,
however 5001 ms passed since the start of the transaction.

Because the cliff is time-based rather than count-based, a slower or busier database moves it down — 1000 tests took 5.6s here and would fail on a loaded instance.

The fix

Identity resolution already ran in memory; only the writes were per-row. They are now batched:

  • one updateMany for identities matched exactly on their primary fingerprint, where last-seen is the only field that can have moved (the fingerprint covers file path, suite, title and params hash, so the old per-row rewrite of those was a no-op)
  • createMany for new identities, then a re-read by fingerprint so a concurrent run that inserted the same test first wins the id rather than us dangling a foreign key
  • createMany for stitches and for the executions themselves

Execution ids are generated up front, so retry links resolve inline instead of needing a second pass, and a replay reuses whatever id is already stored for each ordinal — RCA reports and artifacts point at those.

Per-row work remains only where it is genuinely per-row and genuinely rare: renames and moves.

The transaction bound raised alongside it is headroom for a loaded database, not the fix.

Verification

apps/worker/src/__tests__/scale.test.ts ingests 3000 executions and asserts every row landed. Reverting processor.ts to its previous state fails that test with the timeout above, so the guard is not vacuous. A second case replays a batch and asserts execution ids are unchanged.

Measured against local Postgres: 3000 executions now persist in ~15s, and 5000 — the contract maximum — in ~29s. Both previously failed outright.

Full suite green: 56/56 tasks.

Still open

Most of that wall-clock is the per-identity scoring loop after the transaction, which is still one round trip per test. It is slow rather than broken, and I would rather it were measured and fixed on its own than folded in here.

Prisma caps an interactive transaction at five seconds. processJob wrote one
statement per test inside a single transaction, so any suite past roughly a
thousand tests overran the cap. The API had already answered 202, the job then
failed all five attempts and went to the dead letter, and nothing surfaced
anywhere the user looks — the run simply never appeared.

The contract accepts 5000 executions per batch, so the ingest path promised
several times what it could actually process.

Resolution now happens entirely in memory, then writes go out in batches: one
updateMany for identities whose fingerprint matched exactly, createMany for new
identities and for stitches, and createMany for the executions. Execution ids
are generated up front so retry links resolve without a second pass, and a
replay reuses the ids already stored for each ordinal — RCA reports and
artifacts reference them.

The transaction bound is headroom for a loaded database, not a substitute for
the batching.

Verified against a live Postgres: 3000 executions failed on the previous code
with "Transaction already closed ... timeout was 5000 ms" and now persists, as
does 5000, the contract maximum.
@AKogut
AKogut merged commit 1a3ee28 into main Aug 4, 2026
3 checks passed
@AKogut
AKogut deleted the fix/full-suite-ingestion branch August 4, 2026 14:07
AKogut added a commit that referenced this pull request Aug 4, 2026
Partially addresses #236.

## What was slow

Scoring one identity costs five reads (`computeIdentityScore`) plus a
transaction that writes the score and any health events. Identities do
not depend on one another, yet they ran strictly in sequence:

```ts
for (const identityId of affected) {
  await scoreIdentity(prisma, identityId, ctx)
}
```

After #234 the ingest transaction is a handful of batched statements, so
this loop is where a large run's wall clock now goes — roughly 25 of the
29 seconds a 5000-test run took.

## The change

A bounded worker pool. **No scoring logic changes** — same queries, same
transaction per identity, only overlapped.

## Measured

Against local Postgres, sequential → concurrent:

| executions | before | after |
|---|---|---|
| 1000 | 4.3s | 2.4s |
| 3000 | 14.8s | 8.9s |
| 5000 | 28.9s | 17.9s |

## Why the cap is 4 and not higher

Raising it pays very little:

| concurrency | 5000 executions |
|---|---|
| 4 | 17.9s |
| 8 | 15.6s |
| 12 | 14.9s |

It flattens because the limit is the **number** of queries — five per
identity, 25 000 for a 5000-test run — not their serialisation.
Meanwhile each task holds a connection, briefly two, and overrunning
Prisma's pool on a small deployment trades latency for `P2024` pool
timeouts, which would turn a slow run into a failed one.
`FLAKEMETRY_SCORING_CONCURRENCY` is there for anyone who has measured
their own pool.

That flattening is the argument *for* #236, not against it: batching the
per-identity reads is still the actual fix, and I am leaving the issue
open. This is a mitigation that costs almost nothing and risks almost
nothing.

Full suite green: 62/62 tasks, 88 worker tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant