origamitower / folktale

[not actively maintained!] A standard library for functional programming in JavaScript
https://folktale.origamitower.com/
MIT License
2.04k stars 102 forks source link

How to Aggregate Errors on Tasks? #222

Open vladejs opened 5 years ago

vladejs commented 5 years ago

This is a use case I haven't been able to solve.

I have 3 http requests, if one fails, I want, at least, the result of the other 2. Ideally, I would want to know what error happened.

I'm still using data.task from folktale 1, and this is how I do the requests, which fails fast (if one fails, I only get the first error with no result):

const mergeResults = data => logs => customer => ({ data, logs, customer })

liftA3(
  mergeResults,
  getDataFromAPI(userId),
  getLogs(userId),
  getCustomer(customerID)
).fork(
   error => ... Handle the first thrown error,
   result => ... All 3 requests succeeded here
)

Here, I would like to have data and logs returned if getCustomer API call fails. Thanks in advance

robotlolita commented 5 years ago

Sadly there's no simple way of doing this, but you can convert the Task<A, B> to a Task<void, Validation<A, B>> — where the task always "succeeds". Then you'll need a function that will aggregate the errors from Validation. This is easier since there's no concurrency involved, and Validation already has an applicative instance that does aggregation.

You'll also need control.async to run stuff in parallel.

For example:

const Task = require('data.task');
const Validation = require('data.either');
const { parallel } = require('control.async');

const toTaskOfResults = task =>
  task.fold(error => Task.of(Validation.Failure([error])),
            value => Task.of(Validation.Success(value)));

parallel([
  getDataFromAPI(userId),
  getLogs(userId),
  getCustomer(userId)
]).map(([a, b, c]) => liftA3(mergeResults, a, b, c))
  .fork(
    ...
  );

If you write a liftA that takes an array of things, and make it curried, you can simplify that to .map(liftA(mergeResults)) too.

vladejs commented 5 years ago

Sorry, I didn't understand well your example. Where should I use the toTaskOfResults fn? I'm also open to a solution using folktale 2 task version though.

In your example at the end there is the standard fork call, so I can't see how you get the failures plus the successes.

On Thu, Jul 18, 2019, 1:25 PM Quil notifications@github.com wrote:

Sadly there's no simple way of doing this, but you can convert the Task<A, B> to a Task<void, Validation<A, B>> — where the task always "succeeds". Then you'll need a function that will aggregate the errors from Validation. This is easier since there's no concurrency involved, and Validation already has an applicative instance that does aggregation.

You'll also need control.async to run stuff in parallel.

For example:

const Task = require('data.task');

const Validation = require('data.either');

const { parallel } = require('control.async');

const toTaskOfResults = task =>

task.fold(error => Task.of(Validation.Failure([error])),

        value => Task.of(Validation.Success(value)));

parallel([

getDataFromAPI(userId),

getLogs(userId),

getCustomer(userId)

]).map(([a, b, c]) => liftA3(mergeResults, a, b, c))

.fork(

...

);

If you write a liftA that takes an array of things, and make it curried, you can simplify that to .map(liftA(mergeResults)) too.

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHub https://github.com/origamitower/folktale/issues/222?email_source=notifications&email_token=ADANIASCGBXE42UMOAOLVUDQACRQTA5CNFSM4IE4YQK2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOD2JGLGQ#issuecomment-512910746, or mute the thread https://github.com/notifications/unsubscribe-auth/ADANIARNJFQRBXN4ZNXTTDTQACRQTANCNFSM4IE4YQKQ .

robotlolita commented 5 years ago

Oh, oops, this is what I get for not testing things. There's so much wrong with that example, sorry (I haven't touched v1 for a long time) :')

Anyway. My suggestion was that you transform all of your Task<Error, Result> to Task<void, Validation<Error, Result>>—so all of your tasks will be successful, in Task parlance, but they'll still carry the distinction between failure and success in its value. This is because Task was designed to be sequential, in the sense that if anything fails, then everything else should fail as well—it doesn't make sense to continue processing. Sometimes this assumption doesn't hold, but there aren't operations on Task that help with those edge cases yet, not even in Folktale 2.

You can construct such operation by combining Validations and control.async's parallel (or Folktale 2's Task.waitAll):

const Task = require('data.task');
const Validation = require('data.validation');
const { parallel } = require('control.async')(Task);

const toTaskOfValidations = (task) =>
  task.map(Validation.of)
      .orElse(error => Task.of(Validation.Failure([error])));

const liftA = (Applicative, f) => (applicatives) =>
  applicatives.reduce((a, b) => a.ap(b), Applicative.of(f));

const parallelCollectingFailures = (f, tasks) =>
  parallel(tasks.map(toTaskOfValidations))
    .map(liftA(Validation, f))
    .chain(validation => validation.fold(Task.rejected, Task.of));

And example usage would be like this:

const getDataFromAPI = a => Task.of('data');
const getLogs = b => Task.rejected('logs');
const getCustomer = c => Task.rejected('customer');

const mergeResults = data => logs => customer => ({ data, logs, customer });

const userId = 1;

parallelCollectingFailures(mergeResults, [
  getDataFromAPI(userId),
  getLogs(userId),
  getCustomer(userId)
]).fork(
    error => console.log('error', error),
    value => console.log('ok', value)
  );

Which should give you error [ 'logs', 'customer' ] when you run it.

vladejs commented 5 years ago

Thank you for the detailed example. Indeed is very hard to do it.

This second example gives either all the errors, or a single success, so I'll be only able to tell the user what errors happened without providing the succeeded data.

In the context of the example, I would like to provide as the final result an object like so:

{
   logs: "Error, can't connect to database",
   customer: "Error, can't connect to Stripe",
   data: "data"
}
diasbruno commented 5 years ago

[Edit] This is equivalent to toTaskOfResults as @robotlolita pointed out.

but you can convert the Task<A, B> to a Task<void, Validation<A, B>> — where the task always "succeeds"

Another option is to convert a rejected task into a resolved one.

// here you would have 
// f, g :: () -> Task () b
// but you can also use a `bimap` to make it something link
// f, g :: () -> Task () (Validation a b)
const validate = task => task.map(Success).orElse(compose(Task.of, Failure));
const f = () => validate(Task.of(1));
const g = () => validate(Task.rejected(2));

parallel([f(), g()]).fork(/* unused */, data => ...);

// data = [ folktale:Validation.Success({ value: 1 }),
//          folktale:Validation.Failure({ value: 2 }) ]
robotlolita commented 5 years ago

Oh! So you want individual results rather than the overall results. That's easier.

With:

const Task = require('data.task');
const Validation = require('data.validation');
const { parallel } = require('control.async')(Task);

const toTaskOfValidations = (task) =>
  task.map(Validation.of)
      .orElse(error => Task.of(Validation.Failure(error)));

const runEach = (tasks) =>
  parallel(tasks.map(toTaskOfValidations));

And use it as:

const getDataFromAPI = a => Task.of('data');
const getLogs = b => Task.rejected('logs');
const getCustomer = c => Task.rejected('customer');

const mergeResults = (data, logs, customer) => ({ data, logs, customer });

const userId = 1;

runEach([
  getDataFromAPI(userId),
  getLogs(userId),
  getCustomer(userId)
]).map(xs => mergeResults(...xs))
  .fork(
    error => /* never happens */,
    value => console.log(value)
  );

Here each value of the results record will be a Validation, however, so you'll need to pattern match on whether each individual result is a failure or a success.

vladejs commented 5 years ago

Great idea. Let me explore your suggestion. Thank you very much

On Sat, Jul 20, 2019, 10:46 AM Bruno Dias notifications@github.com wrote:

Another option is to convert a rejected task into a resolved one.

// here you would have // f, g :: () -> Task () b// but you can also use a bimap to make it something link// f, g :: () -> Task () (Validation a b)const f = () => v(Task.of(1));const g = () => v(Task.rejected(2));const v = task => task.map(Success).orElse(compose(Task.of, Failure)); parallel([f(), g()]).fork(/ unused /, data => ...); // data = [ folktale:Validation.Success({ value: 1 }),// folktale:Validation.Failure({ value: 2 }) ]

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHub https://github.com/origamitower/folktale/issues/222?email_source=notifications&email_token=ADANIASPQZPCWY2Q3UMTRADQAMQLFA5CNFSM4IE4YQK2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOD2NPYII#issuecomment-513473569, or mute the thread https://github.com/notifications/unsubscribe-auth/ADANIAQ3VOJS4BFMTTNFSLLQAMQLFANCNFSM4IE4YQKQ .

diasbruno commented 5 years ago

Sorry for suddenly jump in. I've recently needed something like this, but with plain promises.