diff --git a/.formatter.exs b/.formatter.exs index f435c5a..eb61d3c 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -4,7 +4,7 @@ "test/**/*.{ex,exs}", "mix.exs" ], - # line_length: 100, + line_length: 100, locals_without_parens: [ ] ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f778c0..bfa2311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## [Unreleased] +## 2.1.0 - 2023--11-14 +### Changed +- Further improved documentation. +- `WaitForIt.case_wait/3` will now raise a `CaseClauseError` on timeout if there is no `else` block. +- `WaitForIt.cond_wait/2` will now raise a `CondClauseError` on timeout if there is no `else` block. + +## 2.0.0 - 2023-11-02 +### Changed +- Much improved documentation. +- Breaking change to return value of `WaitForIt.wait/2`, `WaitForIt.case_wait/3`, and `WaitForIt.cond_wait/2`. +- Rewrite of WaitForIt internals. +- Moved legacy code to `WaitForIt.V1`. + +## 1.4.0 - 2023-10-24 +### Added +- Add WaitForIt.wait! macro. + +## [1.3.0] - 2020-04-02 +### Changed +- Use DynamicSupervisor to manage condition variables. + +## [1.2.1] - 2019-03-14 +### Added +- Add `:pre_wait` option to all forms of waiting. ## [1.2.0] - 2019-03-08 ### Added -- Add support for match clauses in else block of case_wait. [(Issue #9)](https://github.com/jvoegele/wait_for_it/issues/9) +- Add support for match clauses in `else` block of `case_wait`. [(Issue #9)](https://github.com/jvoegele/wait_for_it/issues/9) ## [1.1.1] - 2018-03-03 ### Added @@ -16,7 +39,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [1.1.0] - 2017-09-02 ### Added -- Add support for else clause in case_wait and cond_wait. [(Issue #4)](https://github.com/jvoegele/wait_for_it/issues/4) +- Add support for `else` clause in `case_wait` and `cond_wait`. [(Issue #4)](https://github.com/jvoegele/wait_for_it/issues/4) - Add this CHANGELOG ### Changed diff --git a/README.md b/README.md index f61caa3..306aba2 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,9 @@ # WaitForIt -Various ways to wait for things to happen. - -Since most Elixir systems are highly concurrent, there must be a way to coordinate and synchronize -the processes in the system. While the language provides features (such as -`Process.sleep/1` and `receive`/`after`) that can be used to implement such synchronization, they are -inconvenient to use for this purpose. `WaitForIt` builds on top of these language features to -provide convenient and easy-to-use facilities for synchronizing concurrent activities. While -this is likely most useful for test code in which tests must wait for concurrent or asynchronous -activities to complete, it is also useful in any scenario where concurrent processes must -coordinate their activity. Examples include asynchronous event handling, producer-consumer -processes, and time-based activity. +Various ways of waiting for things to happen. + +This library allows you to wait on the results of asynchronous or remote operations using +intuitive and familiar syntax based on built-in Elixir language constructs. There are three distinct forms of waiting provided: @@ -24,13 +17,12 @@ See the [API reference](https://hexdocs.pm/wait_for_it/WaitForIt.html) for full ## Installation -`wait_for_it` can be installed from Hex by adding `wait_for_it` to your list -of dependencies in `mix.exs`: +`wait_for_it` can be installed by adding it to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:wait_for_it, "~> 1.1"} + {:wait_for_it, "~> 2.1"} ] end ``` @@ -38,5 +30,3 @@ end Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) and published on [HexDocs](https://hexdocs.pm). Once published, the docs can be found at [https://hexdocs.pm/wait_for_it](https://hexdocs.pm/wait_for_it). - -Sponsored by Ropig http://ropig.com diff --git a/config/config.exs b/config/config.exs index 792e08d..b970e22 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,6 +1,6 @@ # This file is responsible for configuring your application # and its dependencies with the aid of the Mix.Config module. -use Mix.Config +import Config # This configuration is loaded before any dependency and is restricted # to this project. If another project depends on this project, this diff --git a/lib/wait_for_it.ex b/lib/wait_for_it.ex index a71d373..6120f7f 100644 --- a/lib/wait_for_it.ex +++ b/lib/wait_for_it.ex @@ -1,108 +1,366 @@ defmodule WaitForIt do @moduledoc ~S""" - WaitForIt provides macros for various ways to wait for things to happen. + Provides various ways of waiting for things to happen. - Since most Elixir systems are highly concurrent there must be a way to coordinate and synchronize - the processes in the system. While the language provides features (such as `Process.sleep/1` and - `receive`/`after`) that can be used to implement such synchronization, they are inconvenient to - use for this purpose. `WaitForIt` builds on top of these language features to provide convenient - and easy-to-use facilities for synchronizing concurrent activities. While this is likely most - useful for test code in which tests must wait for concurrent or asynchronous activities to - complete, it is also useful in any scenario where concurrent processes must coordinate their - activity. Examples include asynchronous event handling, producer-consumer processes, and - time-based activity. + ## Overview - There are three distinct forms of waiting provided: + Elixir is a functional programming language with an emphasis on immmutability of data. However, + when dealing with shared state or interacting with external systems, *change happens*. - 1. The `wait` macro waits until a given expression evaluates to a truthy value. - 2. The `case_wait` macro waits until a given expression evaluates to a value that - matches any one of the given case clauses (looks like an Elixir `case` expression). - 3. The `cond_wait` macro waits until any one of the given expressions evaluates to a truthy - value (looks like an Elixir `cond` expression). + WaitForIt provides various ways of waiting for such changes to happen. - All three forms accept the same set of options to control their behavior: + While Elixir provides several language and standard library features (such as + `Process.sleep/1`, `receive/1`/`after`, and `Task.async/1`/`Task.await/2`) that can be used to + implement waiting, they are inconvenient to use for this purpose. WaitForIt builds on top of + these language features to provide convenient and easy-to-use facilities for waiting on specific + conditions. While this is likely most useful for test code in which tests must wait for + concurrent or asynchronous activities to complete, it is also useful in any scenario where + concurrent processes must coordinate their activity. Examples include asynchronous event + handling, producer-consumer processes, and time-based activity. + + ## Quick start + + To use WaitForIt, you must first `require WaitForIt` or `import WaitForIt`. + + There are three distinct forms of waiting provided. Jump to the docs for each for more + information. + + #### wait + + The `wait/2` macro waits until a given expression evaluates to a truthy value. + + # Wait up to one minute for a file to exist, and then print its contents + if WaitForIt.wait(File.exists?("data.csv"), timeout: :timer.minutes(1)) do + IO.puts(File.read!("data.csv")) + else + IO.warn("Stopped waiting for the file to exist") + end + + #### case_wait + + The `case_wait/3` macro waits until a given expression evaluates to a value that matches any one + of the given case clauses. It looks and acts like an Elixir `case/2` expression except that it + can take an optional `else` clause. + + # Wait for 30 seconds for a directory to exist, and then write a file in it + WaitForIt.case_wait(File.stat("data"), timeout: :timer.seconds(30)) do + {:ok, %File.Stat{type: :directory}} -> + File.write!("data/greeting.txt", "Hello, world!") + else + {:ok, %File.Stat{type: type}} -> + IO.warn("Expected 'data' to be a directory but its type is #{inspect(type)}") + + {:error, reason} -> + IO.warn("Could not stat 'data': #{inspect(reason)}") + end + + #### cond_wait + + The `cond_wait/2` macro waits until any one of the given expressions evaluates to a truthy value. + It looks and acts like an Elixir `cond/1` expression except that it can take an optional `else` + clause. + + # Wait for up to one minute for either a specific file to exist OR for the top of the minute + # to be reached. + WaitForIt.cond_wait(timeout: :timer.seconds(10), frequency: 500) do + File.exists?("data/process.json") -> + IO.puts("Processing...") + + NaiveDateTime.utc_now().second == 0 -> + IO.puts("Processing...") + else + IO.warn("Stopped waiting since neither condition ever became truthy") + end + + ### Options + + All three forms of waiting accept the same set of options to control their behavior: * `:timeout` - the amount of time to wait (in milliseconds) before giving up + * `:pre_wait` - wait for the given number of milliseconds before evaluating conditions for the first time * `:frequency` - the polling frequency (in milliseconds) at which to re-evaluate conditions - * `:signal` - disable polling and use a condition variable of the given name instead - - The `:signal` option warrants further explanation. By default, all three forms of waiting use - polling to periodically re-evaluate conditions to determine if waiting should continue. The - frequency of polling is controlled by the `:frequency` option. However, if the `:signal` option - is given it disables polling altogether. Instead of periodically re-evaluating conditions at a - particular frequency, a _condition variable_ is used to signal when conditions should be - re-evaluated. It is expected that the `signal` macro will be used to unblock the waiting code - in order to re-evaluate conditions. For example, imagine a typical producer-consumer problem in - which a consumer process waits for items to appear in some buffer while a separate producer - process occasionally place items in the buffer. In this scenario, the consumer process might use - the `wait` macro with the `:signal` option to wait until there are some items in the buffer and - the producer process would use the `signal` macro to tell the consumer that it might be time for - it to check the buffer again. - - ``` - # CONSUMER - # assume the existence of a `buffer_size` function - WaitForIt.wait buffer_size() >= 4, signal: :wait_for_buffer - ``` - - ``` - # PRODUCER - # put some things in buffer, then: - WaitForIt.signal(:wait_for_buffer) - ``` - - Notice that the same condition variable name `:wait_for_buffer` is used in both cases. It is - important to note that when using condition variables for signaling like this, both the `wait` - invocation and the `signal` invocation should be in the same Elixir module. This is because - `WaitForIt` uses the calling module as a namespace for condition variable names to prevent - accidental name collisions with other registered processes in the application. Also note that - just because a condition variable has been signalled does not necessarily mean that any waiters - on that condition variable can stop waiting. Rather, a signal indicates that waiters should - re-evaluate their waiting conditions to determine if they should continue to wait or not. + * `:signal` - disable polling and use a signal of the given name instead + + See [Polling-based waiting](#module-polling-based-waiting) for more information on the + `:frequency` option and [Signal-based waiting](#module-signal-based-waiting) for more + information on the `:signal` option. + + ## Waitable expressions and waiting conditions + + _Waitable expressions_ and _waiting conditions_ are fundamental concepts in WaitForIt. + + A _waitable expression_ is any arbitrary Elixir expression that can be evaluated one or more + times to produce a value. + + A _waiting condition_ is a conditional expression that indicates whether waiting should continue + or be halted with a particular value. + + In the case of `wait/2`, there is a single waitable expression, which is passed as the first + argument of the macro, and an implicit waiting condition, which is based on the truthiness + of the associated waitable expression. For example: + + WaitForIt.wait(2 + 2 == 5, timeout: 200) + + In this example the waitable expression is `2 + 2 == 5` and the implicit waiting condition is + the truthiness of that expression. The waitable expression is repeatedly evaluated until the + value that it produces satisfies the waiting condition. In this case, the value of evaluating + the expression is always `false` so it will never satisfy the waiting condition of a truthy + value, and will therefore result in a timeout. + + For `case_wait/3`, there is a single waitable expression and one or more explicit waiting + conditions expressed as case clauses. For example: + + WaitForIt.case_wait(File.stat("data.csv"), timeout: :timer.seconds(10)) do + {:ok, %File.Stat{} = file_stat} -> IO.inspect(file_stat) + end + + In this example the waitable expression is `File.stat("data.csv")`, which, upon evaluation, + results in a value of either `{:ok, %FileStat{}}` or `{:error, reason}`. There is also one + explicit waiting condition, which is the case clause `{:ok, %File.Stat{} = file_stat}`. + The waitable expression will be repeatedly evaluated until it produces a value satisfying the + lone waiting condition. In other words, it will wait until the file exists or a timeout occurs. + + For `cond_wait/2`, there can be one or more waitable expressions and each one is paired with an + implicit waiting condition, which is the truthiness of the waitable expression value. + For example: + + WaitForIt.cond_wait(timeout: :timer.hours(1)) do + Date.utc_today().day == 1 -> IO.puts("It's the first day of the month") + NaiveDateTime.utc_now().minute == 30 -> IO.puts("It's half past the hour") + end + + In this example, there are two waitable expressions: `Date.utc_today().day == 1` and + `NaiveDateTime.utc_now().minute == 30`. Each of these is paired with an implicit waiting + condition, which is the truthiness of the value produced by evaluating the expression. + + ### Idempotency of waitable expressions + + Waitable expressions are by their nature subject to change with repeated evaluations over time. + Therefore, idempotent expressions are of little use in the context of waiting, since waiting + would either halt immediately (if the expression already saitisfies the waiting conditions) + or never halt at all (if it does not satisfy the waiting conditions). + + It is important, however, that any side-effects that can occur during evaluation of the + expression are safe and predictable, since the expression may be evaluated an inderminate + number of times while waiting. + + ## Polling-based waiting + + By default, WaitForIt uses a polling-based waiting mode in which waitable expressions are + periodically re-evaluated until waiting conditions have been met or a timeout has occurred. + The frequency at which waitable expressions are evaluated can be controlled by the `:frequency` + option, which specifies the delay between evaluations in milliseconds and is supported by all + forms of waiting. + + > #### Polling "frequency" {: .neutral} + > + > The term "frequency" is something of a misnomer as it is used here, since it is a time value + > (milliseconds) rather than a rate. A more accurate term would be `:polling_interval`, or + > perhaps simply `:interval`, but `:frequency` is already in use. + > + > For the curious, the actual frequency in Hertz can be derived from the value of the + > `:frequency` option using this formula: `1 / (:frequency / 1000)` + > + > Thus a `:frequency` value of 100 yields a frequency of 10 Hz. + + ## Signal-based waiting + + Signal-based waiting obviates the need for polling by using a signaling mechanism to indicate + that waiting conditions should be re-evaluated in response to some event. With signal-based + waiting, instead of periodically re-evaluating conditions at a particular frequency, a signal + is sent to waiters to indicate when waiting conditions should be re-evaluated. It is expected + that the `signal/1` function will be used to unblock the waiting code in order to re-evaluate + the waiting conditions. + + To use signal-based waiting instead of polling-based waiting use the `:signal` option that is + supported by all forms of waiting. The value of the `:signal` option is an arbitrary term + (typically an atom or a tuple of atoms) that serves as the binding between the waiting + conditions and the asynchronous code that can alter the outcome of those waiting conditions. + When the `:signal` option is used, WaitForIt will automatically wait until a matching signal is + received and then re-evaluate waiting conditions. If the waiting conditions are saitisfied then + the wait is halted, if not then the wait continues until the next signal is received or a + timeout occurs. + + By way of example, imagine a typical producer-consumer problem in which a consumer process waits + for items to appear in some buffer while a separate producer process occasionally place items in + the buffer. In this scenario, the consumer process might use the `wait/2` macro with the + `:signal` option to wait until there are some items in the buffer and the producer process would + use the `signal/1` function to tell the consumer that it might be time for it to check the + buffer again. + + # CONSUMER process + WaitForIt.wait Buffer.count() >= 4, signal: :wait_for_buffer + + # PRODUCER process + # put some things in buffer, then signal waiters + Buffer.put(1) + Buffer.put(2) + WaitForIt.signal(:wait_for_buffer) + + Notice that the same signal name, `:wait_for_buffer`, is used by both the consumer and the + producer, which is what allows the producer to signal to the consumer that waiting conditions + should be re-evaluated. It is important to realize that just because a signal has been emitted + does not necessarily mean that any waiting conditions have been satisfied. Rather, a signal + indicates that waiters should re-evaluate their waiting conditions to determine if they should + continue to wait or not. + + ## Using WaitForIt in tests + + One common use case for waiting on the results of asynchronous operations is in tests, + particularly in integration or end-to-end tests. This section will present examples of using + the various forms of waiting in test code. All examples assume that the `WaitForIt` module has + been imported in the test module, such as follows: + + defmodule MyTest do + use ExUnit.Case + import WaitForIt + end + + The `wait/2` macro can be used directly in assertions, since it returns the truthy or falsy + value that the waitable expression evaluated to (i.e. a truthy value for successful waits or a + falsy value for timeouts). For example, to assert that a particular database record is + eventually inserted into the database can be as simple as: + + assert wait(Repo.get(User, user_id)) + + Alternatively, pattern-matching can be used in some cases to make stronger assertions, such as: + + assert %User{first_name: "Elijah"} = wait(Repo.get(User, user_id), timeout: 1_000) + + The `case_wait/3` macro offers greater flexibility in the sense that it allows for matching on + any one of a series of case clauses and also allows for the use of an `else` block if none of + the case clauses eventually match. For example, to assert that a particular database record is + eventually inserted and that it has particular values: + + case_wait Repo.get(User, user_id), timeout: 1_000 do + %User{id: ^user_id} = user -> + assert user.first_name == "Elijah" + assert Date.compare(user.birth_date, ~D[2023-07-20]) == :eq + else + unexpected -> + flunk("Expected a User record for Elijah, got something else: #{inspect(unexpected)}") + end + + Or to test if exactly one or two records are returned for a particular query, something like + the following can be used: + + case_wait Repo.all(some_query), timeout: 2_000, frequency: 500 do + [only_thing] -> assert only_thing.id == 42 + [thing1, thing2] -> assert thing1.id == 1 and thing2.id == 2 + else + [] -> flunk("expected one or two things, got no things") + [_ | _] = things -> flunk("expected one or two things, got #{length(things)} things") + end + + ## A note on "catch-all" clauses + + It is common to include "catch-all" clauses in normal Elixir `case/2` and `cond/1` expressions. + Often, a `case/2` expression will include a final catch-all clause (like `_`) which will always + match, Similarly, a `cond/1` expression will typically include a final always-truthy condition + (like `true`) which will always match. + + When using the waiting variants of these constructs, `case_wait/3` and `cond_wait/2`, it is + *not* recommended to use such catch-all clauses. The reason for this is that, since catch-all + clauses by definition always match, including one as a waiting condition would not allow for + re-evaluating any other waiting conditions and would terminate the wait immediately after the + first evaluation. + + Instead of using a catch-all clause that always matches, an `else` clause can be used instead. + Both `case_wait/3` and `cond_wait/2` support `else` clauses, and these clauses are evaluated + whenever a waiting operation results in a timeout, which allows for customizing the behavior + and return value of the expression in the event of a timeout. """ - alias WaitForIt.Helpers + @typedoc """ + Type to represent an expression that can be waited on. + """ + @type wait_expression :: Macro.t() + + @typedoc """ + Options that can be used to control waiting behavior. + """ + @type wait_opt :: + {:timeout, non_neg_integer()} + | {:frequency, non_neg_integer()} + | {:pre_wait, non_neg_integer()} + | {:signal, atom() | nil} + + @typedoc """ + Options that can be used to control waiting behavior. + + See `t:wait_opt/0`. + """ + @type wait_opts :: [wait_opt()] @doc ~S""" Wait until the given `expression` evaluates to a truthy value. - Returns `{:ok, value}` or `{:timeout, timeout_milliseconds}`. + Returns the truthy value that ended the wait, or the last falsy value evaluated if a timeout + occurred. + + > #### Warning {: .warning} + > + > The value returned from this macro has changed as of version 2.0. + > + > In previous versions, `{:ok, value}` would be returned for the success case, and + > `{:timeout, timeout_milliseconds}` would be returned for the timeout case. + > + > As of version 2.0, the final value of the wait expression is returned directly, which will + > be a truthy value for the success case and a falsy value for the timeout case. This allows + > the `wait/2` macro to be used in conditional expressions, such as in `if/2`/`else` expressions, + > or in assertions in tests. + > + > If you are migrating from version 1.x and rely on the return value, you can enable the + > previous behavior by using the `WaitForIt.V1.wait/2` macro instead. ## Options See the WaitForIt module documentation for further discussion of these options. * `:timeout` - the amount of time to wait (in milliseconds) before giving up + * `:pre_wait` - wait for the given number of milliseconds before evaluating conditions for the first time * `:frequency` - the polling frequency (in milliseconds) at which to re-evaluate conditions - * `:signal` - disable polling and use a condition variable of the given name instead + * `:signal` - disable polling and use a signal of the given name instead ## Examples - Wait until the top of the hour: + Wait until the top of the hour: WaitForIt.wait Time.utc_now.minute == 0, frequency: 60_000, timeout: 60_000 * 60 - Wait up to one minute for a particular record to appear in the database: + Wait up to one minute for a particular record to appear in the database: - case WaitForIt.wait Repo.get(Post, 42), frequency: 1000, timeout: 60_000 do - {:ok, data} -> IO.inspect(data) - {:timeout, timeout} -> IO.puts("Gave up after #{timeout} milliseconds") + if data = WaitForIt.wait Repo.get(Post, 42), frequency: 1000, timeout: :timer.seconds(60) do + IO.inspect(data) + else + IO.puts("Gave up after #{timeout} milliseconds") end + + Assert that a database record is created by some asynchronous process: + + do_some_async_work() + assert %Post{id: 42} = WaitForIt.wait Repo.get(Post, 42) """ + @doc section: :wait defmacro wait(expression, opts \\ []) do - frequency = Keyword.get(opts, :frequency, 100) - timeout = Keyword.get(opts, :timeout, 5_000) - condition_var = Keyword.get(opts, :signal, nil) + quote do + require WaitForIt.Waitable.BasicWait + + waitable = WaitForIt.Waitable.BasicWait.create(unquote(expression)) + WaitForIt.Waiting.wait(waitable, unquote(opts), __ENV__) + end + end + @doc """ + The same as `wait/2` but raises a `WaitForIt.TimeoutError` exception if it fails. + """ + @doc section: :wait + defmacro wait!(expression, opts \\ []) do quote do - require WaitForIt.Helpers - - Helpers.wait( - Helpers.make_function(unquote(expression)), - unquote(frequency), - unquote(timeout), - Helpers.localized_name(unquote(condition_var)) - ) + require WaitForIt.Waitable.BasicWait + + waitable = WaitForIt.Waitable.BasicWait.create(unquote(expression)) + WaitForIt.Waiting.wait!(waitable, unquote(opts), __ENV__) end end @@ -110,36 +368,47 @@ defmodule WaitForIt do Wait until the given `expression` matches one of the case clauses in the given block. Returns the value of the matching clause, the value of the optional `else` clause, - or a tuple of the form `{:timeout, timeout_milliseconds}`. + or the last evaluated value of the expression in the event of a timeout. The `do` block passed to this macro must be a series of case clauses exactly like a built-in - Elixir `case` expression. Just like a `case` expression, the clauses will attempt to be matched - from top to bottom and the first one that matches will provide the resulting value of the - expression. The difference with `case_wait` is that if none of the clauses initially matches it + Elixir `case/2` expression. Just like a `case/2` expression, the clauses will attempt to be + matched from top to bottom and the first one that matches will provide the resulting value of the + expression. The difference with `case_wait/3` is that if none of the clauses initially matches it will wait and periodically re-evaluate the clauses until one of them does match or a timeout occurs. An optional `else` clause may also be used to provide the value in case of a timeout. If an `else` clause is provided and a timeout occurs, then the `else` clause will be evaluated and - the resulting value of the `else` clause becomes the value of the `case_wait` expression. If no - `else` clause is provided and a timeout occurs, then the value of the `case_wait` expression is a - tuple of the form `{:timeout, timeout_milliseconds}`. - - The optional `else` clause may also take the form of match clauses, such as those in a case - expression. In this form, the `else` clause can match on the final value of the expression that - was evaluated before the timeout occurred. See the examples below for an example of this. + the resulting value of the `else` clause becomes the value of the `case_wait/3` expression. If no + `else` clause is provided and a timeout occurs, then a `CaseClauseError` is raised, exactly as + if a normal Elixir `case/2` expression were being used. + + The optional `else` clause may also take the form of match clauses, such as those in the `else` + clause of a `with/1` expression. In this form, the `else` clause can match on the final value + of the expression that was evaluated before the timeout occurred. See the examples below for an + example of this. + + > #### Beware "catch-all" clauses {: .warning} + > + > `case_wait/3` expressions should *not* include a final "catch-all" clause, such as `_`, which + > would always match. Instead, an `else` clause can be used to customize the behavior and + > return value in the event of a waiting timeout. + > + > See [A note on "catch-all" clauses](#module-a-note-on-catch-all-clauses) in the module docs + > for further information. ## Options See the WaitForIt module documentation for further discussion of these options. * `:timeout` - the amount of time to wait (in milliseconds) before giving up + * `:pre_wait` - wait for the given number of milliseconds before evaluating conditions for the first time * `:frequency` - the polling frequency (in milliseconds) at which to re-evaluate conditions - * `:signal` - disable polling and use a condition variable of the given name instead + * `:signal` - disable polling and use a signal of the given name instead ## Examples - Wait until queue has at least 5 messages, then return them: + Wait until queue has at least 5 messages, then return them: WaitForIt.case_wait Queue.get_messages(queue), timeout: 30_000, frequency: 100 do messages when length(messages) > 4 -> messages @@ -148,7 +417,7 @@ defmodule WaitForIt do messages -> messages end - A thermostat that keeps temperature in a small range: + A thermostat that keeps temperature in a small range: def thermostat(desired_temperature) do WaitForIt.case_wait get_current_temperature() do @@ -160,37 +429,67 @@ defmodule WaitForIt do thermostat(desired_temperature) end - Ring the church bells every 15 minutes: + Wait until the process mailbox is small enough before flooding it with more messages: - def church_bell_chimes do - count = WaitForIt.case_wait Time.utc_now.minute, frequency: 60_000, timeout: 60_000 * 60 do - 15 -> 1 - 30 -> 2 - 45 -> 3 - 0 -> 4 - end - IO.puts(String.duplicate(" ding ding ding dong ", count)) - church_bell_chimes() + WaitForIt.case_wait Process.info(stream_pid, :message_queue_len), + frequency: 10, + timeout: 60_000 do + {:message_queue_len, len} when len < 500 -> + send_chunk(conn, chunk) + else + len -> + raise "Timeout while sending stream response. [message_queue_len: #{len}]" end + + > #### Production-ready {: .info} + > + > The above example is a real-world use of WaitForIt that was used to solve an issue with chunked + > HTTP responses using [plug_cowboy](https://github.com/elixir-plug/plug_cowboy). The underlying + > issue has since been fixed but this example is a good illustration of using WaitForIt to + > solve a production problem. + > + > See https://github.com/elixir-plug/plug_cowboy/issues/10 for background and further details, + > if interested. + """ + @doc section: :case_wait defmacro case_wait(expression, opts \\ [], blocks) do - frequency = Keyword.get(opts, :frequency, 100) - timeout = Keyword.get(opts, :timeout, 5_000) - condition_var = Keyword.get(opts, :signal) - do_block = Keyword.get(blocks, :do) + case_clauses = Keyword.get(blocks, :do) else_block = Keyword.get(blocks, :else) quote do - require WaitForIt.Helpers - - Helpers.case_wait( - Helpers.make_function(unquote(expression)), - unquote(frequency), - unquote(timeout), - Helpers.localized_name(unquote(condition_var)), - Helpers.make_case_function(unquote(do_block)), - Helpers.make_else_function(unquote(else_block)) - ) + require WaitForIt.Waitable.CaseWait + + waitable = + WaitForIt.Waitable.CaseWait.create( + unquote(expression), + unquote(case_clauses), + unquote(else_block) + ) + + WaitForIt.Waiting.wait(waitable, unquote(opts), __ENV__) + end + end + + @doc """ + The same as `case_wait/3` but raises a `WaitForIt.TimeoutError` exception if it fails. + """ + @doc section: :case_wait + defmacro case_wait!(expression, opts \\ [], blocks) do + case_clauses = Keyword.get(blocks, :do) + else_block = Keyword.get(blocks, :else) + + quote do + require WaitForIt.Waitable.CaseWait + + waitable = + WaitForIt.Waitable.CaseWait.create( + unquote(expression), + unquote(case_clauses), + unquote(else_block) + ) + + WaitForIt.Waiting.wait!(waitable, unquote(opts), __ENV__) end end @@ -198,32 +497,42 @@ defmodule WaitForIt do Wait until one of the expressions in the given block evaluates to a truthy value. Returns the value corresponding with the matching expression, the value of the optional `else` - clause, or a tuple of the form `{:timeout, timeout_milliseconds}`. + clause, or `nil` in the event of a timeout. The `do` block passed to this macro must be a series of expressions exactly like a built-in - Elixir `cond` expression. Just like a `cond` expression, the embedded expresions will be + Elixir `cond/1` expression. Just like a `cond/1` expression, the embedded expresions will be evaluated from top to bottom and the first one that is truthy will provide the resulting value of - the expression. The difference with `cond_wait` is that if none of the expressions is initially + the expression. The difference with `cond_wait/2` is that if none of the expressions is initially truthy it will wait and periodically re-evaluate them until one of them becomes truthy or a timeout occurs. An optional `else` clause may also be used to provide the value in case of a timeout. If an `else` clause is provided and a timeout occurs, then the `else` clause will be evaluated and - the resulting value of the `else` clause becomes the value of the `cond_wait` expression. If no - `else` clause is provided and a timeout occurs, then the value of the `cond_wait` expression is a - tuple of the form `{:timeout, timeout_milliseconds}`. + the resulting value of the `else` clause becomes the value of the `cond_wait/2` expression. If no + `else` clause is provided and a timeout occurs, then a `CondClauseError` is raised, exactly as + if a normal Elixir `cond/1` expression were being used. + + > #### Beware "catch-all" clauses {: .warning} + > + > `cond_wait/2` expressions should *not* include a final "catch-all" clause, such as `true`, + > which would always match. Instead, an `else` clause can be used to customize the behavior and + > return value in the event of a waiting timeout. + > + > See [A note on "catch-all" clauses](#module-a-note-on-catch-all-clauses) in the module docs + > for further information. ## Options See the WaitForIt module documentation for further discussion of these options. * `:timeout` - the amount of time to wait (in milliseconds) before giving up + * `:pre_wait` - wait for the given number of milliseconds before evaluating conditions for the first time * `:frequency` - the polling frequency (in milliseconds) at which to re-evaluate conditions - * `:signal` - disable polling and use a condition variable of the given name instead + * `:signal` - disable polling and use a signal of the given name instead ## Examples - Trigger an alarm when any sensors go beyond a threshold: + Trigger an alarm when any sensors go beyond a threshold: def sound_the_alarm do WaitForIt.cond_wait timeout: 60_000 * 60 * 24 do @@ -233,41 +542,58 @@ defmodule WaitForIt do else IO.puts("All is good...for now.") end + + # Recursively call to wait for the next sensor readings... sound_the_alarm() end """ + @doc section: :cond_wait defmacro cond_wait(opts \\ [], blocks) do - frequency = Keyword.get(opts, :frequency, 100) - timeout = Keyword.get(opts, :timeout, 5_000) - condition_var = Keyword.get(opts, :signal) - do_block = Keyword.get(blocks, :do) + cond_clauses = Keyword.get(blocks, :do) else_block = Keyword.get(blocks, :else) quote do - require WaitForIt.Helpers - - Helpers.cond_wait( - unquote(frequency), - unquote(timeout), - Helpers.localized_name(unquote(condition_var)), - Helpers.make_cond_function(unquote(do_block)), - Helpers.make_function(unquote(else_block)) - ) + require WaitForIt.Waitable.CondWait + + waitable = + WaitForIt.Waitable.CondWait.create( + unquote(cond_clauses), + unquote(else_block) + ) + + WaitForIt.Waiting.wait(waitable, unquote(opts), __ENV__) end end - @doc ~S""" - Send a signal to the given condition variable to indicate that any processes waiting on the - condition variable should re-evaluate their wait conditions. - - The caller of `signal` must be in the same Elixir module as any waiters on the same condition - variable since the module is used as a namespace for condition variables. This is to prevent - accidental name collisions as well as to enforce good practice for encapsulation. + @doc """ + The same as `cond_wait/2` but raises a `WaitForIt.TimeoutError` exception if it fails. """ - defmacro signal(condition_var) do + @doc section: :cond_wait + defmacro cond_wait!(opts \\ [], blocks) do + cond_clauses = Keyword.get(blocks, :do) + else_block = Keyword.get(blocks, :else) + quote do - require WaitForIt.Helpers - Helpers.condition_var_signal(Helpers.localized_name(unquote(condition_var))) + require WaitForIt.Waitable.CondWait + + waitable = + WaitForIt.Waitable.CondWait.create( + unquote(cond_clauses), + unquote(else_block) + ) + + WaitForIt.Waiting.wait!(waitable, unquote(opts), __ENV__) end end + + @doc """ + Send a signal to indicate that any processes waiting on the signal should re-evaluate their + waiting conditions. + """ + @doc section: :signal + def signal(signal) do + Registry.dispatch(WaitForIt.SignalRegistry, signal, fn waiters -> + for {pid, _env} <- waiters, do: send(pid, {:wait_for_it_signal, signal}) + end) + end end diff --git a/lib/wait_for_it/application.ex b/lib/wait_for_it/application.ex index d059a8a..de5dc7c 100644 --- a/lib/wait_for_it/application.ex +++ b/lib/wait_for_it/application.ex @@ -2,15 +2,14 @@ defmodule WaitForIt.Application do @moduledoc false use Application - import Supervisor.Spec, warn: false def start(_type, _args) do children = [ - supervisor(Registry, [:unique, WaitForIt.ConditionVariable.registry()]), - supervisor(WaitForIt.ConditionVariable.Supervisor, []) + {Registry, keys: :duplicate, name: WaitForIt.SignalRegistry}, + {Registry, keys: :unique, name: WaitForIt.V1.ConditionVariable.registry()}, + WaitForIt.V1.ConditionVariable.Supervisor ] - opts = [strategy: :one_for_one, name: WaitForIt.Supervisor] - Supervisor.start_link(children, opts) + Supervisor.start_link(children, strategy: :one_for_one, name: WaitForIt.Supervisor) end end diff --git a/lib/wait_for_it/condition_variable/supervisor.ex b/lib/wait_for_it/condition_variable/supervisor.ex deleted file mode 100644 index fb5ec4e..0000000 --- a/lib/wait_for_it/condition_variable/supervisor.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule WaitForIt.ConditionVariable.Supervisor do - use Supervisor - - def start_link do - Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) - end - - def create_condition_variable do - Supervisor.start_child(__MODULE__, []) - end - - def named_condition_variable(name) when is_atom(name) do - case Supervisor.start_child(__MODULE__, [name]) do - {:ok, pid} when is_pid(pid) -> - {:ok, pid} - - {:error, {:already_started, pid}} when is_pid(pid) -> - {:ok, pid} - - {:error, reason} -> - {:error, reason} - end - end - - def init(:ok) do - children = [ - worker(WaitForIt.ConditionVariable, [], restart: :temporary) - ] - - supervise(children, strategy: :simple_one_for_one) - end -end diff --git a/lib/wait_for_it/evaluation.ex b/lib/wait_for_it/evaluation.ex new file mode 100644 index 0000000..17dd6bf --- /dev/null +++ b/lib/wait_for_it/evaluation.ex @@ -0,0 +1,76 @@ +defmodule WaitForIt.Evaluation do + @moduledoc """ + Helper module for capturing compile-time expressions (i.e. ASTs) and evaluating them at runtime. + """ + + defmacro capture_expression(nil), do: nil + defmacro capture_expression(expression), do: quote(do: fn -> unquote(expression) end) + + def eval_expression(captured_expression) when is_function(captured_expression) do + captured_expression.() + end + + defmacro capture_case_clauses(case_clauses) do + quote do + fn expr -> + case expr do + unquote(case_clauses) + end + end + end + end + + def eval_case_expression(value, case_clauses) when is_function(case_clauses) do + case_clauses.(value) + end + + defmacro capture_cond_clauses(cond_clauses) do + quote do + fn -> + cond do + unquote(cond_clauses) + end + end + end + end + + def eval_cond_expression(cond_clauses) when is_function(cond_clauses) do + cond_clauses.() + end + + defmacro capture_with_clauses(with_clauses, do_block) do + quote do + fn -> + with unquote(with_clauses) do + unquote(do_block) + end + end + end + end + + defmacro capture_else_block(nil), do: nil + + defmacro capture_else_block([{:->, _, _} | _] = clauses) do + quote do + fn value -> + case value do + unquote(clauses) + end + end + end + end + + defmacro capture_else_block(else_block) do + quote do + fn _ -> + unquote(else_block) + end + end + end + + def eval_else_block(value, nil), do: value + + def eval_else_block(value, else_block) when is_function(else_block) do + else_block.(value) + end +end diff --git a/lib/wait_for_it/timeout_error.ex b/lib/wait_for_it/timeout_error.ex new file mode 100644 index 0000000..0ffbf9d --- /dev/null +++ b/lib/wait_for_it/timeout_error.ex @@ -0,0 +1,63 @@ +defmodule WaitForIt.TimeoutError do + @moduledoc """ + Exception type to represent a timeout that occurred while waiting. + """ + + defexception [:message, :waitable, :timeout, :last_value, :env] + + @type t :: %__MODULE__{ + __exception__: true, + message: String.t(), + waitable: WaitForIt.Waitable.t(), + timeout: non_neg_integer(), + last_value: term(), + env: env() + } + + @typedoc """ + Type to represent the `:env` field of `WaitForIt.TimeoutError` exceptions. + + This struct is a subset of of `Macro.Env` and contains the following fields: + + * `context` - the context of the environment; it can be nil (default context), :guard + (inside a guard) or :match (inside a match) + * `context_modules` - a list of modules defined in the current context + * `file` - the current absolute file name as a binary + * `function` - a tuple as {atom, integer}, where the first element is the function name and + the second its arity; returns nil if not inside a function + * `line` - the current line as an integer + * `module` - the current module name + """ + @type env :: %{ + context: Macro.Env.context(), + context_modules: Macro.Env.context_modules(), + file: Macro.Env.file(), + function: Macro.Env.name_arity() | nil, + line: Macro.Env.line(), + module: module() + } + + def exception(opts) do + timeout = Keyword.fetch!(opts, :timeout) + waitable = Keyword.fetch!(opts, :waitable) + message = "timeout in #{WaitForIt.Waitable.wait_type(waitable)}: #{timeout}ms" + + params = %{ + message: message, + timeout: timeout, + waitable: waitable, + last_value: opts[:last_value], + env: make_env(opts[:env]) + } + + struct(__MODULE__, params) + end + + @doc false + @spec make_env(Macro.Env.t()) :: env() + def make_env(%Macro.Env{} = env), + do: Map.take(env, [:context, :context_modules, :file, :function, :line, :module]) + + @spec make_env(any()) :: nil + def make_env(_), do: nil +end diff --git a/lib/wait_for_it/condition_variable.ex b/lib/wait_for_it/v1/condition_variable/condition_variable.ex similarity index 68% rename from lib/wait_for_it/condition_variable.ex rename to lib/wait_for_it/v1/condition_variable/condition_variable.ex index 8369d47..c0c45ae 100644 --- a/lib/wait_for_it/condition_variable.ex +++ b/lib/wait_for_it/v1/condition_variable/condition_variable.ex @@ -1,26 +1,25 @@ -defmodule WaitForIt.ConditionVariable do - use GenServer +defmodule WaitForIt.V1.ConditionVariable do + @moduledoc false + + use GenServer, + restart: :temporary @default_idle_timeout 60_000 defstruct waiters: [], idle_timeout: @default_idle_timeout - def start_link do - start_link(idle_timeout: @default_idle_timeout) - end + def start_link, do: start_link([]) - def start_link(name) when is_atom(name) do - start_link(name, idle_timeout: @default_idle_timeout) - end - - def start_link(idle_timeout: idle_timeout) when is_integer(idle_timeout) do - GenServer.start_link(__MODULE__, idle_timeout) - end + def start_link(opts) when is_list(opts) do + name_opt = + case Keyword.get(opts, :name) do + name when is_atom(name) -> [name: via_tuple(name)] + _ -> [] + end - def start_link(name, idle_timeout: idle_timeout) - when is_atom(name) and is_integer(idle_timeout) do - GenServer.start_link(__MODULE__, idle_timeout, name: via_tuple(name)) + idle_timeout = Keyword.get(opts, :idle_timeout, @default_idle_timeout) + GenServer.start_link(__MODULE__, idle_timeout, name_opt) end def registry, do: __MODULE__.Registry @@ -44,19 +43,23 @@ defmodule WaitForIt.ConditionVariable do end end + @impl true def init(idle_timeout) do {:ok, %__MODULE__{idle_timeout: idle_timeout}} end + @impl true def handle_call({:wait, ref}, {from, _tag}, state) do {:reply, :ok, Map.update!(state, :waiters, &[{from, ref} | &1])} end + @impl true def handle_cast(:signal, state) do for {pid, ref} <- state.waiters, do: send(pid, {:signal, ref}) {:noreply, %{state | waiters: []}, state.idle_timeout} end + @impl true def handle_info(:timeout, state) do {:stop, :normal, state} end diff --git a/lib/wait_for_it/v1/condition_variable/supervisor.ex b/lib/wait_for_it/v1/condition_variable/supervisor.ex new file mode 100644 index 0000000..daaa12c --- /dev/null +++ b/lib/wait_for_it/v1/condition_variable/supervisor.ex @@ -0,0 +1,33 @@ +defmodule WaitForIt.V1.ConditionVariable.Supervisor do + @moduledoc false + + use DynamicSupervisor + + alias WaitForIt.V1.ConditionVariable + + def start_link(arg) do + DynamicSupervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + def create_condition_variable do + DynamicSupervisor.start_child(__MODULE__, ConditionVariable) + end + + def named_condition_variable(name) when is_atom(name) do + case DynamicSupervisor.start_child(__MODULE__, {ConditionVariable, name: name}) do + {:ok, pid} when is_pid(pid) -> + {:ok, pid} + + {:error, {:already_started, pid}} when is_pid(pid) -> + {:ok, pid} + + {:error, reason} -> + {:error, reason} + end + end + + @impl true + def init(_) do + DynamicSupervisor.init(strategy: :one_for_one) + end +end diff --git a/lib/wait_for_it/helpers.ex b/lib/wait_for_it/v1/helpers.ex similarity index 83% rename from lib/wait_for_it/helpers.ex rename to lib/wait_for_it/v1/helpers.ex index 43de280..9fdab73 100644 --- a/lib/wait_for_it/helpers.ex +++ b/lib/wait_for_it/v1/helpers.ex @@ -1,7 +1,7 @@ -defmodule WaitForIt.Helpers do +defmodule WaitForIt.V1.Helpers do @moduledoc false - alias WaitForIt.ConditionVariable + alias WaitForIt.V1.ConditionVariable defmacro localized_name(name) do if name do @@ -11,7 +11,7 @@ defmodule WaitForIt.Helpers do defmacro make_function(nil), do: nil - defmacro make_function(expression), do: quote do: fn -> unquote(expression) end + defmacro make_function(expression), do: quote(do: fn -> unquote(expression) end) defmacro make_case_function(cases) do quote do @@ -53,6 +53,14 @@ defmodule WaitForIt.Helpers do end end + defmacro pre_wait(time) when is_integer(time) and time > 0 do + quote do: Process.sleep(unquote(time)) + end + + defmacro pre_wait(0) do + quote do: :ok + end + def wait(expression, frequency, timeout, condition_var) do loop(frequency, timeout, condition_var, fn -> value = expression.() @@ -61,6 +69,14 @@ defmodule WaitForIt.Helpers do |> handle_wait_result() end + def wait!(expression, frequency, timeout, condition_var) do + loop(frequency, timeout, condition_var, fn -> + value = expression.() + if value, do: {:break, value}, else: {:loop, value} + end) + |> handle_wait_bang_result() + end + def case_wait(expression, frequency, timeout, condition_var, do_block, else_block) do loop(frequency, timeout, condition_var, fn -> value = expression.() @@ -159,8 +175,17 @@ defmodule WaitForIt.Helpers do defp handle_wait_result({@tag, {:timeout, timeout, _}}), do: {:timeout, timeout} defp handle_wait_result({@tag, value}), do: {:ok, value} + defp handle_wait_bang_result({@tag, {:timeout, timeout, value}}) do + raise WaitForIt.TimeoutError, timeout: timeout, wait_type: :wait!, last_value: value + end + + defp handle_wait_bang_result({@tag, value}), do: value + defp handle_case_wait_result({@tag, {:timeout, timeout, _}}, nil), do: {:timeout, timeout} - defp handle_case_wait_result({@tag, {:timeout, _timeout, value}}, else_block), do: else_block.(value) + + defp handle_case_wait_result({@tag, {:timeout, _timeout, value}}, else_block), + do: else_block.(value) + defp handle_case_wait_result({@tag, value}, _else_block), do: value defp handle_cond_wait_result({@tag, {:timeout, timeout, _}}, nil), do: {:timeout, timeout} diff --git a/lib/wait_for_it/v1/wait_for_it.ex b/lib/wait_for_it/v1/wait_for_it.ex new file mode 100644 index 0000000..a00236e --- /dev/null +++ b/lib/wait_for_it/v1/wait_for_it.ex @@ -0,0 +1,254 @@ +defmodule WaitForIt.V1 do + @moduledoc deprecated: + "This is a legacy module for backward compatibility only; new code should use the main WaitForIt module instead" + + @doc ~S""" + Wait until the given `expression` evaluates to a truthy value. + + Returns `{:ok, value}` or `{:timeout, timeout_milliseconds}`. + + ## Options + + See the WaitForIt module documentation for further discussion of these options. + + * `:timeout` - the amount of time to wait (in milliseconds) before giving up + * `:frequency` - the polling frequency (in milliseconds) at which to re-evaluate conditions + * `:signal` - disable polling and use a condition variable of the given name instead + * `:pre_wait` - wait for the given number of milliseconds before evaluating conditions for the first time + + ## Examples + + Wait until the top of the hour: + + WaitForIt.wait Time.utc_now.minute == 0, frequency: 60_000, timeout: 60_000 * 60 + + Wait up to one minute for a particular record to appear in the database: + + case WaitForIt.wait Repo.get(Post, 42), frequency: 1000, timeout: 60_000 do + {:ok, data} -> IO.inspect(data) + {:timeout, timeout} -> IO.puts("Gave up after #{timeout} milliseconds") + end + """ + defmacro wait(expression, opts \\ []) do + frequency = Keyword.get(opts, :frequency, 100) + timeout = Keyword.get(opts, :timeout, 5_000) + condition_var = Keyword.get(opts, :signal, nil) + pre_wait = Keyword.get(opts, :pre_wait, 0) + + quote do + require WaitForIt.V1.Helpers, as: Helpers + Helpers.pre_wait(unquote(pre_wait)) + + Helpers.wait( + Helpers.make_function(unquote(expression)), + unquote(frequency), + unquote(timeout), + Helpers.localized_name(unquote(condition_var)) + ) + end + end + + @doc ~S""" + Wait until the given `expression` evaluates to a truthy value. + + Returns the truthy value or raises a `WaitForIt.TimeoutError` if a timeout occurs. + + ## Options + + See the WaitForIt module documentation for further discussion of these options. + + * `:timeout` - the amount of time to wait (in milliseconds) before giving up + * `:frequency` - the polling frequency (in milliseconds) at which to re-evaluate conditions + * `:signal` - disable polling and use a condition variable of the given name instead + * `:pre_wait` - wait for the given number of milliseconds before evaluating conditions for the first time + """ + defmacro wait!(expression, opts \\ []) do + frequency = Keyword.get(opts, :frequency, 100) + timeout = Keyword.get(opts, :timeout, 5_000) + condition_var = Keyword.get(opts, :signal, nil) + pre_wait = Keyword.get(opts, :pre_wait, 0) + + quote do + require WaitForIt.V1.Helpers, as: Helpers + Helpers.pre_wait(unquote(pre_wait)) + + Helpers.wait!( + Helpers.make_function(unquote(expression)), + unquote(frequency), + unquote(timeout), + Helpers.localized_name(unquote(condition_var)) + ) + end + end + + @doc ~S""" + Wait until the given `expression` matches one of the case clauses in the given block. + + Returns the value of the matching clause, the value of the optional `else` clause, + or a tuple of the form `{:timeout, timeout_milliseconds}`. + + The `do` block passed to this macro must be a series of case clauses exactly like a built-in + Elixir `case` expression. Just like a `case` expression, the clauses will attempt to be matched + from top to bottom and the first one that matches will provide the resulting value of the + expression. The difference with `case_wait` is that if none of the clauses initially matches it + will wait and periodically re-evaluate the clauses until one of them does match or a timeout + occurs. + + An optional `else` clause may also be used to provide the value in case of a timeout. If an + `else` clause is provided and a timeout occurs, then the `else` clause will be evaluated and + the resulting value of the `else` clause becomes the value of the `case_wait` expression. If no + `else` clause is provided and a timeout occurs, then the value of the `case_wait` expression is a + tuple of the form `{:timeout, timeout_milliseconds}`. + + The optional `else` clause may also take the form of match clauses, such as those in a case + expression. In this form, the `else` clause can match on the final value of the expression that + was evaluated before the timeout occurred. See the examples below for an example of this. + + ## Options + + See the WaitForIt module documentation for further discussion of these options. + + * `:timeout` - the amount of time to wait (in milliseconds) before giving up + * `:frequency` - the polling frequency (in milliseconds) at which to re-evaluate conditions + * `:signal` - disable polling and use a condition variable of the given name instead + * `:pre_wait` - wait for the given number of milliseconds before evaluating conditions for the first time + + ## Examples + + Wait until queue has at least 5 messages, then return them: + + WaitForIt.case_wait Queue.get_messages(queue), timeout: 30_000, frequency: 100 do + messages when length(messages) > 4 -> messages + else + # If after 30 seconds we still don't have 5 messages, just return the messages we do have. + messages -> messages + end + + A thermostat that keeps temperature in a small range: + + def thermostat(desired_temperature) do + WaitForIt.case_wait get_current_temperature() do + temp when temp > desired_temperature + 2 -> + turn_on_air_conditioning() + temp when temp < desired_temperature - 2 -> + turn_on_heat() + end + thermostat(desired_temperature) + end + + Ring the church bells every 15 minutes: + + def church_bell_chimes do + count = WaitForIt.case_wait Time.utc_now.minute, frequency: 60_000, timeout: 60_000 * 60 do + 15 -> 1 + 30 -> 2 + 45 -> 3 + 0 -> 4 + end + IO.puts(String.duplicate(" ding ding ding dong ", count)) + church_bell_chimes() + end + """ + defmacro case_wait(expression, opts \\ [], blocks) do + frequency = Keyword.get(opts, :frequency, 100) + timeout = Keyword.get(opts, :timeout, 5_000) + condition_var = Keyword.get(opts, :signal) + do_block = Keyword.get(blocks, :do) + else_block = Keyword.get(blocks, :else) + pre_wait = Keyword.get(opts, :pre_wait, 0) + + quote do + require WaitForIt.V1.Helpers, as: Helpers + Helpers.pre_wait(unquote(pre_wait)) + + Helpers.case_wait( + Helpers.make_function(unquote(expression)), + unquote(frequency), + unquote(timeout), + Helpers.localized_name(unquote(condition_var)), + Helpers.make_case_function(unquote(do_block)), + Helpers.make_else_function(unquote(else_block)) + ) + end + end + + @doc ~S""" + Wait until one of the expressions in the given block evaluates to a truthy value. + + Returns the value corresponding with the matching expression, the value of the optional `else` + clause, or a tuple of the form `{:timeout, timeout_milliseconds}`. + + The `do` block passed to this macro must be a series of expressions exactly like a built-in + Elixir `cond` expression. Just like a `cond` expression, the embedded expresions will be + evaluated from top to bottom and the first one that is truthy will provide the resulting value of + the expression. The difference with `cond_wait` is that if none of the expressions is initially + truthy it will wait and periodically re-evaluate them until one of them becomes truthy or a + timeout occurs. + + An optional `else` clause may also be used to provide the value in case of a timeout. If an + `else` clause is provided and a timeout occurs, then the `else` clause will be evaluated and + the resulting value of the `else` clause becomes the value of the `cond_wait` expression. If no + `else` clause is provided and a timeout occurs, then the value of the `cond_wait` expression is a + tuple of the form `{:timeout, timeout_milliseconds}`. + + ## Options + + See the WaitForIt module documentation for further discussion of these options. + + * `:timeout` - the amount of time to wait (in milliseconds) before giving up + * `:frequency` - the polling frequency (in milliseconds) at which to re-evaluate conditions + * `:signal` - disable polling and use a condition variable of the given name instead + * `:pre_wait` - wait for the given number of milliseconds before evaluating conditions for the first time + + ## Examples + + Trigger an alarm when any sensors go beyond a threshold: + + def sound_the_alarm do + WaitForIt.cond_wait timeout: 60_000 * 60 * 24 do + read_sensor(:sensor1) > 9 -> IO.puts("Alarm: :sensor1 too high!") + read_sensor(:sensor2) < 100 -> IO.puts("Alarm: :sensor2 too low!") + read_sensor(:sensor3) < 0 -> IO.puts("Alarm: :sensor3 below zero!") + else + IO.puts("All is good...for now.") + end + sound_the_alarm() + end + """ + defmacro cond_wait(opts \\ [], blocks) do + frequency = Keyword.get(opts, :frequency, 100) + timeout = Keyword.get(opts, :timeout, 5_000) + condition_var = Keyword.get(opts, :signal) + do_block = Keyword.get(blocks, :do) + else_block = Keyword.get(blocks, :else) + pre_wait = Keyword.get(opts, :pre_wait, 0) + + quote do + require WaitForIt.V1.Helpers, as: Helpers + Helpers.pre_wait(unquote(pre_wait)) + + Helpers.cond_wait( + unquote(frequency), + unquote(timeout), + Helpers.localized_name(unquote(condition_var)), + Helpers.make_cond_function(unquote(do_block)), + Helpers.make_function(unquote(else_block)) + ) + end + end + + @doc ~S""" + Send a signal to the given condition variable to indicate that any processes waiting on the + condition variable should re-evaluate their wait conditions. + + The caller of `signal` must be in the same Elixir module as any waiters on the same condition + variable since the module is used as a namespace for condition variables. This is to prevent + accidental name collisions as well as to enforce good practice for encapsulation. + """ + defmacro signal(condition_var) do + quote do + require WaitForIt.V1.Helpers, as: Helpers + Helpers.condition_var_signal(Helpers.localized_name(unquote(condition_var))) + end + end +end diff --git a/lib/wait_for_it/waitable/basic_wait.ex b/lib/wait_for_it/waitable/basic_wait.ex new file mode 100644 index 0000000..e7fef61 --- /dev/null +++ b/lib/wait_for_it/waitable/basic_wait.ex @@ -0,0 +1,35 @@ +defmodule WaitForIt.Waitable.BasicWait do + @moduledoc """ + Implementation of the `WaitForIt.Waitable` protocol for basic truthy/falsy wait conditions. + """ + + defstruct [:expression] + + defmacro create(quoted_expression) do + quote do + require WaitForIt.Evaluation + + %WaitForIt.Waitable.BasicWait{ + expression: WaitForIt.Evaluation.capture_expression(unquote(quoted_expression)) + } + end + end + + defimpl WaitForIt.Waitable do + alias WaitForIt.Waitable.BasicWait + + def wait_type(%BasicWait{}), do: :wait + + def evaluate(%BasicWait{expression: expression}, _env) do + value = WaitForIt.Evaluation.eval_expression(expression) + + if value do + {:halt, value} + else + {:cont, value} + end + end + + def handle_timeout(_waitable, last_value, _env), do: last_value + end +end diff --git a/lib/wait_for_it/waitable/case_wait.ex b/lib/wait_for_it/waitable/case_wait.ex new file mode 100644 index 0000000..7a73878 --- /dev/null +++ b/lib/wait_for_it/waitable/case_wait.ex @@ -0,0 +1,44 @@ +defmodule WaitForIt.Waitable.CaseWait do + @moduledoc """ + Implementation of the `WaitForIt.Waitable` protocol for the `WaitForIt.case_wait/3` construct. + """ + + defstruct [:expression, :case_clauses, :else_block] + + defmacro create(quoted_expression, case_clauses, else_block \\ nil) do + quote do + require WaitForIt.Evaluation + + %WaitForIt.Waitable.CaseWait{ + expression: WaitForIt.Evaluation.capture_expression(unquote(quoted_expression)), + case_clauses: WaitForIt.Evaluation.capture_case_clauses(unquote(case_clauses)), + else_block: WaitForIt.Evaluation.capture_else_block(unquote(else_block)) + } + end + end + + defimpl WaitForIt.Waitable do + alias WaitForIt.Waitable.CaseWait + + def wait_type(%CaseWait{}), do: :case_wait + + def evaluate(%CaseWait{expression: expression, case_clauses: case_clauses}, _env) do + value = WaitForIt.Evaluation.eval_expression(expression) + + try do + result = WaitForIt.Evaluation.eval_case_expression(value, case_clauses) + {:halt, result} + rescue + CaseClauseError -> {:cont, value} + end + end + + def handle_timeout(%CaseWait{else_block: nil}, last_value, env) do + reraise CaseClauseError, [term: last_value], Macro.Env.stacktrace(env) + end + + def handle_timeout(%CaseWait{else_block: else_block}, last_value, _env) do + WaitForIt.Evaluation.eval_else_block(last_value, else_block) + end + end +end diff --git a/lib/wait_for_it/waitable/cond_wait.ex b/lib/wait_for_it/waitable/cond_wait.ex new file mode 100644 index 0000000..b17b2ba --- /dev/null +++ b/lib/wait_for_it/waitable/cond_wait.ex @@ -0,0 +1,41 @@ +defmodule WaitForIt.Waitable.CondWait do + @moduledoc """ + Implementation of the `WaitForIt.Waitable` protocol for the `WaitForIt.cond_wait/3` construct. + """ + + defstruct [:cond_clauses, :else_block] + + defmacro create(cond_clauses, else_block \\ nil) do + quote do + require WaitForIt.Evaluation + + %WaitForIt.Waitable.CondWait{ + cond_clauses: WaitForIt.Evaluation.capture_cond_clauses(unquote(cond_clauses)), + else_block: WaitForIt.Evaluation.capture_else_block(unquote(else_block)) + } + end + end + + defimpl WaitForIt.Waitable do + alias WaitForIt.Waitable.CondWait + + def wait_type(%CondWait{}), do: :cond_wait + + def evaluate(%CondWait{cond_clauses: cond_clauses}, _env) do + try do + result = WaitForIt.Evaluation.eval_cond_expression(cond_clauses) + {:halt, result} + rescue + CondClauseError -> {:cont, nil} + end + end + + def handle_timeout(%CondWait{else_block: nil}, _last_value, env) do + reraise CondClauseError, Macro.Env.stacktrace(env) + end + + def handle_timeout(%CondWait{else_block: else_block}, last_value, _env) do + WaitForIt.Evaluation.eval_else_block(last_value, else_block) + end + end +end diff --git a/lib/wait_for_it/waitable/waitable.ex b/lib/wait_for_it/waitable/waitable.ex new file mode 100644 index 0000000..042b2de --- /dev/null +++ b/lib/wait_for_it/waitable/waitable.ex @@ -0,0 +1,54 @@ +defprotocol WaitForIt.Waitable do + @moduledoc """ + Protocol used for evaluating waitable expressions against waiting conditions to determine if + waiting should continue or halt with a final value. + """ + + @type wait_type :: atom() + @type value :: any() + + @spec wait_type(t()) :: wait_type() + def wait_type(waitable) + + @doc """ + Evaluates the waitable expression to provide its value, or to continue to wait. + + It should return `{:halt, value}` if the wait is over and the final value of the waitable + expression has been determined, or `{:cont, value}` if waiting should continue. + """ + @spec evaluate(t(), Macro.Env.t()) :: {:halt, value()} | {:cont, value()} + def evaluate(waitable, env) + + @doc """ + Provides the final value of the waitable expression in the event of a timeout. + """ + @spec handle_timeout(t(), value(), Macro.Env.t()) :: value() + def handle_timeout(waitable, last_value, env) +end + +defprotocol WaitForIt.Waitable.Raise do + @moduledoc """ + Protocol used to customize exceptions that are raised in the event of a timeout. + """ + + @fallback_to_any true + + @spec raise_timeout_error( + t(), + WaitForIt.Waitable.value(), + timeout_ms :: non_neg_integer(), + Macro.Env.t() + ) :: + no_return() + def raise_timeout_error(waitable, last_value, timeout_ms, env) +end + +defimpl WaitForIt.Waitable.Raise, for: Any do + def raise_timeout_error(waitable, last_value, timeout_ms, env) do + raise WaitForIt.TimeoutError, + waitable: waitable, + timeout: timeout_ms, + last_value: last_value, + env: env + end +end diff --git a/lib/wait_for_it/waitable/with_wait.ex b/lib/wait_for_it/waitable/with_wait.ex new file mode 100644 index 0000000..c2d4da0 --- /dev/null +++ b/lib/wait_for_it/waitable/with_wait.ex @@ -0,0 +1,19 @@ +defmodule WaitForIt.Waitable.WithWait do + @moduledoc """ + Implementation of the `WaitForIt.Waitable` protocol for the `WaitForIt.with_wait/3` construct. + """ + + defstruct [:with_clauses, :do_block, :else_block] + + defmacro create(with_clauses, do_block, else_block \\ nil) do + quote do + require WaitForIt.Evaluation + + %WaitForIt.Waitable.WithWait{ + with_clauses: WaitForIt.Evaluation.capture_with_clauses(unquote(with_clauses)), + do_block: WaitForIt.Evaluation.capture_expression(unquote(do_block)), + else_block: WaitForIt.Evaluation.capture_else_block(unquote(else_block)) + } + end + end +end diff --git a/lib/wait_for_it/waiting.ex b/lib/wait_for_it/waiting.ex new file mode 100644 index 0000000..7cfc9c4 --- /dev/null +++ b/lib/wait_for_it/waiting.ex @@ -0,0 +1,121 @@ +defmodule WaitForIt.Waiting do + @moduledoc false + + alias WaitForIt.Waitable + + @default_wait_opts [ + timeout: 5_000, + frequency: 100, + pre_wait: 0 + ] + + def wait(waitable, wait_opts, env) do + wait_loop(waitable, merge_wait_opts(wait_opts), env) + end + + def wait!(waitable, wait_opts, env) do + wait_opts = merge_wait_opts(Keyword.put_new(wait_opts, :on_timeout, :raise)) + wait_loop(waitable, wait_opts, env) + end + + defp merge_wait_opts(user_specified_opts) do + Keyword.merge(@default_wait_opts, user_specified_opts) + end + + def wait_loop(waitable, wait_opts, env) do + pre_wait(wait_opts[:pre_wait]) + + if wait_opts[:signal] do + signaling_wait_loop(waitable, wait_opts, env) + else + polling_wait_loop(waitable, wait_opts, env) + end + end + + defp polling_wait_loop(waitable, wait_opts, env) do + time_bomb = start_time_bomb(self(), wait_opts[:timeout]) + wait_for_tick = fn -> wait_for_tick(wait_opts[:frequency], time_bomb) end + + try do + eval_loop(waitable, wait_opts, env, wait_for_tick) + after + stop_time_bomb(time_bomb) + end + end + + defp signaling_wait_loop(waitable, wait_opts, env) do + signal = wait_opts[:signal] + register_for_signal(signal, env) + wait_for_signal = fn -> wait_for_signal(signal, wait_opts[:timeout], now()) end + + try do + eval_loop(waitable, wait_opts, env, wait_for_signal) + after + unregister_from_signal(signal) + end + end + + defp eval_loop(waitable, wait_opts, env, sleeper_fun) do + case Waitable.evaluate(waitable, env) do + {:cont, value} -> + case sleeper_fun.() do + :loop -> + eval_loop(waitable, wait_opts, env, sleeper_fun) + + {:timeout, timeout} -> + if wait_opts[:on_timeout] == :raise do + Waitable.Raise.raise_timeout_error(waitable, value, timeout, env) + else + Waitable.handle_timeout(waitable, value, env) + end + end + + {:halt, value} -> + value + end + end + + defp pre_wait(0), do: :ok + defp pre_wait(time), do: Process.sleep(time) + + defp now, do: System.system_time(:millisecond) + + defp start_time_bomb(waiting_pid, timeout) do + {:ok, time_bomb_pid} = + Task.start(fn -> + Process.sleep(timeout) + send(waiting_pid, {self(), timeout}) + end) + + time_bomb_pid + end + + defp stop_time_bomb(time_bomb) when is_pid(time_bomb), do: Process.exit(time_bomb, :kill) + + defp wait_for_tick(tick_time, time_bomb) when is_integer(tick_time) and is_pid(time_bomb) do + receive do + {^time_bomb, timeout} -> {:timeout, timeout} + after + tick_time -> :loop + end + end + + defp register_for_signal(signal, env) do + Registry.register(WaitForIt.SignalRegistry, signal, env) + end + + defp unregister_from_signal(signal) do + Registry.unregister(WaitForIt.SignalRegistry, signal) + end + + defp wait_for_signal(signal, timeout, start_time) do + elapsed_time = now() - start_time + remaining_time = timeout - elapsed_time + + receive do + {:wait_for_it_signal, ^signal} -> :loop + after + remaining_time -> {:timeout, timeout} + end + end +end diff --git a/mix.exs b/mix.exs index 7751d4d..a23ff07 100644 --- a/mix.exs +++ b/mix.exs @@ -1,16 +1,25 @@ defmodule WaitForIt.Mixfile do use Mix.Project + @version "2.1.2" + @source_url "https://github.com/jvoegele/wait_for_it" + def project do [ app: :wait_for_it, - version: "1.2.0", - description: "Elixir library for waiting for things to happen", - source_url: "https://github.com/jvoegele/wait_for_it", - elixir: "~> 1.5", + version: @version, + elixir: "~> 1.15", start_permanent: Mix.env() == :prod, + deps: deps(), + + # Hex package: package(), - deps: deps() + description: "Elixir library providing various ways of waiting for things to happen", + + # Docs + name: "WaitForIt", + source_url: @source_url, + docs: docs() ] end @@ -25,10 +34,10 @@ defmodule WaitForIt.Mixfile do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ex_doc, "~> 0.19.3", only: :dev, runtime: false}, - {:dialyxir, "~> 0.5", only: [:dev, :test], runtime: false}, - {:credo, "~> 0.9.2", only: [:dev, :test], runtime: false}, - {:stream_data, "~> 0.4", only: [:dev, :test]} + {:ex_doc, "~> 0.30.9", only: :dev, runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev], runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:stream_data, "~> 0.6", only: [:dev, :test]} ] end @@ -37,8 +46,29 @@ defmodule WaitForIt.Mixfile do name: :wait_for_it, files: ["lib", "mix.exs", "README.md", "LICENSE", "CHANGELOG.md"], maintainers: ["Jason Voegele"], - licenses: ["Apache 2.0"], - links: %{"GitHub" => "https://github.com/jvoegele/wait_for_it"} + licenses: ["Apache-2.0"], + links: %{"GitHub" => @source_url} + ] + end + + @doc_modules [WaitForIt, WaitForIt.Waitable, WaitForIt.TimeoutError, WaitForIt.V1] + + defp docs do + [ + main: "WaitForIt", + extras: [ + "CHANGELOG.md": [title: "Changelog"], + LICENSE: [title: "License"] + ], + groups_for_docs: [ + wait: &(&1[:section] == :wait), + case_wait: &(&1[:section] == :case_wait), + cond_wait: &(&1[:section] == :cond_wait), + signaling: &(&1[:section] == :signal) + ], + filter_modules: fn module, _meta -> + module in @doc_modules + end ] end end diff --git a/mix.lock b/mix.lock index 48c292f..a69863b 100644 --- a/mix.lock +++ b/mix.lock @@ -1,12 +1,16 @@ %{ - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, - "credo": {:hex, :credo, "0.9.2", "841d316612f568beb22ba310d816353dddf31c2d94aa488ae5a27bb53760d0bf", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, - "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, - "earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.19.3", "3c7b0f02851f5fc13b040e8e925051452e41248f685e40250d7e40b07b9f8c10", [:mix], [{:earmark, "~> 1.2", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, - "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, - "stream_data": {:hex, :stream_data, "0.4.0", "128c01bfd0fae0108d169eee1772aeed6958604f8782abc2d6e11da4e52468b0", [:mix], [], "hexpm"}, + "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, + "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, + "dialyxir": {:hex, :dialyxir, "1.4.2", "764a6e8e7a354f0ba95d58418178d486065ead1f69ad89782817c296d0d746a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "516603d8067b2fd585319e4b13d3674ad4f314a5902ba8130cd97dc902ce6bbd"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.37", "2ad73550e27c8946648b06905a57e4d454e4d7229c2dafa72a0348c99d8be5f7", [:mix], [], "hexpm", "6b19783f2802f039806f375610faa22da130b8edc21209d0bff47918bb48360e"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_doc": {:hex, :ex_doc, "0.30.9", "d691453495c47434c0f2052b08dd91cc32bc4e1a218f86884563448ee2502dd2", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d7aaaf21e95dc5cddabf89063327e96867d00013963eadf2c6ad135506a8bc10"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, + "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, + "stream_data": {:hex, :stream_data, "0.6.0", "e87a9a79d7ec23d10ff83eb025141ef4915eeb09d4491f79e52f2562b73e5f47", [:mix], [], "hexpm", "b92b5031b650ca480ced047578f1d57ea6dd563f5b57464ad274718c9c29501c"}, } diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..d27d3c6 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,2 @@ +ExUnit.configure(exclude: [pending: true, wip: true, legacy: true]) ExUnit.start() diff --git a/test/wait_for_it/condition_variable/supervisor_test.exs b/test/wait_for_it/v1/condition_variable/supervisor_test.exs similarity index 86% rename from test/wait_for_it/condition_variable/supervisor_test.exs rename to test/wait_for_it/v1/condition_variable/supervisor_test.exs index 506b88c..8d5f377 100644 --- a/test/wait_for_it/condition_variable/supervisor_test.exs +++ b/test/wait_for_it/v1/condition_variable/supervisor_test.exs @@ -1,6 +1,8 @@ -defmodule WaitForIt.ConditionVariable.SupervisorTest do +defmodule WaitForIt.V1.ConditionVariable.SupervisorTest do use ExUnit.Case - alias WaitForIt.ConditionVariable + alias WaitForIt.V1.ConditionVariable + + @moduletag :legacy describe "create_condition_variable/0" do test "starts a new child process and returns {:ok, pid}" do diff --git a/test/wait_for_it/condition_variable_test.exs b/test/wait_for_it/v1/condition_variable_test.exs similarity index 73% rename from test/wait_for_it/condition_variable_test.exs rename to test/wait_for_it/v1/condition_variable_test.exs index e8844ca..eaf2c20 100644 --- a/test/wait_for_it/condition_variable_test.exs +++ b/test/wait_for_it/v1/condition_variable_test.exs @@ -1,6 +1,8 @@ -defmodule WaitForIt.ConditionVariableTest do +defmodule WaitForIt.V1.ConditionVariableTest do use ExUnit.Case - alias WaitForIt.ConditionVariable + alias WaitForIt.V1.ConditionVariable + + @moduletag :legacy describe "start_link/0" do test "starts a new process and returns {:ok, pid}" do @@ -11,24 +13,24 @@ defmodule WaitForIt.ConditionVariableTest do describe "start_link/1" do test "registers new process" do - {:ok, pid} = ConditionVariable.start_link(:new_condition_var) + {:ok, pid} = ConditionVariable.start_link(name: :new_condition_var) assert [{pid, nil}] == Registry.lookup(ConditionVariable.registry(), :new_condition_var) end test "returns {:error, {:already_started, pid}} if already registered" do - {:ok, pid} = ConditionVariable.start_link(:condition_var) - assert {:error, {:already_started, ^pid}} = ConditionVariable.start_link(:condition_var) + {:ok, pid} = ConditionVariable.start_link(name: :condition_var) + assert {:error, {:already_started, ^pid}} = ConditionVariable.start_link(name: :condition_var) end end test "wait blocks until signal received" do - {:ok, _pid} = ConditionVariable.start_link(:cond_wait) + {:ok, _pid} = ConditionVariable.start_link(name: :cond_wait) Task.async(fn -> for _ <- 1..10, do: ConditionVariable.signal(:cond_wait) end) :ok = ConditionVariable.wait(:cond_wait) end test "wait times out if no signal received" do - {:ok, _pid} = ConditionVariable.start_link(:cond_wait) + {:ok, _pid} = ConditionVariable.start_link(name: :cond_wait) :timeout = ConditionVariable.wait(:cond_wait, timeout: 10) end diff --git a/test/wait_for_it/v1/wait_for_it_test.exs b/test/wait_for_it/v1/wait_for_it_test.exs new file mode 100644 index 0000000..4d5c161 --- /dev/null +++ b/test/wait_for_it/v1/wait_for_it_test.exs @@ -0,0 +1,355 @@ +defmodule WaitForIt.V1.WaitForItTest do + use ExUnit.Case + use ExUnitProperties + + import WaitForIt.V1 + + @moduletag :legacy + + defp increment_counter do + counter = (Process.get(:counter) || 0) + 1 + Process.put(:counter, counter) + counter + end + + defp init_counter(initial) do + Agent.start_link(fn -> initial end) + end + + defp get_counter(counter_pid) do + Agent.get(counter_pid, & &1) + end + + defp increment_counter(counter_pid) do + try do + Agent.update(counter_pid, fn n -> n + 1 end) + catch + :exit, _ -> nil + end + end + + defp increment_task(counter_pid, opts) do + sleep_time = Keyword.get(opts, :sleep_time, 0) + max = Keyword.get(opts, :max, 1_000_000) + condition_var = Keyword.get(opts, :signal) + + Task.start_link(fn -> + for _ <- 1..max do + increment_counter(counter_pid) + if condition_var, do: signal(condition_var) + Process.sleep(sleep_time) + end + end) + end + + describe "wait/2" do + test "waits for expression to be truthy" do + {:ok, true} = wait(increment_counter() > 2) + assert 3 == Process.get(:counter) + end + + test "accepts a :frequency option" do + wait(increment_counter() > 4, frequency: 1, pre_wait: 1) + assert 5 == Process.get(:counter) + end + + test "accepts a :timeout option" do + timeout = 10 + {:timeout, ^timeout} = wait(increment_counter() > timeout, timeout: timeout, frequency: 1) + assert Process.get(:counter) < timeout + end + + test "accepts a :signal option" do + {:ok, counter} = init_counter(0) + _task = increment_task(counter, max: 1000, signal: :counter_wait) + assert {:ok, true} == wait(get_counter(counter) > 99, signal: :counter_wait) + assert get_counter(counter) > 99 + end + + test "times out if signal not received" do + {:ok, counter} = init_counter(0) + assert {:timeout, 10} == wait(get_counter(counter) > 99, signal: :counter_wait, timeout: 10) + end + end + + describe "wait!/2" do + test "waits for expression to be truthy" do + assert wait!(increment_counter() > 2) + assert 3 == Process.get(:counter) + end + + test "accepts a :frequency option" do + wait!(increment_counter() > 4, frequency: 1, pre_wait: 1) + assert 5 == Process.get(:counter) + end + + test "accepts a :timeout option" do + timeout = 10 + + assert_raise WaitForIt.TimeoutError, fn -> + wait!(increment_counter() > timeout, timeout: timeout, frequency: 1) + end + + assert Process.get(:counter) < timeout + end + + test "accepts a :signal option" do + {:ok, counter} = init_counter(0) + _task = increment_task(counter, max: 1000, signal: :counter_wait) + assert wait!(get_counter(counter) > 99, signal: :counter_wait) + assert get_counter(counter) > 99 + end + + test "times out if signal not received" do + {:ok, counter} = init_counter(0) + + %WaitForIt.TimeoutError{timeout: timeout, last_value: last_value} = + assert_raise WaitForIt.TimeoutError, fn -> + wait!(get_counter(counter) > 99, signal: :counter_wait, timeout: 10) + end + + assert timeout == 10 + assert last_value == false + end + end + + describe "case_wait/2" do + test "waits for expression to match one of the given patterns" do + {:ok, counter} = init_counter(0) + _task = increment_task(counter, sleep_time: 1) + + result = + case_wait get_counter(counter) do + value when value >= 10 -> value + end + + assert result >= 10 + end + + test "accepts a :frequency option" do + case_wait increment_counter(), frequency: 1, pre_wait: 1 do + 5 -> 5 + end + + assert 5 == Process.get(:counter) + end + + test "accepts a :timeout option" do + timeout = 10 + + {:timeout, ^timeout} = + case_wait increment_counter(), timeout: timeout, frequency: 1 do + 11 -> 11 + end + + assert Process.get(:counter) < timeout + end + + test "accepts a :signal option" do + {:ok, counter} = init_counter(0) + _task = increment_task(counter, max: 1000, signal: :counter_wait) + + count = + case_wait get_counter(counter), signal: :counter_wait do + value when value > 99 -> value + end + + assert count > 99 + assert get_counter(counter) >= count + end + + property "times out if signal not received" do + check all(timeout <- integer(5..50)) do + {:ok, counter} = init_counter(0) + + result = + case_wait get_counter(counter), signal: :counter_wait, timeout: timeout do + 100 -> 100 + end + + assert result == {:timeout, timeout} + end + end + + test "accepts an else block" do + {:ok, counter} = init_counter(0) + + result = + case_wait get_counter(counter), signal: :counter_wait, timeout: 10 do + 100 -> 100 + else + {:timeout, :else_clause} + end + + assert result == {:timeout, :else_clause} + end + + test "accepts an else block with case clauses" do + result = + case_wait increment_counter(), frequency: 5, timeout: 10 do + 100 -> 100 + else + :foo -> :will_never_reach_here + n when is_integer(n) -> {:else, n} + :bar -> :will_never_reach_here + end + + assert {:else, count} = result + assert count < 10 + end + end + + describe "cond_wait/1" do + test "waits for one of the given expressions to be truthy" do + :ok = + cond_wait do + 2 + 2 == 5 -> 1984 + :answer == 42 -> :question + increment_counter() == 3 -> :ok + end + + assert 3 == Process.get(:counter) + end + + test "accepts a :frequency option" do + 5 = + cond_wait frequency: 1, pre_wait: 1 do + ( + count = increment_counter() + count > 4 + ) -> + count + + 2 + 2 == 5 -> + 1984 + + :answer == 42 -> + :question + end + + assert 5 == Process.get(:counter) + end + + test "accepts a :timeout option" do + timeout = 10 + + {:timeout, ^timeout} = + cond_wait timeout: timeout, frequency: 1 do + 11 == increment_counter() -> :ok + end + + assert Process.get(:counter) < timeout + end + + test "accepts a :signal option" do + {:ok, counter} = init_counter(0) + _task = increment_task(counter, max: 1000, signal: :counter_wait) + + result = + cond_wait signal: :counter_wait do + get_counter(counter) > 99 -> :century + end + + assert result == :century + assert get_counter(counter) > 99 + end + + test "times out if signal not received" do + {:ok, counter} = init_counter(0) + timeout = 10 + + result = + cond_wait signal: :counter_wait, timeout: 10 do + get_counter(counter) > 99 -> :will_never_get_here + end + + assert result == {:timeout, timeout} + end + + test "accepts an else block" do + {:ok, _counter} = init_counter(0) + + result = + cond_wait signal: :counter_wait, timeout: 10 do + :answer == 42 -> true + else + {:timeout, :else_clause} + end + + assert result == {:timeout, :else_clause} + end + end + + describe "multiple waiters using :signal option" do + property "all wait until they receive the signal" do + check all( + factor <- integer(1..10), + waiter_count <- integer(1..20) + ) do + {:ok, counter} = init_counter(0) + + tasks = + for i <- 1..waiter_count do + Task.async(fn -> + case_wait get_counter(counter), signal: :counter_wait do + n when n > i * factor -> + {:ok, n} + else + {:error, get_counter(counter)} + end + end) + end + + _task = increment_task(counter, signal: :counter_wait) + + for task <- tasks do + assert {:ok, _} = Task.await(task) + end + end + end + + property "death of waiting process does not affect other waiters" do + check all( + waiter_count <- integer(3..50), + kill_count <- integer(0..3), + kill_reason <- member_of([:normal, :kill, :shutdown, :die]), + kill_count <= waiter_count + ) do + {:ok, counter} = init_counter(0) + + {:ok, task_supervisor} = Task.Supervisor.start_link() + + tasks = + for i <- 1..waiter_count do + Task.Supervisor.async_nolink(task_supervisor, fn -> + case_wait get_counter(counter), signal: :counter_wait do + n when n > i * 3 -> + {:ok, n} + else + {:error, get_counter(counter)} + end + end) + end + + tasks + |> Enum.take_random(kill_count) + |> Enum.each(fn task -> Process.exit(task.pid, kill_reason) end) + + _task = increment_task(counter, signal: :counter_wait) + + completed_tasks = + tasks + |> Enum.map(&Task.yield(&1, 500)) + |> Enum.filter(&filter_ok/1) + + expected_completed_count = + if kill_reason == :normal, do: waiter_count, else: waiter_count - kill_count + + assert length(completed_tasks) == expected_completed_count + end + end + + defp filter_ok({:ok, _}), do: true + defp filter_ok(_), do: false + end +end diff --git a/test/wait_for_it_test.exs b/test/wait_for_it_test.exs index 988aaa1..07b3b9e 100644 --- a/test/wait_for_it_test.exs +++ b/test/wait_for_it_test.exs @@ -1,8 +1,11 @@ defmodule WaitForItTest do use ExUnit.Case use ExUnitProperties + import WaitForIt + doctest WaitForIt + defp increment_counter do counter = (Process.get(:counter) || 0) + 1 Process.put(:counter, counter) @@ -28,12 +31,12 @@ defmodule WaitForItTest do defp increment_task(counter_pid, opts) do sleep_time = Keyword.get(opts, :sleep_time, 0) max = Keyword.get(opts, :max, 1_000_000) - condition_var = Keyword.get(opts, :signal) + signal = Keyword.get(opts, :signal) Task.start_link(fn -> for _ <- 1..max do increment_counter(counter_pid) - if condition_var, do: signal(condition_var) + if signal, do: WaitForIt.signal(signal) Process.sleep(sleep_time) end end) @@ -41,31 +44,72 @@ defmodule WaitForItTest do describe "wait/2" do test "waits for expression to be truthy" do - {:ok, true} = wait(increment_counter() > 2) + assert wait(increment_counter() > 2) == true assert 3 == Process.get(:counter) end test "accepts a :frequency option" do - wait(increment_counter() > 4, frequency: 1) + assert wait(increment_counter() > 4, frequency: 1, pre_wait: 1) assert 5 == Process.get(:counter) end test "accepts a :timeout option" do timeout = 10 - {:timeout, ^timeout} = wait(increment_counter() > timeout, timeout: timeout, frequency: 1) + refute wait(increment_counter() > timeout, timeout: timeout, frequency: 1) assert Process.get(:counter) < timeout end test "accepts a :signal option" do {:ok, counter} = init_counter(0) _task = increment_task(counter, max: 1000, signal: :counter_wait) - assert {:ok, true} == wait(get_counter(counter) > 99, signal: :counter_wait) + assert wait(get_counter(counter) > 99, signal: :counter_wait) == true assert get_counter(counter) > 99 end test "times out if signal not received" do {:ok, counter} = init_counter(0) - assert {:timeout, 10} == wait(get_counter(counter) > 99, signal: :counter_wait, timeout: 10) + refute wait(get_counter(counter) > 99, signal: :wait_in_vain, timeout: 10) + end + end + + describe "wait!/2" do + test "waits for expression to be truthy" do + assert wait!(increment_counter() > 2) + assert 3 == Process.get(:counter) + end + + test "accepts a :frequency option" do + wait!(increment_counter() > 4, frequency: 1, pre_wait: 1) + assert 5 == Process.get(:counter) + end + + test "accepts a :timeout option" do + timeout = 10 + + assert_raise WaitForIt.TimeoutError, fn -> + wait!(increment_counter() > timeout, timeout: timeout, frequency: 1) + end + + assert Process.get(:counter) < timeout + end + + test "accepts a :signal option" do + {:ok, counter} = init_counter(0) + _task = increment_task(counter, max: 1000, signal: :counter_wait) + assert wait!(get_counter(counter) > 99, signal: :counter_wait) + assert get_counter(counter) > 99 + end + + test "times out if signal not received" do + {:ok, counter} = init_counter(0) + + %WaitForIt.TimeoutError{timeout: timeout, last_value: last_value} = + assert_raise WaitForIt.TimeoutError, fn -> + wait!(get_counter(counter) > 99, signal: :wait_in_vain, timeout: 10) + end + + assert timeout == 10 + assert last_value == false end end @@ -83,7 +127,7 @@ defmodule WaitForItTest do end test "accepts a :frequency option" do - case_wait increment_counter(), frequency: 1 do + case_wait increment_counter(), frequency: 1, pre_wait: 1 do 5 -> 5 end @@ -93,12 +137,15 @@ defmodule WaitForItTest do test "accepts a :timeout option" do timeout = 10 - {:timeout, ^timeout} = - case_wait increment_counter(), timeout: timeout, frequency: 1 do - 11 -> 11 + %CaseClauseError{term: last_value} = + assert_raise CaseClauseError, fn -> + case_wait increment_counter(), timeout: timeout, frequency: 1 do + 11 -> 11 + end end - assert Process.get(:counter) < timeout + assert is_integer(last_value) and last_value < timeout + assert Process.get(:counter) == last_value end test "accepts a :signal option" do @@ -114,24 +161,25 @@ defmodule WaitForItTest do assert get_counter(counter) >= count end - property "times out if signal not received" do - check all timeout <- integer(5..50) do - {:ok, counter} = init_counter(0) + test "times out if signal not received" do + {:ok, counter} = init_counter(0) - result = - case_wait get_counter(counter), signal: :counter_wait, timeout: timeout do + %CaseClauseError{term: result} = + assert_raise CaseClauseError, fn -> + case_wait get_counter(counter), signal: :wait_in_vain, timeout: 10 do 100 -> 100 end + end - assert result == {:timeout, timeout} - end + assert is_integer(result) + assert result < 10 end test "accepts an else block" do {:ok, counter} = init_counter(0) result = - case_wait get_counter(counter), signal: :counter_wait, timeout: 10 do + case_wait get_counter(counter), signal: :wait_in_vain, timeout: 10 do 100 -> 100 else {:timeout, :else_clause} @@ -169,7 +217,7 @@ defmodule WaitForItTest do test "accepts a :frequency option" do 5 = - cond_wait frequency: 1 do + cond_wait frequency: 1, pre_wait: 1 do ( count = increment_counter() count > 4 @@ -189,10 +237,11 @@ defmodule WaitForItTest do test "accepts a :timeout option" do timeout = 10 - {:timeout, ^timeout} = + assert_raise CondClauseError, fn -> cond_wait timeout: timeout, frequency: 1 do 11 == increment_counter() -> :ok end + end assert Process.get(:counter) < timeout end @@ -212,21 +261,19 @@ defmodule WaitForItTest do test "times out if signal not received" do {:ok, counter} = init_counter(0) - timeout = 10 - result = + assert_raise CondClauseError, fn -> cond_wait signal: :counter_wait, timeout: 10 do get_counter(counter) > 99 -> :will_never_get_here end - - assert result == {:timeout, timeout} + end end test "accepts an else block" do {:ok, _counter} = init_counter(0) result = - cond_wait signal: :counter_wait, timeout: 10 do + cond_wait signal: :wait_in_vain, timeout: 10 do :answer == 42 -> true else {:timeout, :else_clause} @@ -238,8 +285,10 @@ defmodule WaitForItTest do describe "multiple waiters using :signal option" do property "all wait until they receive the signal" do - check all factor <- integer(1..10), - waiter_count <- integer(1..20) do + check all( + factor <- integer(1..10), + waiter_count <- integer(1..20) + ) do {:ok, counter} = init_counter(0) tasks = @@ -263,10 +312,12 @@ defmodule WaitForItTest do end property "death of waiting process does not affect other waiters" do - check all waiter_count <- integer(3..50), - kill_count <- integer(0..3), - kill_reason <- member_of([:normal, :kill, :shutdown, :die]), - kill_count <= waiter_count do + check all( + waiter_count <- integer(3..50), + kill_count <- integer(0..3), + kill_reason <- member_of([:normal, :kill, :shutdown, :die]), + kill_count <= waiter_count + ) do {:ok, counter} = init_counter(0) {:ok, task_supervisor} = Task.Supervisor.start_link()