Module: ModelExt::Reservations::Callbacks

Extended by:
ActiveSupport::Concern
Includes:
ElasticAPM::SpanHelpers
Included in:
Reservation
Defined in:
lib/model_ext/reservations/callbacks.rb

Overview

callbacks stuff

Instance Method Summary collapse

Instance Method Details

#push_booking_notificationObject

NOTE: Cache update methods (update_inventory_cache_inline, detect_and_refresh_stale_inventory_cache) have been removed. Cache updates are now handled ASYNCHRONOUSLY via Kafka by the UpdateMirageCache concern which fires events in after_commit.

Flow: Reservation save → after_commit → UpdateMirageCache.clear_mirage_cache

→ Kafka EVENTS::INVENTORY::CACHE::TOPIC → Mirage::CacheConsumer
→ Inventory::UpdateCacheWorker → refresh ALL packages' cache

Benefits of async-only approach:

  1. Faster DB transactions (no blocking cache updates)

  2. Single source of truth for cache invalidation (Kafka)

  3. No duplicate cache updates (sync + async were redundant)

  4. DB-level lock (inv_lvl1_available?) prevents overbooking regardless of cache staleness



238
239
240
241
242
243
244
245
# File 'lib/model_ext/reservations/callbacks.rb', line 238

def push_booking_notification
  is_pending_confirmation = ack == false && confirmed_by.nil? && active == true
  not_temporary = is_temporary == false && for_locking_system == false
  return unless is_pending_confirmation && not_temporary

  NotificationWorkers::Partner::BookingWorker.perform_async(id)
  true
end

#push_inventory_notificationObject



247
248
249
250
# File 'lib/model_ext/reservations/callbacks.rb', line 247

def push_inventory_notification
  NotificationWorkers::Partner::InventoryWorker.perform_async(id)
  true
end

#trigger_creation_sync(sync_type = 'full') ⇒ void

This method returns an undefined value.

Manual sync trigger for reservation creation (immediate for admin/staff, delayed for users)

Call this method explicitly after reservation creation to ensure proper timing and avoid missed relations during the creation process.

Parameters:

  • sync_type (String) (defaults to: 'full')

    the type of sync to perform (default: 'full')



285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/model_ext/reservations/callbacks.rb', line 285

def trigger_creation_sync(sync_type = 'full')
  if created_by_admin_staff_or_owner?
    # Admin/Staff/Owner: Immediate sync for real-time partner portal updates
    begin
      Partner::ReservationSummarySyncWorker.new.perform(id, sync_type)
    rescue StandardError => e
      APMErrorHandler.report('Immediate creation sync failed', {
                               reservation_id: id,
                               sync_type: sync_type,
                               created_by: created_by,
                               error_message: e.message,
                               exception: e,
                             })
      # Fallback to async
      Partner::ReservationSummarySyncWorker.perform_async(id, sync_type)
    end
  else
    # User bookings: Use delayed sync with protection against for_locking_system
    unless for_locking_system?
      Partner::ReservationSummarySyncWorker.perform_in(1.second, id, sync_type)
    end
  end
rescue StandardError => e
  APMErrorHandler.report('Failed to trigger creation sync', {
                           reservation_id: id,
                           sync_type: sync_type,
                           created_by: created_by,
                           error_message: e.message,
                           exception: e,
                         })
end

#trigger_destroy_syncvoid

This method returns an undefined value.

Manual sync trigger for destruction (async)

Call this method when a reservation is being destroyed.



370
371
372
373
374
375
376
377
378
# File 'lib/model_ext/reservations/callbacks.rb', line 370

def trigger_destroy_sync
  Partner::ReservationSummarySyncWorker.perform_async(id, 'destroy')
rescue StandardError => e
  APMErrorHandler.report('Failed to trigger destroy sync', {
                           reservation_id: id,
                           error_message: e.message,
                           exception: e,
                         })
end

#trigger_immediate_sync(sync_type = 'full') ⇒ void

This method returns an undefined value.

Manual sync trigger for immediate updates

Parameters:

  • sync_type (String) (defaults to: 'full')

    the type of sync to perform (default: 'full')



341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
# File 'lib/model_ext/reservations/callbacks.rb', line 341

def trigger_immediate_sync(sync_type = 'full')
  begin
    Partner::ReservationSummarySyncWorker.new.perform(id, sync_type)
  rescue StandardError => e
    APMErrorHandler.report('Immediate sync failed', {
                             reservation_id: id,
                             sync_type: sync_type,
                             created_by: created_by,
                             error_message: e.message,
                             exception: e,
                           })
    # Fallback to async
    Partner::ReservationSummarySyncWorker.perform_async(id, sync_type)
  end
rescue StandardError => e
  APMErrorHandler.report('Failed to trigger immediate sync', {
                           reservation_id: id,
                           sync_type: sync_type,
                           created_by: created_by,
                           error_message: e.message,
                           exception: e,
                         })
end

#trigger_priority_sync(sync_type = 'full') ⇒ void

This method returns an undefined value.

Manual sync trigger for priority updates (non-blocking but fast)

Use this for critical updates that need fast processing but should not block the main application flow. Executes with minimal delay (0.1 seconds).

Parameters:

  • sync_type (String) (defaults to: 'full')

    the type of sync to perform (default: 'full')



324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/model_ext/reservations/callbacks.rb', line 324

def trigger_priority_sync(sync_type = 'full')
  # Non-blocking but high priority sync with minimal delay
  Partner::ReservationSummarySyncWorker.perform_in(1.second, id, sync_type)
rescue StandardError => e
  APMErrorHandler.report('Failed to trigger priority sync', {
                           reservation_id: id,
                           sync_type: sync_type,
                           created_by: created_by,
                           error_message: e.message,
                           exception: e,
                         })
end

#trigger_summary_sync(sync_type = 'full') ⇒ void

This method returns an undefined value.

Manual sync trigger for general updates (async)

Use this for non-critical updates like package/add-on changes, general status updates, or when immediate sync is not required.

Parameters:

  • sync_type (String) (defaults to: 'full')

    the type of sync to perform. Valid values:

    - 'full': Sync all reservation data (default).
    - 'package': Sync package-related data only.
    - 'status': Sync status updates only.
    - 'financial': Sync financial data only.
    - 'loyalty': Sync loyalty level changes only.
    - 'destroy': Sync reservation destruction.
    

    (Other values may be supported; see ReservationSummarySyncWorker for details.)



267
268
269
270
271
272
273
274
275
276
# File 'lib/model_ext/reservations/callbacks.rb', line 267

def trigger_summary_sync(sync_type = 'full')
  Partner::ReservationSummarySyncWorker.perform_async(id, sync_type)
rescue StandardError => e
  APMErrorHandler.report('Failed to trigger summary sync', {
                           reservation_id: id,
                           sync_type: sync_type,
                           error_message: e.message,
                           exception: e,
                         })
end

#update_inv_relationsObject



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
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
109
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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/model_ext/reservations/callbacks.rb', line 61

def update_inv_relations
  # Guard against invalid states
  # Note: This callback runs in after_commit (AFTER transaction commits) to avoid FK constraint violations.
  # MySQL InnoDB only sees committed data for FK checks, so we must wait for commit.
  unless persisted? && !destroyed?
    APMErrorHandler.report('update_inv_relations called on invalid reservation state',
                           context: { reservation_id: id, persisted: persisted?, destroyed: destroyed? })
    return
  end

  # CRITICAL: Verify the reservation still exists in the database.
  # This guards against race conditions where the reservation was deleted/rolled back
  # after commit but before this callback executes.
  unless self.class.exists?(id)
    APMErrorHandler.report('update_inv_relations: reservation does not exist in DB',
                           context: { reservation_id: id })
    return
  end

  changed_attribute_keys = transaction_changed_attributes.keys

  # Early return if no relevant attributes changed
  relevant_changed_attrs = %w[active ack adult date start_time end_time
                              for_locking_system is_temporary restaurant_id service_type].select do |key|
    changed_attribute_keys.include?(key)
  end

  if relevant_changed_attrs.blank?
    return
  end

  affect_old_data = %w[service_type date start_time end_time].select do |key|
    changed_attribute_keys.include?(key)
  end.present?

  inv_reservation_class = nil
  inv_class = nil

  if affect_old_data
    old_service_type = service_type

    if changed_attribute_keys.include?('service_type')
      old_service_type = transaction_changed_attributes['service_type']
      inv_reservation_class = inventory_reservation_klass(old_service_type)
      inv_class = inventory_klass(old_service_type)
    else
      inv_reservation_class = inventory_reservation_klass
      # Use inventory_klass for consistency, but fall back to restaurant.selected_inventory_model
      # since the current service_type might determine a different inventory class
      inv_class = inventory_klass || restaurant.selected_inventory_model
    end

    # Query inventory reservations for the current reservation
    inventory_reservations = inv_reservation_class.where(restaurant_id: restaurant_id, reservation_id: id)
    # collect inventory IDs from the reservations
    inventory_ids = inventory_reservations.distinct.pluck(inv_reservation_class.inventory_id_column)
    # Delete all inventory reservations for this reservation as they will be recreated with updated data
    inventory_reservations.delete_all

    # Sort inventory IDs to ensure consistent lock ordering
    sorted_inventory_ids = inventory_ids.sort

    # No transaction needed here since this is an after_commit callback
    inv_class.where(id: sorted_inventory_ids).order(:id).each do |inv|
      inv.with_lock do
        # recalculate total booked seats for the inventory
        # it is needed to restore the seat left quota after this reservation has been updated
        # or created
        inv.calc_total_booked_seat
        inv.save!
      end
    end
  else
    inv_reservation_class = inventory_reservation_klass
    # Use inventory_klass for consistency, but fall back to restaurant.selected_inventory_model
    # since the current service_type might determine a different inventory class
    inv_class = inventory_klass || restaurant.selected_inventory_model
  end

  invs = inv_class.where(date: date, restaurant_id: restaurant_id).
    where('start_time >= ? AND start_time <= ? AND end_time >= ? AND end_time <= ?',
          start_time_format, end_time_format, start_time_format, end_time_format)

  inventory_reservations = []

  inventory_relation = if restaurant.use_third_party_inventory?
                         'inventory_id'
                       else
                         "#{inv_class.to_s.underscore}_id"
                       end

  booked_seat = if active?
                  if ack? || (is_temporary? || for_locking_system?)
                    adult
                  else
                    0
                  end
                else
                  0
                end

  ir_attributes_base = {
    reservation_id: id,
    booked_seat: booked_seat,
    restaurant_id: restaurant.id,
  }
  inv_class_as_string = inv_class.to_s

  invs.each do |inv|
    ir_attributes = ir_attributes_base.dup

    if service_type == 'dine_in'
      ir_attributes[inventory_relation] = inv.id[0]
      ir_attributes[:inventory_type] = inv_class_as_string
    else
      ir_attributes[inventory_relation] = inv.id
    end
    inventory_reservations.push ir_attributes
  end

  if inventory_reservations.present?
    Retriable.retriable(
      on: [ActiveRecord::Deadlocked, ActiveRecord::InvalidForeignKey],
      tries: 3,
      base_interval: 0.1,
      multiplier: 2,
      rand_factor: 0.5,
      on_retry: APMErrorHandler.report_retriable_event('reservation.inventory.update'),
    ) do
      inv_reservation_class.import!(inventory_reservations,
                                    on_duplicate_key_update: [:booked_seat],
                                    raise_error: true, validate: false, batch_size: 1000)
    end
  end

  # Sort inventories by ID to ensure consistent lock ordering across transactions
  sorted_invs = invs.sort_by(&:id)

  Retriable.retriable(
    on: ActiveRecord::Deadlocked,
    tries: 3,
    base_interval: 0.1,
    multiplier: 2,
    rand_factor: 0.5,
    on_retry: APMErrorHandler.report_retriable_event('reservation.inventory.update'),
  ) do
    sorted_invs.each do |inv|
      inv.with_lock do
        # recalculate total booked seats for the inventory
        # it is needed to consume the seat left quota after this reservation has been updated
        # or created
        inv.calc_total_booked_seat
        inv.save!
      end
    end
  end

  # NOTE: Cache update is handled asynchronously via Kafka by UpdateMirageCache concern.
  # The concern fires a Kafka event in after_commit which is consumed by
  # Mirage::CacheConsumer → Inventory::UpdateCacheWorker.
  # This provides eventual consistency while keeping the DB transaction fast.
end