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

Wrap Abort

Лучше всего, когда альтернатива не совершает необратимых операций до своего коммита.
Иногда это правило нарушается, и нам приходится компенсировать последствия запуска, если коммит уходит к другой альтернативе.

Нужно это довольно редко, поэтому в Hopac ограничились лишь двумя функциями:

module Alt =
    val wrapAbortJob: Job<unit> -> Alt<'x> -> Alt<'x>
    val wrapAbortFun: (unit -> unit) -> Alt<'x> -> Alt<'x>

Обе функции возвращают новую альтернативу, в которой скрывается реакция на abort.

Описанный в прошлой части Alt.after_ разбирается с успешной веткой, которая происходит после коммита. В то время как Alt.wrapAbort_ отрабатывает негативный сценарий, когда коммит ушёл к другой альтернативе. Эти ветки и эти функции противопоставляются друг другу. Они взаимоисключающие.

alt
|> Alt.afterFun ^ fun msg -> 
    printfn "Alt закоммитился и двигается дальше."
    msg
|> Alt.wrapAbortFun ^ fun () -> printfn "Alt был отменён."

В качестве аналогии можно вспомнить метод Register у CancellationToken. Этот метод позволял зарегистрировать реакцию на отмену токена. В зависимости от перегрузки это был либо Action, либо Task.

У меня сложилось впечатление, что добрая половина разработчиков о Register даже не слышала. В Hopac о wrapAbort знают почти все из-за особенностей документации. Но используют так же редко, как и CancellationToken.Register.

Важно, что wrapAbort не имеет доступа к контексту Alt. Реакция не получит на вход каких-либо данных, если вы не замкнёте их в рамках функции самостоятельно. Поэтому wrapAbort можно условно разделить на 2 типа.

Первый тип работает с уже готовым Alt. Когда мы не участвовали в его генерации и получили его в запечатанном виде. В этом случае сделать что-либо полезное очень сложно.
И задача wrapAbort сводится к логированию и прочим сугубо диагностическим функциям.

Второй тип обращается к Alt на этапе его создания, где мы имеем доступ ко всем параметрам. В большинстве своём это касается стадии до выхода из Alt.prepare_.

let findNearest args = 
    Alt.prepare ^ job {
        let result = IVar()
        let finder = Finder()
        do! Job.queue ^ job {
            match! cache.TryFind args with
            | None -> 
                let! nearest = finder.Find args
                do! cache.AddFound args nearest
                do! IVar.fill result nearest
            | Some last -> 
                let! nearest = finder.FindBased(args, last)
                do! cache.UpdateFound args nearest
                do! IVar.fill result nearest
        }
        return 
            result
            // Второй тип.
            // Отсюда нам доступны любые объекты.
            |> Alt.wrapAbortJob ^ job {
                do! finder.Stop()
                do! cache.TryUpdate args finder.Current
            }

    }
    |> Alt.wrapAbortFun ^ fun () -> 
        // Первый тип.
        // А здесь мы бессильны.
        printfn "Альтернатива отменена"

With Nack

Nack -- сокращение от Negative Acknowledgment, что дословно переводится как "отрицательное подтверждение". Данный термин в документации означает знание того, что какая-то другая альтернатива заполучила коммит. Автор Hopac начинает с Alt.withNack_, а потом использует его для объяснения других, более простых концепций, типа Alt.wrapAbort_ и Alt.prepare. Этот порядок удобен при проектировании Alt, ибо сначала реализуется полная версия, а остальные хелперы дописываются на базе основной функции. Но последовательность от сложного к простому вводит в заблуждение новичков. Понять nack с ходу они, как правило, не могут, а идти дальше бояться, т. к. ошибочно считают пробел в этой области критическим.

Однако мы к данному моменту ознакомились с большинством концепций и можем выйти на битву с боссом. В Hopac есть две функции:

module Alt
    val withNackFun : (unit Promise -> #Alt<'x>) -> 'x Alt
    val withNackJob : (unit Promise -> #Job<#Alt<'x>>) -> 'x Alt

Их задача отрабатывать наиболее хардкорный вариант Alt.prepare, что может возникнуть в разработке. Этим функциям скармливаются фабрики, которые, в свою очередь, принимают этот самый nack : unit Promise.

Когда получаешь в руки unit Job / Alt / Promise, не всегда понимаешь, является ли эта единица командой или запросом. То есть вызвав её, мы что-то спровоцируем или лишь присоединимся к другим ожидающим. В этом конкретном случае мы будем именно ждать.
Но в других ситуациях подобная неопределённость иногда оборачивается серьёзными проблемами, и я рекомендую для подобных сценариев определять конкретные типы с member this.Wait/Execute.

Nack разродится результатом, когда общий Alt пройдёт стадию коммита. Безотносительно к тому, какая из альтернатив стала избранной. Это означает, что в отличие от Alt.wrapAbort_, вы не можете реагировать на nack как на взаимоисключающую ветвь.

Alt.withNackJob ^ fun nack -> job {
    do! Job.queue ^ job {
        do! nack
        // Будет вызываться всегда и у всех альтернатив,
        // включая успешную, 
        // как только произойдёт коммит.
        printfn "NACK!"
    }
    return ...
}

Этот факт сбивает. И я не вижу ему объяснение в области логики построения API. Вполне возможно, что подобное решение продиктовано лишь выигрышем в производительности.

Nack в первую очередь необходим для присоединения других акторов к текущему Alt.choose. В обсуждении Alt.prepare мы разбирали случай, когда внешний наблюдатель мог запросить часть внутренних данных у актора. Эта операция не имела сайд-эффектов, что позволяло игнорировать ситуацию, когда наблюдатель переставал нуждаться в информации.

// Сервер.
start ^ Job.foreverServer ^ job {
    match! mbx with
    | Query.GetState (id, reply : 'state option -> unit Job) ->
        // Пробует заполнить reply, даже если в нём уже нет необходимости.
        do! reply ^ localState.TryFind id
    | _ -> ...
}
// Запрос.
let getState id = Alt.prepare ^ job { // : _ Alt
    let reply = IVar()
    do! Mailbox.send mbx ^ Query.GetState (id, IVar.tryFill reply)
    return reply
}

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

В качестве примера возьмём случай создания новой сущности в недрах сервера. Мы создаём новый экземпляр на основе обязательных входных данных, после чего регистрируем экземпляр в системе. Если написать работу сервера по аналогии с предыдущим примером, то мы увидим, что ни сервер, ни команда, не имеют альтернативных средств коммуникации, кроме однонаправленного IVar:

// Сервер / актор.
start ^ Job.foreverServer ^ job {
    match! mbx with
    | Command.Create (required, reply : _ -> unit Job) ->
        let entity = Entity.create required
        // Сайд-эффект с укладыванием новой сущности в хранилище.
        localState.Register required
        // Отправка свежего экземпляра.
        do! reply entity
    | _ -> ...
}
// Команда.
let create required = Alt.prepare ^ job { // : _ Alt
    let reply = IVar()
    do! Mailbox.send mbx ^ Command.GetState (id, IVar.tryFill reply)
    return reply
}

Если мы захотим создать сущность в течение секунды или не создавать вовсе, то у нас ничего не получится, т. к. после попадания в очередь команда становится неостановимой.
Например, следующий код при большой очереди может вернуть None.

// Попытка создать сущность в течение 1 секунды.
timeOutMillis 1_000 ^->. None
<|> create dto ^-> Some

При этом сущность всё равно будет создана, но позднее. Чтобы этого избежать, можно передать флаг актуальности из запроса на сервер. Воспользуемся Alt.withNackJob. В качестве флага будет работать nack.Full, если он поднят, то дальнейшее движение бессмысленно, т. к. (<|>) ушёл в другую ветку. Можно представить, что на месте nack находится CancellationToken, у которого необходимо проверить IsCancellationRequested.

start ^ Job.foreverServer ^ job {
    match! mbx with
    | Command.Create (required, nack : unit Promise, reply : _ -> unit Job) ->
        if not nack.Full then
            let entity = Entity.create required
            // Сайд-эффект с укладыванием новой сущности в хранилище.
            localState.Register required
            // Отправка свежего экземпляра.
            do! reply entity
    | _ -> ...
}
let create required = Alt.withNackJob ^ fun nack -> job { // : _ Alt
    let reply = IVar()
    do! Mailbox.send mbx ^ Command.GetState (id, nack, IVar.tryFill reply)
    return reply
}

Такая реализация убережёт нас от создания сущностей сильно позже указанного времени.
Но чем ближе к границе таймаута окажется обработка команды на стороне сервера, тем выше шанс, что create вернёт ложный None. (Ну или сервер создаст ложную сущность.)
Чтобы этого избежать, необходимо противопоставить nack и регистрацию сущности в рамках одного коммита на стороне сервера. Для этого пересылка ответа должна быть альтернативой и для команды (как получателя), и для сервера (как отправителя). Этим свойством обладает Ch.

start ^ Job.foreverServer ^ job {
    match! mbx with
    | Command.Create (required, nack : unit Alt, reply : _ -> unit Alt) ->
        let entity = Entity.create required
        do! Alt.choose [
            // Либо уже не надо.
            nack
            // Либо отправляем ответ.
            reply entity ^-> fun () ->
                // Сайд-эффект с укладыванием новой сущности в хранилище.
                // Перенесён в фазу строго после отправки.
                localState.Register required
        ]    
    | _ -> ...
}
let create required = Alt.withNackJob ^ fun nack -> job { // : _ Alt
    let reply = Ch()
    do! Mailbox.send mbx ^ Command.GetState (id, nack, Ch.give reply)
    return reply
}

Как видим, кошерное использование Alt.withNack_ требует другого Alt.choose где-то ещё.
Что задирает порог вхождения, т. к. по факту мы пытаемся создать один Alt, используя свойства другого в очень нетривиальном взаимодействии на солидном расстоянии от первого. Именно поэтому я вообще против данного параграфа при первом знакомстве. Если читатель нашёл что-то полезное ранее, но намертво забуксовал здесь, то я бы рекомендовал сначала применить понятое в бою, а к данной теме вернуться позднее.

В отличие от приводимых мною, примеры из документации стараются ограничиться компактным иммутабельным состоянием в рамках одного Job.iterateServer. Это хорошая практика, но конкретно в моих проектах нишу иммутабельных состояний плотно занимают Hopac.Stream. А вот работа с внешними "мутабельными" ресурсами чаще ведётся именно при помощи серверов. Тем не менее nack отлично ложится и на иммутабельное состояние:

start ^ Job.iterateServer State.empty ^ fun state -> job {
    match! mbx with
    | Command.Create (required, nack : unit Alt, reply : _ -> unit Alt) ->
        let entity = Entity.create required
        return! Alt.choose [
            // Либо уже не надо.
            nack ^->. state
            // Либо отправляем ответ.
            reply entity ^-> fun () ->
                state.Register required
        ]    
    | _ -> ...
}

Достаточно явным образом указать на возвращение к существующему state.

Если попытаться отранжировать все пройденные варианты создания Alt по частоте использования, то получится такая последовательность:

  • Чаще всего используют Alt (и его наследников) напрямую.

  • Реже их оборачивают в Alt.paranoid.

  • Ещё реже готовят через Alt.prepare.

  • Очень редко оборачивают в Alt.wrapAbort.

  • И также редко создают через Alt.withNack.

Alt.paranoid относится скорее к инкапсуляции, а не к сути альтернативы. Если исключить его, то имеет смысл предпочитать верхнюю строчку нижней. Разумеется, верхнюю планку должны определять требования к гарантиям выполнения, а не лень.

Исключения

В завершение надо коснуться обработки исключений. 'a Job может иметь явный результат типа 'a, и неявный, типа exn. Если в какой-то момент вычисления Job происходит выброс исключения, то оно сохраняется в соответствующее поле. (Что иногда можно встретить в дебаге.) После чего исключение по выделенной ветке экспрессом мчится к финишу.

На финише, если кто-то ждёт Job через run, то исключение будет выброшено ещё раз синхронным образом. Если же Job парит в великом ничто, будучи запущенным через start или какой-нибудь Job.foreverServer, то Hopac направит исключение в глобальный хендлер.
Тот выведет в консоль сообщение о неотловленном исключении. Остановить бег к финишу может самописный обработчик исключений. Подключается он либо через try ... with внутри билдера, либо через Job.tryIn, Job.tryWith и т. п. В этом случае необходимо будет определить процедуру возвращения обратно в основной поток вычислений.

Всё это даёт ускоренную обработку ошибок. Особенно у тех, кто не пренебрегает использовать Job.raises вместо raise. Но при этом стек исключения сокращается до абсолютно бесполезных величин. Поэтому внутри Hopac исключения рекомендуется навьючивать максимальным объёмом полезной информации, не отходя от кассы. Ибо другого шанса раскрыть контекст у вас не будет.

В совокупности Job работает с исключениями как ROP-механизм, как Result<'a, exn>. Но скрывает этот процесс от конечного пользователя, пока последний уверен в успехе. Alt, в свою очередь, скрывает внутри себя фазы подготовки, коммита, завершения и отмены. При столкновении этих двух черных ящиков ROP-механизм радикально усложняется.

С этого момента нам необходимо учитывать, в какой фазе произошло исключение. А также в какой именно альтернативе (избранной или побочной).

Проще всего с аутсайдерами. Те, кто не успел, сгинут во тьме. И от них не останется даже воспоминаний записей в глобальном логере. (В отличие от Job.)

Если альтернатива упадёт после коммита (т. е. внутри Alt.after_), то она механически передаст своё исключение наружу также, как это делает Job. Никакого отката к другим альтернативам не будет, даже если они способны выдать нормальный результат.

Проблемы начинаются тогда, когда альтернатива выбрасывает исключение до того, как кто-то другой перейдёт в состояние ready. Alt.choose будет считать, что данная альтернатива пришла в состояние ready самой первой, и произведёт операцию коммита именно с ней. Это может создать тонну проблем, если исключение возникает в чересчур плоских доменах, и при этом мы хотим дождаться корректной альтернативы. Возможна ситуация, когда у нас просто не хватит нужного слоя, чтобы разобраться с исключением до того, как оно навернёт чужой для себя актор (или систему).

Hopac определяет:

module Alt =
    val tryIn : Alt<'x> -> ('x -> #Job<'y>) -> (exn -> #Job<'y>) -> Alt<'y>
    // Недостающие аналоги методов из модуля `Job` можно набросать самостоятельно на основе `Alt.tryIn`.

Эта штука действительно позволит отловить исключение на любом этапе. Но, как только это исключение будет выброшено, данная альтернатива затребует коммита. То есть можно считать, что отлов исключения окажется в фазе Alt.after_. Часто необходимо проигнорировать все ошибочные альтернативы, сосредоточиться только на успешных вариантах. Для этого надо, чтобы исходная альтернатива не генерировала исключение. Сделать это не всегда возможно.

Если ошибка происходит на этапе подготовки альтернативы, т. е. в функциях Alt.prepare_ или Alt.withNack_, то необходимо подготовить её отлов самостоятельно. После чего выдать Alt.never() вместо нормальной альтернативы.

Alt.prepare ^ job {
    try 
        // Вызов чего-то, что может спровоцировать выбор исключения.
        let validateInput = validate input
        let reply = IVar()
        do! send ^ Query.Sample(validateInput, IVar.tryFill reply)
        // Возвращаем корректный вариант.
        return Alt.paranoid reply
    with ex -> 
        // log
        // Возвращаем альтернативу, которая ничего не вернёт.
        return Alt.never()        
}

Я видел пару раз, как люди уходили в бесконечное ожидание через Job.never() внутри Alt.prepare. Hopac от этого точно не зависнет. Но я не могу утверждать, что Hopac сможет адекватно пересчитать альтернативу и зачистить пространство после подобного хака. В подготовленной команде Alt.never() быстро считывается и учитывается далее при проектировании.

Ещё следует обратить внимание на передачу reply. В примере доступ к ней изолирован через IVar.tryFill, поэтому на стороне сервера нельзя будет сделать что-либо ещё, кроме заполнения IVar. Любое падение сервера не коснётся альтернативы из примера.

Однако если отправить reply непосредственно, то сервер может воспользоваться:

module IVar = 
    val tryFillFailure : 'x IVar -> exn -> unit Job

В этом случае reply будет заполнен исключением вместо корректного значения типа 'a.
Приведённая выше обвязка никак не остановит выброс переданного исключения. А значит, и коммит может уйти не туда.

Остальные типы, как правило, не обладают функциями аналогичными IVar.tryFillFailre. Но я бы рассматривал подобный риск как ещё один повод не отправлять во внешний мир необёрнутые типы.

Вместо заключения

Повествование о технических деталях Alt закончилось. Если у меня получилось, то теперь основной проблемой читателя должна стать архитектура на базе Hopac.

Hopac даёт много новых средств в области лаконичности, производительности и т. п. Но ключевым преимуществом является возможность перевести асинхронные вычисления из области "необычайного и гадательного" в область жёстких детерминированных структур.
Alt легко склеивается с алгебраическими типами, что в большинстве случаев позволяет безболезненно смешивать асинхронность с чистым доменом без ущерба для последнего.
Поэтому длительное время после ввода Hopac в игру, вы будете ощущать постоянное давление снизу.

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

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

Эволюция кодовой базы при использовании Hopac будет сопровождаться аналогичным конфликтом базисов. Будут люди, которые уже в воде, и будут странные персонажи, пытающиеся ходить по заиленным камням сквозь поток на своих двоих. Использовать в качестве примитива по умолчанию Async/Task или Job? Мутабельные или иммутабельные сущности? Изначально цельная сущность или лишь проекция-склейка существующих компонентов? Линейные коллекции или кучи в великом ничто? И т. д. и т. п. Вопросов много и потребность их задавать возникнет по мере роста владения Hopac. Договориться раз и навсегда заранее не получится.

В отличие от большинства известных мне асинхронных абстракций, Hopac -- это в первую очередь плод разума, а не следствие машинных ограничений или "привычных" практик. Это изначально математическая абстракция, которую лишь со временем облекли в код. Большая часть необходимых "богу-машине" жертв была уже давно принесена. Тонны нечитабельного кода под капотом уже давно упакованы в готовый пакет, осталось лишь освоить и пользоваться.

Автор статьи @kleidemos


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS

Комментарии (0)