spree_cm_commissioner 2.12.7.pre.pre1 → 2.12.8
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 +4 -4
- data/Gemfile.lock +1 -1
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_job.rb +2 -3
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert_job.rb +2 -3
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb +2 -3
- data/app/models/spree_cm_commissioner/inventory_item.rb +2 -22
- data/app/services/spree_cm_commissioner/inventory_holds/acquire.rb +40 -106
- data/app/services/spree_cm_commissioner/inventory_holds/convert.rb +9 -76
- data/app/services/spree_cm_commissioner/inventory_holds/release.rb +6 -48
- data/app/services/spree_cm_commissioner/inventory_items/adjust_available_quantity.rb +3 -4
- data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities.rb +4 -6
- data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_by_variant.rb +1 -11
- data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert.rb +4 -6
- data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold.rb +3 -6
- data/app/services/spree_cm_commissioner/inventory_items/move_to_locked.rb +12 -57
- data/app/services/spree_cm_commissioner/redis_stock/base.rb +2 -3
- data/app/services/spree_cm_commissioner/redis_stock/restock.rb +3 -35
- data/app/services/spree_cm_commissioner/redis_stock/unstock.rb +3 -35
- data/config/locales/en.yml +0 -2
- data/config/locales/km.yml +0 -2
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 742162ce28f156432f76f071c6538e682fecfa8008758acc698d283cb2a3742c
|
|
4
|
+
data.tar.gz: bfafd91f275bc28a16234e3153ee8d83aa5b5040be06acca7850770b2573fe01
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f8c8857a35fe48099949496f9e69c0741ca32a18a05fbc05afafdc1cb5cd7feb3394b3b0158cbb27dea96f585e378f9a4a14c0d2fbac1b8cee6e54c8a51be443
|
|
7
|
+
data.tar.gz: d8e013c7a5172fabbfea21e83ac52d5231351ed380960e1308b458ad1e7b6cd960bb2ca7a6204b82448f3d494c17cb3eb3ebb9a5d704228b901c789dacef09f2
|
data/Gemfile.lock
CHANGED
|
@@ -23,14 +23,13 @@ module SpreeCmCommissioner
|
|
|
23
23
|
"line_item:#{options[:line_item_ids].compact.uniq.sort.join(':')}"
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
# :line_item_ids, :inventory_id_and_quantities, :caller_source
|
|
26
|
+
# :line_item_ids, :inventory_id_and_quantities, :caller_source
|
|
27
27
|
#
|
|
28
28
|
# :caller_source is a string like "ClassName#method" identifying who enqueued the job.
|
|
29
29
|
def bulk_adjust_quantities!(options = {})
|
|
30
30
|
SpreeCmCommissioner::InventoryItems::BulkAdjustQuantities.call!(
|
|
31
31
|
inventory_id_and_quantities: options[:inventory_id_and_quantities],
|
|
32
|
-
caller_source: options[:caller_source]
|
|
33
|
-
correlation_id: options[:correlation_id]
|
|
32
|
+
caller_source: options[:caller_source]
|
|
34
33
|
)
|
|
35
34
|
end
|
|
36
35
|
end
|
data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert_job.rb
CHANGED
|
@@ -32,15 +32,14 @@ module SpreeCmCommissioner
|
|
|
32
32
|
.join(',')
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
# :order_id, :inventory_id_and_quantities, :caller_source
|
|
35
|
+
# :order_id, :inventory_id_and_quantities, :caller_source
|
|
36
36
|
def bulk_adjust_quantities_on_convert(options = {})
|
|
37
37
|
raise ArgumentError, 'order_id is required' if options[:order_id].blank?
|
|
38
38
|
raise ArgumentError, 'inventory_id_and_quantities is required' if options[:inventory_id_and_quantities].blank?
|
|
39
39
|
|
|
40
40
|
SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnConvert.call(
|
|
41
41
|
inventory_id_and_quantities: options[:inventory_id_and_quantities],
|
|
42
|
-
caller_source: options[:caller_source]
|
|
43
|
-
correlation_id: options[:correlation_id]
|
|
42
|
+
caller_source: options[:caller_source]
|
|
44
43
|
)
|
|
45
44
|
end
|
|
46
45
|
end
|
|
@@ -38,7 +38,7 @@ module SpreeCmCommissioner
|
|
|
38
38
|
.join(',')
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
# :order_id, :inventory_id_and_quantities, :caller_source
|
|
41
|
+
# :order_id, :inventory_id_and_quantities, :caller_source
|
|
42
42
|
#
|
|
43
43
|
# :order_id is used for unique idempotency key generation to prevent duplicate
|
|
44
44
|
# processing, though it's not used in the adjustment itself.
|
|
@@ -49,8 +49,7 @@ module SpreeCmCommissioner
|
|
|
49
49
|
|
|
50
50
|
SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnHold.call(
|
|
51
51
|
inventory_id_and_quantities: options[:inventory_id_and_quantities],
|
|
52
|
-
caller_source: options[:caller_source]
|
|
53
|
-
correlation_id: options[:correlation_id]
|
|
52
|
+
caller_source: options[:caller_source]
|
|
54
53
|
)
|
|
55
54
|
end
|
|
56
55
|
end
|
|
@@ -107,10 +107,7 @@ module SpreeCmCommissioner
|
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
# This method is only used when admin update stock
|
|
110
|
-
def adjust_quantity!(quantity
|
|
111
|
-
correlation_id ||= SecureRandom.uuid
|
|
112
|
-
quantity_available_before = redis_quantity_after = nil
|
|
113
|
-
|
|
110
|
+
def adjust_quantity!(quantity)
|
|
114
111
|
with_lock do
|
|
115
112
|
# IMPORTANT: Apply quantity changes directly without defensive clamping.
|
|
116
113
|
# The model validation will catch any attempts to go negative, surfacing bugs
|
|
@@ -121,8 +118,6 @@ module SpreeCmCommissioner
|
|
|
121
118
|
#
|
|
122
119
|
# Why? Clamping masks bugs. Validation errors are better than silent data loss.
|
|
123
120
|
# See: /docs/lessons-learned/inventory-consistency-issues.md#lesson-learned-async-job-validation-strategy
|
|
124
|
-
quantity_available_before = quantity_available
|
|
125
|
-
|
|
126
121
|
self.max_capacity = max_capacity + quantity
|
|
127
122
|
self.quantity_available = quantity_available + quantity
|
|
128
123
|
save!
|
|
@@ -132,23 +127,8 @@ module SpreeCmCommissioner
|
|
|
132
127
|
# high-contention hot path, so keeping it in the same transaction as `save!` means a
|
|
133
128
|
# Redis failure rolls the DB change back too, instead of leaving the DB and Redis counts
|
|
134
129
|
# out of sync with nothing to reconcile them.
|
|
135
|
-
|
|
130
|
+
adjust_quantity_in_redis(quantity)
|
|
136
131
|
end
|
|
137
|
-
|
|
138
|
-
# Logged after with_lock releases: I/O here would otherwise extend how long the row's
|
|
139
|
-
# SELECT ... FOR UPDATE is held under high-traffic contention — same reasoning as the other
|
|
140
|
-
# Bulk* adjusters in this gem.
|
|
141
|
-
CmAppLogger.log(
|
|
142
|
-
label: "#{self.class.name}#adjust_quantity!",
|
|
143
|
-
data: {
|
|
144
|
-
correlation_id: correlation_id,
|
|
145
|
-
inventory_item_id: id,
|
|
146
|
-
delta: quantity,
|
|
147
|
-
quantity_available_before: quantity_available_before,
|
|
148
|
-
quantity_available_after: quantity_available,
|
|
149
|
-
redis_quantity_after: redis_quantity_after
|
|
150
|
-
}
|
|
151
|
-
)
|
|
152
132
|
end
|
|
153
133
|
|
|
154
134
|
# Stock and hold keys. `locked: true` addresses the withheld pool — see LineItem#locked_stock?,
|
|
@@ -13,10 +13,6 @@ module SpreeCmCommissioner
|
|
|
13
13
|
AcquireLimitExceededError = Class.new(StandardError)
|
|
14
14
|
|
|
15
15
|
def call(order:, expires_at:)
|
|
16
|
-
# One id per acquire attempt, threaded through the Redis-mutation log below and into the
|
|
17
|
-
# DB-sync job's own log (enqueue_sync_inventory_on_hold) — greppable end-to-end trail from
|
|
18
|
-
# "Redis hold counter changed" to "DB row actually caught up", independent of async timing.
|
|
19
|
-
correlation_id = SecureRandom.uuid
|
|
20
16
|
line_items = order.line_items.select(&:should_hold_inventory?)
|
|
21
17
|
return success(nil) if line_items.empty?
|
|
22
18
|
|
|
@@ -33,25 +29,21 @@ module SpreeCmCommissioner
|
|
|
33
29
|
raise AcquireLimitExceededError, result.error.to_s unless result.success?
|
|
34
30
|
end
|
|
35
31
|
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
with_advisory_lock!(order) do
|
|
33
|
+
hold = acquire_hold_for_order!(order, line_items, expires_at)
|
|
38
34
|
|
|
39
|
-
enqueue_release_job(
|
|
40
|
-
enqueue_sync_inventory_on_hold(order
|
|
35
|
+
enqueue_release_job(hold)
|
|
36
|
+
enqueue_sync_inventory_on_hold(order)
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
# held — so a slow log write can't extend how long other requests for this order wait on it.
|
|
47
|
-
CmAppLogger.log(
|
|
48
|
-
label: "#{self.class.name}#call success",
|
|
49
|
-
data: { correlation_id: correlation_id, order_id: order.id, hold_id: hold.id }
|
|
50
|
-
)
|
|
38
|
+
CmAppLogger.log(
|
|
39
|
+
label: "#{self.class.name}#call success",
|
|
40
|
+
data: { order_id: order.id, hold_id: hold.id }
|
|
41
|
+
)
|
|
51
42
|
|
|
52
|
-
|
|
43
|
+
success(hold)
|
|
44
|
+
end
|
|
53
45
|
rescue StandardError => e
|
|
54
|
-
error = { error_type: e.class.name.demodulize,
|
|
46
|
+
error = { error_type: e.class.name.demodulize, order_id: order.id, message: e.message }
|
|
55
47
|
CmAppLogger.error(label: "#{self.class.name}#call failed", data: error)
|
|
56
48
|
failure(nil, e.message)
|
|
57
49
|
end
|
|
@@ -81,71 +73,45 @@ module SpreeCmCommissioner
|
|
|
81
73
|
#
|
|
82
74
|
# Risk: Redis deduction could orphan if exception raised after Redis commit but before DB.
|
|
83
75
|
# Accepted—advisory lock ensures single process per order prevents impact.
|
|
84
|
-
|
|
85
|
-
# insufficient stock — any exception, not just Rollback, unwinds a `transaction do` block and
|
|
86
|
-
# rolls it back; Rollback is just the one case ActiveRecord swallows instead of re-raising.
|
|
87
|
-
# Using our own error class means it still propagates out normally (real transaction or a
|
|
88
|
-
# plain `.and_yield` test double alike) so the rescue below can log, then re-raise it.
|
|
89
|
-
#
|
|
90
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
91
|
-
def acquire_hold_for_order!(order, line_items, expires_at, correlation_id)
|
|
76
|
+
def acquire_hold_for_order!(order, line_items, expires_at)
|
|
92
77
|
hold = SpreeCmCommissioner::InventoryHold.find_or_initialize_by(order: order)
|
|
93
78
|
return hold if hold.persisted? && hold.active?
|
|
94
79
|
return hold if hold.persisted? && hold.payment_locked?
|
|
95
80
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
hold.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
hold.save!
|
|
113
|
-
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
|
|
114
|
-
raise AcquireHoldSaveError, I18n.t('inventory_hold.acquire.db_save_error')
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
begin
|
|
118
|
-
redis_rows, redis_applied = acquire_redis_stock!(line_items)
|
|
119
|
-
rescue Redis::BaseError, Redis::CommandError, IOError
|
|
120
|
-
raise AcquireRedisDeductionError, I18n.t('inventory_hold.acquire.redis_error')
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
raise AcquireInsufficientStockError, I18n.t('inventory_hold.acquire.insufficient_stock') unless redis_applied
|
|
81
|
+
SpreeCmCommissioner::InventoryHold.transaction do
|
|
82
|
+
begin
|
|
83
|
+
hold.assign_attributes(
|
|
84
|
+
user: order.user,
|
|
85
|
+
status: :active,
|
|
86
|
+
held_at: hold.held_at || Time.current,
|
|
87
|
+
expires_at: expires_at,
|
|
88
|
+
payment_locked_at: nil,
|
|
89
|
+
release_reason: nil,
|
|
90
|
+
total_quantity_on_hold: line_items.sum(&:quantity),
|
|
91
|
+
ip_address: order.last_ip_address
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
hold.save!
|
|
95
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
|
|
96
|
+
raise AcquireHoldSaveError, I18n.t('inventory_hold.acquire.db_save_error')
|
|
124
97
|
end
|
|
125
|
-
rescue AcquireInsufficientStockError => e
|
|
126
|
-
# Logged after the transaction has already rolled back and released its row lock — never
|
|
127
|
-
# while held — so a slow log write can't extend how long other requests wait on this hold.
|
|
128
|
-
log_redis_hold_mutation(order: order, hold: hold, correlation_id: correlation_id, rows: redis_rows, applied: false) if redis_rows
|
|
129
|
-
raise e
|
|
130
|
-
end
|
|
131
98
|
|
|
132
|
-
|
|
133
|
-
|
|
99
|
+
begin
|
|
100
|
+
positive = acquire_redis_stock!(line_items)
|
|
101
|
+
raise AcquireInsufficientStockError, I18n.t('inventory_hold.acquire.insufficient_stock') unless positive
|
|
102
|
+
rescue Redis::BaseError, Redis::CommandError, IOError
|
|
103
|
+
raise AcquireRedisDeductionError, I18n.t('inventory_hold.acquire.redis_error')
|
|
104
|
+
end
|
|
134
105
|
|
|
135
|
-
|
|
106
|
+
hold
|
|
107
|
+
end
|
|
136
108
|
end
|
|
137
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
138
109
|
|
|
139
|
-
# Pure Redis I/O — no logging in here (that happens after acquire_hold_for_order!'s
|
|
140
|
-
# transaction closes). Returns [rows, applied] rather than logging directly, so the mutation
|
|
141
|
-
# detail survives even on the insufficient-stock path where the caller rolls back and
|
|
142
|
-
# re-raises.
|
|
143
110
|
def acquire_redis_stock!(line_items)
|
|
144
111
|
stock_keys = []
|
|
145
112
|
hold_keys = []
|
|
146
113
|
quantities = []
|
|
147
114
|
warmable_items = []
|
|
148
|
-
locked_flags = []
|
|
149
115
|
|
|
150
116
|
line_items.each do |li|
|
|
151
117
|
# A locked-stock line reserves from the locked pool's own key pair. The Lua below is
|
|
@@ -161,57 +127,26 @@ module SpreeCmCommissioner
|
|
|
161
127
|
hold_keys << item.redis_hold_key(locked: locked)
|
|
162
128
|
quantities << li.quantity
|
|
163
129
|
warmable_items << item
|
|
164
|
-
locked_flags << locked
|
|
165
130
|
end
|
|
166
131
|
end
|
|
167
132
|
|
|
168
|
-
return
|
|
133
|
+
return true if hold_keys.empty?
|
|
169
134
|
|
|
170
|
-
|
|
171
|
-
# report exactly what Redis held a moment before this eval — zero extra Redis round trips,
|
|
172
|
-
# the warm already reads it.
|
|
173
|
-
cached_items = SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(warmable_items).call
|
|
135
|
+
SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(warmable_items).call
|
|
174
136
|
interleaved_keys = stock_keys.zip(hold_keys).flatten
|
|
175
137
|
|
|
176
138
|
result = SpreeCmCommissioner.inventory_redis_pool.with do |redis|
|
|
177
139
|
redis.eval(acquire_lua_script, keys: interleaved_keys, argv: quantities)
|
|
178
140
|
end
|
|
179
141
|
|
|
180
|
-
|
|
181
|
-
[rows, result.positive?]
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
# `quantity_on_hold`/`quantity_locked_on_hold` on each cached item is exactly what Redis held
|
|
185
|
-
# immediately before this eval (the warm above just read it). `applied: false` means the eval
|
|
186
|
-
# rejected the whole batch (insufficient stock) — nothing was written, so there is no "after".
|
|
187
|
-
# `rows` is [cached_item, quantity_delta, locked?] per inventory item, in eval order.
|
|
188
|
-
def log_redis_hold_mutation(order:, hold:, correlation_id:, rows:, applied:)
|
|
189
|
-
CmAppLogger.log(
|
|
190
|
-
label: "#{self.class.name}#acquire_redis_stock!",
|
|
191
|
-
data: {
|
|
192
|
-
correlation_id: correlation_id,
|
|
193
|
-
order_id: order.id,
|
|
194
|
-
hold_id: hold.id,
|
|
195
|
-
applied: applied,
|
|
196
|
-
items: rows.map do |cached_item, delta, locked|
|
|
197
|
-
before = locked ? cached_item.quantity_locked_on_hold : cached_item.quantity_on_hold
|
|
198
|
-
{
|
|
199
|
-
inventory_item_id: cached_item.inventory_item_id,
|
|
200
|
-
locked: locked,
|
|
201
|
-
quantity_on_hold_before: before,
|
|
202
|
-
delta: delta,
|
|
203
|
-
quantity_on_hold_after: applied ? before + delta : before
|
|
204
|
-
}
|
|
205
|
-
end
|
|
206
|
-
}
|
|
207
|
-
)
|
|
142
|
+
result.positive?
|
|
208
143
|
end
|
|
209
144
|
|
|
210
145
|
def enqueue_release_job(hold)
|
|
211
146
|
SpreeCmCommissioner::InventoryHolds::ReleaseJob.set(wait_until: hold.expires_at).perform_later(hold_id: hold.id)
|
|
212
147
|
end
|
|
213
148
|
|
|
214
|
-
def enqueue_sync_inventory_on_hold(order
|
|
149
|
+
def enqueue_sync_inventory_on_hold(order)
|
|
215
150
|
inventory_id_and_quantities = order.line_items.flat_map do |line_item|
|
|
216
151
|
next [] unless line_item.should_hold_inventory?
|
|
217
152
|
|
|
@@ -229,7 +164,6 @@ module SpreeCmCommissioner
|
|
|
229
164
|
SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnHoldJob.perform_later(
|
|
230
165
|
order_id: order.id,
|
|
231
166
|
caller_source: "#{self.class.name}#enqueue_sync_inventory_on_hold",
|
|
232
|
-
correlation_id: correlation_id,
|
|
233
167
|
inventory_id_and_quantities: inventory_id_and_quantities
|
|
234
168
|
)
|
|
235
169
|
end
|
|
@@ -4,59 +4,30 @@ module SpreeCmCommissioner
|
|
|
4
4
|
prepend ::Spree::ServiceModule::Base
|
|
5
5
|
extend SpreeCmCommissioner::ServiceModuleThrowable
|
|
6
6
|
|
|
7
|
-
HoldExpiredError = Class.new(StandardError)
|
|
8
|
-
|
|
9
7
|
def call(order:)
|
|
10
|
-
# One id per conversion attempt, threaded into the Redis-mutation log below and the DB-sync
|
|
11
|
-
# job's own log — same purpose as Acquire's correlation_id.
|
|
12
|
-
correlation_id = SecureRandom.uuid
|
|
13
8
|
hold = InventoryHold.find_by(order: order)
|
|
14
9
|
return success(order) if hold.nil?
|
|
15
|
-
return success(order) if hold.
|
|
16
|
-
return failure(nil, hold_expired_message) if hold.finalized?
|
|
17
|
-
|
|
18
|
-
redis_rows = nil
|
|
10
|
+
return success(order) if hold.finalized?
|
|
19
11
|
|
|
20
12
|
InventoryHold.transaction do
|
|
21
13
|
hold.lock!
|
|
22
14
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# else already converted it" as success — a losing duplicate request should surface as
|
|
29
|
-
# a failure, not a false success for work it didn't do.
|
|
30
|
-
|
|
31
|
-
hold.update!(status: :converted)
|
|
32
|
-
redis_rows = convert_hold_redis!(order)
|
|
33
|
-
enqueue_sync_inventory(order, correlation_id)
|
|
15
|
+
unless hold.finalized?
|
|
16
|
+
hold.update!(status: :converted)
|
|
17
|
+
convert_hold_redis!(order)
|
|
18
|
+
enqueue_sync_inventory(order)
|
|
19
|
+
end
|
|
34
20
|
end
|
|
35
21
|
|
|
36
|
-
# Logged after the transaction commits and hold.lock! releases its row lock — never while
|
|
37
|
-
# held — so a slow log write can't extend how long other requests wait on this hold.
|
|
38
|
-
log_redis_convert_mutation(order: order, hold: hold, correlation_id: correlation_id, rows: redis_rows) if redis_rows
|
|
39
|
-
|
|
40
22
|
success(order)
|
|
41
23
|
rescue StandardError => e
|
|
42
|
-
error = { error_type: e.class.name.demodulize,
|
|
24
|
+
error = { error_type: e.class.name.demodulize, order_id: order.id, message: e.message }
|
|
43
25
|
CmAppLogger.error(label: "#{self.class.name}#call failed", data: error)
|
|
44
26
|
failure(nil, e.message)
|
|
45
27
|
end
|
|
46
28
|
|
|
47
29
|
private
|
|
48
30
|
|
|
49
|
-
# A hold that is `released` (any reason — expiry, payment timeout, cancellation) by the time
|
|
50
|
-
# the order tries to complete means its reservation already expired and the unit may have
|
|
51
|
-
# been reallocated to someone else. Completion must not silently treat that as "already
|
|
52
|
-
# handled" the way an already-`converted` hold correctly is (idempotent re-entry). Failing
|
|
53
|
-
# here surfaces as UnableToConvertInventoryError from OrderHolds::Reserve, which rolls back
|
|
54
|
-
# the :complete transition — the same rollback path a genuine insufficient-stock failure in
|
|
55
|
-
# RedisStock::Unstock already takes.
|
|
56
|
-
def hold_expired_message
|
|
57
|
-
I18n.t('inventory_hold.convert.hold_expired')
|
|
58
|
-
end
|
|
59
|
-
|
|
60
31
|
# Moves a held unit to a permanent sale by decrementing the hold counter AND the stock
|
|
61
32
|
# counter together, in one atomic Lua call. These used to be two separate Redis round trips
|
|
62
33
|
# (hold decrement here, stock decrement in RedisStock::Unstock, called a moment later from
|
|
@@ -67,14 +38,11 @@ module SpreeCmCommissioner
|
|
|
67
38
|
#
|
|
68
39
|
# RedisStock::Unstock skips any line item that should_hold_inventory? for exactly this reason
|
|
69
40
|
# — this method is now the only place their stock key gets decremented.
|
|
70
|
-
# Pure Redis I/O — no logging in here (that happens after the caller's transaction closes).
|
|
71
|
-
# Returns the rows the mutation log needs, or nil when there was nothing to convert.
|
|
72
41
|
def convert_hold_redis!(order)
|
|
73
42
|
stock_keys = []
|
|
74
43
|
hold_keys = []
|
|
75
44
|
quantities = []
|
|
76
45
|
warmable_items = []
|
|
77
|
-
locked_flags = []
|
|
78
46
|
|
|
79
47
|
order.line_items.each do |li|
|
|
80
48
|
next unless li.should_hold_inventory?
|
|
@@ -89,51 +57,17 @@ module SpreeCmCommissioner
|
|
|
89
57
|
hold_keys << item.redis_hold_key(locked: locked)
|
|
90
58
|
quantities << li.quantity
|
|
91
59
|
warmable_items << item
|
|
92
|
-
locked_flags << locked
|
|
93
60
|
end
|
|
94
61
|
end
|
|
95
62
|
|
|
96
63
|
return if hold_keys.empty?
|
|
97
64
|
|
|
98
|
-
|
|
99
|
-
# decrement, so the mutation log (logged later, after the transaction) needs no extra Redis
|
|
100
|
-
# round trip.
|
|
101
|
-
cached_items = SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(warmable_items).call
|
|
65
|
+
SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(warmable_items).call
|
|
102
66
|
|
|
103
67
|
interleaved_keys = stock_keys.zip(hold_keys).flatten
|
|
104
68
|
SpreeCmCommissioner.inventory_redis_pool.with do |redis|
|
|
105
69
|
redis.eval(convert_hold_lua_script, keys: interleaved_keys, argv: quantities)
|
|
106
70
|
end
|
|
107
|
-
|
|
108
|
-
cached_items.each_with_index.map { |cached_item, i| [cached_item, quantities[i], locked_flags[i]] }
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# This script always applies (no insufficiency check — the unit was already validated and
|
|
112
|
-
# reserved at Acquire time), so every item here has a real "after": both the stock and hold
|
|
113
|
-
# counters drop by the same delta. `rows` is [cached_item, quantity_delta, locked?] per
|
|
114
|
-
# inventory item, in eval order.
|
|
115
|
-
def log_redis_convert_mutation(order:, hold:, correlation_id:, rows:)
|
|
116
|
-
CmAppLogger.log(
|
|
117
|
-
label: "#{self.class.name}#convert_hold_redis!",
|
|
118
|
-
data: {
|
|
119
|
-
correlation_id: correlation_id,
|
|
120
|
-
order_id: order.id,
|
|
121
|
-
hold_id: hold.id,
|
|
122
|
-
items: rows.map do |cached_item, delta, locked|
|
|
123
|
-
stock_before = locked ? cached_item.quantity_locked : cached_item.quantity_available
|
|
124
|
-
hold_before = locked ? cached_item.quantity_locked_on_hold : cached_item.quantity_on_hold
|
|
125
|
-
{
|
|
126
|
-
inventory_item_id: cached_item.inventory_item_id,
|
|
127
|
-
locked: locked,
|
|
128
|
-
delta: delta,
|
|
129
|
-
quantity_available_before: stock_before,
|
|
130
|
-
quantity_available_after: stock_before - delta,
|
|
131
|
-
quantity_on_hold_before: hold_before,
|
|
132
|
-
quantity_on_hold_after: hold_before - delta
|
|
133
|
-
}
|
|
134
|
-
end
|
|
135
|
-
}
|
|
136
|
-
)
|
|
137
71
|
end
|
|
138
72
|
|
|
139
73
|
def convert_hold_lua_script
|
|
@@ -152,7 +86,7 @@ module SpreeCmCommissioner
|
|
|
152
86
|
# because the hold is gone, quantity_available drops because the unit is permanently sold) via
|
|
153
87
|
# ONE job that applies both under a single row lock per item — see
|
|
154
88
|
# InventoryItems::BulkAdjustQuantitiesOnConvert for why this isn't the usual two separate jobs.
|
|
155
|
-
def enqueue_sync_inventory(order
|
|
89
|
+
def enqueue_sync_inventory(order)
|
|
156
90
|
adjustments = order.line_items.flat_map do |line_item|
|
|
157
91
|
next [] unless line_item.should_hold_inventory?
|
|
158
92
|
|
|
@@ -171,7 +105,6 @@ module SpreeCmCommissioner
|
|
|
171
105
|
SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnConvertJob.perform_later(
|
|
172
106
|
order_id: order.id,
|
|
173
107
|
caller_source: "#{self.class.name}#enqueue_sync_inventory",
|
|
174
|
-
correlation_id: correlation_id,
|
|
175
108
|
inventory_id_and_quantities: adjustments
|
|
176
109
|
)
|
|
177
110
|
end
|
|
@@ -9,11 +9,7 @@ module SpreeCmCommissioner
|
|
|
9
9
|
def call(hold:, reason: :hold_expired, scheduled_release: false)
|
|
10
10
|
return success(hold) if hold.finalized?
|
|
11
11
|
|
|
12
|
-
# One id per release attempt, threaded into the Redis-mutation log below and the DB-sync
|
|
13
|
-
# job's own log — same purpose as Acquire's/Convert's correlation_id.
|
|
14
|
-
correlation_id = SecureRandom.uuid
|
|
15
12
|
released = false
|
|
16
|
-
redis_rows = nil
|
|
17
13
|
|
|
18
14
|
InventoryHold.transaction do
|
|
19
15
|
hold.lock!
|
|
@@ -27,15 +23,11 @@ module SpreeCmCommissioner
|
|
|
27
23
|
next if scheduled_release && hold.expires_at > Time.current
|
|
28
24
|
|
|
29
25
|
hold.update!(status: :released, release_reason: reason)
|
|
30
|
-
|
|
31
|
-
enqueue_sync_inventory_on_hold(hold.order
|
|
26
|
+
restock_redis!(hold)
|
|
27
|
+
enqueue_sync_inventory_on_hold(hold.order)
|
|
32
28
|
released = true
|
|
33
29
|
end
|
|
34
30
|
|
|
35
|
-
# Logged after the transaction commits and hold.lock! releases its row lock — never while
|
|
36
|
-
# held — so a slow log write can't extend how long other requests wait on this hold.
|
|
37
|
-
log_redis_release_mutation(hold: hold, correlation_id: correlation_id, rows: redis_rows) if redis_rows
|
|
38
|
-
|
|
39
31
|
# Notify AFTER the transaction commits (deliver_later must not be enqueued inside an open
|
|
40
32
|
# transaction), and only when THIS invocation performed the release — the `next` guards above
|
|
41
33
|
# commit without releasing, so this keeps notifications idempotent across stale/concurrent jobs.
|
|
@@ -63,10 +55,8 @@ module SpreeCmCommissioner
|
|
|
63
55
|
SpreeCmCommissioner::ReleaseInventoryItemNotificationSender.call(notificable: hold.order)
|
|
64
56
|
end
|
|
65
57
|
|
|
66
|
-
# Pure Redis I/O — no logging in here (that happens after the caller's transaction closes).
|
|
67
|
-
# Returns the rows the mutation log needs, or nil when there was nothing to release.
|
|
68
58
|
def restock_redis!(hold)
|
|
69
|
-
keys, quantities, warmable_items
|
|
59
|
+
keys, quantities, warmable_items = hold_keys_and_quantities(hold.order)
|
|
70
60
|
return if keys.empty?
|
|
71
61
|
|
|
72
62
|
release_hold_lua_script = <<~LUA
|
|
@@ -78,46 +68,16 @@ module SpreeCmCommissioner
|
|
|
78
68
|
return 1
|
|
79
69
|
LUA
|
|
80
70
|
|
|
81
|
-
|
|
82
|
-
# decrement, so the mutation log (logged later, after the transaction) needs no extra Redis
|
|
83
|
-
# round trip.
|
|
84
|
-
cached_items = SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(warmable_items).call
|
|
71
|
+
SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(warmable_items).call
|
|
85
72
|
SpreeCmCommissioner.inventory_redis_pool.with do |redis|
|
|
86
73
|
redis.eval(release_hold_lua_script, keys: keys, argv: quantities)
|
|
87
74
|
end
|
|
88
|
-
|
|
89
|
-
cached_items.each_with_index.map { |cached_item, i| [cached_item, quantities[i], locked_flags[i]] }
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# This script always applies (no insufficiency check — releasing a hold can't oversell), so
|
|
93
|
-
# every item here has a real "after". `rows` is [cached_item, quantity_delta, locked?] per
|
|
94
|
-
# inventory item, in eval order.
|
|
95
|
-
def log_redis_release_mutation(hold:, correlation_id:, rows:)
|
|
96
|
-
CmAppLogger.log(
|
|
97
|
-
label: "#{self.class.name}#restock_redis!",
|
|
98
|
-
data: {
|
|
99
|
-
correlation_id: correlation_id,
|
|
100
|
-
order_id: hold.order.id,
|
|
101
|
-
hold_id: hold.id,
|
|
102
|
-
items: rows.map do |cached_item, delta, locked|
|
|
103
|
-
before = locked ? cached_item.quantity_locked_on_hold : cached_item.quantity_on_hold
|
|
104
|
-
{
|
|
105
|
-
inventory_item_id: cached_item.inventory_item_id,
|
|
106
|
-
locked: locked,
|
|
107
|
-
delta: delta,
|
|
108
|
-
quantity_on_hold_before: before,
|
|
109
|
-
quantity_on_hold_after: before - delta
|
|
110
|
-
}
|
|
111
|
-
end
|
|
112
|
-
}
|
|
113
|
-
)
|
|
114
75
|
end
|
|
115
76
|
|
|
116
77
|
def hold_keys_and_quantities(order)
|
|
117
78
|
keys = []
|
|
118
79
|
quantities = []
|
|
119
80
|
warmable_items = []
|
|
120
|
-
locked_flags = []
|
|
121
81
|
|
|
122
82
|
order.line_items.includes(:variant).find_each do |li|
|
|
123
83
|
next unless li.should_hold_inventory?
|
|
@@ -131,14 +91,13 @@ module SpreeCmCommissioner
|
|
|
131
91
|
keys << item.redis_hold_key(locked: locked)
|
|
132
92
|
quantities << li.quantity
|
|
133
93
|
warmable_items << item
|
|
134
|
-
locked_flags << locked
|
|
135
94
|
end
|
|
136
95
|
end
|
|
137
96
|
|
|
138
|
-
[keys, quantities, warmable_items
|
|
97
|
+
[keys, quantities, warmable_items]
|
|
139
98
|
end
|
|
140
99
|
|
|
141
|
-
def enqueue_sync_inventory_on_hold(order
|
|
100
|
+
def enqueue_sync_inventory_on_hold(order)
|
|
142
101
|
inventory_id_and_quantities = order.line_items.flat_map do |line_item|
|
|
143
102
|
next [] unless line_item.should_hold_inventory?
|
|
144
103
|
|
|
@@ -157,7 +116,6 @@ module SpreeCmCommissioner
|
|
|
157
116
|
SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnHoldJob.perform_later(
|
|
158
117
|
order_id: order.id,
|
|
159
118
|
caller_source: "#{self.class.name}#enqueue_sync_inventory_on_hold",
|
|
160
|
-
correlation_id: correlation_id,
|
|
161
119
|
inventory_id_and_quantities: inventory_id_and_quantities
|
|
162
120
|
)
|
|
163
121
|
end
|
|
@@ -37,14 +37,14 @@ module SpreeCmCommissioner
|
|
|
37
37
|
# thing RedisStock::Unstock and InventoryHolds::Convert do before their own Lua scripts.
|
|
38
38
|
warm_redis_key!(inventory_item) if warm_redis
|
|
39
39
|
|
|
40
|
-
before = after =
|
|
40
|
+
before = after = nil
|
|
41
41
|
|
|
42
42
|
apply = lambda do
|
|
43
43
|
before = inventory_item.quantity_available
|
|
44
44
|
after = before + delta
|
|
45
45
|
|
|
46
46
|
inventory_item.update!(quantity_available: after)
|
|
47
|
-
|
|
47
|
+
inventory_item.adjust_quantity_in_redis(delta)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
lock ? inventory_item.with_lock(&apply) : apply.call
|
|
@@ -59,8 +59,7 @@ module SpreeCmCommissioner
|
|
|
59
59
|
inventory_item_id: inventory_item.id,
|
|
60
60
|
quantity_available_before: before,
|
|
61
61
|
delta: delta,
|
|
62
|
-
quantity_available_after: after
|
|
63
|
-
redis_quantity_after: redis_after
|
|
62
|
+
quantity_available_after: after
|
|
64
63
|
}
|
|
65
64
|
)
|
|
66
65
|
|
|
@@ -4,10 +4,10 @@ module SpreeCmCommissioner
|
|
|
4
4
|
prepend ::Spree::ServiceModule::Base
|
|
5
5
|
extend SpreeCmCommissioner::ServiceModuleThrowable
|
|
6
6
|
|
|
7
|
-
def call(inventory_id_and_quantities:, caller_source: nil
|
|
7
|
+
def call(inventory_id_and_quantities:, caller_source: nil)
|
|
8
8
|
CmAppLogger.log(
|
|
9
9
|
label: "#{self.class.name}#call started",
|
|
10
|
-
data: {
|
|
10
|
+
data: { caller_source: caller_source, inventory_id_and_quantities: inventory_id_and_quantities }
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
ActiveRecord::Base.transaction do
|
|
@@ -17,7 +17,7 @@ module SpreeCmCommissioner
|
|
|
17
17
|
inventory_item = items_by_id[inventory_id]
|
|
18
18
|
next if inventory_item.nil?
|
|
19
19
|
|
|
20
|
-
adjust_quantity_available(inventory_item, quantity, caller_source,
|
|
20
|
+
adjust_quantity_available(inventory_item, quantity, caller_source, locked: locked)
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -38,7 +38,7 @@ module SpreeCmCommissioner
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
# locked defaults to false so the original three-argument signature keeps working.
|
|
41
|
-
def adjust_quantity_available(inventory_item, quantity, caller_source,
|
|
41
|
+
def adjust_quantity_available(inventory_item, quantity, caller_source, locked: false)
|
|
42
42
|
column = locked ? :quantity_locked : :quantity_available
|
|
43
43
|
|
|
44
44
|
# IMPORTANT: Apply the quantity change directly without defensive clamping.
|
|
@@ -63,7 +63,6 @@ module SpreeCmCommissioner
|
|
|
63
63
|
CmAppLogger.log(
|
|
64
64
|
label: "#{self.class.name}#adjust_quantity_available",
|
|
65
65
|
data: {
|
|
66
|
-
correlation_id: correlation_id,
|
|
67
66
|
caller_source: caller_source,
|
|
68
67
|
inventory_item_id: inventory_item.id,
|
|
69
68
|
locked: locked,
|
|
@@ -77,7 +76,6 @@ module SpreeCmCommissioner
|
|
|
77
76
|
CmAppLogger.error(
|
|
78
77
|
label: "#{self.class.name}#adjust_quantity_available failed",
|
|
79
78
|
data: {
|
|
80
|
-
correlation_id: correlation_id,
|
|
81
79
|
caller_source: caller_source,
|
|
82
80
|
inventory_item_id: inventory_item.id,
|
|
83
81
|
locked: locked,
|
data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_by_variant.rb
CHANGED
|
@@ -5,18 +5,8 @@ module SpreeCmCommissioner
|
|
|
5
5
|
extend SpreeCmCommissioner::ServiceModuleThrowable
|
|
6
6
|
|
|
7
7
|
def call(variant:, quantity:)
|
|
8
|
-
# One id per batch — every inventory item touched below logs it via
|
|
9
|
-
# InventoryItem#adjust_quantity!, tying this whole variant-wide adjustment together in a
|
|
10
|
-
# search, same as the other Bulk* services' correlation_id.
|
|
11
|
-
correlation_id = SecureRandom.uuid
|
|
12
|
-
|
|
13
|
-
CmAppLogger.log(
|
|
14
|
-
label: "#{self.class.name}#call started",
|
|
15
|
-
data: { correlation_id: correlation_id, variant_id: variant.id, delta: quantity }
|
|
16
|
-
)
|
|
17
|
-
|
|
18
8
|
variant.inventory_items.active.find_each do |inventory_item|
|
|
19
|
-
inventory_item.adjust_quantity!(quantity
|
|
9
|
+
inventory_item.adjust_quantity!(quantity)
|
|
20
10
|
end
|
|
21
11
|
|
|
22
12
|
success(nil)
|
data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert.rb
CHANGED
|
@@ -10,10 +10,10 @@ module SpreeCmCommissioner
|
|
|
10
10
|
prepend ::Spree::ServiceModule::Base
|
|
11
11
|
extend SpreeCmCommissioner::ServiceModuleThrowable
|
|
12
12
|
|
|
13
|
-
def call(inventory_id_and_quantities:, caller_source: nil
|
|
13
|
+
def call(inventory_id_and_quantities:, caller_source: nil)
|
|
14
14
|
CmAppLogger.log(
|
|
15
15
|
label: "#{self.class.name}#call started",
|
|
16
|
-
data: {
|
|
16
|
+
data: { caller_source: caller_source, inventory_id_and_quantities: inventory_id_and_quantities }
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
ActiveRecord::Base.transaction do
|
|
@@ -23,7 +23,7 @@ module SpreeCmCommissioner
|
|
|
23
23
|
inventory_item = items_by_id[inventory_id]
|
|
24
24
|
next if inventory_item.nil?
|
|
25
25
|
|
|
26
|
-
adjust_quantities(inventory_item, quantity, caller_source,
|
|
26
|
+
adjust_quantities(inventory_item, quantity, caller_source, locked: locked)
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
|
|
@@ -40,7 +40,7 @@ module SpreeCmCommissioner
|
|
|
40
40
|
.transform_values { |entries| entries.sum { |entry| entry[:quantity] } }
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
def adjust_quantities(inventory_item, quantity, caller_source,
|
|
43
|
+
def adjust_quantities(inventory_item, quantity, caller_source, locked: false)
|
|
44
44
|
available_column = locked ? :quantity_locked : :quantity_available
|
|
45
45
|
hold_column = locked ? :quantity_locked_on_hold : :quantity_on_hold
|
|
46
46
|
|
|
@@ -60,7 +60,6 @@ module SpreeCmCommissioner
|
|
|
60
60
|
CmAppLogger.log(
|
|
61
61
|
label: "#{self.class.name}#adjust_quantities",
|
|
62
62
|
data: {
|
|
63
|
-
correlation_id: correlation_id,
|
|
64
63
|
caller_source: caller_source,
|
|
65
64
|
inventory_item_id: inventory_item.id,
|
|
66
65
|
locked: locked,
|
|
@@ -77,7 +76,6 @@ module SpreeCmCommissioner
|
|
|
77
76
|
CmAppLogger.error(
|
|
78
77
|
label: "#{self.class.name}#adjust_quantities failed",
|
|
79
78
|
data: {
|
|
80
|
-
correlation_id: correlation_id,
|
|
81
79
|
caller_source: caller_source,
|
|
82
80
|
inventory_item_id: inventory_item.id,
|
|
83
81
|
locked: locked,
|
|
@@ -4,11 +4,10 @@ module SpreeCmCommissioner
|
|
|
4
4
|
prepend ::Spree::ServiceModule::Base
|
|
5
5
|
extend SpreeCmCommissioner::ServiceModuleThrowable
|
|
6
6
|
|
|
7
|
-
def call(inventory_id_and_quantities:, caller_source: nil
|
|
7
|
+
def call(inventory_id_and_quantities:, caller_source: nil)
|
|
8
8
|
CmAppLogger.log(
|
|
9
9
|
label: "#{self.class.name}#call started",
|
|
10
10
|
data: {
|
|
11
|
-
correlation_id: correlation_id,
|
|
12
11
|
caller_source: caller_source,
|
|
13
12
|
inventory_id_and_quantities: inventory_id_and_quantities
|
|
14
13
|
}
|
|
@@ -21,7 +20,7 @@ module SpreeCmCommissioner
|
|
|
21
20
|
inventory_item = items_by_id[inventory_id]
|
|
22
21
|
next if inventory_item.nil?
|
|
23
22
|
|
|
24
|
-
adjust_quantity_on_hold(inventory_item, quantity, caller_source,
|
|
23
|
+
adjust_quantity_on_hold(inventory_item, quantity, caller_source, locked: locked)
|
|
25
24
|
end
|
|
26
25
|
end
|
|
27
26
|
|
|
@@ -45,7 +44,7 @@ module SpreeCmCommissioner
|
|
|
45
44
|
end
|
|
46
45
|
|
|
47
46
|
# locked defaults to false so the original three-argument signature keeps working.
|
|
48
|
-
def adjust_quantity_on_hold(inventory_item, quantity, caller_source,
|
|
47
|
+
def adjust_quantity_on_hold(inventory_item, quantity, caller_source, locked: false)
|
|
49
48
|
column = locked ? :quantity_locked_on_hold : :quantity_on_hold
|
|
50
49
|
|
|
51
50
|
# IMPORTANT: Apply the quantity change directly without defensive clamping.
|
|
@@ -70,7 +69,6 @@ module SpreeCmCommissioner
|
|
|
70
69
|
CmAppLogger.log(
|
|
71
70
|
label: "#{self.class.name}#adjust_quantity_on_hold",
|
|
72
71
|
data: {
|
|
73
|
-
correlation_id: correlation_id,
|
|
74
72
|
caller_source: caller_source,
|
|
75
73
|
inventory_item_id: inventory_item.id,
|
|
76
74
|
locked: locked,
|
|
@@ -84,7 +82,6 @@ module SpreeCmCommissioner
|
|
|
84
82
|
CmAppLogger.error(
|
|
85
83
|
label: "#{self.class.name}#adjust_quantity_on_hold failed",
|
|
86
84
|
data: {
|
|
87
|
-
correlation_id: correlation_id,
|
|
88
85
|
caller_source: caller_source,
|
|
89
86
|
inventory_item_id: inventory_item.id,
|
|
90
87
|
locked: locked,
|
|
@@ -25,54 +25,39 @@ module SpreeCmCommissioner
|
|
|
25
25
|
# key could not be warmed and the adjustment would clamp the date to 0 for a year.
|
|
26
26
|
return failure(nil, :inactive_inventory_item) unless inventory_item.active?
|
|
27
27
|
|
|
28
|
-
# One id per transfer, tying this call's Redis-mutation log to its DB-value logs below —
|
|
29
|
-
# there's no async job on this path (row and Redis are written together), but a shared id
|
|
30
|
-
# still makes the two log lines trivially joinable in a search.
|
|
31
|
-
correlation_id = SecureRandom.uuid
|
|
32
28
|
outcome = nil
|
|
33
|
-
redis_mutation = nil
|
|
34
29
|
|
|
35
30
|
# requires_new, not a bare with_lock: an ActiveRecord::Rollback raised in a nested transaction
|
|
36
31
|
# is swallowed without rolling anything back, so a refused transfer would leave the row already
|
|
37
32
|
# moved. The savepoint makes the rollback real wherever this is called from.
|
|
38
33
|
inventory_item.transaction(requires_new: true) do
|
|
39
34
|
inventory_item.lock!
|
|
40
|
-
outcome
|
|
35
|
+
outcome = transfer!(inventory_item, quantity, caller_source, user)
|
|
41
36
|
raise ActiveRecord::Rollback unless outcome == :moved
|
|
42
37
|
end
|
|
43
38
|
|
|
44
|
-
# Logged after the transaction commits/rolls back and inventory_item.lock! releases its row
|
|
45
|
-
# lock — never while held — so a slow log write can't extend how long other requests wait on
|
|
46
|
-
# this item.
|
|
47
|
-
log_redis_move_mutation(inventory_item, quantity, redis_mutation, correlation_id) if redis_mutation
|
|
48
|
-
|
|
49
39
|
return failure(nil, outcome) unless outcome == :moved
|
|
50
40
|
|
|
51
|
-
|
|
52
|
-
log(inventory_item, quantity, outcome, correlation_id)
|
|
41
|
+
log(inventory_item, quantity, outcome)
|
|
53
42
|
success(inventory_item)
|
|
54
43
|
end
|
|
55
44
|
|
|
56
45
|
private
|
|
57
46
|
|
|
58
|
-
# Runs inside the locked transaction. Returns
|
|
59
|
-
# {cached_item:, applied:} once move_stock_in_redis has actually run, else nil. No logging in
|
|
60
|
-
# here (that happens in `call`, after the transaction closes).
|
|
47
|
+
# Runs inside the locked transaction. Returns the outcome symbol.
|
|
61
48
|
def transfer!(inventory_item, quantity, caller_source, user)
|
|
62
49
|
new_locked = inventory_item.quantity_locked + quantity
|
|
63
|
-
return
|
|
50
|
+
return :quantity_exceeds_lock if new_locked.negative?
|
|
64
51
|
|
|
65
52
|
# Neither column may go negative. The Redis script below is the authoritative check because it
|
|
66
53
|
# is the only one that sees holds, but it runs after the row write — so bound the row here or
|
|
67
54
|
# the update raises RecordInvalid instead of returning a named failure the admin can read.
|
|
68
55
|
new_available = inventory_item.quantity_available - quantity
|
|
69
|
-
return
|
|
56
|
+
return insufficient_stock_reason(quantity) if new_available.negative?
|
|
70
57
|
|
|
71
58
|
# SET-NX both pools from the row before anything is written, so the script below never reads a
|
|
72
|
-
# cold key as 0 and moves units that were never there.
|
|
73
|
-
|
|
74
|
-
# transaction) needs, no extra round trip.
|
|
75
|
-
cached_item = warm_pools(inventory_item)
|
|
59
|
+
# cold key as 0 and moves units that were never there.
|
|
60
|
+
warm_pools(inventory_item)
|
|
76
61
|
|
|
77
62
|
# ORDER MATTERS, and not for style. Redis has no rollback, and InventoryItems::Reset cannot
|
|
78
63
|
# recompute quantity_locked — there are no rows to derive it from — so a Redis write that
|
|
@@ -80,42 +65,14 @@ module SpreeCmCommissioner
|
|
|
80
65
|
# it back if the script refuses.
|
|
81
66
|
inventory_item.update!(quantity_locked: new_locked, quantity_available: new_available)
|
|
82
67
|
|
|
83
|
-
|
|
84
|
-
redis_mutation = { cached_item: cached_item, applied: applied }
|
|
85
|
-
return [insufficient_stock_reason(quantity), redis_mutation] unless applied
|
|
68
|
+
return insufficient_stock_reason(quantity) unless move_stock_in_redis(inventory_item, quantity)
|
|
86
69
|
|
|
87
70
|
# Inside the same savepoint as the row update above, so a later failure in this same call would
|
|
88
71
|
# roll this row back with it — the ledger can never disagree with quantity_locked.
|
|
89
72
|
create_movement!(inventory_item, quantity, caller_source, user)
|
|
73
|
+
log_adjustment(inventory_item, quantity, caller_source)
|
|
90
74
|
|
|
91
|
-
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# `applied: false` means the script refused (would drain a pool below what's held) — nothing
|
|
95
|
-
# was written, so there is no "after" for either pool.
|
|
96
|
-
def log_redis_move_mutation(inventory_item, quantity, redis_mutation, correlation_id)
|
|
97
|
-
cached_item = redis_mutation[:cached_item]
|
|
98
|
-
applied = redis_mutation[:applied]
|
|
99
|
-
locking = quantity.positive?
|
|
100
|
-
delta = quantity.abs
|
|
101
|
-
from_before = locking ? cached_item.quantity_available : cached_item.quantity_locked
|
|
102
|
-
to_before = locking ? cached_item.quantity_locked : cached_item.quantity_available
|
|
103
|
-
|
|
104
|
-
CmAppLogger.log(
|
|
105
|
-
label: "#{self.class.name}#move_stock_in_redis",
|
|
106
|
-
data: {
|
|
107
|
-
correlation_id: correlation_id,
|
|
108
|
-
inventory_item_id: inventory_item.id,
|
|
109
|
-
applied: applied,
|
|
110
|
-
from_pool: locking ? 'public' : 'locked',
|
|
111
|
-
to_pool: locking ? 'locked' : 'public',
|
|
112
|
-
delta: delta,
|
|
113
|
-
from_before: from_before,
|
|
114
|
-
from_after: applied ? from_before - delta : from_before,
|
|
115
|
-
to_before: to_before,
|
|
116
|
-
to_after: applied ? to_before + delta : to_before
|
|
117
|
-
}
|
|
118
|
-
)
|
|
75
|
+
:moved
|
|
119
76
|
end
|
|
120
77
|
|
|
121
78
|
# The one durable record of this transfer — CmAppLogger below is a log line, not a queryable row.
|
|
@@ -185,11 +142,10 @@ module SpreeCmCommissioner
|
|
|
185
142
|
# AdjustAvailableQuantity used to write quantity_available and carry this log line. The script
|
|
186
143
|
# above owns both stock keys now, so the row update is folded into transfer! — but caller_source
|
|
187
144
|
# is threaded from the admin controller specifically so the audit trail names the action.
|
|
188
|
-
def log_adjustment(inventory_item, quantity, caller_source
|
|
145
|
+
def log_adjustment(inventory_item, quantity, caller_source)
|
|
189
146
|
CmAppLogger.log(
|
|
190
147
|
label: "#{self.class.name}#transfer!",
|
|
191
148
|
data: {
|
|
192
|
-
correlation_id: correlation_id,
|
|
193
149
|
caller_source: caller_source || "#{self.class.name}#transfer!",
|
|
194
150
|
inventory_item_id: inventory_item.id,
|
|
195
151
|
quantity_available: inventory_item.quantity_available,
|
|
@@ -199,11 +155,10 @@ module SpreeCmCommissioner
|
|
|
199
155
|
)
|
|
200
156
|
end
|
|
201
157
|
|
|
202
|
-
def log(inventory_item, quantity, outcome
|
|
158
|
+
def log(inventory_item, quantity, outcome)
|
|
203
159
|
CmAppLogger.log(
|
|
204
160
|
label: "#{self.class.name}#call",
|
|
205
161
|
data: {
|
|
206
|
-
correlation_id: correlation_id,
|
|
207
162
|
inventory_item_id: inventory_item.id,
|
|
208
163
|
quantity: quantity,
|
|
209
164
|
quantity_available: inventory_item.quantity_available,
|
|
@@ -28,12 +28,11 @@ module SpreeCmCommissioner
|
|
|
28
28
|
SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(inventory_items).call
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def schedule_sync_inventory(inventory_id_and_quantities
|
|
31
|
+
def schedule_sync_inventory(inventory_id_and_quantities)
|
|
32
32
|
SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesJob.perform_later(
|
|
33
33
|
inventory_id_and_quantities: inventory_id_and_quantities,
|
|
34
34
|
line_item_ids: @line_item_ids,
|
|
35
|
-
caller_source: "#{self.class.name}#schedule_sync_inventory"
|
|
36
|
-
correlation_id: correlation_id
|
|
35
|
+
caller_source: "#{self.class.name}#schedule_sync_inventory"
|
|
37
36
|
)
|
|
38
37
|
end
|
|
39
38
|
end
|
|
@@ -4,7 +4,6 @@ module SpreeCmCommissioner
|
|
|
4
4
|
# override
|
|
5
5
|
def call(line_item_ids:)
|
|
6
6
|
@line_item_ids = line_item_ids
|
|
7
|
-
correlation_id = SecureRandom.uuid
|
|
8
7
|
|
|
9
8
|
keys = []
|
|
10
9
|
quantities = []
|
|
@@ -29,51 +28,20 @@ module SpreeCmCommissioner
|
|
|
29
28
|
end
|
|
30
29
|
end
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
# increment, so the mutation log below needs no extra Redis round trip.
|
|
34
|
-
cached_items = warm_redis_keys!(warmable_items)
|
|
31
|
+
warm_redis_keys!(warmable_items)
|
|
35
32
|
|
|
36
|
-
|
|
37
|
-
log_redis_restock_mutation(correlation_id: correlation_id, cached_items: cached_items, quantities: quantities, locked_flags: locked_flags,
|
|
38
|
-
applied: applied
|
|
39
|
-
)
|
|
40
|
-
return failure(nil, :unable_to_restock) unless applied
|
|
33
|
+
return failure(nil, :unable_to_restock) unless restock(keys, quantities, max_capacities)
|
|
41
34
|
|
|
42
35
|
inventory_id_and_quantities = inventory_ids.map.with_index do |inventory_id, i|
|
|
43
36
|
{ inventory_id: inventory_id, quantity: quantities[i], locked: locked_flags[i] }
|
|
44
37
|
end
|
|
45
38
|
|
|
46
|
-
schedule_sync_inventory(inventory_id_and_quantities
|
|
39
|
+
schedule_sync_inventory(inventory_id_and_quantities)
|
|
47
40
|
success(nil)
|
|
48
41
|
end
|
|
49
42
|
|
|
50
43
|
private
|
|
51
44
|
|
|
52
|
-
# `applied: false` means the eval rejected the whole batch (would exceed max_capacity) —
|
|
53
|
-
# nothing was written, so there is no "after" for those items.
|
|
54
|
-
def log_redis_restock_mutation(correlation_id:, cached_items:, quantities:, locked_flags:, applied:)
|
|
55
|
-
CmAppLogger.log(
|
|
56
|
-
label: "#{self.class.name}#restock",
|
|
57
|
-
data: {
|
|
58
|
-
correlation_id: correlation_id,
|
|
59
|
-
line_item_ids: @line_item_ids,
|
|
60
|
-
applied: applied,
|
|
61
|
-
items: cached_items.each_with_index.map do |cached_item, i|
|
|
62
|
-
locked = locked_flags[i]
|
|
63
|
-
before = locked ? cached_item.quantity_locked : cached_item.quantity_available
|
|
64
|
-
delta = quantities[i]
|
|
65
|
-
{
|
|
66
|
-
inventory_item_id: cached_item.inventory_item_id,
|
|
67
|
-
locked: locked,
|
|
68
|
-
quantity_available_before: before,
|
|
69
|
-
delta: delta,
|
|
70
|
-
quantity_available_after: applied ? before + delta : before
|
|
71
|
-
}
|
|
72
|
-
end
|
|
73
|
-
}
|
|
74
|
-
)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
45
|
# max_capacity is the ceiling for both pools. For :locked it is loose rather than exact — the
|
|
78
46
|
# true ceiling would be the size of the lock, which is not tracked — but it is always an upper
|
|
79
47
|
# bound on what can sit in the locked pool, so it never rejects a legitimate restock. Going
|
|
@@ -4,7 +4,6 @@ module SpreeCmCommissioner
|
|
|
4
4
|
# override
|
|
5
5
|
def call(line_item_ids:)
|
|
6
6
|
@line_item_ids = line_item_ids
|
|
7
|
-
correlation_id = SecureRandom.uuid
|
|
8
7
|
|
|
9
8
|
keys = []
|
|
10
9
|
quantities = []
|
|
@@ -31,51 +30,20 @@ module SpreeCmCommissioner
|
|
|
31
30
|
end
|
|
32
31
|
end
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
# decrement, so the mutation log below needs no extra Redis round trip.
|
|
36
|
-
cached_items = warm_redis_keys!(warmable_items)
|
|
33
|
+
warm_redis_keys!(warmable_items)
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
log_redis_unstock_mutation(correlation_id: correlation_id, cached_items: cached_items, quantities: quantities, locked_flags: locked_flags,
|
|
40
|
-
applied: applied
|
|
41
|
-
)
|
|
42
|
-
return failure(nil, :unable_to_unstock) unless applied
|
|
35
|
+
return failure(nil, :unable_to_unstock) unless unstock(keys, quantities)
|
|
43
36
|
|
|
44
37
|
inventory_id_and_quantities = inventory_ids.map.with_index do |inventory_id, i|
|
|
45
38
|
{ inventory_id: inventory_id, quantity: -quantities[i], locked: locked_flags[i] }
|
|
46
39
|
end
|
|
47
40
|
|
|
48
|
-
schedule_sync_inventory(inventory_id_and_quantities
|
|
41
|
+
schedule_sync_inventory(inventory_id_and_quantities)
|
|
49
42
|
success(nil)
|
|
50
43
|
end
|
|
51
44
|
|
|
52
45
|
private
|
|
53
46
|
|
|
54
|
-
# `applied: false` means the eval rejected the whole batch (would go negative) — nothing was
|
|
55
|
-
# written, so there is no "after" for those items.
|
|
56
|
-
def log_redis_unstock_mutation(correlation_id:, cached_items:, quantities:, locked_flags:, applied:)
|
|
57
|
-
CmAppLogger.log(
|
|
58
|
-
label: "#{self.class.name}#unstock",
|
|
59
|
-
data: {
|
|
60
|
-
correlation_id: correlation_id,
|
|
61
|
-
line_item_ids: @line_item_ids,
|
|
62
|
-
applied: applied,
|
|
63
|
-
items: cached_items.each_with_index.map do |cached_item, i|
|
|
64
|
-
locked = locked_flags[i]
|
|
65
|
-
before = locked ? cached_item.quantity_locked : cached_item.quantity_available
|
|
66
|
-
delta = quantities[i]
|
|
67
|
-
{
|
|
68
|
-
inventory_item_id: cached_item.inventory_item_id,
|
|
69
|
-
locked: locked,
|
|
70
|
-
quantity_available_before: before,
|
|
71
|
-
delta: delta,
|
|
72
|
-
quantity_available_after: applied ? before - delta : before
|
|
73
|
-
}
|
|
74
|
-
end
|
|
75
|
-
}
|
|
76
|
-
)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
47
|
def unstock(keys, quantities)
|
|
80
48
|
SpreeCmCommissioner.inventory_redis_pool.with do |redis|
|
|
81
49
|
redis.eval(unstock_redis_script, keys: keys, argv: quantities)
|
data/config/locales/en.yml
CHANGED
|
@@ -1006,8 +1006,6 @@ en:
|
|
|
1006
1006
|
exceeded_active_holds: "You've reached the limit of reservations you can hold at once. Please complete or cancel one before reserving more."
|
|
1007
1007
|
ip_rate_limit: "Too many reservation attempts from your network in a short time. Please wait a while and try again."
|
|
1008
1008
|
hold_cooldown: "Your previous reservation just expired. Please wait a couple of minutes before reserving again."
|
|
1009
|
-
convert:
|
|
1010
|
-
hold_expired: "Your reservation expired before payment completed. Please try booking again."
|
|
1011
1009
|
|
|
1012
1010
|
# Displayed as-is on stock-status badges pushed to Firestore (see
|
|
1013
1011
|
# SpreeCmCommissioner::Products::PublishStockStatusToFirestore) — not read via I18n at request
|
data/config/locales/km.yml
CHANGED
|
@@ -605,8 +605,6 @@ km:
|
|
|
605
605
|
exceeded_active_holds: "អ្នកកក់ដល់ចំនួនកំណត់អតិបរមាក្នុងពេលតែមួយហើយ។ សូមបញ្ចប់ ឬលុបការកក់មួយ មុនពេលកក់បន្ថែម។"
|
|
606
606
|
ip_rate_limit: "មានការព្យាយាមកក់ច្រើនពេកពីបណ្តាញរបស់អ្នកក្នុងរយៈពេលខ្លី។ សូមរង់ចាំបន្តិច រួចព្យាយាមម្តងទៀត។"
|
|
607
607
|
hold_cooldown: "ការកក់មុនរបស់អ្នកទើបតែផុតកំណត់។ សូមរង់ចាំពីរបីនាទី មុនពេលកក់ម្តងទៀត។"
|
|
608
|
-
convert:
|
|
609
|
-
hold_expired: "ការកក់របស់អ្នកបានផុតកំណត់មុនពេលទូទាត់ប្រាក់បានបញ្ចប់។ សូមព្យាយាមកក់ម្តងទៀត។"
|
|
610
608
|
|
|
611
609
|
stock_status:
|
|
612
610
|
pending_hold: "តម្រូវការខ្ពស់"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: spree_cm_commissioner
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.12.
|
|
4
|
+
version: 2.12.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- You
|
|
@@ -3781,9 +3781,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
3781
3781
|
version: '2.7'
|
|
3782
3782
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
3783
3783
|
requirements:
|
|
3784
|
-
- - "
|
|
3784
|
+
- - ">="
|
|
3785
3785
|
- !ruby/object:Gem::Version
|
|
3786
|
-
version:
|
|
3786
|
+
version: '0'
|
|
3787
3787
|
requirements:
|
|
3788
3788
|
- none
|
|
3789
3789
|
rubygems_version: 3.4.1
|