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
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



101
102
103
104
105
106
107
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 101

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_session_attributes(session) ⇒ Object



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

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
    }.merge(sanitized_device_attributes)
  )
end

#assign_token_and_create_session_to_dbObject



114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 114

def assign_token_and_create_session_to_db
  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)
  session.save!
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.



44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 44

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



152
153
154
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 152

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.



78
79
80
81
82
83
84
85
86
87
88
89
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 78

def create_new_session
  # Advisory lock ensures the capacity check and session creation are atomic across all app instances.
  advisory_lock(SESSION_CREATION_LOCK_KEY) do
    return context.fail!(message: 'sessions_reach_it_maximum') if full?

    assign_token_and_create_session_to_db
  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.



158
159
160
161
162
163
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 158

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



169
170
171
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 169

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

#full?Boolean

Returns:

  • (Boolean)


91
92
93
94
95
96
97
98
99
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 91

def full?
  max = 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

  SpreeCmCommissioner::WaitingRoomSession.active.count >= max
end

#generate_jwt_token(max_expired_at = nil) ⇒ Object



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

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



165
166
167
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 165

def max_expired_at_ceiling
  context.max_expired_at_ceiling ||= WAITING_ROOM_SESSION_MAX_DURATION_IN_SECOND.seconds.from_now
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.



67
68
69
70
71
72
73
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 67

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.



144
145
146
147
148
149
150
# File 'app/interactors/spree_cm_commissioner/waiting_room_session_creator.rb', line 144

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



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

def 
  Rails.application.credentials.
end