├── .github └── workflows │ ├── lint_rubocop.yml │ ├── test_ruby_2.7.yml │ └── test_ruby_3.x.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Appraisals ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── _config.yml ├── app └── controllers │ └── cloudtasker │ └── worker_controller.rb ├── bin ├── console └── setup ├── cloudtasker.gemspec ├── config └── routes.rb ├── docs ├── BATCH_JOBS.md ├── CRON_JOBS.md ├── STORABLE_JOBS.md └── UNIQUE_JOBS.md ├── examples ├── cloud-run │ ├── .ruby-version │ ├── Dockerfile │ ├── Gemfile │ ├── Gemfile.lock │ ├── README.md │ ├── Rakefile │ ├── app │ │ ├── assets │ │ │ ├── config │ │ │ │ └── manifest.js │ │ │ ├── images │ │ │ │ └── .keep │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── channels │ │ │ └── application_cable │ │ │ │ ├── channel.rb │ │ │ │ └── connection.rb │ │ ├── controllers │ │ │ ├── application_controller.rb │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ └── enqueue_job_controller.rb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── javascript │ │ │ └── packs │ │ │ │ └── application.js │ │ ├── jobs │ │ │ ├── application_job.rb │ │ │ └── example_job.rb │ │ ├── mailers │ │ │ └── application_mailer.rb │ │ ├── models │ │ │ ├── application_record.rb │ │ │ └── concerns │ │ │ │ └── .keep │ │ ├── views │ │ │ └── layouts │ │ │ │ ├── application.html.erb │ │ │ │ ├── mailer.html.erb │ │ │ │ └── mailer.text.erb │ │ └── workers │ │ │ └── dummy_worker.rb │ ├── bin │ │ ├── rails │ │ ├── rake │ │ └── setup │ ├── config.ru │ ├── config │ │ ├── application.rb │ │ ├── boot.rb │ │ ├── cable.yml │ │ ├── credentials.yml.enc │ │ ├── database.yml │ │ ├── environment.rb │ │ ├── environments │ │ │ ├── development.rb │ │ │ ├── production.rb │ │ │ └── test.rb │ │ ├── initializers │ │ │ ├── application_controller_renderer.rb │ │ │ ├── assets.rb │ │ │ ├── backtrace_silencers.rb │ │ │ ├── cloudtasker.rb │ │ │ ├── content_security_policy.rb │ │ │ ├── cookies_serializer.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── inflections.rb │ │ │ ├── mime_types.rb │ │ │ └── wrap_parameters.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── master.key │ │ ├── puma.rb │ │ ├── routes.rb │ │ ├── spring.rb │ │ └── storage.yml │ ├── db │ │ ├── development.sqlite3 │ │ └── test.sqlite3 │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── log │ │ ├── .keep │ │ └── development.log │ ├── public │ │ ├── 404.html │ │ ├── 422.html │ │ ├── 500.html │ │ ├── apple-touch-icon-precomposed.png │ │ ├── apple-touch-icon.png │ │ └── favicon.ico │ ├── storage │ │ └── .keep │ └── tmp │ │ ├── .keep │ │ ├── development_secret.txt │ │ └── restart.txt ├── rails │ ├── .ruby-version │ ├── Gemfile │ ├── Gemfile.lock │ ├── Procfile │ ├── README.md │ ├── Rakefile │ ├── app │ │ ├── assets │ │ │ ├── config │ │ │ │ └── manifest.js │ │ │ ├── images │ │ │ │ └── .keep │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── channels │ │ │ └── application_cable │ │ │ │ ├── channel.rb │ │ │ │ └── connection.rb │ │ ├── controllers │ │ │ ├── application_controller.rb │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ └── enqueue_job_controller.rb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── javascript │ │ │ └── packs │ │ │ │ └── application.js │ │ ├── jobs │ │ │ ├── application_job.rb │ │ │ └── example_job.rb │ │ ├── mailers │ │ │ └── application_mailer.rb │ │ ├── models │ │ │ ├── application_record.rb │ │ │ └── concerns │ │ │ │ └── .keep │ │ ├── views │ │ │ └── layouts │ │ │ │ ├── application.html.erb │ │ │ │ ├── mailer.html.erb │ │ │ │ └── mailer.text.erb │ │ └── workers │ │ │ ├── batch_worker.rb │ │ │ ├── critical_worker.rb │ │ │ ├── cron_worker.rb │ │ │ ├── dummy_worker.rb │ │ │ └── uniq_executing_worker.rb │ ├── bin │ │ ├── rails │ │ ├── rake │ │ └── setup │ ├── config.ru │ ├── config │ │ ├── application.rb │ │ ├── boot.rb │ │ ├── cable.yml │ │ ├── credentials.yml.enc │ │ ├── database.yml │ │ ├── environment.rb │ │ ├── environments │ │ │ ├── development.rb │ │ │ ├── production.rb │ │ │ └── test.rb │ │ ├── initializers │ │ │ ├── application_controller_renderer.rb │ │ │ ├── backtrace_silencers.rb │ │ │ ├── cloudtasker.rb │ │ │ ├── content_security_policy.rb │ │ │ ├── cookies_serializer.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── inflections.rb │ │ │ ├── mime_types.rb │ │ │ └── wrap_parameters.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── master.key │ │ ├── puma.rb │ │ ├── routes.rb │ │ ├── spring.rb │ │ └── storage.yml │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── log │ │ └── .keep │ ├── public │ │ ├── 404.html │ │ ├── 422.html │ │ ├── 500.html │ │ ├── apple-touch-icon-precomposed.png │ │ ├── apple-touch-icon.png │ │ └── favicon.ico │ ├── storage │ │ └── .keep │ └── tmp │ │ ├── .keep │ │ └── development_secret.txt └── sinatra │ ├── .ruby-version │ ├── Gemfile │ ├── Gemfile.lock │ ├── Procfile │ ├── README.md │ ├── app.rb │ ├── app │ └── workers │ │ ├── batch_worker.rb │ │ ├── cron_worker.rb │ │ ├── dummy_worker.rb │ │ └── uniq_executing_worker.rb │ ├── bin │ └── console │ └── config │ └── initializers │ └── cloudtasker.rb ├── exe └── cloudtasker ├── gemfiles ├── .bundle │ └── config ├── google_cloud_tasks_1.0.gemfile ├── google_cloud_tasks_1.1.gemfile ├── google_cloud_tasks_1.2.gemfile ├── google_cloud_tasks_1.3.gemfile ├── google_cloud_tasks_1.4.gemfile ├── google_cloud_tasks_1.5.gemfile ├── google_cloud_tasks_2.0.gemfile ├── google_cloud_tasks_2.1.gemfile ├── rails_5.2.gemfile ├── rails_6.0.gemfile ├── rails_6.1.gemfile ├── rails_7.0.gemfile ├── rails_7.1.gemfile ├── semantic_logger_3.4.gemfile ├── semantic_logger_4.6.gemfile ├── semantic_logger_4.7.0.gemfile └── semantic_logger_4.7.2.gemfile ├── lib ├── active_job │ └── queue_adapters │ │ └── cloudtasker_adapter.rb ├── cloudtasker.rb ├── cloudtasker │ ├── authentication_error.rb │ ├── authenticator.rb │ ├── backend │ │ ├── google_cloud_task_v1.rb │ │ ├── google_cloud_task_v2.rb │ │ ├── memory_task.rb │ │ └── redis_task.rb │ ├── batch.rb │ ├── batch │ │ ├── batch_progress.rb │ │ ├── extension │ │ │ └── worker.rb │ │ ├── job.rb │ │ ├── middleware.rb │ │ └── middleware │ │ │ └── server.rb │ ├── cli.rb │ ├── cloud_task.rb │ ├── config.rb │ ├── cron.rb │ ├── cron │ │ ├── job.rb │ │ ├── middleware.rb │ │ ├── middleware │ │ │ └── server.rb │ │ └── schedule.rb │ ├── dead_worker_error.rb │ ├── engine.rb │ ├── invalid_worker_error.rb │ ├── local_server.rb │ ├── max_task_size_exceeded_error.rb │ ├── meta_store.rb │ ├── middleware │ │ └── chain.rb │ ├── missing_worker_arguments_error.rb │ ├── redis_client.rb │ ├── retry_worker_error.rb │ ├── storable.rb │ ├── storable │ │ └── worker.rb │ ├── testing.rb │ ├── unique_job.rb │ ├── unique_job │ │ ├── conflict_strategy │ │ │ ├── base_strategy.rb │ │ │ ├── raise.rb │ │ │ ├── reject.rb │ │ │ └── reschedule.rb │ │ ├── job.rb │ │ ├── lock │ │ │ ├── base_lock.rb │ │ │ ├── no_op.rb │ │ │ ├── until_executed.rb │ │ │ ├── until_executing.rb │ │ │ └── while_executing.rb │ │ ├── lock_error.rb │ │ ├── middleware.rb │ │ └── middleware │ │ │ ├── client.rb │ │ │ └── server.rb │ ├── version.rb │ ├── worker.rb │ ├── worker_handler.rb │ ├── worker_logger.rb │ └── worker_wrapper.rb └── tasks │ └── setup_queue.rake └── spec ├── active_job └── queue_adapters │ ├── cloudtasker_adapter │ └── job_wrapper_spec.rb │ └── cloudtasker_adapter_spec.rb ├── cloudtasker ├── authenticator_spec.rb ├── backend │ ├── google_cloud_task_v1_spec.rb │ ├── google_cloud_task_v2_spec.rb │ ├── memory_task_spec.rb │ └── redis_task_spec.rb ├── batch │ ├── batch_progress_spec.rb │ ├── extension │ │ └── worker_spec.rb │ ├── job_spec.rb │ ├── middleware │ │ └── server_spec.rb │ └── middleware_spec.rb ├── cloud_task_spec.rb ├── config_spec.rb ├── cron │ ├── job_spec.rb │ ├── middleware │ │ └── server_spec.rb │ ├── middleware_spec.rb │ └── schedule_spec.rb ├── meta_store_spec.rb ├── middleware │ └── chain_spec.rb ├── redis_client_spec.rb ├── storable │ └── worker_spec.rb ├── testing_spec.rb ├── unique_job │ ├── conflict_strategy │ │ ├── raise_spec.rb │ │ ├── reject_spec.rb │ │ └── reschedule_spec.rb │ ├── job_spec.rb │ ├── lock │ │ ├── no_op_spec.rb │ │ ├── until_executed_spec.rb │ │ ├── until_executing_spec.rb │ │ └── while_executing_spec.rb │ ├── middleware │ │ ├── client_spec.rb │ │ └── server_spec.rb │ └── middleware_spec.rb ├── worker_controller_spec.rb ├── worker_handler_spec.rb ├── worker_logger_spec.rb ├── worker_spec.rb └── worker_wrapper_spec.rb ├── cloudtasker_spec.rb ├── dummy └── config │ ├── application.rb │ ├── boot.rb │ └── environment.rb ├── integration ├── active_job_spec.rb └── batch_worker_spec.rb ├── shared ├── active_job │ └── instantiation_context.rb └── cloudtasker │ └── unique_job │ ├── conflict_strategy │ └── base_strategy.rb │ └── lock │ └── base_lock.rb ├── spec_helper.rb └── support ├── dead_batch_worker.rb ├── test_batch_worker.rb ├── test_middleware.rb ├── test_middleware2.rb ├── test_middleware3.rb ├── test_non_worker.rb ├── test_storable_worker.rb ├── test_worker.rb └── test_worker2.rb /.github/workflows/lint_rubocop.yml: -------------------------------------------------------------------------------- 1 | name: Rubocop 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: zhulik/redis-action@1.1.0 11 | - uses: ruby/setup-ruby@v1 12 | with: 13 | ruby-version: '3.3.0' 14 | bundler-cache: true 15 | - run: bundle exec rubocop 16 | -------------------------------------------------------------------------------- /.github/workflows/test_ruby_2.7.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2.7 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | ruby: 11 | - '2.7' 12 | appraisal: 13 | - 'google_cloud_tasks_1.0' 14 | - 'google_cloud_tasks_1.1' 15 | - 'google_cloud_tasks_1.2' 16 | - 'google_cloud_tasks_1.3' 17 | - 'google_cloud_tasks_1.4' 18 | - 'google_cloud_tasks_1.5' 19 | - 'google_cloud_tasks_2.0' 20 | - 'google_cloud_tasks_2.1' 21 | - 'rails_5.2' 22 | - 'rails_6.0' 23 | - 'rails_6.1' 24 | - 'rails_7.0' 25 | - 'semantic_logger_3.4' 26 | - 'semantic_logger_4.6' 27 | - 'semantic_logger_4.7.0' 28 | - 'semantic_logger_4.7.2' 29 | env: 30 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.appraisal }}.gemfile 31 | steps: 32 | - uses: actions/checkout@v2 33 | - uses: zhulik/redis-action@1.1.0 34 | - uses: ruby/setup-ruby@v1 35 | with: 36 | ruby-version: ${{ matrix.ruby }} 37 | bundler-cache: true 38 | - run: bundle exec rspec 39 | -------------------------------------------------------------------------------- /.github/workflows/test_ruby_3.x.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 3.x 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | ruby: 11 | - '3.0' 12 | - '3.1' 13 | - '3.2' 14 | - '3.3' 15 | appraisal: 16 | - 'google_cloud_tasks_1.0' 17 | - 'google_cloud_tasks_1.1' 18 | - 'google_cloud_tasks_1.2' 19 | - 'google_cloud_tasks_1.3' 20 | - 'google_cloud_tasks_1.4' 21 | - 'google_cloud_tasks_1.5' 22 | - 'google_cloud_tasks_2.0' 23 | - 'google_cloud_tasks_2.1' 24 | - 'rails_6.1' 25 | - 'rails_7.0' 26 | - 'semantic_logger_3.4' 27 | - 'semantic_logger_4.6' 28 | - 'semantic_logger_4.7.0' 29 | - 'semantic_logger_4.7.2' 30 | env: 31 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.appraisal }}.gemfile 32 | steps: 33 | - uses: actions/checkout@v2 34 | - uses: zhulik/redis-action@1.1.0 35 | - uses: ruby/setup-ruby@v1 36 | with: 37 | ruby-version: ${{ matrix.ruby }} 38 | bundler-cache: true 39 | - run: bundle exec rspec 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /examples/rails/log/*.log 7 | /examples/rails/tmp/ 8 | /gemfiles/*.gemfile.lock 9 | /log/ 10 | /pkg/ 11 | /spec/reports/ 12 | /spec/dummy/log/ 13 | /spec/dummy/tmp/ 14 | /tmp/ 15 | 16 | # Ignore lock files (e.g. Gemfile.lock) 17 | /Gemfile.lock 18 | 19 | # rspec failure tracking 20 | .rspec_status 21 | 22 | # Dev databases 23 | *.sqlite3 24 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: rubocop-rspec 2 | 3 | AllCops: 4 | NewCops: enable 5 | SuggestExtensions: false 6 | TargetRubyVersion: 2.7 7 | Exclude: 8 | - 'gemfiles/**/*' 9 | - 'vendor/**/*' 10 | 11 | Metrics/ClassLength: 12 | Max: 300 13 | 14 | Metrics/ModuleLength: 15 | Max: 150 16 | 17 | Metrics/AbcSize: 18 | Max: 30 19 | Exclude: 20 | - 'spec/support/*' 21 | 22 | Metrics/PerceivedComplexity: 23 | Max: 20 24 | 25 | Layout/LineLength: 26 | Max: 120 27 | 28 | Metrics/MethodLength: 29 | Max: 20 30 | 31 | RSpec/DescribeClass: 32 | Exclude: 33 | - 'spec/integration/**/*_spec.rb' 34 | 35 | RSpec/ExpectInHook: 36 | Enabled: false 37 | 38 | RSpec/EmptyLineAfterHook: 39 | Enabled: false 40 | 41 | RSpec/ScatteredSetup: 42 | Enabled: false 43 | 44 | Metrics/BlockLength: 45 | Exclude: 46 | - cloudtasker.gemspec 47 | - 'spec/**/*' 48 | 49 | Style/Documentation: 50 | Exclude: 51 | - 'examples/**/*' 52 | - 'spec/**/*' 53 | 54 | Metrics/ParameterLists: 55 | CountKeywordArgs: false 56 | 57 | Metrics/CyclomaticComplexity: 58 | Max: 15 59 | 60 | Lint/EmptyBlock: 61 | Exclude: 62 | - 'examples/rails/config/routes.rb' 63 | 64 | RSpec/MessageSpies: 65 | Enabled: false 66 | 67 | RSpec/MultipleExpectations: 68 | Exclude: 69 | - 'examples/**/*' 70 | - 'spec/integration/**/*' 71 | 72 | RSpec/AnyInstance: 73 | Enabled: false 74 | 75 | RSpec/MultipleMemoizedHelpers: 76 | Enabled: false 77 | 78 | RSpec/NoExpectationExample: 79 | AllowedPatterns: 80 | - ^expect_ 81 | - ^assert_ 82 | 83 | RSpec/IndexedLet: 84 | Enabled: false 85 | 86 | RSpec/StubbedMock: 87 | Enabled: false 88 | 89 | RSpec/VerifiedDoubles: 90 | Exclude: 91 | - spec/cloudtasker/cloud_task_spec.rb 92 | - spec/cloudtasker/backend/google_cloud_task_v1_spec.rb 93 | - spec/cloudtasker/backend/google_cloud_task_v2_spec.rb -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'google_cloud_tasks_1.0' do 4 | gem 'google-cloud-tasks', '~> 1.0.0' 5 | end 6 | 7 | appraise 'google_cloud_tasks_1.1' do 8 | gem 'google-cloud-tasks', '~> 1.1.0' 9 | end 10 | 11 | appraise 'google_cloud_tasks_1.2' do 12 | gem 'google-cloud-tasks', '~> 1.2.0' 13 | end 14 | 15 | appraise 'google_cloud_tasks_1.3' do 16 | gem 'google-cloud-tasks', '~> 1.3.0' 17 | end 18 | 19 | appraise 'google_cloud_tasks_1.4' do 20 | gem 'google-cloud-tasks', '~> 1.4.0' 21 | end 22 | 23 | appraise 'google_cloud_tasks_1.5' do 24 | gem 'google-cloud-tasks', '~> 1.5.0' 25 | end 26 | 27 | appraise 'google_cloud_tasks_2.0' do 28 | gem 'google-cloud-tasks', '~> 2.0.0' 29 | end 30 | 31 | appraise 'google_cloud_tasks_2.1' do 32 | gem 'google-cloud-tasks', '~> 2.1.0' 33 | end 34 | 35 | if RUBY_VERSION < '3' 36 | appraise 'rails_5.2' do 37 | gem 'rails', '~> 5.2.0' 38 | gem 'rspec-rails' 39 | end 40 | 41 | appraise 'rails_6.0' do 42 | gem 'rails', '~> 6.0.0' 43 | gem 'rspec-rails' 44 | end 45 | end 46 | 47 | appraise 'rails_6.1' do 48 | gem 'rails', '~> 6.1.0' 49 | gem 'rspec-rails' 50 | end 51 | 52 | if RUBY_VERSION >= '2.7' 53 | appraise 'rails_7.0' do 54 | gem 'rails', '~> 7.0.0' 55 | gem 'rspec-rails' 56 | end 57 | 58 | appraise 'rails_7.1' do 59 | gem 'rails', '~> 7.1' 60 | gem 'rspec-rails' 61 | end 62 | end 63 | 64 | appraise 'semantic_logger_3.4' do 65 | gem 'semantic_logger', '3.4.1' 66 | end 67 | 68 | appraise 'semantic_logger_4.6' do 69 | gem 'semantic_logger', '4.6.1' 70 | end 71 | 72 | appraise 'semantic_logger_4.7.0' do 73 | gem 'semantic_logger', '4.7.0' 74 | end 75 | 76 | appraise 'semantic_logger_4.7.2' do 77 | gem 'semantic_logger', '4.7.2' 78 | end 79 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at arnaud.lachaume@maestrano.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in cloudtasker.gemspec 6 | gemspec 7 | 8 | # Dev dependencies 9 | gem 'appraisal', github: 'thoughtbot/appraisal' 10 | gem 'bundler', '~> 2.0' 11 | gem 'rake', '>= 12.3.3' 12 | gem 'rspec', '~> 3.0' 13 | gem 'rspec-json_expectations', '~> 2.2' 14 | gem 'rubocop', '~> 1.64.1' 15 | gem 'rubocop-rspec', '~> 3.0.1' 16 | gem 'semantic_logger' 17 | gem 'timecop' 18 | gem 'webmock' 19 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Arnaud Lachaume 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /app/controllers/cloudtasker/worker_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | # Handle execution of workers 5 | class WorkerController < ActionController::Base 6 | # No need for CSRF verification on API endpoints 7 | skip_forgery_protection 8 | 9 | # Authenticate all requests. 10 | before_action :authenticate! 11 | 12 | # Return 401 when API Token is invalid 13 | rescue_from AuthenticationError do 14 | head :unauthorized 15 | end 16 | 17 | # POST /cloudtasker/run 18 | # 19 | # Run a worker from a Cloud Task payload 20 | # 21 | def run 22 | # Process payload 23 | WorkerHandler.execute_from_payload!(payload) 24 | head :no_content 25 | rescue DeadWorkerError 26 | # 205: job will NOT be retried 27 | head :reset_content 28 | rescue InvalidWorkerError 29 | # 404: Job will be retried 30 | head :not_found 31 | rescue StandardError 32 | # 422: Job will be retried 33 | head :unprocessable_entity 34 | end 35 | 36 | private 37 | 38 | # 39 | # Parse the request body and return the JSON payload 40 | # 41 | # @return [String] The JSON payload 42 | # 43 | def json_payload 44 | @json_payload ||= begin 45 | # Get raw body 46 | content = request.body.read 47 | 48 | # Decode content if the body is Base64 encoded 49 | if request.headers[Cloudtasker::Config::ENCODING_HEADER].to_s.downcase == 'base64' 50 | content = Base64.decode64(content) 51 | end 52 | 53 | # Return the content 54 | content 55 | end 56 | end 57 | 58 | # 59 | # Parse the request body and return the actual job 60 | # payload. 61 | # 62 | # @return [Hash] The job payload 63 | # 64 | def payload 65 | # Return content parsed as JSON and add job retries count 66 | @payload ||= JSON.parse(json_payload).merge(job_retries: job_retries, task_id: task_id) 67 | end 68 | 69 | # 70 | # Extract the number of times this task failed at runtime. 71 | # 72 | # @return [Integer] The number of failures. 73 | # 74 | def job_retries 75 | request.headers[Cloudtasker::Config::RETRY_HEADER].to_i 76 | end 77 | 78 | # 79 | # Return the Google Cloud Task ID from headers. 80 | # 81 | # @return [String] The task ID. 82 | # 83 | def task_id 84 | request.headers[Cloudtasker::Config::TASK_ID_HEADER] 85 | end 86 | 87 | # 88 | # Authenticate incoming requests using a bearer token 89 | # 90 | # See Cloudtasker::Authenticator#verification_token 91 | # 92 | def authenticate! 93 | if (signature = request.headers[Cloudtasker::Config::CT_SIGNATURE_HEADER]) 94 | # Verify content signature 95 | Authenticator.verify_signature!(signature, json_payload) 96 | else 97 | # Get authorization token from custom header (since v0.14.0) or fallback to 98 | # former authorization header (jobs enqueued by v0.13 and below) 99 | bearer_token = request.headers[Cloudtasker::Config::CT_AUTHORIZATION_HEADER].to_s.split.last || 100 | request.headers[Cloudtasker::Config::OIDC_AUTHORIZATION_HEADER].to_s.split.last 101 | 102 | # Verify the token 103 | Authenticator.verify!(bearer_token) 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'cloudtasker' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /cloudtasker.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 'cloudtasker/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'cloudtasker' 9 | spec.version = Cloudtasker::VERSION 10 | spec.authors = ['Arnaud Lachaume'] 11 | spec.email = ['arnaud.lachaume@keypup.io'] 12 | 13 | spec.summary = 'Background jobs for Ruby using Google Cloud Tasks (beta)' 14 | spec.description = 'Background jobs for Ruby using Google Cloud Tasks (beta)' 15 | spec.homepage = 'https://github.com/keypup-io/cloudtasker' 16 | spec.license = 'MIT' 17 | 18 | spec.metadata['homepage_uri'] = spec.homepage 19 | spec.metadata['source_code_uri'] = 'https://github.com/keypup-io/cloudtasker' 20 | spec.metadata['changelog_uri'] = 'https://github.com/keypup-io/cloudtasker/master/tree/CHANGELOG.md' 21 | 22 | # Specify which files should be added to the gem when it is released. 23 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 24 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 25 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(examples|test|spec|features)/}) } 26 | end 27 | spec.bindir = 'exe' 28 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 29 | spec.require_paths = ['lib'] 30 | 31 | spec.required_ruby_version = '>= 2.7.0' 32 | 33 | spec.add_dependency 'activesupport' 34 | spec.add_dependency 'connection_pool' 35 | spec.add_dependency 'fugit' 36 | spec.add_dependency 'google-cloud-tasks' 37 | spec.add_dependency 'jwt' 38 | spec.add_dependency 'redis' 39 | spec.add_dependency 'retriable' 40 | 41 | spec.metadata['rubygems_mfa_required'] = 'true' 42 | end 43 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Cloudtasker::Engine.routes.draw do 4 | post '/run', to: 'worker#run' 5 | end 6 | -------------------------------------------------------------------------------- /docs/STORABLE_JOBS.md: -------------------------------------------------------------------------------- 1 | # Cloudtasker Storable Jobs 2 | 3 | **Supported since**: `v0.14.0` 4 | **Note**: this extension requires redis 5 | 6 | The Cloudtasker storage extension allows you to park jobs in a specific garage lane and enqueue (pull) them when specific conditions have been met. 7 | 8 | This extension is useful when you need to prepare some jobs (e.g. you are retrieving data from an API and must process some of it asynchronously) but only process them when some programmatic conditions have been met (e.g. a series of preliminary preparation jobs have run successfully). Using parked jobs is a leaner (and cheaper) approach than using guard logic in the `perform` method to re-enqueue a job until a set of conditions is satisfied. The latter tends to generate a lot of jobs/logs pollution. 9 | 10 | ## Configuration 11 | 12 | You can enable storable jobs by adding the following to your cloudtasker initializer: 13 | ```ruby 14 | # The storable extension is optional and must be explicitly required 15 | require 'cloudtasker/storable' 16 | 17 | Cloudtasker.configure do |config| 18 | # Specify your redis url. 19 | # Defaults to `redis://localhost:6379/0` if unspecified 20 | config.redis = { url: 'redis://some-host:6379/0' } 21 | end 22 | ``` 23 | 24 | Then you can make workers storable by including the `Cloudtasker::Storable::Worker` concern into your workers: 25 | ```ruby 26 | class MyWorker 27 | include Cloudtasker::Worker 28 | include Cloudtasker::Storable::Worker 29 | 30 | def perform(...) 31 | # Do stuff 32 | end 33 | end 34 | ``` 35 | 36 | ## Parking jobs 37 | You can park jobs to a specific garage lane using the `push_to_store(store_name, *worker_args)` class method: 38 | ```ruby 39 | MyWorker.push_to_store('some-customer-reference:some-task-group', job_arg1, job_arg2) 40 | ``` 41 | 42 | ## Pulling jobs 43 | You can pull and enqueue jobs using the `pull_all_from_store(store_name)` class method: 44 | ```ruby 45 | MyWorker.pull_all_from_store('some-customer-reference:some-task-group') 46 | ``` 47 | 48 | If you need to enqueue jobs with specific options or using any special means, you can call `pull_all_from_store(store_name)` with a block. When a block is passed the method yield each worker's set of arguments. 49 | ```ruby 50 | # Delay the enqueuing of parked jobs by 30 seconds 51 | MyWorker.pull_all_from_store('some-customer-reference:some-task-group') do |args| 52 | MyWorker.perform_in(30, *args) 53 | end 54 | 55 | # Enqueue parked jobs on a specific queue, with a 10s delay 56 | MyWorker.pull_all_from_store('some-customer-reference:some-task-group') do |args| 57 | MyWorker.schedule(args: args, time_in: 10, queue: 'critical') 58 | end 59 | 60 | # Enqueue parked jobs as part of a job's current batch (the logic below assumes 61 | # we are inside a job's `perform` method) 62 | MyWorker.pull_all_from_store('some-customer-reference:some-task-group') do |args| 63 | batch.add(MyWorker, *args) 64 | 65 | # Or with a specific queue 66 | # batch.add_to_queue('critical', SubWorker, *args) 67 | end 68 | ``` 69 | -------------------------------------------------------------------------------- /examples/cloud-run/.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.3.0 2 | -------------------------------------------------------------------------------- /examples/cloud-run/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official lightweight Ruby image. 2 | # https://hub.docker.com/_/ruby 3 | FROM ruby:3.2.1 4 | 5 | # Install bundler 6 | RUN gem update --system 7 | RUN gem install bundler 8 | 9 | # Install production dependencies. 10 | WORKDIR /usr/src/app 11 | COPY Gemfile Gemfile.lock ./ 12 | RUN bundle install 13 | ENV BUNDLE_FROZEN=true 14 | 15 | # Copy local code to the container image. 16 | COPY . ./ 17 | 18 | # Environment 19 | ENV RAILS_ENV production 20 | ENV RAILS_MAX_THREADS 60 21 | ENV RAILS_LOG_TO_STDOUT true 22 | 23 | # Run the web service on container startup. 24 | CMD ["bundle", "exec", "rails", "s", "-p", "8080"] -------------------------------------------------------------------------------- /examples/cloud-run/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | ruby '~> 3.3' 7 | 8 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' 9 | gem 'rails', '~> 7.1' 10 | 11 | # Application server 12 | gem 'puma' 13 | 14 | # Background jobs via Cloud Tasks 15 | gem 'cloudtasker', github: 'keypup-io/cloudtasker' 16 | 17 | # Active record adapter 18 | gem 'sqlite3' 19 | 20 | group :development do 21 | gem 'foreman' 22 | end 23 | -------------------------------------------------------------------------------- /examples/cloud-run/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative 'config/application' 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /examples/cloud-run/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link cloudtasker_manifest.js 4 | -------------------------------------------------------------------------------- /examples/cloud-run/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/cloud-run/app/assets/images/.keep -------------------------------------------------------------------------------- /examples/cloud-run/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /examples/cloud-run/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Channel < ActionCable::Channel::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /examples/cloud-run/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Connection < ActionCable::Connection::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /examples/cloud-run/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | end 5 | -------------------------------------------------------------------------------- /examples/cloud-run/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/cloud-run/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /examples/cloud-run/app/controllers/enqueue_job_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A simple controller that enqueues jobs for test 4 | # purpose on Cloud Run 5 | class EnqueueJobController < ApplicationController 6 | # GET /enqueue/dummy 7 | def dummy 8 | DummyWorker.perform_async 9 | 10 | render plain: 'DummyWorker enqueued' 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /examples/cloud-run/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationHelper 4 | end 5 | -------------------------------------------------------------------------------- /examples/cloud-run/app/javascript/packs/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require rails-ujs 14 | //= require activestorage 15 | //= require_tree . 16 | -------------------------------------------------------------------------------- /examples/cloud-run/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationJob < ActiveJob::Base 4 | # Automatically retry jobs that encountered a deadlock 5 | # retry_on ActiveRecord::Deadlocked 6 | 7 | # Most jobs are safe to ignore if the underlying records are no longer available 8 | # discard_on ActiveJob::DeserializationError 9 | end 10 | -------------------------------------------------------------------------------- /examples/cloud-run/app/jobs/example_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ExampleJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform(*args) 7 | Rails.logger.info('Example job starting...') 8 | Rails.logger.info(args.inspect) 9 | sleep(3) 10 | Rails.logger.info('Example job done!') 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /examples/cloud-run/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationMailer < ActionMailer::Base 4 | default from: 'from@example.com' 5 | layout 'mailer' 6 | end 7 | -------------------------------------------------------------------------------- /examples/cloud-run/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /examples/cloud-run/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/cloud-run/app/models/concerns/.keep -------------------------------------------------------------------------------- /examples/cloud-run/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | 8 | <%= stylesheet_link_tag 'application', media: 'all' %> 9 | 10 | 11 | 12 | <%= yield %> 13 | 14 | 15 | -------------------------------------------------------------------------------- /examples/cloud-run/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/cloud-run/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /examples/cloud-run/app/workers/dummy_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DummyWorker 4 | include Cloudtasker::Worker 5 | 6 | def perform 7 | Rails.logger.info('Dummy worker starting...') 8 | sleep(3) 9 | Rails.logger.info('Dummy worker done!') 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /examples/cloud-run/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | APP_PATH = File.expand_path('../config/application', __dir__) 5 | require_relative '../config/boot' 6 | require 'rails/commands' 7 | -------------------------------------------------------------------------------- /examples/cloud-run/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative '../config/boot' 5 | require 'rake' 6 | Rake.application.run 7 | -------------------------------------------------------------------------------- /examples/cloud-run/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'fileutils' 5 | 6 | # path to your application root. 7 | APP_ROOT = File.expand_path('..', __dir__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | FileUtils.chdir APP_ROOT do 14 | # This script is a way to setup or update your development environment automatically. 15 | # This script is idempotent, so that you can run it at anytime and get an expectable outcome. 16 | # Add necessary setup steps to this file. 17 | 18 | puts '== Installing dependencies ==' 19 | system! 'gem install bundler --conservative' 20 | system('bundle check') || system!('bundle install') 21 | 22 | # puts "\n== Copying sample files ==" 23 | # unless File.exist?('config/database.yml') 24 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 25 | # end 26 | 27 | puts "\n== Preparing database ==" 28 | system! 'bin/rails db:prepare' 29 | 30 | puts "\n== Removing old logs and tempfiles ==" 31 | system! 'bin/rails log:clear tmp:clear' 32 | 33 | puts "\n== Restarting application server ==" 34 | system! 'bin/rails restart' 35 | end 36 | -------------------------------------------------------------------------------- /examples/cloud-run/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative 'config/environment' 6 | 7 | run Rails.application 8 | -------------------------------------------------------------------------------- /examples/cloud-run/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'boot' 4 | 5 | require 'rails/all' 6 | 7 | Bundler.require(*Rails.groups) 8 | 9 | module Dummy 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 6.0 13 | 14 | # Settings in config/environments/* take precedence over those specified here. 15 | # Application configuration can go into files in config/initializers 16 | # -- all .rb files in that directory are automatically loaded after loading 17 | # the framework and any gems in your application. 18 | 19 | # Use cloudtasker as the ActiveJob backend: 20 | config.active_job.queue_adapter = :cloudtasker 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /examples/cloud-run/config/boot.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 | -------------------------------------------------------------------------------- /examples/cloud-run/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /examples/cloud-run/config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | jBUFm9LaktoqjLw7egZ/rqypP+0j0BrZyENZx2s4oGW9zhkrjGCUQvOPP6mL1LZlE1NeQX7yYwd4tM0KV4gBbSXcoLGZUD/KtWXg8ZMJraxBz3Klgt1SSoEDnKXbziqSPXXy0e4w9SqBflD9HAdg4hYj4I7m7utnrWctLB446Xn8LhApr/EPwmtLImRXVcLkY5fCQPrQ367lIbFxqX3vgQhadtZZnF6jpnD8R0m+QvhAJompM2jK3xZPilEG0TFZ5E/yd1Um+5PV+OFaM949zTR9mrINR+N6EzqzQg2oLoRJtXizEGyS399Lj8fL2yWj6fXvpeyGLbZEJRMECdWkoIfuRt5d+SRFEe39jprTTs1KNaMv5ajT5pzTQqb64W7dlBjw2oYRswfarDDuOv0eAWS1U7fTlQX3zgl8--yinJxK0UdO3VWhEq--NZjJhOTLbYEO+LqaGs60qg== -------------------------------------------------------------------------------- /examples/cloud-run/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /examples/cloud-run/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative 'application' 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /examples/cloud-run/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | $stdout.sync = true 5 | # Settings specified here will take precedence over those in config/application.rb. 6 | 7 | # In the development environment your application's code is reloaded on 8 | # every request. This slows down response time but is perfect for development 9 | # since you don't have to restart the web server when you make code changes. 10 | config.cache_classes = false 11 | 12 | # Do not eager load code on boot. 13 | config.eager_load = false 14 | 15 | # Show full error reports. 16 | config.consider_all_requests_local = true 17 | 18 | # Enable/disable caching. By default caching is disabled. 19 | # Run rails dev:cache to toggle caching. 20 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 21 | config.action_controller.perform_caching = true 22 | config.action_controller.enable_fragment_cache_logging = true 23 | 24 | config.cache_store = :memory_store 25 | config.public_file_server.headers = { 26 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 27 | } 28 | else 29 | config.action_controller.perform_caching = false 30 | 31 | config.cache_store = :null_store 32 | end 33 | 34 | # Store uploaded files on the local file system (see config/storage.yml for options). 35 | config.active_storage.service = :local 36 | 37 | # Don't care if the mailer can't send. 38 | config.action_mailer.raise_delivery_errors = false 39 | 40 | config.action_mailer.perform_caching = false 41 | 42 | # Print deprecation notices to the Rails logger. 43 | config.active_support.deprecation = :log 44 | 45 | # Raise an error on page load if there are pending migrations. 46 | config.active_record.migration_error = :page_load 47 | 48 | # Highlight code that triggered database queries in logs. 49 | config.active_record.verbose_query_logs = true 50 | 51 | # Debug mode disables concatenation and preprocessing of assets. 52 | # This option may cause significant delays in view rendering with a large 53 | # number of complex assets. 54 | config.assets.debug = true 55 | 56 | # Suppress logger output for asset requests. 57 | config.assets.quiet = true 58 | 59 | # Raises error for missing translations. 60 | # config.action_view.raise_on_missing_translations = true 61 | 62 | # Use an evented file watcher to asynchronously detect changes in source code, 63 | # routes, locales, etc. This feature depends on the listen gem. 64 | # config.file_watcher = ActiveSupport::EventedFileUpdateChecker 65 | end 66 | -------------------------------------------------------------------------------- /examples/cloud-run/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | config.cache_classes = false 12 | 13 | # Do not eager load code on boot. This avoids loading your whole application 14 | # just for the purpose of running a single test. If you are using a tool that 15 | # preloads Rails for running tests, you may have to set it to true. 16 | config.eager_load = false 17 | 18 | # Configure public file server for tests with Cache-Control for performance. 19 | config.public_file_server.enabled = true 20 | config.public_file_server.headers = { 21 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 22 | } 23 | 24 | # Show full error reports and disable caching. 25 | config.consider_all_requests_local = true 26 | config.action_controller.perform_caching = false 27 | config.cache_store = :null_store 28 | 29 | # Raise exceptions instead of rendering exception templates. 30 | config.action_dispatch.show_exceptions = false 31 | 32 | # Disable request forgery protection in test environment. 33 | config.action_controller.allow_forgery_protection = false 34 | 35 | # Store uploaded files on the local file system in a temporary directory. 36 | config.active_storage.service = :test 37 | 38 | config.action_mailer.perform_caching = false 39 | 40 | # Tell Action Mailer not to deliver emails to the real world. 41 | # The :test delivery method accumulates sent emails in the 42 | # ActionMailer::Base.deliveries array. 43 | config.action_mailer.delivery_method = :test 44 | 45 | # Print deprecation notices to the stderr. 46 | config.active_support.deprecation = :stderr 47 | 48 | # Raises error for missing translations. 49 | # config.action_view.raise_on_missing_translations = true 50 | end 51 | -------------------------------------------------------------------------------- /examples/cloud-run/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # ActiveSupport::Reloader.to_prepare do 6 | # ApplicationController.renderer.defaults.merge!( 7 | # http_host: 'example.org', 8 | # https: false 9 | # ) 10 | # end 11 | -------------------------------------------------------------------------------- /examples/cloud-run/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Version of your assets, change this if you want to expire all your assets. 6 | Rails.application.config.assets.version = '1.0' 7 | 8 | # Add additional assets to the asset load path. 9 | # Rails.application.config.assets.paths << Emoji.images_path 10 | 11 | # Precompile additional assets. 12 | # application.js, application.css, and all non-JS/CSS in the app/assets 13 | # folder are already added. 14 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 15 | -------------------------------------------------------------------------------- /examples/cloud-run/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 6 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 7 | 8 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 9 | # Rails.backtrace_cleaner.remove_silencers! 10 | -------------------------------------------------------------------------------- /examples/cloud-run/config/initializers/cloudtasker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Cloudtasker.configure do |config| 4 | # 5 | # GCP Configuration 6 | # 7 | config.gcp_project_id = 'your-project-id' 8 | config.gcp_location_id = 'us-central1' 9 | config.gcp_queue_prefix = 'cloudtasker-demo' 10 | 11 | # 12 | # Domain 13 | # 14 | # config.processor_host = 'https://xxxx.ngrok.io' 15 | # 16 | config.processor_host = 'https://your-cloud-run-service.a.run.app' 17 | 18 | # OpenID Connect configuration 19 | # You need to create a IAM service account first. See the README. 20 | # config.oidc = { service_account_email: 'cloudtasker-demo@your-project-id.iam.gserviceaccount.com' } 21 | end 22 | -------------------------------------------------------------------------------- /examples/cloud-run/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Define an application-wide content security policy 6 | # For further information see the following documentation 7 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 8 | 9 | # Rails.application.config.content_security_policy do |policy| 10 | # policy.default_src :self, :https 11 | # policy.font_src :self, :https, :data 12 | # policy.img_src :self, :https, :data 13 | # policy.object_src :none 14 | # policy.script_src :self, :https 15 | # policy.style_src :self, :https 16 | 17 | # # Specify URI for violation reports 18 | # # policy.report_uri "/csp-violation-report-endpoint" 19 | # end 20 | 21 | # If you are using UJS then enable automatic nonce generation 22 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 23 | 24 | # Set the nonce only to specific directives 25 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src) 26 | 27 | # Report CSP violations to a specified URI 28 | # For further information see the following documentation: 29 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 30 | # Rails.application.config.content_security_policy_report_only = true 31 | -------------------------------------------------------------------------------- /examples/cloud-run/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Specify a serializer for the signed and encrypted cookie jars. 6 | # Valid options are :json, :marshal, and :hybrid. 7 | Rails.application.config.action_dispatch.cookies_serializer = :json 8 | -------------------------------------------------------------------------------- /examples/cloud-run/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Configure sensitive parameters which will be filtered from the log file. 6 | Rails.application.config.filter_parameters += [:password] 7 | -------------------------------------------------------------------------------- /examples/cloud-run/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Add new inflection rules using the following format. Inflections 6 | # are locale specific, and you may define rules for as many different 7 | # locales as you wish. All of these examples are active by default: 8 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 9 | # inflect.plural /^(ox)$/i, '\1en' 10 | # inflect.singular /^(ox)en/i, '\1' 11 | # inflect.irregular 'person', 'people' 12 | # inflect.uncountable %w( fish sheep ) 13 | # end 14 | 15 | # These inflection rules are supported but not enabled by default: 16 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 17 | # inflect.acronym 'RESTful' 18 | # end 19 | -------------------------------------------------------------------------------- /examples/cloud-run/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Add new mime types for use in respond_to blocks: 6 | # Mime::Type.register "text/richtext", :rtf 7 | -------------------------------------------------------------------------------- /examples/cloud-run/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # This file contains settings for ActionController::ParamsWrapper which 6 | # is enabled by default. 7 | 8 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 9 | ActiveSupport.on_load(:action_controller) do 10 | wrap_parameters format: [:json] 11 | end 12 | 13 | # To enable root element in JSON for ActiveRecord objects. 14 | # ActiveSupport.on_load(:active_record) do 15 | # self.include_root_in_json = true 16 | # end 17 | -------------------------------------------------------------------------------- /examples/cloud-run/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /examples/cloud-run/config/master.key: -------------------------------------------------------------------------------- 1 | adb9cb04c606955610b91d1219271d02 -------------------------------------------------------------------------------- /examples/cloud-run/config/puma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Puma can serve each request in a thread from an internal thread pool. 4 | # The `threads` method setting takes two numbers: a minimum and maximum. 5 | # Any libraries that use thread pools should be configured to match 6 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 7 | # and maximum; this matches the default thread size of Active Record. 8 | # 9 | max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) 10 | min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count } 11 | threads min_threads_count, max_threads_count 12 | 13 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 14 | # 15 | port ENV.fetch('PORT', 3000) 16 | 17 | # Specifies the `environment` that Puma will run in. 18 | # 19 | environment ENV.fetch('RAILS_ENV', 'development') 20 | 21 | # Specifies the number of `workers` to boot in clustered mode. 22 | # Workers are forked web server processes. If using threads and workers together 23 | # the concurrency of the application would be max `threads` * `workers`. 24 | # Workers do not work on JRuby or Windows (both of which do not support 25 | # processes). 26 | # 27 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 28 | 29 | # Use the `preload_app!` method when specifying a `workers` number. 30 | # This directive tells Puma to first boot the application and load code 31 | # before forking the application. This takes advantage of Copy On Write 32 | # process behavior so workers use less memory. 33 | # 34 | # preload_app! 35 | 36 | # Allow puma to be restarted by `rails restart` command. 37 | plugin :tmp_restart 38 | -------------------------------------------------------------------------------- /examples/cloud-run/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | get '/enqueue/dummy', to: 'enqueue_job#dummy' 5 | end 6 | -------------------------------------------------------------------------------- /examples/cloud-run/config/spring.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Spring.watch( 4 | '.ruby-version', 5 | '.rbenv-vars', 6 | 'tmp/restart.txt', 7 | 'tmp/caching-dev.txt' 8 | ) 9 | -------------------------------------------------------------------------------- /examples/cloud-run/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /examples/cloud-run/db/development.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/cloud-run/db/development.sqlite3 -------------------------------------------------------------------------------- /examples/cloud-run/db/test.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/cloud-run/db/test.sqlite3 -------------------------------------------------------------------------------- /examples/cloud-run/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/cloud-run/lib/assets/.keep -------------------------------------------------------------------------------- /examples/cloud-run/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/cloud-run/log/.keep -------------------------------------------------------------------------------- /examples/cloud-run/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /examples/cloud-run/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /examples/cloud-run/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /examples/cloud-run/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/cloud-run/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /examples/cloud-run/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/cloud-run/public/apple-touch-icon.png -------------------------------------------------------------------------------- /examples/cloud-run/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/cloud-run/public/favicon.ico -------------------------------------------------------------------------------- /examples/cloud-run/storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/cloud-run/storage/.keep -------------------------------------------------------------------------------- /examples/cloud-run/tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/cloud-run/tmp/.keep -------------------------------------------------------------------------------- /examples/cloud-run/tmp/development_secret.txt: -------------------------------------------------------------------------------- 1 | 6a0868194e931706b65bfaee87eea7197c9c4725dea92499f24461fe9c9cf8f3d3b1a2874ccab440ef36b898ae47bba4621c01343821afcb6b67186977fb80ea -------------------------------------------------------------------------------- /examples/cloud-run/tmp/restart.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/cloud-run/tmp/restart.txt -------------------------------------------------------------------------------- /examples/rails/.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.3.0 2 | -------------------------------------------------------------------------------- /examples/rails/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | ruby '~> 3.3' 7 | 8 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' 9 | gem 'rails', '~> 7.1' 10 | 11 | # Application server 12 | gem 'puma' 13 | 14 | # Background jobs via Cloud Tasks 15 | gem 'cloudtasker', path: '../../' 16 | 17 | # Active record adapter 18 | gem 'sqlite3' 19 | 20 | group :development do 21 | gem 'foreman' 22 | end 23 | -------------------------------------------------------------------------------- /examples/rails/Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec rails s -p 3000 2 | worker: bundle exec cloudtasker -q critical,2 -q default,3 -------------------------------------------------------------------------------- /examples/rails/README.md: -------------------------------------------------------------------------------- 1 | # Example usage with Rails 2 | 3 | ## Run using the local processing server 4 | 5 | 1. Install dependencies: `bundle install` 6 | 2. Launch the server: `foreman start` 7 | 3. Open a Rails console: `rails c` 8 | 4. Enqueue workers: 9 | ```ruby 10 | DummyWorker.perform_async 11 | ``` 12 | 13 | ## Run using Cloud Tasks 14 | 15 | 1. Ensure that your [Google Cloud SDK](https://cloud.google.com/sdk/docs/quickstarts) is setup. 16 | 2. Install dependencies: `bundle install` 17 | 3. Start an [ngrok](https://ngrok.com) tunnel: `ngrok http 3000` 18 | 4. Edit the [initializer](./config/initializers/cloudtasker.rb) 19 | * Add the configuration of your GCP queue 20 | * Set `config.processor_host` to the ngrok http or https url 21 | * Set `config.mode` to `:production` 22 | 5. Setup your queues on Google Cloud Tasks: 23 | ``` 24 | # Default and critical queues 25 | rake cloudtasker:setup_queue 26 | rake cloudtasker:setup_queue name=critical 27 | ``` 28 | 6. Launch the server: `foreman start web` 29 | 7. Open a Rails console: `rails c` 30 | 8. Enqueue workers: 31 | ```ruby 32 | DummyWorker.perform_async 33 | ``` 34 | -------------------------------------------------------------------------------- /examples/rails/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative 'config/application' 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /examples/rails/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link cloudtasker_manifest.js 4 | -------------------------------------------------------------------------------- /examples/rails/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/rails/app/assets/images/.keep -------------------------------------------------------------------------------- /examples/rails/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /examples/rails/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Channel < ActionCable::Channel::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /examples/rails/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Connection < ActionCable::Connection::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /examples/rails/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | end 5 | -------------------------------------------------------------------------------- /examples/rails/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/rails/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /examples/rails/app/controllers/enqueue_job_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A simple controller that enqueues jobs for test 4 | # purpose on Cloud Run 5 | class EnqueueJobController < ApplicationController 6 | # GET /enqueue/dummy 7 | def dummy 8 | DummyWorker.perform_async 9 | 10 | render plain: 'DummyWorker enqueued' 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /examples/rails/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationHelper 4 | end 5 | -------------------------------------------------------------------------------- /examples/rails/app/javascript/packs/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require rails-ujs 14 | //= require activestorage 15 | //= require_tree . 16 | -------------------------------------------------------------------------------- /examples/rails/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationJob < ActiveJob::Base 4 | # Automatically retry jobs that encountered a deadlock 5 | # retry_on ActiveRecord::Deadlocked 6 | 7 | # Most jobs are safe to ignore if the underlying records are no longer available 8 | # discard_on ActiveJob::DeserializationError 9 | end 10 | -------------------------------------------------------------------------------- /examples/rails/app/jobs/example_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ExampleJob < ApplicationJob 4 | queue_as :default 5 | 6 | def perform(*args) 7 | Rails.logger.info('Example job starting...') 8 | Rails.logger.info(args.inspect) 9 | sleep(3) 10 | Rails.logger.info('Example job done!') 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /examples/rails/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationMailer < ActionMailer::Base 4 | default from: 'from@example.com' 5 | layout 'mailer' 6 | end 7 | -------------------------------------------------------------------------------- /examples/rails/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /examples/rails/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/rails/app/models/concerns/.keep -------------------------------------------------------------------------------- /examples/rails/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | 8 | <%= stylesheet_link_tag 'application', media: 'all' %> 9 | 10 | 11 | 12 | <%= yield %> 13 | 14 | 15 | -------------------------------------------------------------------------------- /examples/rails/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/rails/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /examples/rails/app/workers/batch_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BatchWorker 4 | include Cloudtasker::Worker 5 | 6 | def perform(level = 0, instance = 0) 7 | Rails.logger.info("#{self.class} level=#{level} instance=#{instance} | starting...") 8 | # sleep(1) 9 | 10 | # Enqueue children 11 | 10.times { |n| batch.add(BatchWorker, level + 1, n) } if level < 2 12 | 13 | Rails.logger.info("#{self.class} level=#{level} instance=#{instance} | done!") 14 | end 15 | 16 | def on_child_complete(child) 17 | msg = [ 18 | "#{self.class} level=#{job_args[0].to_i} instance=#{job_args[1].to_i}", 19 | "on_child_complete level=#{child.job_args[0]} instance=#{child.job_args[1]}" 20 | ].join(' | ') 21 | Rails.logger.info(msg) 22 | end 23 | 24 | def on_batch_node_complete(child) 25 | msg = [ 26 | "#{self.class} level=#{job_args[0].to_i} instance=#{job_args[1].to_i}", 27 | "on_batch_node_complete level=#{child.job_args[0].to_i} instance=#{child.job_args[1].to_i}" 28 | ].join(' | ') 29 | Rails.logger.info(msg) 30 | end 31 | 32 | def on_batch_complete 33 | msg = [ 34 | "#{self.class} level=#{job_args[0].to_i} instance=#{job_args[1].to_i}", 35 | 'on_batch_complete' 36 | ].join(' | ') 37 | Rails.logger.info(msg) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /examples/rails/app/workers/critical_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CriticalWorker 4 | include Cloudtasker::Worker 5 | 6 | cloudtasker_options queue: :critical 7 | 8 | def perform 9 | Rails.logger.info('Critical worker starting...') 10 | sleep(3) 11 | Rails.logger.info('Critical worker done!') 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /examples/rails/app/workers/cron_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CronWorker 4 | include Cloudtasker::Worker 5 | 6 | def perform(arg1) 7 | Rails.logger.info("#{self.class} starting with arg1=#{arg1}...") 8 | sleep(3) 9 | Rails.logger.info("#{self.class} done!") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /examples/rails/app/workers/dummy_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DummyWorker 4 | include Cloudtasker::Worker 5 | 6 | def perform 7 | Rails.logger.info('Dummy worker starting...') 8 | sleep(3) 9 | Rails.logger.info('Dummy worker done!') 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /examples/rails/app/workers/uniq_executing_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UniqExecutingWorker 4 | include Cloudtasker::Worker 5 | 6 | cloudtasker_options lock: :while_executing, on_conflict: :reschedule, lock_ttl: 90 7 | 8 | def unique_args(args) 9 | [args[0], args[1]] 10 | end 11 | 12 | def perform(arg1, arg2, arg3) 13 | Rails.logger.info("#{self.class} with args=#{[arg1, arg2, arg3].inspect} starting...") 14 | sleep(10) 15 | Rails.logger.info("#{self.class} with args=#{[arg1, arg2, arg3].inspect} done!") 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /examples/rails/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | APP_PATH = File.expand_path('../config/application', __dir__) 5 | require_relative '../config/boot' 6 | require 'rails/commands' 7 | -------------------------------------------------------------------------------- /examples/rails/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative '../config/boot' 5 | require 'rake' 6 | Rake.application.run 7 | -------------------------------------------------------------------------------- /examples/rails/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'fileutils' 5 | 6 | # path to your application root. 7 | APP_ROOT = File.expand_path('..', __dir__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | FileUtils.chdir APP_ROOT do 14 | # This script is a way to setup or update your development environment automatically. 15 | # This script is idempotent, so that you can run it at anytime and get an expectable outcome. 16 | # Add necessary setup steps to this file. 17 | 18 | puts '== Installing dependencies ==' 19 | system! 'gem install bundler --conservative' 20 | system('bundle check') || system!('bundle install') 21 | 22 | # puts "\n== Copying sample files ==" 23 | # unless File.exist?('config/database.yml') 24 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 25 | # end 26 | 27 | puts "\n== Preparing database ==" 28 | system! 'bin/rails db:prepare' 29 | 30 | puts "\n== Removing old logs and tempfiles ==" 31 | system! 'bin/rails log:clear tmp:clear' 32 | 33 | puts "\n== Restarting application server ==" 34 | system! 'bin/rails restart' 35 | end 36 | -------------------------------------------------------------------------------- /examples/rails/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative 'config/environment' 6 | 7 | run Rails.application 8 | -------------------------------------------------------------------------------- /examples/rails/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'boot' 4 | 5 | require 'rails/all' 6 | 7 | Bundler.require(*Rails.groups) 8 | 9 | module Dummy 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 7.0 13 | 14 | # Settings in config/environments/* take precedence over those specified here. 15 | # Application configuration can go into files in config/initializers 16 | # -- all .rb files in that directory are automatically loaded after loading 17 | # the framework and any gems in your application. 18 | 19 | # Use cloudtasker as the ActiveJob backend: 20 | config.active_job.queue_adapter = :cloudtasker 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /examples/rails/config/boot.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 | -------------------------------------------------------------------------------- /examples/rails/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /examples/rails/config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | jBUFm9LaktoqjLw7egZ/rqypP+0j0BrZyENZx2s4oGW9zhkrjGCUQvOPP6mL1LZlE1NeQX7yYwd4tM0KV4gBbSXcoLGZUD/KtWXg8ZMJraxBz3Klgt1SSoEDnKXbziqSPXXy0e4w9SqBflD9HAdg4hYj4I7m7utnrWctLB446Xn8LhApr/EPwmtLImRXVcLkY5fCQPrQ367lIbFxqX3vgQhadtZZnF6jpnD8R0m+QvhAJompM2jK3xZPilEG0TFZ5E/yd1Um+5PV+OFaM949zTR9mrINR+N6EzqzQg2oLoRJtXizEGyS399Lj8fL2yWj6fXvpeyGLbZEJRMECdWkoIfuRt5d+SRFEe39jprTTs1KNaMv5ajT5pzTQqb64W7dlBjw2oYRswfarDDuOv0eAWS1U7fTlQX3zgl8--yinJxK0UdO3VWhEq--NZjJhOTLbYEO+LqaGs60qg== -------------------------------------------------------------------------------- /examples/rails/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /examples/rails/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative 'application' 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /examples/rails/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | $stdout.sync = true 5 | # Settings specified here will take precedence over those in config/application.rb. 6 | 7 | # In the development environment your application's code is reloaded on 8 | # every request. This slows down response time but is perfect for development 9 | # since you don't have to restart the web server when you make code changes. 10 | config.cache_classes = false 11 | 12 | # Do not eager load code on boot. 13 | config.eager_load = false 14 | 15 | # Show full error reports. 16 | config.consider_all_requests_local = true 17 | 18 | # Enable/disable caching. By default caching is disabled. 19 | # Run rails dev:cache to toggle caching. 20 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 21 | config.action_controller.perform_caching = true 22 | config.action_controller.enable_fragment_cache_logging = true 23 | 24 | config.cache_store = :memory_store 25 | config.public_file_server.headers = { 26 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 27 | } 28 | else 29 | config.action_controller.perform_caching = false 30 | 31 | config.cache_store = :null_store 32 | end 33 | 34 | # Store uploaded files on the local file system (see config/storage.yml for options). 35 | config.active_storage.service = :local 36 | 37 | # Don't care if the mailer can't send. 38 | config.action_mailer.raise_delivery_errors = false 39 | 40 | config.action_mailer.perform_caching = false 41 | 42 | # Print deprecation notices to the Rails logger. 43 | config.active_support.deprecation = :log 44 | 45 | # Raise an error on page load if there are pending migrations. 46 | config.active_record.migration_error = :page_load 47 | 48 | # Highlight code that triggered database queries in logs. 49 | config.active_record.verbose_query_logs = true 50 | 51 | # Raises error for missing translations. 52 | # config.action_view.raise_on_missing_translations = true 53 | 54 | # Use an evented file watcher to asynchronously detect changes in source code, 55 | # routes, locales, etc. This feature depends on the listen gem. 56 | # config.file_watcher = ActiveSupport::EventedFileUpdateChecker 57 | end 58 | -------------------------------------------------------------------------------- /examples/rails/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | config.cache_classes = false 12 | 13 | # Do not eager load code on boot. This avoids loading your whole application 14 | # just for the purpose of running a single test. If you are using a tool that 15 | # preloads Rails for running tests, you may have to set it to true. 16 | config.eager_load = false 17 | 18 | # Configure public file server for tests with Cache-Control for performance. 19 | config.public_file_server.enabled = true 20 | config.public_file_server.headers = { 21 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 22 | } 23 | 24 | # Show full error reports and disable caching. 25 | config.consider_all_requests_local = true 26 | config.action_controller.perform_caching = false 27 | config.cache_store = :null_store 28 | 29 | # Raise exceptions instead of rendering exception templates. 30 | config.action_dispatch.show_exceptions = false 31 | 32 | # Disable request forgery protection in test environment. 33 | config.action_controller.allow_forgery_protection = false 34 | 35 | # Store uploaded files on the local file system in a temporary directory. 36 | config.active_storage.service = :test 37 | 38 | config.action_mailer.perform_caching = false 39 | 40 | # Tell Action Mailer not to deliver emails to the real world. 41 | # The :test delivery method accumulates sent emails in the 42 | # ActionMailer::Base.deliveries array. 43 | config.action_mailer.delivery_method = :test 44 | 45 | # Print deprecation notices to the stderr. 46 | config.active_support.deprecation = :stderr 47 | 48 | # Raises error for missing translations. 49 | # config.action_view.raise_on_missing_translations = true 50 | end 51 | -------------------------------------------------------------------------------- /examples/rails/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # ActiveSupport::Reloader.to_prepare do 6 | # ApplicationController.renderer.defaults.merge!( 7 | # http_host: 'example.org', 8 | # https: false 9 | # ) 10 | # end 11 | -------------------------------------------------------------------------------- /examples/rails/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 6 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 7 | 8 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 9 | # Rails.backtrace_cleaner.remove_silencers! 10 | -------------------------------------------------------------------------------- /examples/rails/config/initializers/cloudtasker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/unique_job' 4 | require 'cloudtasker/cron' 5 | require 'cloudtasker/batch' 6 | 7 | Cloudtasker.configure do |config| 8 | # 9 | # GCP Configuration 10 | # 11 | config.gcp_project_id = 'some-project' 12 | config.gcp_location_id = 'us-east1' 13 | config.gcp_queue_prefix = 'my-app' 14 | 15 | # 16 | # Domain 17 | # 18 | # config.processor_host = 'https://xxxx.ngrok.io' 19 | # 20 | config.processor_host = 'http://localhost:3000' 21 | 22 | # 23 | # Uncomment to process tasks via Cloud Task. 24 | # Requires a ngrok tunnel. 25 | # 26 | # config.mode = :production 27 | 28 | # 29 | # Global error Hooks 30 | # 31 | config.on_error = lambda { |error, worker| 32 | Rails.logger.error("Uh oh... worker #{worker&.job_id} had the following error: #{error}") 33 | } 34 | config.on_dead = ->(error, worker) { Rails.logger.error("Damn... worker #{worker&.job_id} died with: #{error}") } 35 | end 36 | 37 | # 38 | # Setup cron job 39 | # 40 | # Cloudtasker::Cron::Schedule.load_from_hash!( 41 | # 'my_worker' => { 42 | # 'worker' => 'CronWorker', 43 | # 'cron' => '* * * * *', 44 | # 'queue' => 'critical', 45 | # 'args' => ['foo'] 46 | # } 47 | # ) 48 | -------------------------------------------------------------------------------- /examples/rails/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Define an application-wide content security policy 6 | # For further information see the following documentation 7 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 8 | 9 | # Rails.application.config.content_security_policy do |policy| 10 | # policy.default_src :self, :https 11 | # policy.font_src :self, :https, :data 12 | # policy.img_src :self, :https, :data 13 | # policy.object_src :none 14 | # policy.script_src :self, :https 15 | # policy.style_src :self, :https 16 | 17 | # # Specify URI for violation reports 18 | # # policy.report_uri "/csp-violation-report-endpoint" 19 | # end 20 | 21 | # If you are using UJS then enable automatic nonce generation 22 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 23 | 24 | # Set the nonce only to specific directives 25 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src) 26 | 27 | # Report CSP violations to a specified URI 28 | # For further information see the following documentation: 29 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 30 | # Rails.application.config.content_security_policy_report_only = true 31 | -------------------------------------------------------------------------------- /examples/rails/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Specify a serializer for the signed and encrypted cookie jars. 6 | # Valid options are :json, :marshal, and :hybrid. 7 | Rails.application.config.action_dispatch.cookies_serializer = :json 8 | -------------------------------------------------------------------------------- /examples/rails/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Configure sensitive parameters which will be filtered from the log file. 6 | Rails.application.config.filter_parameters += [:password] 7 | -------------------------------------------------------------------------------- /examples/rails/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Add new inflection rules using the following format. Inflections 6 | # are locale specific, and you may define rules for as many different 7 | # locales as you wish. All of these examples are active by default: 8 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 9 | # inflect.plural /^(ox)$/i, '\1en' 10 | # inflect.singular /^(ox)en/i, '\1' 11 | # inflect.irregular 'person', 'people' 12 | # inflect.uncountable %w( fish sheep ) 13 | # end 14 | 15 | # These inflection rules are supported but not enabled by default: 16 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 17 | # inflect.acronym 'RESTful' 18 | # end 19 | -------------------------------------------------------------------------------- /examples/rails/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Add new mime types for use in respond_to blocks: 6 | # Mime::Type.register "text/richtext", :rtf 7 | -------------------------------------------------------------------------------- /examples/rails/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # This file contains settings for ActionController::ParamsWrapper which 6 | # is enabled by default. 7 | 8 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 9 | ActiveSupport.on_load(:action_controller) do 10 | wrap_parameters format: [:json] 11 | end 12 | 13 | # To enable root element in JSON for ActiveRecord objects. 14 | # ActiveSupport.on_load(:active_record) do 15 | # self.include_root_in_json = true 16 | # end 17 | -------------------------------------------------------------------------------- /examples/rails/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /examples/rails/config/master.key: -------------------------------------------------------------------------------- 1 | adb9cb04c606955610b91d1219271d02 -------------------------------------------------------------------------------- /examples/rails/config/puma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Puma can serve each request in a thread from an internal thread pool. 4 | # The `threads` method setting takes two numbers: a minimum and maximum. 5 | # Any libraries that use thread pools should be configured to match 6 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 7 | # and maximum; this matches the default thread size of Active Record. 8 | # 9 | max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) 10 | min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count } 11 | threads min_threads_count, max_threads_count 12 | 13 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 14 | # 15 | port ENV.fetch('PORT', 3000) 16 | 17 | # Specifies the `environment` that Puma will run in. 18 | # 19 | environment ENV.fetch('RAILS_ENV', 'development') 20 | 21 | # Specifies the number of `workers` to boot in clustered mode. 22 | # Workers are forked web server processes. If using threads and workers together 23 | # the concurrency of the application would be max `threads` * `workers`. 24 | # Workers do not work on JRuby or Windows (both of which do not support 25 | # processes). 26 | # 27 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 28 | 29 | # Use the `preload_app!` method when specifying a `workers` number. 30 | # This directive tells Puma to first boot the application and load code 31 | # before forking the application. This takes advantage of Copy On Write 32 | # process behavior so workers use less memory. 33 | # 34 | # preload_app! 35 | 36 | # Allow puma to be restarted by `rails restart` command. 37 | plugin :tmp_restart 38 | -------------------------------------------------------------------------------- /examples/rails/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | get '/enqueue/dummy', to: 'enqueue_job#dummy' 5 | end 6 | -------------------------------------------------------------------------------- /examples/rails/config/spring.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Spring.watch( 4 | '.ruby-version', 5 | '.rbenv-vars', 6 | 'tmp/restart.txt', 7 | 'tmp/caching-dev.txt' 8 | ) 9 | -------------------------------------------------------------------------------- /examples/rails/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /examples/rails/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/rails/lib/assets/.keep -------------------------------------------------------------------------------- /examples/rails/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/rails/log/.keep -------------------------------------------------------------------------------- /examples/rails/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /examples/rails/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /examples/rails/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /examples/rails/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/rails/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /examples/rails/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/rails/public/apple-touch-icon.png -------------------------------------------------------------------------------- /examples/rails/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/rails/public/favicon.ico -------------------------------------------------------------------------------- /examples/rails/storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/rails/storage/.keep -------------------------------------------------------------------------------- /examples/rails/tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keypup-io/cloudtasker/c9b50be501595b7bafdcd9ba852bcf87e2cd34bd/examples/rails/tmp/.keep -------------------------------------------------------------------------------- /examples/rails/tmp/development_secret.txt: -------------------------------------------------------------------------------- 1 | 6a0868194e931706b65bfaee87eea7197c9c4725dea92499f24461fe9c9cf8f3d3b1a2874ccab440ef36b898ae47bba4621c01343821afcb6b67186977fb80ea -------------------------------------------------------------------------------- /examples/sinatra/.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.3.0 2 | -------------------------------------------------------------------------------- /examples/sinatra/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | ruby '~> 3.3' 7 | 8 | # Server 9 | gem 'puma' 10 | 11 | # Web framework 12 | gem 'rackup', '~> 2.1' 13 | gem 'sinatra' 14 | 15 | # Background jobs via Cloud Tasks 16 | gem 'cloudtasker', path: '../../' 17 | 18 | group :development do 19 | gem 'foreman' 20 | end 21 | -------------------------------------------------------------------------------- /examples/sinatra/Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec ruby app.rb -p 3000 2 | worker: bundle exec cloudtasker -q critical,2 -q default,3 -------------------------------------------------------------------------------- /examples/sinatra/README.md: -------------------------------------------------------------------------------- 1 | # Example usage with Sinatra 2 | 3 | ## Run using the local processing server 4 | 5 | 1. Install dependencies: `bundle install` 6 | 2. Launch the server: `foreman start` 7 | 3. Open a Sinatra console: `./bin/console` 8 | 4. Enqueue workers: 9 | ```ruby 10 | DummyWorker.perform_async 11 | ``` 12 | 13 | ## Run using Cloud Tasks 14 | 15 | 1. Ensure that your [Google Cloud SDK](https://cloud.google.com/sdk/docs/quickstarts) is setup. 16 | 2. Install dependencies: `bundle install` 17 | 3. Start an [ngrok](https://ngrok.com) tunnel: `ngrok http 3000` 18 | 4. Edit the [initializer](./config/initializers/cloudtasker.rb) 19 | * Add the configuration of your GCP queue 20 | * Set `config.processor_host` to the ngrok http or https url 21 | * Set `config.mode` to `:production` 22 | 5. Launch the server: `foreman start web` 23 | 6. Open a Sinatra console: `./bin/console` 24 | 7. Setup your queues on Google Cloud Tasks: 25 | ```ruby 26 | # Default and critical queues 27 | Cloudtasker::CloudTask.setup_production_queue 28 | Cloudtasker::CloudTask.setup_production_queue(name: 'critical') 29 | ``` 30 | 8. Enqueue workers: 31 | ```ruby 32 | DummyWorker.perform_async 33 | ``` 34 | -------------------------------------------------------------------------------- /examples/sinatra/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'sinatra' 4 | 5 | # Require project files 6 | Dir.glob('./config/initializers/*.rb').sort.each { |file| require file } 7 | Dir.glob('./app/workers/*.rb').sort.each { |file| require file } 8 | 9 | #--------------------------------------------------- 10 | # Routes 11 | #--------------------------------------------------- 12 | 13 | get '/' do 14 | 'Hello!' 15 | end 16 | 17 | # rubocop:disable Metrics/BlockLength 18 | post '/cloudtasker/run' do 19 | # Capture content and decode content 20 | json_payload = request.body.read 21 | if request.env['HTTP_CONTENT_TRANSFER_ENCODING'].to_s.downcase == 'base64' 22 | json_payload = Base64.decode64(json_payload) 23 | end 24 | 25 | # Authenticate the request 26 | if (signature = request.env['HTTP_X_CLOUDTASKER_SIGNATURE']) 27 | # Verify content signature 28 | Cloudtasker::Authenticator.verify_signature!(signature, json_payload) 29 | else 30 | # Get authorization token from custom header (since v0.14.0) or fallback to 31 | # former authorization header (jobs enqueued by v0.13 and below) 32 | auth_token = request.env['HTTP_X_CLOUDTASKER_AUTHORIZATION'].to_s.split.last || 33 | request.env['HTTP_AUTHORIZATION'].to_s.split.last 34 | 35 | # Verify the token 36 | Cloudtasker::Authenticator.verify!(auth_token) 37 | end 38 | 39 | # Format job payload 40 | payload = JSON.parse(json_payload) 41 | .merge( 42 | job_retries: request.env[Cloudtasker::Config::RETRY_HEADER].to_i, 43 | task_id: request.env[Cloudtasker::Config::TASK_ID_HEADER] 44 | ) 45 | 46 | # Process payload 47 | Cloudtasker::WorkerHandler.execute_from_payload!(payload) 48 | return 204 49 | rescue Cloudtasker::DeadWorkerError 50 | # 205: job will NOT be retried 51 | return 205 52 | rescue Cloudtasker::AuthenticationError 53 | # 401: Unauthorized 54 | return 401 55 | rescue Cloudtasker::InvalidWorkerError 56 | # 404: Job will be retried 57 | return 404 58 | rescue StandardError 59 | # 422: Job will be retried 60 | return 423 61 | end 62 | # rubocop:enable Metrics/BlockLength 63 | -------------------------------------------------------------------------------- /examples/sinatra/app/workers/batch_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BatchWorker 4 | include Cloudtasker::Worker 5 | 6 | def perform(level = 0, instance = 0) 7 | logger.info("#{self.class} level=#{level} instance=#{instance} | starting...") 8 | # sleep(1) 9 | 10 | # Enqueue children 11 | 10.times { |n| batch.add(BatchWorker, level + 1, n) } if level < 2 12 | 13 | logger.info("#{self.class} level=#{level} instance=#{instance} | done!") 14 | end 15 | 16 | def on_child_complete(child) 17 | msg = [ 18 | "#{self.class} level=#{job_args[0].to_i} instance=#{job_args[1].to_i}", 19 | "on_child_complete level=#{child.job_args[0].to_i} instance=#{child.job_args[1].to_i}" 20 | ].join(' | ') 21 | logger.info(msg) 22 | end 23 | 24 | def on_batch_node_complete(child) 25 | msg = [ 26 | "#{self.class} level=#{job_args[0].to_i} instance=#{job_args[1].to_i}", 27 | "on_batch_node_complete level=#{child.job_args[0].to_i} instance=#{child.job_args[1].to_i}" 28 | ].join(' | ') 29 | logger.info(msg) 30 | end 31 | 32 | def on_batch_complete 33 | msg = [ 34 | "#{self.class} level=#{job_args[0].to_i} instance=#{job_args[1].to_i}", 35 | 'on_batch_complete' 36 | ].join(' | ') 37 | logger.info(msg) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /examples/sinatra/app/workers/cron_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CronWorker 4 | include Cloudtasker::Worker 5 | 6 | def perform(arg1) 7 | logger.info("#{self.class} starting with arg1=#{arg1}...") 8 | sleep(3) 9 | logger.info("#{self.class} done!") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /examples/sinatra/app/workers/dummy_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DummyWorker 4 | include Cloudtasker::Worker 5 | 6 | def perform 7 | logger.info('Dummy worker starting...') 8 | sleep(3) 9 | logger.info('Dummy worker done!') 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /examples/sinatra/app/workers/uniq_executing_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UniqExecutingWorker 4 | include Cloudtasker::Worker 5 | 6 | cloudtasker_options lock: :while_executing, on_conflict: :reschedule, lock_ttl: 90 7 | 8 | def unique_args(args) 9 | [args[0], args[1]] 10 | end 11 | 12 | def perform(arg1, arg2, arg3) 13 | logger.info("#{self.class} with args=#{[arg1, arg2, arg3].inspect} starting...") 14 | sleep(10) 15 | logger.info("#{self.class} with args=#{[arg1, arg2, arg3].inspect} done!") 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /examples/sinatra/bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require File.expand_path('../app.rb', __dir__) 6 | 7 | require 'irb' 8 | IRB.start(__FILE__) 9 | -------------------------------------------------------------------------------- /examples/sinatra/config/initializers/cloudtasker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Require cloudtasker and its extensions 4 | require 'cloudtasker' 5 | require 'cloudtasker/unique_job' 6 | require 'cloudtasker/cron' 7 | require 'cloudtasker/batch' 8 | 9 | Cloudtasker.configure do |config| 10 | # 11 | # Secret used to authenticate job requests 12 | # 13 | config.secret = 'some-secret' 14 | 15 | # 16 | # GCP Configuration 17 | # 18 | config.gcp_project_id = 'some-project' 19 | config.gcp_location_id = 'us-east1' 20 | config.gcp_queue_prefix = 'my-app' 21 | 22 | # 23 | # Domain 24 | # 25 | # config.processor_host = 'https://xxxx.ngrok.io' 26 | # 27 | config.processor_host = 'http://localhost:3000' 28 | 29 | # 30 | # Uncomment to process tasks via Cloud Task. 31 | # Requires a ngrok tunnel. 32 | # 33 | # config.mode = :production 34 | end 35 | 36 | # 37 | # Setup cron job 38 | # 39 | # Cloudtasker::Cron::Schedule.load_from_hash!( 40 | # 'my_worker' => { 41 | # 'worker' => 'CronWorker', 42 | # 'cron' => '* * * * *', 43 | # 'queue' => 'critical', 44 | # 'args' => ['foo'] 45 | # } 46 | # ) 47 | -------------------------------------------------------------------------------- /exe/cloudtasker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'cloudtasker/cli' 6 | require 'optparse' 7 | 8 | options = {} 9 | OptionParser.new do |opts| 10 | opts.banner = 'Usage: cloudtasker [options]' 11 | 12 | opts.on( 13 | '-q QUEUE', '--queue=QUEUE', 14 | 'Queue to process and number of threads. ' \ 15 | "Examples: '-q critical' | '-q critical,2' | '-q critical,3 -q defaults,2'" 16 | ) do |o| 17 | options[:queues] ||= [] 18 | options[:queues] << o.split(',') 19 | end 20 | end.parse! 21 | 22 | begin 23 | Cloudtasker::CLI.run(options) 24 | rescue StandardError => e 25 | raise e if $DEBUG 26 | 27 | warn e.message 28 | warn e.backtrace.join("\n") 29 | exit 1 30 | end 31 | -------------------------------------------------------------------------------- /gemfiles/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_RETRY: "1" 3 | -------------------------------------------------------------------------------- /gemfiles/google_cloud_tasks_1.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger" 13 | gem "timecop" 14 | gem "webmock" 15 | gem "google-cloud-tasks", "~> 1.0.0" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/google_cloud_tasks_1.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger" 13 | gem "timecop" 14 | gem "webmock" 15 | gem "google-cloud-tasks", "~> 1.1.0" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/google_cloud_tasks_1.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger" 13 | gem "timecop" 14 | gem "webmock" 15 | gem "google-cloud-tasks", "~> 1.2.0" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/google_cloud_tasks_1.3.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger" 13 | gem "timecop" 14 | gem "webmock" 15 | gem "google-cloud-tasks", "~> 1.3.0" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/google_cloud_tasks_1.4.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger" 13 | gem "timecop" 14 | gem "webmock" 15 | gem "google-cloud-tasks", "~> 1.4.0" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/google_cloud_tasks_1.5.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger" 13 | gem "timecop" 14 | gem "webmock" 15 | gem "google-cloud-tasks", "~> 1.5.0" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/google_cloud_tasks_2.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger" 13 | gem "timecop" 14 | gem "webmock" 15 | gem "google-cloud-tasks", "~> 2.0.0" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/google_cloud_tasks_2.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger" 13 | gem "timecop" 14 | gem "webmock" 15 | gem "google-cloud-tasks", "~> 2.1.0" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/rails_5.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger" 13 | gem "timecop" 14 | gem "webmock" 15 | gem "rails", "~> 5.2.0" 16 | gem "rspec-rails" 17 | 18 | gemspec path: "../" 19 | -------------------------------------------------------------------------------- /gemfiles/rails_6.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger" 13 | gem "timecop" 14 | gem "webmock" 15 | gem "rails", "~> 6.0.0" 16 | gem "rspec-rails" 17 | 18 | gemspec path: "../" 19 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger" 13 | gem "timecop" 14 | gem "webmock" 15 | gem "rails", "~> 6.1.0" 16 | gem "rspec-rails" 17 | 18 | gemspec path: "../" 19 | -------------------------------------------------------------------------------- /gemfiles/rails_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger" 13 | gem "timecop" 14 | gem "webmock" 15 | gem "rails", "~> 7.0.0" 16 | gem "rspec-rails" 17 | 18 | gemspec path: "../" 19 | -------------------------------------------------------------------------------- /gemfiles/rails_7.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger" 13 | gem "timecop" 14 | gem "webmock" 15 | gem "rails", "~> 7.1" 16 | gem "rspec-rails" 17 | 18 | gemspec path: "../" 19 | -------------------------------------------------------------------------------- /gemfiles/semantic_logger_3.4.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger", "3.4.1" 13 | gem "timecop" 14 | gem "webmock" 15 | 16 | gemspec path: "../" 17 | -------------------------------------------------------------------------------- /gemfiles/semantic_logger_4.6.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger", "4.6.1" 13 | gem "timecop" 14 | gem "webmock" 15 | 16 | gemspec path: "../" 17 | -------------------------------------------------------------------------------- /gemfiles/semantic_logger_4.7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger", "4.7.0" 13 | gem "timecop" 14 | gem "webmock" 15 | 16 | gemspec path: "../" 17 | -------------------------------------------------------------------------------- /gemfiles/semantic_logger_4.7.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal", github: "thoughtbot/appraisal" 6 | gem "bundler", "~> 2.0" 7 | gem "rake", ">= 12.3.3" 8 | gem "rspec", "~> 3.0" 9 | gem "rspec-json_expectations", "~> 2.2" 10 | gem "rubocop", "~> 1.64.1" 11 | gem "rubocop-rspec", "~> 3.0.1" 12 | gem "semantic_logger", "4.7.2" 13 | gem "timecop" 14 | gem "webmock" 15 | 16 | gemspec path: "../" 17 | -------------------------------------------------------------------------------- /lib/active_job/queue_adapters/cloudtasker_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # ActiveJob docs: http://guides.rubyonrails.org/active_job_basics.html 4 | # Example adapters ref: https://github.com/rails/rails/tree/master/activejob/lib/active_job/queue_adapters 5 | 6 | module ActiveJob 7 | module QueueAdapters 8 | # == Cloudtasker adapter for Active Job 9 | # 10 | # To use Cloudtasker set the queue_adapter config to +:cloudtasker+. 11 | # 12 | # Rails.application.config.active_job.queue_adapter = :cloudtasker 13 | class CloudtaskerAdapter 14 | SERIALIZATION_FILTERED_KEYS = [ 15 | 'executions', # Given by the worker at processing 16 | 'provider_job_id', # Also given by the worker at processing 17 | 'priority' # Not used 18 | ].freeze 19 | 20 | # Enqueues the given ActiveJob instance for execution 21 | # 22 | # @param job [ActiveJob::Base] The ActiveJob instance 23 | # 24 | # @return [Cloudtasker::CloudTask] The Google Task response 25 | # 26 | def enqueue(job) 27 | build_worker(job).schedule 28 | end 29 | 30 | # Enqueues the given ActiveJob instance for execution at a given time 31 | # 32 | # @param job [ActiveJob::Base] The ActiveJob instance 33 | # @param precise_timestamp [Integer] The timestamp at which the job must be executed 34 | # 35 | # @return [Cloudtasker::CloudTask] The Google Task response 36 | # 37 | def enqueue_at(job, precise_timestamp) 38 | build_worker(job).schedule(time_at: Time.at(precise_timestamp)) 39 | end 40 | 41 | # Determines if enqueuing will check and wait for an associated transaction completes before enqueuing 42 | # 43 | # @return [Boolean] True always as this is the default from QueueAdapters::AbstractAdapter 44 | def enqueue_after_transaction_commit? 45 | true 46 | end 47 | 48 | private 49 | 50 | def build_worker(job) 51 | job_serialization = job.serialize.except(*SERIALIZATION_FILTERED_KEYS) 52 | 53 | JobWrapper.new( 54 | job_id: job_serialization.delete('job_id'), 55 | job_queue: job_serialization.delete('queue_name'), 56 | job_args: [job_serialization] 57 | ) 58 | end 59 | 60 | # == Job Wrapper for the Cloudtasker adapter 61 | # 62 | # Executes jobs scheduled by the Cloudtasker ActiveJob adapter 63 | class JobWrapper # :nodoc: 64 | include Cloudtasker::Worker 65 | 66 | # Executes the given serialized ActiveJob call. 67 | # - See https://api.rubyonrails.org/classes/ActiveJob/Core.html#method-i-serialize 68 | # 69 | # @param [Hash] job_serialization The serialized ActiveJob call 70 | # 71 | # @return [any] The execution of the ActiveJob call 72 | # 73 | def perform(job_serialization, *_extra_options) 74 | job_executions = job_retries < 1 ? 0 : (job_retries + 1) 75 | 76 | job_serialization.merge!( 77 | 'job_id' => job_id, 78 | 'queue_name' => job_queue, 79 | 'provider_job_id' => task_id, 80 | 'executions' => job_executions, 81 | 'priority' => nil 82 | ) 83 | 84 | Base.execute job_serialization 85 | end 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/cloudtasker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/object/blank' 4 | require 'active_support/core_ext/object/try' 5 | require 'active_support/core_ext/string/inflections' 6 | require 'active_support/core_ext/string/filters' 7 | require 'active_support/security_utils' 8 | 9 | require 'cloudtasker/version' 10 | require 'cloudtasker/config' 11 | 12 | require 'cloudtasker/authentication_error' 13 | require 'cloudtasker/dead_worker_error' 14 | require 'cloudtasker/retry_worker_error' 15 | require 'cloudtasker/invalid_worker_error' 16 | require 'cloudtasker/missing_worker_arguments_error' 17 | require 'cloudtasker/max_task_size_exceeded_error' 18 | 19 | require 'cloudtasker/middleware/chain' 20 | require 'cloudtasker/authenticator' 21 | require 'cloudtasker/cloud_task' 22 | require 'cloudtasker/worker_logger' 23 | require 'cloudtasker/worker_handler' 24 | require 'cloudtasker/meta_store' 25 | require 'cloudtasker/worker' 26 | 27 | # Define and manage Cloud Task based workers 28 | module Cloudtasker 29 | attr_writer :config 30 | 31 | # 32 | # Cloudtasker configurator. 33 | # 34 | def self.configure 35 | yield(config) 36 | end 37 | 38 | # 39 | # Return the Cloudtasker configuration. 40 | # 41 | # @return [Cloudtasker::Config] The Cloudtasker configuration. 42 | # 43 | def self.config 44 | @config ||= Config.new 45 | end 46 | 47 | # 48 | # Return the Cloudtasker logger. 49 | # 50 | # @return [Logger] The Cloudtasker logger. 51 | # 52 | def self.logger 53 | config.logger 54 | end 55 | end 56 | 57 | require 'cloudtasker/engine' if defined?(Rails::Engine) 58 | -------------------------------------------------------------------------------- /lib/cloudtasker/authentication_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | class AuthenticationError < StandardError 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/cloudtasker/authenticator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'openssl' 4 | 5 | module Cloudtasker 6 | # Manage token generation and verification 7 | module Authenticator 8 | module_function 9 | 10 | # Algorithm used to sign the verification token 11 | JWT_ALG = 'HS256' 12 | 13 | # 14 | # Return the cloudtasker configuration. See Cloudtasker#configure. 15 | # 16 | # @return [Cloudtasker::Config] The library configuration. 17 | # 18 | def config 19 | Cloudtasker.config 20 | end 21 | 22 | # 23 | # A Json Web Token (JWT) which will be used by the processor 24 | # to authenticate the job. 25 | # 26 | # @return [String] The jwt token 27 | # 28 | def verification_token 29 | JWT.encode({ iat: Time.now.to_i }, config.secret, JWT_ALG) 30 | end 31 | 32 | # 33 | # The Authorization header content 34 | # 35 | # @return [String] The Bearer authorization header 36 | # 37 | def bearer_token 38 | "Bearer #{verification_token}" 39 | end 40 | 41 | # 42 | # Verify a bearer token (jwt token) 43 | # 44 | # @param [String] bearer_token The token to verify. 45 | # 46 | # @return [Boolean] Return true if the token is valid 47 | # 48 | def verify(bearer_token) 49 | JWT.decode(bearer_token, config.secret) 50 | rescue JWT::VerificationError, JWT::DecodeError 51 | false 52 | end 53 | 54 | # 55 | # Verify a bearer token and raise a `Cloudtasker::AuthenticationError` 56 | # if the token is invalid. 57 | # 58 | # @param [String] bearer_token The token to verify. 59 | # 60 | # @return [Boolean] Return true if the token is valid 61 | # 62 | def verify!(bearer_token) 63 | verify(bearer_token) || raise(AuthenticationError) 64 | end 65 | 66 | # 67 | # Generate a signature for a payload 68 | # 69 | # @param [String] payload The JSON payload 70 | # 71 | # @return [String] The HMAC signature 72 | # 73 | def sign_payload(payload) 74 | OpenSSL::HMAC.hexdigest('sha256', config.secret, payload) 75 | end 76 | 77 | # 78 | # Verify that a signature matches the payload and raise a `Cloudtasker::AuthenticationError` 79 | # if the signature is invalid. 80 | # 81 | # @param [String] signature The tested signature 82 | # @param [String] payload The JSON payload 83 | # 84 | # @return [Boolean] Return true if the signature is valid 85 | # 86 | def verify_signature!(signature, payload) 87 | ActiveSupport::SecurityUtils.secure_compare(signature, sign_payload(payload)) || raise(AuthenticationError) 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/cloudtasker/batch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'batch/middleware' 4 | 5 | Cloudtasker::Batch::Middleware.configure 6 | -------------------------------------------------------------------------------- /lib/cloudtasker/batch/extension/worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module Batch 5 | module Extension 6 | # Include batch related methods onto Cloudtasker::Worker 7 | # See: Cloudtasker::Batch::Middleware#configure 8 | module Worker 9 | attr_accessor :batch, :parent_batch 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/cloudtasker/batch/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/redis_client' 4 | 5 | require_relative 'extension/worker' 6 | require_relative 'batch_progress' 7 | require_relative 'job' 8 | 9 | require_relative 'middleware/server' 10 | 11 | module Cloudtasker 12 | module Batch 13 | # Registration module 14 | module Middleware 15 | def self.configure 16 | Cloudtasker.configure do |config| 17 | config.server_middleware { |c| c.add(Middleware::Server) } 18 | end 19 | 20 | # Inject worker extension on main module 21 | Cloudtasker::Worker.include(Extension::Worker) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/cloudtasker/batch/middleware/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module Batch 5 | module Middleware 6 | # Server middleware, invoked when jobs are executed 7 | class Server 8 | def call(worker, **_kwargs, &block) 9 | Job.for(worker).execute(&block) 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/cloudtasker/cron.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'cron/middleware' 4 | 5 | Cloudtasker::Cron::Middleware.configure 6 | -------------------------------------------------------------------------------- /lib/cloudtasker/cron/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/redis_client' 4 | 5 | require_relative 'schedule' 6 | require_relative 'job' 7 | require_relative 'middleware/server' 8 | 9 | module Cloudtasker 10 | module Cron 11 | # Registration module 12 | module Middleware 13 | def self.configure 14 | Cloudtasker.configure do |config| 15 | config.server_middleware { |c| c.add(Middleware::Server) } 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/cloudtasker/cron/middleware/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module Cron 5 | module Middleware 6 | # Server middleware, invoked when jobs are executed 7 | class Server 8 | def call(worker, **_kwargs, &block) 9 | Job.new(worker).execute(&block) 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/cloudtasker/dead_worker_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | class DeadWorkerError < StandardError 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/cloudtasker/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | # Cloudtasker Rails engine 5 | class Engine < ::Rails::Engine 6 | isolate_namespace Cloudtasker 7 | 8 | config.before_initialize do 9 | # Mount cloudtasker processing endpoint 10 | Rails.application.routes.append do 11 | mount Cloudtasker::Engine, at: '/cloudtasker' 12 | end 13 | 14 | # Add ActiveJob adapter 15 | require 'active_job/queue_adapters/cloudtasker_adapter' if defined?(::ActiveJob::Railtie) 16 | end 17 | 18 | config.generators do |g| 19 | g.test_framework :rspec, fixture: false 20 | g.assets false 21 | g.helper false 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/cloudtasker/invalid_worker_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | class InvalidWorkerError < StandardError 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/cloudtasker/local_server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/backend/redis_task' 4 | 5 | module Cloudtasker 6 | # Process jobs stored in Redis. 7 | # Only to be used in development. 8 | class LocalServer 9 | # Max number of task requests sent to the processing server 10 | CONCURRENCY = (ENV['CLOUDTASKER_CONCURRENCY'] || 5).to_i 11 | 12 | # Default number of threads to allocate to process a specific queue 13 | QUEUE_CONCURRENCY = 1 14 | 15 | # Job Polling. How frequently to poll jobs in redis. 16 | JOB_POLLING_FREQUENCY = 0.5 # seconds 17 | 18 | # 19 | # Stop the local server. 20 | # 21 | def stop 22 | @done = true 23 | 24 | # Terminate threads and repush tasks 25 | @threads&.values&.flatten&.each do |t| 26 | t.terminate 27 | t['task']&.retry_later(0, is_error: false) 28 | end 29 | 30 | # Wait for main server to be done 31 | sleep 1 while @start&.alive? 32 | end 33 | 34 | # 35 | # Start the local server 36 | # 37 | # @param [Hash] opts Server options. 38 | # 39 | # 40 | def start(opts = {}) 41 | # Extract queues to process 42 | queues = opts[:queues].to_a.any? ? opts[:queues] : [[nil, CONCURRENCY]] 43 | 44 | # Display start banner 45 | queue_labels = queues.map { |n, c| "#{n || 'all'}=#{c || QUEUE_CONCURRENCY}" }.join(' ') 46 | Cloudtasker.logger.info("[Cloudtasker/Server] Processing queues: #{queue_labels}") 47 | 48 | # Start processing queues 49 | @start ||= Thread.new do 50 | until @done 51 | queues.each { |(n, c)| process_jobs(n, c) } 52 | sleep JOB_POLLING_FREQUENCY 53 | end 54 | Cloudtasker.logger.info('[Cloudtasker/Server] Local server exiting...') 55 | end 56 | end 57 | 58 | # 59 | # Process enqueued workers. 60 | # 61 | # 62 | def process_jobs(queue = nil, concurrency = nil) 63 | @threads ||= {} 64 | @threads[queue] ||= [] 65 | max_threads = (concurrency || QUEUE_CONCURRENCY).to_i 66 | 67 | # Remove any done thread 68 | @threads[queue].select!(&:alive?) 69 | 70 | # Process tasks 71 | while @threads[queue].count < max_threads && (task = Cloudtasker::Backend::RedisTask.pop(queue)) 72 | @threads[queue] << Thread.new(task) { |t| process_task(t) } 73 | end 74 | end 75 | 76 | # 77 | # Process a given task 78 | # 79 | # @param [Cloudtasker::CloudTask] task The task to process 80 | # 81 | def process_task(task) 82 | Thread.current['task'] = task 83 | Thread.current['attempts'] = 0 84 | 85 | # Deliver task 86 | begin 87 | Thread.current['task']&.deliver 88 | rescue Errno::EBADF, Errno::ECONNREFUSED => e 89 | raise(e) unless Thread.current['attempts'] < 3 90 | 91 | # Retry on connection error, in case the web server is not 92 | # started yet. 93 | Thread.current['attempts'] += 1 94 | sleep(3) 95 | retry 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/cloudtasker/max_task_size_exceeded_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | # Handle Cloud Task size quota 5 | # See: https://cloud.google.com/appengine/quotas#Task_Queue 6 | # 7 | class MaxTaskSizeExceededError < StandardError 8 | MSG = 'The size of Cloud Tasks must not exceed 100KB' 9 | 10 | def initialize(msg = MSG) 11 | super 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/cloudtasker/meta_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | # Manage meta information on workers. This meta stored is intended 5 | # to be used by middlewares needing to store extra information on the 6 | # job. 7 | # The objective of this class is to provide a shared store to middleware 8 | # while controlling access to its keys by preveenting access the hash directly 9 | # (e.g. avoid wild merge or replace operations). 10 | class MetaStore 11 | # 12 | # Build a new instance of the class. 13 | # 14 | # @param [] hash The worker meta hash 15 | # 16 | def initialize(hash = {}) 17 | @meta = JSON.parse((hash || {}).to_json, symbolize_names: true) 18 | end 19 | 20 | # 21 | # Retrieve meta entry. 22 | # 23 | # @param [String, Symbol] key The key of the meta entry. 24 | # 25 | # @return [Any] The value of the meta entry. 26 | # 27 | def get(key) 28 | @meta[key.to_sym] if key 29 | end 30 | 31 | # 32 | # Set meta entry 33 | # 34 | # @param [String, Symbol] key The key of the meta entry. 35 | # @param [Any] val The value of the meta entry. 36 | # 37 | # @return [Any] The value set 38 | # 39 | def set(key, val) 40 | @meta[key.to_sym] = val if key 41 | end 42 | 43 | # 44 | # Remove a meta information. 45 | # 46 | # @param [String, Symbol] key The key of the entry to delete. 47 | # 48 | # @return [Any] The value of the deleted key 49 | # 50 | def del(key) 51 | @meta.delete(key.to_sym) if key 52 | end 53 | 54 | # 55 | # Return the meta store as Hash. 56 | # 57 | # @return [Hash] The meta store as Hash. 58 | # 59 | def to_h 60 | # Deep dup 61 | JSON.parse(@meta.to_json, symbolize_names: true) 62 | end 63 | 64 | # 65 | # Return the meta store as json. 66 | # 67 | # @param [Array] *arg The to_json args. 68 | # 69 | # @return [String] The meta store as json. 70 | # 71 | def to_json(*arg) 72 | @meta.to_json(*arg) 73 | end 74 | 75 | # 76 | # Equality operator. 77 | # 78 | # @param [Any] other The object being compared. 79 | # 80 | # @return [Boolean] True if the object is equal. 81 | # 82 | def ==(other) 83 | to_json == other.try(:to_json) 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/cloudtasker/missing_worker_arguments_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | class MissingWorkerArgumentsError < StandardError 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/cloudtasker/retry_worker_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | class RetryWorkerError < StandardError 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/cloudtasker/storable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'storable/worker' 4 | -------------------------------------------------------------------------------- /lib/cloudtasker/storable/worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module Storable 5 | # Add ability to store and pull workers in Redis under a specific namespace 6 | module Worker 7 | # Add class method to including class 8 | def self.included(base) 9 | base.extend(ClassMethods) 10 | end 11 | 12 | # Module class methods 13 | module ClassMethods 14 | # 15 | # Return the namespaced store key used to store jobs that 16 | # have been parked and should be manually popped later. 17 | # 18 | # @param [String] namespace The user-provided store namespace 19 | # 20 | # @return [String] The full store cache key 21 | # 22 | def store_cache_key(namespace) 23 | cache_key([Config::WORKER_STORE_PREFIX, namespace]) 24 | end 25 | 26 | # 27 | # Push the worker to a namespaced store. 28 | # 29 | # @param [String] namespace The store namespace 30 | # @param [Array] *args List of worker arguments 31 | # 32 | # @return [String] The number of elements added to the store 33 | # 34 | def push_to_store(namespace, *args) 35 | redis.rpush(store_cache_key(namespace), [args.to_json]) 36 | end 37 | 38 | # 39 | # Push many workers to a namespaced store at once. 40 | # 41 | # @param [String] namespace The store namespace 42 | # @param [Array>] args_list A list of arguments for each worker 43 | # 44 | # @return [String] The number of elements added to the store 45 | # 46 | def push_many_to_store(namespace, args_list) 47 | redis.rpush(store_cache_key(namespace), args_list.map(&:to_json)) 48 | end 49 | 50 | # 51 | # Pull the jobs from the namespaced store and enqueue them. 52 | # 53 | # @param [String] namespace The store namespace. 54 | # @param [Integer] page_size The number of items to pull on each page. Defaults to 1000. 55 | # 56 | def pull_all_from_store(namespace, page_size: 1000) 57 | items = nil 58 | 59 | while items.nil? || items.present? 60 | # Pull items 61 | items = redis.lpop(store_cache_key(namespace), page_size).to_a 62 | 63 | # For each item, execute block or enqueue it 64 | items.each do |args_json| 65 | worker_args = JSON.parse(args_json) 66 | 67 | if block_given? 68 | yield(worker_args) 69 | else 70 | perform_async(*worker_args) 71 | end 72 | end 73 | end 74 | end 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/cloudtasker/testing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/backend/memory_task' 4 | 5 | module Cloudtasker 6 | # Enable/Disable test mode for Cloudtasker 7 | module Testing 8 | module_function 9 | 10 | # 11 | # Set the test mode, either permanently or 12 | # temporarily (via block). 13 | # 14 | # @param [Symbol] mode The test mode. 15 | # 16 | # @return [Symbol] The test mode. 17 | # 18 | def switch_test_mode(mode) 19 | if block_given? 20 | current_mode = @test_mode 21 | begin 22 | @test_mode = mode 23 | yield 24 | ensure 25 | @test_mode = current_mode 26 | end 27 | else 28 | @test_mode = mode 29 | end 30 | end 31 | 32 | # 33 | # Set cloudtasker to real mode temporarily 34 | # 35 | # @param [Proc] &block The block to run in real mode 36 | # 37 | def enable!(&block) 38 | switch_test_mode(:enabled, &block) 39 | end 40 | 41 | # 42 | # Set cloudtasker to fake mode temporarily 43 | # 44 | # @param [Proc] &block The block to run in fake mode 45 | # 46 | def fake!(&block) 47 | switch_test_mode(:fake, &block) 48 | end 49 | 50 | # 51 | # Set cloudtasker to inline mode temporarily 52 | # 53 | # @param [Proc] &block The block to run in inline mode 54 | # 55 | def inline!(&block) 56 | switch_test_mode(:inline, &block) 57 | end 58 | 59 | # 60 | # Return true if Cloudtasker is enabled. 61 | # 62 | def enabled? 63 | !@test_mode || @test_mode == :enabled 64 | end 65 | 66 | # 67 | # Return true if Cloudtasker is in fake mode. 68 | # 69 | # @return [Boolean] True if jobs must be processed through drain calls. 70 | # 71 | def fake? 72 | @test_mode == :fake 73 | end 74 | 75 | # 76 | # Return true if Cloudtasker is in inline mode. 77 | # 78 | # @return [Boolean] True if jobs are run inline. 79 | # 80 | def inline? 81 | @test_mode == :inline 82 | end 83 | 84 | # 85 | # Return true if tasks should be managed in memory. 86 | # 87 | # @return [Boolean] True if jobs are managed in memory. 88 | # 89 | def in_memory? 90 | !enabled? 91 | end 92 | end 93 | 94 | # Add extra methods for testing purpose 95 | module Worker 96 | # 97 | # Clear all jobs. 98 | # 99 | def self.clear_all 100 | Backend::MemoryTask.clear 101 | end 102 | 103 | # 104 | # Run all the jobs. 105 | # 106 | # @return [Array] The return values of the workers perform method. 107 | # 108 | def self.drain_all 109 | Backend::MemoryTask.drain 110 | end 111 | 112 | # Module class methods 113 | module ClassMethods 114 | # 115 | # Return all jobs related to this worker class. 116 | # 117 | # @return [Array] The list of tasks 118 | # 119 | def jobs 120 | Backend::MemoryTask.all(to_s) 121 | end 122 | 123 | # 124 | # Run all jobs related to this worker class. 125 | # 126 | # @return [Array] The return values of the workers perform method. 127 | # 128 | def drain 129 | Backend::MemoryTask.drain(to_s) 130 | end 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/cloudtasker/unique_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'unique_job/middleware' 4 | 5 | Cloudtasker::UniqueJob::Middleware.configure 6 | 7 | module Cloudtasker 8 | # UniqueJob configurator 9 | module UniqueJob 10 | # The maximum duration a lock can remain in place 11 | # after schedule time. 12 | DEFAULT_LOCK_TTL = 10 * 60 # 10 minutes 13 | 14 | class << self 15 | attr_writer :lock_ttl 16 | 17 | # Configure the middleware 18 | def configure 19 | yield(self) 20 | end 21 | 22 | # 23 | # Return the max TTL for locks 24 | # 25 | # @return [Integer] The lock TTL. 26 | # 27 | def lock_ttl 28 | @lock_ttl || DEFAULT_LOCK_TTL 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/cloudtasker/unique_job/conflict_strategy/base_strategy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module UniqueJob 5 | module ConflictStrategy 6 | # Base behaviour for conflict strategies 7 | class BaseStrategy 8 | attr_reader :job 9 | 10 | # 11 | # Build a new instance of the class. 12 | # 13 | # @param [Cloudtasker::UniqueJob::Job] job The UniqueJob job 14 | # 15 | def initialize(job) 16 | @job = job 17 | end 18 | 19 | # 20 | # Handling logic to perform when a conflict occurs while 21 | # scheduling a job. 22 | # 23 | # We return nil to flag the job as not scheduled 24 | # 25 | def on_schedule 26 | nil 27 | end 28 | 29 | # 30 | # Handling logic to perform when a conflict occurs while 31 | # executing a job. 32 | # 33 | def on_execute 34 | nil 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/cloudtasker/unique_job/conflict_strategy/raise.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module UniqueJob 5 | module ConflictStrategy 6 | # This strategy raises an error on conflict, both on client and server side. 7 | class Raise < BaseStrategy 8 | RESCHEDULE_DELAY = 5 # seconds 9 | 10 | # Raise a Cloudtasker::UniqueJob::LockError 11 | def on_schedule 12 | raise_lock_error 13 | end 14 | 15 | # Raise a Cloudtasker::UniqueJob::LockError 16 | def on_execute 17 | raise_lock_error 18 | end 19 | 20 | private 21 | 22 | def raise_lock_error 23 | raise(UniqueJob::LockError, id: job.id, unique_id: job.unique_id) 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/cloudtasker/unique_job/conflict_strategy/reject.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module UniqueJob 5 | module ConflictStrategy 6 | # This strategy rejects the job on conflict. This is equivalent to "do nothing". 7 | class Reject < BaseStrategy 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/cloudtasker/unique_job/conflict_strategy/reschedule.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module UniqueJob 5 | module ConflictStrategy 6 | # This strategy reschedules the job on conflict. This strategy can only 7 | # be used with processing locks (e.g. while_executing). 8 | class Reschedule < BaseStrategy 9 | RESCHEDULE_DELAY = 5 # seconds 10 | 11 | # 12 | # A conflict on schedule means that this strategy is being used 13 | # with a lock scheduling strategy (e.g. until_executed) instead of a 14 | # processing strategy (e.g. while_executing). In this case we let the 15 | # scheduling happen as it does not make sense to reschedule in this context. 16 | # 17 | def on_schedule 18 | yield 19 | end 20 | 21 | # 22 | # Reschedule the job. 23 | # 24 | def on_execute 25 | job.worker.reenqueue(RESCHEDULE_DELAY) 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/cloudtasker/unique_job/lock/base_lock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module UniqueJob 5 | module Lock 6 | # Base behaviour for locks 7 | class BaseLock 8 | attr_reader :job 9 | 10 | # 11 | # Build a new instance of the class. 12 | # 13 | # @param [Cloudtasker::UniqueJob::Job] job The UniqueJob job 14 | # 15 | def initialize(job) 16 | @job = job 17 | end 18 | 19 | # 20 | # Return the worker configuration options. 21 | # 22 | # @return [Hash] The worker configuration options. 23 | # 24 | def options 25 | job.options 26 | end 27 | 28 | # 29 | # Return the strategy to use by default. Can be overriden in each lock. 30 | # 31 | # @return [Cloudtasker::UniqueJob::ConflictStrategy::BaseStrategy] The strategy to use by default. 32 | # 33 | def default_conflict_strategy 34 | ConflictStrategy::Reject 35 | end 36 | 37 | # 38 | # Return the conflict strategy to use on conflict 39 | # 40 | # @return [Cloudtasker::UniqueJob::ConflictStrategy::BaseStrategy] The instantiated strategy. 41 | # 42 | def conflict_instance 43 | @conflict_instance ||= 44 | begin 45 | # Infer lock class and get instance 46 | strategy_name = options[:on_conflict] 47 | strategy_klass = ConflictStrategy.const_get(strategy_name.to_s.split('_').collect(&:capitalize).join) 48 | strategy_klass.new(job) 49 | rescue NameError 50 | default_conflict_strategy.new(job) 51 | end 52 | end 53 | 54 | # 55 | # Lock logic invoked when a job is scheduled (client middleware). 56 | # 57 | def schedule 58 | yield 59 | end 60 | 61 | # 62 | # Lock logic invoked when a job is executed (server middleware). 63 | # 64 | def execute 65 | yield 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/cloudtasker/unique_job/lock/no_op.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module UniqueJob 5 | module Lock 6 | # Equivalent to no lock 7 | class NoOp < BaseLock 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/cloudtasker/unique_job/lock/until_executed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module UniqueJob 5 | module Lock 6 | # Conflict if any other job with the same args is scheduled or moved to execution 7 | # while the first job is pending or executing. 8 | class UntilExecuted < BaseLock 9 | # 10 | # Acquire a lock for the job and trigger a conflict 11 | # if the lock could not be acquired. 12 | # 13 | def schedule(&block) 14 | job.lock! 15 | yield 16 | rescue LockError 17 | conflict_instance.on_schedule(&block) 18 | end 19 | 20 | # 21 | # Acquire a lock for the job and trigger a conflict 22 | # if the lock could not be acquired. 23 | # 24 | def execute(&block) 25 | job.lock! 26 | yield 27 | rescue LockError 28 | conflict_instance.on_execute(&block) 29 | ensure 30 | # Unlock the job on any error to avoid deadlocks. 31 | job.unlock! 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/cloudtasker/unique_job/lock/until_executing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module UniqueJob 5 | module Lock 6 | # Conflict if any other job with the same args is scheduled 7 | # while the first job is pending. 8 | class UntilExecuting < BaseLock 9 | # 10 | # Acquire a lock for the job and trigger a conflict 11 | # if the lock could not be acquired. 12 | # 13 | def schedule(&block) 14 | job.lock! 15 | yield 16 | rescue LockError 17 | conflict_instance.on_schedule(&block) 18 | end 19 | 20 | # 21 | # Release the lock and perform the job. 22 | # 23 | def execute 24 | job.unlock! 25 | yield 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/cloudtasker/unique_job/lock/while_executing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module UniqueJob 5 | module Lock 6 | # Conflict if any other job with the same args is moved to execution 7 | # while the first job is executing. 8 | class WhileExecuting < BaseLock 9 | # 10 | # Acquire a lock for the job and trigger a conflict 11 | # if the lock could not be acquired. 12 | # 13 | def execute(&block) 14 | job.lock! 15 | yield 16 | rescue LockError 17 | conflict_instance.on_execute(&block) 18 | ensure 19 | # Unlock the job on any error to avoid deadlocks. 20 | job.unlock! 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/cloudtasker/unique_job/lock_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module UniqueJob 5 | class LockError < StandardError 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/cloudtasker/unique_job/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/redis_client' 4 | 5 | require_relative 'lock_error' 6 | 7 | require_relative 'conflict_strategy/base_strategy' 8 | require_relative 'conflict_strategy/raise' 9 | require_relative 'conflict_strategy/reject' 10 | require_relative 'conflict_strategy/reschedule' 11 | 12 | require_relative 'lock/base_lock' 13 | require_relative 'lock/no_op' 14 | require_relative 'lock/until_executed' 15 | require_relative 'lock/until_executing' 16 | require_relative 'lock/while_executing' 17 | 18 | require_relative 'job' 19 | 20 | require_relative 'middleware/client' 21 | require_relative 'middleware/server' 22 | 23 | module Cloudtasker 24 | module UniqueJob 25 | # Registration module 26 | module Middleware 27 | def self.configure 28 | Cloudtasker.configure do |config| 29 | config.client_middleware { |c| c.add(Middleware::Client) } 30 | config.server_middleware { |c| c.add(Middleware::Server) } 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/cloudtasker/unique_job/middleware/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module UniqueJob 5 | module Middleware 6 | # TODO: kwargs to job otherwise it won't get the time_at 7 | # Client middleware, invoked when jobs are scheduled 8 | class Client 9 | def call(worker, _opts = {}, &block) 10 | Job.new(worker).lock_instance.schedule(&block) 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/cloudtasker/unique_job/middleware/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | module UniqueJob 5 | module Middleware 6 | # Server middleware, invoked when jobs are executed 7 | class Server 8 | def call(worker, **_kwargs, &block) 9 | Job.new(worker).lock_instance.execute(&block) 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/cloudtasker/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cloudtasker 4 | VERSION = '0.14.0' 5 | end 6 | -------------------------------------------------------------------------------- /lib/cloudtasker/worker_wrapper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/worker' 4 | 5 | module Cloudtasker 6 | # A worker class used to schedule jobs without actually 7 | # instantiating the worker class. This is useful for middlewares 8 | # needing to enqueue jobs in a Rails initializer. Rails 6 complains 9 | # about instantiating workers in an iniitializer because of autoloading 10 | # in zeitwerk mode. 11 | # 12 | # Downside of this wrapper: any cloudtasker_options specified on on the 13 | # worker_class will be ignored. 14 | # 15 | # See: https://github.com/rails/rails/issues/36363 16 | # 17 | class WorkerWrapper 18 | include Worker 19 | 20 | attr_accessor :worker_name 21 | 22 | # 23 | # Build a new instance of the class. 24 | # 25 | # @param [String] worker_class The name of the worker class. 26 | # @param [Hash] **opts The worker arguments. 27 | # 28 | def initialize(worker_name:, **opts) 29 | @worker_name = worker_name 30 | super(**opts) 31 | end 32 | 33 | # 34 | # Override parent. Return the underlying worker class name. 35 | # 36 | # @return [String] The worker class. 37 | # 38 | def job_class_name 39 | worker_name 40 | end 41 | 42 | # 43 | # Return a new instance of the worker with the same args and metadata 44 | # but with a different id. 45 | # 46 | # @return [Cloudtasker::WorkerWrapper] 47 | # 48 | def new_instance 49 | self.class.new(worker_name: worker_name, job_queue: job_queue, job_args: job_args, job_meta: job_meta) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/tasks/setup_queue.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/config' 4 | require 'cloudtasker/cloud_task' 5 | 6 | ENV['GOOGLE_AUTH_SUPPRESS_CREDENTIALS_WARNINGS'] ||= 'true' 7 | 8 | namespace :cloudtasker do 9 | desc 'Setup a Cloud Task queue. (default options: ' \ 10 | "name=#{Cloudtasker::Config::DEFAULT_JOB_QUEUE}, " \ 11 | "concurrency=#{Cloudtasker::Config::DEFAULT_QUEUE_CONCURRENCY}, " \ 12 | "retries=#{Cloudtasker::Config::DEFAULT_QUEUE_RETRIES})" 13 | task setup_queue: :environment do 14 | puts Cloudtasker::CloudTask.setup_production_queue( 15 | name: ENV.fetch('name', nil), 16 | concurrency: ENV.fetch('concurrency', nil), 17 | retries: ENV.fetch('retries', nil) 18 | ) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/active_job/queue_adapters/cloudtasker_adapter/job_wrapper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | if defined?(Rails) 6 | RSpec.describe ActiveJob::QueueAdapters::CloudtaskerAdapter::JobWrapper do 7 | include_context 'of Cloudtasker ActiveJob instantiation' 8 | 9 | subject(:worker) { described_class.new(**example_job_wrapper_args.merge(task_id: '00000001')) } 10 | 11 | let(:example_unreconstructed_job_serialization) do 12 | example_job_serialization.except('job_id', 'queue_name', 'provider_job_id', 'executions', 'priority') 13 | end 14 | 15 | let(:example_reconstructed_job_serialization) do 16 | example_job_serialization.merge( 17 | 'job_id' => worker.job_id, 18 | 'queue_name' => worker.job_queue, 19 | 'provider_job_id' => worker.task_id, 20 | 'executions' => 0, 21 | 'priority' => nil 22 | ) 23 | end 24 | 25 | describe '#perform' do 26 | it "calls 'ActiveJob::Base.execute' with the job serialization" do 27 | expect(ActiveJob::Base).to receive(:execute).with(example_reconstructed_job_serialization) 28 | worker.perform(example_unreconstructed_job_serialization) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/active_job/queue_adapters/cloudtasker_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | if defined?(Rails) 6 | RSpec.describe ActiveJob::QueueAdapters::CloudtaskerAdapter do 7 | include_context 'of Cloudtasker ActiveJob instantiation' 8 | 9 | subject(:adapter) { described_class.new } 10 | 11 | let(:example_job_wrapper_double) do 12 | instance_double("#{described_class.name}::JobWrapper", example_job_wrapper_args) 13 | .tap { |double| allow(double).to receive(:schedule) } 14 | end 15 | 16 | around { |e| Timecop.freeze { e.run } } 17 | before { allow(described_class::JobWrapper).to receive(:new).and_return(example_job_wrapper_double) } 18 | 19 | shared_examples 'of instantiating a Cloudtasker JobWrapper from ActiveJob' do 20 | it 'instantiates a new CloudtaskerAdapter JobWrapper for the given job' do 21 | expect(described_class::JobWrapper).to receive(:new).with(example_job_wrapper_args) 22 | adapter.enqueue(example_job) 23 | end 24 | end 25 | 26 | describe '#enqueue' do 27 | include_examples 'of instantiating a Cloudtasker JobWrapper from ActiveJob' 28 | 29 | it 'enqueues the new CloudtaskerAdapter JobWrapper to execute' do 30 | expect(example_job_wrapper_double).to receive(:schedule) 31 | adapter.enqueue(example_job) 32 | end 33 | end 34 | 35 | describe '#enqueue_at' do 36 | let(:example_execution_timestamp) { 1.week.from_now.to_i } 37 | let(:expected_execution_time) { Time.at(example_execution_timestamp) } 38 | 39 | include_examples 'of instantiating a Cloudtasker JobWrapper from ActiveJob' 40 | 41 | it 'enqueues the new CloudtaskerAdapter JobWrapper to execute at the given time' do 42 | expect(example_job_wrapper_double).to receive(:schedule).with(time_at: expected_execution_time) 43 | adapter.enqueue_at(example_job, example_execution_timestamp) 44 | end 45 | end 46 | 47 | describe '#enqueue_after_transaction_commit?' do 48 | it { expect(adapter).to be_enqueue_after_transaction_commit } 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/cloudtasker/authenticator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Cloudtasker::Authenticator do 4 | let(:config) { Cloudtasker.config } 5 | 6 | describe '.verification_token' do 7 | subject { described_class.verification_token } 8 | 9 | let(:expected_token) { JWT.encode({ iat: Time.now.to_i }, config.secret, described_class::JWT_ALG) } 10 | 11 | around { |e| Timecop.freeze { e.run } } 12 | 13 | it { is_expected.to eq(expected_token) } 14 | end 15 | 16 | describe '.bearer_token' do 17 | subject { described_class.bearer_token } 18 | 19 | let(:verification_token) { '123456789' } 20 | 21 | before { expect(described_class).to receive(:verification_token).and_return(verification_token) } 22 | it { is_expected.to eq("Bearer #{verification_token}") } 23 | end 24 | 25 | describe '.verify' do 26 | subject { described_class.verify(token) } 27 | 28 | let(:token) { JWT.encode({ iat: Time.now.to_i }, secret, described_class::JWT_ALG) } 29 | 30 | context 'with valid token' do 31 | let(:secret) { config.secret } 32 | 33 | it { is_expected.to be_truthy } 34 | end 35 | 36 | context 'with invalid token' do 37 | let(:secret) { "#{config.secret}a" } 38 | 39 | it { is_expected.to be_falsey } 40 | end 41 | end 42 | 43 | describe '.verify!' do 44 | subject(:verify!) { described_class.verify!(token) } 45 | 46 | let(:token) { JWT.encode({ iat: Time.now.to_i }, secret, described_class::JWT_ALG) } 47 | 48 | context 'with valid token' do 49 | let(:secret) { config.secret } 50 | 51 | it { is_expected.to be_truthy } 52 | end 53 | 54 | context 'with invalid token' do 55 | let(:secret) { "#{config.secret}a" } 56 | 57 | it { expect { verify! }.to raise_error(Cloudtasker::AuthenticationError) } 58 | end 59 | end 60 | 61 | describe '.sign_payload' do 62 | subject { described_class.sign_payload(payload) } 63 | 64 | let(:payload) { { 'foo' => 'bar' }.to_json } 65 | 66 | it { is_expected.to eq(OpenSSL::HMAC.hexdigest('sha256', config.secret, payload)) } 67 | end 68 | 69 | describe '.verify_signature!' do 70 | subject(:verify!) { described_class.verify_signature!(signature, payload) } 71 | 72 | let(:payload) { { 'foo' => 'bar' }.to_json } 73 | 74 | context 'with valid token' do 75 | let(:signature) { described_class.sign_payload(payload) } 76 | 77 | it { is_expected.to be_truthy } 78 | end 79 | 80 | context 'with invalid token' do 81 | let(:signature) { 'some-invalid-signature' } 82 | 83 | it { expect { verify! }.to raise_error(Cloudtasker::AuthenticationError) } 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/cloudtasker/batch/extension/worker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/batch/middleware' 4 | 5 | RSpec.describe Cloudtasker::Batch::Extension::Worker do 6 | describe '#batch' do 7 | subject { worker.batch } 8 | 9 | let(:worker) { TestWorker.new } 10 | 11 | before { worker.batch = Cloudtasker::Batch::Job.new(worker) } 12 | it { is_expected.to be_a(Cloudtasker::Batch::Job) } 13 | it { is_expected.to have_attributes(worker: worker) } 14 | end 15 | 16 | describe '#parent_batch' do 17 | subject { worker.parent_batch } 18 | 19 | let(:worker) { TestWorker.new } 20 | 21 | before { worker.parent_batch = Cloudtasker::Batch::Job.new(worker) } 22 | it { is_expected.to be_a(Cloudtasker::Batch::Job) } 23 | it { is_expected.to have_attributes(worker: worker) } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/cloudtasker/batch/middleware/server_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/batch/middleware' 4 | 5 | RSpec.describe Cloudtasker::Batch::Middleware::Server do 6 | let(:middleware) { described_class.new } 7 | 8 | describe '#call' do 9 | let(:worker) { instance_double(Cloudtasker::Worker) } 10 | let(:job) { instance_double(Cloudtasker::Batch::Job) } 11 | 12 | before { allow(Cloudtasker::Batch::Job).to receive(:for).with(worker).and_return(job) } 13 | before { allow(job).to receive(:execute).and_yield } 14 | it { expect { |b| middleware.call(worker, &b) }.to yield_control } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/cloudtasker/batch/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/cron/middleware' 4 | 5 | RSpec.describe Cloudtasker::Batch::Middleware do 6 | describe '.configure' do 7 | before { described_class.configure } 8 | 9 | it { expect(Cloudtasker.config.server_middleware).to exist(Cloudtasker::Batch::Middleware::Server) } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/cloudtasker/cron/middleware/server_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/cron/middleware' 4 | 5 | RSpec.describe Cloudtasker::Cron::Middleware::Server do 6 | let(:middleware) { described_class.new } 7 | 8 | describe '#call' do 9 | let(:worker) { instance_double(Cloudtasker::Worker) } 10 | let(:job) { instance_double(Cloudtasker::Cron::Job) } 11 | 12 | before { allow(Cloudtasker::Cron::Job).to receive(:new).with(worker).and_return(job) } 13 | before { allow(job).to receive(:execute).and_yield } 14 | it { expect { |b| middleware.call(worker, &b) }.to yield_control } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/cloudtasker/cron/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/cron/middleware' 4 | 5 | RSpec.describe Cloudtasker::Cron::Middleware do 6 | describe '.configure' do 7 | before { described_class.configure } 8 | 9 | it { expect(Cloudtasker.config.server_middleware).to exist(Cloudtasker::Cron::Middleware::Server) } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/cloudtasker/meta_store_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Cloudtasker::MetaStore do 4 | describe '.new' do 5 | let(:hash) { { 'foo' => 'bar' } } 6 | let(:meta) { described_class.new(hash) } 7 | 8 | it { expect(meta.to_h).to eq(JSON.parse(hash.to_json, symbolize_names: true)) } 9 | end 10 | 11 | describe '#set' do 12 | let(:meta) { described_class.new } 13 | let(:key) { 'some_id' } 14 | let(:val) { 'foo' } 15 | 16 | before { meta.set(key, val) } 17 | it { expect(meta.to_h[key.to_sym]).to eq(val) } 18 | end 19 | 20 | describe '#get' do 21 | let(:meta) { described_class.new } 22 | let(:key) { 'some_id' } 23 | let(:val) { 'foo' } 24 | 25 | before { meta.set(key, val) } 26 | it { expect(meta.get(key)).to eq(val) } 27 | end 28 | 29 | describe '#del' do 30 | let(:meta) { described_class.new } 31 | let(:key) { 'some_id' } 32 | 33 | before { meta.set(key, 'foo') } 34 | before { meta.del(key) } 35 | it { expect(meta.to_h).not_to have_key(key.to_sym) } 36 | end 37 | 38 | describe '#to_h' do 39 | subject { described_class.new(hash).to_h } 40 | 41 | let(:hash) { { 'foo' => 'bar' } } 42 | 43 | it { is_expected.to eq(JSON.parse(hash.to_json, symbolize_names: true)) } 44 | end 45 | 46 | describe '#to_json' do 47 | subject { described_class.new(hash).to_json } 48 | 49 | let(:hash) { { 'foo' => 'bar' } } 50 | 51 | it { is_expected.to eq(hash.to_json) } 52 | end 53 | 54 | describe '#==' do 55 | subject { described_class.new(hash) } 56 | 57 | let(:hash) { { 'foo' => 'bar' } } 58 | 59 | context 'with identical hash' do 60 | it { is_expected.to eq(hash) } 61 | end 62 | 63 | context 'with identical meta' do 64 | it { is_expected.to eq(described_class.new(hash)) } 65 | end 66 | 67 | context 'with different object' do 68 | it { is_expected.not_to eq('foo') } 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/cloudtasker/redis_client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/redis_client' 4 | 5 | RSpec.describe Cloudtasker::RedisClient do 6 | let(:redis_client) { described_class.new } 7 | 8 | describe '.client' do 9 | subject { described_class.client.with { |c| c } } 10 | 11 | it { expect(described_class.client).to be_a(ConnectionPool) } 12 | it { is_expected.to be_a(Redis) } 13 | it { is_expected.to have_attributes(id: Cloudtasker.config.redis[:url]) } 14 | end 15 | 16 | describe '#client' do 17 | subject { redis_client.client } 18 | 19 | it { is_expected.to eq(described_class.client) } 20 | end 21 | 22 | describe '#fetch' do 23 | subject { redis_client.fetch(key) } 24 | 25 | let(:key) { 'foo' } 26 | let(:content) { { 'foo' => 'bar' } } 27 | 28 | before { redis_client.set(key, content.to_json) } 29 | it { is_expected.to eq(JSON.parse(content.to_json, symbolize_names: true)) } 30 | end 31 | 32 | describe '#write' do 33 | subject { redis_client.fetch(key) } 34 | 35 | let(:key) { 'foo' } 36 | let(:content) { { 'foo' => 'bar' } } 37 | 38 | before { redis_client.write(key, content) } 39 | it { is_expected.to eq(JSON.parse(content.to_json, symbolize_names: true)) } 40 | end 41 | 42 | describe '#clear' do 43 | subject { redis_client.keys } 44 | 45 | before do 46 | redis_client.set('foo', 'bar') 47 | redis_client.clear 48 | end 49 | 50 | it { is_expected.to be_empty } 51 | end 52 | 53 | describe '#with_lock' do 54 | let(:key) { 'cache-key' } 55 | let(:lock_key) { 'cloudtasker/lock/cache-key' } 56 | let(:redis) { instance_double(Redis) } 57 | let(:lock_duration) { 30 } 58 | let(:expected_lock_duration) { described_class::LOCK_DURATION } 59 | 60 | before do 61 | allow(redis_client.client).to receive(:with).and_yield(redis) 62 | allow(redis).to receive(:del) 63 | allow(redis).to receive(:set) 64 | .with(lock_key, true, nx: true, ex: expected_lock_duration) 65 | .and_return(true) 66 | end 67 | after { expect(redis).to have_received(:set) } 68 | 69 | context 'with no max_wait' do 70 | it { expect { |b| redis_client.with_lock(key, &b) }.to yield_control } 71 | end 72 | 73 | context 'with max_wait' do 74 | let(:expected_lock_duration) { lock_duration } 75 | 76 | it { expect { |b| redis_client.with_lock(key, max_wait: lock_duration, &b) }.to yield_control } 77 | end 78 | end 79 | 80 | describe '#search' do 81 | subject { redis_client.search(pattern).sort } 82 | 83 | let(:keys) { 50.times.map { |n| "foo/#{n}" }.sort } 84 | 85 | before { keys.each { |e| redis_client.set(e, true) } } 86 | 87 | context 'with keys matching pattern' do 88 | let(:pattern) { 'foo/*' } 89 | 90 | it { is_expected.to eq(keys) } 91 | end 92 | 93 | context 'with no keys matching' do 94 | let(:pattern) { 'bar/*' } 95 | 96 | it { is_expected.to be_empty } 97 | end 98 | end 99 | 100 | describe '#get' do 101 | subject { redis_client.get(key) } 102 | 103 | let(:key) { 'foo' } 104 | let(:content) { 'bar' } 105 | 106 | before { redis_client.set(key, content) } 107 | it { is_expected.to eq(content) } 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /spec/cloudtasker/storable/worker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Cloudtasker::Storable::Worker do 4 | let(:worker_class) { TestStorableWorker } 5 | 6 | describe '.store_cache_key' do 7 | subject { worker_class.store_cache_key(namespace) } 8 | 9 | let(:namespace) { :some_key } 10 | 11 | it { is_expected.to eq(worker_class.cache_key([Cloudtasker::Config::WORKER_STORE_PREFIX, namespace])) } 12 | end 13 | 14 | describe '.push_to_store' do 15 | subject { worker_class.push_to_store(namespace, *args) } 16 | 17 | let(:namespace) { 'some-namespace' } 18 | let(:args) { [1, 'two', { three: true }] } 19 | let(:store_cache_key) { worker_class.store_cache_key(namespace) } 20 | 21 | after { expect(worker_class.redis.lrange(store_cache_key, 0, -1)).to eq([args.to_json]) } 22 | it { is_expected.to eq(1) } 23 | end 24 | 25 | describe '.push_many_to_store' do 26 | subject { worker_class.push_many_to_store(namespace, args_list) } 27 | 28 | let(:namespace) { 'some-namespace' } 29 | let(:args_list) { [[2, 'four'], [1, 'two', { three: true }]] } 30 | let(:store_cache_key) { worker_class.store_cache_key(namespace) } 31 | 32 | after { expect(worker_class.redis.lrange(store_cache_key, 0, -1)).to eq(args_list.map(&:to_json)) } 33 | it { is_expected.to eq(args_list.size) } 34 | end 35 | 36 | describe '.pull_all_from_store' do 37 | subject { worker_class.pull_all_from_store(namespace, page_size: page_size) } 38 | 39 | let(:namespace) { 'some-namespace' } 40 | let(:page_size) { 5 } 41 | let(:results) { [] } 42 | let(:arg_list) { Array.new((page_size * 2) + 1) { |n| [n] } } 43 | let(:store_cache_key) { worker_class.store_cache_key(namespace) } 44 | 45 | before { worker_class.push_many_to_store(namespace, arg_list) } 46 | after { expect(worker_class.redis.lrange(store_cache_key, 0, -1)).to be_empty } 47 | 48 | context 'with no block' do 49 | before { allow(worker_class).to receive(:perform_async) { |*args| results << args } } 50 | after { expect(results.sort).to eq(arg_list.sort) } 51 | it { is_expected.to be_nil } 52 | end 53 | 54 | context 'with block' do 55 | subject do 56 | worker_class.pull_all_from_store(namespace, page_size: page_size) do |args| 57 | results << args 58 | end 59 | end 60 | 61 | before { expect(worker_class).not_to receive(:perform_async) } 62 | after { expect(results.sort).to eq(arg_list.sort) } 63 | it { is_expected.to be_nil } 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/cloudtasker/testing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Cloudtasker::Testing do 4 | before { Cloudtasker::Backend::MemoryTask.clear } 5 | before { TestWorker.has_run = false } 6 | after { described_class.enable! } 7 | 8 | describe '.fake!' do 9 | subject { Cloudtasker::Backend::MemoryTask.all } 10 | 11 | context 'with option set' do 12 | before { described_class.fake! } 13 | before { TestWorker.perform_async(1, 2) } 14 | it { is_expected.to match([be_a(Cloudtasker::Backend::MemoryTask)]) } 15 | end 16 | 17 | context 'with block' do 18 | around do |e| 19 | described_class.fake! { e.run } 20 | expect(described_class).to be_enabled 21 | end 22 | before { TestWorker.perform_async(1, 2) } 23 | it { is_expected.to match([be_a(Cloudtasker::Backend::MemoryTask)]) } 24 | end 25 | end 26 | 27 | describe '.inline!' do 28 | subject { Cloudtasker::Backend::MemoryTask.all } 29 | 30 | context 'with option set' do 31 | before { described_class.inline! } 32 | before { TestWorker.perform_async(1, 2) } 33 | after { expect(TestWorker).to have_run } 34 | it { is_expected.to eq([]) } 35 | end 36 | 37 | context 'with block' do 38 | around do |e| 39 | described_class.inline! { e.run } 40 | expect(described_class).to be_enabled 41 | end 42 | before { TestWorker.perform_async(1, 2) } 43 | after { expect(TestWorker).to have_run } 44 | it { is_expected.to eq([]) } 45 | end 46 | end 47 | 48 | describe 'job draining' do 49 | before { described_class.fake! } 50 | before { TestWorker.perform_async(1, 2) } 51 | before { TestWorker.drain } 52 | it { expect(TestWorker).to have_run } 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/cloudtasker/unique_job/conflict_strategy/raise_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/unique_job/middleware' 4 | 5 | RSpec.describe Cloudtasker::UniqueJob::ConflictStrategy::Raise do 6 | let(:worker) { TestWorker.new(job_args: [1, 2]) } 7 | let(:job) { Cloudtasker::UniqueJob::Job.new(worker) } 8 | let(:strategy) { described_class.new(job) } 9 | 10 | it_behaves_like Cloudtasker::UniqueJob::ConflictStrategy::BaseStrategy 11 | 12 | describe '#on_schedule' do 13 | it { expect { strategy.on_schedule }.to raise_error(Cloudtasker::UniqueJob::LockError) } 14 | end 15 | 16 | describe '#on_execute' do 17 | it { expect { strategy.on_execute }.to raise_error(Cloudtasker::UniqueJob::LockError) } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/cloudtasker/unique_job/conflict_strategy/reject_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/unique_job/middleware' 4 | 5 | RSpec.describe Cloudtasker::UniqueJob::ConflictStrategy::Reject do 6 | let(:worker) { TestWorker.new(job_args: [1, 2]) } 7 | let(:job) { Cloudtasker::UniqueJob::Job.new(worker) } 8 | let(:strategy) { described_class.new(job) } 9 | 10 | it_behaves_like Cloudtasker::UniqueJob::ConflictStrategy::BaseStrategy 11 | 12 | describe '#on_schedule' do 13 | subject { strategy.on_schedule } 14 | 15 | it { is_expected.to be_falsey } 16 | it { expect { |b| strategy.on_schedule(&b) }.not_to yield_control } 17 | end 18 | 19 | describe '#on_execute' do 20 | subject { strategy.on_execute } 21 | 22 | it { is_expected.to be_falsey } 23 | it { expect { |b| strategy.on_execute(&b) }.not_to yield_control } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/cloudtasker/unique_job/conflict_strategy/reschedule_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/unique_job/middleware' 4 | 5 | RSpec.describe Cloudtasker::UniqueJob::ConflictStrategy::Reschedule do 6 | let(:worker) { TestWorker.new(job_args: [1, 2]) } 7 | let(:job) { Cloudtasker::UniqueJob::Job.new(worker) } 8 | let(:strategy) { described_class.new(job) } 9 | 10 | it_behaves_like Cloudtasker::UniqueJob::ConflictStrategy::BaseStrategy 11 | 12 | describe '#on_schedule' do 13 | it { expect { |b| strategy.on_schedule(&b) }.to yield_control } 14 | end 15 | 16 | describe '#on_execute' do 17 | subject { strategy.on_execute } 18 | 19 | before { allow(worker).to receive(:reenqueue).with(described_class::RESCHEDULE_DELAY).and_return(true) } 20 | after { expect(worker).to have_received(:reenqueue) } 21 | 22 | it { is_expected.to be_truthy } 23 | it { expect { |b| strategy.on_execute(&b) }.not_to yield_control } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/cloudtasker/unique_job/lock/no_op_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/unique_job/middleware' 4 | 5 | RSpec.describe Cloudtasker::UniqueJob::Lock::NoOp do 6 | let(:worker) { TestWorker.new(job_args: [1, 2]) } 7 | let(:job) { Cloudtasker::UniqueJob::Job.new(worker) } 8 | let(:lock) { described_class.new(job) } 9 | 10 | it_behaves_like Cloudtasker::UniqueJob::Lock::BaseLock 11 | 12 | describe '#schedule' do 13 | it { expect { |b| lock.schedule(&b) }.to yield_control } 14 | end 15 | 16 | describe '#execute' do 17 | it { expect { |b| lock.execute(&b) }.to yield_control } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/cloudtasker/unique_job/lock/until_executed_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/unique_job/middleware' 4 | 5 | RSpec.describe Cloudtasker::UniqueJob::Lock::UntilExecuted do 6 | let(:worker) { TestWorker.new(job_args: [1, 2]) } 7 | let(:job) { Cloudtasker::UniqueJob::Job.new(worker) } 8 | let(:lock) { described_class.new(job) } 9 | 10 | it_behaves_like Cloudtasker::UniqueJob::Lock::BaseLock 11 | 12 | describe '#schedule' do 13 | context 'with lock available' do 14 | before { allow(job).to receive(:lock!) } 15 | it { expect { |b| lock.schedule(&b) }.to yield_control } 16 | end 17 | 18 | context 'with lock acquired by another job' do 19 | before { allow(job).to receive(:lock!).and_raise(Cloudtasker::UniqueJob::LockError) } 20 | before { allow(lock.conflict_instance).to receive(:on_schedule) } 21 | after { expect(lock.conflict_instance).to have_received(:on_schedule) { |&b| expect(b).to be_a(Proc) } } 22 | it { expect { |b| lock.schedule(&b) }.not_to yield_control } 23 | end 24 | end 25 | 26 | describe '#execute' do 27 | before { allow(job).to receive(:lock!) } 28 | before { allow(job).to receive(:unlock!) } 29 | after { expect(job).to have_received(:unlock!) } 30 | 31 | context 'with lock available' do 32 | it { expect { |b| lock.execute(&b) }.to yield_control } 33 | end 34 | 35 | context 'with lock acquired by another job' do 36 | before { allow(job).to receive(:lock!).and_raise(Cloudtasker::UniqueJob::LockError) } 37 | before { allow(lock.conflict_instance).to receive(:on_execute) } 38 | after { expect(lock.conflict_instance).to have_received(:on_execute) { |&b| expect(b).to be_a(Proc) } } 39 | it { expect { |b| lock.execute(&b) }.not_to yield_control } 40 | end 41 | 42 | context 'with runtime error' do 43 | let(:error) { ArgumentError } 44 | let(:block) { proc { raise(error) } } 45 | 46 | it { expect { lock.execute(&block) }.to raise_error(error) } 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/cloudtasker/unique_job/lock/until_executing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/unique_job/middleware' 4 | 5 | RSpec.describe Cloudtasker::UniqueJob::Lock::UntilExecuting do 6 | let(:worker) { TestWorker.new(job_args: [1, 2]) } 7 | let(:job) { Cloudtasker::UniqueJob::Job.new(worker) } 8 | let(:lock) { described_class.new(job) } 9 | 10 | it_behaves_like Cloudtasker::UniqueJob::Lock::BaseLock 11 | 12 | describe '#schedule' do 13 | context 'with lock available' do 14 | before { allow(job).to receive(:lock!) } 15 | it { expect { |b| lock.schedule(&b) }.to yield_control } 16 | end 17 | 18 | context 'with lock acquired by another job' do 19 | before { allow(job).to receive(:lock!).and_raise(Cloudtasker::UniqueJob::LockError) } 20 | before { allow(lock.conflict_instance).to receive(:on_schedule) } 21 | after { expect(lock.conflict_instance).to have_received(:on_schedule) { |&b| expect(b).to be_a(Proc) } } 22 | it { expect { |b| lock.schedule(&b) }.not_to yield_control } 23 | end 24 | end 25 | 26 | describe '#execute' do 27 | before { allow(job).to receive(:unlock!) } 28 | after { expect(job).to have_received(:unlock!) } 29 | it { expect { |b| lock.execute(&b) }.to yield_control } 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/cloudtasker/unique_job/lock/while_executing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/unique_job/middleware' 4 | 5 | RSpec.describe Cloudtasker::UniqueJob::Lock::WhileExecuting do 6 | let(:worker) { TestWorker.new(job_args: [1, 2]) } 7 | let(:job) { Cloudtasker::UniqueJob::Job.new(worker) } 8 | let(:lock) { described_class.new(job) } 9 | 10 | it_behaves_like Cloudtasker::UniqueJob::Lock::BaseLock 11 | 12 | describe '#schedule' do 13 | it { expect { |b| lock.schedule(&b) }.to yield_control } 14 | end 15 | 16 | describe '#execute' do 17 | before { allow(job).to receive(:lock!) } 18 | before { allow(job).to receive(:unlock!) } 19 | after { expect(job).to have_received(:unlock!) } 20 | 21 | context 'with lock available' do 22 | it { expect { |b| lock.execute(&b) }.to yield_control } 23 | end 24 | 25 | context 'with lock acquired by another job' do 26 | before { allow(job).to receive(:lock!).and_raise(Cloudtasker::UniqueJob::LockError) } 27 | before { allow(lock.conflict_instance).to receive(:on_execute) } 28 | after { expect(lock.conflict_instance).to have_received(:on_execute) { |&b| expect(b).to be_a(Proc) } } 29 | it { expect { |b| lock.execute(&b) }.not_to yield_control } 30 | end 31 | 32 | context 'with runtime error' do 33 | let(:error) { ArgumentError } 34 | let(:block) { proc { raise(error) } } 35 | 36 | it { expect { lock.execute(&block) }.to raise_error(error) } 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/cloudtasker/unique_job/middleware/client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/unique_job/middleware' 4 | 5 | RSpec.describe Cloudtasker::UniqueJob::Middleware::Client do 6 | let(:middleware) { described_class.new } 7 | 8 | describe '#call' do 9 | let(:lock_instance) { instance_double(Cloudtasker::UniqueJob::Lock::UntilExecuted) } 10 | let(:worker) { instance_double(Cloudtasker::Worker) } 11 | let(:job) { instance_double(Cloudtasker::UniqueJob::Job) } 12 | 13 | before { allow(Cloudtasker::UniqueJob::Job).to receive(:new).with(worker).and_return(job) } 14 | before { allow(job).to receive(:lock_instance).and_return(lock_instance) } 15 | before { allow(lock_instance).to receive(:schedule).and_yield } 16 | it { expect { |b| middleware.call(worker, &b) }.to yield_control } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/cloudtasker/unique_job/middleware/server_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/unique_job/middleware' 4 | 5 | RSpec.describe Cloudtasker::UniqueJob::Middleware::Server do 6 | let(:middleware) { described_class.new } 7 | 8 | describe '#call' do 9 | let(:lock_instance) { instance_double(Cloudtasker::UniqueJob::Lock::UntilExecuted) } 10 | let(:worker) { instance_double(Cloudtasker::Worker) } 11 | let(:job) { instance_double(Cloudtasker::UniqueJob::Job) } 12 | 13 | before { allow(Cloudtasker::UniqueJob::Job).to receive(:new).with(worker).and_return(job) } 14 | before { allow(job).to receive(:lock_instance).and_return(lock_instance) } 15 | before { allow(lock_instance).to receive(:execute).and_yield } 16 | it { expect { |b| middleware.call(worker, &b) }.to yield_control } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/cloudtasker/unique_job/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/unique_job/middleware' 4 | 5 | RSpec.describe Cloudtasker::UniqueJob::Middleware do 6 | describe '.configure' do 7 | before { described_class.configure } 8 | 9 | it { expect(Cloudtasker.config.client_middleware).to exist(Cloudtasker::UniqueJob::Middleware::Client) } 10 | it { expect(Cloudtasker.config.server_middleware).to exist(Cloudtasker::UniqueJob::Middleware::Server) } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/cloudtasker/worker_wrapper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/worker_wrapper' 4 | 5 | RSpec.describe Cloudtasker::WorkerWrapper do 6 | let(:worker_class) { 'TestWorker' } 7 | 8 | describe '.ancestors' do 9 | subject { described_class.ancestors } 10 | 11 | it { is_expected.to include(Cloudtasker::Worker) } 12 | end 13 | 14 | describe '.new' do 15 | subject { described_class.new(**worker_args.merge(worker_name: worker_class)) } 16 | 17 | let(:id) { SecureRandom.uuid } 18 | let(:args) { [1, 2] } 19 | let(:meta) { { foo: 'bar' } } 20 | let(:retries) { 3 } 21 | let(:queue) { 'critical' } 22 | let(:worker_args) { { job_queue: queue, job_args: args, job_id: id, job_meta: meta, job_retries: retries } } 23 | let(:expected_args) do 24 | { 25 | job_queue: queue, 26 | job_args: args, 27 | job_id: id, 28 | job_meta: eq(meta), 29 | job_retries: retries, 30 | worker_name: worker_class 31 | } 32 | end 33 | 34 | it { is_expected.to have_attributes(expected_args) } 35 | end 36 | 37 | describe '#job_class_name' do 38 | subject { described_class.new(worker_name: worker_class).job_class_name } 39 | 40 | it { is_expected.to eq(worker_class) } 41 | end 42 | 43 | describe '#new_instance' do 44 | subject(:new_instance) { worker.new_instance } 45 | 46 | let(:job_args) { [1, 2] } 47 | let(:job_meta) { { foo: 'bar' } } 48 | let(:job_queue) { 'critical' } 49 | let(:attrs) { { worker_name: worker_class, job_queue: job_queue, job_args: job_args, job_meta: job_meta } } 50 | let(:worker) { described_class.new(**attrs) } 51 | 52 | it { is_expected.to have_attributes(attrs.merge(job_meta: eq(job_meta))) } 53 | it { expect(new_instance.job_id).not_to eq(worker.job_id) } 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/cloudtasker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Cloudtasker do 4 | describe '::VERSION' do 5 | subject { Cloudtasker::VERSION } 6 | 7 | it { is_expected.not_to be_nil } 8 | end 9 | 10 | describe '.logger' do 11 | subject { described_class.logger } 12 | 13 | it { is_expected.to eq(described_class.config.logger) } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'boot' 4 | 5 | require 'rails' 6 | require 'action_controller/railtie' 7 | require 'active_job/railtie' 8 | 9 | Bundler.require(*Rails.groups) 10 | require 'cloudtasker' 11 | 12 | module Dummy 13 | class Application < Rails::Application 14 | # Initialize configuration defaults for originally generated Rails version. 15 | config.load_defaults Rails.version.to_f 16 | 17 | # Settings in config/environments/* take precedence over those specified here. 18 | # Application configuration can go into files in config/initializers 19 | # -- all .rb files in that directory are automatically loaded after loading 20 | # the framework and any gems in your application. 21 | config.eager_load = false 22 | 23 | # Use cloudtasker as the ActiveJob backend: 24 | config.active_job.queue_adapter = :cloudtasker 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.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 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative 'application' 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /spec/integration/active_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | if defined?(Rails) 6 | RSpec.describe 'ActiveJob integration' do 7 | let(:example_job_arguments) { [1, 'two', { three: 3 }] } 8 | 9 | let(:example_job_class) do 10 | Class.new(ActiveJob::Base) do 11 | def self.name 12 | 'ExampleJob' 13 | end 14 | end 15 | end 16 | 17 | let(:expected_cloud_task_http_request_data) do 18 | a_hash_including(body: expected_cloud_task_body) 19 | end 20 | 21 | let(:expected_cloud_task_create_argument) do 22 | a_hash_including(http_request: expected_cloud_task_http_request_data, queue: 'default') 23 | end 24 | 25 | describe 'Calling .perform_later on an ActiveJob class' do 26 | let(:expected_cloud_task_body) do 27 | include_json( 28 | 'worker' => 'ActiveJob::QueueAdapters::CloudtaskerAdapter::JobWrapper', 29 | 'job_args' => a_collection_including( 30 | a_hash_including( 31 | 'job_class' => example_job_class.name, 32 | 'arguments' => ActiveJob::Arguments.serialize(example_job_arguments) 33 | ) 34 | ) 35 | ) 36 | end 37 | 38 | context 'without any custom execution setup' do 39 | it 'enqueues the job to run as soon as possible' do 40 | expect(Cloudtasker::CloudTask).to receive(:create).with(expected_cloud_task_create_argument) 41 | example_job_class.perform_later(*example_job_arguments) 42 | end 43 | end 44 | 45 | context 'with a custom execution wait time' do 46 | let(:wait_time) { 1.week } 47 | let(:expected_calculated_datetime) { wait_time.from_now } 48 | let(:expected_cloud_task_create_argument) do 49 | a_hash_including( 50 | http_request: expected_cloud_task_http_request_data, 51 | queue: 'default', 52 | schedule_time: expected_calculated_datetime.to_i 53 | ) 54 | end 55 | 56 | around { |e| Timecop.freeze { e.run } } 57 | 58 | it 'enqueues the job to run at the calculated datetime' do 59 | expect(Cloudtasker::CloudTask).to receive(:create).with(expected_cloud_task_create_argument) 60 | example_job_class.set(wait: wait_time).perform_later(*example_job_arguments) 61 | end 62 | end 63 | 64 | context 'with a different queue to execute the job' do 65 | let(:example_queue_name) { 'another-queue' } 66 | let(:expected_cloud_task_create_argument) do 67 | a_hash_including( 68 | http_request: expected_cloud_task_http_request_data, 69 | queue: example_queue_name 70 | ) 71 | end 72 | 73 | it 'enqueues the job in the specified queue' do 74 | expect(Cloudtasker::CloudTask).to receive(:create).with(expected_cloud_task_create_argument) 75 | example_job_class.set(queue: example_queue_name).perform_later(*example_job_arguments) 76 | end 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/integration/batch_worker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'cloudtasker/batch/middleware' 5 | 6 | RSpec.describe 'Batch Worker' do 7 | # Activate middleware 8 | before { Cloudtasker::Batch::Middleware.configure } 9 | 10 | describe 'regular batch' do 11 | let(:worker_class) { TestBatchWorker } 12 | let(:expected_callback_counts) do 13 | { 14 | # 1 level 0 15 | 0 => 1, 16 | # 1 (level 0) * 2 (level 1) 17 | 1 => 2, 18 | # 1 (level 0) * 2 (level 1) * 2 (level 2) 19 | 2 => 4, 20 | # 1 (level 0) * 2 (level 1) * 2 (level 2) * 2 (level 3 / batch expansion) * 21 | 3 => 8 22 | } 23 | end 24 | 25 | before do 26 | # Perform jobs 27 | Cloudtasker::Testing.fake! do 28 | worker_class.perform_async 29 | 30 | # Process jobs iteratively until the batch is complete 31 | # Limit the number of iterations to 50 to prevent unexpected infinite loops 32 | 50.times do 33 | Cloudtasker::Worker.drain_all 34 | break if worker_class.jobs.to_a.empty? 35 | end 36 | end 37 | end 38 | 39 | it 'completes the batch' do 40 | expect(TestBatchWorker.jobs).to be_empty 41 | expect(TestBatchWorker.callback_counts).to eq(expected_callback_counts) 42 | end 43 | end 44 | 45 | describe 'dead batch' do 46 | let(:worker_class) { DeadBatchWorker } 47 | 48 | let(:expected_callback_counts) do 49 | { 50 | # (1 level 0 succeed) 51 | 0 => 1, 52 | # (2 level 1 success) 53 | 1 => 2 54 | # No level 2 - they all failed 55 | } 56 | end 57 | 58 | before do 59 | # Perform jobs 60 | Cloudtasker::Testing.fake! do 61 | worker_class.perform_async 62 | 63 | # Process jobs iteratively until the batch is complete 64 | # Limit the number of iterations to 50 to prevent unexpected infinite loops 65 | # The batch 66 | 50.times do 67 | Cloudtasker::Worker.drain_all 68 | break if worker_class.jobs.to_a.empty? 69 | end 70 | end 71 | end 72 | 73 | it 'completes the batch' do 74 | expect(worker_class.jobs.to_a).to be_empty 75 | expect(worker_class.callback_counts).to eq(expected_callback_counts) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/shared/active_job/instantiation_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable RSpec/ContextWording 4 | RSpec.shared_context 'of Cloudtasker ActiveJob instantiation' do 5 | let(:example_job_class) do 6 | Class.new(ActiveJob::Base) do 7 | def self.name 8 | 'ExampleJob' 9 | end 10 | end 11 | end 12 | 13 | let(:example_job_setup) { {} } 14 | let(:example_job_arguments) { [1, 'two', { three: 3 }] } 15 | let(:example_job) { example_job_class.new(*example_job_arguments) } 16 | 17 | let(:example_job_serialization) do 18 | example_job.serialize.except( 19 | 'job_id', 'priority', 'executions', 'queue_name', 'provider_job_id' 20 | ) 21 | end 22 | 23 | let(:example_job_wrapper_args) do 24 | { 25 | job_queue: example_job.queue_name, 26 | job_args: [example_job_serialization], 27 | job_id: example_job.job_id 28 | } 29 | end 30 | end 31 | # rubocop:enable RSpec/ContextWording 32 | -------------------------------------------------------------------------------- /spec/shared/cloudtasker/unique_job/conflict_strategy/base_strategy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/unique_job/middleware' 4 | 5 | RSpec.shared_examples Cloudtasker::UniqueJob::ConflictStrategy::BaseStrategy do 6 | describe '.new' do 7 | subject { described_class.new(job) } 8 | 9 | let(:worker) { TestWorker.new(job_args: [1, 2]) } 10 | let(:job) { Cloudtasker::UniqueJob::Job.new(worker) } 11 | 12 | it { is_expected.to have_attributes(job: job) } 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/shared/cloudtasker/unique_job/lock/base_lock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cloudtasker/unique_job/middleware' 4 | 5 | RSpec.shared_examples Cloudtasker::UniqueJob::Lock::BaseLock do 6 | let(:worker) { TestWorker.new(job_args: [1, 2]) } 7 | let(:job) { Cloudtasker::UniqueJob::Job.new(worker) } 8 | let(:lock) { described_class.new(job) } 9 | 10 | describe '.new' do 11 | subject { lock } 12 | 13 | it { is_expected.to have_attributes(job: job) } 14 | end 15 | 16 | describe '#default_conflict_strategy' do 17 | subject { lock.default_conflict_strategy } 18 | 19 | it { is_expected.to be < Cloudtasker::UniqueJob::ConflictStrategy::BaseStrategy } 20 | end 21 | 22 | describe '#options' do 23 | subject { lock.options } 24 | 25 | it { is_expected.to eq(job.options) } 26 | end 27 | 28 | describe '#conflict_instance' do 29 | subject { lock.conflict_instance } 30 | 31 | before { allow(lock).to receive(:options).and_return(job_opts) } 32 | 33 | context 'with no conflict strategy' do 34 | let(:job_opts) { {} } 35 | 36 | it { is_expected.to be_a(lock.default_conflict_strategy) } 37 | it { is_expected.to have_attributes(job: job) } 38 | end 39 | 40 | context 'with invalid conflict strategy' do 41 | let(:job_opts) { { on_conflict: 'foo' } } 42 | 43 | it { is_expected.to be_a(lock.default_conflict_strategy) } 44 | it { is_expected.to have_attributes(job: job) } 45 | end 46 | 47 | context 'with valid lock strategy' do 48 | let(:job_opts) { { on_conflict: 'reschedule' } } 49 | 50 | it { is_expected.to be_a(Cloudtasker::UniqueJob::ConflictStrategy::Reschedule) } 51 | it { is_expected.to have_attributes(job: job) } 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'timecop' 5 | require 'webmock/rspec' 6 | require 'semantic_logger' 7 | require 'rspec/json_expectations' 8 | 9 | # Configure Rails dummary app if Rails is in context 10 | if Gem.loaded_specs.key?('rails') 11 | ENV['RAILS_ENV'] ||= 'test' 12 | require File.expand_path('dummy/config/environment.rb', __dir__) 13 | require 'rspec/rails' 14 | end 15 | 16 | # Require main library (after Rails has done so) 17 | require 'cloudtasker' 18 | require 'cloudtasker/testing' 19 | require 'cloudtasker/unique_job' 20 | require 'cloudtasker/cron' 21 | require 'cloudtasker/batch' 22 | require 'cloudtasker/storable' 23 | 24 | # Require supporting files 25 | Dir['./spec/support/**/*.rb'].sort.each { |f| require f } 26 | Dir['./spec/shared/**/*.rb'].sort.each { |f| require f } 27 | 28 | RSpec.configure do |config| 29 | # Enable flags like --only-failures and --next-failure 30 | config.example_status_persistence_file_path = '.rspec_status' 31 | 32 | # Disable RSpec exposing methods globally on `Module` and `main` 33 | config.disable_monkey_patching! 34 | 35 | config.expect_with :rspec do |c| 36 | c.syntax = :expect 37 | end 38 | 39 | # Ensure cache is clean before each test 40 | config.before do 41 | Cloudtasker.config.client_middleware.clear 42 | Cloudtasker.config.server_middleware.clear 43 | 44 | # Flush redis keys 45 | Cloudtasker::RedisClient.new.clear 46 | end 47 | 48 | # NOTE: Retriable is configured in a conditional before 49 | # block to avoid requiring the gem in the spec helper. This 50 | # ensures that classes have defined the proper requires. 51 | config.before(:all) do 52 | if defined?(Retriable) 53 | # Do not wait between retries 54 | Retriable.configure do |c| 55 | c.multiplier = 1.0 56 | c.rand_factor = 0.0 57 | c.base_interval = 0 58 | end 59 | end 60 | end 61 | end 62 | 63 | # Configure for tests 64 | Cloudtasker.configure do |config| 65 | # GCP 66 | config.gcp_project_id = 'my-project-id' 67 | config.gcp_location_id = 'us-east2' 68 | config.gcp_queue_prefix = 'my-queue' 69 | 70 | # Processor 71 | config.secret = 'my$s3cr3t' 72 | config.processor_host = 'http://localhost' 73 | config.processor_path = '/mynamespace/run' 74 | 75 | # Redis 76 | config.redis = { url: "redis://#{ENV['REDIS_HOST'] || 'localhost'}:6379/15" } 77 | 78 | # Logger 79 | config.logger = Logger.new(nil) 80 | 81 | # Hooks 82 | config.on_error = ->(w, e) {} 83 | config.on_dead = ->(w, e) {} 84 | end 85 | -------------------------------------------------------------------------------- /spec/support/dead_batch_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DeadBatchWorker 4 | include Cloudtasker::Worker 5 | 6 | class << self 7 | attr_accessor :callback_counts, :callback_error_counts 8 | end 9 | 10 | def perform(level = 0) 11 | # Flag parent as incomplete 12 | if level == 0 13 | self.class.callback_counts = {} 14 | self.class.callback_error_counts = {} 15 | end 16 | 17 | # Fail jobs on their first few runs 18 | raise(StandardError, 'batch worker error') if job_retries < level 19 | 20 | # Enqueue child jobs 21 | 2.times { batch.add(self.class, level + 1) } if level < 2 22 | end 23 | 24 | def on_batch_complete 25 | level = job_args[0].to_i 26 | 27 | # Alway fail grand children callbacks 28 | raise(StandardError, 'batch callback error') if level == 2 29 | 30 | # Add callback result 31 | self.class.callback_counts[level] ||= 0 32 | self.class.callback_counts[level] += 1 33 | end 34 | 35 | def on_dead 36 | # Make it worse. Make the on_dead callback fail. 37 | raise(StandardError, 'on_dead error') 38 | end 39 | 40 | def on_child_dead(_child_worker) 41 | # Make it worse. Make the on_child_dead callback fail. 42 | raise(StandardError, 'on_child_dead error') 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/support/test_batch_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestBatchWorker 4 | include Cloudtasker::Worker 5 | 6 | class << self 7 | attr_accessor :callback_registry, :callback_error_counts 8 | end 9 | 10 | def perform(level = 0) 11 | # Initialize callback counters on top parent's run 12 | if level == 0 13 | self.class.callback_registry = {} 14 | self.class.callback_error_counts = {} 15 | end 16 | 17 | # Fail jobs on their first few runs 18 | raise(StandardError, 'batch worker error') if job_retries < level 19 | 20 | # Enqueue child jobs 21 | 2.times { batch.add(self.class, level + 1) } if level < 2 22 | 23 | # Expand parent batch. Limit batch expansion to level 2 only (last child level) 24 | # to avoid infinite loops. Expand batch before the job starts failing on on_batch_complete. 25 | 2.times { parent_batch.add(self.class, level + 1) } if level == 2 && job_retries < 3 26 | end 27 | 28 | # Hook invoked when a batch completes 29 | def on_batch_complete 30 | level = job_args[0].to_i 31 | 32 | # Fail callbacks on their first few runs 33 | self.class.callback_error_counts[job_id] ||= 0 34 | self.class.callback_error_counts[job_id] += 1 35 | raise(StandardError, 'batch callback error') if self.class.callback_error_counts[job_id] <= 2 36 | 37 | # Register batch as complete 38 | self.class.callback_registry[level] ||= Set.new 39 | self.class.callback_registry[level].add(job_id) 40 | end 41 | 42 | # Return the number of jobs completed per level 43 | def self.callback_counts 44 | callback_registry.transform_values(&:size) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/support/test_middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestMiddleware 4 | attr_accessor :arg, :called 5 | 6 | def initialize(arg = nil) 7 | @arg = arg 8 | end 9 | 10 | def call(worker, opts = {}) 11 | @called = true 12 | worker.middleware_called = true if worker.respond_to?(:middleware_called) 13 | worker.middleware_opts = opts if worker.respond_to?(:middleware_opts) 14 | yield 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/test_middleware2.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestMiddleware2 4 | def call(_worker) 5 | yield 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/test_middleware3.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestMiddleware3 4 | def call(_worker) 5 | yield 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/test_non_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestNonWorker 4 | attr_accessor :job_id, :job_meta 5 | 6 | def initialize(*_args) 7 | @job_meta = Cloudtasker::MetaStore.new 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/test_storable_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestStorableWorker 4 | include Cloudtasker::Worker 5 | include Cloudtasker::Storable::Worker 6 | 7 | attr_accessor :middleware_called, :middleware_opts, :has_run 8 | 9 | class << self 10 | attr_accessor :has_run 11 | 12 | def has_run? 13 | has_run 14 | end 15 | end 16 | 17 | cloudtasker_options foo: 'bar' 18 | 19 | def perform(arg1, arg2) 20 | self.class.has_run = true 21 | arg1 + arg2 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/support/test_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestWorker 4 | include Cloudtasker::Worker 5 | 6 | attr_accessor :middleware_called, :middleware_opts, :has_run 7 | 8 | class << self 9 | attr_accessor :has_run 10 | 11 | def has_run? 12 | has_run 13 | end 14 | end 15 | 16 | cloudtasker_options foo: 'bar' 17 | 18 | def perform(arg1, arg2) 19 | self.class.has_run = true 20 | arg1 + arg2 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/support/test_worker2.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestWorker2 4 | include Cloudtasker::Worker 5 | 6 | attr_accessor :middleware_called, :middleware_opts 7 | 8 | cloudtasker_options foo: 'bar' 9 | 10 | def perform(arg1, arg2) 11 | arg1 + arg2 12 | end 13 | end 14 | --------------------------------------------------------------------------------