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/README.md
ADDED
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
# Hanikamu::Operation
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Hanikamu/hanikamu-operation/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
A Ruby gem that extends [hanikamu-service](https://github.com/Hanikamu/hanikamu-service) with advanced operation patterns including distributed locking, database transactions, form validations, and guard conditions. Perfect for building robust, concurrent-safe business operations in Rails applications.
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
1. [Why Hanikamu::Operation?](#why-hanikamuoperation)
|
|
10
|
+
2. [Quick Start](#quick-start)
|
|
11
|
+
3. [Installation](#installation)
|
|
12
|
+
4. [Setup](#setup)
|
|
13
|
+
5. [Usage](#usage)
|
|
14
|
+
- [Basic Operation](#basic-operation)
|
|
15
|
+
- [Distributed Locking](#distributed-locking-with-within_mutex)
|
|
16
|
+
- [Database Transactions](#database-transactions-with-within_transaction)
|
|
17
|
+
- [Form Validations](#form-validations)
|
|
18
|
+
- [Guard Conditions](#guard-conditions)
|
|
19
|
+
- [Block Requirements](#block-requirements)
|
|
20
|
+
- [Complete Example](#complete-example-combining-all-features)
|
|
21
|
+
6. [Error Handling](#error-handling)
|
|
22
|
+
7. [Best Practices](#best-practices)
|
|
23
|
+
8. [Configuration Reference](#configuration-reference)
|
|
24
|
+
9. [Testing](#testing)
|
|
25
|
+
10. [Development](#development)
|
|
26
|
+
11. [Contributing](#contributing)
|
|
27
|
+
12. [License](#license)
|
|
28
|
+
13. [Credits](#credits)
|
|
29
|
+
|
|
30
|
+
## Why Hanikamu::Operation?
|
|
31
|
+
|
|
32
|
+
`hanikamu-operation` builds upon the service object pattern established by `hanikamu-service`, adding critical infrastructure concerns that complex business operations require:
|
|
33
|
+
|
|
34
|
+
### Core Principles from hanikamu-service
|
|
35
|
+
|
|
36
|
+
- **Single Responsibility**: Each operation encapsulates one business transaction
|
|
37
|
+
- **Type Safety**: Input validation via dry-struct type checking
|
|
38
|
+
- **Monadic Error Handling**: `.call` returns `Success` or `Failure` monads; `.call!` raises exceptions
|
|
39
|
+
- **Clean Architecture**: Business logic isolated from models and controllers
|
|
40
|
+
- **Predictable Interface**: All operations follow the same `.call` / `.call!` pattern
|
|
41
|
+
|
|
42
|
+
### Extended Operation Capabilities
|
|
43
|
+
|
|
44
|
+
Building on this foundation, `hanikamu-operation` adds:
|
|
45
|
+
|
|
46
|
+
- **Distributed Locking**: Prevent race conditions across multiple processes/servers using Redis locks (Redlock algorithm)
|
|
47
|
+
- **Database Transactions**: Wrap operations in ActiveRecord transactions with automatic rollback
|
|
48
|
+
- **Form Validations**: ActiveModel validations on the operation itself
|
|
49
|
+
- **Guard Conditions**: Pre-execution business rule validation (e.g., permissions, state checks)
|
|
50
|
+
- **Block Requirements**: Enforce callback patterns for operations that need them
|
|
51
|
+
|
|
52
|
+
### When to Use
|
|
53
|
+
|
|
54
|
+
Use `Hanikamu::Operation` (instead of plain `Hanikamu::Service`) when your business logic requires:
|
|
55
|
+
|
|
56
|
+
- **Concurrency Control**: Multiple users/processes might execute the same operation simultaneously
|
|
57
|
+
- **Transactional Integrity**: Multiple database changes must succeed/fail atomically
|
|
58
|
+
- **Complex Validation**: Both input validation AND business rule validation
|
|
59
|
+
- **State Guards**: Pre-conditions that determine if the operation can proceed
|
|
60
|
+
- **Critical Sections**: Code that must not be interrupted or run concurrently
|
|
61
|
+
|
|
62
|
+
## Quick Start
|
|
63
|
+
|
|
64
|
+
**1. Install the gem**
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# Gemfile
|
|
68
|
+
gem 'hanikamu-operation', '~> 0.1.0'
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bundle install
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**2. Configure Redis (required for distributed locking)**
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# config/initializers/hanikamu_operation.rb
|
|
79
|
+
require 'redis-client'
|
|
80
|
+
|
|
81
|
+
Hanikamu::Operation.configure do |config|
|
|
82
|
+
config.redis_client = RedisClient.new(url: ENV.fetch('REDIS_URL'))
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**3. Create an operation**
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
class Payments::ChargeOperation < Hanikamu::Operation
|
|
90
|
+
attribute :user_id, Types::Integer
|
|
91
|
+
validates :user_id, presence: true
|
|
92
|
+
|
|
93
|
+
within_mutex(:mutex_lock)
|
|
94
|
+
within_transaction(:base)
|
|
95
|
+
|
|
96
|
+
def execute
|
|
97
|
+
user = User.find(user_id)
|
|
98
|
+
user.charge!
|
|
99
|
+
response user: user
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def mutex_lock
|
|
105
|
+
"user:#{user_id}:charge"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**4. Call the operation**
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
# Raises exceptions on failure
|
|
114
|
+
Payments::ChargeOperation.call!(user_id: current_user.id)
|
|
115
|
+
|
|
116
|
+
# Returns Success/Failure monad
|
|
117
|
+
result = Payments::ChargeOperation.call(user_id: current_user.id)
|
|
118
|
+
if result.success?
|
|
119
|
+
user = result.success.user
|
|
120
|
+
else
|
|
121
|
+
errors = result.failure
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Installation
|
|
126
|
+
|
|
127
|
+
Add to your application's Gemfile:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
gem 'hanikamu-operation', '~> 0.1.0'
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Then execute:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
bundle install
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Setup
|
|
140
|
+
|
|
141
|
+
### Rails Application Setup Guide
|
|
142
|
+
|
|
143
|
+
Follow these steps to integrate Hanikamu::Operation into a Rails application:
|
|
144
|
+
|
|
145
|
+
**Step 1: Add the gem to your Gemfile**
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
# Gemfile
|
|
149
|
+
gem 'hanikamu-operation', '~> 0.1.0'
|
|
150
|
+
gem 'redis-client', '~> 0.22' # Required for distributed locking
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
bundle install
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Step 2: Define your Types module**
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
# app/types.rb
|
|
161
|
+
module Types
|
|
162
|
+
include Dry.Types()
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**Step 3: Create the initializer**
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
# config/initializers/hanikamu_operation.rb
|
|
170
|
+
require 'redis-client'
|
|
171
|
+
|
|
172
|
+
Hanikamu::Operation.configure do |config|
|
|
173
|
+
config.redis_client = RedisClient.new(
|
|
174
|
+
url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
|
|
175
|
+
reconnect_attempts: 3,
|
|
176
|
+
timeout: 1.0
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Step 4: Add Redis to your development environment**
|
|
182
|
+
|
|
183
|
+
For Docker Compose:
|
|
184
|
+
|
|
185
|
+
```yaml
|
|
186
|
+
# docker-compose.yml
|
|
187
|
+
services:
|
|
188
|
+
redis:
|
|
189
|
+
image: redis:7-alpine
|
|
190
|
+
ports:
|
|
191
|
+
- "6379:6379"
|
|
192
|
+
volumes:
|
|
193
|
+
- redis_data:/data
|
|
194
|
+
|
|
195
|
+
web:
|
|
196
|
+
# ... your Rails app config
|
|
197
|
+
environment:
|
|
198
|
+
REDIS_URL: redis://redis:6379/0
|
|
199
|
+
depends_on:
|
|
200
|
+
- redis
|
|
201
|
+
|
|
202
|
+
volumes:
|
|
203
|
+
redis_data:
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
For local development without Docker:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
# macOS with Homebrew
|
|
210
|
+
brew install redis
|
|
211
|
+
brew services start redis
|
|
212
|
+
|
|
213
|
+
# Your .env file
|
|
214
|
+
REDIS_URL=redis://localhost:6379/0
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Step 5: Create your first operation**
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
# Create operations directory
|
|
221
|
+
mkdir -p app/operations/users
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
# app/operations/users/create_user_operation.rb
|
|
226
|
+
module Users
|
|
227
|
+
class CreateUserOperation < Hanikamu::Operation
|
|
228
|
+
attribute :email, Types::String
|
|
229
|
+
attribute :password, Types::String
|
|
230
|
+
|
|
231
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
232
|
+
validates :password, length: { minimum: 8 }
|
|
233
|
+
|
|
234
|
+
within_transaction(:base)
|
|
235
|
+
|
|
236
|
+
def execute
|
|
237
|
+
user = User.create!(
|
|
238
|
+
email: email,
|
|
239
|
+
password: password
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
response user: user
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Step 6: Use in your controller**
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
# app/controllers/users_controller.rb
|
|
252
|
+
class UsersController < ApplicationController
|
|
253
|
+
def create
|
|
254
|
+
result = Users::CreateUserOperation.call(user_params)
|
|
255
|
+
|
|
256
|
+
if result.success?
|
|
257
|
+
user = result.success.user
|
|
258
|
+
render json: { user: user }, status: :created
|
|
259
|
+
else
|
|
260
|
+
error = result.failure
|
|
261
|
+
case error
|
|
262
|
+
when Hanikamu::Operation::FormError
|
|
263
|
+
render json: { errors: error.errors.full_messages }, status: :unprocessable_entity
|
|
264
|
+
else
|
|
265
|
+
render json: { error: error.message }, status: :internal_server_error
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
private
|
|
271
|
+
|
|
272
|
+
def user_params
|
|
273
|
+
params.require(:user).permit(:email, :password)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**Step 7: Configure for production**
|
|
279
|
+
|
|
280
|
+
Set your Redis URL in production (Heroku, AWS, etc.):
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
# Heroku
|
|
284
|
+
heroku addons:create heroku-redis:mini
|
|
285
|
+
# REDIS_URL is automatically set
|
|
286
|
+
|
|
287
|
+
# Or set manually
|
|
288
|
+
heroku config:set REDIS_URL=redis://your-redis-host:6379/0
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Detailed Configuration Options
|
|
292
|
+
|
|
293
|
+
If you need more control, create a detailed initializer:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
# config/initializers/hanikamu_operation.rb
|
|
297
|
+
require 'redis-client'
|
|
298
|
+
|
|
299
|
+
Hanikamu::Operation.configure do |config|
|
|
300
|
+
# Required: Redis client for distributed locking
|
|
301
|
+
config.redis_client = RedisClient.new(
|
|
302
|
+
url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
|
|
303
|
+
reconnect_attempts: 3,
|
|
304
|
+
timeout: 1.0
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Optional: Customize Redlock settings (these are the defaults)
|
|
308
|
+
config.mutex_expire_milliseconds = 1500 # Lock TTL
|
|
309
|
+
config.redlock_retry_count = 6 # Number of retry attempts
|
|
310
|
+
config.redlock_retry_delay = 500 # Milliseconds between retries
|
|
311
|
+
config.redlock_retry_jitter = 50 # Random jitter to prevent thundering herd
|
|
312
|
+
config.redlock_timeout = 0.1 # Redis command timeout
|
|
313
|
+
|
|
314
|
+
# Optional: Add errors to whitelist (Redlock::LockError is always included by default)
|
|
315
|
+
config.whitelisted_errors = [CustomBusinessError]
|
|
316
|
+
end
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Redis Setup by Environment
|
|
320
|
+
|
|
321
|
+
**For Development (Docker Compose)**:
|
|
322
|
+
|
|
323
|
+
```yaml
|
|
324
|
+
# docker-compose.yml
|
|
325
|
+
services:
|
|
326
|
+
redis:
|
|
327
|
+
image: redis:7-alpine
|
|
328
|
+
volumes:
|
|
329
|
+
- redis_data:/data
|
|
330
|
+
networks:
|
|
331
|
+
- app_network
|
|
332
|
+
|
|
333
|
+
app:
|
|
334
|
+
# ... your app config
|
|
335
|
+
environment:
|
|
336
|
+
REDIS_URL: redis://redis:6379/0
|
|
337
|
+
depends_on:
|
|
338
|
+
- redis
|
|
339
|
+
networks:
|
|
340
|
+
- app_network
|
|
341
|
+
|
|
342
|
+
volumes:
|
|
343
|
+
redis_data:
|
|
344
|
+
|
|
345
|
+
networks:
|
|
346
|
+
app_network:
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
**For Production**:
|
|
350
|
+
|
|
351
|
+
Use a managed Redis service (AWS ElastiCache, Heroku Redis, Redis Labs, etc.) and set the `REDIS_URL` environment variable.
|
|
352
|
+
|
|
353
|
+
## Usage
|
|
354
|
+
|
|
355
|
+
Hanikamu::Operation provides five key features that you can combine as needed:
|
|
356
|
+
|
|
357
|
+
| Feature | Declaration | Purpose |
|
|
358
|
+
|---------|-------------|----------|
|
|
359
|
+
| **Input Attributes** | `attribute :name, Type` | Define typed input parameters |
|
|
360
|
+
| **Form Validations** | `validates :field, ...` | Validate input values (format, presence, etc.) |
|
|
361
|
+
| **Guard Conditions** | `guard do ... end` | Validate business rules and state before execution |
|
|
362
|
+
| **Distributed Locking** | `within_mutex(:method)` | Prevent concurrent execution of the same resource |
|
|
363
|
+
| **Database Transactions** | `within_transaction(:base)` | Wrap execution in an atomic database transaction |
|
|
364
|
+
| **Block Requirement** | `block true` | Require a block to be passed to the operation |
|
|
365
|
+
|
|
366
|
+
### Basic Operation
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
module Types
|
|
370
|
+
include Dry.Types()
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
class CreatePayment < Hanikamu::Operation
|
|
374
|
+
attribute :user_id, Types::Integer
|
|
375
|
+
attribute :amount_cents, Types::Integer
|
|
376
|
+
attribute :payment_method_id, Types::String
|
|
377
|
+
|
|
378
|
+
validates :amount_cents, numericality: { greater_than: 0 }
|
|
379
|
+
|
|
380
|
+
def execute
|
|
381
|
+
payment = Payment.create!(
|
|
382
|
+
user_id: user_id,
|
|
383
|
+
amount_cents: amount_cents,
|
|
384
|
+
payment_method_id: payment_method_id,
|
|
385
|
+
status: 'completed'
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
response payment: payment
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Usage
|
|
393
|
+
result = CreatePayment.call!(user_id: 123, amount_cents: 5000, payment_method_id: 'pm_123')
|
|
394
|
+
# => #<struct payment=#<Payment...>>
|
|
395
|
+
|
|
396
|
+
# Or with monadic interface
|
|
397
|
+
result = CreatePayment.call(user_id: 123, amount_cents: 5000, payment_method_id: 'pm_123')
|
|
398
|
+
if result.success?
|
|
399
|
+
payment = result.success.payment
|
|
400
|
+
else
|
|
401
|
+
error = result.failure
|
|
402
|
+
end
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### Distributed Locking with `within_mutex`
|
|
406
|
+
|
|
407
|
+
Prevent race conditions by ensuring only one process can execute the operation for a specific resource at a time. Uses the Redlock algorithm for distributed systems safety.
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
class ProcessSubscriptionRenewal < Hanikamu::Operation
|
|
411
|
+
attribute :subscription_id, Types::Integer
|
|
412
|
+
|
|
413
|
+
# The :mutex_lock method will be called to generate the lock identifier
|
|
414
|
+
within_mutex(:mutex_lock, expire_milliseconds: 3000)
|
|
415
|
+
|
|
416
|
+
def execute
|
|
417
|
+
subscription = Subscription.find(subscription_id)
|
|
418
|
+
subscription.renew!
|
|
419
|
+
subscription.charge_payment!
|
|
420
|
+
|
|
421
|
+
response subscription: subscription
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
private
|
|
425
|
+
|
|
426
|
+
# This method returns the Redis lock key
|
|
427
|
+
# Must be unique per resource you want to lock
|
|
428
|
+
def mutex_lock
|
|
429
|
+
"subscription:#{subscription_id}:renewal"
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# If another process holds the lock, this raises Redlock::LockError
|
|
434
|
+
ProcessSubscriptionRenewal.call!(subscription_id: 456)
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
**How it works**:
|
|
438
|
+
1. `within_mutex(:method_name)` tells the operation which method to call for the lock key
|
|
439
|
+
2. Before `execute` runs, the operation calls your method (e.g., `mutex_lock`) to get a unique string
|
|
440
|
+
3. It attempts to acquire a distributed lock using that key
|
|
441
|
+
4. If successful, `execute` runs and the lock is released afterward
|
|
442
|
+
5. If the lock can't be acquired, raises `Redlock::LockError`
|
|
443
|
+
6. Locks automatically expire after `expire_milliseconds` (default: 1500ms) to prevent deadlocks
|
|
444
|
+
|
|
445
|
+
**Key points**:
|
|
446
|
+
- The method name (`:mutex_lock`) can be anything you want
|
|
447
|
+
- The method must return a string that uniquely identifies the resource being locked
|
|
448
|
+
- Use different lock keys for different types of operations on the same resource
|
|
449
|
+
- Common pattern: `"resource_type:#{id}:operation_name"`
|
|
450
|
+
|
|
451
|
+
**Common patterns**:
|
|
452
|
+
```ruby
|
|
453
|
+
# Lock by resource ID
|
|
454
|
+
within_mutex(:mutex_lock)
|
|
455
|
+
|
|
456
|
+
def mutex_lock
|
|
457
|
+
"stream:#{stream_id}:processing"
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Lock by multiple attributes
|
|
461
|
+
within_mutex(:mutex_lock)
|
|
462
|
+
|
|
463
|
+
def mutex_lock
|
|
464
|
+
"user:#{user_id}:account:#{account_id}:transfer"
|
|
465
|
+
end
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### Database Transactions with `within_transaction`
|
|
469
|
+
|
|
470
|
+
Ensure multiple database changes succeed or fail together atomically. If any database operation raises an exception, all changes are rolled back.
|
|
471
|
+
|
|
472
|
+
```ruby
|
|
473
|
+
class TransferFunds < Hanikamu::Operation
|
|
474
|
+
attribute :from_account_id, Types::Integer
|
|
475
|
+
attribute :to_account_id, Types::Integer
|
|
476
|
+
attribute :amount_cents, Types::Integer
|
|
477
|
+
|
|
478
|
+
validates :amount_cents, numericality: { greater_than: 0 }
|
|
479
|
+
|
|
480
|
+
# Wrap the execute method in a database transaction
|
|
481
|
+
within_transaction(:base)
|
|
482
|
+
|
|
483
|
+
def execute
|
|
484
|
+
from_account = Account.lock.find(from_account_id)
|
|
485
|
+
to_account = Account.lock.find(to_account_id)
|
|
486
|
+
|
|
487
|
+
from_account.withdraw!(amount_cents)
|
|
488
|
+
to_account.deposit!(amount_cents)
|
|
489
|
+
|
|
490
|
+
response(
|
|
491
|
+
from_account: from_account,
|
|
492
|
+
to_account: to_account
|
|
493
|
+
)
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Both withdraw and deposit happen atomically
|
|
498
|
+
# If either fails, both are rolled back
|
|
499
|
+
TransferFunds.call!(from_account_id: 1, to_account_id: 2, amount_cents: 10000)
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
**Transaction Options**:
|
|
503
|
+
- `within_transaction(:base)` - Use `ActiveRecord::Base.transaction` (most common)
|
|
504
|
+
- `within_transaction(User)` - Use a specific model's transaction (useful for multiple databases)
|
|
505
|
+
|
|
506
|
+
**Important**: Use transactions when you have multiple database writes that must succeed or fail together. Without a transaction, if the second write fails, the first write remains in the database.
|
|
507
|
+
|
|
508
|
+
### Form Validations
|
|
509
|
+
|
|
510
|
+
Validate input values using familiar ActiveModel validations. These run after type checking but before guards and execution.
|
|
511
|
+
|
|
512
|
+
```ruby
|
|
513
|
+
class RegisterUser < Hanikamu::Operation
|
|
514
|
+
attribute :email, Types::String
|
|
515
|
+
attribute :password, Types::String
|
|
516
|
+
attribute :age, Types::Integer
|
|
517
|
+
|
|
518
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
519
|
+
validates :password, length: { minimum: 8 }
|
|
520
|
+
validates :age, numericality: { greater_than_or_equal_to: 18 }
|
|
521
|
+
|
|
522
|
+
def execute
|
|
523
|
+
user = User.create!(email: email, password: password, age: age)
|
|
524
|
+
response user: user
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Invalid inputs raise FormError
|
|
529
|
+
RegisterUser.call!(email: 'invalid', password: '123', age: 15)
|
|
530
|
+
# => Hanikamu::Operation::FormError: Email is invalid, Password is too short, Age must be >= 18
|
|
531
|
+
|
|
532
|
+
# With monadic interface
|
|
533
|
+
result = RegisterUser.call(email: 'invalid', password: '123', age: 15)
|
|
534
|
+
result.failure.errors.full_messages
|
|
535
|
+
# => ["Email is invalid", "Password is too short (minimum is 8 characters)", "Age must be greater than or equal to 18"]
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
**When to use**: Validate input format, presence, length, format, or value ranges. If correcting the input arguments could make the operation succeed, use form validations.
|
|
539
|
+
|
|
540
|
+
### Guard Conditions
|
|
541
|
+
|
|
542
|
+
Validate business rules, permissions, and system state before execution. Unlike form validations (which check input values), guards check whether the operation can proceed given the current state of your system.
|
|
543
|
+
|
|
544
|
+
```ruby
|
|
545
|
+
class PublishArticle < Hanikamu::Operation
|
|
546
|
+
attribute :article_id, Types::Integer
|
|
547
|
+
attribute :user_id, Types::Integer
|
|
548
|
+
|
|
549
|
+
# Define guard conditions using a block
|
|
550
|
+
guard do
|
|
551
|
+
# Access operation attributes directly using delegates
|
|
552
|
+
delegates :article_id, :user_id
|
|
553
|
+
|
|
554
|
+
validate :user_must_be_author
|
|
555
|
+
validate :article_must_be_draft
|
|
556
|
+
|
|
557
|
+
def article
|
|
558
|
+
@article ||= Article.find(article_id)
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def user
|
|
562
|
+
@user ||= User.find(user_id)
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def user_must_be_author
|
|
566
|
+
errors.add(:user, "must be the article author") unless article.user_id == user.id
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def article_must_be_draft
|
|
570
|
+
errors.add(:article, "must be in draft status") unless article.draft?
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def execute
|
|
575
|
+
article = Article.find(article_id)
|
|
576
|
+
article.update!(status: 'published', published_at: Time.current)
|
|
577
|
+
|
|
578
|
+
response article: article
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# Raises GuardError if guards fail
|
|
583
|
+
PublishArticle.call!(article_id: 999, user_id: 1)
|
|
584
|
+
# => Hanikamu::Operation::GuardError: User must be the article author, Article must be in draft status
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
**Form Validations vs Guards**:
|
|
588
|
+
|
|
589
|
+
| | Form Validations | Guards |
|
|
590
|
+
|---|---|---|
|
|
591
|
+
| **Purpose** | Validate input values | Validate system state and business rules |
|
|
592
|
+
| **Example** | Email format, password length | User permissions, resource status |
|
|
593
|
+
| **Error** | `FormError` | `GuardError` |
|
|
594
|
+
| **When** | After type check, before guards | After validations, before execution |
|
|
595
|
+
| **Can succeed later?** | Yes, by correcting inputs | Maybe, if system state changes |
|
|
596
|
+
|
|
597
|
+
**When to use guards**: Check permissions, verify resource state, enforce business rules that depend on the current state of your system (not just the input values).
|
|
598
|
+
|
|
599
|
+
### Block Requirements
|
|
600
|
+
|
|
601
|
+
Some operations need to yield data back to the caller (for batch processing, streaming, etc.). Use `block true` to enforce that a block is provided.
|
|
602
|
+
|
|
603
|
+
```ruby
|
|
604
|
+
class BatchProcessRecords < Hanikamu::Operation
|
|
605
|
+
attribute :record_ids, Types::Array.of(Types::Integer)
|
|
606
|
+
|
|
607
|
+
block true # Callers must provide a block
|
|
608
|
+
|
|
609
|
+
def execute(&block)
|
|
610
|
+
record_ids.each do |id|
|
|
611
|
+
record = Record.find(id)
|
|
612
|
+
yield record # Pass each record to the caller
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
response processed_count: record_ids.size
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Valid usage with a block
|
|
620
|
+
BatchProcessRecords.call!(record_ids: [1, 2, 3]) do |record|
|
|
621
|
+
puts "Processing #{record.id}"
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Calling without a block raises an error
|
|
625
|
+
BatchProcessRecords.call!(record_ids: [1, 2, 3])
|
|
626
|
+
# => Hanikamu::Operation::MissingBlockError: This service requires a block to be called
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
**When to use**: Batch processors, iterators, or any operation where the caller needs to handle each item individually.
|
|
630
|
+
|
|
631
|
+
### Complete Example: Combining All Features
|
|
632
|
+
|
|
633
|
+
```ruby
|
|
634
|
+
class CheckoutOrder < Hanikamu::Operation
|
|
635
|
+
attribute :order_id, Types::Integer
|
|
636
|
+
attribute :user_id, Types::Integer
|
|
637
|
+
attribute :payment_method_id, Types::String
|
|
638
|
+
|
|
639
|
+
# Form validation
|
|
640
|
+
validates :payment_method_id, presence: true
|
|
641
|
+
|
|
642
|
+
# Guard conditions using a block
|
|
643
|
+
guard do
|
|
644
|
+
# Shortcut helper to delegate to the operation instance
|
|
645
|
+
delegates :order_id, :user_id
|
|
646
|
+
|
|
647
|
+
validate :user_owns_order
|
|
648
|
+
validate :order_not_checked_out
|
|
649
|
+
validate :sufficient_inventory
|
|
650
|
+
|
|
651
|
+
def order
|
|
652
|
+
@order ||= Order.find(order_id)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def user
|
|
656
|
+
@user ||= User.find(user_id)
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def user_owns_order
|
|
660
|
+
errors.add(:order, "does not belong to user") unless order.user_id == user.id
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def order_not_checked_out
|
|
664
|
+
errors.add(:order, "already checked out") if order.checked_out?
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def sufficient_inventory
|
|
668
|
+
order.line_items.each do |item|
|
|
669
|
+
if item.product.stock < item.quantity
|
|
670
|
+
errors.add(:base, "Insufficient stock for #{item.product.name}")
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Distributed lock to prevent double-checkout
|
|
677
|
+
within_mutex(:mutex_lock, expire_milliseconds: 5000)
|
|
678
|
+
|
|
679
|
+
# Database transaction for atomicity
|
|
680
|
+
within_transaction(:base)
|
|
681
|
+
|
|
682
|
+
def execute
|
|
683
|
+
order = Order.find(order_id)
|
|
684
|
+
|
|
685
|
+
# Decrease inventory
|
|
686
|
+
order.line_items.each do |item|
|
|
687
|
+
item.product.decrement!(:stock, item.quantity)
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
# Process payment
|
|
691
|
+
payment = Payment.create!(
|
|
692
|
+
order: order,
|
|
693
|
+
user_id: user_id,
|
|
694
|
+
amount_cents: order.total_cents,
|
|
695
|
+
payment_method_id: payment_method_id
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
# Mark order as checked out
|
|
699
|
+
order.update!(
|
|
700
|
+
status: 'completed',
|
|
701
|
+
checked_out_at: Time.current
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
response order: order, payment: payment
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
private
|
|
708
|
+
|
|
709
|
+
def mutex_lock
|
|
710
|
+
"order:#{order_id}:checkout"
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# Usage
|
|
715
|
+
result = CheckoutOrder.call(
|
|
716
|
+
order_id: 789,
|
|
717
|
+
user_id: 123,
|
|
718
|
+
payment_method_id: 'pm_abc'
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
if result.success?
|
|
722
|
+
order = result.success.order
|
|
723
|
+
payment = result.success.payment
|
|
724
|
+
# Send confirmation email, etc.
|
|
725
|
+
else
|
|
726
|
+
# Handle FormError, GuardError, or other failures
|
|
727
|
+
errors = result.failure
|
|
728
|
+
end
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
## Error Handling
|
|
732
|
+
|
|
733
|
+
### Understanding Validation Layers
|
|
734
|
+
|
|
735
|
+
Operations validate at three distinct levels, each serving a specific purpose:
|
|
736
|
+
|
|
737
|
+
**1. Type Validation (Dry::Struct::Error)**
|
|
738
|
+
- Validates that input arguments are of the correct type
|
|
739
|
+
- Raised automatically by dry-struct before the operation executes
|
|
740
|
+
- Example: Passing a string when an integer is expected
|
|
741
|
+
|
|
742
|
+
**2. Form Validation (Hanikamu::Operation::FormError)**
|
|
743
|
+
- Validates input argument values and basic business rules
|
|
744
|
+
- Raised when the provided values don't meet criteria
|
|
745
|
+
- **Key principle**: Correcting the arguments may allow the operation to succeed
|
|
746
|
+
- Examples: Missing required fields, invalid format, duplicate values, out-of-range numbers
|
|
747
|
+
|
|
748
|
+
**3. Guard Validation (Hanikamu::Operation::GuardError)**
|
|
749
|
+
- Validates system state and pre-conditions
|
|
750
|
+
- Raised when arguments are valid but the system state prevents execution
|
|
751
|
+
- **Key principle**: The operation cannot proceed due to current state, regardless of argument changes
|
|
752
|
+
- Examples: Resource already processed, insufficient permissions, preconditions not met
|
|
753
|
+
|
|
754
|
+
### Error Types Reference
|
|
755
|
+
|
|
756
|
+
| Error Class | When Raised | Contains |
|
|
757
|
+
|-------------|-------------|----------|
|
|
758
|
+
| `Dry::Struct::Error` | Type validation fails (wrong argument types) | Type error details |
|
|
759
|
+
| `Hanikamu::Operation::FormError` | Input validation fails (ActiveModel validations) | `errors` - ActiveModel::Errors object |
|
|
760
|
+
| `Hanikamu::Operation::GuardError` | Guard validation fails (business rules/state) | `errors` - ActiveModel::Errors object |
|
|
761
|
+
| `Hanikamu::Operation::MissingBlockError` | Block required but not provided | Standard error message |
|
|
762
|
+
| `Hanikamu::Operation::ConfigurationError` | Redis client not configured | Configuration instructions |
|
|
763
|
+
| `Redlock::LockError` | Cannot acquire distributed lock | Lock details (always whitelisted by default) |
|
|
764
|
+
|
|
765
|
+
### FormError vs GuardError: Practical Examples
|
|
766
|
+
|
|
767
|
+
**FormError Example** - Invalid or incorrect input arguments:
|
|
768
|
+
|
|
769
|
+
```ruby
|
|
770
|
+
# Attempting to create a user with invalid inputs
|
|
771
|
+
result = Users::CreateUserOperation.call(
|
|
772
|
+
email: "taken@example.com",
|
|
773
|
+
password: "short",
|
|
774
|
+
password_confirmation: "wrong"
|
|
775
|
+
)
|
|
776
|
+
# => Failure(#<Hanikamu::Operation::FormError:
|
|
777
|
+
# Email has been taken,
|
|
778
|
+
# Password is too short,
|
|
779
|
+
# Password confirmation does not match password>)
|
|
780
|
+
|
|
781
|
+
# Correcting the arguments allows success
|
|
782
|
+
result = Users::CreateUserOperation.call(
|
|
783
|
+
email: "unique@example.com",
|
|
784
|
+
password: "securePassword123!",
|
|
785
|
+
password_confirmation: "securePassword123!"
|
|
786
|
+
)
|
|
787
|
+
# => Success(#<struct user=#<User id: 46, email: "unique@example.com">>)
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
**GuardError Example** - Valid arguments but invalid system state:
|
|
791
|
+
|
|
792
|
+
```ruby
|
|
793
|
+
# First attempt succeeds
|
|
794
|
+
result = Users::CompleteUserOperation.call!(user_id: 46)
|
|
795
|
+
# => Success(#<struct user=#<User id: 46, completed_at: "2025-11-26">>)
|
|
796
|
+
|
|
797
|
+
# Second attempt fails due to state, even with valid arguments
|
|
798
|
+
result = Users::CompleteUserOperation.call!(user_id: 46)
|
|
799
|
+
# => Failure(#<Hanikamu::Operation::GuardError: User has already been completed>)
|
|
800
|
+
|
|
801
|
+
# The arguments are still correct, but the operation cannot proceed
|
|
802
|
+
# because the user's state has changed
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
**Type Error Example** - Wrong argument type:
|
|
806
|
+
|
|
807
|
+
```ruby
|
|
808
|
+
# Passing wrong type raises immediately
|
|
809
|
+
Users::CompleteUserOperation.call!(user_id: "not-a-number")
|
|
810
|
+
# => Raises Dry::Struct::Error
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
### Using `.call!` (Raises Exceptions)
|
|
814
|
+
|
|
815
|
+
```ruby
|
|
816
|
+
begin
|
|
817
|
+
result = CreatePayment.call!(user_id: 1, amount_cents: -100, payment_method_id: 'pm_123')
|
|
818
|
+
rescue Hanikamu::Operation::FormError => e
|
|
819
|
+
# Input validation failed
|
|
820
|
+
puts e.message # => "Amount cents must be greater than 0"
|
|
821
|
+
puts e.errors.full_messages
|
|
822
|
+
rescue Hanikamu::Operation::GuardError => e
|
|
823
|
+
# Business rule validation failed
|
|
824
|
+
puts e.errors.full_messages
|
|
825
|
+
rescue Redlock::LockError => e
|
|
826
|
+
# Could not acquire distributed lock
|
|
827
|
+
puts "Operation locked, try again later"
|
|
828
|
+
end
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
### Using `.call` (Returns Monads)
|
|
832
|
+
|
|
833
|
+
```ruby
|
|
834
|
+
result = CreatePayment.call(user_id: 1, amount_cents: -100, payment_method_id: 'pm_123')
|
|
835
|
+
|
|
836
|
+
case result
|
|
837
|
+
when Dry::Monads::Success
|
|
838
|
+
payment = result.success.payment
|
|
839
|
+
puts "Payment created: #{payment.id}"
|
|
840
|
+
when Dry::Monads::Failure
|
|
841
|
+
error = result.failure
|
|
842
|
+
|
|
843
|
+
case error
|
|
844
|
+
when Hanikamu::Operation::FormError
|
|
845
|
+
puts "Validation errors: #{error.errors.full_messages.join(', ')}"
|
|
846
|
+
when Hanikamu::Operation::GuardError
|
|
847
|
+
puts "Business rule violated: #{error.errors.full_messages.join(', ')}"
|
|
848
|
+
when Redlock::LockError
|
|
849
|
+
puts "Resource locked, try again"
|
|
850
|
+
else
|
|
851
|
+
puts "Unknown error: #{error.message}"
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
## Best Practices
|
|
857
|
+
|
|
858
|
+
### Single Responsibility Principle
|
|
859
|
+
Each operation should handle one specific type of state change with a clear, unambiguous interface. Avoid operations that do multiple unrelated things.
|
|
860
|
+
|
|
861
|
+
### Naming Conventions
|
|
862
|
+
|
|
863
|
+
Operations should follow this naming pattern:
|
|
864
|
+
|
|
865
|
+
**Format**: `[Namespace(s)]::[Verb][Noun]Operation`
|
|
866
|
+
|
|
867
|
+
Examples:
|
|
868
|
+
- `Users::CreateUserOperation`
|
|
869
|
+
- `Orders::CompleteCheckoutOperation`
|
|
870
|
+
- `Payments::ProcessRefundOperation`
|
|
871
|
+
- `Portfolios::Saxo::CreateTransactionsOperation`
|
|
872
|
+
|
|
873
|
+
Use imperative verb forms (Create, Update, Complete, Process, Cancel) that clearly communicate the action being performed.
|
|
874
|
+
|
|
875
|
+
### Robust Validation Strategy
|
|
876
|
+
|
|
877
|
+
1. **Type Safety First**: Use Dry::Types for all attributes to catch type errors early
|
|
878
|
+
2. **Form Validations**: Validate argument values using ActiveModel validations
|
|
879
|
+
3. **Guard Conditions**: Validate system state and preconditions before execution
|
|
880
|
+
4. **Clear Error Messages**: Provide actionable error messages that guide users to corrections
|
|
881
|
+
|
|
882
|
+
### Use the Response Helper
|
|
883
|
+
|
|
884
|
+
Always return a response struct from your operations:
|
|
885
|
+
|
|
886
|
+
```ruby
|
|
887
|
+
def execute
|
|
888
|
+
user = User.create!(email: email, password: password)
|
|
889
|
+
|
|
890
|
+
# Good: Explicit response with clear interface
|
|
891
|
+
response user: user
|
|
892
|
+
|
|
893
|
+
# Avoid: Implicit return
|
|
894
|
+
# user
|
|
895
|
+
end
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
Benefits:
|
|
899
|
+
- Provides clear interface for testing
|
|
900
|
+
- Makes return values explicit
|
|
901
|
+
- Allows for easy extension (add more fields to response)
|
|
902
|
+
|
|
903
|
+
### Comprehensive Testing
|
|
904
|
+
|
|
905
|
+
Write tests for each operation covering:
|
|
906
|
+
- **Happy path**: Valid inputs and successful execution
|
|
907
|
+
- **Type validation**: Wrong argument types
|
|
908
|
+
- **Form validation**: Invalid argument values
|
|
909
|
+
- **Guard validation**: Invalid system states
|
|
910
|
+
- **Edge cases**: Boundary conditions and race scenarios
|
|
911
|
+
- **Concurrency**: Multiple simultaneous executions (if using mutexes)
|
|
912
|
+
|
|
913
|
+
### Transaction and Lock Ordering
|
|
914
|
+
|
|
915
|
+
When combining features, use this order:
|
|
916
|
+
|
|
917
|
+
```ruby
|
|
918
|
+
class MyOperation < Hanikamu::Operation
|
|
919
|
+
# 1. Guards (validate state first)
|
|
920
|
+
guard do
|
|
921
|
+
# validations
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
# 2. Mutex (acquire lock)
|
|
925
|
+
within_mutex(:mutex_lock)
|
|
926
|
+
|
|
927
|
+
# 3. Transaction (wrap database changes)
|
|
928
|
+
within_transaction(:base)
|
|
929
|
+
|
|
930
|
+
def execute
|
|
931
|
+
# implementation
|
|
932
|
+
end
|
|
933
|
+
end
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
## Configuration Reference
|
|
937
|
+
|
|
938
|
+
```ruby
|
|
939
|
+
Hanikamu::Operation.configure do |config|
|
|
940
|
+
# Required
|
|
941
|
+
config.redis_client = RedisClient.new(url: ENV['REDIS_URL'])
|
|
942
|
+
|
|
943
|
+
# Optional Redlock settings (defaults shown)
|
|
944
|
+
config.mutex_expire_milliseconds = 1500 # Lock expires after 1.5 seconds
|
|
945
|
+
config.redlock_retry_count = 6 # Retry 6 times
|
|
946
|
+
config.redlock_retry_delay = 500 # Wait 500ms between retries
|
|
947
|
+
config.redlock_retry_jitter = 50 # Add ±50ms random jitter
|
|
948
|
+
config.redlock_timeout = 0.1 # Redis command timeout: 100ms
|
|
949
|
+
|
|
950
|
+
# Optional error whitelisting (Redlock::LockError always included by default)
|
|
951
|
+
config.whitelisted_errors = [] # Add custom errors here
|
|
952
|
+
end
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
## Testing
|
|
956
|
+
|
|
957
|
+
When testing operations with distributed locks, configure a test Redis instance:
|
|
958
|
+
|
|
959
|
+
```ruby
|
|
960
|
+
# spec/spec_helper.rb or test/test_helper.rb
|
|
961
|
+
|
|
962
|
+
require 'redis-client'
|
|
963
|
+
|
|
964
|
+
RSpec.configure do |config|
|
|
965
|
+
config.before(:suite) do
|
|
966
|
+
Hanikamu::Operation.config.redis_client = RedisClient.new(
|
|
967
|
+
url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/1') # Use DB 1 for tests
|
|
968
|
+
)
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
config.after(:each) do
|
|
972
|
+
# Clean up Redis between tests if needed
|
|
973
|
+
Hanikamu::Operation.config.redis_client.call('FLUSHDB')
|
|
974
|
+
end
|
|
975
|
+
end
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
**Testing Locked Operations**:
|
|
979
|
+
|
|
980
|
+
```ruby
|
|
981
|
+
RSpec.describe ProcessSubscriptionRenewal do
|
|
982
|
+
it "prevents concurrent execution" do
|
|
983
|
+
subscription = create(:subscription)
|
|
984
|
+
lock_key = "subscription:#{subscription.id}:renewal"
|
|
985
|
+
|
|
986
|
+
# Simulate another process holding the lock
|
|
987
|
+
Hanikamu::Operation.redis_lock.lock!(lock_key, 2000) do
|
|
988
|
+
expect {
|
|
989
|
+
described_class.call!(subscription_id: subscription.id)
|
|
990
|
+
}.to raise_error(Redlock::LockError)
|
|
991
|
+
end
|
|
992
|
+
end
|
|
993
|
+
end
|
|
994
|
+
```
|
|
995
|
+
|
|
996
|
+
## Development
|
|
997
|
+
|
|
998
|
+
```bash
|
|
999
|
+
# Install dependencies
|
|
1000
|
+
bundle install
|
|
1001
|
+
|
|
1002
|
+
# Run tests
|
|
1003
|
+
make rspec
|
|
1004
|
+
|
|
1005
|
+
# Run linter
|
|
1006
|
+
make cops
|
|
1007
|
+
|
|
1008
|
+
# Access console
|
|
1009
|
+
make console
|
|
1010
|
+
|
|
1011
|
+
# Access shell
|
|
1012
|
+
make shell
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
## Contributing
|
|
1016
|
+
|
|
1017
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/Hanikamu/hanikamu-operation.
|
|
1018
|
+
|
|
1019
|
+
## License
|
|
1020
|
+
|
|
1021
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
1022
|
+
|
|
1023
|
+
## Credits
|
|
1024
|
+
|
|
1025
|
+
Built by [Hanikamu](https://github.com/Hanikamu) on top of:
|
|
1026
|
+
- [hanikamu-service](https://github.com/Hanikamu/hanikamu-service) - Base service pattern
|
|
1027
|
+
- [dry-rb](https://dry-rb.org/) - Type system and monads
|
|
1028
|
+
- [Redlock](https://github.com/leandromoreira/redlock-rb) - Distributed locking algorithm
|