acidic_job 0.2.2 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: efbbe68a5d9822e76723f8724743f58b6566706937fa1e939f1fba67ccb8fece
4
- data.tar.gz: 48a2cc89d6c9cc0bc13dc95abcce456b5be78bb15ae583e68fd9c72b86ed466a
3
+ metadata.gz: 943a9bc87b07b1e4a2ec7d0a83ffa8a23917124386cfc601780f95d36c62d23a
4
+ data.tar.gz: 2f534e9ebd0d533ae4b7572fb1da1451df9a1a2ed68fc231bc4f4de323de9e84
5
5
  SHA512:
6
- metadata.gz: 5f095c6a40548b07d4216e8939f52b533778071e4eef2df4090e35f681766a391805a394966d39a3e809e6d1c48094bbb39e768e986401a847882fe8b61ce5ef
7
- data.tar.gz: e05b1a3f238492038c40e1a3d51110c52f7062b0143b12d78ac3aace525ea225a13db139f951bc6cf46cc04b14429beaa55c99b0f772c806014b746b88e14897
6
+ metadata.gz: 59f4ecc2e773d72bbef2d386a223e9859ee6f14c657e706c593e7a3f4c994c86aae885ef0b2993cbb916a1380daf3231882255685b6ffa360ffc4bf5dee591d8
7
+ data.tar.gz: 2a8d540fabb8528a9c305b9d7cf7fe12588c14760557f08f4c380dfb6b45fdafec1232767234809101d6aa0e8f44f8ac895b120664dec8e533d180900ad06c6f
data/.rubocop.yml CHANGED
@@ -12,3 +12,6 @@ Style/StringLiteralsInInterpolation:
12
12
 
13
13
  Layout/LineLength:
14
14
  Max: 120
15
+
16
+ Style/Documentation:
17
+ Enabled: false
data/Gemfile CHANGED
@@ -24,3 +24,7 @@ gem "sqlite3"
24
24
  gem "database_cleaner"
25
25
 
26
26
  gem "simplecov"
27
+
28
+ gem "pry"
29
+
30
+ gem "sidekiq"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- acidic_job (0.2.2)
4
+ acidic_job (0.5.0)
5
5
  activerecord (>= 4.0.0)
6
6
  activesupport
7
7
 
@@ -37,7 +37,9 @@ GEM
37
37
  zeitwerk (~> 2.3)
38
38
  ast (2.4.2)
39
39
  builder (3.2.4)
40
+ coderay (1.1.3)
40
41
  concurrent-ruby (1.1.9)
42
+ connection_pool (2.2.5)
41
43
  crass (1.0.6)
42
44
  database_cleaner (2.0.1)
43
45
  database_cleaner-active_record (~> 2.0.0)
@@ -65,6 +67,9 @@ GEM
65
67
  parallel (1.20.1)
66
68
  parser (3.0.1.1)
67
69
  ast (~> 2.4.1)
70
+ pry (0.14.1)
71
+ coderay (~> 1.1)
72
+ method_source (~> 1.0)
68
73
  racc (1.5.2)
69
74
  rack (2.2.3)
70
75
  rack-test (1.1.0)
@@ -82,6 +87,7 @@ GEM
82
87
  thor (~> 1.0)
83
88
  rainbow (3.0.0)
84
89
  rake (13.0.4)
90
+ redis (4.4.0)
85
91
  regexp_parser (2.1.1)
86
92
  rexml (3.2.5)
87
93
  rubocop (1.18.3)
@@ -100,6 +106,10 @@ GEM
100
106
  rubocop-rake (0.6.0)
101
107
  rubocop (~> 1.0)
102
108
  ruby-progressbar (1.11.0)
109
+ sidekiq (6.2.2)
110
+ connection_pool (>= 2.2.2)
111
+ rack (~> 2.0)
112
+ redis (>= 4.2.0)
103
113
  simplecov (0.21.2)
104
114
  docile (~> 1.1)
105
115
  simplecov-html (~> 0.11)
@@ -123,11 +133,13 @@ DEPENDENCIES
123
133
  activerecord (~> 6.1.3.2)
124
134
  database_cleaner
125
135
  minitest (~> 5.0)
136
+ pry
126
137
  railties (>= 4.0)
127
138
  rake (~> 13.0)
128
139
  rubocop (~> 1.7)
129
140
  rubocop-minitest
130
141
  rubocop-rake
142
+ sidekiq
131
143
  simplecov
132
144
  sqlite3
133
145
 
data/acidic_job.gemspec CHANGED
@@ -3,15 +3,15 @@
3
3
  require_relative "lib/acidic_job/version"
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = "acidic_job"
7
- spec.version = AcidicJob::VERSION
8
- spec.authors = ["fractaledmind"]
9
- spec.email = ["stephen.margheim@gmail.com"]
6
+ spec.name = "acidic_job"
7
+ spec.version = AcidicJob::VERSION
8
+ spec.authors = ["fractaledmind"]
9
+ spec.email = ["stephen.margheim@gmail.com"]
10
10
 
11
- spec.summary = "Idempotent operations for Rails apps, built on top of ActiveJob."
12
- spec.description = "Idempotent operations for Rails apps, built on top of ActiveJob."
13
- spec.homepage = "https://github.com/fractaledmind/acidic_job"
14
- spec.license = "MIT"
11
+ spec.summary = "Idempotent operations for Rails apps, built on top of ActiveJob."
12
+ spec.description = "Idempotent operations for Rails apps, built on top of ActiveJob."
13
+ spec.homepage = "https://github.com/fractaledmind/acidic_job"
14
+ spec.license = "MIT"
15
15
  spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
16
16
 
17
17
  spec.metadata["homepage_uri"] = spec.homepage
@@ -23,12 +23,12 @@ Gem::Specification.new do |spec|
23
23
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
24
24
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
25
25
  end
26
- spec.bindir = "exe"
27
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
26
+ spec.bindir = "exe"
27
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
28
28
  spec.require_paths = ["lib"]
29
29
 
30
- spec.add_dependency "activesupport"
31
30
  spec.add_dependency "activerecord", ">= 4.0.0"
31
+ spec.add_dependency "activesupport"
32
32
  spec.add_development_dependency "railties", ">= 4.0"
33
33
 
34
34
  # For more information and examples about making a new gem, checkout our
data/blog_post.md ADDED
@@ -0,0 +1,28 @@
1
+ # ACIDic Operations in Rails
2
+
3
+ At the conceptual heart of basically any software are "operations"—the discrete actions the software performs. At the horizon of basically any software is the goal to make that sofware _robust_. Typically, one makes a software system robust by making each of its operations robust. Moreover, typically, robustness in software is considered as the software being "ACIDic"—atomic, consistent, isolated, durable.
4
+
5
+ In a loosely collected series of articles, Brandur Leach lays out the core techniques and principles required to make an HTTP API properly ACIDic:
6
+
7
+ 1. https://brandur.org/acid
8
+ 2. https://brandur.org/http-transactions
9
+ 3. https://brandur.org/job-drain
10
+ 4. https://brandur.org/idempotency-keys
11
+
12
+ With these techniques and principles in mind, our challenge is bring them into the world of a standard Rails application. This will require us to conceptually map the concepts of an HTTP request, an API server action, and an HTTP response into the world of a running Rails process.
13
+
14
+ We can begin to make this mapping by observing that an API server action is a specific instantiation of the general concept of an "operation". Like all operations, it has a "trigger" (the HTTP request) and a "response" (the HTTP response). So, what we need is a foundation upon which to build our Rails "operations".
15
+
16
+ In order to help us find that tool, let us consider the necessary characteristics we need. We need something that we can easily trigger from other Ruby code throughout our Rails application (controller actions, model methods, model callbacks, etc.). It should also be able to be run both synchronously (blocking execution and then returning its response to the caller) and asychronously (non-blocking and the caller doesn't know its response). It should then also be able to retry a specific operation (in much the way that an API consumer can "retry an operation" by hitting the same endpoint with the same request).
17
+
18
+ As we lay out these characteristics, I imagine your mind is going where mine went—`ActiveJob` gives us a solid foundation upon which we can build "ACIDic" operations.
19
+
20
+ So, our challenge to build tooling which will allow us to make "operational" jobs _robust_.
21
+
22
+ What we need primarily is to be able to make our jobs *idempotent*, and one of the simplest yet still most powerful tools for making an operation idempotent is the idempotency key. As laid out in the article linked above, an idempotency key is a record that we store in our database to uniquely identify a particular execution of an operation and a related "recovery point" for where we are in the process of that operation.
23
+
24
+
25
+
26
+
27
+
28
+
@@ -0,0 +1,15 @@
1
+ module AcidicJob
2
+ class Error < StandardError; end
3
+
4
+ class MismatchedIdempotencyKeyAndJobArguments < Error; end
5
+
6
+ class LockedIdempotencyKey < Error; end
7
+
8
+ class UnknownRecoveryPoint < Error; end
9
+
10
+ class UnknownAtomicPhaseType < Error; end
11
+
12
+ class SerializedTransactionConflict < Error; end
13
+
14
+ class UnknownJobAdapter < Error; end
15
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module AcidicJob
6
+ class Key < ActiveRecord::Base
7
+ RECOVERY_POINT_FINISHED = "FINISHED"
8
+
9
+ self.table_name = "acidic_job_keys"
10
+
11
+ serialize :error_object
12
+ serialize :job_args
13
+
14
+ validates :idempotency_key, presence: true, uniqueness: { scope: %i[job_name job_args] }
15
+ validates :job_name, presence: true
16
+ validates :last_run_at, presence: true
17
+ validates :recovery_point, presence: true
18
+
19
+ def finished?
20
+ recovery_point == RECOVERY_POINT_FINISHED
21
+ end
22
+
23
+ def succeeded?
24
+ finished? && !failed?
25
+ end
26
+
27
+ def failed?
28
+ error_object.present?
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module AcidicJob
6
+ module PerformTransactionallyExtension
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ def perform_transactionally(*args)
11
+ attributes = if self < ActiveJob::Base
12
+ {
13
+ adapter: "activejob",
14
+ job_name: self.name,
15
+ job_args: job_or_instantiate(*args).serialize
16
+ }
17
+ elsif self.include? Sidekiq::Worker
18
+ {
19
+ adapter: "sidekiq",
20
+ job_name: self.name,
21
+ job_args: args
22
+ }
23
+ else
24
+ raise UnknownJobAdapter
25
+ end
26
+
27
+ AcidicJob::Staged.create!(attributes)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ module PerformWrapper
5
+ def perform(*args, **kwargs)
6
+ # store arguments passed into `perform` so that we can later persist
7
+ # them to `AcidicJob::Key#job_args` for both ActiveJob and Sidekiq::Worker
8
+ @arguments_for_perform = if args.any? && kwargs.any?
9
+ args + [kwargs]
10
+ elsif args.any? && kwargs.none?
11
+ args
12
+ elsif args.none? && kwargs.any?
13
+ [kwargs]
14
+ else
15
+ []
16
+ end
17
+
18
+ super
19
+ end
20
+ end
21
+ end
@@ -11,6 +11,7 @@ module AcidicJob
11
11
  end
12
12
 
13
13
  def call(key:)
14
+ # Skip AR callbacks as there are none on the model
14
15
  key.update_column(:recovery_point, name)
15
16
  end
16
17
  end
@@ -6,7 +6,8 @@
6
6
  module AcidicJob
7
7
  class Response
8
8
  def call(key:)
9
- key.update!(
9
+ # Skip AR callbacks as there are none on the model
10
+ key.update_columns(
10
11
  locked_at: nil,
11
12
  recovery_point: Key::RECOVERY_POINT_FINISHED
12
13
  )
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module AcidicJob
6
+ class Staged < ActiveRecord::Base
7
+ self.table_name = "staged_acidic_jobs"
8
+
9
+ validates :adapter, presence: true
10
+ validates :job_name, presence: true
11
+ validates :job_args, presence: true
12
+
13
+ serialize :job_args
14
+
15
+ after_create_commit :enqueue_job
16
+
17
+ def enqueue_job
18
+ if adapter == "activejob"
19
+ job = ActiveJob::Base.deserialize(job_args)
20
+ job.enqueue
21
+ elsif adapter == "sidekiq"
22
+ Sidekiq::Client.push("class" => job_name, "args" => job_args)
23
+ else
24
+ raise UnknownJobAdapter.new(adapter: adapter)
25
+ end
26
+
27
+ # TODO: ensure successful enqueuing before deletion
28
+ delete
29
+ end
30
+ end
31
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AcidicJob
4
- VERSION = "0.2.2"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/acidic_job.rb CHANGED
@@ -1,60 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "acidic_job/version"
4
+ require_relative "acidic_job/errors"
4
5
  require_relative "acidic_job/no_op"
5
6
  require_relative "acidic_job/recovery_point"
6
7
  require_relative "acidic_job/response"
8
+ require_relative "acidic_job/key"
9
+ require_relative "acidic_job/staged"
10
+ require_relative "acidic_job/perform_wrapper"
11
+ require_relative "acidic_job/perform_transactionally_extension"
7
12
  require "active_support/concern"
8
13
 
9
- # rubocop:disable Metrics/ModuleLength, Style/Documentation, Metrics/AbcSize, Metrics/MethodLength
14
+ # rubocop:disable Metrics/ModuleLength, Metrics/AbcSize, Metrics/MethodLength
10
15
  module AcidicJob
11
- class MismatchedIdempotencyKeyAndJobArguments < StandardError; end
12
-
13
- class LockedIdempotencyKey < StandardError; end
14
-
15
- class UnknownRecoveryPoint < StandardError; end
16
-
17
- class UnknownAtomicPhaseType < StandardError; end
18
-
19
- class SerializedTransactionConflict < StandardError; end
20
-
21
- class Key < ActiveRecord::Base
22
- RECOVERY_POINT_FINISHED = "FINISHED"
23
-
24
- self.table_name = "acidic_job_keys"
25
-
26
- serialize :error_object
27
-
28
- validates :job_name, presence: true
29
- validates :job_args, presence: true
30
- validates :idempotency_key, presence: true
31
- validates :last_run_at, presence: true
32
- validates :recovery_point, presence: true
33
-
34
- def finished?
35
- recovery_point == RECOVERY_POINT_FINISHED
36
- end
37
-
38
- def succeeded?
39
- finished? && !failed?
40
- end
41
-
42
- def failed?
43
- error_object.present?
44
- end
45
- end
46
-
47
16
  extend ActiveSupport::Concern
48
17
 
49
18
  included do
50
19
  attr_reader :key
20
+ attr_accessor :arguments_for_perform
51
21
 
52
- # discard_on MismatchedIdempotencyKeyAndJobArguments
53
- # discard_on UnknownRecoveryPoint
54
- # discard_on UnknownAtomicPhaseType
55
- # discard_on MissingRequiredAttribute
56
- # retry_on LockedIdempotencyKey
57
- # retry_on ActiveRecord::SerializationFailure
22
+ # Extend ActiveJob with `perform_transactionally` class method
23
+ include PerformTransactionallyExtension
24
+
25
+ # Ensure our `perform` method always runs first to gather parameters
26
+ prepend PerformWrapper
58
27
  end
59
28
 
60
29
  # Number of seconds passed which we consider a held idempotency key lock to be
@@ -63,8 +32,8 @@ module AcidicJob
63
32
  # this might not happen 100% of the time, so this is a hedge against it.
64
33
  IDEMPOTENCY_KEY_LOCK_TIMEOUT = 90
65
34
 
66
- # &block
67
- def idempotently(with:) # &block
35
+ # takes a block
36
+ def idempotently(with:)
68
37
  # set accessors for each argument passed in to ensure they are available
69
38
  # to the step methods the job will have written
70
39
  define_accessors_for_passed_arguments(with)
@@ -84,7 +53,7 @@ module AcidicJob
84
53
  # close proximity, one of the two will be aborted by Postgres because we're
85
54
  # using a transaction with SERIALIZABLE isolation level. It may not look
86
55
  # it, but this code is safe from races.
87
- ensure_idempotency_key_record(job_id, with, defined_steps.first)
56
+ ensure_idempotency_key_record(idempotency_key_value, defined_steps.first)
88
57
 
89
58
  # if the key record is already marked as finished, immediately return its result
90
59
  return @key.succeeded? if @key.finished?
@@ -114,10 +83,16 @@ module AcidicJob
114
83
  @_steps
115
84
  end
116
85
 
86
+ def safely_finish_acidic_job
87
+ # Short circuits execution by sending execution right to 'finished'.
88
+ # So, ends the job "successfully"
89
+ AcidicJob::Response.new
90
+ end
91
+
117
92
  private
118
93
 
119
94
  def atomic_phase(key, proc = nil, &block)
120
- error = false
95
+ rescued_error = false
121
96
  phase_callable = (proc || block)
122
97
 
123
98
  begin
@@ -127,13 +102,15 @@ module AcidicJob
127
102
  phase_result.call(key: key)
128
103
  end
129
104
  rescue StandardError => e
130
- error = e
105
+ rescued_error = e
131
106
  raise e
132
107
  ensure
108
+ return unless rescued_error
109
+
133
110
  # If we're leaving under an error condition, try to unlock the idempotency
134
- # key right away so that another request can try again.
111
+ # key right away so that another request can try again.3
135
112
  begin
136
- key.update_columns(locked_at: nil, error_object: error) if error.present?
113
+ key.update_columns(locked_at: nil, error_object: rescued_error)
137
114
  rescue StandardError => e
138
115
  # We're already inside an error condition, so swallow any additional
139
116
  # errors from here and just send them to logs.
@@ -142,7 +119,7 @@ module AcidicJob
142
119
  end
143
120
  end
144
121
 
145
- def ensure_idempotency_key_record(key_val, job_args, first_step)
122
+ def ensure_idempotency_key_record(key_val, first_step)
146
123
  isolation_level = case ActiveRecord::Base.connection.adapter_name.downcase.to_sym
147
124
  when :sqlite
148
125
  :read_uncommitted
@@ -156,11 +133,15 @@ module AcidicJob
156
133
  if @key
157
134
  # Programs enqueuing multiple jobs with different parameters but the
158
135
  # same idempotency key is a bug.
159
- raise MismatchedIdempotencyKeyAndJobArguments if @key.job_args != job_args.deep_stringify_keys.inspect
136
+ if @key.job_args != @arguments_for_perform
137
+ raise MismatchedIdempotencyKeyAndJobArguments
138
+ end
160
139
 
161
140
  # Only acquire a lock if the key is unlocked or its lock has expired
162
141
  # because the original job was long enough ago.
163
- raise LockedIdempotencyKey if @key.locked_at && @key.locked_at > Time.current - IDEMPOTENCY_KEY_LOCK_TIMEOUT
142
+ if @key.locked_at && @key.locked_at > Time.current - IDEMPOTENCY_KEY_LOCK_TIMEOUT
143
+ raise LockedIdempotencyKey
144
+ end
164
145
 
165
146
  # Lock the key and update latest run unless the job is already finished.
166
147
  @key.update!(last_run_at: Time.current, locked_at: Time.current) unless @key.finished?
@@ -171,7 +152,7 @@ module AcidicJob
171
152
  last_run_at: Time.current,
172
153
  recovery_point: first_step,
173
154
  job_name: self.class.name,
174
- job_args: job_args.inspect
155
+ job_args: @arguments_for_perform
175
156
  )
176
157
  end
177
158
  end
@@ -205,5 +186,13 @@ module AcidicJob
205
186
  end
206
187
  end
207
188
  end
189
+
190
+ def idempotency_key_value
191
+ return job_id if defined?(job_id) && !job_id.nil?
192
+ return jid if defined?(jid) && !jid.nil?
193
+
194
+ require 'securerandom'
195
+ SecureRandom.hex
196
+ end
208
197
  end
209
- # rubocop:enable Metrics/ModuleLength, Style/Documentation, Metrics/AbcSize, Metrics/MethodLength
198
+ # rubocop:enable Metrics/ModuleLength, Metrics/AbcSize, Metrics/MethodLength
@@ -1,11 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "rails/generators"
2
4
  require "rails/generators/active_record"
3
5
 
4
- # This generator adds a migration for the {FriendlyId::History
5
- # FriendlyId::History} addon.
6
6
  class AcidicJobGenerator < ActiveRecord::Generators::Base
7
- # ActiveRecord::Generators::Base inherits from Rails::Generators::NamedBase which requires a NAME parameter for the
8
- # new table name. Our generator always uses 'acidic_job_keys', so we just set a random name here.
7
+ # ActiveRecord::Generators::Base inherits from Rails::Generators::NamedBase
8
+ # which requires a NAME parameter for the new table name.
9
+ # Our generator always uses "acidic_job_keys", so we just set a random name here.
9
10
  argument :name, type: :string, default: "random_name"
10
11
 
11
12
  source_root File.expand_path("templates", __dir__)
@@ -21,17 +22,23 @@ class AcidicJobGenerator < ActiveRecord::Generators::Base
21
22
  end
22
23
 
23
24
  # Copies the migration template to db/migrate.
24
- def copy_files
25
- migration_template "migration.rb",
25
+ def copy_acidic_job_keys_migration_files
26
+ migration_template "create_acidic_job_keys_migration.rb.erb",
26
27
  "db/migrate/create_acidic_job_keys.rb"
27
28
  end
28
29
 
30
+ def copy_staged_acidic_jobs_migration_files
31
+ migration_template "create_staged_acidic_jobs_migration.rb.erb",
32
+ "db/migrate/create_staged_acidic_jobs.rb"
33
+ end
34
+
29
35
  protected
30
- def migration_class
31
- if ActiveRecord::VERSION::MAJOR >= 5
32
- ActiveRecord::Migration["#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"]
33
- else
34
- ActiveRecord::Migration
35
- end
36
+
37
+ def migration_class
38
+ if ActiveRecord::VERSION::MAJOR >= 5
39
+ ActiveRecord::Migration["#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"]
40
+ else
41
+ ActiveRecord::Migration
36
42
  end
43
+ end
37
44
  end
@@ -3,15 +3,16 @@ class CreateAcidicJobKeys < <%= migration_class %>
3
3
  create_table :acidic_job_keys do |t|
4
4
  t.string :idempotency_key, null: false
5
5
  t.string :job_name, null: false
6
- t.text :job_args, null: false
6
+ t.text :job_args, null: true
7
7
  t.datetime :last_run_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
8
8
  t.datetime :locked_at, null: true
9
9
  t.string :recovery_point, null: false
10
10
  t.text :error_object
11
11
  t.timestamps
12
12
 
13
- t.index %i[idempotency_key job_name job_args], unique: true,
14
- name: "idx_acidic_job_keys_on_idempotency_key_n_job_name_n_job_args"
13
+ t.index %i[idempotency_key job_name job_args],
14
+ unique: true,
15
+ name: "idx_acidic_job_keys_on_idempotency_key_n_job_name_n_job_args"
15
16
  end
16
17
  end
17
18
  end
@@ -0,0 +1,9 @@
1
+ class CreateStagedAcidicJobs < <%= migration_class %>
2
+ def change
3
+ create_table :staged_acidic_jobs do |t|
4
+ t.string :adapter, null: false
5
+ t.string :job_name, null: false
6
+ t.text :job_args, null: true
7
+ end
8
+ end
9
+ end
data/slides.md ADDED
@@ -0,0 +1,65 @@
1
+ # ACIDic Jobs
2
+
3
+ ## A bit about me
4
+
5
+ - programming in Ruby for 6 years
6
+ - working for test IO / EPAM
7
+ - consulting for RCRDSHP
8
+ - building Smokestack QA on the side
9
+
10
+ ## Jobs are essential
11
+
12
+ - job / operation / work
13
+ - in every company, with every app, jobs are essential. Why?
14
+ - jobs are what your app *does*, expressed as a distinct unit
15
+ - jobs can be called from anywhere, run sync or async, and have retry mechanisms built-in
16
+
17
+ ## Jobs are internal API endpoints
18
+
19
+ - Like API endpoints, both are discrete units of work
20
+ - Like API endpoints, we should expect failure
21
+ - Like API endpoints, we should expect retries
22
+ - Like API endpoints, we should expect concurrency
23
+ - this symmetry allows us to port much of the wisdom built up over decades of building robust APIs to our app job infrastructure
24
+
25
+ ## ACIDic APIs
26
+
27
+ In a loosely collected series of articles, Brandur Leach lays out the core techniques and principles required to make an HTTP API properly ACIDic:
28
+
29
+ 1. https://brandur.org/acid
30
+ 2. https://brandur.org/http-transactions
31
+ 3. https://brandur.org/job-drain
32
+ 4. https://brandur.org/idempotency-keys
33
+
34
+ His central points can be summarized as follows:
35
+
36
+ - "ACID databases are one of the most important tools in existence for ensuring maintainability and data correctness in big production systems"
37
+ - "for a common idempotent HTTP request, requests should map to backend transactions at 1:1"
38
+ - "We can dequeue jobs gracefully by using a transactionally-staged job drain."
39
+ - "Implementations that need to make synchronous changes in foreign state (i.e. outside of a local ACID store) are somewhat more difficult to design. ... To guarantee idempotency on this type of endpoint we’ll need to introduce idempotency keys."
40
+
41
+ Key concepts:
42
+
43
+ - foreign state mutations
44
+ - The reason that the local vs. foreign distinction matters is that unlike a local set of operations where we can leverage an ACID store to roll back a result that we didn’t like, once we make our first foreign state mutation, we’re committed one way or another
45
+ - "An atomic phase is a set of local state mutations that occur in transactions between foreign state mutations."
46
+ - "A recovery point is a name of a check point that we get to after having successfully executed any atomic phase or foreign state mutation"
47
+ - "transactionally-staged job drain"
48
+ - "With this pattern, jobs aren’t immediately sent to the job queue. Instead, they’re staged in a table within the relational database itself, and the ACID properties of the running transaction keep them invisible until they’re ready to be worked. A secondary enqueuer process reads the table and sends any jobs it finds to the job queue before removing their rows."
49
+
50
+
51
+ https://github.com/mperham/sidekiq/wiki/Best-Practices#2-make-your-job-idempotent-and-transactional
52
+
53
+ 2. Make your job idempotent and transactional
54
+
55
+ Idempotency means that your job can safely execute multiple times. For instance, with the error retry functionality, your job might be half-processed, throw an error, and then be re-executed over and over until it successfully completes. Let's say you have a job which voids a credit card transaction and emails the user to let them know the charge has been refunded:
56
+
57
+ ```ruby
58
+ def perform(card_charge_id)
59
+ charge = CardCharge.find(card_charge_id)
60
+ charge.void_transaction
61
+ Emailer.charge_refunded(charge).deliver
62
+ end
63
+ ```
64
+
65
+ What happens when the email fails to render due to a bug? Will the void_transaction method handle the case where a charge has already been refunded? You can use a database transaction to ensure data changes are rolled back if there is an error or you can write your code to be resilient in the face of errors. Just remember that Sidekiq will execute your job at least once, not exactly once.
metadata CHANGED
@@ -1,43 +1,43 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acidic_job
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - fractaledmind
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-08-20 00:00:00.000000000 Z
11
+ date: 2021-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: activesupport
14
+ name: activerecord
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '0'
19
+ version: 4.0.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '0'
26
+ version: 4.0.0
27
27
  - !ruby/object:Gem::Dependency
28
- name: activerecord
28
+ name: activesupport
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: 4.0.0
33
+ version: '0'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: 4.0.0
40
+ version: '0'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: railties
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -71,13 +71,21 @@ files:
71
71
  - acidic_job.gemspec
72
72
  - bin/console
73
73
  - bin/setup
74
+ - blog_post.md
74
75
  - lib/acidic_job.rb
76
+ - lib/acidic_job/errors.rb
77
+ - lib/acidic_job/key.rb
75
78
  - lib/acidic_job/no_op.rb
79
+ - lib/acidic_job/perform_transactionally_extension.rb
80
+ - lib/acidic_job/perform_wrapper.rb
76
81
  - lib/acidic_job/recovery_point.rb
77
82
  - lib/acidic_job/response.rb
83
+ - lib/acidic_job/staged.rb
78
84
  - lib/acidic_job/version.rb
79
85
  - lib/generators/acidic_job_generator.rb
80
- - lib/generators/templates/migration.rb
86
+ - lib/generators/templates/create_acidic_job_keys_migration.rb.erb
87
+ - lib/generators/templates/create_staged_acidic_jobs_migration.rb.erb
88
+ - slides.md
81
89
  homepage: https://github.com/fractaledmind/acidic_job
82
90
  licenses:
83
91
  - MIT