spree_cm_commissioner 2.14.8 → 2.14.10

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.
Files changed (24) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/concerns/spree_cm_commissioner/taxon_visible_products_preloadable.rb +24 -0
  4. data/app/controllers/spree/api/v2/storefront/taxons_controller_decorator.rb +2 -0
  5. data/app/controllers/spree/api/v2/tenant/taxons_controller.rb +2 -0
  6. data/app/jobs/concerns/spree_cm_commissioner/idempotent_job.rb +7 -6
  7. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_job.rb +4 -13
  8. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert_job.rb +2 -21
  9. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb +3 -29
  10. data/app/models/concerns/spree_cm_commissioner/firestore_connection.rb +7 -9
  11. data/app/models/spree_cm_commissioner/inventory_hold.rb +5 -0
  12. data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +1 -4
  13. data/app/services/spree_cm_commissioner/inventory_holds/acquire.rb +28 -17
  14. data/app/services/spree_cm_commissioner/inventory_holds/convert.rb +1 -0
  15. data/app/services/spree_cm_commissioner/inventory_holds/release.rb +1 -0
  16. data/app/services/spree_cm_commissioner/queue_draft_orders/publish_status.rb +8 -8
  17. data/app/services/spree_cm_commissioner/redis_stock/base.rb +7 -1
  18. data/app/services/spree_cm_commissioner/waiting_room/publish_lobby_path.rb +1 -8
  19. data/app/services/spree_cm_commissioner/waiting_room_system_metadata_fetcher.rb +2 -8
  20. data/config/initializers/firestore.rb +19 -0
  21. data/lib/spree_cm_commissioner/firestore_clients.rb +49 -0
  22. data/lib/spree_cm_commissioner/version.rb +1 -1
  23. data/lib/spree_cm_commissioner.rb +1 -0
  24. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ceb90915f56d7b2621b44c00abe7b65adbdf5093f566d3b801333496c3333402
4
- data.tar.gz: 418a9332f9ddf34711f4c251c5c6040388e191efa8f2f8eefc11d12777a6f79a
3
+ metadata.gz: de45fdbe1a6ac4c18da7e6b8fbd3eb6f4fe7241bb10e23c0d4c23973a91b7170
4
+ data.tar.gz: 54156df2cef71cff39256c04a949869559c0e477f6c1ba413d8b0ef91761d616
5
5
  SHA512:
6
- metadata.gz: 6ef4fcfc3e0feb5af8d54f06a7321e1369a35b87f625d8465a70edb5c2ac2502e180812ba60fb3d5d081bc746367bcb756e9124416719f73af0143cea86242b6
7
- data.tar.gz: 46af2a2e96874f557e5921b671838148b148791a408c0c55b26d7647fb4fc1fc11ea775d25ee5998da402d0f7cac4b00374bb269ee21eaef5edc088bb1053bdd
6
+ metadata.gz: '0383b1bb4e0459522cc0004b2a841a5697e18b9b28ff62469a8e1ce5501f91e7fb3fb8899689c2174d997484231b71883408069aca5f53ed0af400bb8e091cd6'
7
+ data.tar.gz: 5adeb841a23fb3d28a74e0972d34c478a0a80e26ba5ab114bb4d837aac2dadaf98fa9e696aff506fa4ed0de1acaa48af64067e8db8cd34803aa2518e0661945d
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.14.8)
37
+ spree_cm_commissioner (2.14.10)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -0,0 +1,24 @@
1
+ module SpreeCmCommissioner
2
+ # The taxon serializer renders `visible_products` as loaded, so the sales-channel filter lives here:
3
+ # preloading it scoped to the request's channel hides tenant-only products on BookMe+ and platform-only ones on a tenant app.
4
+ # Prepend it (not include) so it wraps a controller's own `resource`/`paginated_collection`.
5
+ module TaxonVisibleProductsPreloadable
6
+ private
7
+
8
+ def resource
9
+ @resource ||= super.tap { |taxon| preload_visible_products([taxon]) }
10
+ end
11
+
12
+ def paginated_collection
13
+ @paginated_collection ||= super.tap { |taxons| preload_visible_products(taxons.to_a) }
14
+ end
15
+
16
+ def preload_visible_products(taxons)
17
+ ActiveRecord::Associations::Preloader.new(
18
+ records: taxons,
19
+ associations: :visible_products,
20
+ scope: Spree::Product.sellable_on(Spree::Product.sales_channel_for(@tenant))
21
+ ).call
22
+ end
23
+ end
24
+ end
@@ -3,6 +3,8 @@ module Spree
3
3
  module V2
4
4
  module Storefront
5
5
  module TaxonsControllerDecorator
6
+ include SpreeCmCommissioner::TaxonVisibleProductsPreloadable
7
+
6
8
  def scope
7
9
  super.where(preview: false)
8
10
  end
@@ -3,6 +3,8 @@ module Spree
3
3
  module V2
4
4
  module Tenant
5
5
  class TaxonsController < BaseController
6
+ prepend SpreeCmCommissioner::TaxonVisibleProductsPreloadable
7
+
6
8
  private
7
9
 
8
10
  def collection_serializer
@@ -1,12 +1,13 @@
1
1
  module SpreeCmCommissioner
2
2
  module IdempotentJob
3
- def with_idempotency(key = nil, &block)
4
- SpreeCmCommissioner::IdempotencyKey.run_once(key.presence || default_idempotency_key, &block)
5
- end
3
+ # The enqueuer mints one key per side effect being mirrored (e.g. one per Redis change) and
4
+ # passes it in the job args, so a retry or re-delivery of that job dedups while a genuinely new
5
+ # change always applies. No fallback: a key derived from the payload made legitimate repeats
6
+ # (unstock after restock, a re-acquired hold) look like duplicates and silently skipped them.
7
+ def with_idempotency(key, &block)
8
+ raise ArgumentError, "#{self.class.name} requires an idempotency_key" if key.blank?
6
9
 
7
- # Namespaced so the key is self-describing in the DB (no `source` column needed).
8
- def default_idempotency_key
9
- "#{self.class.name}:#{job_id}"
10
+ SpreeCmCommissioner::IdempotencyKey.run_once(key, &block)
10
11
  end
11
12
  end
12
13
  end
@@ -6,26 +6,17 @@ module SpreeCmCommissioner
6
6
  queue_as :inventory_item
7
7
 
8
8
  def perform(options = {})
9
- with_idempotency(idempotency_key_for(options)) do
9
+ with_idempotency(options[:idempotency_key]) do
10
10
  bulk_adjust_quantities!(options)
11
11
  end
12
12
  end
13
13
 
14
14
  private
15
15
 
16
- # Dedup on the line items being synced, so a re-pushed payload for the same
17
- # line items no-ops. An explicit key wins; with no line items we fall back to
18
- # the per-enqueue job_id (returning 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[:line_item_ids].blank?
22
-
23
- "line_item:#{options[:line_item_ids].compact.uniq.sort.join(':')}"
24
- end
25
-
26
- # :line_item_ids, :inventory_id_and_quantities, :caller_source
16
+ # :line_item_ids, :inventory_id_and_quantities, :caller_source, :idempotency_key
27
17
  #
28
- # :caller_source is a string like "ClassName#method" identifying who enqueued the job.
18
+ # :line_item_ids is for tracing only. :caller_source is a string like "ClassName#method"
19
+ # identifying who enqueued the job.
29
20
  def bulk_adjust_quantities!(options = {})
30
21
  SpreeCmCommissioner::InventoryItems::BulkAdjustQuantities.call!(
31
22
  inventory_id_and_quantities: options[:inventory_id_and_quantities],
@@ -6,33 +6,14 @@ module SpreeCmCommissioner
6
6
  queue_as :inventory_item
7
7
 
8
8
  def perform(options = {})
9
- with_idempotency(idempotency_key_for(options)) do
9
+ with_idempotency(options[:idempotency_key]) do
10
10
  bulk_adjust_quantities_on_convert(options)
11
11
  end
12
12
  end
13
13
 
14
14
  private
15
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
16
+ # :order_id, :inventory_id_and_quantities, :caller_source, :idempotency_key
36
17
  def bulk_adjust_quantities_on_convert(options = {})
37
18
  raise ArgumentError, 'order_id is required' if options[:order_id].blank?
38
19
  raise ArgumentError, 'inventory_id_and_quantities is required' if options[:inventory_id_and_quantities].blank?
@@ -6,42 +6,16 @@ module SpreeCmCommissioner
6
6
  queue_as :inventory_item
7
7
 
8
8
  def perform(options = {})
9
- with_idempotency(idempotency_key_for(options)) do
9
+ with_idempotency(options[:idempotency_key]) do
10
10
  bulk_adjust_quantities_on_hold(options)
11
11
  end
12
12
  end
13
13
 
14
14
  private
15
15
 
16
- # Dedup on the order + the exact adjustment set, so a re-pushed identical
17
- # on-hold payload no-ops while a genuinely different one re-applies. An explicit
18
- # key wins; with no order_id we fall back to the per-enqueue job_id (returning
19
- # nil lets with_idempotency apply that default).
20
- def idempotency_key_for(options)
21
- return options[:idempotency_key] if options[:idempotency_key].present?
22
- return nil if options[:order_id].blank?
23
-
24
- "order:#{options[:order_id]}:hold:#{adjustments_fingerprint(options[:inventory_id_and_quantities])}"
25
- end
26
-
27
- # Readable, order-independent fingerprint of the adjustment payload, e.g. "1=2,2=-1:locked".
28
- # Delimiter hierarchy: ":" separates key fields, "," separates adjustments, "=" pairs id/qty.
29
- #
30
- # The pool is part of the identity: a mixed order can carry a public and a locked adjustment
31
- # with the same inventory_id and quantity, and without this they fingerprint identically — so
32
- # one of the two would be dropped as a duplicate and its counter never mirrored.
33
- def adjustments_fingerprint(adjustments)
34
- Array(adjustments)
35
- .map { |adj| adj.with_indifferent_access.values_at(:inventory_id, :quantity, :locked) }
36
- .sort_by { |inventory_id, quantity, locked| [inventory_id.to_s, quantity.to_s, locked.to_s] }
37
- .map { |inventory_id, quantity, locked| locked ? "#{inventory_id}=#{quantity}:locked" : "#{inventory_id}=#{quantity}" }
38
- .join(',')
39
- end
40
-
41
- # :order_id, :inventory_id_and_quantities, :caller_source
16
+ # :order_id, :inventory_id_and_quantities, :caller_source, :idempotency_key
42
17
  #
43
- # :order_id is used for unique idempotency key generation to prevent duplicate
44
- # processing, though it's not used in the adjustment itself.
18
+ # :order_id is required for tracing, though it's not used in the adjustment itself.
45
19
  # :caller_source is a string like "ClassName#method" identifying who enqueued the job.
46
20
  def bulk_adjust_quantities_on_hold(options = {})
47
21
  raise ArgumentError, 'order_id is required' if options[:order_id].blank?
@@ -1,10 +1,11 @@
1
1
  require 'google/cloud/firestore'
2
2
 
3
3
  module SpreeCmCommissioner
4
- # Shared Cloud Firestore client construction, used across the waiting-room services (see
5
- # WaitingRoom::Admission::CallGuests, WaitingRoom::Positions::Stamp,
6
- # WaitingRoom::Sessions::Issue) plus a #firestore_available? guard so environments without the
7
- # credential (dev, test, CI) no-op instead of raising on connect.
4
+ # Cloud Firestore access for services (see WaitingRoom::Admission::CallGuests,
5
+ # WaitingRoom::Positions::Stamp, WaitingRoom::Sessions::Issue) plus a #firestore_available?
6
+ # guard so environments without the credential (dev, test, CI) no-op instead of raising on
7
+ # connect. The client comes from FirestoreClients, shared per process and per project; a service
8
+ # for another Firebase project overrides #service_account.
8
9
  module FirestoreConnection
9
10
  extend ActiveSupport::Concern
10
11
 
@@ -13,14 +14,11 @@ module SpreeCmCommissioner
13
14
  end
14
15
 
15
16
  def firestore
16
- @firestore ||= Google::Cloud::Firestore.new(
17
- project_id: service_account[:project_id],
18
- credentials: service_account
19
- )
17
+ @firestore ||= SpreeCmCommissioner::FirestoreClients.fetch(service_account)
20
18
  end
21
19
 
22
20
  def service_account
23
- @service_account ||= Rails.application.credentials.cloud_firestore_service_account
21
+ @service_account ||= SpreeCmCommissioner::FirestoreClients.default_service_account
24
22
  end
25
23
  end
26
24
  end
@@ -23,6 +23,11 @@ module SpreeCmCommissioner
23
23
 
24
24
  store_public_metadata :total_quantity_on_hold, :integer
25
25
 
26
+ # The DB sync for this acquisition (see InventoryHolds::Acquire), kept so a re-entry can re-send
27
+ # it under the same ledger key when the original enqueue never happened.
28
+ store_private_metadata :on_hold_sync_idempotency_key, :string
29
+ store_private_metadata :on_hold_sync_payload, :array
30
+
26
31
  def finalized?
27
32
  converted? || released?
28
33
  end
@@ -4,10 +4,7 @@ module Spree
4
4
  module TaxonSerializerDecorator
5
5
  def self.prepended(base)
6
6
  base.has_many :vendors, serializer: ::Spree::V2::Storefront::VendorSerializer
7
- # The app lists an event section's tickets from this relation, so products a tenant keeps off BookMe+ are dropped here.
8
- base.has_many :visible_products, serializer: ::Spree::V2::Storefront::ProductSerializer do |taxon|
9
- taxon.visible_products.sellable_on(:platform)
10
- end
7
+ base.has_many :visible_products, serializer: ::Spree::V2::Storefront::ProductSerializer
11
8
  base.has_many :places, serializer: ::Spree::V2::Storefront::PlaceSerializer
12
9
 
13
10
  base.has_one :vendor, serializer: ::Spree::V2::Storefront::VendorSerializer
@@ -33,7 +33,10 @@ module SpreeCmCommissioner
33
33
  hold = acquire_hold_for_order!(order, line_items, expires_at)
34
34
 
35
35
  enqueue_release_job(hold)
36
- enqueue_sync_inventory_on_hold(order)
36
+ # Re-sent on every call, including re-entry with an existing hold: if this enqueue failed
37
+ # after the hold committed, the retry recovers it. The key stored with the acquisition makes
38
+ # a repeat a no-op in the ledger once the sync has applied.
39
+ enqueue_sync_inventory_on_hold(hold)
37
40
 
38
41
  CmAppLogger.log(
39
42
  label: "#{self.class.name}#call success",
@@ -88,7 +91,11 @@ module SpreeCmCommissioner
88
91
  payment_locked_at: nil,
89
92
  release_reason: nil,
90
93
  total_quantity_on_hold: line_items.sum(&:quantity),
91
- ip_address: order.last_ip_address
94
+ ip_address: order.last_ip_address,
95
+ # Saved in the same transaction as the Redis reservation, so the DB sync for exactly
96
+ # this acquisition can be re-sent later without inventing a new key or payload.
97
+ on_hold_sync_idempotency_key: SecureRandom.uuid,
98
+ on_hold_sync_payload: on_hold_sync_payload(line_items)
92
99
  )
93
100
 
94
101
  hold.save!
@@ -107,6 +114,15 @@ module SpreeCmCommissioner
107
114
  end
108
115
  end
109
116
 
117
+ # Same line items and active inventory items that acquire_redis_stock! reserves.
118
+ def on_hold_sync_payload(line_items)
119
+ line_items.flat_map do |line_item|
120
+ line_item.inventory_items.active.map do |inventory_item|
121
+ { inventory_id: inventory_item.id, quantity: line_item.quantity, locked: line_item.locked_stock? }
122
+ end
123
+ end
124
+ end
125
+
110
126
  def acquire_redis_stock!(line_items)
111
127
  stock_keys = []
112
128
  hold_keys = []
@@ -146,25 +162,20 @@ module SpreeCmCommissioner
146
162
  SpreeCmCommissioner::InventoryHolds::ReleaseJob.set(wait_until: hold.expires_at).perform_later(hold_id: hold.id)
147
163
  end
148
164
 
149
- def enqueue_sync_inventory_on_hold(order)
150
- inventory_id_and_quantities = order.line_items.flat_map do |line_item|
151
- next [] unless line_item.should_hold_inventory?
152
-
153
- line_item.inventory_items.active.map do |inventory_item|
154
- {
155
- inventory_id: inventory_item.id,
156
- quantity: line_item.quantity,
157
- locked: line_item.locked_stock?
158
- }
159
- end
160
- end
165
+ def enqueue_sync_inventory_on_hold(hold)
166
+ key = hold.on_hold_sync_idempotency_key
167
+ payload = hold.on_hold_sync_payload
161
168
 
162
- return if inventory_id_and_quantities.blank?
169
+ # Holds acquired before the sync event was stored carry neither; their sync was sent when they
170
+ # were acquired.
171
+ return if key.blank? || payload.blank?
163
172
 
164
173
  SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnHoldJob.perform_later(
165
- order_id: order.id,
174
+ order_id: hold.order_id,
175
+ idempotency_key: key,
166
176
  caller_source: "#{self.class.name}#enqueue_sync_inventory_on_hold",
167
- inventory_id_and_quantities: inventory_id_and_quantities
177
+ # Read back from jsonb with string keys; BulkAdjustQuantitiesOnHold reads symbol keys.
178
+ inventory_id_and_quantities: payload.map(&:symbolize_keys)
168
179
  )
169
180
  end
170
181
 
@@ -123,6 +123,7 @@ module SpreeCmCommissioner
123
123
 
124
124
  SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnConvertJob.perform_later(
125
125
  order_id: order.id,
126
+ idempotency_key: SecureRandom.uuid,
126
127
  caller_source: "#{self.class.name}#enqueue_sync_inventory",
127
128
  inventory_id_and_quantities: adjustments
128
129
  )
@@ -128,6 +128,7 @@ module SpreeCmCommissioner
128
128
 
129
129
  SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnHoldJob.perform_later(
130
130
  order_id: order.id,
131
+ idempotency_key: SecureRandom.uuid,
131
132
  caller_source: "#{self.class.name}#enqueue_sync_inventory_on_hold",
132
133
  inventory_id_and_quantities: inventory_id_and_quantities
133
134
  )
@@ -1,7 +1,8 @@
1
1
  module SpreeCmCommissioner
2
2
  module QueueDraftOrders
3
3
  # Writes a queue-draft-order status document's current state and appends a `histories` entry
4
- # in the same call, so no call site can update one without the other.
4
+ # in one batch commit, so no call site can update one without the other — and the request
5
+ # path (QueueDraftOrders::Enqueue) pays a single Firestore round trip, not two.
5
6
  #
6
7
  # Firestore layout:
7
8
  # statuses/draft_orders/{date}/{document_id} — current status + terminal fields
@@ -21,13 +22,12 @@ module SpreeCmCommissioner
21
22
 
22
23
  now = Time.zone.now
23
24
  document = firestore.doc(document_path)
24
- document.set(fields.merge(status: status, updated_at: now), merge: true)
25
- document.col('histories').add(
26
- previous_status: previous_status,
27
- next_status: status,
28
- message: message,
29
- created_at: now
30
- )
25
+ history = document.col('histories').doc
26
+
27
+ firestore.batch do |batch|
28
+ batch.set(document, fields.merge(status: status, updated_at: now), merge: true)
29
+ batch.create(history, { previous_status: previous_status, next_status: status, message: message, created_at: now })
30
+ end
31
31
 
32
32
  success(document_path)
33
33
  rescue StandardError => e
@@ -28,11 +28,17 @@ module SpreeCmCommissioner
28
28
  SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(inventory_items).call
29
29
  end
30
30
 
31
+ # One key per Redis mutation, minted here rather than derived from line_item_ids: the same line
32
+ # items are legitimately synced several times (unstock on complete, restock on cancel, unstock
33
+ # again on resume), and a shared key made every sync after the first a silent no-op, leaving
34
+ # the DB behind Redis until a later sale tripped the quantity_available >= 0 validation.
35
+ # Carried in the job args, so a retry or re-delivery of this same job still dedups.
31
36
  def schedule_sync_inventory(inventory_id_and_quantities)
32
37
  SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesJob.perform_later(
33
38
  inventory_id_and_quantities: inventory_id_and_quantities,
34
39
  line_item_ids: @line_item_ids,
35
- caller_source: "#{self.class.name}#schedule_sync_inventory"
40
+ caller_source: "#{self.class.name}#schedule_sync_inventory",
41
+ idempotency_key: SecureRandom.uuid
36
42
  )
37
43
  end
38
44
  end
@@ -15,6 +15,7 @@ module SpreeCmCommissioner
15
15
  class PublishLobbyPath
16
16
  prepend ::Spree::ServiceModule::Base
17
17
  extend SpreeCmCommissioner::ServiceModuleThrowable
18
+ include SpreeCmCommissioner::FirestoreConnection
18
19
 
19
20
  def call
20
21
  # merge: true so we never clobber the lobby's `full` / `available_slots` fields.
@@ -40,14 +41,6 @@ module SpreeCmCommissioner
40
41
  def logs_document
41
42
  @logs_document ||= lobby_document.col('logs').doc('publish_lobby_path_job')
42
43
  end
43
-
44
- def firestore
45
- @firestore ||= Google::Cloud::Firestore.new(project_id: service_account[:project_id], credentials: service_account)
46
- end
47
-
48
- def service_account
49
- @service_account ||= Rails.application.credentials.cloud_firestore_service_account
50
- end
51
44
  end
52
45
  end
53
46
  end
@@ -2,6 +2,8 @@ require 'google/cloud/firestore'
2
2
 
3
3
  module SpreeCmCommissioner
4
4
  class WaitingRoomSystemMetadataFetcher
5
+ include SpreeCmCommissioner::FirestoreConnection
6
+
5
7
  attr_reader :document_data
6
8
 
7
9
  def initialize(firestore: nil)
@@ -56,13 +58,5 @@ module SpreeCmCommissioner
56
58
  def document
57
59
  @document ||= firestore.col('metadata').doc('system')
58
60
  end
59
-
60
- def firestore
61
- @firestore ||= Google::Cloud::Firestore.new(project_id: service_account[:project_id], credentials: service_account)
62
- end
63
-
64
- def service_account
65
- @service_account ||= Rails.application.credentials.cloud_firestore_service_account
66
- end
67
61
  end
68
62
  end
@@ -0,0 +1,19 @@
1
+ require 'google/cloud/firestore'
2
+
3
+ # The generated defaults give point reads/writes a 60s–300s deadline and keep retrying
4
+ # RESOURCE_EXHAUSTED/UNAVAILABLE until it expires, so on a slow Firestore a request-path write
5
+ # (QueueDraftOrders::PublishStatus) outlives Rack::Timeout's 30s and ends as a
6
+ # RequestTimeoutException (an Exception, so no `rescue StandardError` sees it) instead of a clean
7
+ # failure. The deadline covers retries too, so this is the most one call can block.
8
+ #
9
+ # Set on the class-level config because it is every client instance's parent config, so it also
10
+ # reaches services that build their own client. `Google::Cloud::Firestore.new(timeout:)` would not
11
+ # work: the per-RPC defaults take precedence over the client-wide timeout.
12
+ #
13
+ # Queries and streams (run_query, listen, write, ...) keep their defaults — they legitimately page
14
+ # through large result sets and only run from jobs/dashboard reads.
15
+ Google::Cloud::Firestore::V1::Firestore::Client.configure do |config|
16
+ %i[get_document batch_get_documents create_document update_document delete_document commit batch_write].each do |rpc|
17
+ config.rpcs.public_send(rpc).timeout = 10.0
18
+ end
19
+ end
@@ -0,0 +1,49 @@
1
+ require 'google/cloud/firestore'
2
+
3
+ module SpreeCmCommissioner
4
+ # Process-wide registry of Cloud Firestore clients, one per Firebase project (`project_id`), so
5
+ # a service that talks to a second project just passes that project's service account and still
6
+ # gets a shared client.
7
+ #
8
+ # Shared rather than built per service instance: each new client opens its own gRPC channel, TLS
9
+ # handshake and OAuth token fetch, which on request paths like QueueDraftOrders::Enqueue was paid
10
+ # on every request. Clients are dropped when the PID changes so a forked worker (Puma runs with
11
+ # preload_app!) never reuses its parent's client; checking here instead of in a puma
12
+ # on_worker_boot hook also covers Sidekiq, rake and any other fork. That only works while the
13
+ # parent has made no Firestore RPC before forking — gRPC itself refuses to run in a child after
14
+ # the parent used it (unless GRPC_ENABLE_FORK_SUPPORT, Linux only) — so never call Firestore
15
+ # during app boot. RPC deadlines are set in config/initializers/firestore.rb.
16
+ module FirestoreClients
17
+ MUTEX = Mutex.new
18
+
19
+ class << self
20
+ # service_account: [Hash] a Firebase service-account credential; defaults to
21
+ # `cloud_firestore_service_account`.
22
+ def fetch(service_account = default_service_account)
23
+ project_id = service_account[:project_id]
24
+
25
+ MUTEX.synchronize do
26
+ reset_unlocked! if @pid != Process.pid
27
+ @clients[project_id] ||= Google::Cloud::Firestore.new(project_id: project_id, credentials: service_account)
28
+ end
29
+ end
30
+
31
+ def default_service_account
32
+ Rails.application.credentials.cloud_firestore_service_account
33
+ end
34
+
35
+ # Specs stub Google::Cloud::Firestore.new per example; without this the first example's
36
+ # double would be served to every later one.
37
+ def reset!
38
+ MUTEX.synchronize { reset_unlocked! }
39
+ end
40
+
41
+ private
42
+
43
+ def reset_unlocked!
44
+ @clients = {}
45
+ @pid = Process.pid
46
+ end
47
+ end
48
+ end
49
+ end
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.14.8'.freeze
2
+ VERSION = '2.14.10'.freeze
3
3
 
4
4
  module_function
5
5
 
@@ -21,6 +21,7 @@ require 'spree_cm_commissioner/rate'
21
21
  require 'spree_cm_commissioner/rate_closings'
22
22
  require 'spree_cm_commissioner/rates_availability_grid'
23
23
  require 'spree_cm_commissioner/s3_url_generator'
24
+ require 'spree_cm_commissioner/firestore_clients'
24
25
  require 'spree_cm_commissioner/service_module_throwable'
25
26
  require 'spree_cm_commissioner/serializable_transaction'
26
27
  require 'spree_cm_commissioner/jwt_token'
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.14.8
4
+ version: 2.14.10
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-03 00:00:00.000000000 Z
11
+ date: 2026-11-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: spree
@@ -822,6 +822,7 @@ files:
822
822
  - app/controllers/concerns/spree_cm_commissioner/load_test_login_token_generation.rb
823
823
  - app/controllers/concerns/spree_cm_commissioner/operator/event_authorization.rb
824
824
  - app/controllers/concerns/spree_cm_commissioner/order_concern.rb
825
+ - app/controllers/concerns/spree_cm_commissioner/taxon_visible_products_preloadable.rb
825
826
  - app/controllers/concerns/spree_cm_commissioner/turnstile_protectable.rb
826
827
  - app/controllers/concerns/spree_cm_commissioner/waiting_room_authorization.rb
827
828
  - app/controllers/spree/admin/account_deletions_controller.rb
@@ -3176,6 +3177,7 @@ files:
3176
3177
  - config/initializers/assets.rb
3177
3178
  - config/initializers/doorkeeper.rb
3178
3179
  - config/initializers/exception_notification.rb
3180
+ - config/initializers/firestore.rb
3179
3181
  - config/initializers/google_map.rb
3180
3182
  - config/initializers/paper_trail.rb
3181
3183
  - config/initializers/phonelib.rb
@@ -3808,6 +3810,7 @@ files:
3808
3810
  - lib/spree_cm_commissioner/distance.rb
3809
3811
  - lib/spree_cm_commissioner/engine.rb
3810
3812
  - lib/spree_cm_commissioner/factories.rb
3813
+ - lib/spree_cm_commissioner/firestore_clients.rb
3811
3814
  - lib/spree_cm_commissioner/intercity_taxi/map_place.rb
3812
3815
  - lib/spree_cm_commissioner/jwt_token.rb
3813
3816
  - lib/spree_cm_commissioner/line_item_jwt_token.rb