BitPage

Кэш с ключом на дату: пять способов показать не тот день, и почему их не ловят зелёные тесты

Автор:  ·   · 14 мин чтения

Короткий ответ: как только ключ кэша начинает зависеть от даты, кэш перестаёт быть одним потоком данных и становится несколькими. Экран обязан склеивать их по ключу, и каждое место, где ключ едет отдельно от своих данных, — готовый дефект: дата без чисел, флаг ошибки без даты, запрос без результата. Компилятор их не видит, а тесты на мгновенных фейках проходят зелёными, потому что проверяют мапперы, а не поведение во времени. Главное правило срока хранения — min(сегодня − N, запрошенный день): отсчёт от сегодня, чтобы окно не уезжало в будущее, и ограничение запрошенным днём, чтобы открытый день не стёр сам себя.

История — про Android-приложение с дневником, где сервер остаётся единственным источником истины, а локально живёт только кэш последних ответов. Код за ночную сессию писал ИИ-агент, после каждого закрытого экрана его читал отдельный агент-ревьюер с чистым контекстом. Все дефекты ниже нашёл ревьюер. Ни один не нашли тесты, хотя за сессию их число почти удвоилось — с пары сотен до трёх с лишним.

Стек: Kotlin 2.4.10, Jetpack Compose (BOM 2026.08.00), Navigation 3 1.1.7, Room 3.0.2, Retrofit 3.0.0 с конвертером kotlinx.serialization 1.11.0, coroutines 1.11.0, OkHttp 5.5.0. Бэкенд — FastAPI с Pydantic.

Коротко (TL;DR)

Почему кэш с ключом на дату — это не «кэш плюс параметр»

Разница в том, что экран за сессию наблюдает не один объект, а меняющуюся их последовательность, и обязан сам отвечать за соответствие. У кэша по одному ключу — скажем, истории веса — всё просто: одна строка в базе, один поток, один экран. Подписался и забыл.

Как только ключ включает дату, у каждого прожитого дня появляется своя строка: nutrition.day.2026-09-16, рядом своя строка у сводки того же дня. Человек листает дни вперёд-назад, и цель наблюдения меняется под ним. Всё, что экран держал «одно на экран» — текущие данные, признак загрузки, признак ошибки, летящий запрос, — внезапно обязано быть «одно на ключ».

Инсайт, который стоило знать в начале: это не кэш с параметром, это несколько независимых потоков данных, которые экран обязан склеивать по ключу. Дальше достаточно пройтись по швам и найти все места, где ключ и данные едут отдельно. Их оказалось пять.

Что разъезжаетсяСимптом для человекаТест, который ловит
Срок хранения и открытый деньДень старше окна навсегда в загрузкеoldestKeptDay(today, requested) <= requested
Дата и снимокВчерашние числа под сегодняшней датойПосле переключения нет пары «новая дата + старые данные»
Признак ошибки и датаПереключил день во время запроса — вечная загрузкаСтарый запрос отменён, новый отправлен
Строка поиска и результатЗапросы уходят без единого нажатияДесять секунд покоя — ноль запросов
Момент обновления и возврат по стекуВернулся назад — данные старыеДве подписки на uiState, счётчик вызовов refresh

Срок хранения: две попытки, и вторая опаснее первой

Правило чистки кэша должно отсчитываться от сегодняшнего дня и при этом никогда не заходить за день, который человек прямо сейчас открыл. Оба условия нужны одновременно, и каждое по отдельности даёт свой дефект.

Первая версия отсчитывала окно от обновляемой даты: чистим снимки старше двух недель от того дня, который сейчас грузим. Выглядит естественно — раз у каждого дня своя строка, пусть каждая и подчищает соседей. Контракт при этом разрешает читать будущие даты. Человек пролистал вперёд на пару недель, окно запрошенная дата − 14 уехало в будущее и стёрло кэш всех прошлых дней вместе с сегодняшним.

Правка напрашивалась: считать от сегодня, today − 14, окно больше никуда не уезжает. Это и оказалось самым опасным местом за всю ночь — потому что правка не была багом, она была исправлением. Открываешь день старше двух недель, снимок приходит с сервера, сохраняется — и тут же удаляется тем же самым обновлением, потому что он старше границы. Экран остаётся в вечной загрузке, где нет даже кнопки «Повторить».

Обманывает тут всё сразу. Правка логично устраняет предыдущий дефект. Сборка зелёная. А единственный день, который открывают при ручной проверке, — сегодняшний, и он не затронут.

Граница окнаСегодняДень в будущемДень старше окна
requested − 14работаетстирает весь прошлый кэшработает
today − 14работаетработаетстирает сам себя
min(today − 14, requested)работаетработаетработает

Правило целиком помещается в четыре строки, и важно, что оно ровно одно на все ключи с датой:

1
2
3
4
5
internal object DaySnapshotRetention {
    const val KEPT_DAYS = 14L
    fun oldestKeptDay(today: LocalDate, requestedDate: LocalDate): LocalDate =
        minOf(today.minusDays(KEPT_DAYS), requestedDate)
}

К моменту находки это правило жило двумя копиями — в дневнике и в сводке дня. Одну ошибку пришлось чинить дважды, и именно это, а не сама формула, стало уроком: любое правило, у которого появился второй потребитель, выносится в одно место сразу. «Правило есть» и «правило одно» — разные вещи.

Тест, который ловит регрессию, состоит из одного утверждения — день никогда не удаляет сам себя:

1
2
3
4
@Test fun `a day older than the window never deletes itself`() {
    val requested = today.minusDays(40)
    assertThat(DaySnapshotRetention.oldestKeptDay(today, requested)).isAtMost(requested)
}

Ключ и данные обязаны ехать одной парой

Если дата и её числа приходят из разных потоков, экран рано или поздно покажет их вперемешку — вопрос только в том, насколько медленная база у пользователя. Симптом выглядит как мелочь: на доли секунды после переключения дня заголовок уже новый, а калории под ним ещё вчерашние. На эмуляторе с быстрым диском этого не видно вообще.

Лечится тем, что дата перестаёт быть отдельным состоянием и становится частью данных:

1
selectedDate.flatMapLatest { date -> repo.observeDay(date).map { date to it } }

Документация flatMapLatest описывает ровно нужную семантику:

Returns a flow that switches to a new flow produced by transform function every time the original flow emits a value. When the original flow emits a new value, the previous flow produced by transform block is cancelled.

Отмена предыдущего потока здесь не оптимизация, а часть корректности: без неё старый день продолжает эмитить в тот же экран.

Та же болезнь у признака ошибки. failed: Boolean на экране с переключением дней не относится ни к чему конкретному: переключил день во время летящего запроса — запрос прошлого дня не отменён, флаг повис непонятно от какого дня, а новый день не запрошен никогда. Вечная загрузка без кнопки повтора. Флаг обязан носить ключ:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
private val failedDate = MutableStateFlow<LocalDate?>(null)

private fun requestRefresh() {
    val date = selectedDate.value
    if (refreshJob?.isActive == true && requestedDate == date) return
    refreshJob?.cancel()
    requestedDate = date
    if (failedDate.value == date) failedDate.value = null
    refreshJob = viewModelScope.launch {
        val outcome = repo.refreshDay(date)
        failedDate.value = if (outcome is Outcome.Failure) date else null
    }
}

Строка if (failedDate.value == date) failedDate.value = null появилась не сразу. Без неё «Повторить» висит поверх уже летящего запроса, нажатие молча отбрасывается проверкой на активную задачу, и кнопка выглядит сломанной.

init у ViewModel в стеке выполняется один раз

Обновление данных нельзя вешать на init, потому что ViewModel переживает уход экрана внутри стека и повторно не создаётся. Документация Android формулирует это прямо:

Your ViewModel is then scoped to the Lifecycle of the ViewModelStoreOwner. It remains in memory until its ViewModelStoreOwner goes away permanently.

«Permanently» — ключевое слово. Уход вперёд по стеку — не «permanently», модель остаётся жива, init за сессию отрабатывает ровно один раз.

Сценарий целиком внутри приложения: сводка дня → дневник → добавить еду → назад. Баланс дня остаётся старым до перезапуска. Обманывает то, что на любом новом экране всё свежее: дефект проявляется только на возврате назад, а тестировать обычно идут вперёд.

Первое исправление закрывало только счастливый путь: запись еды стала сама перечитывать сводку дня. Один сетевой сбой сразу после записи — и снова старый баланс до перезапуска. Правильное место — не запись, а подписка:

1
2
3
val uiState = combine(/* … */)
    .onStart { requestRefresh() }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Loading)

Семантика WhileSubscribed описана в документации coroutines так:

Sharing is started when the first subscriber appears, immediately stops when the last subscriber disappears (by default), keeping the replay cache forever (by default).

То есть подписчик появляется при каждом возвращении экрана — и onStart срабатывает снова, в отличие от init. Защита от повторного запроса поверх идущего при этом обязательна, иначе возврат по стеку начнёт множить запросы.

Отдельная мелочь, которая стоила бы дорого: дату дня экран берёт из ключа навигации, а не считает LocalDate.now() второй раз у себя. Иначе на границе суток запись еды уедет в другой день, чем тот, который человек видит в заголовке.

Зелёный тест на мгновенном фейке проверяет маппер, а не поведение

Тест с фейком, отвечающим мгновенно, физически не способен увидеть самозацикливание — потому что цикл в нём не успевает раскрутиться. Экран поиска продукта наблюдал uiState целиком и при изменении строки запроса слал запрос. В то же состояние писался флаг «идёт поиск» и результат. Запись флага — новое изменение состояния — новый повод искать.

Экран без единого нажатия слал запрос примерно каждые 350 мс по кругу. Тест на поиск существовал и был зелёным: с мгновенным фейком переход «идёт поиск» → «готово» укладывался в один тик, и повторный триггер не возникал.

Чинится в два приёма. Триггером наблюдается только вход — строка запроса; результат живёт в отдельном потоке и склеивается через combine. А тест переписывается на фейк с delay() в ответе и виртуальным временем:

1
2
3
4
5
6
@Test fun `idle screen sends no requests`() = runTest {
    val vm = SearchViewModel(FakeRepo(answerDelay = 300.milliseconds))
    backgroundScope.launch { vm.uiState.collect() }
    advanceTimeBy(10.seconds)
    assertThat(repo.callCount).isEqualTo(0)
}

advanceTimeBy документирован как «Moves the virtual clock of this dispatcher forward by the specified amount, running the scheduled tasks in the meantime» — десять секунд модельного покоя проходят мгновенно, и любой цикл за это время себя выдаёт.

Правило, которое я вынес: фейки с задержкой — по умолчанию для всего, что наблюдает потоки. Мгновенный фейк проверяет преобразование данных, а не поведение во времени, и выдавать одно за другое — самый дешёвый способ получить зелёный набор тестов поверх сломанного экрана.

Две мины на границе с сервером

Обе связаны с тем, что типы и коды на границе значат не то, что кажется.

Retrofit не видит ? у suspend-функции. Запрос целей пользователя для аккаунта, где целей ещё нет, отвечает 200 с телом null. Объявление suspend fun goals(): GoalsDto? от падения не спасает. Причина не в конвертере, а уровнем выше: тип ответа Retrofit достаёт из параметра Continuation<? super T>, а нотация нулевости в сигнатуру Java не попадает — её пришлось бы вычитывать из котлиновской аннотации @Metadata. В исходниках HttpServiceMethod.java на этом месте прямо стоит незакрытая задача:

1
2
3
4
5
continuationIsUnit = Utils.isUnit(responseType);
// TODO figure out if type is nullable or not
// Metadata metadata = method.getDeclaringClass().getAnnotation(Metadata.class)
// Find the entry for method
// Determine if return type is nullable or not

Флаг continuationBodyNullable объявлен как false и таким же уходит дальше. Для пустого тела это даёт характерное исключение с дословным текстом из KotlinExtensions.kt: Response from ${service}.${method} was null but response body type was declared as non-null. Для тела null разбор ломается ещё раньше, в конвертере, — оба слоя одинаково слепы к вопросительному знаку.

Лечение — вынести решение «есть данные или нет» из сети в data-слой:

1
2
3
4
@GET("api/nutrition/goals") suspend fun goals(): JsonElement

internal fun Json.toGoalsDto(element: JsonElement): GoalsDto =
    if (element is JsonNull) GoalsDto() else decodeFromJsonElement(GoalsDto.serializer(), element)

FastAPI отвечает на двух языках, и различает их только код. Занятый адрес при регистрации приходит как 400 с телом {"detail":"Email уже зарегистрирован"} — текст написан для пользователя. Слишком короткий пароль приходит как 422 с телом вида [{"type":"string_too_short","loc":["body","password"],"msg":"String should have at least 6 characters"}] — это отчёт Pydantic, написанный для разработчика.

Разница не в стиле, а в происхождении. 400 порождает HTTPException, который написал автор бэкенда. 422 порождает обработчик RequestValidationError, отдающий exc.errors() — машинный список, где string_too_short документирован Pydantic как ошибка, которая «is raised when the input value is a string whose length is less than the field’s min_length constraint».

Соблазн одинаковый в обоих случаях — показать пришедший текст человеку. Во втором случае это означает расписаться, что клиент не знает собственных ограничений. Правило вышло такое: текст сервера показываем при 400 и 403, а при 422 форма объясняет отказ своими словами по заранее известным пределам, и проверяет их до отправки запроса.

1
2
3
4
5
fun userFacingMessage(error: AppError): String? = when (error) {
    is AppError.Forbidden -> error.message.orNullIfBlank()
    is AppError.Server -> if (error.httpCode == 400) error.message.orNullIfBlank() else null
    else -> null
}

Рядом — мелкая, но злая деталь про авторизацию: список анонимных запросов задаётся поимённо, а не префиксом пути. Перехватчик, освобождающий от токена весь /api/auth/, ломает повторную отправку письма подтверждения, которая лежит там же, но токен требует, и отвечает 401 всегда.

Почему триста тестов ничего не поймали

Потому что набор тестов подтверждает отсутствие известных поломок, а не отсутствие дефектов. За сессию их стало больше трёх сотен, qualityGate был зелёным на каждом коммите, и ни один дефект из перечисленных выше не пришёл из красного теста. Все блокирующие замечания — а их набралось больше десятка — пришли от чтения кода независимым ревьюером. Тест появлялся после находки — как замок на уже найденную дверь.

Честная оговорка: это наблюдение на одной сессии, а не сравнение двух подходов на одной задаче. Утверждать «ревью сильнее тестов» на таком основании нельзя. Что можно утверждать — все пять дефектов принадлежат к классу, который тесты на мгновенных фейках не ловят конструктивно: они про соответствие ключа и данных во времени, а мгновенный фейк время схлопывает.

И ручная проверка здесь помогает не больше. Для кэша с ключом на дату проверять «на сегодня» не доказывает ничего: три из пяти дефектов на сегодняшнем дне не воспроизводятся вовсе.

Что сделать, по шагам

Порядок такой, чтобы сначала закрыть то, что портит данные молча, а потом то, что видно глазами.

  1. Найдите все места, где ключ хранится отдельно от своих данных. Признак — поле типа Boolean или «текущий X» на экране, у которого меняется цель наблюдения.
  2. Замените каждый такой флаг на «ключ или null»: failedDate: LocalDate? вместо failed: Boolean.
  3. Склейте данные с ключом через flatMapLatest { key -> observe(key).map { key to it } } — так, чтобы пара физически не могла разъехаться.
  4. Проверьте правило срока хранения на трёх положениях: сегодня, день в будущем, день старше окна. Граница — min(today − N, requested).
  5. Убедитесь, что правило существует в одном экземпляре. Две копии — гарантия, что вторую забудут.
  6. Перенесите запрос обновления из init в onStart потока состояния и добавьте защиту от повторного запроса поверх идущего.
  7. Отмените летящий запрос при смене ключа и снимайте прежний отказ при начале новой попытки.
  8. Переведите тесты потоков на фейки с задержкой. Добавьте тест «экран в покое N секунд — ноль запросов» на каждый экран, который сам инициирует запросы.
  9. Проверяйте вручную три дня, а не один: сегодня, будущий, старше окна.

Шаг 8 стоит предпоследним, но по отдаче он первый: без него остальные восемь придётся находить глазами каждый раз заново.

Итог

Кэш с ключом на параметр стоит проектировать как множество потоков с самого начала, а не как один поток с дополнительным аргументом. Разница чисто умозрительная ровно до первого дефекта, а дальше она определяет, сколько мест придётся чинить: при правильной постановке ключ и данные склеены в одной точке, при неправильной — разъезжаются в пяти.

Практический вывод, который переживёт конкретные версии библиотек: самое опасное изменение в этой истории было не багом, а исправлением. Оно устраняло реальную найденную проблему, проходило все проверки и тихо ломало то, что при ручном тесте никто не открывает. Правка, закрывающая дефект в правиле с двумя параметрами, обязана проверяться по обоим — иначе вы просто меняете один сломанный край на другой.

И про зелёные тесты. Набор из трёхсот тестов — это утверждение «известные поломки не вернулись», и оно ценно ровно настолько, насколько полон список известных. Отсутствие красного не означает отсутствия дефекта; оно означает, что никто ещё не написал тест, который бы их поймал.

Первоисточники: HttpServiceMethod.java в square/retrofit, KotlinExtensions.kt, flatMapLatest, SharingStarted.WhileSubscribed, stateIn, advanceTimeBy, ViewModel и его область жизни, StateFlow и SharedFlow, ошибки валидации Pydantic, обработка ошибок в FastAPI.

#Android #Kotlin #Compose #кэш #тестирование

<< Previous Post

|

Next Post >>