weft/poll

Bounded polling in the caller’s own process.

Some waits cannot be handed to another process: a launcher that needs a lock before it can do anything else, a caller reaching into a tree that may be mid-restart, a shell that must know whether a server came up before it prints the address. The waiting process is the one that needs the answer, the thing it is waiting on is a synchronous probe rather than a message, and the only honest bound is the wall clock. Every such site ends up hand-rolling the same loop — try, check the deadline, sleep, recurse — and every copy re-decides the same three corners: whether the first attempt sleeps first, whether an attempt is made at the deadline itself, and how a probe that failed for good is told apart from one that merely has not succeeded yet.

until is that loop, decided once. The first attempt is immediate. An attempt that says Retry is followed by a sleep of every milliseconds, clipped to what remains of the budget, and then another attempt; when the budget is spent a last attempt is still made, so a probe that would have succeeded exactly at the deadline is not reported as expired. Fail ends the wait at once with the caller’s own error, and Expired is a distinct outcome rather than an error the caller has to invent — “did not happen in time” is a fact about the clock, not about the probe.

Whose clock, and what the wait carries

until measures the monotonic clock and sleeps with process.sleep, which is what a wait on a real resource wants. Some callers do not have that option: a system with an injected time capability runs its waits on a logical clock its own simulation steps, and a wait that consulted the operating system instead would either hang a simulated run or make it non-deterministic. Clock is that pair of decisions — how to read the time and how to rest — made a value, monotonic() is the one until uses, and until_on is until with the caller’s own.

The other thing a hand-rolled loop does that a fn() -> Attempt cannot is carry something between attempts: the handles already settled, the epoch the last exchange reported, the refusal to hand back if the budget runs out. fold_until is the same loop with the probe threading a state value, and until_on is the special case where that value is Nil. A state carried this way is also what makes expiry informative: RanOut hands back the state as the last attempt left it, so a caller who has been accumulating an answer keeps it rather than starting again.

A long wait should not probe as hard as a short one, so the interval is an Interval rather than a number: Fixed for a wait bounded in milliseconds, Doubling for one bounded in minutes, where a fixed interval is the difference between a few probes and a few thousand.

This module deliberately owns no process: it sleeps in the caller, which is the whole point. A wait that could be a message should be a weft/state_machine state with a timeout instead.

Example

import weft/poll

pub fn await_lock(path: String) -> Result(Lock, String) {
  case
    poll.until(within: 5000, every: 25, attempt: fn() {
      case try_lock(path) {
        Ok(lock) -> poll.Done(lock)
        Error("busy") -> poll.Retry
        Error(reason) -> poll.Fail(reason)
      }
    })
  {
    poll.Answered(lock) -> Ok(lock)
    poll.Failed(reason) -> Error("acquire lock: " <> reason)
    poll.Expired -> Error("timed out waiting for the lock")
  }
}

Types

What one attempt of the probe reports.

pub type Attempt(a, e) {
  Done(value: a)
  Retry
  Fail(error: e)
}

Constructors

  • Done(value: a)

    The probe succeeded; the wait is over.

    Arguments

    value

    The value the probe produced.

  • Retry

    Not yet. Sleep and try again, budget permitting.

  • Fail(error: e)

    The probe failed in a way that more time will not fix. The wait ends at once, carrying the caller’s own error.

    Arguments

    error

    Why the probe cannot succeed.

How a bounded wait reads the time and how it rests.

Both halves are injected together because they are one decision: a wait that measured a simulated clock and slept on the real one would burn wall time to make no logical progress, and a wait that measured the real clock and rested by stepping a simulated one would never end. now returns a millisecond timestamp on whatever base the caller uses — the only thing this module does with it is subtract two readings — and sleep must pass roughly that many of the same milliseconds before it returns.

Examples

// A wait on a session's own injected time capability, resting by
// stepping the simulation rather than by sleeping.
poll.Clock(now: fn() { session_now() }, sleep: fn(ms) { advance(ms) })
pub type Clock {
  Clock(now: fn() -> Int, sleep: fn(Int) -> Nil)
}

Constructors

  • Clock(now: fn() -> Int, sleep: fn(Int) -> Nil)

    Arguments

    now

    The current time in milliseconds, on the caller’s own base.

    sleep

    Rest for this many milliseconds of that same base.

How long a wait rests between attempts.

The choice is about what a wait costs whatever it is probing. A wait bounded in milliseconds wants the same short gap every time; a wait bounded in minutes wants to stop asking so often once it is clear the answer is not imminent, and doubling is the cheapest schedule that does that without a parameter nobody can pick.

pub type Interval {
  Fixed(ms: Int)
  Doubling(from: Int, to: Int)
}

Constructors

  • Fixed(ms: Int)

    The same gap after every attempt.

    Arguments

    ms

    Milliseconds between attempts; anything below one is treated as one.

  • Doubling(from: Int, to: Int)

    Doubling from from, capped at to. A wait that ends quickly pays from; one that runs long settles at to.

    Arguments

    from

    The first gap, in milliseconds.

    to

    The longest gap the doubling reaches, in milliseconds.

How a bounded wait ended.

pub type Outcome(a, e) {
  Answered(value: a)
  Failed(error: e)
  Expired
}

Constructors

  • Answered(value: a)

    An attempt reported Done.

    Arguments

    value

    The value that attempt produced.

  • Failed(error: e)

    An attempt reported Fail.

    Arguments

    error

    The error that attempt carried.

  • Expired

    Every attempt inside the budget, including the last one made at the deadline, reported Retry.

What one attempt of a probe that carries work forward reports.

The same three answers as Attempt, except that “not yet” hands back the state the next attempt should see. A probe that has nothing to carry wants Attempt and until_on.

pub type Pass(a, e, state) {
  Settled(value: a)
  Pending(state: state)
  Broken(error: e)
}

Constructors

  • Settled(value: a)

    The probe succeeded; the wait is over.

    Arguments

    value

    The value the probe produced.

  • Pending(state: state)

    Not yet. Sleep and try again, budget permitting, with this state.

    Arguments

    state

    What the next attempt is given.

  • Broken(error: e)

    The probe failed in a way that more time will not fix.

    Arguments

    error

    Why the probe cannot succeed.

How a bounded wait that carried state ended.

RanOut differs from Expired in exactly one way, and it is the reason fold_until exists: it hands back the state as the last attempt left it. A caller who has been accumulating a partial answer — the handles that did settle, the refusal the last exchange reported — keeps it rather than being told only that time ran out.

pub type Verdict(a, e, state) {
  Answer(value: a)
  Failure(error: e)
  RanOut(state: state)
}

Constructors

  • Answer(value: a)

    An attempt reported Settled.

    Arguments

    value

    The value that attempt produced.

  • Failure(error: e)

    An attempt reported Broken.

    Arguments

    error

    The error that attempt carried.

  • RanOut(state: state)

    Every attempt inside the budget, including the last one made at the deadline, reported Pending.

    Arguments

    state

    The state the last attempt left behind.

Values

pub fn fold_until(
  clock clock: Clock,
  within within: Int,
  every every: Interval,
  from state: state,
  attempt attempt: fn(state) -> Pass(a, e, state),
) -> Verdict(a, e, state)

Poll attempt on clock, threading a state value from one attempt to the next.

This is the loop the other two are written in terms of, and the one to reach for when an attempt has something to tell its successor: the work already done, the token the last exchange handed back, the answer to give up with. Expiry hands that state back as RanOut rather than discarding it, so a wait that half-succeeded reports what it got.

The interval is an Interval here rather than a number because a wait that carries state is usually the long one, and a long wait probing at a short fixed interval is the difference between a few dozen reads and a few thousand.

Examples

// Wait for every handle to settle, keeping the ones that already have.
poll.fold_until(
  clock: session_clock,
  within: 30_000,
  every: poll.Doubling(from: 25, to: 250),
  from: dict.new(),
  attempt: fn(settled) {
    let settled = list.fold(handles, settled, poll_one)
    case dict.size(settled) == list.length(handles) {
      True -> poll.Settled(settled)
      False -> poll.Pending(settled)
    }
  },
)
// -> poll.Answer(all), or poll.RanOut(the ones that did settle)
pub fn monotonic() -> Clock

The clock until uses: erlang:monotonic_time and process.sleep.

Monotonic rather than system time, so a wall-clock step cannot lengthen or shorten a wait.

Examples

poll.until_on(clock: poll.monotonic(), within: 5000, every: poll.Fixed(25), attempt:)
// -> exactly what `poll.until(within: 5000, every: 25, attempt:)` does
pub fn until(
  within within: Int,
  every every: Int,
  attempt attempt: fn() -> Attempt(a, e),
) -> Outcome(a, e)

Poll attempt until it answers, fails, or within milliseconds have passed, sleeping every milliseconds between attempts.

The first attempt is immediate, a final attempt is made when the budget runs out, and the sleep never overshoots the deadline. A within of zero therefore still makes exactly one attempt. Time is measured on the monotonic clock, so a wall-clock step cannot lengthen or shorten the wait; a caller whose time base is its own uses until_on, and one whose probe has something to tell its successor uses fold_until.

Examples

poll.until(within: 1000, every: 50, attempt: fn() {
  case probe() {
    Ok(value) -> poll.Done(value)
    Error(Nil) -> poll.Retry
  }
})
// -> poll.Answered(value), or poll.Expired after about a second
pub fn until_on(
  clock clock: Clock,
  within within: Int,
  every every: Interval,
  attempt attempt: fn() -> Attempt(a, e),
) -> Outcome(a, e)

Poll attempt on clock rather than on the monotonic clock.

Every rule until fixes still holds — immediate first attempt, a last attempt at the deadline, a sleep clipped to what remains, Fail distinct from Retry — and all of them are now measured and rested on the caller’s own time base. That is what lets a wait belonging to a system with an injected clock run under that system’s simulation instead of against the operating system.

One consequence is worth stating plainly: the wait ends when clock says the budget is spent, so a clock whose now never moves is a wait that never expires. That is a fact about the clock rather than about this function, and a caller handing in a fixture clock should hand in one that advances.

Examples

poll.until_on(
  clock: session_clock,
  within: 30_000,
  every: poll.Doubling(from: 25, to: 250),
  attempt: fn() {
    case read_record() {
      Ok(record) -> poll.Done(record)
      Error(Nil) -> poll.Retry
    }
  },
)
Search Document