where_is_waldo 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -0
- data/README.md +10 -0
- data/VERSION +1 -1
- data/app/channels/where_is_waldo/presence_channel.rb +29 -1
- data/app/services/where_is_waldo/adapters/base_adapter.rb +31 -5
- data/app/services/where_is_waldo/adapters/database_adapter.rb +30 -7
- data/app/services/where_is_waldo/adapters/redis_adapter.rb +47 -31
- data/app/services/where_is_waldo/broadcaster.rb +5 -3
- data/app/services/where_is_waldo/presence_service.rb +18 -1
- data/app/services/where_is_waldo/roster.rb +12 -13
- data/lib/generators/where_is_waldo/install/templates/create_presences.rb.tt +9 -4
- data/lib/where_is_waldo/configuration.rb +18 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9caee3a6cb5747d9ce5ab460315fdaca5a9f05f7521dbe07f36a6d9fe5de48d9
|
|
4
|
+
data.tar.gz: f7e293f87b04039c7a2dbc21e6a80ef574a773438800588a9f87255b9336497a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 95e63474cdbfff6c224b4bc4882bba3fb96cb157bf73a7769a1755f8b6326cba60e19aee6b06fc8fb58fc483496a68f8aa3e6bd03513a1782802ca123f8b9cb7
|
|
7
|
+
data.tar.gz: 9abb3e965ddf1a13722e5700c2c3c8927c9b6b43593801e592670ec4ab09483ce2a3cf2da6a764dfbfb204e238a0107cbd9628067c5e06b0d7920a24412a0726
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,98 @@
|
|
|
3
3
|
Notable changes to where_is_waldo. Format loosely follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/).
|
|
5
5
|
|
|
6
|
+
## 0.1.5
|
|
7
|
+
|
|
8
|
+
### Security / correctness
|
|
9
|
+
|
|
10
|
+
- **Session keys are now namespaced by subject_id.** Both adapters previously
|
|
11
|
+
keyed session rows by `session_id` alone (`waldo:session:<sid>` in Redis;
|
|
12
|
+
`unique_by: session_column` upsert in the DB). If two authenticated
|
|
13
|
+
subjects independently supplied the same `session_id` — client-supplied
|
|
14
|
+
values, JWT `jti` reuse across users, etc. — one subject's `connect`
|
|
15
|
+
would overwrite the other's row, and subsequent `heartbeat`/`disconnect`
|
|
16
|
+
calls could reach into the wrong subject's presence. Now:
|
|
17
|
+
- **RedisAdapter**: session key is `waldo:session:<subject_id>:<session_id>`.
|
|
18
|
+
The reverse-map key `waldo:session_subject:<session_id>` is removed —
|
|
19
|
+
callers must pass `subject_id` to `heartbeat`, `session_status`, and
|
|
20
|
+
session-scoped `disconnect`, so the disambiguation is at the API
|
|
21
|
+
boundary rather than a lookup that could return the wrong subject.
|
|
22
|
+
- **DatabaseAdapter**: `Presence.upsert(unique_by: [subject_column,
|
|
23
|
+
session_column])`. `heartbeat` / `session_status` / session-scoped
|
|
24
|
+
`disconnect` all scope by both keys. The install generator's migration
|
|
25
|
+
template now creates a composite unique index on `(subject_column,
|
|
26
|
+
session_column)` instead of a unique index on `session_column` alone.
|
|
27
|
+
|
|
28
|
+
### Breaking API changes
|
|
29
|
+
|
|
30
|
+
- `heartbeat` — `subject_id:` is now a required kwarg
|
|
31
|
+
(`heartbeat(session_id:, subject_id:, ...)`).
|
|
32
|
+
- `session_status` — signature is `session_status(session_id, subject_id)`
|
|
33
|
+
(previously `session_status(session_id)`).
|
|
34
|
+
- `disconnect(session_id:)` — now raises `ArgumentError` when called with
|
|
35
|
+
`session_id:` but no `subject_id:`. Subject-only `disconnect(subject_id:)`
|
|
36
|
+
and paired `disconnect(session_id:, subject_id:)` are the two supported
|
|
37
|
+
shapes.
|
|
38
|
+
- `Broadcaster.broadcast_to_session(session_id, subject_id, message_type, data)`
|
|
39
|
+
— `subject_id` inserted as the second positional argument (previously
|
|
40
|
+
`broadcast_to_session(session_id, message_type, data)`).
|
|
41
|
+
|
|
42
|
+
### DB migration
|
|
43
|
+
|
|
44
|
+
Hosts on the `:database` adapter must swap the unique index on the presences
|
|
45
|
+
table:
|
|
46
|
+
```ruby
|
|
47
|
+
remove_index :presences, :<session_column>
|
|
48
|
+
add_index :presences, [:<subject_column>, :<session_column>], unique: true
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 0.1.4
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- **Roster reads now route through the configured adapter.** Previously
|
|
56
|
+
`Roster.states_for` (which powers `state_for`, `snapshot`, `members_for`,
|
|
57
|
+
every roster delta) queried the `Presence` ActiveRecord model directly.
|
|
58
|
+
Under `adapter = :redis`, writes went to Redis but reads hit an empty (or
|
|
59
|
+
absent) database table — presence dots stayed grey no matter how many
|
|
60
|
+
heartbeats came in. `Roster` now calls `PresenceService.sessions_for_subjects`
|
|
61
|
+
which delegates to whichever adapter is configured, so writes and reads
|
|
62
|
+
share one store on every adapter.
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
|
|
66
|
+
- `Adapters::Base#sessions_for_subjects(subject_ids, timeout:)` — bulk-read
|
|
67
|
+
live sessions grouped by subject id, with a correct-but-unoptimized default
|
|
68
|
+
implementation that fans out over `sessions_for_subject`. `DatabaseAdapter`
|
|
69
|
+
overrides it with a single bulk query (`Presence.where(subject_col => ids)`
|
|
70
|
+
+ `includes(:subject)`); `RedisAdapter` overrides with per-subject reads
|
|
71
|
+
filtered by heartbeat threshold. Timeout defaults to `config.timeout`.
|
|
72
|
+
- `PresenceService.sessions_for_subjects` — public delegate so callers stay
|
|
73
|
+
off the adapter directly.
|
|
74
|
+
- `Configuration#suppress_presence_proc` — callable that decides, per
|
|
75
|
+
connection, whether to skip presence registration for that subscriber.
|
|
76
|
+
Receives the ActionCable connection; returns truthy to suppress. A
|
|
77
|
+
suppressed subscriber still subscribes normally (streams from
|
|
78
|
+
`where_is_waldo:subject:<id>`, receives broadcasts, can invoke channel
|
|
79
|
+
actions) — they just don't register a Presence row / heartbeat / roster
|
|
80
|
+
transition. Use for cases where the WebSocket session is legitimate but
|
|
81
|
+
shouldn't be counted as "the subject is present" (e.g. support-user
|
|
82
|
+
impersonation tabs).
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
config.suppress_presence_proc = ->(connection) {
|
|
86
|
+
connection.request.session[:su_user].present?
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Changed
|
|
91
|
+
|
|
92
|
+
- `Roster.aggregate` / `session_level` / `platform` now consume presence
|
|
93
|
+
hashes (`session[:tab_visible]`, `session[:metadata]`) — matching the
|
|
94
|
+
shape every adapter already returned — instead of ActiveRecord `Presence`
|
|
95
|
+
method calls. No behavior change for callers; existing per-device and
|
|
96
|
+
per-subject aggregation semantics preserved.
|
|
97
|
+
|
|
6
98
|
## 0.1.3
|
|
7
99
|
|
|
8
100
|
### Fixed
|
data/README.md
CHANGED
|
@@ -120,6 +120,16 @@ WhereIsWaldo.configure do |config|
|
|
|
120
120
|
config.roster_org = ->(user) { user.account }
|
|
121
121
|
config.roster_members = ->(org) { org.users.active }
|
|
122
122
|
|
|
123
|
+
# Optional: skip presence registration for specific connections while
|
|
124
|
+
# keeping them subscribed for broadcasts. Return truthy to suppress. The
|
|
125
|
+
# subscriber still streams from where_is_waldo:subject:<id> and receives
|
|
126
|
+
# WhereIsWaldo.broadcast_to messages — they just don't count as present.
|
|
127
|
+
# Typical use: support-user impersonation tabs shouldn't light up as the
|
|
128
|
+
# impersonated user in teammates' rosters.
|
|
129
|
+
config.suppress_presence_proc = ->(connection) {
|
|
130
|
+
connection.request.session[:su_user].present?
|
|
131
|
+
}
|
|
132
|
+
|
|
123
133
|
# Redis adapter
|
|
124
134
|
# config.redis_client = Redis.new(url: ENV["REDIS_URL"])
|
|
125
135
|
end
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.5
|
|
@@ -5,6 +5,13 @@ module WhereIsWaldo
|
|
|
5
5
|
def subscribed
|
|
6
6
|
stream_from subject_stream
|
|
7
7
|
|
|
8
|
+
# The subject_stream subscription above is unconditional: even when the
|
|
9
|
+
# subscriber's presence is suppressed (see suppress_presence_proc), the
|
|
10
|
+
# tab is a legitimate consumer of WhereIsWaldo.broadcast_to signaling
|
|
11
|
+
# and shouldn't be cut off from messages just because it doesn't count
|
|
12
|
+
# as "present."
|
|
13
|
+
return if presence_suppressed?
|
|
14
|
+
|
|
8
15
|
register_presence
|
|
9
16
|
|
|
10
17
|
# Seed the local transition gate, resolve the roster delivery strategy for
|
|
@@ -16,7 +23,9 @@ module WhereIsWaldo
|
|
|
16
23
|
end
|
|
17
24
|
|
|
18
25
|
def unsubscribed
|
|
19
|
-
|
|
26
|
+
return if presence_suppressed?
|
|
27
|
+
|
|
28
|
+
WhereIsWaldo.disconnect(session_id: waldo_session_id, subject_id: waldo_subject_id)
|
|
20
29
|
|
|
21
30
|
# Recompute the subject's aggregate (they may still be present in another
|
|
22
31
|
# tab/device) and announce the change to the org roster.
|
|
@@ -24,6 +33,8 @@ module WhereIsWaldo
|
|
|
24
33
|
end
|
|
25
34
|
|
|
26
35
|
def heartbeat(data)
|
|
36
|
+
return if presence_suppressed?
|
|
37
|
+
|
|
27
38
|
data = data.with_indifferent_access
|
|
28
39
|
|
|
29
40
|
tab_visible = data[:tab_visible] != false
|
|
@@ -31,6 +42,7 @@ module WhereIsWaldo
|
|
|
31
42
|
|
|
32
43
|
WhereIsWaldo.heartbeat(
|
|
33
44
|
session_id: waldo_session_id,
|
|
45
|
+
subject_id: waldo_subject_id,
|
|
34
46
|
tab_visible: tab_visible,
|
|
35
47
|
subject_active: subject_active,
|
|
36
48
|
last_activity_at: data[:last_activity_at],
|
|
@@ -48,6 +60,22 @@ module WhereIsWaldo
|
|
|
48
60
|
|
|
49
61
|
private
|
|
50
62
|
|
|
63
|
+
# Memoized per-subscription. Host apps configure suppress_presence_proc
|
|
64
|
+
# to gate WHICH subscriptions register presence — e.g. a support-user
|
|
65
|
+
# impersonation tab is a legitimate WS consumer but shouldn't count as
|
|
66
|
+
# the impersonated subject being "here." Nil proc → default behavior
|
|
67
|
+
# (presence always registered).
|
|
68
|
+
def presence_suppressed?
|
|
69
|
+
# Ivars are @wiw_-prefixed throughout this channel to avoid clobbering
|
|
70
|
+
# host-app ActionCable state; keep the prefix over the cop's rename.
|
|
71
|
+
# rubocop:disable Naming/MemoizedInstanceVariableName
|
|
72
|
+
return @wiw_presence_suppressed if defined?(@wiw_presence_suppressed)
|
|
73
|
+
|
|
74
|
+
proc = WhereIsWaldo.config.suppress_presence_proc
|
|
75
|
+
@wiw_presence_suppressed = proc ? !!proc.call(connection) : false
|
|
76
|
+
# rubocop:enable Naming/MemoizedInstanceVariableName
|
|
77
|
+
end
|
|
78
|
+
|
|
51
79
|
def register_presence
|
|
52
80
|
WhereIsWaldo.connect(
|
|
53
81
|
session_id: waldo_session_id,
|
|
@@ -12,9 +12,11 @@ module WhereIsWaldo
|
|
|
12
12
|
raise NotImplementedError
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# Remove a presence
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# Remove a presence. Session-scoped disconnect requires both keys so
|
|
16
|
+
# a caller-supplied session_id can't disconnect another subject's row.
|
|
17
|
+
# Subject-only disconnect removes every session for that subject.
|
|
18
|
+
# @param session_id [String] Session identifier (requires subject_id when set)
|
|
19
|
+
# @param subject_id [Integer/String] Subject identifier
|
|
18
20
|
# @return [Boolean] success
|
|
19
21
|
def disconnect(session_id: nil, subject_id: nil)
|
|
20
22
|
raise NotImplementedError
|
|
@@ -22,11 +24,14 @@ module WhereIsWaldo
|
|
|
22
24
|
|
|
23
25
|
# Update heartbeat
|
|
24
26
|
# @param session_id [String] Session identifier
|
|
27
|
+
# @param subject_id [Integer/String] Subject identifier (required —
|
|
28
|
+
# pairs with session_id to disambiguate colliding session ids across
|
|
29
|
+
# subjects)
|
|
25
30
|
# @param tab_visible [Boolean] Is tab in foreground
|
|
26
31
|
# @param subject_active [Boolean] Recent activity
|
|
27
32
|
# @param metadata [Hash] Additional data
|
|
28
33
|
# @return [Boolean] success
|
|
29
|
-
def heartbeat(session_id:, tab_visible: true, subject_active: true, metadata: {})
|
|
34
|
+
def heartbeat(session_id:, subject_id:, tab_visible: true, subject_active: true, metadata: {})
|
|
30
35
|
raise NotImplementedError
|
|
31
36
|
end
|
|
32
37
|
|
|
@@ -44,10 +49,31 @@ module WhereIsWaldo
|
|
|
44
49
|
raise NotImplementedError
|
|
45
50
|
end
|
|
46
51
|
|
|
52
|
+
# Get live sessions for many subjects in one call, grouped by subject id.
|
|
53
|
+
# Subjects with no live sessions are omitted from the returned hash.
|
|
54
|
+
# Adapters should override for efficiency; this default is a correct but
|
|
55
|
+
# unoptimized fan-out over `sessions_for_subject`.
|
|
56
|
+
# @param subject_ids [Array<Integer/String>] Subject identifiers
|
|
57
|
+
# @param timeout [Integer, nil] Seconds threshold; sessions with
|
|
58
|
+
# `last_heartbeat` older than `Time.current - timeout` are excluded
|
|
59
|
+
# @return [Hash{Integer/String => Array<Hash>}] subject_id => sessions
|
|
60
|
+
def sessions_for_subjects(subject_ids, timeout: nil)
|
|
61
|
+
ids = Array(subject_ids).compact.uniq
|
|
62
|
+
return {} if ids.empty?
|
|
63
|
+
|
|
64
|
+
threshold = Time.current - (timeout || default_timeout)
|
|
65
|
+
ids.each_with_object({}) do |sid, memo|
|
|
66
|
+
live = sessions_for_subject(sid).select { |s| s[:last_heartbeat] && s[:last_heartbeat] >= threshold }
|
|
67
|
+
memo[sid] = live if live.any?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
47
71
|
# Get session status
|
|
48
72
|
# @param session_id [String] Session identifier
|
|
73
|
+
# @param subject_id [Integer/String] Subject identifier — required
|
|
74
|
+
# for the same reason as heartbeat (see above)
|
|
49
75
|
# @return [Hash, nil] Presence record or nil
|
|
50
|
-
def session_status(session_id)
|
|
76
|
+
def session_status(session_id, subject_id)
|
|
51
77
|
raise NotImplementedError
|
|
52
78
|
end
|
|
53
79
|
|
|
@@ -19,8 +19,12 @@ module WhereIsWaldo
|
|
|
19
19
|
updated_at: now
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
# (subject, session) is the unique key — see the install migration
|
|
23
|
+
# template. Keying on session alone would let a caller-supplied
|
|
24
|
+
# session_id colliding across two subjects upsert onto each other's
|
|
25
|
+
# row.
|
|
22
26
|
# rubocop:disable Rails/SkipsModelValidations -- intentional for performance
|
|
23
|
-
Presence.upsert(attrs, unique_by: session_column)
|
|
27
|
+
Presence.upsert(attrs, unique_by: [subject_column, session_column])
|
|
24
28
|
# rubocop:enable Rails/SkipsModelValidations
|
|
25
29
|
true
|
|
26
30
|
rescue StandardError => e
|
|
@@ -29,15 +33,21 @@ module WhereIsWaldo
|
|
|
29
33
|
end
|
|
30
34
|
|
|
31
35
|
def disconnect(session_id: nil, subject_id: nil)
|
|
36
|
+
raise ArgumentError, "disconnect(session_id:) requires subject_id:" if session_id && !subject_id
|
|
37
|
+
|
|
32
38
|
scope = build_lookup_scope(session_id: session_id, subject_id: subject_id)
|
|
33
39
|
scope.delete_all
|
|
34
40
|
true
|
|
41
|
+
rescue ArgumentError
|
|
42
|
+
raise
|
|
35
43
|
rescue StandardError => e
|
|
36
44
|
Rails.logger.error "[WhereIsWaldo] Disconnect failed: #{e.message}"
|
|
37
45
|
false
|
|
38
46
|
end
|
|
39
47
|
|
|
40
|
-
|
|
48
|
+
# rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
49
|
+
def heartbeat(session_id:, subject_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
|
|
50
|
+
# rubocop:enable Metrics/ParameterLists, Layout/LineLength
|
|
41
51
|
now = Time.current
|
|
42
52
|
updates = {
|
|
43
53
|
last_heartbeat: now,
|
|
@@ -53,7 +63,9 @@ module WhereIsWaldo
|
|
|
53
63
|
end
|
|
54
64
|
updates[:metadata] = metadata if metadata.present?
|
|
55
65
|
|
|
56
|
-
|
|
66
|
+
# Scope by both — a caller-supplied session_id shouldn't be able to
|
|
67
|
+
# heartbeat another subject's row.
|
|
68
|
+
scope = Presence.where(session_column => session_id, subject_column => subject_id)
|
|
57
69
|
|
|
58
70
|
# rubocop:disable Rails/SkipsModelValidations -- intentional for performance
|
|
59
71
|
scope.update_all(updates).positive?
|
|
@@ -77,8 +89,19 @@ module WhereIsWaldo
|
|
|
77
89
|
scope.map(&:as_presence_hash)
|
|
78
90
|
end
|
|
79
91
|
|
|
80
|
-
def
|
|
81
|
-
|
|
92
|
+
def sessions_for_subjects(subject_ids, timeout: nil)
|
|
93
|
+
ids = Array(subject_ids).compact.uniq
|
|
94
|
+
return {} if ids.empty?
|
|
95
|
+
|
|
96
|
+
threshold = (timeout || default_timeout).seconds.ago
|
|
97
|
+
scope = Presence.where(subject_column => ids).where("last_heartbeat > ?", threshold)
|
|
98
|
+
scope = scope.includes(:subject) if config.subject_class_constant
|
|
99
|
+
scope.group_by { |row| row[subject_column] }
|
|
100
|
+
.transform_values { |rows| rows.map(&:as_presence_hash) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def session_status(session_id, subject_id)
|
|
104
|
+
scope = Presence.where(session_column => session_id, subject_column => subject_id)
|
|
82
105
|
scope = scope.includes(:subject) if config.subject_class_constant
|
|
83
106
|
scope.first&.as_presence_hash
|
|
84
107
|
end
|
|
@@ -91,8 +114,8 @@ module WhereIsWaldo
|
|
|
91
114
|
private
|
|
92
115
|
|
|
93
116
|
def build_lookup_scope(session_id: nil, subject_id: nil)
|
|
94
|
-
if session_id
|
|
95
|
-
Presence.where(session_column => session_id)
|
|
117
|
+
if session_id && subject_id
|
|
118
|
+
Presence.where(session_column => session_id, subject_column => subject_id)
|
|
96
119
|
elsif subject_id
|
|
97
120
|
Presence.where(subject_column => subject_id)
|
|
98
121
|
else
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module WhereIsWaldo
|
|
4
4
|
module Adapters
|
|
5
|
-
|
|
5
|
+
# Redis backing needs more plumbing (keying, TTLs, pipelines) than the
|
|
6
|
+
# DB adapter, so it legitimately runs past the default class-length limit.
|
|
7
|
+
class RedisAdapter < BaseAdapter # rubocop:disable Metrics/ClassLength
|
|
6
8
|
def connect(session_id:, subject_id:, metadata: {})
|
|
7
9
|
now = Time.current.to_i
|
|
8
10
|
|
|
@@ -18,17 +20,15 @@ module WhereIsWaldo
|
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
redis.multi do |tx|
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
+
# Session key includes subject_id so two subjects with the same
|
|
24
|
+
# caller-supplied session_id can't overwrite each other's row.
|
|
25
|
+
tx.set(session_key(subject_id, session_id), presence_data.to_json, ex: ttl)
|
|
23
26
|
|
|
24
27
|
# Add to online subjects sorted set with timestamp as score
|
|
25
28
|
tx.zadd(online_subjects_key, now, subject_id)
|
|
26
29
|
|
|
27
30
|
# Add to subject's sessions set
|
|
28
31
|
tx.sadd(subject_sessions_key(subject_id), session_id)
|
|
29
|
-
|
|
30
|
-
# Map session to subject for reverse lookup
|
|
31
|
-
tx.set(session_subject_key(session_id), subject_id, ex: ttl)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
true
|
|
@@ -39,19 +39,25 @@ module WhereIsWaldo
|
|
|
39
39
|
|
|
40
40
|
def disconnect(session_id: nil, subject_id: nil)
|
|
41
41
|
if session_id
|
|
42
|
-
|
|
42
|
+
raise ArgumentError, "disconnect(session_id:) requires subject_id:" unless subject_id
|
|
43
|
+
|
|
44
|
+
disconnect_session(subject_id, session_id)
|
|
43
45
|
elsif subject_id
|
|
44
46
|
disconnect_subject(subject_id)
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
true
|
|
50
|
+
rescue ArgumentError
|
|
51
|
+
raise
|
|
48
52
|
rescue StandardError => e
|
|
49
53
|
Rails.logger.error "[WhereIsWaldo] Redis disconnect failed: #{e.message}"
|
|
50
54
|
false
|
|
51
55
|
end
|
|
52
56
|
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
# rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
58
|
+
def heartbeat(session_id:, subject_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
|
|
59
|
+
# rubocop:enable Metrics/ParameterLists, Layout/LineLength
|
|
60
|
+
data = get_presence_data(subject_id, session_id)
|
|
55
61
|
return false unless data
|
|
56
62
|
|
|
57
63
|
now = Time.current.to_i
|
|
@@ -67,7 +73,7 @@ module WhereIsWaldo
|
|
|
67
73
|
data["metadata"] = data["metadata"].merge(metadata) if metadata.present?
|
|
68
74
|
|
|
69
75
|
redis.multi do |tx|
|
|
70
|
-
tx.set(session_key(session_id), data.to_json, ex: ttl)
|
|
76
|
+
tx.set(session_key(subject_id, session_id), data.to_json, ex: ttl)
|
|
71
77
|
tx.zadd(online_subjects_key, now, data["subject_id"])
|
|
72
78
|
end
|
|
73
79
|
|
|
@@ -88,13 +94,30 @@ module WhereIsWaldo
|
|
|
88
94
|
session_ids = redis.smembers(subject_sessions_key(subject_id))
|
|
89
95
|
|
|
90
96
|
session_ids.filter_map do |sid|
|
|
91
|
-
data = get_presence_data(sid)
|
|
97
|
+
data = get_presence_data(subject_id, sid)
|
|
92
98
|
build_presence_hash(data) if data
|
|
93
99
|
end
|
|
94
100
|
end
|
|
95
101
|
|
|
96
|
-
def
|
|
97
|
-
|
|
102
|
+
def sessions_for_subjects(subject_ids, timeout: nil)
|
|
103
|
+
ids = Array(subject_ids).compact.uniq
|
|
104
|
+
return {} if ids.empty?
|
|
105
|
+
|
|
106
|
+
threshold = Time.current.to_i - (timeout || default_timeout)
|
|
107
|
+
ids.each_with_object({}) do |sid, memo|
|
|
108
|
+
session_ids = redis.smembers(subject_sessions_key(sid))
|
|
109
|
+
live = session_ids.filter_map do |cid|
|
|
110
|
+
data = get_presence_data(sid, cid)
|
|
111
|
+
next unless data && data["last_heartbeat"].to_i >= threshold
|
|
112
|
+
|
|
113
|
+
build_presence_hash(data)
|
|
114
|
+
end
|
|
115
|
+
memo[sid] = live if live.any?
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def session_status(session_id, subject_id)
|
|
120
|
+
data = get_presence_data(subject_id, session_id)
|
|
98
121
|
return nil unless data
|
|
99
122
|
|
|
100
123
|
build_presence_hash(data)
|
|
@@ -111,7 +134,7 @@ module WhereIsWaldo
|
|
|
111
134
|
# Check if any sessions are still active
|
|
112
135
|
session_ids = redis.smembers(subject_sessions_key(subject_id))
|
|
113
136
|
all_stale = session_ids.all? do |sid|
|
|
114
|
-
data = get_presence_data(sid)
|
|
137
|
+
data = get_presence_data(subject_id, sid)
|
|
115
138
|
!data || data["last_heartbeat"].to_i < threshold
|
|
116
139
|
end
|
|
117
140
|
|
|
@@ -141,8 +164,11 @@ module WhereIsWaldo
|
|
|
141
164
|
config.redis_prefix || "where_is_waldo"
|
|
142
165
|
end
|
|
143
166
|
|
|
144
|
-
|
|
145
|
-
|
|
167
|
+
# Session data key. Namespaced by subject_id so a caller-supplied
|
|
168
|
+
# session_id colliding across two authenticated subjects doesn't let one
|
|
169
|
+
# clobber the other's presence row (see CHANGELOG 0.1.5).
|
|
170
|
+
def session_key(subject_id, session_id)
|
|
171
|
+
"#{key_prefix}:session:#{subject_id}:#{session_id}"
|
|
146
172
|
end
|
|
147
173
|
|
|
148
174
|
def online_subjects_key
|
|
@@ -153,27 +179,17 @@ module WhereIsWaldo
|
|
|
153
179
|
"#{key_prefix}:subject:#{subject_id}:sessions"
|
|
154
180
|
end
|
|
155
181
|
|
|
156
|
-
def
|
|
157
|
-
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def get_presence_data(session_id)
|
|
161
|
-
json = redis.get(session_key(session_id))
|
|
182
|
+
def get_presence_data(subject_id, session_id)
|
|
183
|
+
json = redis.get(session_key(subject_id, session_id))
|
|
162
184
|
return nil unless json
|
|
163
185
|
|
|
164
186
|
JSON.parse(json)
|
|
165
187
|
end
|
|
166
188
|
|
|
167
|
-
def disconnect_session(session_id)
|
|
168
|
-
data = get_presence_data(session_id)
|
|
169
|
-
return unless data
|
|
170
|
-
|
|
171
|
-
subject_id = data["subject_id"]
|
|
172
|
-
|
|
189
|
+
def disconnect_session(subject_id, session_id)
|
|
173
190
|
redis.multi do |tx|
|
|
174
|
-
tx.del(session_key(session_id))
|
|
191
|
+
tx.del(session_key(subject_id, session_id))
|
|
175
192
|
tx.srem(subject_sessions_key(subject_id), session_id)
|
|
176
|
-
tx.del(session_subject_key(session_id))
|
|
177
193
|
end
|
|
178
194
|
|
|
179
195
|
# If no more sessions for this subject, remove from online set
|
|
@@ -183,7 +199,7 @@ module WhereIsWaldo
|
|
|
183
199
|
|
|
184
200
|
def disconnect_subject(subject_id)
|
|
185
201
|
session_ids = redis.smembers(subject_sessions_key(subject_id))
|
|
186
|
-
session_ids.each { |sid| disconnect_session(sid) }
|
|
202
|
+
session_ids.each { |sid| disconnect_session(subject_id, sid) }
|
|
187
203
|
redis.zrem(online_subjects_key, subject_id)
|
|
188
204
|
end
|
|
189
205
|
|
|
@@ -35,14 +35,16 @@ module WhereIsWaldo
|
|
|
35
35
|
|
|
36
36
|
# Broadcast to a specific session
|
|
37
37
|
# @param session_id [String] Session identifier
|
|
38
|
+
# @param subject_id [Integer/String] Subject identifier — required
|
|
39
|
+
# to disambiguate colliding session ids across subjects
|
|
38
40
|
# @param message_type [String, Symbol] Message type
|
|
39
41
|
# @param data [Hash] Message payload
|
|
40
|
-
def broadcast_to_session(session_id, message_type, data = {})
|
|
41
|
-
status = PresenceService.session_status(session_id)
|
|
42
|
+
def broadcast_to_session(session_id, subject_id, message_type, data = {})
|
|
43
|
+
status = PresenceService.session_status(session_id, subject_id)
|
|
42
44
|
return false unless status
|
|
43
45
|
|
|
44
46
|
message = build_message(message_type, data, target_session: session_id)
|
|
45
|
-
ActionCable.server.broadcast(subject_stream(
|
|
47
|
+
ActionCable.server.broadcast(subject_stream(subject_id), message)
|
|
46
48
|
true
|
|
47
49
|
end
|
|
48
50
|
|
|
@@ -29,14 +29,20 @@ module WhereIsWaldo
|
|
|
29
29
|
|
|
30
30
|
# Update heartbeat for a session
|
|
31
31
|
# @param session_id [String] Session identifier
|
|
32
|
+
# @param subject_id [Integer/String] Subject identifier (required —
|
|
33
|
+
# pairs with session_id to disambiguate colliding session ids across
|
|
34
|
+
# subjects)
|
|
32
35
|
# @param tab_visible [Boolean] Is tab in foreground
|
|
33
36
|
# @param subject_active [Boolean] Recent activity
|
|
34
37
|
# @param last_activity_at [Integer] Unix timestamp (ms) of last user activity
|
|
35
38
|
# @param metadata [Hash] Additional data to merge
|
|
36
39
|
# @return [Boolean] success
|
|
37
|
-
|
|
40
|
+
# rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
41
|
+
def heartbeat(session_id:, subject_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
|
|
42
|
+
# rubocop:enable Metrics/ParameterLists, Layout/LineLength
|
|
38
43
|
adapter.heartbeat(
|
|
39
44
|
session_id: session_id,
|
|
45
|
+
subject_id: subject_id,
|
|
40
46
|
tab_visible: tab_visible,
|
|
41
47
|
subject_active: subject_active,
|
|
42
48
|
last_activity_at: last_activity_at,
|
|
@@ -74,8 +80,19 @@ module WhereIsWaldo
|
|
|
74
80
|
# @return [Array<Hash>] Presence records
|
|
75
81
|
delegate :sessions_for_subject, to: :adapter
|
|
76
82
|
|
|
83
|
+
# Get live sessions for many subjects in one call, grouped by subject id.
|
|
84
|
+
# Preferred over calling `sessions_for_subject` per id when the caller
|
|
85
|
+
# already has an id list (e.g. roster aggregation) — adapters can (and do)
|
|
86
|
+
# implement this as a bulk read.
|
|
87
|
+
# @param subject_ids [Array<Integer/String>] Subject identifiers
|
|
88
|
+
# @param timeout [Integer, nil] Seconds threshold (defaults to config.timeout)
|
|
89
|
+
# @return [Hash{Integer/String => Array<Hash>}] subject_id => sessions
|
|
90
|
+
delegate :sessions_for_subjects, to: :adapter
|
|
91
|
+
|
|
77
92
|
# Get status of a specific session
|
|
78
93
|
# @param session_id [String] Session identifier
|
|
94
|
+
# @param subject_id [Integer/String] Subject identifier — required
|
|
95
|
+
# for the same reason as heartbeat
|
|
79
96
|
# @return [Hash, nil] Presence record or nil
|
|
80
97
|
delegate :session_status, to: :adapter
|
|
81
98
|
|
|
@@ -165,25 +165,24 @@ module WhereIsWaldo
|
|
|
165
165
|
|
|
166
166
|
private
|
|
167
167
|
|
|
168
|
-
# Aggregate state for many subjects in one
|
|
168
|
+
# Aggregate state for many subjects in one adapter call, keyed by
|
|
169
|
+
# subject id. Routes through PresenceService so it matches whichever
|
|
170
|
+
# adapter is configured — reading Presence directly would only work on
|
|
171
|
+
# `:database`, but the roster snapshot needs to see whatever the writer
|
|
172
|
+
# sees under `:redis` too.
|
|
169
173
|
def states_for(subject_ids, timeout: nil)
|
|
170
174
|
ids = Array(subject_ids).compact.uniq
|
|
171
175
|
return {} if ids.empty?
|
|
172
176
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
rows = Presence.where(subject_col => ids)
|
|
177
|
-
.where("last_heartbeat > ?", threshold)
|
|
178
|
-
.to_a
|
|
179
|
-
|
|
180
|
-
rows.group_by { |row| row[subject_col] }
|
|
181
|
-
.transform_values { |sessions| aggregate(sessions) }
|
|
177
|
+
WhereIsWaldo::PresenceService
|
|
178
|
+
.sessions_for_subjects(ids, timeout: timeout)
|
|
179
|
+
.transform_values { |sessions| aggregate(sessions) }
|
|
182
180
|
end
|
|
183
181
|
|
|
184
182
|
# Reduce a subject's live sessions to per-device statuses plus an overall
|
|
185
183
|
# roll-up. Sessions are grouped by platform (so several browser tabs form
|
|
186
184
|
# one "web" status); the overall status is the highest across platforms.
|
|
185
|
+
# @param sessions [Array<Hash>] presence hashes from the adapter
|
|
187
186
|
# @return [Hash] { status: "active", devices: { "web" => "active", ... } }
|
|
188
187
|
def aggregate(sessions)
|
|
189
188
|
devices = sessions.group_by { |s| platform(s) }
|
|
@@ -204,13 +203,13 @@ module WhereIsWaldo
|
|
|
204
203
|
# a hidden tab / backgrounded app is :background; a visible/foreground
|
|
205
204
|
# session is :active when working, else :idle.
|
|
206
205
|
def session_level(session)
|
|
207
|
-
return :background unless session
|
|
206
|
+
return :background unless session[:tab_visible]
|
|
208
207
|
|
|
209
|
-
session
|
|
208
|
+
session[:subject_active] ? :active : :idle
|
|
210
209
|
end
|
|
211
210
|
|
|
212
211
|
def platform(session)
|
|
213
|
-
meta = session
|
|
212
|
+
meta = session[:metadata]
|
|
214
213
|
value = meta && (meta["platform"] || meta[:platform])
|
|
215
214
|
(value.presence || DEFAULT_PLATFORM).to_s
|
|
216
215
|
end
|
|
@@ -22,10 +22,15 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration[7.0]
|
|
|
22
22
|
t.timestamps
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
#
|
|
25
|
+
# (subject, session) must be unique together. Keying by session alone
|
|
26
|
+
# would let two authenticated subjects that happen to collide on a
|
|
27
|
+
# session id (client-supplied values, JWT jti reuse, etc.) upsert onto
|
|
28
|
+
# each other's row.
|
|
29
|
+
add_index :<%= table_name %>, [:<%= subject_column %>, :<%= session_column %>], unique: true
|
|
30
|
+
|
|
31
|
+
# Session lookups (heartbeat, disconnect, session_status) always scope
|
|
32
|
+
# by (subject, session); the composite unique index above serves those.
|
|
33
|
+
# Query by subject alone
|
|
29
34
|
add_index :<%= table_name %>, :<%= subject_column %>
|
|
30
35
|
|
|
31
36
|
# Cleanup old records
|
|
@@ -32,6 +32,23 @@ module WhereIsWaldo
|
|
|
32
32
|
# :authenticate_proc - proc to authenticate connection, receives request
|
|
33
33
|
attr_accessor :channel_name, :authenticate_proc
|
|
34
34
|
|
|
35
|
+
# :suppress_presence_proc - callable that decides, per connection, whether
|
|
36
|
+
# to SKIP presence registration for that subscriber. Receives the
|
|
37
|
+
# ActionCable connection (host apps can read session/cookies/env off it,
|
|
38
|
+
# e.g. via `connection.request`). Returns truthy to suppress.
|
|
39
|
+
#
|
|
40
|
+
# A suppressed subscriber still subscribes normally — they receive
|
|
41
|
+
# broadcasts to `where_is_waldo:subject:<id>` (WhereIsWaldo.broadcast_to*
|
|
42
|
+
# signaling) and any other Waldo capability — they just don't register a
|
|
43
|
+
# Presence row / heartbeat / roster transition. Use for cases where the
|
|
44
|
+
# WebSocket session is legitimate but shouldn't be counted as "the subject
|
|
45
|
+
# is present", e.g. support-user impersonation tabs.
|
|
46
|
+
#
|
|
47
|
+
# config.suppress_presence_proc = ->(connection) {
|
|
48
|
+
# connection.request.session[:su_user].present?
|
|
49
|
+
# }
|
|
50
|
+
attr_accessor :suppress_presence_proc
|
|
51
|
+
|
|
35
52
|
# Default audience resolver for the Broadcastable concern. A lambda that,
|
|
36
53
|
# given a record, returns the AR scope to broadcast to (e.g. that record's
|
|
37
54
|
# account members). Set once per app to match its container, e.g.:
|
|
@@ -106,6 +123,7 @@ module WhereIsWaldo
|
|
|
106
123
|
# ActionCable defaults
|
|
107
124
|
@channel_name = "WhereIsWaldo::PresenceChannel"
|
|
108
125
|
@authenticate_proc = nil
|
|
126
|
+
@suppress_presence_proc = nil
|
|
109
127
|
|
|
110
128
|
# Broadcastable default audience (set per app)
|
|
111
129
|
@broadcast_audience = nil
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: where_is_waldo
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Scott Gibson
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|