Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: APIs for recoverable failures #45080

Open
tkf opened this issue Apr 25, 2022 · 8 comments
Open

RFC: APIs for recoverable failures #45080

tkf opened this issue Apr 25, 2022 · 8 comments

Comments

@tkf
Copy link
Member

tkf commented Apr 25, 2022

There have been discussions on API for recoverable failures #41966 #34821 #44397 albeit case-by-case basis. By "recoverable," it means that the caller program expects certain failure modes (which is documented as public API of the callee) and thus the author of the caller program is willing to handle "success" and "failure" paths efficiently. We need a basic protocol for such APIs since the compiler is not very eager on optimizing exception-based error handling 1 (and, as a result, e.g., it is hard to use in constrained run-time environments such as GPU devices). I suggest looking at these issues as a whole and trying to come up with a principled solution. This was also discussed in #43773 but no concrete solution was proposed. This issue discusses a two-part RFC that is aiming at landing a concrete solution.

The first part of the RFC is to create simple wrapper types like Some but conveys "success" and "failure" semantics in some sense. Namely, I suggest adding

struct Ok{T}
    value::T
end

struct Err{T}
    value::T
end

A lot of interesting mechanisms can be built around them that may even require changing the struct itself (e.g., Try.jl). To maximize what's possible in 1.x time-frame, I suggest only exporting abstract types from Base and putting concrete structs in Base.Experimental.

The second part of the RFC is to add some Union{Ok,Err}-valued functions to address issues like #41966, #34821, and #44397. Some possible APIs include:

API Success Failure
tryget(dict, key) Ok(key′ => value) 2 Err(KeyError(key))
trysetwith!(f, dict, key) Ok(key′ => f()) 2 Ok(key′ => dict[key]) 2
tryset!(dict, key, value) Ok(key′ => value′) 2 Ok(key′ => dict[key]) 2
tryput!(xs, x) Ok(x) Err(ClosedError(xs)) etc.
trytake!(xs) Ok(x) Err(ClosedError(xs)) etc.
tryfetch(xs) Ok(x) Err(ClosedError(xs)) etc.
trypop!(xs) Ok(x) Err(EmptyError(xs)) etc.
trypopfirst!(xs) Ok(x) Err(EmptyError(xs)) etc.
trypush!(xs, x) Ok(x′) (Ok(xs)?) 2 Err(ClosedError(xs)) etc.
trypushfirst!(xs, x) Ok(x′) (Ok(xs)?) 2 Err(ClosedError(xs)) etc.
trygetonly(xs) Ok(xs) Err(EmptyError(xs))

(Concrete implementations of tryget(dict, key), trysetwith!(f, dict, key), and tryset!(dict, key, value) 3 can be found in PreludeDicts.jl. Some of other functions are implemented in TryExperimental.jl.)

I suggest promoting try prefix for this "calling convention." This is similar to the ! suffix convention that hints mutation in some sense.

Of course, we already have a couple of functions with try prefix in Base (but fortunately not in stdlib) APIs:

julia> collect(Iterators.filter(startswith("try"), Iterators.map(string, Iterators.flatten(Iterators.map(names  Base.require, collect(keys(Base.loaded_modules)))))))
2-element Vector{String}:
 "trylock"
 "tryparse"

We can leave them as-is but it may also be reasonable to introduce and promote aliases (as we obviously cannot remove them during 1.x time frame). For example, trylock could be called racylock or lock_nowait. ConcurrentUtils.jl uses race_ prefix for racy and non-deterministic APIs in general including trylock. That said, since trylock is a pretty standard terminology, I understand that people may want to keep it. tryparse could be renamed to maybeparse. However, rather than simple rename, it may be better to clean up the API so that it is possible to support, e.g., maybeparse(Union{Int,Nothing}, "nothing")::Union{Nothing,Some{<:Union{Int,Nothing}}} == Some(nothing) #43746.

The "exit condition" of the RFC is not implementing the above APIs in the table. Rather, I think it'd be a good idea to start with one of #41966, #34821, or #44397. However, it seems to be a better idea to try arriving at a naming convention before tackling individual problems. That said, I think it is also a reasonable end result to decide not using any naming scheme for this type of API.

In summary, I think it'd be great if we can decide (at least):

  • Adding the Ok and Err types in Base or not
  • Using a naming convention for this kind of API or not

Footnotes

  1. For more discussion on the co-existence of two error handling mechanisms, I included a section in Try.jl README: When to throw? When to return?

  2. indicates that the returned value include the object that is actually stored in the container. For example, tryset!(dict, key, value) may return Ok(key′ => value′) with key !== key′ and value !== value′ if key and value are converted. 2 3 4 5 6 7

  3. Note that tryset!(dict, key, value)/trysetwith!(f, dict, key) are the "failable" counter parts of get!(dict, key, value)/get!(f, dict, key) respectively. I swapped get with set since (1) it clarifies the implication on the failure path and (2) adding the with suffix as in mergewith! is more natural.

@bramtayl
Copy link
Contributor

bramtayl commented Apr 25, 2022

Can we get the optimizer to optimize try-catch better instead? Then we wouldn't need the Ok and Err wrappers. try-catch syntax is clunky, so I can see it being nice to have a function. Maybe something like recover that dispatches on the first argument? Then we'd only need one new function. E.g.

recover(get, dict, key) do
    # do this if key doesn't exist, otherwise return dict[key]
end
recover(put!, xs, x) do
    # do this if xs is closed, otherwise return x like usual
end

@jakobnissen
Copy link
Contributor

I think this is a good initiative. It's at the same time clear to me that recoverable errors in Julia Base would be amazing, and also not clear to me at all which API is nicest. Having Base Ok/Err is a good starting point. It's quite possible settling on a good API takes some trial and error.

@tkf
Copy link
Member Author

tkf commented Apr 25, 2022

Can we get the optimizer to optimize try-catch better instead?

The obvious obstacles in the optimization current exception handling system are:

  • The compiler needs to be able to infer entire call tree to enumerate all possible exceptions that can be thrown. This is the case even for functions whose behavior does not affect the return type f(ref::Ref{Any}) = (g(ref[]); 1).
  • Current task system is set up in such a way that a yield point can throw arbitrary exception. As such, concurrent Julia programs that may do IO and/or synchronization cannot be inferred more precisely than Any. Solving this probably require a very sophisticated approach.

Another approach (perhaps more "straightforward" in some sense) is to introduce "typed catch" clause and carry all exception types that may be handled by the caller in a given call stack. The compiler can then lower all possible throws to conditional returns; i.e., it is a "simple" lowering pass. However, it makes compiling code much more intensive since every throwable functions have to be re-compiled for every combination of the exception handlers. (Generalizing this, we will go into the usual discussion of effect handlers. This may be a fruitful exploration but it'd be hard to predict when we can have nice exception handling.)

But, more importantly, as linked from the OP, I think there is an aspect in which this error value convention makes sense beyond optimization: When to throw? When to return?

Maybe something like recover

It is important to realize that recover(f, handler, args...) API is isomorphic to ("as powerful as") recover_f(handler, args...). That said, the approach of using recover_f(handler, args...) actually makes some sense. This is discussed as (semi-)CPS approach in #43773 (reply in thread) and I explored it (see the follow-up comment). However, my impression is that this is much harder to use and less optimizable than Union{Ok,Err}-valued functions.

@bramtayl
Copy link
Contributor

recover(f, handler, args...) API is isomorphic to ("as powerful as") recover_f(handler, args...)

Sure, but the recover API is more well factored; and well-factored code is the official reason Base nixed underscores in names. Having just one function is a strategy that could work with the API above too (although try is a reserved word so we'd need a different name).

@tkf
Copy link
Member Author

tkf commented Apr 26, 2022

Actually, I think it'd be better to clarify a more concrete point first.

If recover(f, handler, args...) does not call handler on success, then there has to be a way for the caller of recover to distinguish if handler is called or not. Note that capturing a Ref{Bool}-like object does/may not work on "non-standard" environments like GPU and distributed. So, some mechanism like Ok/Err value is required for distinguishing two cases.

Alternatively, a more principled approach is to call the handler on successful results (e.g., Ok(dict[key]) on recover(getindex, handler, dict, key)). However, the handler needs to be able to distinguish between success and failure. Again, some mechanism like Ok/Err values or an additional boolean flag is required. Furthermore, there are additional problems as discussed in the CPS approach I linked above.

So, independent of whether or not handler is called on success, it is unavoidable to introduce a convention like Ok/Err, the "Go style" (result, error) return value, or something else. That is to say, we still need to answer the first question in my opening post.

@bramtayl
Copy link
Contributor

So would try(get, dict, key) work as an API work instead of first one you suggested? Maybe even with a macro so you could write @try get(dict, key)?

@tkf
Copy link
Member Author

tkf commented Apr 29, 2022

I think discussing try(get, dict, key) requires clarifying nontrivial advantages of it. I think reducing names like tryget is rather weak as a primary motivation 1. IMHO, the biggest upside is clarifying the relationship of try(f, args...) and f(args...). In particular, it is appealing to introduce

abstract type DerivedFromTry <: Function end
function try end

function (f::DerivedFromTry)(args...; kwargs...)
    result = try(f, args...; kwargs...)
    if result isa Ok
        return unwrap(result)
    else
        throw(unwrap_err(result))
    end
end

to codify that f(args...) is defined in terms of try(f, args...).

Then the API designer can choose to derive f(args...) from try(f, args...). A toy example:

struct Get <: DerivedFromTry end
const get = Get()
# maybe `function get::DerivedFromTry end` can be a short hand for this

function try(::typeof(get), dict::AbstractDict, key)
    if haskey(dict, key)
        return Ok(dict[key])
    else
        return Err(KeyError(key))
    end
end

However, there are cases where it's not useful to call f(args...) while try(f, args...) does make sense. For example, it may be useful to have a "racy" version of take!(channel) that does not block. It is a very low-level function and so its main use would be try(take_nowait!, channel) rather than take_nowait!(channel) for performance. So, I think the benefit of try(f, args...) is unclear in this case.

Overall, I'm neutral on whether or not using APIs like try(f, args...). If we go this direction, I prefer going one step more and use the "CPS" approach try(onreturn, f, args...) but I need more design exploration for this to make it work well in Julia.

Meanwhile, I think it'd be great if other people can share their thoughts on try(f, args...).

Footnotes

  1. specificsemantics(f, args...) introduces "namespace" in the sense (args...) -> specificsemantics(f, args...) is equivalent to SpecificSemantics.f where SpecificSemantics is a module. However, Julia's Base and stdlib favor flat namespace. That said, it is interesting that this "namespace" is open in the sense that the new typeof(f) can be introduced later.

@adkabo
Copy link
Contributor

adkabo commented Apr 29, 2022

-1 for try(foo, a) as a normal method of programming: it's a complexity overhead on all error-safe functions.

tryfoo it imposes a keystroke penalty on error-safe programming which makes exceptions the default, but that seems unavoidable for 1.x. A keystroke overhead is preferable to a complexity overhead.

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

No branches or pull requests

4 participants