Files
wehub-resource-sync 2114ccd278
CI / Lint & Test (Python 3.13) (push) Failing after 2s
CI / Lint & Test (Python 3.14) (push) Failing after 1s
CI / Lint & Test (Python 3.12) (push) Failing after 2s
CI / DCO Check (push) Has been skipped
Scorecard supply-chain security / Scorecard analysis (push) Failing after 2s
chore: import upstream snapshot with attribution
2026-07-13 12:23:39 +08:00

8.6 KiB
Raw Permalink Blame History

Pitfalls & Lessons Learned

Hard-won lessons from building this module. If you're extending the batch scanner, read this before touching the concurrency or patch code.


Thread Safety

Class attributes are shared across threads — instance attributes are not

The original approach saved, mutated, and restored LLMAnalyzerBase.response_schema as a class attribute. With 4 threads running graph.invoke() concurrently, Thread A restored the original value while Thread B's meta-analyzer was still creating instances — sporadic 400 errors.

Lesson: self.response_schema = None writes to self.__dict__. Python MRO finds the instance attribute before the class attribute. Each analyzer gets its own copy. Zero shared state, zero races.

asyncio.Semaphore instances are independent per graph invocation

Upstream uses asyncio.Semaphore(10) per analyzer. When N skills run in parallel via ThreadPoolExecutor, each skill creates independent semaphore instances — theoretical peak is N × 40 concurrent requests. The --workers knob is the only practical throttle without modifying upstream.

Lesson: Count layers of concurrency before adding more. This system already has three (ThreadPoolExecutorasyncio.Semaphore → 20-analyzer fan-out).


DeepSeek Compatibility

response_format → HTTP 400, silently corrupts the connection pool

DeepSeek's API does not support structured output. Sending response_format returns 400, which httpx does not clean up properly. Subsequent requests on the same connection pool fail with obscure errors.

Lesson: Patch 1 (response_schema = None) must be applied before any LLMAnalyzerBase instantiation. The setup_deepseek_compat() context manager guarantees this.

Pydantic v2 alias precedence: timeout beats request_timeout

ChatOpenAI.__init__ accepts both timeout (alias) and request_timeout (canonical). When both are present in **kwargs, Pydantic v2 prefers the alias. The client is cached eagerly — patching after __init__ returns is too late.

Lesson: Overwrite kwargs["timeout"] (alias) before the original constructor runs. kwargs["request_timeout"] = value is silently ignored.

Account-level rate limiting cannot be bypassed with multiple keys

10 API keys under one DeepSeek account share a single concurrency budget. The pool provides key-level failover but cannot increase throughput beyond the account limit. API speed also varies 23× by time of day (99s at 6am, 160s at 4pm).

Lesson: The pool helps with per-key 429s. It cannot fix account-level throttling.


Performance Optimization Pitfalls

Seven optimization attempts were evaluated and reverted. Each made things worse.

Attempt What happened Why it failed
Async pool (re-entrant asyncio.run) Deadlocks asyncio.run() cannot be nested; graph.invoke() already calls it
Global shared semaphore Slower than baseline Cross-thread lock contention outweighed any request smoothing
Slot-count-based scheduling Workers starved Available slots ≠ available concurrency budget
ChatOpenAI instance caching Slower than baseline Internal AsyncClient is event-loop-bound; cached instances cross loops
Batch-level pool wrapping Lost key isolation One bad key blocked all workers
Connection-pool reuse 400 contamination spread Corrupted connections propagated across requests
Immediate retry on 429 Thundering herd Retry without backoff multiplied load on the rate limiter

Lesson: The baseline (ThreadPoolExecutor + ApiKeyPool + 30s exponential backoff) is the most stable configuration found after 13 iterations. Any optimization that changes the concurrency model should be benchmarked against the 23-skill fixture suite with both --no-llm and LLM modes.


Cross-Platform Gotchas

shutil.rmtree hangs on macOS with dangling file descriptors

When httpx connections are corrupted (e.g., after a 400 response), the temp directory may contain files with dangling fd. shutil.rmtree blocks indefinitely on macOS. ignore_errors=True handles this on all tested platforms.

ProcessPoolExecutor + macOS spawn = 30s timeouts

macOS Python 3.13 uses spawn as the default multiprocessing start method. Each child process reimports LangGraph + LangChain, causing 30+ second startup times. fork mode is unavailable on macOS since Python 3.8.

Lesson: ThreadPoolExecutor is the only viable option for cross-platform parallel skill scanning without modifying upstream.


Patch Design

Narrow exception handlers

Catching Exception in a parse-response path masks the difference between "the LLM returned bad JSON" (recoverable, log and return []) and "the schema changed upstream" (needs a code fix). Split into:

try:
    data = json.loads(text)
except json.JSONDecodeError:
    # LLM output malformed — recoverable
    return []
try:
    result = Model.model_validate(data)
except Exception:
    # Schema mismatch or unexpected error — log and surface
    return []

Lesson: The second except Exception is a safety net for upstream changes. The first except JSONDecodeError is narrowly scoped to LLM output quality.

Verify upstream signatures at patch time

Monkey-patches depend on upstream method signatures. If upstream changes a patched method's parameters, the patch can break silently (wrong number of arguments passed through *args/**kwargs).

_verify_patch_targets() checks signatures at context-enter time and raises immediately with a clear error message naming the mismatched method.

Lesson: Defensive guards catch drift before it becomes a runtime mystery.


from ... import creates local references that module-level patches miss

set_api_pool() originally patched only skillspector.llm_utils.get_chat_model. But llm_analyzer_base imports it via from skillspector.llm_utils import get_chat_model at module level — creating a local reference in llm_analyzer_base's namespace. Patching the source module left this local reference pointing to the original function. Graph analyzers (95% of LLM calls) bypassed the pool entirely.

Lesson: When monkey-patching a function, grep for from <module> import <function> across the entire codebase. Every such import creates an independent reference that must also be patched. Dual-patch fix: assign to both llm_utils.get_chat_model and llm_analyzer_base.get_chat_model.


High-Risk Areas

Summary of the concurrency-heavy, failure-prone code rng1995 flagged. Full inventory with per-function mutation coverage was in the now-removed RISK_TABLE.md.

Area Risk Key danger Covered by
ApiKeyPool.acquire() 🔴 Condition.wait() blocking, infinite loop, least-load min() TestAcquireRelease, TestConcurrentAcquireRelease
ApiKeyPool.release() 🔴 notify_all() wakes threads, backoff formula, success=True/False paths TestRateLimitBackoff, TestResourceLeakRecovery
PooledChatModel._invoke_with_retry() 🔴 Sync retry loop, 429 detection, key switching, max 5 retries Integration test coverage
_apply_patches() 🔴 Replaces 5 class methods + asyncio.run globally TestContextManagerApplyRestore
_restore_patches() 🔴 Nested exit logic, depth counter, restores 7 patches TestContextManagerNesting
_patched_chatopenai_init (Patch 6) 🔴 Pydantic alias priority — timeout vs request_timeout TestPatch6ChatOpenAITimeout
GapFillAnalyzer.parse_response() 🔴 4 layers: JSON→Pydantic→confidence→rule_id filter TestParseResponse* (35 tests)
_verify_patch_targets() 🟡 17 signature verifications — any failure should raise TestGuardPatch1* through TestGuardPatch7* (17 tests)

Development Workflow

Always test with a real API key before claiming "it works"

The --no-llm path is fast and deterministic. The LLM path adds network latency, rate limiting, and JSON output variance. Many bugs only manifest under concurrent LLM load. Run at least one --workers 4 LLM scan before declaring a change complete.

The fixture suite is your safety net

python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f terminal --workers 8
cd contrib/batch_scan/tests/tests-pro && python random_numbered.py
python contrib/batch_scan/tests/tests-pro/mutation_max.py

Three commands catch most regressions: batch scan → unit tests → mutation tests. Run all three after any change to api_pool.py, runner.py, or gap_fill.py.