Skip to content

RFC: Added ServiceLifecycle dependency and started integration #141

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

Closed
wants to merge 2 commits into from

Conversation

fabianfett
Copy link
Member

The current start and shutdown services is okay but not great. Thankfully ServiceLifecycle has been released two days ago. Let's see if we can use it for AWS Lambda Runtime.

This is a draft pr, exploring possibilities and starting a discussion.

Open ends/questions

  • In the first approach we only expose the ServiceLifecycle to the user but don't use it ourselves, even though we could. But for this we will need to think about state handling (current state of mind):
    • Currently lambda can be shutdown because of two reasons:
      • The user cancels the process (only possible for DEBUG builds)
      • The Lambda can not communicate correctly with the Control Plane API
    • We could handle the first shutdown case with the new ServiceLifecycle fairly easy.
  • Do we want to deprecate the shutdown methods? So that those can be removed before 1.0?

preconditionFailure("Failed to shutdown service: \(error)")
}

eventLoop.shutdownGracefully { error in
Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't that also be triggered by the lifecycle shutdown as a lifecycle task?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think thats a good idea: register the event loop in L110 then just call serviceLifecycle.shutdown

@@ -118,11 +121,19 @@ public enum Lambda {
#if DEBUG
signalSource.cancel()
#endif
eventLoop.shutdownGracefully { error in

serviceLifecycle.shutdown { (error) in
Copy link
Contributor

Choose a reason for hiding this comment

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

why (error)?

@@ -32,13 +33,17 @@ extension Lambda {
/// - note: The `EventLoop` is shared with the Lambda runtime engine and should be handled with extra care.
/// Most importantly the `EventLoop` must never be blocked.
public let eventLoop: EventLoop

/// `ServiceLifecycle` to register services with
public let serviceLifecycle: ServiceLifecycle
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe simplify name to "lifecycle"?

Copy link
Member Author

@fabianfett fabianfett Sep 1, 2020

Choose a reason for hiding this comment

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

Not sure if just lifecycle might add confusion because of the Lambda.Lifecycle. Your call.

@@ -22,6 +23,7 @@ extension Lambda {
/// - note: It is intended to be used within a single `EventLoop`. For this reason this class is not thread safe.
public final class Lifecycle {
private let eventLoop: EventLoop
private let serviceLifecycle: ServiceLifecycle
Copy link
Contributor

Choose a reason for hiding this comment

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

ditto on name

let promise = self.eventLoop.makePromise(of: ByteBufferLambdaHandler.self)
// after we have created the LambdaHandler we must now start the services.
// in order to not have to map once our success case returns the handler.
serviceLifecycle.start { (error) in
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if we should add sugar in the lifecycle NIO compact module to make this kind of integration easier. wdyt?

Copy link
Member Author

Choose a reason for hiding this comment

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

defer {
serviceLifecycle.shutdown()
serviceLifecycle.wait()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if we can now simplify the shutdown logic a bit by relying more on the users using lifecycle

defer {
serviceLifecycle.shutdown()
serviceLifecycle.wait()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess we can register the server with lifecycle but maybe will make the test harder to reason about

@tomerd
Copy link
Contributor

tomerd commented Jul 31, 2020

this is cool. added some ideas to discuss

@pokryfka pokryfka mentioned this pull request Aug 14, 2020
4 tasks
@fabianfett fabianfett changed the title Added ServiceLifecycle dependency and started integration RFC: Added ServiceLifecycle dependency and started integration Sep 2, 2020
@fabianfett
Copy link
Member Author

Hi @tomerd,

I've taken some time to think about the ServiceLifecycle some more. Now, I'm not sure if there is way to bring the two mental models of the lifecycle together:

  • Lambda Runtime follows a synchronous pattern of Service creation and start
  • ServiceLifecycle follows an async pattern of service registration, start and stop. Services can only be registered, before the ServiceLifecycle is started. Only those services will be shutdown that have been started.

Let's look at a more complex LambdaHandler. (Sorry that the example has gotten so big, but we need something complex here...). The static func create is our LambdaFactory that is called during startup. In this method we get some credentials from the AWS Parameter Store, that we use later to establish a connection to a database. This is an AWS recommended pattern and we use libraries common in the swift server realm. (AsyncHTTPClient, Soto, MySQLKit)

If we the credentials can't be obtained from the parameter store, we must fail the Lambda in startup and report an error. We do this by using EventLoopFutures. In the case of an error, while getting the credentials, the httpClient and the awsClient must be shutdown, since they already have internal state (an open http connection).

Right now I can't see how we can achieve this using ServiceLifecycle – I don't know when we should call start on the Lifecycle... There are three options:

  1. Call ServiceLifecycle.start before the create function. In this case we can't register any service to the Lifecycle.
  2. Call ServiceLifecycle.start after the successful create function. In this case we can't use the Lifecycle to shutdown services that have already been started (in this case httpClient and awsClient), since only Tasks that have been started will be stopped. If we can't get the parameters in this example neither httpClient nor awsClient could be stopped.
  3. Dirty hack, but only option that i can see right now to make it work: We call ServiceLifecycle.start after create – no matter the outcome of the LambdaFactory. If the factory has failed, we assume that developers have only used registerShutdown for their services and registered only services that are already started. Hence we could assume that all registered services have been started and have therefore an empty start handler in their Task. We could then shut them down immediately after their empty start. But in this case we would use the ServiceLifecycle in a very particular way (which probably wasn't intended at all).
import AsyncHTTPClient
import AWSLambdaEvents
import AWSLambdaRuntimeCore
import Dispatch
import Logging
import MySQLKit
import NIO
import SotoSSM

public class MyLambdaHandler: EventLoopLambdaHandler {
    public typealias In = SNS.Event
    public typealias Out = Void

    let httpClient: HTTPClient
    let database: EventLoopGroupConnectionPool<MySQLConnectionSource>

    public static func create(_ context: Lambda.InitializationContext) -> EventLoopFuture<ByteBufferLambdaHandler> {
        let eventLoop = context.eventLoop
        let httpClient = HTTPClient(eventLoopGroupProvider: .shared(eventLoop))
        // serviceLifecycle.registerShutdown(label: "httpClient") { (completion) in
        //   httpClient.shutdown(completion)
        // }
        
        let awsClient = AWSClient(httpClientProvider: .shared(httpClient))
        // serviceLifecycle.registerShutdown(label: "awsClient") { (completion) in
        //   awsClient.shutdown(completion)
        // }
        let client = SSM(client: awsClient, region: .useast1)

        return eventLoop.makeSucceededFuture(()).flatMapThrowing { (_) -> String in
            let stageName = try Lambda.env(for: "STAGE")

            return stageName
        }.flatMap { (stageName) -> EventLoopFuture<(SSM.GetParametersResult, String)> in
            let parameters = [
                "/\(stageName)/database/username",
                "/\(stageName)/database/password",
            ]

            return client.getParameters(.init(names: parameters)).map { ($0, stageName) }
        }.flatMapThrowing { parameters, stageName in
            let config = MySQLConfiguration(
                hostname: try Lambda.env(for: "DATABASE_HOST"),
                username: try parameters.value(for: "/\(stageName)/database/username"),
                password: try parameters.value(for: "/\(stageName)/database/password"),
                database: try Lambda.env(for: "DATABASE_NAME"),
                tlsConfiguration: nil
            )

            let pool = EventLoopGroupConnectionPool(
                source: MySQLConnectionSource(configuration: config),
                on: eventLoop
            )
            // serviceLifecycle.registerShutdown(label: "database-pool") { (completion) in
            //   pool.shutdown(completion)
            // }
            return pool
        }.flatMapThrowing { (pool) -> ByteBufferLambdaHandler in
            MyLambdaHandler(database: pool, httpClient: httpClient)
        }
    }

    init(database: EventLoopGroupConnectionPool<MySQLConnectionSource>, httpClient: HTTPClient) {
        self.database = database
        self.httpClient = httpClient
    }

    public func handle(context: Lambda.Context, event: SNS.Event) -> EventLoopFuture<Void> {
        // ... do something using the httpClient and the database
        preconditionFailure()
    }
}

extension SSM.GetParametersResult {
    func value(for name: String) throws -> String {
        guard let value = parameters?.first(where: { $0.name == name })?.value else {
            throw ReviewImporterLambdaHandler.Error.missingParameterStoreValue(name: name, invalidParameters: invalidParameters)
        }

        return value
    }
}

extension Lambda {
    static func env(for name: String) throws -> String {
        guard let env = Lambda.env(name) else {
            throw ReviewImporterLambdaHandler.Error.missingEnvironmentVariable(name)
        }
        return env
    }
}

Excited about your thoughts. CC: @ktoso @yim-lee

@tomerd
Copy link
Contributor

tomerd commented Oct 5, 2020

hi @fabianfett if you want to keep this around, could you please update this PR to be against the main branch so we can delete the old master branch

@fabianfett fabianfett force-pushed the ff-service-lifecycle branch from 35b1a3f to 9f976d7 Compare January 21, 2021 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants