Skip to content

Commit faf8961

Browse files
authored
Support async job processing in EBS SQSD middleware (#168)
* Support async job processing in EBS SQSD middleware * rubocop cleanups * Add specs * Update readme * Reset sample-app from testing * Bust cache * Use bundler latest * pr cleanups + add background processing of periodic tasks * Fix rubocop * cleanup sample-app from testing * Add changelog * Readme updates
1 parent 8aa3afa commit faf8961

File tree

6 files changed

+163
-22
lines changed

6 files changed

+163
-22
lines changed

.rubocop.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@ Metrics/BlockLength:
1717
- 'spec/**/*.rb'
1818

1919
Metrics/MethodLength:
20+
Max: 15
2021
Exclude:
2122
- 'spec/**/*.rb'
2223

2324
Metrics/ModuleLength:
2425
Exclude:
2526
- 'spec/**/*.rb'
2627

28+
Metrics/ClassLength:
29+
Max: 150
30+
2731
Naming/FileName:
2832
Exclude:
2933
- 'lib/aws-sdk-rails.rb'

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
Unreleased Changes
22
------------------
33

4+
* Feature - Support async job processing in Elastic Beanstalk middleware. (#167)
5+
46
5.0.0 (2024-11-21)
57
------------------
68

README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,15 +126,42 @@ the `AWS_PROCESS_BEANSTALK_WORKER_REQUESTS` environment variable to `true` in
126126
the worker environment configuration. The
127127
[SQS Daemon](https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/using-features-managing-env-tiers.html#worker-daemon)
128128
running on the worker sends messages as a POST request to `http://localhost/`.
129-
The aws-sdk-rails middleware will forward each request and parameters to their
129+
The ElasticBeanstalkSQSD middleware will forward each request and parameters to their
130130
appropriate jobs. The middleware will only process requests from the SQS daemon
131131
and will pass on others and so will not interfere with other routes in your
132132
application.
133133

134134
To protect against forgeries, daemon requests will only be processed if they
135135
originate from localhost or the Docker host.
136136

137-
Periodic (scheduled) jobs are also supported with this approach. Elastic
137+
#### Running Jobs Async
138+
By default the ElasticBeanstalkSQSD middleware will process jobs synchronously
139+
and will not complete the request until the job has finished executing. For
140+
long running jobs (exceeding the configured nginix timeout on the worker) this
141+
may cause timeouts and incomplete executions.
142+
143+
To run jobs asynchronously, set the `AWS_PROCESS_BEANSTALK_WORKER_JOBS_ASYNC`
144+
environment variable to `true` in your worker environment. Jobs will be queued
145+
in a ThreadPoolExecutor and the request will return a 200 OK immediately and the
146+
SQS message will be deleted and the job will be executed in the background.
147+
148+
By default the executor will use the available processor count as the the
149+
max_threads. You can configure the max threads for the executor by setting
150+
the `AWS_PROCESS_BEANSTALK_WORKER_THREADS` environment variable.
151+
152+
When there is no additional capacity to execute a task, the middleware
153+
returns a 429 (too many requests) response which will result in the
154+
sqsd NOT deleting the message. The message will be retried again once its
155+
visibility timeout is reached.
156+
157+
Periodic (scheduled) tasks will also be run asynchronously in the same way.
158+
Elastic beanstalk queues a message for the periodic task and if there is
159+
no capacity to execute the task, it will be retried again once the message's
160+
visibility timeout is reached.
161+
162+
#### Periodic (scheduled) jobs
163+
[Periodic (scheduled) tasks](https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/using-features-managing-env-tiers.html#worker-periodictasks)
164+
are also supported with this approach. Elastic
138165
Beanstalk workers support the addition of a `cron.yaml` file in the application
139166
root to configure this. You can call your jobs from your controller actions
140167
or if you name your cron job the same as your job class and set the URL to

lib/aws/rails/middleware/elastic_beanstalk_sqsd.rb

Lines changed: 83 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ class ElasticBeanstalkSQSD
88
def initialize(app)
99
@app = app
1010
@logger = ::Rails.logger
11+
12+
return unless ENV['AWS_PROCESS_BEANSTALK_WORKER_JOBS_ASYNC']
13+
14+
@executor = init_executor
1115
end
1216

1317
def call(env)
@@ -25,48 +29,108 @@ def call(env)
2529
end
2630

2731
# Execute job or periodic task based on HTTP request context
28-
periodic_task?(request) ? execute_periodic_task(request) : execute_job(request)
32+
execute(request)
33+
end
34+
35+
def shutdown(timeout = nil)
36+
return unless @executor
37+
38+
@logger.info("Shutting down SQS EBS background job executor. Timeout: #{timeout}")
39+
@executor.shutdown
40+
clean_shutdown = @executor.wait_for_termination(timeout)
41+
@logger.info("SQS EBS background executor shutdown complete. Clean: #{clean_shutdown}")
2942
end
3043

3144
private
3245

46+
def init_executor
47+
threads = Integer(ENV.fetch('AWS_PROCESS_BEANSTALK_WORKER_THREADS',
48+
Concurrent.available_processor_count || Concurrent.processor_count))
49+
options = {
50+
max_threads: threads,
51+
max_queue: 1,
52+
auto_terminate: false, # register our own at_exit to gracefully shutdown
53+
fallback_policy: :abort # Concurrent::RejectedExecutionError must be handled
54+
}
55+
at_exit { shutdown }
56+
57+
Concurrent::ThreadPoolExecutor.new(options)
58+
end
59+
60+
def execute(request)
61+
if periodic_task?(request)
62+
execute_periodic_task(request)
63+
else
64+
execute_job(request)
65+
end
66+
end
67+
3368
def execute_job(request)
69+
if @executor
70+
_execute_job_background(request)
71+
else
72+
_execute_job_now(request)
73+
end
74+
end
75+
76+
# Execute a job in the current thread
77+
def _execute_job_now(request)
3478
# Jobs queued from the SQS adapter contain the JSON message in the request body.
3579
job = ::ActiveSupport::JSON.decode(request.body.string)
3680
job_name = job['job_class']
3781
@logger.debug("Executing job: #{job_name}")
38-
_execute_job(job, job_name)
39-
[200, { 'Content-Type' => 'text/plain' }, ["Successfully ran job #{job_name}."]]
40-
rescue NameError
41-
internal_error_response
42-
end
43-
44-
def _execute_job(job, job_name)
4582
::ActiveJob::Base.execute(job)
83+
[200, { 'Content-Type' => 'text/plain' }, ["Successfully ran job #{job_name}."]]
4684
rescue NameError => e
4785
@logger.error("Job #{job_name} could not resolve to a class that inherits from Active Job.")
4886
@logger.error("Error: #{e}")
49-
raise e
87+
internal_error_response
88+
end
89+
90+
# Execute a job using the thread pool executor
91+
def _execute_job_background(request)
92+
job_data = ::ActiveSupport::JSON.decode(request.body.string)
93+
@logger.debug("Queuing background job: #{job_data['job_class']}")
94+
@executor.post(job_data) do |job|
95+
::ActiveJob::Base.execute(job)
96+
end
97+
[200, { 'Content-Type' => 'text/plain' }, ["Successfully queued job #{job_data['job_class']}"]]
98+
rescue Concurrent::RejectedExecutionError
99+
msg = 'No capacity to execute job.'
100+
@logger.info(msg)
101+
[429, { 'Content-Type' => 'text/plain' }, [msg]]
50102
end
51103

52104
def execute_periodic_task(request)
53105
# The beanstalk worker SQS Daemon will add the 'X-Aws-Sqsd-Taskname' for periodic tasks set in cron.yaml.
54106
job_name = request.headers['X-Aws-Sqsd-Taskname']
55-
@logger.debug("Creating and executing periodic task: #{job_name}")
56-
_execute_periodic_task(job_name)
57-
[200, { 'Content-Type' => 'text/plain' }, ["Successfully ran periodic task #{job_name}."]]
58-
rescue NameError
59-
internal_error_response
60-
end
61-
62-
def _execute_periodic_task(job_name)
63107
job = job_name.constantize.new
64-
job.perform_now
108+
if @executor
109+
_execute_periodic_task_background(job)
110+
else
111+
_execute_periodic_task_now(job)
112+
end
65113
rescue NameError => e
66114
@logger.error("Periodic task #{job_name} could not resolve to an Active Job class " \
67115
'- check the cron name spelling and set the path as / in cron.yaml.')
68116
@logger.error("Error: #{e}.")
69-
raise e
117+
internal_error_response
118+
end
119+
120+
def _execute_periodic_task_now(job)
121+
@logger.debug("Executing periodic task: #{job.class}")
122+
job.perform_now
123+
[200, { 'Content-Type' => 'text/plain' }, ["Successfully ran periodic task #{job.class}."]]
124+
end
125+
126+
def _execute_periodic_task_background(job)
127+
@logger.debug("Queuing bakground periodic task: #{job.class}")
128+
@executor.post(job, &:perform_now)
129+
[200, { 'Content-Type' => 'text/plain' }, ["Successfully queued periodic task #{job.class}"]]
130+
rescue Concurrent::RejectedExecutionError
131+
msg = 'No capacity to execute periodic task.'
132+
@logger.info(msg)
133+
[429, { 'Content-Type' => 'text/plain' }, [msg]]
70134
end
71135

72136
def internal_error_response

spec/aws/rails/middleware/elastic_beanstalk_sqsd_spec.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,50 @@ module Middleware
219219
include_examples 'is valid in either cgroup1 or cgroup2'
220220
end
221221

222+
context 'when AWS_PROCESS_BEANSTALK_WORKER_JOBS_ASYNC' do
223+
before(:each) do
224+
ENV['AWS_PROCESS_BEANSTALK_WORKER_JOBS_ASYNC'] = 'true'
225+
end
226+
227+
after(:each) do
228+
ENV.delete('AWS_PROCESS_BEANSTALK_WORKER_JOBS_ASYNC')
229+
end
230+
231+
it 'queues job' do
232+
expect_any_instance_of(Concurrent::ThreadPoolExecutor).to receive(:post)
233+
expect(response[0]).to eq(200)
234+
expect(response[2]).to eq(['Successfully queued job ElasticBeanstalkJob'])
235+
end
236+
237+
context 'no capacity' do
238+
it 'returns too many requests error' do
239+
allow_any_instance_of(Concurrent::ThreadPoolExecutor).to receive(:post)
240+
.and_raise Concurrent::RejectedExecutionError
241+
242+
expect(response[0]).to eq(429)
243+
end
244+
end
245+
246+
context 'periodic task' do
247+
let(:is_periodic_task) { true }
248+
249+
it 'queues job' do
250+
expect_any_instance_of(Concurrent::ThreadPoolExecutor).to receive(:post)
251+
expect(response[0]).to eq(200)
252+
expect(response[2]).to eq(['Successfully queued periodic task ElasticBeanstalkPeriodicTask'])
253+
end
254+
255+
context 'no capacity' do
256+
it 'returns too many requests error' do
257+
allow_any_instance_of(Concurrent::ThreadPoolExecutor).to receive(:post)
258+
.and_raise Concurrent::RejectedExecutionError
259+
260+
expect(response[0]).to eq(429)
261+
end
262+
end
263+
end
264+
end
265+
222266
def stub_runs_in_neither_docker_container
223267
proc_1_cgroup = <<~CONTENT
224268
0::/

0 commit comments

Comments
 (0)