Skip to content
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

Listener Middleware #803

Merged
merged 54 commits into from
Jul 30, 2015
Merged

Listener Middleware #803

merged 54 commits into from
Jul 30, 2015

Conversation

michaelansel
Copy link
Collaborator

Builds on #801 (57b7220) to allow injecting arbitrary code between the Listener match and execute steps. Like in Express, middleware can interrupt the response process, preventing the Listener callback from ever being executed. Middleware can perform operations both on the way towards the Listener callback (before callback execution) and on the way away from the Listener callback (after callback execution/middleware interrupt).

As a side effect, listeners are now executed asynchronously. Behavior around message.done should remain the same (process until message.done is true).

Example usage (imposes authorization policy on all scripts): https://github.com/michaelansel/hubot-rbac/blob/master/src/rbac.coffee

API Change:

  • Robot.receive and Listener.call are now asynchronous. Any external code depending on knowing when all Listeners have been tested (in receive) or when the Listener callback has finished executing (in call) will need to be updated to use the new callback parameter. Code that doesn't care about timing should continue to work without modification.

@michaelansel
Copy link
Collaborator Author

Not sure if there is a clean way to create a PR that shows this is dependent on another branch...

Code is complete; working on documentation.

@michaelansel michaelansel changed the title Middleware Listener Middleware Oct 25, 2014
Allow injecting arbitrary code between the Listener match and execute
steps. Like in Express, middleware can interrupt the response process,
preventing the Listener callback from ever being executed. Middleware
can perform operations both on the way towards the Listener callback
(before callback execution) and on the way away from the Listener
callback (after callback execution/middleware interrupt).

As a side effect, listeners are now executed asynchronously. Behavior
around message.done should remain the same (process until message.done
is true).
@michaelansel
Copy link
Collaborator Author

Documentation complete; ready for review and merge

Everything appears to work fine with 0.1.0, so relaxing the version
requirement.
# If not, the middleware should call the 'done' function with
# no arguments.
#
# Returns nothing.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

provided that @Middleware is an array, the push operation should return the new size of the array (as per https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push)?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'd probably want to explicitly return null or undefined after that then.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. I'll change to return undefined.

@technicalpickles
Copy link
Member

As a side effect, listeners are now executed asynchronously. Behavior around message.done should remain the same (process until message.done is true).

I'll have to review the changes more, but I'd be reallly concerned about how this might affect existing code. It looks like you are using detectSeries though, which I think means that while the calls are async, they are called in series, so it should preserve existing behavior.

@technicalpickles
Copy link
Member

Also wanted to note, I was considering the async module, but was also looking at run-async. I came across it while trying to understand how yeoman-generator works.

In particular, this has the benefit of being indifferent to if the callback function returns immediately, or is called asynchronously. Mostly, it gives control to the script writer.

@michaelansel
Copy link
Collaborator Author

I'd be really concerned about how this might affect existing code.

Correct, detectSeries should maintain the old behavior. Upon further review, I do note that exception handling changes a bit: if anything in the middleware stack yields to the event loop, the stack does reset and listener callback exceptions will trigger an uncaught. I think the best way to address this is:

  1. middleware is responsible for catching it's own exceptions
  2. move/add try-catch to executeListener (L49 in listener.coffee)

I'll add another commit with the new try-catch and expected behavior.

As a point of experience, we've been running this specific change (copied over from #724) since June and haven't noticed any strange behavior (but we also haven't had any asynchronous middleware in the stack).

@michaelansel
Copy link
Collaborator Author

Also wanted to note, I was considering the async module, but was also looking at run-async. I came across it while trying to understand how yeoman-generator works.

In particular, this has the benefit of being indifferent to if the callback function returns immediately, or is called asynchronously. Mostly, it gives control to the script writer.

Interesting! Never heard of run-async before. I would be up for changing the script interface to leverage that, but I think we should require basic middleware to stay as-is because middleware must make a decision (continue or cancel). I feel that adding the option to run synchronous might overcomplicate an already non-intuitive interface.

That said, I think it would be pretty easy to build a synchronous wrapper for middleware that allows you to return a boolean for continue/cancel instead of hitting a callback. In that situation, you would probably lose access to wrapping the done function (meaning, you can only do work before listener callback execution and not afterwards).

Regarding changing the scripting interface, I'd like to hold off on changing the interface yet. I'm still roughing out ideas, but I'd like to build out the rackup file method of launching and use that as a way to easily deprecate the current script format (robot.useLegacyScript). I hope to have examples figured out enough for review within a week or so.

Due to the potentially asynchronous nature of middleware, we need to
push the try-catch block down to surround just the listener callback. As
a result, middleware is responsible for catching its own exceptions and
emitting an 'error' event.

The original try-catch is left in place to catch any errors in bad
synchronous middleware. Not guaranteed to catch it all, but might as
well catch some.
@michaelansel
Copy link
Collaborator Author

Fixed!

In the event of an entire middleware stack that never defers to the
event loop (or no middleware at all), an extremely long stack trace
builds up (each listener building on the last). Instead, we defer to the
event loop after testing each listener, resulting in much more
manageable stack sizes. This should not change effective behavior of
listener processing unless a script relies on all listeners being
evaluated in a single turn of the event loop (which is already really
bad).
@@ -100,6 +100,10 @@ It wouldn't be called for:
* has anyone ever mentioned how lovely you are when you open pod bay doors?
* because it lacks the robot's name

The options Object is a way to attach arbitrary metadata to a Listener (hear/respond entry) and enable easy extension of the core hubot functionality. By default, the only handled option inside the options Object is `id`. Additional options may be handled by other scripts that extend hubot; for more information, see the Middleware section of this document.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can make 'Middleware section' a link to #middleware, which should just work when viewing it.

@technicalpickles
Copy link
Member

I've been digging into this code a lot more today, and the part I'm having a trouble with is Listener's executeAllMiddleware. I've been wondering if there'd be a simpler way to implement. The async library has a waterfall method that might be applicable. It calls each thing in series, and lets you pass onto the next method, and then has a callback for when it finished. If something passes an error, that the rest of the series is ended and the callback gets a error as the first arg.

So, what I was thinking is that the tasks could be the middleware. If the middleware wants to stop, it can invoke the tasks' callback with specific type of error, that the waterfall's callback can check for. If there's not an error, the waterfall's callback can just execute the listener as normal.

@technicalpickles
Copy link
Member

Doing some more reading, just series has the same control flow (ie invoke next callback with error to stop going to the next thing).

Using waterfall might be better, as you'd be able to manipulate the arguments passed into a tasks' callback, and then pass them into the next task. At the end, the listener would be invoked with whatever the last script sent to it.

@bhuga
Copy link
Contributor

bhuga commented Jul 27, 2015

@michaelansel anything I can do to help this along? We're getting to the point internally where I want to make sure we're not diverging.

@michaelansel
Copy link
Collaborator Author

Really just stuck on time right now. I haven't had time to think about pretty much anything Hubot related. The only outstanding thing I have is tweaking the interface. I'm thinking robot.listenerMiddleware (context, next, done) -> is the right way to go. With that changed, I'm game for merging this, adding receive middleware immediately afterwards, and punting on response middleware for a little while until I have time to dig into it more (because it can be hacked in using receive middleware). What do you think?

@bhuga
Copy link
Contributor

bhuga commented Jul 28, 2015

I'm thinking robot.listenerMiddleware (context, next, done) -> is the right way to go

I think that's fine too.

With that changed, I'm game for merging this, adding receive middleware immediately afterwards, and punting on response middleware for a little while until I have time to dig into it more (because it can be hacked in using receive middleware). What do you think?

That sounds solid. I'm a little wary diving into this today, especially with the docs in a separate pull. But if we land this, it'll be much easier for me to jump in and help with receive middleware directly.

@michaelansel
Copy link
Collaborator Author

Okay, API updated. I read through the docs in michaelansel#2 and it looks like all that is left is either not yet implemented or already covered. Any other thoughts on this before I do a final review pass? I'll see if I can find someone new to give everything a read through...

@bhuga
Copy link
Contributor

bhuga commented Jul 29, 2015

Any other thoughts on this before I do a final review pass? I'll see if I can find someone new to give everything a read through...

This sounds great to me. Excited to get my hands on this!

@michaelansel
Copy link
Collaborator Author

While I'm getting additional review from coworkers, @technicalpickles do you have any reservations about merging this?

@@ -592,7 +592,7 @@ In addition to a regular expression and callback, the `hear` and `respond` funct

The most important and most common metadata key is `id`. Every Listener should be given a unique name (options.id; defaults to `null`). Names should be scoped by module (e.g. 'my-module.my-listener'). These names allow other scripts to directly address individual listeners and extend them with additional functionality like authorization and rate limiting.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading through the documentation, I had some thoughts.

  • I think it'd be nice to bold "every", as this tool will be much much more useful as external packages are updated.
  • It seems like it would be a good idea to mention, that existing packages will need to be updated to be able to take advantage of this. Maybe this particular document isn't the best location, but at least for a while, I would probably be helpful to point out that these features will require support from package authors.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! Not really a part of middleware though. Would you mind putting together a quick PR, and I'll merge it separately?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I can do that in a bit.

Thanks for all this hard work! It will make my hubot's maintenance muuuuuuuch better!

@michaelansel
Copy link
Collaborator Author

I've done a pretty careful review of the docs. Everything looks in order. Time to push the big green button!

michaelansel added a commit that referenced this pull request Jul 30, 2015
@michaelansel michaelansel merged commit c970711 into hubotio:master Jul 30, 2015
@bhuga
Copy link
Contributor

bhuga commented Jul 30, 2015

Awesomesauce. Happy almost-birthday, #803!

yes cookie

@cycomachead
Copy link
Contributor

Thanks again @michaelansel, and everyone else! 👍

@sdimkov
Copy link
Contributor

sdimkov commented Jul 31, 2015

OMG did this just got merged? Great job everyone :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants