import type Redis from "ioredis"; /** * CPS distribuído (agente.md secao 77): "token bucket ou equivalente, * Redis, múltiplos workers, nunca sleep() como controle." Implementado * como um contador de janela fixa de 1s por chave (INCR + PEXPIRE), checado * e incrementado num único script Lua — atômico mesmo com N workers * concorrentes, sem sleep nenhum. * * A hierarquia (agente.md secao 62: GLOBAL → NODE → TENANT → TRUNK → * CAMPAIGN) é uma única chamada com várias chaves: só incrementa TODAS se * TODAS tiverem espaço — nunca incrementa parcialmente e desfaz depois. */ const CPS_WINDOW_SCRIPT = ` local n = #KEYS for i = 1, n do local limit = tonumber(ARGV[i]) local current = tonumber(redis.call('GET', KEYS[i]) or '0') if current >= limit then return 0 end end for i = 1, n do local newval = redis.call('INCR', KEYS[i]) if newval == 1 then redis.call('PEXPIRE', KEYS[i], 1000) end end return 1 `; export interface CpsCheck { key: string; maxPerSecond: number; } /** Tenta consumir 1 slot de CPS em TODOS os níveis da hierarquia de uma vez * (só chaves com limite definido — null/undefined = sem limite nesse * nível, nem entra no script). Retorna false se qualquer nível estourar. */ export async function tryAcquireCps(redis: Redis, checks: CpsCheck[]): Promise { const applicable = checks.filter((c) => c.maxPerSecond > 0); if (applicable.length === 0) return true; const keys = applicable.map((c) => c.key); const args = applicable.map((c) => String(c.maxPerSecond)); const result = await redis.eval(CPS_WINDOW_SCRIPT, keys.length, ...keys, ...args); return result === 1; } /** * Lock de campanha (agente.md secao 79): `dialer:campaign:{id}`, TTL, * ownership token (só quem detém o token renova/libera), renewal, safe * release (compare-and-delete via Lua — nunca libera o lock de outro dono * por engano numa corrida entre "TTL expirou" e "release chegou atrasado"). */ function lockKey(campaignId: string): string { return `dialer:campaign:${campaignId}`; } export async function acquireCampaignLock( redis: Redis, campaignId: string, ownerToken: string, ttlMs: number, ): Promise { const result = await redis.set(lockKey(campaignId), ownerToken, "PX", ttlMs, "NX"); return result === "OK"; } const RENEW_SCRIPT = ` if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end `; export async function renewCampaignLock( redis: Redis, campaignId: string, ownerToken: string, ttlMs: number, ): Promise { const result = await redis.eval(RENEW_SCRIPT, 1, lockKey(campaignId), ownerToken, String(ttlMs)); return result === 1; } const RELEASE_SCRIPT = ` if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end `; export async function releaseCampaignLock(redis: Redis, campaignId: string, ownerToken: string): Promise { const result = await redis.eval(RELEASE_SCRIPT, 1, lockKey(campaignId), ownerToken); return result === 1; }