├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Appraisals ├── Gemfile ├── Guardfile ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── cloudwatch_scheduler.gemspec ├── gemfiles ├── .bundle │ └── config ├── rails_6.gemfile ├── rails_6.gemfile.lock ├── rails_7.gemfile └── rails_7.gemfile.lock ├── lib ├── cloudwatch_scheduler.rb └── cloudwatch_scheduler │ ├── configuration.rb │ ├── engine.rb │ ├── job.rb │ ├── provisioner.rb │ ├── task.rb │ ├── tasks │ └── setup.rake │ └── version.rb └── spec ├── cloudwatch_scheduler ├── job_spec.rb └── provisioner_spec.rb ├── cloudwatch_scheduler_spec.rb ├── dummy-61.rb ├── dummy-70.rb ├── rails_helper.rb └── spec_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | rspec: 9 | name: RSpec 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | gemfile: [ rails_6, rails_7 ] 15 | env: 16 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: 3.2 24 | 25 | 26 | - name: Bundle 27 | run: | 28 | bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs=4 --retry=3 29 | 30 | - name: RSpec 31 | run: | 32 | bundle exec rspec 33 | 34 | # build: 35 | # name: Build + Publish 36 | # runs-on: ubuntu-latest 37 | # needs: rspec 38 | 39 | # steps: 40 | # - uses: actions/checkout@master 41 | # - name: Set up Ruby 2.6 42 | # uses: actions/setup-ruby@v1 43 | # with: 44 | # version: 2.6.x 45 | 46 | # - name: Publish to RubyGems 47 | # run: | 48 | # mkdir -p $HOME/.gem 49 | # touch $HOME/.gem/credentials 50 | # chmod 0600 $HOME/.gem/credentials 51 | # printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 52 | # gem build *.gemspec 53 | # gem push *.gem 54 | # env: 55 | # GEM_HOST_API_KEY: ${{secrets.RUBYGEMS_AUTH_TOKEN}} 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | 11 | gemfiles/*.lock 12 | 13 | spec/dummy-*/db/*.sqlite3 14 | spec/dummy-*/db/*.sqlite3-journal 15 | spec/dummy-*/log/*.log 16 | spec/dummy-*/tmp/ 17 | spec/dummy-*/.sass-cache 18 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - https://relaxed.ruby.style/rubocop.yml 3 | 4 | require: 5 | - rubocop-rspec 6 | 7 | AllCops: 8 | DisplayCopNames: true 9 | DisplayStyleGuide: true 10 | NewCops: enable 11 | TargetRubyVersion: 2.6 12 | 13 | Exclude: 14 | - "vendor/**/*" 15 | - "spec/fixtures/**/*" 16 | - "bin/**/*" 17 | - "gemfiles/*" 18 | 19 | 20 | Layout: 21 | Severity: error 22 | Lint: 23 | Severity: error 24 | 25 | Layout/HashAlignment: 26 | EnforcedHashRocketStyle: table 27 | EnforcedColonStyle: table 28 | Layout/LineLength: 29 | Enabled: true 30 | Max: 140 31 | Exclude: 32 | - db/migrate/*.rb 33 | Lint/AmbiguousBlockAssociation: 34 | Exclude: 35 | - "spec/**/*" # `expect { }.to change { }` is fine 36 | Lint/ShadowingOuterLocalVariable: 37 | # Shadowing outer local variables with block parameters is often useful to 38 | # not reinvent a new name for the same thing, it highlights the relation 39 | # between the outer variable and the parameter. The cases where it's actually 40 | # confusing are rare, and usually bad for other reasons already, for example 41 | # because the method is too long. 42 | Enabled: false 43 | Metrics/BlockLength: 44 | Exclude: 45 | - config/routes.rb 46 | - db/migrate/*.rb 47 | - lib/tasks/**/* 48 | - Gemfile 49 | - Guardfile 50 | - shared_context 51 | - feature 52 | - app/admin/* 53 | IgnoredMethods: 54 | - configure 55 | - context 56 | - define 57 | - describe 58 | - factory 59 | - it 60 | - namespace 61 | - specify 62 | - task 63 | - shared_examples_for 64 | - shared_context 65 | - feature 66 | - define_type 67 | Metrics/ClassLength: 68 | Exclude: 69 | - "spec/**/*_spec.rb" 70 | Naming/RescuedExceptionsVariableName: 71 | PreferredName: ex 72 | Naming/FileName: 73 | Enabled: false 74 | Naming/AccessorMethodName: 75 | # Avoid writing accessors in Ruby, but this catches too many false positives 76 | Enabled: false 77 | Naming/MethodParameterName: 78 | Enabled: false 79 | Style/EmptyLiteral: 80 | Enabled: false 81 | Style/FormatStringToken: 82 | Enabled: false 83 | Style/FrozenStringLiteralComment: 84 | Enabled: true 85 | SafeAutoCorrect: true 86 | Style/HashEachMethods: 87 | Enabled: true 88 | Style/HashSyntax: 89 | Exclude: 90 | - lib/tasks/**/*.rake 91 | Style/HashTransformKeys: 92 | Enabled: true 93 | Style/HashTransformValues: 94 | Enabled: true 95 | Style/MethodCallWithoutArgsParentheses: 96 | Enabled: true 97 | Style/NumericLiterals: 98 | Enabled: false 99 | Style/StringChars: 100 | Enabled: true 101 | Style/StringLiterals: 102 | Enabled: true 103 | EnforcedStyle: double_quotes 104 | Style/SymbolArray: 105 | MinSize: 4 106 | 107 | # 0.81 108 | Lint/RaiseException: 109 | Enabled: true 110 | Lint/StructNewOverride: 111 | Enabled: false 112 | 113 | # 0.82 114 | Layout/SpaceAroundMethodCallOperator: 115 | Enabled: true 116 | Style/ExponentialNotation: 117 | Enabled: true 118 | 119 | # 0.83 120 | Layout/EmptyLinesAroundAttributeAccessor: 121 | Enabled: true 122 | Style/SlicingWithRange: 123 | Enabled: true 124 | 125 | # 0.84 126 | Lint/DeprecatedOpenSSLConstant: 127 | Enabled: true 128 | 129 | # 0.85 130 | Lint/MixedRegexpCaptureTypes: 131 | Enabled: true 132 | Style/RedundantRegexpCharacterClass: 133 | Enabled: true 134 | Style/RedundantRegexpEscape: 135 | Enabled: true 136 | 137 | # 0.86 138 | Style/RedundantFetchBlock: 139 | Enabled: true 140 | Lint/ConstantResolution: 141 | Enabled: false 142 | 143 | # 0.87 144 | Style/AccessorGrouping: 145 | Enabled: true 146 | Style/BisectedAttrAccessor: 147 | Enabled: true 148 | Style/RedundantAssignment: 149 | Enabled: true 150 | 151 | # 0.88 152 | Lint/DuplicateElsifCondition: 153 | Enabled: true 154 | Style/ArrayCoercion: 155 | Enabled: true 156 | Style/CaseLikeIf: 157 | Enabled: true 158 | Style/HashAsLastArrayItem: 159 | Enabled: true 160 | Style/HashLikeCase: 161 | Enabled: true 162 | Style/RedundantFileExtensionInRequire: 163 | Enabled: true 164 | 165 | # 0.89 166 | Lint/BinaryOperatorWithIdenticalOperands: 167 | Enabled: true 168 | Lint/DuplicateRescueException: 169 | Enabled: true 170 | Lint/EmptyConditionalBody: 171 | Enabled: true 172 | Lint/FloatComparison: 173 | Enabled: true 174 | Lint/MissingSuper: 175 | Enabled: false 176 | Lint/OutOfRangeRegexpRef: 177 | Enabled: true 178 | Lint/SelfAssignment: 179 | Enabled: true 180 | Lint/TopLevelReturnWithArgument: 181 | Enabled: true 182 | Lint/UnreachableLoop: 183 | Enabled: true 184 | Style/ExplicitBlockArgument: 185 | Enabled: true 186 | Style/GlobalStdStream: 187 | Enabled: false 188 | Style/OptionalBooleanParameter: 189 | Enabled: false 190 | Style/SingleArgumentDig: 191 | Enabled: false 192 | Style/SoleNestedConditional: 193 | Enabled: true 194 | Style/StringConcatenation: 195 | # `"a" + var + "b"` should be interpolated, but `"a" + var` or `var + "b"` is 196 | # fine concatted 197 | Enabled: false 198 | 199 | # 0.90 200 | Lint/DuplicateRequire: 201 | Enabled: true 202 | Lint/EmptyFile: 203 | Enabled: true 204 | Lint/TrailingCommaInAttributeDeclaration: 205 | Enabled: true 206 | Lint/UselessMethodDefinition: 207 | Enabled: true 208 | Style/CombinableLoops: 209 | Enabled: true 210 | Style/KeywordParametersOrder: 211 | Enabled: true 212 | Style/RedundantSelfAssignment: 213 | Enabled: true 214 | 215 | # 0.91 216 | Layout/BeginEndAlignment: 217 | Enabled: true 218 | Lint/ConstantDefinitionInBlock: 219 | Enabled: false 220 | Lint/IdentityComparison: 221 | Enabled: true 222 | Lint/UselessTimes: 223 | Enabled: true 224 | 225 | # 0.93 226 | Lint/HashCompareByIdentity: 227 | Enabled: true 228 | Lint/RedundantSafeNavigation: 229 | Enabled: true 230 | Style/ClassEqualityComparison: 231 | Enabled: true 232 | 233 | # 1.1 234 | Lint/DuplicateRegexpCharacterClassElement: 235 | Enabled: true 236 | Lint/EmptyBlock: 237 | Enabled: false 238 | Lint/ToEnumArguments: 239 | Enabled: true 240 | Lint/UnmodifiedReduceAccumulator: 241 | Enabled: true 242 | Style/ArgumentsForwarding: 243 | Enabled: true 244 | Style/SwapValues: 245 | Enabled: true 246 | Style/DocumentDynamicEvalDefinition: 247 | Enabled: false 248 | 249 | #1.2 250 | Lint/NoReturnInBeginEndBlocks: 251 | Enabled: false 252 | Style/CollectionCompact: 253 | Enabled: true 254 | Style/NegatedIfElseCondition: 255 | Enabled: true 256 | 257 | # 1.3 258 | Lint/DuplicateBranch: 259 | Enabled: true 260 | Lint/EmptyClass: 261 | Enabled: true 262 | Style/NilLambda: 263 | Enabled: true 264 | 265 | # 1.4 266 | Style/RedundantArgument: 267 | Enabled: false # Better to be explicit 268 | 269 | # 1.5 270 | Lint/UnexpectedBlockArity: 271 | Enabled: true 272 | 273 | # 1.7 274 | Layout/SpaceBeforeBrackets: 275 | Enabled: false #spaces are sometimes necessary 276 | Lint/AmbiguousAssignment: 277 | Enabled: true 278 | Style/HashExcept: 279 | Enabled: true 280 | 281 | # 1.8 282 | Lint/DeprecatedConstants: 283 | Enabled: true 284 | Lint/LambdaWithoutLiteralBlock: 285 | Enabled: true 286 | Lint/RedundantDirGlobSort: 287 | Enabled: true 288 | Style/EndlessMethod: 289 | Enabled: true 290 | 291 | # 1.9 292 | 293 | Lint/NumberedParameterAssignment: 294 | Enabled: true 295 | Lint/OrAssignmentToConstant: 296 | Enabled: true 297 | Lint/TripleQuotes: 298 | Enabled: true 299 | Style/IfWithBooleanLiteralBranches: 300 | Enabled: true 301 | Lint/SymbolConversion: 302 | Enabled: true 303 | 304 | # 1.10 305 | Gemspec/DateAssignment: 306 | Enabled: true 307 | Style/HashConversion: 308 | Enabled: true 309 | 310 | 311 | # Rspec 312 | RSpec/Capybara/FeatureMethods: 313 | Enabled: false 314 | RSpec/ContextWording: 315 | Enabled: false 316 | RSpec/DescribeClass: 317 | Enabled: false 318 | RSpec/DescribedClass: 319 | Enabled: false 320 | RSpec/ExampleLength: 321 | Max: 10 322 | RSpec/ExampleWording: 323 | Enabled: false 324 | RSpec/ExpectChange: 325 | EnforcedStyle: block 326 | RSpec/Focus: 327 | Severity: error 328 | RSpec/ImplicitExpect: 329 | Enabled: false 330 | RSpec/LeadingSubject: 331 | Enabled: false 332 | RSpec/MessageSpies: 333 | Enabled: false 334 | RSpec/MultipleExpectations: 335 | Max: 4 336 | RSpec/NestedGroups: 337 | Max: 4 338 | RSpec/NotToNot: 339 | Enabled: false 340 | RSpec/ExpectInHook: 341 | Enabled: false 342 | RSpec/LetSetup: 343 | Enabled: false 344 | RSpec/MultipleMemoizedHelpers: 345 | Enabled: false 346 | 347 | # 1.44 348 | RSpec/StubbedMock: 349 | Enabled: true 350 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise "rails-7" do 4 | gem "railties", "~> 7" 5 | gem "activejob", "~> 7" 6 | end 7 | 8 | appraise "rails-6" do 9 | gem "railties", "~> 6.1" 10 | gem "activejob", "~> 6.1" 11 | end 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in cloudwatch_scheduler.gemspec 6 | gemspec 7 | 8 | group :development, :test do 9 | gem "awesome_print" 10 | gem "guard" 11 | gem "guard-rspec" 12 | 13 | gem "rubocop" 14 | gem "rubocop-rspec" 15 | 16 | gem "appraisal" 17 | end 18 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A sample Guardfile 4 | # More info at https://github.com/guard/guard#readme 5 | 6 | ## Uncomment and set this to only include directories you want to watch 7 | # directories %w(app lib config test spec features) \ 8 | # .select{|d| Dir.exists?(d) ? d : UI.warning("Directory #{d} does not exist")} 9 | 10 | ## Note: if you are using the `directories` clause above and you are not 11 | ## watching the project directory ('.'), then you will want to move 12 | ## the Guardfile to a watched dir and symlink it back, e.g. 13 | # 14 | # $ mkdir config 15 | # $ mv Guardfile config/ 16 | # $ ln -s config/Guardfile . 17 | # 18 | # and, you'll have to watch "config/Guardfile" instead of "Guardfile" 19 | 20 | # Note: The cmd option is now required due to the increasing number of ways 21 | # rspec may be run, below are examples of the most common uses. 22 | # * bundler: 'bundle exec rspec' 23 | # * bundler binstubs: 'bin/rspec' 24 | # * spring: 'bin/rspec' (This will use spring if running and you have 25 | # installed the spring binstubs per the docs) 26 | # * zeus: 'zeus rspec' (requires the server to be started separately) 27 | # * 'just' rspec: 'rspec' 28 | 29 | guard :rspec, cmd: "bundle exec rspec", failed_mode: :focus do 30 | require "guard/rspec/dsl" 31 | dsl = Guard::RSpec::Dsl.new(self) 32 | 33 | # Feel free to open issues for suggestions and improvements 34 | 35 | # RSpec files 36 | rspec = dsl.rspec 37 | watch(rspec.spec_helper) { rspec.spec_dir } 38 | watch(rspec.spec_support) { rspec.spec_dir } 39 | watch(rspec.spec_files) 40 | 41 | # Ruby files 42 | ruby = dsl.ruby 43 | dsl.watch_spec_files_for(ruby.lib_files) 44 | 45 | # Rails files 46 | rails = dsl.rails(view_extensions: %w(erb haml slim)) 47 | dsl.watch_spec_files_for(rails.app_files) 48 | dsl.watch_spec_files_for(rails.views) 49 | 50 | watch(rails.controllers) do |m| 51 | [ 52 | rspec.spec.call("routing/#{m[1]}_routing"), 53 | rspec.spec.call("controllers/#{m[1]}_controller"), 54 | rspec.spec.call("acceptance/#{m[1]}") 55 | ] 56 | end 57 | 58 | # Rails config changes 59 | watch(rails.spec_helper) { rspec.spec_dir } 60 | watch(rails.routes) { "#{rspec.spec_dir}/routing" } 61 | watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" } 62 | 63 | # Capybara features specs 64 | watch(rails.view_dirs) { |m| rspec.spec.call("features/#{m[1]}") } 65 | watch(rails.layouts) { |m| rspec.spec.call("features/#{m[1]}") } 66 | 67 | # Turnip features and steps 68 | watch(%r{^spec/acceptance/(.+)\.feature$}) 69 | watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) do |m| 70 | Dir[File.join("**/#{m[1]}.feature")][0] || "spec/acceptance" 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudwatchScheduler 2 | 3 | [![Actions Status](https://github.com/paul/cloudwatch_scheduler/workflows/CI/badge.svg)](https://github.com/paul/cloudwatch_scheduler/actions) 4 | 5 | Are you using Rails 4.2+ and ActiveJob with the [Shoryuken 6 | driver][shoryuken-driver] to use SQS? Do you have recurring jobs that you kick 7 | off periodically with the [amazing Clockwork gem][clockwork]? Tired of paying 8 | for a Heroku dyno just to run the clockwork instance? Then *CloudwatchScheduler* is just 9 | the gem for you! 10 | 11 | CloudwatchScheduler uses [AWS Cloudwatch scheduled event rules][cloudwatch-events] to 12 | push a message on the cloudwatch_scheduler queue according to the schedule you provide, 13 | using a simple DSL similar to the Clockwork DSL you're already familiar with. 14 | The rules are free, and the messages cost a few billionths of a cent each, 15 | saving you over $25/mo in Heroku dyno costs! Wow!! 16 | 17 | And thats not all! It will automatically provision the Cloudwatch Events and Queues via a simple rake task! Amazing! 18 | 19 | ## Installation 20 | 21 | Add this line to your application's Gemfile: 22 | 23 | ```ruby 24 | gem 'cloudwatch_scheduler' 25 | ``` 26 | 27 | And then execute: 28 | 29 | $ bundle 30 | 31 | Or install it yourself as: 32 | 33 | $ gem install cloudwatch_scheduler 34 | 35 | ## Usage 36 | 37 | Make yourself a `config/cloudwatch_schedule.rb` file: 38 | 39 | ```ruby 40 | require "cloudwatch_scheduler" 41 | 42 | CloudwatchScheduler do |config| 43 | 44 | # 4am every day 45 | task "spawn_analytics_jobs", cron: "0 4 * * ? *" do 46 | return if Rails.application.config.deploy_env.sandbox? 47 | AnalyticsSpawnerJob.perform_later 48 | end 49 | 50 | task "capture_pg_stats", every: 5.minutes do 51 | PgHero.capture_query_stats 52 | end 53 | 54 | # Instead of a block, you can provide any object that responds to `#call` 55 | task "collect_analytics", AnalyticsCollector.new, cron: "*/15 6-18 * * *" 56 | end 57 | ``` 58 | 59 | By default, the Cloudwatch will put events on the `cloudwatch_scheduler` queue 60 | (possibly modified with a prefix from your Shoryuken config). To use a 61 | different queue, CloudwatchScheduler supports some configuration: 62 | 63 | ```ruby 64 | CloudwatchScheduler do |config| 65 | # Queue name to use for events. If you have your Shoryken configured to 66 | # prefix queue names (eg, `production_my_queue`), that will be respected here 67 | # as well 68 | config.queue_name = :my_queue # default `cloudwatch_scheduler` 69 | 70 | # SQS Visibility Timeout - how long will the job be allowed to be worked 71 | # before being made available for retry. This should be (much) shorter than 72 | # the shortest interval between runs of a scheduled task 73 | config.queue_visibility_timeout = 5.minutes # default 1.minute 74 | 75 | # SQS Max Receive Count - how many times can a task be retried before its 76 | # considered failed? 77 | config.queue_max_receive_count = 5 # default 2 78 | 79 | # SQS dead letter queue - Move failed jobs to the dead-letter queue for later investigation or replaying? 80 | config.use_dead_letter_queue = false # default true 81 | end 82 | ``` 83 | 84 | You'll also need to inform Shoryuken about the `cloudwatch_scheduler` queue (or 85 | whatever queue name you're using), either in the `config/shoryuken.yml` or with 86 | `-q cloudwatch_scheduler` on the command line. 87 | 88 | Then do `rake cloudwatch_scheduler:setup`, and CloudwatchScheduler will 89 | provision the Cloudwatch Events and SQS Queue. Start your Shoruken workers as 90 | normal, and the `CloudwatchScheduler::Job` will get those events, and perform 91 | the tasks defined. 92 | 93 | ### Tips 94 | 95 | Generally, you'll want your tasks to be as short as possible, so they don't tie 96 | up the worker process, or fail and need to be retried. If you have more work 97 | than can be accomplished in a few seconds, or is failure prone, then I suggest 98 | having your scheduled task enqueue another job on a different queue. This way, 99 | you can have different retry rules for different kinds of jobs, and the events 100 | worker stays focused on just processing the events. 101 | 102 | ### IAM Permissions 103 | 104 | The setup task requires some permissions in the AWS account to create the queue 105 | and Cloudwatch Events. Here's a sample policy: 106 | 107 | ```json 108 | { 109 | "Version": "2012-10-17", 110 | "Statement": [ 111 | { 112 | "Effect": "Allow", 113 | "Action": [ 114 | "sqs:CreateQueue", 115 | "sqs:GetQueueAttributes", 116 | "sqs:SetQueueAttributes", 117 | ], 118 | "Resource": [ 119 | "arn:aws:sqs:REGION:AWS_ACCOUNT:cloudwatch_scheduler", 120 | "arn:aws:sqs:REGION:AWS_ACCOUNT:cloudwatch_scheduler-failures" 121 | ] 122 | }, 123 | { 124 | "Effect": "Allow", 125 | "Action": [ 126 | "events:PutRule", 127 | "events:PutTargets" 128 | ], 129 | "Resource": [ 130 | "*" 131 | ] 132 | } 133 | ] 134 | } 135 | ``` 136 | 137 | ## Development 138 | 139 | After checking out the repo, run `bin/setup` to install dependencies. Then, run 140 | `rake spec` to run the tests. You can also run `bin/console` for an interactive 141 | prompt that will allow you to experiment. 142 | 143 | To install this gem onto your local machine, run `bundle exec rake install`. To 144 | release a new version, update the version number in `version.rb`, and then run 145 | `bundle exec rake release`, which will create a git tag for the version, push 146 | git commits and tags, and push the `.gem` file to 147 | [rubygems.org](https://rubygems.org). 148 | 149 | ## Contributing 150 | 151 | Bug reports and pull requests are welcome on GitHub at 152 | https://github.com/paul/cloudwatch_scheduler. 153 | 154 | [shoryuken-driver]: https://github.com/phstc/shoryuken/wiki/Rails-Integration-Active-Job 155 | [clockwork]: https://rubygems.org/gems/clockwork 156 | [cloudwatch-events]: http://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/ScheduledEvents.html 157 | 158 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "cloudwatch_scheduler" 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 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /cloudwatch_scheduler.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "cloudwatch_scheduler/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "cloudwatch_scheduler" 9 | spec.version = CloudwatchScheduler::VERSION 10 | spec.authors = ["Paul Sadauskas"] 11 | spec.email = ["psadauskas@gmail.com"] 12 | 13 | spec.summary = "Use AWS CloudWatch events to trigger recurring jobs." 14 | spec.description = "Use Cloudwatch Events to kick off recurring SQS ActiveJob jobs." 15 | spec.homepage = "https://github.com/paul/cloudwatch_scheduler" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r(^exe/)) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.required_ruby_version = ">= 2.6" 23 | 24 | spec.add_dependency "aws-sdk-cloudwatchevents", "~> 1.13" 25 | spec.add_dependency "aws-sdk-sqs", "~> 1.10" 26 | spec.add_dependency "rails", ">= 5.2.0" 27 | spec.add_dependency "shoryuken", ">= 2.0" 28 | 29 | spec.add_development_dependency "bundler", ">= 1.12" 30 | spec.add_development_dependency "rake", "~> 13.0" 31 | spec.add_development_dependency "rspec", "~> 3.0" 32 | end 33 | -------------------------------------------------------------------------------- /gemfiles/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_RETRY: "1" 3 | -------------------------------------------------------------------------------- /gemfiles/rails_6.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "railties", "~> 6.1" 6 | gem "activejob", "~> 6.1" 7 | 8 | group :development, :test do 9 | gem "awesome_print" 10 | gem "guard" 11 | gem "guard-rspec" 12 | gem "rubocop" 13 | gem "rubocop-rspec" 14 | gem "appraisal" 15 | end 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/rails_6.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | cloudwatch_scheduler (1.1.1) 5 | aws-sdk-cloudwatchevents (~> 1.13) 6 | aws-sdk-sqs (~> 1.10) 7 | rails (>= 5.2.0) 8 | shoryuken (>= 2.0) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | actioncable (6.1.7.3) 14 | actionpack (= 6.1.7.3) 15 | activesupport (= 6.1.7.3) 16 | nio4r (~> 2.0) 17 | websocket-driver (>= 0.6.1) 18 | actionmailbox (6.1.7.3) 19 | actionpack (= 6.1.7.3) 20 | activejob (= 6.1.7.3) 21 | activerecord (= 6.1.7.3) 22 | activestorage (= 6.1.7.3) 23 | activesupport (= 6.1.7.3) 24 | mail (>= 2.7.1) 25 | actionmailer (6.1.7.3) 26 | actionpack (= 6.1.7.3) 27 | actionview (= 6.1.7.3) 28 | activejob (= 6.1.7.3) 29 | activesupport (= 6.1.7.3) 30 | mail (~> 2.5, >= 2.5.4) 31 | rails-dom-testing (~> 2.0) 32 | actionpack (6.1.7.3) 33 | actionview (= 6.1.7.3) 34 | activesupport (= 6.1.7.3) 35 | rack (~> 2.0, >= 2.0.9) 36 | rack-test (>= 0.6.3) 37 | rails-dom-testing (~> 2.0) 38 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 39 | actiontext (6.1.7.3) 40 | actionpack (= 6.1.7.3) 41 | activerecord (= 6.1.7.3) 42 | activestorage (= 6.1.7.3) 43 | activesupport (= 6.1.7.3) 44 | nokogiri (>= 1.8.5) 45 | actionview (6.1.7.3) 46 | activesupport (= 6.1.7.3) 47 | builder (~> 3.1) 48 | erubi (~> 1.4) 49 | rails-dom-testing (~> 2.0) 50 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 51 | activejob (6.1.7.3) 52 | activesupport (= 6.1.7.3) 53 | globalid (>= 0.3.6) 54 | activemodel (6.1.7.3) 55 | activesupport (= 6.1.7.3) 56 | activerecord (6.1.7.3) 57 | activemodel (= 6.1.7.3) 58 | activesupport (= 6.1.7.3) 59 | activestorage (6.1.7.3) 60 | actionpack (= 6.1.7.3) 61 | activejob (= 6.1.7.3) 62 | activerecord (= 6.1.7.3) 63 | activesupport (= 6.1.7.3) 64 | marcel (~> 1.0) 65 | mini_mime (>= 1.1.0) 66 | activesupport (6.1.7.3) 67 | concurrent-ruby (~> 1.0, >= 1.0.2) 68 | i18n (>= 1.6, < 2) 69 | minitest (>= 5.1) 70 | tzinfo (~> 2.0) 71 | zeitwerk (~> 2.3) 72 | appraisal (2.4.1) 73 | bundler 74 | rake 75 | thor (>= 0.14.0) 76 | ast (2.4.2) 77 | awesome_print (1.9.2) 78 | aws-eventstream (1.2.0) 79 | aws-partitions (1.777.0) 80 | aws-sdk-cloudwatchevents (1.60.0) 81 | aws-sdk-core (~> 3, >= 3.174.0) 82 | aws-sigv4 (~> 1.1) 83 | aws-sdk-core (3.174.0) 84 | aws-eventstream (~> 1, >= 1.0.2) 85 | aws-partitions (~> 1, >= 1.651.0) 86 | aws-sigv4 (~> 1.5) 87 | jmespath (~> 1, >= 1.6.1) 88 | aws-sdk-sqs (1.57.0) 89 | aws-sdk-core (~> 3, >= 3.174.0) 90 | aws-sigv4 (~> 1.1) 91 | aws-sigv4 (1.5.2) 92 | aws-eventstream (~> 1, >= 1.0.2) 93 | builder (3.2.4) 94 | coderay (1.1.3) 95 | concurrent-ruby (1.2.2) 96 | crass (1.0.6) 97 | date (3.3.3) 98 | diff-lcs (1.5.0) 99 | erubi (1.12.0) 100 | ffi (1.15.5) 101 | formatador (1.1.0) 102 | globalid (1.1.0) 103 | activesupport (>= 5.0) 104 | guard (2.18.0) 105 | formatador (>= 0.2.4) 106 | listen (>= 2.7, < 4.0) 107 | lumberjack (>= 1.0.12, < 2.0) 108 | nenv (~> 0.1) 109 | notiffany (~> 0.0) 110 | pry (>= 0.13.0) 111 | shellany (~> 0.0) 112 | thor (>= 0.18.1) 113 | guard-compat (1.2.1) 114 | guard-rspec (4.7.3) 115 | guard (~> 2.1) 116 | guard-compat (~> 1.1) 117 | rspec (>= 2.99.0, < 4.0) 118 | i18n (1.14.1) 119 | concurrent-ruby (~> 1.0) 120 | jmespath (1.6.2) 121 | json (2.6.3) 122 | listen (3.8.0) 123 | rb-fsevent (~> 0.10, >= 0.10.3) 124 | rb-inotify (~> 0.9, >= 0.9.10) 125 | loofah (2.21.3) 126 | crass (~> 1.0.2) 127 | nokogiri (>= 1.12.0) 128 | lumberjack (1.2.8) 129 | mail (2.8.1) 130 | mini_mime (>= 0.1.1) 131 | net-imap 132 | net-pop 133 | net-smtp 134 | marcel (1.0.2) 135 | method_source (1.0.0) 136 | mini_mime (1.1.2) 137 | minitest (5.18.0) 138 | nenv (0.3.0) 139 | net-imap (0.3.4) 140 | date 141 | net-protocol 142 | net-pop (0.1.2) 143 | net-protocol 144 | net-protocol (0.2.1) 145 | timeout 146 | net-smtp (0.3.3) 147 | net-protocol 148 | nio4r (2.5.9) 149 | nokogiri (1.15.2-x86_64-linux) 150 | racc (~> 1.4) 151 | notiffany (0.1.3) 152 | nenv (~> 0.1) 153 | shellany (~> 0.0) 154 | parallel (1.23.0) 155 | parser (3.2.2.3) 156 | ast (~> 2.4.1) 157 | racc 158 | pry (0.14.2) 159 | coderay (~> 1.1) 160 | method_source (~> 1.0) 161 | racc (1.7.0) 162 | rack (2.2.7) 163 | rack-test (2.1.0) 164 | rack (>= 1.3) 165 | rails (6.1.7.3) 166 | actioncable (= 6.1.7.3) 167 | actionmailbox (= 6.1.7.3) 168 | actionmailer (= 6.1.7.3) 169 | actionpack (= 6.1.7.3) 170 | actiontext (= 6.1.7.3) 171 | actionview (= 6.1.7.3) 172 | activejob (= 6.1.7.3) 173 | activemodel (= 6.1.7.3) 174 | activerecord (= 6.1.7.3) 175 | activestorage (= 6.1.7.3) 176 | activesupport (= 6.1.7.3) 177 | bundler (>= 1.15.0) 178 | railties (= 6.1.7.3) 179 | sprockets-rails (>= 2.0.0) 180 | rails-dom-testing (2.0.3) 181 | activesupport (>= 4.2.0) 182 | nokogiri (>= 1.6) 183 | rails-html-sanitizer (1.6.0) 184 | loofah (~> 2.21) 185 | nokogiri (~> 1.14) 186 | railties (6.1.7.3) 187 | actionpack (= 6.1.7.3) 188 | activesupport (= 6.1.7.3) 189 | method_source 190 | rake (>= 12.2) 191 | thor (~> 1.0) 192 | rainbow (3.1.1) 193 | rake (13.0.6) 194 | rb-fsevent (0.11.2) 195 | rb-inotify (0.10.1) 196 | ffi (~> 1.0) 197 | regexp_parser (2.8.0) 198 | rexml (3.2.5) 199 | rspec (3.12.0) 200 | rspec-core (~> 3.12.0) 201 | rspec-expectations (~> 3.12.0) 202 | rspec-mocks (~> 3.12.0) 203 | rspec-core (3.12.2) 204 | rspec-support (~> 3.12.0) 205 | rspec-expectations (3.12.3) 206 | diff-lcs (>= 1.2.0, < 2.0) 207 | rspec-support (~> 3.12.0) 208 | rspec-mocks (3.12.5) 209 | diff-lcs (>= 1.2.0, < 2.0) 210 | rspec-support (~> 3.12.0) 211 | rspec-support (3.12.0) 212 | rubocop (1.52.0) 213 | json (~> 2.3) 214 | parallel (~> 1.10) 215 | parser (>= 3.2.0.0) 216 | rainbow (>= 2.2.2, < 4.0) 217 | regexp_parser (>= 1.8, < 3.0) 218 | rexml (>= 3.2.5, < 4.0) 219 | rubocop-ast (>= 1.28.0, < 2.0) 220 | ruby-progressbar (~> 1.7) 221 | unicode-display_width (>= 2.4.0, < 3.0) 222 | rubocop-ast (1.29.0) 223 | parser (>= 3.2.1.0) 224 | rubocop-capybara (2.18.0) 225 | rubocop (~> 1.41) 226 | rubocop-factory_bot (2.23.1) 227 | rubocop (~> 1.33) 228 | rubocop-rspec (2.22.0) 229 | rubocop (~> 1.33) 230 | rubocop-capybara (~> 2.17) 231 | rubocop-factory_bot (~> 2.22) 232 | ruby-progressbar (1.13.0) 233 | shellany (0.0.1) 234 | shoryuken (6.0.0) 235 | aws-sdk-core (>= 2) 236 | concurrent-ruby 237 | thor 238 | sprockets (4.2.0) 239 | concurrent-ruby (~> 1.0) 240 | rack (>= 2.2.4, < 4) 241 | sprockets-rails (3.4.2) 242 | actionpack (>= 5.2) 243 | activesupport (>= 5.2) 244 | sprockets (>= 3.0.0) 245 | thor (1.2.2) 246 | timeout (0.3.2) 247 | tzinfo (2.0.6) 248 | concurrent-ruby (~> 1.0) 249 | unicode-display_width (2.4.2) 250 | websocket-driver (0.7.5) 251 | websocket-extensions (>= 0.1.0) 252 | websocket-extensions (0.1.5) 253 | zeitwerk (2.6.8) 254 | 255 | PLATFORMS 256 | x86_64-linux 257 | 258 | DEPENDENCIES 259 | activejob (~> 6.1) 260 | appraisal 261 | awesome_print 262 | bundler (>= 1.12) 263 | cloudwatch_scheduler! 264 | guard 265 | guard-rspec 266 | railties (~> 6.1) 267 | rake (~> 13.0) 268 | rspec (~> 3.0) 269 | rubocop 270 | rubocop-rspec 271 | 272 | BUNDLED WITH 273 | 2.2.16 274 | -------------------------------------------------------------------------------- /gemfiles/rails_7.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "railties", "~> 7" 6 | gem "activejob", "~> 7" 7 | 8 | group :development, :test do 9 | gem "awesome_print" 10 | gem "guard" 11 | gem "guard-rspec" 12 | gem "rubocop" 13 | gem "rubocop-rspec" 14 | gem "appraisal" 15 | end 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/rails_7.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | cloudwatch_scheduler (1.1.1) 5 | aws-sdk-cloudwatchevents (~> 1.13) 6 | aws-sdk-sqs (~> 1.10) 7 | rails (>= 5.2.0) 8 | shoryuken (>= 2.0) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | actioncable (7.0.5) 14 | actionpack (= 7.0.5) 15 | activesupport (= 7.0.5) 16 | nio4r (~> 2.0) 17 | websocket-driver (>= 0.6.1) 18 | actionmailbox (7.0.5) 19 | actionpack (= 7.0.5) 20 | activejob (= 7.0.5) 21 | activerecord (= 7.0.5) 22 | activestorage (= 7.0.5) 23 | activesupport (= 7.0.5) 24 | mail (>= 2.7.1) 25 | net-imap 26 | net-pop 27 | net-smtp 28 | actionmailer (7.0.5) 29 | actionpack (= 7.0.5) 30 | actionview (= 7.0.5) 31 | activejob (= 7.0.5) 32 | activesupport (= 7.0.5) 33 | mail (~> 2.5, >= 2.5.4) 34 | net-imap 35 | net-pop 36 | net-smtp 37 | rails-dom-testing (~> 2.0) 38 | actionpack (7.0.5) 39 | actionview (= 7.0.5) 40 | activesupport (= 7.0.5) 41 | rack (~> 2.0, >= 2.2.4) 42 | rack-test (>= 0.6.3) 43 | rails-dom-testing (~> 2.0) 44 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 45 | actiontext (7.0.5) 46 | actionpack (= 7.0.5) 47 | activerecord (= 7.0.5) 48 | activestorage (= 7.0.5) 49 | activesupport (= 7.0.5) 50 | globalid (>= 0.6.0) 51 | nokogiri (>= 1.8.5) 52 | actionview (7.0.5) 53 | activesupport (= 7.0.5) 54 | builder (~> 3.1) 55 | erubi (~> 1.4) 56 | rails-dom-testing (~> 2.0) 57 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 58 | activejob (7.0.5) 59 | activesupport (= 7.0.5) 60 | globalid (>= 0.3.6) 61 | activemodel (7.0.5) 62 | activesupport (= 7.0.5) 63 | activerecord (7.0.5) 64 | activemodel (= 7.0.5) 65 | activesupport (= 7.0.5) 66 | activestorage (7.0.5) 67 | actionpack (= 7.0.5) 68 | activejob (= 7.0.5) 69 | activerecord (= 7.0.5) 70 | activesupport (= 7.0.5) 71 | marcel (~> 1.0) 72 | mini_mime (>= 1.1.0) 73 | activesupport (7.0.5) 74 | concurrent-ruby (~> 1.0, >= 1.0.2) 75 | i18n (>= 1.6, < 2) 76 | minitest (>= 5.1) 77 | tzinfo (~> 2.0) 78 | appraisal (2.4.1) 79 | bundler 80 | rake 81 | thor (>= 0.14.0) 82 | ast (2.4.2) 83 | awesome_print (1.9.2) 84 | aws-eventstream (1.2.0) 85 | aws-partitions (1.777.0) 86 | aws-sdk-cloudwatchevents (1.60.0) 87 | aws-sdk-core (~> 3, >= 3.174.0) 88 | aws-sigv4 (~> 1.1) 89 | aws-sdk-core (3.174.0) 90 | aws-eventstream (~> 1, >= 1.0.2) 91 | aws-partitions (~> 1, >= 1.651.0) 92 | aws-sigv4 (~> 1.5) 93 | jmespath (~> 1, >= 1.6.1) 94 | aws-sdk-sqs (1.57.0) 95 | aws-sdk-core (~> 3, >= 3.174.0) 96 | aws-sigv4 (~> 1.1) 97 | aws-sigv4 (1.5.2) 98 | aws-eventstream (~> 1, >= 1.0.2) 99 | builder (3.2.4) 100 | coderay (1.1.3) 101 | concurrent-ruby (1.2.2) 102 | crass (1.0.6) 103 | date (3.3.3) 104 | diff-lcs (1.5.0) 105 | erubi (1.12.0) 106 | ffi (1.15.5) 107 | formatador (1.1.0) 108 | globalid (1.1.0) 109 | activesupport (>= 5.0) 110 | guard (2.18.0) 111 | formatador (>= 0.2.4) 112 | listen (>= 2.7, < 4.0) 113 | lumberjack (>= 1.0.12, < 2.0) 114 | nenv (~> 0.1) 115 | notiffany (~> 0.0) 116 | pry (>= 0.13.0) 117 | shellany (~> 0.0) 118 | thor (>= 0.18.1) 119 | guard-compat (1.2.1) 120 | guard-rspec (4.7.3) 121 | guard (~> 2.1) 122 | guard-compat (~> 1.1) 123 | rspec (>= 2.99.0, < 4.0) 124 | i18n (1.14.1) 125 | concurrent-ruby (~> 1.0) 126 | jmespath (1.6.2) 127 | json (2.6.3) 128 | listen (3.8.0) 129 | rb-fsevent (~> 0.10, >= 0.10.3) 130 | rb-inotify (~> 0.9, >= 0.9.10) 131 | loofah (2.21.3) 132 | crass (~> 1.0.2) 133 | nokogiri (>= 1.12.0) 134 | lumberjack (1.2.8) 135 | mail (2.8.1) 136 | mini_mime (>= 0.1.1) 137 | net-imap 138 | net-pop 139 | net-smtp 140 | marcel (1.0.2) 141 | method_source (1.0.0) 142 | mini_mime (1.1.2) 143 | minitest (5.18.0) 144 | nenv (0.3.0) 145 | net-imap (0.3.4) 146 | date 147 | net-protocol 148 | net-pop (0.1.2) 149 | net-protocol 150 | net-protocol (0.2.1) 151 | timeout 152 | net-smtp (0.3.3) 153 | net-protocol 154 | nio4r (2.5.9) 155 | nokogiri (1.15.2-x86_64-linux) 156 | racc (~> 1.4) 157 | notiffany (0.1.3) 158 | nenv (~> 0.1) 159 | shellany (~> 0.0) 160 | parallel (1.23.0) 161 | parser (3.2.2.3) 162 | ast (~> 2.4.1) 163 | racc 164 | pry (0.14.2) 165 | coderay (~> 1.1) 166 | method_source (~> 1.0) 167 | racc (1.7.0) 168 | rack (2.2.7) 169 | rack-test (2.1.0) 170 | rack (>= 1.3) 171 | rails (7.0.5) 172 | actioncable (= 7.0.5) 173 | actionmailbox (= 7.0.5) 174 | actionmailer (= 7.0.5) 175 | actionpack (= 7.0.5) 176 | actiontext (= 7.0.5) 177 | actionview (= 7.0.5) 178 | activejob (= 7.0.5) 179 | activemodel (= 7.0.5) 180 | activerecord (= 7.0.5) 181 | activestorage (= 7.0.5) 182 | activesupport (= 7.0.5) 183 | bundler (>= 1.15.0) 184 | railties (= 7.0.5) 185 | rails-dom-testing (2.0.3) 186 | activesupport (>= 4.2.0) 187 | nokogiri (>= 1.6) 188 | rails-html-sanitizer (1.6.0) 189 | loofah (~> 2.21) 190 | nokogiri (~> 1.14) 191 | railties (7.0.5) 192 | actionpack (= 7.0.5) 193 | activesupport (= 7.0.5) 194 | method_source 195 | rake (>= 12.2) 196 | thor (~> 1.0) 197 | zeitwerk (~> 2.5) 198 | rainbow (3.1.1) 199 | rake (13.0.6) 200 | rb-fsevent (0.11.2) 201 | rb-inotify (0.10.1) 202 | ffi (~> 1.0) 203 | regexp_parser (2.8.0) 204 | rexml (3.2.5) 205 | rspec (3.12.0) 206 | rspec-core (~> 3.12.0) 207 | rspec-expectations (~> 3.12.0) 208 | rspec-mocks (~> 3.12.0) 209 | rspec-core (3.12.2) 210 | rspec-support (~> 3.12.0) 211 | rspec-expectations (3.12.3) 212 | diff-lcs (>= 1.2.0, < 2.0) 213 | rspec-support (~> 3.12.0) 214 | rspec-mocks (3.12.5) 215 | diff-lcs (>= 1.2.0, < 2.0) 216 | rspec-support (~> 3.12.0) 217 | rspec-support (3.12.0) 218 | rubocop (1.52.0) 219 | json (~> 2.3) 220 | parallel (~> 1.10) 221 | parser (>= 3.2.0.0) 222 | rainbow (>= 2.2.2, < 4.0) 223 | regexp_parser (>= 1.8, < 3.0) 224 | rexml (>= 3.2.5, < 4.0) 225 | rubocop-ast (>= 1.28.0, < 2.0) 226 | ruby-progressbar (~> 1.7) 227 | unicode-display_width (>= 2.4.0, < 3.0) 228 | rubocop-ast (1.29.0) 229 | parser (>= 3.2.1.0) 230 | rubocop-capybara (2.18.0) 231 | rubocop (~> 1.41) 232 | rubocop-factory_bot (2.23.1) 233 | rubocop (~> 1.33) 234 | rubocop-rspec (2.22.0) 235 | rubocop (~> 1.33) 236 | rubocop-capybara (~> 2.17) 237 | rubocop-factory_bot (~> 2.22) 238 | ruby-progressbar (1.13.0) 239 | shellany (0.0.1) 240 | shoryuken (6.0.0) 241 | aws-sdk-core (>= 2) 242 | concurrent-ruby 243 | thor 244 | thor (1.2.2) 245 | timeout (0.3.2) 246 | tzinfo (2.0.6) 247 | concurrent-ruby (~> 1.0) 248 | unicode-display_width (2.4.2) 249 | websocket-driver (0.7.5) 250 | websocket-extensions (>= 0.1.0) 251 | websocket-extensions (0.1.5) 252 | zeitwerk (2.6.8) 253 | 254 | PLATFORMS 255 | x86_64-linux 256 | 257 | DEPENDENCIES 258 | activejob (~> 7) 259 | appraisal 260 | awesome_print 261 | bundler (>= 1.12) 262 | cloudwatch_scheduler! 263 | guard 264 | guard-rspec 265 | railties (~> 7) 266 | rake (~> 13.0) 267 | rspec (~> 3.0) 268 | rubocop 269 | rubocop-rspec 270 | 271 | BUNDLED WITH 272 | 2.2.16 273 | -------------------------------------------------------------------------------- /lib/cloudwatch_scheduler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cloudwatch_scheduler/configuration" 4 | require "cloudwatch_scheduler/task" 5 | require "cloudwatch_scheduler/provisioner" 6 | 7 | require "cloudwatch_scheduler/engine" if defined?(Rails) 8 | 9 | # rubocop:disable Naming/MethodName 10 | def CloudwatchScheduler(&config) 11 | CloudwatchScheduler.global.tap { |c| c.configure(&config) } 12 | end 13 | # rubocop:enable Naming/MethodName 14 | 15 | module CloudwatchScheduler 16 | def self.global 17 | @global ||= CloudwatchScheduler::Configuration.new 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/cloudwatch_scheduler/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/numeric/time" 4 | 5 | module CloudwatchScheduler 6 | class Configuration 7 | attr_accessor :queue_name, 8 | :queue_visibility_timeout, 9 | :queue_max_receive_count, 10 | :use_dead_letter_queue 11 | 12 | attr_reader :tasks 13 | 14 | def initialize 15 | @tasks = {} 16 | set_defaults 17 | end 18 | 19 | def configure(&config) 20 | instance_exec(self, &config) 21 | self 22 | end 23 | 24 | def task(name, callable = nil, **kwargs, &block) 25 | @tasks[name] = Task.new(name, callable, **kwargs, &block) 26 | end 27 | 28 | def set_defaults 29 | @queue_name = :cloudwatch_scheduler 30 | @queue_visibility_timeout = 1.minute 31 | @queue_max_receive_count = 2 32 | @use_dead_letter_queue = true 33 | end 34 | 35 | def actual_queue_name 36 | CloudwatchScheduler::Job.queue_name 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/cloudwatch_scheduler/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CloudwatchScheduler 4 | class Engine < Rails::Engine 5 | initializer "cloudwatch_scheduler.setup_job" do 6 | config.to_prepare do 7 | # Have to do this in initializer rather than require time because it 8 | # inherits from ApplicationJob 9 | require "cloudwatch_scheduler/job" 10 | 11 | # Explicitly register this worker, because Shoryuken expects the message 12 | # attributes to specify the job class, and these jobs are produced by 13 | # Cloudwatch Events, which provide no way to set message attributes 14 | Shoryuken.worker_registry.register_worker( 15 | CloudwatchScheduler::Job.queue_name, 16 | ActiveJob::QueueAdapters::ShoryukenAdapter::JobWrapper 17 | ) 18 | 19 | # Load the configuration 20 | require Rails.root.join("config/cloudwatch_schedule").to_s 21 | end 22 | end 23 | 24 | rake_tasks do 25 | load "cloudwatch_scheduler/tasks/setup.rake" 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/cloudwatch_scheduler/job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CloudwatchScheduler 4 | class Job < ::ApplicationJob 5 | queue_as CloudwatchScheduler.global.queue_name 6 | 7 | def initialize(config: CloudwatchScheduler.global) 8 | @config = config 9 | end 10 | 11 | def perform(job_to_spawn) 12 | @config.tasks[job_to_spawn].invoke 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/cloudwatch_scheduler/provisioner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "aws-sdk-sqs" 4 | require "aws-sdk-cloudwatchevents" 5 | 6 | module CloudwatchScheduler 7 | class Provisioner 8 | attr_reader :config 9 | 10 | def initialize(config, sqs_client: Aws::SQS::Client.new, 11 | cwe_client: Aws::CloudWatchEvents::Client.new) 12 | @config = config 13 | @sqs_client = sqs_client 14 | @cwe_client = cwe_client 15 | end 16 | 17 | def provision 18 | create_queue! 19 | create_events! 20 | end 21 | 22 | def create_queue! 23 | attributes = { "VisibilityTimeout" => config.queue_visibility_timeout.to_s } 24 | @queue_url = sqs.create_queue(queue_name: queue_name, attributes: attributes).queue_url 25 | 26 | create_dead_letter_queue! if config.use_dead_letter_queue 27 | end 28 | 29 | def create_events! 30 | rule_arns = config.tasks.map do |_name, task| 31 | rule_arn = cwe.put_rule( 32 | name: task.rule_name, 33 | schedule_expression: task.rule_schedule_expression, 34 | state: "ENABLED", 35 | description: "CloudwatchScheduler task" 36 | ).rule_arn 37 | 38 | cwe.put_targets( 39 | rule: task.rule_name, 40 | targets: [ 41 | { 42 | id: task.rule_name, 43 | arn: queue_arn, 44 | input: task.event_data.to_json 45 | } 46 | ] 47 | ) 48 | 49 | rule_arn 50 | end 51 | 52 | policy = { 53 | Version: "2012-10-17", 54 | Id: "#{queue_arn}/SQSDefaultPolicy", 55 | Statement: rule_arns.map do |rule_arn| 56 | { 57 | Sid: "TrustCWESendingToSQS", 58 | Effect: "Allow", 59 | Principal: { 60 | AWS: "*" 61 | }, 62 | Action: "sqs:SendMessage", 63 | Resource: queue_arn, 64 | Condition: { 65 | ArnEquals: { 66 | "aws:SourceArn": rule_arn 67 | } 68 | } 69 | } 70 | end 71 | } 72 | 73 | sqs.set_queue_attributes( 74 | queue_url: queue_url, 75 | attributes: { 76 | "Policy" => policy.to_json 77 | } 78 | ) 79 | end 80 | 81 | private 82 | 83 | def create_dead_letter_queue! 84 | dlq_name = "#{queue_name}-failures" 85 | dlq_url = sqs.create_queue(queue_name: dlq_name).queue_url 86 | dlq_arn = sqs.get_queue_attributes(queue_url: dlq_url, attribute_names: ["QueueArn"]).attributes["QueueArn"] 87 | 88 | redrive_attrs = { 89 | maxReceiveCount: config.queue_max_receive_count.to_s, 90 | deadLetterTargetArn: dlq_arn 91 | }.to_json 92 | 93 | attributes = { "RedrivePolicy" => redrive_attrs } 94 | 95 | sqs.set_queue_attributes(queue_url: queue_url, attributes: attributes) 96 | end 97 | 98 | def queue_name 99 | config.actual_queue_name 100 | end 101 | 102 | def queue_url 103 | @queue_url ||= sqs.get_queue_url(queue_name: queue_name).queue_url 104 | end 105 | 106 | def queue_arn 107 | @queue_arn ||= 108 | sqs 109 | .get_queue_attributes(queue_url: queue_url, attribute_names: ["QueueArn"]) 110 | .attributes["QueueArn"] 111 | end 112 | 113 | def cwe 114 | @cwe_client 115 | end 116 | 117 | def sqs 118 | @sqs_client 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/cloudwatch_scheduler/task.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/digest/uuid" 4 | 5 | module CloudwatchScheduler 6 | class Task 7 | attr_reader :name, :job 8 | 9 | def initialize(name, callable = nil, every: nil, cron: nil, &block) 10 | @name = name 11 | @every, @cron = every, cron 12 | fail "You must specify one of every: or cron:" unless [@every, @cron].any? 13 | 14 | fail "You must specifiy either callable or a block, not both" if callable && block_given? 15 | 16 | @job = callable || block 17 | end 18 | 19 | def invoke 20 | @job.call 21 | end 22 | 23 | def job_id 24 | Digest::UUID.uuid_v5(Digest::UUID::DNS_NAMESPACE, name) 25 | end 26 | 27 | # {"job_class":"PollActionJob","job_id":"d319ca2e-235f-492b-ab9d-a76d35490ae9", 28 | # "queue_name":"scalar-production-poller","priority":null,"arguments":[433], 29 | # "locale":"en"} 30 | def event_data 31 | { 32 | job_class: CloudwatchScheduler::Job.name, 33 | job_id: job_id, 34 | queue_name: CloudwatchScheduler::Job.queue_name, 35 | arguments: [name], 36 | locale: "en", 37 | priority: nil 38 | } 39 | end 40 | 41 | def rule_name 42 | limit = 64 - CloudwatchScheduler::Job.queue_name.length 43 | [name[0, limit - 1], CloudwatchScheduler::Job.queue_name].join("-") 44 | end 45 | 46 | def rule_schedule_expression 47 | if @every 48 | rate_exp 49 | else 50 | cron_exp 51 | end 52 | end 53 | 54 | def rate_exp 55 | units = if @every % 1.day == 0 56 | "day" 57 | elsif @every % 1.hour == 0 58 | "hour" 59 | elsif @every % 1.minute == 0 60 | "minute" 61 | else 62 | fail "Intervals less than 1 minute are not allowed by Cloudwatch Events." 63 | end 64 | 65 | qty = @every.to_i / 1.send(units) 66 | 67 | "rate(#{qty.to_i} #{units.pluralize(qty)})" 68 | end 69 | 70 | def cron_exp 71 | "cron(#{@cron})" 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/cloudwatch_scheduler/tasks/setup.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :cloudwatch_scheduler do 4 | desc "Create AWS Cloudwatch Event Rules for all defined tasks" 5 | task setup: :environment do 6 | Aws.config[:logger] = Logger.new(STDOUT) 7 | require Rails.root.join("config/cloudwatch_schedule").to_s 8 | 9 | config = CloudwatchScheduler.global 10 | CloudwatchScheduler::Provisioner.new(config).provision 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/cloudwatch_scheduler/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CloudwatchScheduler 4 | VERSION = "1.1.1" 5 | end 6 | -------------------------------------------------------------------------------- /spec/cloudwatch_scheduler/job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | require "cloudwatch_scheduler/job" 6 | 7 | RSpec.describe CloudwatchScheduler::Job do 8 | before do 9 | stub_const("Probe", Object.new) 10 | allow(Probe).to receive :call 11 | end 12 | 13 | let(:event) do 14 | config.tasks["test task"].event_data.stringify_keys 15 | end 16 | 17 | context "with a callable object" do 18 | let(:config) do 19 | CloudwatchScheduler.global.configure do |_config| 20 | task "test task", Probe, every: 1.minute 21 | end 22 | end 23 | 24 | it "should execute the task" do 25 | ActiveJob::Base.execute(event) 26 | 27 | expect(Probe).to have_received(:call) 28 | end 29 | end 30 | 31 | context "with a block" do 32 | let(:config) do 33 | CloudwatchScheduler.global.configure do |_config| 34 | task "test task", every: 1.minute do 35 | Probe.call 36 | end 37 | end 38 | end 39 | 40 | it "should execute the task" do 41 | ActiveJob::Base.execute(event) 42 | 43 | expect(Probe).to have_received(:call) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/cloudwatch_scheduler/provisioner_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe CloudwatchScheduler::Provisioner do 6 | before do 7 | Aws.config[:stub_responses] = true 8 | end 9 | 10 | let(:config) do 11 | CloudwatchScheduler do |config| 12 | config.queue_name = "example_queue_name" 13 | end 14 | end 15 | 16 | let(:sqs_client) do 17 | Aws::SQS::Client.new( 18 | stub_responses: { 19 | create_queue: { queue_url: "https://sqs.example/my-queue" }, 20 | get_queue_attributes: { attributes: { "QueueArn" => "my-queue-arn" } } 21 | } 22 | ) 23 | end 24 | 25 | it "should work" do 26 | CloudwatchScheduler::Provisioner.new(config, sqs_client: sqs_client).provision 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/cloudwatch_scheduler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe CloudwatchScheduler do 6 | it "has a version number" do 7 | expect(CloudwatchScheduler::VERSION).not_to be nil 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy-61.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Set up gems listed in the Gemfile. 4 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 5 | 6 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 7 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 8 | 9 | require "active_job/railtie" 10 | 11 | require "bundler" 12 | Bundler.require(*Rails.groups) 13 | 14 | module Dummy 15 | class Application < Rails::Application 16 | # Initialize configuration defaults for originally generated Rails version. 17 | config.load_defaults 6.1 18 | 19 | # Settings in config/environments/* take precedence over those specified here. 20 | # Application configuration can go into files in config/initializers 21 | # -- all .rb files in that directory are automatically loaded after loading 22 | # the framework and any gems in your application. 23 | end 24 | end 25 | 26 | class ApplicationJob < ActiveJob::Base 27 | end 28 | 29 | # Initialize the Rails application. 30 | Rails.application.initialize! 31 | -------------------------------------------------------------------------------- /spec/dummy-70.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Set up gems listed in the Gemfile. 4 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 5 | 6 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 7 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 8 | 9 | require "active_job/railtie" 10 | 11 | require "bundler" 12 | Bundler.require(*Rails.groups) 13 | 14 | module Dummy 15 | class Application < Rails::Application 16 | # Initialize configuration defaults for originally generated Rails version. 17 | config.load_defaults 7.0 18 | 19 | # Settings in config/environments/* take precedence over those specified here. 20 | # Application configuration can go into files in config/initializers 21 | # -- all .rb files in that directory are automatically loaded after loading 22 | # the framework and any gems in your application. 23 | end 24 | end 25 | 26 | class ApplicationJob < ActiveJob::Base 27 | end 28 | 29 | # Initialize the Rails application. 30 | Rails.application.initialize! 31 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "rails" 6 | rails_version = [Rails::VERSION::MAJOR, Rails::VERSION::MINOR].join 7 | require_relative "dummy-#{rails_version}" 8 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "cloudwatch_scheduler" 5 | 6 | require "awesome_print" 7 | --------------------------------------------------------------------------------