Skip to content

Cheat sheet — Enrichment with Runspaces & Throttling

Companion to Module 04 — Enrichment with Runspaces & Throttling · CC BY 4.0 — print it, pin it, share it.

Last reviewed: 2026-07

ForEach-Object -Parallel — fan out with a bound

# Run the body in separate runspaces, at most N concurrently.
$indicators | ForEach-Object -ThrottleLimit 5 -Parallel {
    $ip = $_                       # the current item
    Start-Sleep -Milliseconds 50  # stand-in for an API call
    [pscustomobject]@{ Indicator = $ip; Checked = $true }
}

-ThrottleLimit is a politeness contract with whatever you call, not a raw speed dial. Bound it to the feed's rate limit. Unset or set-to-input-size = effectively unbounded = rate-limited or IP-banned. -Parallel requires PowerShell 7+.

$using: — the parallel body can't see your variables

$feed = @{ '185.220.101.7' = 'Emotet' }        # outer scope
$bag  = [System.Collections.Concurrent.ConcurrentBag[pscustomobject]]::new()

$indicators | ForEach-Object -ThrottleLimit 5 -Parallel {
    $table = $using:feed          # REQUIRED — without $using: it's $null
    $sink  = $using:bag
    $hit   = $table[$_]
    $sink.Add([pscustomobject]@{ Indicator = $_; Malware = $hit })
}

The parallel scriptblock is a fresh runspace; outer variables are copied in only via $using:. Forget it and lookups run against $null (everything looks "clean" — the silent failure).

Thread-safe collection — never += across threads

# WRONG — read-modify-write race; silently drops results, count wobbles run to run.
$results = @()
$in | ForEach-Object -Parallel { $results += $_ }     # <-- broken

# RIGHT — ConcurrentBag is safe for concurrent .Add()
$bag = [System.Collections.Concurrent.ConcurrentBag[pscustomobject]]::new()
$in | ForEach-Object -Parallel { ($using:bag).Add($_) }
$bag.ToArray() | Sort-Object Indicator                # stable output order

Prove the race: run the += version several times over a few thousand items and watch .Count change. Other thread-safe options: [System.Collections.Concurrent.ConcurrentDictionary[TKey,TValue]] (dedup + lookup), [System.Collections.Concurrent.ConcurrentQueue[T]] (FIFO).

Retry with exponential backoff

$attempt = 0
while ($true) {
    $attempt++
    try {
        $result = Get-Enrichment $ip    # e.g. a real API call that can 429/timeout
        break
    }
    catch {
        if ($attempt -ge $maxRetry) { throw }
        Start-Sleep -Milliseconds ($baseMs * [math]::Pow(2, $attempt - 1))  # 50,100,200...
    }
}

Back off exponentially (not instant retry) so a transient 429 Too Many Requests or dropped connection recovers without hammering the feed. Add jitter (+ (Get-Random -Max $baseMs)) to avoid a thundering herd when many lookups fail at once.

The correct enrichment shape (load once, fan out, collect, retry)

1. Collect + DE-DUP indicators        -> query each unique one exactly once
2. Load the feed ONCE (this thread)   -> in-memory lookup; keep network out of the hot loop
3. ForEach-Object -Parallel -Throttle -> $using: the feed + the ConcurrentBag
4.   per item: retry-with-backoff lookup -> .Add() the typed result to the bag
5. $bag.ToArray() | Sort-Object       -> deterministic, typed output

Raw runspace pool — what the wrapper does underneath

$pool = [runspacefactory]::CreateRunspacePool(1, 5)   # min, max (the throttle)
$pool.Open()
$jobs = foreach ($ip in $indicators) {
    $ps = [powershell]::Create()
    $ps.RunspacePool = $pool
    [void]$ps.AddScript({ param($x) "checked $x" }).AddArgument($ip)
    [pscustomobject]@{ PS = $ps; Handle = $ps.BeginInvoke() }
}
$results = foreach ($j in $jobs) { $j.PS.EndInvoke($j.Handle); $j.PS.Dispose() }
$pool.Close(); $pool.Dispose()

Reach for the raw pool only when you need shared session state (a pre-imported module, one reused HTTP client) or fine-grained BeginInvoke/EndInvoke control. For fan-out enrich, ForEach-Object -Parallel is enough. Note: -Parallel runspaces don't inherit loaded modules — Import-Module inside the body if the block needs a cmdlet.

The feed (abuse.ch Feodo Tracker) — snapshot vs live

# Offline snapshot (the lab default — deterministic, testable):
Invoke-VigilEnrichment -Indicator $ips -FeedPath ./data/feodo_ipblocklist.csv -ThrottleLimit 5

# Live feed: download ONCE, outside the parallel body, then enrich against the file:
Invoke-RestMethod 'https://feodotracker.abuse.ch/downloads/ipblocklist.csv' |
    Set-Content ./feodo_live.csv
Invoke-VigilEnrichment -Indicator $ips -FeedPath ./feodo_live.csv

CSV columns: first_seen_utc,dst_ip,dst_port,c2_status,last_online,malware. Never fetch the feed inside the parallel block — one bulk read, then pure lookups.

Copilot tells to catch (the three traps + one)

[ ] $using: present on every outer variable the -Parallel body reads? (else $null)
[ ] Results in a ConcurrentBag/.Add(), not $x += inside -Parallel? (else racy, drops rows)
[ ] -ThrottleLimit bounded to the feed's limit, not unset / = input size? (else rate-limited)
[ ] Each lookup wrapped in bounded retry + backoff? (else one 429 kills the batch)

Comments

Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).