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.
- checksums.yaml +4 -4
- data/.github/FUNDING.yml +13 -0
- data/.github/workflows/main.yml +12 -15
- data/.gitignore +3 -1
- data/.rubocop.yml +50 -5
- data/.ruby-version +1 -0
- data/Gemfile.lock +114 -198
- data/README.md +163 -246
- data/TODO +77 -0
- data/acidic_job.gemspec +8 -10
- data/app/models/acidic_job/entry.rb +19 -0
- data/app/models/acidic_job/execution.rb +50 -0
- data/app/models/acidic_job/record.rb +11 -0
- data/app/models/acidic_job/value.rb +7 -0
- data/bin/console +5 -2
- data/bin/test_all +26 -0
- data/gemfiles/rails_7.0.gemfile +4 -1
- data/gemfiles/rails_7.1.gemfile +11 -0
- data/gemfiles/rails_7.2.gemfile +11 -0
- data/gemfiles/rails_8.0.gemfile +11 -0
- data/lib/acidic_job/arguments.rb +31 -0
- data/lib/acidic_job/builder.rb +29 -0
- data/lib/acidic_job/context.rb +26 -0
- data/lib/acidic_job/engine.rb +46 -0
- data/lib/acidic_job/errors.rb +91 -12
- data/lib/acidic_job/log_subscriber.rb +50 -0
- data/lib/acidic_job/serializers/exception_serializer.rb +31 -0
- data/lib/acidic_job/serializers/job_serializer.rb +27 -0
- data/lib/acidic_job/serializers/new_record_serializer.rb +25 -0
- data/lib/acidic_job/serializers/range_serializer.rb +28 -0
- data/lib/acidic_job/testing.rb +8 -12
- data/lib/acidic_job/version.rb +1 -1
- data/lib/acidic_job/workflow.rb +185 -0
- data/lib/acidic_job.rb +15 -284
- data/lib/generators/acidic_job/install_generator.rb +3 -3
- data/lib/generators/acidic_job/templates/create_acidic_job_tables_migration.rb.erb +33 -0
- metadata +45 -115
- data/.ruby_version +0 -1
- data/.tool-versions +0 -1
- data/gemfiles/rails_6.1.gemfile +0 -8
- data/lib/acidic_job/awaiting.rb +0 -102
- data/lib/acidic_job/extensions/action_mailer.rb +0 -29
- data/lib/acidic_job/extensions/active_job.rb +0 -40
- data/lib/acidic_job/extensions/noticed.rb +0 -54
- data/lib/acidic_job/extensions/sidekiq.rb +0 -111
- data/lib/acidic_job/finished_point.rb +0 -16
- data/lib/acidic_job/idempotency_key.rb +0 -82
- data/lib/acidic_job/perform_wrapper.rb +0 -22
- data/lib/acidic_job/recovery_point.rb +0 -18
- data/lib/acidic_job/rspec_configuration.rb +0 -31
- data/lib/acidic_job/run.rb +0 -100
- data/lib/acidic_job/serializer.rb +0 -163
- data/lib/acidic_job/staging.rb +0 -38
- data/lib/acidic_job/step.rb +0 -104
- data/lib/acidic_job/test_case.rb +0 -9
- data/lib/acidic_job/upgrade_service.rb +0 -118
- data/lib/generators/acidic_job/drop_tables_generator.rb +0 -26
- data/lib/generators/acidic_job/templates/create_acidic_job_runs_migration.rb.erb +0 -19
- 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
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
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
|
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
|
-
|
11
|
-
|
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"
|
data/gemfiles/rails_7.0.gemfile
CHANGED
@@ -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
|
data/lib/acidic_job/errors.rb
CHANGED
@@ -1,27 +1,106 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module AcidicJob
|
4
|
-
class Error < StandardError
|
4
|
+
class Error < StandardError
|
5
|
+
end
|
5
6
|
|
6
|
-
class
|
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
|
13
|
+
class UndefinedWorkflowBlockError < Error
|
14
|
+
def message
|
15
|
+
"block must be passed to `execute_workflow`"
|
16
|
+
end
|
17
|
+
end
|
9
18
|
|
10
|
-
class
|
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
|
25
|
+
class MissingStepsError < Error
|
26
|
+
def message
|
27
|
+
"workflow must define at least one step"
|
28
|
+
end
|
29
|
+
end
|
13
30
|
|
14
|
-
class
|
31
|
+
class ArgumentMismatchError < Error
|
32
|
+
def initialize(expected, existing)
|
33
|
+
super
|
34
|
+
@expected = expected
|
35
|
+
@existing = existing
|
36
|
+
end
|
15
37
|
|
16
|
-
|
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
|
47
|
+
class DefinitionMismatchError < Error
|
48
|
+
def initialize(expected, existing)
|
49
|
+
super
|
50
|
+
@expected = expected
|
51
|
+
@existing = existing
|
52
|
+
end
|
19
53
|
|
20
|
-
|
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
|
63
|
+
class UndefinedStepError < Error
|
64
|
+
def initialize(step)
|
65
|
+
super
|
66
|
+
@step = step
|
67
|
+
end
|
23
68
|
|
24
|
-
|
69
|
+
def message
|
70
|
+
"workflow does not reference this step: #{@step.inspect}"
|
71
|
+
end
|
72
|
+
end
|
25
73
|
|
26
|
-
class
|
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
|