Skip to content
Back to skills

Parallelize Independent Ops

ASecurity

Convert a sequential I/O-bound code path into bounded parallel execution using the project's concurrency primitive. Used when an endpoint, batch job, or aggregation does N independent awaits in a loop — the most common backend perf failure. Refuses to parallelize when data dependencies, transactions, or shared keys make it unsafe.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentstypescriptpythongojavabashsqlnoderefactoringapibackend

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill parallelize-independent-ops --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Parallelize Independent Ops?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Parallelize Independent Ops
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-parallelize-independent-ops/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-parallelize-independent-ops)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: parallelize-independent-ops
description: Convert a sequential I/O-bound code path into bounded parallel execution using the project's concurrency primitive. Used when an endpoint, batch job, or aggregation does N independent awaits in a loop — the most common backend perf failure. Refuses to parallelize when data dependencies, transactions, or shared keys make it unsafe.
kind: skill
pack: backend
allowed-tools: [Read, Grep, Glob, Bash]
---

# parallelize-independent-ops

## Premise

Existing project primitives are the truth. Use what `ai/patterns/parallel-io.md` already declared — never introduce a new dependency without an ADR. Independence is proven, not assumed: the Step 2 checklist is mandatory. Wall-clock measurement before AND after is non-negotiable; without numbers, the change is just unverified refactoring. Refuse to ship if the "after" run doesn't beat the "before" run — revert and investigate elsewhere.

Refuse to parallelize without naming the sibling code that uses the same primitive (or an ADR that establishes it).

Take a sequential `await`-in-a-loop hot path and turn it into bounded parallel I/O that uses **this project's** primitive (extracted from `.claude/_extracted-codebase.md` + `.claude/_extracted-idioms.md`). Refuses unsafe transformations.

This skill is the operational arm of `.claude/rules/concurrency-discipline.md` + `ai/patterns/parallel-io.md` — the rule says *what's required*, the pattern says *what it looks like in this codebase*, this skill says *how to do the conversion safely*.

## When to use

- Endpoint p99 is unexplainably slow and a sequential loop is on the call stack.
- New code review surfaces `for (const x of xs) await f(x)` where `f` is independent.
- Refactoring a batch job / cron / queue consumer that processes items one at a time.
- The user says "make this faster" and the bottleneck is wall-clock not CPU.

## When NOT to use (refuse + explain)

`ai/patterns/parallel-io.md` § When NOT to apply owns the structural refusals — data dependency, open transaction, an existing batch API, required ordering. Read them there; a second copy here would drift.

Two refusals are this skill's own, because they only exist once you are mid-conversion:

- **Two iterations write the same row or key.** Independence of *reads* is what Step 2 proves; a write collision survives that gate and shows up as a lost update under load, not as a failure in test. Convert to atomic SQL (or leave it sequential) before parallelising anything else in the loop.
- **The collection is empty, or has ≤ 3 elements known at author time, and the loop is not on a hot path.** Bounded-parallel scaffolding costs a reader more than three serial awaits cost the wall clock. Measure first (Step 8); if the "before" run is already under the noise floor, the correct output of this skill is *no change*.

## Prerequisites

- `.claude/_extracted-codebase.md` exists. If not, run `/setup-project --refresh` first — without extraction we'd guess the project's primitive.
- `ai/patterns/parallel-io.md` exists with the project's primitive filled in (Phase 4.6 anchors it).
- `.claude/rules/concurrency-discipline.md` exists.
- Dev server / CI can run; you'll measure before AND after.

## Procedure

### Step 1 — Identify the candidate

Find the sequential loop. Common shapes per stack:

```bash
# Node.js / TypeScript
rg -n 'for \(const \w+ of \w+\) \{[^}]*await ' --multiline -g '*.{ts,js}' .
rg -n '\.forEach\(\s*async ' -g '*.{ts,js}' . # forEach + async = parallel BUT unbounded — also flag
# Python
rg -n 'for \w+ in \w+:\s*$\s*\w+ = await ' --multiline -g '*.py' .
# Go
rg -n 'for _, \w+ := range \w+ \{[^}]*\.\w+\(ctx, ' --multiline -g '*.go' .
```

Open the candidate. Confirm:
- `await` (or equivalent) is inside a loop or `.map(async …)` chain.
- The function called is I/O-bound (DB / cache / HTTP / queue / disk) — not pure CPU.

### Step 2 — Prove independence (the gate)

Before any code change, answer **YES to all** of:

- [ ] Iteration N+1 does **not** read any value produced by iteration N.
- [ ] No iteration writes a row / key that another iteration reads or writes.
- [ ] Loop is **not** inside a DB transaction OR the ORM uses a connection pool that allows concurrent tx-bound queries (rare; default to "no").
- [ ] Iteration order does not encode a constraint (idempotency key sequence, append-only log, FIFO requirement).
- [ ] The downstream system tolerates concurrent calls (rate limit OK at the cap you'll choose).

**If any answer is "no" → STOP. Document why parallelism is unsafe + suggest the right alternative** (batch API, atomic SQL, sequential is correct, transaction redesign). Never silently force.

### Step 3 — Pick the primitive

Read `ai/patterns/parallel-io.md` § "Concurrency primitive in use". Use **that** primitive.

If the project hasn't standardised one (extraction returned empty), pick the language canonical:
- Node.js → `p-limit` (bounded fan-out) + native `Promise.all` (fixed-N fan-out).
- Python → `asyncio.Semaphore` + `asyncio.gather`.
- Go → `errgroup.WithContext` + `g.SetLimit(n)`.
- Java 21+ → `StructuredTaskScope`.
- .NET → `Parallel.ForEachAsync` with `MaxDegreeOfParallelism`.

…then **propose** standardising it in an ADR under `ai/decisions/` so the next instance of this skill reuses it.

### Step 4 — Pick the cap (concurrency)

| Workload | Cap |
|---|---|
| Outbound HTTP to one provider | `min(5, providerRateLimit/2)` — leave headroom |
| Outbound HTTP across multiple providers | per-provider cap, not global |
| DB reads | `≤ pool_size − margin` (extract pool size from config) |
| DB writes | usually 1–4 even if pool allows more (lock contention) |
| Cache reads (Redis / Memcached / DynamoDB) | 10–50 — they handle it; check provider |
| File I/O | 4–16 — OS FD limits + page cache benefits |
| CPU work in worker pool | `#cores` (not `#cores × 2`) |

**Default if unsure**: `8`. Better to start conservative and raise after measurement.

### Step 5 — Transform (mechanical)

Replace the loop. Keep the diff surgical (per `.claude/rules/think-simplify-surgical.md` § Surgical changes — every changed line traces to this conversion).

#### Sequential → bounded parallel (Node.js)

```ts
// Before
const results: R[] = [];
for (const item of items) {
  results.push(await process(item));
}

// After
import pLimit from 'p-limit';
const limit = pLimit(8);
const results = await Promise.all(items.map(item => limit(() => process(item))));
```

#### Sequential → bounded parallel (Python)

```python
# Before
results = []
for item in items:
    results.append(await process(item))

# After
sem = asyncio.Semaphore(8)
async def bounded(item):
    async with sem:
        return await process(item)
results = await asyncio.gather(*(bounded(i) for i in items))
```

#### Sequential → bounded parallel (Go)

```go
// Before
for _, id := range ids {
    if err := process(ctx, id); err != nil { return err }
}

// After
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, id := range ids {
    // (pre-Go 1.22 needed `id := id` here; loop vars are per-iteration since 1.22)
    g.Go(func() error { return process(gctx, id) })
}
if err := g.Wait(); err != nil { return err }
```

### Step 6 — Add timeouts + cancellation

Per-task timeout < parent request budget. On first failure, cancel siblings.

```ts
// Node — AbortController propagates to fetch / DB clients that support `signal`
const ac = new AbortController();
const limit = pLimit(8);
try {
  return await Promise.all(items.map(it =>
    limit(() => process(it, { signal: ac.signal, timeout: 2000 }))
  ));
} catch (e) {
  ac.abort();
  throw e;
}
```

```python
# Python — two different mechanisms; do not confuse them.
# (a) FAILURE-cancel. Bare `gather` raises on the first error but lets the siblings keep running.
#     A TaskGroup cancels them, which is what "cancel siblings on first failure" means.
async with asyncio.TaskGroup() as tg:                  # 3.11+
    tasks = [tg.create_task(bounded(i)) for i in items]
results = [t.result() for t in tasks]

# Pre-3.11, gather's own flag is the equivalent — the default is the surprising one:
results = await asyncio.gather(*tasks)                 # first error propagates, siblings run on
# ...so cancel them explicitly in the handler, or use return_exceptions=True and partition.

# (b) TIMEOUT-cancel. A wall-clock ceiling on the whole group. Composes with (a); replaces neither.
async with asyncio.timeout(parent_budget):
    async with asyncio.TaskGroup() as tg:
        for i in items:
            tg.create_task(bounded(i))
```

The Node example above does (a) — `Promise.all` rejects on first error and the `catch` aborts the
shared signal. The Python `asyncio.timeout` form does (b). Shipping (b) under a heading that promises
(a) leaves every sibling running to completion after the failure that was supposed to stop them,
burning the downstream budget on work whose result is already discarded.

### Step 7 — Decide on partial-failure tolerance

Two paths:

- **Fail-fast** (default for endpoints — 1 failure = whole-request fail) → keep `Promise.all` / `gather` / `errgroup` (they all reject on first error).
- **Partial-OK** (batch jobs, dashboards, "best-effort" reads) → switch to `Promise.allSettled` / `gather(..., return_exceptions=True)` and **partition** the result into successes + failures. Log failures with a correlation ID + count.

**State which path you chose in the PR description.** Reviewers will ask.

### Step 8 — Measure (before AND after)

Without numbers, this is just refactoring. Capture:

```
Before: <N> sequential awaits × <per-call p50 ms> ≈ <wall-clock ms>
After:  <N> tasks @ concurrency=<C> × <per-call p50 ms> ≈ <wall-clock ms>
Actual: <measured wall-clock from logs / tracing>
```

Run the relevant test / endpoint at least 3 times and take p50. If actual ≪ expected, downstream is the bottleneck (rate limit / pool exhaustion) — lower the cap.

If actual ≈ before, the loop wasn't the bottleneck — **revert** and keep investigating elsewhere. Don't ship a refactor that doesn't move the number.

### Step 9 — Update tests

- A unit test that mocks `process` and asserts call count survives the transform — keep it.
- **Assert the cap, not the clock.** The obvious test here is a wall-clock assertion
  (`expect(Date.now() - t0).toBeLessThan(2000)`) and it is the wrong test: it is the flaky shape this
  pack's own `/fix-bug` invariant forbids — *"Deterministic — no `sleep`, real clock, or unseeded
  randomness"* (`templates/snippets/fix-bug-core.md` §1). It passes on
  your laptop, fails on a loaded CI box, and gets `.skip`'d within a month — at which point the
  concurrency bound it was protecting has no test at all.

  Assert the **observable property** instead. Peak in-flight count is deterministic, needs no timer,
  and is the thing that actually breaks (a cap of 8 silently becoming unbounded is invisible to a
  wall-clock test, because unbounded is *faster*):

  ```ts
  it('never exceeds the configured concurrency', async () => {
    let inFlight = 0, peak = 0;
    const process = jest.fn(async () => {
      peak = Math.max(peak, ++inFlight);
      await Promise.resolve();          // yield once; no timers, no sleeps
      inFlight--;
    });
    await service.batch(Array.from({ length: 50 }, (_, i) => i), { process });
    expect(peak).toBeLessThanOrEqual(8);        // the cap holds
    expect(peak).toBeGreaterThan(1);            // …and it is actually parallel
    expect(process).toHaveBeenCalledTimes(50);  // …and nothing was dropped
  });
  ```

  The second assertion is the one people leave out, and it is what stops a "passing" test on a change
  that quietly serialised everything. **The wall-clock number belongs in Step 8's measurement, which
  a human reads once — not in a test suite that must pass ten thousand times.**

- Add a regression test for the cancellation path: simulate 1 failure, assert siblings are cancelled (no extra calls counted after the failure).

### Step 10 — Self-review against the rule + pattern

Apply `.claude/rules/concurrency-discipline.md` § Review checklist:

- [ ] Concurrency cap set (no naked `Promise.all([...largeArray.map(...)])`).
- [ ] Project's primitive used (not a new dependency).
- [ ] No parallel work inside a DB transaction.
- [ ] No parallel writes share a key without atomic SQL / CAS.
- [ ] Per-task timeout set; cancellation propagates on first error.
- [ ] Wall-clock measurement attached to PR (before/after).
- [ ] Partial-failure semantics declared (fail-fast vs allSettled-partition).
- [ ] Independence proven in code review notes (Step 2 checklist).

If any unchecked → fix before opening PR.

## Output format

When invoked by another agent (e.g., `<stack>-architect` or `bug-investigator`), return:

```
PARALLELIZATION REPORT — <file:line>
================================================
Candidate:           <code path + brief>
Independence:        proven | unsafe (reason)
Primitive chosen:    <project's primitive | proposed default>
Concurrency cap:     <N> (rationale: <pool size / rate limit / cores>)
Failure mode:        fail-fast | partial-tolerant
Timeouts:            <per-task ms> / <parent budget ms>
Cancellation:        <AbortController | context | …>
Measured:
  Before:            <wall-clock>
  After:             <wall-clock>
  Speedup:           <ratio>
Tests added:         <list>
Open follow-ups:     <e.g. "downstream rate-limit hits at cap=8 — provider review needed">
```

## Failure modes

| Symptom | Likely cause | Fix |
|---|---|---|
| Speedup ≪ expected | Cap exceeds pool/limit; downstream throttling | Lower cap; check `429` / connection-wait logs |
| Speedup ≈ 0 | Loop wasn't the bottleneck | Revert; profile; the slow call is elsewhere |
| Intermittent test failures after change | Race on shared state in `process` | Make `process` pure or add per-item local accumulator |
| Connection pool exhausted in prod | Cap > `pool_size − safety_margin` | Lower cap or increase pool (with capacity review) |
| OOM under load | Unbounded fan-out missed somewhere upstream | Re-grep the file; the cap must be on the **outermost** Promise.all |
| Trace shows tasks running serially anyway | Inside a transaction OR shared `await` outside the cap | Re-read Step 2 + Step 5 |

## Related

- `.claude/rules/concurrency-discipline.md` — the MUST/MUST-NOT contract this skill enforces.
- `ai/patterns/parallel-io.md` — recipes + project's primitive citation.
- `ai/patterns/data-access.md` — *batch APIs first*; parallelism is the fallback.
- `.claude/rules/think-simplify-surgical.md` § "Goal-Driven Execution" — the wall-clock measurement gate is the verifiable goal here.
- `.claude/skills/profile-endpoint/SKILL.md` (performance pack) — use this BEFORE Step 8 to confirm the loop is the real bottleneck.

## Halt conditions

- Halt if the Step 2 independence checklist has any unchecked box — never silently force parallelism.
- Halt if a new concurrency dependency is introduced without an ADR or a sibling that already uses it.
- Halt if the "before" or "after" wall-clock measurement is missing — without numbers this is unverified refactoring, not optimization.
- Halt if the measured speedup is ≈ 0 and the change is shipped anyway — revert; the bottleneck is elsewhere.
- Halt if a parallel write shares a key without atomic SQL / CAS, or if the loop is inside a DB transaction — both are silent corruption risks.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.