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

Lazy Listener Functions #34

Closed
3 tasks done
seratch opened this issue Aug 7, 2020 · 0 comments
Closed
3 tasks done

Lazy Listener Functions #34

seratch opened this issue Aug 7, 2020 · 0 comments
Assignees
Labels
enhancement New feature or request
Milestone

Comments

@seratch
Copy link
Member

seratch commented Aug 7, 2020

This is a proposal for a new feature, "Lazy Listener Functions" in Bolt for Python. This is a new feature for advanced developers that want to build serious Slack apps using Function as a Service. Adding this feature doesn't prevent most developers from using Bolt as with Bolt JS and Bolt Java. If this feature is greatly successful for many users, we may implement the same concept in other Bolt frameworks in the future.

Motivation

The primary motivation of this feature is to realize a more handy way to run asynchronous code after acknowledging incoming requests from Slack within 3 seconds.

When I was thinking about possible solutions for FaaS support in Bolt for JS, I came up with a "two-phase app" concept.

However, I think it was not simple enough, and the concept was far different from the original Bolt framework.

Lazy listener functions are more powerful and simpler than that approach. This mechanism enables developers to more easily manage async operations, even in FaaS environments.

API Design

Let's say you have the following slash command listener. Of course, this works without any issues.

from slack_bolt import App
app = App()

command = "/start-long-task"

@app.command(command)
def do_everything_here(payload, ack, respond, client):
    if payload.get("text", None) is None:
        ack(f":x: Usage: {command} (task name here)")
    else:
        task_name = payload["text"]
        ack(f"Accepted! (task: {task_name})") # This immediately sends 200 OK to Slack

        # start a long task
        time.sleep(5) # Doing something meaningful takes time
        task_name = payload["text"]
        respond(f"Completed! (task: {task_name}, url: https://example.com/result)")

With Lazy listener functions, the same code can be as below. All you need to do is give two types of args, ack and lazy, to a listener. By using this interface, you can separate the acknowledgment phase and long operation in a concise way.

def respond_to_slack_within_3_seconds(payload, ack):
    if payload.get("text", None) is None:
        ack(f":x: Usage: {command} (task name here)")
    else:
        task_name = payload["text"]
        ack(f"Accepted! (task: {task_name})")

def run_backend_operation(respond, payload):
    time.sleep(5) # Doing something meaningful takes time
    task_name = payload["text"]
    respond(f"Completed! (task: {task_name}, url: https://example.com/result)")

app.command(command)(
    ack=respond_to_slack_within_3_seconds,
    lazy=[run_backend_operation]
)

ack is responsible for returning an immediate HTTP response to Slack API servers within 3 seconds. By contrast, lazy functions are not supposed to return any response. They can do anything by leveraging all the listener args apart from ack() utility. Also, they are completely free from 3-second timeouts.

As the lazy is a list, you can set multiple lazy functions to a single listener. The lazy functions will be executed in parallel.

def acknowledge_anyway(ack):
    ack()

def open_modal(payload, client):
    api_response = client.views_open(trigger_id=payload["trigger_id"], view={ })

def start_background_job(payload):
    # do a long task
    pass

app.command("/data-request")(
    ack=acknowledge_anyway,
    lazy=[open_modal, start_background_job],
)

Mechanism

For complex apps, this mechanism can improve code readability. But the biggest benefit of this API is not readability. This mechanism provides better flexibility for choosing the runtime to execute asynchronous operations.

To support Lazy Listener Functions, the App/AsyncApp class should have a LazyListenerRunner internally. The component manages and runs lazy functions. Its start method kicks an async operation with the indication of recursive execution. The run method runs the lazy function with given payload and respond and say utilities.

class LazyListenerRunner:
    # Bolt runs this method when starting a new async operation
    # This method copies the payload and sets the lazy function's information to it.
    # The duplicated request has lazy_only: True and lazy_function_name: str.
    def start(self, lazy_function: Callable[..., None], request: BoltRequest) -> None:
        pass

    # This method can be executed in any of a different thread, Future, and a different runtime
    # In the case of AWS Lambda, this method is supposed to be invoked in a different Lambda container.
    def run(self, lazy_function: Callable[..., None], request: BoltRequest) -> None:
        pass

LazyListenerRunner submits a new async execution to its runtime. The runtime to use is fully customizable. It can be a different thread, asyncio's Future, different server/container, and whatever you prefer.

The out-of-the-box AWS Lambda adapter takes advantage of it.

class LambdaLazyListenerRunner(LazyListenerRunner):
    def __init__(self):
        self.lambda_client = boto3.client("lambda")

    def start(self, function: Callable[..., None], request: BoltRequest) -> None:
        event: dict = request.context["lambda_request"] # duplicated original request
        headers = event["headers"]
        headers["x-slack-bolt-lazy-only"] = "1" # set lazy execution's specific header
        headers["x-slack-bolt-lazy-function-name"] = request.lazy_function_name # the function to run later
        event["method"] = "NONE"
        invocation = self.lambda_client.invoke(
            FunctionName=request.context["aws_lambda_function_name"],
            InvocationType="Event",
            Payload=json.dumps(event),
        )

Runners can rely on either of request.lazy_only / request.lazy_function_name (when sharing memory) or two x-slack-bolt- headers in recursive requests (when submitting a new request - these are converted to request.lazy_only and request.lazy_fucntion_name inside Bolt).

  • "x-slack-bolt-lazy-only" header value -> request.lazy_only: bool
  • "x-slack-bolt-lazy-function-name" header value -> request.lazy_function_name: Optional[str]

When Bolt runs App#dispatch (or AsyncApp#async_dispatch) method recursively, request.lazy_only should be True and request.lazy_function_name should exist. In the case, ack function and other lazy functions will be skipped.

Next Steps

I've already implemented the initial version of this feature. I will come up with a WIP (Work In Progress) pull request and am happy to hear feedback from other maintainers and the communities!

Requirements (place an x in each of the [ ])

  • I've read and understood the Contributing guidelines and have done my best effort to follow them.
  • I've read and agree to the Code of Conduct.
  • I've searched for any related issues and avoided creating a duplicate issue here.
@seratch seratch added the enhancement New feature or request label Aug 7, 2020
@seratch seratch added this to the 0.3.0a0 milestone Aug 7, 2020
@seratch seratch self-assigned this Aug 7, 2020
seratch added a commit to seratch/bolt-python that referenced this issue Aug 7, 2020
seratch added a commit to seratch/bolt-python that referenced this issue Aug 17, 2020
seratch added a commit to seratch/bolt-python that referenced this issue Aug 26, 2020
seratch added a commit that referenced this issue Aug 26, 2020
* Implement Lazy Listener Functions #34

* Add unit tests

* Remove test.pypi.org from instructions

* Fix errors in Python 3.6

* Apply formtter to samples

* Fix type hints
@seratch seratch closed this as completed Aug 26, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

1 participant