Class: SpreeCmCommissioner::InventoryHolds::Acquire
- Inherits:
-
Object
- Object
- SpreeCmCommissioner::InventoryHolds::Acquire
- Extended by:
- ServiceModuleThrowable
- Includes:
- Spree::ServiceModule::Base
- Defined in:
- app/services/spree_cm_commissioner/inventory_holds/acquire.rb
Constant Summary collapse
- AcquireRedisDeductionError =
Class.new(StandardError)
- AcquireHoldSaveError =
Class.new(StandardError)
- AcquireHoldLockConflictError =
Class.new(StandardError)
- AcquireInsufficientStockError =
Class.new(StandardError)
- AcquireLimitExceededError =
Class.new(StandardError)
Instance Method Summary collapse
-
#acquire_hold_for_order!(order, line_items, expires_at) ⇒ Object
Acquires active hold for the order, or returns existing if active.
-
#acquire_lua_script ⇒ Object
Lua script atomically reserves inventory across items: 1.
- #acquire_redis_stock!(line_items) ⇒ Object
- #call(order:, expires_at:) ⇒ Object
- #enqueue_release_job(hold) ⇒ Object
- #enqueue_sync_inventory_on_hold(order) ⇒ Object
-
#with_advisory_lock!(order) ⇒ Object
Uses PostgreSQL advisory locks to ensure only one process per order can acquire a hold at a time.
Methods included from ServiceModuleThrowable
Instance Method Details
#acquire_hold_for_order!(order, line_items, expires_at) ⇒ Object
Acquires active hold for the order, or returns existing if active.
Creates new hold with :active status and deducts from Redis stock. On DB save failure: transaction rolls back. On Redis failure: transaction rolls back hold (Redis untouched, no manual release needed).
Risk: Redis deduction could orphan if exception raised after Redis commit but before DB. Accepted—advisory lock ensures single process per order prevents impact.
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'app/services/spree_cm_commissioner/inventory_holds/acquire.rb', line 76 def acquire_hold_for_order!(order, line_items, expires_at) hold = SpreeCmCommissioner::InventoryHold.find_or_initialize_by(order: order) return hold if hold.persisted? && hold.active? return hold if hold.persisted? && hold.payment_locked? SpreeCmCommissioner::InventoryHold.transaction do begin hold.assign_attributes( user: order.user, status: :active, held_at: hold.held_at || Time.current, expires_at: expires_at, payment_locked_at: nil, release_reason: nil, total_quantity_on_hold: line_items.sum(&:quantity), ip_address: order.last_ip_address ) hold.save! rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique raise AcquireHoldSaveError, I18n.t('inventory_hold.acquire.db_save_error') end begin positive = acquire_redis_stock!(line_items) raise AcquireInsufficientStockError, I18n.t('inventory_hold.acquire.insufficient_stock') unless positive rescue Redis::BaseError, Redis::CommandError, IOError raise AcquireRedisDeductionError, I18n.t('inventory_hold.acquire.redis_error') end hold end end |
#acquire_lua_script ⇒ Object
Lua script atomically reserves inventory across items:
- Validate: Ensure available stock (stock - held) >= quantity for ALL items
- Commit: If all pass, increment hold counters atomically
- Return: 1 (success) or 0 (failure/insufficient stock) Atomicity prevents overselling and race conditions.
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 |
# File 'app/services/spree_cm_commissioner/inventory_holds/acquire.rb', line 176 def acquire_lua_script <<~LUA -- KEYS = [stock_key_1, hold_key_1, stock_key_2, hold_key_2, ...] -- ARGV = [qty_1, qty_2, ...] for i = 1, #ARGV do local stock = tonumber(redis.call('GET', KEYS[(i-1)*2+1]) or 0) local held = tonumber(redis.call('GET', KEYS[(i-1)*2+2]) or 0) if stock - held < tonumber(ARGV[i]) then return 0 end end for i = 1, #ARGV do redis.call('INCRBY', KEYS[(i-1)*2+2], tonumber(ARGV[i])) end return 1 LUA end |
#acquire_redis_stock!(line_items) ⇒ Object
110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'app/services/spree_cm_commissioner/inventory_holds/acquire.rb', line 110 def acquire_redis_stock!(line_items) stock_keys = [] hold_keys = [] quantities = [] warmable_items = [] line_items.each do |li| # A locked-stock line reserves from the locked pool's own key pair. The Lua below is # unchanged and never knew which pool it was reading — `stock - held >= qty` against the # locked keys is the same guarantee, applied to the operator's allotment. # # This is also why the check still works when public stock is sold out: the agent's units # were never in the public key, so reading it would refuse a purchase that should succeed. locked = li.locked_stock? li.inventory_items.active.each do |item| stock_keys << item.redis_key(locked: locked) hold_keys << item.redis_hold_key(locked: locked) quantities << li.quantity warmable_items << item end end return true if hold_keys.empty? SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(warmable_items).call interleaved_keys = stock_keys.zip(hold_keys).flatten result = SpreeCmCommissioner.inventory_redis_pool.with do |redis| redis.eval(acquire_lua_script, keys: interleaved_keys, argv: quantities) end result.positive? end |
#call(order:, expires_at:) ⇒ Object
15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
# File 'app/services/spree_cm_commissioner/inventory_holds/acquire.rb', line 15 def call(order:, expires_at:) line_items = order.line_items.select(&:should_hold_inventory?) return success(nil) if line_items.empty? # Skipped when every holdable line draws on locked stock. These caps exist to stop # anonymous users hoarding PUBLIC stock; a token holder drawing on their own allotment cannot # touch anyone else's inventory and was already authorized when the draft was created. Without # this, an agency office on one IP hits MAX_HOLDS_PER_IP_PER_HOUR by its sixth booking. # # `all?`, not `any?`: a MIXED order touches public stock too and stays limited, so one locked # line cannot buy exemption for the public lines beside it. Overselling is still blocked by # the Redis check below, exactly as it is for load-test traffic (RequestGuard). unless line_items.all?(&:locked_stock?) result = SpreeCmCommissioner::InventoryHolds::ValidateLimits.call(order: order) raise AcquireLimitExceededError, result.error.to_s unless result.success? end with_advisory_lock!(order) do hold = acquire_hold_for_order!(order, line_items, expires_at) enqueue_release_job(hold) enqueue_sync_inventory_on_hold(order) CmAppLogger.log( label: "#{self.class.name}#call success", data: { order_id: order.id, hold_id: hold.id } ) success(hold) end rescue StandardError => e error = { error_type: e.class.name.demodulize, order_id: order.id, message: e. } CmAppLogger.error(label: "#{self.class.name}#call failed", data: error) failure(nil, e.) end |
#enqueue_release_job(hold) ⇒ Object
145 146 147 |
# File 'app/services/spree_cm_commissioner/inventory_holds/acquire.rb', line 145 def enqueue_release_job(hold) SpreeCmCommissioner::InventoryHolds::ReleaseJob.set(wait_until: hold.expires_at).perform_later(hold_id: hold.id) end |
#enqueue_sync_inventory_on_hold(order) ⇒ Object
149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 |
# File 'app/services/spree_cm_commissioner/inventory_holds/acquire.rb', line 149 def enqueue_sync_inventory_on_hold(order) inventory_id_and_quantities = order.line_items.flat_map do |line_item| next [] unless line_item.should_hold_inventory? line_item.inventory_items.active.map do |inventory_item| { inventory_id: inventory_item.id, quantity: line_item.quantity, locked: line_item.locked_stock? } end end return if inventory_id_and_quantities.blank? SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnHoldJob.perform_later( order_id: order.id, caller_source: "#{self.class.name}#enqueue_sync_inventory_on_hold", inventory_id_and_quantities: inventory_id_and_quantities ) end |
#with_advisory_lock!(order) ⇒ Object
Uses PostgreSQL advisory locks to ensure only one process per order can acquire a hold at a time.
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
# File 'app/services/spree_cm_commissioner/inventory_holds/acquire.rb', line 52 def with_advisory_lock!(order) lock_id = Zlib.crc32("spree_cm_commissioner:inventory_hold_acquire:#{order.id}") # select_value returns a Ruby boolean (pg adapter >= 1.0) or string "t"/"f" (older adapters). # Explicitly check both forms so lock failures are never silently treated as success. locked = [true, 't'].include?(ActiveRecord::Base.connection.select_value("SELECT pg_try_advisory_lock(#{lock_id})")) raise AcquireHoldLockConflictError, I18n.t('inventory_hold.acquire.lock_timeout') unless locked begin yield ensure ActiveRecord::Base.connection.execute("SELECT pg_advisory_unlock(#{lock_id})") end end |