ONECDC audit 0.2.1 · 09.09.2026 · static
узел — crit 3 high 17 med 18 · low 7

Независимый аудит · исходники · без живой 1С

Три обещания, которые код не держит

В 0.2.1 нельзя считать доказанными гарантии «ACK означает полное сохранение», «повтор восстанавливает обработку» и «full_load не затирает свежее». Ниже — один каталог из 45 находок, а не сверка сверок.

45
узлов · 33 по коду · 12 при условиях
ACK

Пакет можно подтвердить, не сохранив объект и не поставив сигнал. CDC-01 · CDC-07 · CDC-13

SNAP

Снимок устаревает между страницами и может затереть уже подтверждённое состояние. CDC-04 · CDC-05 · CDC-06

OWN

Второй процесс того же плана стирает защиту первого; heartbeat не доказывает rollback. CDC-02 · CDC-08 · CDC-09

критическаявысокаясредняянизкаяячейка открывает карточку

Вердикт

OneCDC — зрелый конвейер с честной документацией ограничений 1С, но несколько инвариантов проекта нарушены конкретными ветками кода. Это не список стиля и не «нет pool_pre_ping». Это места, где КХД расходится с источником, витрина теряет строки или очередь обмена встаёт.

Доставка

Commit данных, журнал, сигнал и Notify — разные транзакции. Повтор no-op не восстановит потерянное уведомление. Объект без ключа может быть подтверждён без сохранения.

Согласованность

$skip по живому источнику пропускает строки. No-op CDC не двигает защитную метку. Плоские бухгалтерские страницы режут набор регистратора. Новые строки группы теряются после собственного UPDATE.

Ключи и значения

Транслит склеивает имена. Составное примитивное измерение оставляет в ключе нулевой GUID. Очистка строки в 1С не доезжает, если колонки нет в пачке. Double идёт через float.

Процессы

Owner = имя плана уничтожает чужие merge. Heartbeat 90 с не отличает паузу от смерти. Два HandlerLoop одного NAME переписывают витрину без отката.

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

Конвейер и где он рвётся

Один пакет не живёт в одной транзакции: HTTP и PostgreSQL так не умеют. Идемпотентный upsert необходим и недостаточен. Нажмите ступень — каталог оставит только связанные узлы.

Конструктор → служебные таблицы → run_once: $metadata → ReceivedNo+1 → SelectChanges → разбор → merge → журнал → сигнал → Notify. full_load ставит метку страницы до чтения. HandlerLoop двигает watermark по границе WriteTracker. FullLoadCron берёт захват и вызывает ту же выгрузку.

Каталог

Семь глав одного отчёта. Карточки раскрываются; j/k листают, / ищет.

01

Критический контур

Три ветки, каждая из которых сама по себе расходится КХД с 1С или ломает витрину в штатной раскладке.

CDC-01

Очистка строкового реквизита в 1С не доезжает до БД: старое значение остаётся навсегда

Данные · цикл измененийподтверждено по коду
Критическая

data_reader.py 917–922, 173–189 · db_writer.py 109–112 · dbmerge.py 745–751, 1292–1374 · msg002_nomenklatura.xml пустые элементы вида <d:Артикул/>

Механизм

Парсер берёт из m:properties только строковые значения: if k.startswith('d:') and isinstance(v, str). Пустой реквизит 1С отдаёт пустым элементом (<d:Артикул/>, в записанных ответах таких сотни), xmltodict превращает его в None, и поле в запись не попадает вовсе. Дальше два этапа усиливают эффект:

  • DataObject.add_records дописывает None только тем записям, у которых поле есть у соседей по пачке. Если поле пусто у всех строк пачки, колонки в данных нет.
  • dbmerge формирует список колонок из первой записи (_get_fields_from_list_of_dict) и в фазе UPDATE пишет только эти колонки. Колонка, отсутствующая в данных, в SET не попадает.

Для чисел, дат, булевых и ссылок 1С присылает текстовое значение (0, 0001-01-01T00:00:00, false, нулевой GUID), они конвертируются и пишутся. Дефект касается ровно строковых полей — а это наименования, артикулы, комментарии, коды, PredefinedDataName, Значение_Value составных реквизитов.

Сценарий

  • В 1С у номенклатуры заполнен «Артикул» = A-100; пакет изменений доехал, в БД Artikul = 'A-100'.
  • Пользователь стирает артикул и записывает элемент. В пакете приходит <d:Артикул/>.
  • Если в этом же пакете нет другой номенклатуры с непустым артикулом, колонки Artikul в данных нет; UPDATE её не трогает; merged_on может измениться из-за DataVersion… нет — DataVersion в skip_compare_fields, поэтому строка вообще «не изменилась».
  • Результат: в КХД навсегда A-100, обработчик не вызван, last_full_load_rows_modified тоже покажет 0 — полная выгрузка страдает тем же самым.

Результат зависит от состава пачки: та же очистка «сработает», если по соседству окажется запись с непустым значением этого поля. Это делает дефект трудновоспроизводимым в тестах и незаметным в эксплуатации.

Последствия

Систематическое расхождение КХД с источником по всем строковым полям, которые когда-либо очищались. Полная выгрузка не лечит (та же ветка парсера). Витрины, фильтрующие по пустому значению, дают неверный результат; «самопроверка CDC» через rows_modified = 0 ложно успокаивает.

Что исправить

  • В _get_record_fields различать три случая: значение-строка, пустой скаляр (None или словарь с m:null="true" без m:type) и коллекция (словарь с m:type="Collection(…)"). Пустой скаляр писать явно как ''/None по типу поля из метаданных.
  • Состав колонок merge брать из метаданных объекта, а не из фактического состава пачки: тогда UPDATE всегда покрывает все известные поля.
  • Регрессия: пакет из одной записи, у которой единственное строковое поле очищено; ожидание — в БД пустая строка/NULL и сдвинутый merged_on.

Как проверить

Оффлайн: подать в read_data_entries entry с <d:Артикул/> после сохранения строки с Артикул='X' в ту же схему. Проверить значение колонки после DBWriter.save. Тесты test_noise_and_tombstones.py этот случай не покрывают: там сравниваются только равные значения и сокращённый набор.

CDC-02

Второй процесс с тем же планом обмена стирает реестр незавершённых merge первого — витрина теряет строки

Обработчики · граница окнаподтверждено по коду
Критическая

write_tracker.py 97, 149–167, 169–186, 217–223 · replicator.py 532 · full_load_claim.py 24–30 (правильный образец owner) · test_handlers.py 696–715 (test_restart_forgets_own_stale_writes закрепляет поведение)

Механизм

WriteTracker получает owner = exchange_name — имя плана обмена без хоста, pid и случайного суффикса. Конструктор вызывает _cleanup(), который удаляет все строки этого owner как «остатки прошлого запуска». У соседнего механизма — FullLoadClaim — авторы уже сделали owner уникальным на экземпляр (exchange:host:pid:hex) ровно по этой причине; реестр merge этот урок не получил.

Три следствия одного решения:

  • живые строки первого процесса удаляются при старте второго;
  • счётчик id = f'{owner}:{n}' в обоих процессах начинается с нуля — возможна коллизия первичного ключа и падение track();
  • heartbeat() продлевает все строки с owner, то есть чужие тоже — брошенная строка умершего процесса-тёзки не протухнет никогда, пока жив второй.

Сценарий

  • Процесс A (репликатор) пишет толстую страницу полной выгрузки регистра; в onecdc_writes_in_process его строка держит границу окна на started_at.
  • Стартует процесс B с тем же exchange_name: второй контейнер при rolling update, отдельный контейнер с FullLoadCron (рекомендуемая документацией раскладка), ручной run_once рядом с работающим run_forever.
  • _cleanup() процесса B удаляет строку A. Обработчик считает boundary = now(), обрабатывает окно и записывает last_run_at.
  • A коммитит merge; merged_on его строк — время начала транзакций UPDATE/INSERT, левее уже записанного last_run_at.
  • Инкремент merged_on > last_run_at эти строки не увидит никогда. Сырая таблица выглядит здоровой, витрина — нет.

Последствия

Молчаливая потеря строк витрины в штатной, описанной в README раскладке «репликатор и расписание в разных контейнерах одного плана». Тот же owner ломает и отметку страницы полной выгрузки (page_started_at берётся из той же границы): снимок может затереть свежее изменение.

Что исправить

  • Owner уникальный на экземпляр по образцу FullLoadClaim; свои строки прошлого запуска не чистить по имени, а полагаться на MERGE_HEARTBEAT_TTL и MERGE_ABANDONED_TTL.
  • Опционально advisory lock на (schema, exchange_name) для режима «ровно один репликатор на план».
  • Переписать test_restart_forgets_own_stale_writes: два живых трекера одного плана не вправе удалять чужие свежие строки.

Как проверить

Оффлайн, без 1С: открыть track() в одном трекере, создать второй с тем же owner, вызвать boundary() из HandlerLoop между cleanup и выходом из track(). Ожидание: граница прижата к started_at; факт: now().

CDC-03

Транслитерация неинъективна: разные объекты и поля 1С молча попадают в одну таблицу или колонку

Имена · схема БДподтверждено по коду
Критическая

name_mapper.py 3–18, 47–61, 63–94 · data_reader.py 201–206 (dict(zip) оставляет последнее) · full_load_keys.py 43–49 (байтовое усечение есть только здесь) · PLAN.md 118

Механизм

Таблица замен отображает е и э в e, ъ и ь в пустоту, кириллическое имя может совпасть с латинским (Ёлка → Yolka). Хэш добавляется только при превышении 63 символов или совпадении со служебным именем — уже после транслита, то есть от транслита, а не от оригинала, и всего 4 hex-символа (16 бит). Проверка «ambiguous» есть лишь в обратном разрешении имени (resolve_object_name) и не защищает обычный путь CDC: map_object_name к этому моменту уже записал оба объекта в одну таблицу.

Отдельно: длина считается в символах (len(name)), а PostgreSQL ограничивает идентификатор 63 байтами. Буквы вне таблицы (украинские і ї є ґ, белорусская ў, любая латиница с диакритикой) остаются многобайтовыми: Catalog_ + 60×і — 63 символа и 113 байт. Соседний модуль full_load_keys режет по байтам — два правила в одном пакете.

Третий канал: поле 1С, чей транслит точно совпал со служебным именем (merged_on, is_deleted_or_empty, exchange_message_no), проходит ветку «это наше поле» без хэша и конфликтует с колонкой, которой управляет dbmerge или парсер.

Сценарий

from onecdc.name_mapper import NameMapper
m = NameMapper()
m.map_object_name('Catalog_Сер') == m.map_object_name('Catalog_Сэр')     # 'Catalog_Ser'
m.map_field_name('Объект') == m.map_field_name('Обьект')                  # 'Obekt'
len(m.map_object_name('Catalog_' + 'і'*60).encode())                    # 113 байт

Два объекта плана обмена с именами, различающимися только е/э или ъ/ь, после первого пакета лежат в одной таблице; scoped-mark и mark_missing одного объекта помечают строки другого. Два поля одной таблицы с одинаковым транслитом: dict(zip(out_keys, row)) оставляет значение последнего.

Последствия

Смешение данных двух объектов, ложные пометки удаления, падение CREATE TABLE/рефлексии на длинных не-русских именах. Обнаружить постфактум трудно: имена в БД выглядят правдоподобно.

Что исправить

  • Вести множество занятых идентификаторов на схему; при коллизии добавлять хэш оригинального имени (8–10 hex), а не транслита.
  • Считать длину в байтах UTF-8 общей функцией с full_load_keys; расширить таблицу транслита на украинский/белорусский алфавиты.
  • Служебные имена: любое поле 1С, транслит которого совпал с ними, хэшировать всегда — «своё» служебное поле добавляет не парсер данных, а writer.
  • Тесты на пары выше: сейчас NameMapper не покрыт ни одним тестом.

Как проверить

Оффлайн: сниппет выше. Против живой 1С: два справочника с именами «Сер»/«Сэр» в плане обмена.

02

Снимок полной выгрузки

Сверка должна выравнивать данные. Здесь она вносит ложные удаления, затирает свежее или объявляет успех на неполном чтении.

CDC-04

Полная выгрузка не вставляет новые строки группы, если в той же странице обновилась существующая строка этой группы

Полная выгрузка · guardподтверждено по коду
Высокая

db_writer.py 154–160, 239–248 · replicator.py 885, 927–929 · dbmerge.py 632–666 (UPDATE коммитится до INSERT), 1183–1224, 1292–1374 · test_version_guard.py 23–26 (NEW_RUN = 2100 год скрывает дефект)

Механизм

Для регистров и табличных частей в режиме полной выгрузки DBWriter.save передаёт три условия: update_condition = merged_on < page_started_at, delete_condition = scoped ∧ merged_on < page_started_at и insert_condition = NOT EXISTS (строка той же группы с merged_on ≥ page_started_at). Отметка page_started_at берётся до чтения страницы.

dbmerge.exec выполняет фазы последовательно: UPDATE (и коммит), затем INSERT. Фаза UPDATE штампует изменённым строкам merged_on = now(), то есть значение позже page_started_at. Фаза INSERT видит эти собственные строки как «группу, переписанную после отметки», и insert_condition отбрасывает все новые строки этой группы.

Тесты guard'ов используют NEW_RUN = datetime(2100,1,1): любой now() меньше 2100 года, поэтому в тестах собственный UPDATE группу не «греет». В рабочем коде отметка — реальное время за секунды до merge.

Сценарий

  • В БД табличная часть документа D: строки 1, 2 (обе с merged_on старше прогона).
  • В 1С у строки 1 поменяли количество и добавили строку 3; изменение в обмен не попало (для этого и нужна сверка).
  • Полная выгрузка читает D одной страницей: [1', 2, 3].
  • UPDATE: строка 1 обновлена, merged_on = t₁ > page_started_at.
  • INSERT: для строки 3 условие NOT EXISTS(g.Ref_Key = D ∧ g.merged_on ≥ page_started_at) ложно из-за строки 1. Строка 3 не вставлена.
  • Отчёт прогона: rows_modified = 1, finished_at проставлен, last_full_load_dt записан. Строка 3 появится только на следующей полной выгрузке (тогда строка 1 уже равна источнику, UPDATE не сработает, группа «холодная»).

То же для набора движений регистра: перепроведённый документ с изменённой суммой и добавленным движением.

Последствия

Полная выгрузка — заявленный способ «выровнять данные», но один прогон не сходится: результат сходится только за два прогона при неизменном источнике. Метрика last_full_load_rows_modified и самопроверка «должно быть 0» становятся неверными. В бухгалтерском регистре, где окно читается целиком, эффект тот же.

Что исправить

  • Исключать из _group_not_touched_since строки, изменённые этим же merge: например, сравнивать не с page_started_at, а с отметкой, взятой после собственной фазы UPDATE, либо помечать группы, которые прогон сам обновил, и пропускать guard для них.
  • Проще: выполнять для группы один атомарный шаг «если группа холодная — заменить целиком (delete-mark + upsert)», а не три независимых условия.
  • Регрессия с реальной отметкой (db_now() за миг до сохранения), а не с 2100 годом: группа с изменённой и добавленной строкой в одной странице.

Как проверить

Оффлайн против PostgreSQL: сохранить группу [1,2] обычным save; затем save([1', 2, 3], full_load_started_at=writer.db_now()). Ожидание — три живые строки; факт — строка 3 отсутствует.

CDC-05

Пометка пропавших строк по $skip-страницам объявляет живые строки удалёнными: изменяемый источник и неуникальная сортировка

Полная выгрузка · пометка пропавшихподтверждено по коду
Высокая

replicator.py 804–836 (recheck только при окнах), 943–951, 1170–1212 · data_reader.py 339–378 ($orderby по Ref_Key → автоупорядочивание по представлению) · full_load_keys.py 111–164 · DESIGN.md 474–486, 513–545 · test_full_load.py 956 (test_run_without_any_window_does_not_recheck закрепляет отсутствие перепроверки)

Механизм

Ключи страниц копятся в таблице прогона, и после последней страницы всё, чего там нет, помечается is_deleted_or_empty. Перепроверка кандидата запросом в 1С делается только если прогон шёл окнами (recheck = date_filter is not None or windowed). Сплошной прогон «в одну выборку» перепроверки не делает — а именно у него две независимые причины пропустить живую строку:

  • Изменяемый источник. $skip=N — смещение в заново построенной выборке. Удаление или переезд в сортировке любой строки из уже прочитанного префикса сдвигает следующую страницу на одну позицию: живая строка не прочитана ни в одну страницу.
  • Неуникальный ключ сортировки. Для справочников и документов $orderby=Ref_Key 1С разворачивает в автоупорядочивание по представлению (наименование/код). Одинаковые наименования — обычное дело (номенклатура в разных группах, контрагенты-тёзки). Порядок внутри такой ничьей СУБД 1С не гарантирует между запросами (особенно при параллельных планах), и страницы дают дубли и пропуски даже без единого изменения в источнике. Авторы измерили именно это на табличной части (87 строк: 4 дубля, 5 потерь) и починили добавлением LineNumber — но у справочника тай-брейкера нет.

Сценарий

источник: [A, B, C, D], страница 1 = [A, B]
удалён A (или B переименован и уехал вниз)
страница 2: $skip=2 над [B, C, D] → [D]
C не попал в таблицу ключей; merged_on у C старый; recheck=False
mark_missing → C помечен удалённым, ресурсы обнулены, merged_on поднят → витрина пересчитана «без C»

Для справочника в 10⁵ строк со сплошным чтением (даты нет, окон нет) вероятность события пропорциональна числу страниц и активности пользователей ночью — и она ненулевая при каждом ночном прогоне.

Последствия

Ложные удаления в КХД с поднятым merged_on: витрина «увидит» удаление и пересчитает агрегаты без живой строки. Возврат — только следующей полной выгрузкой, если строка не изменится в 1С. Ошибка необратима для внешних приёмников (отправка события об удалении).

Что исправить

  • Перепроверять всех кандидатов запросом по ключу всегда, а не только при окнах — цена пропорциональна числу кандидатов, а их обычно мало.
  • Сохранять подтверждённые живые строки (перепроверка их читает) через save с guard, а не только дописывать ключи.
  • Документировать, что источник не даёт snapshot isolation, и что сплошной $skip по справочнику допустим только с перепроверкой.
  • Для сущностей добавлять в $orderby детерминированный тай-брейкер, если 1С его принимает (например, Code), и проверить поведение на живой базе.

Как проверить

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

CDC-06

Игнорирование «шума» лишает снимок защиты: no-op пакет не обновляет метку, и устаревший снимок затирает подтверждённое состояние

Полная выгрузка · guardподтверждено по коду
Высокая

db_writer.py 116–125, 167–180, 219–237 · replicator.py 863–919 · README_DB.md 171–175

Механизм

Guard полной выгрузки опирается только на merged_on, а merged_on по замыслу двигается лишь при изменении бизнес-значений (exchange_message_no и DataVersion в skip_compare_fields). Значит «строку никто не переписывал после отметки страницы» и «строку никто не подтверждал после отметки» — разные факты, а guard проверяет первый, хотя нужен второй.

Сценарий

t0  БД: A                          источник: A
t1  источник: B                     (изменение ещё в очереди)
t2  full_load читает страницу: B    (page_started_at = t2⁻)
t3  источник снова A
t4  CDC получает пакет с A → значения равны → no-op → merged_on не тронут → пакет подтверждён
t5  full_load сохраняет B: merged_on(t0) < page_started_at → guard пропускает → БД: B

В очереди 1С последнего A уже нет. Контрпример не требует ни двух процессов, ни нарушения порядка пакетов. Симметрично: no-op CDC не защищает живой ключ от ошибочной пометки в mark_missing (та же _older_than_run).

Последствия

Полная выгрузка, призванная сверять данные, вносит устаревшее значение поверх актуального, и ни один сигнал об этом не приходит. Вероятность растёт с длительностью прогона (часы на больших регистрах) и активностью правок «туда-обратно» (проведение/отмена проведения).

Что исправить

  • Разделить два времени: merged_on (изменение значений — для витрин) и отдельная отметка последнего подтверждённого наблюдения CDC (seen_on), обновляемая и при no-op. Guard'ы и _older_than_run должны сравниваться со второй.
  • Дешёвый вариант: в режиме изменений всегда писать exchange_message_no (он не в compare, но в update — dbmerge это умеет только если строка «изменилась»), то есть нужен отдельный UPDATE по ключам пакета для no-op строк.

Как проверить

Оффлайн: последовательность save(A), save(A, emn=2) (no-op), save(B, full_load_started_at=t между ними). Ожидание — A; факт — B.

CDC-10

Бухгалтерский full_load скрывает отказ окна, а на однодневном окне зацикливается без backoff и без остановки

Полная выгрузка · регистр бухгалтерииподтверждено по коду
Высокая

replicator.py 886–903, 1011–1021, 1044, 1052–1067, 1084–1089 · data_reader.py 401–438

Механизм

В ветке by_period непостоянный HTTPError превращается в exhausted=False — «сузьте окно». _load_by_windows учитывает этот флаг только в цикле шагающих окон; у верхнего открытого окна (read(anchor, None)) и у хвоста (read(None, cursor)) возвращаемое значение игнорируется. Окно, дошедшее до FULL_LOAD_WINDOW_MIN_DAYS = 1, при повторной ошибке снова получает window_days = max(1, 1 // 3) = 1 и перечитывается бесконечно: ни счётчика попыток, ни паузы, ни проверки StopSignal.

Сценарий

  • Верхнее окно [anchor, +inf) ответило 500 (переполнение временных файлов) → окно молча пропущено → прогон «успешен», full_load_is_required снят, свежие проводки не загружены. С mark_missing=True перепроверка по регистратору не даст ложных пометок, но и значения/субконто не запишет.
  • Однодневное окно с 200 тысячами проводок (закрытие месяца) стабильно даёт 500 → поток выгрузки крутится вечно, каждый оборот — тяжёлый запрос к 1С; SIGTERM цикл не прерывает, контейнер добивается SIGKILL.

Последствия

Неполная таблица регистра бухгалтерии, объявленная выгруженной; либо бесконечная нагрузка на сервер 1С без диагностики.

Что исправить

  • Учитывать exhausted у верхнего и хвостового окон; на минимальном окне пробрасывать ошибку после N попыток с паузой.
  • Проверять StopSignal между окнами и страницами; не завершать прогон и не помечать пропавших, пока все окна не прочитаны.
  • Для окна, не влезающего в 1С даже за день, добавить дробление по Recorder_Type или по часам внутри суток.

Как проверить

Оффлайн: read_accounting_register, отвечающий 500 на первый вызов; проверить, что full_load не возвращает успех. Для цикла — mock, всегда отвечающий 500, и таймер.

CDC-11

Окна по периоду режут набор регистратора бухгалтерии, а writer заменяет группу целиком: страницы помечают строки друг друга

Полная выгрузка · регистр бухгалтериипри указанных условиях
Высокая

replicator.py 790–802, 885–891 · data_reader.py 401–438 · db_writer.py 139–162, 270–287 · metadata_reader.py 305–320 (object_key регистра = Recorder + Recorder_Type)

Механизм

Для регистров, подчинённых регистратору, окно строится лямбдой RecordSet/any(…) и отдаёт набор целиком — на этом держится scoped-пометка «строки группы, которых нет в странице, выпали из набора». Регистр бухгалтерии читается иначе: из виртуальной таблицы RecordsWithExtDimensions(Condition='Period ge … and Period lt …'), которая возвращает плоские движения, отфильтрованные по периоду. Если у одного регистратора движения лежат в разных окнах (или пользователь задал период, отсекающий часть набора), страница несёт часть группы, а _scoped_delete_condition по (Recorder, Recorder_Type) помечает остальные движения регистратора как выпавшие.

Guard merged_on < page_started_at не спасает: свежее окно загружается первым, а старое — следующим, и строки свежего окна к тому моменту старше отметки новой страницы? Нет — они новее, поэтому защищены. Но обратная сторона верна: движения старого окна, уже лежащие в БД от прошлых прогонов, помечаются страницей свежего окна.

Сценарий

Документ «Операция» с проводками за 31.03 23:50 и 01.04 00:10 (один регистратор, два периода). Окна: [01.04, +inf) и [02.03, 01.04). Первое окно приносит только апрельскую проводку; scoped-mark помечает мартовскую (из БД) удалённой и гасит суммы. Второе окно приносит мартовскую и «воскрешает» её — но с новым merged_on, лишним сигналом и ложными событиями для внешних приёмников; при обрыве между окнами — ложная пометка остаётся.

Последствия

Ложные пометки и обнуления ресурсов бухгалтерских проводок; в промежутке между окнами витрина считает по искажённым данным. Условие достижимости — регистратор с движениями в разных периодах; платформа этого не запрещает, код не проверяет.

Что исправить

  • Для плоских бухгалтерских окон использовать upsert без scoped-пометки, а исчезновение движений определять единым проходом mark_missing по всему окну прогона.
  • Либо дочитывать полный набор регистратора (read_by_key) для регистраторов, встретившихся у края окна.

Как проверить

Оффлайн: подать в DBWriter.save два «окна» одного регистратора с full_load_started_at и проверить флаги между ними.

CDC-12

mark_missing не распространяет физическое удаление владельца на его табличные части

Полная выгрузка · пометка пропавшихподтверждено по коду
Высокая

replicator.py 1158–1168, 1170–1212 · data_reader.py 1138–1174 · DESIGN.md 831–832

Механизм

Таблица ключей и финальная пометка ведутся только по самому объекту. Табличные части «помечаются вместе с владельцем» лишь когда владелец пришёл в странице с пустой коллекцией. Если документ физически удалён в 1С до прогона, его entry не приходит вовсе, вложенные ТЧ не обрабатываются, и их строки в БД сохраняют is_deleted_or_empty = false, старые суммы и старый merged_on.

Сценарий

  • Документ D с 40 строками ТЧ загружен.
  • В 1С D помечен на удаление и удалён «Удалением помеченных объектов». Пометка доехала (ТЧ получили флаг от родителя), но затем удаление в обмен не пришло.
  • Если между пометкой и удалением документ ещё раз записали без пометки (типичное «сняли пометку — удалили другой обработкой»), строки ТЧ снова живые.
  • Ночной full_load помечает только строку документа; 40 строк ТЧ живут вечно, подписчики только на таблицу ТЧ сигнал не получают.

Последствия

Неполнота пометки удалений в таблицах ТЧ — как раз тех, из которых обычно считаются суммы витрин.

Что исправить

  • После пометки владельца помечать строки его ТЧ по Ref_Key с тем же guard по merged_on и сигналом подписчикам ТЧ.
  • End-to-end тест: удаление документа с ранее загруженными строками ТЧ.

Как проверить

Оффлайн: фейковый сервер без документа D при наличии его ТЧ в БД; проверить флаг строк ТЧ после full_load.

CDC-19

Табличная часть, выгруженная напрямую через full_load, помечает строки собственных предыдущих страниц как выпавшие

Полная выгрузка · APIподтверждено по коду
Высокая

replicator.py 666–670, 843–952, 1461–1480 · data_reader.py 339–378 · db_writer.py 139–162

Механизм

Публичный full_load принимает имя ТЧ (Document_X_Товары) и листает её плоскими строками по Ref_Key, LineNumber. DBWriter.save при этом делает scoped-пометку по владельцу на каждой странице, предполагая, что страница несёт группу целиком. Если строки одного владельца попали на две страницы, вторая помечает строки первой как выпавшие; page_started_at берётся заново, поэтому первая страница уже «старая» и guard её не защищает.

Сценарий

ТЧ владельца D: строки 1..4, page_size=2
страница 1 [1,2] → save; страница 2 [3,4] → save: scoped-mark по D помечает 1,2
таблица ключей содержит все четыре — но пометку не отменяет

Последствия

Ложные пометки и обнуление ресурсов в ТЧ. Комментарии в коде опасность признают, но API режим не запрещает, а list_objects/README_API описывают ТЧ как допустимый объект.

Что исправить

  • Запретить прямую выгрузку ТЧ с понятной ошибкой и подсказкой имени владельца; либо в этом режиме отключать scoped-пометку и помечать через mark_missing по итогам прогона.

Как проверить

Оффлайн: фейковый feed ТЧ с 4 строками одного владельца, batch_size=2.

Замысел и его граница

DESIGN.md/код: «Отдельная сущность у табличной части в OData всё же есть, и full_load, если его натравить прямо на неё, будет листать её… ради такого случая ничья и разрешается». То есть режим осознанно поддержан для пагинации, но его взаимодействие со scoped-пометкой не рассмотрено. Тезис о поддержке режима оспорен: пагинация корректна, запись — нет.

CDC-21

Перепроверка кандидатов строит guid-литерал для составных полей ключа (Recorder_Key, составные измерения): 1С отвечает 500 или пустотой

Полная выгрузка · пометка пропавшихпри указанных условиях
Средняя

replicator.py 1296–1305, 1247–1281 · data_reader.py 133–151, 755–756 · metadata_reader.py 257–260, 277–280 (составные поля принудительно Guid) · DESIGN.md 872–885 (таблица ответов на фильтр по Recorder)

Механизм

В метаданных любое поле с парным _Type и поле Recorder принудительно получают тип Guid. _still_in_1c для независимого регистра строит $filter через _odata_literal(value, 'Guid') → Измерение eq guid'…'. По таблице из DESIGN.md сравнение составного поля с guid-литералом даёт 500 «нельзя сравнивать поля неограниченной длины», а со строковым — 200 и ноль строк. Первое роняет прогон на последнем шаге (все страницы уже прочитаны), второе — помечает всех кандидатов удалёнными. Отдельно read_by_key сериализует Recorder_Key (на проводе Edm.Guid) строковым литералом — обратная ошибка того же рода.

Сценарий

Независимый регистр «Значения свойств объектов» с измерением «Объект» составного типа в ключе. Ночной FullLoadCron за неделю: 200 кандидатов на пометку → перепроверка → 500 → прогон падает после полного чтения; так каждую ночь. Отключить mark_missing у расписания нельзя.

Последствия

Невозможность завершить сверку для регистров с составными измерениями (или регистров с Recorder_Key), либо ложная массовая пометка — в зависимости от реакции конкретной платформы.

Что исправить

  • Хранить «тип на проводе» отдельно от SQL-типа; для составных полей использовать синтаксис 1С cast(guid'…', 'StandardODATA.Тип') либо перепроверять прямым адресом.
  • Для Recorder_Key — guid-литерал в адресе.

Как проверить

Требует живой 1С: реакция на eq guid'…' по составному измерению. Статически подтверждена только генерация литерала.

Замысел и его граница

DESIGN.md подробно описывает, почему по Recorder нельзя фильтровать, и делает для регистраторных регистров исключение (прямой адрес). Для независимых регистров с составными измерениями тот же аргумент применим, но исключение не сделано — тезис документа распространён на случай, который он сам не рассмотрел.

CDC-22

Таймаут или обрыв на странице не уменьшает страницу, а фоновая выгрузка перезапускается с нуля каждый цикл без backoff

Полная выгрузка · устойчивостьподтверждено по коду
Средняя

replicator.py 895–919 (только HTTPError), 1599–1610, 1612–1625 · metadata_reader.py 25

Механизм

Адаптация размера страницы срабатывает только на requests.HTTPError. ReadTimeout, ConnectionError, обрыв по Content-Length — исключения других классов: они пробрасываются, прогон падает, а _full_load_page_size остаётся прежним. _run_full_load оставляет full_load_is_required, и следующий цикл (через interval, обычно 60 с) начинает объект заново с skip=0 и тем же размером страницы. Ни счётчика неудач, ни паузы, ни точки возобновления. _dispatch_full_loads при этом захватывает сразу все помеченные объекты, даже если воркеров два: остальные ждут в очереди с активным захватом, недоступные расписаниям других процессов.

Сценарий

Регистр с толстыми наборами: страница адаптировалась до 30 entry, но одна entry на 12 МБ формируется 1С дольше 15 минут → ReadTimeout → прогон упал на 40-й странице → через минуту всё с начала, те же 39 страниц, тот же таймаут. Сутки непрерывной нагрузки на 1С без единой полезной записи после 39-й страницы.

Последствия

Бесконечная тяжёлая нагрузка на сервер 1С, отсутствие прогресса и невидимость проблемы (в логе — одна строка ошибки в час).

Что исправить

  • Ловить requests.RequestException (не только HTTPError) и делить страницу так же, как при 500.
  • Экспоненциальный backoff и предел попыток для фоновой выгрузки объекта; фиксировать прогресс (последнее успешно прочитанное окно) и возобновлять с него.
  • Захватывать объект в момент фактического старта воркера, а не при постановке в очередь.

Как проверить

Оффлайн: mock read_object, бросающий ReadTimeout на N-й странице; проверить размер страницы и число повторов.

Замысел и его граница

DESIGN.md: «В run_forever упавшая фоновая выгрузка повторится на следующем цикле — и начнёт уже с подобранного размера». Тезис верен только для отказов с HTTP-кодом; для таймаутов подобранный размер не меняется. Оспорено.

03

Доставка изменений

ACK, сигнал обработчику и повтор пакета — три разных факта. Код часто считает их одним.

CDC-07

Сбой между commit merge и сигналом теряет уведомление обработчику; повтор пакета его не восстанавливает

Обработчики · доставка сигналаподтверждено по коду
Высокая

replicator.py 594–614, 624–646, 924–937 · handlers.py 571–588, 590–615 · db_logs.py 80–104

Механизм

Merge, завершение строки журнала, снятие строки реестра и сигнал обработчику — четыре отдельные транзакции (а внутри merge — ещё три коммита dbmerge). Если процесс падает или БД отказывает после коммита данных, но до HandlerSignals.signal, пакет не подтверждён и придёт снова. Но повторный merge увидит уже записанные значения: rows_modified = 0, added_fields = {}, и _signal_handlers ничего не поставит. Затем пакет будет подтверждён.

Тот же разрыв на каждой странице полной выгрузки и у request_full_rebuild: новая колонка добавлена, DDL закоммичен, а требование пересборки не записано — повтор колонку заново не «добавит».

Сценарий

  • save(Document_X) закоммичен: строки обновлены, merged_on = t.
  • Обрыв соединения на write_result или signal; исключение; пакет не подтверждён.
  • Следующий цикл: тот же пакет, merge no-op, сигнала нет, подтверждение отправлено.
  • Обработчик не вызван. Он увидит строки с merged_on = t только при следующем сигнале по любой подписанной таблице — которого может не быть часами.

Последствия

Витрина/внешний приёмник отстаёт на неопределённое время; при enabled=false/паузе обработчика сигнал теряется совсем. Нарушено обещание «репликатор знает, когда данные изменились, и сам вызывает ваш код».

Что исправить

  • Сделать сигнал долговечной обязанностью: ставить update_requested_at в той же транзакции, что и последняя фаза merge (dbmerge допускает commit_all_steps=False, но коммитит сам; нужен хук или вынесение сигнала в outbox-таблицу, которую разбирает отдельный шаг «до подтверждения пакета»).
  • Минимум: сигналить подписчикам всех таблиц пакета до NotifyChangesReceived, независимо от rows_modified, если предыдущая попытка обработки пакета этого номера не завершилась (признак — незакрытая строка журнала с тем же message_no).

Как проверить

Оффлайн: подменить HandlerSignals.signal исключением на первом вызове, прогнать run_once дважды; проверить update_requested_at подписчика.

CDC-13

Объект с нераспознанным первичным ключом подтверждается без сохранения; частично распознанный ключ склеивает разные записи

Цикл изменений · доставкаподтверждено по коду
Высокая

metadata_reader.py 292–302 · db_writer.py 100–107 · replicator.py 575–591, 605–614

Механизм

_read_metadata_item_key берёт только те поля Key/PropertyRef, что попали в распознанные properties: поле ключа неподдержанного типа молча выпадает. DBWriter.save при пустом ключе пишет warning и возвращает None; оркестратор не отличает «нечего сохранять» от «сохранить невозможно», закрывает строку журнала и подтверждает пакет. Если распознана лишь часть составного ключа, две разные записи 1С получают одинаковый ключ в БД, и одна перезаписывает другую.

Сценарий

Независимый регистр сведений с измерением типа, которого нет в type_mapping (например, Edm.Time или неизвестный будущий тип платформы): в метаданных объект есть, ключ неполон. Пакет с его записями подтверждён, строки либо не сохранены (WARNING в логе), либо склеены.

Последствия

Нарушение контракта «ACK только после успешного сохранения»: данные теряются с одной строкой WARNING, которую никто не читает.

Что исправить

  • При разборе $metadata ронять объект (и запуск), если любой компонент объявленного ключа не распознан; для регистров запрещать сохранение с усечённым ключом.
  • В save различать допустимый no-op (пусто) и фатальный skip (нет ключа) — второе должно быть исключением до NotifyChangesReceived.

Как проверить

Оффлайн: $metadata с ключом из поля неизвестного типа; убедиться, что run_once не подтверждает пакет.

Замысел и его граница

DESIGN.md обосновывает мягкость к неизвестным типам полей («не ошибка метаданных») и к неподдерживаемым классам объектов. Ключ — другой случай: без него объект нельзя сохранить вовсе, и молчаливое подтверждение противоречит собственному инварианту документа «notify только после успешного save».

CDC-15

HTTP 404 и любой неожиданный XML трактуются как «данных нет»: перепроверка и полная выгрузка принимают отказ прокси за пустоту

Сеть · разбор ответовподтверждено по коду
Высокая

data_reader.py 392–393, 574–575, 759–766, 800–802 · change_reader.py 39–40 · replicator.py 1279–1280

Механизм

read_by_key считает любой 404 отсутствием объекта — включая 404 от IIS/nginx при снятой публикации, ошибке маршрутизации или (как авторы сами описывают) 404.15 «query string too long». Вызванный из перепроверки кандидатов, такой 404 разрешает пометить живые наборы удалёнными. read_object, _request_ext_dimensions и read_changes принимают любой корректный XML без ожидаемого feed/d:Result за пустой набор: HTML-страница шлюза с кодом 200 или XML-ошибка без m:error выглядят как «объект пуст».

Сценарий

  • Перепроверка регистра идёт по одному запросу на регистратор; веб-сервер перезапускается и на 30 секунд отдаёт 404 на всё → все кандидаты «отсутствуют» → массовая пометка удалёнными с обнулением ресурсов.
  • Балансировщик отдаёт 200 с HTML «Service temporarily unavailable»; xmltodict разбирает как XML без feed; страница = 0 entry → прогон завершён, mark_missing помечает всё, что не увидел.

Последствия

Массовые ложные удаления при кратковременных инфраструктурных сбоях — самый тяжёлый исход из возможных для сверки.

Что исправить

  • 404 считать отсутствием только при подтверждённом теле ответа 1С («Экземпляр сущности не найден» / m:error); иные 404 пробрасывать.
  • Требовать структуру OData (feed с Atom-пространством имён либо d:Result); всё прочее — ошибка формата, прогон прерывается без пометок.

Как проверить

Оффлайн: фейковый сервер, отдающий 404 без тела и 200 с HTML; проверить, что full_load падает, а не помечает.

Замысел и его граница

DESIGN.md: «Отсутствие набора 1С сообщает честным 404 „Экземпляр сущности не найден“ — это ответ, а не ошибка». Тезис верен для 1С, но код не проверяет, что ответил именно 1С; в том же документе описан IIS, отдающий 404 по совсем другой причине. Тезис оспаривается на его же основаниях.

CDC-16

Кэш подписок на 30 секунд и выключение обработчика оставляют изменения без сигнала

Обработчики · сигналподтверждено по коду
Высокая

handlers.py 149, 540–569, 699–725, 767–771 · test_handlers.py 392 (test_disabled_handler_does_not_accumulate_window)

Механизм

HandlerSignals кэширует список подписчиков (только enabled) на SUBSCRIPTIONS_TTL = 30 с. Новый HandlerLoop делает стартовый прогон, но изменения, закоммиченные после границы этого прогона и до обновления кэша репликатора, сигнала не получают. При enabled=false цикл сбрасывает грязные отметки, репликатор через TTL перестаёт сигналить, а обратное включение само отметку не ставит и last_run_at не двигает.

Сценарий

  • t=0: обработчик стартует, обрабатывает окно до boundary₀, регистрируется в таблице.
  • t=5: репликатор сохраняет подписанную таблицу; его кэш (прочитан в t=−10) обработчика не содержит → сигнала нет.
  • Дальнейших изменений этой таблицы нет неделю → строки с merged_on ∈ (boundary₀, t=5] ждут неделю.

Про enabled: документация обещает «окно за время простоя не копят», но last_run_at не двигается, и первый сигнал после включения всё равно прочитает весь простой — только позже и по случайному поводу.

Последствия

Отставание витрины на неопределённое время без ошибок в логах; поведение при выключении/включении расходится с документацией.

Что исправить

  • При регистрации/включении ставить себе update_requested_at = now() (catch-up) и инвалидировать кэш репликатора (версия подписок в таблице).
  • Сигналить и выключенным обработчикам (метка копится), а решение «не обрабатывать» принимать в цикле.

Как проверить

Оффлайн: HandlerSignals с прогретым кэшем, регистрация нового HandlerLoop, signal() в пределах 30 с — метки нет.

Замысел и его граница

DESIGN.md обосновывает кэш экономией запросов «на каждую страницу полной выгрузки» — резонно, но окно потери сигнала при регистрации документ не обсуждает. Про enabled README_DB утверждает, что окно не копится; код показывает обратное. Оба тезиса оспорены.

CDC-17

Пакет только из неподдерживаемых объектов зацикливает очередь; смешанный пакет теряет их безвозвратно; дроссель переносит классы, которых Python не знает

Цикл изменений · поддержка классовподтверждено по коду
Высокая

data_reader.py 831–863 · replicator.py 588–591 · metadata_reader.py 81–89 · onecdc-throttle-module.txt 171–201 (константы и регистры расчёта переносятся в очередь отдачи) · DESIGN.md 372–382

Механизм

Парсер пропускает классы вне SUPPORTED_TYPES с одним WARNING на пакет. Подтверждение шлётся только при len(self.changes) > 0. Отсюда два разных исхода одного явления: смешанный пакет подтверждается (регистрации неподдержанных объектов потеряны), пакет из одних неподдержанных — не подтверждается и повторяется каждый цикл с тем же MessageNo. Дроссель на стороне 1С при этом сознательно переносит в очередь отдачи константы и регистры расчёта; заполнив лимит ЛимитОчереди() такими регистрациями, он закупоривает очередь для поддерживаемых объектов, а Python-сторона их только повторяет.

Сценарий

  • Регламентное задание переносит 500 регистраций регистра расчёта (закрытие месяца по зарплате).
  • onecdc читает пакет: все entry неподдержанные, changes пуст, notify нет.
  • Каждые 60 с — тот же SelectChanges на 500 объектов; очередь B полна, новые справочники в неё не переносятся; репликация всего плана стоит, в логе только WARNING.

Последствия

Остановка репликации плана целиком либо потеря изменений неподдержанных классов — в зависимости от состава пакета; поведение не соответствует ни документации, ни ожиданиям оператора.

Что исправить

  • Единая политика: считать число исходных entry отдельно от числа сохранённых объектов; для пакета с неподдержанными классами — либо явный «карантин» с подтверждением и записью в журнал, либо остановка с ERROR, но одинаково для обоих случаев.
  • Согласовать ОбъектыОбхода дросселя с SUPPORTED_TYPES; на старте сверять состав плана (через ExchangePlan_*/метаданные) и предупреждать о классах вне поддержки.

Как проверить

Оффлайн: пакет только из CalculationRegister_*; проверить, что notify не шлётся и цикл повторяет номер.

Замысел и его граница

DESIGN.md сознательно выбирает «пакет всё равно подтверждается, решение принимает человек» — с этой политикой для смешанного пакета можно согласиться как с осознанным компромиссом. Но код не реализует её для пакета только из неподдержанных объектов (там подтверждения нет, и очередь зацикливается), а собственный 1С-модуль проекта создаёт именно такие пакеты. Находка сохранена как расхождение кода с заявленным замыслом.

CDC-18

Размер ответа 1С и рабочий набор памяти ничем не ограничены; дроссель считает объекты, а не байты

Устойчивость · памятьподтверждено по коду
Высокая

change_reader.py 35–40 · data_reader.py 388–396, 570–575, 891 (окно бухгалтерии целиком) · replicator.py 63–64, 1194–1199 · full_load_keys.py 137–138 · metadata_reader.py 25 (read timeout 900 с)

Механизм

requests загружает тело целиком, затем создаются Unicode-текст, дерево xmltodict, колоночные списки DataObject и список records для dbmerge — пять представлений одних данных одновременно. FULL_LOAD_TARGET_BYTES — целевой размер следующей страницы, а не потолок ответа; entry неделима; окно регистра бухгалтерии читается без Top. Кандидаты mark_missing материализуются списком. Read timeout ограничивает паузу между байтами, а не длительность передачи.

Сценарий

Массовое перепроведение 300 реализаций с ТЧ по 5 000 строк без дросселя: один SelectChanges — сотни мегабайт XML, RSS процесса — в разы больше, OOM-kill контейнера; пакет не подтверждён, следующий цикл повторяет тот же ответ. С дросселем в 500 объектов сценарий тот же, потому что лимит — в регистрациях.

Последствия

Циклический OOM без прогресса; параллельно — временные файлы на сервере приложений 1С.

Что исправить

  • Потоковое чтение с потолком принятых байтов (stream=True, счётчик, обрыв без ACK и ERROR «включите/ужесточите дроссель»).
  • Общий deadline на запрос; для больших пакетов — потоковый разбор (iterparse) и посущностное сохранение.
  • В дросселе — лимит по числу строк ТЧ/движений, а не только по объектам.

Как проверить

Оффлайн: фейковый сервер с ответом 1 ГБ; наблюдать RSS.

Замысел и его граница

DESIGN.md честно признаёт: «Размер пакета: со стороны клиента его ограничить нечем» и предлагает дроссель. Соглашаемся с невозможностью ограничить формирование пакета в 1С, но не с отсутствием защиты на приёме: клиент может отказаться от гигантского ответа до разбора и сообщить об этом, а не умирать по OOM. Находка сохранена.

CDC-20

Один несохраняемый объект блокирует весь план обмена навсегда: карантина и dead-letter нет

Цикл изменений · устойчивостьподтверждено по коду
Высокая

replicator.py 575–581 (RuntimeError), 594–614, 1536–1565 · data_reader.py 905–910 (ValueError), 1153–1163 · change_reader.py 46–48 (fill_ext_dimensions внутри read_changes)

Механизм

Пакет сохраняется целиком или не подтверждается. Любое неустранимое исключение на одном объекте — ValueError('Metadata not found') для ТЧ, чей _RowType не опубликован, RuntimeError('No metadata object'), конфликт типов колонки (создана как String по автодетекту неизвестного поля, позже метаданные дали другой тип), ошибка уникальности из CDC-14, неустранимый отказ дочитки субконто регистра бухгалтерии — повторяется каждый цикл с растущим backoff до 30 минут. Без вмешательства человека очередь стоит бесконечно; журнал показывает лишь незакрытые строки.

Сценарий

В план обмена добавили документ, а его табличную часть в состав OData не включили. Первый же пакет с этим документом: ValueError при разборе строк ТЧ → цикл падает → через час пауза 30 минут → все остальные объекты плана тоже не реплицируются, пока администратор не прочитает лог.

Последствия

Полная остановка репликации плана из-за одного объекта; при этом сам механизм backoff маскирует проблему (ошибка в логе раз в 30 минут).

Что исправить

  • Карантин объекта: после N неудач сохранять пакет без него, писать объект и причину в журнал/отдельную таблицу и подтверждать пакет, поднимая ERROR; либо явный режим «стоп» с метрикой.
  • Проверять на старте состав OData: для каждого объекта плана все его ТЧ должны быть в $metadata.

Как проверить

Оффлайн: $metadata без _RowType табличной части при пакете с её строками.

Замысел и его граница

DESIGN.md обосновывает «упал merge — пакет придёт снова» как at-least-once и это верно для временных сбоев. Для детерминированных ошибок документ решения не предлагает; тезис «повтор безвреден» оспаривается: повтор бесконечен и блокирует остальные объекты.

04

Ключи, типы и значения

Пустые строки, составные измерения, float и надгробия: значение либо не доезжает, либо склеивается.

CDC-14

Составное примитивное измерение выпадает из первичного ключа: значение уходит в X_Value, а в ключе остаётся нулевой GUID

Ключи и типыподтверждено по коду
Высокая

metadata_reader.py 271–287, 292–302 · data_reader.py 255–276, 948–953, 971–974

Механизм

Для поля X с парным X_Type метаданные принудительно назначают X = Guid и заводят синтетическую колонку X_Value = String, но в первичный ключ попадают только исходные X и X_Type. Парсер примитивного значения пишет X = None, X_Value = '…'; обязательный ключ X затем получает нулевой UUID из _default_key_value. Две записи с разными примитивными значениями измерения дают одинаковый PK (UUID(0), 'String').

Сценарий

независимый регистр, измерение «Объект» составного типа (Справочник, Строка, Число)
запись 1: Объект = 'A' (Edm.String)  → PK (00000000-…, 'String'), X_Value='A'
запись 2: Объект = 'B' (Edm.String)  → PK (00000000-…, 'String'), X_Value='B'
staging: дубликат PK → ошибка вставки, либо вторая перезаписывает первую

Последствия

Потеря или склейка записей регистров с составными измерениями; при ошибке уникальности — весь пакет не сохраняется и цикл встаёт (см. CDC-23).

Что исправить

  • Хранить составной ключ без потерь: включать X_Value (или каноническое текстовое представление значения) в ключ таблицы для полей, входящих в PK.
  • Не использовать нулевой GUID как замену повреждённого/примитивного значения ключа — только для честной пустой ссылки.

Как проверить

Оффлайн: $metadata с ключом из составного поля и два entry с разными строковыми значениями; проверить число строк после save.

Замысел и его граница

DESIGN.md («Составной тип — не обязательно ссылка») объясняет соседнюю колонку _Value для реквизитов, но рассматривает только неключевые поля. Про составное поле в первичном ключе документ молчит — тезиса, который можно было бы принять, нет.

CDC-23

У полностью опустевшего набора ресурсы не гасятся: сброс в NULL зависит от состава колонок текущей пачки

Данные · надгробияподтверждено по коду
Средняя

db_writer.py 151, 182–200 · data_reader.py 1031–1054 · README_DB.md 196–203

Механизм

_resource_reset_values берёт ресурсы только из ключей records[0] — «dbmerge требует существующую колонку». Фиктивная запись пустого набора содержит лишь ключ и служебные поля, поэтому словарь пуст: старые строки помечаются, но их суммы остаются. Если в той же пачке есть другой, непустой набор того же регистра, колонки есть, и сброс внезапно работает. Документация обещает «вторую линию обороны для SUM без фильтра» безусловно.

Сценарий

Документ распроведён (набор движений пуст). Витрина SUM("Zakazano") без фильтра по флагу (или SUM(...) * (NOT flag)::int — здесь как раз защищено) продолжает считать распроведённые движения.

Последствия

Неверные агрегаты в запросах, доверившихся документации; результат зависит от случайного соседства в пакете.

Что исправить

  • Определять список ресурсов по фактической схеме целевой таблицы (она уже отражена в dbmerge), а не по records[0]; для фиктивной записи заполнять ресурсы NULL явно.

Как проверить

Оффлайн: test_noise_and_tombstones.py проверяет только сокращённый непустой набор; добавить случай пустого набора.

Замысел и его граница

README_DB объясняет обнуление как страховку; ограничение «только колонки текущей пачки» — реализационное, замыслом не обосновано.

CDC-36

Табличная часть, пришедшая как xsi:nil или пустой скаляр, не создаёт надгробие: старые строки остаются живыми

Данные · табличные частипри указанных условиях
Средняя

data_reader.py 1129–1136, 1153–1170 · PLAN.md 116–118 · msg005_zakaz_klienta_2.xml пустые ТЧ как <d:X m:type="Collection(…)"/>

Механизм

Отбор ТЧ: isinstance(v, dict) and v.get('@xsi:nil') != 'true'. Элемент с xsi:nil="true" отбрасывается целиком; пустой элемент без атрибутов — не словарь; словарь с m:null="true" без m:type проходит отбор, но пропускается в _get_entity_records (нет @m:type). Во всех трёх формах фиктивная запись не создаётся и ранее загруженные строки ТЧ остаются с is_deleted_or_empty=false. В записанных ответах платформы 8.5 опустевшая ТЧ приходит как <d:X m:type="Collection(…)"/> — эта форма обрабатывается правильно. PLAN.md сам помечает формат nil как «уточнить».

Сценарий

Платформа/публикация, отдающая очищенную ТЧ через xsi:nil: после очистки ТЧ в 1С строки в БД живы, суммы не обнулены.

Последствия

Условный: зависит от формата ответа конкретной платформы. Полная выгрузка владельца с тем же nil-ответом не исправляет.

Что исправить

  • Опознавать ТЧ по метаданным владельца (список ТЧ известен), а не по форме элемента; отсутствие строк в любой форме — надгробие.

Как проверить

Оффлайн: entry с {'@xsi:nil':'true','@m:type':'Collection(…)'}.

Замысел и его граница

PLAN.md признаёт вопрос открытым — оспаривать нечего; находка условная, важность средняя.

CDC-37

Edm.Double разбирается через float до NUMERIC: значения с более чем 15–17 значащими цифрами теряют точность до записи

Ключи и типыподтверждено по коду
Средняя

data_reader.py 885–888, 147–148 · metadata_reader.py 45

Механизм

_convert_value делает float(value), целевая колонка — Numeric(). Для типичных сумм (до 15 значащих цифр) кратчайшее представление float восстанавливает исходную десятичную строку, поэтому 3333.33 доезжает точно. Потери начинаются с 16–17 значащих цифр: 9007199254740993 становится 9007199254740992, суммы вида 123456789012345.67 искажаются. Числа 1С допускают до 38 разрядов; для числового измерения в ключе разные значения могут слиться. _odata_literal для больших float отдаёт экспоненциальную форму, которую OData не примет.

Сценарий

Регистр бухгалтерии с количеством Число(15,6) и валютной суммой большого номинала: сверка rows_modified будет ненулевой из-за расхождения младших разрядов, а не из-за реальных изменений.

Последствия

Искажение точных величин выше границы float; для большинства конфигураций — латентно.

Что исправить

  • Разбирать Double в Decimal из исходной строки; согласовать _odata_literal и JSON-экспорт.

Как проверить

Оффлайн: _convert_value('9007199254740993', 'Double').

Замысел и его граница

Тип Numeric в type_mapping показывает, что точность считалась важной; промежуточный float этому замыслу противоречит.

05

Процессы, владение, время

Heartbeat, owner, параллельные обработчики, метаданные, остановка и гонки merge.

CDC-08

Истечение heartbeat принимается за доказательство rollback: живая, но замедленная транзакция теряет защиту границы и захвата

Процессы · отметки живостипри указанных условиях
Высокая

write_tracker.py 26–40, 196–215 · full_load_claim.py 44–53, 80–110 · replicator.py 1612–1625 · metadata_reader.py 578–597 · README_DB.md 343–350, 377–381

Механизм

Граница окна и захват объекта исключают строки, у которых heartbeat_at старше 90 с. Документация формулирует это как «транзакции умершего процесса СУБД уже откатила». Но отсутствие отметки 90 секунд не отличает мёртвый процесс от живого, который: ждёт блокировку в PostgreSQL, стоит на GC/паузе контейнера, исчерпал пул соединений (сами авторы описывают этот случай), потерял одно соединение из-за сетевого сбоя при живых остальных. Fencing'а нет: потерявший lease процесс продолжает писать и после «перехвата».

Сценарий

  • Граница. Merge A застрял на 3 минуты (блокировка на целевой таблице от чужого CREATE INDEX обработчика). Heartbeat-поток не получил соединения из пула (все у страниц выгрузки). boundary() исключает строку A, обработчик двигает last_run_at. A дописывает строки с merged_on левее — они потеряны для инкремента, хотя сигнал придёт.
  • Захват. Полная выгрузка объекта идёт 2 часа; heartbeat не продлился 90 с; расписание в другом контейнере захватывает объект и начинает вторую выгрузку. Первый процесс, не проверяя владение, дочитывает свои страницы и вызывает mark_full_loaded; оба пишут rows_modified и метрики поверх друг друга; 1С делает двойную работу.

Последствия

Потеря строк витрины и двойная нагрузка на 1С в условиях, которые документация относит к «эксплуатационным» (нехватка пула), но которые не сигнализируются ничем, кроме постфактум расхождений.

Что исправить

  • Для границы: держать реестр в той же транзакции, что merge, или брать её из pg_stat_activity/advisory lock живой сессии — истёкший heartbeat может отсекать строку только когда сессия-владелец точно отсутствует.
  • Для захвата: fencing token (номер эпохи в onecdc_metadata_objects), проверяемый перед каждой записью страницы и в mark_full_loaded; при потере lease — прервать прогон.
  • Отдельное соединение (или отдельный пул) для heartbeat-потоков, чтобы дефицит пула не превращался в ложную смерть.

Как проверить

Оффлайн: держать track() открытым дольше 90 с с заблокированным heartbeat (mock) и проверить, что boundary() его перешагивает; затем закоммитить merge и убедиться, что строки левее last_run_at.

CDC-09

Два процесса с одним именем обработчика исполняют витрину параллельно: CAS проверяется после записи и не откатывает результат

Обработчики · конкуренцияподтверждено по коду
Высокая

handlers.py 699–725, 760–791, 807–876, 901–976, 1008–1050, 1052–1067 · README_API.md «NAME … два одноимённых поделят одну отметку»

Механизм

Ограничение «блок и инкремент в одном потоке» действует внутри одного HandlerLoop. Ничто не мешает поднять второй процесс с тем же NAME (rolling restart, дублирование контейнера, запуск обработчиков «отдельно» при живом runner с теми же классами). Оба читают состояние, оба вызывают handle. Условие last_run_at == expected в _save_success проверяется после того, как пользовательский SQL закоммитился: «CAS вернул False» означает только, что отметка не сдвинута, но витрина уже переписана. Пересборка пишет rebuild_cursor и last_full_rebuild_dt вообще без CAS.

Сценарий

  • Процесс P1 выполняет блок пересборки за март (delete-merge месяца).
  • P2 (новая версия контейнера) видит cursor='2026-02', продолжает с марта же, затем инкремент двигает last_run_at.
  • P1 заканчивает свой мартовский блок позже и удаляет/переписывает группы, в которые P2 уже применил свежие изменения; last_run_at уже за ними — пересчитать их некому.

Для обработчика-отправщика во внешнюю систему это неконтролируемые двойные отправки.

Последствия

Молчаливое затирание свежих результатов витрины и двойные внешние эффекты при штатных операциях развёртывания.

Что исправить

  • Межпроцессный захват имени обработчика (advisory lock или та же схема owner/heartbeat, что у полной выгрузки) до исполнения handle/rebuild; при потере lease — прерывать.
  • До реализации — явно документировать «ровно один активный экземпляр на NAME» как обязательное условие.

Как проверить

Оффлайн: два HandlerLoop одного класса в двух потоках с handle, пишущим счётчик вызовов; убедиться, что оба выполняются за одно окно.

CDC-24

Шторм пересборок витрин при первичной выгрузке: каждая новая колонка, появившаяся на очередной странице, заказывает полную пересборку

Обработчики · пересборкаподтверждено по коду
Средняя

replicator.py 636–646, 932–937 · handlers.py 590–615, 778–791, 1047–1048 · DESIGN.md 1025–1029

Механизм

dbmerge заводит колонки по фактическим данным страницы; реквизит, пустой на первых страницах, добавляет колонку посреди прогона. На каждое added_fields репликатор ставит full_rebuild_is_required всем подписчикам таблицы. Обработчик стартует пересборку (окно с начала времён), снимает флаг на старте; следующая колонка ставит его снова — и пересборка повторяется после завершения текущей. За первичную выгрузку документа с 40 реквизитами, из которых половина редко заполнена, витрина будет пересобрана до 20 раз, каждый раз по неполным данным, конкурируя с самой выгрузкой за БД.

Сценарий

Первый запуск на базе с историей за 5 лет: полная выгрузка регистра идёт 6 часов; витрина по нему (пересборка 40 минут) стартует заново после каждой новой колонки и заканчивает лишь через несколько часов после выгрузки.

Последствия

Кратное удорожание первичной загрузки и длительная неконсистентность витрины; для отправщиков с ON_FULL_LOAD=True — повторные отправки «всего с начала времён».

Что исправить

  • Создавать колонки из $metadata при первом создании таблицы, а не по данным — тогда посреди прогона колонки не появляются.
  • Откладывать заказ пересборки до конца полной выгрузки объекта (одна пересборка на прогон).

Как проверить

Оффлайн: две страницы, вторая с новым полем; считать вызовы request_full_rebuild.

Замысел и его граница

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

CDC-25

Наивные отметки now()::timestamp немонотонны при переводе часов: в повторяющийся час окна обработчиков и guard'ы ломаются

Время · часы БДпри указанных условиях
Средняя

common_functions.py 12–22 · dbmerge.py 994–1001 · handlers.py 1029–1041 · full_load_keys.py 116–122 · write_tracker.py 208–215 · docker-compose.yml TZ

Механизм

Все служебные отметки (merged_on, last_run_at, started_at, heartbeat_at, отметки страниц) — timestamp without time zone, получаемые из now() через приведение по TimeZone сессии PostgreSQL. Если этот пояс имеет сезонный перевод (не «Europe/Moscow», а, например, «Europe/Berlin» или пояс хоста), то в ночь перехода на зимнее время локальное время возвращается на час: now()::timestamp идёт назад. Обработчик, зафиксировавший last_run_at = 02:59, следующим окном получит boundary = 02:05; условие boundary <= window_start заставит его ждать час, а все строки, смёрженные в повторный час, получат merged_on < last_run_at и никогда не попадут в merged_on > last_run_at. Guard'ы полной выгрузки и TTL heartbeat в этот час тоже сравнивают несравнимое.

Сценарий

PostgreSQL с timezone = 'Europe/Berlin' (пояс сервера, не переопределённый в postgresql.conf), контейнер с TZ=Europe/Moscow. Последнее воскресенье октября: ночные пакеты между 02:00 и 03:00 (второй раз) не попадают в витрину.

Последствия

Раз в год потеря окна инкремента без ошибок; кроме того, TZ процесса не влияет на пояс сессии PostgreSQL, что документация не оговаривает.

Что исправить

  • Хранить служебные отметки в timestamptz либо принудительно SET TIME ZONE 'UTC' для сессий engine (событие connect) и документировать требование.

Как проверить

Оффлайн: SET TIME ZONE 'Europe/Berlin' и SELECT now()::timestamp вокруг момента перевода (можно с pg_sleep в тестовом контейнере с подменой времени) — воспроизводится без 1С.

Замысел и его граница

DESIGN/README обосновывают наивные отметки «одним часовым поясом БД и совпадением с merged_on» — согласны, что внутри одной сессии значения согласованы. Оспаривается допущение, что локальное время монотонно: оно верно лишь для поясов без сезонного перевода.

CDC-26

Реестр объектов синхронизируется с удалением, а словарь метаданных мутируется под чужими потоками: стираются захват и состояние, возможен RuntimeError

Метаданные · реестрподтверждено по коду
Средняя

metadata_reader.py 322–337, 339–408, 460–519 (delete_mode='delete'), 570–576 · replicator.py 1456–1459, 578–581 · data_reader.py 654–668, 844–847, 932–935

Механизм

Три независимых слабости одного жизненного цикла:

  • _fetch_and_parse_metadata только добавляет ключи в self, никогда не удаляя; поэтому объект, пропавший из $metadata, остаётся в памяти до рестарта и в _sync_objects(list(self.keys())). Обещанное «пропавшие удаляются из реестра» до рестарта не выполняется.
  • Зато после рестарта _sync_objects с delete_mode='delete' удаляет строку вместе с last_full_load_dt, full_load_is_required, full_load_owner и full_load_heartbeat_at. Синхронизация вызывается лениво из любого потока при незнакомом поле, то есть и во время чужой выгрузки: временное исчезновение объекта из публикации (перенастройка состава OData внешней обработкой проекта, обновление конфигурации) стирает живой захват — heartbeat/release обновляют 0 строк, следующая синхронизация вставляет строку с пустым владельцем, и второй процесс захватывает объект параллельно первому; mark_full_loaded обновляет 0 строк, история выгрузки теряется.
  • get_metadata перестраивает словарь под блокировкой, но читатели её не берут: _full_load_tables, _find_ext_dimension_chart, resolve_object_name итерируют self.metadata из потоков выгрузки. Одновременная вставка ключа из потока изменений даёт RuntimeError: dictionary changed size during iteration — страница или пакет падают и повторяются.

Дополнительно is_loaded = True ставится до _sync_objects: при её отказе следующий run_once считает метаданные загруженными и работает с objects_table = None; пустой $metadata оставляет objects_table = None, и list_full_load_required падает на None.c. Ответ с несколькими Schema превращает словарь в список — формат условный, на живой 1С не подтверждён.

Сценарий

Администратор выполняет в внешней обработке «Включить план и его объекты в OData»: состав переустанавливается целиком; в это время $metadata на секунды отдаёт неполный список; репликатор, встретив новое поле, перечитывает метаданные и удаляет из реестра половину объектов вместе с их захватами и отметками. Через минуту объекты возвращаются «новыми» и уходят в повторную полную выгрузку.

Последствия

Потеря состояния выгрузок (дорогие повторы), двойные выгрузки, спорадические падения страниц.

Что исправить

  • Собирать новый словарь отдельно и публиковать атомарной заменой ссылки; is_loaded — только после успешной синхронизации.
  • Реестр: не удалять строки, а помечать «отсутствует в $metadata с …»; захват и отметки не трогать никогда автоматически.
  • Читателям брать снимок (list(self.metadata) под тем же lock) либо использовать неизменяемые словари.

Как проверить

Оффлайн: test_metadata_objects.py зовёт _sync_objects напрямую; добавить повторный разбор двух разных $metadata и параллельную итерацию.

Замысел и его граница

DESIGN.md обосновывает ленивое перечитывание метаданных и синхронизацию через dbmerge (delete). Согласны с ленивостью; оспариваем удаление строк реестра, хранящих захват и историю: документ сам называет реестр единственным межпроцессным заслоном, а его строки уничтожаются побочным эффектом другой операции.

CDC-27

Сигнал остановки теряется до создания цикла, а после SIGTERM очередь полных выгрузок дорабатывается целиком; grace period контейнера этого не покрывает

Остановка процессаподтверждено по коду
Средняя

stop_signal.py 33–36, 48–67, 71–86 · replicator.py 1517–1518, 1541–1566, 1599–1610 · cron_runner.py 121–131 · docker-compose.yml stop_grace_period: 60s · metadata_reader.py 25

Механизм

Перехватчик сигналов лишь обходит уже созданные StopSignal; во время конструкторов, первого run_once или ручного full_load список пуст, и SIGTERM просто проглатывается — процесс продолжает работу, а повторный docker stop заканчивается SIGKILL. После старта цикла остановка ждёт конца итерации, а with ThreadPoolExecutor — завершения всех уже поставленных в очередь выгрузок (в очередь ставятся все помеченные объекты сразу); ни страницы, ни окна StopSignal не проверяют. Итерация может длиться 15 минут только на SelectChanges (read timeout), а очередь выгрузок — часы; stop_grace_period: 60s в примере гарантирует SIGKILL с незакрытыми merge и строками реестра до TTL. Конструкторы библиотеки к тому же заменяют обработчики сигналов приложения-хозяина без цепочки вызовов.

Сценарий

Rolling update при очереди из 12 объектов на первичную выгрузку: SIGTERM → «Replication loop stopping, waiting for full loads to finish» → через 60 с SIGKILL посреди merge → staging-таблица и строка реестра остаются, страницы перечитываются заново после рестарта.

Последствия

Непредсказуемое время остановки, «грязные» остатки в БД, невозможность корректно остановить процесс до первого цикла.

Что исправить

  • Процессное событие остановки, наследуемое новыми циклами; отмена не начатых futures (cancel_futures=True) и проверка stop между страницами/окнами без объявления прерванной выгрузки успешной.
  • В примере compose — stop_grace_period, согласованный с реальной семантикой, и документация.
  • Не ставить обработчики сигналов в конструкторах библиотечных классов; предоставить явный install_signal_handlers() для точки входа.

Как проверить

Оффлайн: отправить SIGTERM процессу до run_forever, затем запустить цикл — он не остановится.

Замысел и его граница

stop_signal.py подробно объясняет, почему перехват ставится в конструкторах (ThreadPoolExecutor и рабочие потоки). Аргумент принимается для контейнера; но он не объясняет ни потерю раннего сигнала, ни ожидание всей очереди. Тезис «останавливаемся после текущей итерации» оспорен: фактически — после всей очереди.

CDC-33

Публичный claim_full_load до загрузки метаданных «успешно» ничего не захватывает

Полная выгрузка · захватпри указанных условиях
Средняя

full_load_claim.py 88–92 · replicator.py 1568–1597

Механизм

При objects_table is None claim() возвращает True без строки в БД. Два процесса, вызвавшие claim_full_load на свежих экземплярах до первого get_metadata, оба входят в блок. Штатный FullLoadCron сначала разрешает имена (загружает метаданные), а фоновая диспетчеризация идёт после run_once — они не затронуты; затронут только прямой пользовательский вызов, который README_API описывает как «занять объект под выгрузку … в том числе ДРУГОЙ ПРОЦЕСС».

Сценарий

Своё расписание пользователя: with rep.claim_full_load(name): rep.full_load(name) в двух контейнерах одновременно на старте → двойная выгрузка.

Последствия

Двойная работа 1С в самом дорогом месте; данные не портятся (guard'ы).

Что исправить

  • В claim_full_load сначала обеспечить метаданные и реестр, затем атомарный захват; не возвращать True «вхолостую».

Как проверить

Оффлайн: два Replicator без get_metadata, оба claim — оба True.

Замысел и его граница

Комментарий кода: «прямой вызов full_load должен отрабатывать и на пустой базе» — согласны для full_load, который намеренно не охраняется; но claim_full_load обещает именно охрану, и её отсутствие противоречит контракту.

CDC-35

Некорректные параметры зацикливают выгрузку, дают горячий цикл или отключают обработчик без ошибки

Валидация параметровподтверждено по коду
Средняя

replicator.py 314–325, 666, 943–949 · __main__.py 33–45 · handlers.py 390 · cron_runner.py 226–231 · common_functions.py 141–165

Механизм

  • full_load(batch_size=0): $top=0 → page=0, условие page < page_size ложно, skip не растёт — бесконечный цикл у объекта без оси даты.
  • request_timeout=(30,) — кортеж из одного элемента проходит проверку, но requests парой его не считает.
  • _number принимает NaN и inf: ONECDC_POLL_INTERVAL=nan делает wait пустым (горячий цикл), inf останавливает опрос после первого оборота.
  • Handler.ON = "Catalog_X" (строка) превращается в множество символов и не совпадает ни с чем: обработчик зарегистрирован и никогда не вызван.
  • _bound(timedelta(hours=12)) вычитает из date и теряет часы; date.max/окно у datetime.min — переполнение.
  • Границы с долями секунды: OData-литерал усекает микросекунды, а _marking_scope сравнивает исходный datetime — области чтения и пометки расходятся на краю; для aware-datetime смещение отбрасывается в OData и учитывается SQL-параметром.

Сценарий

См. перечень; каждый пункт воспроизводится вызовом с указанным значением.

Последствия

Зависания, лишняя нагрузка, «мёртвые» обработчики без диагностики.

Что исправить

  • Валидировать положительные конечные batch_size/interval/timeout и форму пары; ON — только коллекция строк; нормализовать границы (усечение до секунды в обоих местах, только naive) и проверять порядок.

Как проверить

Оффлайн: вызовы с перечисленными значениями.

Замысел и его граница

Не обосновано; конструкторы проекта декларируют «ошибка конфигурации падает сразу и с внятным текстом» — перечисленные случаи из этого правила выпадают.

CDC-39

Одновременные merge одной таблицы (страница выгрузки и пакет изменений) могут столкнуться на уникальном ключе

Конкуренция · БДпри указанных условиях
Низкая

db_writer.py 128–162 · dbmerge.py 1183–1224 (INSERT … SELECT с anti-join, без ON CONFLICT)

Механизм

Ничто не сериализует merge одной целевой таблицы из разных потоков и процессов. Фаза INSERT dbmerge — INSERT … SELECT … LEFT JOIN … WHERE target.pk IS NULL; при READ COMMITTED две конкурентные вставки одного ключа обе проходят anti-join, вторая получает нарушение уникальности. Исключение роняет страницу (повтор со следующего цикла с нуля, см. CDC-22) или пакет (повтор, идемпотентно).

Сценарий

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

Последствия

Спорадические падения с самовосстановлением; в связке с CDC-22 — дорогие перезапуски выгрузки.

Что исправить

  • ON CONFLICT DO NOTHING/DO UPDATE в dbmerge для PostgreSQL либо advisory lock на таблицу на время merge.

Как проверить

Оффлайн: два потока save одного ключа в цикле.

Замысел и его граница

Гонка снимка и изменений рассмотрена в проекте на уровне значений (guard'ы), но не на уровне транзакций СУБД.

06

Безопасность и запуск

HTTP Basic, секреты в логах, SQL-помощник, URL, GUID узла, шаблон runner.

CDC-28

Handler.execute/query: имя схемы не экранируется, а str.format ломает легитимный SQL с фигурными скобками

Безопасность · SQLподтверждено по коду
Средняя

handlers.py 324–344 · replicator.py 295–302 · zakazy_klientov_grouped.py 230–232

Механизм

schema_prefix строит f'"{context.schema}"' вручную; _check_db_schema только strip(). Имя схемы с двойной кавычкой даёт синтаксически иной SQL (при недоверенной конфигурации — инъекция). Основной writer и _check_create_schema используют SQLAlchemy и защищены. Отдельно sql.format(schema=…) интерпретирует любые фигурные скобки: JSON-литерал '{"k":1}'::jsonb в DDL витрины даёт KeyError ещё до запроса. Параметры значений (**params) при этом передаются правильно.

Сценарий

ONECDC_DB_SCHEMA='onecdc"; DROP TABLE public.t; --'
Handler().execute(ctx, 'select 1 from {schema}."T"')   # исполняемый посторонний SQL
Handler().execute(ctx, "select '{\"k\":1}'::jsonb")   # KeyError: '"k"'

Последствия

Ошибка доверенной конфигурации, а не удалённая уязвимость (схему задаёт оператор), но публичный помощник непригоден для SQL с JSON.

Что исправить

  • Экранировать идентификатор через engine.dialect.identifier_preparer.quote_schema; валидировать имя схемы в конструкторе.
  • Подставлять только маркер {schema} (str.replace/string.Template), не форматировать весь текст.

Как проверить

Оффлайн: оба сниппета.

Замысел и его граница

Документация относит SQL обработчиков к пользовательскому коду с доверенной конфигурацией — с этим согласны, поэтому важность средняя. Дефект с фигурными скобками замыслом не объясняется.

CDC-29

Пароли, URL с userinfo, значения полей и полные трейсбеки попадают в исключения, лог и таблицу onecdc_handlers

Безопасность · раскрытие данныхподтверждено по коду
Средняя

replicator.py 198–223 · common_functions.py 165–171 · data_reader.py 900–902, 936–937 · handlers.py 1086–1089

Механизм

_check_odata_auth кладёт repr всего значения (включая пароль) в ValueError; _check_odata_url печатает URL как есть — с user:pass@, если его туда вписали; raise_for_status логирует контекст запроса, а для read_object это полный $filter с бизнес-ключами и датами. Неконвертируемое значение поля логируется целиком; last_error хранит 4000 символов трейсбека, куда попадают SQL, параметры драйвера и DSN — читает любой, у кого есть SELECT на схему. Предупреждение о неизвестном поле пишется на каждую запись страницы (лог-флуд на десятки тысяч строк).

Сценарий

Опечатка в конфиге: odata_auth=('odata','secret','x') → в трейсбеке и логе контейнера — пароль. Обработчик падает на SQL с литералами ИНН/сумм → они в onecdc_handlers.last_error для всех читателей схемы.

Последствия

Утечка учётных данных и бизнес-значений в журналы с более слабым контролем доступа.

Что исправить

  • Не выводить значения auth/URL; централизованное редактирование (hide_password, маскирование userinfo).
  • В last_error — тип и короткое сообщение; полный трейсбек только в лог. Предупреждение о неизвестном поле — один раз на объект.

Как проверить

Оффлайн: сниппет с кортежем из трёх элементов.

Замысел и его граница

Документация подчёркивает «понятные ошибки вместо трейсов» и скрывает пароль в DSN — замысел правильный, но применён не везде.

CDC-30

Basic Auth к 1С по незашифрованному HTTP во всех штатных примерах; проверка «https при пароле» отсутствует

Безопасность · транспортпри указанных условиях
Средняя

replicator.py 198–211 · README.md 303–315, 326–334 · docker-compose.yml ONECDC_ODATA_URL / ONECDC_ODATA_PASSWORD · runner.py 65–66

Механизм

Учётные данные передаются как HTTP Basic; библиотека принимает http:// с непустым паролем без предупреждения, и именно этот вариант дан во всех примерах. HTTPS при этом поддерживается, проверка сертификата не отключена, корпоративный CA задаётся стандартными переменными REQUESTS_CA_BUNDLE/CURL_CA_BUNDLE — отдельный параметр verify не требуется.

Сценарий

Публикация 1С на HTTP во внутренней сети; пароль пользователя с правами на план обмена читается любым наблюдателем трафика.

Последствия

Компрометация учётной записи OData; риск условный (зависит от сети), не обход авторизации.

Что исправить

  • Предупреждение (или ошибка без явного allow_insecure) при http:// с паролем; примеры — только https://; пароли в compose — через ${…}/secrets.

Как проверить

tcpdump на порт 80: заголовок Authorization: Basic ….

Замысел и его граница

Замысел «работает с любой публикацией» понятен; тезис не оспаривается, но безопасный дефолт примеров — обязанность проекта.

CDC-31

Имя плана обмена и значения ключей интерполируются в OData-URL без экранирования

Конфигурация · OData URLпри указанных условиях
Средняя

replicator.py 226–240 · change_reader.py 33, 60, 72 · data_reader.py 755–756

Механизм

_check_exchange_name запрещает лишь / и пробел; кавычка, &, # и пустое имя после снятия префикса проходят и ломают вложенный литерал DataExchangePoint='…ExchangePlan_X(guid'…')' (фрагмент # может отрезать MessageNo). read_by_key строит Поле='значение' без удвоения кавычек внутри значения, хотя _odata_literal для $filter это делает.

Сценарий

exchange_name="A'B" → 1С отвечает 400 → run_forever считает ошибку постоянной и уходит в 30-минутную паузу с невнятным сообщением.

Последствия

Ошибка доверенной конфигурации; удалённого вектора нет. Значения из 1С (Recorder_Type) кавычек не содержат.

Что исправить

  • Белый список идентификатора плана (^[\w]+$ с кириллицей); собирать query структурно; в read_by_key удваивать кавычки.

Как проверить

Оффлайн: сниппет.

Замысел и его граница

Не обосновано замыслом; проверка в конструкторе появилась именно ради «внятной ошибки на месте», но неполна.

CDC-32

Неверный или иначе записанный GUID узла не роняет запуск, а сбрасывает номер пакета к 1

Конфигурация · узел обменапри указанных условиях
Средняя

replicator.py 243–256 · change_reader.py 109–128

Механизм

GUID проверяется регулярным выражением без канонизации (верхний регистр допустим), сравнивается с Ref_Key узла как строка; при несовпадении get_last_received_no пишет WARNING и возвращает 0, и цикл запрашивает MessageNo=1. ThisNode здесь тоже не запрещён.

Сценарий

GUID скопирован в верхнем регистре из формы 1С → WARNING → SelectChanges?MessageNo=1 → ответ 1С на «чужой» номер (ошибка или пакет с неверной нумерацией) → backoff. Ошибка конфигурации маскируется под сетевую.

Последствия

Неверная последовательность обмена; чтение чужого узла не доказано.

Что исправить

  • Канонизировать через uuid.UUID и сравнивать как UUID; узел не найден или ThisNode — ValueError, как для пустого guid.

Как проверить

Оффлайн: фейковый сервер, guid в верхнем регистре.

Замысел и его граница

DESIGN.md обосновывает, что «всё состояние очереди живёт в 1С» — согласны; fallback к нулю этому замыслу противоречит: при ненайденном узле состояние неизвестно, а не равно нулю.

CDC-34

Шаблон runner.py: ручной max_workers оставляет добавленные циклы в очереди навсегда; auth обязателен в отличие от env-режима

Шаблон запускапри указанных условиях
Средняя

runner.py 55, 66, 118–131 · docker-entrypoint.sh 12–25

Механизм

Раскомментировав обработчики и расписания, но забыв max_workers и pool_size, пользователь получает единственный поток, навсегда занятый репликатором: задания обработчиков сидят в очереди ThreadPoolExecutor, витрины не обновляются, ошибок нет. Шаблон требует обе переменные auth (KeyError без пользователя), тогда как python -m onecdc в той же ситуации работает без авторизации: две раскладки одного образа ведут себя по-разному. Смонтированный runner игнорирует ONECDC_MODE, ONECDC_POLL_INTERVAL, ONECDC_LOG_LEVEL, ONECDC_FULL_LOAD_WORKERS, которые compose предлагает настраивать.

Сценарий

Смонтировать config/, раскомментировать два HandlerLoop и их submit, оставить max_workers=1: репликация идёт, onecdc_handlers.last_run_at остаётся NULL.

Последствия

Молчаливый отказ витрин при типовой правке шаблона.

Что исправить

  • Собирать список циклов и считать max_workers/pool_size от его длины; общую сборку auth вынести в функцию, используемую и entrypoint'ом; читать в шаблоне те же переменные окружения.

Как проверить

По шагам выше.

Замысел и его граница

Комментарии шаблона предупреждают об изменении пула — это осознанно; оспаривается лишь то, что шаблон полагается на внимательность там, где можно посчитать.

07

Эксплуатация и контракт

Документация, пул, lockfile, субконто, журнал, секреты в тестах, семантика ON_FULL_LOAD.

CDC-38

Документация обещает свойства, которых код не даёт, и противоречит сама себе

Документация · контрактподтверждено по коду
Средняя

README_API.md 56–72, 76–78, 115–122 · README_DB.md 287–292 · CHANGELOG.md 104–112, 134–136 · DESIGN.md 438–441 · README_ENV.md 395–400 · cron_runner.py 169–190

Механизм

  • mark_missing: README_API в одном абзаце — «по умолчанию включено» и «по умолчанию выключено»; CHANGELOG — «по умолчанию выключено; у расписания флаг тот же FullLoadCron(..., mark_missing=True)», хотя параметра у расписания нет (test_marking_is_not_configurable).
  • CHANGELOG в Added перечисляет «keyset-пагинацию», DESIGN.md объясняет, почему keyset убран.
  • DESIGN.md ссылается на Replicator._period_partitions — метода нет (переименован в _load_by_windows).
  • list_objects описан как «состав плана обмена», а возвращает весь опубликованный $metadata без ТЧ; массовая выгрузка по списку охватит лишние объекты.
  • README_DB: «full_load() и FullLoadCron реестр не трогают» — первый прогон расписания вызывает mark_full_loaded.
  • HandlerContext.sources документирует changes/full_load, из БД приходит только db_signal.
  • README_ENV: env-режим «плюс полные выгрузки объектов, которые встали в очередь» — в режиме once диспетчеризации нет, новые объекты никогда не получат первичную выгрузку.
  • Комментарий «карта видов субконто строится один раз на процесс» — фактически на экземпляр DataReader, то есть на каждый вызов full_load.

Сценарий

Оператор, доверившись README_API, считает пометку выключенной и не ожидает ночных пометок; другой настраивает mark_missing у расписания и получает TypeError.

Последствия

Неверные эксплуатационные решения; часть документа описывает несуществующий код.

Что исправить

  • Согласовать документы с контрактом; отдельно описать bootstrap/реестр и разницу env-entrypoint и пользовательского runner.

Как проверить

Чтение перечисленных мест.

Замысел и его граница

Проект требует править README/DESIGN в том же коммите, что и поведение; перечисленное показывает, где правило не выдержано.

CDC-40

Расчёт пула в документации не учитывает max_overflow, а engine создаётся без pool_pre_ping

Пул соединенийподтверждено по коду
Низкая

__main__.py 62 · runner.py 55 · README_DB.md 354–381

Механизм

create_engine(pool_size=N) оставляет max_overflow=10: фактически доступно до N+10 соединений, поэтому «отметка живости, не получившая соединения» наступает позже, чем описано, а суммарное число соединений нескольких контейнеров может превысить max_connections сервера. Без pool_pre_ping первая операция после простоя (ночь, failover) получает «server closed the connection unexpectedly»; цикл переживает это backoff'ом, но heartbeat захвата в этот момент может не продлиться (см. CDC-08).

Сценарий

Три контейнера по 5+10 соединений против max_connections=40.

Последствия

Эксплуатационный; данных не портит.

Что исправить

  • Явно задавать max_overflow, pool_pre_ping=True, pool_recycle; поправить формулу в README_DB.

Как проверить

—

Замысел и его граница

README_DB подробно считает соединения — тем заметнее пропуск overflow.

CDC-41

Нет lock-файла и закрепления зависимостей: CI, публикация и образ собираются из «последних» версий

Поставка · воспроизводимостьподтверждено по коду
Низкая

pyproject.toml dependencies · .github/workflows/ci.yml: uv sync / uv publish · Dockerfile FROM python:3.13-slim, pip install · AGENTS.md: «creates the locked development environment»

Механизм

В дереве нет uv.lock (в .gitignore он не исключён — просто отсутствует), поэтому uv sync в CI разрешает зависимости заново при каждом запуске, а тест-матрица не воспроизводима. Образ ставит onecdc[postgres]==X с открытыми диапазонами зависимостей и без дайджеста базового образа. onecdc опирается на нестандартные возможности dbmerge (insert_condition, delete_mark_values, added_fields) с нижней границей >=1.0.22 без верхней.

Сценарий

Мажорное обновление dbmerge/SQLAlchemy меняет поведение merge; CI зелёный на вчерашних версиях, образ собран сегодня.

Последствия

Риск поставки, не дефект логики.

Что исправить

  • Закоммитить uv.lock, верхние границы или точные версии в образе, дайджест базового образа.

Как проверить

—

Замысел и его граница

Установка образа с PyPI обоснована в Dockerfile и CHANGELOG (версия образа = версия пакета) и принята; в дефекты попадает только незакреплённость версий.

CDC-42

Поиск плана видов субконто прерывает выгрузку любой ошибкой, кроме 404

Регистр бухгалтерии · субконтопри указанных условиях
Низкая

data_reader.py 654–668

Механизм

_find_ext_dimension_chart опрашивает прямым адресом каждый ChartOfCharacteristicTypes конфигурации; 404 трактуется как «не здесь», любой другой код (403 на план без прав; 500 на неопубликованный) — raise_for_status и падение страницы или пакета. Ошибка повторяется на каждом прогоне, пока карта не построена (см. CDC-20 про блокировку очереди).

Сценарий

У пользователя OData нет чтения на «Виды субконто» другого учёта — каждое чтение регистра бухгалтерии падает с 403.

Последствия

Условный; блокирует регистр бухгалтерии целиком.

Что исправить

  • Пропускать планы с 403/500 с предупреждением; искать вид субконто через план счетов, где он объявлен.

Как проверить

Оффлайн: mock с 403 на первый план.

Замысел и его граница

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

CDC-43

Журнал загрузок и лог растут без ограничений; предупреждения о неизвестном поле пишутся на каждую запись

Эксплуатация · служебные данныеподтверждено по коду
Низкая

db_logs.py 71–78 · data_reader.py 936–937 · write_tracker.py 40 (уборка только реестра merge)

Механизм

onecdc_replicator_log получает строку на объект на пакет (при опросе раз в минуту и десятке объектов — миллионы строк в год) без индекса и без политики хранения. Предупреждение «Metadata field … not found» выводится на каждую запись страницы после однократного перечитывания метаданных — десятки тысяч строк лога за одну выгрузку. Остатки staging-таблиц tmp_*/tmpkeys_* после падений именуются для ручной уборки, но автоматической уборки нет.

Сценарий

Год эксплуатации: таблица журнала в гигабайтах, мониторинговые запросы по finished_at IS NULL — полным сканом.

Последствия

Эксплуатационный.

Что исправить

  • Индекс по started_at, ретенция; предупреждение — раз на объект; уборка staging по имени старше N часов.

Как проверить

—

Замысел и его граница

README_DB предназначает журнал для мониторинга; его рост не обсуждается.

CDC-44

Учётные данные и адреса лабораторного контура в репозитории; живой тест подтверждает пакеты в 1С

Гигиена репозиторияподтверждено по коду
Низкая

debug_trade.py 18–22 · debug_buh.py 18–22 · record_1c.py 25–29 · test_run_forever_live.py notify включён · docker-compose.yml литералы паролей

Механизм

Литералы паролей и внутренние адреса в трёх файлах; record_1c.py по умолчанию использует административную учётную запись. Для демо допустимо, для форка в корпоративный git — нет; .env.example, обещанный PLAN.md, отсутствует. Живой тест с включённым notify списывает очередь тестовой базы — при ошибочном указании боевого контура последствия необратимы.

Сценарий

Копирование debug_trade.py с боевыми параметрами в общий репозиторий.

Последствия

Низкий; действующих секретов не выявлено.

Что исправить

  • Переменные окружения и .env.example; защита живых тестов явным флагом подтверждения.

Как проверить

—

Замысел и его граница

AGENTS.md запрещает боевые DSN — правило есть, шаблоны его не поддерживают.

CDC-45

ON_FULL_LOAD=False отключает и сигналы об удалениях, которые находит только полная выгрузка

Обработчики · семантика ON_FULL_LOADподтверждено по коду
Низкая

replicator.py 1204–1211 · handlers.py 540–556

Механизм

Пометка пропавших строк сигналит с источником full_load; подписчик с ON_FULL_LOAD=False её не получает. Физические удаления обнаруживаются исключительно полной выгрузкой, поэтому обработчик-отправщик, отказавшийся от бэкфилла, узнаёт об удалениях лишь тогда, когда случайный сигнал по другой таблице откроет окно, в которое эти строки попадут (их merged_on поднят).

Сценарий

Отправка изменений в очередь сообщений с ON_FULL_LOAD=False: удаления доходят с задержкой до следующего живого изменения любой подписанной таблицы.

Последствия

Задержка событий удаления для внешних приёмников; данные не портятся.

Что исправить

  • Отдельный источник сигнала full_load_marking, не фильтруемый флагом бэкфилла.

Как проверить

Оффлайн: _mark_missing_rows с подписчиком on_full_load=False.

Замысел и его граница

Единый фильтр по источнику обоснован тем, что обработчик не различает источники по метке; граница замысла — объединение бэкфилла и обнаруженного удаления в один источник.

Порядок исправлений

Рекомендация «один процесс + HTTPS + ночной full_load» не закрывает no-op guard, потерю сигнала, OFFSET-пометку и разрезанные группы. До правок каждую из этих гарантий нужно считать ограниченной сценариями ниже.

W1
Границы commit и владения.

CDC-01 пустые строки, CDC-02 уникальный owner, CDC-07 durable-сигнал, CDC-13 fail-closed без ключа, CDC-15 404/XML fail-closed, CDC-06 часы наблюдения, CDC-09 один Handler на NAME, CDC-08 fencing lease.

W2
Полнота сверки.

CDC-04 новые строки группы, CDC-05 перепроверка всегда, CDC-10 ошибки окон, CDC-11 целый набор регистратора, CDC-12 каскад ТЧ, CDC-19 запрет прямой выгрузки ТЧ.

W3
Ключи и значения.

CDC-03 инъективный транслит, CDC-14 составной PK, CDC-23 ресурсы пустого набора, CDC-36 nil ТЧ, CDC-37 Decimal.

W4
Непрерывность.

CDC-16 catch-up подписок, CDC-17 неподдержанные классы, CDC-18 потолок байт, CDC-20 карантин, CDC-22 backoff страницы, CDC-26 реестр, CDC-27 остановка, CDC-35 валидация.

W5
Безопасность и контракт.

TLS при пароле, редактирование ошибок, SQL-помощник, URL/узел, claim, документация и шаблон — CDC-28…CDC-45.

Что принято как замысел

Не всё из каталога — «баг против авторов». Ниже — решения, которые код и DESIGN.md объясняют, и которые этот отчёт не оспаривает целиком. У каждого есть граница: она вынесена в связанную находку.

Снятая гипотеза. Одинаковые Number/Year документов разных месяцев не доказывают частичный агрегат в примере витрины: source_condition применяется к уже агрегированной view с MAX(Period). Это не дефект реализации. Уникальность номера нужно согласовать с зерном витрины отдельно.

Условия, без которых гарантии ещё уже

До исправлений W1–W2 это не «советы», а фактические ограничения развёртывания.

Покрытие, метод, пределы

Просмотрены все модули src/onecdc, env- и python-точки входа, оба примера витрин, Docker/entrypoint/CI/pyproject, текстовые модули формы и дросселя 1С, README*, DESIGN, PLAN, CHANGELOG и сценарии тестов. Записанный XML — контекст формата, не доказательство всех платформ. Бинарный onecdc.epf не декомпилировался. Живая 1С, роли, RLS, изоляция PostgreSQL, CVE и нагрузка не проверялись. Код проекта в этой работе не менялся. Отсутствие находки не означает доказанную безопасность.

Каталог — единый список CDC-01…45. Это не «аудит аудита»: нумерация этого отчёта, карточки с механизмом, сценарием и границей замысла. Сверка с черновиками в отчёт не входит.

ТестЧто покрываетЧего нет
test_version_guard.pyOLD_RUN/NEW_RUN, отдельные save с искусственными отметкаминет реальной отметки перед merge (скрывает CDC-04), нет A→B→A/no-op (CDC-06), нет параллельных handle и живой транзакции с истёкшим heartbeat
test_noise_and_tombstones.pyравные значения, сокращённый непустой набор, возврат строкинет полностью пустого набора без колонок ресурсов (CDC-23), нет очищенного во всех строках строкового поля (CDC-01)
test_composite_values.pyпримитив в дополнительном реквизите ТЧсоставное поле в первичном ключе (CDC-14) не покрыто
test_metadata_objects.pyпрямые вызовы _sync_objectsповторный разбор другого $metadata, параллельная итерация, удаление живого захвата (CDC-26)
test_full_load.pyпагинация, сужение окон, URL, стабильные страницыдвижущаяся выборка и неуникальная сортировка (CDC-05); таймаут вместо HTTPError (CDC-22); test_run_without_any_window_does_not_recheck закрепляет отсутствие перепроверки
test_mark_missing.pyпометка, guard по времени, перепроверка, бюджет запросаудаление владельца с ТЧ (CDC-12), 404 от прокси и HTML 200 (CDC-15), составные измерения в ключе (CDC-21)
test_handlers.pyкоалесценция, сигнал во время прогона, CAS, пересборкаокно commit→signal (CDC-07); test_restart_forgets_own_stale_writes закрепляет удаление чужих живых строк (CDC-02); два экземпляра одного NAME (CDC-09); кэш подписок (CDC-16)
test_accounting_ext_dimensions.pyсборка субконто, лимит сегмента URL, окно без Topотказ верхнего/хвостового окна и вечный повтор (CDC-10), набор регистратора на границе окна (CDC-11), не-404 при поиске плана (CDC-42)
test_pipeline_replay.py + CIполный цикл на записанных ответах, Python 3.10–3.14, PostgreSQL 16Fake1C моделирует metadata/SelectChanges/ACK, но не пагинацию OData и конкуренцию источника; NameMapper не покрыт вовсе

Улучшения, которые не стали дефектами

Session на ридер, pool_pre_ping, миграции служебных таблиц, наблюдение за отставанием, ретенция журнала, полные аннотации и линтер полезны. Их отсутствие само по себе не доказывает потерю данных. records=[] не мутируется; пустой py.typed корректен. Слепые ретраи POST SelectChanges/Notify опасны неоднозначным результатом POST — их нет сознательно.