acidic_job 1.0.0.pre29 → 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +13 -0
  3. data/.github/workflows/main.yml +12 -15
  4. data/.gitignore +3 -1
  5. data/.rubocop.yml +50 -5
  6. data/.ruby-version +1 -0
  7. data/Gemfile.lock +114 -198
  8. data/README.md +163 -246
  9. data/TODO +77 -0
  10. data/acidic_job.gemspec +8 -10
  11. data/app/models/acidic_job/entry.rb +19 -0
  12. data/app/models/acidic_job/execution.rb +50 -0
  13. data/app/models/acidic_job/record.rb +11 -0
  14. data/app/models/acidic_job/value.rb +7 -0
  15. data/bin/console +5 -2
  16. data/bin/test_all +26 -0
  17. data/gemfiles/rails_7.0.gemfile +4 -1
  18. data/gemfiles/rails_7.1.gemfile +11 -0
  19. data/gemfiles/rails_7.2.gemfile +11 -0
  20. data/gemfiles/rails_8.0.gemfile +11 -0
  21. data/lib/acidic_job/arguments.rb +31 -0
  22. data/lib/acidic_job/builder.rb +29 -0
  23. data/lib/acidic_job/context.rb +26 -0
  24. data/lib/acidic_job/engine.rb +46 -0
  25. data/lib/acidic_job/errors.rb +91 -12
  26. data/lib/acidic_job/log_subscriber.rb +50 -0
  27. data/lib/acidic_job/serializers/exception_serializer.rb +31 -0
  28. data/lib/acidic_job/serializers/job_serializer.rb +27 -0
  29. data/lib/acidic_job/serializers/new_record_serializer.rb +25 -0
  30. data/lib/acidic_job/serializers/range_serializer.rb +28 -0
  31. data/lib/acidic_job/testing.rb +8 -12
  32. data/lib/acidic_job/version.rb +1 -1
  33. data/lib/acidic_job/workflow.rb +185 -0
  34. data/lib/acidic_job.rb +15 -284
  35. data/lib/generators/acidic_job/install_generator.rb +3 -3
  36. data/lib/generators/acidic_job/templates/create_acidic_job_tables_migration.rb.erb +33 -0
  37. metadata +45 -115
  38. data/.ruby_version +0 -1
  39. data/.tool-versions +0 -1
  40. data/gemfiles/rails_6.1.gemfile +0 -8
  41. data/lib/acidic_job/awaiting.rb +0 -102
  42. data/lib/acidic_job/extensions/action_mailer.rb +0 -29
  43. data/lib/acidic_job/extensions/active_job.rb +0 -40
  44. data/lib/acidic_job/extensions/noticed.rb +0 -54
  45. data/lib/acidic_job/extensions/sidekiq.rb +0 -111
  46. data/lib/acidic_job/finished_point.rb +0 -16
  47. data/lib/acidic_job/idempotency_key.rb +0 -82
  48. data/lib/acidic_job/perform_wrapper.rb +0 -22
  49. data/lib/acidic_job/recovery_point.rb +0 -18
  50. data/lib/acidic_job/rspec_configuration.rb +0 -31
  51. data/lib/acidic_job/run.rb +0 -100
  52. data/lib/acidic_job/serializer.rb +0 -163
  53. data/lib/acidic_job/staging.rb +0 -38
  54. data/lib/acidic_job/step.rb +0 -104
  55. data/lib/acidic_job/test_case.rb +0 -9
  56. data/lib/acidic_job/upgrade_service.rb +0 -118
  57. data/lib/generators/acidic_job/drop_tables_generator.rb +0 -26
  58. data/lib/generators/acidic_job/templates/create_acidic_job_runs_migration.rb.erb +0 -19
  59. data/lib/generators/acidic_job/templates/drop_acidic_job_keys_migration.rb.erb +0 -27
data/TODO ADDED
@@ -0,0 +1,77 @@
1
+ - [x] Track number of runs per execution (resolves #79)
2
+ This is accomplished with the new `entries` table, as proven by the `idempotency_check` feature, which only runs on a retry.
3
+
4
+ - [x] Store errors with more context and without full error history (resolves #80)
5
+ This is also accomplished with the new `entries` table, by storing a separate error entry for any raised errors.
6
+
7
+ - [x] All traversal over a collection added to the context within the workflow (resolves #81)
8
+ The new collection traversal logic resolves #81, as proven by the test case. You can traverse a key for a context value. For example,
9
+ ```ruby
10
+ execute :prepare_collection
11
+ traverse :collection, using: :process_item
12
+
13
+ def prepare_collection = @ctx[:collection] = 1..5
14
+ def process_item(item) = @processed_items << item
15
+ ```
16
+
17
+ - [x] Handle case where non-idempotent external write succeeds but recording fails (resolves #82)
18
+ The new `idempotency_check` option accomplishes this, by allowing a step making external IO to define a method that checks if the write was already successfully made when re-running a step in a retry.
19
+
20
+ - [x] Ensure any and all exceptions can be serialized and deserialized (resolves #83)
21
+ The original serializer was too naive and couldn't deserialize, for example, an `ActionView::Template::Error` which requires that `$!` be set when initializing the object.
22
+
23
+ The new `ExceptionSerializer` uses Ruby's built-in YAML encoding of exceptions to serialize and deserialize the objects, laying on Zlib to ensure compact byte storage.
24
+
25
+ - [x] Handle duplicate workflow step names (resolves #87)
26
+ Adding a `DuplicateStepError` exception raised when building the workflow definition resolved this issue.
27
+
28
+ - [ ] Add a `context` step type (resolves #89)
29
+ Use case: the workflow may or may not have the data it needs to proceed. If it doesn't, it should make an API call to fetch the data. The API call may take a while, so we should be able to handle that as well.
30
+
31
+ Example usage:
32
+ ```ruby
33
+ context :slack_author, fallback: :fetch_slack_author
34
+
35
+ def slack_author
36
+ Slack::Profile.find_by(uid: @user_uid)
37
+ end
38
+
39
+ def fetch_slack_author
40
+ api_response = Slack::Client.new().get_user(uid: @user_uid)
41
+ if api_response[:ok]
42
+ [Slack::ProcessProfileSlackJob.new(@installation, api_response[:user])]
43
+ else
44
+ raise DoNotRetryJob
45
+ end
46
+ end
47
+ ```
48
+
49
+ Psuedo code for implementation:
50
+ ```ruby
51
+ result = resolve_method(primary_method_name).call
52
+ if result
53
+ @ctx[primary_method_name] = result
54
+ else
55
+ fallback = resolve_method(fallback_method_name).call
56
+ case fallback
57
+ in ActiveJob::Base then awaits(fallback)
58
+ in Array[ActiveJob::Base] then awaits(fallback)
59
+ else @ctx[primary_method_name] = result
60
+ end
61
+ end
62
+ ```
63
+
64
+ - [ ] Add documentation on how to "migrate" a workflow (resolves #90)
65
+ The v1 alpha already includes a check that the persisted workflow definition matches the job's current definition. This ensures that no job tries to run with an outdated definition. However, we should document the proper way to update a workflow definition.
66
+
67
+ The process can only be additive, like a strong migration. First, you create a new job with a new name that is a clone of the original job. Make the necessary changes to the new job. Update your codebase to only enqueue the new job. Deploy this change, where both the new job and the old jobs exist, but the application only enqueues the new job. Once deployed, wait until all currently running instances of the old job complete (provide some docs on how to check this). Once all old job instances are complete, you can safely delete the old job and deploy that change. This process ensures that no job is running with an outdated definition.
68
+
69
+ - [ ] Automatically retry the serializable transaction for find/create the Run record (resolves #91)
70
+ Serializable transactions are prone to failure: https://stackoverflow.com/a/21715207/2884386
71
+
72
+ Currently, if the transaction fails, the job will fail. We need to build automatic retries into the gem, as this failure will naturally resolve. But, we should also add a limit to the number of retries to prevent infinite loops.
73
+
74
+ - [ ] Ensure the gem works with GoodJob (resolves #92 and #94)
75
+ In the current version, GoodJob can't handle messing with the `job_id` and retrying a failed job raises a NoMethodError: "undefined method `utc' for an instance of Float".
76
+
77
+ - [ ] Ensure users can transactionally enqueue other entities, like ActionMailer or Noticed objects
data/acidic_job.gemspec CHANGED
@@ -27,24 +27,22 @@ Gem::Specification.new do |spec|
27
27
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
28
28
  spec.require_paths = ["lib"]
29
29
 
30
- spec.add_dependency "activerecord"
31
- spec.add_dependency "activesupport"
32
- spec.add_dependency "database_cleaner"
33
- spec.add_development_dependency "activejob"
30
+ ">= 7.1".tap do |rails_version|
31
+ spec.add_dependency "activejob", rails_version
32
+ spec.add_dependency "activerecord", rails_version
33
+ spec.add_dependency "activesupport", rails_version
34
+ spec.add_dependency "railties", rails_version
35
+ end
36
+
37
+ spec.add_development_dependency "chaotic_job", ">= 0.2.0"
34
38
  spec.add_development_dependency "combustion"
35
39
  spec.add_development_dependency "minitest"
36
- spec.add_development_dependency "net-smtp"
37
- spec.add_development_dependency "noticed"
38
- spec.add_development_dependency "psych", "> 4.0"
39
- spec.add_development_dependency "railties"
40
40
  spec.add_development_dependency "rake"
41
41
  spec.add_development_dependency "rubocop"
42
42
  spec.add_development_dependency "rubocop-minitest"
43
43
  spec.add_development_dependency "rubocop-rake"
44
- spec.add_development_dependency "sidekiq"
45
44
  spec.add_development_dependency "simplecov"
46
45
  spec.add_development_dependency "sqlite3"
47
- spec.add_development_dependency "warning"
48
46
 
49
47
  # For more information and examples about making a new gem, checkout our
50
48
  # guide at: https://bundler.io/guides/creating_gem.html
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ class Entry < Record
5
+ belongs_to :execution, class_name: "AcidicJob::Execution"
6
+
7
+ def started?
8
+ action == "started"
9
+ end
10
+
11
+ def succeeded?
12
+ action == "succeeded"
13
+ end
14
+
15
+ def errored?
16
+ action == "errored"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ class Execution < Record
5
+ has_many :entries, class_name: "AcidicJob::Entry"
6
+ has_many :values, class_name: "AcidicJob::Value"
7
+
8
+ validates :idempotency_key, presence: true # uniqueness constraint is enforced at the database level
9
+ validates :serialized_job, presence: true
10
+
11
+ scope :finished, -> { where(recover_to: FINISHED_RECOVERY_POINT) }
12
+ scope :outstanding, lambda {
13
+ where.not(recover_to: FINISHED_RECOVERY_POINT).or(where(recover_to: [nil, ""]))
14
+ }
15
+
16
+ def record!(step:, action:, timestamp:, **kwargs)
17
+ AcidicJob.instrument(:record_entry, step: step, action: action, timestamp: timestamp, data: kwargs) do
18
+ entries.create!(
19
+ step: step,
20
+ action: action,
21
+ timestamp: timestamp,
22
+ data: kwargs.stringify_keys!
23
+ )
24
+ end
25
+ end
26
+
27
+ def context
28
+ @context ||= Context.new(self)
29
+ end
30
+
31
+ def finished?
32
+ recover_to.to_s == FINISHED_RECOVERY_POINT
33
+ end
34
+
35
+ def deserialized_job
36
+ serialized_job["job_class"].constantize.new.tap do |job|
37
+ job.deserialize(serialized_job)
38
+ end
39
+ end
40
+
41
+ def raw_arguments
42
+ JSON.parse(serialized_job_before_type_cast)["arguments"]
43
+ end
44
+
45
+ def enqueue_job
46
+ deserialized_job.enqueue
47
+ true
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ class Record < ActiveRecord::Base
5
+ self.abstract_class = true
6
+
7
+ connects_to(**AcidicJob.connects_to) if AcidicJob.connects_to
8
+ end
9
+ end
10
+
11
+ ActiveSupport.run_load_hooks :acidic_job_record, AcidicJob::Record
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ class Value < Record
5
+ belongs_to :execution, class_name: "AcidicJob::Execution"
6
+ end
7
+ end
data/bin/console CHANGED
@@ -2,13 +2,16 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "bundler/setup"
5
+ require "rails"
5
6
  require "acidic_job"
6
7
 
7
8
  # You can add fixtures and/or initialization code here to make experimenting
8
9
  # with your gem easier. You can also use a different console, if you like.
9
10
 
10
- require_relative "../test/support/setup"
11
- require_relative "../test/support/ride_create_job"
11
+ require "combustion"
12
+ require "sqlite3"
13
+ Combustion.path = "test/combustion"
14
+ Combustion.initialize! :active_record
12
15
 
13
16
  # (If you use this, don't forget to add pry to your Gemfile!)
14
17
  # require "pry"
data/bin/test_all ADDED
@@ -0,0 +1,26 @@
1
+ run_tests() {
2
+ if [ "$#" -ne 2 ]; then
3
+ echo "Usage: run_tests RUBY_VERSION RAILS_VERSION"
4
+ echo "Example: run_tests 3.0.7 7.1"
5
+ return 1
6
+ fi
7
+
8
+ local ruby="$1"
9
+ local gemfile="gemfiles/rails_$2.gemfile"
10
+
11
+ echo "**************************************************"
12
+ echo "Running tests with Ruby $1 and Rails $2..."
13
+ ASDF_RUBY_VERSION=$ruby BUNDLE_GEMFILE=$gemfile bundle check ||
14
+ ASDF_RUBY_VERSION=$ruby BUNDLE_GEMFILE=$gemfile bundle install &&
15
+ ASDF_RUBY_VERSION=$ruby BUNDLE_GEMFILE=$gemfile bundle exec rake
16
+ }
17
+
18
+ run_tests "3.0.7" "7.1"
19
+ run_tests "3.1.6" "7.1"
20
+ run_tests "3.1.6" "7.2"
21
+ run_tests "3.2.5" "7.1"
22
+ run_tests "3.2.5" "7.2"
23
+ run_tests "3.2.5" "8.0"
24
+ run_tests "3.3.5" "7.1"
25
+ run_tests "3.3.5" "7.2"
26
+ run_tests "3.3.5" "8.0"
@@ -2,7 +2,10 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gem "activemodel", "~> 7.0.0"
5
+ gem "activejob", "~> 7.0.0"
6
+ gem "activerecord", "~> 7.0.0"
7
+ gem "activesupport", "~> 7.0.0"
6
8
  gem "railties", "~> 7.0.0"
9
+ gem "sqlite3", "~> 1.7.3"
7
10
 
8
11
  gemspec path: "../"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activejob", "~> 7.1.0"
6
+ gem "activerecord", "~> 7.1.0"
7
+ gem "activesupport", "~> 7.1.0"
8
+ gem "railties", "~> 7.1.0"
9
+ gem "sqlite3", ">= 2.0.0"
10
+
11
+ gemspec path: "../"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activejob", "~> 7.2.0"
6
+ gem "activerecord", "~> 7.2.0"
7
+ gem "activesupport", "~> 7.2.0"
8
+ gem "railties", "~> 7.2.0"
9
+ gem "sqlite3", ">= 2.0.0"
10
+
11
+ gemspec path: "../"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activejob", "~> 8.0.0.rc1"
6
+ gem "activerecord", "~> 8.0.0.rc1"
7
+ gem "activesupport", "~> 8.0.0.rc1"
8
+ gem "railties", "~> 8.0.0.rc1"
9
+ gem "sqlite3", ">= 2.1.0"
10
+
11
+ gemspec path: "../"
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/arguments"
4
+
5
+ module AcidicJob
6
+ module Arguments
7
+ include ::ActiveJob::Arguments
8
+
9
+ extend self
10
+
11
+ # `ActiveJob` will throw an error if it tries to deserialize a GlobalID record.
12
+ # However, this isn't the behavior that we want for our custom `ActiveRecord` serializer.
13
+ # Since `ActiveRecord` does _not_ reset instance record state to its pre-transactional state
14
+ # on a transaction ROLLBACK, we can have GlobalID entries in a serialized column that point to
15
+ # non-persisted records. This is ok. We should simply return `nil` for that portion of the
16
+ # serialized field.
17
+ def deserialize_global_id(hash)
18
+ ::GlobalID::Locator.locate hash[GLOBALID_KEY]
19
+ rescue ::ActiveRecord::RecordNotFound
20
+ nil
21
+ end
22
+
23
+ # In order to allow our `NewRecordSerializer` a chance to work, we need to ensure that
24
+ # ActiveJob's first attempt to serialize an ActiveRecord model doesn't throw an exception.
25
+ def convert_to_global_id_hash(argument)
26
+ { GLOBALID_KEY => argument.to_global_id.to_s }
27
+ rescue ::URI::GID::MissingModelIdError
28
+ ::ActiveJob::Serializers.serialize(argument)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ class Builder
5
+ attr_reader :steps
6
+
7
+ def initialize
8
+ @steps = []
9
+ end
10
+
11
+ def step(method_name, transactional: false)
12
+ @steps << { "does" => method_name.to_s, "transactional" => transactional }
13
+ @steps
14
+ end
15
+
16
+ def define_workflow
17
+ # [ { does: "step 1", transactional: true }, { does: "step 2", transactional: false }, ... ]
18
+ @steps << { "does" => FINISHED_RECOVERY_POINT }
19
+
20
+ {}.tap do |workflow|
21
+ @steps.each_cons(2).map do |enter_step, exit_step|
22
+ enter_name = enter_step["does"]
23
+ workflow[enter_name] = enter_step.merge("then" => exit_step["does"])
24
+ end
25
+ end
26
+ # { "step 1": { does: "step 1", transactional: true, then: "step 2" }, ... }
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ class Context
5
+ def initialize(execution)
6
+ @execution = execution
7
+ end
8
+
9
+ def []=(key, value)
10
+ AcidicJob.instrument(:set_context, key: key, value: value) do
11
+ AcidicJob::Value.upsert(
12
+ { execution_id: @execution.id,
13
+ key: key,
14
+ value: value },
15
+ unique_by: %i[execution_id key]
16
+ )
17
+ end
18
+ end
19
+
20
+ def [](key)
21
+ AcidicJob.instrument(:get_context, key: key) do
22
+ @execution.values.select(:value).find_by(key: key)&.value
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace AcidicJob
6
+
7
+ config.acidic_job = ActiveSupport::OrderedOptions.new
8
+
9
+ initializer "acidic_job.config" do
10
+ config.acidic_job.each do |name, value|
11
+ AcidicJob.public_send("#{name}=", value)
12
+ end
13
+ end
14
+
15
+ initializer "acidic_job.logger" do
16
+ ActiveSupport.on_load :acidic_job do
17
+ self.logger = ::Rails.logger if logger == AcidicJob::DEFAULT_LOGGER
18
+ end
19
+
20
+ AcidicJob::LogSubscriber.attach_to :acidic_job
21
+ end
22
+
23
+ initializer "acidic_job.active_job.extensions" do
24
+ ActiveSupport.on_load :active_job do
25
+ require "active_job/serializers"
26
+ require_relative "serializers/exception_serializer"
27
+ require_relative "serializers/new_record_serializer"
28
+ require_relative "serializers/job_serializer"
29
+ require_relative "serializers/range_serializer"
30
+
31
+ ActiveJob::Serializers.add_serializers(
32
+ Serializers::ExceptionSerializer,
33
+ Serializers::NewRecordSerializer,
34
+ Serializers::JobSerializer,
35
+ Serializers::RangeSerializer
36
+ )
37
+ end
38
+ end
39
+
40
+ # :nocov:
41
+ generators do
42
+ require "generators/acidic_job/install_generator"
43
+ end
44
+ # :nocov:
45
+ end
46
+ end
@@ -1,27 +1,106 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AcidicJob
4
- class Error < StandardError; end
4
+ class Error < StandardError
5
+ end
5
6
 
6
- class MismatchedIdempotencyKeyAndJobArguments < Error; end
7
+ class RedefiningWorkflowError < Error
8
+ def message
9
+ "can only call `execute_workflow` once within a job"
10
+ end
11
+ end
7
12
 
8
- class LockedIdempotencyKey < Error; end
13
+ class UndefinedWorkflowBlockError < Error
14
+ def message
15
+ "block must be passed to `execute_workflow`"
16
+ end
17
+ end
9
18
 
10
- class UnknownRecoveryPoint < Error; end
19
+ class InvalidWorkflowBlockError < Error
20
+ def message
21
+ "workflow builder must be yielded to the `execute_workflow` block"
22
+ end
23
+ end
11
24
 
12
- class UnknownAtomicPhaseType < Error; end
25
+ class MissingStepsError < Error
26
+ def message
27
+ "workflow must define at least one step"
28
+ end
29
+ end
13
30
 
14
- class SerializedTransactionConflict < Error; end
31
+ class ArgumentMismatchError < Error
32
+ def initialize(expected, existing)
33
+ super
34
+ @expected = expected
35
+ @existing = existing
36
+ end
15
37
 
16
- class UnknownJobAdapter < Error; end
38
+ def message
39
+ <<~TXT
40
+ existing execution's arguments do not match
41
+ existing: #{@existing.inspect}
42
+ expected: #{@expected.inspect}
43
+ TXT
44
+ end
45
+ end
17
46
 
18
- class NoDefinedSteps < Error; end
47
+ class DefinitionMismatchError < Error
48
+ def initialize(expected, existing)
49
+ super
50
+ @expected = expected
51
+ @existing = existing
52
+ end
19
53
 
20
- class SidekiqBatchRequired < Error; end
54
+ def message
55
+ <<~TXT
56
+ existing execution's definition does not match
57
+ existing: #{@existing.inspect}
58
+ expected: #{@expected.inspect}
59
+ TXT
60
+ end
61
+ end
21
62
 
22
- class TooManyParametersForStepMethod < Error; end
63
+ class UndefinedStepError < Error
64
+ def initialize(step)
65
+ super
66
+ @step = step
67
+ end
23
68
 
24
- class TooManyParametersForParallelJob < Error; end
69
+ def message
70
+ "workflow does not reference this step: #{@step.inspect}"
71
+ end
72
+ end
25
73
 
26
- class UnknownSerializedJobIdentifier < Error; end
74
+ class SucceededStepError < Error
75
+ def initialize(step)
76
+ super
77
+ @step = step
78
+ end
79
+
80
+ def message
81
+ "workflow has already recorded this step as succeeded: #{@step.inspect}"
82
+ end
83
+ end
84
+
85
+ class UndefinedMethodError < Error
86
+ def initialize(step)
87
+ super
88
+ @step = step
89
+ end
90
+
91
+ def message
92
+ "Undefined step method: #{@step.inspect}"
93
+ end
94
+ end
95
+
96
+ class InvalidMethodError < Error
97
+ def initialize(step)
98
+ super
99
+ @step = step
100
+ end
101
+
102
+ def message
103
+ "step method cannot expect arguments: #{@step.inspect}"
104
+ end
105
+ end
27
106
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/log_subscriber"
4
+
5
+ module AcidicJob
6
+ class LogSubscriber < ActiveSupport::LogSubscriber
7
+ def define_workflow(event)
8
+ debug formatted_event(event, action: "Define workflow", **event.payload.slice("job_class", "job_id"))
9
+ end
10
+
11
+ def initialize_workflow(event)
12
+ debug formatted_event(event, action: "Initialize workflow", **event.payload.slice("steps"))
13
+ end
14
+
15
+ def process_workflow(event)
16
+ debug formatted_event(event, action: "Process workflow", **event.payload["execution"].slice("id", "recover_to"))
17
+ end
18
+
19
+ def process_step(event)
20
+ debug formatted_event(event, action: "Process step", **event.payload)
21
+ end
22
+
23
+ def perform_step(event)
24
+ debug formatted_event(event, action: "Perform step", **event.payload)
25
+ end
26
+
27
+ def record_entry(event)
28
+ debug formatted_event(event, action: "Record entry", **event.payload.slice(:step, :action, :timestamp))
29
+ end
30
+
31
+ private
32
+
33
+ def formatted_event(event, action:, **attributes)
34
+ "AcidicJob-#{AcidicJob::VERSION} #{action} (#{event.duration.round(1)}ms) #{formatted_attributes(**attributes)}"
35
+ end
36
+
37
+ def formatted_attributes(**attributes)
38
+ attributes.map { |attr, value| "#{attr}: #{value.inspect}" }.join(", ")
39
+ end
40
+
41
+ def formatted_error(error)
42
+ [error.class, error.message].compact.join(" ")
43
+ end
44
+
45
+ # Use the logger configured for AcidicJob
46
+ def logger
47
+ AcidicJob.logger
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/serializers/object_serializer"
4
+ require "zlib"
5
+ require "yaml"
6
+
7
+ module AcidicJob
8
+ module Serializers
9
+ class ExceptionSerializer < ::ActiveJob::Serializers::ObjectSerializer
10
+ def serialize(exception)
11
+ compressed = Zlib::Deflate.deflate(exception.to_yaml)
12
+
13
+ super("deflated_yaml" => compressed)
14
+ end
15
+
16
+ def deserialize(hash)
17
+ uncompressed = Zlib::Inflate.inflate(hash["deflated_yaml"])
18
+
19
+ if YAML.respond_to?(:unsafe_load)
20
+ YAML.unsafe_load(uncompressed)
21
+ else
22
+ YAML.load(uncompressed) # rubocop:disable Security/YAMLLoad
23
+ end
24
+ end
25
+
26
+ def serialize?(argument)
27
+ defined?(Exception) && argument.is_a?(Exception)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/serializers/object_serializer"
4
+
5
+ module AcidicJob
6
+ module Serializers
7
+ class JobSerializer < ::ActiveJob::Serializers::ObjectSerializer
8
+ def serialize(job)
9
+ # don't serialize the `enqueued_at` value, as ActiveRecord will check if the Run record has changed
10
+ # by comparing the deserialized database value with a temporary in-memory generated value.
11
+ # That temporary in-memory generated value can sometimes have an `enqueued_at` value that is 1 second off
12
+ # from the original. In this case, ActiveRecord will think the record has unsaved changes and block the lock.
13
+ super(job.serialize.except("enqueued_at"))
14
+ end
15
+
16
+ def deserialize(hash)
17
+ job = ::ActiveJob::Base.deserialize(hash)
18
+ job.send(:deserialize_arguments_if_needed)
19
+ job
20
+ end
21
+
22
+ def serialize?(argument)
23
+ defined?(::ActiveJob::Base) && argument.class < ::ActiveJob::Base
24
+ end
25
+ end
26
+ end
27
+ end