Блог ·
Кожен виклик самого себе стояв у хвостовій позиції
Рекурсивний обхід мусив навчитися спинятися на півдорозі й тривати завтра. Переписування було точним, а не приблизним, і 43 тести це довели.
Автор: Provibr
Наші потоки автоматизації є графами. Спрацьовує тригер, обхід відвідує вузли, кожен вузол щось робить, і в якийсь момент в обходу закінчуються дроти, а виконання завершено. До цього тижня тим обходом була рекурсивна функція, яка викликала саму себе раз на кожен вихідний дріт, і це було добре доти, доки виконання завершувалося всередині запиту, що його розпочав.
Вузол погодження цілком руйнує це припущення. Виконання має спинитися, бути записаним і тривати завтра, коли хтось натисне кнопку. Половину стека викликів JavaScript не можна серіалізувати в стовпець бази даних.
Тож обхід став стековою машиною. Цікавим є не те, що він нею став, а те, що переписування було доказово тим самим обходом, а не новим, який, сподіваємось, поводиться так само.
Чому переписування є точним, а не приблизним
Кожен рекурсивний виклик у старому обході стояв у хвостовій позиції. Форма була буквально такою: await follow(node, port); return;. Після повернення з виклику не відбувалося нічого: жодного накопичення, жодного прибирання, жодної другої половини функції, що чекає на завершення свого нащадка.
Саме ця єдина властивість робить перетворення механічним. Обхід у глибину, чия рекурсія сидить у хвості, за побудовою є тотожним явному стеку, до якого ви кладете цілі у зворотному порядку. Там немає жодного рішення дизайну, яке можна зробити хибно, жодного питання про те, в якому порядку відвідувати сусідів, жодного ризику, що вузол, який раніше відвідували після його піддерева, тепер відвідають перед ним.
Наслідком, який нас цікавив, є те, що порядок відвідування лишився незмінним. Наявні тести обходу, сорок три з них, не чіпали, і вони лишилися зеленими. У цьому й полягає весь доказ: тести описують стару поведінку, їх написали до того, як хтось знав про це переписування, і їм не знадобилося жодної правки.
Якби рекурсія не стояла в хвостовій позиції, це було б переписуванням із доданим доказом сумісності, а доказам сумісності ви вірите доти, доки потік якогось клієнта не зробить чогось несподіваного у вівторок.
Що дає стек, чого не могла дати рекурсія
Дві речі, і обидві неможливі зі стеком викликів.
Першою є перерваність. Стан обходу є цілим обходом, вираженим як JSON: стек, відвідані вузли, накопичені змінні, лічильник і відкриті цикли. Він іде до стовпця jsonb у рядку очікування і повертається звідти незмінним. Виконання, яке поставлено на паузу, є рядком, а не призупиненим процесом, тож розгортання, перезапуск чи впалий контейнер його не втрачають.
Другою річчю є цикли. foreach чи repeat кладе на стек дороговказ, а потім іде своїм тілом. Коли стек спорожняється назад до того дороговказу, один прохід завершено, і цикл вирішує, чи буде наступний. Тому граф лишається орієнтованим ациклічним графом, немає жодного дроту, що вказує назад, і все, що тримається на ациклічності, працює далі: досяжність, відсутність циклів, топологічний перегляд потоку списком.
На останньому пункті варто спинитися. Очевидним способом додати цикли до редактора графів є дозволити користувачеві провести ребро назад до попереднього вузла. Це також є способом, що тихо руйнує кожну іншу гарантію, яку дає редактор, і ви дізнаєтеся про це, коли перевірка, написана рік тому, почне вичерпувати час.
Ідемпотентність у трьох шарах, кожен з яких робить щось інше
Виконання на паузі будить робітник через мережу. Робітники повторюють спроби. Тож питанням, яке вирішує, чи є ця можливість безпечною, є те, що станеться, коли те саме виконання розбудять двічі.
Механізмів три, і це не надлишковість. Кожен ловить щось інше.
| Шар | Чим він є | Що він ловить |
|---|---|---|
| Заявка | один UPDATE, що переводить рядок до стану заявки, але лише зі стану очікування | друге пробудження, що надходить, поки перше ще йде обходом |
| Частковий унікальний індекс на виконанні | щонайбільше один відкритий рядок очікування на виконання | саме існування двох рядків очікування для того самого виконання |
| Унікальний індекс на виконанні та позиції кроку | журнал кроків не може містити двох рядків на тій самій позиції | усе, що проминуло перші два |
Заявка є справжньою межею, а інші два є структурою. Причина полягає в тому, що побічні наслідки потоку, надісланий агентові наказ, сповіщення, вихідний запит HTTP, стаються під час обходу, а не після нього. Тож єдиним правильним місцем спинити другого робітника є момент перед тим, як він рушить обходом, а не тоді, коли він спробує записати свої результати. Хто отримує з того оновлення нуль рядків, той не поновлює виконання, і на цьому кінець.
Повтор, що зазнає невдачі, є іншою проблемою, ніж повтор, що дублює. Є стеля у пʼять спроб із відступом на одну, дві, чотири та вісім хвилин, бо обхід, що кидає помилку при поновленні, вилучений шаблон, сервер, якого більше немає, інакше повторювали б на кожному такті вічно, з таблицею черги, що ніколи не порожніє.
Чергою керує один стовпець
Робітник ставить рівно одне питання: які очікування настали. Це стан і час пробудження, і більше нічого.
Крайній строк є часом пробудження в майбутньому. Ухвалити рішення означає поставити час пробудження на зараз. Другого шляху до виконавця навмисно немає, немає схеми, де серверна дія поновлює виконання сама, а робітник теж може це зробити. Два шляхи були б перегонами між ними, і ті перегони були б невидимими, бо обидва шляхи дають виконання, що виглядає правильним.
Це також зробило другий різновид очікування дешевим. Коли зʼявилася тривка затримка, у черзі не змінилося нічого. Це є рядком із часом пробудження і без прикріпленої до нього людини.
Дві таблиці, бо вони живуть різним життям
Механізм і продукт є окремими таблицями, а спокуса злити їх є сильною, бо в перший день вони співвідносяться один до одного.
Рядок механізму відповідає на питання «коли це виконання має тривати і з чим»: продовження, час пробудження, заявка, лічильник спроб. Він є внутрішнім, він є загальним, і його вилучають, коли він відслужив своє.
Рядок продукту відповідає на питання «що запитали, у кого і що вирішили»: заголовок, пояснення, крайній строк, той, хто вирішив, позначка часу. Його читає клієнт. Його перечитує аудитор через рік. Він переживає виконання, якому належав.
Злиття їх дає вам таблицю, де половина стовпців є порожньою, щойно зʼявляється другий різновид очікування, а саме це й сталося, коли через день надійшла тривка затримка.
Дрібні рішення, які легко зробити хибно
Вузол зветься approval, а не approval.wait. Крапка є роздільником простору імен у наших каталогах перекладу, тож вид вузла, що її містить, дає ключ, який розвʼязується у вкладений обʼєкт замість рядка. Це є тим різновидом обмеження, що живе в іншій системі, ніж та, яку ви редагуєте.
Сплив строку має три наслідки, а не чотири: погодити, відхилити або пропустити. Пропуск є тим, над яким треба було подумати, бо очевидний четвертий вибір, «лишити висіти», є не вибором, а відсутністю політики. Пропущене погодження не бере жодної гілки й каже про це в журналі, а це є відмінним від погодженого і відмінним від відхиленого.
Ухвалення рішення є окремим правом, а не властивістю модуля. Без цього людина, яка пише потік, сама затверджує власні кроки погодження, і весь вузол стає оздобою.
Тіло циклу є замкненою ділянкою. Ви не можете завершити виконання зсередини нього, а дріт зсередини тіла до чогось поза ним є помилкою перевірки, а не несподіванкою під час виконання. Вкладення спиняється на трьох рівнях, ітерації на ста на цикл і пʼятистах відвідинах на виконання, і вихід за будь-яку з цих меж є помилкою, а не обрізанням. Обрізання означає, що потік клієнта тихо зробив половину своєї роботи.
repeat не почнеться на шляху, якого ще не існує, бо відсутнє значення читається як хиба, зокрема й для перевірки на нерівність. Саме останнє застереження ловить людей: інтуїтивно «це поле не дорівнює done» має бути істиною, коли поля немає, а ставлення до нього в такий спосіб запускає цикли на даних, які не надійшли.
Три прогалини, які раунд знайшов, а не створив
Першою є код відмови для вузла дії без сервера, який існував від раунду, що його запровадив, і не був у жодному з девʼяти каталогів перекладу. На нього ніхто не натрапив. Тепер є сторожа, яка читає коди причин із файлів джерела, а не зі списку, написаного вручну, і вимагає речення для кожного з них у кожному з девʼяти каталогів. Першого разу вона стала червоною, з 83 знахідками.
Другою є те, що файли тестів цілої цієї підсистеми не були охоплені жодною конфігурацією перевірки типів. Вони не компілювалися в нічиїй збірці. Їх додавання виявило шість прогалин у типах, однією з яких був стан підсумку виконання, типізований як звичайний рядок, а це означає, що кожне порівняння з ним лишалося неперевіреним.
Третьою є те, що поведінку вузла запису до журналу ніде не описали на сторінці виконання, тож клієнт, дивлячись на завершене виконання, бачив крок, призначення якого лишалося непоясненим.
Жодна з них не була спричинена цією роботою. Усі три знайшли тому, що написання нового виду вузла означає вперше за місяці як слід прочитати навколишній код.
Що ми б записали
- Перевірте, чи стоїть ваша рекурсія в хвостовій позиції, перш ніж сперечатися про переписування. Якщо стоїть, перетворення на явний стек є механічним, а ваші наявні тести є доказом.
- Тести, які вам не довелося правити, є найсильнішим свідченням, яке ви можете дістати. Сорок три незмінені зелені тести кажуть більше, ніж сорок три нові.
- Серіалізуйте продовження, а не процес. Виконання на паузі має бути рядком, що переживає розгортання.
- Циклам у редакторі графів не потрібне зворотне ребро. Дороговказ на стеку тримає граф ациклічним, і все, що залежить від ациклічності, працює далі.
- Ідемпотентність належить перед побічними наслідками, а не після них. Заявіть роботу, а тоді робіть її.
- Чергою має керувати один стовпець. Два шляхи до того самого виконавця є перегонами, яких ви не побачите на випробуваннях.
- Механізм і продукт є різними таблицями, щойно зʼявляється другий різновид механізму, а це станеться раніше, ніж ви думаєте.
- Політика спливу строку без усталеного значення не є політикою. Назвіть наслідок, зокрема й той, що означає жодну з гілок.
- Погодження є правом, а не побічним наслідком змоги редагувати.
- Межа має бути помилкою, а не обрізанням. Половина потоку, що звітує про успіх, є гіршою за потік, що спиняється.
Для ясності про те, чим це є: перевірка злиття пройшла 16 із 16 кроків без жодного рядка невдачі, наскрізний запуск проти бази даних розробки був зеленим на 83, браузерний тест зі справжнім робітником шлюзу був зеленим на 27, де єдиною людською дією є клік, а робітник записує в журнал, що розбудив одне й поновив одне, тест полотна був зеленим на 37, а сканування доступності не знайшло нічого на пʼяти сторінках. Не доведено: виробнича збірка, гілки вичерпання спроб і застарілої заявки, вузли repeat і break поза чистим набором тестів, а також цикли, що містять вузол дії.
Із платформної роботи над Provibr. Що той самий граф мусив гарантувати ще до того, як усе це існувало, описано в Потік є графом, і граф має бути скінченним, а редактор, у якому його малюють, описано в Дріт, який можна тягнути, і рівно один тригер.