medikoo / memoizee

Complete memoize/cache solution for JavaScript
ISC License
1.73k stars 61 forks source link

Preserve rejection state when using a .then reject handler #59

Closed Jessidhia closed 8 years ago

Jessidhia commented 8 years ago

On any Promises/A+ implementation, throwing in the reject handler will propagate the rejection.

medikoo commented 8 years ago

@Kovensky I don't think we should do that.

Returned promise is not consumed anywhere, and if one relies on implementation that notifies about unhandled errors, then it'll have its console polutted with tons of "Unhandled error .." reports.

Jessidhia commented 8 years ago

@medikoo It seems I misunderstood the design of this function, so this PR was indeed incorrect (it seems to convert async functions into sync functions?), but I also think you are misunderstanding what finally does, especially as that is your preferred chaining method. What any of the chaining methods do is, they do not "attach a callback" to the Promise, but create a new Promise. That is, you are forking the Promise chain.

In the case of finally, it is a Promise that will run the callback and preserve rejection if the input is rejected. Both Bluebird's documentation and the stage-0 proposal document that behaviour. The finally polyfill is almost literally, @@species support aside, this.then(v => Promise.resolve(callback()).then(() => v), e => Promise.resolve(callback()).then(() => { throw e }).

Also, in your hasFinally branches, the onFailure is always called -- on both success and failure; and due to the previous paragraph's consequences, the rejection case will always trigger an unhandledPromiseRejection event on bluebird/v8, even if the caller has a try/catch or would have used .catch.

If the memoized function does return a Promise when called, but memoizee doesn't / can't modify the return value, then you can just use .then(onSuccess, onFailure), with no re-throwing. As long as the user has called .then or .finally (i.e. created their own fork), debugging tools will see their fork as missing the .catch.

I am not sure on the overall architecture of memoizee, but, as an example, if I were to "hand-memoize" a single-argument Promise-based function, while transparently keeping the rejection-removes-from-cache behaviour, this is how I would do it:


/*

Only need one storage -- why store resolved values, it can just return the same Promise that can be forked however many times the caller needs. Settled Promises just call any .then callback immediately.

*/
handMemoizedAsyncOp.cache = new Map() // or WeakMap, if arg is object
// any -> Promise<any>
function handMemoizedAsyncOp (arg) {
  const memoized = handMemoizedAsyncOp.cache.get(arg)
  if (memoized != null) {
    // it's a Promise that is either not settled yet, or is resolved
    return memoized
  }

  /*

The memoizer doesn't care what happens on success -- it will be correctly cached as a promise resolved by asyncOp in that case.

It only has to handle rejection, deleting itself from the cache; future callers will thus not see a cached promise and will make a new request instead.

Re-throw the rejection value to ensure that the rejection can be observed (and caught) by anyone that received a copy of this Promise.

Not rethrowing will not only prevent caller-attached .catch from running, it will run any caller-attached .then with an undefined argument!

Note that return Promise.reject(e) is, under Promises/A+, exactly the same thing as throw e as far as can be observed.

  */
  const aPromise =
    asyncOp(arg)
    .catch(e => { handMemoizedAsyncOp.cache.delete(arg); throw e })
  // store a Promise that knows to remove itself on rejection
  handMemoizedAsyncOp.cache.set(arg, aPromise)
  // return the cached Promise
  return aPromise
}

All this, of course, depends on a Promises/A+ compliant implementation. If it's not Promises/A+, I have no idea :D

medikoo commented 8 years ago

finally is preferred simply to not register error handler on a promise. We don't want to mute eventual promise rejection if by a chance it was not handled by promise consumer.

Also, in your hasFinally branches, the onFailure is always called -- on both success and failure

Yes, it's totally intended and does no harm, see this code comment: https://github.com/medikoo/memoizee/blob/master/ext/promise.js#L39

Now concerning issues related to specific implementations:

As an additional note: It would be too invasive if memoizee will return different promise object than one returned by original function. I'm sure for many it would be totally unexpected. Therefore we cannot chain promise for our internal needs, re-throw eventual error, and return its result

Jessidhia commented 8 years ago

@medikoo v8 will probably have finally eventually as there is a Promise.prototype.finally proposal; here is its polyfill: https://github.com/ljharb/proposal-promise-finally/blob/master/polyfill.js

I didn't even know Bluebird had a .done since it's not documented as a Promise method. It's also explicitly deprecated (it behaves like jQuery 1's .done).

Thinking more about it, with the restriction that you can't modify the returned Promise, the branch that you linked is actually the only correct branch, regardless of .finally. The only case it will silence errors is if the caller doesn't do anything with the returned Promise. Remember, you're only silencing the error on your fork of the chain, not on the chain the caller received. Rejections only count as handled if all forks of a chain handle them.

Node REPL session with native Promise:

> process.on('unhandledRejection', function (reason) { console.log('unhandled rejection', reason) }), undefined
undefined
> const memoizee = require('memoizee')
undefined
> const test = memoizee(a => Promise.reject(a), { promise: true })
undefined
> test('unused promise, the .then counts as a rejection handler')
Promise {
  <rejected> 'unused promise, the .then counts as a rejection handler' }
> test('used promise, unhandled').then(() => {})
Promise { <pending> }
> unhandled rejection used promise, unhandled
> test('caught rejection').catch(() => {})
Promise { <pending> }
>

Node REPL session with Bluebird Promise:

> global.Promise = require('bluebird'); undefined
undefined
> process.on('unhandledRejection', function (reason) { console.log('unhandled rejection', reason) }); undefined
undefined
> const memoizee = require('memoizee')
undefined
> const test = memoizee(a => Promise.reject(a), { promise: true })
undefined
> test('unused promise')
Promise {
  _bitField: 16777216,
  _fulfillmentHandler0: 'unused promise',
  _rejectionHandler0: undefined,
  _promise0: undefined,
  _receiver0: undefined }
> Fatal unused promise

Crashes with a Fatal error (process.abort()) as you call .done on it by default. Don't do that.

Again using Bluebird, but with promise: 'then':

> global.Promise = require('bluebird'); undefined
undefined
> process.on('unhandledRejection', function (reason) { console.log('unhandled rejection', reason) }); undefined
undefined
> const memoizee = require('memoizee')
undefined
> const test = memoizee(a => Promise.reject(a), { promise: 'then' })
undefined
> test('unused promise')
Promise {
  _bitField: 16777216,
  _fulfillmentHandler0: 'unused promise',
  _rejectionHandler0: undefined,
  _promise0: undefined,
  _receiver0: undefined }
> unhandled rejection unused promise
unhandled rejection unused promise
> test('used promise').then(() => {})
Promise {
  _bitField: 0,
  _fulfillmentHandler0: undefined,
  _rejectionHandler0: undefined,
  _promise0: undefined,
  _receiver0: undefined }
> unhandled rejection used promise
unhandled rejection used promise
unhandled rejection used promise
> test('caught rejection').catch(() => {})
Promise {
  _bitField: 0,
  _fulfillmentHandler0: undefined,
  _rejectionHandler0: undefined,
  _promise0: undefined,
  _receiver0: undefined }
> unhandled rejection caught rejection
unhandled rejection caught rejection

When the promise is not used, there are two unhandled rejection errors: one on the Promise you create with promise.then(function (result) { nextTick(onSuccess.bind(this, result)); });, one on the Promise you create with promise.finally(function () { nextTick(onFailure); });. Only the ends of Promise chains cause unhandled rejection errors.

When the promise is used, I added another end to the chain that doesn't handle it, so now I get 3 rejection errors.

When the promise has a .catch attached, it will catch the rejection on my branch of the Promise, but the two Promises you are creating in your handler don't, so you still cause unhandled rejection errors!

Remember, you are never "attaching handlers" to a Promise, you are always constructing new Promises that merely depend on the previous Promise.

This incorrect behaviour is not unique when using Bluebird; it will also happen when using any other Promises/A+ implementation that has a .finally... like v8 Promises with (a simplified version of) the Promise.prototype.finally polyfill:

> process.on('unhandledRejection', function (reason) { console.log('unhandled rejection', reason) }); undefined
undefined
> Promise.prototype.finally = function (cb) { if (typeof cb !== 'function') { cb = function () {}; } return this.then(v => { Promise.resolve(cb()).then(() => v) }, e => { Promise.resolve(cb()).then(() => { throw e }) }) }
[Function]
> const memoizee = require('memoizee')
undefined
> const test = memoizee(a => Promise.reject(a), { promise: true })
undefined
> test('unused promise')
Promise { <rejected> 'unused promise' }
> unhandled rejection unused promise
unhandled rejection unused promise
> test('used promise').then(() => {})
Promise { <pending> }
> unhandled rejection used promise
unhandled rejection used promise
unhandled rejection used promise
> test('handled promise').catch(() => {})
Promise { <pending> }
> unhandled rejection handled promise
unhandled rejection handled promise
medikoo commented 8 years ago

@medikoo v8 will probably have finally

Yes, probably at some point, but that's not really relevant to this discussion

I didn't even know Bluebird had a .done since it's not documented as a Promise method. It's also explicitly deprecated (it behaves like jQuery 1's .done).

done is the only way to access resolved value without side effects (error swallowing, unnecessary chain extension), and all good implementation should have it. Still it's true there are very different point views on it in, and you'll find people that'll tell you the opposite.

Rejections only count as handled if all forks of a chain handle them.

That's indeed the thing I've overseen when trying then & finally pair. The internally created promise (via then) will be rejected, and as no error handler to it is attached, it wil report Unhandled rejection (doesn't matter that on parent promise error could have been handled) I will fix it so no finally method is used with then, it will make solution limited, but there's probably no clean workaround when no done is available.

medikoo commented 8 years ago

I've published update as v0.4.1, and I've also opened issue to introduce solution you suggested as one of the options -> https://github.com/medikoo/memoizee/issues/60

Rush commented 8 years ago

done is the only way to access resolved value without side effects

With bluebird one can use http://bluebirdjs.com/docs/api/reflect.html in order to find the resolved value without side effects.

medikoo commented 8 years ago

@Rush I would like not to dive into library specific API's, as there are tens of promise libraries out there. done is pretty standard and I think it's as far as I would want to go.