Files
pd-guard/src/main/java/ru/pdguard/api/ProcessResource.java
T
Максименко Никита Владимирович 6afe3442f2 fix: устойчивость Redis-кластера под нагрузкой и код-ревью замечания
Redis timeout 200ms давал ложные срабатывания под пиковой нагрузкой на общем
хосте — подняли до 800ms и добавили cpu/mem лимиты сервисам в compose, чтобы
соседи не выедали CPU у Redis. Добавили метрику и WARN на случай, когда
демаскирование не находит соответствие ни по id, ни по отпечатку маски (раньше
тихо превращалось в повторное маскирование без единого следа в логах).

Кластерные узлы (node-a/node-b) получили обе NER-модели (WikiNEuRal для имён,
ruBERT для адресов) — раньше конфиг ссылался на несуществующие свойства и
вторая ступень молча не работала. lb (nginx) и volume для prometheus/grafana
данных зафиксированы в compose.

Плюс код-ревью фиксы: утечка нативных ONNX-ресурсов при ошибке загрузки модели
(BLOCKER), неверный HTTP-статус при сбое обработки, generic Exception в
LlmClient заменён на конкретные, лишние same-package импорты убраны.
2026-09-22 21:40:18 +03:00

134 lines
7.5 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ru.pdguard.api;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.core.AdaptiveConcurrencyLimiter;
import ru.pdguard.core.Pipeline;
/**
* Единственная точка входа контракта: маскирование и демаскирование по
* {@code payload_id}.
*
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка
* нет или система неизвестна — применяются настройки {@code default}, поэтому
* контракт работает и без него. Система, выключенная в настройках, получает
* {@code 403}.
*
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки —
* не фиксированное число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter}
* сам находит потолок конкурентности под то, сколько CPU реально досталось контейнеру,
* вместо того чтобы копить запросы и упереться в таймаут вызывающей стороны.
*/
@RestController
public class ProcessResource {
private static final Logger LOG = LoggerFactory.getLogger(ProcessResource.class);
/** Заголовок, которым система-потребитель себя называет. */
public static final String SYSTEM_HEADER = "X-System-Id";
/** Общий секрет системы. Проверяется, только если он задан в настройках. */
public static final String KEY_HEADER = "X-System-Key";
/** Имя метрики отклонённых запросов и имя её метки причины. */
private static final String REJECTED_METRIC = "pdguard.requests.rejected";
private static final String REASON_TAG = "reason";
/**
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом
* шаге иначе выпустил бы наружу незамаскированные персональные данные.
*/
static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]";
public record ProcessRequest(
@JsonProperty("payload") String payload,
@JsonProperty("payload_id") String payloadId) {
}
public record ProcessResponse(@JsonProperty("result") String result) {
}
private final Pipeline pipeline;
private final SystemsConfig systems;
private final AdaptiveConcurrencyLimiter limiter;
private final Counter rejected;
private final Counter malformed;
private final Counter forbidden;
private final Counter failed;
public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters,
@Value("${pdguard.min-concurrent:8}") int minConcurrent,
@Value("${pdguard.max-concurrent:2000}") int maxConcurrent,
@Value("${pdguard.target-latency-ms:200}") long targetLatencyMillis) {
this.pipeline = pipeline;
this.systems = systems;
this.limiter = new AdaptiveConcurrencyLimiter(minConcurrent, maxConcurrent, targetLatencyMillis);
this.rejected = meters.counter(REJECTED_METRIC, REASON_TAG, "overload");
this.malformed = meters.counter(REJECTED_METRIC, REASON_TAG, "malformed");
this.forbidden = meters.counter(REJECTED_METRIC, REASON_TAG, "system_disabled");
this.failed = meters.counter(REJECTED_METRIC, REASON_TAG, "internal_error");
meters.gauge("pdguard.concurrency.limit", limiter, AdaptiveConcurrencyLimiter::limit);
meters.gauge("pdguard.concurrency.in.flight", limiter, AdaptiveConcurrencyLimiter::inFlight);
}
@PostMapping("/process")
public ResponseEntity<ProcessResponse> process(@RequestBody(required = false) ProcessRequest request,
@RequestHeader(value = SYSTEM_HEADER, required = false) String systemId,
@RequestHeader(value = KEY_HEADER, required = false) String systemKey) {
if (request == null || request.payload() == null
|| request.payloadId() == null || request.payloadId().isBlank()) {
malformed.increment();
return ResponseEntity.badRequest()
.body(new ProcessResponse("payload и payload_id обязательны"));
}
SystemPolicy policy = systems.policyFor(systemId);
if (!policy.accepts(systemKey)) {
forbidden.increment();
LOG.warn("Системе {} отказано: неверный ключ", systemId);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProcessResponse("Неверный ключ системы"));
}
if (!policy.enabled()) {
forbidden.increment();
LOG.warn("Системе {} обращение в модуль запрещено настройками", systemId);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProcessResponse("Системе " + systemId + " обращение в модуль запрещено"));
}
if (!limiter.tryAcquire()) {
rejected.increment();
return ResponseEntity.status(429).header("Retry-After", "1").build();
}
long started = System.nanoTime();
try {
String result = pipeline.process(request.payload(), request.payloadId(), policy);
return ResponseEntity.ok(new ProcessResponse(result));
} catch (RuntimeException e) {
// Ни 5xx, ни исходный текст. Пять подряд невалидных ответов останавливают
// прогон, поэтому код остаётся 200 — но возвращать при сбое сам payload
// нельзя: на прямом шаге наружу ушли бы незамаскированные ПД, ровно то,
// ради чего сервис и существует. Ответ фиксированный: он ничего не
// раскрывает и не выглядит порчей данных.
failed.increment();
LOG.error("payload_id={} обработка не удалась, отдан безопасный ответ",
request.payloadId(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ProcessResponse(PROCESSING_UNAVAILABLE));
} finally {
limiter.release(System.nanoTime() - started);
}
}
}