spree_cm_commissioner 2.12.3 → 2.12.5
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/interactors/spree_cm_commissioner/waiting_guests_caller.rb +14 -2
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert_job.rb +47 -0
- data/app/services/spree_cm_commissioner/inventory_holds/convert.rb +44 -23
- data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert.rb +96 -0
- data/app/services/spree_cm_commissioner/redis_stock/unstock.rb +4 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c0228f6ca7b7b92d005af33e616b862a1ca576c9320b70510c7f235fbb058848
|
|
4
|
+
data.tar.gz: 66e3e4f595da2175c3fd0b06ca0c5712bb340b3fb8b9e1e1f71cd1495559dda9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3a21bc63b5229c2ab198fa5d1d342b8375ed069d9a4cbee964243310a840a99acf9ab63b426defeca77b8f1f5511fcfa4fa8e5703c72fe580c06d53834452961
|
|
7
|
+
data.tar.gz: 71f6bd10a265ab1dd8c6ed0b30972105f3d2cfa332a2da3b6dfa347c8899eb1e97bb9962a6ff1c831b5cc3c6e3209409c435154e6cdc4f92a162191990bd2263
|
data/Gemfile.lock
CHANGED
|
@@ -9,8 +9,8 @@ module SpreeCmCommissioner
|
|
|
9
9
|
# Release at most this many guests per run, even when far more slots are free. A capacity spike
|
|
10
10
|
# (cold start / lull → active_sessions low → available_slots ≈ max_sessions, e.g. 900) would
|
|
11
11
|
# otherwise release every queued guest at once, dumping 900 simultaneous session-token requests
|
|
12
|
-
# on the server. Capping paces entry at ~MAX_CALLS_PER_RUN
|
|
13
|
-
# the rest stay queued and drain over the following runs.
|
|
12
|
+
# on the server. Capping paces entry at ~MAX_CALLS_PER_RUN per run (e.g. 30s, per
|
|
13
|
+
# config/schedule.yml); the rest stay queued and drain over the following runs.
|
|
14
14
|
MAX_CALLS_PER_RUN = (ENV['WAITING_ROOM_MAX_CALLS_PER_RUN'] || 50).to_i
|
|
15
15
|
|
|
16
16
|
def call
|
|
@@ -59,9 +59,21 @@ module SpreeCmCommissioner
|
|
|
59
59
|
previous_guests + eligible_guests_in(records_path, remaining_slots)
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# This query requires a composite index (allow_to_enter_room_at ==, position !=, queued_at
|
|
63
|
+
# order); create it in Firebase beforehand, same as the plain index #fetch_long_waiting_guests
|
|
64
|
+
# already needed.
|
|
65
|
+
#
|
|
66
|
+
# The position != nil filter closes the call-before-stamp race: without it, a doc could be
|
|
67
|
+
# called here before StampQueuePositions ever assigns it a position, permanently excluding it
|
|
68
|
+
# from #eligible_guests_in (every future run still sees allow_to_enter_room_at == nil, but the
|
|
69
|
+
# calling_all write below sets it non-nil for good) while still counting toward
|
|
70
|
+
# total_exited_queue's unscoped count. Gating on position != nil makes "already stamped" true
|
|
71
|
+
# by construction before a doc can ever be called, instead of relying on StampQueuePositions
|
|
72
|
+
# reliably beating this job to it (e.g. 10s vs. 30s — see cm-market-server/config/schedule.yml).
|
|
62
73
|
def eligible_guests_in(records_path, limit)
|
|
63
74
|
firestore.col(records_path)
|
|
64
75
|
.where('allow_to_enter_room_at', '==', nil)
|
|
76
|
+
.where('position', '!=', nil)
|
|
65
77
|
.order('queued_at')
|
|
66
78
|
.limit(limit)
|
|
67
79
|
.get.to_a
|
|
@@ -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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
69
|
+
redis.eval(convert_hold_lua_script, keys: interleaved_keys, argv: quantities)
|
|
65
70
|
end
|
|
66
71
|
end
|
|
67
72
|
|
|
68
|
-
def
|
|
69
|
-
|
|
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
|
|
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
|
|
103
|
+
return if adjustments.blank?
|
|
83
104
|
|
|
84
|
-
SpreeCmCommissioner::InventoryItems::
|
|
105
|
+
SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnConvertJob.perform_later(
|
|
85
106
|
order_id: order.id,
|
|
86
|
-
caller_source: "#{self.class.name}#
|
|
87
|
-
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.
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- You
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: spree
|
|
@@ -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
|