Background Job: project-aggregates-recalc (пересчёт денормализованных агрегатов)
Пересчёт денормализованных счётчиков и ценовых границ иерархии chessboard из единственного source of
truth — chessboard.property-objects (и chessboard.layouts для уровня группы). Поля-агрегаты — «дома» в
project.md, building.md, layout.md,
layout-group.md; здесь описан только job их обновления (дельта, не переописание
полей).
Назначение
Пересчёт денормализованных агрегатов проекта после изменений цен/статусов объектов недвижимости и структуры
планировок. Держит согласованными: Project.Price (границы цен свободных объектов недвижимости),
Project.PropertyObjectsTotalCount/PropertyObjectsFreeCount; Building.PropertyObjectsTotalCount/PropertyObjectsFreeCount;
Layout.PropertyObjectsCount; LayoutGroup.LayoutsCount/Area. Поля питают ленту/карточку catalog (фильтр
«ценовой диапазон», «кол-во объектов») и измерения dev-stats; пересчёт идемпотентен и восстановим —
значение есть чистая функция состояния propertyObjects/layouts, повторный прогон даёт тот же результат.
Триггер — доменное событие ProjectAggregatesRecalcRequestedEvent c
debounce-коалесингом 30 с по ProjectId; конкурентность — построчная блокировка PostgreSQL без Redis
(ADR-0056). После пересчёта snapshot-события переопубликуются для потребителей.
| Metadata | Значение |
|---|---|
| Owning team | PIN Platform Core |
| Статус документа | draft |
| Область действия | сервис chessboard, проект Pin.Chessboard.Background (jobs), таблица chessboard.project_recalc_requests |
| Зависимости | PostgreSQL (chessboard.property-objects/layouts, целевые агрегаты, очередь пересчёта, очередь исходящих событий), Kafka (публикация событий); upstream-раисеры — мутации объектов недвижимости/планировок, consumer crm-property-object-status, talent-chessboard-sync, job property-object-hold-expiration |
| Assumptions / ограничения | debounce/очередь — на PostgreSQL (ADR-0056, без Redis; claim-lease при кратном росте нагрузки — вне scope); Building.PropertyObjects*Count — вне payload building-события (дом building-upserted.md): job обновляет колонки, но событие на чистый пересчёт счётчиков не шлёт; пороги (debounce 30 с, cap 5 мин, тик 5 с, batch 50, poison 5) — конфигурируемые |
| Change context | greenfield (объявлен в ../README.md «Background», отдельного файла job не было) |
Классификация
| Поле | Значение |
|---|---|
| Сервис | chessboard |
| Тип job | Queue worker (слив debounce-очереди пересчёта), event-triggered |
| Trigger | доменное событие ProjectAggregatesRecalcRequestedEvent (debounce 30 с, коалесинг по ProjectId) + внутренний тик воркера 5 с на слив созревших строк |
| Критичность | medium (отказ → устаревшие счётчики/цены в ленте; продажи, статусы и цены объектов недвижимости не блокируются) |
| Actor / scope | system, cross-tenant по построению (обходит все проекты, scope-фильтры отключены); TenantId каждого проекта берётся из строки очереди для partition key событий |
Расписание И Locking
| Аспект | Правило |
|---|---|
| Schedule | периодический воркер, тик 5 с (UTC); обрабатываются строки project_recalc_requests c DueAt <= now(). Сам пересчёт — событийный (debounce 30 с) |
| Distributed lock | Без Redis (ADR-0056): конкурентность — построчная блокировка с пропуском занятых (FOR UPDATE SKIP LOCKED) по строке очереди (одна строка = один проект = единица коалесинга). N инстансов воркера безопасно делят очередь; занятые строки пропускаются без ожидания |
| Batch size | 50 строк за тик, сортировка по DueAt asc; каждый проект — своя транзакция |
| Max duration | пересчёт одного проекта p99 < 2 с (индексные агрегации); тик < 30 с |
| Cancellation | при остановке воркера текущий проект коммитится или откатывается целиком, недообработанные строки остаются в очереди |
Входная Модель ProjectRecalcRequest
Job-owned операционная строка очереди (не доменный агрегат: доменных событий/инвариантного поведения нет,
tenant-фильтры не применяются — system-scope). Таблица chessboard.project_recalc_requests.
| Поле | Тип | Обязательность | Ограничения (PK/index) | Источник | Описание |
|---|---|---|---|---|---|
ProjectId | Guid | Да | PK (естественный ключ = коалесинг: одна строка на проект) | enqueue-handler | Проект для пересчёта; логическая ссылка на chessboard.projects.id. |
TenantId | Guid | Да | — | enqueue-handler | = DeveloperId проекта; partition key re-публикуемых событий. |
DueAt | DateTimeOffset | Да | index ix_project_recalc_due btree(due_at) — выборка «созревших» | enqueue-handler | Дедлайн debounce: строка созревает в DueAt. |
FirstRequestedAt | DateTimeOffset | Да | — | enqueue-handler | Якорь starvation-cap (страховка от вечного откладывания при непрерывных правках). |
RequestedCount | int | Да | >= 1, default 1 | enqueue-handler | Сколько событий сколлапсировано с создания строки (метрика коалесинга). |
LastReason | RecalcTriggerReason | Да | .HasConversion<string>(), varchar(32) | enqueue-handler | Причина последнего запроса (enum-string): PropertyObjectStatusChanged / PropertyObjectPriceChanged / PropertyObjectUpserted / LayoutChanged / TalentSync / Crud. |
AttemptCount | int | Да | >= 0, default 0 | drain-handler | Число неуспешных прогонов (poison-контроль). |
LastError | string? | Нет | varchar(1024) | drain-handler | Усечённый текст последней инфраструктурной ошибки. |
CreatedAt / UpdatedAt | DateTimeOffset | Да | — | app | Тайминги строки. |
Постановка в очередь (debounce, trailing). Событие ProjectAggregatesRecalcRequestedEvent(ProjectId, TenantId, Reason) поднимается агрегатами (дельта — «Раисеры» ниже) при их изменении и в том же согласованном
изменении добавляет/обновляет строку очереди по ProjectId (триггер не теряется): новая строка →
DueAt = now + 30s, FirstRequestedAt = now; существующая → DueAt = least(now + 30s, FirstRequestedAt + 5 min)
(30 с тишины, но ≤ 5 мин от первого запроса), RequestedCount += 1.
| Раисер (source of truth события) | Дом | Когда |
|---|---|---|
PropertyObjectEntity | property-object.md | смена Status (PropertyObjectStatusChangedEvent), Price, паспортных полей, LayoutId, create/soft-delete |
LayoutEntity | layout.md | create/archive, смена LayoutGroupId, TotalArea (влияет на LayoutGroup.Area*/LayoutsCount) |
BuildingEntity | building.md | create/archive (инициализация счётчиков нового ряда) |
| синк / CRM | consumers | talent-chessboard-sync и crm-property-object-status при изменении статусов/цен объектов недвижимости |
Предусловия И Постусловия
Предусловия (до слива строки).
- Раисер поднял
ProjectAggregatesRecalcRequestedEventи в том же согласованном изменении, что и мутация объекта недвижимости/планировки, добавил/обновил строкуproject_recalc_requests(триггер не теряется). chessboard.property-objects/chessboard.layouts— актуальный source of truth: мутации, породившие запрос, уже закоммичены (значения-агрегаты суть чистая функция их текущего состояния).- Строка очереди созрела (
DueAt <= now()), либо сработал starvation-cap (FirstRequestedAt + 5 min).
Постусловия (после успешного слива строки проекта).
- Денорм-агрегаты проекта, зданий, планировок и групп планировок равны
count(*)/min/maxпо текущему состояниюpropertyObjects/layouts(идемпотентность by construction: повтор даёт те же значения). - Строка
project_recalc_requestsудалена — debounce-окно закрыто; запрос, пришедший во время пересчёта, вставит новую строку. - Переопубликованы snapshot-события только изменившихся рядов:
Project/Layout/LayoutGroupUpsertedKafkaEvent (key =Id, монотонныйVersion);Building— не публикуется (счётчики вне payload). Versionзатронутых рядов инкрементирован,UpdatedAt = now().- При архивном/удалённом проекте — строка удалена, агрегаты и события не трогаются (no-op success).
Алгоритм
Описан цикл обработки одной строки очереди (drain); постановка в очередь (debounce-upsert) — выше.
-
Контекст и доступ. Выполняет system-воркер (scope-фильтры отключены, cross-tenant). Воркер выбирает батч созревших строк очереди (
DueAt <= now(), сортировкаDueAt asc, до 50 строк) под построчной блокировкой с пропуском занятых: параллельный воркер пропустит занятую строку (без двойного пересчёта), а параллельная постановка в очередь дождётся коммита и после удаления строки вставит новую (не теряется). -
Проверка. По id строки очереди читается снапшот проекта: если проекта нет либо он архивирован (
Status = Archived,DeletedAt != null) — строка очереди удаляется, обработка завершается no-op (info-лог): архивный проект агрегатов не несёт. Прочие статусы допустимы (Draft/Published/Hidden); tenant не фильтруется (system-scope). -
Что читаем. Агрегаты не загружаются — пересчёт выполняется одним набором агрегирующих запросов. Для решения о переопубликации читаются лёгкие проекции текущих (до пересчёта) денорм-счётчиков проекта, планировок и групп планировок — для сравнения на шаге 5.
-
Пересчёт (source of truth). Каждое целевое поле — чистая функция состояния
propertyObjects/layouts:Целевое поле Формула (по ProjectId = @p)Источник Project.Price(Min/Max)min/max(price)поfreePropertyObjectchessboard.property-objectsProject.PropertyObjectsFreeCountcount(*)поfreePropertyObjectchessboard.property-objectsProject.PropertyObjectsTotalCountcount(*)поactivePropertyObjectchessboard.property-objectsBuilding.PropertyObjectsFreeCount/PropertyObjectsTotalCountте же предикаты, group by building_idchessboard.property-objectsLayout.PropertyObjectsCountcount(*)поactivePropertyObject,group by layout_idchessboard.property-objectsLayoutGroup.LayoutsCountcount(*)живых планировок группыchessboard.layoutsLayoutGroup.Area(Min/Max)min/max(total_area)живых планировок группыchessboard.layoutsгде
freePropertyObject = (deleted_at is null AND status = 'Free'),activePropertyObject = (deleted_at is null AND status <> 'Hidden')(Hiddenи soft-deleted не учитываются); живая планировка —deleted_at is null. Пустой набор: цены/площади →NULL, счётчики →0; границы (price_max >= price_min,area_max >= area_min) — по построению (min <= max).Version += 1,UpdatedAt = now(). -
Что меняется. Счётчики и ценовые/площадные границы проекта, зданий, планировок и групп планировок обновляются одним набором изменений (без загрузки агрегатов):
projects(цены, счётчики, версия),buildings(счётчики по своим объектам),layouts(счётчик объектов),layout_groups(число планировок, границы площадей). Меняются только скалярные счётчики существующих строк. Гонка с HTTP-правкой того же ряда сериализуется на построчной блокировке; job пишет иные колонки, чем API, — конфликтов оптимистической блокировки нет. -
Какие события фиксируются. После пересчёта переопубликуются snapshot-события изменённых рядов — только там, где денорм-колонки изменились (сравнение с шагом 3), топики
pin.chessboard.<entity>-upserted.v1(ContractVersion = 1, key =Id):ProjectUpsertedKafkaEvent— при измененииPrice/PropertyObjectsTotalCount/PropertyObjectsFreeCount(дом project-upserted.md);LayoutUpsertedKafkaEvent— при измененииPropertyObjectsCount(дом layout-upserted.md);LayoutGroupUpsertedKafkaEvent— при измененииLayoutsCount/Area(дом layout-group-upserted.md).
BuildingUpsertedKafkaEventне публикуется при чистом пересчёте счётчиков:Building.PropertyObjects*Count— вне payload building-события (дом building-upserted.md, «Когда публикуется»); building-счётчики потребители (catalog/dev-stats) выводят из property-object-потока (PropertyObjectUpsertedKafkaEvent/PropertyObjectStatusChangedKafkaEvent). Колонки здания в БД (шаг 5) всё равно обновляются — для API-чтений/шахматки. КлючId+ монотонныйVersionделают повторную публикацию безопасной (потребители — upsert-by-id). -
Согласованность. Обновления счётчиков, записи исходящих событий и удаление строки очереди применяются в одном согласованном изменении. Удаление строки завершает debounce-окно: пришедший во время пересчёта запрос вставит новую строку. Внешних вызовов нет; события уходят потребителям после коммита (at-least-once).
-
Результат. Успех → строка удалена, метрики записаны. Инфраструктурная ошибка → откат всей транзакции (строка остаётся,
DueAtне сдвинут), затемAttemptCount += 1/LastError; повтор на следующем тике.
Граница Транзакции
| Элемент | Правило |
|---|---|
| Unit of work | Per project (одна строка очереди = одна транзакция) |
| Сохраняемое состояние | обновление счётчиков projects/buildings/layouts/layout_groups + исходящие события (project/layout/layout-group) + удаление строки очереди |
| External calls | Нет (никаких HTTP/провайдеров); события — только транзакционно, после коммита (at-least-once) |
| Частичная ошибка | Continue-по-батчу: сбой одного проекта откатывает только его транзакцию; строка остаётся, остальные проекты обрабатываются |
Идемпотентность И Retry
| Сценарий | Правило |
|---|---|
| Job restart | Строки project_recalc_requests переживают рестарт; воркер продолжает слив по DueAt |
| Duplicate item | Невозможен: PK = ProjectId (коалесинг). Параллельный воркер отсекается FOR UPDATE SKIP LOCKED |
| Повторный прогон | Пересчёт — чистая функция propertyObjects/layouts → те же значения (идемпотентно by construction); повторная публикация снапшота безопасна (key=Id, монотонный Version, потребители upsert-by-id) |
| Повторяемая ошибка | Откат транзакции, AttemptCount += 1, LastError; retry на следующем тике (5 с) |
| Poison item | AttemptCount >= 5 → алерт CHESSBOARD_RECALC_POISON, строка остаётся для ручного разбора (runbook); слив прочих проектов не блокируется |
| Starvation | Непрерывные правки не откладывают пересчёт бесконечно: DueAt <= FirstRequestedAt + 5 min (cap) → форс-прогон |
Наблюдаемость
| Тип | Правило |
|---|---|
| Logs | recalc_batch_started (size), project_recalculated (ProjectId, длительность, republished counts), project_skipped_archived, project_recalc_failed (ProjectId, AttemptCount, error) |
| Metrics | chessboard_recalc_queue_depth (gauge); chessboard_recalc_due_backlog (строк c DueAt < now); chessboard_recalc_process_duration_seconds (histogram); chessboard_recalc_runs_total{result}; chessboard_recalc_coalesced_events_total (Σ RequestedCount); chessboard_recalc_republished_total{entity}; лаг доставки событий pin.chessboard.*-upserted.v1 |
| Traces | correlationId тика + per-project span; атрибуты ProjectId, TenantId, LastReason |
| Dashboard | Grafana chessboard-overview (панель recalc: depth, backlog, p99, republished by entity) |
| Alerts | CHESSBOARD_RECALC_BACKLOG (due_backlog > порога > 5 мин — воркер завис/отстаёт); process p99 > SLO; рост runs_total{result=error}; CHESSBOARD_RECALC_POISON; воркер не тикал > 2 интервалов (нет прироста runs_total — liveness) |
SLO Цикла
| Показатель | Цель | Обоснование |
|---|---|---|
| E2E-лаг «событие → обновлённые счётчики/цены» | p99 < ~40 с (debounce 30 с + обработка < 2 с + тик 5 с + запас) | eventually consistent денормализация; ленте/карточке catalog секундная свежесть не требуется |
| Слив backlog | due_backlog → 0 в пределах одного-двух тиков после DueAt | Take=50/тик × N инстансов покрывает объём проектов (тысячи, ADR-0056) |
| Идемпотентность | 0 расхождений денормализации со count(*)/min/max по propertyObjects/layouts; повторный прогон не меняет значений | чистая функция состояния + построчная блокировка с пропуском занятых |
| Доставка событий | at-least-once на каждый изменённый ряд; дедуп у потребителей по Id+Version | транзакционная доставка + upsert-by-id у consumer-ов |
Связанный Runbook
| Сценарий | Runbook |
|---|---|
Растёт due_backlog / завис воркер | ../runbooks/project-aggregates-recalc.md (будущий путь): lock-contention project_recalc_requests, число инстансов воркера, при необходимости рестарт |
Poison-проект (AttemptCount >= 5) | разобрать LastError; проверить целостность propertyObjects/layouts проекта; после фикса — сбросить AttemptCount, DueAt = now() |
| Ручной форс-пересчёт | admin-операция: Upsert project_recalc_requests(ProjectId, DueAt=now()) — пересинк агрегатов проекта |
| Расхождение денормализации | сверка счётчиков с count(*)/min/max по propertyObjects/layouts; форс-пересчёт устраняет дрейф |
Лаг доставки событий pin.chessboard.*-upserted.v1 | ../runbooks/outbox-backlog.md (будущий путь) |
Тесты
| Тест | Ожидание |
|---|---|
| Empty batch | нет созревших строк → тик no-op success |
| Проект без свободных объектов недвижимости | Price = NULL (обе границы), PropertyObjectsFreeCount = 0, PropertyObjectsTotalCount по activePropertyObject; ProjectUpsertedKafkaEvent опубликован |
| Проект без объектов недвижимости/планировок | все счётчики 0, цены/площади NULL |
| Coalescing | burst из N событий одного проекта → 1 строка, 1 прогон, RequestedCount = N |
| Starvation cap | непрерывные правки > 5 мин → форс-прогон на границе cap |
| Duplicate run | повторный пересчёт того же состояния → идентичные значения; снапшот с новым Version только при diff |
| Building counters — без события | пересчёт Building.PropertyObjects*Count обновляет колонки, но BuildingUpsertedKafkaEvent не публикуется (вне payload) |
| Partial failure | сбой проекта откатывает его транзакцию, строка остаётся, AttemptCount += 1; прочие проекты обработаны |
| Concurrency | два воркера на одной строке → SKIP LOCKED, ровно один пересчёт |
| Deleted/archived project | строка удалена, событий нет, no-op success |
| Cancellation | отмена между проектами — graceful; текущий проект коммитится/откатывается целиком |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (4): Entity / Aggregate: BuildingEntity, Entity / Aggregate: LayoutEntity, Entity / Aggregate: LayoutGroupEntity, Entity / Aggregate: ProjectEntity
- API (2): POST /api/v1/developers/property-objects/{propertyObjectId}/price — Изменение цены объекта недвижимости, POST /api/v1/developers/property-objects/{propertyObjectId}/unhide — Возврат объекта недвижимости в продажу
- Use Cases (3): UC-CHS-001. Создание и публикация проекта застройщиком, UC-CHS-002. Добавление здания (литера/корпуса) и объектов проекта застройщиком, UC-CHS-003. Изменение цены объекта застройщиком (события в аналитику)
- Фоновые задачи (1): Background Job: property-object-hold-expiration (страховка от потерянных release-событий CRM)
Связанные Документы
- Дома агрегатов: project.md, building.md, layout.md, layout-group.md, property-object.md (источник события/значений); дома событий: project-upserted.md, building-upserted.md, layout-upserted.md, layout-group-upserted.md.
- Сосед: property-object-hold-expiration.md; ../README.md — карта сервиса.
- ADR: 0056 simplicity-first (денормализация вместо OLAP, queue/polling без Redis, построчная блокировка с пропуском занятых), 0052 tenancy (System cross-tenant scope).