Независимый аудит · исходники · без живой 1С
В 0.2.1 нельзя считать доказанными гарантии «ACK означает полное сохранение», «повтор восстанавливает обработку» и «full_load не затирает свежее». Ниже — один каталог из 45 находок, а не сверка сверок.
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 листают, / ищет.
Три ветки, каждая из которых сама по себе расходится КХД с 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, и поле в запись не попадает вовсе. Дальше два этапа усиливают эффект:
Для чисел, дат, булевых и ссылок 1С присылает текстовое значение (0, 0001-01-01T00:00:00, false, нулевой GUID), они конвертируются и пишутся. Дефект касается ровно строковых полей — а это наименования, артикулы, комментарии, коды, PredefinedDataName, Значение_Value составных реквизитов.
Результат зависит от состава пачки: та же очистка «сработает», если по соседству окажется запись с непустым значением этого поля. Это делает дефект трудновоспроизводимым в тестах и незаметным в эксплуатации.
Систематическое расхождение КХД с источником по всем строковым полям, которые когда-либо очищались. Полная выгрузка не лечит (та же ветка парсера). Витрины, фильтрующие по пустому значению, дают неверный результат; «самопроверка CDC» через rows_modified = 0 ложно успокаивает.
Оффлайн: подать в read_data_entries entry с <d:Артикул/> после сохранения строки с Артикул='X' в ту же схему. Проверить значение колонки после DBWriter.save. Тесты test_noise_and_tombstones.py этот случай не покрывают: там сравниваются только равные значения и сокращённый набор.
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 этот урок не получил.
Три следствия одного решения:
Молчаливая потеря строк витрины в штатной, описанной в README раскладке «репликатор и расписание в разных контейнерах одного плана». Тот же owner ломает и отметку страницы полной выгрузки (page_started_at берётся из той же границы): снимок может затереть свежее изменение.
Оффлайн, без 1С: открыть track() в одном трекере, создать второй с тем же owner, вызвать boundary() из HandlerLoop между cleanup и выходом из track(). Ожидание: граница прижата к started_at; факт: now().
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/рефлексии на длинных не-русских именах. Обнаружить постфактум трудно: имена в БД выглядят правдоподобно.
Оффлайн: сниппет выше. Против живой 1С: два справочника с именами «Сер»/«Сэр» в плане обмена.
Сверка должна выравнивать данные. Здесь она вносит ложные удаления, затирает свежее или объявляет успех на неполном чтении.
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.
То же для набора движений регистра: перепроведённый документ с изменённой суммой и добавленным движением.
Полная выгрузка — заявленный способ «выровнять данные», но один прогон не сходится: результат сходится только за два прогона при неизменном источнике. Метрика last_full_load_rows_modified и самопроверка «должно быть 0» становятся неверными. В бухгалтерском регистре, где окно читается целиком, эффект тот же.
Оффлайн против PostgreSQL: сохранить группу [1,2] обычным save; затем save([1', 2, 3], full_load_started_at=writer.db_now()). Ожидание — три живые строки; факт — строка 3 отсутствует.
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). Сплошной прогон «в одну выборку» перепроверки не делает — а именно у него две независимые причины пропустить живую строку:
источник: [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С. Ошибка необратима для внешних приёмников (отправка события об удалении).
Оффлайн с фейковым сервером: две страницы, между запросами убрать первую строку из выдачи; проверить, что живая строка не помечена. Сейчас test_run_without_any_window_does_not_recheck утверждает противоположное поведение как желаемое.
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).
Полная выгрузка, призванная сверять данные, вносит устаревшее значение поверх актуального, и ни один сигнал об этом не приходит. Вероятность растёт с длительностью прогона (часы на больших регистрах) и активностью правок «туда-обратно» (проведение/отмена проведения).
Оффлайн: последовательность save(A), save(A, emn=2) (no-op), save(B, full_load_started_at=t между ними). Ожидание — A; факт — B.
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.
Неполная таблица регистра бухгалтерии, объявленная выгруженной; либо бесконечная нагрузка на сервер 1С без диагностики.
Оффлайн: read_accounting_register, отвечающий 500 на первый вызов; проверить, что full_load не возвращает успех. Для цикла — mock, всегда отвечающий 500, и таймер.
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, лишним сигналом и ложными событиями для внешних приёмников; при обрыве между окнами — ложная пометка остаётся.
Ложные пометки и обнуления ресурсов бухгалтерских проводок; в промежутке между окнами витрина считает по искажённым данным. Условие достижимости — регистратор с движениями в разных периодах; платформа этого не запрещает, код не проверяет.
Оффлайн: подать в DBWriter.save два «окна» одного регистратора с full_load_started_at и проверить флаги между ними.
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 при наличии его ТЧ в БД; проверить флаг строк ТЧ после full_load.
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 описывают ТЧ как допустимый объект.
Оффлайн: фейковый feed ТЧ с 4 строками одного владельца, batch_size=2.
DESIGN.md/код: «Отдельная сущность у табличной части в OData всё же есть, и full_load, если его натравить прямо на неё, будет листать её… ради такого случая ничья и разрешается». То есть режим осознанно поддержан для пагинации, но его взаимодействие со scoped-пометкой не рассмотрено. Тезис о поддержке режима оспорен: пагинация корректна, запись — нет.
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), либо ложная массовая пометка — в зависимости от реакции конкретной платформы.
Требует живой 1С: реакция на eq guid'…' по составному измерению. Статически подтверждена только генерация литерала.
DESIGN.md подробно описывает, почему по Recorder нельзя фильтровать, и делает для регистраторных регистров исключение (прямой адрес). Для независимых регистров с составными измерениями тот же аргумент применим, но исключение не сделано — тезис документа распространён на случай, который он сам не рассмотрел.
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С, отсутствие прогресса и невидимость проблемы (в логе — одна строка ошибки в час).
Оффлайн: mock read_object, бросающий ReadTimeout на N-й странице; проверить размер страницы и число повторов.
DESIGN.md: «В run_forever упавшая фоновая выгрузка повторится на следующем цикле — и начнёт уже с подобранного размера». Тезис верен только для отказов с HTTP-кодом; для таймаутов подобранный размер не меняется. Оспорено.
ACK, сигнал обработчику и повтор пакета — три разных факта. Код часто считает их одним.
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 закоммичен, а требование пересборки не записано — повтор колонку заново не «добавит».
Витрина/внешний приёмник отстаёт на неопределённое время; при enabled=false/паузе обработчика сигнал теряется совсем. Нарушено обещание «репликатор знает, когда данные изменились, и сам вызывает ваш код».
Оффлайн: подменить HandlerSignals.signal исключением на первом вызове, прогнать run_once дважды; проверить update_requested_at подписчика.
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 с ключом из поля неизвестного типа; убедиться, что run_once не подтверждает пакет.
DESIGN.md обосновывает мягкость к неизвестным типам полей («не ошибка метаданных») и к неподдерживаемым классам объектов. Ключ — другой случай: без него объект нельзя сохранить вовсе, и молчаливое подтверждение противоречит собственному инварианту документа «notify только после успешного save».
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 выглядят как «объект пуст».
Массовые ложные удаления при кратковременных инфраструктурных сбоях — самый тяжёлый исход из возможных для сверки.
Оффлайн: фейковый сервер, отдающий 404 без тела и 200 с HTML; проверить, что full_load падает, а не помечает.
DESIGN.md: «Отсутствие набора 1С сообщает честным 404 „Экземпляр сущности не найден“ — это ответ, а не ошибка». Тезис верен для 1С, но код не проверяет, что ответил именно 1С; в том же документе описан IIS, отдающий 404 по совсем другой причине. Тезис оспаривается на его же основаниях.
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 не двигает.
Про enabled: документация обещает «окно за время простоя не копят», но last_run_at не двигается, и первый сигнал после включения всё равно прочитает весь простой — только позже и по случайному поводу.
Отставание витрины на неопределённое время без ошибок в логах; поведение при выключении/включении расходится с документацией.
Оффлайн: HandlerSignals с прогретым кэшем, регистрация нового HandlerLoop, signal() в пределах 30 с — метки нет.
DESIGN.md обосновывает кэш экономией запросов «на каждую страницу полной выгрузки» — резонно, но окно потери сигнала при регистрации документ не обсуждает. Про enabled README_DB утверждает, что окно не копится; код показывает обратное. Оба тезиса оспорены.
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-сторона их только повторяет.
Остановка репликации плана целиком либо потеря изменений неподдержанных классов — в зависимости от состава пакета; поведение не соответствует ни документации, ни ожиданиям оператора.
Оффлайн: пакет только из CalculationRegister_*; проверить, что notify не шлётся и цикл повторяет номер.
DESIGN.md сознательно выбирает «пакет всё равно подтверждается, решение принимает человек» — с этой политикой для смешанного пакета можно согласиться как с осознанным компромиссом. Но код не реализует её для пакета только из неподдержанных объектов (там подтверждения нет, и очередь зацикливается), а собственный 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С.
Оффлайн: фейковый сервер с ответом 1 ГБ; наблюдать RSS.
DESIGN.md честно признаёт: «Размер пакета: со стороны клиента его ограничить нечем» и предлагает дроссель. Соглашаемся с невозможностью ограничить формирование пакета в 1С, но не с отсутствием защиты на приёме: клиент может отказаться от гигантского ответа до разбора и сообщить об этом, а не умирать по OOM. Находка сохранена.
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 минут).
Оффлайн: $metadata без _RowType табличной части при пакете с её строками.
DESIGN.md обосновывает «упал merge — пакет придёт снова» как at-least-once и это верно для временных сбоев. Для детерминированных ошибок документ решения не предлагает; тезис «повтор безвреден» оспаривается: повтор бесконечен и блокирует остальные объекты.
Пустые строки, составные измерения, float и надгробия: значение либо не доезжает, либо склеивается.
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).
Оффлайн: $metadata с ключом из составного поля и два entry с разными строковыми значениями; проверить число строк после save.
DESIGN.md («Составной тип — не обязательно ссылка») объясняет соседнюю колонку _Value для реквизитов, но рассматривает только неключевые поля. Про составное поле в первичном ключе документ молчит — тезиса, который можно было бы принять, нет.
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 — здесь как раз защищено) продолжает считать распроведённые движения.
Неверные агрегаты в запросах, доверившихся документации; результат зависит от случайного соседства в пакете.
Оффлайн: test_noise_and_tombstones.py проверяет только сокращённый непустой набор; добавить случай пустого набора.
README_DB объясняет обнуление как страховку; ограничение «только колонки текущей пачки» — реализационное, замыслом не обосновано.
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 признаёт вопрос открытым — оспаривать нечего; находка условная, важность средняя.
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; для большинства конфигураций — латентно.
Оффлайн: _convert_value('9007199254740993', 'Double').
Тип Numeric в type_mapping показывает, что точность считалась важной; промежуточный float этому замыслу противоречит.
Heartbeat, owner, параллельные обработчики, метаданные, остановка и гонки merge.
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 процесс продолжает писать и после «перехвата».
Потеря строк витрины и двойная нагрузка на 1С в условиях, которые документация относит к «эксплуатационным» (нехватка пула), но которые не сигнализируются ничем, кроме постфактум расхождений.
Оффлайн: держать track() открытым дольше 90 с с заблокированным heartbeat (mock) и проверить, что boundary() его перешагивает; затем закоммитить merge и убедиться, что строки левее last_run_at.
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.
Для обработчика-отправщика во внешнюю систему это неконтролируемые двойные отправки.
Молчаливое затирание свежих результатов витрины и двойные внешние эффекты при штатных операциях развёртывания.
Оффлайн: два HandlerLoop одного класса в двух потоках с handle, пишущим счётчик вызовов; убедиться, что оба выполняются за одно окно.
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 — повторные отправки «всего с начала времён».
Оффлайн: две страницы, вторая с новым полем; считать вызовы request_full_rebuild.
DESIGN.md признаёт: «для витрины лишняя пересборка — просто дорого». Соглашаемся, что одна лишняя пересборка приемлема; оспариваем оценку «просто дорого» — при загрузке истории это десятки пересборок, и цена сопоставима со всей выгрузкой. Находка сохранена со средней важностью.
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, что документация не оговаривает.
Оффлайн: SET TIME ZONE 'Europe/Berlin' и SELECT now()::timestamp вокруг момента перевода (можно с pg_sleep в тестовом контейнере с подменой времени) — воспроизводится без 1С.
DESIGN/README обосновывают наивные отметки «одним часовым поясом БД и совпадением с merged_on» — согласны, что внутри одной сессии значения согласованы. Оспаривается допущение, что локальное время монотонно: оно верно лишь для поясов без сезонного перевода.
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
Три независимых слабости одного жизненного цикла:
Дополнительно is_loaded = True ставится до _sync_objects: при её отказе следующий run_once считает метаданные загруженными и работает с objects_table = None; пустой $metadata оставляет objects_table = None, и list_full_load_required падает на None.c. Ответ с несколькими Schema превращает словарь в список — формат условный, на живой 1С не подтверждён.
Администратор выполняет в внешней обработке «Включить план и его объекты в OData»: состав переустанавливается целиком; в это время $metadata на секунды отдаёт неполный список; репликатор, встретив новое поле, перечитывает метаданные и удаляет из реестра половину объектов вместе с их захватами и отметками. Через минуту объекты возвращаются «новыми» и уходят в повторную полную выгрузку.
Потеря состояния выгрузок (дорогие повторы), двойные выгрузки, спорадические падения страниц.
Оффлайн: test_metadata_objects.py зовёт _sync_objects напрямую; добавить повторный разбор двух разных $metadata и параллельную итерацию.
DESIGN.md обосновывает ленивое перечитывание метаданных и синхронизацию через dbmerge (delete). Согласны с ленивостью; оспариваем удаление строк реестра, хранящих захват и историю: документ сам называет реестр единственным межпроцессным заслоном, а его строки уничтожаются побочным эффектом другой операции.
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-таблица и строка реестра остаются, страницы перечитываются заново после рестарта.
Непредсказуемое время остановки, «грязные» остатки в БД, невозможность корректно остановить процесс до первого цикла.
Оффлайн: отправить SIGTERM процессу до run_forever, затем запустить цикл — он не остановится.
stop_signal.py подробно объясняет, почему перехват ставится в конструкторах (ThreadPoolExecutor и рабочие потоки). Аргумент принимается для контейнера; но он не объясняет ни потерю раннего сигнала, ни ожидание всей очереди. Тезис «останавливаемся после текущей итерации» оспорен: фактически — после всей очереди.
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'ы).
Оффлайн: два Replicator без get_metadata, оба claim — оба True.
Комментарий кода: «прямой вызов full_load должен отрабатывать и на пустой базе» — согласны для full_load, который намеренно не охраняется; но claim_full_load обещает именно охрану, и её отсутствие противоречит контракту.
replicator.py 314–325, 666, 943–949 · __main__.py 33–45 · handlers.py 390 · cron_runner.py 226–231 · common_functions.py 141–165
См. перечень; каждый пункт воспроизводится вызовом с указанным значением.
Зависания, лишняя нагрузка, «мёртвые» обработчики без диагностики.
Оффлайн: вызовы с перечисленными значениями.
Не обосновано; конструкторы проекта декларируют «ошибка конфигурации падает сразу и с внятным текстом» — перечисленные случаи из этого правила выпадают.
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 — дорогие перезапуски выгрузки.
Оффлайн: два потока save одного ключа в цикле.
Гонка снимка и изменений рассмотрена в проекте на уровне значений (guard'ы), но не на уровне транзакций СУБД.
HTTP Basic, секреты в логах, SQL-помощник, URL, GUID узла, шаблон runner.
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.
Оффлайн: оба сниппета.
Документация относит SQL обработчиков к пользовательскому коду с доверенной конфигурацией — с этим согласны, поэтому важность средняя. Дефект с фигурными скобками замыслом не объясняется.
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 для всех читателей схемы.
Утечка учётных данных и бизнес-значений в журналы с более слабым контролем доступа.
Оффлайн: сниппет с кортежем из трёх элементов.
Документация подчёркивает «понятные ошибки вместо трейсов» и скрывает пароль в DSN — замысел правильный, но применён не везде.
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; риск условный (зависит от сети), не обход авторизации.
tcpdump на порт 80: заголовок Authorization: Basic ….
Замысел «работает с любой публикацией» понятен; тезис не оспаривается, но безопасный дефолт примеров — обязанность проекта.
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) кавычек не содержат.
Оффлайн: сниппет.
Не обосновано замыслом; проверка в конструкторе появилась именно ради «внятной ошибки на месте», но неполна.
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. Ошибка конфигурации маскируется под сетевую.
Неверная последовательность обмена; чтение чужого узла не доказано.
Оффлайн: фейковый сервер, guid в верхнем регистре.
DESIGN.md обосновывает, что «всё состояние очереди живёт в 1С» — согласны; fallback к нулю этому замыслу противоречит: при ненайденном узле состояние неизвестно, а не равно нулю.
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.
Молчаливый отказ витрин при типовой правке шаблона.
По шагам выше.
Комментарии шаблона предупреждают об изменении пула — это осознанно; оспаривается лишь то, что шаблон полагается на внимательность там, где можно посчитать.
Документация, пул, lockfile, субконто, журнал, секреты в тестах, семантика ON_FULL_LOAD.
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
Оператор, доверившись README_API, считает пометку выключенной и не ожидает ночных пометок; другой настраивает mark_missing у расписания и получает TypeError.
Неверные эксплуатационные решения; часть документа описывает несуществующий код.
Чтение перечисленных мест.
Проект требует править README/DESIGN в том же коммите, что и поведение; перечисленное показывает, где правило не выдержано.
__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.
Эксплуатационный; данных не портит.
—
README_DB подробно считает соединения — тем заметнее пропуск overflow.
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 зелёный на вчерашних версиях, образ собран сегодня.
Риск поставки, не дефект логики.
—
Установка образа с PyPI обоснована в Dockerfile и CHANGELOG (версия образа = версия пакета) и принята; в дефекты попадает только незакреплённость версий.
data_reader.py 654–668
_find_ext_dimension_chart опрашивает прямым адресом каждый ChartOfCharacteristicTypes конфигурации; 404 трактуется как «не здесь», любой другой код (403 на план без прав; 500 на неопубликованный) — raise_for_status и падение страницы или пакета. Ошибка повторяется на каждом прогоне, пока карта не построена (см. CDC-20 про блокировку очереди).
У пользователя OData нет чтения на «Виды субконто» другого учёта — каждое чтение регистра бухгалтерии падает с 403.
Условный; блокирует регистр бухгалтерии целиком.
Оффлайн: mock с 403 на первый план.
Перебор планов обоснован отсутствием навигационной ссылки и принят; реакция на коды, отличные от 404, замыслом не покрыта.
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 — полным сканом.
Эксплуатационный.
—
README_DB предназначает журнал для мониторинга; его рост не обсуждается.
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 с боевыми параметрами в общий репозиторий.
Низкий; действующих секретов не выявлено.
—
AGENTS.md запрещает боевые DSN — правило есть, шаблоны его не поддерживают.
replicator.py 1204–1211 · handlers.py 540–556
Пометка пропавших строк сигналит с источником full_load; подписчик с ON_FULL_LOAD=False её не получает. Физические удаления обнаруживаются исключительно полной выгрузкой, поэтому обработчик-отправщик, отказавшийся от бэкфилла, узнаёт об удалениях лишь тогда, когда случайный сигнал по другой таблице откроет окно, в которое эти строки попадут (их merged_on поднят).
Отправка изменений в очередь сообщений с ON_FULL_LOAD=False: удаления доходят с задержкой до следующего живого изменения любой подписанной таблицы.
Задержка событий удаления для внешних приёмников; данные не портятся.
Оффлайн: _mark_missing_rows с подписчиком on_full_load=False.
Единый фильтр по источнику обоснован тем, что обработчик не различает источники по метке; граница замысла — объединение бэкфилла и обнаруженного удаления в один источник.
Рекомендация «один процесс + HTTPS + ночной full_load» не закрывает no-op guard, потерю сигнала, OFFSET-пометку и разрезанные группы. До правок каждую из этих гарантий нужно считать ограниченной сценариями ниже.
Не всё из каталога — «баг против авторов». Ниже — решения, которые код и DESIGN.md объясняют, и которые этот отчёт не оспаривает целиком. У каждого есть граница: она вынесена в связанную находку.
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.py | OLD_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 16 | Fake1C моделирует metadata/SelectChanges/ACK, но не пагинацию OData и конкуренцию источника; NameMapper не покрыт вовсе |
Session на ридер, pool_pre_ping, миграции служебных таблиц, наблюдение за отставанием, ретенция журнала, полные аннотации и линтер полезны. Их отсутствие само по себе не доказывает потерю данных. records=[] не мутируется; пустой py.typed корректен. Слепые ретраи POST SelectChanges/Notify опасны неоднозначным результатом POST — их нет сознательно.