edgycircle_kommando 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e2962518e6deb43c8cd51657f311ebbd96caf84d0e04cfb7505c7c427fddec0b
4
+ data.tar.gz: 8cdfe6722fb04875ee8fc3e0fd1240d8c6edbae8dbddb992cc3a9fa89c4c6166
5
+ SHA512:
6
+ metadata.gz: 895e1f7f17a43f5b9ec5d8fe05a71a09d3b024c55c639f4fcd4c0916597502f0ff23caf352f9001c6d3e45b6b959f134fa590576e83a24383dfc2cf53f9f6434
7
+ data.tar.gz: cdb250ff046a059b5aaf59f7fcc2384ff8174e9fbdde943826d09280403f41f4bbd9f5ec7127158b5dec014f1128dfd01c7e7e93afcd11ef4004bc4e602722f2
@@ -0,0 +1,2 @@
1
+ DROP INDEX index_kommando_scheduled_commands_on_handle_at;
2
+ DROP TABLE kommando_scheduled_commands;
@@ -0,0 +1,12 @@
1
+ CREATE TABLE kommando_scheduled_commands (
2
+ id uuid NOT NULL,
3
+ name character varying NOT NULL,
4
+ parameters json NOT NULL,
5
+ handle_at timestamp without time zone NOT NULL,
6
+ failures json[] NOT NULL,
7
+ wait_for_command_ids uuid[] DEFAULT '{}'::uuid[],
8
+
9
+ PRIMARY KEY (id)
10
+ );
11
+
12
+ CREATE INDEX index_kommando_scheduled_commands_on_handle_at ON kommando_scheduled_commands USING btree (handle_at);
data/lib/kommando.rb ADDED
@@ -0,0 +1,12 @@
1
+ require_relative './kommando/command'
2
+ require_relative './kommando/command_result'
3
+ require_relative './kommando/scheduled_command_runner'
4
+ require_relative './kommando/scheduled_command_worker'
5
+ require_relative './kommando/command_plugins/base'
6
+ require_relative './kommando/command_plugins/execute'
7
+ require_relative './kommando/command_plugins/schedule'
8
+ require_relative './kommando/command_plugins/validate'
9
+ require_relative './kommando/command_plugins/auto_schedule'
10
+
11
+ module Kommando
12
+ end
@@ -0,0 +1,9 @@
1
+ require_relative '../kommando'
2
+
3
+ module Kommando
4
+ class Railtie < Rails::Railtie
5
+ initializer 'kommando.initialize' do
6
+ require_relative './scheduled_command_adapters/active_record'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,27 @@
1
+ require_relative './command_plugins/base'
2
+ require_relative './command_plugins/execute'
3
+ require_relative './command_plugins/schedule'
4
+
5
+ module Kommando
6
+ class Command
7
+ class MissingDependencyError < StandardError; end
8
+
9
+ class MissingParameterError < StandardError; end
10
+
11
+ class ReservedParameterError < StandardError; end
12
+
13
+ class UnknownCommandError < StandardError; end
14
+
15
+ @options = {}
16
+
17
+ def self.plugin(plugin, *args)
18
+ include plugin::InstanceMethods if defined?(plugin::InstanceMethods)
19
+ extend plugin::ClassMethods if defined?(plugin::ClassMethods)
20
+ plugin.configure(self, *args)
21
+ end
22
+
23
+ plugin CommandPlugins::Base
24
+ plugin CommandPlugins::Execute
25
+ plugin CommandPlugins::Schedule
26
+ end
27
+ end
@@ -0,0 +1,15 @@
1
+ module Kommando
2
+ module CommandPlugins
3
+ module AutoSchedule
4
+ def self.configure(command_klass, options)
5
+ command_klass.options[:default_handle_at] = options.fetch(:handle_at)
6
+ end
7
+
8
+ module ClassMethods
9
+ def schedule(dependencies, parameters)
10
+ super(dependencies, { handle_at: options[:default_handle_at].call }.merge(parameters))
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ module Kommando
2
+ module CommandPlugins
3
+ module Base
4
+ def self.configure(command_klass, *args)
5
+ end
6
+
7
+ module ClassMethods
8
+ attr_reader :options
9
+
10
+ def inherited(subclass)
11
+ super
12
+ subclass.instance_variable_set(:@options, options.dup)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,79 @@
1
+ require 'securerandom'
2
+
3
+ module Kommando
4
+ module CommandPlugins
5
+ module Execute
6
+ def self.configure(command_klass, *args)
7
+ end
8
+
9
+ module ClassMethods
10
+ def execute(dependencies, parameters)
11
+ context = {
12
+ dependencies: dependencies,
13
+ parameters: parameters,
14
+ instance: new,
15
+ }
16
+
17
+ context = _before_execute(context)
18
+ return _halt_with_failure(context) if context.key?(:halt)
19
+
20
+ context = _execute(context)
21
+ return _halt_with_failure(context) if context.key?(:halt)
22
+
23
+ context = _after_execute(context)
24
+ return _halt_with_failure(context) if context.key?(:halt)
25
+
26
+ _execute_context_to_result(context)
27
+ end
28
+
29
+ def _before_execute(context)
30
+ unless context[:parameters].key?(:command_id)
31
+ context[:parameters] = context[:parameters].merge({ command_id: SecureRandom.uuid })
32
+ end
33
+
34
+ context
35
+ end
36
+
37
+ def _execute(context)
38
+ context.merge(execute_return_value: context[:instance].handle(context[:dependencies], context[:parameters]))
39
+ rescue StandardError => error
40
+ context.merge(halt: { error: :unhandled_exception, data: error })
41
+ end
42
+
43
+ def _after_execute(context)
44
+ case context[:execute_return_value]
45
+ when CommandResult::Failure
46
+ context.merge(halt: context[:execute_return_value])
47
+ else
48
+ context
49
+ end
50
+ end
51
+
52
+ def _execute_context_to_result(context)
53
+ CommandResult.success(_execute_context_to_data({}, context))
54
+ end
55
+
56
+ def _halt_with_failure(context)
57
+ case context[:halt]
58
+ when CommandResult::Failure
59
+ context[:halt]
60
+ else
61
+ CommandResult.failure(_execute_context_to_data({}, context).merge({ error: context[:halt][:error], details: context[:halt][:data] }))
62
+ end
63
+ end
64
+
65
+ def _execute_context_to_data(data, context)
66
+ data.merge(context.slice(:parameters)).merge(command: name)
67
+ end
68
+
69
+ private
70
+ def reserved_parameter_keys
71
+ [:command_id]
72
+ end
73
+ end
74
+
75
+ module InstanceMethods
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,105 @@
1
+ require 'securerandom'
2
+
3
+ module Kommando
4
+ module CommandPlugins
5
+ module Schedule
6
+ def self.configure(command_klass, *args)
7
+ end
8
+
9
+ module ClassMethods
10
+ def schedule(dependencies, parameters)
11
+ unless dependencies.key?(:schedule_adapter)
12
+ raise Command::MissingDependencyError, 'You need to provide the `:schedule_adapter` dependency'
13
+ end
14
+
15
+ unless parameters.key?(:handle_at)
16
+ raise Command::MissingParameterError, 'You need to provide the `:handle_at` parameter'
17
+ end
18
+
19
+ context = {
20
+ parameters: parameters,
21
+ schedule_adapter: dependencies[:schedule_adapter],
22
+ handle_at: parameters[:handle_at].getutc,
23
+ }
24
+
25
+ context = _before_schedule(context)
26
+ return _halt_schedule_with_failure(context) if context.key?(:halt)
27
+
28
+ context = _schedule(context)
29
+ return _halt_schedule_with_failure(context) if context.key?(:halt)
30
+
31
+ context = _after_schedule(context)
32
+ return _halt_schedule_with_failure(context) if context.key?(:halt)
33
+
34
+ _schedule_context_to_result(context)
35
+ end
36
+
37
+ def _before_schedule(context)
38
+ unless context[:parameters].key?(:command_id)
39
+ context[:parameters] = context[:parameters].merge({ command_id: SecureRandom.uuid })
40
+ end
41
+
42
+ context
43
+ end
44
+
45
+ def _schedule(context)
46
+ context.merge(schedule_return_value: context[:schedule_adapter].schedule!(name, context[:parameters], context[:handle_at]))
47
+ end
48
+
49
+ def _after_schedule(context)
50
+ context
51
+ end
52
+
53
+ def _before_execute(context)
54
+ context[:instance].scheduled_command_results = []
55
+ super(context)
56
+ end
57
+
58
+ def _after_execute(context)
59
+ results = context[:instance].scheduled_command_results
60
+
61
+ if failed_result = results.find(&:error?)
62
+ super(context.merge(halt: CommandResult.failure({ command: name, parameters: context[:parameters] }.merge({ error: :scheduling_error, details: failed_result.error }))))
63
+ else
64
+ super(context)
65
+ end
66
+ end
67
+
68
+ def _schedule_context_to_result(context)
69
+ CommandResult.success(_schedule_context_to_data({}, context))
70
+ end
71
+
72
+ def _execute_context_to_data(data, context)
73
+ results = context[:instance].scheduled_command_results
74
+ super(data.merge(scheduled_commands: results.map(&:value)), context)
75
+ end
76
+
77
+ def _schedule_context_to_data(data, context)
78
+ data.merge(context.slice(:parameters)).merge(command: name)
79
+ end
80
+
81
+ def _halt_schedule_with_failure(context)
82
+ case context[:halt]
83
+ when CommandResult::Failure
84
+ context[:halt]
85
+ else
86
+ CommandResult.failure(_schedule_context_to_data({}, context).merge({ error: context[:halt][:error], details: context[:halt][:data] }))
87
+ end
88
+ end
89
+
90
+ private
91
+ def reserved_parameter_keys
92
+ super.concat([:handle_at, :wait_for_command_ids, :command_id]).uniq
93
+ end
94
+ end
95
+
96
+ module InstanceMethods
97
+ attr_accessor :scheduled_command_results
98
+
99
+ def scheduled(result)
100
+ @scheduled_command_results.push(result)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,52 @@
1
+ module Kommando
2
+ module CommandPlugins
3
+ module Validate
4
+ def self.configure(command_klass, *args)
5
+ end
6
+
7
+ module ClassMethods
8
+ def _before_execute(context)
9
+ if const_defined?('Schema')
10
+ schema_keys = const_get('Schema').schema.key_map.map(&:name).map(&:to_sym)
11
+
12
+ if forbidden_key = reserved_parameter_keys.find { |key| schema_keys.include?(key) }
13
+ raise Command::ReservedParameterError, "The `#{forbidden_key}` parameter is reserved and cannot be used in command schema"
14
+ end
15
+
16
+ reserved_parameters = context[:parameters].slice(*reserved_parameter_keys)
17
+ validation_result = const_get('Schema').new.call(context[:parameters])
18
+
19
+ if validation_result.success?
20
+ super(context.merge(parameters: validation_result.to_h.merge(reserved_parameters)))
21
+ else
22
+ super(context.merge(halt: { error: :schema_error, data: validation_result.errors.to_h }))
23
+ end
24
+ else
25
+ super(context)
26
+ end
27
+ end
28
+
29
+ def _before_schedule(context)
30
+ if const_defined?('Schema')
31
+ schema_keys = const_get('Schema').schema.key_map.map(&:name).map(&:to_sym)
32
+
33
+ if forbidden_key = reserved_parameter_keys.find { |key| schema_keys.include?(key) }
34
+ raise Command::ReservedParameterError, "The `#{forbidden_key}` parameter is reserved and cannot be used in command schema"
35
+ end
36
+
37
+ reserved_parameters = context[:parameters].slice(*reserved_parameter_keys)
38
+ validation_result = const_get('Schema').new.call(context[:parameters])
39
+
40
+ if validation_result.success?
41
+ super(context.merge(parameters: validation_result.to_h.merge(reserved_parameters)))
42
+ else
43
+ super(context.merge(halt: { error: :schema_error, data: validation_result.errors.to_h }))
44
+ end
45
+ else
46
+ super(context)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,68 @@
1
+ module Kommando
2
+ class CommandResult
3
+ class NonExistentError < StandardError; end
4
+
5
+ class NonExistentValue < StandardError; end
6
+
7
+ def self.success(value)
8
+ Success.new(value)
9
+ end
10
+
11
+ def self.failure(error)
12
+ Failure.new(error)
13
+ end
14
+
15
+ class Success
16
+ attr_reader :value
17
+
18
+ def initialize(value)
19
+ raise ArgumentError, 'missing `:command` key' unless value.key?(:command)
20
+ raise ArgumentError, 'missing `:parameters` key' unless value.key?(:parameters)
21
+ @value = value
22
+ end
23
+
24
+ def error
25
+ raise NonExistentError, 'Success results do not have errors'
26
+ end
27
+
28
+ def success?
29
+ true
30
+ end
31
+
32
+ def error?
33
+ false
34
+ end
35
+
36
+ def deconstruct_keys(_)
37
+ { value: @value[:command] }.merge(@value)
38
+ end
39
+ end
40
+
41
+ class Failure
42
+ attr_reader :error
43
+
44
+ def initialize(error)
45
+ raise ArgumentError, 'missing `:error` key' unless error.key?(:error)
46
+ raise ArgumentError, 'missing `:command` key' unless error.key?(:command)
47
+ raise ArgumentError, 'missing `:parameters` key' unless error.key?(:parameters)
48
+ @error = error
49
+ end
50
+
51
+ def value
52
+ raise NonExistentValue, 'Failure results do not have values'
53
+ end
54
+
55
+ def success?
56
+ false
57
+ end
58
+
59
+ def error?
60
+ true
61
+ end
62
+
63
+ def deconstruct_keys(_)
64
+ { error: error }.merge(@error)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,52 @@
1
+ require 'active_record'
2
+
3
+ module Kommando
4
+ module ScheduledCommandAdapters
5
+ class ActiveRecord < ::ActiveRecord::Base
6
+ self.table_name = 'kommando_scheduled_commands'
7
+
8
+ def self.schedule!(command, parameters, handle_at)
9
+ create!({
10
+ id: parameters.fetch(:command_id),
11
+ name: command,
12
+ parameters: parameters,
13
+ handle_at: handle_at,
14
+ failures: [],
15
+ wait_for_command_ids: parameters.fetch(:wait_for_command_ids, []),
16
+ })
17
+ end
18
+
19
+ def self.fetch!(&block)
20
+ transaction do
21
+ record = lock('FOR UPDATE SKIP LOCKED').
22
+ where('TIMEZONE(\'UTC\', NOW()) >= handle_at').
23
+ where.not("wait_for_command_ids && (SELECT array_agg(id) FROM #{table_name})").
24
+ order(handle_at: :asc).
25
+ limit(1).
26
+ first
27
+
28
+ if record
29
+ result = block.call(record.name, record.parameters)
30
+
31
+ if result.success?
32
+ record.destroy
33
+ else
34
+ record.update!({
35
+ failures: record.failures.append(result.error),
36
+ handle_at: (record.handle_at + 5.minutes).getutc,
37
+ })
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ def parameters
44
+ @parameters ||= super.deep_symbolize_keys!
45
+ end
46
+
47
+ def failures
48
+ @failures ||= super.map(&:deep_symbolize_keys!)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,25 @@
1
+ module Kommando
2
+ class ScheduledCommandRunner
3
+ def initialize(coordinator, adapter, dependencies)
4
+ @coordinator = coordinator
5
+ @adapter = adapter
6
+ @dependencies = dependencies
7
+ end
8
+
9
+ def call
10
+ fetched_command = false
11
+
12
+ @adapter.fetch! do |command_name, parameters|
13
+ unless self.class.const_defined?(command_name)
14
+ raise Command::UnknownCommandError, "Unknown command `#{command_name}`"
15
+ end
16
+
17
+ fetched_command = true
18
+
19
+ self.class.const_get(command_name).execute(@dependencies, parameters)
20
+ end
21
+
22
+ @coordinator.schedule_next_run(fetched_command ? 0 : 5)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,23 @@
1
+ module Kommando
2
+ class ScheduledCommandWorker
3
+ def initialize(adapter, dependencies, number_of_threads)
4
+ @adapter = adapter
5
+ @dependencies = dependencies
6
+ @number_of_threads = number_of_threads
7
+ end
8
+
9
+ def start
10
+ @pool = Concurrent::FixedThreadPool.new(@number_of_threads)
11
+ @number_of_threads.times { schedule_next_run(0) }
12
+ @pool.wait_for_termination
13
+ end
14
+
15
+ def stop
16
+ @pool.shutdown
17
+ end
18
+
19
+ def schedule_next_run(delay)
20
+ Concurrent::ScheduledTask.execute(delay, { executor: @pool }, &ScheduledCommandRunner.new(self, @adapter, @dependencies).method(:call))
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,3 @@
1
+ module Kommando
2
+ VERSION = '1.0.2'
3
+ end
metadata ADDED
@@ -0,0 +1,142 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: edgycircle_kommando
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.2
5
+ platform: ruby
6
+ authors:
7
+ - David Strauß
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 1970-01-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: pg
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: dry-validation
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '6.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '6.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '12.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '12.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: minitest
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '5.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '5.0'
97
+ description: Command architecture building blocks.
98
+ email:
99
+ - david.strauss@edgycircle.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - db/migrations/1.down.sql
105
+ - db/migrations/1.up.sql
106
+ - lib/kommando.rb
107
+ - lib/kommando/active_record.rb
108
+ - lib/kommando/command.rb
109
+ - lib/kommando/command_plugins/auto_schedule.rb
110
+ - lib/kommando/command_plugins/base.rb
111
+ - lib/kommando/command_plugins/execute.rb
112
+ - lib/kommando/command_plugins/schedule.rb
113
+ - lib/kommando/command_plugins/validate.rb
114
+ - lib/kommando/command_result.rb
115
+ - lib/kommando/scheduled_command_adapters/active_record.rb
116
+ - lib/kommando/scheduled_command_runner.rb
117
+ - lib/kommando/scheduled_command_worker.rb
118
+ - lib/kommando/version.rb
119
+ homepage: https://github.com/edgycircle/kommando
120
+ licenses:
121
+ - Nonstandard
122
+ metadata: {}
123
+ post_install_message:
124
+ rdoc_options: []
125
+ require_paths:
126
+ - lib
127
+ required_ruby_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: 2.7.0
132
+ required_rubygems_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ requirements: []
138
+ rubygems_version: 3.1.2
139
+ signing_key:
140
+ specification_version: 4
141
+ summary: ''
142
+ test_files: []