mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 20:22:21 +03:00
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:
committed by
GitHub
parent
028ed4ea7b
commit
1f685ebcd7
@@ -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"}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user