lambdakiq 0.0.1 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94edc48f3f08f3e16711bb2ece50cc7cc5fcbda5bdbff4ca83de387b1c991fed
4
- data.tar.gz: 3ad808f67b8b497b4fde72f0377bb24efc8cf2d812cd4e7786a379b418af4f93
3
+ metadata.gz: 5c2216c121ae2ba5e013c8852bc71d8e3e84342cd67053e4d9933a88562c543a
4
+ data.tar.gz: f1c48212d2f14307fbb7a09ac10748d108b0d705270df8e3a5b54047bdcee758
5
5
  SHA512:
6
- metadata.gz: 126c569c9e85bf812dda786e2f14a1eba6206de6072f80d235d60077fec67a9a1bc2195055e1dbf84f58cdf1a632f6559fe81908d2906801d87bae430191d9e6
7
- data.tar.gz: 93d33b2b486c0b92a905fd1c3b1c5c4872652e2e42d52c804df42e2ec0209e5fb3cc8744bec480796119c49479d0b43524e33326f01bafd91d8dc9c36992e94d
6
+ metadata.gz: 687c1792d5f9e15ce30db4226a0248b46a874e2c8b55e81327a871977ccbff375ec67eb3c8279a2ff565e7e1c9f05f5147f70a0bec1e6308e8a8fbc2863440f3
7
+ data.tar.gz: 0e88a280f225db1dfb8251b0622e99bf3d781dba6c817fe8f70b8345725613421578da31cc5bac37d8664fd0390431518a741f92d71e8da4087506c742719e10
data/Gemfile.lock CHANGED
@@ -1,53 +1,98 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- lambdakiq (1.0.0)
4
+ lambdakiq (1.0.1)
5
5
  activejob
6
6
  aws-sdk-sqs
7
+ concurrent-ruby
8
+ railties
7
9
 
8
10
  GEM
9
11
  remote: https://rubygems.org/
10
12
  specs:
11
- activejob (6.0.3.4)
12
- activesupport (= 6.0.3.4)
13
+ actionpack (6.1.1)
14
+ actionview (= 6.1.1)
15
+ activesupport (= 6.1.1)
16
+ rack (~> 2.0, >= 2.0.9)
17
+ rack-test (>= 0.6.3)
18
+ rails-dom-testing (~> 2.0)
19
+ rails-html-sanitizer (~> 1.0, >= 1.2.0)
20
+ actionview (6.1.1)
21
+ activesupport (= 6.1.1)
22
+ builder (~> 3.1)
23
+ erubi (~> 1.4)
24
+ rails-dom-testing (~> 2.0)
25
+ rails-html-sanitizer (~> 1.1, >= 1.2.0)
26
+ activejob (6.1.1)
27
+ activesupport (= 6.1.1)
13
28
  globalid (>= 0.3.6)
14
- activesupport (6.0.3.4)
29
+ activesupport (6.1.1)
15
30
  concurrent-ruby (~> 1.0, >= 1.0.2)
16
- i18n (>= 0.7, < 2)
17
- minitest (~> 5.1)
18
- tzinfo (~> 1.1)
19
- zeitwerk (~> 2.2, >= 2.2.2)
31
+ i18n (>= 1.6, < 2)
32
+ minitest (>= 5.1)
33
+ tzinfo (~> 2.0)
34
+ zeitwerk (~> 2.3)
20
35
  aws-eventstream (1.1.0)
21
- aws-partitions (1.399.0)
22
- aws-sdk-core (3.109.3)
36
+ aws-partitions (1.422.0)
37
+ aws-sdk-core (3.111.2)
23
38
  aws-eventstream (~> 1, >= 1.0.2)
24
39
  aws-partitions (~> 1, >= 1.239.0)
25
40
  aws-sigv4 (~> 1.1)
26
41
  jmespath (~> 1.0)
27
- aws-sdk-sqs (1.34.0)
42
+ aws-sdk-sqs (1.35.0)
28
43
  aws-sdk-core (~> 3, >= 3.109.0)
29
44
  aws-sigv4 (~> 1.1)
30
45
  aws-sigv4 (1.2.2)
31
46
  aws-eventstream (~> 1, >= 1.0.2)
47
+ builder (3.2.4)
32
48
  coderay (1.1.3)
33
- concurrent-ruby (1.1.7)
49
+ concurrent-ruby (1.1.8)
50
+ crass (1.0.6)
51
+ erubi (1.10.0)
34
52
  globalid (0.4.2)
35
53
  activesupport (>= 4.2.0)
36
- i18n (1.8.5)
54
+ i18n (1.8.7)
37
55
  concurrent-ruby (~> 1.0)
38
56
  jmespath (1.4.0)
57
+ loofah (2.9.0)
58
+ crass (~> 1.0.2)
59
+ nokogiri (>= 1.5.9)
60
+ macaddr (1.7.2)
61
+ systemu (~> 2.6.5)
39
62
  method_source (1.0.0)
63
+ mini_portile2 (2.5.0)
40
64
  minitest (5.14.2)
41
65
  minitest-focus (1.2.1)
42
66
  minitest (>= 4, < 6)
43
67
  mocha (1.11.2)
68
+ nokogiri (1.11.1)
69
+ mini_portile2 (~> 2.5.0)
70
+ racc (~> 1.4)
44
71
  pry (0.13.1)
45
72
  coderay (~> 1.1)
46
73
  method_source (~> 1.0)
74
+ racc (1.5.2)
75
+ rack (2.2.3)
76
+ rack-test (1.1.0)
77
+ rack (>= 1.0, < 3)
78
+ rails-dom-testing (2.0.3)
79
+ activesupport (>= 4.2.0)
80
+ nokogiri (>= 1.6)
81
+ rails-html-sanitizer (1.3.0)
82
+ loofah (~> 2.3)
83
+ railties (6.1.1)
84
+ actionpack (= 6.1.1)
85
+ activesupport (= 6.1.1)
86
+ method_source
87
+ rake (>= 0.8.7)
88
+ thor (~> 1.0)
47
89
  rake (13.0.1)
48
- thread_safe (0.3.6)
49
- tzinfo (1.2.8)
50
- thread_safe (~> 0.1)
90
+ systemu (2.6.5)
91
+ thor (1.1.0)
92
+ tzinfo (2.0.4)
93
+ concurrent-ruby (~> 1.0)
94
+ uuid (2.3.9)
95
+ macaddr (~> 1.0)
51
96
  zeitwerk (2.4.2)
52
97
 
53
98
  PLATFORMS
@@ -61,6 +106,7 @@ DEPENDENCIES
61
106
  mocha
62
107
  pry
63
108
  rake
109
+ uuid
64
110
 
65
111
  BUNDLED WITH
66
112
  2.1.4
data/README.md CHANGED
@@ -1,12 +1,248 @@
1
+ ![Test](https://github.com/customink/lambdakiq/workflows/Test/badge.svg)
2
+
3
+ ![Lambdakiq: ActiveJob on SQS & Lambda](images/Lambdakiq.png)
1
4
 
2
5
  # Lambdakiq
3
6
 
4
- TODO ...
7
+ <a href="https://lamby.custominktech.com"><img src="https://user-images.githubusercontent.com/2381/59363668-89edeb80-8d03-11e9-9985-2ce14361b7e3.png" alt="Lamby: Simple Rails & AWS Lambda Integration using Rack." align="right" width="300" /></a>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.
8
+
9
+ 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.
10
+
11
+ ## Key Features
12
+
13
+ * Distinct web & jobs Lambda functions.
14
+ * AWS fully managed polling. Event-driven.
15
+ * Maximum 12 retries. Per job configurable.
16
+ * Mirror Sidekiq's retry [backoff](https://github.com/mperham/sidekiq/wiki/Error-Handling#automatic-job-retry) timing.
17
+ * Last retry is at 11 hours 30 minutes.
18
+ * Supports ActiveJob's wait/delay. Up to 15 minutes.
19
+ * Dead messages are stored for up to 14 days.
20
+
21
+ ## Project Setup
22
+
23
+ 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
24
+
25
+
26
+ ### Bundle & Config
27
+
28
+ Add the Lambdakiq gem to your `Gemfile`.
29
+
30
+ ```ruby
31
+ gem 'lambdakiq'
32
+ ```
33
+
34
+ Open `config/initializers/production.rb` and set Lambdakiq as your ActiveJob queue adapter.
35
+
36
+ ```ruby
37
+ config.active_job.queue_adapter = :lambdakiq
38
+ ```
39
+
40
+ Open `app/jobs/application_job.rb` and add our worker module. The queue name will be set by an environment using CloudFormation further down.
41
+
42
+ ```ruby
43
+ class ApplicationJob < ActiveJob::Base
44
+ include Lambdakiq::Worker
45
+ queue_as ENV['JOBS_QUEUE_NAME']
46
+ end
47
+ ```
48
+
49
+ ### SQS Resources
50
+
51
+ 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.
52
+
53
+ ```yaml
54
+ JobsQueue:
55
+ Type: AWS::SQS::Queue
56
+ Properties:
57
+ ReceiveMessageWaitTimeSeconds: 10
58
+ RedrivePolicy:
59
+ deadLetterTargetArn: !GetAtt JobsDLQueue.Arn
60
+ maxReceiveCount: 13
61
+ VisibilityTimeout: 301
62
+
63
+ JobsDLQueue:
64
+ Type: AWS::SQS::Queue
65
+ Properties:
66
+ MessageRetentionPeriod: 1209600
67
+ ```
68
+
69
+ 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.
70
+
71
+ 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.
72
+
73
+ ### Queue Name Environment Variable
74
+
75
+ 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.
76
+
77
+ ```yaml
78
+ Globals:
79
+ Function:
80
+ Environment:
81
+ Variables:
82
+ RAILS_ENV: !Ref RailsEnv
83
+ JOBS_QUEUE_NAME: !GetAtt JobsQueue.QueueName
84
+ ```
85
+
86
+ 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.
87
+
88
+ ### IAM Permissions
89
+
90
+ 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.
91
+
92
+ ```yaml
93
+ Policies:
94
+ - Version: '2012-10-17'
95
+ Statement:
96
+ - Effect: Allow
97
+ Action:
98
+ - sqs:*
99
+ Resource:
100
+ - !Sub arn:aws:sqs:${AWS::Region}:${AWS::AccountId}:${JobsQueue.QueueName}
101
+ ```
102
+
103
+ 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!
104
+
105
+ ```yaml
106
+ JobsLambda:
107
+ Type: AWS::Serverless::Function
108
+ Metadata:
109
+ DockerContext: ./.lamby/RailsLambda
110
+ Dockerfile: Dockerfile
111
+ DockerTag: jobs
112
+ Properties:
113
+ Events:
114
+ SQSJobs:
115
+ Type: SQS
116
+ Properties:
117
+ Queue: !GetAtt JobsQueue.Arn
118
+ BatchSize: 1
119
+ MemorySize: 1792
120
+ PackageType: Image
121
+ Policies:
122
+ - Version: '2012-10-17'
123
+ Statement:
124
+ - Effect: Allow
125
+ Action:
126
+ - sqs:*
127
+ Resource:
128
+ - !Sub arn:aws:sqs:${AWS::Region}:${AWS::AccountId}:${JobsQueue.QueueName}
129
+ Timeout: 300
130
+ ```
131
+
132
+ Here are some key aspects of our `JobsLambda` resource above:
133
+
134
+ * The `Events` property uses the [SQS Type](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-sqs.html).
135
+ * 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.
136
+ * 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.
137
+ * 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.
138
+
139
+ 🎉 Deploy your application and have fun with ActiveJob on SQS & Lambda.
140
+
141
+ ## Configuration
142
+
143
+ Most general Lambdakiq configuration options are exposed via the Rails standard configuration method.
144
+
145
+ ### Rails Configs
146
+
147
+ ```ruby
148
+ config.lambdakiq
149
+ ```
150
+
151
+ * `max_retries=` - Retries for all jobs. Default is the Lambdakiq maximum of `12`.
152
+ * `metrics_namespace=` - The CloudWatch Embedded Metrics namespace. Default is `Lambdakiq`.
153
+ * `metrics_logger=` - Set to the Rails logger which is STDOUT via Lamby/Lambda.
154
+
155
+ ### ActiveJob Configs
156
+
157
+ You can also set configuration options on a per job basis using the `lambdakiq_options` method.
158
+
159
+ ```ruby
160
+ class OrderProcessorJob < ApplicationJob
161
+ lambdakiq_options retry: 2
162
+ end
163
+ ```
164
+
165
+ * `retry` - Overrides the default Lambdakiq `max_retries` for this one job.
166
+
167
+ ## Concurrency & Limits
168
+
169
+ 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.
170
+
171
+ To help keep your queue and workers scalable, reduce the errors raised by your jobs. You an also reduce the retry count.
172
+
173
+ ## Observability with CloudWatch
174
+
175
+ 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).
176
+
177
+ ![CloudWatch Dashboard](https://user-images.githubusercontent.com/2381/106465990-be7a6200-6468-11eb-8461-93db0046cda5.png)
178
+
179
+ 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.
180
+
181
+ ### Metric Dimensions
182
+
183
+ * `AppName` - This is the name of your Rails application. Ex: `MyApp`
184
+ * `JobEvent` - Name of the ActiveSupport Notification. Ex: `*.active_job`.
185
+ * `JobName` - The class name of the ActiveSupport job. Ex: `NotificationJob`
186
+
187
+ ### ActiveJob Event Names
188
+ 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.
189
+
190
+ * `enqueue.active_job`
191
+ * `enqueue_at.active_job`
192
+
193
+ While these event/metrics can be found in the jobs function's log.
194
+
195
+ * `perform_start.active_job`
196
+ * `perform.active_job`
197
+ * `enqueue_retry.active_job`
198
+ * `retry_stopped.active_job`
199
+
200
+ ### Metric Properties
201
+
202
+ 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).
203
+
204
+ * `JobId` - ActiveJob Unique ID. Ex: `9f3b6977-6afc-4769-aed6-bab1ad9a0df5`
205
+ * `QueueName` - SQS Queue Name. Ex: `myapp-JobsQueue-14F18LG6XFUW5.fifo`
206
+ * `MessageId` - SQS Message ID. Ex: `5653246d-dc5e-4c95-9583-b6b83ec78602`
207
+ * `ExceptionName` - Class name of error raised. Present in perform and retry events.
208
+ * `EnqueuedAt` - When ActiveJob enqueued the message. Ex: `2021-01-14T01:43:38Z`
209
+ * `Executions` - The number of current executions. Counts from `1` and up.
210
+ * `JobArg#{n}` - Enumerated serialized arguments.
211
+
212
+ ### Metric Data
213
+
214
+ 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).
215
+
216
+ * `Duration` - Of the job event in milliseconds.
217
+ * `Count` - Of the event.
218
+ * `ExceptionCount` - Of the event. Useful with `ExceptionName`.
219
+
220
+ ### CloudWatch Dashboard Examples
221
+
222
+ Please share how you are using CloudWatch to monitor and/or alert on your ActiveJobs with Lambdakiq!
223
+
224
+ 💬 https://github.com/customink/lambdakiq/discussions/3
225
+
226
+
227
+ ## Common Questions
228
+
229
+ **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).
230
+
231
+ **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:
232
+
233
+ * Simulate `delay_seconds` for ActiveJob's wait by using visibility timeouts under the hood. We still cap it to non-FIFO's 15 minutes.
234
+ * Set both the messages `message_group_id` and `message_deduplication_id` to the unique job id provided by ActiveJob.
235
+
236
+ **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.
5
237
 
6
238
  ```ruby
7
- # TODO ...
239
+ class SomeLowPriorityJob < ApplicationJob
240
+ queue_as ENV['BULK_QUEUE_NAME']
241
+ end
8
242
  ```
9
243
 
244
+ **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.
245
+
10
246
  ## Contributing
11
247
 
12
248
  After checking out the repo, run:
data/Rakefile CHANGED
@@ -5,7 +5,7 @@ require "rake/testtask"
5
5
  Rake::TestTask.new(:test) do |t|
6
6
  t.libs << "test"
7
7
  t.libs << "lib"
8
- t.test_files = FileList["test/**/*_test.rb"]
8
+ t.test_files = FileList["test/cases/**/*_test.rb"]
9
9
  t.verbose = false
10
10
  t.warning = false
11
11
  end
data/bin/_console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "lambdakiq"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/console CHANGED
@@ -1,14 +1,6 @@
1
- #!/usr/bin/env ruby
1
+ #!/bin/bash
2
+ set -e
2
3
 
3
- require "bundler/setup"
4
- require "lambdakiq"
5
-
6
- # You can add fixtures and/or initialization code here to make experimenting
7
- # with your gem easier. You can also use a different console, if you like.
8
-
9
- # (If you use this, don't forget to add pry to your Gemfile!)
10
- # require "pry"
11
- # Pry.start
12
-
13
- require "irb"
14
- IRB.start(__FILE__)
4
+ docker-compose run \
5
+ lambdakiqgem \
6
+ ./bin/_console
@@ -12,17 +12,20 @@ Gem::Specification.new do |spec|
12
12
  spec.homepage = "https://github.com/customink/lambdakiq"
13
13
  spec.license = "MIT"
14
14
  spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
15
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
15
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|images)/}) }
16
16
  end
17
17
  spec.bindir = "exe"
18
18
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
19
  spec.require_paths = ["lib"]
20
20
  spec.add_dependency 'activejob'
21
21
  spec.add_dependency 'aws-sdk-sqs'
22
+ spec.add_dependency 'concurrent-ruby'
23
+ spec.add_dependency 'railties'
22
24
  spec.add_development_dependency 'bundler'
23
25
  spec.add_development_dependency 'rake'
24
26
  spec.add_development_dependency 'minitest'
25
27
  spec.add_development_dependency 'minitest-focus'
26
28
  spec.add_development_dependency 'mocha'
27
29
  spec.add_development_dependency 'pry'
30
+ spec.add_development_dependency 'uuid'
28
31
  end
data/lib/lambdakiq.rb CHANGED
@@ -1,16 +1,41 @@
1
+ require 'json'
2
+ require 'digest'
1
3
  require 'active_job'
2
- require 'aws-sdk-sqs'
4
+ require 'active_job/queue_adapters'
5
+ require 'active_support/all'
3
6
  require 'lambdakiq/version'
4
-
5
- # if defined?(Rails)
6
- # require 'rails/railtie'
7
- # require 'lambdakiq/railtie'
8
- # end
7
+ require 'lambdakiq/error'
8
+ require 'lambdakiq/adapter'
9
+ require 'lambdakiq/client'
10
+ require 'lambdakiq/queue'
11
+ require 'lambdakiq/message'
12
+ require 'lambdakiq/event'
13
+ require 'lambdakiq/job'
14
+ require 'lambdakiq/record'
15
+ require 'lambdakiq/backoff'
16
+ require 'lambdakiq/metrics'
17
+ require 'lambdakiq/worker'
18
+ require 'rails/railtie'
19
+ require 'lambdakiq/railtie'
9
20
 
10
21
  module Lambdakiq
11
22
 
12
- extend self
23
+ def handler(event)
24
+ Job.handler(event)
25
+ end
26
+
27
+ def jobs?(event)
28
+ Event.jobs?(event)
29
+ end
13
30
 
14
- # autoload :Xyz, 'lambdakiq/xyz'
31
+ def client
32
+ @client ||= Client.new
33
+ end
34
+
35
+ def config
36
+ Lambdakiq::Railtie.config.lambdakiq
37
+ end
38
+
39
+ extend self
15
40
 
16
41
  end
@@ -0,0 +1,38 @@
1
+ module ActiveJob
2
+ module QueueAdapters
3
+ class LambdakiqAdapter
4
+
5
+ def enqueue(job, options = {})
6
+ job.lambdakiq_async? ? _enqueue_async(job, options) : _enqueue(job, options)
7
+ end
8
+
9
+ def enqueue_at(job, timestamp)
10
+ enqueue job, delay_seconds: delay_seconds(timestamp)
11
+ end
12
+
13
+ private
14
+
15
+ def delay_seconds(timestamp)
16
+ ds = (timestamp - Time.current.to_i).to_i
17
+ [ds, 900].min
18
+ end
19
+
20
+ def _enqueue(job, options = {})
21
+ queue = Lambdakiq.client.queues[job.queue_name]
22
+ queue.send_message job, options
23
+ end
24
+
25
+ def _enqueue_async(job, options = {})
26
+ Concurrent::Promise
27
+ .execute { _enqueue(job, options) }
28
+ .on_error { |e| async_enqueue_error(e) }
29
+ end
30
+
31
+ def async_enqueue_error(e)
32
+ msg = "[Lambdakiq] Failed to queue job #{job}. Reason: #{e}"
33
+ Rails.logger.error(msg)
34
+ end
35
+
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,40 @@
1
+ module Lambdakiq
2
+ class Backoff
3
+
4
+ MAX_VISIBILITY_TIMEOUT = 43200 # 12 Hours
5
+
6
+ attr_reader :count
7
+
8
+ class << self
9
+
10
+ def backoff(count)
11
+ new(count).backoff
12
+ end
13
+
14
+ end
15
+
16
+ def initialize(count)
17
+ @count = count
18
+ end
19
+
20
+ # From Sidekiq: https://git.io/fhi5O
21
+ #
22
+ def backoff
23
+ case count
24
+ when 1 then 30
25
+ when 2 then 46
26
+ when 3 then 76
27
+ when 4 then 156
28
+ when 5 then 346
29
+ when 6 then 730
30
+ when 7 then 1416
31
+ when 8 then 2536
32
+ when 9 then 4246
33
+ when 10 then 6726
34
+ when 11 then 10180
35
+ when 12 then 14836
36
+ end
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,37 @@
1
+ module Lambdakiq
2
+ class Client
3
+
4
+ class_attribute :default_options,
5
+ instance_writer: false,
6
+ instance_predicate: false,
7
+ default: Hash.new
8
+
9
+ attr_reader :queues
10
+
11
+ def initialize
12
+ @queues = Hash.new do |h, name|
13
+ h[name] = Queue.new(name)
14
+ end
15
+ end
16
+
17
+ def sqs
18
+ @sqs ||= begin
19
+ require 'aws-sdk-sqs'
20
+ Aws::SQS::Client.new(options)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def options
27
+ default_options.tap do |opts|
28
+ opts[:region] ||= region if region
29
+ end
30
+ end
31
+
32
+ def region
33
+ ENV['AWS_REGION']
34
+ end
35
+
36
+ end
37
+ end
@@ -0,0 +1,21 @@
1
+ module Lambdakiq
2
+ class Error < StandardError
3
+ end
4
+
5
+ class JobError < Error
6
+ attr_reader :original_exception, :job
7
+
8
+ def initialize(error)
9
+ @original_exception = error
10
+ super(error.message)
11
+ set_backtrace Rails.backtrace_cleaner.clean(error.backtrace)
12
+ end
13
+ end
14
+
15
+ class FifoDelayError < Error
16
+ def initialize(error)
17
+ super
18
+ set_backtrace([])
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ module Lambdakiq
2
+ module Event
3
+
4
+ def jobs?(event)
5
+ records(event).any? { |r| job?(r) }
6
+ end
7
+
8
+ def job?(record)
9
+ record.dig('messageAttributes', 'lambdakiq', 'stringValue') == '1'
10
+ end
11
+
12
+ def records(event)
13
+ event['Records'] || []
14
+ end
15
+
16
+ extend self
17
+
18
+ end
19
+ end
@@ -0,0 +1,120 @@
1
+ module Lambdakiq
2
+ class Job
3
+
4
+ attr_reader :record, :error
5
+
6
+ class << self
7
+
8
+ def handler(event)
9
+ records = Event.records(event)
10
+ jobs = records.map { |record| new(record) }
11
+ jobs.each(&:perform)
12
+ jwerror = jobs.detect{ |j| j.error }
13
+ return unless jwerror
14
+ raise JobError.new(jwerror.error)
15
+ end
16
+
17
+ end
18
+
19
+ def initialize(record)
20
+ @record = Record.new(record)
21
+ @error = false
22
+ end
23
+
24
+ def job_data
25
+ @job_data ||= JSON.parse(record.body).tap do |data|
26
+ data['provider_job_id'] = record.message_id
27
+ data['executions'] = record.receive_count - 1
28
+ end
29
+ end
30
+
31
+ def active_job
32
+ @active_job ||= ActiveJob::Base.deserialize(job_data)
33
+ end
34
+
35
+ def queue
36
+ Lambdakiq.client.queues[active_job.queue_name]
37
+ end
38
+
39
+ def executions
40
+ active_job.executions
41
+ end
42
+
43
+ def perform
44
+ if fifo_delay?
45
+ fifo_delay
46
+ raise FifoDelayError, active_job.job_id
47
+ end
48
+ execute
49
+ end
50
+
51
+ def execute
52
+ ActiveJob::Base.execute(job_data)
53
+ delete_message
54
+ rescue Exception => e
55
+ increment_executions
56
+ perform_error(e)
57
+ end
58
+
59
+ private
60
+
61
+ def client_params
62
+ { queue_url: queue.queue_url, receipt_handle: record.receipt_handle }
63
+ end
64
+
65
+ def perform_error(e)
66
+ if change_message_visibility
67
+ instrument :enqueue_retry, error: e, wait: record.next_visibility_timeout
68
+ @error = e
69
+ else
70
+ instrument :retry_stopped, error: e
71
+ delete_message
72
+ end
73
+ end
74
+
75
+ def delete_message
76
+ client.delete_message(client_params)
77
+ rescue Exception => e
78
+ true
79
+ end
80
+
81
+ def change_message_visibility
82
+ return false if max_receive_count?
83
+ params = client_params.merge visibility_timeout: record.next_visibility_timeout
84
+ client.change_message_visibility(params)
85
+ true
86
+ end
87
+
88
+ def client
89
+ Lambdakiq.client.sqs
90
+ end
91
+
92
+ def max_receive_count?
93
+ executions > retry_limit
94
+ end
95
+
96
+ def retry_limit
97
+ config_retry = [Lambdakiq.config.max_retries, 12].min
98
+ [ (active_job.lambdakiq_retry || config_retry),
99
+ (queue.max_receive_count - 1) ].min
100
+ end
101
+
102
+ def fifo_delay?
103
+ queue.fifo? && record.fifo_delay_seconds?
104
+ end
105
+
106
+ def fifo_delay
107
+ params = client_params.merge visibility_timeout: record.fifo_delay_visibility_timeout
108
+ client.change_message_visibility(params)
109
+ end
110
+
111
+ def increment_executions
112
+ active_job.executions = active_job.executions + 1
113
+ end
114
+
115
+ def instrument(name, error: nil, wait: nil)
116
+ active_job.send :instrument, name, error: error, wait: wait
117
+ end
118
+
119
+ end
120
+ end
@@ -0,0 +1,67 @@
1
+ module Lambdakiq
2
+ class Message
3
+ LAMBDAKIQ_ATTRIBUTE = { 'lambdakiq' => { string_value: '1', data_type: 'String' } }.freeze
4
+
5
+ attr_reader :queue, :job, :options
6
+
7
+ def initialize(queue, job, options = {})
8
+ @queue = queue
9
+ @job = job
10
+ @options = options
11
+ end
12
+
13
+ def params
14
+ message_params.merge(message_options)
15
+ end
16
+
17
+ private
18
+
19
+ def message_params
20
+ { message_body: message_body,
21
+ message_attributes: message_attributes }
22
+ .merge(message_params_fifo)
23
+ end
24
+
25
+ def message_options
26
+ if queue.fifo?
27
+ options.except(:delay_seconds)
28
+ else
29
+ options
30
+ end
31
+ end
32
+
33
+ def message_body
34
+ JSON.dump(job.serialize)
35
+ end
36
+
37
+ def message_params_fifo
38
+ if queue.fifo?
39
+ { message_group_id: job.job_id,
40
+ message_deduplication_id: job.job_id }
41
+ else
42
+ {}
43
+ end
44
+ end
45
+
46
+ def message_attributes
47
+ LAMBDAKIQ_ATTRIBUTE.merge(delay_seconds_attribute)
48
+ end
49
+
50
+ def delay_seconds
51
+ options[:delay_seconds] || 0
52
+ end
53
+
54
+ def delay_seconds?
55
+ !delay_seconds.zero?
56
+ end
57
+
58
+ def delay_seconds_attribute
59
+ if queue.fifo? && delay_seconds?
60
+ { 'delay_seconds' => { string_value: delay_seconds.to_s, data_type: 'String' } }
61
+ else
62
+ {}
63
+ end
64
+ end
65
+
66
+ end
67
+ end
@@ -0,0 +1,110 @@
1
+ module Lambdakiq
2
+ class Metrics
3
+ attr_reader :event
4
+
5
+ class << self
6
+ def log(event)
7
+ new(event).log
8
+ end
9
+ end
10
+
11
+ def initialize(event)
12
+ @event = event
13
+ @metrics = []
14
+ @properties = {}
15
+ instrument!
16
+ end
17
+
18
+ def log
19
+ logger.info JSON.dump(message)
20
+ end
21
+
22
+ private
23
+
24
+ def job
25
+ event.payload[:job]
26
+ end
27
+
28
+ def job_name
29
+ job.class.name
30
+ end
31
+
32
+ def logger
33
+ Lambdakiq.config.metrics_logger
34
+ end
35
+
36
+ def namespace
37
+ Lambdakiq.config.metrics_namespace
38
+ end
39
+
40
+ def exception_name
41
+ event.payload[:exception].try(:first) ||
42
+ event.payload[:error]&.class&.name
43
+ end
44
+
45
+ def dimensions
46
+ [
47
+ { AppName: rails_app_name },
48
+ { JobEvent: event.name },
49
+ { JobName: job_name }
50
+ ]
51
+ end
52
+
53
+ def instrument!
54
+ put_metric 'Duration', event.duration.to_i, 'Milliseconds'
55
+ put_metric 'Count', 1, 'Count'
56
+ put_metric 'ExceptionCount', 1, 'Count' if exception_name
57
+ set_property 'JobId', job.job_id
58
+ set_property 'JobName', job_name
59
+ set_property 'QueueName', job.queue_name
60
+ set_property 'MessageId', job.provider_job_id if job.provider_job_id
61
+ set_property 'ExceptionName', exception_name if exception_name
62
+ set_property 'EnqueuedAt', job.enqueued_at if job.enqueued_at
63
+ set_property 'Executions', job.executions if job.executions
64
+ job.arguments.each_with_index do |argument, index|
65
+ set_property "JobArg#{index+1}", argument
66
+ end
67
+ end
68
+
69
+ def put_metric(name, value, unit = nil)
70
+ @metrics << { 'Name': name }.tap do |m|
71
+ m['Unit'] = unit if unit
72
+ end
73
+ set_property name, value
74
+ end
75
+
76
+ def set_property(name, value)
77
+ @properties[name] = value
78
+ self
79
+ end
80
+
81
+ def message
82
+ {
83
+ '_aws': {
84
+ 'Timestamp': timestamp,
85
+ 'CloudWatchMetrics': [
86
+ {
87
+ 'Namespace': namespace,
88
+ 'Dimensions': [dimensions.map(&:keys).flatten],
89
+ 'Metrics': @metrics
90
+ }
91
+ ]
92
+ }
93
+ }.tap do |m|
94
+ dimensions.each { |d| m.merge!(d) }
95
+ m.merge!(@properties)
96
+ end
97
+ end
98
+
99
+ def timestamp
100
+ Time.current.strftime('%s%3N').to_i
101
+ end
102
+
103
+ def rails_app_name
104
+ Lambdakiq.config.metrics_app_name ||
105
+ Rails.application.class.name.split('::').first
106
+ end
107
+
108
+ end
109
+ end
110
+
@@ -0,0 +1,55 @@
1
+ module Lambdakiq
2
+ class Queue
3
+
4
+ attr_reader :queue_name,
5
+ :queue_url
6
+
7
+ def initialize(queue_name)
8
+ @queue_name = queue_name
9
+ @queue_url = get_queue_url
10
+ attributes
11
+ end
12
+
13
+ def send_message(job, options = {})
14
+ client.send_message send_message_params(job, options)
15
+ end
16
+
17
+ def attributes
18
+ @attributes ||= client.get_queue_attributes({
19
+ queue_url: queue_url,
20
+ attribute_names: ['All']
21
+ }).attributes
22
+ end
23
+
24
+ def redrive_policy
25
+ @redrive_policy ||= JSON.parse(attributes['RedrivePolicy'])
26
+ end
27
+
28
+ def max_receive_count
29
+ redrive_policy['maxReceiveCount'].to_i
30
+ end
31
+
32
+ def fifo?
33
+ queue_name.ends_with?('.fifo')
34
+ end
35
+
36
+ private
37
+
38
+ def client
39
+ Lambdakiq.client.sqs
40
+ end
41
+
42
+ def get_queue_url
43
+ client.get_queue_url(queue_name: queue_name).queue_url
44
+ end
45
+
46
+ def send_message_params(job, options)
47
+ { queue_url: queue_url }.merge(message_params(job, options))
48
+ end
49
+
50
+ def message_params(job, options)
51
+ Message.new(self, job, options).params
52
+ end
53
+
54
+ end
55
+ end
@@ -1,5 +1,19 @@
1
1
  module Lambdakiq
2
2
  class Railtie < ::Rails::Railtie
3
3
  config.lambdakiq = ActiveSupport::OrderedOptions.new
4
+ config.lambdakiq.max_retries = 12
5
+ config.lambdakiq.metrics_namespace = 'Lambdakiq'
6
+
7
+ config.after_initialize do
8
+ config.active_job.logger = Rails.logger
9
+ config.lambdakiq.metrics_logger = Rails.logger
10
+ end
11
+
12
+ initializer "lambdakiq.metrics" do |app|
13
+ ActiveSupport::Notifications.subscribe(/active_job/) do |*args|
14
+ event = ActiveSupport::Notifications::Event.new *args
15
+ Lambdakiq::Metrics.log(event)
16
+ end
17
+ end
4
18
  end
5
19
  end
@@ -0,0 +1,58 @@
1
+ module Lambdakiq
2
+ class Record
3
+
4
+ attr_reader :data
5
+
6
+ def initialize(data)
7
+ @data = data
8
+ end
9
+
10
+ def body
11
+ data['body']
12
+ end
13
+
14
+ def message_id
15
+ data['messageId']
16
+ end
17
+
18
+ def receipt_handle
19
+ data['receiptHandle']
20
+ end
21
+
22
+ def queue_name
23
+ @queue_name ||= data['eventSourceARN'].split(':').last
24
+ end
25
+
26
+ def attributes
27
+ data['attributes']
28
+ end
29
+
30
+ def fifo_delay_visibility_timeout
31
+ fifo_delay_seconds - (Time.current - sent_at).to_i
32
+ end
33
+
34
+ def fifo_delay_seconds
35
+ data.dig('messageAttributes', 'delay_seconds', 'stringValue').try(:to_i)
36
+ end
37
+
38
+ def fifo_delay_seconds?
39
+ fifo_delay_seconds && (sent_at + fifo_delay_seconds).future?
40
+ end
41
+
42
+ def sent_at
43
+ @sent_at ||= begin
44
+ ts = attributes['SentTimestamp'].to_i / 1000
45
+ Time.zone ? Time.zone.at(ts) : Time.at(ts)
46
+ end
47
+ end
48
+
49
+ def receive_count
50
+ @receive_count ||= attributes['ApproximateReceiveCount'].to_i
51
+ end
52
+
53
+ def next_visibility_timeout
54
+ @next_visibility_timeout ||= Backoff.backoff(receive_count)
55
+ end
56
+
57
+ end
58
+ end
@@ -1,3 +1,3 @@
1
1
  module Lambdakiq
2
- VERSION = '0.0.1'
2
+ VERSION = '1.0.1'
3
3
  end
@@ -0,0 +1,28 @@
1
+ module Lambdakiq
2
+ module Worker
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class_attribute :lambdakiq_options_hash,
7
+ instance_predicate: false,
8
+ default: Hash.new
9
+ end
10
+
11
+ class_methods do
12
+
13
+ def lambdakiq_options(options = {})
14
+ self.lambdakiq_options_hash = options.symbolize_keys
15
+ end
16
+
17
+ end
18
+
19
+ def lambdakiq_retry
20
+ lambdakiq_options_hash[:retry]
21
+ end
22
+
23
+ def lambdakiq_async?
24
+ !!lambdakiq_options_hash[:async]
25
+ end
26
+
27
+ end
28
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lambdakiq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken Collins
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-11-29 00:00:00.000000000 Z
11
+ date: 2021-02-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -38,6 +38,34 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: concurrent-ruby
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: railties
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
41
69
  - !ruby/object:Gem::Dependency
42
70
  name: bundler
43
71
  requirement: !ruby/object:Gem::Requirement
@@ -122,6 +150,20 @@ dependencies:
122
150
  - - ">="
123
151
  - !ruby/object:Gem::Version
124
152
  version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: uuid
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
125
167
  description: Scalable Rails Background Processing with AWS Lambda & SQS.
126
168
  email:
127
169
  - kcollins@customink.com
@@ -139,6 +181,7 @@ files:
139
181
  - LICENSE.txt
140
182
  - README.md
141
183
  - Rakefile
184
+ - bin/_console
142
185
  - bin/_setup
143
186
  - bin/_test
144
187
  - bin/bootstrap
@@ -147,10 +190,21 @@ files:
147
190
  - bin/test
148
191
  - bin/update
149
192
  - docker-compose.yml
150
- - lamby.gemspec
193
+ - lambdakiq.gemspec
151
194
  - lib/lambdakiq.rb
195
+ - lib/lambdakiq/adapter.rb
196
+ - lib/lambdakiq/backoff.rb
197
+ - lib/lambdakiq/client.rb
198
+ - lib/lambdakiq/error.rb
199
+ - lib/lambdakiq/event.rb
200
+ - lib/lambdakiq/job.rb
201
+ - lib/lambdakiq/message.rb
202
+ - lib/lambdakiq/metrics.rb
203
+ - lib/lambdakiq/queue.rb
152
204
  - lib/lambdakiq/railtie.rb
205
+ - lib/lambdakiq/record.rb
153
206
  - lib/lambdakiq/version.rb
207
+ - lib/lambdakiq/worker.rb
154
208
  - vendor/.keep
155
209
  homepage: https://github.com/customink/lambdakiq
156
210
  licenses: