«Добавь ленту активности в проект» и «нужен журнал аудита» звучат как одна задача. Я так и подумал, написал одну таблицу и через два месяца обнаружил, что она плохо решает обе.
Разница выясняется на конкретном вопросе: что записать, когда пользователь поменял приоритет задачи с высокого на средний.
Лента и аудит — разные вопросы
Лента активности отвечает на вопрос «что произошло в проекте». Её читает участник команды, ей нужна человеческая формулировка, аватарки, группировка по дням. Записи старше месяца никто не смотрит. Событие «Алексей поменял приоритет» — идеальная запись для ленты.
Журнал аудита отвечает на вопрос «кто и когда это сделал». Его читают в конфликтной ситуации, через полгода после события. Ему нужны точные значения до и после, IP-адрес, идентификатор сессии. Формулировка не нужна вовсе, нужны данные.
Держать их в одной таблице я всё-таки решил, и вот почему. Событие одно и то же. Разделив хранилища, я получил бы двойную запись на каждое действие и постоянный риск, что одно из них забудут. Разница — в объёме контекста, а не в природе события.
Схема записи
Фрагмент сущности с полями и индексами, важными для журнала:
#[ORM\Entity]
#[ORM\Table(name: 'activity_log')]
#[ORM\Index(columns: ['organization_id', 'created_at', 'id'])]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'created_at'])]
class ActivityRecord
{
#[ORM\Column(type: 'bigint')]
#[ORM\Id, ORM\GeneratedValue]
private int $id;
#[ORM\Column(type: UuidType::NAME)]
private Uuid $organizationId;
#[ORM\Column(length: 40)]
private string $entityType;
#[ORM\Column(type: UuidType::NAME)]
private Uuid $entityId;
#[ORM\Column(length: 60)]
private string $eventType;
#[ORM\Column(type: UuidType::NAME, nullable: true)]
private ?Uuid $actorMemberId;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, options: ['jsonb' => true])]
private array $context = [];
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
}Первичный ключ здесь числовой, и это единственная таблица в проекте, где я отступил от UUID. Записей много, пишутся они пачками, наружу идентификатор не уходит. Сам ключ занимает восемь байт вместо шестнадцати, но реальную экономию надо считать по таблице и всем индексам, а не выводить только из размера типа.
actorMemberId допускает null, потому что действие может совершить система: закрытие спринта по расписанию, импорт, миграция данных. Пустой актор — это «система», и в ленте так и пишется.
Тип jsonb, а не json: по контексту иногда нужно искать, а jsonb позволяет добавить GIN-индекс или индекс на конкретное выражение. В показанной схеме такого индекса пока нет: добавлять его заранее для всех ключей было бы дороже редких поисков.
Явная запись против подписки на Doctrine
Самый спорный выбор во всей конструкции.
Подписка на события Doctrine выглядит идеально: один слушатель на onFlush, и всё пишется само. Ни один разработчик не забудет, покрытие стопроцентное.
Я попробовал и отказался через неделю. Три причины.
Слушатель не знает намерения. Doctrine видит, что у сущности поменялось поле status с open на closed. Он не видит, было ли это закрытие задачи пользователем, автоматическое закрытие при завершении спринта или откат неверного действия. В ленте это три разных события с разными формулировками.
Шум. Пересчёт денормализованного счётчика комментариев — изменение сущности. Обновление времени последней активности — изменение. За сутки лента наполовину состояла из событий, которые никому не интересны. Фильтрация превращалась в список полей-исключений, который надо поддерживать.
Сложность отладки. Когда запись появляется сама, найти, откуда она взялась, тяжело. Стек вызовов ведёт в flush().
Явный вызов выглядит скучно и работает:
$this->activity->record(
entityType: 'issue',
entityId: $issue->getId(),
eventType: 'issue.priority_changed',
context: [
'title' => $issue->getTitle(),
'from' => $old->value,
'to' => $new->value,
],
);Да, это лишняя строка в каждом сервисном методе. Да, её можно забыть. Забытая запись — это отсутствующая строка в ленте, что заметно на первом же тесте функции. Лишняя запись из автоматики — это мусор, который замечаешь через месяц.
Контекст, который читается через год
Главное правило контекста: запись должна быть понятна без обращения к другим таблицам.
Соблазн хранить только идентификаторы велик — они компактнее, и всегда можно подтянуть актуальные значения. Именно «актуальные» и ломает всё. Задачу переименовали, и запись годичной давности показывает новое название рядом со старым событием. Задачу удалили — запись показывает пустое место.
Поэтому в контекст кладутся значения на момент события:
{
"title": "Починить экспорт в CSV",
"from": "high",
"to": "medium",
"actor_name": "Алексей М.",
"project_key": "WM"
}Дублирование намеренное. Журнал — это история, а история не должна меняться задним числом.
Одного соглашения для этого мало. Роль приложения может только добавлять записи, но не обновлять их; очистка идёт отдельной ролью и только по политике хранения. Для чувствительных событий доступ к исходному контексту проверяется отдельно от доступа к обычной ленте.
Для аудиторской части в контекст добавляется технический блок: IP, идентификатор сессии, источник действия (интерфейс, API, консоль). Он пишется не для всех событий, а для тех, что помечены как чувствительные: изменение прав, удаление, экспорт данных.
Чтение ленты
Смещение с большими значениями работает плохо: OFFSET 5000 заставляет базу прочитать и выбросить пять тысяч строк. Для бесконечной прокрутки это лечится курсором.
/**
* @param null|array{created_at: \DateTimeImmutable, id: int} $cursor
*/
public function feed(Uuid $orgId, ?array $cursor, int $limit = 30): array
{
$qb = $this->createQueryBuilder('a')
->where('a.organizationId = :org')->setParameter('org', $orgId)
->orderBy('a.createdAt', 'DESC')
->addOrderBy('a.id', 'DESC')
->setMaxResults($limit);
if ($cursor !== null) {
$qb->andWhere(
'(a.createdAt < :createdAt OR (a.createdAt = :createdAt AND a.id < :id))'
)
->setParameter('createdAt', $cursor['created_at'])
->setParameter('id', $cursor['id']);
}
return $qb->getQuery()->getResult();
}Курсор состоит из времени и числового идентификатора. Один идентификатор не равен времени: параллельные транзакции могут получить номера в одном порядке, а зафиксироваться в другом. id здесь разрешает совпадения времени и делает порядок стабильным. С UUID v7 можно построить похожий курсор, но дополнительный уникальный компонент всё равно нужен.
Индекс по организации, дате и идентификатору обслуживает общую ленту, индекс по типу и идентификатору сущности — историю конкретной задачи. Оба составные, и порядок колонок в них важен: сначала то, по чему фильтруем, потом то, по чему сортируем.
Рост таблицы
Полтора миллиона записей за год на одном активном клиенте. Это не много, но растёт линейно и не остановится.
Я рассматривал секционирование по месяцам. Красивое решение: старые секции отцепляются, архивируются, отбрасываются. Отложил, потому что при полутора миллионах строк нет проблемы, которую оно решает, а сложность появляется сразу.
Что сделал вместо этого — разделил срок хранения по типам событий. Шумные события (просмотры, изменения позиции на доске) чистятся через 90 дней. Значимые (создание, удаление, изменение прав, экспорт) живут три года.
private const RETENTION = [
'issue.viewed' => 'P30D',
'issue.position_changed' => 'P90D',
'member.role_changed' => 'P3Y',
'organization.data_exported' => 'P3Y',
];Правило по умолчанию — год. Команда чистки гоняется еженедельно и удаляет партиями по десять тысяч, чтобы не держать долгую транзакцию.
Что бы сделал иначе
Отделил бы чувствительные события в отдельную таблицу с самого начала. Не из-за структуры, а из-за прав доступа: сейчас лента и аудит читаются из одной таблицы, и разграничение «участник видит ленту, администратор видит аудит» живёт в условиях запроса. Это работает, но это ровно то место, где однажды кто-то забудет условие.
И завёл бы реестр типов событий как энум с самого начала, а не строкой. Сейчас типов около шестидесяти, они разбросаны по вызовам, и получить полный список можно только поиском по коду.