DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
# The server-side implementation of PresenceChannels. See also {PresenceController}
|
|
|
|
# and +app/assets/javascripts/discourse/app/services/presence.js+
|
|
|
|
class PresenceChannel
|
|
|
|
class NotFound < StandardError; end
|
|
|
|
class InvalidAccess < StandardError; end
|
|
|
|
class ConfigNotLoaded < StandardError; end
|
|
|
|
class InvalidConfig < StandardError; end
|
|
|
|
|
|
|
|
class State
|
|
|
|
include ActiveModel::Serialization
|
|
|
|
|
|
|
|
attr_reader :message_bus_last_id
|
|
|
|
attr_reader :user_ids
|
|
|
|
attr_reader :count
|
|
|
|
|
|
|
|
def initialize(message_bus_last_id: , user_ids: nil, count: nil)
|
|
|
|
raise "user_ids or count required" if user_ids.nil? && count.nil?
|
|
|
|
@message_bus_last_id = message_bus_last_id
|
|
|
|
@user_ids = user_ids
|
|
|
|
@count = count || user_ids.count
|
|
|
|
end
|
|
|
|
|
|
|
|
def users
|
|
|
|
return nil if user_ids.nil?
|
|
|
|
User.where(id: user_ids)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
# Class for managing config of PresenceChannel
|
|
|
|
# Three parameters can be provided on initialization:
|
|
|
|
# public: boolean value. If true, channel information is visible to all users (default false)
|
|
|
|
# allowed_user_ids: array of user_ids that can view, and become present in, the channel (default [])
|
|
|
|
# allowed_group_ids: array of group_ids that can view, and become present in, the channel (default [])
|
|
|
|
# count_only: boolean. If true, user identities are never revealed to clients. (default [])
|
|
|
|
class Config
|
|
|
|
NOT_FOUND ||= "notfound"
|
|
|
|
|
2021-10-22 11:24:55 -04:00
|
|
|
attr_accessor :public, :allowed_user_ids, :allowed_group_ids, :count_only, :timeout
|
|
|
|
|
|
|
|
def initialize(public: false, allowed_user_ids: nil, allowed_group_ids: nil, count_only: false, timeout: nil)
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
@public = public
|
|
|
|
@allowed_user_ids = allowed_user_ids
|
|
|
|
@allowed_group_ids = allowed_group_ids
|
|
|
|
@count_only = count_only
|
2021-10-22 11:24:55 -04:00
|
|
|
@timeout = timeout
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.from_json(json)
|
|
|
|
data = JSON.parse(json, symbolize_names: true)
|
|
|
|
data = {} if !data.is_a? Hash
|
2021-10-22 11:24:55 -04:00
|
|
|
new(**data.slice(:public, :allowed_user_ids, :allowed_group_ids, :count_only, :timeout))
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def to_json
|
|
|
|
data = { public: public }
|
|
|
|
data[:allowed_user_ids] = allowed_user_ids if allowed_user_ids
|
|
|
|
data[:allowed_group_ids] = allowed_group_ids if allowed_group_ids
|
|
|
|
data[:count_only] = count_only if count_only
|
2021-10-22 11:24:55 -04:00
|
|
|
data[:timeout] = timeout if timeout
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
data.to_json
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
DEFAULT_TIMEOUT ||= 60
|
2021-10-21 07:42:46 -04:00
|
|
|
CONFIG_CACHE_SECONDS ||= 10
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
GC_SECONDS ||= 24.hours.to_i
|
|
|
|
MUTEX_TIMEOUT_SECONDS ||= 10
|
|
|
|
MUTEX_LOCKED_ERROR ||= "PresenceChannel mutex is locked"
|
|
|
|
|
|
|
|
@@configuration_blocks ||= {}
|
|
|
|
|
|
|
|
attr_reader :name, :timeout, :message_bus_channel_name, :config
|
|
|
|
|
2021-10-07 10:50:14 -04:00
|
|
|
def initialize(name, raise_not_found: true, use_cache: true)
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
@name = name
|
|
|
|
@message_bus_channel_name = "/presence#{name}"
|
|
|
|
|
|
|
|
begin
|
2021-10-07 10:50:14 -04:00
|
|
|
@config = fetch_config(use_cache: use_cache)
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
rescue PresenceChannel::NotFound
|
|
|
|
raise if raise_not_found
|
|
|
|
@config = Config.new
|
|
|
|
end
|
2021-10-22 11:24:55 -04:00
|
|
|
|
|
|
|
@timeout = config.timeout || DEFAULT_TIMEOUT
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
# Is this user allowed to view this channel?
|
|
|
|
# Pass `nil` for anonymous viewers
|
2021-10-07 10:50:14 -04:00
|
|
|
def can_view?(user_id: nil, group_ids: nil)
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
return true if config.public
|
|
|
|
return true if user_id && config.allowed_user_ids&.include?(user_id)
|
2021-10-07 10:50:14 -04:00
|
|
|
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
if user_id && config.allowed_group_ids.present?
|
2021-10-07 10:50:14 -04:00
|
|
|
group_ids ||= GroupUser.where(user_id: user_id).pluck("group_id")
|
|
|
|
return true if (group_ids & config.allowed_group_ids).present?
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
end
|
|
|
|
false
|
|
|
|
end
|
|
|
|
|
|
|
|
# Is a user allowed to enter this channel?
|
|
|
|
# Currently equal to the the can_view? permission
|
2021-10-07 10:50:14 -04:00
|
|
|
def can_enter?(user_id: nil, group_ids: nil)
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
return false if user_id.nil?
|
2021-10-07 10:50:14 -04:00
|
|
|
can_view?(user_id: user_id, group_ids: group_ids)
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
# Mark a user's client as present in this channel. The client_id should be unique per
|
|
|
|
# browser tab. This method should be called repeatedly (at least once every DEFAULT_TIMEOUT)
|
|
|
|
# while the user is present in the channel.
|
|
|
|
def present(user_id:, client_id:)
|
|
|
|
raise PresenceChannel::InvalidAccess if !can_enter?(user_id: user_id)
|
|
|
|
|
|
|
|
mutex_value = SecureRandom.hex
|
|
|
|
result = retry_on_mutex_error do
|
|
|
|
PresenceChannel.redis_eval(
|
|
|
|
:present,
|
|
|
|
redis_keys,
|
|
|
|
[name, user_id, client_id, (Time.zone.now + timeout).to_i, mutex_value]
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
if result == 1
|
|
|
|
begin
|
|
|
|
publish_message(entering_user_ids: [user_id])
|
|
|
|
ensure
|
|
|
|
release_mutex(mutex_value)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
auto_leave
|
|
|
|
end
|
|
|
|
|
|
|
|
# Immediately mark a user's client as leaving the channel
|
|
|
|
def leave(user_id:, client_id:)
|
|
|
|
mutex_value = SecureRandom.hex
|
|
|
|
result = retry_on_mutex_error do
|
|
|
|
PresenceChannel.redis_eval(
|
|
|
|
:leave,
|
|
|
|
redis_keys,
|
|
|
|
[name, user_id, client_id, nil, mutex_value]
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
if result == 1
|
|
|
|
begin
|
|
|
|
publish_message(leaving_user_ids: [user_id])
|
|
|
|
ensure
|
|
|
|
release_mutex(mutex_value)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
auto_leave
|
|
|
|
end
|
|
|
|
|
|
|
|
# Fetch a {PresenceChannel::State} instance representing the current state of this
|
|
|
|
#
|
|
|
|
# @param [Boolean] count_only set true to skip fetching the list of user ids from redis
|
|
|
|
def state(count_only: config.count_only)
|
|
|
|
auto_leave
|
|
|
|
|
|
|
|
if count_only
|
|
|
|
last_id, count = retry_on_mutex_error do
|
|
|
|
PresenceChannel.redis_eval(
|
|
|
|
:count,
|
|
|
|
redis_keys,
|
|
|
|
)
|
|
|
|
end
|
|
|
|
else
|
|
|
|
last_id, ids = retry_on_mutex_error do
|
|
|
|
PresenceChannel.redis_eval(
|
|
|
|
:user_ids,
|
|
|
|
redis_keys,
|
|
|
|
)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
count ||= ids&.count
|
|
|
|
last_id = nil if last_id == -1
|
|
|
|
|
|
|
|
if Rails.env.test? && MessageBus.backend == :memory
|
|
|
|
# Doing it this way is not atomic, but we have no other option when
|
|
|
|
# messagebus is not using the redis backend
|
|
|
|
last_id = MessageBus.last_id(message_bus_channel_name)
|
|
|
|
end
|
|
|
|
|
|
|
|
State.new(message_bus_last_id: last_id, user_ids: ids, count: count)
|
|
|
|
end
|
|
|
|
|
|
|
|
def user_ids
|
|
|
|
state.user_ids
|
|
|
|
end
|
|
|
|
|
|
|
|
def count
|
|
|
|
state(count_only: true).count
|
|
|
|
end
|
|
|
|
|
|
|
|
# Automatically expire all users which have not been 'present' for more than +DEFAULT_TIMEOUT+
|
|
|
|
def auto_leave
|
|
|
|
mutex_value = SecureRandom.hex
|
|
|
|
left_user_ids = retry_on_mutex_error do
|
|
|
|
PresenceChannel.redis_eval(
|
|
|
|
:auto_leave,
|
|
|
|
redis_keys,
|
|
|
|
[name, Time.zone.now.to_i, mutex_value]
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
if !left_user_ids.empty?
|
|
|
|
begin
|
|
|
|
publish_message(leaving_user_ids: left_user_ids)
|
|
|
|
ensure
|
|
|
|
release_mutex(mutex_value)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
# Clear all members of the channel. This is intended for debugging/development only
|
|
|
|
def clear
|
|
|
|
PresenceChannel.redis.del(redis_key_zlist)
|
|
|
|
PresenceChannel.redis.del(redis_key_hash)
|
|
|
|
PresenceChannel.redis.del(redis_key_config)
|
|
|
|
PresenceChannel.redis.del(redis_key_mutex)
|
|
|
|
PresenceChannel.redis.zrem(self.class.redis_key_channel_list, name)
|
|
|
|
end
|
|
|
|
|
|
|
|
# Designed to be run periodically. Checks the channel list for channels with expired members,
|
|
|
|
# and runs auto_leave for each eligable channel
|
|
|
|
def self.auto_leave_all
|
|
|
|
channels_with_expiring_members = PresenceChannel.redis.zrangebyscore(redis_key_channel_list, '-inf', Time.zone.now.to_i)
|
|
|
|
channels_with_expiring_members.each do |name|
|
|
|
|
new(name, raise_not_found: false).auto_leave
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
# Clear all known channels. This is intended for debugging/development only
|
|
|
|
def self.clear_all!
|
|
|
|
channels = PresenceChannel.redis.zrangebyscore(redis_key_channel_list, '-inf', '+inf')
|
|
|
|
channels.each do |name|
|
|
|
|
new(name, raise_not_found: false).clear
|
|
|
|
end
|
|
|
|
|
|
|
|
config_cache_keys = PresenceChannel.redis.scan_each(match: Discourse.redis.namespace_key("_presence_*_config")).to_a
|
|
|
|
PresenceChannel.redis.del(*config_cache_keys) if config_cache_keys.present?
|
|
|
|
end
|
|
|
|
|
|
|
|
# Shortcut to access a redis client for all PresenceChannel activities.
|
|
|
|
# PresenceChannel must use the same Redis server as MessageBus, so that
|
|
|
|
# actions can be applied atomically. For the vast majority of Discourse
|
|
|
|
# installations, this is the same Redis server as `Discourse.redis`.
|
|
|
|
def self.redis
|
|
|
|
if MessageBus.backend == :redis
|
2022-01-13 00:59:19 -05:00
|
|
|
MessageBus.backend_instance.send(:pub_redis) # TODO: avoid a private API?
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
elsif Rails.env.test?
|
|
|
|
Discourse.redis.without_namespace
|
|
|
|
else
|
|
|
|
raise "PresenceChannel is unable to access MessageBus's Redis instance"
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.redis_eval(key, *args)
|
|
|
|
script_sha1 = LUA_SCRIPTS_SHA1[key]
|
|
|
|
raise ArgumentError.new("No script for #{key}") if script_sha1.nil?
|
|
|
|
redis.evalsha script_sha1, *args
|
|
|
|
rescue ::Redis::CommandError => e
|
|
|
|
if e.to_s =~ /^NOSCRIPT/
|
|
|
|
redis.eval LUA_SCRIPTS[key], *args
|
|
|
|
else
|
|
|
|
raise
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
# Register a callback to configure channels with a given prefix
|
|
|
|
# Prefix must match [a-zA-Z0-9_-]+
|
|
|
|
#
|
|
|
|
# For example, this registration will be used for
|
|
|
|
# all channels starting /topic-reply/...:
|
|
|
|
#
|
|
|
|
# register_prefix("topic-reply") do |channel_name|
|
|
|
|
# PresenceChannel::Config.new(public: true)
|
|
|
|
# end
|
|
|
|
#
|
|
|
|
# At runtime, the block will be passed a full channel name. If the channel
|
|
|
|
# should not exist, the block should return `nil`. If the channel should exist,
|
|
|
|
# the block should return a PresenceChannel::Config object.
|
|
|
|
#
|
2021-10-21 07:42:46 -04:00
|
|
|
# Return values may be cached for up to 10 seconds.
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
#
|
|
|
|
# Plugins should use the {Plugin::Instance.register_presence_channel_prefix} API instead
|
|
|
|
def self.register_prefix(prefix, &block)
|
|
|
|
raise "PresenceChannel prefix #{prefix} must match [a-zA-Z0-9_-]+" unless prefix.match? /[a-zA-Z0-9_-]+/
|
|
|
|
raise "PresenceChannel prefix #{prefix} already registered" if @@configuration_blocks&.[](prefix)
|
|
|
|
@@configuration_blocks[prefix] = block
|
|
|
|
end
|
|
|
|
|
|
|
|
# For use in a test environment only
|
|
|
|
def self.unregister_prefix(prefix)
|
|
|
|
raise "Only allowed in test environment" if !Rails.env.test?
|
|
|
|
@@configuration_blocks&.delete(prefix)
|
|
|
|
end
|
|
|
|
|
|
|
|
private
|
|
|
|
|
2021-10-07 10:50:14 -04:00
|
|
|
def fetch_config(use_cache: true)
|
|
|
|
cached_config = if use_cache
|
|
|
|
PresenceChannel.redis.get(redis_key_config)
|
|
|
|
end
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
|
|
|
|
if cached_config == Config::NOT_FOUND
|
|
|
|
raise PresenceChannel::NotFound
|
|
|
|
elsif cached_config
|
|
|
|
Config.from_json(cached_config)
|
|
|
|
else
|
|
|
|
prefix = name[/\/([a-zA-Z0-9_-]+)\/.*/, 1]
|
|
|
|
raise PresenceChannel::NotFound if prefix.nil?
|
|
|
|
|
|
|
|
config_block = @@configuration_blocks[prefix]
|
|
|
|
config_block ||= DiscoursePluginRegistry.presence_channel_prefixes.find { |t| t[0] == prefix }&.[](1)
|
|
|
|
raise PresenceChannel::NotFound if config_block.nil?
|
|
|
|
|
|
|
|
result = config_block.call(name)
|
|
|
|
to_cache = if result.is_a? Config
|
|
|
|
result.to_json
|
|
|
|
elsif result.nil?
|
|
|
|
Config::NOT_FOUND
|
|
|
|
else
|
|
|
|
raise InvalidConfig.new "Expected PresenceChannel::Config or nil. Got a #{result.class.name}"
|
|
|
|
end
|
|
|
|
PresenceChannel.redis.set(redis_key_config, to_cache, ex: CONFIG_CACHE_SECONDS)
|
|
|
|
|
|
|
|
raise PresenceChannel::NotFound if result.nil?
|
|
|
|
result
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def publish_message(entering_user_ids: nil, leaving_user_ids: nil)
|
|
|
|
message = {}
|
|
|
|
if config.count_only
|
|
|
|
message["count_delta"] = entering_user_ids&.count || 0
|
|
|
|
message["count_delta"] -= leaving_user_ids&.count || 0
|
|
|
|
return if message["count_delta"] == 0
|
|
|
|
else
|
|
|
|
message["leaving_user_ids"] = leaving_user_ids if leaving_user_ids.present?
|
|
|
|
if entering_user_ids.present?
|
|
|
|
users = User.where(id: entering_user_ids)
|
|
|
|
message["entering_users"] = ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
params = {}
|
|
|
|
|
|
|
|
if config.public
|
|
|
|
# no params required
|
|
|
|
elsif config.allowed_user_ids || config.allowed_group_ids
|
|
|
|
params[:user_ids] = config.allowed_user_ids
|
|
|
|
params[:group_ids] = config.allowed_group_ids
|
|
|
|
else
|
|
|
|
# nobody is allowed... don't publish anything
|
|
|
|
return
|
|
|
|
end
|
|
|
|
|
|
|
|
MessageBus.publish(message_bus_channel_name, message.as_json, **params)
|
|
|
|
end
|
|
|
|
|
|
|
|
# Most atomic actions are achieved via lua scripts. However, when a lua action
|
|
|
|
# will result in publishing a messagebus message, the atomicity is broken.
|
|
|
|
#
|
|
|
|
# For example, if one process is handling a 'user enter' event, and another is
|
|
|
|
# handling a 'user leave' event, we need to make sure the messagebus messages
|
|
|
|
# are published in the same sequence that the PresenceChannel lua script are run.
|
|
|
|
#
|
|
|
|
# The present/leave/auto_leave lua scripts will automatically acquire this mutex
|
|
|
|
# if needed. If their return value indicates a change has occured, the mutex
|
|
|
|
# should be released via #release_mutex after the messagebus message has been sent
|
|
|
|
#
|
|
|
|
# If they need a change, and the mutex is not available, they will raise an error
|
|
|
|
# and should be retried periodically
|
|
|
|
def redis_key_mutex
|
|
|
|
Discourse.redis.namespace_key("_presence_#{name}_mutex")
|
|
|
|
end
|
|
|
|
|
|
|
|
def release_mutex(mutex_value)
|
|
|
|
PresenceChannel.redis_eval(
|
|
|
|
:release_mutex,
|
|
|
|
[redis_key_mutex],
|
|
|
|
[mutex_value]
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
def retry_on_mutex_error
|
|
|
|
attempts ||= 0
|
|
|
|
yield
|
|
|
|
rescue ::Redis::CommandError => e
|
|
|
|
if e.to_s =~ /#{MUTEX_LOCKED_ERROR}/ && attempts < 1000
|
|
|
|
attempts += 1
|
|
|
|
sleep 0.001
|
|
|
|
retry
|
|
|
|
else
|
|
|
|
raise
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
# The redis key which MessageBus uses to store the 'last_id' for the channel
|
|
|
|
# associated with this PresenceChannel.
|
|
|
|
def message_bus_last_id_key
|
|
|
|
return "" if Rails.env.test? && MessageBus.backend == :memory
|
|
|
|
|
|
|
|
# TODO: Avoid using private MessageBus methods here
|
|
|
|
encoded_channel_name = MessageBus.send(:encode_channel_name, message_bus_channel_name)
|
2022-01-13 00:59:19 -05:00
|
|
|
MessageBus.backend_instance.send(:backlog_id_key, encoded_channel_name)
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def redis_keys
|
|
|
|
[redis_key_zlist, redis_key_hash, self.class.redis_key_channel_list, message_bus_last_id_key, redis_key_mutex]
|
|
|
|
end
|
|
|
|
|
|
|
|
# The zlist is a list of client_ids, ranked by their expiration timestamp
|
|
|
|
# we periodically delete the 'lowest ranked' items in this list based on the `timeout` of the channel
|
|
|
|
def redis_key_zlist
|
|
|
|
Discourse.redis.namespace_key("_presence_#{name}_zlist")
|
|
|
|
end
|
|
|
|
|
|
|
|
# The hash contains a map of user_id => session_count
|
|
|
|
# when the count for a user reaches 0, the key is deleted
|
|
|
|
# We use this hash to efficiently count the number of present users
|
|
|
|
def redis_key_hash
|
|
|
|
Discourse.redis.namespace_key("_presence_#{name}_hash")
|
|
|
|
end
|
|
|
|
|
|
|
|
# The hash contains a map of user_id => session_count
|
|
|
|
# when the count for a user reaches 0, the key is deleted
|
|
|
|
# We use this hash to efficiently count the number of present users
|
|
|
|
def redis_key_config
|
|
|
|
Discourse.redis.namespace_key("_presence_#{name}_config")
|
|
|
|
end
|
|
|
|
|
|
|
|
# This list contains all active presence channels, ranked with the expiration timestamp of their least-recently-seen client_id
|
|
|
|
# We periodically check the 'lowest ranked' items in this list based on the `timeout` of the channel
|
|
|
|
def self.redis_key_channel_list
|
|
|
|
Discourse.redis.namespace_key("_presence_channels")
|
|
|
|
end
|
|
|
|
|
|
|
|
COMMON_PRESENT_LEAVE_LUA = <<~LUA
|
|
|
|
local channel = ARGV[1]
|
|
|
|
local user_id = ARGV[2]
|
|
|
|
local client_id = ARGV[3]
|
|
|
|
local expires = ARGV[4]
|
|
|
|
local mutex_value = ARGV[5]
|
|
|
|
|
|
|
|
local zlist_key = KEYS[1]
|
|
|
|
local hash_key = KEYS[2]
|
|
|
|
local channels_key = KEYS[3]
|
|
|
|
local message_bus_id_key = KEYS[4]
|
|
|
|
local mutex_key = KEYS[5]
|
|
|
|
|
|
|
|
local mutex_locked = redis.call('EXISTS', mutex_key) == 1
|
|
|
|
|
|
|
|
local zlist_elem = tostring(user_id) .. " " .. tostring(client_id)
|
|
|
|
LUA
|
|
|
|
|
|
|
|
UPDATE_GLOBAL_CHANNELS_LUA = <<~LUA
|
|
|
|
-- Update the global channels list with the timestamp of the oldest client
|
|
|
|
local oldest_client = redis.call('ZRANGE', zlist_key, 0, 0, 'WITHSCORES')
|
|
|
|
if table.getn(oldest_client) > 0 then
|
|
|
|
local oldest_client_expire_timestamp = oldest_client[2]
|
|
|
|
redis.call('ZADD', channels_key, tonumber(oldest_client_expire_timestamp), tostring(channel))
|
|
|
|
else
|
|
|
|
-- The channel is now empty, delete from global list
|
|
|
|
redis.call('ZREM', channels_key, tostring(channel))
|
|
|
|
end
|
|
|
|
LUA
|
|
|
|
|
|
|
|
LUA_SCRIPTS ||= {}
|
|
|
|
|
|
|
|
LUA_SCRIPTS[:present] = <<~LUA
|
|
|
|
#{COMMON_PRESENT_LEAVE_LUA}
|
|
|
|
|
|
|
|
if mutex_locked then
|
|
|
|
local mutex_required = redis.call('HGET', hash_key, tostring(user_id)) == false
|
|
|
|
if mutex_required then
|
|
|
|
error("#{MUTEX_LOCKED_ERROR}")
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
local added_clients = redis.call('ZADD', zlist_key, expires, zlist_elem)
|
|
|
|
local added_users = 0
|
|
|
|
if tonumber(added_clients) > 0 then
|
|
|
|
local new_count = redis.call('HINCRBY', hash_key, tostring(user_id), 1)
|
|
|
|
if new_count == 1 then
|
|
|
|
added_users = 1
|
|
|
|
redis.call('SET', mutex_key, mutex_value, 'EX', #{MUTEX_TIMEOUT_SECONDS})
|
|
|
|
end
|
2021-08-27 14:44:28 -04:00
|
|
|
-- Add the channel to the global channel list. 'NX' means the value will
|
|
|
|
-- only be set if doesn't already exist
|
|
|
|
redis.call('ZADD', channels_key, "NX", expires, tostring(channel))
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
redis.call('EXPIREAT', hash_key, expires + #{GC_SECONDS})
|
|
|
|
redis.call('EXPIREAT', zlist_key, expires + #{GC_SECONDS})
|
|
|
|
|
|
|
|
return added_users
|
|
|
|
LUA
|
|
|
|
|
|
|
|
LUA_SCRIPTS[:leave] = <<~LUA
|
|
|
|
#{COMMON_PRESENT_LEAVE_LUA}
|
|
|
|
|
|
|
|
if mutex_locked then
|
|
|
|
local user_session_count = redis.call('HGET', hash_key, tostring(user_id))
|
|
|
|
local mutex_required = user_session_count == 1 and redis.call('ZRANK', zlist_key, zlist_elem) ~= false
|
|
|
|
if mutex_required then
|
|
|
|
error("#{MUTEX_LOCKED_ERROR}")
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
-- Remove the user from the channel zlist
|
|
|
|
local removed_clients = redis.call('ZREM', zlist_key, zlist_elem)
|
|
|
|
|
|
|
|
local removed_users = 0
|
|
|
|
if tonumber(removed_clients) > 0 then
|
|
|
|
#{UPDATE_GLOBAL_CHANNELS_LUA}
|
|
|
|
|
|
|
|
-- Update the user session count in the channel hash
|
|
|
|
local val = redis.call('HINCRBY', hash_key, user_id, -1)
|
|
|
|
if val <= 0 then
|
|
|
|
redis.call('HDEL', hash_key, user_id)
|
|
|
|
removed_users = 1
|
|
|
|
redis.call('SET', mutex_key, mutex_value, 'EX', #{MUTEX_TIMEOUT_SECONDS})
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
return removed_users
|
|
|
|
LUA
|
|
|
|
|
|
|
|
LUA_SCRIPTS[:release_mutex] = <<~LUA
|
|
|
|
local mutex_key = KEYS[1]
|
|
|
|
local expected_value = ARGV[1]
|
|
|
|
|
|
|
|
if redis.call("GET", mutex_key) == expected_value then
|
|
|
|
redis.call("DEL", mutex_key)
|
|
|
|
end
|
|
|
|
LUA
|
|
|
|
|
|
|
|
LUA_SCRIPTS[:user_ids] = <<~LUA
|
|
|
|
local zlist_key = KEYS[1]
|
|
|
|
local hash_key = KEYS[2]
|
|
|
|
local message_bus_id_key = KEYS[4]
|
|
|
|
local mutex_key = KEYS[5]
|
|
|
|
|
|
|
|
if redis.call('EXISTS', mutex_key) > 0 then
|
|
|
|
error('#{MUTEX_LOCKED_ERROR}')
|
|
|
|
end
|
|
|
|
|
|
|
|
local user_ids = redis.call('HKEYS', hash_key)
|
|
|
|
table.foreach(user_ids, function(k,v) user_ids[k] = tonumber(v) end)
|
|
|
|
|
|
|
|
local message_bus_id = tonumber(redis.call('GET', message_bus_id_key))
|
|
|
|
if message_bus_id == nil then
|
|
|
|
message_bus_id = -1
|
|
|
|
end
|
|
|
|
|
|
|
|
return { message_bus_id, user_ids }
|
|
|
|
LUA
|
|
|
|
|
|
|
|
LUA_SCRIPTS[:count] = <<~LUA
|
|
|
|
local zlist_key = KEYS[1]
|
|
|
|
local hash_key = KEYS[2]
|
|
|
|
local message_bus_id_key = KEYS[4]
|
|
|
|
local mutex_key = KEYS[5]
|
|
|
|
|
|
|
|
if redis.call('EXISTS', mutex_key) > 0 then
|
|
|
|
error('#{MUTEX_LOCKED_ERROR}')
|
|
|
|
end
|
|
|
|
|
|
|
|
local message_bus_id = tonumber(redis.call('GET', message_bus_id_key))
|
|
|
|
if message_bus_id == nil then
|
|
|
|
message_bus_id = -1
|
|
|
|
end
|
|
|
|
|
|
|
|
local count = redis.call('HLEN', hash_key)
|
|
|
|
|
|
|
|
return { message_bus_id, count }
|
|
|
|
LUA
|
|
|
|
|
|
|
|
LUA_SCRIPTS[:auto_leave] = <<~LUA
|
|
|
|
local zlist_key = KEYS[1]
|
|
|
|
local hash_key = KEYS[2]
|
|
|
|
local channels_key = KEYS[3]
|
|
|
|
local mutex_key = KEYS[5]
|
|
|
|
local channel = ARGV[1]
|
|
|
|
local time = ARGV[2]
|
|
|
|
local mutex_value = ARGV[3]
|
|
|
|
|
2021-08-27 13:55:54 -04:00
|
|
|
local expire = redis.call('ZRANGEBYSCORE', zlist_key, '-inf', time)
|
DEV: Introduce PresenceChannel API for core and plugin use
PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently 'replying' to a specific topic, editing a specific wiki post, etc.
A few key pieces of information about the system:
- PresenceChannels are identified by a name of the format `/prefix/blah`, where `prefix` has been configured by some core/plugin implementation, and `blah` can be any string the implementation wants to use.
- Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients 'present' in a channel, they will be deduplicated so that the user is only counted once
- Developers can configure the existence and configuration of channels 'just in time' using a callback. The result of this is cached for 2 minutes.
- Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in 'count_only' mode, where the identity of present users is not revealed to end-users.
- The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
- Clients can enter/leave at will. If a client has not marked itself 'present' in the last 60 seconds, they will automatically 'leave' the channel. The JS implementation takes care of this regular check-in.
- On the client-side, PresenceChannel instances can be fetched from the `presence` ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
- When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the `subscribe` method on the channel.
- The PresenceChannel JS object is an ember object. The `users` and `count` property can be used directly in ember templates, and in computed properties.
- It is important to make sure that you `unsubscribe()` and `leave()` any PresenceChannel objects after use
An example implementation may look something like this. On the server:
```ruby
register_presence_channel_prefix("site") do |channel|
next nil unless channel == "/site/online"
PresenceChannel::Config.new(public: true)
end
```
And on the client, a component could be implemented like this:
```javascript
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default Component.extend({
presence: service(),
init() {
this._super(...arguments);
this.set("presenceChannel", this.presence.getChannel("/site/online"));
},
didInsertElement() {
this.presenceChannel.enter();
this.presenceChannel.subscribe();
},
willDestroyElement() {
this.presenceChannel.leave();
this.presenceChannel.unsubscribe();
},
});
```
With this template:
```handlebars
Online: {{presenceChannel.count}}
<ul>
{{#each presenceChannel.users as |user|}}
<li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
{{/each}}
</ul>
```
2021-08-27 09:43:39 -04:00
|
|
|
|
|
|
|
local has_mutex = false
|
|
|
|
|
|
|
|
local get_mutex = function()
|
|
|
|
if redis.call('SETNX', mutex_key, mutex_value) == 0 then
|
|
|
|
error("#{MUTEX_LOCKED_ERROR}")
|
|
|
|
end
|
|
|
|
redis.call('EXPIRE', mutex_key, #{MUTEX_TIMEOUT_SECONDS})
|
|
|
|
has_mutex = true
|
|
|
|
end
|
|
|
|
|
|
|
|
local expired_user_ids = {}
|
|
|
|
|
|
|
|
local expireOld = function(k, v)
|
|
|
|
local user_id = v:match("[^ ]+")
|
|
|
|
|
|
|
|
if (not has_mutex) and (tonumber(redis.call('HGET', hash_key, user_id)) == 1) then
|
|
|
|
get_mutex()
|
|
|
|
end
|
|
|
|
|
|
|
|
local val = redis.call('HINCRBY', hash_key, user_id, -1)
|
|
|
|
if val <= 0 then
|
|
|
|
table.insert(expired_user_ids, tonumber(user_id))
|
|
|
|
redis.call('HDEL', hash_key, user_id)
|
|
|
|
end
|
|
|
|
redis.call('ZREM', zlist_key, v)
|
|
|
|
end
|
|
|
|
|
|
|
|
table.foreach(expire, expireOld)
|
|
|
|
|
|
|
|
#{UPDATE_GLOBAL_CHANNELS_LUA}
|
|
|
|
|
|
|
|
return expired_user_ids
|
|
|
|
LUA
|
|
|
|
LUA_SCRIPTS.freeze
|
|
|
|
|
|
|
|
LUA_SCRIPTS_SHA1 = LUA_SCRIPTS.transform_values do |script|
|
|
|
|
Digest::SHA1.hexdigest(script)
|
|
|
|
end.freeze
|
|
|
|
end
|