63198c5d9f
Background ingestion/enrichment stages (summary, question, graph, multimodal) all target the same chat model, and asynq queue weights are scheduling priority rather than throttling. A large upload could therefore burst up to the entire worker pool of concurrent chat calls against a single provider, unbounded across replicas, with no model-side rate limit. Govern concurrency at the chat-client layer (the one place that sees every task type), keyed by model ID: - internal/models/limiter: Redis ZSET + Lua distributed semaphore with a lease heartbeat, expired-lease reclaim for crashed holders, and fail-open on any backend error or context cancellation. - chat: concurrencyChat decorator (outermost in NewChat) acquires a per-model slot only for background tasks; interactive chat is never gated. - router/task: asynq middleware tags every worker execution as background. - container: wire the Redis limiter with WEKNORA_MODEL_MAX_CONCURRENCY (model.max_concurrency), default 8; enabled only when Redis is present. Tested with miniredis: concurrency cap, expired-lease reclaim, per-key isolation, and all fail-open paths (nil client, backend down, cancelled ctx).