feat(quota): serializa concorrência por conexão no caminho quota-share (FASE 2.1) (#4970)

O gating de quota-share em selectQuotaShareTarget é fail-open: uma conexão
at-cap só é despriorizada, nunca bloqueada. Com 1 conexão por conta de
assinatura (caso comum), chamadas concorrentes ainda floodam a conta (→ 429 +
cooldown) — provado live na .15: 3 chamadas concorrentes com max_concurrent=1
despacharam todas em 94ms.

Adiciona um semáforo POR CONEXÃO em torno do dispatch quota-share: chamadas
excedentes esperam na fila em vez de floodar (key qsconn:<connectionId>, cap =
max_concurrent da conexão). Fail-open em fila saturada/timeout para nunca
piorar disponibilidade. Gated por strategy===quota-share + kill-switch
resilienceSettings.quotaShareConcurrencyLimit (default on; UI no ResilienceTab).

Lógica extraível isolada no leaf puro combo/quotaShareConcurrency.ts
(unit-testado: estabilidade da key, no-op sem cap, serialização real,
fail-open). Settings + schema + UI espelham comboCooldownWait.

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-24 23:54:43 -03:00
committed by GitHub
parent 028ed4ea7b
commit 1f685ebcd7
10 changed files with 458 additions and 6 deletions

View File

@@ -43,6 +43,10 @@ type ComboCooldownWaitSettings = {
budgetMs: number;
};
type QuotaShareConcurrencyLimitSettings = {
enabled: boolean;
};
type ProviderCooldownSettings = {
enabled: boolean;
minRetryCooldownMs: number;
@@ -61,6 +65,7 @@ type ResilienceResponse = {
};
waitForCooldown: WaitForCooldownSettings;
comboCooldownWait: ComboCooldownWaitSettings;
quotaShareConcurrencyLimit: QuotaShareConcurrencyLimitSettings;
providerCooldown: ProviderCooldownSettings;
};
@@ -848,6 +853,79 @@ function ComboCooldownWaitCard({
);
}
function QuotaShareConcurrencyLimitCard({
value,
onSave,
saving,
}: {
value: QuotaShareConcurrencyLimitSettings;
onSave: (next: QuotaShareConcurrencyLimitSettings) => Promise<void>;
saving: boolean;
}) {
const t = useTranslations("settings");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
const title =
t("resilienceQuotaShareConcurrencyTitle") || "Quota-share per-connection concurrency";
const desc =
t("resilienceQuotaShareConcurrencyDesc") ||
"For quota-share combos only: when a connection sets a Max Concurrent cap, serialize concurrent requests to that subscription account so it is never flooded past its ceiling — excess requests wait in the queue instead of getting a 429. The cap comes from each connection's Max Concurrent field; this switch only enables/disables honoring it.";
return (
<Card className="p-6">
<div className="mb-4 flex items-start justify-between gap-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary">filter_list</span>
<h2 className="text-lg font-bold">{title}</h2>
</div>
<ActionRow
editing={editing}
saving={saving}
onEdit={() => setEditing(true)}
onCancel={() => {
setDraft(value);
setEditing(false);
}}
onSave={async () => {
await onSave(draft);
setEditing(false);
}}
/>
</div>
<p className="mb-4 text-sm text-text-muted">{desc}</p>
<div className="grid grid-cols-1 gap-3">
{editing ? (
<BooleanField
label={t("resilienceEnableServerWait") || "Enabled"}
description={
t("resilienceQuotaShareConcurrencyToggleDesc") ||
"Quota-share combos only; honors each connection's Max Concurrent cap."
}
checked={draft.enabled}
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
/>
) : (
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">
{t("resilienceEnableServerWait") || "Enabled"}
</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.enabled ? t("statusEnabled") : t("statusDisabled")}
</div>
</div>
)}
</div>
</Card>
);
}
function ProviderCooldownCard({
value,
onSave,
@@ -1086,6 +1164,13 @@ export default function ResilienceTab() {
saving={savingSection === "comboCooldownWait"}
onSave={(comboCooldownWait) => savePatch("comboCooldownWait", { comboCooldownWait })}
/>
<QuotaShareConcurrencyLimitCard
value={data.quotaShareConcurrencyLimit}
saving={savingSection === "quotaShareConcurrencyLimit"}
onSave={(quotaShareConcurrencyLimit) =>
savePatch("quotaShareConcurrencyLimit", { quotaShareConcurrencyLimit })
}
/>
<ProviderCooldownCard
value={data.providerCooldown}
saving={savingSection === "providerCooldown"}

View File

@@ -134,6 +134,7 @@ export async function GET() {
maxRetryWaitSec: resilience.waitForCooldown.maxRetryWaitSec,
},
comboCooldownWait: resilience.comboCooldownWait,
quotaShareConcurrencyLimit: resilience.quotaShareConcurrencyLimit,
providerCooldown: resilience.providerCooldown,
legacy: buildLegacyResilienceCompat(resilience),
});
@@ -196,6 +197,12 @@ export async function PATCH(request) {
body.comboCooldownWait as ResilienceSettingsPatch["comboCooldownWait"],
}
: {}),
...(body.quotaShareConcurrencyLimit
? {
quotaShareConcurrencyLimit:
body.quotaShareConcurrencyLimit as ResilienceSettingsPatch["quotaShareConcurrencyLimit"],
}
: {}),
...(body.providerCooldown
? {
providerCooldown: body.providerCooldown as ResilienceSettingsPatch["providerCooldown"],

View File

@@ -57,6 +57,21 @@ export interface ComboCooldownWaitSettings {
budgetMs: number;
}
/**
* Per-connection concurrency limit for quota-share (`qtSd/…`) combos (FASE 2.1).
* The quota-share gating in selectQuotaShareTarget is fail-open and cannot
* hard-limit a single-connection pool, so concurrent requests to one
* subscription account can still flood it (→ 429 + cooldown). When a connection
* declares a positive `max_concurrent` ceiling, this layer serializes concurrent
* requests to that account through a per-connection semaphore (excess requests
* wait in the queue instead of flooding). Kill-switch only: the cap itself comes
* from each connection's `max_concurrent`. Wiring lives in
* open-sse/services/combo/quotaShareConcurrency.ts.
*/
export interface QuotaShareConcurrencyLimitSettings {
enabled: boolean;
}
export interface ProviderCooldownSettings {
/**
* Minimum cooldown (ms) before a failed provider/connection can be retried.
@@ -141,6 +156,7 @@ export interface ResilienceSettings {
providerBreaker: Record<AuthCategory, ProviderBreakerProfileSettings>;
waitForCooldown: WaitForCooldownSettings;
comboCooldownWait: ComboCooldownWaitSettings;
quotaShareConcurrencyLimit: QuotaShareConcurrencyLimitSettings;
providerCooldown: ProviderCooldownSettings;
quotaPreflight: QuotaPreflightSettings;
streamRecovery: StreamRecoverySettings;
@@ -152,6 +168,7 @@ export interface ResilienceSettingsPatch {
providerBreaker?: Partial<Record<AuthCategory, Partial<ProviderBreakerProfileSettings>>>;
waitForCooldown?: Partial<WaitForCooldownSettings>;
comboCooldownWait?: Partial<ComboCooldownWaitSettings>;
quotaShareConcurrencyLimit?: Partial<QuotaShareConcurrencyLimitSettings>;
providerCooldown?: Partial<ProviderCooldownSettings>;
quotaPreflight?: Partial<QuotaPreflightSettings>;
streamRecovery?: Partial<StreamRecoverySettings>;
@@ -272,6 +289,13 @@ export const DEFAULT_RESILIENCE_SETTINGS: ResilienceSettings = {
maxAttempts: 2,
budgetMs: 8000,
},
// FASE 2.1: serialize concurrent quota-share requests per connection when the
// connection sets a max_concurrent cap, so a subscription account is not
// flooded past its concurrency ceiling. Kill-switch only (default on); the cap
// comes from each connection's max_concurrent.
quotaShareConcurrencyLimit: {
enabled: true,
},
providerCooldown: {
minRetryCooldownMs: Number(process.env.PROVIDER_COOLDOWN_MIN_MS || "5000"),
maxRetryCooldownMs: Number(process.env.PROVIDER_COOLDOWN_MAX_MS || "300000"),
@@ -550,6 +574,14 @@ function normalizeComboCooldownWaitSettings(
return { enabled, maxWaitMs, maxAttempts, budgetMs };
}
function normalizeQuotaShareConcurrencyLimitSettings(
next: unknown,
fallback: QuotaShareConcurrencyLimitSettings
): QuotaShareConcurrencyLimitSettings {
const record = asRecord(next);
return { enabled: toBoolean(record.enabled, fallback.enabled) };
}
function normalizeProviderCooldownSettings(
next: unknown,
fallback: ProviderCooldownSettings
@@ -665,6 +697,7 @@ function buildLegacyFallback(settings: JsonRecord): ResilienceSettings {
maxRetryWaitMs: waitMaxRetrySec * 1000,
},
comboCooldownWait: DEFAULT_RESILIENCE_SETTINGS.comboCooldownWait,
quotaShareConcurrencyLimit: DEFAULT_RESILIENCE_SETTINGS.quotaShareConcurrencyLimit,
providerCooldown: DEFAULT_RESILIENCE_SETTINGS.providerCooldown,
quotaPreflight: DEFAULT_RESILIENCE_SETTINGS.quotaPreflight,
streamRecovery: streamRecoveryDefaults,
@@ -708,6 +741,10 @@ export function resolveResilienceSettings(
current.comboCooldownWait,
fallback.comboCooldownWait
),
quotaShareConcurrencyLimit: normalizeQuotaShareConcurrencyLimitSettings(
current.quotaShareConcurrencyLimit,
fallback.quotaShareConcurrencyLimit
),
providerCooldown: normalizeProviderCooldownSettings(
current.providerCooldown,
fallback.providerCooldown
@@ -757,6 +794,10 @@ export function mergeResilienceSettings(
updates.comboCooldownWait,
current.comboCooldownWait
),
quotaShareConcurrencyLimit: normalizeQuotaShareConcurrencyLimitSettings(
updates.quotaShareConcurrencyLimit,
current.quotaShareConcurrencyLimit
),
providerCooldown: normalizeProviderCooldownSettings(
updates.providerCooldown,
current.providerCooldown

View File

@@ -106,6 +106,15 @@ export const comboCooldownWaitSettingsSchema = z
})
.strict();
// FASE 2.1: kill-switch for the per-connection quota-share concurrency limit.
// The cap itself comes from each connection's max_concurrent, so only `enabled`
// is configurable here.
export const quotaShareConcurrencyLimitSettingsSchema = z
.object({
enabled: z.boolean().optional(),
})
.strict();
export const providerCooldownSettingsSchema = z
.object({
enabled: z.boolean().optional(),
@@ -146,6 +155,7 @@ export const updateResilienceSchema = z
.optional(),
waitForCooldown: waitForCooldownSettingsSchema.optional(),
comboCooldownWait: comboCooldownWaitSettingsSchema.optional(),
quotaShareConcurrencyLimit: quotaShareConcurrencyLimitSettingsSchema.optional(),
providerCooldown: providerCooldownSettingsSchema.optional(),
profiles: z
.object({
@@ -164,6 +174,7 @@ export const updateResilienceSchema = z
!value.providerBreaker &&
!value.waitForCooldown &&
!value.comboCooldownWait &&
!value.quotaShareConcurrencyLimit &&
!value.providerCooldown &&
!value.profiles &&
!value.defaults