-
Notifications
You must be signed in to change notification settings - Fork 21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Future API changes #9
Comments
I think the aFuture
aFuture.onSuccess(cb) // <- triggered here
await something
aFuture.onError(cb2) aFuture
aFuture.onError(cb2) // <- triggered here
await something
aFuture.onSuccess(cb) What I propose: aFuture.onSuccess(cb).onError(cb2)
await something
aFuture.trigger() // <- triggered here and: await aFuture.onSuccess(cb).onError(cb2) //👌 |
clearly my worry is that someone forgets to call |
On the other hand, I can imagine people misusing const datas = Promise.all(['url','url2'].map(fetch)) // the downloading will begin here
someStuff()
await something
someMoreStuff()
useDatas(await datas) // the datas are ready but now const datas = Future.sequence(['url','url2'].map(Future.liftBBBB(fetch)))
someStuff()
await something
someMoreStuff()
useDatas(await datas) // the downloading will only begin here What is bound to happen if this lib becomes popular is that |
To be honest it was borderline for me whether to make Future lazy or not. In the end there is still the option of having Future eager, and if you want a lazy version, you can use ()=>Future. Any opinion about that? One use-case for Future being lazy was: and I actually coded this (will push this soonish). But actually this does not require Future being lazy, since we can build the futures on the fly within Future.traverse! What would require them being lazy is: But we could have the feature only on traverse... Lazy futures have other advantages of course, but I don't want to cause too much confusion.. |
🤷♂️ such is the price of laziness. There's no TypeScript or even runtime way to warn people that their Futures aren't being triggered, because building up and then not triggering a pipeline is a valid usecase. Perhaps there should be a |
I think there are valid usecases for lazy future pipelines if we consider that LazyFutures are to Streams as Options are to Arrays. Just like a Future pipeline can shortcircuit on a failure, it can also conditionally never get triggered (for example because another Future going into the same Theoretically, the stream usecases don't actually need a LazyFuture, unlike what I originally thought: declare const awaitEither: (Stream<Future<T>>)=>Stream<Either<T>>
declare const awaitOption: (Stream<Future<T>>)=>Stream<Option<T>>
declare const awaitSuccess: (Stream<Future<T>>)=>Stream<T> As long as the source stream lazily produces eager futures on pull, that's perfectly sufficient. |
relevant, because monix Task is lazy =>
Which is exactly what you were saying. And you're right, this is more sane. So, what we don't want is what we have now, complicated mechanisms, "the future will be forced if you call map, or if you call onComplete, but not if you call filter". There's one "but" though. Because if we build from a Promise, it's already started anyway. I also think we don't want both Future and LazyFuture, at least not for some time. So I think we should pick one. So I see two options:
What we've established now is that the fact that the user must be well aware that the Future is lazy. At this point I'm reconsidering, thinking maybe we should go for the simple solution, an eager Future like what most people are used to. After all this is what you get in scala, in vavr. Unsure about this right now. |
Good discussion! What scenarios are we loosing with solution 2? |
@RPallas92 well if Future is eager, it starts the moment you create it. While with a lazy version, you can create many futures without worrying, only later deciding which ones you're actually going to run. What @qm3ster mentioned was for instance:
What I had in mind was the {maxConcurrent:X} option for Future.sequence & Future.traverse. So, you would create like 20 futures. and you would say, "run them, but I want a most 3 to run in parrallel", because you don't want to overload the server for instance. I already have this coded (minus some details which is why I didn't push yet). Now, if you have lazy Futures, you can offer I guess by having lazy futures, we're giving the programmer another tool, on the other hand we give him/her one more thing to think about.. "was this future triggered? when will it be triggered?". And there is some mess like... myFuture.runAsync(); I mean you won't forget to call runAsync() if you're interested in the result, because you'll have to get the value out at some point, and that'll trigger it. The only risk is for side effects where it would return void. Like to hide a throbber when an action finished, inside the onComplete. Currently leaning towards dropping the laziness, but I don't really know what's the right choice. Just guessing, I'm really on the edge. If nothing else, to be doing what most everybody else is doing and what most everybody is expecting the library to do. |
Why won't this run? It should. The |
I also accidentally wrote this thing: interface Lazy<T> {
(): T
}
interface LazyPromise<T> extends Lazy<Promise<T>> {}
import {once, memoize1, memoize2} from './memoization'
const _trigger = <T>(lp: LazyPromise<T>): Promise<T> => lp()
const trigger = memoize1(_trigger as any) as typeof _trigger
const _map = <T, U>(fn: (val: T) => U, lp: LazyPromise<T>): LazyPromise<U> =>
once(() => trigger(lp).then(fn))
const map = memoize2(_map as any) as typeof _map
const _filter = <T>(
predicate: (T) => boolean,
lp: LazyPromise<T>,
): LazyPromise<T> =>
once(() =>
trigger(lp).then(
val =>
predicate(val) ? val : (Promise.reject('filtered') as Promise<T>),
),
)
const filter = memoize2(_filter as any) as typeof _filter
const rejectionSymbol = {}
const _sequence = <T>(lps: LazyPromise<T>[]): LazyPromise<T[]> =>
once(() =>
Promise.all(lps.map(lp => trigger(lp).catch(() => rejectionSymbol))).then(
results => results.filter(r => r !== rejectionSymbol) as T[],
),
)
const sequence = memoize1(_sequence as any) as typeof _sequence export const once = <R, A extends any[], F extends (...args: A) => R>(
fn: F,
): F => {
let real = (...args: A) => {
const val = fn(...args)
real = () => val
return val
}
return ((...args) => real(...args)) as F
}
export const memoize1 = <R, T1 extends object, F extends (a: T1) => R>(
fn: F,
): F => {
const map1 = new WeakMap<T1, R>()
return ((a: T1): R => {
let res = map1.get(a)
if (!res) map1.set(a, (res = fn(a)))
return res
}) as F
}
export const memoize2 = <
R,
T1 extends object,
T2 extends object,
F extends (a: T1, b: T2) => R
>(
fn: F,
): F => {
const map1 = new WeakMap<T1, WeakMap<T2, R>>()
return ((a: T1, b: T2): R => {
let map2 = map1.get(a)
if (!map2) map1.set(a, (map2 = new WeakMap()))
let res = map2.get(b)
if (!res) map2.set(b, (res = fn(a, b)))
return res
}) as F
} It's probably dumb and unperformant, with all that function wrapping instead of stateful objects, but it shows that at least some current functions can be without too much difficulty written around a Lazy of a Promise |
My understanding is that we don't need to worry about that? Underlying the Future is a promise anyway, and the only thing we do is to call But you're right that for sanity's sake, if the future is lazy, once the future is triggered, anything called further down the line on that promise should be triggered too. |
I started working on a PR in which I would remove laziness and also take into account feedback on the current API from @qm3ster (who had other comments besides the laziness/eagerness), and also add the do-like constructor. I didn't strongly decide to remove laziness, but I figure also in other areas prelude.ts tried to keep things simple and working in the way people expect them to be. I'll submit this through a PR so that people can comment on the changes. I'm still open for feedback on the laziness/eagerness aspect! Implementation simplicity is also a (lesser) concern. |
If lazy futures are to exist, the triggering should only propagate up the chain, not down. const master = Future.of(async() => 1)
const obj = {
master
lowBranch: master.map(x=>x-1)
highBranch: master.map(x=>x+1)
}
Object.entries(obj).forEach(([name, future]) => {future.onSuccess(x=>{console.log(name,x)})}
obj.highBranch.runAsync()
// event loop shenanigans
// > master 1
// > highBranch 2 In the above example, Oh, you meant late binding.
But that violates the expectation of the word |
Closing this bug as it ended up focusing on the laziness which we discarded. I did open #12 to discuss Future.liftXXX. |
continuing the discussion from #8
@qm3ster => all good points, food for thought. Note that prelude.ts has a
Stream
collection type and yes it works as you described. I would consider renaming the on* methods and have them possibly returning void instead of returning the Future, but I'm not sure for the name right now (maybe onFailed=>ifFail, onSucceeded=>ifSuccess, onCompleted=>match or matchValue), or even if that's really the good path forward.That's also connected to some changes I'm working on in these methods regarding error handling.
And besides that I think yes probably map & flatMap should be lazy, and we can also add an explicit. .trigger() (with in apidoc that you don't need it if you do await, or if you do X or Y and so on). The trigger would likely return void.
The text was updated successfully, but these errors were encountered: