├── 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 | ![LambdaPunch](https://user-images.githubusercontent.com/2381/123561512-c23fb580-d776-11eb-9780-71d606cd8f2c.png) 2 | 3 | [![Test](https://github.com/rails-lambda/lambda_punch/actions/workflows/test.yml/badge.svg)](https://github.com/rails-lambda/lambda_punch/actions/workflows/test.yml) 4 | 5 | # 👊 LambdaPunch 6 | 7 | Lamby: Simple Rails & AWS Lambda Integration using Rack.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 | AWS Lambda Extensions with LambdaPunch async job queue processing. 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 | ![metric-math](https://user-images.githubusercontent.com/2381/123561591-4eea7380-d777-11eb-8682-c20b9460f112.png) 121 | 122 | ![durations](https://user-images.githubusercontent.com/2381/123561590-4e51dd00-d777-11eb-96b2-d886c91aedb0.png) 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 | --------------------------------------------------------------------------------