├── test
├── dummy_app
│ ├── config
│ │ └── .keep
│ ├── tmp
│ │ ├── .keep
│ │ └── development_secret.txt
│ ├── app
│ │ ├── jobs
│ │ │ ├── application_job.rb
│ │ │ └── basic_job.rb
│ │ └── assets
│ │ │ └── config
│ │ │ └── manifest.js
│ └── init.rb
├── cases
│ ├── notifier_test.rb
│ ├── rails_test.rb
│ ├── rake_test.rb
│ ├── worker_test.rb
│ └── queue_test.rb
├── test_helper.rb
└── test_helpers
│ ├── jobs_helpers.rb
│ ├── stream_helpers.rb
│ └── event_helpers.rb
├── bin
├── test
├── console
└── setup
├── .devcontainer
├── Dockerfile
├── postCreate.sh
└── devcontainer.json
├── lib
├── lambda_punch
│ ├── version.rb
│ ├── error.rb
│ ├── extensions
│ │ └── lambdapunch
│ ├── railtie.rb
│ ├── tasks
│ │ └── install.rake
│ ├── rails
│ │ └── active_job.rb
│ ├── server.rb
│ ├── logger.rb
│ ├── queue.rb
│ ├── notifier.rb
│ ├── api.rb
│ └── worker.rb
└── lambda_punch.rb
├── Gemfile
├── images
├── Invoke-Phase.png
├── Invoke-Phase.pxm
├── LambdaPunch.png
└── LambdaPunch.sketch
├── .gitignore
├── Rakefile
├── exe
└── lambda_punch
├── .github
└── workflows
│ └── test.yml
├── LICENSE.txt
├── lambda_punch.gemspec
├── CHANGELOG.md
├── Gemfile.lock
├── CODE_OF_CONDUCT.md
└── README.md
/test/dummy_app/config/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy_app/tmp/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/bin/test:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | bundle exec rake test
5 |
--------------------------------------------------------------------------------
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mcr.microsoft.com/devcontainers/ruby:3.1
2 |
--------------------------------------------------------------------------------
/lib/lambda_punch/version.rb:
--------------------------------------------------------------------------------
1 | module LambdaPunch
2 | VERSION = "1.1.4"
3 | end
4 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 | gemspec
3 |
4 | gem "rake"
5 | gem "minitest"
6 |
--------------------------------------------------------------------------------
/test/dummy_app/app/jobs/application_job.rb:
--------------------------------------------------------------------------------
1 | class ApplicationJob < ActiveJob::Base
2 | end
3 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | docker-compose run \
5 | lambdapunch \
6 | bundle console
7 |
--------------------------------------------------------------------------------
/images/Invoke-Phase.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rails-lambda/lambda_punch/HEAD/images/Invoke-Phase.png
--------------------------------------------------------------------------------
/images/Invoke-Phase.pxm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rails-lambda/lambda_punch/HEAD/images/Invoke-Phase.pxm
--------------------------------------------------------------------------------
/images/LambdaPunch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rails-lambda/lambda_punch/HEAD/images/LambdaPunch.png
--------------------------------------------------------------------------------
/images/LambdaPunch.sketch:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rails-lambda/lambda_punch/HEAD/images/LambdaPunch.sketch
--------------------------------------------------------------------------------
/.devcontainer/postCreate.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | sudo rm -rf /opt
5 | sudo mkdir /opt
6 | sudo chown -R $USER /opt
7 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | echo '== Installing dependencies =='
5 | bundle config set --local path 'vendor/bundle'
6 | bundle install
7 |
--------------------------------------------------------------------------------
/lib/lambda_punch/error.rb:
--------------------------------------------------------------------------------
1 | module LambdaPunch
2 | class Error < StandardError
3 | end
4 |
5 | class EventTypeError < Error
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/*
2 | /vendor/bundle
3 | /.yardoc
4 | /_yardoc/
5 | /coverage/
6 | /doc/
7 | /pkg/
8 | /spec/reports/
9 | /tmp/
10 | Gemfile.lock
11 |
--------------------------------------------------------------------------------
/test/dummy_app/tmp/development_secret.txt:
--------------------------------------------------------------------------------
1 | 6c6998a8ef4600de60b8c9f42f9a87a5d71f12dd4e689a6738bc3e5bac9962b0e63e81f8052b320c1959f2346edff5ea07ba0907af7ac07b6fc6034713d66648
--------------------------------------------------------------------------------
/test/dummy_app/app/jobs/basic_job.rb:
--------------------------------------------------------------------------------
1 | class BasicJob < ApplicationJob
2 | def perform(object)
3 | TestHelpers::PerformBuffer.add "BasicJob with: #{object.inspect}"
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/gem_tasks"
2 | require "rake/testtask"
3 |
4 | Rake::TestTask.new(:test) do |t|
5 | t.libs << "test"
6 | t.libs << "lib"
7 | t.test_files = FileList["test/cases/*_test.rb"]
8 | end
9 |
10 | task default: :test
11 |
--------------------------------------------------------------------------------
/test/cases/notifier_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class NotifierTest < LambdaPunchSpec
4 |
5 | it 'has an accessor for the temp file' do
6 | expect(LambdaPunch.tmp_file).must_equal '/tmp/lambdapunch-handled'
7 | end
8 |
9 | end
10 |
--------------------------------------------------------------------------------
/test/dummy_app/app/assets/config/manifest.js:
--------------------------------------------------------------------------------
1 | require "bundler/gem_tasks"
2 | require "rake/testtask"
3 |
4 | Rake::TestTask.new(:test) do |t|
5 | t.libs << "test"
6 | t.libs << "lib"
7 | t.test_files = FileList["test/cases/*_test.rb"]
8 | end
9 |
10 | task default: :test
11 |
--------------------------------------------------------------------------------
/lib/lambda_punch/extensions/lambdapunch:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | ENV['BUNDLE_GEMFILE'] = "#{ENV['LAMBDA_TASK_ROOT']}/Gemfile"
4 | require 'bundler/setup'
5 | require 'lambda_punch'
6 |
7 | LambdaPunch.register!
8 | LambdaPunch.start_worker!
9 |
10 | while true do
11 | LambdaPunch.loop
12 | end
13 |
--------------------------------------------------------------------------------
/exe/lambda_punch:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require 'rubygems'
4 | require 'rake'
5 |
6 | spec = Gem::Specification.find_by_name 'lambda_punch'
7 | load "#{spec.gem_dir}/lib/lambda_punch/tasks/install.rake"
8 |
9 | command = ARGV[0] || 'install'
10 |
11 | Rake::Task["lambda_punch:#{command}"].invoke
12 |
--------------------------------------------------------------------------------
/lib/lambda_punch/railtie.rb:
--------------------------------------------------------------------------------
1 | require 'rails'
2 | require 'rails/engine'
3 | require 'active_job'
4 |
5 | module LambdaPunch
6 | class Railtie < Rails::Railtie
7 | railtie_name :lambda_punch
8 |
9 | rake_tasks do
10 | load "lambda_punch/tasks/install.rake"
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 | on: [push]
3 | jobs:
4 | test:
5 | runs-on: ubuntu-20.04
6 | steps:
7 | - name: Checkout
8 | uses: actions/checkout@v3
9 | - name: Setup & Test
10 | uses: devcontainers/ci@v0.3
11 | with:
12 | env: |
13 | CI
14 | runCmd: |
15 | ./bin/setup
16 | ./bin/test
17 |
--------------------------------------------------------------------------------
/lib/lambda_punch/tasks/install.rake:
--------------------------------------------------------------------------------
1 | require 'rake'
2 |
3 | namespace :lambda_punch do
4 |
5 | desc "Install the LambdaPunch Lambda Extension."
6 | task :install do
7 | require 'fileutils'
8 | FileUtils.mkdir_p '/opt/extensions'
9 | extension = File.expand_path "#{__dir__}/../extensions/lambdapunch"
10 | FileUtils.cp extension, '/opt/extensions/'
11 | end
12 |
13 | end
14 |
--------------------------------------------------------------------------------
/lib/lambda_punch/rails/active_job.rb:
--------------------------------------------------------------------------------
1 | module ActiveJob
2 | module QueueAdapters
3 | class LambdaPunchAdapter
4 |
5 | def enqueue(job, options = {})
6 | job_data = job.serialize
7 | LambdaPunch.push { ActiveJob::Base.execute(job_data) }
8 | end
9 |
10 | def enqueue_at(job, timestamp)
11 | enqueue(job)
12 | end
13 |
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/cases/rails_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class RailsTest < LambdaPunchSpec
4 |
5 | it 'works with active job' do
6 | expect(lambda_punch_jobs.length).must_equal 0
7 | BasicJob.perform_later(42)
8 | expect(lambda_punch_jobs.length).must_equal 1
9 | LambdaPunch::Queue.new.call
10 | expect(perform_buffer_last_value).must_equal "BasicJob with: 42"
11 | end
12 |
13 | end
14 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lambda_punch",
3 | "build": {
4 | "dockerfile": "Dockerfile"
5 | },
6 | "features": {
7 | "ghcr.io/devcontainers/features/sshd:latest": {}
8 | },
9 | "customizations": {
10 | "vscode": {
11 | "settings": {
12 | "editor.formatOnSave": true
13 | },
14 | "extensions": []
15 | }
16 | },
17 | "postCreateCommand": ".devcontainer/postCreate.sh",
18 | "remoteUser": "vscode"
19 | }
20 |
--------------------------------------------------------------------------------
/lib/lambda_punch/server.rb:
--------------------------------------------------------------------------------
1 | module LambdaPunch
2 | class Server
3 |
4 | include Singleton
5 |
6 | class << self
7 |
8 | def uri
9 | 'druby://127.0.0.1:9030'
10 | end
11 |
12 | def start!
13 | require 'concurrent'
14 | LambdaPunch.logger.info "Server.start!..."
15 | instance
16 | end
17 |
18 | end
19 |
20 | def initialize
21 | @queue = Queue.new
22 | DRb.start_service self.class.uri, @queue
23 | rescue Errno::EADDRINUSE
24 | end
25 |
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/test/dummy_app/init.rb:
--------------------------------------------------------------------------------
1 | require 'rails'
2 | require 'active_model/railtie'
3 | require 'active_job/railtie'
4 | require 'rails/test_unit/railtie'
5 |
6 | module Dummy
7 | class Application < ::Rails::Application
8 | config.root = File.join __FILE__, '..'
9 | config.eager_load = true
10 | logger = ActiveSupport::Logger.new(StringIO.new)
11 | logger.formatter = ActiveSupport::Logger::SimpleFormatter.new
12 | config.logger = logger
13 | config.active_job.queue_adapter = :lambda_punch
14 | end
15 | end
16 |
17 | Dummy::Application.initialize!
18 |
--------------------------------------------------------------------------------
/lib/lambda_punch/logger.rb:
--------------------------------------------------------------------------------
1 | module LambdaPunch
2 | class Logger
3 |
4 | attr_reader :level
5 |
6 | def initialize
7 | @level = (ENV['LAMBDA_PUNCH_LOG_LEVEL'] || 'error').upcase.to_sym
8 | end
9 |
10 | def logger
11 | @logger ||= ::Logger.new(STDOUT).tap do |l|
12 | l.level = logger_level
13 | l.formatter = proc { |_s, _d, _p, m| "[LambdaPunch] #{m}\n" }
14 | end
15 | end
16 |
17 | private
18 |
19 | def logger_level
20 | ::Logger.const_defined?(@level) ? ::Logger.const_get(@level) : ::Logger::ERROR
21 | end
22 |
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/lambda_punch/queue.rb:
--------------------------------------------------------------------------------
1 | module LambdaPunch
2 | class Queue
3 |
4 | class << self
5 |
6 | def push(block)
7 | jobs << block
8 | end
9 |
10 | def jobs
11 | @jobs ||= Concurrent::Array.new
12 | end
13 |
14 | end
15 |
16 | def call
17 | jobs.each do |job|
18 | begin
19 | job.call
20 | rescue => e
21 | LambdaPunch.error_handler.call(e)
22 | end
23 | end
24 | true
25 | ensure
26 | jobs.clear
27 | end
28 |
29 | private
30 |
31 | def jobs
32 | self.class.jobs
33 | end
34 |
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/test/cases/rake_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 | load 'lambda_punch/tasks/install.rake'
3 |
4 | class RakeTest < LambdaPunchSpec
5 | before do
6 | FileUtils.rm_rf '/opt/extensions'
7 | end
8 |
9 | it 'installs extension' do
10 | refute File.exist?('/opt/extensions/lambdapunch')
11 | Rake::Task['lambda_punch:install'].execute
12 | assert File.exist?('/opt/extensions/lambdapunch')
13 | end
14 |
15 | it 'has an exec installer' do
16 | root = File.expand_path(File.join __FILE__, '..', '..', '..')
17 | exe = "#{root}/exe/lambda_punch"
18 | `#{exe} install`
19 | assert File.exist?('/opt/extensions/lambdapunch')
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | ENV['RAILS_ENV'] = 'test'
2 | require 'rails'
3 | require 'lambda_punch'
4 | require 'pry'
5 | require 'minitest/autorun'
6 | require 'minitest/focus'
7 | require_relative './dummy_app/init'
8 | require_relative './test_helpers/stream_helpers'
9 | require_relative './test_helpers/event_helpers'
10 | require_relative './test_helpers/jobs_helpers'
11 |
12 | LambdaPunch.start_server!
13 | LambdaPunch.start_worker!
14 |
15 | class LambdaPunchSpec < Minitest::Spec
16 |
17 | include TestHelpers::StreamHelpers,
18 | TestHelpers::EventHelpers,
19 | TestHelpers::JobsHelpers
20 |
21 | before do
22 | clear_lambda_punch_queue!
23 | perform_buffer_clear!
24 | end
25 |
26 | end
27 |
--------------------------------------------------------------------------------
/test/test_helpers/jobs_helpers.rb:
--------------------------------------------------------------------------------
1 | module TestHelpers
2 | module PerformBuffer
3 | def clear
4 | values.clear
5 | end
6 |
7 | def add(value)
8 | values << value
9 | end
10 |
11 | def values
12 | @values ||= []
13 | end
14 |
15 | def last_value
16 | values.last
17 | end
18 |
19 | extend self
20 | end
21 | module JobsHelpers
22 | private
23 |
24 | def lambda_punch_jobs
25 | LambdaPunch::Queue.jobs
26 | end
27 |
28 | def clear_lambda_punch_queue!
29 | lambda_punch_jobs.clear
30 | end
31 |
32 | def perform_buffer_clear!
33 | PerformBuffer.clear
34 | end
35 |
36 | def perform_buffer_last_value
37 | PerformBuffer.last_value
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/test/cases/worker_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class WorkerTest < LambdaPunchSpec
4 |
5 | it 'timeout can be 0 or negative and run instantly' do
6 | @expected = false
7 | event = invoke_event deadline_ms_from_now: -1000
8 | LambdaPunch.push { @expected = true }
9 | out = capture(:stdout) { LambdaPunch::Worker.call(event) }
10 | expect(@expected).must_equal true
11 | expect(out).wont_include 'timeout reached'
12 | end
13 |
14 | it 'will not timeout when file notifier handles the request early' do
15 | @expected = false
16 | event = invoke_event deadline_ms_from_now: 3000
17 | LambdaPunch.push { @expected = true }
18 | LambdaPunch.handled!(context)
19 | out = capture(:stdout) { LambdaPunch::Worker.call(event) }
20 | expect(@expected).must_equal true
21 | expect(out).wont_include 'timeout reached'
22 | end
23 |
24 | end
25 |
--------------------------------------------------------------------------------
/test/test_helpers/stream_helpers.rb:
--------------------------------------------------------------------------------
1 | module TestHelpers
2 | module StreamHelpers
3 |
4 | private
5 |
6 | def silence_stream(stream)
7 | old_stream = stream.dup
8 | stream.reopen(IO::NULL)
9 | stream.sync = true
10 | yield
11 | ensure
12 | stream.reopen(old_stream)
13 | old_stream.close
14 | end
15 |
16 | def quietly
17 | silence_stream(STDOUT) do
18 | silence_stream(STDERR) do
19 | yield
20 | end
21 | end
22 | end
23 |
24 | def capture(stream)
25 | stream = stream.to_s
26 | captured_stream = Tempfile.new(stream)
27 | stream_io = eval("$#{stream}")
28 | origin_stream = stream_io.dup
29 | stream_io.reopen(captured_stream)
30 | yield
31 | stream_io.rewind
32 | return captured_stream.read
33 | ensure
34 | captured_stream.close
35 | captured_stream.unlink
36 | stream_io.reopen(origin_stream)
37 | end
38 |
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/lib/lambda_punch/notifier.rb:
--------------------------------------------------------------------------------
1 | module LambdaPunch
2 | class Notifier
3 |
4 | FILE = "#{Dir.tmpdir}/lambdapunch-handled"
5 |
6 | class << self
7 |
8 | def handled!(context)
9 | File.open(FILE, 'w') do |f|
10 | f.write context.aws_request_id
11 | end
12 | end
13 |
14 | def request_id
15 | File.read(FILE)
16 | end
17 |
18 | def tmp_file
19 | FILE
20 | end
21 |
22 | end
23 |
24 | def initialize
25 | @notifier = INotify::Notifier.new
26 | File.open(FILE, 'w') { |f| f.write('') } unless File.exist?(FILE)
27 | end
28 |
29 | def watch
30 | @notifier.watch(FILE, :modify, :oneshot) { yield(request_id) }
31 | end
32 |
33 | def process
34 | @notifier.process
35 | end
36 |
37 | def close
38 | logger.debug "Notifier#close"
39 | @notifier.close rescue true
40 | end
41 |
42 | def request_id
43 | self.class.request_id
44 | end
45 |
46 | private
47 |
48 | def logger
49 | LambdaPunch.logger
50 | end
51 |
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2021 Ken Collins
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/test/test_helpers/event_helpers.rb:
--------------------------------------------------------------------------------
1 | require 'securerandom'
2 |
3 | module TestHelpers
4 | module EventHelpers
5 |
6 | private
7 |
8 | def context
9 | @context ||= LambdaContext.new
10 | end
11 |
12 | def invoke_event(deadline_ms_from_now: 2000)
13 | deadline_ms = (Time.now.to_f * 1000).to_i + deadline_ms_from_now
14 | {
15 | "eventType" => "INVOKE",
16 | "deadlineMs" => deadline_ms,
17 | "requestId" => context.aws_request_id,
18 | "invokedFunctionArn" => "arn:aws:lambda:us-east-1:012345678901:function:lambdapunch:live",
19 | "tracing" => {
20 | "type" => "X-Amzn-Trace-Id",
21 | "value" => "Root=1-60d26025-01eef3a30f72afd16dfb2982;Parent=7905e3756d42aff7;Sampled=0"
22 | }
23 | }
24 | end
25 |
26 | end
27 |
28 | class LambdaContext
29 |
30 | def aws_request_id
31 | @aws_request_id ||= SecureRandom.uuid
32 | end
33 |
34 | def invoked_function_arn
35 | 'arn:aws:lambda:us-east-1:012345678901:function:lambdapunch:live'
36 | end
37 |
38 | def log_stream_name
39 | '2020/07/05[$LATEST]88b3605521bf4d7abfaa7bfa6dcd45f1'
40 | end
41 |
42 | def function_name
43 | 'lambdapunch'
44 | end
45 |
46 | def memory_limit_in_mb
47 | '512'
48 | end
49 |
50 | def function_version
51 | '$LATEST'
52 | end
53 |
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/lambda_punch.rb:
--------------------------------------------------------------------------------
1 | require 'uri'
2 | require 'drb'
3 | require 'json'
4 | require 'tmpdir'
5 | require 'logger'
6 | require 'net/http'
7 | require 'singleton'
8 | require 'lambda_punch/api'
9 | require 'lambda_punch/error'
10 | require 'lambda_punch/logger'
11 | require 'lambda_punch/queue'
12 | require 'lambda_punch/server'
13 | require 'lambda_punch/worker'
14 | require 'lambda_punch/version'
15 | require 'lambda_punch/notifier'
16 | if defined?(Rails)
17 | require 'lambda_punch/railtie'
18 | require 'lambda_punch/rails/active_job'
19 | end
20 |
21 | module LambdaPunch
22 |
23 | def push(&block)
24 | Queue.push(block)
25 | end
26 |
27 | def register!
28 | Api.register!
29 | end
30 |
31 | def loop
32 | Api.loop
33 | end
34 |
35 | def start_server!
36 | Server.start!
37 | end
38 |
39 | def start_worker!
40 | Worker.start!
41 | end
42 |
43 | def logger
44 | @logger ||= Logger.new.logger
45 | end
46 |
47 | def handled!(context)
48 | Notifier.handled!(context)
49 | end
50 |
51 | def error_handler
52 | @error_handler ||= lambda do |e|
53 | logger.error "Queue#call::error => #{e.message}"
54 | logger.error e.backtrace[0..10].join("\n")
55 | end
56 | end
57 |
58 | def error_handler=(func)
59 | @error_handler = func
60 | end
61 |
62 | def tmp_file
63 | Notifier.tmp_file
64 | end
65 |
66 | extend self
67 |
68 | end
69 |
--------------------------------------------------------------------------------
/lambda_punch.gemspec:
--------------------------------------------------------------------------------
1 | require_relative "lib/lambda_punch/version"
2 |
3 | Gem::Specification.new do |spec|
4 | spec.name = "lambda_punch"
5 | spec.version = LambdaPunch::VERSION
6 | spec.authors = ["Ken Collins"]
7 | spec.email = ["ken@metaskills.net"]
8 | spec.summary = "LambdaPunch: Async Processing using Lambda Extensions"
9 | spec.description = "LambdaPunch: Async Processing using Lambda Extensions"
10 | spec.homepage = "https://github.com/rails-lambda/lambda_punch"
11 | spec.license = "MIT"
12 | spec.metadata["homepage_uri"] = spec.homepage
13 | spec.metadata["source_code_uri"] = "https://github.com/rails-lambda/lambda_punch"
14 | spec.metadata["changelog_uri"] = "https://github.com/rails-lambda/lambda_punch/blob/main/CHANGELOG.md"
15 | spec.files = Dir.chdir(File.expand_path(__dir__)) do
16 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features|images)/}) }
17 | end
18 | spec.bindir = "exe"
19 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
20 | spec.require_paths = ["lib"]
21 | spec.add_dependency "concurrent-ruby"
22 | spec.add_dependency "rake"
23 | spec.add_dependency "rb-inotify"
24 | spec.add_dependency "timeout"
25 | spec.add_development_dependency "minitest-focus"
26 | spec.add_development_dependency "pry"
27 | spec.add_development_dependency "rails"
28 | end
29 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## [1.1.4] - 2024-06-09
2 |
3 | ### Changed
4 |
5 | - The `LambdaPunch.start_server!` to now rescue `Errno::EADDRINUSE` errors.
6 |
7 | ## [1.1.3] - 2023-04-22
8 |
9 | ### Added
10 |
11 | - Test for `error_handler` stack trace.
12 |
13 | ## [1.1.2] - 2023-04-22
14 |
15 | ### Added
16 |
17 | - The `error_handler` will now print the first 10 lines of a stack trace.
18 |
19 | ## [1.1.1] - 2023-04-16
20 |
21 | ### Fixed
22 |
23 | - The `lambda_punch` install file.
24 |
25 | ## [1.1.0] - 2023-04-16
26 |
27 | ### Added
28 |
29 | - New `lambda_punch` install interface.
30 |
31 | ## [1.0.3] - 2021-10-27
32 |
33 | ### Added
34 |
35 | - Any Timeout Gem. Tempfile Location Interface
36 |
37 | ### Changed
38 |
39 | - Remove timeout version lock.
40 |
41 | ## [1.0.2] - 2021-07-24
42 |
43 | ### Changed
44 |
45 | - Lazy initialization of Notifier's temp file.
46 |
47 | ## [1.0.0] - 2021-07-05
48 |
49 | ### Added
50 |
51 | - Rails ActiveJob support.
52 |
53 | ## [0.0.8] - 2021-06-28
54 |
55 | - Guard against early notifier.
56 |
57 | ## [0.0.7] - 2021-06-28
58 |
59 | - Fix queue logger.
60 |
61 | ## [0.0.6] - 2021-06-28
62 |
63 | - Ensure Queue is always callable.
64 | - Change default log level to `error`.
65 |
66 | ## [0.0.5] - 2021-06-28
67 |
68 | - Ensure Queue is always present.
69 |
70 | ## [0.0.4] - 2021-06-28
71 |
72 | - Fix install rake bug. Non-Rails rake doc.
73 |
74 | ## [0.0.3] - 2021-06-28
75 |
76 | - Add rake runtime dep.
77 |
78 | ## [0.0.2] - 2021-06-28
79 |
80 | - Alpha release.
81 |
82 | ## [0.0.1] - 2021-06-23
83 |
84 | - Project name hold.
85 |
--------------------------------------------------------------------------------
/test/cases/queue_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class QueueTest < LambdaPunchSpec
4 |
5 | before do
6 | LambdaPunch.error_handler = nil
7 | end
8 |
9 | it 'must push jobs via blocks to the queue' do
10 | expect(lambda_punch_jobs.length).must_equal 0
11 | LambdaPunch.push { }
12 | expect(lambda_punch_jobs.length).must_equal 1
13 | end
14 |
15 | it 'must call all jobs and clear queue' do
16 | @expected = false
17 | LambdaPunch.push { @expected = true }
18 | LambdaPunch::Queue.new.call
19 | expect(lambda_punch_jobs.length).must_equal 0
20 | expect(@expected).must_equal true
21 | end
22 |
23 | it 'must be able to call the queue with an invoke event payload using a timeout' do
24 | @expected = false
25 | event = invoke_event deadline_ms_from_now: 1000
26 | LambdaPunch.push { @expected = true }
27 | out = capture(:stdout) { LambdaPunch::Worker.call(event) }
28 | expect(@expected).must_equal true
29 | expect(lambda_punch_jobs.length).must_equal 0
30 | expect(out).must_include 'timeout reached'
31 | end
32 |
33 | it 'will log errors' do
34 | LambdaPunch.push { raise('hell') }
35 | out = capture(:stdout) { LambdaPunch::Queue.new.call }
36 | expect(out).must_include 'hell'
37 | end
38 |
39 | it 'will log error backtraces' do
40 | LambdaPunch.push do
41 | hash_with_default_error = HashWithIndifferentAccess.new { |h| raise 'stack' }
42 | hash_with_default_error['boom']
43 | end
44 | out = capture(:stdout) { LambdaPunch::Queue.new.call }
45 | expect(out).must_include 'lambda_punch/test/cases/queue_test.rb'
46 | expect(out).must_include 'gems/activesupport'
47 | end
48 |
49 | it 'will allow a custom error handler to be used' do
50 | LambdaPunch.error_handler = lambda { |e| puts("test-#{e.class.name}") }
51 | LambdaPunch.push { raise('hell') }
52 | out = capture(:stdout) { LambdaPunch::Queue.new.call }
53 | expect(out).must_include 'test-RuntimeError'
54 | end
55 |
56 | end
57 |
--------------------------------------------------------------------------------
/lib/lambda_punch/api.rb:
--------------------------------------------------------------------------------
1 | module LambdaPunch
2 | # Interface to Lambda's Extensions API using simple `Net::HTTP` calls.
3 | #
4 | # Lambda Extensions API
5 | # https://docs.aws.amazon.com/lambda/latest/dg/runtimes-extensions-api.html
6 | #
7 | class Api
8 |
9 | EXTENSION_NAME = 'lambdapunch'
10 |
11 | include Singleton
12 |
13 | class << self
14 |
15 | def register!
16 | instance.register!
17 | end
18 |
19 | def loop
20 | instance.loop
21 | end
22 |
23 | end
24 |
25 | def register!
26 | return if @registered
27 | uri = URI.parse "#{base_uri}/register"
28 | http = Net::HTTP.new uri.host, uri.port
29 | request = Net::HTTP::Post.new uri.request_uri
30 | request['Content-Type'] = 'application/vnd.aws.lambda.extension+json'
31 | request['Lambda-Extension-Name'] = EXTENSION_NAME
32 | request.body = %q|{"events":["INVOKE","SHUTDOWN"]}|
33 | http.request(request).tap do |r|
34 | logger.debug "Api#register! => #{r.class.name.inspect}, body: #{r.body}"
35 | @registered = true
36 | @extension_id = r.each_header.to_h['lambda-extension-identifier']
37 | logger.debug "Api::ExtensionId => #{@extension_id}"
38 | end
39 | end
40 |
41 | def loop
42 | resp = event_next
43 | event_payload = JSON.parse(resp.body)
44 | case event_payload['eventType']
45 | when 'INVOKE' then invoke(event_payload)
46 | when 'SHUTDOWN' then shutdown
47 | else
48 | event_type_error(event_payload)
49 | end
50 | end
51 |
52 | private
53 |
54 | def event_next
55 | uri = URI.parse "#{base_uri}/event/next"
56 | http = Net::HTTP.new uri.host, uri.port
57 | request = Net::HTTP::Get.new uri.request_uri
58 | request['Content-Type'] = 'application/vnd.aws.lambda.extension+json'
59 | request['Lambda-Extension-Identifier'] = @extension_id
60 | http.request(request).tap do |r|
61 | logger.debug "Api#event_next => #{r.class.name.inspect}, body: #{r.body}"
62 | end
63 | end
64 |
65 | def invoke(event_payload)
66 | logger.debug "Api#invoke => #{JSON.dump(event_payload)}" if logger.debug?
67 | Worker.call(event_payload)
68 | end
69 |
70 | def shutdown
71 | logger.info 'Api#shutdown...'
72 | DRb.stop_service rescue true
73 | exit
74 | end
75 |
76 | private
77 |
78 | def base_uri
79 | "http://#{ENV['AWS_LAMBDA_RUNTIME_API']}/2020-01-01/extension"
80 | end
81 |
82 | def logger
83 | LambdaPunch.logger
84 | end
85 |
86 | def event_type_error(event_payload)
87 | message = "Unknown event type: #{event_payload['eventType'].inspect}"
88 | logger.fatal(message)
89 | raise EventTypeError.new(message)
90 | end
91 |
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | lambda_punch (1.1.4)
5 | concurrent-ruby
6 | rake
7 | rb-inotify
8 | timeout
9 |
10 | GEM
11 | remote: https://rubygems.org/
12 | specs:
13 | actioncable (7.0.4.3)
14 | actionpack (= 7.0.4.3)
15 | activesupport (= 7.0.4.3)
16 | nio4r (~> 2.0)
17 | websocket-driver (>= 0.6.1)
18 | actionmailbox (7.0.4.3)
19 | actionpack (= 7.0.4.3)
20 | activejob (= 7.0.4.3)
21 | activerecord (= 7.0.4.3)
22 | activestorage (= 7.0.4.3)
23 | activesupport (= 7.0.4.3)
24 | mail (>= 2.7.1)
25 | net-imap
26 | net-pop
27 | net-smtp
28 | actionmailer (7.0.4.3)
29 | actionpack (= 7.0.4.3)
30 | actionview (= 7.0.4.3)
31 | activejob (= 7.0.4.3)
32 | activesupport (= 7.0.4.3)
33 | mail (~> 2.5, >= 2.5.4)
34 | net-imap
35 | net-pop
36 | net-smtp
37 | rails-dom-testing (~> 2.0)
38 | actionpack (7.0.4.3)
39 | actionview (= 7.0.4.3)
40 | activesupport (= 7.0.4.3)
41 | rack (~> 2.0, >= 2.2.0)
42 | rack-test (>= 0.6.3)
43 | rails-dom-testing (~> 2.0)
44 | rails-html-sanitizer (~> 1.0, >= 1.2.0)
45 | actiontext (7.0.4.3)
46 | actionpack (= 7.0.4.3)
47 | activerecord (= 7.0.4.3)
48 | activestorage (= 7.0.4.3)
49 | activesupport (= 7.0.4.3)
50 | globalid (>= 0.6.0)
51 | nokogiri (>= 1.8.5)
52 | actionview (7.0.4.3)
53 | activesupport (= 7.0.4.3)
54 | builder (~> 3.1)
55 | erubi (~> 1.4)
56 | rails-dom-testing (~> 2.0)
57 | rails-html-sanitizer (~> 1.1, >= 1.2.0)
58 | activejob (7.0.4.3)
59 | activesupport (= 7.0.4.3)
60 | globalid (>= 0.3.6)
61 | activemodel (7.0.4.3)
62 | activesupport (= 7.0.4.3)
63 | activerecord (7.0.4.3)
64 | activemodel (= 7.0.4.3)
65 | activesupport (= 7.0.4.3)
66 | activestorage (7.0.4.3)
67 | actionpack (= 7.0.4.3)
68 | activejob (= 7.0.4.3)
69 | activerecord (= 7.0.4.3)
70 | activesupport (= 7.0.4.3)
71 | marcel (~> 1.0)
72 | mini_mime (>= 1.1.0)
73 | activesupport (7.0.4.3)
74 | concurrent-ruby (~> 1.0, >= 1.0.2)
75 | i18n (>= 1.6, < 2)
76 | minitest (>= 5.1)
77 | tzinfo (~> 2.0)
78 | builder (3.2.4)
79 | coderay (1.1.3)
80 | concurrent-ruby (1.2.2)
81 | crass (1.0.6)
82 | date (3.3.3)
83 | erubi (1.12.0)
84 | ffi (1.15.5)
85 | globalid (1.1.0)
86 | activesupport (>= 5.0)
87 | i18n (1.12.0)
88 | concurrent-ruby (~> 1.0)
89 | loofah (2.20.0)
90 | crass (~> 1.0.2)
91 | nokogiri (>= 1.5.9)
92 | mail (2.8.1)
93 | mini_mime (>= 0.1.1)
94 | net-imap
95 | net-pop
96 | net-smtp
97 | marcel (1.0.2)
98 | method_source (1.0.0)
99 | mini_mime (1.1.2)
100 | minitest (5.18.0)
101 | minitest-focus (1.3.1)
102 | minitest (>= 4, < 6)
103 | net-imap (0.3.4)
104 | date
105 | net-protocol
106 | net-pop (0.1.2)
107 | net-protocol
108 | net-protocol (0.2.1)
109 | timeout
110 | net-smtp (0.3.3)
111 | net-protocol
112 | nio4r (2.5.9)
113 | nokogiri (1.14.3-aarch64-linux)
114 | racc (~> 1.4)
115 | nokogiri (1.14.3-arm64-darwin)
116 | racc (~> 1.4)
117 | pry (0.14.2)
118 | coderay (~> 1.1)
119 | method_source (~> 1.0)
120 | racc (1.6.2)
121 | rack (2.2.6.4)
122 | rack-test (2.1.0)
123 | rack (>= 1.3)
124 | rails (7.0.4.3)
125 | actioncable (= 7.0.4.3)
126 | actionmailbox (= 7.0.4.3)
127 | actionmailer (= 7.0.4.3)
128 | actionpack (= 7.0.4.3)
129 | actiontext (= 7.0.4.3)
130 | actionview (= 7.0.4.3)
131 | activejob (= 7.0.4.3)
132 | activemodel (= 7.0.4.3)
133 | activerecord (= 7.0.4.3)
134 | activestorage (= 7.0.4.3)
135 | activesupport (= 7.0.4.3)
136 | bundler (>= 1.15.0)
137 | railties (= 7.0.4.3)
138 | rails-dom-testing (2.0.3)
139 | activesupport (>= 4.2.0)
140 | nokogiri (>= 1.6)
141 | rails-html-sanitizer (1.5.0)
142 | loofah (~> 2.19, >= 2.19.1)
143 | railties (7.0.4.3)
144 | actionpack (= 7.0.4.3)
145 | activesupport (= 7.0.4.3)
146 | method_source
147 | rake (>= 12.2)
148 | thor (~> 1.0)
149 | zeitwerk (~> 2.5)
150 | rake (13.0.6)
151 | rb-inotify (0.10.1)
152 | ffi (~> 1.0)
153 | thor (1.2.1)
154 | timeout (0.3.2)
155 | tzinfo (2.0.6)
156 | concurrent-ruby (~> 1.0)
157 | websocket-driver (0.7.5)
158 | websocket-extensions (>= 0.1.0)
159 | websocket-extensions (0.1.5)
160 | zeitwerk (2.6.7)
161 |
162 | PLATFORMS
163 | aarch64-linux
164 | arm64-darwin-22
165 |
166 | DEPENDENCIES
167 | lambda_punch!
168 | minitest
169 | minitest-focus
170 | pry
171 | rails
172 | rake
173 |
174 | BUNDLED WITH
175 | 2.2.3
176 |
--------------------------------------------------------------------------------
/lib/lambda_punch/worker.rb:
--------------------------------------------------------------------------------
1 | module LambdaPunch
2 | # This `LambdaPunch::Worker` has a few responsibilities:
3 | #
4 | # 1. Maintain a class level DRb reference to your function's `LambdaPunch::Queue` object.
5 | # 2. Process extension `INVOKE` events by waiting for your function to complete.
6 | # 3. Triggering your application to perform work after each request.
7 | #
8 | class Worker
9 |
10 | class << self
11 |
12 | # Method to lazily require rb-inotify and start the DRb service.
13 | #
14 | def start!
15 | LambdaPunch.logger.info "Worker.start!..."
16 | require 'timeout'
17 | require 'rb-inotify'
18 | DRb.start_service
19 | new_drb_queue
20 | end
21 |
22 | # Creates a new instance of this object with the event payload from the `LambdaPunch::Api#invoke`
23 | # method and immediately performs the `call` method which waits for the function's handler to complete.
24 | #
25 | def call(event_payload)
26 | new(event_payload).call
27 | end
28 |
29 | # A safe and resilient way to call the remote queue.
30 | #
31 | def call_queue
32 | queue.call
33 | rescue DRb::DRbConnError
34 | LambdaPunch.logger.error "Worker#call_queue => DRb::DRbConnError"
35 | new_drb_queue
36 | queue.call
37 | end
38 |
39 | private
40 |
41 | # The `@queue` object is the local process' reference to the application `LambdaPunch::Queue`
42 | # instance which does all the work in the applciation's scope.
43 | #
44 | def queue
45 | @queue
46 | end
47 |
48 | def new_drb_queue
49 | @queue = DRbObject.new_with_uri(Server.uri)
50 | end
51 |
52 | end
53 |
54 | def initialize(event_payload)
55 | @invoked = false
56 | @event_payload = event_payload
57 | @notifier = Notifier.new
58 | @notifier.watch { |request_id| notified(request_id) }
59 | @request_id_notifier = nil
60 | end
61 |
62 | # Here we wait for the application's handler to signal it is done via the `LambdaPunch::Notifier` or if the
63 | # function has timed out. In either event there may be work to perform in the `LambdaPunch::Queue`. This method
64 | # also ensures any clean up is done. For example, closing file notifications.
65 | #
66 | def call
67 | timeout { @notifier.process unless invoked? }
68 | rescue Timeout::Error
69 | logger.error "Worker#call => Function timeout reached."
70 | ensure
71 | @notifier.close
72 | self.class.call_queue
73 | end
74 |
75 | private
76 |
77 | # The Notifier's watch handler would set this instance variable to `true`. We also return `true`
78 | # if the extension's invoke palyload event has a `requestId` matching what the handler has written
79 | # to the `LambdaPunch::Notifier` file location. See also `request_ids_match?` method. Lastly if
80 | # the timeout
81 | #
82 | def invoked?
83 | @invoked || request_ids_match? || timed_out?
84 | end
85 |
86 | # The unique AWS reqeust id that both the extension and handler receive for each invoke. This one
87 | # represents the extension's side.
88 | #
89 | def request_id_payload
90 | @event_payload['requestId']
91 | end
92 |
93 | # Set via the `LambdaPunch::Notifier` watch event from the your function's handler.
94 | #
95 | def request_id_notifier
96 | @request_id_notifier
97 | end
98 |
99 | # Check if notified via inotify or in some rare case the function's handler has already completed
100 | # and written the matching request id via the context object to the `LambdaPunch::Notifier` file.
101 | #
102 | def request_ids_match?
103 | request_id_payload == (request_id_notifier || Notifier.request_id)
104 | end
105 |
106 | # A safe timeout method which accounts for a 0 or negative timeout value.
107 | #
108 | def timeout
109 | @timeout = timeout_seconds
110 | if timed_out?
111 | yield
112 | else
113 | Timeout.timeout(@timeout) { yield }
114 | end
115 | end
116 |
117 | # Helps guard for deadline milliseconds in the past.
118 | #
119 | def timed_out?
120 | @timeout == 0 || @timeout < 0
121 | end
122 |
123 | # The function's timeout in seconds using the `INVOKE` event payload's `deadlineMs` value.
124 | #
125 | def timeout_seconds
126 | deadline_milliseconds = @event_payload['deadlineMs']
127 | deadline = Time.at(deadline_milliseconds / 1000.0)
128 | deadline - Time.now
129 | end
130 |
131 | # Our `LambdaPunch::Notifier` instance callback.
132 | #
133 | def notified(request_id)
134 | @invoked = true
135 | @request_id_notifier = request_id
136 | end
137 |
138 | def logger
139 | LambdaPunch.logger
140 | end
141 |
142 | def noop ; end
143 |
144 | end
145 | end
146 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8 |
9 | ## Our Standards
10 |
11 | Examples of behavior that contributes to a positive environment for our community include:
12 |
13 | * Demonstrating empathy and kindness toward other people
14 | * Being respectful of differing opinions, viewpoints, and experiences
15 | * Giving and gracefully accepting constructive feedback
16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
17 | * Focusing on what is best not just for us as individuals, but for the overall community
18 |
19 | Examples of unacceptable behavior include:
20 |
21 | * The use of sexualized language or imagery, and sexual attention or
22 | advances of any kind
23 | * Trolling, insulting or derogatory comments, and personal or political attacks
24 | * Public or private harassment
25 | * Publishing others' private information, such as a physical or email
26 | address, without their explicit permission
27 | * Other conduct which could reasonably be considered inappropriate in a
28 | professional setting
29 |
30 | ## Enforcement Responsibilities
31 |
32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
33 |
34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
35 |
36 | ## Scope
37 |
38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
39 |
40 | ## Enforcement
41 |
42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at ken@metaskills.net. All complaints will be reviewed and investigated promptly and fairly.
43 |
44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident.
45 |
46 | ## Enforcement Guidelines
47 |
48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
49 |
50 | ### 1. Correction
51 |
52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
53 |
54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
55 |
56 | ### 2. Warning
57 |
58 | **Community Impact**: A violation through a single incident or series of actions.
59 |
60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
61 |
62 | ### 3. Temporary Ban
63 |
64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
65 |
66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
67 |
68 | ### 4. Permanent Ban
69 |
70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
71 |
72 | **Consequence**: A permanent ban from any sort of public interaction within the community.
73 |
74 | ## Attribution
75 |
76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
78 |
79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
80 |
81 | [homepage]: https://www.contributor-covenant.org
82 |
83 | For answers to common questions about this code of conduct, see the FAQ at
84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
85 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | [](https://github.com/rails-lambda/lambda_punch/actions/workflows/test.yml)
4 |
5 | # 👊 LambdaPunch
6 |
7 |
Asynchronous background job processing for AWS Lambda with Ruby using [Lambda Extensions](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-extensions-api.html). Inspired by the [SuckerPunch](https://github.com/brandonhilkert/sucker_punch) gem but specifically tooled to work with Lambda's invoke model.
8 |
9 | **For a more robust background job solution, please consider using AWS SQS with the [Lambdakiq](https://github.com/rails-lambda/lambdakiq) gem. A drop-in replacement for [Sidekiq](https://github.com/mperham/sidekiq) when running Rails in AWS Lambda using the [Lamby](https://lamby.cloud/) gem.**
10 |
11 | ## 🏗 Architecture
12 |
13 | Because AWS Lambda [freezes the execution environment](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-context.html) after each invoke, there is no "process" that continues to run after the handler's response. However, thanks to Lambda Extensions along with its "early return", we can do two important things. First, we leverage the [rb-inotify](https://github.com/guard/rb-inotify) gem to send the extension process a simulated `POST-INVOKE` event. We then use [Distributed Ruby](https://ruby-doc.org/stdlib-3.0.1/libdoc/drb/rdoc/DRb.html) (DRb) from the extension to signal your application to work jobs off a queue. Both of these are synchronous calls. Once complete the LambdaPunch extensions signals it is done and your function is ready for the next request.
14 |
15 |
16 |
17 | The LambdaPunch extension process is very small and lean. It only requires a few Ruby libraries and needed gems from your application's bundle. Its only job is to send signals back to your application on the runtime. It does this within a few milliseconds and adds no noticeable overhead to your function.
18 |
19 | ## 🎁 Installation
20 |
21 | Add this line to your project's `Gemfile` and then make sure to `bundle install` afterward. It is only needed in the `production` group.
22 |
23 | ```ruby
24 | gem 'lambda_punch'
25 | ```
26 |
27 | Within your project or [Rails application's](https://lamby.cloud/docs/anatomy) `Dockerfile`, add the following. Make sure you do this before you `COPY` your code. The idea is to implicitly use the default `USER root` since it needs permission to create an `/opt/extensions` directory.
28 |
29 | ```dockerfile
30 | RUN gem install lambda_punch && lambda_punch install
31 | ```
32 |
33 | LambdaPunch uses the `LAMBDA_TASK_ROOT` environment variable to find your project's Gemfile. If you are using a provided AWS Runtime container, this should be set for you to `/var/task`. However, if you are using your own base image, make sure to set this to your project directory.
34 |
35 | ```dockerfile
36 | ENV LAMBDA_TASK_ROOT=/app
37 | ```
38 |
39 | Installation with AWS Lambda via the [Lamby](https://lamby.cloud/) v4 (or higher) gem can be done using Lamby's `handled_proc` config. For example, appends these to your `config/environments/production.rb` file. Here we are ensuring that the LambdaPunch DRb server is running and that after each Lamby request we notify LambdaPunch.
40 |
41 | ```ruby
42 | config.to_prepare { LambdaPunch.start_server! }
43 | config.lamby.handled_proc = Proc.new do |_event, context|
44 | LambdaPunch.handled!(context)
45 | end
46 | ```
47 |
48 | If you are using an older version of Lamby or a simple Ruby project with your own handler method, the installation would look something like this:
49 |
50 | ```ruby
51 | LambdaPunch.start_server!
52 | def handler(event:, context:)
53 | # ...
54 | ensure
55 | LambdaPunch.handled!(context)
56 | end
57 | ```
58 |
59 | ## 🧰 Usage
60 |
61 | Anywhere in your application's code, use the `LambdaPunch.push` method to add blocks of code to your jobs queue.
62 |
63 | ```ruby
64 | LambdaPunch.push do
65 | # ...
66 | end
67 | ```
68 |
69 | A common use case would be to ensure the [New Relic APM](https://dev.to/aws-heroes/using-new-relic-apm-with-rails-on-aws-lambda-51gi) flushes its data after each request. Using Lamby in your `config/environments/production.rb` file would look like this:
70 |
71 | ```ruby
72 | config.to_prepare { LambdaPunch.start_server! }
73 | config.lamby.handled_proc = Proc.new do |_event, context|
74 | LambdaPunch.push { NewRelic::Agent.agent.flush_pipe_data }
75 | LambdaPunch.handled!(context)
76 | end
77 | ```
78 |
79 | ### ActiveJob
80 |
81 | You can use LambdaPunch with Rails' ActiveJob. **For a more robust background job solution, please consider using AWS SQS with the [Lambdakiq](https://github.com/rails-lambda/lambdakiq) gem.**
82 |
83 | ```ruby
84 | config.active_job.queue_adapter = :lambda_punch
85 | ```
86 |
87 | ### Timeouts
88 |
89 | Your function's timeout is the max amount to handle the request and process all extension's invoke events. If your function times out, it is possible that queued jobs will not be processed until the next invoke.
90 |
91 | If your application integrates with API Gateway (which has a 30 second timeout) then it is possible your function can be set with a higher timeout to perform background work. Since work is done after each invoke, the LambdaPunch queue should be empty when your function receives the `SHUTDOWN` event. If jobs are in the queue when this happens they will have two seconds max to work them down before being lost.
92 |
93 | **For a more robust background job solution, please consider using AWS SQS with the [Lambdakiq](https://github.com/rails-lambda/lambdakiq) gem.**
94 |
95 | ### Logging
96 |
97 | The default log level is `error`, so you will not see any LambdaPunch lines in your logs. However, if you want some low level debugging information on how LambdaPunch is working, you can use this environment variable to change the log level.
98 |
99 | ```yaml
100 | Environment:
101 | Variables:
102 | LAMBDA_PUNCH_LOG_LEVEL: debug
103 | ```
104 |
105 | ### Errors
106 |
107 | As jobs are worked off the queue, all job errors are simply logged. If you want to customize this, you can set your own error handler.
108 |
109 | ```ruby
110 | LambdaPunch.error_handler = lambda { |e| ... }
111 | ```
112 |
113 | ## 📊 CloudWatch Metrics
114 |
115 | When using Extensions, your function's CloudWatch `Duration` metrics will be the sum of your response time combined with your extension's execution time. For example, if your request takes `200ms` to respond but your background task takes `1000ms` your duration will be a combined `1200ms`. For more details see the _"Performance impact and extension overhead"_ section of the [Lambda Extensions API
116 | ](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-extensions-api.html)
117 |
118 | Thankfully, when using Lambda Extensions, CloudWatch will create a `PostRuntimeExtensionsDuration` metric that you can use to isolate your true response times `Duration` [using some metric math](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/using-metric-math.html). Here is an example where the metric math above is used in the first "Duration (response)" widget.
119 |
120 | 
121 |
122 | 
123 |
124 | ## 👷🏽♀️ Development
125 |
126 | After checking out the repo, run the following commands to build a Docker image and install dependencies.
127 |
128 | ```shell
129 | $ ./bin/bootstrap
130 | $ ./bin/setup
131 | ```
132 |
133 | Then, to run the tests use the following command.
134 |
135 | ```shell
136 | $ ./bin/test
137 | ```
138 |
139 | You can also run the `./bin/console` command for an interactive prompt within the development Docker container. Likewise you can use `./bin/run ...` followed by any command which would be executed within the same container.
140 |
141 | ## 💖 Contributing
142 |
143 | Bug reports and pull requests are welcome on GitHub at https://github.com/rails-lambda/lambda_punch. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/rails-lambda/lambda_punch/blob/main/CODE_OF_CONDUCT.md).
144 |
145 | ## 👩⚖️ License
146 |
147 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
148 |
149 | ## 🤝 Code of Conduct
150 |
151 | Everyone interacting in the LambdaPunch project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/rails-lambda/lambda_punch/blob/main/CODE_OF_CONDUCT.md).
152 |
--------------------------------------------------------------------------------