Зачем: хеш байтов слепка в описи не определён без порядка записей — шов резолюции #72 к #81, который #81 закрыл только для ключей. Что: одна строка в §8 — порядок строк равен порядку рождения заказов (возрастание order_id); детерминизм даёт его без новой механики. Проверка: чтение; вычитание — #84. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
51 KiB
Заказы бэкенда (этап 3): слепок окна, судьба заказа, мост к склейке
Статус: черновик — впереди проход на вычитание (#84) и приёмка владельцем (#88). Дата: 2026-08-16. Мандат — карта #69 (этап 3, родитель #5); собрано тикетом #87. Развилки: приём (#70), генератор слепков (#71), расхождения и опоздания (#72), мост к склейке (#73), место в стартовом мире (#74), форма записи на проводе (#81), брак и версии в ODS (#80). Источники: мастер-спека «Боевой реализм стенда (v2)» — разделы 2–5 и 7; спека генератора; ADR 0008; ADR 0010; спецификация приёма заказов; исследование формата слепка.
Зачем
Мастер-спека решила, что у стенда есть второй источник: раз в модельный день бэкенд магазина выгружает полный слепок заказов окна изменяемости, и деньги в витринах считаются по нему, а не по трекеру. Как этот источник устроен — кто порождает заказ, откуда берётся его судьба, как слепок доезжает до ODS и что из этого видит менти, — она отложила. Карта #69 прошла семь развилок; эта спека собирает их решения в одну картину. По ней этап 3 режется на тикеты (#89) — после вычитания (#84) и приёмки владельцем (#88).
Здесь не переоткрывается решённое мастер-спекой: поля слепка, окно K = 7 как константа мира, три статуса, приоритет классов расхождений, правило «поведение и атрибуцию считаем по трекеру, деньги — по бэкенду». Рамка, перед которой отвечает каждое решение, — «Чем меряется генератор» в спеке генератора: конструкция внутри оправдана только наблюдаемым эффектом на выходе.
Целевая картина одним взглядом
- Заказ — проекция, не порождение. Торговая половина дня-функции уже посчитала корзину, цены, купон и номер заказа; заказная половина навешивает судьбу и собирает слепок. Ни одного нового броска в торговом подпотоке.
- Слепок дня D — чистая функция (зерно, D): состояние заказов, рождённых в дни D−6…D, снятое на границе суток D|D+1. Отправляет его следующий прогон — ночная выгрузка бэкенда за вчера.
- Судьба заказа решается при рождении и обязана уложиться в окно K либо не
случиться вовсе. На выходе из окна заказ либо
paid, либоcancelled. - Расхождения и опоздания — часть мира, а не грязь: два подпотока — заказная и событийная стороны; броски независимы, пересечения выходят арифметикой, приоритет классов работает по-настоящему.
- Мост к склейке: план состава владеет человеком; его непрозрачный
person_idзаказ показывает какuser_id, кликстрим остаётся анонимным. - На проводе — один JSON-документ на заказ: деньги строками с двумя
знаками, времена RFC 3339 в UTC с миллисекундами,
itemsобычным массивом. - Приём — пакетный забор: одно прямое чтение Kafka в STG, два
INSERT SELECTв типизированный ODS и таблицу ошибок;ods.order_snapshotпринимает версии заказа наReplacingMergeTree(updated_at). - Стартовый мир отправляет семь слепков (дни 0…6): у последнего прожитого дня клики есть, а заказов нет, и график выручки дозаполняется по ходу мира.
1. Откуда берётся заказ
Резолюция развилки «Генератор слепков: где живёт и чем связан с событиями».
Заказ и событие purchase — не два порождения, а две проекции одного факта
мира. Корзина, цены, купон и номер заказа посчитаны торговой половиной
дня-функции; заказная половина берёт заказы дня готовой структурой — вторым
выходом commerce, — навешивает на них жизнь заказа и собирает слепок. В
подпоток COMMERCE не добавляется ни одного нового броска: мир не сдвигается,
согласованность двух источников не удерживается, а получается по построению.
Следствия, которые уже решены соседями и здесь только связываются:
- у всякого заказа изначально ровно одно событие
purchase; заказ без события в трекере — не отдельная порода, а класс B, и делает его событийная сторона выбрасыванием события после присвоения номера (раздел 4); - номер заказа общий у обеих проекций:
order_id= клиентскийpurchaseID, читаемый номер «день и порядковый номер покупки» (спека генератора, раздел 9); нумеруются все покупки, дошедшие до потока дня, — до всяких потерь; - скидка заказа выводится из промокода события по таблице «код → скидка» — числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
В модели строка заказа в базе источника создаётся синхронно с покупкой, поэтому
день рождения заказа и день создания строки совпадают. Смысл полей от этого не
меняется: created_at и updated_at — аудит строки по часам базы источника,
бизнес-время покупки живёт в purchase.UTCEventTime (раздел 6).
Отклонено: выводить заказ разбором собственного вывода (purchaseID, сырой
ecommerce) — бэкенд стал бы читателем трекера ровно там, где стенд учит, что
это разные источники; независимая модель бэкенда (заказ первичен, событие —
эхо) — кто купил, решает воронка, а воронка — это трафик, то есть опрокидывание
всего генератора; слепок собирает SQL стенда из событий — второй источник
исчезает вместе с уроком «две версии правды».
2. Слепок и его доставка
Та же резолюция #71.
Запуск. Третья команда того же пакета — snapshot --day D [--days N], свой
приёмник, топик orders. Два источника — два запуска: трекер и бэкенд видны
глазами как два производителя, каждый со своим топиком. Один прогон с двумя
выходами отклонён: экономии он не даёт (со сдвигом отправки окно слепка и
сыгранный день не пересекаются вовсе), а правило «приёмник выбирается тем, что
для него назвали» ломает. Отклонены также: отдельный пакет и образ —
библиотека на двоих ради одной команды; генератор пишет слепок файлом, в
топик льёт даг — второй путь доставки и второй сериализатор; слепок едет
топиком hits — убивает два режима приёма.
Сборка окна. Слепок дня D несёт заказы, рождённые в дни D−6…D, и собирается переигровкой этих семи дней: заказы дня — производная всей воронки дня, дешёвого пути к ним нет. Цена — семь проигрышей дня (~14 с) на слепок; подряд идущие дни одного прогона считаются по разу, у начала оси окно усекается само. Отклонено: кэш заказов на томе — состояние между прогонами; окно держит хранилище — топик перестаёт нести слепок; K = 1 — это страховочный срез 1 мастер-спеки, он в резерве.
Отправка. Слепок снимается на границе суток, а отправляется следующим прогоном: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка бэкенда за вчера, как в бою. Содержимое слепка — чистая функция (зерно, D), от момента отправки не зависит. Следствия:
- живой день перестаёт быть особым случаем: своего дага у него нет, слепок живого дня отправит следующий прогон;
- пропущенный день лечится окном: слепок переснимается и даёт те же байты, отдельного механизма самовосстановления нет;
- покупки текущего дня в сверке всегда
awaiting_order— сюжет «вчера не сходилось, сегодня сошлось», ради которого мастер-спека этот класс завела; - цена — один лишний проигрыш дня на прогон (окно и сыгранный день не пересекаются, проигрышей всегда восемь).
На старте оси дня −1 нет, поэтому прогон дня 0 не отправляет ничего; первый слепок — дня 0 — уезжает прогоном дня 1 (исследование формата).
Случайность. Судьбу заказов бросает свой подпоток, ветвящийся по дню
рождения заказа: слепок несёт семь дней рождения сразу, и судьбу каждого заказа
обязан читать из его собственного дня. Вся судьба решается при рождении,
поэтому слепок любого дня — чтение готовой судьбы, а не накопление состояния.
Отклонено: дописывать броски в конец COMMERCE — правка заказа и правка
торгового поведения стали бы одним рычагом; бросать состояние в подпотоке дня
слепка — траектория заказа зависела бы от того, какие слепки снимали.
3. Судьба заказа
Резолюция развилки «Расхождения A–D и опоздания: механика, доли и что обещано», ревизия после двух холодных ревью.
Рамка. Расхождение — не грязь и не шум, а часть мира: судьба заказа, решённая при его рождении и уложенная в окно K целиком.
Два подпотока дня. Заказная сторона — судьба заказа: исход, моменты,
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и
дубль. Довод за разделение — различимость по описи: правка заказной механики не
двигает хеши событий, правка событийной не двигает байты слепка, и по
покрасневшим хешам видно, какую сторону трогали. Прежние имена DISCREPANCIES
и LATECOMERS решения не переживают — они названы по классам витрины, а
компонент называет часть мира; новые стороны занимают те же позиции, имена — при
реализации. Отклонено: один компонент на всю судьбу — правка событийной
механики молча меняла бы байты слепка; компонент на класс — пять имён под
ручки калибровки, которые крутятся разом.
Правило формы, без которого разделение не работает: броски заказной стороны
делаются на полную длину дня, а не на отобранных заказах. Иначе длина броска
становится функцией доли, и правка одной доли перебрасывает весь подпоток после
себя (в генераторе такое место уже есть — _at_least_one; здесь оно не
повторяется). Изоляцию даёт форма броска, а не число подпотоков.
Путь по статусам. Три исхода, все внутри окна: оплачен; оплачен и
отменён; не оплачен и отменён. Инвариант на выходе из окна: заказ либо
paid, либо cancelled; created — только промежуточное состояние. За окном
будущего у заказа нет, а заказ, навсегда застрявший в created, — модельная
небрежность, которой в выгрузке живого магазина соответствия нет. Поэтому
нового значения mismatch_class не нужно: cancelled покрывает обе дороги
отмены (шестое значение занято awaiting_order).
Форма броска.
- Исход — таблица долей из трёх строк; доля неоплаченных пишется явной строкой, а не оставляется читателю складывать хвост в уме.
- Моменты — таблица целых весов «сколько часов от рождения — с каким весом», строки 0…143, плюс равномерная секунда внутри часа — чтобы разности времён аудита не давали точных равенств (урок правки #50).
- Моментов бросается всегда два, на полную длину дня; у одномоментных исходов второй выбрасывается. Где их два по существу, ранний считается оплатой — порядок выходит сортировкой, условной точки отсчёта не нужно.
143 часа — самый узкий край окна: у заказа, рождённого в конце суток, до последнего его слепка 144 часа. Таблица кончается там, где кончается окно у самого невезучего: вылезти нечему, сторожа не нужно. Цена — заказ, рождённый в начале суток, не использует почти сутки своего окна; в хвосте таблицы веса мизерные, в данных это не видно. Форма из #71 — «вес за краем окна означает „не оплачен никогда“» — этим отменена: такой заказ теперь отменяется, а край окна не выражается числом часов — иначе доля неоплаченных стала бы функцией часа покупки, и менти нашёл бы этот наклон первым же разрезом.
Доли. Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как она делится между двумя дорогами — строки таблицы исходов. Точные числа — калибровка этапа 7; проверок вида «отмен от 4 до 6 процентов» не заводим.
Что из двух дорог видно. В dds.order дороги неразличимы — там последняя
версия; различает их история версий: сырьё STG и физические версии
ods.order_snapshot до фоновых слияний, а в витринах — выручка дня, которая
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока
решает этап 4. Отклонено: отмена только после оплаты — неоплаченному некуда
деться, кроме как остаться брошенным; мгновенная отмена при рождении —
«дыхание» окна на отменах исчезает; отмен нет вовсе — страховочный срез 1,
он в резерве.
4. Классы расхождений и опоздание
Та же резолюция #72. Классы и ориентиры долей — мастер-спека, раздел 4; здесь — механика каждого.
Дельта суммы (C) — вычеркнутая позиция. Товара не оказалось в наличии,
позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а
items_total меньше на её стоимость. Момента у неё нет — заказ приезжает
урезанным во всех своих слепках: первый слепок снимается на границе суток,
когда склад заказ уже собрал. Заказ из одной позиции дельты не получает —
пустых заказов не бывает. Позиция выбирается равновероятно: корреляция со
спросом на доле 1–2% статистически ненаблюдаема — менти платил бы за неё
таблицей чисел мира, а увидеть не мог бы ничем. Доводы за вычёркивание:
остаток — сотни рублей, он торчит в витрине сверки сам; расхождение
объясняется сравнением позиций — разбором вложенного JSON и ARRAY JOIN,
ровно тем навыком, ради которого позиции разбираются; история рассказывается
словами без легенды про генератор. Отклонено: переоценка позиции и другое
количество — дельта в десятки рублей, её надо захотеть заметить; чистая
дельта без истории — тупик, объяснить нечем; врёт клиент, а не бэкенд —
заказ у нас проекция той же корзины.
Потеря события (B) — точечная. Уходит строка purchase, просмотр
/confirmation остаётся: события уезжают разными запросами, потерять один и
сохранить другой — обычное дело. Единственный вариант, при котором потеря
видна со стороны трекера: до подтверждения дошли сто, покупок девяносто семь.
Дубль события (D) — сюжетный. Обновление страницы шлёт и просмотр, и
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
сюжетном разрыв снова равен доле потерь. purchaseID, суммы и позиции у дубля
один в один — посчитал наивно, удвоил выручку. Дубль случается только там, где
до следующего визита куки остаётся запас сверх таймаута: иначе сборка сессий у
менти разошлась бы с VisitID — сломался бы эталон, ради которого VisitID
в потоке лежит. Задержка дубля — секунды-минуты, короче таймаута визита,
поэтому VisitID тот же. Полночь режет дубль парой — просмотр вместе с
покупкой, по тому же правилу, что у подтверждения с торговым хвостом. Обе
порчи случаются после присвоения номеров. Отклонено: обе точечные — дубль
затирает урок потери; обе сюжетные — потеря перестаёт быть видна со стороны
трекера.
Гарантия моста сильнее порчи. Назначенные планом покупки не теряются, и
назначенные планом заказы не опаздывают: dds.identity_map строится из моста
«purchase ↔ заказ», и выброшенное событие — как и заказ, не попавший ни в
один снятый слепок, — уносит куку из карты. Это был бы отказ лабы склейки, а
не расхождение в данных; менти различить не может. Гарантия плана старше
расхождения.
Опоздание — заказ прячется от ранних слепков. created_at не
подделывается — строка создана, когда заказ родился, — но в слепках дней
d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ
дожил: отменённый на второй день и опоздавший на третий приедет в первом же
своём слепке как cancelled — «выгрузка догоняет жизнь». Задержка — таблица
весов из трёх строк: 0 на подавляющем весе, 1 и 2 — это и есть «D+1/D+2»
мастер-спеки. Меряется она в днях снятия слепка, а не отправки: иначе сдвиг
отправки удвоился бы, и обещанные D+1/D+2 стали бы D+2/D+3. Дальше таблица не
идёт: заказ с δ = 6 приехал бы ровно
в одном слепке, и обещание «пропущенный день ничего не ломает» на нём
перестало бы быть верным; при δ ≤ 2 у всякого заказа слепков не меньше пяти.
Легенда: заказ ушёл в ручную обработку и попал в выгрузку позже. Следствие для
переливки не ново: у прошлых дней окна меняется и состав, а не только суммы —
«дыхание» статусов это уже требовало. Отклонено: сдвиг created_at — заказ
уехал бы в чужую партицию, и «выручка дня D» перестала бы отвечать покупкам
дня D — сломалась бы та самая сверка, ради которой всё строится.
Пересечения. Броски независимы, пересечения выходят арифметикой, приоритет мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль решается только у выживших покупок, а назначенное планом не теряется и не опаздывает — дельта и отмена ему разрешены, моста они не рвут. Следствие для калибровки: брошенная доля и наблюдаемая в сверке — разные числа (часть заказов забирают победители по приоритету, часть у класса C недоступна — однопозиционных заказов больше половины). Отклонено: один класс на заказ — приоритет в SQL стал бы мёртвой веткой, которую менти читает как живую.
5. Что хранит опись
Резолюции #72 и «Места заказов в стартовом мире».
Опись хранит только то, чего движение мира не меняет. Хеш кладём всегда, счётчик — только когда назван его читатель.
Хеш — растяжка: он не обещает, что числа те же вечно, он обещает, что их сдвиг
не пройдёт молча, и читает его git diff. Счётчик — опора: обещание
конкретного числа тому, кто его прочтёт, и платит за него каждая правка
генератора.
- У каждого отправленного слепка — своя строка с хешем байтов. Байты слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же байты») опись начинает сторожить. Окно изменяемости хешу не мешает: дышат заказы, а не байты слепка — слепок дня D по-прежнему чистая функция (зерно, D).
- Счётчики классов расхождений — наблюдаемых, после приоритета, по итоговой судьбе заказов дня; единица счёта — заказ, и опись называет её словом. Сойтись с запросом менти они могут только на днях с закрытым окном: при N сыгранных днях таких N − 7 (день d закрывается слепком d + 6, а последний отправленный слепок несёт день N − 2). Читатель у них придёт этапом 4 — проверка сверки; не окажется читателя — та же бритва режет и их.
- Контрольные числа идентичности не заводятся вовсе: uniq известных пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них не выходит; генератор сторожит хеш, транспорт — счёт событий.
- Опись описывает мир, а не доставку. Работа генератора кончается на
Kafka: дошли ли байты до
ods.order_snapshot— вопрос стенда и его проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка приёма заказов — число заведётся вместе с ней.
6. Мост к склейке: человек, person_id, user_id
Резолюция развилки «Мост к склейке: user_id и двухкуковые пары».
Причинная модель: план состава порождает человека; ему принадлежат одна или две куки, каждая наблюдается в кликстриме как отдельный посетитель. Визит может оформить заказ — тогда заказная сторона представляет того же человека как пользователя магазина. Это не модель аккаунта: регистрации нет, люди без визитов не порождаются, внутреннее знание наружу не выдаётся.
План владеет устойчивой личностью и отношением «кука принадлежит человеку».
Минимальная форма — непрозрачный внутренний person_id, выровненный по кукам:
у двух кук пары он одинаков. Заказ выводит то же значение под родным именем
user_id (UInt64); в кликстрим ни person_id, ни user_id не попадает —
анонимность формата держится формой, а не забывчивостью сериализатора.
person_id — последний обычный бросок потока случайности когорты, после уже
принятых свойств: добавление личности не сдвигает куки, пары, возвраты и
паспорта. Для второй куки повторяется ID её человека. Значения — ниже 2^53.
Раздельные прогоны ничего не хранят и не согласуют: генератор событий и
команда слепка заново спрашивают один план и получают тот же person_id.
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
ClientID несут один user_id; событие Метрики не раскрывает user_id;
отдельные прогоны одного мира дают то же соответствие; принятый мир
воспроизводит эффект склейки без случайных ложных объединений — конкретные
числа пар контрактом описи не являются. Межкогортные столкновения принимаются
по той же дисциплине, что у случайных ClientID: принимается конкретный
канонический мир по внешнему результату.
Отклонено: заказная сторона сама назначает личность — родство кук всё равно пришлось бы спрашивать у плана; материализованный реестр кука↔пользователь — состояние между прогонами без нового внешнего эффекта; первая кука как ID человека — магазин оказался бы замаскированным продолжением трекера; структурная координата, хеш или глобальная последовательность — больше механики при тех же данных; полная модель аккаунтов — менти её не наблюдает.
7. Заказы в стартовом мире
Резолюция развилки #74. Стартовый мир — не полный мир, а мир, остановленный на границе суток 7|8. Генератор играет два источника с разными темпами — поток Метрики и ночную выгрузку магазина; разные темпы дают всё остальное.
world-init играет восемь дней одним прогоном batch --day 0 --days 8;
слепки отправляет второй запуск — команда snapshot того же диапазона (два
источника — два запуска, раздел 2), и со сдвигом отправки уезжают слепки
дней 0…6. Слепок дня 7 снят на границе
суток и уедет первым же ходом мира. Особого режима у стартового мира нет:
правило отправки живёт в одном месте — в проигрывателе; генератору это решение
не стоит ничего.
Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет;
глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся.
График выручки заваливается к правому краю и дозаполняется, пока мир едет.
Это не издержка стенда, а главный наблюдаемый эффект второго источника: ночная
выгрузка отстаёт, свежие дни предварительны. На свежем стенде лаба сверки
видит целый день awaiting_order — норма, а не поломка; как это назвать
менти — за витринами этапа 4.
Цена принята с открытыми глазами: у 18 из 73 пар стартового мира поздний
назначенный заказ падает на день 7 (замерено на каноническом зерне), и до
первого хода мира этих пар в dds.identity_map нет. Опись пар не считает
(раздел 5), поэтому красной проверки из этого не выходит. Отклонено:
дослать восьмой слепок — исчезает awaiting_order на свежем стенде, в CLI
заводится рычаг, стартовый мир становится особым случаем ровно там, где #71
его убирал; счётчик пар учится спрашивать про слепки — число верно ровно до
первого хода мира; отменить сдвиг отправки — awaiting_order пропал бы
навсегда; подогнать план под горизонт — мир перестал бы быть чистой функцией
зерна.
Замеры канонического зерна (сняты при резолюции): пар в плане 73, в слепках стартового мира доезжает 55; на эталонном мире — 192 и 172; дней с закрытым окном — один на стартовом, семь на эталонном.
8. Запись на проводе
Резолюция развилки «Форма записи слепка на проводе»; основания и проверка разбора — в исследовании формата.
{
"order_id": "20260603-0001",
"user_id": 42,
"status": "paid",
"created_at": "2026-06-03T14:21:07.123Z",
"updated_at": "2026-06-03T14:24:18.456Z",
"items_total": "1299.90",
"discount": "0.00",
"delivery": "199.00",
"total": "1498.90",
"items": [
{"sku": "sku-17", "qty": 1, "price": "1299.90"}
],
"snapshot_date": "2026-06-07"
}
- Деньги — строки с ровно двумя знаками после точки, включая
items[].price: сохраняется десятичная запись источника, сумма не проходит через JSON-число/Float. Разница с кликстримом намеренна — тамpurchaseRevenueприезжает JSON-числом вArray(Float64), а бэкенд передаёт деньги строками для точногоDecimal: расхождение представлений денег в двух источниках и есть урок. items— обычный массив JSON, не строка с JSON внутри. В ODS колонка остаётсяString: приём извлекает сырой фрагмент черезJSONExtractRaw, а разбирается массив один раз — на границе ODS → DDS.created_atиupdated_at— аудит строки по часам базы источника, UTC с настоящими миллисекундами, одна узкая формаYYYY-MM-DDTHH:mm:ss.SSSZ;.000выводится честно.updated_atмонотонен по версиям одного заказа и служит версиейReplacingMergeTree. Бизнес-время покупки остаётся вpurchase.UTCEventTime; новогоordered_atне добавляется — потребителя нет.snapshot_date— дата завершившегося модельного дня, снятая на исходящей границе суток; одна на всю выгрузку, не глобальная константа и не дата отправки. Константы мира — D0, пояс модельного календаря и окно K = 7.- Порядок ключей — порядок полей контракта; у позиции —
sku,qty,price: воспроизводимые байты без сортировки ключей. Порядок строк внутри слепка — порядок рождения заказов, он же возрастаниеorder_id: детерминизм даёт его даром, а хешу слепка в описи нужен именно названный порядок. - Сериализация — явная функция рядом с
events(...)в существующемserialize.py: один словарь с вложенным списком и одинorjson.dumpsна запись. Классы кодеков, реестр схем и отдельныйdumpsдляitemsне заводятся.
Отклонено: JSON-числа для денег — теряют десятичную форму и провоцируют путь
через Float; строка с JSON в items — двойная сериализация и второй разбор;
секундная точность — отбрасывает точность аудита источника; глобальная
SNAPSHOT_DATE — смешивает правило календаря с результатом его вычисления.
9. Приём: от топика до ODS
Резолюции развилок «Приём заказов: нужен ли слепку слой сырья» и «Брак в слепке: куда уходит и по каким классам». Источник истины здесь — ADR 0008, ADR 0010 и спецификация приёма; ниже — картина одним абзацем на слой.
Топик orders — одна партиция; чтец байтовый (RawBLOB), объявлен только на
clickhouse-01 и без ON CLUSTER, матвью к нему не привязана. Сырьё забирает
пакетный шаг под управлением Airflow: одно прямое чтение Kafka вставляет
порцию в stg.orders_raw с _load_id = run_id запуска. Один следующий
task_id двумя взаимодополняющими INSERT SELECT читает неизменный срез STG:
годные строки — в ods.order_snapshot, брак — в ods.order_snapshot_errors с
классом not_an_object / keyset_mismatch / field_invalid. Строгий приём
проверяет только контракт провода — 11 верхних ключей и канонические формы;
позиции и бизнес-инварианты остаются ниже границы.
ods.order_snapshot принимает версии заказа на
ReplacingMergeTree(updated_at): ключ — order_id, партиция — день
неизменного created_at, шардирование по cityHash64(order_id).
snapshot_date — происхождение строки, а не ключ публикации: прямое чтение
Kafka не задаёт границы полного слепка, поэтому прежняя замена партиции
(ADR 0008) отменена ADR 0010. Точное текущее состояние читается через
ods.order_v; нужен ли при сборке DDS FINAL, argMax или инкрементальный
приём — решается вместе с DDS.
Стенд получает два режима приёма рядом — поток против слепка, push против pull, — и это опорная точка раздела 12 мастер-спеки. Тот же даг проигрывает модельный день и забирает слепок; со сдвигом отправки он переливает слепок предыдущего дня — ровно тот, который сам и положил в топик.
10. Правила кода этапа 3
Хвосты резолюций, обязательные для реализации:
- Броски заказной стороны — на полную длину дня, а не на отобранных заказах (правило формы, раздел 3); моментов всегда два.
- Целочисленная случайность наследуется правилом кода этапа 2: таблицы целых весов, никаких плавающих распределений; деньги — в целых копейках.
- Подпотоки — по позиции в дереве: стороны судьбы ветвятся по дню рождения
заказа; в
COMMERCEновых бросков нет;person_id— последний бросок когорты. - Окно K = 7 — константа мира в конфигурации мира, рядом с D0 и поясом (исследование формата).
- Один сериализатор: запись слепка собирает явная функция
serialize.py, прямыхjson.dumpsпо коду нет. - Значения идентификаторов — ниже 2^53 (
user_idнаравне с прочими). - Спорные API (DDL ClickHouse, операторы Airflow) перед кодом сверять через MCP Context7 — правило AGENTS.md.
11. Расхождения с принятым
Внесены тем же коммитом, что и эта спека:
- Мастер-спека, раздел 2: цепочка статусов допускает прямой переход
created → cancelled; на выходе из окна заказ либоpaid, либоcancelled(#72). - Мастер-спека, раздел 4: у класса C названа механика — вычеркнутая
позиция; ориентир «~5%» читается как доля отменённых вообще;
«
amount_delta— необъяснённый остаток» читать как «не объяснённый приведением к сравнимой базе» — сравнением позиций он объясняется, на этом стоит урок класса C; счётчики описи — наблюдаемых классов, после приоритета, в заказах (#72). - Мастер-спека, раздел 5: «опись хранит число именно таких пар» снято —
число растёт, пока мир едет; ядро лабы — неравенство
uniq(посетителей) > uniq(людей)— верно в любой день (#74). - Мастер-спека, раздел 8: опись получает строку на слепок с хешем байтов; контрольные числа идентичности не заводятся; «заказы и выручка по дням» читать как «по дням с закрытым окном» (#72, #74).
- Спека генератора, раздел 2: в перечислении компонентов дня расхождения и опоздания уступают место заказной и событийной сторонам (#72).
- ADR 0008: фраза «переливается ровно то, что он положил в топик» уточнена — со сдвигом отправки даг переливает слепок предыдущего дня (#71).
- CONTEXT.md: новый термин «судьба заказа» (#72); термины «человек» / «посетитель» / «пользователь магазина», «дата слепка», «время аудита источника» и поправки к «Слепку» и «Описи мира» внесены ранее, при закрытии #72–#81.
Ранее внесённые той же картой изменения (уже в main): раздел 12 мастер-спеки переписан на «поток против слепка», сенсор дневного батча снят, первый даг переехал на этап 3 (#70); разделы 2 и 7 мастер-спеки — формат провода и версионный ODS (#81, #80).
12. Решается при нарезке и позже
- Имена подпотоков сторон судьбы — при реализации; из мёртвых имён никто не бросает, переименование ничего не сдвигает (#72).
- Конкретные веса и доли — таблицы исходов, моментов, задержки опоздания, доли классов, стоимость доставки — калибровка при реализации; финальная фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки потребуют только числа, не устройство.
- Проверки приёма — разовая приёмка допущения «один запуск — одно чтение» и опыты из «Рисков и проверки» спецификации приёма — тикет реализации приёма.
_load_idвыше ODS — вместе с устройствомdds.order(#85).- Контур проверок качества для расхождений (даг DQ,
dm.dq_summary) — остаётся в тумане карты #69; естественное место разговора — этап 4. - Каноническое чтение событий
ods.event_v— отдельный тикет #86, к механике заказов не привязан.