fix(gps): остановлен 429-шторм — throttle SSE gps_asset_moved (3 сек/asset) и клиентских инвалидаций (60 сек), polling позиций 60 сек, статусов 5 мин
Каждая точка ingest (~1/сек) broadcast'илась SSE всем клиентам орг., клиент на каждое событие делал invalidateQueries без throttle → 2+ GET/сек → исчерпание per-user бакета apiLimiter (1500/15мин) → все API устройства 429 (формы не грузились). Модель теперь как у нормальных трекеров: точки пишутся в БД, карта читает по интервалу. +4 unit-теста троттла (server/gps/sse-throttle.ts)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { gpsStorage } from "./storage";
|
||||
import { OSRM_URL } from "./osrm";
|
||||
import { shouldPublishGpsAssetMoved } from "./sse-throttle";
|
||||
import { storage } from "../storage";
|
||||
import { webPushService } from "../services/web-push.service";
|
||||
import { eventBus, publishNotificationSSE } from "../routes/shared";
|
||||
@@ -521,19 +522,22 @@ export async function processPosition(
|
||||
recordedAt,
|
||||
isLbs: true,
|
||||
});
|
||||
eventBus.publishEvent({
|
||||
type: "gps_asset_moved",
|
||||
data: {
|
||||
assetId,
|
||||
lat: input.lat,
|
||||
lng: input.lng,
|
||||
speed: input.speed ?? null,
|
||||
course: input.course ?? null,
|
||||
recordedAt: recordedAt.toISOString(),
|
||||
isLbs: true,
|
||||
},
|
||||
organizationId,
|
||||
});
|
||||
// SSE-broadcast троттлим (не чаще раза в 3 сек на объект) — запись в БД выше не трогаем
|
||||
if (shouldPublishGpsAssetMoved(assetId)) {
|
||||
eventBus.publishEvent({
|
||||
type: "gps_asset_moved",
|
||||
data: {
|
||||
assetId,
|
||||
lat: input.lat,
|
||||
lng: input.lng,
|
||||
speed: input.speed ?? null,
|
||||
course: input.course ?? null,
|
||||
recordedAt: recordedAt.toISOString(),
|
||||
isLbs: true,
|
||||
},
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -633,20 +637,24 @@ export async function processPosition(
|
||||
}
|
||||
|
||||
// 3. SSE-событие о перемещении — broadcast по организации,
|
||||
// чтобы карта у всех открытых клиентов обновилась в реальном времени
|
||||
eventBus.publishEvent({
|
||||
type: "gps_asset_moved",
|
||||
data: {
|
||||
assetId,
|
||||
lat: input.lat,
|
||||
lng: input.lng,
|
||||
speed: input.speed ?? null,
|
||||
course: input.course ?? null,
|
||||
recordedAt: recordedAt.toISOString(),
|
||||
isLbs,
|
||||
},
|
||||
organizationId,
|
||||
});
|
||||
// чтобы карта у всех открытых клиентов обновилась в реальном времени.
|
||||
// Троттлим не чаще раза в 3 сек на объект, иначе при потоке точек
|
||||
// ~1/сек клиентская инвалидация исчерпывает apiLimiter (429).
|
||||
if (shouldPublishGpsAssetMoved(assetId)) {
|
||||
eventBus.publishEvent({
|
||||
type: "gps_asset_moved",
|
||||
data: {
|
||||
assetId,
|
||||
lat: input.lat,
|
||||
lng: input.lng,
|
||||
speed: input.speed ?? null,
|
||||
course: input.course ?? null,
|
||||
recordedAt: recordedAt.toISOString(),
|
||||
isLbs,
|
||||
},
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Проверка геозон — только для настоящих GPS-точек;
|
||||
// LBS-координаты вышек в логику геозон не пускаем
|
||||
|
||||
27
server/gps/sse-throttle.ts
Normal file
27
server/gps/sse-throttle.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* In-memory throttle для SSE-событий gps_asset_moved.
|
||||
* Трекеры присылают точки ~1 раз в секунду; без ограничения каждый открытый
|
||||
* клиент организации получает broadcast каждую секунду и инвалидирует запросы
|
||||
* позиций/объектов, что исчерпывает per-user бакет apiLimiter (429).
|
||||
* Запись позиции в БД не затрагивается — ограничивается только broadcast.
|
||||
*/
|
||||
|
||||
const GPS_MOVED_SSE_MIN_INTERVAL_MS = 3_000;
|
||||
const MAX_TRACKED_ASSETS = 1_000;
|
||||
|
||||
const lastPublishedAt = new Map<number, number>();
|
||||
|
||||
/** true — событие публиковать; false — пропустить (с последней публикации прошло меньше интервала) */
|
||||
export function shouldPublishGpsAssetMoved(assetId: number, now = Date.now()): boolean {
|
||||
const last = lastPublishedAt.get(assetId);
|
||||
if (last !== undefined && now - last < GPS_MOVED_SSE_MIN_INTERVAL_MS) return false;
|
||||
|
||||
lastPublishedAt.set(assetId, now);
|
||||
// Защита от неограниченного роста карты: чистим устаревшие записи при превышении лимита
|
||||
if (lastPublishedAt.size > MAX_TRACKED_ASSETS) {
|
||||
for (const [id, ts] of lastPublishedAt) {
|
||||
if (now - ts >= GPS_MOVED_SSE_MIN_INTERVAL_MS) lastPublishedAt.delete(id);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user