Skip to content

fix(etl): прекъснат derive да не се бърка с първо пускане - #337

Merged
todorkolev merged 1 commit into
mainfrom
fix/catchup-freshness-fallback
Sep 2, 2026
Merged

fix(etl): прекъснат derive да не се бърка с първо пускане#337
todorkolev merged 1 commit into
mainfrom
fix/catchup-freshness-fallback

Conversation

@todorkolev

Copy link
Copy Markdown
Collaborator

Планировчикът на --catchup се допитва до два свидетеля за докъде е стигнало зареждането, и двата са преходни по устройство:

  • raw_contracts е временният staging, който се събаря след всяко зареждане;
  • refresh-slice.sql зачерква data_freshness.as_of в първия си батч (setup, за да не твърди полуопреснена повърхност свежест) и го възстановява чак в globals - осемнайсет батча по-късно. Всеки батч е отделна атомарна група, тъй че всеки провал между тях оставя as_of празен.

Тогава и двата мълчат едновременно, и това е неразличимо от студена база - макар последствията да са различни. Студено пускане наистина иска целия поток. Прекъснат derive върху пълна повърхност обаче преизгражда от DEFAULT_FROM: 2020 - днес, започвайки с DELETE FROM contracts. Не това е поискал прекъснатият бяг. Наблюдавано локално: прекъснат amendments батч направи следващия catchup пълно преизграждане, което после гръмна на FOREIGN KEY.

Какво прави

  • Сервираният корпус различава двата случая - използван само като да/не, никога като дата. Нарочно не става трети воден знак: contracts носи дати на публикуване, не дните на кофите, от които се смята прозорецът (сервираната схема изобщо няма колона source), а дата на публикуване, изпреварваща своята кофа, би преместила прозореца отвъд кофи, които никога не са зареждани - тихо прескачане, което е по-лошо от всяко преизграждане. Затова: има редове, няма воден знак → отказваме и казваме защо.
  • Отказът сочи възстановяване, което наистина работи. Клонът без воден знак заковаваше derive: full, тъй че препоръчаното --from печаташе редовен план, а живият бяг после се отказваше от него (тесен прозорец + пълен derive е точно комбинацията, която assertDeriveWindowSafe брани). Сега клонът уважава изричен --derive; по подразбиране пак full, защото неговият подразбиращ прозорец тръгва от началото на потока.
  • Пътят с --work-db печата plan.derive, без да му се подчинява: строи нова work база от прозореца и я изпраща наедро. За опашката, която отказът препоръчва, това би заменило сервирания корпус с тази опашка. Там slice вече се отказва - и планът се смята преди пътят да изтрие заварената work база и да приложи миграциите, защото „няма да продължа" след нанесена щета не е отказ.

Студената база пак планира пълен backfill - това е заковано с тест.

Тестове

13 зелени (10 нови): отказ върху пълна повърхност със съобщение, което насочва; сондата е COUNT, не MAX, и само като последна инстанция; студена база пак планира пълен backfill; --from е аварийният изход; препоръчаното възстановяване е изпълнимо; съобщението назовава точно уважаваните флагове; --work-db отказва slice преди зареждане и без да трие заварената база, а пълен derive минава.

Фалшивият node в харнеса вече логва argv - иначе проверката „отказва преди зареждането" беше куха и не можеше да се провали.

Само за свързаните лица и общи подобрения - без нови функционалности.

@github-actions

Copy link
Copy Markdown

Test coverage

Workspace Lines Δ Branches Δ Functions Statements
apps/etl 75.43% +1.43pp 63.52% +5.32pp 70.00% 74.11%
apps/web 91.50% +0.50pp 83.11% +0.71pp 92.00% 90.34%
packages/config 92.85% +0.05pp 72.22% +0.02pp 92.85% 89.18%
packages/db 94.67% +0.17pp 79.32% +0.02pp 87.50% 91.75%
packages/ingest 90.42% +4.12pp 85.82% +5.42pp 82.05% 88.65%
packages/shared 95.50% +0.00pp 80.83% +0.03pp 92.30% 89.56%
packages/test-support 100.00% +0.00pp 100.00% +0.00pp 100.00% 100.00%
Total (informational) 92.01% 81.94% 88.37% 90.02%

✅ No workspace dropped below its baseline (tolerance 0.5pp).

📈 Coverage rose by more than 1pp — run node scripts/check-coverage.mjs --update locally and commit coverage-baseline.json to ratchet the threshold up.

@ydimitrof ydimitrof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Много добре обмислена промяна. Тя решава реален проблем с интегритета на данните: прекъснат derive (изтрито raw_contracts + NULL-нат data_freshness.as_of) вече не се бърка с първо пускане и не води до пълно предеривиране върху жива, попълнена повърхност. Резолюцията на плана е преместена ПРЕДИ каквото и да е докосване на work DB, така че отказът вече не унищожава съществуваща база — това е коректно и добре покрито с тестове. servedCorpusRows() умишлено е COUNT, а не дата, и скоупнат по namespace-ите на id — правилно решение, обяснено ясно. Тестовете са изчерпателни и добре мотивирани.

Нямам блокиращи забележки. Оставям една неблокираща препоръка за втвърдяване на проверката в --work-db пътя (allowlist вместо denylist), защото там евентуална неразпозната стойност на --derive е единственият остатъчен път към тиха загуба на данни.

Не открих враждебен код, инжекционни вектори или изтичане на тайни. SQL-ите ползват литерални LIKE шаблони без интерполация на вход.

Comment thread scripts/import.mjs Outdated

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Прегледах #337 при HEAD bfcf8531 — трасирано срещу actual code.

Коректно (потвърдено): Разграничението interrupted-derive vs first-run е издържано. resolveCatchupPlan ползва servedCorpusRows() само като yes/no, не като watermark (raw_contracts е torn-down след load, data_freshness.as_of е NULL по време на refresh-slice — двата witness-а са transient), затова refuse-ва вместо да гадае. Веригата fail-closed държи: refuse → drop transient staging; assertDeriveWindowSafe (line 527, преди всеки destructive derive) probe-ва EXISTS per full-clear таблица от @full-clear в normalize-raw.sql и fail-closed при непрочетен corpus.

Minor — bare --from без стойност заобикаля собствения refuse: arg('from') за --from без = връща true (helper, line 57–60), затова !arg('from') е false и refuse-ът на line 248 се прескача, независимо от servedCorpusRows(). Надолу String(arg('from') || DEFAULT_FROM) дава from="true". Това не е загуба на данни — validateDay вътре в assertDeriveWindowSafe гърми с windowFrom must be YYYY-MM-DD преди derive-а → hard fail-closed. Но операторът получава cryptic грешка вместо насочващото съобщение на самия guard. Fix: третирай arg('from') === true като липсващ (!arg('from') || arg('from') === true), за да стигне до refuse-а с правилното съобщение.

Иначе — clean. Triage роля → COMMENT (не approve); вердикт: коректно + fail-safe.

Comment thread scripts/import.mjs Outdated
@todorkolev
todorkolev force-pushed the fix/catchup-freshness-fallback branch from bfcf853 to 7609636 Compare September 1, 2026 10:39
@todorkolev

Copy link
Copy Markdown
Collaborator Author

Благодаря на двамата — и двете препоръки са приложени в 7609636.

@ydimitrof — allowlist вместо denylist. Проверих и опасението се потвърждава: validateDeriveMode се вика на ред 476, извън runWorkBackfill (389-471), тъй че този път никога не стига до нея. Проверка само за === "slice" пропускаше --derive=typo право към изпращането наедро. Сега е !== "full": всичко, което не се разпознава положително, се отказва. Когато провалът е тиха замяна на сервирания корпус, това е единственото безопасно подразбиране.

@lyubomir-bozhinov — гол --from. Точно както си го проследил: arg("from") връща true, !arg("from") е false, отказът се прескача и операторът получава windowFrom must be YYYY-MM-DD вместо съобщението, което обяснява какво всъщност е станало. Сега само низова стойност се брои за прозорец; --from без стойност стига до отказа с насочващото съобщение. Затворен провал беше и преди — но само единият вариант казва какво да направиш.

Два нови теста, по един на всяка находка; и двата падат при връщане на старото поведение (проверено с мутация). Целият пакет scripts/: 138 зелени.

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix-ът е коректен — потвърдено при HEAD 76096362. Bare --from без стойност вече не заобикаля refuse-а: rawFrom = arg('from')explicitFrom = typeof rawFrom === 'string' ? rawFrom : null (import.mjs:253), тъй че boolean-ът от bare --from става null; !explicitFrom && servedCorpusRows() > 0 (:254) гърми правилно, а String(explicitFrom || DEFAULT_FROM) (:269) пада на DEFAULT_FROM вместо на „true". Точно поправката от бележката, при това по-стегнато (всяко не-string → null). Нямам блокери.

latestLoadedDate се допитва до два свидетеля и двата са преходни по
устройство. raw_contracts е временният staging, който се събаря след
всяко зареждане. А refresh-slice.sql зачерква data_freshness.as_of в
ПЪРВИЯ си батч (setup - за да не твърди полуопреснена повърхност свежест)
и го възстановява чак в globals, осемнайсет батча по-късно; всеки батч е
отделна атомарна група, тъй че всеки провал между тях оставя as_of празен.

Тогава и двата мълчат едновременно - и това е неразличимо от студена база,
макар последствията да са различни. Студено пускане наистина иска целия
поток. Прекъснат derive върху пълна повърхност обаче преизгражда от
DEFAULT_FROM: 2020 - днес, започвайки с DELETE FROM contracts. Не това е
поискал прекъснатият бяг.

Сервираният корпус различава двата случая - използван САМО като да/не,
никога като дата. Нарочно не става трети воден знак: contracts носи дати
на публикуване, не дните на кофите, от които се смята прозорецът
(сервираната схема изобщо няма колона source), а дата на публикуване,
изпреварваща своята кофа, би преместила прозореца ОТВЪД кофи, които никога
не са зареждани - тихо прескачане, което е по-лошо от всяко преизграждане.
Затова: има редове, няма воден знак - отказваме и казваме защо.

Отказът сочи възстановяване, което наистина работи. Клонът без воден знак
заковаваше derive: 'full', тъй че препоръчаното --from печаташе редовен
план, а живият бяг после се отказваше от него: тесен прозорец с пълен
derive е точно комбинацията, която assertDeriveWindowSafe брани. Сега
клонът уважава изричен --derive (по подразбиране пак full, защото неговият
подразбиращ прозорец тръгва от началото на потока).

А пътят с --work-db печата plan.derive, без да му се подчинява: строи нова
work база от прозореца и я изпраща НАЕДРО, тоест се държи като пълен derive
каквото и да казва планът. За прозорец от началото на потока това е
безобидно; за опашката, която отказът препоръчва, би заменило сервирания
корпус с тази опашка. Там slice вече се отказва - и планът се смята ПРЕДИ
пътят да изтрие заварената work база и да приложи миграциите, защото
"няма да продължа" след като щетата е нанесена не е отказ.

13 теста: отказ върху пълна повърхност със съобщение, което насочва;
сондата е COUNT, не MAX, и само като последна инстанция; студена база пак
планира пълен backfill; --from е аварийният изход; препоръчаното
възстановяване е изпълнимо; съобщението назовава точно уважаваните флагове;
--work-db отказва slice преди зареждане и без да трие заварената база, а
пълен derive минава. Фалшивият node вече логва argv, за да е проверката
"преди зареждането" истинска, а не куха.

По ревютата:

Пазачът на --work-db става allowlist (!== 'full'), не denylist (ydimitrof).
validateDeriveMode се вика само по живия път - runWorkBackfill никога не
стига до него - тъй че неразпозната стойност на --derive подминаваше
проверка само за 'slice' и влизаше право в изпращането наедро. Когато
провалът е тиха замяна на сервирания корпус, единственото безопасно
подразбиране е "всичко, което не разпознавам положително".

Гол --from (без стойност) се брои за липсващ (lyubomir-bozhinov). arg()
връща true за флаг без стойност, тъй че отказът се прескачаше и операторът
получаваше по-късното "windowFrom must be YYYY-MM-DD" вместо съобщението,
което обяснява прекъснатия derive. Затворен провал и в двата случая, но
само единият казва какво да направиш.

15 теста (2 нови). Целият пакет scripts/: 138 зелени.
@todorkolev
todorkolev force-pushed the fix/catchup-freshness-fallback branch from 2deac18 to 80dd367 Compare September 2, 2026 13:23
@todorkolev
todorkolev merged commit ec60a3d into main Sep 2, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants