Class: SpreeCmCommissioner::WaitingRoomSessionCreator

Inherits:
BaseInteractor
  • Object
show all
Defined in:
app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb

Constant Summary collapse

SESSION_CREATION_LOCK_KEY =
'waiting_room_session_creation'.freeze
WAITING_ROOM_SESSION_MAX_DURATION_IN_SECOND =

Soft ceiling on how long a session may keep renewing before it competes for capacity again (see #max_expired_at_ceiling and #renew_active_session). 600s default; HOLD_DURATION (8 min) + PAYMENT_LOCK_MIN_DURATION extension can push a genuinely active checkout to ~13 min, so this is a soft cap — past it, the session self-expires and the next call falls back to the full?-gated path, rather than being evicted outright.

(ENV['WAITING_ROOM_SESSION_MAX_DURATION_IN_SECOND'] || 600).to_i
WAITING_ROOM_SESSION_EXPIRE_DURATION_IN_SECOND =

How long a session's JWT/expired_at is valid before it needs renewal. 3min default.

(ENV['WAITING_ROOM_SESSION_EXPIRE_DURATION_IN_SECOND'] || (60 * 3)).to_i
WAITING_ROOM_BYPASS_HEADROOM_MULTIPLIER =

A verified bypasser is admitted past the normal max, but only up to this bounded headroom (max * multiplier) — never without limit — so a burst of stuck-payment returns can't push the protected pages far past capacity. Default 1.1 (10% headroom); 1 gates bypassers at max. See #bypass_full?.

(ENV['WAITING_ROOM_BYPASS_HEADROOM_MULTIPLIER'] || 1.1).to_f
PERMITTED_DEVICE_ATTRIBUTES =

Device attributes the client may set on the session. device_id and device_fingerprint are real columns; the rest are virtual columns backed by public/private metadata (see WaitingRoomSession). Anything else the client sends is dropped so we never persist arbitrary payloads.

%w[
  device_id device_fingerprint
  os_name os_version device_model device_manufacturer device_type
  device_environment app_version app_build timezone network_type
].freeze

Instance Method Summary collapse

Instance Method Details

#advisory_lock(lock_key) ⇒ Object



141
142
143
144
145
146
147
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 141

def advisory_lock(lock_key)
  lock_id = Zlib.crc32(lock_key)
  SpreeCmCommissioner::WaitingRoomSession.transaction do
    SpreeCmCommissioner::WaitingRoomSession.connection.execute("SELECT pg_advisory_xact_lock(#{lock_id})")
    yield
  end
end

#apply_bypass_attributes(session) ⇒ Object

Server-set audit trail for a granted bypass — never client-supplied.



171
172
173
174
175
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 171

def apply_bypass_attributes(session)
  session.bypassed = true
  session.bypass_payment_number = bypass_result.value[:payment_number]
  session.bypass_source = bypass_result.value[:source]
end

#apply_session_attributes(session) ⇒ Object



177
178
179
180
181
182
183
184
185
186
187
188
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 177

def apply_session_attributes(session)
  session.assign_attributes(
    {
      jwt_token: context.jwt_token,
      expired_at: expired_at,
      remote_ip: remote_ip,
      page_path: page_path,
      tenant_id: tenant_id,
      user: user
    }.merge(sanitized_device_attributes)
  )
end

#assign_token_and_create_session_to_db(bypass: false) ⇒ Object



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 154

def assign_token_and_create_session_to_db(bypass: false)
  session = SpreeCmCommissioner::WaitingRoomSession.where(guest_identifier: waiting_guest_firebase_doc_id).first_or_initialize
  context.room_session = session

  # A brand-new row gets a fresh ceiling. A row that already had one (lapsed but not yet past
  # its ceiling — see #call) keeps that same ceiling: reviving it here must not silently grant
  # it a new one just because it lapsed. Computed before generate_jwt_token so expired_at is
  # always capped against the ceiling that's actually being persisted, not a different one.
  session.max_expired_at ||= max_expired_at_ceiling
  generate_jwt_token(session.max_expired_at)

  apply_session_attributes(session)
  apply_bypass_attributes(session) if bypass
  session.save!
end

#bypass_full?Boolean

Higher, bounded ceiling used only for a verified bypasser: max plus the headroom (max * WAITING_ROOM_BYPASS_HEADROOM_MULTIPLIER, floored). Once the room reaches it, even a bypasser is rejected. Never called on the normal-joiner path, so the hot path is unchanged.

Returns:

  • (Boolean)


129
130
131
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 129

def bypass_full?
  SpreeCmCommissioner::WaitingRoomSession.active.count >= (max_sessions_count * WAITING_ROOM_BYPASS_HEADROOM_MULTIPLIER).floor
end

#bypass_resultObject

Memoized so #apply_bypass_attributes can read the verified payment number/source back off the result (result.value) without re-running the verify. user is the same doorkeeper-resolved caller already stamped on the session (nil for an app token/anonymous request).



116
117
118
119
120
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 116

def bypass_result
  @bypass_result ||= SpreeCmCommissioner::WaitingRoom::BypassEligibility.call(
    jwt: bypass_jwt, order_token: bypass_order_token, user: user
  )
end

#bypass_session?Boolean

Whether this admission may skip full?. The client relays a signed token plus the order token it was signed with (the X-Spree-Order-Token header) — WaitingRoom::BypassEligibility decides, from that single verified decode plus the caller's own auth (see BypassEligibility#owned_by_caller?), whether it's genuine. Both must be present before we even look, so a normal joiner pays nothing.

Returns:

  • (Boolean)


107
108
109
110
111
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 107

def bypass_session?
  return false if bypass_jwt.blank? || bypass_order_token.blank?

  bypass_result.success?
end

#callObject

Three ways in here:

Condition #1 — queued: WaitingGuestsCaller job precreated the row when it called this guest, so they just find and renew that existing row (if necessary).

Condition #2 — the row exists but its max_expired_at ceiling has already passed. Reject outright rather than silently reviving it: the ceiling is a hard cap the server controls, not something a renewal retry should be able to extend (see WaitingRoomSession#past_ceiling?).

Condition #3 — bypassed the queue: lobby doc showed full: false / has_queue: false, so the app called createSession directly. The caller never touched this guest, no row exists yet, so they fall through to a normal create_new_session. A row that's lapsed (expired_at passed) but still under its own ceiling also lands here — the revival path in #create_new_session.



50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 50

def call
  return context.fail!(message: 'must_provide_waiting_guest_firebase_doc_id') if waiting_guest_firebase_doc_id.blank?
  return context.fail!(message: 'must_provide_remote_ip') if remote_ip.blank?

  existing = SpreeCmCommissioner::WaitingRoomSession.find_by(guest_identifier: waiting_guest_firebase_doc_id)

  if existing&.active?
    renew_active_session(existing)
  elsif existing&.past_ceiling?
    context.fail!(message: 'waiting_room_session_expired')
  else
    create_new_session
  end
end

#call_other_waiting_guestsObject



201
202
203
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 201

def call_other_waiting_guests
  SpreeCmCommissioner::WaitingGuestsCallerJob.perform_later
end

#create_new_sessionObject

No active row and no ceiling breach (see #call): a true new admission, a bypass-path entry, or a row that's lapsed but still under its own max_expired_at — goes through the normal advisory-lock + full? gated path.



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 84

def create_new_session
  # Checked before the lock, not inside it — the lock is the capacity serialization point and
  # shouldn't be held any longer than necessary. A normal joiner short-circuits here for free.
  bypass = bypass_session?

  # Advisory lock ensures the capacity check and session creation are atomic across all app instances.
  advisory_lock(SESSION_CREATION_LOCK_KEY) do
    # Normal joiner is gated at max (full?); a verified bypasser at the higher, bounded ceiling
    # (bypass_full?), so a full room still admits bypassers only up to the overshoot.
    return context.fail!(message: 'sessions_reach_it_maximum') if bypass ? bypass_full? : full?

    assign_token_and_create_session_to_db(bypass: bypass)
  end

  # commented because of following bug: https://github.com/channainfo/commissioner/issues/2185
  # this job is already run every 1mn, disabling it still work.
  # call_other_waiting_guests
end

#expired_at(max_expired_at = nil) ⇒ Object

Capped at max_expired_at so a late renewal can't push expired_at (and the JWT's exp, see generate_jwt_token) past the ceiling the session is meant to respect.



207
208
209
210
211
212
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 207

def expired_at(max_expired_at = nil)
  context.expired_at ||= [
    WAITING_ROOM_SESSION_EXPIRE_DURATION_IN_SECOND.seconds.from_now,
    max_expired_at
  ].compact.min
end

#firestoreObject



218
219
220
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 218

def firestore
  @firestore ||= Google::Cloud::Firestore.new(project_id: [:project_id], credentials: )
end

#full?Boolean

Returns:

  • (Boolean)


122
123
124
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 122

def full?
  SpreeCmCommissioner::WaitingRoomSession.active.count >= max_sessions_count
end

#generate_jwt_token(max_expired_at = nil) ⇒ Object



149
150
151
152
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 149

def generate_jwt_token(max_expired_at = nil)
  payload = { exp: expired_at(max_expired_at).to_i }
  context.jwt_token = JWT.encode(payload, ENV.fetch('WAITING_ROOM_SESSION_SIGNATURE'), 'HS256')
end

#max_expired_at_ceilingObject



214
215
216
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 214

def max_expired_at_ceiling
  context.max_expired_at_ceiling ||= WAITING_ROOM_SESSION_MAX_DURATION_IN_SECOND.seconds.from_now
end

#max_sessions_countObject



133
134
135
136
137
138
139
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 133

def max_sessions_count
  Rails.cache.fetch('waiting_room/max_sessions_count', expires_in: 1.hour) do
    fetcher = SpreeCmCommissioner::WaitingRoomSystemMetadataFetcher.new(firestore: firestore)
    fetcher.load_document_data
    fetcher.max_sessions_count_with_min
  end
end

#renew_active_session(session) ⇒ Object

An already-reserved slot is already counted in WaitingRoomSession.active.count, so re-checking capacity on its own renewal can only ever reject the holder's own session — this is the eviction-bug fix. No lock, no full? guard.

expired_at is always capped at session.max_expired_at (see #expired_at), so a session past its soft cap self-expires here instead of being renewed: this write persists an expired_at in the past, which drops it out of WaitingRoomSession.active — the guest's next call then finds no active row and falls through to create_new_session to compete for capacity again.



73
74
75
76
77
78
79
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 73

def renew_active_session(session)
  context.room_session = session

  generate_jwt_token(session.max_expired_at)
  apply_session_attributes(session)
  session.save!
end

#sanitized_device_attributesObject

Whitelisted, flat device attributes. Real columns (device_id, device_fingerprint) and virtual columns (StoreMetadata) are assigned the same way; the model routes each to the right store.



193
194
195
196
197
198
199
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 193

def sanitized_device_attributes
  attributes = device_attributes
  attributes = attributes.to_unsafe_h if attributes.respond_to?(:to_unsafe_h)
  return {} unless attributes.is_a?(Hash)

  attributes.symbolize_keys.slice(*PERMITTED_DEVICE_ATTRIBUTES.map(&:to_sym))
end

#service_accountObject



222
223
224
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 222

def 
  Rails.application.credentials.
end