Class: Phone::QueueRoster

Inherits:
Object
  • Object
show all
Defined in:
app/services/phone/queue_roster.rb

Overview

Service object: queue roster.

The cached per-queue roster the switchboard renders: each queue's live ring
order as the PBX sees it, merged with the CRM employee behind each member.
The raw PBX snapshot is fresh for CACHE_TTL and also retained as a
last-known fallback (the PBX round trip is one getCurrentStatus per queue).
The employee merge runs on every read so a roster change in the CRM shows up
without waiting for the cache. An expired snapshot is served as stale while
the worker refreshes it; a cold cache renders empty/loading state while the
same worker prewarms it. A browser request never calls the PBX.

PullPhoneQueueStatusWorker is the only path that pays the per-queue round
trip. Keep CACHE_TTL above the worker's interval, and keep QueueRoster.cache_snapshot
merging rather than replacing.

Constant Summary collapse

CACHE_KEY =

Rails.cache key for the raw PBX snapshot.

'phone/queue_roster/v1'
LAST_KNOWN_CACHE_KEY =

Long-lived fallback key for the last successfully refreshed snapshot.

"#{CACHE_KEY}/last_known"
LAST_KNOWN_CACHE_TTL =

The fallback tier was written without expires_in on the assumption that
meant "never expires" — but Rails.cache carries a global 1-hour default
(config/initializers/150_redis_cache.rb), so the roster went cold an hour
after the worker's last 18:00 run: guaranteed "phone system could not be
reached" every morning before 07:00 and all weekend. Write it with an
explicit long TTL instead; a stale roster (flagged as such) beats a blank
page, and the worker's next run replaces it anyway.

2.weeks
REFRESH_REQUESTED_CACHE_KEY =

Short-lived gate that coalesces request-triggered background refreshes.

"#{CACHE_KEY}/refresh_requested"
REFRESH_REQUEST_TTL =

Minimum interval between request-triggered refresh attempts.

5.minutes
CACHE_WRITE_LOCK_KEY =

PostgreSQL advisory-lock key for cache read-modify-write operations.

'phone_queue_roster_cache'
CACHE_WRITE_LOCK_TIMEOUT =

Maximum seconds a cache writer waits for another writer to finish.

10
CACHE_TTL =

How long the raw PBX snapshot is considered fresh before a read serves the
last-known copy and asynchronously requests a refresh. Must outlive
PullPhoneQueueStatusWorker's hourly interval,
or the worker's write expires between runs and the roster page pays the
whole per-queue round trip inside a request — 28-34s, AppSignal #6601.
The worker rewrites every hour, so real staleness stays under that;
this window only decides who does the pulling.

65.minutes

Class Method Summary collapse

Class Method Details

.apply_member_order(queue_account_id, ordered_member_ids) ⇒ void

This method returns an undefined value.

Writes a just-applied ring order back into the cached snapshot, so a
successful reorder re-renders from cache instead of busting it and
paying one getCurrentStatus per queue on the very next read. No-op on a
cold cache or when the cached membership no longer matches (the next
background refresh repairs it in that case). A stale snapshot is updated
only in the last-known tier and requests reconciliation; it is never
promoted into the fresh tier.

Parameters:

  • queue_account_id (Integer)

    Switchvox queue account id

  • ordered_member_ids (Array<String>)

    member account ids in the new ring order



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'app/services/phone/queue_roster.rb', line 122

def apply_member_order(, ordered_member_ids)
  with_cache_write_lock do
    fresh_snapshot = Rails.cache.read(CACHE_KEY)
    snapshot = (fresh_snapshot || Rails.cache.read(LAST_KNOWN_CACHE_KEY))&.deep_dup
    members = snapshot&.dig(, 'members')
    next if members.blank?

    by_id = members.index_by { |member| member['account_id'].to_s }
    reordered = ordered_member_ids.map { |id| by_id[id.to_s] }
    next if reordered.any?(&:nil?) || reordered.size != members.size

    reordered.each_with_index { |member, index| member['order'] = index + 1 }
    snapshot[]['members'] = reordered
    if fresh_snapshot
      write_snapshot(snapshot)
    else
      Rails.cache.write(LAST_KNOWN_CACHE_KEY, snapshot, expires_in: LAST_KNOWN_CACHE_TTL)
      enqueue_refresh
    end
  end
rescue WithAdvisoryLock::FailedToAcquireLock => e
  Rails.logger.error "[Phone::QueueRoster] apply_member_order lock failed for queue #{}: #{e.message}"
end

.bust_cachevoid

This method returns an undefined value.



94
95
96
97
98
# File 'app/services/phone/queue_roster.rb', line 94

def bust_cache
  Rails.cache.delete(CACHE_KEY)
  Rails.cache.delete(LAST_KNOWN_CACHE_KEY)
  Rails.cache.delete(REFRESH_REQUESTED_CACHE_KEY)
end

.cached_member_order(queue_account_id) ⇒ Array<String>

The last cached ring order for one queue, WITHOUT a PBX round trip.
Used to audit a manual reorder against what the switchboard actually
showed the user when they dragged.

Parameters:

  • queue_account_id (Integer)

    Switchvox queue account id

Returns:

  • (Array<String>)

    member account ids in ring order; [] on a cold cache



106
107
108
109
# File 'app/services/phone/queue_roster.rb', line 106

def cached_member_order()
  snapshot = cached_snapshot
  snapshot&.dig(, 'members')&.pluck('account_id') || []
end

.refresh(pbx_snapshot = nil) ⇒ Hash

Force-writes the cache. Called by PullPhoneQueueStatusWorker after each
pull so the switchboard's first render never pays the per-queue round
trip itself. An INCOMPLETE pull (get_queues_snapshot silently skips
errored queues) is MERGED over the cache rather than dropped: a queue
that didn't answer keeps its last known entry, so the page never loses
it and the drift audit keeps its baseline. Completeness still gates that
audit — PullPhoneQueueStatusWorker checks it on the returned snapshot —
which is the only place it was ever load-bearing.

Parameters:

  • pbx_snapshot (Hash, nil) (defaults to: nil)

    an already-pulled snapshot to cache
    instead of pulling again (the worker pulls a wider set of queues once
    and shares it); nil pulls here as before. Sliced to the registry either
    way, so a wider snapshot never widens the cache.

Returns:

  • (Hash)

    the freshly pulled snapshot; {} on PBX failure



84
85
86
87
88
89
90
91
# File 'app/services/phone/queue_roster.rb', line 84

def refresh(pbx_snapshot = nil)
  pbx_snapshot = (pbx_snapshot || fetch_pbx_snapshot).slice(*PhoneQueue::REGISTRY.keys)
  cache_snapshot(pbx_snapshot)
  pbx_snapshot
rescue StandardError => e
  Rails.logger.error "[Phone::QueueRoster] refresh failed: #{e.class}: #{e.message}"
  {}
end

.snapshotHash{Integer => Hash}

Returns queue account id => { 'name', 'strategy',
'queue' => PhoneQueue, 'members' => [{ 'account_id', 'fullname', 'order',
'employee_id', 'employee_name' }], 'stale' => Boolean }; {} when a cold
cache is waiting for its asynchronous prewarm.

Returns:

  • (Hash{Integer => Hash})

    queue account id => { 'name', 'strategy',
    'queue' => PhoneQueue, 'members' => [{ 'account_id', 'fullname', 'order',
    'employee_id', 'employee_name' }], 'stale' => Boolean }; {} when a cold
    cache is waiting for its asynchronous prewarm



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'app/services/phone/queue_roster.rb', line 52

def snapshot
  if (pbx_snapshot = Rails.cache.read(CACHE_KEY))
    Rails.cache.write(LAST_KNOWN_CACHE_KEY, pbx_snapshot, unless_exist: true, expires_in: LAST_KNOWN_CACHE_TTL)
    return merge_employees(pbx_snapshot.deep_dup, stale: false)
  end

  if (pbx_snapshot = Rails.cache.read(LAST_KNOWN_CACHE_KEY))
    enqueue_refresh
    return merge_employees(pbx_snapshot.deep_dup, stale: true)
  end

  enqueue_refresh
  {}
rescue StandardError => e
  Rails.logger.error "[Phone::QueueRoster] snapshot failed: #{e.class}: #{e.message}"
  {}
end