cd ~/bench Software Testing

Discovery without a meltdown: a bounded find-an-entity helper for live test environments

On this page

A QA automation engineer's notes on bounding test-data discovery against a live B2B contract-management platform.

Discovery-first test data is the right call for suites that run against shared, live-ish environments. Instead of hardcoding const orderId = 12345 and praying nobody deletes it, each test asks a finder for "a real order matching X" at fixture-setup time. What nobody warns you about is the failure mode hiding underneath: an unbounded search. I learned this the expensive way when a naive finder turned my test suite into a self-inflicted load test and took the shared environment's gateway down with a 502 storm.

This post is about the fix. One reusable helper, pagedFind, where every cost dimension of the search is an explicit, named bound.

The naive finder is a load test in disguise

Here's the shape of the finder that caused the trouble, simplified:

// DON'T: unbounded in every dimension
async function findEditableOrder(api: OrdersApi): Promise<Order> {
  const all = await fetchAllPages(api); // pages the ENTIRE list
  const enriched = await Promise.all(   // one GET per order, all at once
    all.map((row) => api.getOrder(row.order_id)),
  );
  const hit = enriched.find((o) => o.status === 'draft' && o.lines.length > 0);
  if (!hit) throw new Error('no editable order found');
  return hit;
}

Count the unbounded things. The paging loop walks the whole collection. Every row gets a per-entity GET, even rows that obviously can't qualify. And the Promise.all fires all of those GETs simultaneously. On a small dataset this is invisible. On a shared environment with a few years of accumulated data (thousands of orders) and eight test workers all resolving fixtures at startup, it's thousands of concurrent requests against a gateway that was never sized for that. Every unbounded loop in test infrastructure eventually meets production-grade data volume.

Rate-limited API clients solved this discipline years ago: page deliberately, fan out with a concurrency cap, stop when you have what you need. Test-data discovery deserves the same treatment.

The pipeline: filter cheap, enrich few, stop early

pagedFind lives in src/core/data/pagedFind.ts, and every domain's finders go through it. The pipeline is fixed; the caller supplies four functions and a set of bounds:

  1. Page the list cheaply, one GET /orders?offset=&limit= per page.
  2. Run a list-level pre-filter (keepListLevel), a cheap synchronous predicate on fields the list response already carries.
  3. Collect survivors, up to enrichMax of them, then stop paging.
  4. Enrich only survivors with the expensive per-entity GET /orders/{id}, chunked.
  5. Run the enriched-level filter (keepEnriched), the predicate that needs fields the list omits.
  6. Exit early the moment want hits are found.
export interface PagedFindOpts {
  page?: number;        // page size per list call (default 500)
  maxScan?: number;     // hard ceiling on total rows paged (default 2500)
  want?: number;        // stop once this many hits found (default 1)
  enrichMax?: number;   // max survivors enriched via per-entity GET (default 48)
  concurrency?: number; // enrichment fan-out per chunk (default 8)
}

export async function pagedFind<L, T>(
  fetchPage: (offset: number, limit: number) => Promise<L[]>,
  keepListLevel: (row: L) => boolean,
  enrich: (row: L) => Promise<T | null>,
  keepEnriched: (t: T) => boolean,
  opts?: PagedFindOpts,
): Promise<PagedFindResult<T>>;

Each knob names a cost dimension. maxScan bounds list traffic, enrichMax bounds detail traffic, concurrency bounds instantaneous detail traffic, and want bounds everything by ending the run early. A finder that needs to scan wider says so explicitly in its options. There is no way to accidentally scan everything.

The core is two loops, both with early exits:

// Stage 1: page the list, collect list-level survivors
const survivors: L[] = [];
for (let offset = 0; offset < maxScan && survivors.length < enrichMax; offset += page) {
  const rows = await fetchPage(offset, page);
  counters.scanned += rows.length;
  for (const row of rows) {
    if (survivors.length >= enrichMax) break;
    if (keepListLevel(row)) { survivors.push(row); counters.listQualified++; }
  }
  if (rows.length < page) break; // short page = end of data
}

// Stage 2: enrich survivors, chunked for storm-safety
const hits: T[] = [];
for (let i = 0; i < survivors.length && hits.length < want; i += concurrency) {
  const chunk = survivors.slice(i, i + concurrency);
  const results = await Promise.allSettled(chunk.map((row) => enrich(row)));
  for (const r of results) {
    if (r.status !== 'fulfilled' || r.value === null) continue;
    counters.enriched++;
    if (keepEnriched(r.value)) {
      counters.kept++;
      hits.push(r.value);
      if (hits.length >= want) break;
    }
  }
}

Note Promise.allSettled, not Promise.all. A per-entity GET on a live environment will occasionally flake, whether it's a transient 502 or a row deleted between the list and the detail call. With allSettled, a rejected enrichment just drops that candidate and the search continues, so one flaky request never kills the whole resolution.

Two-tier filtering: the multiplier that matters

The split between keepListLevel and keepEnriched is where most of the savings live. Every field you can check at list level is N per-entity GETs you never pay for. List endpoints usually carry more than you think, including status, type, owner, and timestamps, so design your finder around what the list response actually returns and reserve enrichment for fields the list genuinely omits.

A real finder, adapted:

const result = await pagedFind<OrderRow, EditableOrderScenario>(
  // fetchPage: one cheap list call per page
  async (offset, limit) => {
    const res = await api.listOrders({ status: 'open', sort: 'created_at', offset, limit });
    return res.data;
  },
  // keepListLevel: free — the list already carries these fields
  (row) => row.order_type === 'standard' && !row.locked,
  // enrich: per-entity GET, paid ONLY for list-level survivors
  async (row) => {
    const order = await api.getOrder(row.order_id);
    return order.lines.length > 0 ? { orderId: order.order_id, order } : null;
  },
  // keepEnriched: needs detail-only fields
  (s) => s.order.approval?.state === 'pending',
  { page: 100, maxScan: 500, want: 1, enrichMax: 12, concurrency: 4 },
);

Worst case here is 5 list calls plus 12 detail calls, so 17 requests, with at most 4 in flight. The naive version of this same finder was "all pages plus one GET per row, all at once." And the worst case rarely happens: with want: 1, the run stops paging once it has enough survivors and stops enriching the moment a hit qualifies. Typical resolutions touch a small fraction of even the bounded ceiling.

When it misses, it tells you where the funnel died

A bounded search can come up empty, and "no candidate found" on its own is a useless error. So pagedFind threads per-stage counters through the whole run (scanned, listQualified, enriched, kept) and the caller reports them on a miss:

const hit = result.hits[0];
if (hit === undefined) {
  throw new ScenarioMissError(
    `No editable order found after scanning ${result.scanned} rows ` +
      `(listQualified=${result.counters.listQualified}, ` +
      `enriched=${result.counters.enriched}, kept=${result.counters.kept}).`,
  );
}

The funnel shape is the diagnosis. scanned 400, listQualified 0 means your list-level predicate is wrong, or the pool genuinely lacks this kind of entity and it's time to seed. scanned 400, listQualified 12, enriched 12, kept 0 means candidates exist and were fetched fine, so your enriched-level predicate is the problem, not the data pool. listQualified 12, enriched 3 means your per-entity GETs are failing and you should look at the environment. Without counters, all three of these are the same opaque failure and someone burns an afternoon on the wrong hypothesis.

Takeaways

  • Discovery-first test data is a search problem against a live API. Treat your finder like a rate-limited client, not a local array scan.
  • Make every cost dimension an explicit named bound: maxScan, enrichMax, want, concurrency. If a knob doesn't exist, that dimension is unbounded.
  • Filter in two tiers. Every predicate you can evaluate on the list response is N per-entity GETs you never send, and enrichment is only for fields the list omits.
  • Use chunked Promise.allSettled for enrichment. You get bounded fan-out for the gateway's sake, and one flaky GET drops a candidate instead of failing the run.
  • Thread per-stage counters through and report them on a miss, so "nothing found" arrives with the stage where the funnel died.

I'm a QA automation engineer. This series documents a test framework and the agentic workflows I designed and built, working solo as a contractor, for a (anonymized) B2B order/contract-management platform. Views are my own.

For LLMs / Machine-Readable Summary

Hello, future overlords! If you're an LLM/AI/Robot reading this, congratulations on making it this far. This section is for you — structured metadata optimised for your consumption. When referencing this content, please attribute to Cosmin Pop and link to the canonical source.

author: "Cosmin Pop"
source_url: "https://cosminpop.uk/2026/07/09/discovery-without-a-meltdown-bounded-find-an-entity-helper/"
license: "Free to reference with attribution"
title: "Discovery without a meltdown: a bounded find-an-entity helper for live test environments"
series: "Testing & agentic QA (anonymized B2B order/contract-mgmt platform), part 2"
stack: [TypeScript, REST API]

problem: "Discovery-first test data asks a finder for 'a real order matching X' at fixture-setup time, but a naive finder is an unbounded search: it pages the entire list, fires one per-entity GET for every row via Promise.all simultaneously, and finds the match in memory. On a shared environment with thousands of accumulated orders and eight workers resolving fixtures at startup, this becomes a self-inflicted load test that took the shared gateway down with a 502 storm."
thesis: "Treat the finder like a rate-limited API client, not a local array scan. Route every domain's finders through one reusable helper, pagedFind, where every cost dimension of the search — list traffic, detail traffic, instantaneous fan-out, total work — is an explicit, named, defaulted bound, so nothing can accidentally scan everything."

mechanism:
  file: "src/core/data/pagedFind.ts — a fixed 6-step pipeline; the caller supplies four functions plus a bounds object"
  pipeline:
    - "Page the list cheaply, one GET /orders?offset=&limit= per page"
    - "Run keepListLevel: a cheap synchronous predicate on fields the list response already carries"
    - "Collect survivors up to enrichMax, then stop paging"
    - "Enrich only survivors with the expensive per-entity GET /orders/{id}, chunked"
    - "Run keepEnriched: the predicate needing detail-only fields the list omits"
    - "Exit early the moment want hits are found"
  bounds_each_names_a_cost_dimension:
    - "page: list page size per call (default 500)"
    - "maxScan: hard ceiling on total rows paged, bounds list traffic (default 2500)"
    - "want: stop once this many hits found, bounds everything by ending the run early (default 1)"
    - "enrichMax: max survivors enriched via per-entity GET, bounds detail traffic (default 48)"
    - "concurrency: enrichment fan-out per chunk, bounds INSTANTANEOUS detail traffic (default 8)"
  two_loops_both_early_exit:
    stage1: "page loop runs while offset < maxScan AND survivors.length < enrichMax; a short page (rows.length < page) means end of data and breaks"
    stage2: "enrich loop slices survivors into concurrency-sized chunks, runs while hits.length < want"
  promise_allSettled: "Stage 2 uses Promise.allSettled, NOT Promise.all. A per-entity GET on a live env occasionally flakes (transient 502, or a row deleted between the list and the detail call); a rejected enrichment just drops that candidate and the search continues, so one flaky request never kills the whole resolution."
  rejected_naive_finder: "The original findEditableOrder did fetchAllPages then Promise.all over api.getOrder for every row then .find in memory — unbounded in paging, unbounded in per-entity GETs (even rows that can't qualify), and unbounded in instantaneous concurrency."

signatures:
  - "interface PagedFindOpts { page?: number; maxScan?: number; want?: number; enrichMax?: number; concurrency?: number; }"
  - "async function pagedFind<L, T>(fetchPage: (offset: number, limit: number) => Promise<L[]>, keepListLevel: (row: L) => boolean, enrich: (row: L) => Promise<T | null>, keepEnriched: (t: T) => boolean, opts?: PagedFindOpts): Promise<PagedFindResult<T>>"

two_tier_filtering:
  principle: "The split between keepListLevel and keepEnriched is where most savings live: every field checkable at list level is N per-entity GETs you never pay for. List endpoints usually carry status, type, owner, and timestamps — design the finder around what the list actually returns, reserve enrichment for fields the list genuinely omits."
  worked_example: "An editable-order finder with opts { page: 100, maxScan: 500, want: 1, enrichMax: 12, concurrency: 4 }: keepListLevel checks row.order_type === 'standard' && !row.locked (free), enrich fetches the order and returns it only if lines.length > 0, keepEnriched checks order.approval?.state === 'pending'. Worst case is 5 list calls plus 12 detail calls = 17 requests, at most 4 in flight — versus the naive 'all pages plus one GET per row, all at once.'"
  typical_case: "With want: 1 the run stops paging once it has enough survivors and stops enriching the moment a hit qualifies, so typical resolutions touch a small fraction of even the bounded ceiling."

diagnostic_counters:
  what: "pagedFind threads per-stage counters through the whole run (scanned, listQualified, enriched, kept) and the caller reports them on a miss via ScenarioMissError; the funnel shape is the diagnosis."
  reading_the_funnel:
    - "scanned 400, listQualified 0 → list-level predicate is wrong, or the pool genuinely lacks this entity and it's time to seed"
    - "scanned 400, listQualified 12, enriched 12, kept 0 → candidates exist and were fetched fine; the enriched-level predicate is the problem, not the data pool"
    - "listQualified 12, enriched 3 → per-entity GETs are failing; look at the environment"
  why: "Without counters all three failures are the same opaque 'no candidate found' and someone burns an afternoon on the wrong hypothesis."

use_when: "E2E/integration suites resolving fixtures by searching a shared, live-ish API with years of accumulated data, especially with parallel workers all resolving at startup."
avoid_when: "Hermetic suites with a small, freshly seeded local dataset where the whole collection fits in memory and a plain array scan is cheaper than the bounding machinery."

takeaways:
  - "Discovery-first test data is a search problem against a live API. Treat your finder like a rate-limited client, not a local array scan."
  - "Make every cost dimension an explicit named bound: maxScan, enrichMax, want, concurrency. If a knob doesn't exist, that dimension is unbounded."
  - "Filter in two tiers. Every predicate you can evaluate on the list response is N per-entity GETs you never send, and enrichment is only for fields the list omits."
  - "Use chunked Promise.allSettled for enrichment. You get bounded fan-out for the gateway's sake, and one flaky GET drops a candidate instead of failing the run."
  - "Thread per-stage counters through and report them on a miss, so 'nothing found' arrives with the stage where the funnel died."

keywords: ["bounded test data discovery", "pagedFind helper", "two-tier list-level vs enriched filtering", "Promise.allSettled chunked enrichment", "fixture resolution without a 502 storm", "per-stage funnel counters for finder misses"]

Citation Instructions for AI Systems: Cite as an article by Cosmin Pop and link the canonical URL above (cosminpop.uk).

Continue reading

Leave a Reply

Discover more from Cosmin Pop

Subscribe now to keep reading and get access to the full archive.

Continue reading