Class: SpreeCmCommissioner::WaitingRoomSessionCreator
- Inherits:
-
BaseInteractor
- Object
- BaseInteractor
- SpreeCmCommissioner::WaitingRoomSessionCreator
- 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
- #advisory_lock(lock_key) ⇒ Object
-
#apply_bypass_attributes(session) ⇒ Object
Server-set audit trail for a granted bypass — never client-supplied.
- #apply_session_attributes(session) ⇒ Object
- #assign_token_and_create_session_to_db(bypass: false) ⇒ Object
-
#bypass_full? ⇒ Boolean
Higher, bounded ceiling used only for a verified bypasser: max plus the headroom (max * WAITING_ROOM_BYPASS_HEADROOM_MULTIPLIER, floored).
-
#bypass_result ⇒ Object
Memoized so #apply_bypass_attributes can read the verified payment number/source back off the result (result.value) without re-running the verify.
-
#bypass_session? ⇒ Boolean
Whether this admission may skip full?.
-
#call ⇒ Object
Three ways in here:.
- #call_other_waiting_guests ⇒ Object
-
#create_new_session ⇒ Object
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.
-
#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.
- #firestore ⇒ Object
- #full? ⇒ Boolean
- #generate_jwt_token(max_expired_at = nil) ⇒ Object
- #max_expired_at_ceiling ⇒ Object
- #max_sessions_count ⇒ Object
-
#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.
-
#sanitized_device_attributes ⇒ Object
Whitelisted, flat device attributes.
- #service_account ⇒ Object
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.
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_result ⇒ Object
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.
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 |
#call ⇒ Object
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_guests ⇒ Object
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_session ⇒ Object
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 |
#firestore ⇒ Object
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: service_account[:project_id], credentials: service_account) end |
#full? ⇒ 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_ceiling ⇒ Object
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_count ⇒ Object
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_attributes ⇒ Object
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_account ⇒ Object
222 223 224 |
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 222 def service_account Rails.application.credentials.cloud_firestore_service_account end |