fix(etl): never pair a full derive with a partial catch-up window - #270
Conversation
`--catchup` escalated to `derive=full` whenever the gap exceeded LARGE_GAP_DAYS (14), but the catch-up window is gap-aware and only covers the tail of the feed. A full derive rebuilds the domain from staging (normalize-raw.sql opens with `DELETE FROM contracts`), so the corpus was rebuilt from those few weeks and every older contract was dropped and never reloaded. The two halves were never meant to go together: a full derive belongs with an explicit, feed-wide window, a gap-aware window with a slice derive. Only the "existing corpus + large gap" branch mismatched them. - catch-up now always derives a slice, however wide the gap; an explicit `--derive=full` still overrides - `assertDeriveWindowSafe` refuses a full derive whose window does not reach the start of the feed while a corpus exists, before the load rather than after the delete - the invariant lives in @sigma/ingest as `fullDeriveIsSafe`, unit-tested alongside computeCatchupWindow The deployed cron is unaffected: it refreshes every 6 hours so it never accumulates the gap, and RefreshWorkflow does not run normalize-raw. This bites local and recovery runs, exactly when a corpus is being restored.
|
Прегледах срещу дифа на връх Корпусът наистина се трие. Няма #158 регресия — проверих специално. Guard-ът е fail-closed преди зареждане. Единствената деструктивна комбинация ( |
Пазачът срещу пълен derive с частичен прозорец броеше редовете в `contracts`, а изчистването в normalize-raw.sql изпразва четиринайсет таблици. Корпус без договори, но с попълнени `tenders`, `bidders` или `authorities` - точно каквото оставя половинчат пробег - минаваше през пазача и презиждането ги отнасяше, без да има откъде да се върнат. Списъкът вече идва от самия SQL през нов знак `@full-clear`, вместо да е преписан в JS: преписът на един рушащ списък е бъг със закъснител. Обхватът спира преди `data_freshness` и `pipeline_stats` - те се пишат наново всеки пробег и ако се броят за корпус, пазачът отказва всичко, включително първоначалното зареждане. Проверката вали и когато не може да отговори: липсваща таблица правеше `safeD1` да върне празно, което се четеше като „няма корпус" и пускаше рушащия път. Отказът вече и разчиства преходния staging след себе си. Тестовете карат истинския `import.mjs` като отделен процес с подставени `wrangler` и `node`, защото проверката само на предиката е точно каквото пропусна този бъг. Мутацията „питай пак само за договорите" вали три теста. Описанието в docs/etl.md твърдеше, че отказът важи само при изрично `--derive=full`; всъщност важи и за подразбиращия се - поправено.
Test coverage
✅ No workspace dropped below its baseline (tolerance 0.5pp). 📈 Coverage rose by more than 1pp — run |
Ревюто счупи пазача с промяна, която в преглед изглежда като форматиране: `DELETE FROM "search_index";` е валиден SQLite, но разборът признаваше само голи имена, тъй че таблицата изпадаше от списъка и дупката се отваряше наново - корпус, в който само тя е попълнена, минаваше през пазача. По-лошото беше в теста. Той извеждаше очаквания списък от същия файл със свое копие на същия израз, тъй че наследяваше същата слепота: и двата набора оставаха зелени, докато пазачът пропускаше рушащия път. Оракул, който споделя допусканията на кода, не проверява нищо. Затова списъкът в теста вече е изписан на ръка, а отделна проверка държи него и SQL-а един срещу друг - всяко разминаване вали, вместо да изчезне. Разборът приема и трите форми кавички на SQLite. Поправено и твърдението в docs/etl.md, че `--catchup` **винаги** прави slice: при първо пускане, когато няма заредени дни, той взима прозорец от началото на емисията и пълен derive.
Поправката от #277 нямаше нищо, което да я пази: `import.mjs` изпълнява код при внасяне, тъй че `safeD1` не се тества модулно. Сглобката от #270 обаче кара истинския скрипт като отделен процес с подставен `wrangler`, и това е достатъчно. Подставката е сверена срещу истинския двоичен файл, а не измислена - при `d1 execute --json` върху липсваща таблица грешката от SQLite излиза на stdout, известията на stderr, а съобщението на изключението е само „Command failed". Точно това разминаване изпускаше старата проверка. Наблюдаваният изход е записан в теста. Мутациите: връщането на проверката само върху `err.message` вали два теста, а превръщането на safeD1 в общ капан вали третия.
Проблемът
pnpm run import --catchupизбирашеderive=full, щом празнината надхвърлиLARGE_GAP_DAYS(14 дни). Прозорецът на догонването обаче е gap-aware - той покрива само опашката на емисията (maxLoadedDateминус 3 дни lookback).Пълният derive презижда доменните таблици от staging:
scripts/normalize-raw.sqlзапочва с изчистване, а transient staging таблиците се пресъздават празни на всяко пускане и се пълнят само с прозореца. Тоест корпусът се възстановяваше от тези няколко седмици, а всичко по-старо отпадаше и не се връщаше.Двете половини никога не е трябвало да вървят заедно, точно както е описано в
docs/etl.md: пълен derive върви с изричен прозорец през цялата емисия, gap-aware прозорец върви със slice derive. Разминаваше се само клонът „съществуващ корпус + голяма празнина" - при липсващ корпус кодът вече прави правилното (from = DEFAULT_FROM+ пълен derive).Открито наживо: локално догонване с 49-дневна празнина избра
derive=fullвърху корпус от 193 023 договора.Промяната
--derive=fullпродължава да важиassertDeriveWindowSafeотказва пълен derive, чийто прозорец не стига до началото на емисията, докато има зареден корпус. Проверката е преди зареждането, не след триенето@sigma/ingestкатоfullDeriveIsSafe, доcomputeCatchupWindow, с тестовеdocs/etl.mdописва вече действителното поведениеLARGE_GAP_DAYSотпада - вече няма кой да го четеПоправка след ревюто: пазачът допускаше точно загубата, срещу която стои
Първата редакция питаше
SELECT COUNT(*) FROM contracts. Изчистването вnormalize-raw.sqlизпразва четиринайсет таблици.Значи корпус без договори, но с попълнени
tenders,biddersилиauthorities- точно каквото оставя половинчат пробег - минаваше през пазача, а презиждането ги отнасяше без откъде да се върнат. Пазачът срещу загуба на данни допускаше загуба на данни.Три неща в поправката:
@full-clear, вместо да е преписан в JS. Преписът на един рушащ списък е бъг със закъснител - ето го доказателството. Обхватът спира предиdata_freshnessиpipeline_stats: те се пишат наново всеки пробег и ако се броят за корпус, пазачът отказва всичко, включително първоначалното зареждане, а пазач, който винаги отказва, го изтриват;safeD1да върне празно, което се четеше като „няма корпус" - една липсваща таблица заслепяваше пазача за другите тринайсет и пускаше рушащия път;raw_contracts.Как е проверено този път
Проверката само на предиката е точно каквото пропусна този бъг:
fullDeriveIsSafeбеше зелена, докато мястото, което я вика, задаваше грешния въпрос.Затова
scripts/import-guard.test.mjsкара истинскияimport.mjsкато отделен процес, с подставениwranglerиnodeнай-отпред в PATH, и твърди за това какво скриптът е направил: отказал ли е, кои таблици е назовал, изчистил ли е след себе си, и тръгнало ли е зареждането. Отделноrefresh-full-clear.test.tsдържи разбора вързан за истинския SQL файл.Мутации:
contracts@full-clearе махнат от SQL-аcontractsВтори кръг ревю: тестът споделяше слепотата на кода
Ревюто счупи пазача с промяна, която в преглед изглежда като форматиране:
Валиден SQLite. Разборът признаваше само голи имена, тъй че таблицата изпадаше от списъка и дупката се отваряше наново - корпус, в който само тя е попълнена, минаваше през пазача и зареждането тръгваше.
По-лошото беше в теста. Той извеждаше очаквания списък от същия SQL файл със свое копие на същия израз, тъй че наследяваше същата слепота: и двата набора оставаха зелени, докато пазачът пропускаше рушащия път. Оракул, който споделя допусканията на кода, не проверява нищо - същата грешка по форма като първата, само едно ниво по-нагоре.
Поправката е в двете посоки: разборът приема и трите форми кавички на SQLite, а списъкът в теста вече е изписан на ръка, с отделна проверка, която държи него и SQL-а един срещу друг. Разминаване вали, вместо да изчезне.
Ревюто потвърди и че шестте искани мутации по производствения код падат, и че самата сглобка с подставените двоични файлове не може да мине празна (пет начина да я счупя дават от 1 до 7 провала).
Второто му намиране: описанието твърдеше, че
--catchupвинаги прави slice. При първо пускане, когато няма заредени дни, той взима прозорец от началото на емисията и пълен derive. Поправено в описанието.Обхват
Деплойнатият cron не е засегнат: пуска се на 6 часа, тъй че никога не натрупва такава празнина, а
RefreshWorkflowизобщо не викаnormalize-raw. Ударът е по локалните и възстановителните пускания, тоест точно когато някой възстановява корпус след срив.Описанието в
docs/etl.mdтвърдеше, че отказът важи само при изрично--derive=full. Всъщност важи и за подразбиращия се - без--catchupподразбиращият се derive еfull, тъй че иimport.mjs --from=2026-06-01получава същия отказ. Поправено в описанието, не в кода: отказът там е правилното поведение.Проверка
scripts/: 86 теста;@sigma/ingest: 59;tsc --noEmitчист--catchupмина 49-дневен прозорец със slice derive и reconciliation gate-ът мина (5 проверки, 1 пропусната, 1 предупреждение), тъй че slice derive държи и на дълъг прозорец