fix(log): разблокировать спул лог-коллектора при устаревших таймстемпах

Раньше одна пачка в спуле с таймстемпом вне окна приёма Loki (≈7 суток)
топила весь пуш: Loki отвечал 400 на весь batch, спул никогда не чистился,
и новые логи (в т.ч. телефона) не доходили. Теперь flush() выгребает ВСЕ
готовые пачки за тик (FIFO) и отбрасывает записи вне окна Loki, а при
неудаче отправки спула буфер не трогается (ждёт следующий тик). Добавлены
тесты на очистку устаревших записей и FIFO-ретрай спула.
This commit is contained in:
2026-08-30 03:30:39 +03:00
parent 6cd4a8de43
commit 1aa389a3a0
2 changed files with 67 additions and 6 deletions
@@ -6,6 +6,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/** Окно приёма Loki: записи старше этого возраста (или из будущего) отбрасываем. */
private const val RETENTION_MS = 7L * 24 * 60 * 60 * 1000
/**
* «Абстрактный коллектор» лог-пачек: собирает записи в батчи и по времени
* шлёт их «вниз» через [sink] (абстракция следующего коллектора).
@@ -95,17 +98,40 @@ class BatchingLogCollector(
* 2) новый батч из буфера; не слано — прихраняем в [spool].
*/
suspend fun flush() {
val spooled = spool.peek()
if (spooled != null) {
if (sendBatch(spooled)) {
// Ретраи спула: выгребаем ВСЕ готовые пачки за тик (FIFO), а не одну.
// Записи с «неприемлемым» таймстемпом (старше окна Loki ~7 дней или из
// будущего) отбрасываем — иначе одна такая пачка топит весь пуш (Loki
// отвечает 400 на весь batch) и спул никогда не чистится.
var spooled = spool.peek()
while (spooled != null) {
val cleaned = clean(spooled)
val ok = if (cleaned.entries.isEmpty()) true else sendBatch(cleaned)
if (ok) {
spool.take()
}
spooled = spool.peek()
} else {
// спул не ушёл — буфер не трогаем, ждём следующий тик
return
}
val batch = drainBuffer() ?: return
if (!sendBatch(batch)) {
spool.write(batch)
}
val batch = drainBuffer() ?: return
val cleaned = clean(batch)
if (cleaned.entries.isNotEmpty()) {
if (!sendBatch(cleaned)) spool.write(cleaned)
}
}
/**
* Отбросить записи вне окна приёма Loki: старше [RETENTION_MS] или из будущего.
* Такие записи Loki всё равно отверг бы всем батчем (HTTP 400), поэтому их
* безопасно выкинуть — они не могут попасть в хранилище.
*/
private fun clean(batch: LogBatch): LogBatch {
val now = clock.nowMillis()
val min = now - RETENTION_MS
val max = now + 3_600_000L
val kept = batch.entries.filter { it.ts in min..max }
return if (kept.size == batch.entries.size) batch else batch.copy(entries = kept)
}
private suspend fun sendBatch(batch: LogBatch): Boolean =
@@ -137,4 +137,39 @@ class BatchingLogCollectorTest {
collector.flush()
assertNull(sink.sent.firstOrNull())
}
@Test
fun `out-of-range spooled entries are dropped, buffer still flushed`() = runTest {
val sink = FakeSink()
val spool = InMemoryLogSpool()
val now = 1_000_000_000_000L
val collector = BatchingLogCollector("phone", sink, spool, FixedClock(now))
// «старые» пачки в спуле (метки из прошлого, как при загрузке без NTP)
spool.write(LogBatch("phone", listOf(LogEntry(0, "old", "stale", "phone"))))
spool.write(LogBatch("phone", listOf(LogEntry(123, "older", "stale2", "phone"))))
collector.record("fresh", "current") // ts = now → валидно
collector.flush()
// старые пачки выкинуты (не топят пуш), свежая ушла в sink
assertEquals(0, spool.pendingCount())
assertEquals(1, sink.sent.size)
assertEquals(listOf("fresh"), sink.sent[0].entries.map { it.tag })
}
@Test
fun `valid spooled batch retried and sent before buffer`() = runTest {
val sink = FakeSink(fail = true)
val spool = InMemoryLogSpool()
val collector = BatchingLogCollector("phone", sink, spool, FixedClock(1_000_000_000_000L))
collector.record("a", "1")
collector.flush() // → спул (fail)
sink.fail = false
collector.record("b", "2")
collector.flush()
assertEquals(2, sink.sent.size)
assertEquals(listOf("a", "b"), sink.sent.flatMap { it.entries }.map { it.tag })
assertEquals(0, spool.pendingCount())
}
}