From b0a7341dc862ecdecb9142f4003edaf36cab5d5c Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Sat, 28 Nov 2020 16:35:09 -0500 Subject: [PATCH 01/34] Initial project. --- lib/lambdakiq/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/lambdakiq/version.rb b/lib/lambdakiq/version.rb index 386ef2b..b266797 100644 --- a/lib/lambdakiq/version.rb +++ b/lib/lambdakiq/version.rb @@ -1,3 +1,3 @@ module Lambdakiq - VERSION = '0.0.1' + VERSION = '1.0.0' end From 61078643973bc4257d0ebd11ac4bcc87617b8b85 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Sat, 28 Nov 2020 20:19:44 -0500 Subject: [PATCH 02/34] Send A Message! --- lib/lambdakiq.rb | 11 ++++++++++- lib/lambdakiq/adapter.rb | 22 ++++++++++++++++++++++ lib/lambdakiq/client.rb | 35 +++++++++++++++++++++++++++++++++++ lib/lambdakiq/queue.rb | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 lib/lambdakiq/adapter.rb create mode 100644 lib/lambdakiq/client.rb create mode 100644 lib/lambdakiq/queue.rb diff --git a/lib/lambdakiq.rb b/lib/lambdakiq.rb index 52e7f4d..ce2b8e2 100644 --- a/lib/lambdakiq.rb +++ b/lib/lambdakiq.rb @@ -1,6 +1,11 @@ +require 'json' +require 'digest' require 'active_job' -require 'aws-sdk-sqs' +require 'active_job/queue_adapters' require 'lambdakiq/version' +require 'lambdakiq/adapter' +require 'lambdakiq/client' +require 'lambdakiq/queue' # if defined?(Rails) # require 'rails/railtie' @@ -9,6 +14,10 @@ module Lambdakiq + def client + @client ||= Client.new + end + extend self # autoload :Xyz, 'lambdakiq/xyz' diff --git a/lib/lambdakiq/adapter.rb b/lib/lambdakiq/adapter.rb new file mode 100644 index 0000000..a2c8484 --- /dev/null +++ b/lib/lambdakiq/adapter.rb @@ -0,0 +1,22 @@ +module ActiveJob + module QueueAdapters + class LambdakiqAdapter + + def enqueue(job, options = {}) + queue = Lambdakiq.client.queues[job.queue_name] + queue.send_message job, options + end + + def enqueue_at(job, timestamp) + enqueue job, delay_seconds: delay_seconds(timestamp) + end + + private + + def delay_seconds(timestamp) + timestamp - Time.current.to_f + end + + end + end +end diff --git a/lib/lambdakiq/client.rb b/lib/lambdakiq/client.rb new file mode 100644 index 0000000..07acd9a --- /dev/null +++ b/lib/lambdakiq/client.rb @@ -0,0 +1,35 @@ +module Lambdakiq + class Client + attr_reader :queues + + def initialize + @queues = Hash.new do |h, name| + h[name] = Queue.new(name) + end + end + + def sqs + @sqs ||= begin + require 'aws-sdk-sqs' + Aws::SQS::Client.new(options) + end + end + + private + + def options + default_options.tap do |opts| + opts[:region] = region if region + end + end + + def region + ENV['AWS_REGION'] + end + + def default_options + {} + end + + end +end diff --git a/lib/lambdakiq/queue.rb b/lib/lambdakiq/queue.rb new file mode 100644 index 0000000..7b47005 --- /dev/null +++ b/lib/lambdakiq/queue.rb @@ -0,0 +1,38 @@ +module Lambdakiq + class Queue + attr_reader :queue_name, + :queue_url + + def initialize(queue_name) + @queue_name = queue_name + @queue_url = get_queue_url + end + + def send_message(job, options = {}) + client.send_message params(job, options) + end + + private + + def client + Lambdakiq.client.sqs + end + + def params(job, options) + message_params(job) + .merge(queue_url: queue_url) + .merge(options) + end + + def message_params(job) + { message_body: JSON.dump(job.serialize), + message_group_id: 'LambdakiqMessage', + message_deduplication_id: job.job_id } + end + + def get_queue_url + client.get_queue_url(queue_name: queue_name).queue_url + end + + end +end From fedc6050016d11c158fd248c82931454d0c40a6b Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Sun, 29 Nov 2020 14:53:38 -0500 Subject: [PATCH 03/34] Some FIFO/Delay Logic --- lamby.gemspec | 1 + lib/lambdakiq.rb | 9 ++------- lib/lambdakiq/adapter.rb | 3 ++- lib/lambdakiq/client.rb | 1 + lib/lambdakiq/queue.rb | 42 +++++++++++++++++++++++++++++----------- 5 files changed, 37 insertions(+), 19 deletions(-) diff --git a/lamby.gemspec b/lamby.gemspec index 14652cc..1a7ebd6 100644 --- a/lamby.gemspec +++ b/lamby.gemspec @@ -19,6 +19,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency 'activejob' spec.add_dependency 'aws-sdk-sqs' + spec.add_dependency 'railties' spec.add_development_dependency 'bundler' spec.add_development_dependency 'rake' spec.add_development_dependency 'minitest' diff --git a/lib/lambdakiq.rb b/lib/lambdakiq.rb index ce2b8e2..0452c03 100644 --- a/lib/lambdakiq.rb +++ b/lib/lambdakiq.rb @@ -6,11 +6,8 @@ require 'lambdakiq/adapter' require 'lambdakiq/client' require 'lambdakiq/queue' - -# if defined?(Rails) -# require 'rails/railtie' -# require 'lambdakiq/railtie' -# end +require 'rails/railtie' +require 'lambdakiq/railtie' module Lambdakiq @@ -20,6 +17,4 @@ def client extend self - # autoload :Xyz, 'lambdakiq/xyz' - end diff --git a/lib/lambdakiq/adapter.rb b/lib/lambdakiq/adapter.rb index a2c8484..a633b0d 100644 --- a/lib/lambdakiq/adapter.rb +++ b/lib/lambdakiq/adapter.rb @@ -14,7 +14,8 @@ def enqueue_at(job, timestamp) private def delay_seconds(timestamp) - timestamp - Time.current.to_f + ds = timestamp - Time.current.to_f + [ds, 900].min end end diff --git a/lib/lambdakiq/client.rb b/lib/lambdakiq/client.rb index 07acd9a..9b965b9 100644 --- a/lib/lambdakiq/client.rb +++ b/lib/lambdakiq/client.rb @@ -1,5 +1,6 @@ module Lambdakiq class Client + attr_reader :queues def initialize diff --git a/lib/lambdakiq/queue.rb b/lib/lambdakiq/queue.rb index 7b47005..cb57a8c 100644 --- a/lib/lambdakiq/queue.rb +++ b/lib/lambdakiq/queue.rb @@ -1,5 +1,6 @@ module Lambdakiq class Queue + attr_reader :queue_name, :queue_url @@ -9,7 +10,11 @@ def initialize(queue_name) end def send_message(job, options = {}) - client.send_message params(job, options) + client.send_message send_message_params(job, options) + end + + def fifo? + queue_name.ends_with?('.fifo') end private @@ -18,20 +23,35 @@ def client Lambdakiq.client.sqs end - def params(job, options) - message_params(job) - .merge(queue_url: queue_url) - .merge(options) + def get_queue_url + client.get_queue_url(queue_name: queue_name).queue_url end - def message_params(job) - { message_body: JSON.dump(job.serialize), - message_group_id: 'LambdakiqMessage', - message_deduplication_id: job.job_id } + def send_message_params(job, options) + { queue_url: queue_url } + .merge(message_params(job, options)) + .merge(options).tap do |params| + params.delete(:delay_seconds) if fifo? + end end - def get_queue_url - client.get_queue_url(queue_name: queue_name).queue_url + def message_params(job, options) + { message_body: JSON.dump(job.serialize) }.tap do |params| + if fifo? + params[:message_group_id] = 'LambdakiqMessage' + params[:message_deduplication_id] = job.job_id + end + params[:message_attributes] = message_attributes(job, options) + end + end + + def message_attributes(_job, options) + {}.tap do |attrs| + ds = options[:delay_seconds] + if ds && fifo? + attrs['delay_seconds'] = { string_value: ds.to_i.to_s, data_type: 'String' } + end + end end end From cd47e1c5a19cc861bfd40f516d1a1df0f61f3647 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Mon, 30 Nov 2020 06:56:38 -0500 Subject: [PATCH 04/34] Simple confirm/redrive setup. --- Gemfile.lock | 38 ++++++++++++++++ bin/_console | 14 ++++++ bin/console | 18 ++------ lib/lambdakiq.rb | 13 ++++++ lib/lambdakiq/adapter.rb | 2 +- lib/lambdakiq/backoff.rb | 40 ++++++++++++++++ lib/lambdakiq/event.rb | 19 ++++++++ lib/lambdakiq/job.rb | 78 ++++++++++++++++++++++++++++++++ lib/lambdakiq/message.rb | 67 +++++++++++++++++++++++++++ lib/lambdakiq/queue.rb | 39 ++++++++-------- lib/lambdakiq/railtie.rb | 2 + lib/lambdakiq/record.rb | 46 +++++++++++++++++++ test/event_test.rb | 16 +++++++ test/lambdakiq_test.rb | 7 --- test/test_helper.rb | 6 +++ test/test_helper/events/basic.rb | 68 ++++++++++++++-------------- 16 files changed, 396 insertions(+), 77 deletions(-) create mode 100755 bin/_console create mode 100644 lib/lambdakiq/backoff.rb create mode 100644 lib/lambdakiq/event.rb create mode 100644 lib/lambdakiq/job.rb create mode 100644 lib/lambdakiq/message.rb create mode 100644 lib/lambdakiq/record.rb create mode 100644 test/event_test.rb delete mode 100644 test/lambdakiq_test.rb diff --git a/Gemfile.lock b/Gemfile.lock index be2f065..d69fd2d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,10 +4,24 @@ PATH lambdakiq (1.0.0) activejob aws-sdk-sqs + railties GEM remote: https://rubygems.org/ specs: + actionpack (6.0.3.4) + actionview (= 6.0.3.4) + activesupport (= 6.0.3.4) + rack (~> 2.0, >= 2.0.8) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actionview (6.0.3.4) + activesupport (= 6.0.3.4) + builder (~> 3.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.1, >= 1.2.0) activejob (6.0.3.4) activesupport (= 6.0.3.4) globalid (>= 0.3.6) @@ -29,22 +43,46 @@ GEM aws-sigv4 (~> 1.1) aws-sigv4 (1.2.2) aws-eventstream (~> 1, >= 1.0.2) + builder (3.2.4) coderay (1.1.3) concurrent-ruby (1.1.7) + crass (1.0.6) + erubi (1.10.0) globalid (0.4.2) activesupport (>= 4.2.0) i18n (1.8.5) concurrent-ruby (~> 1.0) jmespath (1.4.0) + loofah (2.8.0) + crass (~> 1.0.2) + nokogiri (>= 1.5.9) method_source (1.0.0) + mini_portile2 (2.4.0) minitest (5.14.2) minitest-focus (1.2.1) minitest (>= 4, < 6) mocha (1.11.2) + nokogiri (1.10.10) + mini_portile2 (~> 2.4.0) pry (0.13.1) coderay (~> 1.1) method_source (~> 1.0) + rack (2.2.3) + rack-test (1.1.0) + rack (>= 1.0, < 3) + rails-dom-testing (2.0.3) + activesupport (>= 4.2.0) + nokogiri (>= 1.6) + rails-html-sanitizer (1.3.0) + loofah (~> 2.3) + railties (6.0.3.4) + actionpack (= 6.0.3.4) + activesupport (= 6.0.3.4) + method_source + rake (>= 0.8.7) + thor (>= 0.20.3, < 2.0) rake (13.0.1) + thor (1.0.1) thread_safe (0.3.6) tzinfo (1.2.8) thread_safe (~> 0.1) diff --git a/bin/_console b/bin/_console new file mode 100755 index 0000000..36a4d09 --- /dev/null +++ b/bin/_console @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby + +require "bundler/setup" +require "lambdakiq" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/console b/bin/console index 36a4d09..d577a66 100755 --- a/bin/console +++ b/bin/console @@ -1,14 +1,6 @@ -#!/usr/bin/env ruby +#!/bin/bash +set -e -require "bundler/setup" -require "lambdakiq" - -# You can add fixtures and/or initialization code here to make experimenting -# with your gem easier. You can also use a different console, if you like. - -# (If you use this, don't forget to add pry to your Gemfile!) -# require "pry" -# Pry.start - -require "irb" -IRB.start(__FILE__) +docker-compose run \ + lambdakiqgem \ + ./bin/_console diff --git a/lib/lambdakiq.rb b/lib/lambdakiq.rb index 0452c03..e552cbd 100644 --- a/lib/lambdakiq.rb +++ b/lib/lambdakiq.rb @@ -6,11 +6,24 @@ require 'lambdakiq/adapter' require 'lambdakiq/client' require 'lambdakiq/queue' +require 'lambdakiq/message' +require 'lambdakiq/event' +require 'lambdakiq/job' +require 'lambdakiq/record' +require 'lambdakiq/backoff' require 'rails/railtie' require 'lambdakiq/railtie' module Lambdakiq + def handle(event) + Job.handle(event) + end + + def jobs?(event) + Event.jobs?(event) + end + def client @client ||= Client.new end diff --git a/lib/lambdakiq/adapter.rb b/lib/lambdakiq/adapter.rb index a633b0d..038c9c1 100644 --- a/lib/lambdakiq/adapter.rb +++ b/lib/lambdakiq/adapter.rb @@ -14,7 +14,7 @@ def enqueue_at(job, timestamp) private def delay_seconds(timestamp) - ds = timestamp - Time.current.to_f + ds = (timestamp - Time.current.to_i).to_i [ds, 900].min end diff --git a/lib/lambdakiq/backoff.rb b/lib/lambdakiq/backoff.rb new file mode 100644 index 0000000..547d493 --- /dev/null +++ b/lib/lambdakiq/backoff.rb @@ -0,0 +1,40 @@ +module Lambdakiq + class Backoff + + MAX_VISIBILITY_TIMEOUT = 43200 # 12 Hours + + attr_reader :count + + class << self + + def backoff(count) + new(count).backoff + end + + end + + def initialize(count) + @count = count + end + + # From Sidekiq: https://git.io/fhi5O + # + def backoff + case count + when 1 then 30 + when 2 then 46 + when 3 then 76 + when 4 then 156 + when 5 then 346 + when 6 then 730 + when 7 then 1416 + when 8 then 2536 + when 9 then 4246 + when 10 then 6726 + when 11 then 10180 + when 12 then 14836 + end + end + + end +end diff --git a/lib/lambdakiq/event.rb b/lib/lambdakiq/event.rb new file mode 100644 index 0000000..3947c74 --- /dev/null +++ b/lib/lambdakiq/event.rb @@ -0,0 +1,19 @@ +module Lambdakiq + module Event + + def jobs?(event) + records(event).any? { |r| job?(r) } + end + + def job?(record) + record.dig('messageAttributes', 'lambdakiq', 'stringValue') == '1' + end + + def records(event) + event['Records'] || [] + end + + extend self + + end +end diff --git a/lib/lambdakiq/job.rb b/lib/lambdakiq/job.rb new file mode 100644 index 0000000..d77a1c5 --- /dev/null +++ b/lib/lambdakiq/job.rb @@ -0,0 +1,78 @@ +module Lambdakiq + class Job + + attr_reader :record, :error, :sent_timestamp + + class << self + + def handle(event) + records = Event.records(event) + jobs = records.map { |r| new(r) } + jobs.each(&:perform) + error = jobs.detect{ |j| j.error } + error ? raise(j.error) : true + end + + end + + def initialize(record) + @record = Record.new(record) + @error = false + end + + def job_data + @job_data ||= JSON.parse(record.body) + end + + def queue + Lambdakiq.client.queues[record.queue_name] + end + + def performed? + @started_at.present? && !error + end + + def perform + @started_at = Time.current + ActiveJob::Base.execute(job_data) + rescue Exception => e + perform_error(e) + end + + private + + def client_params + { queue_url: queue.queue_url, receipt_handle: record.receipt_handle } + end + + def perform_error(e) + if change_message_visibility + @error = e + else + delete_message + end + end + + def delete_message + client.delete_message(client_params) + rescue Exception => e + true + end + + def change_message_visibility + return false if max_receive_count? + params = client_params.merge visibility_timeout: record.next_visibility_timeout + client.change_message_visibility(params) + true + end + + def client + Lambdakiq.client.sqs + end + + def max_receive_count? + record.max_receive_count? || record.receive_count >= queue.max_receive_count + end + + end +end diff --git a/lib/lambdakiq/message.rb b/lib/lambdakiq/message.rb new file mode 100644 index 0000000..056beae --- /dev/null +++ b/lib/lambdakiq/message.rb @@ -0,0 +1,67 @@ +module Lambdakiq + class Message + LAMBDAKIQ_ATTRIBUTE = { 'lambdakiq' => { string_value: '1', data_type: 'String' } }.freeze + + attr_reader :queue, :job, :options + + def initialize(queue, job, options = {}) + @queue = queue + @job = job + @options = options + end + + def params + message_params.merge(message_options) + end + + private + + def message_params + { message_body: message_body, + message_attributes: message_attributes } + .merge(message_params_fifo) + end + + def message_options + if queue.fifo? + options.except(:delay_seconds) + else + options + end + end + + def message_body + JSON.dump(job.serialize) + end + + def message_params_fifo + if queue.fifo? + { message_group_id: 'LambdakiqMessage', + message_deduplication_id: job.job_id } + else + {} + end + end + + def message_attributes + LAMBDAKIQ_ATTRIBUTE.merge(delay_seconds_attribute) + end + + def delay_seconds + options[:delay_seconds] || 0 + end + + def delay_seconds? + !delay_seconds.zero? + end + + def delay_seconds_attribute + if queue.fifo? && delay_seconds? + { 'delay_seconds' => { string_value: delay_seconds.to_s, data_type: 'String' } } + else + {} + end + end + + end +end diff --git a/lib/lambdakiq/queue.rb b/lib/lambdakiq/queue.rb index cb57a8c..d1968ae 100644 --- a/lib/lambdakiq/queue.rb +++ b/lib/lambdakiq/queue.rb @@ -7,12 +7,28 @@ class Queue def initialize(queue_name) @queue_name = queue_name @queue_url = get_queue_url + attributes end def send_message(job, options = {}) client.send_message send_message_params(job, options) end + def attributes + @attributes ||= client.get_queue_attributes({ + queue_url: queue_url, + attribute_names: ['All'] + }).attributes + end + + def redrive_policy + @redrive_policy ||= JSON.parse(attributes['RedrivePolicy']) + end + + def max_receive_count + redrive_policy['maxReceiveCount'].to_i + end + def fifo? queue_name.ends_with?('.fifo') end @@ -28,30 +44,11 @@ def get_queue_url end def send_message_params(job, options) - { queue_url: queue_url } - .merge(message_params(job, options)) - .merge(options).tap do |params| - params.delete(:delay_seconds) if fifo? - end + { queue_url: queue_url }.merge(message_params(job, options)) end def message_params(job, options) - { message_body: JSON.dump(job.serialize) }.tap do |params| - if fifo? - params[:message_group_id] = 'LambdakiqMessage' - params[:message_deduplication_id] = job.job_id - end - params[:message_attributes] = message_attributes(job, options) - end - end - - def message_attributes(_job, options) - {}.tap do |attrs| - ds = options[:delay_seconds] - if ds && fifo? - attrs['delay_seconds'] = { string_value: ds.to_i.to_s, data_type: 'String' } - end - end + Message.new(self, job, options).params end end diff --git a/lib/lambdakiq/railtie.rb b/lib/lambdakiq/railtie.rb index b8636d4..48e748f 100644 --- a/lib/lambdakiq/railtie.rb +++ b/lib/lambdakiq/railtie.rb @@ -1,5 +1,7 @@ module Lambdakiq class Railtie < ::Rails::Railtie config.lambdakiq = ActiveSupport::OrderedOptions.new + # TODO: Should this be per job too? + config.max_retries = 12 end end diff --git a/lib/lambdakiq/record.rb b/lib/lambdakiq/record.rb new file mode 100644 index 0000000..9496535 --- /dev/null +++ b/lib/lambdakiq/record.rb @@ -0,0 +1,46 @@ +module Lambdakiq + class Record + + attr_reader :data + + def initialize(data) + @data = data + end + + def body + data['body'] + end + + def receipt_handle + data['receiptHandle'] + end + + def attributes + data['attributes'] + end + + def queue_name + @queue_name ||= data['eventSourceARN'].split(':').last + end + + def sent_at + @sent_at ||= begin + ts = attributes['SentTimestamp'].to_i + Time.at(ts/1000) + end + end + + def receive_count + @receive_count ||= attributes['ApproximateReceiveCount'].to_i + end + + def max_receive_count? + receive_count >= 12 + end + + def next_visibility_timeout + @next_visibility_timeout ||= Backoff.backoff(receive_count) + end + + end +end diff --git a/test/event_test.rb b/test/event_test.rb new file mode 100644 index 0000000..a1ade77 --- /dev/null +++ b/test/event_test.rb @@ -0,0 +1,16 @@ +require 'test_helper' + +class EventTest < LambdakiqSpec + it '.records' do + event = event_basic + records = Lambdakiq::Event.records(event) + expect(records).must_be_instance_of(Array) + expect(records.length).must_equal(1) + end + + it '.jobs?' do + event = event_basic + jobs = Lambdakiq::Event.jobs?(event) + expect(jobs).must_equal(true) + end +end diff --git a/test/lambdakiq_test.rb b/test/lambdakiq_test.rb deleted file mode 100644 index 05dd39f..0000000 --- a/test/lambdakiq_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'test_helper' - -class HandlerTest < LambdakiqSpec - it 'starting off' do - expect('true').must_equal 'true' - end -end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0b001e3..505e1f7 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -8,4 +8,10 @@ class LambdakiqSpec < Minitest::Spec + private + + def event_basic(overrides = {}) + TestHelpers::Events::Basic.create(overrides) + end + end diff --git a/test/test_helper/events/basic.rb b/test/test_helper/events/basic.rb index 3f572c7..fd14578 100644 --- a/test/test_helper/events/basic.rb +++ b/test/test_helper/events/basic.rb @@ -2,41 +2,39 @@ module TestHelpers module Events class Basic < Base - self.event = { - "Records" => [ - { - "messageId" => "c42c6b3a-1c01-48eb-8934-aeb4e0638aa7", - "receiptHandle" => "AQEBsQ60u/KXaRcorTDrqJ6zTs/p5nQ9Bbym4JSTvoW6g4dTMReChX5Quh3OP/+34ZFSgGKCwN8MixfUFag+SCc/SSFcZoBqbPAjHktQ00BnVemjYZp8fS3xHOjczjPNW2Ds1k5ijZn1v+zxwWtzSKSVSAQJVneh0+4p0zfXehKvlQWI8mYIm7ixdml1zPanosbOn50njp3eN6DGOx0QLPwYELViDv0/zSIzSxfsac0jw2waO1o1jtsU87XJ25v46TlBeuGhMKFmJ6fkiUNqTtx75v6FXtbM16W21Jhw6Tbh6+Q=", - "body" => "{\"job_class\" =>\"KiqitJob\",\"job_id\" =>\"24a293dd-18b6-4f07-aa45-337589956826\",\"provider_job_id\" =>null,\"queue_name\" =>\"lambdakiq-jobs.fifo\",\"priority\" =>null,\"arguments\" =>[83],\"executions\" =>0,\"exception_executions\" =>{},\"locale\" =>\"en\",\"timezone\" =>\"UTC\",\"enqueued_at\" =>\"2020-11-28T03 =>03 =>00Z\"}", - "attributes" => { - "ApproximateReceiveCount" => "1", - "SentTimestamp" => "1606532580760", - "SequenceNumber" => "18858016414384115456", - "MessageGroupId" => "ShoryukenMessage", - "SenderId" => "AROA4DJKY67RIRD72L5DE", - "MessageDeduplicationId" => "6f872995370771f172e98af04e09267266f0b618e0d0486c140023afaf689c08", - "ApproximateFirstReceiveTimestamp" => "1606532580760" - }, - "messageAttributes" => { - "shoryuken_class" => { - "stringValue" => "ActiveJob => =>QueueAdapters => =>ShoryukenAdapter => =>JobWrapper", - "stringListValues" => [ - - ], - "binaryListValues" => [ - - ], - "dataType" => "String" - } - }, - "md5OfBody" => "f903390c94cdcca2443b8d0e86422edb", - "md5OfMessageAttributes" => "ff41d67aace8f6c385e8a5071b828b5c", - "eventSource" => "aws =>sqs", - "eventSourceARN" => "arn =>aws =>sqs =>us-east-1 =>831702759394 =>lambdakiq-jobs.fifo", - "awsRegion" => "us-east-1" - } - ] - }.freeze + self.event = JSON.load(' + { + "Records": [ + { + "messageId": "9081fe74-bc79-451f-a03a-2fe5c6e2f807", + "receiptHandle": "AQEBgbn8GmF1fMo4z3IIqlJYymS6e7NBynwE+LsQlzjjdcKtSIomGeKMe0noLC9UDShUSe8bzr0s+pby03stHNRv1hgg4WRB5YT4aO0dwOuio7LvMQ/VW88igQtWmca78K6ixnU9X5Sr6J+/+WMvjBgIdvO0ycAM2tyJ1nxRHs/krUoLo/bFCnnwYh++T5BLQtFjFGrRkPjWnzjAbLWKU6Hxxr5lkHSxGhjfAoTCOjhi9crouXaWD+H1uvoGx/O/ZXaeMNjKIQoKjhFguwbEpvrq2Pfh2x9nRgBP3cKa9qw4Q3oFQ0MiQAvnK+UO8cCnsKtD", + "body": "{\"job_class\":\"KiqitJob\",\"job_id\":\"527cd37e-08f4-4aa8-9834-a46220cdc5a3\",\"provider_job_id\":null,\"queue_name\":\"lambdakiq-JobsQueue-WUEPEWOCIY1Z.fifo\",\"priority\":null,\"arguments\":[16],\"executions\":0,\"exception_executions\":{},\"locale\":\"en\",\"timezone\":\"UTC\",\"enqueued_at\":\"2020-11-30T13:07:36Z\"}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1606741656429", + "SequenceNumber": "18858069937755376128", + "MessageGroupId": "LambdakiqMessage", + "SenderId": "AROA4DJKY67RBVYCN5UZ3", + "MessageDeduplicationId": "527cd37e-08f4-4aa8-9834-a46220cdc5a3", + "ApproximateFirstReceiveTimestamp": "1606741656429" + }, + "messageAttributes": { + "lambdakiq": { + "stringValue": "1", + "stringListValues": [], + "binaryListValues": [], + "dataType": "String" + } + }, + "md5OfMessageAttributes": "5fde2d817e4e6b7f28735d3b1725f817", + "md5OfBody": "6477b54fb64dde974ea7514e87d3b8a5", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:831702759394:lambdakiq-JobsQueue-WUEPEWOCIY1Z.fifo", + "awsRegion": "us-east-1" + } + ] + } + ').freeze end end From 7f7f29c81ef1b2d91d160628695e922ab797fab6 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Wed, 30 Dec 2020 09:45:59 -0500 Subject: [PATCH 05/34] Use AWS SDK stubs. Client w/default options. New TODO file. --- Gemfile.lock | 6 ++ README.md | 12 ++++ Rakefile | 2 +- TODO.md | 70 +++++++++++++++++++++++ lamby.gemspec | 1 + lib/lambdakiq.rb | 1 + lib/lambdakiq/client.rb | 11 ++-- lib/lambdakiq/railtie.rb | 3 +- test/cases/basic_job_test.rb | 26 +++++++++ test/cases/basic_nofifo_job_test.rb | 16 ++++++ test/{ => cases}/event_test.rb | 0 test/test_helper.rb | 39 +++++++++++-- test/test_helper/api_call_tracker.rb | 23 ++++++++ test/test_helper/client_helpers.rb | 21 +++++++ test/test_helper/events/base.rb | 2 +- test/test_helper/events/basic.rb | 2 +- test/test_helper/jobs.rb | 4 ++ test/test_helper/jobs/application_job.rb | 7 +++ test/test_helper/jobs/basic_job.rb | 9 +++ test/test_helper/jobs/basic_nofifo_job.rb | 10 ++++ test/test_helper/jobs/buffer.rb | 23 ++++++++ test/test_helper/message_helpers.rb | 25 ++++++++ 22 files changed, 298 insertions(+), 15 deletions(-) create mode 100644 TODO.md create mode 100644 test/cases/basic_job_test.rb create mode 100644 test/cases/basic_nofifo_job_test.rb rename test/{ => cases}/event_test.rb (100%) create mode 100644 test/test_helper/api_call_tracker.rb create mode 100644 test/test_helper/client_helpers.rb create mode 100644 test/test_helper/jobs.rb create mode 100644 test/test_helper/jobs/application_job.rb create mode 100644 test/test_helper/jobs/basic_job.rb create mode 100644 test/test_helper/jobs/basic_nofifo_job.rb create mode 100644 test/test_helper/jobs/buffer.rb create mode 100644 test/test_helper/message_helpers.rb diff --git a/Gemfile.lock b/Gemfile.lock index d69fd2d..5b689d0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -56,6 +56,8 @@ GEM loofah (2.8.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) + macaddr (1.7.2) + systemu (~> 2.6.5) method_source (1.0.0) mini_portile2 (2.4.0) minitest (5.14.2) @@ -82,10 +84,13 @@ GEM rake (>= 0.8.7) thor (>= 0.20.3, < 2.0) rake (13.0.1) + systemu (2.6.5) thor (1.0.1) thread_safe (0.3.6) tzinfo (1.2.8) thread_safe (~> 0.1) + uuid (2.3.9) + macaddr (~> 1.0) zeitwerk (2.4.2) PLATFORMS @@ -99,6 +104,7 @@ DEPENDENCIES mocha pry rake + uuid BUNDLED WITH 2.1.4 diff --git a/README.md b/README.md index b877de1..5307b4c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,18 @@ TODO ... # TODO ... ``` +## Usage + +Open `config/application.rb` and set Lambdakiq as your default ActiveJob queue adapter. + +```ruby +module YourApp + class Application < Rails::Application + config.active_job.queue_adapter = :lambdakiq + end +end +``` + ## Contributing After checking out the repo, run: diff --git a/Rakefile b/Rakefile index 76f2c87..dba4c3a 100644 --- a/Rakefile +++ b/Rakefile @@ -5,7 +5,7 @@ require "rake/testtask" Rake::TestTask.new(:test) do |t| t.libs << "test" t.libs << "lib" - t.test_files = FileList["test/**/*_test.rb"] + t.test_files = FileList["test/cases/*_test.rb"] t.verbose = false t.warning = false end diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..d5e8105 --- /dev/null +++ b/TODO.md @@ -0,0 +1,70 @@ + +# TODO + +* Do I need a worker module? +* Do I support vanillia ActiveJob? + +```ruby +class ExampleJob < ActiveJob::Base + retry_on ErrorLoadingSite, wait: 5.minutes, queue: :low_priority +``` + +* Do a some form of Async queue if this works on Lambda? + - Does Sidekiq do this? + +```ruby +def _enqueue(job, send_message_opts = {}) + Concurrent::Promise + .execute { super(job, send_message_opts) } + .on_error do |e| + Rails.logger.error "Failed to queue job #{job}. Reason: #{e}" + error_handler = Aws::Rails::SqsActiveJob.config.async_queue_error_handler + error_handler.call(e, job, send_message_opts) if error_handler + end +end +``` + +* Use job's `attr_accessor :executions` vs `ApproximateReceiveCount` + +## Doc Points + +* Same as Sidekiq + - Interface +* Differences with Sidekiq + - Max future/delay job is 15 minutes. Uses SQS `delay_seconds`. + - Max retries is 12. + +## Our Siqekiq Interfaces + +```ruby +sidekiq_options queue: :bulk +sidekiq_options retry: 0 +sidekiq_options backtrace: true, retry: 5 +sidekiq_options retry: false +``` + +DO I MIRROR or MIGRATE + +## Max Retries + +* Max is twelve. +* + +## Migrating from Sidekiq + + +#### Single Job + +```ruby +class GuestsCleanupJob < ApplicationJob + self.queue_adapter = :lambdakiq +end +``` + +#### Optional + +* Rename all `sidekiq_options` to `lambdakiq_options` + +```ruby +ActiveJob::Base.logger = Logger.new(IO::NULL) +``` diff --git a/lamby.gemspec b/lamby.gemspec index 1a7ebd6..5def7e4 100644 --- a/lamby.gemspec +++ b/lamby.gemspec @@ -26,4 +26,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'minitest-focus' spec.add_development_dependency 'mocha' spec.add_development_dependency 'pry' + spec.add_development_dependency 'uuid' end diff --git a/lib/lambdakiq.rb b/lib/lambdakiq.rb index e552cbd..f3b50a6 100644 --- a/lib/lambdakiq.rb +++ b/lib/lambdakiq.rb @@ -2,6 +2,7 @@ require 'digest' require 'active_job' require 'active_job/queue_adapters' +require 'active_support/all' require 'lambdakiq/version' require 'lambdakiq/adapter' require 'lambdakiq/client' diff --git a/lib/lambdakiq/client.rb b/lib/lambdakiq/client.rb index 9b965b9..d11d411 100644 --- a/lib/lambdakiq/client.rb +++ b/lib/lambdakiq/client.rb @@ -1,6 +1,11 @@ module Lambdakiq class Client + class_attribute :default_options, + instance_writer: false, + instance_predicate: false, + default: Hash.new + attr_reader :queues def initialize @@ -20,7 +25,7 @@ def sqs def options default_options.tap do |opts| - opts[:region] = region if region + opts[:region] ||= region if region end end @@ -28,9 +33,5 @@ def region ENV['AWS_REGION'] end - def default_options - {} - end - end end diff --git a/lib/lambdakiq/railtie.rb b/lib/lambdakiq/railtie.rb index 48e748f..7d78c4e 100644 --- a/lib/lambdakiq/railtie.rb +++ b/lib/lambdakiq/railtie.rb @@ -1,7 +1,6 @@ module Lambdakiq class Railtie < ::Rails::Railtie config.lambdakiq = ActiveSupport::OrderedOptions.new - # TODO: Should this be per job too? - config.max_retries = 12 + config.lambdakiq.max_retries = 12 end end diff --git a/test/cases/basic_job_test.rb b/test/cases/basic_job_test.rb new file mode 100644 index 0000000..5072997 --- /dev/null +++ b/test/cases/basic_job_test.rb @@ -0,0 +1,26 @@ +require 'test_helper' + +class BasicJobTest < LambdakiqSpec + before do + TestHelper::Jobs::BasicJob.perform_later('somework') + end + + it 'message body' do + expect(sent_message_body['queue_name']).must_equal 'lambdakiq-JobsQueue-TESTING123.fifo' + expect(sent_message_body['job_class']).must_equal 'TestHelper::Jobs::BasicJob' + expect(sent_message_body['arguments']).must_equal ['somework'] + end + + it 'message attributes identify this as a Lambdakiq job' do + lambdakiq = sent_message_attributes['lambdakiq'] + expect(lambdakiq).must_be_instance_of Hash + expect(lambdakiq[:data_type]).must_equal 'String' + expect(lambdakiq[:string_value]).must_equal '1' + end + + it 'message group and deduplication id for default fifo queue are sent' do + expect(sent_message_params[:message_group_id]).must_equal 'LambdakiqMessage' + expect(sent_message_params[:message_deduplication_id]).must_be :present? + UUID.validate(sent_message_params[:message_deduplication_id]) + end +end diff --git a/test/cases/basic_nofifo_job_test.rb b/test/cases/basic_nofifo_job_test.rb new file mode 100644 index 0000000..1fe0a25 --- /dev/null +++ b/test/cases/basic_nofifo_job_test.rb @@ -0,0 +1,16 @@ +require 'test_helper' + +class BasicNofifoJobTest < LambdakiqSpec + before do + TestHelper::Jobs::BasicNofifoJob.perform_later('somework') + end + + it 'message body has no fifo queue nave vs fifo super class ' do + expect(sent_message_body['queue_name']).must_equal 'lambdakiq-JobsQueue-TESTING123' + end + + it 'message group and deduplication id not used for non fifo queues' do + expect(sent_message_params[:message_group_id]).must_be_nil + expect(sent_message_params[:message_deduplication_id]).must_be_nil + end +end diff --git a/test/event_test.rb b/test/cases/event_test.rb similarity index 100% rename from test/event_test.rb rename to test/cases/event_test.rb diff --git a/test/test_helper.rb b/test/test_helper.rb index 505e1f7..68420ff 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,17 +1,46 @@ -$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) -require 'lambdakiq' -require 'pry' +ENV['RAILS_ENV'] = 'test' +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) +require 'bundler/setup' +Bundler.require :default, :development, :test +require 'aws-sdk-sqs' require 'minitest/autorun' require 'minitest/focus' require 'mocha/minitest' -require 'test_helper/event_helpers' +Dir['test/test_helper/*.{rb}'].each { |f| require_relative "../#{f}" } + +ActiveJob::Base.queue_adapter = :lambdakiq +ActiveJob::Base.logger = Logger.new(IO::NULL) +Aws::SQS::Client.add_plugin(TestHelper::ApiCallTracker) +Lambdakiq::Client.default_options.merge! stub_responses: true class LambdakiqSpec < Minitest::Spec + include TestHelper::ClientHelpers, + TestHelper::MessageHelpers + + before do + client_reset! + client_stub_responses + clear_api_tracker! + clear_jobs_buffer! + end + private def event_basic(overrides = {}) - TestHelpers::Events::Basic.create(overrides) + TestHelper::Events::Basic.create(overrides) + end + + def clear_api_tracker! + TestHelper::ApiCallTracker.api_calls.clear + end + + def clear_jobs_buffer! + TestHelper::Jobs::Buffer.clear + end + + def buffer_last_value + TestHelper::Jobs::Buffer.last_value end end diff --git a/test/test_helper/api_call_tracker.rb b/test/test_helper/api_call_tracker.rb new file mode 100644 index 0000000..abc647c --- /dev/null +++ b/test/test_helper/api_call_tracker.rb @@ -0,0 +1,23 @@ +module TestHelper + class ApiCallTracker < Seahorse::Client::Plugin + + @api_calls = [] + + class << self + + attr_reader :api_calls + + def called_operations + api_calls.map { |resp| resp.context.operation_name } + end + + end + + handle do |context| + response = @handler.call(context) + ApiCallTracker.api_calls << response + response + end + + end +end diff --git a/test/test_helper/client_helpers.rb b/test/test_helper/client_helpers.rb new file mode 100644 index 0000000..2961863 --- /dev/null +++ b/test/test_helper/client_helpers.rb @@ -0,0 +1,21 @@ +module TestHelper + module ClientHelpers + + private + + def client + Lambdakiq.client.sqs + end + + def client_reset! + Lambdakiq.instance_variable_set :@client, nil + end + + def client_stub_responses + client.stub_responses(:get_queue_url, { + queue_url: 'https://sqs.us-stubbed-1.amazonaws.com' + }) + end + + end +end diff --git a/test/test_helper/events/base.rb b/test/test_helper/events/base.rb index 64eaa90..f470ddb 100644 --- a/test/test_helper/events/base.rb +++ b/test/test_helper/events/base.rb @@ -1,4 +1,4 @@ -module TestHelpers +module TestHelper module Events class Base diff --git a/test/test_helper/events/basic.rb b/test/test_helper/events/basic.rb index fd14578..fa21395 100644 --- a/test/test_helper/events/basic.rb +++ b/test/test_helper/events/basic.rb @@ -1,4 +1,4 @@ -module TestHelpers +module TestHelper module Events class Basic < Base diff --git a/test/test_helper/jobs.rb b/test/test_helper/jobs.rb new file mode 100644 index 0000000..11f74f9 --- /dev/null +++ b/test/test_helper/jobs.rb @@ -0,0 +1,4 @@ +require 'test_helper/jobs/application_job' +require 'test_helper/jobs/buffer' +require 'test_helper/jobs/basic_job' +require 'test_helper/jobs/basic_nofifo_job' diff --git a/test/test_helper/jobs/application_job.rb b/test/test_helper/jobs/application_job.rb new file mode 100644 index 0000000..b730f77 --- /dev/null +++ b/test/test_helper/jobs/application_job.rb @@ -0,0 +1,7 @@ +module TestHelper + module Jobs + class ApplicationJob < ActiveJob::Base + queue_as 'lambdakiq-JobsQueue-TESTING123.fifo' + end + end +end diff --git a/test/test_helper/jobs/basic_job.rb b/test/test_helper/jobs/basic_job.rb new file mode 100644 index 0000000..ecd7b22 --- /dev/null +++ b/test/test_helper/jobs/basic_job.rb @@ -0,0 +1,9 @@ +module TestHelper + module Jobs + class BasicJob < ApplicationJob + def perform(object) + Buffer.add "BasicJob with: #{object.inspect}" + end + end + end +end diff --git a/test/test_helper/jobs/basic_nofifo_job.rb b/test/test_helper/jobs/basic_nofifo_job.rb new file mode 100644 index 0000000..6348751 --- /dev/null +++ b/test/test_helper/jobs/basic_nofifo_job.rb @@ -0,0 +1,10 @@ +module TestHelper + module Jobs + class BasicNofifoJob < ApplicationJob + queue_as 'lambdakiq-JobsQueue-TESTING123' + def perform(object) + Buffer.add "BasicNofifoJob with: #{object.inspect}" + end + end + end +end diff --git a/test/test_helper/jobs/buffer.rb b/test/test_helper/jobs/buffer.rb new file mode 100644 index 0000000..8219065 --- /dev/null +++ b/test/test_helper/jobs/buffer.rb @@ -0,0 +1,23 @@ +module TestHelper + module Jobs + module Buffer + class << self + def clear + values.clear + end + + def add(value) + values << value + end + + def values + @values ||= [] + end + + def last_value + values.last + end + end + end + end +end diff --git a/test/test_helper/message_helpers.rb b/test/test_helper/message_helpers.rb new file mode 100644 index 0000000..34f4369 --- /dev/null +++ b/test/test_helper/message_helpers.rb @@ -0,0 +1,25 @@ +module TestHelper + module MessageHelpers + + private + + def sent_message + client.api_requests.reverse.detect { |r| + r[:operation_name] == :send_message + } || flunk('No sent message request found.') + end + + def sent_message_params + sent_message[:params] + end + + def sent_message_body + JSON.parse sent_message_params[:message_body] + end + + def sent_message_attributes + sent_message_params[:message_attributes] + end + + end +end From bba9357887f16a553e5cbd627edbde21675a583f Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Wed, 30 Dec 2020 09:56:36 -0500 Subject: [PATCH 06/34] Remove Job Buffer & Api Trackers. Not needed with SDK stubs. --- TODO.md | 3 +++ test/test_helper.rb | 15 --------------- test/test_helper/api_call_tracker.rb | 23 ----------------------- test/test_helper/jobs.rb | 1 - test/test_helper/jobs/basic_job.rb | 2 +- test/test_helper/jobs/basic_nofifo_job.rb | 2 +- test/test_helper/jobs/buffer.rb | 23 ----------------------- 7 files changed, 5 insertions(+), 64 deletions(-) delete mode 100644 test/test_helper/api_call_tracker.rb delete mode 100644 test/test_helper/jobs/buffer.rb diff --git a/TODO.md b/TODO.md index d5e8105..d3d6c0b 100644 --- a/TODO.md +++ b/TODO.md @@ -33,6 +33,9 @@ end * Differences with Sidekiq - Max future/delay job is 15 minutes. Uses SQS `delay_seconds`. - Max retries is 12. +* Client Optoins. + - Uses `ENV['AWS_REGION']` for `region`. Likely never need to touch this. + - Default Client Options. Show with config init or railtie? ## Our Siqekiq Interfaces diff --git a/test/test_helper.rb b/test/test_helper.rb index 68420ff..f89acf6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -10,7 +10,6 @@ ActiveJob::Base.queue_adapter = :lambdakiq ActiveJob::Base.logger = Logger.new(IO::NULL) -Aws::SQS::Client.add_plugin(TestHelper::ApiCallTracker) Lambdakiq::Client.default_options.merge! stub_responses: true class LambdakiqSpec < Minitest::Spec @@ -21,8 +20,6 @@ class LambdakiqSpec < Minitest::Spec before do client_reset! client_stub_responses - clear_api_tracker! - clear_jobs_buffer! end private @@ -31,16 +28,4 @@ def event_basic(overrides = {}) TestHelper::Events::Basic.create(overrides) end - def clear_api_tracker! - TestHelper::ApiCallTracker.api_calls.clear - end - - def clear_jobs_buffer! - TestHelper::Jobs::Buffer.clear - end - - def buffer_last_value - TestHelper::Jobs::Buffer.last_value - end - end diff --git a/test/test_helper/api_call_tracker.rb b/test/test_helper/api_call_tracker.rb deleted file mode 100644 index abc647c..0000000 --- a/test/test_helper/api_call_tracker.rb +++ /dev/null @@ -1,23 +0,0 @@ -module TestHelper - class ApiCallTracker < Seahorse::Client::Plugin - - @api_calls = [] - - class << self - - attr_reader :api_calls - - def called_operations - api_calls.map { |resp| resp.context.operation_name } - end - - end - - handle do |context| - response = @handler.call(context) - ApiCallTracker.api_calls << response - response - end - - end -end diff --git a/test/test_helper/jobs.rb b/test/test_helper/jobs.rb index 11f74f9..00e4bb0 100644 --- a/test/test_helper/jobs.rb +++ b/test/test_helper/jobs.rb @@ -1,4 +1,3 @@ require 'test_helper/jobs/application_job' -require 'test_helper/jobs/buffer' require 'test_helper/jobs/basic_job' require 'test_helper/jobs/basic_nofifo_job' diff --git a/test/test_helper/jobs/basic_job.rb b/test/test_helper/jobs/basic_job.rb index ecd7b22..2a9f5b2 100644 --- a/test/test_helper/jobs/basic_job.rb +++ b/test/test_helper/jobs/basic_job.rb @@ -2,7 +2,7 @@ module TestHelper module Jobs class BasicJob < ApplicationJob def perform(object) - Buffer.add "BasicJob with: #{object.inspect}" + object end end end diff --git a/test/test_helper/jobs/basic_nofifo_job.rb b/test/test_helper/jobs/basic_nofifo_job.rb index 6348751..62753df 100644 --- a/test/test_helper/jobs/basic_nofifo_job.rb +++ b/test/test_helper/jobs/basic_nofifo_job.rb @@ -3,7 +3,7 @@ module Jobs class BasicNofifoJob < ApplicationJob queue_as 'lambdakiq-JobsQueue-TESTING123' def perform(object) - Buffer.add "BasicNofifoJob with: #{object.inspect}" + object end end end diff --git a/test/test_helper/jobs/buffer.rb b/test/test_helper/jobs/buffer.rb deleted file mode 100644 index 8219065..0000000 --- a/test/test_helper/jobs/buffer.rb +++ /dev/null @@ -1,23 +0,0 @@ -module TestHelper - module Jobs - module Buffer - class << self - def clear - values.clear - end - - def add(value) - values << value - end - - def values - @values ||= [] - end - - def last_value - values.last - end - end - end - end -end From f66876bd0e3dc7c11c18a45d0f10b3acbb86cbe0 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Wed, 30 Dec 2020 09:59:44 -0500 Subject: [PATCH 07/34] Refactor event helpers. --- test/test_helper.rb | 9 ++------- test/test_helper/event_helpers.rb | 12 ++++++++++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index f89acf6..067aca9 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -15,17 +15,12 @@ class LambdakiqSpec < Minitest::Spec include TestHelper::ClientHelpers, - TestHelper::MessageHelpers + TestHelper::MessageHelpers, + TestHelper::EventHelpers before do client_reset! client_stub_responses end - private - - def event_basic(overrides = {}) - TestHelper::Events::Basic.create(overrides) - end - end diff --git a/test/test_helper/event_helpers.rb b/test/test_helper/event_helpers.rb index 91538fd..8eb607d 100644 --- a/test/test_helper/event_helpers.rb +++ b/test/test_helper/event_helpers.rb @@ -1,2 +1,14 @@ require 'test_helper/events/base' require 'test_helper/events/basic' + +module TestHelper + module EventHelpers + + private + + def event_basic(overrides = {}) + Events::Basic.create(overrides) + end + + end +end From 449621f97552a0cdbaded838d1e8351f85217e5b Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Wed, 30 Dec 2020 13:58:04 -0500 Subject: [PATCH 08/34] Use queue name from job. New RecordTest. --- TODO.md | 10 ++++++++ lib/lambdakiq/job.rb | 12 ++++++--- lib/lambdakiq/record.rb | 8 +++--- test/cases/record_test.rb | 42 ++++++++++++++++++++++++++++++++ test/test_helper/events/basic.rb | 4 +-- 5 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 test/cases/record_test.rb diff --git a/TODO.md b/TODO.md index d3d6c0b..206ef54 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,9 @@ end ``` * Use job's `attr_accessor :executions` vs `ApproximateReceiveCount` +* Error handlers. Ensure we easily hook into Rollbar, etc. +* Is `delete_message` message needed? Is 200 from consumer implied delete? +* Can I set Rails tempalte `VisibilityTimeout` to just +1 of function timeout or full 43200? ## Doc Points @@ -33,6 +36,8 @@ end * Differences with Sidekiq - Max future/delay job is 15 minutes. Uses SQS `delay_seconds`. - Max retries is 12. + * Sidekiq: 25 retries (20 days, 11 hours) + * Lambdakiq: 12 retries (11 hours, 28 minutes) * Client Optoins. - Uses `ENV['AWS_REGION']` for `region`. Likely never need to touch this. - Default Client Options. Show with config init or railtie? @@ -64,6 +69,11 @@ class GuestsCleanupJob < ApplicationJob end ``` +#### Death Notifications + +https://github.com/mperham/sidekiq/wiki/Error-Handling#death-notification + + #### Optional * Rename all `sidekiq_options` to `lambdakiq_options` diff --git a/lib/lambdakiq/job.rb b/lib/lambdakiq/job.rb index d77a1c5..1fab150 100644 --- a/lib/lambdakiq/job.rb +++ b/lib/lambdakiq/job.rb @@ -7,10 +7,10 @@ class << self def handle(event) records = Event.records(event) - jobs = records.map { |r| new(r) } + jobs = records.map { |record| new(record) } jobs.each(&:perform) - error = jobs.detect{ |j| j.error } - error ? raise(j.error) : true + jwerror = jobs.detect{ |j| j.error } + jwerror ? raise(jwerror.error) : true end end @@ -24,8 +24,12 @@ def job_data @job_data ||= JSON.parse(record.body) end + def active_job + @active_job ||= ActiveJob::Base.deserialize(job_data) + end + def queue - Lambdakiq.client.queues[record.queue_name] + Lambdakiq.client.queues[active_job.queue_name] end def performed? diff --git a/lib/lambdakiq/record.rb b/lib/lambdakiq/record.rb index 9496535..8187a22 100644 --- a/lib/lambdakiq/record.rb +++ b/lib/lambdakiq/record.rb @@ -15,14 +15,14 @@ def receipt_handle data['receiptHandle'] end - def attributes - data['attributes'] - end - def queue_name @queue_name ||= data['eventSourceARN'].split(':').last end + def attributes + data['attributes'] + end + def sent_at @sent_at ||= begin ts = attributes['SentTimestamp'].to_i diff --git a/test/cases/record_test.rb b/test/cases/record_test.rb new file mode 100644 index 0000000..fbeb9d5 --- /dev/null +++ b/test/cases/record_test.rb @@ -0,0 +1,42 @@ +require 'test_helper' + +class RecordTest < LambdakiqSpec + let(:event) { event_basic } + let(:records) { Lambdakiq::Event.records(event) } + let(:record) { Lambdakiq::Record.new(records.first) } + + it '#body' do + expect(record.body).must_be_instance_of String + expect(JSON.parse(record.body)).must_be_instance_of Hash + end + + it '#receipt_handle' do + expect(record.receipt_handle).must_be_instance_of String + expect(record.receipt_handle).must_match /AQE.*KtD/ + end + + it '#queue_name' do + expect(record.queue_name).must_equal 'lambdakiq-JobsQueue-TESTING123.fifo' + end + + it '#attributes' do + expect(record.attributes).must_be_instance_of Hash + end + + it '#sent_at' do + sent_at = record.sent_at + expect(sent_at).must_be_instance_of Time + expect(sent_at.year).must_equal 2020 + expect(sent_at.month).must_equal 11 + expect(sent_at.day).must_equal 30 + end + + it '#receive_count' do + expect(record.receive_count).must_equal 1 + end + + it '#max_receive_count?' do + expect(record.max_receive_count?).must_equal false + end + +end diff --git a/test/test_helper/events/basic.rb b/test/test_helper/events/basic.rb index fa21395..27e56ae 100644 --- a/test/test_helper/events/basic.rb +++ b/test/test_helper/events/basic.rb @@ -8,7 +8,7 @@ class Basic < Base { "messageId": "9081fe74-bc79-451f-a03a-2fe5c6e2f807", "receiptHandle": "AQEBgbn8GmF1fMo4z3IIqlJYymS6e7NBynwE+LsQlzjjdcKtSIomGeKMe0noLC9UDShUSe8bzr0s+pby03stHNRv1hgg4WRB5YT4aO0dwOuio7LvMQ/VW88igQtWmca78K6ixnU9X5Sr6J+/+WMvjBgIdvO0ycAM2tyJ1nxRHs/krUoLo/bFCnnwYh++T5BLQtFjFGrRkPjWnzjAbLWKU6Hxxr5lkHSxGhjfAoTCOjhi9crouXaWD+H1uvoGx/O/ZXaeMNjKIQoKjhFguwbEpvrq2Pfh2x9nRgBP3cKa9qw4Q3oFQ0MiQAvnK+UO8cCnsKtD", - "body": "{\"job_class\":\"KiqitJob\",\"job_id\":\"527cd37e-08f4-4aa8-9834-a46220cdc5a3\",\"provider_job_id\":null,\"queue_name\":\"lambdakiq-JobsQueue-WUEPEWOCIY1Z.fifo\",\"priority\":null,\"arguments\":[16],\"executions\":0,\"exception_executions\":{},\"locale\":\"en\",\"timezone\":\"UTC\",\"enqueued_at\":\"2020-11-30T13:07:36Z\"}", + "body": "{\"job_class\":\"TestHelper::Jobs::BasicJob\",\"job_id\":\"527cd37e-08f4-4aa8-9834-a46220cdc5a3\",\"provider_job_id\":null,\"queue_name\":\"lambdakiq-JobsQueue-TESTING123.fifo\",\"priority\":null,\"arguments\":[16],\"executions\":0,\"exception_executions\":{},\"locale\":\"en\",\"timezone\":\"UTC\",\"enqueued_at\":\"2020-11-30T13:07:36Z\"}", "attributes": { "ApproximateReceiveCount": "1", "SentTimestamp": "1606741656429", @@ -29,7 +29,7 @@ class Basic < Base "md5OfMessageAttributes": "5fde2d817e4e6b7f28735d3b1725f817", "md5OfBody": "6477b54fb64dde974ea7514e87d3b8a5", "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-1:831702759394:lambdakiq-JobsQueue-WUEPEWOCIY1Z.fifo", + "eventSourceARN": "arn:aws:sqs:us-east-1:831702759394:lambdakiq-JobsQueue-TESTING123.fifo", "awsRegion": "us-east-1" } ] From 8aa55955268a20323db9891fa8b4ea891cebbafa Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Thu, 31 Dec 2020 09:26:29 -0500 Subject: [PATCH 09/34] TODO --- TODO.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TODO.md b/TODO.md index 206ef54..b1f7a7a 100644 --- a/TODO.md +++ b/TODO.md @@ -41,6 +41,8 @@ end * Client Optoins. - Uses `ENV['AWS_REGION']` for `region`. Likely never need to touch this. - Default Client Options. Show with config init or railtie? +* Max Message Size: + - FIFO: 256 KB?? ## Our Siqekiq Interfaces From d195290e954484bce075382fe023af8c29f8ec8f Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Thu, 31 Dec 2020 09:28:19 -0500 Subject: [PATCH 10/34] Debug #receive_count --- lib/lambdakiq/record.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/lambdakiq/record.rb b/lib/lambdakiq/record.rb index 8187a22..979540e 100644 --- a/lib/lambdakiq/record.rb +++ b/lib/lambdakiq/record.rb @@ -31,6 +31,7 @@ def sent_at end def receive_count + puts "RECEIVE_COUNT: #{attributes['ApproximateReceiveCount'].to_i}" @receive_count ||= attributes['ApproximateReceiveCount'].to_i end From 98ae3065de0136ce80af2fbfea902ac9ef3c88e7 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Thu, 31 Dec 2020 11:35:15 -0500 Subject: [PATCH 11/34] Test jobs in full with retries. --- TODO.md | 2 ++ lib/lambdakiq/record.rb | 1 - test/cases/basic_job_test.rb | 3 +- test/cases/job_test.rb | 18 +++++++++++ test/cases/queue_test.rb | 9 ++++++ test/cases/record_test.rb | 2 +- test/test_helper.rb | 6 ++-- test/test_helper/api_request_helpers.rb | 41 ++++++++++++++++++++++++ test/test_helper/client_helpers.rb | 4 +++ test/test_helper/events/base.rb | 8 ++++- test/test_helper/events/basic.rb | 2 +- test/test_helper/jobs.rb | 1 + test/test_helper/jobs/application_job.rb | 2 +- test/test_helper/jobs/error_job.rb | 9 ++++++ test/test_helper/message_helpers.rb | 25 --------------- test/test_helper/queue_helpers.rb | 18 +++++++++++ 16 files changed, 118 insertions(+), 33 deletions(-) create mode 100644 test/cases/job_test.rb create mode 100644 test/cases/queue_test.rb create mode 100644 test/test_helper/api_request_helpers.rb create mode 100644 test/test_helper/jobs/error_job.rb delete mode 100644 test/test_helper/message_helpers.rb create mode 100644 test/test_helper/queue_helpers.rb diff --git a/TODO.md b/TODO.md index b1f7a7a..d20f760 100644 --- a/TODO.md +++ b/TODO.md @@ -28,6 +28,7 @@ end * Error handlers. Ensure we easily hook into Rollbar, etc. * Is `delete_message` message needed? Is 200 from consumer implied delete? * Can I set Rails tempalte `VisibilityTimeout` to just +1 of function timeout or full 43200? +* Can I get rid of the Job re-raising and rely on change message visibility alone? ## Doc Points @@ -43,6 +44,7 @@ end - Default Client Options. Show with config init or railtie? * Max Message Size: - FIFO: 256 KB?? +* Setting `maxReceiveCount` hard codes your retries to -1 of that value at the queue level. ## Our Siqekiq Interfaces diff --git a/lib/lambdakiq/record.rb b/lib/lambdakiq/record.rb index 979540e..8187a22 100644 --- a/lib/lambdakiq/record.rb +++ b/lib/lambdakiq/record.rb @@ -31,7 +31,6 @@ def sent_at end def receive_count - puts "RECEIVE_COUNT: #{attributes['ApproximateReceiveCount'].to_i}" @receive_count ||= attributes['ApproximateReceiveCount'].to_i end diff --git a/test/cases/basic_job_test.rb b/test/cases/basic_job_test.rb index 5072997..88268e0 100644 --- a/test/cases/basic_job_test.rb +++ b/test/cases/basic_job_test.rb @@ -3,10 +3,11 @@ class BasicJobTest < LambdakiqSpec before do TestHelper::Jobs::BasicJob.perform_later('somework') + expect(sent_message).must_be :present? end it 'message body' do - expect(sent_message_body['queue_name']).must_equal 'lambdakiq-JobsQueue-TESTING123.fifo' + expect(sent_message_body['queue_name']).must_equal queue_name expect(sent_message_body['job_class']).must_equal 'TestHelper::Jobs::BasicJob' expect(sent_message_body['arguments']).must_equal ['somework'] end diff --git a/test/cases/job_test.rb b/test/cases/job_test.rb new file mode 100644 index 0000000..2e1db76 --- /dev/null +++ b/test/cases/job_test.rb @@ -0,0 +1,18 @@ +require 'test_helper' + +class JobTest < LambdakiqSpec + it 'must change message visibility to next value for failed jobs' do + stub_get_queue_attributes maxReceiveCount: 8 + event = event_basic attributes: { ApproximateReceiveCount: '7' }, job_class: 'TestHelper::Jobs::ErrorJob' + expect(->{ Lambdakiq::Job.handle(event) }).must_raise 'HELL' + expect(change_message_visibility).must_be :present? + expect(change_message_visibility_params[:visibility_timeout]).must_equal 1416 + end + + it 'must delete message for failed jobs at the end of the queue/message max receive count' do + stub_get_queue_attributes maxReceiveCount: 8 + event = event_basic attributes: { ApproximateReceiveCount: '8' }, job_class: 'TestHelper::Jobs::ErrorJob' + Lambdakiq::Job.handle(event) + expect(delete_message).must_be :present? + end +end diff --git a/test/cases/queue_test.rb b/test/cases/queue_test.rb new file mode 100644 index 0000000..7291144 --- /dev/null +++ b/test/cases/queue_test.rb @@ -0,0 +1,9 @@ +require 'test_helper' + +class QueueTest < LambdakiqSpec + it '#max_receive_count' do + stub_get_queue_attributes maxReceiveCount: 8 + queue = Lambdakiq.client.queues[queue_name] + expect(queue.max_receive_count).must_equal 8 + end +end diff --git a/test/cases/record_test.rb b/test/cases/record_test.rb index fbeb9d5..d0330ef 100644 --- a/test/cases/record_test.rb +++ b/test/cases/record_test.rb @@ -16,7 +16,7 @@ class RecordTest < LambdakiqSpec end it '#queue_name' do - expect(record.queue_name).must_equal 'lambdakiq-JobsQueue-TESTING123.fifo' + expect(record.queue_name).must_equal queue_name end it '#attributes' do diff --git a/test/test_helper.rb b/test/test_helper.rb index 067aca9..72dbe89 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,4 +1,5 @@ ENV['RAILS_ENV'] = 'test' +ENV['TEST_QUEUE_NAME'] ||= 'lambdakiq-JobsQueue-TESTING123.fifo' ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) require 'bundler/setup' Bundler.require :default, :development, :test @@ -15,8 +16,9 @@ class LambdakiqSpec < Minitest::Spec include TestHelper::ClientHelpers, - TestHelper::MessageHelpers, - TestHelper::EventHelpers + TestHelper::ApiRequestHelpers, + TestHelper::EventHelpers, + TestHelper::QueueHelpers before do client_reset! diff --git a/test/test_helper/api_request_helpers.rb b/test/test_helper/api_request_helpers.rb new file mode 100644 index 0000000..f17096e --- /dev/null +++ b/test/test_helper/api_request_helpers.rb @@ -0,0 +1,41 @@ +module TestHelper + module ApiRequestHelpers + + private + + def delete_message + api_requests.reverse.detect do |r| + r[:operation_name] == :delete_message + end + end + + def change_message_visibility + api_requests.reverse.detect do |r| + r[:operation_name] == :change_message_visibility + end + end + + def change_message_visibility_params + change_message_visibility[:params] + end + + def sent_message + api_requests.reverse.detect do |r| + r[:operation_name] == :send_message + end + end + + def sent_message_params + sent_message[:params] + end + + def sent_message_body + JSON.parse sent_message_params[:message_body] + end + + def sent_message_attributes + sent_message_params[:message_attributes] + end + + end +end diff --git a/test/test_helper/client_helpers.rb b/test/test_helper/client_helpers.rb index 2961863..bd1cfde 100644 --- a/test/test_helper/client_helpers.rb +++ b/test/test_helper/client_helpers.rb @@ -17,5 +17,9 @@ def client_stub_responses }) end + def api_requests + client.api_requests + end + end end diff --git a/test/test_helper/events/base.rb b/test/test_helper/events/base.rb index f470ddb..d1ab667 100644 --- a/test/test_helper/events/base.rb +++ b/test/test_helper/events/base.rb @@ -6,7 +6,13 @@ class Base self.event = Hash.new def self.create(overrides = {}) - event.deep_merge(overrides.stringify_keys) + job_class = overrides.delete(:job_class) + event.deep_dup.tap do |e| + e['Records'].each do |r| + r.deep_merge!(overrides.deep_stringify_keys) + r['body'].sub! 'TestHelper::Jobs::BasicJob', job_class if job_class + end + end end end diff --git a/test/test_helper/events/basic.rb b/test/test_helper/events/basic.rb index 27e56ae..a5e86ea 100644 --- a/test/test_helper/events/basic.rb +++ b/test/test_helper/events/basic.rb @@ -8,7 +8,7 @@ class Basic < Base { "messageId": "9081fe74-bc79-451f-a03a-2fe5c6e2f807", "receiptHandle": "AQEBgbn8GmF1fMo4z3IIqlJYymS6e7NBynwE+LsQlzjjdcKtSIomGeKMe0noLC9UDShUSe8bzr0s+pby03stHNRv1hgg4WRB5YT4aO0dwOuio7LvMQ/VW88igQtWmca78K6ixnU9X5Sr6J+/+WMvjBgIdvO0ycAM2tyJ1nxRHs/krUoLo/bFCnnwYh++T5BLQtFjFGrRkPjWnzjAbLWKU6Hxxr5lkHSxGhjfAoTCOjhi9crouXaWD+H1uvoGx/O/ZXaeMNjKIQoKjhFguwbEpvrq2Pfh2x9nRgBP3cKa9qw4Q3oFQ0MiQAvnK+UO8cCnsKtD", - "body": "{\"job_class\":\"TestHelper::Jobs::BasicJob\",\"job_id\":\"527cd37e-08f4-4aa8-9834-a46220cdc5a3\",\"provider_job_id\":null,\"queue_name\":\"lambdakiq-JobsQueue-TESTING123.fifo\",\"priority\":null,\"arguments\":[16],\"executions\":0,\"exception_executions\":{},\"locale\":\"en\",\"timezone\":\"UTC\",\"enqueued_at\":\"2020-11-30T13:07:36Z\"}", + "body": "{\"job_class\":\"TestHelper::Jobs::BasicJob\",\"job_id\":\"527cd37e-08f4-4aa8-9834-a46220cdc5a3\",\"provider_job_id\":null,\"queue_name\":\"lambdakiq-JobsQueue-TESTING123.fifo\",\"priority\":null,\"arguments\":[\"test\"],\"executions\":0,\"exception_executions\":{},\"locale\":\"en\",\"timezone\":\"UTC\",\"enqueued_at\":\"2020-11-30T13:07:36Z\"}", "attributes": { "ApproximateReceiveCount": "1", "SentTimestamp": "1606741656429", diff --git a/test/test_helper/jobs.rb b/test/test_helper/jobs.rb index 00e4bb0..6d6929f 100644 --- a/test/test_helper/jobs.rb +++ b/test/test_helper/jobs.rb @@ -1,3 +1,4 @@ require 'test_helper/jobs/application_job' require 'test_helper/jobs/basic_job' require 'test_helper/jobs/basic_nofifo_job' +require 'test_helper/jobs/error_job' diff --git a/test/test_helper/jobs/application_job.rb b/test/test_helper/jobs/application_job.rb index b730f77..177236f 100644 --- a/test/test_helper/jobs/application_job.rb +++ b/test/test_helper/jobs/application_job.rb @@ -1,7 +1,7 @@ module TestHelper module Jobs class ApplicationJob < ActiveJob::Base - queue_as 'lambdakiq-JobsQueue-TESTING123.fifo' + queue_as ENV['TEST_QUEUE_NAME'] end end end diff --git a/test/test_helper/jobs/error_job.rb b/test/test_helper/jobs/error_job.rb new file mode 100644 index 0000000..2c3497a --- /dev/null +++ b/test/test_helper/jobs/error_job.rb @@ -0,0 +1,9 @@ +module TestHelper + module Jobs + class ErrorJob < ApplicationJob + def perform(object) + raise('HELL') + end + end + end +end diff --git a/test/test_helper/message_helpers.rb b/test/test_helper/message_helpers.rb deleted file mode 100644 index 34f4369..0000000 --- a/test/test_helper/message_helpers.rb +++ /dev/null @@ -1,25 +0,0 @@ -module TestHelper - module MessageHelpers - - private - - def sent_message - client.api_requests.reverse.detect { |r| - r[:operation_name] == :send_message - } || flunk('No sent message request found.') - end - - def sent_message_params - sent_message[:params] - end - - def sent_message_body - JSON.parse sent_message_params[:message_body] - end - - def sent_message_attributes - sent_message_params[:message_attributes] - end - - end -end diff --git a/test/test_helper/queue_helpers.rb b/test/test_helper/queue_helpers.rb new file mode 100644 index 0000000..495e4ad --- /dev/null +++ b/test/test_helper/queue_helpers.rb @@ -0,0 +1,18 @@ +module TestHelper + module QueueHelpers + + private + + def queue_name + ENV['TEST_QUEUE_NAME'] + end + + def stub_get_queue_attributes(maxReceiveCount: 13) + redrive_policy = JSON.dump({maxReceiveCount: maxReceiveCount.to_s}) + client.stub_responses(:get_queue_attributes, { + attributes: { 'RedrivePolicy' => redrive_policy } + }) + end + + end +end From c3c97457c626e0a34dbe7256ef46ac3a27f2d646 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Thu, 31 Dec 2020 11:47:14 -0500 Subject: [PATCH 12/34] Use `handler` name like Lamby. --- lib/lambdakiq.rb | 4 ++-- lib/lambdakiq/job.rb | 2 +- test/cases/job_test.rb | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/lambdakiq.rb b/lib/lambdakiq.rb index f3b50a6..ec9e54e 100644 --- a/lib/lambdakiq.rb +++ b/lib/lambdakiq.rb @@ -17,8 +17,8 @@ module Lambdakiq - def handle(event) - Job.handle(event) + def handler(event) + Job.handler(event) end def jobs?(event) diff --git a/lib/lambdakiq/job.rb b/lib/lambdakiq/job.rb index 1fab150..5112c75 100644 --- a/lib/lambdakiq/job.rb +++ b/lib/lambdakiq/job.rb @@ -5,7 +5,7 @@ class Job class << self - def handle(event) + def handler(event) records = Event.records(event) jobs = records.map { |record| new(record) } jobs.each(&:perform) diff --git a/test/cases/job_test.rb b/test/cases/job_test.rb index 2e1db76..b7a9625 100644 --- a/test/cases/job_test.rb +++ b/test/cases/job_test.rb @@ -4,7 +4,7 @@ class JobTest < LambdakiqSpec it 'must change message visibility to next value for failed jobs' do stub_get_queue_attributes maxReceiveCount: 8 event = event_basic attributes: { ApproximateReceiveCount: '7' }, job_class: 'TestHelper::Jobs::ErrorJob' - expect(->{ Lambdakiq::Job.handle(event) }).must_raise 'HELL' + expect(->{ Lambdakiq::Job.handler(event) }).must_raise 'HELL' expect(change_message_visibility).must_be :present? expect(change_message_visibility_params[:visibility_timeout]).must_equal 1416 end @@ -12,7 +12,7 @@ class JobTest < LambdakiqSpec it 'must delete message for failed jobs at the end of the queue/message max receive count' do stub_get_queue_attributes maxReceiveCount: 8 event = event_basic attributes: { ApproximateReceiveCount: '8' }, job_class: 'TestHelper::Jobs::ErrorJob' - Lambdakiq::Job.handle(event) + Lambdakiq::Job.handler(event) expect(delete_message).must_be :present? end end From 3347d177e4798e9068558dc4038d5572caa42665 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Thu, 31 Dec 2020 18:41:10 -0500 Subject: [PATCH 13/34] Return a custom wrapped JobError with clean backtrace. --- TODO.md | 5 +---- lib/lambdakiq.rb | 1 + lib/lambdakiq/error.rb | 13 +++++++++++++ lib/lambdakiq/job.rb | 3 ++- 4 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 lib/lambdakiq/error.rb diff --git a/TODO.md b/TODO.md index d20f760..7fbc8cf 100644 --- a/TODO.md +++ b/TODO.md @@ -24,11 +24,9 @@ def _enqueue(job, send_message_opts = {}) end ``` -* Use job's `attr_accessor :executions` vs `ApproximateReceiveCount` * Error handlers. Ensure we easily hook into Rollbar, etc. -* Is `delete_message` message needed? Is 200 from consumer implied delete? * Can I set Rails tempalte `VisibilityTimeout` to just +1 of function timeout or full 43200? -* Can I get rid of the Job re-raising and rely on change message visibility alone? +* Do this in our gem. `ActiveJob::Base.logger = Logger.new(IO::NULL)` ## Doc Points @@ -60,7 +58,6 @@ DO I MIRROR or MIGRATE ## Max Retries * Max is twelve. -* ## Migrating from Sidekiq diff --git a/lib/lambdakiq.rb b/lib/lambdakiq.rb index ec9e54e..a2f2eb2 100644 --- a/lib/lambdakiq.rb +++ b/lib/lambdakiq.rb @@ -4,6 +4,7 @@ require 'active_job/queue_adapters' require 'active_support/all' require 'lambdakiq/version' +require 'lambdakiq/error' require 'lambdakiq/adapter' require 'lambdakiq/client' require 'lambdakiq/queue' diff --git a/lib/lambdakiq/error.rb b/lib/lambdakiq/error.rb new file mode 100644 index 0000000..bc0ec7a --- /dev/null +++ b/lib/lambdakiq/error.rb @@ -0,0 +1,13 @@ +module Lambdakiq + class Error < StandardError + attr_reader :original_exception, :job + + def initialize(error) + @original_exception = error + super(error.message) + set_backtrace Rails.backtrace_cleaner.clean(error.backtrace) + end + end + + class JobError < Error ; end +end diff --git a/lib/lambdakiq/job.rb b/lib/lambdakiq/job.rb index 5112c75..ca2e8d4 100644 --- a/lib/lambdakiq/job.rb +++ b/lib/lambdakiq/job.rb @@ -10,7 +10,8 @@ def handler(event) jobs = records.map { |record| new(record) } jobs.each(&:perform) jwerror = jobs.detect{ |j| j.error } - jwerror ? raise(jwerror.error) : true + return unless jwerror + raise JobError.new(jwerror.error) end end From e4f77ccc41108d4d8551ee719d651d053e939687 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Fri, 1 Jan 2021 09:31:56 -0500 Subject: [PATCH 14/34] DEBUG Delay. --- lib/lambdakiq/adapter.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/lambdakiq/adapter.rb b/lib/lambdakiq/adapter.rb index 038c9c1..6deb805 100644 --- a/lib/lambdakiq/adapter.rb +++ b/lib/lambdakiq/adapter.rb @@ -8,6 +8,7 @@ def enqueue(job, options = {}) end def enqueue_at(job, timestamp) + puts "DELAY: #{delay_seconds(timestamp)}" enqueue job, delay_seconds: delay_seconds(timestamp) end From b5c684acf6c514f2e8cb101ec42422ea99d20fe4 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Fri, 1 Jan 2021 09:52:45 -0500 Subject: [PATCH 15/34] Test wrapped error. --- lib/lambdakiq/adapter.rb | 1 - test/cases/job_test.rb | 9 +++++++-- test/cases/queue_test.rb | 1 - test/test_helper.rb | 1 + test/test_helper/client_helpers.rb | 8 ++++++++ test/test_helper/queue_helpers.rb | 7 ------- 6 files changed, 16 insertions(+), 11 deletions(-) diff --git a/lib/lambdakiq/adapter.rb b/lib/lambdakiq/adapter.rb index 6deb805..038c9c1 100644 --- a/lib/lambdakiq/adapter.rb +++ b/lib/lambdakiq/adapter.rb @@ -8,7 +8,6 @@ def enqueue(job, options = {}) end def enqueue_at(job, timestamp) - puts "DELAY: #{delay_seconds(timestamp)}" enqueue job, delay_seconds: delay_seconds(timestamp) end diff --git a/test/cases/job_test.rb b/test/cases/job_test.rb index b7a9625..869ce15 100644 --- a/test/cases/job_test.rb +++ b/test/cases/job_test.rb @@ -2,15 +2,20 @@ class JobTest < LambdakiqSpec it 'must change message visibility to next value for failed jobs' do - stub_get_queue_attributes maxReceiveCount: 8 event = event_basic attributes: { ApproximateReceiveCount: '7' }, job_class: 'TestHelper::Jobs::ErrorJob' expect(->{ Lambdakiq::Job.handler(event) }).must_raise 'HELL' expect(change_message_visibility).must_be :present? expect(change_message_visibility_params[:visibility_timeout]).must_equal 1416 end + it 'wraps returned errors with no backtrace which avoids excessive/duplicate cloudwatch logging' do + event = event_basic job_class: 'TestHelper::Jobs::ErrorJob' + error = expect(->{ Lambdakiq::Job.handler(event) }).must_raise 'HELL' + expect(error.class.name).must_equal 'Lambdakiq::JobError' + expect(error.backtrace).must_equal [] + end + it 'must delete message for failed jobs at the end of the queue/message max receive count' do - stub_get_queue_attributes maxReceiveCount: 8 event = event_basic attributes: { ApproximateReceiveCount: '8' }, job_class: 'TestHelper::Jobs::ErrorJob' Lambdakiq::Job.handler(event) expect(delete_message).must_be :present? diff --git a/test/cases/queue_test.rb b/test/cases/queue_test.rb index 7291144..6a34420 100644 --- a/test/cases/queue_test.rb +++ b/test/cases/queue_test.rb @@ -2,7 +2,6 @@ class QueueTest < LambdakiqSpec it '#max_receive_count' do - stub_get_queue_attributes maxReceiveCount: 8 queue = Lambdakiq.client.queues[queue_name] expect(queue.max_receive_count).must_equal 8 end diff --git a/test/test_helper.rb b/test/test_helper.rb index 72dbe89..7ec926b 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,6 +3,7 @@ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) require 'bundler/setup' Bundler.require :default, :development, :test +require 'rails' require 'aws-sdk-sqs' require 'minitest/autorun' require 'minitest/focus' diff --git a/test/test_helper/client_helpers.rb b/test/test_helper/client_helpers.rb index bd1cfde..59160ea 100644 --- a/test/test_helper/client_helpers.rb +++ b/test/test_helper/client_helpers.rb @@ -15,11 +15,19 @@ def client_stub_responses client.stub_responses(:get_queue_url, { queue_url: 'https://sqs.us-stubbed-1.amazonaws.com' }) + redrive_policy = JSON.dump({maxReceiveCount: max_receive_count.to_s}) + client.stub_responses(:get_queue_attributes, { + attributes: { 'RedrivePolicy' => redrive_policy } + }) end def api_requests client.api_requests end + def max_receive_count + 8 + end + end end diff --git a/test/test_helper/queue_helpers.rb b/test/test_helper/queue_helpers.rb index 495e4ad..2a3bf80 100644 --- a/test/test_helper/queue_helpers.rb +++ b/test/test_helper/queue_helpers.rb @@ -7,12 +7,5 @@ def queue_name ENV['TEST_QUEUE_NAME'] end - def stub_get_queue_attributes(maxReceiveCount: 13) - redrive_policy = JSON.dump({maxReceiveCount: maxReceiveCount.to_s}) - client.stub_responses(:get_queue_attributes, { - attributes: { 'RedrivePolicy' => redrive_policy } - }) - end - end end From 59811de3223162648c04e8b579bba02cc8f3065c Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Fri, 1 Jan 2021 15:38:14 -0500 Subject: [PATCH 16/34] Initial metrics PORO. --- lib/lambdakiq.rb | 1 + lib/lambdakiq/metrics.rb | 73 ++++++++++++++++++++++++++++++ test/test_helper/client_helpers.rb | 9 ++-- 3 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 lib/lambdakiq/metrics.rb diff --git a/lib/lambdakiq.rb b/lib/lambdakiq.rb index a2f2eb2..d8c6278 100644 --- a/lib/lambdakiq.rb +++ b/lib/lambdakiq.rb @@ -13,6 +13,7 @@ require 'lambdakiq/job' require 'lambdakiq/record' require 'lambdakiq/backoff' +require 'lambdakiq/metrics' require 'rails/railtie' require 'lambdakiq/railtie' diff --git a/lib/lambdakiq/metrics.rb b/lib/lambdakiq/metrics.rb new file mode 100644 index 0000000..7ce5515 --- /dev/null +++ b/lib/lambdakiq/metrics.rb @@ -0,0 +1,73 @@ +module Lambdakiq + class Metrics + + def initialize + @logger = ActiveJob::Base.logger + @namespace = Rails.application.class.name.split('::').first + @dimensions = Concurrent::Array.new + @metrics = Concurrent::Array.new + @properties = Concurrent::Hash.new + end + + def metrics + yield(self) + ensure + flush + end + + def flush + @logger.info(message) unless empty? + end + + def benchmark + value = nil + seconds = Benchmark.realtime { value = yield } + milliseconds = (seconds * 1000).to_i + [value, milliseconds] + end + + def put_dimension(name, value) + @dimensions << { name => value } + self + end + + def put_metric(name, value, unit = nil) + @metrics << { 'Name' => name }.tap do |m| + m['Unit'] = unit if unit + end + set_property name, value + end + + def set_property(name, value) + @properties[name] = value + self + end + + def empty? + [@dimensions, @metrics, @properties].all?(&:empty?) + end + + def message + { + '_aws' => { + 'Timestamp' => timestamp, + 'CloudWatchMetrics' => [ + { + 'Namespace' => @namespace, + 'Dimensions' => [@dimensions.map(&:keys).flatten], + 'Metrics' => @metrics + } + ] + } + }.tap do |m| + @dimensions.each { |dim| m.merge!(dim) } + m.merge!(@properties) + end + end + + def timestamp + Time.now.strftime('%s%3N').to_i + end + + end +end diff --git a/test/test_helper/client_helpers.rb b/test/test_helper/client_helpers.rb index 59160ea..5e2e5e5 100644 --- a/test/test_helper/client_helpers.rb +++ b/test/test_helper/client_helpers.rb @@ -1,5 +1,10 @@ module TestHelper module ClientHelpers + extend ActiveSupport::Concern + + included do + let(:max_receive_count) { 8 } + end private @@ -25,9 +30,5 @@ def api_requests client.api_requests end - def max_receive_count - 8 - end - end end From 5723df0bbabe8e1e1630b9e9a1eda71d6cf5b4c9 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Fri, 1 Jan 2021 20:37:21 -0500 Subject: [PATCH 17/34] Few more job tests WRT delay and FIFO. --- Rakefile | 2 +- TODO.md | 12 ++++++++++++ lib/lambdakiq.rb | 1 + lib/lambdakiq/worker.rb | 5 +++++ test/cases/jobs/basic_job_delay_test.rb | 15 +++++++++++++++ test/cases/jobs/basic_job_nofifo_delay_test.rb | 17 +++++++++++++++++ .../basic_job_nofifo_job_test.rb} | 3 ++- test/cases/{ => jobs}/basic_job_test.rb | 4 ++++ test/cases/queue_test.rb | 7 ++++++- test/test_helper/jobs/basic_nofifo_job.rb | 2 +- 10 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 lib/lambdakiq/worker.rb create mode 100644 test/cases/jobs/basic_job_delay_test.rb create mode 100644 test/cases/jobs/basic_job_nofifo_delay_test.rb rename test/cases/{basic_nofifo_job_test.rb => jobs/basic_job_nofifo_job_test.rb} (85%) rename test/cases/{ => jobs}/basic_job_test.rb (86%) diff --git a/Rakefile b/Rakefile index dba4c3a..81933a9 100644 --- a/Rakefile +++ b/Rakefile @@ -5,7 +5,7 @@ require "rake/testtask" Rake::TestTask.new(:test) do |t| t.libs << "test" t.libs << "lib" - t.test_files = FileList["test/cases/*_test.rb"] + t.test_files = FileList["test/cases/**/*_test.rb"] t.verbose = false t.warning = false end diff --git a/TODO.md b/TODO.md index 7fbc8cf..e52709f 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,10 @@ end - FIFO: 256 KB?? * Setting `maxReceiveCount` hard codes your retries to -1 of that value at the queue level. +Q: How do I handle job priorities? +A: Use different queues. + + ## Our Siqekiq Interfaces ```ruby @@ -61,6 +65,14 @@ DO I MIRROR or MIGRATE ## Migrating from Sidekiq +#### Change Worker + +```ruby +class ApplicationJob < ActiveJob::Base + include Sidekiq::Worker + include Lambdakiq::Worker +end +``` #### Single Job diff --git a/lib/lambdakiq.rb b/lib/lambdakiq.rb index d8c6278..48bab9c 100644 --- a/lib/lambdakiq.rb +++ b/lib/lambdakiq.rb @@ -14,6 +14,7 @@ require 'lambdakiq/record' require 'lambdakiq/backoff' require 'lambdakiq/metrics' +require 'lambdakiq/worker' require 'rails/railtie' require 'lambdakiq/railtie' diff --git a/lib/lambdakiq/worker.rb b/lib/lambdakiq/worker.rb new file mode 100644 index 0000000..c49594f --- /dev/null +++ b/lib/lambdakiq/worker.rb @@ -0,0 +1,5 @@ +module Lambdakiq + module Worker + + end +end diff --git a/test/cases/jobs/basic_job_delay_test.rb b/test/cases/jobs/basic_job_delay_test.rb new file mode 100644 index 0000000..df61ce6 --- /dev/null +++ b/test/cases/jobs/basic_job_delay_test.rb @@ -0,0 +1,15 @@ +require 'test_helper' + +class BasicJobDelayTest < LambdakiqSpec + before do + TestHelper::Jobs::BasicJob.set(wait: 5.minutes).perform_later('somework') + expect(sent_message).must_be :present? + end + + it 'message attributes include `delay_seconds` since no wait was set' do + delay_seconds = sent_message_attributes['delay_seconds'] + expect(delay_seconds).must_be :present? + expect(delay_seconds[:data_type]).must_equal 'String' + expect(delay_seconds[:string_value]).must_equal '300' + end +end diff --git a/test/cases/jobs/basic_job_nofifo_delay_test.rb b/test/cases/jobs/basic_job_nofifo_delay_test.rb new file mode 100644 index 0000000..96c26d4 --- /dev/null +++ b/test/cases/jobs/basic_job_nofifo_delay_test.rb @@ -0,0 +1,17 @@ +require 'test_helper' + +class BasicJobNofifoDelayTest < LambdakiqSpec + before do + TestHelper::Jobs::BasicNofifoJob.set(wait: 5.minutes).perform_later('somework') + expect(sent_message).must_be :present? + end + + it 'uses default `delay_seconds` since non-FIFO queues support this natively' do + expect(sent_message_params[:delay_seconds]).must_equal 300 + end + + it 'message attributes exclude `delay_seconds` since non-FIFO queues support this natively' do + delay_seconds = sent_message_attributes['delay_seconds'] + expect(delay_seconds).must_be_nil + end +end diff --git a/test/cases/basic_nofifo_job_test.rb b/test/cases/jobs/basic_job_nofifo_job_test.rb similarity index 85% rename from test/cases/basic_nofifo_job_test.rb rename to test/cases/jobs/basic_job_nofifo_job_test.rb index 1fe0a25..c39433c 100644 --- a/test/cases/basic_nofifo_job_test.rb +++ b/test/cases/jobs/basic_job_nofifo_job_test.rb @@ -1,8 +1,9 @@ require 'test_helper' -class BasicNofifoJobTest < LambdakiqSpec +class BasicJobNofifoTest < LambdakiqSpec before do TestHelper::Jobs::BasicNofifoJob.perform_later('somework') + expect(sent_message).must_be :present? end it 'message body has no fifo queue nave vs fifo super class ' do diff --git a/test/cases/basic_job_test.rb b/test/cases/jobs/basic_job_test.rb similarity index 86% rename from test/cases/basic_job_test.rb rename to test/cases/jobs/basic_job_test.rb index 88268e0..fbd42d8 100644 --- a/test/cases/basic_job_test.rb +++ b/test/cases/jobs/basic_job_test.rb @@ -19,6 +19,10 @@ class BasicJobTest < LambdakiqSpec expect(lambdakiq[:string_value]).must_equal '1' end + it 'message attributes do not include `delay_seconds` since no wait was set' do + expect(sent_message_attributes.key?('delay_seconds')).must_equal false + end + it 'message group and deduplication id for default fifo queue are sent' do expect(sent_message_params[:message_group_id]).must_equal 'LambdakiqMessage' expect(sent_message_params[:message_deduplication_id]).must_be :present? diff --git a/test/cases/queue_test.rb b/test/cases/queue_test.rb index 6a34420..5efb081 100644 --- a/test/cases/queue_test.rb +++ b/test/cases/queue_test.rb @@ -1,8 +1,13 @@ require 'test_helper' class QueueTest < LambdakiqSpec + let(:queue) { Lambdakiq.client.queues[queue_name] } + + it '#fifo?' do + expect(queue.fifo?).must_equal true + end + it '#max_receive_count' do - queue = Lambdakiq.client.queues[queue_name] expect(queue.max_receive_count).must_equal 8 end end diff --git a/test/test_helper/jobs/basic_nofifo_job.rb b/test/test_helper/jobs/basic_nofifo_job.rb index 62753df..bdd345e 100644 --- a/test/test_helper/jobs/basic_nofifo_job.rb +++ b/test/test_helper/jobs/basic_nofifo_job.rb @@ -1,7 +1,7 @@ module TestHelper module Jobs class BasicNofifoJob < ApplicationJob - queue_as 'lambdakiq-JobsQueue-TESTING123' + queue_as ENV['TEST_QUEUE_NAME'].sub('.fifo','') def perform(object) object end From d521c55d939f31cee6ad3f30c8c60586ded543ed Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Sat, 2 Jan 2021 09:41:00 -0500 Subject: [PATCH 18/34] FIFO Queues Work With Delay. Logging/Perform Helpers. --- TODO.md | 1 + lib/lambdakiq/job.rb | 19 ++++++---- lib/lambdakiq/metrics.rb | 2 +- lib/lambdakiq/record.rb | 16 +++++++- test/cases/job_test.rb | 46 +++++++++++++++++++++++ test/test_helper.rb | 8 +++- test/test_helper/event_helpers.rb | 14 +++++++ test/test_helper/jobs/basic_job.rb | 2 +- test/test_helper/jobs/basic_nofifo_job.rb | 2 +- test/test_helper/jobs/error_job.rb | 1 + test/test_helper/log_helpers.rb | 15 ++++++++ test/test_helper/perform_helpers.rb | 32 ++++++++++++++++ 12 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 test/test_helper/log_helpers.rb create mode 100644 test/test_helper/perform_helpers.rb diff --git a/TODO.md b/TODO.md index e52709f..07461f8 100644 --- a/TODO.md +++ b/TODO.md @@ -47,6 +47,7 @@ end Q: How do I handle job priorities? A: Use different queues. +* How we allow FIFO queues to work with delay using message visibility. ## Our Siqekiq Interfaces diff --git a/lib/lambdakiq/job.rb b/lib/lambdakiq/job.rb index ca2e8d4..3565097 100644 --- a/lib/lambdakiq/job.rb +++ b/lib/lambdakiq/job.rb @@ -1,7 +1,7 @@ module Lambdakiq class Job - attr_reader :record, :error, :sent_timestamp + attr_reader :record, :error class << self @@ -33,13 +33,13 @@ def queue Lambdakiq.client.queues[active_job.queue_name] end - def performed? - @started_at.present? && !error - end - def perform - @started_at = Time.current - ActiveJob::Base.execute(job_data) + if queue.fifo? && record.fifo_delay_seconds? + delay_fifo_message_visibility + else + ActiveJob::Base.execute(job_data) + end + delete_message rescue Exception => e perform_error(e) end @@ -79,5 +79,10 @@ def max_receive_count? record.max_receive_count? || record.receive_count >= queue.max_receive_count end + def delay_fifo_message_visibility + params = client_params.merge visibility_timeout: record.fifo_delay_visibility_timeout + client.change_message_visibility(params) + end + end end diff --git a/lib/lambdakiq/metrics.rb b/lib/lambdakiq/metrics.rb index 7ce5515..271f3fd 100644 --- a/lib/lambdakiq/metrics.rb +++ b/lib/lambdakiq/metrics.rb @@ -66,7 +66,7 @@ def message end def timestamp - Time.now.strftime('%s%3N').to_i + Time.current.strftime('%s%3N').to_i end end diff --git a/lib/lambdakiq/record.rb b/lib/lambdakiq/record.rb index 8187a22..6175d7f 100644 --- a/lib/lambdakiq/record.rb +++ b/lib/lambdakiq/record.rb @@ -23,10 +23,22 @@ def attributes data['attributes'] end + def fifo_delay_visibility_timeout + fifo_delay_seconds - (Time.current - sent_at).to_i + end + + def fifo_delay_seconds + data.dig('messageAttributes', 'delay_seconds', 'stringValue').try(:to_i) + end + + def fifo_delay_seconds? + fifo_delay_seconds && (sent_at + fifo_delay_seconds).future? + end + def sent_at @sent_at ||= begin - ts = attributes['SentTimestamp'].to_i - Time.at(ts/1000) + ts = attributes['SentTimestamp'].to_i / 1000 + Time.zone ? Time.zone.at(ts) : Time.at(ts) end end diff --git a/test/cases/job_test.rb b/test/cases/job_test.rb index 869ce15..068160c 100644 --- a/test/cases/job_test.rb +++ b/test/cases/job_test.rb @@ -1,11 +1,23 @@ require 'test_helper' class JobTest < LambdakiqSpec + it 'must perform basic job' do + Lambdakiq::Job.handler(event_basic) + expect(delete_message).must_be :present? + expect(change_message_visibility).must_be_nil + expect(perform_buffer_last_value).must_equal 'BasicJob with: "test"' + expect(active_job_log).must_include 'Performing TestHelper::Jobs::BasicJob' + expect(active_job_log).must_include 'Performed TestHelper::Jobs::BasicJob' + end + it 'must change message visibility to next value for failed jobs' do event = event_basic attributes: { ApproximateReceiveCount: '7' }, job_class: 'TestHelper::Jobs::ErrorJob' expect(->{ Lambdakiq::Job.handler(event) }).must_raise 'HELL' expect(change_message_visibility).must_be :present? expect(change_message_visibility_params[:visibility_timeout]).must_equal 1416 + expect(perform_buffer_last_value).must_equal 'ErrorJob with: "test"' + expect(active_job_log).must_include 'Performing TestHelper::Jobs::ErrorJob' + expect(active_job_log).must_include 'Error performing TestHelper::Jobs::ErrorJob' end it 'wraps returned errors with no backtrace which avoids excessive/duplicate cloudwatch logging' do @@ -13,11 +25,45 @@ class JobTest < LambdakiqSpec error = expect(->{ Lambdakiq::Job.handler(event) }).must_raise 'HELL' expect(error.class.name).must_equal 'Lambdakiq::JobError' expect(error.backtrace).must_equal [] + expect(perform_buffer_last_value).must_equal 'ErrorJob with: "test"' + expect(active_job_log).must_include 'Performing TestHelper::Jobs::ErrorJob' + expect(active_job_log).must_include 'Error performing TestHelper::Jobs::ErrorJob' end it 'must delete message for failed jobs at the end of the queue/message max receive count' do event = event_basic attributes: { ApproximateReceiveCount: '8' }, job_class: 'TestHelper::Jobs::ErrorJob' Lambdakiq::Job.handler(event) expect(delete_message).must_be :present? + expect(perform_buffer_last_value).must_equal 'ErrorJob with: "test"' + expect(active_job_log).must_include 'Performing TestHelper::Jobs::ErrorJob' + expect(active_job_log).must_include 'Error performing TestHelper::Jobs::ErrorJob' + end + + it 'must not perform and allow fifo queue to use message visibility as delay' do + event = event_basic_delay minutes: 6 + Lambdakiq::Job.handler(event) + expect(change_message_visibility).must_be :present? + expect(change_message_visibility_params[:visibility_timeout]).must_equal 6.minutes + expect(perform_buffer_last_value).must_be_nil + expect(active_job_log).must_be :blank? + end + + it 'must not perform and allow fifo queue to use message visibility as delay (using SentTimestamp)' do + event = event_basic_delay minutes: 10, timestamp: 2.minutes.ago.strftime('%s%3N') + Lambdakiq::Job.handler(event) + expect(change_message_visibility).must_be :present? + expect(change_message_visibility_params[:visibility_timeout]).must_equal 8.minutes + expect(perform_buffer_last_value).must_be_nil + expect(active_job_log).must_be :blank? + end + + it 'must perform and allow fifo queue to use message visibility as delay but not when SentTimestamp is too far in the past' do + event = event_basic_delay minutes: 2, timestamp: 3.minutes.ago.strftime('%s%3N') + Lambdakiq::Job.handler(event) + expect(delete_message).must_be :present? + expect(change_message_visibility).must_be_nil + expect(perform_buffer_last_value).must_equal 'BasicJob with: "test"' + expect(active_job_log).must_include 'Performing TestHelper::Jobs::BasicJob' + expect(active_job_log).must_include 'Performed TestHelper::Jobs::BasicJob' end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 7ec926b..f822e4a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -5,13 +5,13 @@ Bundler.require :default, :development, :test require 'rails' require 'aws-sdk-sqs' +require 'stringio' require 'minitest/autorun' require 'minitest/focus' require 'mocha/minitest' Dir['test/test_helper/*.{rb}'].each { |f| require_relative "../#{f}" } ActiveJob::Base.queue_adapter = :lambdakiq -ActiveJob::Base.logger = Logger.new(IO::NULL) Lambdakiq::Client.default_options.merge! stub_responses: true class LambdakiqSpec < Minitest::Spec @@ -19,11 +19,15 @@ class LambdakiqSpec < Minitest::Spec include TestHelper::ClientHelpers, TestHelper::ApiRequestHelpers, TestHelper::EventHelpers, - TestHelper::QueueHelpers + TestHelper::QueueHelpers, + TestHelper::LogHelpers, + TestHelper::PerformHelpers before do client_reset! client_stub_responses + reset_active_job_logger! + perform_buffer_clear! end end diff --git a/test/test_helper/event_helpers.rb b/test/test_helper/event_helpers.rb index 8eb607d..f695391 100644 --- a/test/test_helper/event_helpers.rb +++ b/test/test_helper/event_helpers.rb @@ -10,5 +10,19 @@ def event_basic(overrides = {}) Events::Basic.create(overrides) end + def event_basic_delay(minutes: 5, timestamp: Time.current.strftime('%s%3N')) + Events::Basic.create( + attributes: { SentTimestamp: timestamp }, + messageAttributes: { + delay_seconds: { + stringValue: minutes.minutes.to_s, + stringListValues: [], + binaryListValues: [], + dataType: 'String' + } + } + ) + end + end end diff --git a/test/test_helper/jobs/basic_job.rb b/test/test_helper/jobs/basic_job.rb index 2a9f5b2..15c21ff 100644 --- a/test/test_helper/jobs/basic_job.rb +++ b/test/test_helper/jobs/basic_job.rb @@ -2,7 +2,7 @@ module TestHelper module Jobs class BasicJob < ApplicationJob def perform(object) - object + TestHelper::PerformBuffer.add "BasicJob with: #{object.inspect}" end end end diff --git a/test/test_helper/jobs/basic_nofifo_job.rb b/test/test_helper/jobs/basic_nofifo_job.rb index bdd345e..0902009 100644 --- a/test/test_helper/jobs/basic_nofifo_job.rb +++ b/test/test_helper/jobs/basic_nofifo_job.rb @@ -3,7 +3,7 @@ module Jobs class BasicNofifoJob < ApplicationJob queue_as ENV['TEST_QUEUE_NAME'].sub('.fifo','') def perform(object) - object + TestHelper::PerformBuffer.add "BasicNofifoJob with: #{object.inspect}" end end end diff --git a/test/test_helper/jobs/error_job.rb b/test/test_helper/jobs/error_job.rb index 2c3497a..b5b99a7 100644 --- a/test/test_helper/jobs/error_job.rb +++ b/test/test_helper/jobs/error_job.rb @@ -2,6 +2,7 @@ module TestHelper module Jobs class ErrorJob < ApplicationJob def perform(object) + TestHelper::PerformBuffer.add "ErrorJob with: #{object.inspect}" raise('HELL') end end diff --git a/test/test_helper/log_helpers.rb b/test/test_helper/log_helpers.rb new file mode 100644 index 0000000..26ea6e7 --- /dev/null +++ b/test/test_helper/log_helpers.rb @@ -0,0 +1,15 @@ +module TestHelper + module LogHelpers + extend ActiveSupport::Concern + + included do + let(:active_job_log) { ActiveJob::Base.logger.instance_variable_get(:@logdev).instance_variable_get(:@dev).string } + end + + private + + def reset_active_job_logger! + ActiveJob::Base.logger = Logger.new(StringIO.new) + end + end +end diff --git a/test/test_helper/perform_helpers.rb b/test/test_helper/perform_helpers.rb new file mode 100644 index 0000000..be1c686 --- /dev/null +++ b/test/test_helper/perform_helpers.rb @@ -0,0 +1,32 @@ +module TestHelper + module PerformBuffer + def clear + values.clear + end + + def add(value) + values << value + end + + def values + @values ||= [] + end + + def last_value + values.last + end + + extend self + end + module PerformHelpers + private + + def perform_buffer_clear! + PerformBuffer.clear + end + + def perform_buffer_last_value + PerformBuffer.last_value + end + end +end From e70546b119be6ea5a5ab36fd7aae781e8796b673 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Sat, 2 Jan 2021 20:12:01 -0500 Subject: [PATCH 19/34] CloudWatch Embedded Metrics --- lib/lambdakiq.rb | 4 ++ lib/lambdakiq/job.rb | 21 ++++--- lib/lambdakiq/metrics.rb | 99 ++++++++++++++++++++++----------- lib/lambdakiq/railtie.rb | 13 +++++ lib/lambdakiq/record.rb | 6 +- test/cases/job_test.rb | 37 ++++++++---- test/cases/record_test.rb | 2 +- test/dummy_app/config/.keep | 0 test/dummy_app/init.rb | 14 +++++ test/test_helper.rb | 5 +- test/test_helper/log_helpers.rb | 16 +++++- 11 files changed, 158 insertions(+), 59 deletions(-) create mode 100644 test/dummy_app/config/.keep create mode 100644 test/dummy_app/init.rb diff --git a/lib/lambdakiq.rb b/lib/lambdakiq.rb index 48bab9c..518e926 100644 --- a/lib/lambdakiq.rb +++ b/lib/lambdakiq.rb @@ -32,6 +32,10 @@ def client @client ||= Client.new end + def config + Lambdakiq::Railtie.config.lambdakiq + end + extend self end diff --git a/lib/lambdakiq/job.rb b/lib/lambdakiq/job.rb index 3565097..5f53b94 100644 --- a/lib/lambdakiq/job.rb +++ b/lib/lambdakiq/job.rb @@ -22,7 +22,10 @@ def initialize(record) end def job_data - @job_data ||= JSON.parse(record.body) + @job_data ||= JSON.parse(record.body).tap do |data| + data['provider_job_id'] = record.message_id + data['executions'] = record.receive_count - 1 + end end def active_job @@ -34,16 +37,16 @@ def queue end def perform - if queue.fifo? && record.fifo_delay_seconds? - delay_fifo_message_visibility - else - ActiveJob::Base.execute(job_data) - end + fifo_delay? ? fifo_delay : execute delete_message rescue Exception => e perform_error(e) end + def execute + ActiveJob::Base.execute(job_data) + end + private def client_params @@ -79,7 +82,11 @@ def max_receive_count? record.max_receive_count? || record.receive_count >= queue.max_receive_count end - def delay_fifo_message_visibility + def fifo_delay? + queue.fifo? && record.fifo_delay_seconds? + end + + def fifo_delay params = client_params.merge visibility_timeout: record.fifo_delay_visibility_timeout client.change_message_visibility(params) end diff --git a/lib/lambdakiq/metrics.rb b/lib/lambdakiq/metrics.rb index 271f3fd..f9c1e27 100644 --- a/lib/lambdakiq/metrics.rb +++ b/lib/lambdakiq/metrics.rb @@ -1,38 +1,72 @@ module Lambdakiq class Metrics + attr_reader :event - def initialize - @logger = ActiveJob::Base.logger - @namespace = Rails.application.class.name.split('::').first - @dimensions = Concurrent::Array.new - @metrics = Concurrent::Array.new - @properties = Concurrent::Hash.new + class << self + def log(event) + new(event).log + end end - def metrics - yield(self) - ensure - flush + def initialize(event) + @event = event + @metrics = [] + @properties = {} + instrument! end - def flush - @logger.info(message) unless empty? + def log + logger.info JSON.dump(message) end - def benchmark - value = nil - seconds = Benchmark.realtime { value = yield } - milliseconds = (seconds * 1000).to_i - [value, milliseconds] + private + + def job + event.payload[:job] end - def put_dimension(name, value) - @dimensions << { name => value } - self + def job_name + job.class.name + end + + def logger + Lambdakiq.config.metrics_logger + end + + def namespace + Lambdakiq.config.metrics_namespace + end + + def exception + event.payload[:exception].try(:first) + end + + def dimensions + [ + { AppName: rails_app_name }, + { JobEvent: event.name }, + { JobName: job_name } + ] + end + + def instrument! + put_metric 'Duration', event.duration.to_i, 'Milliseconds' + put_metric job_name, 1, 'Count' + put_metric 'Exceptions', 1, 'Count' if exception + set_property 'JobId', job.job_id + set_property 'JobName', job_name + set_property 'QueueName', job.queue_name + set_property 'MessageId', job.provider_job_id if job.provider_job_id + set_property 'Exception', exception if exception + set_property 'EnqueuedAt', job.enqueued_at if job.enqueued_at + set_property 'Executions', job.executions if job.executions + job.arguments.each_with_index do |argument, index| + set_property "JobArg#{index+1}", argument + end end def put_metric(name, value, unit = nil) - @metrics << { 'Name' => name }.tap do |m| + @metrics << { 'Name': name }.tap do |m| m['Unit'] = unit if unit end set_property name, value @@ -43,24 +77,20 @@ def set_property(name, value) self end - def empty? - [@dimensions, @metrics, @properties].all?(&:empty?) - end - def message { - '_aws' => { - 'Timestamp' => timestamp, - 'CloudWatchMetrics' => [ + '_aws': { + 'Timestamp': timestamp, + 'CloudWatchMetrics': [ { - 'Namespace' => @namespace, - 'Dimensions' => [@dimensions.map(&:keys).flatten], - 'Metrics' => @metrics + 'Namespace': namespace, + 'Dimensions': [dimensions.map(&:keys).flatten], + 'Metrics': @metrics } ] } }.tap do |m| - @dimensions.each { |dim| m.merge!(dim) } + dimensions.each { |d| m.merge!(d) } m.merge!(@properties) end end @@ -69,5 +99,10 @@ def timestamp Time.current.strftime('%s%3N').to_i end + def rails_app_name + Rails.application.class.name.split('::').first + end + end end + diff --git a/lib/lambdakiq/railtie.rb b/lib/lambdakiq/railtie.rb index 7d78c4e..667c340 100644 --- a/lib/lambdakiq/railtie.rb +++ b/lib/lambdakiq/railtie.rb @@ -2,5 +2,18 @@ module Lambdakiq class Railtie < ::Rails::Railtie config.lambdakiq = ActiveSupport::OrderedOptions.new config.lambdakiq.max_retries = 12 + config.lambdakiq.metrics_namespace = 'Lambdakiq' + + config.after_initialize do + config.active_job.logger = Rails.logger + config.lambdakiq.metrics_logger = Rails.logger + end + + initializer "lambdakiq.metrics" do |app| + ActiveSupport::Notifications.subscribe(/active_job/) do |*args| + event = ActiveSupport::Notifications::Event.new *args + Lambdakiq::Metrics.log(event) + end + end end end diff --git a/lib/lambdakiq/record.rb b/lib/lambdakiq/record.rb index 6175d7f..70fcf81 100644 --- a/lib/lambdakiq/record.rb +++ b/lib/lambdakiq/record.rb @@ -11,6 +11,10 @@ def body data['body'] end + def message_id + data['messageId'] + end + def receipt_handle data['receiptHandle'] end @@ -47,7 +51,7 @@ def receive_count end def max_receive_count? - receive_count >= 12 + receive_count >= Lambdakiq.config.max_retries end def next_visibility_timeout diff --git a/test/cases/job_test.rb b/test/cases/job_test.rb index 068160c..9f4bc45 100644 --- a/test/cases/job_test.rb +++ b/test/cases/job_test.rb @@ -6,8 +6,21 @@ class JobTest < LambdakiqSpec expect(delete_message).must_be :present? expect(change_message_visibility).must_be_nil expect(perform_buffer_last_value).must_equal 'BasicJob with: "test"' - expect(active_job_log).must_include 'Performing TestHelper::Jobs::BasicJob' - expect(active_job_log).must_include 'Performed TestHelper::Jobs::BasicJob' + expect(logger).must_include 'Performing TestHelper::Jobs::BasicJob' + expect(logger).must_include 'Performed TestHelper::Jobs::BasicJob' + end + + it 'logs cloudwatch embedded metrics' do + Lambdakiq::Job.handler(event_basic) + metric = logged_metric('perform.active_job') + expect(metric).must_be :present? + expect(metric['AppName']).must_equal 'Dummy' + expect(metric['JobName']).must_equal 'TestHelper::Jobs::BasicJob' + expect(metric['Duration']).must_equal 0 + expect(metric['JobId']).must_equal '527cd37e-08f4-4aa8-9834-a46220cdc5a3' + expect(metric['QueueName']).must_equal 'lambdakiq-JobsQueue-TESTING123.fifo' + expect(metric['MessageId']).must_equal '9081fe74-bc79-451f-a03a-2fe5c6e2f807' + expect(metric['JobArg1']).must_equal 'test' end it 'must change message visibility to next value for failed jobs' do @@ -16,8 +29,8 @@ class JobTest < LambdakiqSpec expect(change_message_visibility).must_be :present? expect(change_message_visibility_params[:visibility_timeout]).must_equal 1416 expect(perform_buffer_last_value).must_equal 'ErrorJob with: "test"' - expect(active_job_log).must_include 'Performing TestHelper::Jobs::ErrorJob' - expect(active_job_log).must_include 'Error performing TestHelper::Jobs::ErrorJob' + expect(logger).must_include 'Performing TestHelper::Jobs::ErrorJob' + expect(logger).must_include 'Error performing TestHelper::Jobs::ErrorJob' end it 'wraps returned errors with no backtrace which avoids excessive/duplicate cloudwatch logging' do @@ -26,8 +39,8 @@ class JobTest < LambdakiqSpec expect(error.class.name).must_equal 'Lambdakiq::JobError' expect(error.backtrace).must_equal [] expect(perform_buffer_last_value).must_equal 'ErrorJob with: "test"' - expect(active_job_log).must_include 'Performing TestHelper::Jobs::ErrorJob' - expect(active_job_log).must_include 'Error performing TestHelper::Jobs::ErrorJob' + expect(logger).must_include 'Performing TestHelper::Jobs::ErrorJob' + expect(logger).must_include 'Error performing TestHelper::Jobs::ErrorJob' end it 'must delete message for failed jobs at the end of the queue/message max receive count' do @@ -35,8 +48,8 @@ class JobTest < LambdakiqSpec Lambdakiq::Job.handler(event) expect(delete_message).must_be :present? expect(perform_buffer_last_value).must_equal 'ErrorJob with: "test"' - expect(active_job_log).must_include 'Performing TestHelper::Jobs::ErrorJob' - expect(active_job_log).must_include 'Error performing TestHelper::Jobs::ErrorJob' + expect(logger).must_include 'Performing TestHelper::Jobs::ErrorJob' + expect(logger).must_include 'Error performing TestHelper::Jobs::ErrorJob' end it 'must not perform and allow fifo queue to use message visibility as delay' do @@ -45,7 +58,7 @@ class JobTest < LambdakiqSpec expect(change_message_visibility).must_be :present? expect(change_message_visibility_params[:visibility_timeout]).must_equal 6.minutes expect(perform_buffer_last_value).must_be_nil - expect(active_job_log).must_be :blank? + expect(logger).must_be :blank? end it 'must not perform and allow fifo queue to use message visibility as delay (using SentTimestamp)' do @@ -54,7 +67,7 @@ class JobTest < LambdakiqSpec expect(change_message_visibility).must_be :present? expect(change_message_visibility_params[:visibility_timeout]).must_equal 8.minutes expect(perform_buffer_last_value).must_be_nil - expect(active_job_log).must_be :blank? + expect(logger).must_be :blank? end it 'must perform and allow fifo queue to use message visibility as delay but not when SentTimestamp is too far in the past' do @@ -63,7 +76,7 @@ class JobTest < LambdakiqSpec expect(delete_message).must_be :present? expect(change_message_visibility).must_be_nil expect(perform_buffer_last_value).must_equal 'BasicJob with: "test"' - expect(active_job_log).must_include 'Performing TestHelper::Jobs::BasicJob' - expect(active_job_log).must_include 'Performed TestHelper::Jobs::BasicJob' + expect(logger).must_include 'Performing TestHelper::Jobs::BasicJob' + expect(logger).must_include 'Performed TestHelper::Jobs::BasicJob' end end diff --git a/test/cases/record_test.rb b/test/cases/record_test.rb index d0330ef..8e83998 100644 --- a/test/cases/record_test.rb +++ b/test/cases/record_test.rb @@ -25,7 +25,7 @@ class RecordTest < LambdakiqSpec it '#sent_at' do sent_at = record.sent_at - expect(sent_at).must_be_instance_of Time + expect(sent_at).must_be_instance_of ActiveSupport::TimeWithZone expect(sent_at.year).must_equal 2020 expect(sent_at.month).must_equal 11 expect(sent_at.day).must_equal 30 diff --git a/test/dummy_app/config/.keep b/test/dummy_app/config/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/dummy_app/init.rb b/test/dummy_app/init.rb new file mode 100644 index 0000000..48d455e --- /dev/null +++ b/test/dummy_app/init.rb @@ -0,0 +1,14 @@ +require 'rails/all' + +module Dummy + class Application < ::Rails::Application + config.root = File.join __FILE__, '..' + config.eager_load = true + logger = ActiveSupport::Logger.new(StringIO.new) + logger.formatter = ActiveSupport::Logger::SimpleFormatter.new + config.logger = logger + config.active_job.queue_adapter = :lambdakiq + end +end + +Dummy::Application.initialize! diff --git a/test/test_helper.rb b/test/test_helper.rb index f822e4a..0d0b664 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -10,9 +10,8 @@ require 'minitest/focus' require 'mocha/minitest' Dir['test/test_helper/*.{rb}'].each { |f| require_relative "../#{f}" } - -ActiveJob::Base.queue_adapter = :lambdakiq Lambdakiq::Client.default_options.merge! stub_responses: true +require_relative './dummy_app/init' class LambdakiqSpec < Minitest::Spec @@ -26,7 +25,7 @@ class LambdakiqSpec < Minitest::Spec before do client_reset! client_stub_responses - reset_active_job_logger! + logger_reset! perform_buffer_clear! end diff --git a/test/test_helper/log_helpers.rb b/test/test_helper/log_helpers.rb index 26ea6e7..ccd4849 100644 --- a/test/test_helper/log_helpers.rb +++ b/test/test_helper/log_helpers.rb @@ -3,13 +3,23 @@ module LogHelpers extend ActiveSupport::Concern included do - let(:active_job_log) { ActiveJob::Base.logger.instance_variable_get(:@logdev).instance_variable_get(:@dev).string } + let(:logger) { Rails.logger.instance_variable_get(:@logdev).instance_variable_get(:@dev).string } end private - def reset_active_job_logger! - ActiveJob::Base.logger = Logger.new(StringIO.new) + def logged_metric(event) + metric = logged_metrics.reverse.detect { |l| l.include?(event) } + JSON.parse(metric) if metric end + + def logged_metrics + logger.each_line.select { |l| l.include? 'CloudWatchMetrics' } + end + + def logger_reset! + Rails.logger.instance_variable_get(:@logdev).instance_variable_get(:@dev).truncate 0 + end + end end From 760be272c829772a2ed614cb318dec1ae67182ed Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Sun, 3 Jan 2021 20:17:21 -0500 Subject: [PATCH 20/34] Implement Worker mixin with lambdakiq_options. --- TODO.md | 2 + lib/lambdakiq/job.rb | 23 ++++++++-- lib/lambdakiq/record.rb | 4 -- lib/lambdakiq/worker.rb | 19 ++++++++ test/cases/job_test.rb | 48 ++++++++++++++++++++ test/cases/record_test.rb | 4 -- test/test_helper/jobs.rb | 2 + test/test_helper/jobs/application_job.rb | 1 + test/test_helper/jobs/error_job_no_retry.rb | 11 +++++ test/test_helper/jobs/error_job_one_retry.rb | 11 +++++ 10 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 test/test_helper/jobs/error_job_no_retry.rb create mode 100644 test/test_helper/jobs/error_job_one_retry.rb diff --git a/TODO.md b/TODO.md index 07461f8..28bd120 100644 --- a/TODO.md +++ b/TODO.md @@ -48,6 +48,8 @@ Q: How do I handle job priorities? A: Use different queues. * How we allow FIFO queues to work with delay using message visibility. +* Your SQS queue must have a `RedrivePolicy` policy! + https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-sqs-queues.html#aws-sqs-queue-redrive ## Our Siqekiq Interfaces diff --git a/lib/lambdakiq/job.rb b/lib/lambdakiq/job.rb index 5f53b94..e704d06 100644 --- a/lib/lambdakiq/job.rb +++ b/lib/lambdakiq/job.rb @@ -36,15 +36,20 @@ def queue Lambdakiq.client.queues[active_job.queue_name] end + def executions + active_job.executions + end + def perform fifo_delay? ? fifo_delay : execute - delete_message - rescue Exception => e - perform_error(e) end def execute ActiveJob::Base.execute(job_data) + delete_message + rescue Exception => e + increment_executions + perform_error(e) end private @@ -79,7 +84,13 @@ def client end def max_receive_count? - record.max_receive_count? || record.receive_count >= queue.max_receive_count + executions > retry_limit + end + + def retry_limit + config_retry = [Lambdakiq.config.max_retries, 12].min + [ (active_job.lambdakiq_retry || config_retry), + (queue.max_receive_count - 1) ].min end def fifo_delay? @@ -91,5 +102,9 @@ def fifo_delay client.change_message_visibility(params) end + def increment_executions + active_job.executions = active_job.executions + 1 + end + end end diff --git a/lib/lambdakiq/record.rb b/lib/lambdakiq/record.rb index 70fcf81..82e85e3 100644 --- a/lib/lambdakiq/record.rb +++ b/lib/lambdakiq/record.rb @@ -50,10 +50,6 @@ def receive_count @receive_count ||= attributes['ApproximateReceiveCount'].to_i end - def max_receive_count? - receive_count >= Lambdakiq.config.max_retries - end - def next_visibility_timeout @next_visibility_timeout ||= Backoff.backoff(receive_count) end diff --git a/lib/lambdakiq/worker.rb b/lib/lambdakiq/worker.rb index c49594f..475f9ed 100644 --- a/lib/lambdakiq/worker.rb +++ b/lib/lambdakiq/worker.rb @@ -1,5 +1,24 @@ module Lambdakiq module Worker + extend ActiveSupport::Concern + + included do + class_attribute :lambdakiq_options_hash, + instance_predicate: false, + default: Hash.new + end + + class_methods do + + def lambdakiq_options(options = {}) + self.lambdakiq_options_hash = options.symbolize_keys + end + + end + + def lambdakiq_retry + lambdakiq_options_hash[:retry] + end end end diff --git a/test/cases/job_test.rb b/test/cases/job_test.rb index 9f4bc45..66f5da8 100644 --- a/test/cases/job_test.rb +++ b/test/cases/job_test.rb @@ -1,6 +1,23 @@ require 'test_helper' class JobTest < LambdakiqSpec + + it '#active_job - a deserialize representation of what will be executed' do + aj = job.active_job + expect(aj).must_be_instance_of TestHelper::Jobs::BasicJob + expect(aj.job_id).must_equal '527cd37e-08f4-4aa8-9834-a46220cdc5a3' + expect(aj.queue_name).must_equal queue_name + expect(aj.enqueued_at).must_equal '2020-11-30T13:07:36Z' + expect(aj.executions).must_equal 0 + expect(aj.provider_job_id).must_equal '9081fe74-bc79-451f-a03a-2fe5c6e2f807' + end + + it '#active_job - executions uses ApproximateReceiveCount' do + event = event_basic attributes: { ApproximateReceiveCount: '3' } + aj = job(event: event).active_job + expect(aj.executions).must_equal 2 + end + it 'must perform basic job' do Lambdakiq::Job.handler(event_basic) expect(delete_message).must_be :present? @@ -44,6 +61,7 @@ class JobTest < LambdakiqSpec end it 'must delete message for failed jobs at the end of the queue/message max receive count' do + # See ClientHelpers for setting queue to max receive count of 8. event = event_basic attributes: { ApproximateReceiveCount: '8' }, job_class: 'TestHelper::Jobs::ErrorJob' Lambdakiq::Job.handler(event) expect(delete_message).must_be :present? @@ -55,6 +73,7 @@ class JobTest < LambdakiqSpec it 'must not perform and allow fifo queue to use message visibility as delay' do event = event_basic_delay minutes: 6 Lambdakiq::Job.handler(event) + expect(delete_message).must_be :blank? expect(change_message_visibility).must_be :present? expect(change_message_visibility_params[:visibility_timeout]).must_equal 6.minutes expect(perform_buffer_last_value).must_be_nil @@ -64,6 +83,7 @@ class JobTest < LambdakiqSpec it 'must not perform and allow fifo queue to use message visibility as delay (using SentTimestamp)' do event = event_basic_delay minutes: 10, timestamp: 2.minutes.ago.strftime('%s%3N') Lambdakiq::Job.handler(event) + expect(delete_message).must_be :blank? expect(change_message_visibility).must_be :present? expect(change_message_visibility_params[:visibility_timeout]).must_equal 8.minutes expect(perform_buffer_last_value).must_be_nil @@ -79,4 +99,32 @@ class JobTest < LambdakiqSpec expect(logger).must_include 'Performing TestHelper::Jobs::BasicJob' expect(logger).must_include 'Performed TestHelper::Jobs::BasicJob' end + + it 'must use `lambdakiq_options` retry options set to 0 and not retry job' do + event = event_basic job_class: 'TestHelper::Jobs::ErrorJobNoRetry' + Lambdakiq::Job.handler(event) + expect(delete_message).must_be :present? + expect(perform_buffer_last_value).must_equal 'ErrorJobNoRetry with: "test"' + expect(logger).must_include 'Performing TestHelper::Jobs::ErrorJobNoRetry' + expect(logger).must_include 'Error performing TestHelper::Jobs::ErrorJobNoRetry' + end + + it 'must use `lambdakiq_options` retry options set to 1 and retry job' do + event = event_basic job_class: 'TestHelper::Jobs::ErrorJobOneRetry' + error = expect(->{ Lambdakiq::Job.handler(event) }).must_raise 'HELL' + expect(delete_message).must_be :blank? + expect(perform_buffer_last_value).must_equal 'ErrorJobOneRetry with: "test"' + expect(change_message_visibility).must_be :present? + expect(change_message_visibility_params[:visibility_timeout]).must_equal 30.seconds + expect(logger).must_include 'Performing TestHelper::Jobs::ErrorJobOneRetry' + expect(logger).must_include 'Error performing TestHelper::Jobs::ErrorJobOneRetry' + end + + private + + def job(event: event_basic) + record = Lambdakiq::Event.records(event).first + Lambdakiq::Job.new(record) + end + end diff --git a/test/cases/record_test.rb b/test/cases/record_test.rb index 8e83998..3aae93b 100644 --- a/test/cases/record_test.rb +++ b/test/cases/record_test.rb @@ -35,8 +35,4 @@ class RecordTest < LambdakiqSpec expect(record.receive_count).must_equal 1 end - it '#max_receive_count?' do - expect(record.max_receive_count?).must_equal false - end - end diff --git a/test/test_helper/jobs.rb b/test/test_helper/jobs.rb index 6d6929f..bde1755 100644 --- a/test/test_helper/jobs.rb +++ b/test/test_helper/jobs.rb @@ -2,3 +2,5 @@ require 'test_helper/jobs/basic_job' require 'test_helper/jobs/basic_nofifo_job' require 'test_helper/jobs/error_job' +require 'test_helper/jobs/error_job_no_retry' +require 'test_helper/jobs/error_job_one_retry' diff --git a/test/test_helper/jobs/application_job.rb b/test/test_helper/jobs/application_job.rb index 177236f..624aefb 100644 --- a/test/test_helper/jobs/application_job.rb +++ b/test/test_helper/jobs/application_job.rb @@ -2,6 +2,7 @@ module TestHelper module Jobs class ApplicationJob < ActiveJob::Base queue_as ENV['TEST_QUEUE_NAME'] + include Lambdakiq::Worker end end end diff --git a/test/test_helper/jobs/error_job_no_retry.rb b/test/test_helper/jobs/error_job_no_retry.rb new file mode 100644 index 0000000..ac1634e --- /dev/null +++ b/test/test_helper/jobs/error_job_no_retry.rb @@ -0,0 +1,11 @@ +module TestHelper + module Jobs + class ErrorJobNoRetry < ApplicationJob + lambdakiq_options retry: 0 + def perform(object) + TestHelper::PerformBuffer.add "ErrorJobNoRetry with: #{object.inspect}" + raise('HELL') + end + end + end +end diff --git a/test/test_helper/jobs/error_job_one_retry.rb b/test/test_helper/jobs/error_job_one_retry.rb new file mode 100644 index 0000000..5d0c344 --- /dev/null +++ b/test/test_helper/jobs/error_job_one_retry.rb @@ -0,0 +1,11 @@ +module TestHelper + module Jobs + class ErrorJobOneRetry < ApplicationJob + lambdakiq_options retry: 1 + def perform(object) + TestHelper::PerformBuffer.add "ErrorJobOneRetry with: #{object.inspect}" + raise('HELL') + end + end + end +end From 061fc5ab32d44ad9d0e00f50fe5d959f7b46bbea Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Sun, 3 Jan 2021 21:14:38 -0500 Subject: [PATCH 21/34] Stronger time sensitive tests. --- TODO.md | 5 ++++- test/cases/job_test.rb | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 28bd120..d9a4105 100644 --- a/TODO.md +++ b/TODO.md @@ -95,5 +95,8 @@ https://github.com/mperham/sidekiq/wiki/Error-Handling#death-notification * Rename all `sidekiq_options` to `lambdakiq_options` ```ruby -ActiveJob::Base.logger = Logger.new(IO::NULL) +config.after_initialize do + config.active_job.logger = Rails.logger + config.lambdakiq.metrics_logger = Rails.logger +end ``` diff --git a/test/cases/job_test.rb b/test/cases/job_test.rb index 66f5da8..6fd55c4 100644 --- a/test/cases/job_test.rb +++ b/test/cases/job_test.rb @@ -75,7 +75,7 @@ class JobTest < LambdakiqSpec Lambdakiq::Job.handler(event) expect(delete_message).must_be :blank? expect(change_message_visibility).must_be :present? - expect(change_message_visibility_params[:visibility_timeout]).must_equal 6.minutes + expect(change_message_visibility_params[:visibility_timeout]).must_be_close_to 6.minutes, 1 expect(perform_buffer_last_value).must_be_nil expect(logger).must_be :blank? end @@ -85,7 +85,7 @@ class JobTest < LambdakiqSpec Lambdakiq::Job.handler(event) expect(delete_message).must_be :blank? expect(change_message_visibility).must_be :present? - expect(change_message_visibility_params[:visibility_timeout]).must_equal 8.minutes + expect(change_message_visibility_params[:visibility_timeout]).must_be_close_to 8.minutes, 1 expect(perform_buffer_last_value).must_be_nil expect(logger).must_be :blank? end From 2f68769c24a22dd7c22196b75e3d67b6e2ca859a Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Sun, 3 Jan 2021 21:53:18 -0500 Subject: [PATCH 22/34] TODO Notes. --- TODO.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index d9a4105..f5c8167 100644 --- a/TODO.md +++ b/TODO.md @@ -2,11 +2,13 @@ # TODO * Do I need a worker module? + A: Yes. * Do I support vanillia ActiveJob? + A: No, these are not compatible. ```ruby -class ExampleJob < ActiveJob::Base - retry_on ErrorLoadingSite, wait: 5.minutes, queue: :low_priority +class AjRetryOnAndWait < ApplicationJob + retry_on ArgumentError, wait: 5.minutes ``` * Do a some form of Async queue if this works on Lambda? From 05dc1ef8727531988ccb4f4a5c0be48b0e200ada Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Sun, 3 Jan 2021 23:18:32 -0500 Subject: [PATCH 23/34] Fix FIFO pseudo delays using visibility timeout. --- lib/lambdakiq/error.rb | 6 +++++- lib/lambdakiq/job.rb | 6 +++++- test/cases/job_test.rb | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/lambdakiq/error.rb b/lib/lambdakiq/error.rb index bc0ec7a..6159e44 100644 --- a/lib/lambdakiq/error.rb +++ b/lib/lambdakiq/error.rb @@ -1,5 +1,8 @@ module Lambdakiq class Error < StandardError + end + + class JobError < Error attr_reader :original_exception, :job def initialize(error) @@ -9,5 +12,6 @@ def initialize(error) end end - class JobError < Error ; end + class FifoDelayError < Error + end end diff --git a/lib/lambdakiq/job.rb b/lib/lambdakiq/job.rb index e704d06..3f340a4 100644 --- a/lib/lambdakiq/job.rb +++ b/lib/lambdakiq/job.rb @@ -41,7 +41,11 @@ def executions end def perform - fifo_delay? ? fifo_delay : execute + if fifo_delay? + fifo_delay + raise FifoDelayError, active_job.job_id + end + execute end def execute diff --git a/test/cases/job_test.rb b/test/cases/job_test.rb index 6fd55c4..6ba6b7f 100644 --- a/test/cases/job_test.rb +++ b/test/cases/job_test.rb @@ -72,7 +72,7 @@ class JobTest < LambdakiqSpec it 'must not perform and allow fifo queue to use message visibility as delay' do event = event_basic_delay minutes: 6 - Lambdakiq::Job.handler(event) + error = expect(->{ Lambdakiq::Job.handler(event) }).must_raise 'HELL' expect(delete_message).must_be :blank? expect(change_message_visibility).must_be :present? expect(change_message_visibility_params[:visibility_timeout]).must_be_close_to 6.minutes, 1 @@ -82,7 +82,7 @@ class JobTest < LambdakiqSpec it 'must not perform and allow fifo queue to use message visibility as delay (using SentTimestamp)' do event = event_basic_delay minutes: 10, timestamp: 2.minutes.ago.strftime('%s%3N') - Lambdakiq::Job.handler(event) + error = expect(->{ Lambdakiq::Job.handler(event) }).must_raise 'HELL' expect(delete_message).must_be :blank? expect(change_message_visibility).must_be :present? expect(change_message_visibility_params[:visibility_timeout]).must_be_close_to 8.minutes, 1 From 02316bd3ef1c6bec36eaed7565375472104e38e0 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Mon, 4 Jan 2021 12:53:24 -0500 Subject: [PATCH 24/34] No backtrace for FifoDelayError --- lib/lambdakiq/error.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/lambdakiq/error.rb b/lib/lambdakiq/error.rb index 6159e44..2c0179b 100644 --- a/lib/lambdakiq/error.rb +++ b/lib/lambdakiq/error.rb @@ -13,5 +13,9 @@ def initialize(error) end class FifoDelayError < Error + def initialize(error) + super + set_backtrace([]) + end end end From 58f692eefe2f3d2798fb0f01268dd4751846df59 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Mon, 4 Jan 2021 14:19:26 -0500 Subject: [PATCH 25/34] Support async enqueue with concurrent-ruby. --- Gemfile.lock | 1 + TODO.md | 10 +--------- lamby.gemspec | 1 + lib/lambdakiq/adapter.rb | 19 +++++++++++++++++-- lib/lambdakiq/worker.rb | 4 ++++ test/cases/jobs/basic_async_job_test.rb | 15 +++++++++++++++ test/test_helper.rb | 14 ++++++++++++++ test/test_helper/jobs.rb | 1 + test/test_helper/jobs/basic_async_job.rb | 10 ++++++++++ 9 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 test/cases/jobs/basic_async_job_test.rb create mode 100644 test/test_helper/jobs/basic_async_job.rb diff --git a/Gemfile.lock b/Gemfile.lock index 5b689d0..875c387 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,6 +4,7 @@ PATH lambdakiq (1.0.0) activejob aws-sdk-sqs + concurrent-ruby railties GEM diff --git a/TODO.md b/TODO.md index f5c8167..afbe1ef 100644 --- a/TODO.md +++ b/TODO.md @@ -15,15 +15,7 @@ class AjRetryOnAndWait < ApplicationJob - Does Sidekiq do this? ```ruby -def _enqueue(job, send_message_opts = {}) - Concurrent::Promise - .execute { super(job, send_message_opts) } - .on_error do |e| - Rails.logger.error "Failed to queue job #{job}. Reason: #{e}" - error_handler = Aws::Rails::SqsActiveJob.config.async_queue_error_handler - error_handler.call(e, job, send_message_opts) if error_handler - end -end +lambdakiq_options async: true ``` * Error handlers. Ensure we easily hook into Rollbar, etc. diff --git a/lamby.gemspec b/lamby.gemspec index 5def7e4..86a0a34 100644 --- a/lamby.gemspec +++ b/lamby.gemspec @@ -19,6 +19,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency 'activejob' spec.add_dependency 'aws-sdk-sqs' + spec.add_dependency 'concurrent-ruby' spec.add_dependency 'railties' spec.add_development_dependency 'bundler' spec.add_development_dependency 'rake' diff --git a/lib/lambdakiq/adapter.rb b/lib/lambdakiq/adapter.rb index 038c9c1..6f20616 100644 --- a/lib/lambdakiq/adapter.rb +++ b/lib/lambdakiq/adapter.rb @@ -3,8 +3,7 @@ module QueueAdapters class LambdakiqAdapter def enqueue(job, options = {}) - queue = Lambdakiq.client.queues[job.queue_name] - queue.send_message job, options + job.lambdakiq_async? ? _enqueue_async(job, options) : _enqueue(job, options) end def enqueue_at(job, timestamp) @@ -18,6 +17,22 @@ def delay_seconds(timestamp) [ds, 900].min end + def _enqueue(job, options = {}) + queue = Lambdakiq.client.queues[job.queue_name] + queue.send_message job, options + end + + def _enqueue_async(job, options = {}) + Concurrent::Promise + .execute { _enqueue(job, options) } + .on_error { |e| async_enqueue_error(e) } + end + + def async_enqueue_error(e) + msg = "[Lambdakiq] Failed to queue job #{job}. Reason: #{e}" + Rails.logger.error(msg) + end + end end end diff --git a/lib/lambdakiq/worker.rb b/lib/lambdakiq/worker.rb index 475f9ed..5f3f739 100644 --- a/lib/lambdakiq/worker.rb +++ b/lib/lambdakiq/worker.rb @@ -20,5 +20,9 @@ def lambdakiq_retry lambdakiq_options_hash[:retry] end + def lambdakiq_async? + !!lambdakiq_options_hash[:async] + end + end end diff --git a/test/cases/jobs/basic_async_job_test.rb b/test/cases/jobs/basic_async_job_test.rb new file mode 100644 index 0000000..070204a --- /dev/null +++ b/test/cases/jobs/basic_async_job_test.rb @@ -0,0 +1,15 @@ +require 'test_helper' + +class BasicAsyncJobTest < LambdakiqSpec + before do + TestHelper::Jobs::BasicAsyncJob.perform_later('somework') + expect(sent_message).must_be :blank? + wait_for('Waiting for sent message API call.') { sent_message } + end + + it 'message body' do + expect(sent_message_body['queue_name']).must_equal queue_name + expect(sent_message_body['job_class']).must_equal 'TestHelper::Jobs::BasicAsyncJob' + expect(sent_message_body['arguments']).must_equal ['somework'] + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0d0b664..d517723 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -6,6 +6,7 @@ require 'rails' require 'aws-sdk-sqs' require 'stringio' +require 'timeout' require 'minitest/autorun' require 'minitest/focus' require 'mocha/minitest' @@ -29,4 +30,17 @@ class LambdakiqSpec < Minitest::Spec perform_buffer_clear! end + private + + def wait_for(message, timeout: 2) + Timeout.timeout(timeout) do + loop do + value = yield + value ? break : sleep(0.1) + end + end + rescue Timeout::Error + flunk(message) + end + end diff --git a/test/test_helper/jobs.rb b/test/test_helper/jobs.rb index bde1755..2ab6626 100644 --- a/test/test_helper/jobs.rb +++ b/test/test_helper/jobs.rb @@ -1,5 +1,6 @@ require 'test_helper/jobs/application_job' require 'test_helper/jobs/basic_job' +require 'test_helper/jobs/basic_async_job' require 'test_helper/jobs/basic_nofifo_job' require 'test_helper/jobs/error_job' require 'test_helper/jobs/error_job_no_retry' diff --git a/test/test_helper/jobs/basic_async_job.rb b/test/test_helper/jobs/basic_async_job.rb new file mode 100644 index 0000000..e373e49 --- /dev/null +++ b/test/test_helper/jobs/basic_async_job.rb @@ -0,0 +1,10 @@ +module TestHelper + module Jobs + class BasicAsyncJob < ApplicationJob + lambdakiq_options async: true + def perform(object) + TestHelper::PerformBuffer.add "BasicAsyncJob with: #{object.inspect}" + end + end + end +end From 340cd8335d364b4a22f7c2957c86d5ade273cc28 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Mon, 4 Jan 2021 16:41:26 -0500 Subject: [PATCH 26/34] Allow metric app name to be a config. --- lib/lambdakiq/metrics.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/lambdakiq/metrics.rb b/lib/lambdakiq/metrics.rb index f9c1e27..c6f9da2 100644 --- a/lib/lambdakiq/metrics.rb +++ b/lib/lambdakiq/metrics.rb @@ -100,7 +100,8 @@ def timestamp end def rails_app_name - Rails.application.class.name.split('::').first + Lambdakiq.config.metrics_app_name || + Rails.application.class.name.split('::').first end end From b61c2f56eb7ad0e85b5c643cbcf417d11950bf40 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Sun, 10 Jan 2021 17:07:04 -0500 Subject: [PATCH 27/34] Tweak Metrics. --- TODO.md | 4 ++++ lib/lambdakiq/metrics.rb | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index afbe1ef..816e355 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,10 @@ A: Use different queues. * Your SQS queue must have a `RedrivePolicy` policy! https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-sqs-queues.html#aws-sqs-queue-redrive + +https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-specification-template-anatomy-globals.html + + ## Our Siqekiq Interfaces ```ruby diff --git a/lib/lambdakiq/metrics.rb b/lib/lambdakiq/metrics.rb index c6f9da2..7ba57ec 100644 --- a/lib/lambdakiq/metrics.rb +++ b/lib/lambdakiq/metrics.rb @@ -51,8 +51,8 @@ def dimensions def instrument! put_metric 'Duration', event.duration.to_i, 'Milliseconds' - put_metric job_name, 1, 'Count' - put_metric 'Exceptions', 1, 'Count' if exception + put_metric 'Count', 1, 'Count' + put_metric 'ExceptionCount', 1, 'Count' if exception set_property 'JobId', job.job_id set_property 'JobName', job_name set_property 'QueueName', job.queue_name From 282b4b8229faa9cf92a8eab09ff284f7f148cc2c Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Sun, 10 Jan 2021 22:18:02 -0500 Subject: [PATCH 28/34] Instrument enqueue_retry and retry_stopped --- TODO.md | 2 ++ lib/lambdakiq/job.rb | 6 ++++++ lib/lambdakiq/metrics.rb | 9 +++++---- test/cases/job_test.rb | 11 +++++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 816e355..5161f8a 100644 --- a/TODO.md +++ b/TODO.md @@ -98,3 +98,5 @@ config.after_initialize do config.lambdakiq.metrics_logger = Rails.logger end ``` + +Instrument enqueue_retry and retry_stopped diff --git a/lib/lambdakiq/job.rb b/lib/lambdakiq/job.rb index 3f340a4..7b4644a 100644 --- a/lib/lambdakiq/job.rb +++ b/lib/lambdakiq/job.rb @@ -64,8 +64,10 @@ def client_params def perform_error(e) if change_message_visibility + instrument :enqueue_retry, error: e, wait: record.next_visibility_timeout @error = e else + instrument :retry_stopped, error: e delete_message end end @@ -110,5 +112,9 @@ def increment_executions active_job.executions = active_job.executions + 1 end + def instrument(name, error: nil, wait: nil) + active_job.send :instrument, name, error: error, wait: wait + end + end end diff --git a/lib/lambdakiq/metrics.rb b/lib/lambdakiq/metrics.rb index 7ba57ec..9d1c8aa 100644 --- a/lib/lambdakiq/metrics.rb +++ b/lib/lambdakiq/metrics.rb @@ -37,8 +37,9 @@ def namespace Lambdakiq.config.metrics_namespace end - def exception - event.payload[:exception].try(:first) + def exception_name + event.payload[:exception].try(:first) || + event.payload[:error]&.class&.name end def dimensions @@ -52,12 +53,12 @@ def dimensions def instrument! put_metric 'Duration', event.duration.to_i, 'Milliseconds' put_metric 'Count', 1, 'Count' - put_metric 'ExceptionCount', 1, 'Count' if exception + put_metric 'ExceptionCount', 1, 'Count' if exception_name set_property 'JobId', job.job_id set_property 'JobName', job_name set_property 'QueueName', job.queue_name set_property 'MessageId', job.provider_job_id if job.provider_job_id - set_property 'Exception', exception if exception + set_property 'ExceptionName', exception_name if exception_name set_property 'EnqueuedAt', job.enqueued_at if job.enqueued_at set_property 'Executions', job.executions if job.executions job.arguments.each_with_index do |argument, index| diff --git a/test/cases/job_test.rb b/test/cases/job_test.rb index 6ba6b7f..7a52cb5 100644 --- a/test/cases/job_test.rb +++ b/test/cases/job_test.rb @@ -48,6 +48,12 @@ class JobTest < LambdakiqSpec expect(perform_buffer_last_value).must_equal 'ErrorJob with: "test"' expect(logger).must_include 'Performing TestHelper::Jobs::ErrorJob' expect(logger).must_include 'Error performing TestHelper::Jobs::ErrorJob' + # binding.pry ; return + expect(logged_metric('retry_stopped.active_job')).must_be_nil + enqueue_retry = logged_metric('enqueue_retry.active_job') + expect(enqueue_retry).must_be :present? + expect(enqueue_retry['Executions']).must_equal 7 + expect(enqueue_retry['ExceptionName']).must_equal 'RuntimeError' end it 'wraps returned errors with no backtrace which avoids excessive/duplicate cloudwatch logging' do @@ -68,6 +74,11 @@ class JobTest < LambdakiqSpec expect(perform_buffer_last_value).must_equal 'ErrorJob with: "test"' expect(logger).must_include 'Performing TestHelper::Jobs::ErrorJob' expect(logger).must_include 'Error performing TestHelper::Jobs::ErrorJob' + expect(logged_metric('enqueue_retry.active_job')).must_be_nil + retry_stopped = logged_metric('retry_stopped.active_job') + expect(retry_stopped).must_be :present? + expect(retry_stopped['Executions']).must_equal 8 + expect(retry_stopped['ExceptionName']).must_equal 'RuntimeError' end it 'must not perform and allow fifo queue to use message visibility as delay' do From f77587e2289f5ce7cec8dbaea0b5d84fb0b94fbb Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Thu, 14 Jan 2021 12:48:35 -0500 Subject: [PATCH 29/34] Docs and MessageGroupId change. --- README.md | 67 ++++++++++++++++++++++++++++++- TODO.md | 1 + lamby.gemspec | 2 +- lib/lambdakiq/message.rb | 2 +- test/cases/jobs/basic_job_test.rb | 2 +- test/test_helper/events/basic.rb | 2 +- 6 files changed, 71 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5307b4c..a108c92 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ -# Lambdakiq +![Lambdakiq Logo](images/Lambdakiq.png) + +# ActiveJobs on Lambda + +(serverless sidekiq) + TODO ... @@ -19,6 +24,66 @@ module YourApp end ``` + +## Standard or FIFO? + +... + +## Observability: CloudWatch Embedded Metrics + +Get ready to gain way more insights into your ActiveJobs using AWS' [CloudWatch](https://aws.amazon.com/cloudwatch/) service. Every AWS service, including SQS & Lambda, publishes detailed [CloudWatch Metrics](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/working_with_metrics.html). This gem leverages [CloudWatch Embedded Metrics](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format.html) to add detailed ActiveJob metrics to that system. You can mix and match these data points to build your own [CloudWatch Dashboards](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Dashboards.html). If needed, any combination can be used to trigger [CloudWatch Alarms](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html). + +Metrics are published under the `Lambdakiq` namespace. This is configurable using `config.lambdakiq.metrics_namespace` but should not be needed since all metrics are published using these three dimensions which allow you to easily segment metrics/dashboards to a specific application. + +* `AppName` - This is the name of your Rails application. Ex: `MyApp` +* `JobEvent` - Name of the ActiveSupport Notification. Ex: `*.active_job`. +* `JobName` - The class name of the ActiveSupport job. Ex: `NotificationJob` + +For reference, here are the `JobEvent` names published by ActiveSupport. A few of these are instrumented by Lambdakiq since we use custom retry logic like Sidekiq. These event/metrics are found in the Rails application CloudWatch logs. + +* `enqueue.active_job` +* `enqueue_at.active_job` + +While these event/metrics can be found in the jobs function's log. + +* `perform_start.active_job` +* `perform.active_job` +* `enqueue_retry.active_job` +* `retry_stopped.active_job` + +These are the properties published with each metric. + +* `JobId` - ActiveJob Unique ID. Ex: `9f3b6977-6afc-4769-aed6-bab1ad9a0df5` +* `QueueName` - SQS Queue Name. Ex: `myapp-JobsQueue-14F18LG6XFUW5.fifo` +* `MessageId` - SQS Message ID. Ex: `5653246d-dc5e-4c95-9583-b6b83ec78602` +* `ExceptionName` - Class name of error raised. Present in perform and retry events. +* `EnqueuedAt` - When ActiveJob enqueued the message. Ex: `2021-01-14T01:43:38Z` +* `Executions` - The number of current executions. Counts from `1` and up. +* `JobArg#{n}` - Enumerated serialized arguments. + +And finally, here are the metrics which each dimension can chart. + +* `Duration` - Of the job event in milliseconds. +* `Count` - Of the event. +* `ExceptionCount` - Of the event. Useful with `ExceptionName`. + +### CloudWatch Dashboard Examples + +... + +### CloudWatch Insights Query Examples + + +``` +fields @timestamp, Executions, @message +| filter ispresent(JobEvent) and JobEvent = 'perform.active_job' +| filter JobName = 'NotificationJob' +| sort @timestamp asc +| limit 20 +``` + + + ## Contributing After checking out the repo, run: diff --git a/TODO.md b/TODO.md index 5161f8a..8df197f 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,5 @@ + # TODO * Do I need a worker module? diff --git a/lamby.gemspec b/lamby.gemspec index 86a0a34..63fe1cb 100644 --- a/lamby.gemspec +++ b/lamby.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |spec| spec.homepage = "https://github.com/customink/lambdakiq" spec.license = "MIT" spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do - `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|images)/}) } end spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } diff --git a/lib/lambdakiq/message.rb b/lib/lambdakiq/message.rb index 056beae..fd48cfd 100644 --- a/lib/lambdakiq/message.rb +++ b/lib/lambdakiq/message.rb @@ -36,7 +36,7 @@ def message_body def message_params_fifo if queue.fifo? - { message_group_id: 'LambdakiqMessage', + { message_group_id: job.job_id, message_deduplication_id: job.job_id } else {} diff --git a/test/cases/jobs/basic_job_test.rb b/test/cases/jobs/basic_job_test.rb index fbd42d8..3797998 100644 --- a/test/cases/jobs/basic_job_test.rb +++ b/test/cases/jobs/basic_job_test.rb @@ -24,8 +24,8 @@ class BasicJobTest < LambdakiqSpec end it 'message group and deduplication id for default fifo queue are sent' do - expect(sent_message_params[:message_group_id]).must_equal 'LambdakiqMessage' expect(sent_message_params[:message_deduplication_id]).must_be :present? UUID.validate(sent_message_params[:message_deduplication_id]) + expect(sent_message_params[:message_group_id]).must_equal sent_message_params[:message_deduplication_id] end end diff --git a/test/test_helper/events/basic.rb b/test/test_helper/events/basic.rb index a5e86ea..03a318f 100644 --- a/test/test_helper/events/basic.rb +++ b/test/test_helper/events/basic.rb @@ -13,7 +13,7 @@ class Basic < Base "ApproximateReceiveCount": "1", "SentTimestamp": "1606741656429", "SequenceNumber": "18858069937755376128", - "MessageGroupId": "LambdakiqMessage", + "MessageGroupId": "527cd37e-08f4-4aa8-9834-a46220cdc5a3", "SenderId": "AROA4DJKY67RBVYCN5UZ3", "MessageDeduplicationId": "527cd37e-08f4-4aa8-9834-a46220cdc5a3", "ApproximateFirstReceiveTimestamp": "1606741656429" From ad4d170a2702e36b9dfd1886bef0fd246ebe7ec8 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Thu, 14 Jan 2021 12:48:48 -0500 Subject: [PATCH 30/34] Doc images. --- images/Lambdakiq.png | Bin 0 -> 11672 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 images/Lambdakiq.png diff --git a/images/Lambdakiq.png b/images/Lambdakiq.png new file mode 100644 index 0000000000000000000000000000000000000000..afda138afa9ccd439fc088c21218afb911cc16ad GIT binary patch literal 11672 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 literal 0 HcmV?d00001 From 654ebfbfba2efb945dec30d70ec0260969d0ee54 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Thu, 14 Jan 2021 18:53:19 -0500 Subject: [PATCH 31/34] Todo --- TODO.md | 1 + 1 file changed, 1 insertion(+) diff --git a/TODO.md b/TODO.md index 8df197f..70b2e74 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,7 @@ lambdakiq_options async: true ## Doc Points +* Periodic Job - CLoudWatch Events & Cron * Same as Sidekiq - Interface * Differences with Sidekiq From 4571334b0fdc9ccfcab4b8046178cf104d9014fa Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Thu, 14 Jan 2021 19:27:40 -0500 Subject: [PATCH 32/34] DEBUG: Can we change visibility without consumer errors? --- lib/lambdakiq/job.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/lambdakiq/job.rb b/lib/lambdakiq/job.rb index 7b4644a..c9c2db5 100644 --- a/lib/lambdakiq/job.rb +++ b/lib/lambdakiq/job.rb @@ -10,8 +10,9 @@ def handler(event) jobs = records.map { |record| new(record) } jobs.each(&:perform) jwerror = jobs.detect{ |j| j.error } - return unless jwerror - raise JobError.new(jwerror.error) + return { statusCode: 422 } + # return unless jwerror + # raise JobError.new(jwerror.error) end end @@ -50,7 +51,7 @@ def perform def execute ActiveJob::Base.execute(job_data) - delete_message + # delete_message rescue Exception => e increment_executions perform_error(e) From 0e1c4eacc4548abb776fd58be00d726929e0fd28 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Thu, 14 Jan 2021 21:51:37 -0500 Subject: [PATCH 33/34] Revert "DEBUG: Can we change visibility without consumer errors?" This reverts commit 4571334b0fdc9ccfcab4b8046178cf104d9014fa. --- lib/lambdakiq/job.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/lambdakiq/job.rb b/lib/lambdakiq/job.rb index c9c2db5..7b4644a 100644 --- a/lib/lambdakiq/job.rb +++ b/lib/lambdakiq/job.rb @@ -10,9 +10,8 @@ def handler(event) jobs = records.map { |record| new(record) } jobs.each(&:perform) jwerror = jobs.detect{ |j| j.error } - return { statusCode: 422 } - # return unless jwerror - # raise JobError.new(jwerror.error) + return unless jwerror + raise JobError.new(jwerror.error) end end @@ -51,7 +50,7 @@ def perform def execute ActiveJob::Base.execute(job_data) - # delete_message + delete_message rescue Exception => e increment_executions perform_error(e) From 78aa8ea378ce1abd9414e24a439e6923468c7327 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Mon, 1 Feb 2021 12:04:42 -0500 Subject: [PATCH 34/34] FINAL DOCS! --- README.md | 212 +++++++++++++++++++++++++---- TODO.md | 104 -------------- images/Lambdakiq.png | Bin 11672 -> 29323 bytes images/Lambdakiq.sketch | Bin 0 -> 204800 bytes lamby.gemspec => lambdakiq.gemspec | 0 5 files changed, 185 insertions(+), 131 deletions(-) delete mode 100644 TODO.md create mode 100644 images/Lambdakiq.sketch rename lamby.gemspec => lambdakiq.gemspec (100%) diff --git a/README.md b/README.md index a108c92..ac7f8fa 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,190 @@ -![Lambdakiq Logo](images/Lambdakiq.png) +![Lambdakiq: ActiveJob on SQS & Lambda](images/Lambdakiq.png) -# ActiveJobs on Lambda +# Lambdakiq -(serverless sidekiq) +Lamby: Simple Rails & AWS Lambda Integration using Rack.A drop-in replacement for [Sidekiq](https://github.com/mperham/sidekiq) when running Rails in AWS Lambda using the [Lamby](https://lamby.custominktech.com) gem. +Lambdakiq allows you to leverage AWS' managed infrastructure to the fullest extent. Gone are the days of managing pods and long polling processes. Instead AWS delivers messages directly to your Rails' job functions and scales it up and down as needed. Observability is built in using AWS CloudWatch Metrics, Dashboards, and Alarms. Learn more about [Using AWS Lambda with Amazon SQS](https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html) or get started now. -TODO ... +## Key Features + +* Distinct web & jobs Lambda functions. +* AWS fully managed polling. Event-driven. +* Maximum 12 retries. Per job configurable. +* Mirror Sidekiq's retry [backoff](https://github.com/mperham/sidekiq/wiki/Error-Handling#automatic-job-retry) timing. +* Last retry is at 11 hours 30 minutes. +* Supports ActiveJob's wait/delay. Up to 15 minutes. +* Dead messages are stored for up to 14 days. + +## Project Setup + +This gem assumes your Rails application is on AWS Lambda, ideally with our [Lamby](https://lamby.custominktech.com) gem. It could be using Lambda's traditional zip package type or the newer [container](https://dev.to/aws-heroes/lambda-containers-with-rails-a-perfect-match-4lgb) format. If Rails on Lambda is new to you, consider following our [quick start](https://lamby.custominktech.com/docs/quick_start) guide to get your first application up and running. From there, to use Lambdakiq, here are steps to setup your project + + +### Bundle & Config + +Add the Lambdakiq gem to your `Gemfile`. ```ruby -# TODO ... +gem 'lambdakiq' ``` -## Usage +Open `config/initializers/production.rb` and set Lambdakiq as your ActiveJob queue adapter. -Open `config/application.rb` and set Lambdakiq as your default ActiveJob queue adapter. +```ruby +config.active_job.queue_adapter = :lambdakiq +``` + +Open `app/jobs/application_job.rb` and add our worker module. The queue name will be set by an environment using CloudFormation further down. ```ruby -module YourApp - class Application < Rails::Application - config.active_job.queue_adapter = :lambdakiq - end +class ApplicationJob < ActiveJob::Base + include Lambdakiq::Worker + queue_as ENV['JOBS_QUEUE_NAME'] end ``` +### SQS Resources + +Open up your project's SAM [`template.yaml`](https://lamby.custominktech.com/docs/anatomy#file-template-yaml) file and make the following additions and changes. First, we need to create your [SQS queues](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-sqs-queues.html) under the `Resources` section. -## Standard or FIFO? +```yaml +JobsQueue: + Type: AWS::SQS::Queue + Properties: + ReceiveMessageWaitTimeSeconds: 10 + RedrivePolicy: + deadLetterTargetArn: !GetAtt JobsDLQueue.Arn + maxReceiveCount: 13 + VisibilityTimeout: 301 + +JobsDLQueue: + Type: AWS::SQS::Queue + Properties: + MessageRetentionPeriod: 1209600 +``` -... +In this example above we are also creating a queue to automatically handle our redrives and storage for any dead messages. We use [long polling](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-short-and-long-polling.html#sqs-long-polling) to receive messages for lower costs. In most cases your message is consumed almost immediately. Sidekiq polling is around 10s too. -## Observability: CloudWatch Embedded Metrics +The max receive count is 13 which means you get 12 retries. This is done so we can mimic Sidekiq's [automatic retry and backoff](https://github.com/mperham/sidekiq/wiki/Error-Handling#automatic-job-retry). The dead letter queue retains messages for the maximum of 14 days. This can be changed as needed. We also make no assumptions on how you want to handle dead jobs. -Get ready to gain way more insights into your ActiveJobs using AWS' [CloudWatch](https://aws.amazon.com/cloudwatch/) service. Every AWS service, including SQS & Lambda, publishes detailed [CloudWatch Metrics](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/working_with_metrics.html). This gem leverages [CloudWatch Embedded Metrics](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format.html) to add detailed ActiveJob metrics to that system. You can mix and match these data points to build your own [CloudWatch Dashboards](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Dashboards.html). If needed, any combination can be used to trigger [CloudWatch Alarms](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html). +### Queue Name Environment Variable + +We need to pass the newly created queue's name as an environment variable to your soon to be created jobs function. Since it is common for your Rails web and jobs functions to share these, we can leverage [SAM's Globals](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-specification-template-anatomy-globals.html) section. + +```yaml +Globals: + Function: + Environment: + Variables: + RAILS_ENV: !Ref RailsEnv + JOBS_QUEUE_NAME: !GetAtt JobsQueue.QueueName +``` + +We can remove the `Environment` section from our web function and all functions in this stack will now use the globals. Here we are using an [intrinsic function](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html) to pass the queue's name as the `JOBS_QUEUE_NAME` environment variable. + +### IAM Permissions + +Both functions will need capabilities to access the SQS jobs queue. We can add or extend the [SAM Policies](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-function.html#sam-function-policies) section of our `RailsLambda` web function so it (and our soon to be created jobs function) have full capabilities to this new queue. + +```yaml +Policies: + - Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - sqs:* + Resource: + - !Sub arn:aws:sqs:${AWS::Region}:${AWS::AccountId}:${JobsQueue.QueueName} +``` + +Now we can duplicate our `RailsLambda` resource YAML (except for the `Events` property) to a new `JobsLambda` one. This gives us a distinct Lambda function to process jobs whose events, memory, timeout, and more can be independently tuned. However, both the `web` and `jobs` functions will use the same ECR container image! + +```yaml +JobsLambda: + Type: AWS::Serverless::Function + Metadata: + DockerContext: ./.lamby/RailsLambda + Dockerfile: Dockerfile + DockerTag: jobs + Properties: + Events: + SQSJobs: + Type: SQS + Properties: + Queue: !GetAtt JobsQueue.Arn + BatchSize: 1 + MemorySize: 1792 + PackageType: Image + Policies: + - Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - sqs:* + Resource: + - !Sub arn:aws:sqs:${AWS::Region}:${AWS::AccountId}:${JobsQueue.QueueName} + Timeout: 300 +``` + +Here are some key aspects of our `JobsLambda` resource above: + +* The `Events` property uses the [SQS Type](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-sqs.html). +* Our [BatchSize](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-sqs.html#sam-function-sqs-batchsize) is set to one so we can handle retrys more easily without worrying about idempotency in larger batches. +* The `Metadata`'s Docker properties must be the same as our web function except for the `DockerTag`. This is needed for the image to be shared. This works around a known [SAM issue](https://github.com/aws/aws-sam-cli/issues/2466) vs using the `ImageConfig` property. +* The jobs function `Timeout` must be lower than the `JobsQueue`'s `VisibilityTimeout` property. When the batch size is one, the queue's visibility is generally one second more. + +🎉 Deploy your application and have fun with ActiveJob on SQS & Lambda. + +## Configuration + +Most general Lambdakiq configuration options are exposed via the Rails standard configuration method. + +### Rails Configs + +```ruby +config.lambdakiq +``` + +* `max_retries=` - Retries for all jobs. Default is the Lambdakiq maximum of `12`. +* `metrics_namespace=` - The CloudWatch Embedded Metrics namespace. Default is `Lambdakiq`. +* `metrics_logger=` - Set to the Rails logger which is STDOUT via Lamby/Lambda. + +### ActiveJob Configs + +You can also set configuration options on a per job basis using the `lambdakiq_options` method. + +```ruby +class OrderProcessorJob < ApplicationJob + lambdakiq_options retry: 2 +end +``` + +* `retry` - Overrides the default Lambdakiq `max_retries` for this one job. + +## Concurrency & Limits + +AWS SQS is highly scalable with [few limits](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-quotas.html). As your jobs in SQS increases so should your concurrent functions to process that work. However, as this article, ["Why isn't my Lambda function with an Amazon SQS event source scaling optimally?"](https://aws.amazon.com/premiumsupport/knowledge-center/lambda-sqs-scaling/) describes it is possible that errors will effect your concurrency. + +To help keep your queue and workers scalable, reduce the errors raised by your jobs. You an also reduce the retry count. + +## Observability with CloudWatch + +Get ready to gain way more insights into your ActiveJobs using AWS' [CloudWatch](https://aws.amazon.com/cloudwatch/) service. Every AWS service, including SQS & Lambda, publishes detailed [CloudWatch Metrics](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/working_with_metrics.html). This gem leverages [CloudWatch Embedded Metrics](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format.html) to add detailed ActiveJob metrics to that system. You can mix and match these data points to build your own [CloudWatch Dashboards](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Dashboards.html). If needed, any combination can be used to trigger [CloudWatch Alarms](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html). Much like Sumo Logic, you can search & query for data using [CloudWatch Logs Insights](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/AnalyzingLogData.html). + +![CloudWatch Dashboard](https://user-images.githubusercontent.com/2381/106465990-be7a6200-6468-11eb-8461-93db0046cda5.png) Metrics are published under the `Lambdakiq` namespace. This is configurable using `config.lambdakiq.metrics_namespace` but should not be needed since all metrics are published using these three dimensions which allow you to easily segment metrics/dashboards to a specific application. +### Metric Dimensions + * `AppName` - This is the name of your Rails application. Ex: `MyApp` * `JobEvent` - Name of the ActiveSupport Notification. Ex: `*.active_job`. * `JobName` - The class name of the ActiveSupport job. Ex: `NotificationJob` -For reference, here are the `JobEvent` names published by ActiveSupport. A few of these are instrumented by Lambdakiq since we use custom retry logic like Sidekiq. These event/metrics are found in the Rails application CloudWatch logs. +### ActiveJob Event Names +For reference, here are the `JobEvent` names published by ActiveSupport. A few of these are instrumented by Lambdakiq since we use custom retry logic like Sidekiq. These event/metrics are found in the Rails application CloudWatch logs because they publish/enqueue jobs. * `enqueue.active_job` * `enqueue_at.active_job` @@ -51,7 +196,9 @@ While these event/metrics can be found in the jobs function's log. * `enqueue_retry.active_job` * `retry_stopped.active_job` -These are the properties published with each metric. +### Metric Properties + +These are the properties published with each metric. Remember, properties can not be used as metric data in charts but can be searched using [CloudWatch Logs Insights](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/AnalyzingLogData.html). * `JobId` - ActiveJob Unique ID. Ex: `9f3b6977-6afc-4769-aed6-bab1ad9a0df5` * `QueueName` - SQS Queue Name. Ex: `myapp-JobsQueue-14F18LG6XFUW5.fifo` @@ -61,7 +208,9 @@ These are the properties published with each metric. * `Executions` - The number of current executions. Counts from `1` and up. * `JobArg#{n}` - Enumerated serialized arguments. -And finally, here are the metrics which each dimension can chart. +### Metric Data + +And finally, here are the metrics which each dimension can chart using [CloudWatch Metrics & Dashboards](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Dashboards.html). * `Duration` - Of the job event in milliseconds. * `Count` - Of the event. @@ -69,20 +218,29 @@ And finally, here are the metrics which each dimension can chart. ### CloudWatch Dashboard Examples -... +Please share how you are using CloudWatch to monitor and/or alert on your ActiveJobs with Lambdakiq! -### CloudWatch Insights Query Examples +💬 https://github.com/customink/lambdakiq/discussions/3 -``` -fields @timestamp, Executions, @message -| filter ispresent(JobEvent) and JobEvent = 'perform.active_job' -| filter JobName = 'NotificationJob' -| sort @timestamp asc -| limit 20 -``` +## Common Questions +**Are Scheduled Jobs Supported?** - No. If you need a scheduled job please use the [SAM Schedule](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-schedule.html) event source which invokes your function with an [Eventbridege AWS::Events::Rule](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-events-rule.html). + +**Are FIFO Queues Supported?** - Yes. When you create your [AWS::SQS::Queue](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-sqs-queues.html) resources you can set the [FifoQueue](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-sqs-queues.html#aws-sqs-queue-fifoqueue) property to `true`. Remember that both your jobs queue and the redrive queue must be the same. When using FIFO we: + +* Simulate `delay_seconds` for ActiveJob's wait by using visibility timeouts under the hood. We still cap it to non-FIFO's 15 minutes. +* Set both the messages `message_group_id` and `message_deduplication_id` to the unique job id provided by ActiveJob. + +**Can I Use Multiple Queues?** - Yes. Nothing is stopping you from creating any number of queues and/or functions to process them. Your subclasses can use ActiveJob's `queue_as` method as needed. This is an easy way to handle job priorities too. + +```ruby +class SomeLowPriorityJob < ApplicationJob + queue_as ENV['BULK_QUEUE_NAME'] +end +``` +**What Is The Max Message Size?** - 256KB. ActiveJob messages should be small however since Rails uses the [GlobalID](https://github.com/rails/globalid) gem to avoid marshaling large data structures to jobs. ## Contributing diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 70b2e74..0000000 --- a/TODO.md +++ /dev/null @@ -1,104 +0,0 @@ - - -# TODO - -* Do I need a worker module? - A: Yes. -* Do I support vanillia ActiveJob? - A: No, these are not compatible. - -```ruby -class AjRetryOnAndWait < ApplicationJob - retry_on ArgumentError, wait: 5.minutes -``` - -* Do a some form of Async queue if this works on Lambda? - - Does Sidekiq do this? - -```ruby -lambdakiq_options async: true -``` - -* Error handlers. Ensure we easily hook into Rollbar, etc. -* Can I set Rails tempalte `VisibilityTimeout` to just +1 of function timeout or full 43200? -* Do this in our gem. `ActiveJob::Base.logger = Logger.new(IO::NULL)` - -## Doc Points - -* Periodic Job - CLoudWatch Events & Cron -* Same as Sidekiq - - Interface -* Differences with Sidekiq - - Max future/delay job is 15 minutes. Uses SQS `delay_seconds`. - - Max retries is 12. - * Sidekiq: 25 retries (20 days, 11 hours) - * Lambdakiq: 12 retries (11 hours, 28 minutes) -* Client Optoins. - - Uses `ENV['AWS_REGION']` for `region`. Likely never need to touch this. - - Default Client Options. Show with config init or railtie? -* Max Message Size: - - FIFO: 256 KB?? -* Setting `maxReceiveCount` hard codes your retries to -1 of that value at the queue level. - -Q: How do I handle job priorities? -A: Use different queues. - -* How we allow FIFO queues to work with delay using message visibility. -* Your SQS queue must have a `RedrivePolicy` policy! - https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-sqs-queues.html#aws-sqs-queue-redrive - - -https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-specification-template-anatomy-globals.html - - -## Our Siqekiq Interfaces - -```ruby -sidekiq_options queue: :bulk -sidekiq_options retry: 0 -sidekiq_options backtrace: true, retry: 5 -sidekiq_options retry: false -``` - -DO I MIRROR or MIGRATE - -## Max Retries - -* Max is twelve. - -## Migrating from Sidekiq - -#### Change Worker - -```ruby -class ApplicationJob < ActiveJob::Base - include Sidekiq::Worker - include Lambdakiq::Worker -end -``` - -#### Single Job - -```ruby -class GuestsCleanupJob < ApplicationJob - self.queue_adapter = :lambdakiq -end -``` - -#### Death Notifications - -https://github.com/mperham/sidekiq/wiki/Error-Handling#death-notification - - -#### Optional - -* Rename all `sidekiq_options` to `lambdakiq_options` - -```ruby -config.after_initialize do - config.active_job.logger = Rails.logger - config.lambdakiq.metrics_logger = Rails.logger -end -``` - -Instrument enqueue_retry and retry_stopped diff --git a/images/Lambdakiq.png b/images/Lambdakiq.png index afda138afa9ccd439fc088c21218afb911cc16ad..0cedca0442e741f73dd350bceafcafe6265c6f15 100644 GIT binary patch literal 29323 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 diff --git a/images/Lambdakiq.sketch b/images/Lambdakiq.sketch new file mode 100644 index 0000000000000000000000000000000000000000..a7d7c3716287d9d810ad7231da0c157854fe0a55 GIT binary patch literal 204800 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