Schedule
This page covers Fx.Schedule, the policies that decide how often and with what delay an effect is retried after a failure (eff:retry) or repeated after a success (eff:repeatWith). Use it for database deadlocks, flaky HTTP calls, waiting for a resource to come up, autosaves and periodic jobs. For values over time, see Streams (Fx.Stream.fromSchedule).
Examples print values with a --> result comment showing what they print.
How a schedule works
A schedule is a table { initial = state, step = fun(state, input, now) }. After each run of the effect, step is called with the current state, an input (the error for retry, the value for repeatWith) and the current game time in ms. It returns three values:
continue(boolean): run again?delayMs(number): how long to wait before the next run (0runs again at once, without a tick in between);nextState.
The first run always happens; the schedule only decides about the runs after it. Schedules are immutable values: each retry or repeatWith starts from initial, so one schedule can be shared by many effects.
Constructors
Fx.Schedule.spaced(ms) and Fx.Schedule.fixed(ms)
Fx.Schedule.spaced(ms: integer) -> Schedule
Fx.Schedule.fixed(ms: integer) -> Schedule
- Returns: a schedule that continues forever with a delay of
mseach time.fixedis the same function asspaced: the delay is counted from the end of each run, not on a fixed clock.
local ticks = 0
local autosave = Fx.sync(function() ticks = ticks + 1 end):repeatWith(Fx.Schedule.spaced(30000))
Fx.runFork(autosave:timeoutOption(100000), { onExit = function() print(ticks) end }) --> 4
Fx.Schedule.forever
Fx.Schedule.forever: Schedule
- Notes: continues forever with no delay. Combine it with
take,whileInputorupTo, or the effect loops without waiting.
local n = 0
local count = Fx.sync(function() n = n + 1 return n end):repeatWith(Fx.Schedule.forever:take(4))
print(Fx.runSync(count)) --> 5
Fx.Schedule.recurs(n) and Fx.Schedule.once
Fx.Schedule.recurs(n: integer) -> Schedule
Fx.Schedule.once: Schedule -- recurs(1)
- Returns: a schedule that continues
ntimes with no delay. Withretry, that isnretries (n + 1attempts); withrepeatWith,nrepeats (n + 1runs).
local attempts = 0
local ping = Fx.suspend(function() attempts = attempts + 1 return Fx.fail('timeout') end)
Fx.runSyncExit(ping:retry(Fx.Schedule.once))
print(attempts) --> 2
Fx.Schedule.exponential(base, factor?)
Fx.Schedule.exponential(base: integer, factor?: number) -> Schedule -- factor defaults to 2
- Returns: a schedule that continues forever with delays
base,base*factor,base*factor^2, ... (rounded down). - Notes: pair it with
take(n),upTo(ms)ormaxDelay(ms).
local s = Fx.Schedule.exponential(100)
local st, delays = s.initial, {}
for i = 1, 4 do
local _, d, nextSt = s.step(st, nil, 0)
delays[i], st = d, nextSt
end
print(table.concat(delays, ' ')) --> 100 200 400 800
Fx.Schedule.fibonacci(base)
Fx.Schedule.fibonacci(base: integer) -> Schedule
- Returns: a schedule that continues forever with delays
base,base,2*base,3*base,5*base,8*base, ...
local s = Fx.Schedule.fibonacci(100)
local st, delays = s.initial, {}
for i = 1, 6 do
local _, d, nextSt = s.step(st, nil, 0)
delays[i], st = d, nextSt
end
print(table.concat(delays, ' ')) --> 100 100 200 300 500 800
Fx.Schedule.make(initial, step)
Fx.Schedule.make(initial: S, step: fun(state: S, input: any, now: integer): (boolean, integer, S)) -> Schedule
- Parameters:
initial, the starting state.step, as described in How a schedule works. - Returns: a custom schedule. It has all the methods below.
-- Retry while the error says the player is still loading, 250 ms apart, at most 20 times.
local whileLoading = Fx.Schedule.make(0, function(count, err)
return count < 20 and err == 'still loading', 250, count + 1
end)
local tries = 0
local getCharacter = Fx.suspend(function()
tries = tries + 1
if tries < 4 then return Fx.fail('still loading') end
return Fx.succeed({ name = 'Ana' })
end)
Fx.runFork(getCharacter:retry(whileLoading), { onExit = function(exit) print(exit.value.name, tries) end })
--> Ana 4
Combinators
Every combinator returns a new schedule; the original is unchanged.
schedule:take(n)
schedule:take(n: integer) -> Schedule
- Returns: the same delays, stopping after at most
nrecurrences. Same asschedule:intersect(Fx.Schedule.recurs(n)).
local attempts = 0
local eff = Fx.suspend(function() attempts = attempts + 1 return Fx.fail('busy') end)
Fx.runFork(eff:retry(Fx.Schedule.spaced(100):take(3)), { onExit = function() print(attempts) end }) --> 4
schedule:intersect(other)
schedule:intersect(other: Schedule) -> Schedule
- Returns: a schedule that continues only while both continue, using the longer of the two delays.
local s = Fx.Schedule.exponential(100):intersect(Fx.Schedule.spaced(250))
local _, d1, st = s.step(s.initial, nil, 0)
local _, d2 = s.step(st, nil, 0)
print(d1, d2) --> 250 250
schedule:union(other)
schedule:union(other: Schedule) -> Schedule
- Returns: a schedule that continues while either continues. When both continue it uses the shorter delay; when only one does, that one's delay.
local quickThenSlow = Fx.Schedule.spaced(100):take(2):union(Fx.Schedule.spaced(1000):take(4))
local st, delays = quickThenSlow.initial, {}
for i = 1, 5 do
local c, d, nextSt = quickThenSlow.step(st, nil, 0)
delays[i], st = c and d or 'stop', nextSt
end
print(table.concat(delays, ' ')) --> 100 100 1000 1000 stop
schedule:jittered(min?, max?)
schedule:jittered(min?: number, max?: number) -> Schedule -- defaults 0.8 and 1.2
- Returns: the same schedule with each delay multiplied by a random factor between
minandmax(usingmath.random), rounded down. - Notes: spreads retries from many players so they do not hit a service at the same moment.
local s = Fx.Schedule.spaced(1000):jittered()
local _, d = s.step(s.initial, nil, 0)
print(d >= 800 and d <= 1200) --> true
schedule:maxDelay(ms)
schedule:maxDelay(ms: integer) -> Schedule
- Returns: the same schedule with every delay capped at
ms.
local s = Fx.Schedule.exponential(500):maxDelay(2000)
local st, delays = s.initial, {}
for i = 1, 5 do
local _, d, nextSt = s.step(st, nil, 0)
delays[i], st = d, nextSt
end
print(table.concat(delays, ' ')) --> 500 1000 2000 2000 2000
schedule:upTo(ms)
schedule:upTo(ms: integer) -> Schedule
- Returns: the same schedule, stopping once the next run would start more than
msafter the first step. - Notes: the clock starts at the first call to
step, which is after the first run finished (forretry, at the first failure), not when the effect started.
local attempts = 0
local connect = Fx.suspend(function() attempts = attempts + 1 return Fx.fail('refused') end)
Fx.runFork(connect:retry(Fx.Schedule.spaced(1000):upTo(3500)), {
onExit = function(exit) print(attempts, Fx.Cause.failureOption(exit.cause)) end,
})
--> 4 refused
schedule:whileInput(pred)
schedule:whileInput(pred: fun(input: any): boolean) -> Schedule
- Returns: the same schedule, continuing only while
pred(input)is true. The input is the error forretryand the value forrepeatWith.
local Busy = Fx.TaggedError('Busy')
local attempts = 0
local eff = Fx.suspend(function()
attempts = attempts + 1
if attempts == 2 then return Fx.fail('fatal: account frozen') end
return Busy {}:fail()
end)
local onlyBusy = Fx.Schedule.spaced(50):whileInput(function(e) return Busy.is(e) end)
Fx.runFork(eff:retry(onlyBusy), { onExit = function(exit) print(attempts, Fx.Cause.failureOption(exit.cause)) end })
--> 2 fatal: account frozen
Policies
retry and repeatWith accept:
| Policy | Meaning |
|---|---|
a number n |
Fx.Schedule.recurs(n): n more runs, no delay |
a Schedule |
used as is |
a table { schedule?, times?, while?, until? } |
schedule (default Fx.Schedule.forever), then :take(times), then :whileInput(while), then :whileInput(not until(input)) |
while and until are Lua keywords, so write them as ['while'] and ['until']. A table with only while or until and no times retries without limit and without delay while the predicate allows.
eff:retry(policy)
eff:retry(policy: integer | Schedule | { schedule?: Schedule, times?: integer, ['while']?: fun(err): boolean, ['until']?: fun(err): boolean }) -> Effect<A, E>
- Parameters:
policy, see Policies. The schedule's input is the error. - Returns: an effect that runs
eff, and after each expected failure asks the schedule whether to run it again and how long to wait first. - Fails with: the last error, once the schedule stops.
- Notes: only expected failures are retried. Defects and interruptions end the effect at once. The wait between attempts is an
Fx.sleep, so interrupting the fiber stops the retry loop.
local DbBusy = Fx.TaggedError('DbBusy')
local attempts = 0
local saveCharacter = Fx.suspend(function()
attempts = attempts + 1
if attempts < 3 then return DbBusy {}:fail() end
return Fx.succeed('saved')
end)
local policy = {
schedule = Fx.Schedule.exponential(200):jittered(),
times = 5,
['while'] = function(e) return DbBusy.is(e) end,
}
Fx.runFork(saveCharacter:retry(policy), { onExit = function(exit) print(exit.value, attempts) end })
--> saved 3
eff:repeatWith(schedule)
eff:repeatWith(schedule: integer | Schedule | { schedule?: Schedule, times?: integer, ['while']?: fun(value): boolean, ['until']?: fun(value): boolean }) -> Effect<A, E>
- Parameters:
schedule, a policy as above. The schedule's input is the value of the last run. - Returns: an effect that runs
eff, then runs it again after each success as the schedule allows, and succeeds with the value of the last run. - Fails with: the first failure, which stops the repeat.
- Notes:
eff['repeat'](eff, schedule)is the same function (repeatis a Lua keyword).
local fuel = 100
local drive = Fx.sync(function() fuel = fuel - 30 return fuel end)
local untilEmpty = drive:repeatWith { schedule = Fx.Schedule.spaced(1000), ['until'] = function(left) return left <= 0 end }
Fx.runFork(untilEmpty, { onExit = function(exit) print('fuel left', exit.value) end }) --> fuel left -20
eff:repeatN(n)
eff:repeatN(n: integer) -> Effect<A, E>
- Returns: an effect that runs
effn + 1times in total, with no delay, and succeeds with the last value.
local horn = 0
print(Fx.runSync(Fx.sync(function() horn = horn + 1 return horn end):repeatN(2))) --> 3