openlang

Запуск потребителя по событию остановки

Пришёл вопрос про правило супервизора, которое считает остановленный процесс завершённым, и маленький пример сразу показывает, в чём беда.

Шаг на shell пишет входной файл, после него запланирован потребитель. Писателя приостанавливают на полпути. Супервизор видит событие остановки, и предложенное правило по нему запускает потребителя. Вот сторона писателя на обычном Bash.

writer() {
  for i in 1 2 3; do echo "row $i"; sleep 1; done > out.csv
}
writer &
pid=$!
sleep 1.5
kill -STOP "$pid"
ps -o stat= -p "$pid"    # T
wc -l < out.csv          # 2
kill -CONT "$pid"
wait "$pid"
wc -l < out.csv          # 3

Пока он остановлен, ps показывает T, а в файле две строки из трёх. Потребитель, запущенный в этот момент, читает короткий файл, который выглядит вполне корректно, ведь каждая строка в нём целая. Ничто в файле не говорит, что будет ещё. Возобновите писателя, и третья строка появится, когда потребитель уже отработал на двух.

Остановлен не значит завершён. В Bash приостановленное задание висит в jobs как Stopped, и fg или bg сразу возвращают его к работе. Вывод тоже ещё никто не проверял. И длиться это состояние может сколько угодно. Задание, остановленное в терминале через Ctrl-Z, может простоять, пока кто-нибудь о нём не вспомнит.

Скорее всего, правило выросло из отображения статусов, где всего две корзины, работает и не работает. Остановленный писатель не работает, попадает в одну корзину с завершёнными, и потребитель стартует.

Так что должен требовать супервизор, прежде чем запускать потребителя, и как показывать писателя, пока тот приостановлен? Мне хочется завести на панели третье состояние, но сначала интересно, где проводят границу другие.

Переведено с английскогоПоказать оригинал

Разговор продолжается 17

Следуя доводам
  1. Ссылка на ответ

    Состояние Stopped означает ожидание, и супервизор должен показывать его именно так. fg или bg возвращают писателя к выполнению, а потребитель всё ещё не готов. До готовности два рубежа. Писатель завершается успешно. Затем файл проверяется на то, может ли потребитель им пользоваться.

    Если супервизор написан на Python, всё так же. Каждая задача должна через await дождаться настоящего завершения своего внешнего шага. TaskGroup ждёт свои задачи при выходе из блока, поэтому задача, которая возвращается, едва увидев событие остановки, отмечает не ту веху, и группа спокойно идёт дальше. Даже у правильно завершившейся задачи вывод проверяется прежде, чем от него что-то начнёт зависеть. И пропускайте CancelledError во время ожидания, чтобы отмена супервизора по-прежнему работала.

    Переведено с английскогоПоказать оригинал
  2. Ссылка на ответ

    Я в основном пишу скрипты на PowerShell, и забавно, что он называет тем же словом почти противоположное состояние. Фоновое задание показывает Stopped после Stop-Job, и это значит, что его завершили и оно не вернётся, а состояние паузы с возобновлением называется Suspended, и поддерживают его только некоторые типы заданий. Так что супервизор, который сопоставляет состояния разных оболочек по имени, ошибается в обе стороны. Потребителю нужен Completed плюс проверка файла с результатом, а всё остальное, включая Stopped, лучше считать неготовым. К слову, объект задания отдаёт своё состояние как свойство. Не нужно разбирать вывод jobs, и это половина причин, почему мне нравятся объекты в конвейере.

    Переведено с английскогоПоказать оригинал
  3. Ссылка на ответ

    Прогнал. Писатель пишет в out.tmp, настоящее имя только после того, как wait скажет 0.

    writer() { for i in 1 2 3; do echo "row $i"; sleep 1; done; }
    writer > out.tmp &
    pid=$!
    sleep 1.5
    kill -STOP "$pid"
    ( sleep 2; kill -CONT "$pid" ) &
    if wait "$pid"; then
      mv out.tmp out && echo "consumer may start"
    fi

    Скрипт, управление заданиями выключено. wait пересидел остановку и вернул 0, когда писатель закончил, в out все три строки. Годится.

    Вторую половину я сказал неверно, по крайней мере показать не смог. С set -m наверху /bin/bash 3.2 из macOS всё равно ждал через остановку и вернул 0 после CONT. В руководстве к текущему Bash сказано, что при включённом управлении заданиями wait возвращается, когда задание меняет состояние, и есть wait -f, чтобы ждать именно завершения. 3.2 на -f отвечает invalid option. Так что ранний возврат - это история новых версий Bash. Выключите управление заданиями в супервизоре, и вопрос пропадает.

    Переведено с английскогоПоказать оригинал
  4. Ссылка на ответ

    Насчёт того, откуда берётся событие остановки, всё верно, и на Python это хорошо видно. Супервизор на subprocess случайно такую ошибку не сделает. Ему нужно специально об этом спросить.

    import os, signal, subprocess, time
    
    p = subprocess.Popen(["sleep", "3"])
    p.send_signal(signal.SIGSTOP)
    time.sleep(0.2)
    print(p.poll())                      # None
    _, st = os.waitpid(p.pid, os.WUNTRACED)
    print(os.WIFSTOPPED(st))             # True
    p.send_signal(signal.SIGCONT)
    print(p.wait())                      # 0

    Пока дочерний процесс остановлен, poll() отвечает None, то есть ещё работает, потому что про остановки у ядра он не спрашивает. Узнать об остановке можно, только если самому вызвать waitpid с WUNTRACED, и даже тогда WIFSTOPPED проверяется отдельно от WIFEXITED. Значит, кто-то сознательно написал ветку для остановки и направил её в ветку завершения. Это проектное решение, спрятанное в отображении статусов, и с него я бы начал. Некоторым супервизорам знать об остановке и правда нужно, чтобы показать состояние, и это нормально, пока ветка остановки ведёт куда угодно, только не в завершение. Последний wait() тоже блокируется всю остановку, и именно этого ждёшь от супервизора, который ждёт завершения.

    С панелью я менее уверен. Показывать Stopped отдельным состоянием, по-моему, правильно, но остановленный писатель, которого никто не возобновил, по сути превращается в зависший шаг, и я не знаю, сколько супервизору ждать, прежде чем так его назвать. Таймер кажется произвольным. Без таймера потребитель ждёт вечно.

    Переведено с английскогоПоказать оригинал
  5. Ссылка на ответ

    Спит дальше, остановка его не будит.

    import asyncio, signal
    
    async def main():
        p = await asyncio.create_subprocess_exec("sleep", "2")
        p.send_signal(signal.SIGSTOP)
        try:
            await asyncio.wait_for(p.wait(), 1)
        except TimeoutError:
            print("still waiting")
        p.send_signal(signal.SIGCONT)
        print(await p.wait())
    
    asyncio.run(main())

    Печатает still waiting, а после возобновления 0. Чтобы там ловился голый TimeoutError, нужен 3.11 или новее. Более старые версии бросают asyncio.TimeoutError.

    Переведено с английскогоПоказать оригинал
  6. Ссылка на ответ

    Таймер я бы держал вне решения о готовности. Потребитель ждёт двух вех, кода выхода писателя и проверки вывода, а остановленный писатель не достиг ни одной, так что он просто остаётся неготовым. Таймаут относится к другому вопросу, когда позвать человека. Через какое-то время поднимите оповещение, покажите писателя как Stopped вместе с тем, сколько он уже стоит, и дайте кому-нибудь его возобновить или убить. Если убьют, код выхода ненулевой, первая веха не пройдена, и потребитель не стартует.

    Переведено с английскогоПоказать оригинал
  7. Ссылка на ответ

    К временному файлу с переименованием добавлю одну привычку. Держите out.tmp в том же каталоге, что и out. Переименование в пределах одной файловой системы атомарно, а mv между файловыми системами копирует и потом удаляет, и потребитель может застать половину файла.

    Переведено с английскогоПоказать оригинал
  8. Ссылка на ответ

    Тот же каталог - это половина дела. Вторая половина - имя. Если потребитель берёт вход по глобу вроде *.csv, временный файл out.csv.tmp в него не попадёт, а out.tmp.csv попадёт, и рано или поздно кто-нибудь всё переименует. Скучное решение - точка в начале имени. Глобы Bash пропускают такие файлы, пока не включён dotglob. Назовите его .out.csv.tmp, и об этом можно больше не думать.

    Переведено с английскогоПоказать оригинал
  9. Ссылка на ответ

    Если точечное имя берёте из mktemp, поправьте права до переименования. Он создаёт файл, доступный только владельцу, и mv это сохраняет.

    tmp=$(mktemp ./.out.csv.XXXXXX)
    echo "row 1" > "$tmp"
    ls -l "$tmp" | cut -c1-10    # -rw-------
    chmod 644 "$tmp"
    mv "$tmp" out.csv
    ls -l out.csv | cut -c1-10   # -rw-r--r--

    Без chmod потребитель под другим пользователем найдёт полный файл, который не сможет открыть. Новый и довольно путаный способ упасть.

    Переведено с английскогоПоказать оригинал
  10. Ссылка на ответ

    Я бы вообще не давал потребителю пользоваться глобом. Тот, кто фиксирует оба этапа, уже знает путь, прошедший проверку, так что передайте этот путь потребителю, и временному имени останется только быть уникальным.

    Переведено с английскогоПоказать оригинал
  11. Ссылка на ответ

    Ваш вопрос про таймер заставил меня задуматься, откуда вообще возьмётся «сколько он уже стоит». Гипотеза была такая. waitpid с WUNTRACED сообщает об остановке, пока она длится, и супервизору достаточно его опрашивать. По крайней мере на macOS это не так.

    import os, signal, subprocess, time
    
    p = subprocess.Popen(["sleep", "3"])
    p.send_signal(signal.SIGSTOP)
    time.sleep(0.2)
    pid, st = os.waitpid(p.pid, os.WUNTRACED | os.WNOHANG)
    print(pid == p.pid, os.WIFSTOPPED(st))               # True True
    print(os.waitpid(p.pid, os.WUNTRACED | os.WNOHANG))  # (0, 0)
    p.send_signal(signal.SIGCONT)
    time.sleep(0.2)
    pid, st = os.waitpid(p.pid, os.WCONTINUED | os.WNOHANG)
    print(os.WIFCONTINUED(st))                           # True
    print(p.wait())                                      # 0

    Об остановке сообщают один раз. Второй вызов возвращает (0, 0), хотя дочерний процесс всё ещё стоит, а возобновление - отдельное событие, которое видно только с WCONTINUED. Значит, счётчик простоя должен стартовать в момент, когда супервизор забирает остановку, и закончить его может только событие продолжения. Опрос waitpid потом скажет лишь, что ничего нового не случилось.

    Переведено с английскогоПоказать оригинал
  12. Ссылка на ответ

    Тогда меняется место, где живёт состояние остановки. Если о ней сообщают один раз, дальше она существует только в собственной записи супервизора, значит, запись должна хранить время, когда остановку забрали, а событие продолжения должно её сбрасывать. Это ещё и означает, что честно показать Stopped может только супервизор, который сам ведёт цикл waitpid. Построенный на poll() или на wait() из asyncio, как в примерах caspar, вообще не узнаёт об остановке и вынужден читать ps, а выборка из ps может целиком пропустить короткую остановку.

    Для допуска потребителя всё это неважно. Он по-прежнему ждёт выхода и проверки файла. Это касается только панели, которую хочет предложить snip.

    Переведено с английскогоПоказать оригинал

Другие стороны того же вопроса

  1. После перехода на Kotlin DSL подпроекты не видят implementation

    Переношу старую многопроектную сборку с Groovy DSL на Kotlin DSL. В корневом build.gradle был такой блок. Корень переведён ме…

    Ответы 2kotlingroovy
  2. tryEval пропустил нарочно сломанный пакет

    Есть у меня небольшой обход оверлея, который оборачивает каждый пакет в builtins.tryEval и перечисляет упавшие. Он сообщил, ч…

    Ответы 4nix
  3. Ключи for_each - это адреса, а не ярлыки

    Ключ for_each - не ярлык. Это половина адреса ресурса, terraform_data.queue["prod-orders"], и состояние хранит объект именно…

    Ответы 6hcl