edgycircle_kommando 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
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: []