Feathers: Use AsyncLocalStorage to create a session

Created on 22 Sep 2020  Â·  6Comments  Â·  Source: feathersjs/feathers

This snippet is a basic example using AsyncLocalStorage to create a session object that is available to any "nested" services. This could be useful in passing things like user, authentication, transactions, etc to these service calls nested N levels deep instead of having to "params drilling" (aka explicitly passing params from service to service).

const { AsyncLocalStorage } = require('async_hooks');
const session = new AsyncLocalStorage();

const startSessionAsync = async (context, next) => {
  if (session.getStore()) {
    context.session = session;
    return next();
  } else {
    console.log('starting session: ', context.path);
    return session.run(new Map(), async () => {
      session.getStore().set('time', new Date().getTime());
      context.session = session;
      return next();
    });
  }
};


const logSession = context => {
  console.log(`logging from ${context.path}:${context.type} hook: `, {
    time: context.session.getStore().get('time')
  });
  return context;
};

const callNestedService = async context => {
  const posts = await context.app.service('posts').find({
    query: { user_id: context.result.user_id }
   // Notice that we do not explicitly pass the `context.session` to the posts service
  });
  return context;
}

// Use the `startSessionAsync` in app.hooks. If no session is "active" (via session.getStore()) then a new session
// is created. But, if this service is being called "nested" then a session will already exist and is "continued" to
// this nested service
app.hooks({
  async: [startSessionAsync]
});

usersService.hooks({
  before: {
    all: [logSession]
  }
 after: {
    all: [callNestedService, logSession]
  }
});

postsService.hooks({
  before: {
    all: [logSession]
  }
 after: {
    all: [logSession]
  }
});

usersService.find();

// logging from api/users:before hook:  { time: 1600811184874 }
// logging from api/posts:before hook:  { time: 1600811184874 }
// logging from api/posts:after hook:  { time: 1600811184874 }
// logging from api/users:after hook:  { time: 1600811184874 }

You can see a full working example here: https://github.com/DaddyWarbucks/test-feathers-cls
And you can read more about AsyncLocalStorage here: https://nodejs.org/api/async_hooks.html#async_hooks_class_asynclocalstorage

Note that there are performance penalties for using AsynLocalStorage. A quick Google search will turn up some benchmarks, although none are very detailed.

It is also important to note that AsyncLocalStorage was released in Node v13 (with backports for later v12 versions). So this is a rather new feature. But, I also found https://github.com/kibertoad/asynchronous-local-storage which would be a good example for how to fallback to cls-hooked if we wanted to support back to Node v8.

Proposal

Most helpful comment

I have been thinking about this for a few years now and have come up with some other vanilla feathers solutions as well. For example.

// Extend classes or add mixin with a function that returns a service-like object that picks off
// params you want to pass along and automatically pass them
service.withSession = context => {
  return {
    find(params) {
      return service.find({ session: context.params.session, ...params })  
   }
  ...other methods
  }
}

// can be used like this in a hook
app.service("albums").withSession(context).find({ ... })

And another option is something like this

const sessionHook = context => {
  context.params.session = { ... }

  context.sessionService = (serviceName) => {
    find(params) {
      return context.app.service(serviceName).find({ session: context.params.session, ...params })  
   }
  ...other methods
  }

  return context;
}

// and can be used in a hook like this
context.sessionService('albums').find({ ... })

All 6 comments

Chimin‘ in...

Just throwing this in here as a potential way to handle this pre v5. I typed this out in response to another issue asking about setting up NewRelic and passing around that transaction, but then deleted it from there because it was more relevant here.

The current way hooks are handled, they are not in the same "execution context" for this to work. But we could create a mixin, or extend classes to make this work I believe. The following code is not tested and just an idea.

const { AsyncLocalStorage } = require('async_hooks');
const session = new AsyncLocalStorage();

app.mixins.push((service, path) => {
  const oldFind = service.find;
  service.find = function (params) {
    if (session.getStore()) {
      return oldFind.call(this, { session, ...params });
    }
    return new Promise((resolve) => {
      session.run(new Map(), () => {
        // session.getStore().set('time', new Date().getTime());
        return oldFind.call(this, { session, ...params ).then(resolve);
      });
    });
  };

// do the same for the rest of the methods
});

We do this in our application as well though we have to set up the initial storage outside of feathers (hooked into the transports for REST and socket.io) to maintain the same context throughout a single request. We used the async-local-storage library (https://github.com/vicanso/async-local-storage) to wrap the async hooks logic.

Would love to have something like this in feathers -- there are some things that really should have per-request visibility across calls without having to explicitly pass in params especially when that sometimes triggers additional logic (thinking about params.user specifically). Definitely helpful for auditing and logging contexts.

I... don't particularly understand this async storage stuff.

I manually added a "trace uuid" in a global hook / middleware, and then have been making sure on each internal service invocation to pass that trace ID along, so that I can correlate all the queries afterwards. It's a bit verbose, but I got it to work on the server. This definitely has a bunch of boilerplate that I'm afraid of getting wrong, so having something that handles this automatically would be very cool.

(I didn't decide or yet what would be a single transaction from the client side.)

I have been thinking about this for a few years now and have come up with some other vanilla feathers solutions as well. For example.

// Extend classes or add mixin with a function that returns a service-like object that picks off
// params you want to pass along and automatically pass them
service.withSession = context => {
  return {
    find(params) {
      return service.find({ session: context.params.session, ...params })  
   }
  ...other methods
  }
}

// can be used like this in a hook
app.service("albums").withSession(context).find({ ... })

And another option is something like this

const sessionHook = context => {
  context.params.session = { ... }

  context.sessionService = (serviceName) => {
    find(params) {
      return context.app.service(serviceName).find({ session: context.params.session, ...params })  
   }
  ...other methods
  }

  return context;
}

// and can be used in a hook like this
context.sessionService('albums').find({ ... })
Was this page helpful?
0 / 5 - 0 ratings

Related issues

andysay picture andysay  Â·  3Comments

perminder-klair picture perminder-klair  Â·  3Comments

davigmacode picture davigmacode  Â·  3Comments

arkenstan picture arkenstan  Â·  3Comments

huytran0605 picture huytran0605  Â·  3Comments