hanikamu-operation 0.1.0
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 +7 -0
- data/CHANGELOG.md +5 -0
- data/Dockerfile +13 -0
- data/LICENSE.txt +21 -0
- data/Makefile +20 -0
- data/README.md +1028 -0
- data/Rakefile +12 -0
- data/docker-compose.yml +22 -0
- data/lib/hanikamu/operation.rb +215 -0
- data/lib/hanikamu-operation.rb +7 -0
- metadata +146 -0
data/Rakefile
ADDED
data/docker-compose.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
version: "3"
|
|
2
|
+
networks:
|
|
3
|
+
docker-compose-example-tier:
|
|
4
|
+
driver: bridge
|
|
5
|
+
services:
|
|
6
|
+
redis:
|
|
7
|
+
image: redis:7-alpine
|
|
8
|
+
networks:
|
|
9
|
+
- docker-compose-example-tier
|
|
10
|
+
app:
|
|
11
|
+
build:
|
|
12
|
+
context: .
|
|
13
|
+
dockerfile: Dockerfile
|
|
14
|
+
environment:
|
|
15
|
+
HISTFILE: /app/tmp/ash_history
|
|
16
|
+
REDIS_URL: redis://redis:6379/15
|
|
17
|
+
volumes:
|
|
18
|
+
- .:/app
|
|
19
|
+
networks:
|
|
20
|
+
- docker-compose-example-tier
|
|
21
|
+
depends_on:
|
|
22
|
+
- redis
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hanikamu
|
|
4
|
+
# :nodoc:
|
|
5
|
+
# rubocop:disable Metrics/ClassLength
|
|
6
|
+
class Operation < Hanikamu::Service
|
|
7
|
+
include ActiveModel::Validations
|
|
8
|
+
|
|
9
|
+
Error = Class.new(Hanikamu::Service::Error)
|
|
10
|
+
|
|
11
|
+
# Error classes
|
|
12
|
+
class FormError < Hanikamu::Service::Error
|
|
13
|
+
attr_reader :form
|
|
14
|
+
|
|
15
|
+
def initialize(form)
|
|
16
|
+
@form = form
|
|
17
|
+
super(form.is_a?(String) ? form : form.errors.full_messages.join(", "))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def errors
|
|
21
|
+
return @form if @form.is_a?(String)
|
|
22
|
+
|
|
23
|
+
@form.errors
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class GuardError < Hanikamu::Service::Error
|
|
28
|
+
attr_reader :guard
|
|
29
|
+
|
|
30
|
+
def initialize(guard)
|
|
31
|
+
@guard = guard
|
|
32
|
+
super(guard.is_a?(String) ? guard : guard.errors.full_messages.join(", "))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def errors
|
|
36
|
+
return @guard if @guard.is_a?(String)
|
|
37
|
+
|
|
38
|
+
@guard.errors
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class MissingBlockError < Hanikamu::Service::Error
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class ConfigurationError < StandardError; end
|
|
46
|
+
|
|
47
|
+
# Configuration
|
|
48
|
+
setting :redis_client
|
|
49
|
+
setting :mutex_expire_milliseconds, default: 1500
|
|
50
|
+
setting :redlock_retry_count, default: 6
|
|
51
|
+
setting :redlock_retry_delay, default: 500
|
|
52
|
+
setting :redlock_retry_jitter, default: 50
|
|
53
|
+
setting :redlock_timeout, default: 0.1
|
|
54
|
+
setting :whitelisted_errors, default: [].freeze, constructor: ->(value) { Array(value) }
|
|
55
|
+
|
|
56
|
+
# Override configure to cascade whitelisted_errors to Hanikamu::Service
|
|
57
|
+
def self.configure
|
|
58
|
+
super do |config|
|
|
59
|
+
yield(config) if block_given?
|
|
60
|
+
|
|
61
|
+
# Always include Redlock::LockError alongside user-provided errors
|
|
62
|
+
whitelisted_errors = ([Redlock::LockError] + Array(config.whitelisted_errors)).uniq
|
|
63
|
+
|
|
64
|
+
# Set on both Operation and Service configs because:
|
|
65
|
+
# - Operation.config is checked when .call is invoked on Operation subclasses
|
|
66
|
+
# - Service.config is set for consistency when directly calling Hanikamu::Service
|
|
67
|
+
config.whitelisted_errors = whitelisted_errors
|
|
68
|
+
Hanikamu::Service.config.whitelisted_errors = whitelisted_errors
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class << self
|
|
73
|
+
def redis_lock
|
|
74
|
+
@redis_lock ||= begin
|
|
75
|
+
unless config.redis_client
|
|
76
|
+
raise(
|
|
77
|
+
ConfigurationError,
|
|
78
|
+
"Hanikamu::Operation.config.redis_client is not configured. " \
|
|
79
|
+
"Please set it in an initializer: Hanikamu::Operation.config.redis_client = your_redis_client"
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Redlock::Client.new(
|
|
84
|
+
[config.redis_client],
|
|
85
|
+
retry_count: config.redlock_retry_count,
|
|
86
|
+
retry_delay: config.redlock_retry_delay,
|
|
87
|
+
retry_jitter: config.redlock_retry_jitter,
|
|
88
|
+
redis_timeout: config.redlock_timeout
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# DSL methods
|
|
94
|
+
def within_mutex(lock_key, expire_milliseconds: nil)
|
|
95
|
+
@_mutex_lock_key = lock_key
|
|
96
|
+
@_mutex_expire_milliseconds = expire_milliseconds || Hanikamu::Operation.config.mutex_expire_milliseconds
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def within_transaction(klass)
|
|
100
|
+
@_transaction_klass = klass
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def block(bool)
|
|
104
|
+
@_block = bool
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Define guard validations using a block
|
|
108
|
+
# The block is evaluated in the context of a Guard class
|
|
109
|
+
# rubocop:disable Metrics/MethodLength
|
|
110
|
+
def guard(&block)
|
|
111
|
+
return unless block
|
|
112
|
+
|
|
113
|
+
# Thread-safe constant definition with mutex
|
|
114
|
+
@guard_definition_mutex ||= Mutex.new
|
|
115
|
+
@guard_definition_mutex.synchronize do
|
|
116
|
+
# Remove existing Guard constant if it exists to support Rails reloading
|
|
117
|
+
remove_const(:Guard) if const_defined?(:Guard, false)
|
|
118
|
+
|
|
119
|
+
# Create a new Guard class with ActiveModel validations
|
|
120
|
+
guard_class = Class.new do
|
|
121
|
+
include ActiveModel::Validations
|
|
122
|
+
|
|
123
|
+
attr_reader :operation
|
|
124
|
+
alias service operation
|
|
125
|
+
|
|
126
|
+
def initialize(operation)
|
|
127
|
+
@operation = operation
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Helper to delegate methods to operation/service
|
|
131
|
+
def self.delegates(*methods)
|
|
132
|
+
methods.each do |method_name|
|
|
133
|
+
define_method(method_name) do
|
|
134
|
+
operation.public_send(method_name)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
class_eval(&block)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
const_set(:Guard, guard_class)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
# rubocop:enable Metrics/MethodLength
|
|
146
|
+
|
|
147
|
+
attr_reader :_mutex_lock_key, :_mutex_expire_milliseconds, :_transaction_klass, :_block
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def call!(&block)
|
|
151
|
+
validate_block!(&block)
|
|
152
|
+
|
|
153
|
+
within_mutex! do
|
|
154
|
+
validate!
|
|
155
|
+
guard!
|
|
156
|
+
|
|
157
|
+
within_transaction! do
|
|
158
|
+
block ? execute(&block) : execute
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def validate_block!(&block)
|
|
164
|
+
return unless self.class._block
|
|
165
|
+
|
|
166
|
+
raise Hanikamu::Operation::MissingBlockError, "This service requires a block to be called" unless block
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def within_mutex!(&)
|
|
170
|
+
return yield if _lock_key.nil?
|
|
171
|
+
|
|
172
|
+
Hanikamu::Operation.redis_lock.lock!(_lock_key, self.class._mutex_expire_milliseconds, &)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def _lock_key
|
|
176
|
+
return if self.class._mutex_lock_key.blank?
|
|
177
|
+
|
|
178
|
+
public_send(self.class._mutex_lock_key)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def within_transaction!(&)
|
|
182
|
+
return yield if transaction_class.nil?
|
|
183
|
+
|
|
184
|
+
transaction_class.transaction(&)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def transaction_class
|
|
190
|
+
return if self.class._transaction_klass.nil?
|
|
191
|
+
return ActiveRecord::Base if self.class._transaction_klass == :base
|
|
192
|
+
|
|
193
|
+
self.class._transaction_klass
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def validate!
|
|
197
|
+
raise Hanikamu::Operation::FormError, self unless valid?
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def guard!
|
|
201
|
+
# Check for Guard constant defined directly on this class, not inherited
|
|
202
|
+
return unless self.class.const_defined?(:Guard, false)
|
|
203
|
+
|
|
204
|
+
# Always create a fresh guard instance for this specific operation
|
|
205
|
+
# This prevents guard leakage when operations call other operations
|
|
206
|
+
@guard = self.class.const_get(:Guard).new(self)
|
|
207
|
+
raise_guard_error! unless @guard.valid?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def raise_guard_error!
|
|
211
|
+
raise Hanikamu::Operation::GuardError, @guard
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
# rubocop:enable Metrics/ClassLength
|
|
215
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: hanikamu-operation
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Nicolai Seerup
|
|
8
|
+
- Alejandro Jimenez
|
|
9
|
+
autorequire:
|
|
10
|
+
bindir: exe
|
|
11
|
+
cert_chain: []
|
|
12
|
+
date: 2025-11-27 00:00:00.000000000 Z
|
|
13
|
+
dependencies:
|
|
14
|
+
- !ruby/object:Gem::Dependency
|
|
15
|
+
name: activemodel
|
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
|
17
|
+
requirements:
|
|
18
|
+
- - ">="
|
|
19
|
+
- !ruby/object:Gem::Version
|
|
20
|
+
version: '6.0'
|
|
21
|
+
- - "<"
|
|
22
|
+
- !ruby/object:Gem::Version
|
|
23
|
+
version: '9.0'
|
|
24
|
+
type: :runtime
|
|
25
|
+
prerelease: false
|
|
26
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
27
|
+
requirements:
|
|
28
|
+
- - ">="
|
|
29
|
+
- !ruby/object:Gem::Version
|
|
30
|
+
version: '6.0'
|
|
31
|
+
- - "<"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '9.0'
|
|
34
|
+
- !ruby/object:Gem::Dependency
|
|
35
|
+
name: activerecord
|
|
36
|
+
requirement: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '6.0'
|
|
41
|
+
- - "<"
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '9.0'
|
|
44
|
+
type: :runtime
|
|
45
|
+
prerelease: false
|
|
46
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
47
|
+
requirements:
|
|
48
|
+
- - ">="
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: '6.0'
|
|
51
|
+
- - "<"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '9.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: activesupport
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '6.0'
|
|
61
|
+
- - "<"
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '9.0'
|
|
64
|
+
type: :runtime
|
|
65
|
+
prerelease: false
|
|
66
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '6.0'
|
|
71
|
+
- - "<"
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '9.0'
|
|
74
|
+
- !ruby/object:Gem::Dependency
|
|
75
|
+
name: hanikamu-service
|
|
76
|
+
requirement: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - "~>"
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0.1'
|
|
81
|
+
type: :runtime
|
|
82
|
+
prerelease: false
|
|
83
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - "~>"
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0.1'
|
|
88
|
+
- !ruby/object:Gem::Dependency
|
|
89
|
+
name: redlock
|
|
90
|
+
requirement: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - "~>"
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '2.0'
|
|
95
|
+
type: :runtime
|
|
96
|
+
prerelease: false
|
|
97
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - "~>"
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '2.0'
|
|
102
|
+
description: Ruby gem for building robust service operations with guard validations,
|
|
103
|
+
distributed mutex locks via Redlock, database transactions, and comprehensive error
|
|
104
|
+
handling. Thread-safe and designed for production Rails applications.
|
|
105
|
+
email:
|
|
106
|
+
executables: []
|
|
107
|
+
extensions: []
|
|
108
|
+
extra_rdoc_files: []
|
|
109
|
+
files:
|
|
110
|
+
- CHANGELOG.md
|
|
111
|
+
- Dockerfile
|
|
112
|
+
- LICENSE.txt
|
|
113
|
+
- Makefile
|
|
114
|
+
- README.md
|
|
115
|
+
- Rakefile
|
|
116
|
+
- docker-compose.yml
|
|
117
|
+
- lib/hanikamu-operation.rb
|
|
118
|
+
- lib/hanikamu/operation.rb
|
|
119
|
+
homepage: https://github.com/Hanikamu/hanikamu-operation
|
|
120
|
+
licenses:
|
|
121
|
+
- MIT
|
|
122
|
+
metadata:
|
|
123
|
+
homepage_uri: https://github.com/Hanikamu/hanikamu-operation
|
|
124
|
+
source_code_uri: https://github.com/Hanikamu/hanikamu-operation
|
|
125
|
+
changelog_uri: https://github.com/Hanikamu/hanikamu-operation/blob/main/CHANGELOG.md
|
|
126
|
+
rubygems_mfa_required: 'true'
|
|
127
|
+
post_install_message:
|
|
128
|
+
rdoc_options: []
|
|
129
|
+
require_paths:
|
|
130
|
+
- lib
|
|
131
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
132
|
+
requirements:
|
|
133
|
+
- - ">="
|
|
134
|
+
- !ruby/object:Gem::Version
|
|
135
|
+
version: 3.2.0
|
|
136
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
137
|
+
requirements:
|
|
138
|
+
- - ">="
|
|
139
|
+
- !ruby/object:Gem::Version
|
|
140
|
+
version: '0'
|
|
141
|
+
requirements: []
|
|
142
|
+
rubygems_version: 3.5.3
|
|
143
|
+
signing_key:
|
|
144
|
+
specification_version: 4
|
|
145
|
+
summary: Service objects with guards, distributed locks, and transactions
|
|
146
|
+
test_files: []
|