spree_cm_commissioner 2.12.3 → 2.12.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b375a606aa20cb41edb29f05952ed603df382741dc452a218c55d63c2017e867
4
- data.tar.gz: 4752e598b58b115740b2205a50517fabb95617e2cea760f1e7a8e9501823033b
3
+ metadata.gz: f4502090937a0e1728668be217bf74bf24b37a424b3b412c1df11e061e72268c
4
+ data.tar.gz: bca33ab83038577e339e01d24cc008c48d71e3af09a3131aa102f35d70433644
5
5
  SHA512:
6
- metadata.gz: c2c18a4eed93d148dc07d07726d1575a5ebb44b5e743f991d452d1ea2754f5772ea20f6c909f729e02907c5d4e5ed8adc4560ae4afee057f38a351bbe87afa91
7
- data.tar.gz: 64e3a068061c742b7684adddab10b3984094f5f77ef8a3ba9fbbe9978ba2d34c7ceb7b68c3bde121da8bde8ed4a4f80873d125813df01627cb50ac38d5045953
6
+ metadata.gz: ca38d3fff9dd926ed84e6bf3ff0565d9af7b8fa6e816448b386fb460a757fdb7b31788dab65d287bedcaba869cd47b4351960bb8ddf0eef86c05caf3db118257
7
+ data.tar.gz: ea4d544a09afe3fe32fce93f52587ffd1b25c0cf986c2ec94a291a2e07e2744d674acb6ef6c0c6a2886303360318d9b9f0d3cfabc2427a6fe775b7e025313e49
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.12.3)
37
+ spree_cm_commissioner (2.12.4)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -0,0 +1,47 @@
1
+ module SpreeCmCommissioner
2
+ module InventoryItems
3
+ class BulkAdjustQuantitiesOnConvertJob < ApplicationUniqueJob
4
+ include SpreeCmCommissioner::IdempotentJob
5
+
6
+ queue_as :inventory_item
7
+
8
+ def perform(options = {})
9
+ with_idempotency(idempotency_key_for(options)) do
10
+ bulk_adjust_quantities_on_convert(options)
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ # Dedup on the order + the exact adjustment set, same shape as BulkAdjustQuantitiesOnHoldJob.
17
+ # An explicit key wins; with no order_id we fall back to the per-enqueue job_id (returning
18
+ # nil lets with_idempotency apply that default).
19
+ def idempotency_key_for(options)
20
+ return options[:idempotency_key] if options[:idempotency_key].present?
21
+ return nil if options[:order_id].blank?
22
+
23
+ "order:#{options[:order_id]}:convert:#{adjustments_fingerprint(options[:inventory_id_and_quantities])}"
24
+ end
25
+
26
+ # Readable, order-independent fingerprint of the adjustment payload, e.g. "1=2,2=-1:locked".
27
+ def adjustments_fingerprint(adjustments)
28
+ Array(adjustments)
29
+ .map { |adj| adj.with_indifferent_access.values_at(:inventory_id, :quantity, :locked) }
30
+ .sort_by { |inventory_id, quantity, locked| [inventory_id.to_s, quantity.to_s, locked.to_s] }
31
+ .map { |inventory_id, quantity, locked| locked ? "#{inventory_id}=#{quantity}:locked" : "#{inventory_id}=#{quantity}" }
32
+ .join(',')
33
+ end
34
+
35
+ # :order_id, :inventory_id_and_quantities, :caller_source
36
+ def bulk_adjust_quantities_on_convert(options = {})
37
+ raise ArgumentError, 'order_id is required' if options[:order_id].blank?
38
+ raise ArgumentError, 'inventory_id_and_quantities is required' if options[:inventory_id_and_quantities].blank?
39
+
40
+ SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnConvert.call(
41
+ inventory_id_and_quantities: options[:inventory_id_and_quantities],
42
+ caller_source: options[:caller_source]
43
+ )
44
+ end
45
+ end
46
+ end
47
+ end
@@ -14,8 +14,8 @@ module SpreeCmCommissioner
14
14
 
15
15
  unless hold.finalized?
16
16
  hold.update!(status: :converted)
17
- clear_hold_redis!(order)
18
- enqueue_sync_inventory_on_hold(order)
17
+ convert_hold_redis!(order)
18
+ enqueue_sync_inventory(order)
19
19
  end
20
20
  end
21
21
 
@@ -28,8 +28,19 @@ module SpreeCmCommissioner
28
28
 
29
29
  private
30
30
 
31
- def clear_hold_redis!(order)
32
- keys = []
31
+ # Moves a held unit to a permanent sale by decrementing the hold counter AND the stock
32
+ # counter together, in one atomic Lua call. These used to be two separate Redis round trips
33
+ # (hold decrement here, stock decrement in RedisStock::Unstock, called a moment later from
34
+ # OrderStateMachine#unstock_inventory!) — a concurrent Acquire could read Redis in the gap
35
+ # between them and see the hold count already down but the stock count not yet down, i.e. a
36
+ # phantom unit of "available" capacity that was never actually freed, letting one order too
37
+ # many acquire a hold.
38
+ #
39
+ # RedisStock::Unstock skips any line item that should_hold_inventory? for exactly this reason
40
+ # — this method is now the only place their stock key gets decremented.
41
+ def convert_hold_redis!(order)
42
+ stock_keys = []
43
+ hold_keys = []
33
44
  quantities = []
34
45
  warmable_items = []
35
46
 
@@ -42,34 +53,44 @@ module SpreeCmCommissioner
42
53
 
43
54
  # Skip .active scope: holds on past-dated items must also be cleared (e.g. delayed conversion after trip date).
44
55
  li.inventory_items.each do |item|
45
- keys << item.redis_hold_key(locked: locked)
56
+ stock_keys << item.redis_key(locked: locked)
57
+ hold_keys << item.redis_hold_key(locked: locked)
46
58
  quantities << li.quantity
47
59
  warmable_items << item
48
60
  end
49
61
  end
50
62
 
51
- return if keys.empty?
52
-
53
- clear_hold_lua_script = <<~LUA
54
- local keys = KEYS
55
- local quantities = ARGV
56
- for i, key in ipairs(keys) do
57
- redis.call('DECRBY', key, tonumber(quantities[i]))
58
- end
59
- return 1
60
- LUA
63
+ return if hold_keys.empty?
61
64
 
62
65
  SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(warmable_items).call
66
+
67
+ interleaved_keys = stock_keys.zip(hold_keys).flatten
63
68
  SpreeCmCommissioner.inventory_redis_pool.with do |redis|
64
- redis.eval(clear_hold_lua_script, keys: keys, argv: quantities)
69
+ redis.eval(convert_hold_lua_script, keys: interleaved_keys, argv: quantities)
65
70
  end
66
71
  end
67
72
 
68
- def enqueue_sync_inventory_on_hold(order)
69
- inventory_id_and_quantities = order.line_items.flat_map do |line_item|
73
+ def convert_hold_lua_script
74
+ <<~LUA
75
+ -- KEYS = [stock_key_1, hold_key_1, stock_key_2, hold_key_2, ...]
76
+ -- ARGV = [qty_1, qty_2, ...]
77
+ for i = 1, #ARGV do
78
+ redis.call('DECRBY', KEYS[(i-1)*2+1], tonumber(ARGV[i]))
79
+ redis.call('DECRBY', KEYS[(i-1)*2+2], tonumber(ARGV[i]))
80
+ end
81
+ return 1
82
+ LUA
83
+ end
84
+
85
+ # Keeps the DB mirrors in sync with both halves of convert_hold_redis! (quantity_on_hold drops
86
+ # because the hold is gone, quantity_available drops because the unit is permanently sold) via
87
+ # ONE job that applies both under a single row lock per item — see
88
+ # InventoryItems::BulkAdjustQuantitiesOnConvert for why this isn't the usual two separate jobs.
89
+ def enqueue_sync_inventory(order)
90
+ adjustments = order.line_items.flat_map do |line_item|
70
91
  next [] unless line_item.should_hold_inventory?
71
92
 
72
- # Skip .active scope for the same reason as clear_hold_redis!.
93
+ # Skip .active scope for the same reason as convert_hold_redis!.
73
94
  line_item.inventory_items.map do |inventory_item|
74
95
  {
75
96
  inventory_id: inventory_item.id,
@@ -79,12 +100,12 @@ module SpreeCmCommissioner
79
100
  end
80
101
  end
81
102
 
82
- return if inventory_id_and_quantities.blank?
103
+ return if adjustments.blank?
83
104
 
84
- SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnHoldJob.perform_later(
105
+ SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnConvertJob.perform_later(
85
106
  order_id: order.id,
86
- caller_source: "#{self.class.name}#enqueue_sync_inventory_on_hold",
87
- inventory_id_and_quantities: inventory_id_and_quantities
107
+ caller_source: "#{self.class.name}#enqueue_sync_inventory",
108
+ inventory_id_and_quantities: adjustments
88
109
  )
89
110
  end
90
111
  end
@@ -0,0 +1,96 @@
1
+ module SpreeCmCommissioner
2
+ module InventoryItems
3
+ # DB mirror of InventoryHolds::Convert#convert_hold_redis!: applies the same delta to
4
+ # quantity_on_hold and quantity_available together, under ONE row lock per inventory item.
5
+ # Doing this as two separate jobs (BulkAdjustQuantitiesOnHoldJob + BulkAdjustQuantitiesJob),
6
+ # each locking and saving independently, left a window between the two locks where a reader
7
+ # could see one column updated and the other not yet — the same class of gap that made the
8
+ # Redis side non-atomic. One lock, one update, removes that window on the DB side too.
9
+ class BulkAdjustQuantitiesOnConvert
10
+ prepend ::Spree::ServiceModule::Base
11
+ extend SpreeCmCommissioner::ServiceModuleThrowable
12
+
13
+ def call(inventory_id_and_quantities:, caller_source: nil)
14
+ CmAppLogger.log(
15
+ label: "#{self.class.name}#call started",
16
+ data: { caller_source: caller_source, inventory_id_and_quantities: inventory_id_and_quantities }
17
+ )
18
+
19
+ ActiveRecord::Base.transaction do
20
+ items_by_id = inventory_items(inventory_id_and_quantities).index_by(&:id)
21
+
22
+ grouped_deltas(inventory_id_and_quantities).each do |(inventory_id, locked), quantity|
23
+ inventory_item = items_by_id[inventory_id]
24
+ next if inventory_item.nil?
25
+
26
+ adjust_quantities(inventory_item, quantity, caller_source, locked: locked)
27
+ end
28
+ end
29
+
30
+ success(nil)
31
+ end
32
+
33
+ private
34
+
35
+ # Grouped by pool flag as well as by item, same as BulkAdjustQuantities/OnHold: a mixed cart
36
+ # can carry a public and a locked adjustment for the same inventory item in one payload.
37
+ def grouped_deltas(inventory_id_and_quantities)
38
+ inventory_id_and_quantities
39
+ .group_by { |entry| [entry[:inventory_id], entry[:locked] || false] }
40
+ .transform_values { |entries| entries.sum { |entry| entry[:quantity] } }
41
+ end
42
+
43
+ def adjust_quantities(inventory_item, quantity, caller_source, locked: false)
44
+ available_column = locked ? :quantity_locked : :quantity_available
45
+ hold_column = locked ? :quantity_locked_on_hold : :quantity_on_hold
46
+
47
+ before_available = before_hold = after_available = after_hold = nil
48
+
49
+ # IMPORTANT: Apply both quantity changes directly without defensive clamping — model
50
+ # validation surfaces bugs in upstream Redis deduction logic rather than hiding them.
51
+ inventory_item.with_lock do
52
+ before_available = inventory_item[available_column]
53
+ before_hold = inventory_item[hold_column]
54
+ after_available = before_available + quantity
55
+ after_hold = before_hold + quantity
56
+
57
+ inventory_item.update!(available_column => after_available, hold_column => after_hold)
58
+ end
59
+
60
+ CmAppLogger.log(
61
+ label: "#{self.class.name}#adjust_quantities",
62
+ data: {
63
+ caller_source: caller_source,
64
+ inventory_item_id: inventory_item.id,
65
+ locked: locked,
66
+ available_column: available_column,
67
+ quantity_available_before: before_available,
68
+ quantity_available_after: after_available,
69
+ hold_column: hold_column,
70
+ quantity_on_hold_before: before_hold,
71
+ quantity_on_hold_after: after_hold,
72
+ delta: quantity
73
+ }
74
+ )
75
+ rescue ActiveRecord::RecordInvalid
76
+ CmAppLogger.error(
77
+ label: "#{self.class.name}#adjust_quantities failed",
78
+ data: {
79
+ caller_source: caller_source,
80
+ inventory_item_id: inventory_item.id,
81
+ locked: locked,
82
+ delta: quantity,
83
+ quantity_available_current: inventory_item[available_column],
84
+ quantity_on_hold_current: inventory_item[hold_column],
85
+ errors: inventory_item.errors.full_messages
86
+ }
87
+ )
88
+ raise
89
+ end
90
+
91
+ def inventory_items(inventory_id_and_quantities)
92
+ SpreeCmCommissioner::InventoryItem.where(id: inventory_id_and_quantities.pluck(:inventory_id)).includes(variant: :product)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -12,6 +12,10 @@ module SpreeCmCommissioner
12
12
  warmable_items = []
13
13
 
14
14
  line_items.each do |li|
15
+ # Already deducted on convert (InventoryHolds::Convert#convert_hold_redis!), so no need to
16
+ # deduct again here.
17
+ next if li.should_hold_inventory?
18
+
15
19
  # Each line item deducts from its own pool: a locked-stock line was never counted in public
16
20
  # stock, so unstocking it there would either fail the Lua guard on a paid order or, where
17
21
  # public stock happens to cover it, oversell silently.
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.12.3'.freeze
2
+ VERSION = '2.12.4'.freeze
3
3
 
4
4
  module_function
5
5
 
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.3
4
+ version: 2.12.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
@@ -1404,6 +1404,7 @@ files:
1404
1404
  - app/jobs/spree_cm_commissioner/inventory_holds/release_job.rb
1405
1405
  - app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_by_variant_job.rb
1406
1406
  - app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_job.rb
1407
+ - app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert_job.rb
1407
1408
  - app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb
1408
1409
  - app/jobs/spree_cm_commissioner/inventory_items/bulk_generate_permanent_items_job.rb
1409
1410
  - app/jobs/spree_cm_commissioner/inventory_items/generate_inventory_items_job.rb
@@ -2306,6 +2307,7 @@ files:
2306
2307
  - app/services/spree_cm_commissioner/inventory_items/adjust_available_quantity.rb
2307
2308
  - app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities.rb
2308
2309
  - app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_by_variant.rb
2310
+ - app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert.rb
2309
2311
  - app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold.rb
2310
2312
  - app/services/spree_cm_commissioner/inventory_items/bulk_generate_permanent_items.rb
2311
2313
  - app/services/spree_cm_commissioner/inventory_items/generate_non_permanent_item.rb