ADR-0095: Investigation performance & scale hardening
Date: 2026-07-06 Status: Accepted Deciders: Adrian (Soft4U), Claude Opus 4.8
Context
A live OB Holding investigation showed ~15–20 min wall-clock with a 3-minute dead zone in which ~6 agents queued ~125s each for a BrightData slot. A code-research pass (4 agents + synthesis, verified against the source) established the scale behaviour precisely:
- Per-investigation latency degrades LINEARLY under concurrent load, not exponentially — every fan-out is hard-capped (network depth ≤2, Tier-B ≤8 targets, retries ≤3), so there is no N² explosion inside a case.
- The prime bottleneck is a process-global
asyncio.Semaphore(3)that serializes ALL BrightData work across the whole worker (brightdata_concurrency.py:64, clamped to [1,3] — the real plan quota). One investigation alone fires ~5–6 simultaneous requests against those 3 slots. NorthData adds a second process-global lock (30 req/min). - The one genuine exponential vector is retry amplification:
run_osint_investigationran with a 30-min timeout,maximum_attempts=3, and NO mid-flight checkpoint — so a contention-induced timeout re-runs the ENTIRE external burst, re-injecting BrightData demand and pushing other cases past THEIR timeout (a cascade that can start at N=2). - The single-machine target is a MacBook Air M4 / 32 GB; Docker Desktop is allocated 15.6 GB and the 11 infra containers had no memory limits (one could OOM the rest; ClickHouse alone used 2.23 GB uncapped). The app tier runs locally (not in Docker).
Decision
Harden performance at every level as five workstreams:
- W1 — Reliability (defuse the exponential vector). A dedicated
RetryPolicy(maximum_attempts=1)forrun_osint_investigation(the activity already fails closed internally, so a whole-activity retry is redundant + the cascade fuse), plus an admission-control semaphore (settings.osint_max_concurrent_investigations=2) so no more OSINT bursts hit the BrightData gate than can finish under the 30-min timeout. - W2 — Sizing for M4/32 GB. Per-container
mem_limit/mem_reservationon all 11 infra services (12 GB total, 3.6 GB headroom in the 15.6 GB Docker budget); Postgres memory tuning (shared_buffers=512MB,effective_cache_size,work_mem, SSDeffective_io_concurrency); Neo4j heap 1 GB / page-cache 512 MB; ClickHouse capped (cgroup-aware) from 2.23 GB → 1.5 GB; Redis configured as a cache tier (maxmemory 512mb,volatile-lru). - W3 — Cross-investigation caching. Redis-cache NorthData registry lookups by
(reg-id|name|country) (
external_cache.cached_external, 24 h TTL, fail-open) to cut the 30-req/min NorthData gate and let same-group cases reuse scans. Compliance boundary: only factual/slow-changing data is cached cross-investigation; the helper supportsscope=case_idfor within-investigation-only caches so a stale "no adverse media" can never suppress a fresh risk signal in a later case. - W4 — Parallelize Tier-B recall. The per-target loop runs concurrently under a bounded
semaphore; BrightData stays capped by its own gate, but the Tavily + LLM-analysis portions
overlap.
gatherpreserves order → deterministic attribution. - W5 — Cross-process rate limits (documented, deferred). The BrightData semaphore and NorthData lock are per-process. Horizontal scale-out (K workers) would multiply — not share — the real account quota, causing 429s that trip breakers into silent data-gaps. A Redis token-bucket is the prerequisite for safe multi-worker scaling. Deferred: the current target is a single machine (one heavy worker; the backend does little BrightData), so per-process limits are effectively the account limits today.
Decision context:
- Latency: W1/W4 cut per-case wall-clock and eliminate the cascade; W3 cuts the concurrent-load curve. Estimated steady-state ~7 investigations/hour/worker, degrading gracefully.
- Dependency surface: no new packages; reuses the existing
cache_service(Redis), circuit breakers, and Temporal retry policies. One new helper module + config knobs. - Debuggability: admission logs slot-waits; the cache is fail-open; container limits make OOM attributable to one service instead of a shared crash.
- Reversibility: every lever is a config knob or a
mem_limit; caching has anexternal_cache_enabledkill-switch. - Blast radius: W1 changes retry behaviour (a hard OSINT failure no longer retries — acceptable vs. the cascade); W2 recreated the infra containers (verified healthy). Additive otherwise.
- Alternative considered: raise the BrightData
Semaphore(3)clamp — rejected (it is the real plan quota; the code comment records that 4 broke a batch). Caching, not a higher clamp, is the demand-side lever.
Consequences
Positive
- The exponential cascade vector is eliminated (W1) — latency now degrades predictably and linearly.
- Container memory is bounded and attributable; ClickHouse no longer free-runs to 2.23 GB.
- NorthData load drops for same-group / repeat investigations; Tier-B recall is faster.
- The system is honestly sized for the M4/32 GB it actually runs on.
Negative
- W1: a genuine hard OSINT failure (e.g. worker crash mid-activity) no longer retries — the workflow fails and the officer re-runs. Accepted as far better than a system-wide cascade under load.
- W3: a 24 h NorthData cache can serve registry data up to a day stale; mitigated by the moderate TTL, the empty-result guard, and monitoring re-runs after TTL. Adverse-media is deliberately NOT cross-investigation-cached.
- W5 is deferred — horizontal scale-out is not yet safe without the cross-process limiter.
Neutral
- Worker activity concurrency stays adaptive (4 on ≥24 GB); the OSINT admission semaphore is now the external-API limiter, decoupling memory-concurrency from BrightData-concurrency.
Alternatives Considered
Alternative 1: Raise BrightData/worker concurrency to "go faster"
- Rejected: the
Semaphore(3)is the real BrightData plan quota (comment: 4 broke a batch); raising worker concurrency without it just converts in-process queue-wait into upstream 429s and breaker-tripped data-gaps. The levers are caching (demand) and admission control (safety), not a higher ceiling.
Alternative 2: Checkpoint-resumable OSINT activity (skip completed agents on retry)
- The ideal long-term fix for retry cost. Deferred as a larger change; W1 (
maximum_attempts=1) removes the acute cascade risk now for near-zero effort.