algorithms · intermediate · 4d
Планировщик сборки на топологической сортировке
Собери планировщик задач на основе DAG — как Make или CI-пайплайн — который упорядочивает задачи по зависимостям, обнаруживает циклы до дедлока и определяет, какие задачи можно выполнять параллельно.
Результат
Планировщик, который принимает граф задач, выдаёт детерминированный топологический порядок (алгоритм Кана с лексикографическим разрешением ничьей), бросает типизированный CycleError с именованием проблемного цикла на любом цикличном вводе, и группирует независимые задачи в параллельные батчи.
Этапы
0/6 · 0%- 01Смоделируй граф задач
До любой сортировки реши, что такое граф задач и какие операции он должен поддерживать. Граф сборки — это направленный ациклический граф (DAG): узлы — задачи, рёбра кодируют «A должна завершиться до B» (a→b означает, что A — предусловие B). Представление важно: список смежности по набору узлов масштабируется лучше матрицы для разреженных графов сборки (большинство задач зависят от нескольких других, а не от каждой). Смоделируй две вспомогательные структуры, которые тебе понадобятся в каждом последующем этапе: карту in-degree (сколько предусловий у каждой задачи ещё не выполнено?) и список смежности (для завершённой задачи, какие задачи она разблокирует?). Обе должны вычисляться за O(V+E). Изолированные узлы — задачи без зависимостей и без зависимых — валидны и не должны молча отбрасываться. Предоставь чистый конструктор, принимающий узлы как массив строк и рёбра как пары [предшественник, преемник], валидирующий, что каждый конец ребра — объявленный узел, и бросающий при неизвестных концах.
Критерии готовности- Карта in-degree и список смежности вычислены за O(V+E), а изолированные узлы присутствуют в обоих с in-degree 0 и пустой смежностью.
- Конструктор бросает описательную ошибку, когда ребро ссылается на узел, не объявленный в наборе узлов.
Самопроверка
Пройди по структурам in-degree и смежности для графа из 5 узлов с 4 рёбрами и двумя изолированными узлами; senior-ревьюер проверяет, что обе структуры покрывают все узлы, in-degree точен, и ты можешь назвать сложность построения каждой.
- 02Топологическая сортировка алгоритмом Кана
Реализуй алгоритм Кана: засей очередь всеми узлами с нулевым in-degree, многократно извлекай узел в результат и уменьшай in-degree его преемников — если преемник достигает нуля, добавь его в очередь. Это O(V+E) и итеративно, без риска переполнения стека рекурсии. Ключевой момент: этот алгоритм по сути недетерминирован, когда несколько узлов одновременно подходят (нулевой in-degree): в реальной системе сборки недетерминизм означает, что повторные сборки дают разный порядок, кэширование ломается, а CI не воспроизводим. Исправь это, используя min-heap или лексикографически сортируя кандидатов с нулевым in-degree перед обработкой — порядок стабилен, дёшев (дополнительно O(k log k) за шаг, где k — количество ничьих) и делает вывод чистой функцией от графа. Результат должен содержать каждый узел ровно один раз, включая изолированные.
Критерии готовности- Для каждого ребра [a, b] во вводе a появляется перед b в выходном массиве, и все узлы (включая изолированные) присутствуют ровно по одному разу.
- Сортировка детерминирована: для фиксированного графа повторные вызовы возвращают идентичный массив, и ты можешь назвать используемое правило разрешения ничьей.
Самопроверка
Для алмазной зависимости (D зависит от B и C, оба зависят от A) прослеживай состояние очереди после каждого шага извлечения и подтверди, что B и C обрабатываются в стабильном порядке; senior-ревьюер проверяет, что ты объясняешь, почему недетерминизм ломает инкрементальные кэши сборки.
- 03Обнаружение циклов и типизированный CycleError
Алгоритм Кана даёт обнаружение циклов бесплатно: если результат после обработки всех узлов с нулевым in-degree содержит меньше узлов, чем ввод, существует хотя бы один цикл — узлы, отсутствующие в результате, — это именно те, что застряли в цикле (все их предшественники тоже были в циклах, поэтому их in-degree никогда не достигал нуля). Это существенное преимущество перед обнаружением на основе DFS: ты получаешь узлы цикла без дополнительных затрат. Но «цикл обнаружен» — это не полезная ошибка. Система сборки должна сообщать оператору, какие задачи взаимно блокируют друг друга, поэтому CycleError должен раскрывать набор узлов, участвующих в цикле, а не просто булево значение. Для самопетли (a→a) сообщение должно называть этот узел; для двухузлового цикла (a→b→a) должны присутствовать оба. Сделай CycleError типизированным классом, расширяющим Error, с полем cycleNodes, экспортируй его, чтобы вызывающие могли проверить instanceof и извлечь цикл для отображения.
Критерии готовности- Любой цикличный ввод (включая самопетли) бросает CycleError (не общий Error), а поле cycleNodes содержит ровно те узлы, что застряли в цикле.
- CycleError расширяет Error и является именованным экспортом, чтобы вызывающие могли поймать его и проверить instanceof независимо от других ошибок.
Самопроверка
Для графа a→b→c→b (c и b образуют цикл, a стоит выше) отследи, какие узлы остаются в карте in-degree после остановки алгоритма, и объясни, почему a не входит в cycleNodes; senior-ревьюер проверяет, что ты получил узлы цикла из остаточного набора, а не из отдельного прохода DFS.
- 04Докажи детерминизм точным тестом вывода
Детерминизм — это не свойство, которое ты предполагаешь, а свойство, которое ты доказываешь тестом, утверждающим точный вывод для известного графа. Выбери нетривиальный граф (не менее 6 узлов, не менее 2 ситуаций разрешения ничьей), вычисли ожидаемый топологический порядок вручную с использованием твоего правила лексикографической ничьей и напиши тест, жёстко кодирующий этот ожидаемый массив. Если вывод меняется — тест падает. Это дисциплинирует будущие рефакторы: если ты меняешь правило ничьей, ты должен обновить ожидаемый вывод, что заставляет тебя думать, не нарушает ли изменение воспроизводимость. Также протестируй грань самого правила ничьей: для узлов ['c','a','b'] без рёбер сортировка должна вернуть ['a','b','c'], а не порядок вставки. Задокументируй правило ничьей в комментарии рядом с реализацией, чтобы намерение было явным и не потерялось при следующем рефакторе.
Критерии готовности- Тест жёстко кодирует ожидаемый вывод для графа из ≥6 узлов и проходит; изменение логики разрешения ничьей ломает этот тест.
- Отдельный тест доказывает, что узлы с одинаковым приоритетом возвращаются в лексикографическом порядке независимо от порядка их объявления.
Самопроверка
Покажи выбранный граф из 6 узлов, выведи ожидаемый порядок шаг за шагом с использованием правила ничьей и назови один сценарий, где другая ничья (порядок вставки, случайный) сломала бы попадание в кэш CI; senior-ревьюер проверяет, что ожидаемый массив в тесте точно совпадает с твоим выводом.
- 05Параллельные батчи: группировка задач по уровню зависимостей
Линейный порядок — это минимум, который должен предоставлять планировщик, но реальная система сборки запускает столько задач параллельно, сколько позволяет граф. Расширение батчей алгоритма Кана даёт тебе это бесплатно: вместо того чтобы добавлять в очередь узлы с нулевым in-degree по одному, собирай все узлы, у которых in-degree достигает нуля в одном раунде, в один батч — этот батч и есть набор задач, которые можно запускать параллельно на этом уровне. Обрабатывай каждый батч как единицу, затем уменьшай in-degree, собирай следующий батч и повторяй. Результат — список батчей (массивов строк), где задачи внутри батча не зависят друг от друга, а каждая задача в батче k+1 зависит хотя бы от одной задачи в батчах 1…k. Это волновая / уровневая синхронная модель, используемая make -j и большинством CI-движков. Докажи это: две задачи без пути между ними должны попасть в один батч; задача, зависящая от одной из них, должна попасть в строго более поздний батч.
Критерии готовности- Функция batches() возвращает список батчей, где задачи внутри каждого батча независимы (нет ребра между любыми двумя задачами в одном батче), а задачи в более поздних батчах имеют все зависимости, удовлетворённые более ранними батчами.
- Тест доказывает, что два несвязанных узла попадают в один батч, а узел, зависящий от одного из них, попадает в строго более поздний батч.
Самопроверка
Для пайплайна A→B→D и сайдкара C→D перечисли ожидаемые батчи (батч 0: {A,C}, батч 1: {B}, батч 2: {D}) и объясни, почему запуск D до B в одном батче был бы неверным; senior-ревьюер проверяет, что твоя реализация batches() не пересортировывает внутри батча иначе, чем tie-break линейной сортировки.
- 06Подключи к настоящему сборочному раннеру
Алгоритм доказан; теперь сделай его полезным инструментом. Собери тонкий раннер, принимающий манифест задач (JSON, отображающий имена задач на массивы команд и имена зависимостей), разрешающий порядок выполнения через topoSort, диспетчеризирующий каждый батч конкурентно (Promise.all по батчу) и стримящий структурированный JSON-вывод на задачу (name, status, stdout, durationMs). Раннер должен отклонять манифест и показывать читаемую ошибку, если topoSort бросает CycleError — называя цикл, чтобы оператор мог исправить манифест без чтения стек-трейсов. Он также должен валидировать, что каждое имя зависимости в манифесте ссылается на объявленную задачу (неизвестная зависимость = завершить сразу при загрузке, а не во время выполнения). Сделай раннер тестируемым, инжектируя функцию-исполнитель (то, что реально запускает shell-команду), чтобы тесты могли проверять поведение планировщика без порождения реальных процессов. Замерь выигрыш по wall-clock времени батчевого раннера против последовательного на синтетическом алмазном графе из 20 задач.
Критерии готовности- Манифест с циклом выдаёт читаемую ошибку с именованием узлов цикла (не сырой стек-трейс), а манифест с неизвестной зависимостью завершается с ошибкой при загрузке.
- Тест инжектирует mock-исполнитель и проверяет, что задачи в одном батче стартуют до того, как стартует любая задача следующего батча, без порождения реальных процессов.
Самопроверка
Вставь структурированный JSON-вывод для цепочки из 3 задач и объясни, как инжектированный исполнитель позволяет юнит-тестировать порядок планирования без порождения процессов; senior-ревьюер проверяет, что вывод CycleError включает имена узлов цикла и что валидация неизвестных зависимостей срабатывает при загрузке, а не в середине выполнения.
Стартер
- README.md
- src/toposort.ts
- test/toposort.test.ts
Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test
Рубрика
| Джуниор | Миддл | Сеньор | |
|---|---|---|---|
| Корректность топологического порядка | Сортировка на основе DFS возвращает валидный порядок для небольших ациклических графов; изолированные узлы могут молча отбрасываться; поведение на циклах не определено или завершается переполнением стека. | Алгоритм Кана обрабатывает все узлы (включая изолированные) за O(V+E), производит валидный топологический порядок для любого DAG и бросает CycleError на любом цикличном вводе, включая самопетли. | Сортировка детерминирована при задокументированном правиле разрешения ничьей (лексикографическом или приоритетном), доказано тестом с жёстко заданным ожидаемым выводом; ты можешь объяснить, почему недетерминизм ломает кэши CI, и назвать стоимость O(k log k) за уровень для разрешения ничьей. |
| Обнаружение циклов и сообщение об ошибках | Циклы вызывают бесконечный цикл или общий Error без информации о том, какие задачи вовлечены; вызывающий не может отличить цикл от других ошибок. | Остаточный набор Кана определяет участников цикла без дополнительных затрат; типизированный класс CycleError (расширяет Error, экспортирует cycleNodes) позволяет вызывающим перехватывать и инспектировать цикл без разбора строки сообщения. | Ошибка всплывает в сборочном раннере как читаемое сообщение с именованием узлов цикла (не стек-трейс), чтобы оператор мог исправить манифест задач; ты можешь обосновать, почему подход остаточного набора предпочтительнее отдельного прохода извлечения цикла Тарьяном/DFS. |
| Детерминизм и параллельное батчирование | Порядок вывода сортировки варьируется между прогонами или зависит от порядка вставки; параллельная группировка не предпринимается. | Стабильное разрешение ничьей делает вывод чистой функцией от графа; batches() группирует задачи, чей in-degree достигает нуля в одном раунде Кана, а тесты проверяют, что независимые задачи делят батч, тогда как их зависимые находятся в более позднем батче. | Параллельный раннер выполняет каждый батч через Promise.all, замеряет выигрыш по wall-clock времени относительно последовательного выполнения и отклоняет манифесты с циклами или неизвестными зависимостями при загрузке с понятной пользователю ошибкой — а не крашем в середине выполнения. |
Эталонный разбор (спойлер)
Почему алгоритм Кана, а не DFS для топологической сортировки в системе сборки: топологическая сортировка на основе DFS рискует переполнением стека на глубоких графах (цепочка из 10 000 задач упирается в лимит стека Node по умолчанию), тогда как Кан итеративен. Важнее то, что Кан даёт обнаружение циклов и набор участников цикла как побочный продукт основного цикла — узлы, никогда не достигающие нулевого in-degree, — это именно участники цикла — так что ты получаешь и корректную сортировку, и точную ошибку за один проход O(V+E).
Налог за детерминизм и почему он оправдан: без разрешения ничьей два прогона Кана на {'B','A','C'} без рёбер могут вернуть любую перестановку. Это означает, что кэшированный результат сборки, ключованный по порядку задач, — промах кэша, даже когда ничего не изменилось. Лексикографическое разрешение ничьей добавляет O(k log k) за уровень (где k — количество одновременно подходящих кандидатов, обычно небольшое) и делает сортировку чистой функцией от графа — один и тот же граф всегда даёт один и тот же порядок, поэтому кэши сборки корректны.
Проблема алмаза и параллельные батчи: в графе A→{B,C}→D, B и C независимы — никакой путь их не соединяет — поэтому они могут выполняться параллельно. Батчинг Кана улавливает это естественно: после обработки A (батч 0) и B, и C одновременно достигают нулевого in-degree (батч 1); после завершения обоих D достигает нуля (батч 2). Последовательный раннер, обрабатывающий B до C, добавляет лишнее wall-clock время, равное min(duration(B), duration(C)).
Принцип проектирования CycleError: типизированный класс ошибки с машиночитаемым полем cycleNodes строго полезнее строкового сообщения. Операторам нужны имена узлов для исправления манифеста; системы мониторинга должны подсчитывать CycleError без разбора строк; юнит-тесты должны проверять instanceof для утверждения правильного типа исключения. Расширение Error (а не просто бросок обычного объекта) сохраняет стек-трейс для отладки, добавляя структурированные данные для программного использования.
Завершай с ошибкой при загрузке, а не в середине выполнения: проверка того, что каждое имя зависимости в манифесте задач ссылается на объявленную задачу, дёшева (один проход по списку рёбер). Откладывание этой проверки до времени выполнения означает, что оператор может ждать минуты выполнения вышестоящих задач, прежде чем обнаружит, что имя нижестоящей зависимости написано с ошибкой. Тот же принцип применим к обнаружению циклов: запусти topoSort на всём манифесте до диспетчеризации любой задачи, чтобы манифест был доказанно валиден до порождения любого процесса.
Сделай по-сеньорски
- Реализуй алгоритм Тарьяна для поиска сильно связных компонент рядом с алгоритмом Кана и сравни их вывод обнаружения циклов: Кан даёт набор узлов-членов цикла за O(V+E); Тарьян даёт каждый сильно связный компонент как отдельную группу. Докажи, что они согласуются в принадлежности к циклу, и обоснуй, когда гранулярность Тарьяна (на SCC, а не на граф) оправдывает дополнительную сложность реализации.
- Добавь приоритетный вес каждому узлу и измени разрешение ничьей с лексикографического на убывание приоритета (лексикографическое как вторичный ключ). Докажи тестом, что высокоприоритетный узел опережает низкоприоритетного одноуровневого соседа, и рассуди, остаётся ли это валидным топологическим порядком.
- Расширь параллельный раннер до соблюдения потолка конкурентности (одновременно не более N задач по всем батчам) и докажи тестом, что потолок никогда не превышается, даже когда батч содержит больше задач, чем позволяет потолок.
- Реализуй инкрементальное перепланирование: получив завершённый прогон и набор изменённых узлов, вычисли минимальный подграф, который необходимо перезапустить (изменённые узлы плюс все транзитивные зависимые), не перезапуская задачи, чьи входные данные не изменились. Это ядро логики пересборки Make.