feat(p9): per-tenant outbound quota + cellId assignment
Task T3.4: OutboundService gains a per-tenant egress cap (tenantLimit/tenantWindowMs, IIOS_TENANT_OUTBOUND_*) counting a scope's commands in the window — a noisy tenant is RATE_LIMITED while others keep sending (noisy-neighbor protection). Persists scopeId on every command (fixes the previously-unscoped table). resolveScope assigns IiosScope.cellId (IIOS_CELL_ID, default cell-default) — the cell-partition hook. Tests: per-tenant cap isolates tenants; new scopes carry a cellId. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,9 @@ export class OutboundService {
|
||||
/** Public so tests can lower them; default from env. */
|
||||
limit = Number(process.env.IIOS_OUTBOUND_LIMIT ?? 5);
|
||||
windowMs = Number(process.env.IIOS_OUTBOUND_WINDOW_MS ?? 60_000);
|
||||
/** Per-tenant (scope) egress cap — noisy-neighbor protection (P9). */
|
||||
tenantLimit = Number(process.env.IIOS_TENANT_OUTBOUND_LIMIT ?? 10_000);
|
||||
tenantWindowMs = Number(process.env.IIOS_TENANT_OUTBOUND_WINDOW_MS ?? 60_000);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -32,16 +35,17 @@ export class OutboundService {
|
||||
const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey } });
|
||||
if (existing) return existing;
|
||||
|
||||
if (!(await this.allow(channelType, target))) {
|
||||
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
|
||||
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
|
||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||
data: { channelType, target, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey },
|
||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey },
|
||||
});
|
||||
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
|
||||
return cmd;
|
||||
}
|
||||
|
||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||
data: { channelType, target, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey },
|
||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey },
|
||||
});
|
||||
|
||||
let result;
|
||||
@@ -68,6 +72,14 @@ export class OutboundService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Per-tenant egress quota (P9): count this scope's commands in the window vs the cap. */
|
||||
private async allowTenant(scopeId?: string): Promise<boolean> {
|
||||
if (!scopeId) return true; // unscoped sends are not tenant-limited
|
||||
const since = new Date(Date.now() - this.tenantWindowMs);
|
||||
const count = await this.prisma.iiosOutboundCommand.count({ where: { scopeId, createdAt: { gte: since } } });
|
||||
return count < this.tenantLimit;
|
||||
}
|
||||
|
||||
/** Token/window bucket keyed by (channelType, target). Resets when the window elapses. */
|
||||
private async allow(channelType: string, target: string): Promise<boolean> {
|
||||
const now = new Date();
|
||||
|
||||
Reference in New Issue
Block a user