Add allSettled, some, and any

Closes #10
Closes #11
Closes #5
This commit is contained in:
Eryn Lynn 2019-09-29 02:03:22 -04:00
parent cef64024e6
commit 75d82b4909
4 changed files with 377 additions and 29 deletions

View file

@ -11,6 +11,9 @@
- Add `done`, `doneCall`, `doneReturn`
- Add `andThenReturn`, `finallyReturn`
- Add `Promise.delay`, `promise:timeout`
- Add `Promise.some`, `Promise.any`
- Add `Promise.allSettled`
- `Promise.all` and `Promise.race` are now cancellable.
# 2.4.0

View file

@ -174,7 +174,7 @@ docs:
desc: |
Begins a Promise chain, calling a synchronous function and returning a Promise resolving with its return value. If the function errors, the returned Promise will be rejected with the error.
`Promise.try` is similar to [[Promise.promisify]], except the callback is executed instantly, and unlike `promisify`, yielding is not allowed with `try`.
`Promise.try` is similar to [[Promise.promisify]], except the callback is invoked immediately instead of returning a new function, and unlike `promisify`, yielding is not allowed with `try`.
```lua
Promise.try(function()
@ -212,6 +212,14 @@ docs:
static: true
params: "promises: array<Promise<T>>"
returns: Promise<array<T>>
- name: allSettled
desc: |
Accepts an array of Promises and returns a new Promise that resolves with an array of in-place PromiseStatuses when all input Promises have settled. This is equivalent to mapping `promise:finally` over the array of Promises.
static: true
params: "promises: array<Promise<T>>"
returns: Promise<array<PromiseStatus>>
- name: race
desc: |
Accepts an array of Promises and returns a new promise that is resolved or rejected as soon as any Promise in the array resolves or rejects.
@ -220,6 +228,26 @@ docs:
static: true
params: "promises: array<Promise<T>>"
returns: Promise<T>
- name: some
desc: |
Accepts an array of Promises and returns a Promise that is resolved as soon as `count` Promises are resolved from the input array. The resolved array values are in the order that the Promises resolved in. When this Promise resolves, all other pending Promises are cancelled if they have no other consumers.
`count` 0 results in an empty array. The resultant array will never have more than `count` elements.
static: true
params: "promises: array<Promise<T>>, count: number"
returns: Promise<array<T>>
- name: any
desc: |
Accepts an array of Promises and returns a Promise that is resolved as soon as *any* of the input Promises resolves. It will reject only if *all* input Promises reject. As soon as one Promises resolves, all other pending Promises are cancelled if they have no other consumers.
Resolves directly with the value of the first resolved Promise. This is essentially [[Promise.some]] with `1` count, except the Promise resolves with the value directly instead of an array with one element.
static: true
params: "promises: array<Promise<T>>"
returns: Promise<T>
- name: delay
desc: |
Returns a Promise that resolves after `seconds` seconds have passed. The Promise resolves with the actual amount of time that was waited.

View file

@ -259,16 +259,121 @@ end
* is resolved when all input promises resolve
* is rejected if ANY input promises reject
]]
function Promise.all(promises)
function Promise._all(traceback, promises, amount)
if type(promises) ~= "table" then
error(ERROR_NON_LIST:format("Promise.all"), 2)
error(ERROR_NON_LIST:format("Promise.all"), 3)
end
-- We need to check that each value is a promise here so that we can produce
-- a proper error rather than a rejected promise with our error.
for i, promise in pairs(promises) do
if not Promise.is(promise) then
error((ERROR_NON_PROMISE_IN_LIST):format("Promise.all", tostring(i)), 2)
error((ERROR_NON_PROMISE_IN_LIST):format("Promise.all", tostring(i)), 3)
end
end
-- If there are no values then return an already resolved promise.
if #promises == 0 or amount == 0 then
return Promise.resolve({})
end
return Promise._newWithSelf(function(self, resolve, reject, onCancel)
self._source = traceback
-- An array to contain our resolved values from the given promises.
local resolvedValues = {}
local newPromises = {}
-- Keep a count of resolved promises because just checking the resolved
-- values length wouldn't account for promises that resolve with nil.
local resolvedCount = 0
local rejectedCount = 0
local done = false
local function cancel()
for _, promise in ipairs(newPromises) do
promise:cancel()
end
end
-- Called when a single value is resolved and resolves if all are done.
local function resolveOne(i, ...)
if done then
return
end
resolvedCount = resolvedCount + 1
if amount == nil then
resolvedValues[i] = ...
else
resolvedValues[resolvedCount] = ...
end
if resolvedCount >= (amount or #promises) then
done = true
resolve(resolvedValues)
cancel()
end
end
onCancel(cancel)
-- We can assume the values inside `promises` are all promises since we
-- checked above.
for i = 1, #promises do
table.insert(
newPromises,
promises[i]:andThen(
function(...)
resolveOne(i, ...)
end,
function(...)
rejectedCount = rejectedCount + 1
if amount == nil or #promises - rejectedCount < amount then
cancel()
done = true
reject(...)
end
end
)
)
end
if done then
cancel()
end
end)
end
function Promise.all(promises)
return Promise._all(debug.traceback(), promises)
end
function Promise.some(promises, amount)
assert(type(amount) == "number", "Bad argument #2 to Promise.some: must be a number")
return Promise._all(debug.traceback(), promises, amount)
end
function Promise.any(promises)
return Promise._all(debug.traceback(), promises, 1):andThen(function(values)
return values[1]
end)
end
function Promise.allSettled(promises)
if type(promises) ~= "table" then
error(ERROR_NON_LIST:format("Promise.allSettled"), 2)
end
-- We need to check that each value is a promise here so that we can produce
-- a proper error rather than a rejected promise with our error.
for i, promise in pairs(promises) do
if not Promise.is(promise) then
error((ERROR_NON_PROMISE_IN_LIST):format("Promise.allSettled", tostring(i)), 2)
end
end
@ -277,46 +382,40 @@ function Promise.all(promises)
return Promise.resolve({})
end
return Promise.new(function(resolve, reject)
return Promise.new(function(resolve, _, onCancel)
-- An array to contain our resolved values from the given promises.
local resolvedValues = {}
local fates = {}
local newPromises = {}
-- Keep a count of resolved promises because just checking the resolved
-- values length wouldn't account for promises that resolve with nil.
local resolvedCount = 0
local finalized = false
local finishedCount = 0
-- Called when a single value is resolved and resolves if all are done.
local function resolveOne(i, ...)
resolvedValues[i] = ...
resolvedCount = resolvedCount + 1
finishedCount = finishedCount + 1
if resolvedCount == #promises then
resolve(resolvedValues)
fates[i] = ...
if finishedCount >= #promises then
resolve(fates)
end
end
onCancel(function()
for _, promise in ipairs(newPromises) do
promise:cancel()
end
end)
-- We can assume the values inside `promises` are all promises since we
-- checked above.
for i = 1, #promises do
if finalized then
break
end
table.insert(
newPromises,
promises[i]:andThen(
promises[i]:finally(
function(...)
resolveOne(i, ...)
end,
function(...)
for _, promise in ipairs(newPromises) do
promise:cancel()
end
finalized = true
reject(...)
end
)
)
@ -337,13 +436,18 @@ function Promise.race(promises)
return Promise.new(function(resolve, reject, onCancel)
local newPromises = {}
local finished = false
local function cancel()
for _, promise in ipairs(newPromises) do
promise:cancel()
end
end
local function finalize(callback)
return function (...)
for _, promise in ipairs(newPromises) do
promise:cancel()
end
cancel()
finished = true
return callback(...)
end
end
@ -358,6 +462,10 @@ function Promise.race(promises)
promise:andThen(finalize(resolve), finalize(reject))
)
end
if finished then
cancel()
end
end)
end

View file

@ -582,6 +582,33 @@ return function()
expect(ok).to.be.ok()
expect(err:find("Non%-promise")).to.be.ok()
end)
it("should cancel pending promises if one rejects", function()
local p = Promise.new(function() end)
expect(Promise.all({
Promise.resolve(),
Promise.reject(),
p
}):getStatus()).to.equal(Promise.Status.Rejected)
expect(p:getStatus()).to.equal(Promise.Status.Cancelled)
end)
it("should cancel promises if it is cancelled", function()
local p = Promise.new(function() end)
p:andThen(function() end)
local promises = {
Promise.new(function() end),
Promise.new(function() end),
p
}
Promise.all(promises):cancel()
expect(promises[1]:getStatus()).to.equal(Promise.Status.Cancelled)
expect(promises[2]:getStatus()).to.equal(Promise.Status.Cancelled)
expect(promises[3]:getStatus()).to.equal(Promise.Status.Started)
end)
end)
describe("Promise.race", function()
@ -614,6 +641,14 @@ return function()
expect(promises[1]:getStatus()).to.equal(Promise.Status.Started)
expect(promises[2]:getStatus()).to.equal(Promise.Status.Cancelled)
expect(promises[3]:getStatus()).to.equal(Promise.Status.Resolved)
local p = Promise.new(function() end)
expect(Promise.race({
Promise.reject(),
Promise.resolve(),
p
}):getStatus()).to.equal(Promise.Status.Rejected)
expect(p:getStatus()).to.equal(Promise.Status.Cancelled)
end)
it("should error if a non-array table is passed in", function()
@ -624,6 +659,23 @@ return function()
expect(ok).to.be.ok()
expect(err:find("Non%-promise")).to.be.ok()
end)
it("should cancel promises if it is cancelled", function()
local p = Promise.new(function() end)
p:andThen(function() end)
local promises = {
Promise.new(function() end),
Promise.new(function() end),
p
}
Promise.race(promises):cancel()
expect(promises[1]:getStatus()).to.equal(Promise.Status.Cancelled)
expect(promises[2]:getStatus()).to.equal(Promise.Status.Cancelled)
expect(promises[3]:getStatus()).to.equal(Promise.Status.Started)
end)
end)
describe("Promise.promisify", function()
@ -790,4 +842,161 @@ return function()
expect(always).to.be.ok()
end)
end)
describe("Promise.some", function()
it("should resolve once the goal is reached", function()
local p = Promise.some({
Promise.resolve(1),
Promise.reject(),
Promise.resolve(2)
}, 2)
expect(p:getStatus()).to.equal(Promise.Status.Resolved)
expect(p._values[1][1]).to.equal(1)
expect(p._values[1][2]).to.equal(2)
end)
it("should error if the goal can't be reached", function()
expect(Promise.some({
Promise.resolve(),
Promise.reject()
}, 2):getStatus()).to.equal(Promise.Status.Rejected)
local reject
local p = Promise.some({
Promise.resolve(),
Promise.new(function(_, r) reject = r end)
}, 2)
expect(p:getStatus()).to.equal(Promise.Status.Started)
reject("foo")
expect(p:getStatus()).to.equal(Promise.Status.Rejected)
expect(p._values[1]).to.equal("foo")
end)
it("should cancel pending Promises once the goal is reached", function()
local resolve
local pending1 = Promise.new(function() end)
local pending2 = Promise.new(function(r) resolve = r end)
local some = Promise.some({
pending1,
pending2,
Promise.resolve()
}, 2)
expect(some:getStatus()).to.equal(Promise.Status.Started)
expect(pending1:getStatus()).to.equal(Promise.Status.Started)
expect(pending2:getStatus()).to.equal(Promise.Status.Started)
resolve()
expect(some:getStatus()).to.equal(Promise.Status.Resolved)
expect(pending1:getStatus()).to.equal(Promise.Status.Cancelled)
expect(pending2:getStatus()).to.equal(Promise.Status.Resolved)
end)
it("should error if passed a non-number", function()
expect(function()
Promise.some({}, "non-number")
end).to.throw()
end)
it("should return an empty array if amount is 0", function()
local p = Promise.some({
Promise.resolve(2)
}, 0)
expect(p:getStatus()).to.equal(Promise.Status.Resolved)
expect(#p._values[1]).to.equal(0)
end)
it("should not return extra values", function()
local p = Promise.some({
Promise.resolve(1),
Promise.resolve(2),
Promise.resolve(3),
Promise.resolve(4),
}, 2)
expect(p:getStatus()).to.equal(Promise.Status.Resolved)
expect(#p._values[1]).to.equal(2)
expect(p._values[1][1]).to.equal(1)
expect(p._values[1][2]).to.equal(2)
end)
it("should cancel promises if it is cancelled", function()
local p = Promise.new(function() end)
p:andThen(function() end)
local promises = {
Promise.new(function() end),
Promise.new(function() end),
p
}
Promise.some(promises, 3):cancel()
expect(promises[1]:getStatus()).to.equal(Promise.Status.Cancelled)
expect(promises[2]:getStatus()).to.equal(Promise.Status.Cancelled)
expect(promises[3]:getStatus()).to.equal(Promise.Status.Started)
end)
describe("Promise.any", function()
it("should return the value directly", function()
local p = Promise.any({
Promise.reject(),
Promise.reject(),
Promise.resolve(1)
})
expect(p:getStatus()).to.equal(Promise.Status.Resolved)
expect(p._values[1]).to.equal(1)
end)
it("should error if all are rejected", function()
expect(Promise.any({
Promise.reject(),
Promise.reject(),
Promise.reject(),
}):getStatus()).to.equal(Promise.Status.Rejected)
end)
end)
end)
describe("Promise.allSettled", function()
it("should resolve with an array of PromiseStatuses", function()
local reject
local p = Promise.allSettled({
Promise.resolve(),
Promise.reject(),
Promise.resolve(),
Promise.new(function(_, r) reject = r end)
})
expect(p:getStatus()).to.equal(Promise.Status.Started)
reject()
expect(p:getStatus()).to.equal(Promise.Status.Resolved)
expect(p._values[1][1]).to.equal(Promise.Status.Resolved)
expect(p._values[1][2]).to.equal(Promise.Status.Rejected)
expect(p._values[1][3]).to.equal(Promise.Status.Resolved)
expect(p._values[1][4]).to.equal(Promise.Status.Rejected)
end)
it("should cancel promises if it is cancelled", function()
local p = Promise.new(function() end)
p:andThen(function() end)
local promises = {
Promise.new(function() end),
Promise.new(function() end),
p
}
Promise.allSettled(promises):cancel()
expect(promises[1]:getStatus()).to.equal(Promise.Status.Cancelled)
expect(promises[2]:getStatus()).to.equal(Promise.Status.Cancelled)
expect(promises[3]:getStatus()).to.equal(Promise.Status.Started)
end)
end)
end