Добавить метрику расхода ИИ-бюджета
Gauge deal.ai.budget.used.ratio{tenant} (доля израсходованного бюджета периода) собирается RuntimeDepthsCollector и публикуется DealMetricsCollector — основа для алертинга. Закрывает BL-ALERT-BUDGET.
This commit is contained in:
+1
-1
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
| ID | Пункт | Источник | Приоритет | Статус |
|
| ID | Пункт | Источник | Приоритет | Статус |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| BL-ALERT-BUDGET | Метрика расхода токенов для алерта бюджета (сейчас алерт не настроен — нет метрики) | этап 12, A | P2 | TECHDEBT |
|
| BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE |
|
||||||
| BL-LOG-ACTOR | Идентичность актора в access-логах (сейчас login/tenant только в `audit_log`) | этап 12, T6 | P3 | TECHDEBT |
|
| BL-LOG-ACTOR | Идентичность актора в access-логах (сейчас login/tenant только в `audit_log`) | этап 12, T6 | P3 | TECHDEBT |
|
||||||
| BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG |
|
| BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG |
|
||||||
| BL-SUSPICIOUS | Расширение детектора подозрительной активности (правила/пороги по логам безопасности) | ТЗ §10.5, этап 12 | P3 | BACKLOG |
|
| BL-SUSPICIOUS | Расширение детектора подозрительной активности (правила/пороги по логам безопасности) | ТЗ §10.5, этап 12 | P3 | BACKLOG |
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру
|
> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру
|
||||||
> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр
|
> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр
|
||||||
> исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника».
|
> исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника».
|
||||||
> Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1307/1307 PASS**, telegram **130/130**, фронт `build` +
|
> Метрика алертинга `deal.ai.budget.used.ratio{tenant}`. Ядро: build 5 sln 0/0,
|
||||||
> `lint:i18n` зелёные. Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
|
> `Deal.Tests.Unit` **1310/1310 PASS**, telegram **130/130**, фронт `build` + `lint:i18n` зелёные.
|
||||||
|
> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
|
||||||
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
|
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
|
||||||
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
|
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
|
||||||
> (`TelegramStore`, `Dialogs`/`TgMessages`, Discovery) в telegram-сервис.
|
> (`TelegramStore`, `Dialogs`/`TgMessages`, Discovery) в telegram-сервис.
|
||||||
|
|||||||
@@ -272,7 +272,9 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
|
|||||||
- `deal_ml_calls_total` / `deal_ml_tokens_total` — вызовы и оценка токенов локального ML;
|
- `deal_ml_calls_total` / `deal_ml_tokens_total` — вызовы и оценка токенов локального ML;
|
||||||
- `deal_audit_events_total{event,actor}` — события аудита по типу/актору;
|
- `deal_audit_events_total{event,actor}` — события аудита по типу/актору;
|
||||||
- `deal_pipeline_queue_depth`, `deal_ml_outbox_depth` — суммарные глубины очередей (пайплайн, MlOutbox)
|
- `deal_pipeline_queue_depth`, `deal_ml_outbox_depth` — суммарные глубины очередей (пайплайн, MlOutbox)
|
||||||
по всем тенантам; `deal_sessions_active` — активные непросроченные сессии пользователей и операторов.
|
по всем тенантам; `deal_sessions_active` — активные непросроченные сессии пользователей и операторов;
|
||||||
|
- `deal_ai_budget_used_ratio{tenant}` — доля израсходованного ИИ-бюджета периода (0..1) по тенантам
|
||||||
|
(осознанное исключение из низкокардинального правила: бюджеты пер-тенантные, алерт должен знать тенанта).
|
||||||
Gauge-значения собирает фоновый `DealMetricsCollector` ядра (каждые 15 с) через существующие
|
Gauge-значения собирает фоновый `DealMetricsCollector` ядра (каждые 15 с) через существующие
|
||||||
сервисы/хранилища (`PipelineProcessingService.QueueCountsAsync`, `IMlLearningStore.CountOutboxAsync`,
|
сервисы/хранилища (`PipelineProcessingService.QueueCountsAsync`, `IMlLearningStore.CountOutboxAsync`,
|
||||||
`public.sessions`/`operator_sessions`); инкремент счётчиков токенов/аудита — там же, где пишутся
|
`public.sessions`/`operator_sessions`); инкремент счётчиков токенов/аудита — там же, где пишутся
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ public sealed class DealMetricsCollector : IHostedService
|
|||||||
DealMetrics.SetPipelineQueueDepth(depths.PipelineQueue);
|
DealMetrics.SetPipelineQueueDepth(depths.PipelineQueue);
|
||||||
DealMetrics.SetMlOutboxDepth(depths.MlOutbox);
|
DealMetrics.SetMlOutboxDepth(depths.MlOutbox);
|
||||||
DealMetrics.SetActiveSessions(depths.ActiveSessions);
|
DealMetrics.SetActiveSessions(depths.ActiveSessions);
|
||||||
|
DealMetrics.ReplaceBudgetRatios(
|
||||||
|
(depths.Budgets ?? []).Select(budget => (budget.TenantId, budget.UsedRatio)));
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ public sealed class RuntimeDepthsCollector
|
|||||||
{
|
{
|
||||||
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
|
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
|
||||||
int sessions = await CountActiveSessionsAsync(cycleScope, ct);
|
int sessions = await CountActiveSessionsAsync(cycleScope, ct);
|
||||||
(long queue, long outbox) = await SumTenantDepthsAsync(cycleScope, ct);
|
IReadOnlyList<TenantRecordDto> tenants = await ListTenantsAsync(cycleScope, ct);
|
||||||
return new RuntimeDepthsDto(queue, outbox, sessions);
|
(long queue, long outbox) = await SumTenantDepthsAsync(tenants, ct);
|
||||||
|
IReadOnlyList<TenantBudgetUsageDto> budgets = await CollectBudgetsAsync(cycleScope, tenants, ct);
|
||||||
|
return new RuntimeDepthsDto(queue, outbox, sessions, budgets);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Считает активные непросроченные сессии пользователей и операторов (public-схема).
|
// Считает активные непросроченные сессии пользователей и операторов (public-схема).
|
||||||
@@ -68,24 +70,16 @@ public sealed class RuntimeDepthsCollector
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Суммирует глубины очередей по всем тенантам реестра.
|
// Список тенантов реестра (без tenant-контекста — public-схема).
|
||||||
// cycleScope: Scope прохода (реестр тенантов читается без tenant-контекста).
|
// cycleScope: Scope прохода.
|
||||||
// ct: Токен отмены.
|
// ct: Токен отмены.
|
||||||
// Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox); недоступность реестра — (0, 0).
|
// Возвращает: Тенанты реестра; недоступность реестра — пустой список.
|
||||||
private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
|
private async Task<IReadOnlyList<TenantRecordDto>> ListTenantsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
|
||||||
{
|
{
|
||||||
long queueDepth = 0;
|
|
||||||
long outboxDepth = 0;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||||
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
|
return await tenantRepository.ListAsync(ct);
|
||||||
foreach (TenantRecordDto tenant in tenants)
|
|
||||||
{
|
|
||||||
(int queue, int outbox) = await CollectTenantAsync(tenant, ct);
|
|
||||||
queueDepth += queue;
|
|
||||||
outboxDepth += outbox;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -94,11 +88,76 @@ public sealed class RuntimeDepthsCollector
|
|||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался");
|
_logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Суммирует глубины очередей переданных тенантов.
|
||||||
|
// tenants: Тенанты реестра.
|
||||||
|
// ct: Токен отмены.
|
||||||
|
// Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox).
|
||||||
|
private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync(
|
||||||
|
IReadOnlyList<TenantRecordDto> tenants,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
long queueDepth = 0;
|
||||||
|
long outboxDepth = 0;
|
||||||
|
foreach (TenantRecordDto tenant in tenants)
|
||||||
|
{
|
||||||
|
(int queue, int outbox) = await CollectTenantAsync(tenant, ct);
|
||||||
|
queueDepth += queue;
|
||||||
|
outboxDepth += outbox;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (queueDepth, outboxDepth);
|
return (queueDepth, outboxDepth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Собирает долю израсходованного ИИ-бюджета по тенантам.
|
||||||
|
// cycleScope: Scope прохода (public-схема: tenant_limits — без tenant-контекста).
|
||||||
|
// tenants: Тенанты реестра.
|
||||||
|
// ct: Токен отмены.
|
||||||
|
// Возвращает: Доли 0..1 по тенантам; сбой одного тенанта — он пропускается.
|
||||||
|
private async Task<IReadOnlyList<TenantBudgetUsageDto>> CollectBudgetsAsync(
|
||||||
|
AsyncServiceScope cycleScope,
|
||||||
|
IReadOnlyList<TenantRecordDto> tenants,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var budgets = new List<TenantBudgetUsageDto>(tenants.Count);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ITenantLimitStore limits = cycleScope.ServiceProvider.GetRequiredService<ITenantLimitStore>();
|
||||||
|
foreach (TenantRecordDto tenant in tenants)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
BudgetStateDto state = await limits.GetStateAsync(tenant.Id, ct);
|
||||||
|
double ratio = state.BudgetTokens > 0
|
||||||
|
? Math.Clamp(state.UsedTokens / (double)state.BudgetTokens, 0.0, 1.0)
|
||||||
|
: 0.0;
|
||||||
|
budgets.Add(new TenantBudgetUsageDto(tenant.Id.ToString("N"), ratio));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(exception, "Сборщик глубин: бюджет тенанта {TenantId} не прочитан", tenant.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(exception, "Сборщик глубин: секция бюджета не удалась");
|
||||||
|
}
|
||||||
|
|
||||||
|
return budgets;
|
||||||
|
}
|
||||||
|
|
||||||
// Считает глубины очередей одного тенанта в собственном scope (SetTenant → сервисы → Reset).
|
// Считает глубины очередей одного тенанта в собственном scope (SetTenant → сервисы → Reset).
|
||||||
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>).
|
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>).
|
||||||
// ct: Токен отмены прохода.
|
// ct: Токен отмены прохода.
|
||||||
|
|||||||
@@ -6,4 +6,9 @@ namespace Deal.Api.Observability;
|
|||||||
/// <param name="PipelineQueue">Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам.</param>
|
/// <param name="PipelineQueue">Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам.</param>
|
||||||
/// <param name="MlOutbox">Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам.</param>
|
/// <param name="MlOutbox">Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам.</param>
|
||||||
/// <param name="ActiveSessions">Число активных непросроченных сессий пользователей и операторов.</param>
|
/// <param name="ActiveSessions">Число активных непросроченных сессий пользователей и операторов.</param>
|
||||||
public sealed record RuntimeDepthsDto(long PipelineQueue, long MlOutbox, int ActiveSessions);
|
/// <param name="Budgets">Использование ИИ-бюджета по тенантам (пусто — данных нет).</param>
|
||||||
|
public sealed record RuntimeDepthsDto(
|
||||||
|
long PipelineQueue,
|
||||||
|
long MlOutbox,
|
||||||
|
int ActiveSessions,
|
||||||
|
IReadOnlyList<TenantBudgetUsageDto>? Budgets = null);
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Deal.Api.Observability;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Использование ИИ-бюджета тенанта — для метрики алертинга.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="TenantId">Id тенанта (Guid в формате «N»).</param>
|
||||||
|
/// <param name="UsedRatio">Доля израсходованного бюджета периода 0..1; бюджет не задан — 0.</param>
|
||||||
|
public sealed record TenantBudgetUsageDto(string TenantId, double UsedRatio);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
|
|
||||||
@@ -38,6 +39,16 @@ public static class DealMetrics
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string AuditActorTagName = "actor";
|
public const string AuditActorTagName = "actor";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя метки тенанта
|
||||||
|
/// </summary>
|
||||||
|
public const string TenantTagName = "tenant";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя метрики доли израсходованного ИИ-бюджета
|
||||||
|
/// </summary>
|
||||||
|
public const string AiBudgetUsedRatioName = "deal.ai.budget.used.ratio";
|
||||||
|
|
||||||
// Meter прикладных метрик (static — один на процесс, как того требует System.Diagnostics.Metrics).
|
// Meter прикладных метрик (static — один на процесс, как того требует System.Diagnostics.Metrics).
|
||||||
private static readonly Meter Meter = new(MeterName);
|
private static readonly Meter Meter = new(MeterName);
|
||||||
|
|
||||||
@@ -98,6 +109,17 @@ public static class DealMetrics
|
|||||||
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._activeSessions)),
|
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._activeSessions)),
|
||||||
description: "Активные непросроченные сессии пользователей и операторов.");
|
description: "Активные непросроченные сессии пользователей и операторов.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Доля израсходованного ИИ-бюджета по тенантам
|
||||||
|
/// </summary>
|
||||||
|
public static readonly ObservableGauge<double> AiBudgetUsedRatio =
|
||||||
|
Meter.CreateObservableGauge(
|
||||||
|
AiBudgetUsedRatioName,
|
||||||
|
ObserveBudgetRatios,
|
||||||
|
description: "Доля израсходованного ИИ-бюджета периода по тенантам (0..1).");
|
||||||
|
|
||||||
|
private static readonly ConcurrentDictionary<string, double> BudgetRatios = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
private static long _pipelineQueueDepth;
|
private static long _pipelineQueueDepth;
|
||||||
private static long _mlOutboxDepth;
|
private static long _mlOutboxDepth;
|
||||||
private static long _activeSessions;
|
private static long _activeSessions;
|
||||||
@@ -163,4 +185,23 @@ public static class DealMetrics
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="count">Активные непросроченные сессии пользователей и операторов.</param>
|
/// <param name="count">Активные непросроченные сессии пользователей и операторов.</param>
|
||||||
public static void SetActiveSessions(long count) => Interlocked.Exchange(ref _activeSessions, count);
|
public static void SetActiveSessions(long count) => Interlocked.Exchange(ref _activeSessions, count);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Публикует доли расхода ИИ-бюджета по тенантам (заменяет предыдущий снимок)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ratios">Пары (id тенанта, доля 0..1).</param>
|
||||||
|
public static void ReplaceBudgetRatios(IEnumerable<(string TenantId, double Ratio)> ratios)
|
||||||
|
{
|
||||||
|
BudgetRatios.Clear();
|
||||||
|
foreach ((string tenantId, double ratio) in ratios)
|
||||||
|
{
|
||||||
|
BudgetRatios[tenantId] = Math.Clamp(ratio, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Снимок долей расхода бюджета для observable-gauge (по измерению на тенант).
|
||||||
|
private static IEnumerable<Measurement<double>> ObserveBudgetRatios()
|
||||||
|
=> BudgetRatios.Select(pair => new Measurement<double>(
|
||||||
|
pair.Value,
|
||||||
|
new TagList { { TenantTagName, pair.Key } }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
{
|
{
|
||||||
private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||||
private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||||
|
private static readonly Guid TenantC = Guid.Parse("33333333-3333-3333-3333-333333333333");
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants()
|
public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants()
|
||||||
@@ -50,6 +51,53 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
Assert.Equal(3, depths.PipelineQueue);
|
Assert.Equal(3, depths.PipelineQueue);
|
||||||
Assert.Equal(1, depths.MlOutbox);
|
Assert.Equal(1, depths.MlOutbox);
|
||||||
Assert.Equal(0, depths.ActiveSessions);
|
Assert.Equal(0, depths.ActiveSessions);
|
||||||
|
Assert.NotNull(depths.Budgets);
|
||||||
|
Assert.Equal(2, depths.Budgets!.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CollectAsync_CollectsBudgetRatiosPerTenant()
|
||||||
|
{
|
||||||
|
var limits = new FakeTenantLimitStore();
|
||||||
|
DateTimeOffset periodStart = DateTimeOffset.UtcNow;
|
||||||
|
limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80);
|
||||||
|
limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55);
|
||||||
|
limits.Preload(TenantC, 100, TenantLimitPeriods.Month, periodStart, 150);
|
||||||
|
|
||||||
|
RuntimeDepthsCollector collector = Build(
|
||||||
|
PipelineStores(TenantA, TenantB, TenantC),
|
||||||
|
OutboxStores(TenantA, TenantB, TenantC),
|
||||||
|
TenantRecords(TenantA, TenantB, TenantC),
|
||||||
|
limits);
|
||||||
|
|
||||||
|
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(depths.Budgets);
|
||||||
|
Assert.Equal(3, depths.Budgets!.Count);
|
||||||
|
Assert.Equal(0.8, RatioFor(depths.Budgets, TenantA), 3);
|
||||||
|
Assert.Equal(0.0, RatioFor(depths.Budgets, TenantB), 3);
|
||||||
|
Assert.Equal(1.0, RatioFor(depths.Budgets, TenantC), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant()
|
||||||
|
{
|
||||||
|
var limits = new FakeTenantLimitStore();
|
||||||
|
limits.FailStateReads.Add(TenantA);
|
||||||
|
limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80);
|
||||||
|
|
||||||
|
RuntimeDepthsCollector collector = Build(
|
||||||
|
PipelineStores(TenantA, TenantB),
|
||||||
|
OutboxStores(TenantA, TenantB),
|
||||||
|
TenantRecords(TenantA, TenantB),
|
||||||
|
limits);
|
||||||
|
|
||||||
|
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(depths.Budgets);
|
||||||
|
TenantBudgetUsageDto budget = Assert.Single(depths.Budgets!);
|
||||||
|
Assert.Equal(TenantB.ToString("N"), budget.TenantId);
|
||||||
|
Assert.Equal(0.8, budget.UsedRatio, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -74,11 +122,26 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
Status = PipelineQueueStatuses.New,
|
Status = PipelineQueueStatuses.New,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static Dictionary<string, FakePipelineStore> PipelineStores(params Guid[] tenants)
|
||||||
|
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore());
|
||||||
|
|
||||||
|
private static Dictionary<string, FakeMlLearningStore> OutboxStores(params Guid[] tenants)
|
||||||
|
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakeMlLearningStore());
|
||||||
|
|
||||||
|
private static TenantRecordDto[] TenantRecords(params Guid[] tenants)
|
||||||
|
=> tenants
|
||||||
|
.Select((tenant, index) => new TenantRecordDto(tenant, $"T{index}", "active", DateTimeOffset.UtcNow))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
private static double RatioFor(IReadOnlyList<TenantBudgetUsageDto> budgets, Guid tenant)
|
||||||
|
=> budgets.Single(budget => budget.TenantId == tenant.ToString("N")).UsedRatio;
|
||||||
|
|
||||||
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
|
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
|
||||||
private static RuntimeDepthsCollector Build(
|
private static RuntimeDepthsCollector Build(
|
||||||
IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant,
|
IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant,
|
||||||
IReadOnlyDictionary<string, FakeMlLearningStore> outboxByTenant,
|
IReadOnlyDictionary<string, FakeMlLearningStore> outboxByTenant,
|
||||||
IReadOnlyList<TenantRecordDto>? tenants = null)
|
IReadOnlyList<TenantRecordDto>? tenants = null,
|
||||||
|
ITenantLimitStore? limitStore = null)
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddSingleton<ITenantContext, TenantContext>();
|
services.AddSingleton<ITenantContext, TenantContext>();
|
||||||
@@ -94,6 +157,7 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
services.AddScoped<PipelineIngestService>();
|
services.AddScoped<PipelineIngestService>();
|
||||||
services.AddScoped<PipelineProcessingService>();
|
services.AddScoped<PipelineProcessingService>();
|
||||||
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)]);
|
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)]);
|
||||||
|
services.AddSingleton<ITenantLimitStore>(limitStore ?? new FakeTenantLimitStore());
|
||||||
|
|
||||||
ServiceProvider provider = services.BuildServiceProvider();
|
ServiceProvider provider = services.BuildServiceProvider();
|
||||||
return new RuntimeDepthsCollector(
|
return new RuntimeDepthsCollector(
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string TenantStatus { get; set; }
|
public string TenantStatus { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Тенанты, для которых <see cref="GetStateAsync"/> бросает исключение (устойчивость сборщиков)
|
||||||
|
/// </summary>
|
||||||
|
public HashSet<Guid> FailStateReads { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Кладёт готовую строку лимита
|
/// Кладёт готовую строку лимита
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -124,6 +129,11 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
|
public Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (FailStateReads.Contains(tenantId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Фейковый сбой чтения состояния лимита тенанта {tenantId}.");
|
||||||
|
}
|
||||||
|
|
||||||
Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
|
Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
|
||||||
ResetIfPeriodExpired(row);
|
ResetIfPeriodExpired(row);
|
||||||
return Task.FromResult(ToStateDto(row, tenantId));
|
return Task.FromResult(ToStateDto(row, tenantId));
|
||||||
|
|||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
using Deal.SharedKernel.Observability;
|
||||||
|
|
||||||
|
namespace Deal.Tests.Unit.SharedKernel.Observability;
|
||||||
|
|
||||||
|
public sealed class DealMetricsBudgetRatioTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ReplaceBudgetRatios_ClampsAndReplacesSnapshot()
|
||||||
|
{
|
||||||
|
var measurements = new Dictionary<string, double>(StringComparer.Ordinal);
|
||||||
|
using var listener = new MeterListener();
|
||||||
|
listener.InstrumentPublished = (instrument, current) =>
|
||||||
|
{
|
||||||
|
if (instrument.Meter.Name == DealMetrics.MeterName
|
||||||
|
&& instrument.Name == DealMetrics.AiBudgetUsedRatioName)
|
||||||
|
{
|
||||||
|
current.EnableMeasurementEvents(instrument);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
listener.SetMeasurementEventCallback<double>((_, value, tags, _) =>
|
||||||
|
{
|
||||||
|
string? tenant = null;
|
||||||
|
foreach (KeyValuePair<string, object?> tag in tags)
|
||||||
|
{
|
||||||
|
if (tag.Key == DealMetrics.TenantTagName)
|
||||||
|
{
|
||||||
|
tenant = tag.Value?.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenant is not null)
|
||||||
|
{
|
||||||
|
measurements[tenant] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
DealMetrics.ReplaceBudgetRatios(new[]
|
||||||
|
{
|
||||||
|
("tenant-a", 1.5),
|
||||||
|
("tenant-b", -0.2),
|
||||||
|
});
|
||||||
|
measurements.Clear();
|
||||||
|
listener.RecordObservableInstruments();
|
||||||
|
|
||||||
|
Assert.Equal(1.0, measurements["tenant-a"], 3);
|
||||||
|
Assert.Equal(0.0, measurements["tenant-b"], 3);
|
||||||
|
Assert.Equal(2, measurements.Count);
|
||||||
|
|
||||||
|
DealMetrics.ReplaceBudgetRatios(new[] { ("tenant-c", 0.4) });
|
||||||
|
measurements.Clear();
|
||||||
|
listener.RecordObservableInstruments();
|
||||||
|
|
||||||
|
Assert.Equal(0.4, measurements["tenant-c"], 3);
|
||||||
|
Assert.False(measurements.ContainsKey("tenant-a"));
|
||||||
|
Assert.Single(measurements);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user