Upgrade to Pro — share decks privately, control downloads, hide ads and more …

ACIDic Jobs: A Layman's Guide to Job Bliss

Stephen
November 07, 2021

ACIDic Jobs: A Layman's Guide to Job Bliss

Background jobs have become an essential component of any Ruby infrastructure, and, as the Sidekiq Best Practices remind us, it is essential that jobs be "idempotent and transactional." But how do we make our jobs idempotent and transactional? In this talk, we will explore various techniques to make our jobs robust and ACIDic.

Stephen

November 07, 2021
Tweet

More Decks by Stephen

Other Decks in Programming

Transcript

  1. • a Ruby class object, • representing a state mutation

    action, • that takes as input a representation of initial state • and produces side-effects representing a next state. A job is
  2. ActionDoer.call *arguments # service object ActionJob.perform_now *arguments # active job

    ActionWorker.new.perform *arguments # sidekiq worker ActionOperation.run *arguments # operation class
  3. • Atomicity: everything succeeds or everything fails • Consistency: the

    data always ends up in a valid state, as defined by your schema • Isolation: concurrent transactions won't conflict with each other • Durability: once committed always committed, even with system failures The ACIDic Guarantees
  4. • Functional Idempotency: the function always returns the same result,

    even if called multiple times f(f(x)) == f(x) • Practical Idempotency: the side-effect(s) will happen once and only once, no matter how many times the job is performed Job.perform == Job.perform & Job.perform The Idempotent Guarantee
  5. • Use a transaction to guarantee atomic execution • Use

    locks to prevent concurrent data access • Use idempotency and retries to ensure eventual completion • Ensure enqueuing other jobs is co-transactional • Split complex operations into steps ACIDic Job Principles — Nathan Griffith
  6. def perform(from_account, to_account, amount) run = JobRun.find_or_create_by!( job_class: self.class, job_id:

    job_id, job_args: [from_account, to_account, amount]) run.with_lock do from_account.lock! to_account.lock! from_account.update!(balance: from_account.balance - amount) to_account.update!(balance: to_account.balance - amount) end end
  7. — Mike Perham “Just remember that Sidekiq will execute your

    job at least once, not exactly once.”
  8. • Use a database record to make job runs transactional

    • Use a database lock to mitigate concurrency issues ACIDic Jobs Level 0 Recap
  9. john_account.balance # initial state # => 100_00 TransferBalanceJob.perform_later(john_account, jane_account, 10_00)

    TransferBalanceJob.perform_later(john_account, jane_account, 10_00) john_account.balance # resulting state # => 80_00 or 90_00 ?
  10. • each job run uses a generic unique entity representing

    this job run • each job run uses a generic unique entity representing this job execution (based on args) Forms of Job Uniqueness
  11. def perform(from_account, to_account, amount) run = JobRun.find_or_create_by!(job_class: self.class, job_id: job_id)

    run.with_lock do return if run.completed? from_account.update!(balance: from_account.balance - amount) to_account.update!(balance: to_account.balance - amount) run.update!(completed_at: Time.current) end end Unique Job by Job Run
  12. john_account.balance # initial state # => 100_00 TransferBalanceJob.perform_later(john_account, jane_account, 10_00)

    TransferBalanceJob.perform_later(john_account, jane_account, 10_00) john_account.balance # resulting state # => 80_00 ?
  13. def perform(from_account, to_account, amount) run = JobRun.find_or_create_by!(job_class: self.class, job_args: [from_account,

    to_account, amount]) run.with_lock do return if run.completed? from_account.update!(balance: from_account.balance - amount) to_account.update!(balance: to_account.balance - amount) run.update!(completed_at: Time.current) end end Unique Job by Execution Args
  14. john_account.balance # initial state # => 100_00 TransferBalanceJob.perform_later(john_account, jane_account, 10_00)

    TransferBalanceJob.perform_later(john_account, jane_account, 10_00) john_account.balance # resulting state # => 90_00 ?
  15. Unique Job flexibly and generically class TransferBalanceJob < ApplicationJob prepend

    UniqueByJobRun uniquely_identified_by_job_id # uniquely_identified_by_job_args def perform(from_account, to_account, amount) from_account.lock! to_account.lock! from_account.update!(balance: from_account.balance - amount) to_account.update!(balance: to_account.balance - amount) end end
  16. john_account.balance # initial state # => 100_00 TransferBalanceJob.perform_later(john_account, jane_account, 10_00)

    TransferBalanceJob.perform_later(john_account, jane_account, 10_00) john_account.balance # resulting state # => 80_00
  17. Unique Job flexibly and generically class TransferBalanceJob < ApplicationJob prepend

    UniqueByJobRun # uniquely_identified_by_job_id uniquely_identified_by_job_args def perform(from_account, to_account, amount) from_account.lock! to_account.lock! from_account.update!(balance: from_account.balance - amount) to_account.update!(balance: to_account.balance - amount) end end
  18. john_account.balance # initial state # => 100_00 TransferBalanceJob.perform_later(john_account, jane_account, 10_00)

    TransferBalanceJob.perform_later(john_account, jane_account, 10_00) john_account.balance # resulting state # => 90_00
  19. • Use a database record to make job runs idempotent

    • custom or generic • by job ID or by job args ACIDic Jobs Level 1 Recap
  20. uniquely_identified_by_job_id def perform(from_account, to_account, amount) from_account.lock! to_account.lock! from_account.update!(balance: from_account.balance -

    amount) to_account.update!(balance: to_account.balance - amount) TransferMailer.with(account: from_account).outbound.deliver_later TransferMailer.with(account: to_account).inbound.deliver_later end
  21. Failure Condition 1 job queue from.save! job process to.save! TransferMailer.deliver_later

    transaction commits job starts job fails job queued by web process and dequeued by background worker
  22. Failure Condition 2 job queue from.save! job process to.save! TransferMailer.deliver_later

    job starts job fails rollback job queued by web process and dequeued by background worker
  23. Solution Option 1 • A database-backed job queue • delayed_job

    • que • good_job But... no Sidekiq and increased db load
  24. uniquely_identified_by_job_id def perform(from_account, to_account, amount) from_account.lock! to_account.lock! from_account.update!(balance: from_account.balance -

    amount) to_account.update!(balance: to_account.balance - amount) TransferMailer.with(account: from_account).outbound.deliver_acidic TransferMailer.with(account: to_account).inbound.deliver_acidic end
  25. def deliver_acidic(options = {}) job = delivery_job_class attributes = {

    adapter: "activejob", job_name: job.name } job_args = if job <= ActionMailer::Parameterized::MailDeliveryJob [@mailer_class.name, @action, "deliver_now", {params: @params, args: @args}] else [@mailer_class.name, @action, "deliver_now", @params, *@args] end attributes[:job_args] = job.new(job_args).serialize StagedJob.create!(attributes) end
  26. class StagedJob < ActiveRecord::Base after_create_commit :enqueue_job def enqueue_job case adapter

    when "activejob" ActiveJob::Base.deserialize(job_args).enqueue when "sidekiq" Sidekiq::Client.push("class" => job_name, "args" => job_args) end end end
  27. • Use transactionally-staged jobs to keep job enqueuing co-transactional with

    standard database operations • while keeping Sidekiq, and • not requiring an independent de-staging process ACIDic Jobs Level 2 Recap
  28. uniquely_identified_by_job_args def perform(order) # ... @job.with_lock do ShopifyAPI::Fulfillment.create!({ ... })

    @job.update!(recovery_point: :send_email) end if @job.recovery_point == :fulfill_order # ... end
  29. include WithAcidity def perform(order) with_acidity do step :process_order step :fulfill_order

    step :send_emails end end def process_order; # ... end def fulfill_order; # ... end def send_emails; # ... end
  30. module WithAcidity def perform_step(current_step_method, next_step_method) return unless @job.recovery_point == current_step_method

    @job.with_lock do method(current_step_method).call @job.update!(recovery_point: next_step_method) end end end
  31. • Use a recovery key to keep track of which

    steps in a workflow job have been successfully completed • make each step ACIDic, and • keep the entire workflow job ACIDic ACIDic Jobs Level 3 Recap
  32. — Mike Perham “Batches are Sidekiq Pro's [tool to] create

    a set of jobs to execute in parallel and then execute a callback when all the jobs are finished.”
  33. • Use Sidekiq Batches to allow parallel, separately queued jobs

    to be used within a multi-step workflow • keep steps serially dependent • while allowing for parallelizatino ACIDic Jobs Level 4 Recap